@xeplr/ui-factory 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -197,6 +197,166 @@ import schemaHandler from '@xeplr/schema-handler' // CommonJS: default
197
197
  const clean = schemaHandler.applySchema(formSchema(screen), req.body) // throws ValidationError listing each field
198
198
  ```
199
199
 
200
+ ## Identifier — the number or code a record is known by
201
+
202
+ Every record already has a key: 25 characters, made on save, never an
203
+ auto-increment and never a sequence. It is unique, stable and completely
204
+ unquotable — nobody reads one over the phone.
205
+
206
+ So **Identifier** is a control, in the palette beside Text. A type rather
207
+ than a flag on a text box, because everything about it follows from choosing
208
+ it — the column, who fills it in, and whether it is on the form at all:
209
+
210
+ | How it is made | column | filled by | on the form |
211
+ |---|---|---|---|
212
+ | **A code** — `CMP-7K3QF9` | `varchar(24) UNIQUE` | the server, on first save | shown, read-only |
213
+ | **A number** — 1, 2, 3 | `integer GENERATED BY DEFAULT AS IDENTITY … UNIQUE` | the database, as the row is inserted | not drawn until it exists |
214
+
215
+ - **A counted number is not on a new form.** It does not exist until the row
216
+ does, so there is nothing to show but an empty box — and an empty box that
217
+ never fills in is a question the form appears to be asking and is not.
218
+ - **A code is random, not counted.** A sequential one needs a counter bumped
219
+ in the insert's transaction, or two saves take the same number, and it still
220
+ shows gaps when a save fails after the number is taken. Its alphabet drops
221
+ O, I and S — one of each confusable pair, so 0, 1 and 5 stay usable: the
222
+ point of a code is surviving a phone call.
223
+ - **Assigned by the server**, checked for a clash inside the insert's own
224
+ transaction — random is not unique, and a code that was free a moment ago is
225
+ not a promise.
226
+ - **Neither ever changes.** Both are left out of an update entirely, not
227
+ cleared: a record whose reference changes is a reference nobody can use.
228
+ - **Never demanded.** A rule on an identifier would refuse the very save that
229
+ produces it. A value that arrives with the record — an import carrying real
230
+ numbers — is kept, which is why the identity is `BY DEFAULT` and not
231
+ `ALWAYS`.
232
+
233
+ `GENERATED BY DEFAULT AS IDENTITY` is built on the **server**: the browser
234
+ sends a screen document and never SQL. `tableSchema.js` is part of
235
+ `@xeplr/ui-factory/model`, which `@xeplr/factory` imports in Node — sharing
236
+ the rules is not shipping SQL to a browser.
237
+
238
+ ## A sub-table: rows that belong to one record
239
+
240
+ This material's characteristics, this order's lines. Put a **List** on the
241
+ form and give it two things:
242
+
243
+ ```
244
+ Records from material_characteristics [ New table… ]
245
+ Belonging to materialId
246
+ ```
247
+
248
+ **The link is what makes it a sub-table.** Without it, a list of another
249
+ table shows every row in the app and **New** saves a row belonging to
250
+ nothing. With it:
251
+
252
+ - the grid shows only rows whose `materialId` is this material — and **none**
253
+ while the record is unsaved, because a record that does not exist yet owns
254
+ nothing, and showing the whole table there is how somebody edits another
255
+ record's lines;
256
+ - **New** hands the child screen this material's id, as `params` — the same
257
+ mechanism a flow's Screen step uses — so the field is filled in and hidden,
258
+ and nobody is asked which material they are adding a line to.
259
+
260
+ **New table…** makes the child there and then — two screens, published, with
261
+ the link column on them and declared as an input — and **opens its designer
262
+ over the page**, because a sub-table is made for the fields it will hold and
263
+ `Name` / `Value` are only a guess at those. Publishing in there sets this
264
+ list's columns and closes it; closing without publishing leaves a working
265
+ table with the two fields it was made with.
266
+
267
+ It sets *Records from* and *Belonging to* together, because a table without
268
+ its link is a list of everybody's rows and a link without a table is nothing
269
+ — neither half is a state worth being able to reach. The table list is
270
+ re-fetched at the same time, or the picker would be blank for a table made a
271
+ moment ago, and a picker whose value is not among its options shows nothing
272
+ at all.
273
+
274
+ **Its columns follow its table.** A list of another table is described by
275
+ that table's rows, not by the screen it sits on — offering the material's
276
+ fields for a list of characteristics ticks four columns that are blank in
277
+ every row. Changing *Records from* clears the chosen columns and the link,
278
+ because a field name from the old table is a header over an empty column, and
279
+ leaving it there reads as the designer not having noticed the change.
280
+
281
+ **A list is not a dropdown.** Its rows are records people create, so there is
282
+ no "typed list" alternative the way there is for a dropdown's options. If
283
+ what you want is *tags* — several values picked from a fixed set — that is a
284
+ **Multi-select** pointing at a master, and the chosen ids live in one column
285
+ of the record. Making it a sub-table means a two-table design for what is
286
+ really one column.
287
+
288
+ ## Masters — the lists the rest of the app points at
289
+
290
+ Departments, statuses, countries, categories. Mark a form as a master in its
291
+ design (select nothing on the canvas → **This is a master**), and say what a
292
+ picker should show for one of its records:
293
+
294
+ ```js
295
+ { master: true, labelField: 'name' } // on the form's own document
296
+ ```
297
+
298
+ **Made, not designed.** Every application repeats the same three things —
299
+ a name, an order of its own, and the master it belongs to — so a master is
300
+ created outright rather than drawn:
301
+
302
+ ```js
303
+ import { createMaster } from '@xeplr/ui-factory'
304
+
305
+ await createMaster(factory, { name: 'States', parent: { table: 'countries', label: 'Country' } })
306
+ // → states_edit + states_list, marked as a master, published
307
+ ```
308
+
309
+ - **Name** is required: a master row with no name is a blank line in every
310
+ dropdown that points at it.
311
+ - **Sort order** is not: Low / Medium / High is never alphabetical, but most
312
+ lists are fine, and a number somebody must invent for every row is a tax on
313
+ adding one.
314
+ - **Belongs to** is a dropdown on the parent master, required when there is
315
+ one — a state in no country is a row nothing can use. A master is never
316
+ offered as its own parent.
317
+
318
+ It is **published** on creation, which is the one place doing that on
319
+ somebody's behalf is right: a master that is a draft has no table, so nothing
320
+ can point at it and nobody can add a row, which is all a master is for.
321
+
322
+ Named in the plural, stored in the singular: *Departments* makes the entity
323
+ `department` and the table `departments`. Without that step `entityNames`
324
+ would make `departmentses`, which is noticed only once the table exists and
325
+ then cannot be renamed.
326
+
327
+ **A flag on a form, not a kind of form.** A master has a table, a list, an
328
+ edit screen and permissions like anything else; a separate type would be two
329
+ things that behave the same until they do not, and every feature after it
330
+ would have to ask which one it is holding. The flag adds a fact; a type adds
331
+ a fork.
332
+
333
+ `labelField` is said once, here, rather than on every dropdown that points at
334
+ this form — thirty dropdowns should not each be told that a department is
335
+ shown by its name.
336
+
337
+ `useScreenSource` returns `masters` alongside `screens`: one entry per FORM
338
+ (`{ key, name, labelField, edit, list, source }`), gathered by `masterForms`,
339
+ so an app's admin section renders a list and groups nothing itself. That is
340
+ what `@xeplr/cli` generates as Configure UI → **Masters**.
341
+
342
+ They are gathered in the admin section rather than scattered through the side
343
+ rail because that is what they are — reference data somebody maintains
344
+ occasionally. The rail stays for the work.
345
+
346
+ **And they are what a dropdown offers first.** A field's *where its options
347
+ come from* now reads:
348
+
349
+ ```
350
+ Masters Departments, Statuses, Countries ← named, first
351
+ Other tables complaints, employees, pod_items ← still there, below
352
+ ```
353
+
354
+ `/factory/tables` marks and names them (`@xeplr/factory`'s
355
+ `tablesForPicker`), so the picker groups rather than guesses. Two `<optgroup>`s
356
+ in a plain `<select>`, not a combobox: typing jumps to an option in every
357
+ browser, which is the searching anybody wants here, and a widget of our own
358
+ would have to reimplement the keyboard to match.
359
+
200
360
  ## Your screens, inside someone else's designer
201
361
 
202
362
  `@xeplr/ui-workflow`'s flow designer has a **Screen** step, and it asks its
@@ -516,6 +676,8 @@ The control whose properties are open, the step a stepper is open at, and the fl
516
676
 
517
677
  Styles are namespaced `.xeplr-factory-*` and read `@xeplr/ui-account`'s theme variables (theme.css), each with a light fallback: `--xeplr-accent`, `--xeplr-accent-text`, `--xeplr-text-primary`, `--xeplr-text-secondary`, `--xeplr-border-primary`, `--xeplr-bg-primary` (the builder's plane), `--xeplr-bg-tertiary` (sheets, panels, popups), `--xeplr-bg-hover`, `--xeplr-bg-overlay`, `--xeplr-input-bg`, `--xeplr-input-border`, `--xeplr-success`, `--xeplr-warning`, `--xeplr-danger`. The screen, builder and popup follow the app's light or dark theme with the @xeplr/ui-table inside them — never a white sheet in a dark app.
518
678
 
679
+ Every dropdown the factory draws — on a screen, in the builder's panels, in the flow designer — has the factory's own ∨ arrow, set in from the right edge with room kept for it, in `--xeplr-text-secondary`. It is styled here, inside the factory's own containers, so an app gets it without adding CSS of its own. A dropdown's **Background** is applied as a background colour, so it never removes the arrow.
680
+
519
681
  Buttons, steps and list actions all show hover, a visible press, and a keyboard-only focus ring.
520
682
 
521
683
  ## Try it
@@ -151,7 +151,8 @@ switch (cmd) {
151
151
  Object.values(CONTROLS).forEach((c) => {
152
152
  process.stdout.write(`${c.type.padEnd(9)} ${c.label}${c.input ? ` — value: ${c.valueType || 'option id'}` : ''}\n`)
153
153
  process.stdout.write(` props: ${c.props.join(', ')}\n`)
154
- if (c.validation.length) process.stdout.write(` validation: ${c.validation.join(', ')}\n`)
154
+ // Not every control has rules to break — an identifier has none.
155
+ if (c.validation && c.validation.length) process.stdout.write(` validation: ${c.validation.join(', ')}\n`)
155
156
  if (c.styles && c.styles.length) process.stdout.write(` style: ${c.styles.join(', ')}\n`)
156
157
  })
157
158
  break
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/ui-factory",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Design data-entry screens on @xeplr/ui-canvas, save them as JSON, run them as auto-saving forms with lists of saved records — built for Claude to draft and people to refine",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/controls.js CHANGED
@@ -99,6 +99,8 @@ const FIELD_BASICS = (extra) => ({
99
99
  ]
100
100
  })
101
101
 
102
+
103
+
102
104
  const PLACEHOLDER = { path: 'props.placeholder', label: 'Placeholder', type: 'text' }
103
105
 
104
106
  /**
@@ -237,6 +239,57 @@ export const CONTROLS = {
237
239
  ]
238
240
  },
239
241
 
242
+ /**
243
+ * AN IDENTIFIER — the number or code this record is known by.
244
+ *
245
+ * A TYPE, not a flag on a text box, because everything about it follows
246
+ * from choosing it: what the column is, who fills it in, and whether it is
247
+ * on the form at all. Drop it on, say how it is made, and the rest is
248
+ * decided.
249
+ *
250
+ * auto the database counts — 1, 2, 3. Nobody types it and nobody sees
251
+ * it while the record is new, because it does not exist yet: the
252
+ * database assigns it as the row is inserted. Not drawn on a live
253
+ * form at all.
254
+ * code the server assigns CMP-7K3QF9 on the first save. Shown, because
255
+ * this is the one people quote to each other — and read-only,
256
+ * because changing it later would break every quote of it.
257
+ *
258
+ * The record's own 25-character key is untouched and still what everything
259
+ * points at. This is the identifier PEOPLE use.
260
+ */
261
+ id: {
262
+ type: 'id',
263
+ label: 'Identifier',
264
+ group: 'Inputs',
265
+ input: true,
266
+ valueType: 'string',
267
+ defaultSize: { w: 0.28, h: 0.08 },
268
+ defaults: { label: 'Reference', idKind: 'code' },
269
+ props: ['name', 'label', 'idKind', 'codePrefix', 'style'],
270
+ // Nothing to break: nobody types an identifier, so there is no rule it
271
+ // could fail. Declared anyway, because every control answers the same
272
+ // questions (see the contract in test/model.test.js).
273
+ validation: [],
274
+ styles: INPUT_STYLES,
275
+ properties: [
276
+ {
277
+ key: 'field',
278
+ title: 'Field',
279
+ fields: [
280
+ { path: 'props.label', label: 'Label', type: 'text' },
281
+ { path: 'props.name', label: 'Field name', type: 'text', help: 'The key this value is saved under' },
282
+ { path: 'props.idKind', label: 'How it is made', type: 'select', options: [
283
+ { value: 'code', label: 'A code — CMP-7K3QF9' },
284
+ { value: 'auto', label: 'A number, counted by the database — 1, 2, 3' }
285
+ ], help: 'A counted number is never shown on the form: it exists only once the row does.' },
286
+ { path: 'props.codePrefix', label: 'Starts with', type: 'text', help: 'CMP → CMP-7K3QF9. Codes only, and optional.' }
287
+ ]
288
+ },
289
+ ...styleGroups(INPUT_STYLES)
290
+ ]
291
+ },
292
+
240
293
  date: {
241
294
  type: 'date',
242
295
  label: 'Date',
@@ -488,7 +541,7 @@ CONTROLS.list = {
488
541
  // PAGE of the app's own (openIn: 'page', which calls onOpenRecord and lets
489
542
  // the app navigate). Without one, Edit opens the row in this screen's own
490
543
  // fields (a list on a form).
491
- props: ['title', 'source', 'editScreen', 'openIn', 'columns', 'pageSize', 'actions', 'style'],
544
+ props: ['title', 'source', 'belongsTo', 'editScreen', 'openIn', 'columns', 'pageSize', 'actions', 'style'],
492
545
  validation: [],
493
546
  styles: ['fontFamily', 'fontSize', 'color', 'background', 'borderColor', 'borderWidth', 'borderRadius'],
494
547
  properties: [
@@ -497,7 +550,14 @@ CONTROLS.list = {
497
550
  title: 'List',
498
551
  fields: [
499
552
  { path: 'props.title', label: 'Title', type: 'text' },
500
- { path: 'props.source', label: 'Records from', type: 'table', help: 'Blank → the table this screen saves to' },
553
+ { path: 'props.source', label: 'Records from', type: 'listTable', help: 'Blank → the table this screen saves to' },
554
+ // THE LINK BACK. A sub-table on a form — this material's
555
+ // characteristics, this order's lines — is rows of another table that
556
+ // belong to THIS record. Without it a list of another table shows
557
+ // every row in the app, and New saves a row belonging to nothing.
558
+ { path: 'props.belongsTo', label: 'Belonging to', type: 'text',
559
+ help: 'The column on those records that points back at this one, e.g. materialId. ' +
560
+ 'It is filled in automatically on New and never shown.' },
501
561
  { path: 'props.editScreen', label: 'Edit in', type: 'screen', help: 'The screen Edit and New open' },
502
562
  { path: 'props.openIn', label: 'Opens in', type: 'select', options: [
503
563
  { value: 'popup', label: 'A popup' },
@@ -3,6 +3,8 @@ import { XeplrCanvas } from '@xeplr/ui-canvas'
3
3
  import ControlView from './ControlView.jsx'
4
4
  import Palette, { DRAG_TYPE } from './Palette.jsx'
5
5
  import PropertyPanel from './PropertyPanel.jsx'
6
+ import { ScreenEditorSample } from './ScreenEditorSample.jsx'
7
+ import { useFactoryBuilder } from '../useFactoryBuilder.js'
6
8
  import { screenStyle } from './styles.js'
7
9
  import { presetFor } from '../presets.js'
8
10
 
@@ -118,6 +120,37 @@ export default function BuilderSample({ ctrl, renderPreview, className, style })
118
120
  )}
119
121
  </header>
120
122
 
123
+ {/* THE CHILD, DESIGNED WHERE IT WAS MADE.
124
+ A sub-table is made for the fields it will hold, and the two it is
125
+ created with are a guess at those. Going to find the new form
126
+ afterwards means going with the thought already lost. Publishing in
127
+ here sets this list's columns and closes it. */}
128
+ {ctrl.designingChild && screenApi && (
129
+ <div className="xeplr-factory-modal-backdrop" onMouseDown={() => ctrl.closeChild(null)}>
130
+ <div
131
+ className="xeplr-factory-modal xeplr-factory-modal--wide"
132
+ role="dialog"
133
+ aria-modal="true"
134
+ aria-label={'Design ' + ctrl.designingChild.name}
135
+ onMouseDown={(e) => e.stopPropagation()}
136
+ >
137
+ <header className="xeplr-factory-modal-head">
138
+ <strong>{ctrl.designingChild.name}</strong>
139
+ <span className="xeplr-factory-modal-spacer" />
140
+ <button type="button" className="xeplr-factory-secondary" onClick={() => ctrl.closeChild(null)}>Done</button>
141
+ </header>
142
+ <div className="xeplr-factory-modal-body">
143
+ <ScreenEditorSample
144
+ api={screenApi}
145
+ Builder={NestedBuilder}
146
+ screenKey={ctrl.designingChild.screenKey}
147
+ onDone={(screen) => ctrl.closeChild(screen)}
148
+ />
149
+ </div>
150
+ </div>
151
+ </div>
152
+ )}
153
+
121
154
  {ctrl.publishResult && ctrl.publishResult.confirm && (
122
155
  <div className="xeplr-factory-publish is-confirm" role="alertdialog" aria-label="Confirm changes to saved data">
123
156
  <div className="xeplr-factory-confirm-text">
@@ -240,7 +273,20 @@ export default function BuilderSample({ ctrl, renderPreview, className, style })
240
273
  tables={ctrl.tables}
241
274
  screens={ctrl.screens}
242
275
  lockedNames={ctrl.lockedNames}
243
- onChange={(path, value) => ctrl.setProperty(ctrl.selectedNode.id, path, value)}
276
+ onChange={(path, value) => (
277
+ // A list's table is not an ordinary property: changing it makes
278
+ // the chosen columns and the link meaningless, and leaving them
279
+ // behind is what reads as the panel not noticing. See
280
+ // setListSource.
281
+ ctrl.selectedNode.type === 'list' && path === 'props.source'
282
+ ? ctrl.setListSource(ctrl.selectedNode.id, value)
283
+ : ctrl.setProperty(ctrl.selectedNode.id, path, value)
284
+ )}
285
+ // A list pointed at a table that does not exist yet can make one
286
+ // — with the link back to this screen's record already on it —
287
+ // so nobody has to leave the designer, go and build a form, and
288
+ // come back to remember what they were doing.
289
+ onMakeChildTable={ctrl.makeChildTable}
244
290
  onConvert={(target) => ctrl.convertControl(ctrl.selectedNode.id, target)}
245
291
  hint={ctrl.selectedNode ? ctrl.hints.find((h) => h.nodeId === ctrl.selectedNode.id) : null}
246
292
  onScreenChange={ctrl.setScreen}
@@ -73,6 +73,41 @@ const TEXT_INPUT = {
73
73
  url: { type: 'url', autoComplete: 'url', inputMode: 'url' }
74
74
  }
75
75
 
76
+ /**
77
+ * AN IDENTIFIER — the number or code this record is known by.
78
+ *
79
+ * Never typed, either way, so there is nothing to type into. What differs is
80
+ * whether it is on the form at all:
81
+ *
82
+ * code shown. This is the one people quote to each other, so they need to
83
+ * see it — read-only, because changing it later breaks every quote.
84
+ * auto NOT SHOWN on a live form. The database assigns it as the row is
85
+ * inserted, so on a new record there is nothing to show but an empty
86
+ * box, and an empty box that never fills in is a question the form
87
+ * appears to be asking and is not. Once the row exists it is worth
88
+ * seeing, so a saved record shows it.
89
+ *
90
+ * On the DESIGN canvas both are drawn, or there would be nothing to select
91
+ * and nothing to configure.
92
+ */
93
+ function IdView({ node, p, value, design }) {
94
+ const counted = p.idKind === 'auto'
95
+ if (counted && !design && (value === undefined || value === null || value === '')) return null
96
+
97
+ return (
98
+ <Field node={node} p={p}>
99
+ <input
100
+ className="xeplr-factory-input"
101
+ type="text"
102
+ value={value ?? ''}
103
+ readOnly
104
+ disabled
105
+ placeholder={counted ? 'Numbered when saved' : 'Given when saved'}
106
+ />
107
+ </Field>
108
+ )
109
+ }
110
+
76
111
  function TextView({ node, p, value, error, onChange, onBlur, disabled }) {
77
112
  const kind = TEXT_INPUT[p.inputType] || { type: 'text' }
78
113
  return (
@@ -395,6 +430,7 @@ function ListControl({ node, design, list }) {
395
430
  }
396
431
 
397
432
  export const VIEWS = {
433
+ id: IdView,
398
434
  text: TextView,
399
435
  textarea: TextareaView,
400
436
  number: NumberView,
@@ -11,7 +11,9 @@ import { boxStyle, fieldStyle } from './styles.js'
11
11
 
12
12
  export default function ListView({ node, doc, design, list, onEdit, onDelete, onNew, currentId, recordKey = 'id', canDelete, fieldNodes, optionsFor, extraActions = [] }) {
13
13
  const p = node.props || {}
14
- const columns = listColumns(doc, node)
14
+ // The rows are passed in because a list of ANOTHER table has no other
15
+ // description of itself — see listColumns.
16
+ const columns = listColumns(doc, node, undefined, (list && list.rows) || [])
15
17
  const actions = p.actions || ['new', 'edit', 'delete']
16
18
  const style = { ...fieldStyle(p.style), ...boxStyle(p.style) }
17
19