Forms and screens
Design data-entry screens as JSON with @xeplr/ui-factory and save their records into real tables with @xeplr/factory.
Two packages work together:
| Package | Runs in | Does |
|---|---|---|
@xeplr/ui-factory |
the browser | the designer (FactoryBuilder), the running form and list (FactoryScreen), front-end hooks, and the xeplr-factory CLI |
@xeplr/factory |
the server | versioned screen designs, records saved into real tables, server hooks, models, and the /factory routes |
npm i @xeplr/factory @xeplr/ui-factory
An app made by @xeplr/cli already has both wired in.
Screens are JSON, records are not
A screen is a JSON document: controls placed on a canvas with labels,
validation, options and styles. Only the design is JSON, stored in the
factory_screens table with one row per screen per version, as a draft or
published.
Records are never stored as JSON. Every form saves into its own table, one column per field, so ordinary SQL works:
select e."firstName", d."name", e."startDate"
from employees e join departments d on d.id = e."departmentId"
where e."isActive"
| Control | Column |
|---|---|
| text | varchar(maxLength) (255 without one) |
| textarea | text |
| number | integer if whole numbers only, else numeric |
| date | date |
| checkbox | boolean NOT NULL DEFAULT false |
| dropdown from a table | varchar(25) REFERENCES "<table>"("id"), a real foreign key |
| dropdown with fixed options | varchar(50) |
Every table also gets id, isActive, mtId1–4 and the audit columns. A table
that a dropdown reads needs an id and a name column.
A screen saves itself in the background a moment after each change, once the values are acceptable. There is no submit button.
Publishing changes the table
Publishing is the only way a table is created or changed. There are no migration files. The table is compared with the screen as the table actually is in the database, and changed in the same transaction as the publish, so either both happen or neither does.
| Change | On publish |
|---|---|
| new field | ADD COLUMN |
| wider (varchar 80 → 120, varchar → text, integer → numeric) | ALTER COLUMN … TYPE |
| narrower, or a different kind of value | refused (409), and nothing runs |
| field removed | asks first: a 409 with confirm: [{ column, records }]. The designer shows “Publishing removes phone, 1,240 saved values”. Publishing again with { "confirmDrop": ["phone"] } drops the column and its values for every company |
| a column no screen created, or one another company’s screen still uses | never dropped |
| field renamed | not possible. Field names that are already columns are locked (lockedNames), because a rename would drop the old column’s data |
Publish a table that others point at first, departments before employees.
To preview the SQL without running it:
npx xeplr-factory migration employee-edit.screen.json
The database user needs permission to create and alter tables in the app database.
An entity is a set of files
A request such as “a form for employees” is an entity. Every entity is the same set of files. Create all of them, even the ones that only run the defaults.
| File | What | Runs in |
|---|---|---|
employee.entity.json |
the spec: name and fields, in reading order | none |
employee-list.screen.json |
the list screen | browser |
employee-edit.screen.json |
the add/edit form that the list opens in a popup | browser |
EditEmployee.jsx |
the form’s page, holding the front-end hooks (EmployeeHooks extends FactoryHooks) |
browser |
EmployeeList.jsx |
the list’s page, which uses EditEmployee.jsx’s hooks |
browser |
employee.hooks.js |
server hooks: save / get / delete × before / after / error / override |
server |
employee.model.js |
server model: EmployeeModel extends FactoryModel, getters and setters |
server |
npx xeplr-factory screens employee.entity.json -o src/screens/employee
--no-pages leaves out the two .jsx files. --force replaces only the
screen JSON. The pages, hooks and model are your code and are always kept.
{
"entity": "employee",
"fields": [
{ "label": "First name", "required": true },
{ "label": "Department", "type": "dropdown", "table": "departments", "required": true },
{ "label": "Start date", "type": "date", "required": true }
],
"listColumns": ["firstName", "departmentId", "startDate"]
}
Names are derived once and agree everywhere: ids employee_list and
employee_edit, files employee-*.screen.json, components EmployeeList and
EditEmployee, table employees. A table dropdown stores the row’s id, so
“Department” becomes departmentId.
Which file for which request
| The request says… | Change |
|---|---|
| a field, a label, a rule, a dropdown, the layout, a colour | the screen JSON, then validate |
| show, hide or filter rows on screen; an extra button on each row; fill in X before saving | the front-end hooks |
| “must”, “only if”, “check against…”, “email when…”, “only managers see…” | the server hooks |
| “store as…”, “convert…”, comma-separated or array, “work out X from Y” | the server model |
| a query in your own server code | factory.table('employees') |
CLI
npx xeplr-factory screens <entity.json> -o <dir> # entity → list + edit screens, pages, server hooks + model
npx xeplr-factory migration <edit.screen.json> # preview the SQL Publish would run
npx xeplr-factory generate <spec.json> -o <screen.json> # one screen from a spec
npx xeplr-factory validate <screen.json> # every problem, with its path
npx xeplr-factory controls # controls, props and rules
npx xeplr-factory schema <screen.json> # the form's @xeplr/schema-handler schema
Front-end hooks
Front-end hooks control what a screen does in the browser: loading, saving,
deleting, and extra row buttons. They are a class you extend. Every method is
the default. Override the one you need and call super.
import { FactoryScreen, FactoryHooks } from '@xeplr/ui-factory'
class TaskHooks extends FactoryHooks {
async save(values, ctx) {
const saved = await super.save({ ...values, title: values.title.trim() }, ctx) // before
toast('Saved ' + saved.title) // after
return saved
}
actions(ctx) {
return [{ label: 'Mark done', onClick: async (record) => { await markDone(record.id); ctx.refresh() } }]
}
}
| Method | Default | ctx |
|---|---|---|
get(ctx) |
fetchRecords for a list, fetchRecord for the record Edit opens |
many, id, screen, source |
save(values, ctx) |
onSave, on every autosave. Returns the saved record |
id, isNew, screen, source |
delete(record, ctx) |
onDelete, after the person confirms |
id, screen, source |
actions(ctx) |
[], meaning extra row buttons { label, onClick(record, ctx) } |
screen, refresh() |
- Change the input and call
superto run code before the default. Awaitsuperand use its result to run code after. Skipsuperto replace the default. - Write hooks as methods, not arrow-function properties, because
superdoes not work in arrow functions. - The same hooks run in the popup that a list opens.
ctx.screensays which screen is calling. - The server keeps only values that are fields of the form. A value added in
savethat is not a field needs a serversave.beforethat accepts it.
Front-end hooks shape what the person sees and sends. Anything that must hold goes in server hooks.
Server hooks
// employee.hooks.js
module.exports = {
save: {
before: async function(ctx) {
if (ctx.values.endDate < ctx.values.startDate) ctx.reject('Ends before it starts', { field: 'endDate' })
return Object.assign({}, ctx.values, { employeeCode: ctx.values.employeeCode.toUpperCase() })
},
after: async function(ctx) { if (ctx.isNew) await mail.welcome(ctx.result) },
error: async function(ctx, err) { log.warn('employee not saved', err) }
},
get: {
before: function(ctx) { if (!ctx.user.isManager) ctx.query.where('employees.departmentId', ctx.user.departmentId) }
},
delete: {
before: function(ctx) { if (ctx.previous.isFounder) ctx.reject('Founders cannot be deleted') }
}
}
await factory.init({ knex: appKnex, hooks: { employee_edit: require('./screens/employee/employee.hooks') } })
Hooks are registered by screen id. A list screen uses the hooks of the screen
it edits in, so one file covers the entity. A misspelt operation or hook
(beforeSave, onSaved) fails at startup instead of silently never running.
| Operation | Covers | Generic work |
|---|---|---|
save |
create and update | the screen’s rules, then insert or update in a transaction, then read the row back |
get |
the list, and one record (ctx.id set) |
a select scoped to the tenant and to active rows |
delete |
delete | isActive = false |
Each operation runs in this order:
before(ctx). On save, return an object to replacectx.values. The screen’s rules then check the result. On get, narrowctx.query. Anywhere,ctx.reject(message, { field })stops with a 422 whose message the form shows on that field.- The generic work.
after(ctx), once it succeeded, withctx.idandctx.resultset. Return a value to replace the response. Ifafterthrows, the operation has still happened.error(ctx, err), when anything above failed. The original error is still the response.
override(ctx) is the whole operation instead. When a screen has one,
nothing generic runs for that operation: no rules, no before, no query, no
after, no error.
save.before may set columns the form does not show (a fullName worked out
from two fields). They must be real columns and not standard ones. Keys that the
UI sends which are not fields of the screen are always dropped.
Models
A model shapes values between your code and the table, like getters and setters. Use one file per form, next to its hooks.
var { FactoryModel } = require('@xeplr/factory')
class TaskModel extends FactoryModel {
static table = 'tasks'
static fields = {
tags: {
set: (value) => (Array.isArray(value) ? value.join(',') : value), // before it is written
get: (value) => (value ? value.split(',') : []) // after it is read
}
}
static toDb(values) { return super.toDb(values) } // override for more than one field at a time
static fromDb(row) { return super.fromDb(row) }
}
await factory.init({ knex, hooks, models: [TaskModel] }) // or factory.registerModel(TaskModel)
Setters run after save.before and before the screen’s rules. Getters run
before get.after. Both run on the routes and in factory.table(). A table
with no model is left as it is. Models shape data, and hooks decide
behaviour.
Your own queries: factory.table()
Form tables are ordinary tables, so knex('tasks') works, but then you have to
remember the factory’s rules yourself. factory.table() is knex with those
rules applied:
var open = await factory.table('tasks').where({ status: 'todo' }) // this company's active rows, through the getters
await factory.table('tasks', { user: req.user }).insert({ title: 'Plant seeds' }) // id, company, audit filled in; setters
await factory.table('tasks', { user: req.user }).where({ id }).update({ status: 'done' }) // audit; cannot change the company
await factory.table('tasks').where({ id }).del() // soft: isActive = false
Inside a hook it is ctx.db(). Use plain knex('tasks') only for work that
must see every company.
Keys and labels
A form has a key (farming_department) and a label. The key names its
screens and its table and never changes once the form is published. The label
is the screens’ name and can be renamed at any time. Code only ever uses keys.
A new form is created with POST /factory/entities { key, label? }, or from
Configure UI → Forms in a generated app.
Server setup and routes
var factory = require('@xeplr/factory')
await factory.init({ knex: appKnex }) // the app database, where entity tables live
app.use(factory.router({ access: true, auth: auth.mtMembershipGate })) // after mtMiddleware()
await factory.publishScreens([require('./screens/task/task-edit.screen.json'), require('./screens/task/task-list.screen.json')])
DB_FACTORY=myapp xeplr-factory-migrate up # creates factory_screens, once
publishScreens runs at startup. It publishes each screen that has no
published version yet, which creates its table, and leaves the rest alone.
A restart therefore never overwrites a design someone has since changed. These
screens are published for every tenant, and a company’s own later version wins
for that company.
| Route | Does |
|---|---|
GET /factory/screens |
every screen: its latest published version, and whether a draft is waiting |
GET /factory/screens/:key |
the latest published version (?draft=true for the draft), with lockedNames |
PUT /factory/screens/:key/draft |
saves the draft. Refused (422) if it does not validate |
POST /factory/screens/:key/publish |
turns the draft into the next version and changes the table. Pass { confirmDrop } to allow drops |
POST /factory/entities |
creates a new form { key, label? }. Refused (409) if the screens or table exist |
GET /factory/tables, GET /factory/options/:table |
the tables in use; [{ id, name }] for a dropdown |
GET /factory/records/:key, GET /factory/records/:key/:id |
a screen’s records; one record |
POST /factory/records/:key/save |
{ id?, values }, which creates or updates. Returns 422 with fields on a rule or reject |
POST /factory/records/:key/delete |
{ id } sets isActive = false |
What a request can reach. A request names a screen, never a table or a
column. Table and column names come only from a published screen and are
checked against the table’s real columns before any query. Values go through
@xeplr/schema-handler with the screen’s own rules, so the server accepts
exactly what the form does.
In the browser, connect the components with createFactoryApi({ fetch: authFetch }).
authFetch returns parsed bodies, and createFactoryApi accepts that.
Access
Every factory route is a row in the auth database’s apis catalog. Add
node_modules/@xeplr/factory/migrations-auth to XEPLR_AUTH_MIGRATIONS to
install them.
| Permission | Covers | Granted to |
|---|---|---|
factory:view |
screens, records, options | Super Admin, CompanyAdmin, Creator, Viewer |
factory:write |
saving and deleting records | Super Admin, CompanyAdmin, Creator |
factory:design |
drafts, new forms, publish, which changes tables | Super Admin, CompanyAdmin |
With { access: true }, each route answers only a caller whose req.access.apis
names it. Anyone else gets 403. A request with no req.access at all is
refused too, because it means the router was mounted where no auth gate ran.
Access is checked per route, not yet per screen or table. Whoever may save records may save them on any published screen.
An app made by @xeplr/cli goes further: designing, creating forms and
publishing are Super Admin only, whatever else a role has been granted
(api/routes/access.js). Relax that there once your app has decided who else
should be allowed.
Last updated 16 September 2026