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 super to run code before the default. Await super and use its result to run code after. Skip super to replace the default.
  • Write hooks as methods, not arrow-function properties, because super does not work in arrow functions.
  • The same hooks run in the popup that a list opens. ctx.screen says which screen is calling.
  • The server keeps only values that are fields of the form. A value added in save that is not a field needs a server save.before that 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:

  1. before(ctx). On save, return an object to replace ctx.values. The screen’s rules then check the result. On get, narrow ctx.query. Anywhere, ctx.reject(message, { field }) stops with a 422 whose message the form shows on that field.
  2. The generic work.
  3. after(ctx), once it succeeded, with ctx.id and ctx.result set. Return a value to replace the response. If after throws, the operation has still happened.
  4. 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