@xeplr/ui-factory 1.2.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 +201 -0
- package/bin/xeplr-factory.js +2 -1
- package/package.json +1 -1
- package/src/controls.js +97 -2
- package/src/designs/BuilderSample.jsx +47 -1
- package/src/designs/ControlView.jsx +73 -2
- package/src/designs/ListView.jsx +3 -1
- package/src/designs/PropertyPanel.jsx +237 -7
- package/src/designs/ScreenSample.jsx +15 -9
- package/src/designs/factory.css +64 -0
- package/src/designs/styles.js +1 -1
- package/src/document.js +51 -0
- package/src/index.js +3 -1
- package/src/masters.js +193 -0
- package/src/model.js +5 -0
- package/src/pages.jsx +37 -1
- package/src/remote.js +22 -2
- package/src/screenSource.js +85 -2
- package/src/tableSchema.js +16 -0
- package/src/useFactoryBuilder.js +103 -2
- package/src/useFactoryScreen.js +202 -29
- package/src/useFlowRun.js +6 -3
- package/src/useScreenSource.js +8 -4
- package/src/validateDocument.js +82 -1
- package/src/values.js +118 -2
package/README.md
CHANGED
|
@@ -197,6 +197,204 @@ 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
|
+
## 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
|
+
|
|
326
|
+
## Masters — the lists the rest of the app points at
|
|
327
|
+
|
|
328
|
+
Departments, statuses, countries, categories. Mark a form as a master in its
|
|
329
|
+
design (select nothing on the canvas → **This is a master**), and say what a
|
|
330
|
+
picker should show for one of its records:
|
|
331
|
+
|
|
332
|
+
```js
|
|
333
|
+
{ master: true, labelField: 'name' } // on the form's own document
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
**Made, not designed.** Every application repeats the same three things —
|
|
337
|
+
a name, an order of its own, and the master it belongs to — so a master is
|
|
338
|
+
created outright rather than drawn:
|
|
339
|
+
|
|
340
|
+
```js
|
|
341
|
+
import { createMaster } from '@xeplr/ui-factory'
|
|
342
|
+
|
|
343
|
+
await createMaster(factory, { name: 'States', parent: { table: 'countries', label: 'Country' } })
|
|
344
|
+
// → states_edit + states_list, marked as a master, published
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
- **Name** is required: a master row with no name is a blank line in every
|
|
348
|
+
dropdown that points at it.
|
|
349
|
+
- **Sort order** is not: Low / Medium / High is never alphabetical, but most
|
|
350
|
+
lists are fine, and a number somebody must invent for every row is a tax on
|
|
351
|
+
adding one.
|
|
352
|
+
- **Belongs to** is a dropdown on the parent master, required when there is
|
|
353
|
+
one — a state in no country is a row nothing can use. A master is never
|
|
354
|
+
offered as its own parent.
|
|
355
|
+
|
|
356
|
+
It is **published** on creation, which is the one place doing that on
|
|
357
|
+
somebody's behalf is right: a master that is a draft has no table, so nothing
|
|
358
|
+
can point at it and nobody can add a row, which is all a master is for.
|
|
359
|
+
|
|
360
|
+
Named in the plural, stored in the singular: *Departments* makes the entity
|
|
361
|
+
`department` and the table `departments`. Without that step `entityNames`
|
|
362
|
+
would make `departmentses`, which is noticed only once the table exists and
|
|
363
|
+
then cannot be renamed.
|
|
364
|
+
|
|
365
|
+
**A flag on a form, not a kind of form.** A master has a table, a list, an
|
|
366
|
+
edit screen and permissions like anything else; a separate type would be two
|
|
367
|
+
things that behave the same until they do not, and every feature after it
|
|
368
|
+
would have to ask which one it is holding. The flag adds a fact; a type adds
|
|
369
|
+
a fork.
|
|
370
|
+
|
|
371
|
+
`labelField` is said once, here, rather than on every dropdown that points at
|
|
372
|
+
this form — thirty dropdowns should not each be told that a department is
|
|
373
|
+
shown by its name.
|
|
374
|
+
|
|
375
|
+
`useScreenSource` returns `masters` alongside `screens`: one entry per FORM
|
|
376
|
+
(`{ key, name, labelField, edit, list, source }`), gathered by `masterForms`,
|
|
377
|
+
so an app's admin section renders a list and groups nothing itself. That is
|
|
378
|
+
what `@xeplr/cli` generates as Configure UI → **Masters**.
|
|
379
|
+
|
|
380
|
+
They are gathered in the admin section rather than scattered through the side
|
|
381
|
+
rail because that is what they are — reference data somebody maintains
|
|
382
|
+
occasionally. The rail stays for the work.
|
|
383
|
+
|
|
384
|
+
**And they are what a dropdown offers first.** A field's *where its options
|
|
385
|
+
come from* now reads:
|
|
386
|
+
|
|
387
|
+
```
|
|
388
|
+
Masters Departments, Statuses, Countries ← named, first
|
|
389
|
+
Other tables complaints, employees, pod_items ← still there, below
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
`/factory/tables` marks and names them (`@xeplr/factory`'s
|
|
393
|
+
`tablesForPicker`), so the picker groups rather than guesses. Two `<optgroup>`s
|
|
394
|
+
in a plain `<select>`, not a combobox: typing jumps to an option in every
|
|
395
|
+
browser, which is the searching anybody wants here, and a widget of our own
|
|
396
|
+
would have to reimplement the keyboard to match.
|
|
397
|
+
|
|
200
398
|
## Your screens, inside someone else's designer
|
|
201
399
|
|
|
202
400
|
`@xeplr/ui-workflow`'s flow designer has a **Screen** step, and it asks its
|
|
@@ -304,6 +502,7 @@ Both walk someone through something, and they answer different questions:
|
|
|
304
502
|
| `stepper` | — | a journey across the top; every other control says which step it is on |
|
|
305
503
|
| `label` | — | text with a heading, subheading or text preset |
|
|
306
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 |
|
|
307
506
|
|
|
308
507
|
A screen is a **sheet**, centred in the space it is given and shown at its
|
|
309
508
|
design width: an elevated card in the app's theme (`--xeplr-bg-tertiary`, with
|
|
@@ -516,6 +715,8 @@ The control whose properties are open, the step a stepper is open at, and the fl
|
|
|
516
715
|
|
|
517
716
|
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
717
|
|
|
718
|
+
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.
|
|
719
|
+
|
|
519
720
|
Buttons, steps and list actions all show hover, a visible press, and a keyboard-only focus ring.
|
|
520
721
|
|
|
521
722
|
## Try it
|
package/bin/xeplr-factory.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
@@ -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: '
|
|
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' },
|
|
@@ -512,6 +572,41 @@ CONTROLS.list = {
|
|
|
512
572
|
]
|
|
513
573
|
}
|
|
514
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
|
+
|
|
515
610
|
export const LABEL_VARIANTS = ['heading', 'subheading', 'text']
|
|
516
611
|
|
|
517
612
|
/** The sizes a label preset starts from, in px at the design width. */
|
|
@@ -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) =>
|
|
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}
|
|
@@ -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
|
}
|
|
@@ -73,6 +74,41 @@ const TEXT_INPUT = {
|
|
|
73
74
|
url: { type: 'url', autoComplete: 'url', inputMode: 'url' }
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
/**
|
|
78
|
+
* AN IDENTIFIER — the number or code this record is known by.
|
|
79
|
+
*
|
|
80
|
+
* Never typed, either way, so there is nothing to type into. What differs is
|
|
81
|
+
* whether it is on the form at all:
|
|
82
|
+
*
|
|
83
|
+
* code shown. This is the one people quote to each other, so they need to
|
|
84
|
+
* see it — read-only, because changing it later breaks every quote.
|
|
85
|
+
* auto NOT SHOWN on a live form. The database assigns it as the row is
|
|
86
|
+
* inserted, so on a new record there is nothing to show but an empty
|
|
87
|
+
* box, and an empty box that never fills in is a question the form
|
|
88
|
+
* appears to be asking and is not. Once the row exists it is worth
|
|
89
|
+
* seeing, so a saved record shows it.
|
|
90
|
+
*
|
|
91
|
+
* On the DESIGN canvas both are drawn, or there would be nothing to select
|
|
92
|
+
* and nothing to configure.
|
|
93
|
+
*/
|
|
94
|
+
function IdView({ node, p, value, design }) {
|
|
95
|
+
const counted = p.idKind === 'auto'
|
|
96
|
+
if (counted && !design && (value === undefined || value === null || value === '')) return null
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<Field node={node} p={p}>
|
|
100
|
+
<input
|
|
101
|
+
className="xeplr-factory-input"
|
|
102
|
+
type="text"
|
|
103
|
+
value={value ?? ''}
|
|
104
|
+
readOnly
|
|
105
|
+
disabled
|
|
106
|
+
placeholder={counted ? 'Numbered when saved' : 'Given when saved'}
|
|
107
|
+
/>
|
|
108
|
+
</Field>
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
76
112
|
function TextView({ node, p, value, error, onChange, onBlur, disabled }) {
|
|
77
113
|
const kind = TEXT_INPUT[p.inputType] || { type: 'text' }
|
|
78
114
|
return (
|
|
@@ -390,11 +426,45 @@ function LabelView({ p }) {
|
|
|
390
426
|
)
|
|
391
427
|
}
|
|
392
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
|
+
|
|
393
462
|
function ListControl({ node, design, list }) {
|
|
394
463
|
return <ListView node={node} design={design} {...(list || {})} />
|
|
395
464
|
}
|
|
396
465
|
|
|
397
466
|
export const VIEWS = {
|
|
467
|
+
id: IdView,
|
|
398
468
|
text: TextView,
|
|
399
469
|
textarea: TextareaView,
|
|
400
470
|
number: NumberView,
|
|
@@ -407,5 +477,6 @@ export const VIEWS = {
|
|
|
407
477
|
file: FileView,
|
|
408
478
|
stepper: StepperView,
|
|
409
479
|
label: LabelView,
|
|
410
|
-
list: ListControl
|
|
480
|
+
list: ListControl,
|
|
481
|
+
subform: SubformView
|
|
411
482
|
}
|
package/src/designs/ListView.jsx
CHANGED
|
@@ -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
|
-
|
|
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
|
|