@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 +39 -0
- package/package.json +1 -1
- package/src/controls.js +35 -0
- package/src/designs/ControlView.jsx +37 -2
- package/src/designs/PropertyPanel.jsx +19 -0
- package/src/designs/ScreenSample.jsx +15 -9
- package/src/designs/factory.css +14 -0
- package/src/pages.jsx +26 -0
- package/src/useFactoryScreen.js +168 -23
- package/src/validateDocument.js +15 -0
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
|
+
"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
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
</
|
|
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} />
|
package/src/designs/factory.css
CHANGED
|
@@ -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…'}
|
package/src/useFactoryScreen.js
CHANGED
|
@@ -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(
|
|
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(
|
|
324
|
-
[check.ok,
|
|
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
|
-
/**
|
|
330
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
|
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: (
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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,
|
package/src/validateDocument.js
CHANGED
|
@@ -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
|
}
|