@xeplr/ui-factory 1.3.0 → 1.4.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
@@ -285,6 +285,44 @@ what you want is *tags* — several values picked from a fixed set — that is a
285
285
  of the record. Making it a sub-table means a two-table design for what is
286
286
  really one column.
287
287
 
288
+ ## A form inside a form
289
+
290
+ This employee's bank details; this department's contact. Access calls it a
291
+ subform. Put a **Form** (palette, Data) on the screen and give it two things:
292
+
293
+ ```
294
+ Form Bank details ← any published form with fields of its own
295
+ Linked by employeeId ← the field on THAT form pointing back here
296
+ ```
297
+
298
+ ```json
299
+ { "id": "n9", "type": "subform", "props": { "title": "Bank details", "screen": "bank_detail_edit", "belongsTo": "employeeId" } }
300
+ ```
301
+
302
+ - **It is drawn as itself**, inline — its own fields, its own layout — and its
303
+ records stay in **its own table**. Nothing of it is a column of this one.
304
+ - **One record of it per record of this one.** It reads the row whose link
305
+ field equals this record's `id` (the same server filter as a sub-table:
306
+ `belongsTo` + `belongsToId`). A record not saved yet has none, so it opens
307
+ empty. Several records of it is a **List**, not this.
308
+ - **One Save.** Its changes are this form's "Unsaved changes". Save checks
309
+ both first — an invalid subform keeps this record from being written too —
310
+ then saves this record (so a new one has an id), then the subform with that
311
+ id in its link field. If the subform fails after this record saved, the
312
+ status says so and names it. A subform nobody touched is not saved: an
313
+ empty row is not worth one.
314
+ - **The link field is never shown or asked for** on the subform — it is filled
315
+ in on save. The form must HAVE that field (a dropdown on this form's table,
316
+ usually): one that does not is refused out loud, with what to change, rather
317
+ than saving a record that belongs to nothing.
318
+ - **A form cannot contain itself**, and a form shown inside another cannot be
319
+ deleted until it is removed there (`DELETE /factory/screens/:key` says where).
320
+
321
+ Controller: the embedded screen is a second `useFactoryScreen` with `link`
322
+ (the link field's name) and `onReady` (it hands the host `{ validate, dirty,
323
+ save, name }`). `FactoryScreen` wires both; a custom design gets `subformFor(node)`,
324
+ `registerSubform(id, api)` and `subformDirty(id, dirty)` from the host's controller.
325
+
288
326
  ## Masters — the lists the rest of the app points at
289
327
 
290
328
  Departments, statuses, countries, categories. Mark a form as a master in its
@@ -464,6 +502,7 @@ Both walk someone through something, and they answer different questions:
464
502
  | `stepper` | — | a journey across the top; every other control says which step it is on |
465
503
  | `label` | — | text with a heading, subheading or text preset |
466
504
  | `list` | — | saved records, with New / Edit / Delete |
505
+ | `subform` | — | another form drawn inside this one (palette: **Form**), saving into its own table — see below |
467
506
 
468
507
  A screen is a **sheet**, centred in the space it is given and shown at its
469
508
  design width: an elevated card in the app's theme (`--xeplr-bg-tertiary`, with
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/ui-factory",
3
- "version": "1.3.0",
3
+ "version": "1.4.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
@@ -572,6 +572,41 @@ CONTROLS.list = {
572
572
  ]
573
573
  }
574
574
 
575
+ // A FORM INSIDE THIS ONE — Access's subform. Another published form, drawn
576
+ // inline with its own fields, saving into ITS OWN table: this employee's bank
577
+ // details. One record of it belongs to each record of this one, through
578
+ // `belongsTo` — the column on that form pointing back here, as on a sub-table
579
+ // list. Several records of it is a List, not this.
580
+ //
581
+ // Nothing of it is stored in this screen's table: it is not an input, so it
582
+ // adds no column. Save writes this record first, then the subform's, with
583
+ // this record's id in its link column.
584
+ CONTROLS.subform = {
585
+ type: 'subform',
586
+ label: 'Form',
587
+ group: 'Data',
588
+ input: false,
589
+ defaultSize: { w: 0.92, h: 0.32 },
590
+ defaults: { title: '' },
591
+ props: ['title', 'screen', 'belongsTo', 'style'],
592
+ validation: [],
593
+ styles: ['borderColor', 'borderWidth', 'borderRadius'],
594
+ properties: [
595
+ {
596
+ key: 'subform',
597
+ title: 'Form',
598
+ fields: [
599
+ { path: 'props.title', label: 'Title', type: 'text', help: 'Shown above it. Blank → none' },
600
+ { path: 'props.screen', label: 'Form', type: 'form', help: 'The published form drawn here, saving into its own table' },
601
+ { path: 'props.belongsTo', label: 'Linked by', type: 'text',
602
+ help: 'The field on that form that points back at this record, e.g. employeeId. ' +
603
+ 'Filled in on save and never shown.' }
604
+ ]
605
+ },
606
+ ...styleGroups(['borderColor', 'borderWidth', 'borderRadius'])
607
+ ]
608
+ }
609
+
575
610
  export const LABEL_VARIANTS = ['heading', 'subheading', 'text']
576
611
 
577
612
  /** The sizes a label preset starts from, in px at the design width. */
@@ -13,7 +13,7 @@ import { fieldStyle, labelStyle, boxStyle } from './styles.js'
13
13
 
14
14
  const ids = (node) => `xeplr-factory-${node.id}`
15
15
 
16
- export default function ControlView({ node, mode = 'live', value, error, options, onChange, onBlur, disabled, list, upload, stepper }) {
16
+ export default function ControlView({ node, mode = 'live', value, error, options, onChange, onBlur, disabled, list, upload, stepper, subform }) {
17
17
  const View = VIEWS[node.type]
18
18
  if (!View) {
19
19
  return <div className="xeplr-factory-unknown">Unknown control “{node.type}”</div>
@@ -33,6 +33,7 @@ export default function ControlView({ node, mode = 'live', value, error, options
33
33
  list={list}
34
34
  upload={upload}
35
35
  stepper={stepper}
36
+ subform={subform}
36
37
  />
37
38
  )
38
39
  }
@@ -425,6 +426,39 @@ function LabelView({ p }) {
425
426
  )
426
427
  }
427
428
 
429
+ /**
430
+ * A form inside this one.
431
+ *
432
+ * On a running screen, its title and then the form itself — drawn by the page
433
+ * (`subform.render`), because a design never mounts a controller; until it
434
+ * has loaded, or when it cannot, that is said in the space it will take.
435
+ *
436
+ * On the builder's canvas, what it will draw and how it is linked — never the
437
+ * form itself, which is another screen's to design.
438
+ */
439
+ function SubformView({ p, node, design, subform }) {
440
+ if (!design && subform) {
441
+ const sub = subform.sub
442
+ return (
443
+ <div className="xeplr-factory-subform" style={boxStyle(p.style)}>
444
+ {p.title && <div className="xeplr-factory-subform-title">{p.title}</div>}
445
+ {sub.error && <div className="xeplr-factory-invalid" role="alert">{sub.error}</div>}
446
+ {!sub.error && !sub.document && <div className="xeplr-factory-hint">Loading…</div>}
447
+ {!sub.error && sub.document && subform.render && subform.render(node, sub)}
448
+ </div>
449
+ )
450
+ }
451
+ return (
452
+ <div className="xeplr-factory-subform xeplr-factory-subform--design" style={boxStyle(p.style)}>
453
+ {p.title && <div className="xeplr-factory-subform-title">{p.title}</div>}
454
+ <div className="xeplr-factory-subform-what">
455
+ {p.screen ? <>Form <code>{p.screen}</code></> : 'Choose a form'}
456
+ {p.belongsTo ? <>, linked by <code>{p.belongsTo}</code></> : ', and the field that links it'}
457
+ </div>
458
+ </div>
459
+ )
460
+ }
461
+
428
462
  function ListControl({ node, design, list }) {
429
463
  return <ListView node={node} design={design} {...(list || {})} />
430
464
  }
@@ -443,5 +477,6 @@ export const VIEWS = {
443
477
  file: FileView,
444
478
  stepper: StepperView,
445
479
  label: LabelView,
446
- list: ListControl
480
+ list: ListControl,
481
+ subform: SubformView
447
482
  }
@@ -550,6 +550,24 @@ function ScreenEditor({ id, value, onChange, screens, doc }) {
550
550
  return <input id={id} className="xeplr-factory-prop-input xeplr-factory-mono" type="text" placeholder="employee_edit" value={value ?? ''} onChange={(e) => onChange(e.target.value.trim() || undefined)} />
551
551
  }
552
552
 
553
+ /**
554
+ * A form to draw inside this one: the screens with fields of their own (a list
555
+ * screen has nothing to fill in), never this one — a form cannot hold itself.
556
+ */
557
+ function FormEditor({ id, value, onChange, screens, doc }) {
558
+ const forms = (screens || []).filter((s) => (!doc || s.id !== doc.id)
559
+ && (!s.document || (s.document.nodes || []).some((n) => n.props && n.props.name)))
560
+ if (forms.length) {
561
+ return (
562
+ <select id={id} className="xeplr-factory-prop-input" value={value ?? ''} onChange={(e) => onChange(e.target.value || undefined)}>
563
+ <option value="">— choose a form</option>
564
+ {forms.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
565
+ </select>
566
+ )
567
+ }
568
+ return <input id={id} className="xeplr-factory-prop-input xeplr-factory-mono" type="text" placeholder="bank_details_edit" value={value ?? ''} onChange={(e) => onChange(e.target.value.trim() || undefined)} />
569
+ }
570
+
553
571
  const ACTION_LABELS = { new: 'New — add a record', edit: 'Edit — open a row for editing', delete: 'Delete — remove a row' }
554
572
 
555
573
  function ActionsEditor({ value, onChange }) {
@@ -732,6 +750,7 @@ export const EDITORS = {
732
750
  color: ColorEditor,
733
751
  table: TableEditor,
734
752
  screen: ScreenEditor,
753
+ form: FormEditor,
735
754
  columns: ColumnsEditor,
736
755
  listTable: ListTableEditor,
737
756
  actions: ActionsEditor,
@@ -124,7 +124,7 @@ function SaveButtons({ ctrl }) {
124
124
  )
125
125
  }
126
126
 
127
- export default function ScreenSample({ ctrl, className, style, renderPopup }) {
127
+ export default function ScreenSample({ ctrl, className, style, renderPopup, renderSubform }) {
128
128
  const { document: doc } = ctrl
129
129
 
130
130
  if (ctrl.documentErrors.length) {
@@ -147,20 +147,23 @@ export default function ScreenSample({ ctrl, className, style, renderPopup }) {
147
147
  // A screen with no fields of its own (a list screen) has nothing to save —
148
148
  // and no sheet: its list is the card (factory.css, --bare).
149
149
  const hasFields = fieldNodes.length > 0
150
- const status = hasFields ? <SaveStatus ctrl={ctrl} /> : null
150
+ // A form inside another has no footer of its own: the host's Save saves it.
151
+ const footer = !ctrl.embedded
152
+ const status = hasFields && footer ? <SaveStatus ctrl={ctrl} /> : null
153
+ // Nor a <form> of its own — one form inside another is not valid HTML.
154
+ const Page = ctrl.embedded ? 'div' : 'form'
151
155
  const steppers = ctrl.steps.nodes
152
156
 
153
157
  return (
154
158
  <div
155
- className={'xeplr-factory-screen' + (hasFields ? '' : ' xeplr-factory-screen--bare') + (className ? ' ' + className : '')}
159
+ className={'xeplr-factory-screen' + (hasFields ? '' : ' xeplr-factory-screen--bare') + (ctrl.embedded ? ' xeplr-factory-screen--embedded' : '') + (className ? ' ' + className : '')}
156
160
  style={{ maxWidth: doc.width, '--xf-margin': `${MARGIN * 100}%`, ...screenStyle(doc), ...style }}
157
161
  >
158
- <form
162
+ <Page
159
163
  className="xeplr-factory-page"
160
164
  style={{ height: `${pageHeight}cqw` }}
161
165
  // Never a submit: Enter posts nothing and saves nothing. Save is a button.
162
- onSubmit={(e) => e.preventDefault()}
163
- noValidate
166
+ {...(ctrl.embedded ? { role: 'group' } : { onSubmit: (e) => e.preventDefault(), noValidate: true })}
164
167
  aria-label={doc.name}
165
168
  >
166
169
  {[...shown].sort((a, b) => (a.z || 0) - (b.z || 0)).map((node) => (
@@ -206,19 +209,22 @@ export default function ScreenSample({ ctrl, className, style, renderPopup }) {
206
209
  onDelete: (rec) => ctrl.deleteRecord(node, rec).catch((err) => window.alert(err.message || 'Could not delete')),
207
210
  onNew: () => (node.props.editScreen ? ctrl.openEditor(node, null) : (ctrl.confirmDiscard() && ctrl.newRecord()))
208
211
  } : undefined}
212
+ // A form inside this one: the page draws it (renderSubform).
213
+ subform={node.type === 'subform' ? { sub: ctrl.subformFor(node), render: renderSubform } : undefined}
209
214
  />
210
215
  </div>
211
216
  ))}
212
- </form>
217
+ </Page>
213
218
  {steppers.map((node, i) => {
214
219
  const last = i === steppers.length - 1
215
220
  return (
221
+ // An embedded form keeps its Back and Next; only Save is the host's.
216
222
  <StepNav key={node.id} ctrl={ctrl} node={node} status={last ? status : null}>
217
- {last && (hasFields || ctrl.doneLabel) && <SaveButtons ctrl={ctrl} />}
223
+ {last && footer && (hasFields || ctrl.doneLabel) && <SaveButtons ctrl={ctrl} />}
218
224
  </StepNav>
219
225
  )
220
226
  })}
221
- {!steppers.length && (hasFields || ctrl.doneLabel) && (
227
+ {footer && !steppers.length && (hasFields || ctrl.doneLabel) && (
222
228
  <div className="xeplr-factory-footer">
223
229
  <div className="xeplr-factory-footer-mid">{status}</div>
224
230
  <SaveButtons ctrl={ctrl} />
@@ -241,6 +241,19 @@
241
241
  .xeplr-factory-text { margin: 0; color: var(--xf-ink); height: 100%; overflow: hidden; line-height: 1.25; box-sizing: border-box; }
242
242
  .xeplr-factory-text--text { color: var(--xf-muted); }
243
243
 
244
+ /* ── a form inside a form ────────────────────────────────────────────────
245
+ The inner form is drawn as itself, without its own sheet or footer: it is
246
+ part of this page, and its Save is this page's Save. Its box scrolls when
247
+ the form is taller than the space it was given. */
248
+ .xeplr-factory-subform { display: flex; flex-direction: column; gap: 8px; width: 100%; height: 100%; box-sizing: border-box; overflow: auto; }
249
+ .xeplr-factory-subform-title { font-weight: 600; font-size: 1.05em; color: var(--xf-ink); }
250
+ .xeplr-factory-screen--embedded { border: none; border-radius: 0; background: none; }
251
+ .xeplr-factory-subform--design {
252
+ justify-content: center; align-items: center; text-align: center; padding: 12px;
253
+ border: 1px dashed var(--xf-border); border-radius: 10px; color: var(--xf-muted);
254
+ }
255
+ .xeplr-factory-subform-what code { color: var(--xf-ink); }
256
+
244
257
  /* ── list ───────────────────────────────────────────────────────────────── */
245
258
  .xeplr-factory-list {
246
259
  display: flex; flex-direction: column; width: 100%; box-sizing: border-box; overflow: auto;
@@ -335,6 +348,7 @@
335
348
  .xeplr-factory-palette-icon--file { border-radius: 2px; border-top: 3px solid var(--xf-muted); height: 12px; width: 10px; }
336
349
  .xeplr-factory-palette-icon--stepper { height: 8px; border-radius: 999px; background: linear-gradient(to right, var(--xf-muted) 0 30%, transparent 30%); border: none; }
337
350
  .xeplr-factory-palette-icon--label { border: none; border-top: 2px solid var(--xf-muted); border-bottom: 2px solid var(--xf-muted); height: 6px; }
351
+ .xeplr-factory-palette-icon--subform { border-style: double; border-width: 3px; height: 11px; }
338
352
  .xeplr-factory-palette-icon--list { height: 12px; background: repeating-linear-gradient(to bottom, var(--xf-muted) 0 1.5px, transparent 1.5px 4px); border: none; }
339
353
  .xeplr-factory-palette-icon--textarea { height: 14px; }
340
354
 
package/src/pages.jsx CHANGED
@@ -79,6 +79,32 @@ export function FactoryScreen(props) {
79
79
  ctrl={ctrl}
80
80
  className={props.className}
81
81
  style={props.style}
82
+ // A form inside this one: the same component again, saving into its own
83
+ // table, checked and saved by this one's Save (see useFactoryScreen).
84
+ // Keyed by the form, never by this record's id — a new record's first
85
+ // save changes that id while the subform is being saved.
86
+ renderSubform={(node, sub) => (
87
+ <FactoryScreen
88
+ key={`${node.id}:${sub.document.id}`}
89
+ document={sub.document}
90
+ record={sub.record || undefined}
91
+ link={node.props.belongsTo}
92
+ onReady={(api) => ctrl.registerSubform(node.id, api)}
93
+ onDirtyChange={(d) => ctrl.subformDirty(node.id, d)}
94
+ recordKey={props.recordKey}
95
+ onSave={props.onSave}
96
+ fetchOptions={props.fetchOptions}
97
+ fetchRecord={props.fetchRecord}
98
+ fetchRecords={props.fetchRecords}
99
+ onDelete={props.onDelete}
100
+ uploadFile={props.uploadFile}
101
+ fileUrl={props.fileUrl}
102
+ screens={props.screens}
103
+ loadScreen={props.loadScreen}
104
+ controls={props.controls}
105
+ hooks={props.hooks}
106
+ />
107
+ )}
82
108
  renderPopup={(popup) => (
83
109
  <ScreenModal
84
110
  title={popup.document ? popup.document.name : 'Loading…'}
@@ -29,6 +29,15 @@ import { useUrlSteps } from './useUrlPlaces.js'
29
29
  // A list whose `editScreen` names another screen opens that screen in a popup
30
30
  // for Edit and New. The screen comes from `screens` ({ id → document }) or the
31
31
  // host's `loadScreen(id)`. The list refreshes as the popup saves.
32
+ //
33
+ // ── A FORM INSIDE THIS ONE (subform) ─────────────────────────────────────
34
+ // A `subform` node draws another published form inline, saving into its own
35
+ // table. The view renders it as a second useFactoryScreen with `link` (the
36
+ // field on it pointing back here) and `onReady`, through which it hands this
37
+ // one a handle: validate / dirty / save. One Save writes THIS record first —
38
+ // so a new one has an id — then every subform with changes, with that id in
39
+ // its link field. An untouched subform is not saved: it is one optional
40
+ // record, and an empty one is not worth a row.
32
41
 
33
42
  /**
34
43
  * @param document a valid screen document
@@ -62,9 +71,23 @@ import { useUrlSteps } from './useUrlPlaces.js'
62
71
  export function useFactoryScreen({
63
72
  document: doc, record, recordKey = 'id', onSave, fetchOptions, fetchRecords, fetchRecord, onDelete, onChange, screens, loadScreen, hooks, uploadFile, fileUrl,
64
73
  onOpenRecord, onDone, onDirtyChange, params, urlKey,
74
+ link, onReady,
65
75
  controls = CONTROLS
66
76
  } = {}) {
67
77
  const check = useMemo(() => validateDocument(doc, controls), [doc, controls])
78
+ // EMBEDDED AS A SUBFORM: `link` is the field pointing back at the record
79
+ // this sits in. It is never shown and never asked for — the host fills it
80
+ // in on save — so it is left out of what is checked here, and a form
81
+ // without that field is a mistake said out loud, not a record saved
82
+ // belonging to nothing.
83
+ const embedded = Boolean(link)
84
+ const checkDoc = useMemo(
85
+ () => (embedded && check.ok ? { ...doc, nodes: doc.nodes.filter((n) => !(n.props && n.props.name === link)) } : doc),
86
+ [doc, link, embedded, check.ok]
87
+ )
88
+ const linkError = embedded && check.ok && !inputNodes(doc, controls).some((n) => n.props.name === link)
89
+ ? { path: '', message: `"${doc.name || doc.id}" has no field "${link}" — add it, or change "Linked by" on the form this is placed in` }
90
+ : null
68
91
  // Read at call time, so a new hooks object never re-runs loads by itself.
69
92
  const hooksRef = useRef(hooks); hooksRef.current = hooks
70
93
  const hook = (name) => hookMethod(hooksRef.current, name)
@@ -139,7 +162,7 @@ export function useFactoryScreen({
139
162
 
140
163
  // Latest of everything a save needs, read at the moment it runs.
141
164
  const live = useRef({})
142
- live.current = { doc, values, recordId, touched, source, onSave, controls, recordKey }
165
+ live.current = { doc, checkDoc, values, recordId, touched, source, onSave, controls, recordKey }
143
166
 
144
167
  // ── options for table dropdowns, fetched once per table ───────────────
145
168
  const [options, setOptions] = useState({}) // node id → { loading, items, error }
@@ -180,7 +203,10 @@ export function useFactoryScreen({
180
203
  // have branches; which of them apply is the app's to say, from its hooks.
181
204
  const [disabledSteps, setDisabledSteps] = useState({})
182
205
  const stepperNodes = useMemo(() => (check.ok ? steppers(doc, controls) : []), [check.ok, doc, controls])
183
- const visibleNodes = useMemo(() => (check.ok ? nodesForSteps(doc, openSteps, controls) : []), [check.ok, doc, openSteps, controls])
206
+ const visibleNodes = useMemo(
207
+ () => (check.ok ? nodesForSteps(doc, openSteps, controls).filter((n) => !(embedded && n.props && n.props.name === link)) : []),
208
+ [check.ok, doc, openSteps, controls, embedded, link]
209
+ )
184
210
 
185
211
  // A screen whose steps changed under it (a field moved to another step, a
186
212
  // step removed) must not stay open at a step that is gone. Steppers this
@@ -320,26 +346,50 @@ export function useFactoryScreen({
320
346
  }, [options])
321
347
  live.current.loadedItems = loadedItems
322
348
 
323
- const state = useMemo(() => (check.ok ? saveState(doc, values, touched, loadedItems, controls) : { canSave: false, status: 'invalid', errors: {}, shown: {} }),
324
- [check.ok, doc, values, touched, loadedItems, controls])
349
+ const state = useMemo(() => (check.ok ? saveState(checkDoc, values, touched, loadedItems, controls) : { canSave: false, status: 'invalid', errors: {}, shown: {} }),
350
+ [check.ok, checkDoc, values, touched, loadedItems, controls])
351
+
352
+ // ── subforms: the handles they hand over, and which hold changes ──────
353
+ const subApis = useRef({}) // node id → { validate, dirty, save, name }
354
+ const subDirty = useRef(new Set()) // node ids of subforms with unsaved changes
355
+ const [subVersion, setSubVersion] = useState(0)
356
+ const registerSubform = useCallback((id, api) => {
357
+ if (api) subApis.current[id] = api
358
+ else { delete subApis.current[id]; subDirty.current.delete(id) }
359
+ }, [])
325
360
 
326
361
  // ── saving ────────────────────────────────────────────────────────────
327
362
  const inFlight = useRef(null) // the promise of the save running now
328
363
 
329
- /** Save — what the Save button does. One AJAX call; never on its own. */
330
- const runSave = useCallback(async () => {
364
+ /**
365
+ * Is it fit to save? Says what is missing on every field — including the
366
+ * ones nobody has been in yet — because pressing Save is the moment to.
367
+ */
368
+ const checkNow = useCallback(() => {
369
+ const l = live.current
370
+ const st = saveState(l.checkDoc, l.values, l.touched, l.loadedItems, l.controls)
371
+ if (st.canSave) return { ok: true }
372
+ const t = new Set([...l.touched, ...Object.keys(st.errors)])
373
+ live.current.touched = t
374
+ setTouched(t)
375
+ setStatus('invalid')
376
+ return { ok: false, errors: st.errors }
377
+ }, [])
378
+
379
+ /**
380
+ * Save — what the Save button does. One AJAX call; never on its own.
381
+ * `extra` is merged into what is sent: a subform's link, from its host.
382
+ */
383
+ const runSave = useCallback(async (extra) => {
331
384
  if (inFlight.current) return inFlight.current
332
385
  const l = live.current
333
- const st = saveState(l.doc, l.values, l.touched, l.loadedItems, l.controls)
334
- if (!st.canSave) {
335
- // Pressing Save is the moment to say what is missing — on every field,
336
- // including the ones nobody has been in yet.
337
- const t = new Set([...l.touched, ...Object.keys(st.errors)])
338
- live.current.touched = t
339
- setTouched(t)
340
- setStatus('invalid')
341
- return { ok: false, errors: st.errors }
342
- }
386
+ // A subform with changes is checked BEFORE anything is written, so a bad
387
+ // bank account never leaves an employee saved without one.
388
+ const subs = Object.entries(subApis.current).filter(([id]) => subDirty.current.has(id))
389
+ const subsOk = subs.map(([, api]) => api.validate()).every(Boolean)
390
+ const own = checkNow()
391
+ if (!own.ok) return own
392
+ if (!subsOk) { setStatus('invalid'); return { ok: false } }
343
393
  const customSave = hooksRef.current && typeof hooksRef.current.save === 'function'
344
394
  const writing = changes.current
345
395
  if (!l.onSave && !customSave) { savedChanges.current = writing; setDirty(false); setStatus('saved'); return { ok: true } }
@@ -349,13 +399,14 @@ export function useFactoryScreen({
349
399
  const task = (async () => {
350
400
  try {
351
401
  const meta = { id: l.recordId, source: l.source, screen: l.doc.id, document: l.doc }
352
- const saved = await hook('save')(l.values, {
402
+ const vals = extra ? { ...l.values, ...extra } : l.values
403
+ const saved = await hook('save')(vals, {
353
404
  ...meta,
354
405
  isNew: l.recordId === null || l.recordId === undefined,
355
406
  // Save is where an app usually learns which branch it is on, so it
356
407
  // can switch steps off from here as well as from step().
357
408
  steps: live.current.steps,
358
- defaults: { save: (vals) => (l.onSave ? l.onSave(vals, meta) : undefined) }
409
+ defaults: { save: (v) => (l.onSave ? l.onSave(v, meta) : undefined) }
359
410
  })
360
411
  // A new record's first save creates it; its id makes the next save an update.
361
412
  if (saved && typeof saved === 'object' && saved[l.recordKey] !== undefined && saved[l.recordKey] !== null) {
@@ -363,7 +414,28 @@ export function useFactoryScreen({
363
414
  live.current.recordId = saved[l.recordKey]
364
415
  }
365
416
  savedChanges.current = writing
366
- const still = changes.current !== writing
417
+
418
+ // THEN THE SUBFORMS, each with this record's id in its link field.
419
+ // One that fails leaves this record saved and says which did not.
420
+ if (subs.length) {
421
+ const failed = []
422
+ for (const [id, api] of subs) {
423
+ const r = await api.save(live.current.recordId)
424
+ if (!r || !r.ok) failed.push(`${api.name()}${r && r.error && r.error.message ? ` (${r.error.message})` : ''}`)
425
+ // Read from the subform itself, now — its own report of being
426
+ // clean arrives after this function has decided what to say.
427
+ else if (!api.dirty()) subDirty.current.delete(id)
428
+ }
429
+ setSubVersion((v) => v + 1)
430
+ if (failed.length) {
431
+ setDirty(true)
432
+ setSaveError(`this record was saved, but ${failed.join(', ')} was not`)
433
+ setStatus('error')
434
+ return { ok: false, record: saved }
435
+ }
436
+ }
437
+
438
+ const still = changes.current !== writing || subDirty.current.size > 0
367
439
  setDirty(still)
368
440
  setSavedAt(new Date())
369
441
  setListVersion((v) => v + 1)
@@ -386,21 +458,45 @@ export function useFactoryScreen({
386
458
  })()
387
459
  inFlight.current = task
388
460
  return task
461
+ }, [setDirty, checkNow])
462
+
463
+ /** A subform's changes are this form's unsaved changes too. */
464
+ const subformDirty = useCallback((id, d) => {
465
+ if (d) subDirty.current.add(id)
466
+ else subDirty.current.delete(id)
467
+ setDirty(changes.current !== savedChanges.current || subDirty.current.size > 0)
468
+ if (d) setStatus('pending')
389
469
  }, [setDirty])
390
470
 
471
+ const unsaved = () => changes.current !== savedChanges.current || subDirty.current.size > 0
472
+
391
473
  /** Save if there is anything unsaved — what a flow's Next does before moving on. */
392
474
  const flush = useCallback(async () => {
393
475
  if (inFlight.current) await inFlight.current
394
- if (changes.current !== savedChanges.current) return runSave()
476
+ if (unsaved()) return runSave()
395
477
  return { ok: true }
396
- }, [runSave])
478
+ }, [runSave]) // eslint-disable-line react-hooks/exhaustive-deps
479
+
480
+ // EMBEDDED: hand the host what it needs to check and save this form as part
481
+ // of its own Save. Read through refs, so the handle never goes stale.
482
+ const onReadyRef = useRef(onReady); onReadyRef.current = onReady
483
+ useEffect(() => {
484
+ if (!onReadyRef.current) return undefined
485
+ onReadyRef.current({
486
+ validate: () => checkNow().ok,
487
+ dirty: () => changes.current !== savedChanges.current,
488
+ save: (ownerId) => runSave({ [link]: ownerId }),
489
+ name: () => live.current.doc.name || live.current.doc.id
490
+ })
491
+ return () => { if (onReadyRef.current) onReadyRef.current(null) }
492
+ }, [runSave, checkNow, link])
397
493
 
398
494
  /**
399
495
  * May the person leave? True when nothing is unsaved, or when they agree to
400
496
  * lose it. Cancel, the popup's × and opening another record all ask this.
401
497
  */
402
498
  const confirmDiscard = useCallback(() => {
403
- if (changes.current === savedChanges.current) return true
499
+ if (!unsaved()) return true
404
500
  // eslint-disable-next-line no-alert
405
501
  return typeof window === 'undefined' || window.confirm('Discard your unsaved changes?')
406
502
  }, [])
@@ -467,6 +563,8 @@ export function useFactoryScreen({
467
563
  setSaveError(null)
468
564
  setServerErrors({})
469
565
  setStatus('idle')
566
+ // Its subforms are this record's, so they are read again for it.
567
+ setSubVersion((v) => v + 1)
470
568
  }, [startValues, recordKey, setDirty])
471
569
 
472
570
  const newRecord = useCallback(() => openRecord(null), [openRecord])
@@ -602,6 +700,49 @@ export function useFactoryScreen({
602
700
  }
603
701
  }, [])
604
702
 
703
+ // ── subforms: the form each draws, and this record's row of it ────────
704
+ //
705
+ // Read when the screen opens, when another record is opened, and after a
706
+ // Save — never just because the id changed, or a new record's first save
707
+ // would re-read an empty row over the subform it is in the middle of saving.
708
+ const [subforms, setSubforms] = useState({}) // node id → { loading, error, document, record }
709
+ const subformNodes = useMemo(() => (check.ok ? doc.nodes.filter((n) => n.type === 'subform') : []), [doc, check.ok])
710
+ useEffect(() => {
711
+ if (!subformNodes.length) return undefined
712
+ let cancelled = false
713
+ subformNodes.forEach((node) => {
714
+ const screenId = node.props.screen
715
+ setSubforms((m) => ({ ...m, [node.id]: { ...(m[node.id] || {}), loading: true, error: null } }))
716
+ ;(async () => {
717
+ let found = screensRef.current && screensRef.current[screenId]
718
+ if (!found && loadScreenRef.current) found = await loadScreenRef.current(screenId)
719
+ if (!found) throw new Error(`Form "${screenId}" was not provided — pass it in \`screens\` or supply loadScreen`)
720
+ const ownerId = live.current.recordId
721
+ // Nothing to read for a record that is not saved yet: it owns nothing.
722
+ if (ownerId === null || ownerId === undefined) return { document: found, record: null }
723
+ const rows = await hook('get')({
724
+ many: true, id: null, screen: screenId, source: found.source || null, node, document: found,
725
+ defaults: {
726
+ get: () => {
727
+ if (!fetchRecordsRef.current) throw new Error(`No fetchRecords was provided to read "${screenId}"`)
728
+ return fetchRecordsRef.current({
729
+ source: found.source || null, screen: screenId, node,
730
+ belongsTo: node.props.belongsTo, belongsToId: ownerId
731
+ })
732
+ }
733
+ }
734
+ })
735
+ return { document: found, record: Array.isArray(rows) && rows.length ? rows[0] : null }
736
+ })().then(
737
+ (got) => { if (!cancelled) setSubforms((m) => ({ ...m, [node.id]: { loading: false, error: null, ...got } })) },
738
+ (err) => { if (!cancelled) setSubforms((m) => ({ ...m, [node.id]: { ...(m[node.id] || {}), loading: false, error: err.message || 'Could not load the form' } })) }
739
+ )
740
+ })
741
+ return () => { cancelled = true }
742
+ }, [subformNodes, subVersion]) // eslint-disable-line react-hooks/exhaustive-deps
743
+
744
+ const subformFor = useCallback((node) => subforms[node.id] || { loading: true, error: null, document: null, record: null }, [subforms])
745
+
605
746
  const closeEditor = useCallback(() => {
606
747
  setPopup(null)
607
748
  setListVersion((v) => v + 1)
@@ -617,7 +758,11 @@ export function useFactoryScreen({
617
758
 
618
759
  return {
619
760
  document: doc,
620
- documentErrors: check.errors,
761
+ documentErrors: linkError ? [...check.errors, linkError] : check.errors,
762
+ embedded,
763
+ subformFor,
764
+ registerSubform,
765
+ subformDirty,
621
766
  popup,
622
767
  fieldsFor,
623
768
  openEditor,
@@ -162,6 +162,7 @@ export function validateDocument(doc, controls = CONTROLS) {
162
162
  if (node.type === 'stepper') checkSteps(props, at, err)
163
163
  checkStep(node, doc, at, err)
164
164
  if (node.type === 'list') checkList(props, doc, at, err)
165
+ if (node.type === 'subform') checkSubform(props, doc, at, err)
165
166
  if (node.type === 'label') {
166
167
  if (typeof props.text !== 'string') err(`${at}.props.text`, 'must be a string')
167
168
  if (props.variant !== undefined && !LABEL_VARIANTS.includes(props.variant)) err(`${at}.props.variant`, `must be one of: ${LABEL_VARIANTS.join(', ')}`)
@@ -417,6 +418,20 @@ function checkList(props, doc, at, err) {
417
418
  }
418
419
  }
419
420
 
421
+ /**
422
+ * A form inside this one. Both halves are required: a form with no link saves
423
+ * a record belonging to nothing, and a link with no form is nothing. Itself is
424
+ * refused — a form containing itself draws forever.
425
+ */
426
+ function checkSubform(props, doc, at, err) {
427
+ if (props.title !== undefined && typeof props.title !== 'string') err(`${at}.props.title`, 'must be a string')
428
+ if (!(nonEmptyString(props.screen) && SCREEN_ID.test(props.screen))) err(`${at}.props.screen`, 'is required — the id of the form drawn here, e.g. "bank_details_edit"')
429
+ else if (props.screen === doc.id) err(`${at}.props.screen`, 'cannot be this screen — a form cannot contain itself')
430
+ if (!(nonEmptyString(props.belongsTo) && /^[A-Za-z_][\w]*$/.test(props.belongsTo))) {
431
+ err(`${at}.props.belongsTo`, 'is required — the field on that form pointing back at this record, e.g. "employeeId"')
432
+ }
433
+ }
434
+
420
435
  function nonEmptyString(v) {
421
436
  return typeof v === 'string' && v.trim() !== ''
422
437
  }