@xeplr/ui-factory 1.7.3 → 1.8.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
@@ -395,6 +395,34 @@ Controllers, for a design of your own: `useFlowBuilder` (the flow, plus
395
395
  functions, `forEvent`). Model: `navigationOf`, `setNavigation`, `arrowTargets`,
396
396
  `setEvent`, `functionsFor`.
397
397
 
398
+ ## Filled in from a screen before — `{previous.<screen>.values.<field>}`
399
+
400
+ In a flow, what one screen saved is handed to the screens after it as plain
401
+ JSON, by SCREEN key (the workflow API's `previous` on every run view):
402
+
403
+ ```json
404
+ { "department_edit": { "values": { "id": "dep_42", "name": "Finance" } } }
405
+ ```
406
+
407
+ A field fills itself from it with a default of
408
+ `{previous.department_edit.values.name}` — or `.id`, the row that screen
409
+ saved, to link to it. Pick it in the builder: **Fill in from** on any field
410
+ offers this screen's own inputs (`{params.x}`) and every form's fields, so
411
+ nothing is typed. Any kind of field may be filled this way, a number or a date
412
+ too.
413
+
414
+ - **No contract.** A screen not on this run's way here, or a field it does not
415
+ have, leaves the field empty — nothing errors. Making the names line up is
416
+ the flow's author's job; declared contracts between steps are the paid
417
+ workflow's.
418
+ - **By screen, not by step**, because the binding lives on the screen, and a
419
+ screen is the same form in every flow. A screen shown twice hands over its
420
+ latest.
421
+ - `FlowRunner` passes the run's `previous` to the screen; with your own design,
422
+ hand it to `useFactoryScreen({ previous })`. Model: `previousBinding`,
423
+ `resolveBinding(value, params, previous)`, `initialValues(doc, params,
424
+ controls, previous)`.
425
+
398
426
  ## Masters — the lists the rest of the app points at
399
427
 
400
428
  Departments, statuses, countries, categories. Mark a form as a master in its
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/ui-factory",
3
- "version": "1.7.3",
3
+ "version": "1.8.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
@@ -95,6 +95,10 @@ const FIELD_BASICS = (extra) => ({
95
95
  { path: 'props.label', label: 'Label', type: 'text' },
96
96
  { path: 'props.name', label: 'Field name', type: 'text', help: 'The key this value is saved under — letters, digits and _' },
97
97
  ...(extra || []),
98
+ // FILLED IN WHEN THE SCREEN OPENS, from somewhere else — the same
99
+ // props.default, holding a binding instead of a typed value.
100
+ { path: 'props.default', label: 'Fill in from', type: 'fillFrom',
101
+ help: 'What this screen is called with, or — in a flow — what a screen before this one saved. Nothing is checked: a screen not on the way here leaves it empty.' },
98
102
  { path: 'props.required', label: 'Required', type: 'toggle' }
99
103
  ]
100
104
  })
@@ -29,7 +29,9 @@ const STATUS = {
29
29
  error: 'Not saved'
30
30
  }
31
31
 
32
- export default function BuilderSample({ ctrl, renderPreview, className, style }) {
32
+ // screenApi: the app's factory API client, for designing a child table in place.
33
+ // forms: the app's forms and their fields (useFormFields), for "Fill in from".
34
+ export default function BuilderSample({ ctrl, renderPreview, className, style, screenApi, forms }) {
33
35
  const { document: doc } = ctrl
34
36
  const [preview, setPreview] = useState(false)
35
37
  const problemCount = ctrl.validation.errors.length
@@ -272,6 +274,7 @@ export default function BuilderSample({ ctrl, renderPreview, className, style })
272
274
  errors={ctrl.selectedNode ? ctrl.errorsByNode[ctrl.selectedNode.id] : ctrl.errorsByNode._document}
273
275
  tables={ctrl.tables}
274
276
  screens={ctrl.screens}
277
+ forms={forms}
275
278
  lockedNames={ctrl.lockedNames}
276
279
  onChange={(path, value) => (
277
280
  // A list's table is not an ordinary property: changing it makes
@@ -1,7 +1,8 @@
1
1
  import { useEffect, useState } from 'react'
2
2
  import { getAtPath } from '../propertyPath.js'
3
3
  import { FIELD_PRESETS, presetFor } from '../presets.js'
4
- import { slugify, inputNodes, paramsText } from '../document.js'
4
+ import { slugify, inputNodes, paramsText, screenParams } from '../document.js'
5
+ import { isBinding, previousBinding } from '../values.js'
5
6
  import { FONT_FAMILIES, STYLE_KEYS, SCREEN_STYLE_KEYS, LIST_ACTIONS, FILE_KINDS } from '../controls.js'
6
7
 
7
8
  // The selected control's properties, GENERATED from its entry in controls.js:
@@ -58,7 +59,7 @@ function FieldType({ node, controls, hint, onConvert, onKeepPlain }) {
58
59
  )
59
60
  }
60
61
 
61
- export default function PropertyPanel({ doc, node, control, controls, errors, tables, screens, lockedNames, onChange, onConvert, hint, onScreenChange, onRemove, onMakeChildTable, selectionCount }) {
62
+ export default function PropertyPanel({ doc, node, control, controls, errors, tables, screens, forms, lockedNames, onChange, onConvert, hint, onScreenChange, onRemove, onMakeChildTable, selectionCount }) {
62
63
  if (selectionCount > 1) {
63
64
  return (
64
65
  <aside className="xeplr-factory-panel">
@@ -95,17 +96,22 @@ export default function PropertyPanel({ doc, node, control, controls, errors, ta
95
96
  // A field name that is already a column cannot change — see setNodeProperty.
96
97
  const locked = field.path === 'props.name' && (lockedNames || []).includes(node.props?.name)
97
98
  const Editor = locked ? LockedEditor : (EDITORS[field.type] || TextEditor)
98
- const id = `xf-prop-${node.id}-${field.path.replace(/\W+/g, '-')}`
99
+ const id = `xf-prop-${node.id}-${field.path.replace(/\W+/g, '-')}-${field.type}`
100
+ // A default that is a BINDING belongs to "Fill in from"; the typed
101
+ // box beside it shows empty rather than the binding's braces.
102
+ const raw = getAtPath(node, field.path)
103
+ const value = field.path === 'props.default' && field.type !== 'fillFrom' && isBinding(raw) ? undefined : raw
99
104
  return (
100
- <div key={field.path} className={`xeplr-factory-prop${errs.length ? ' has-error' : ''}`}>
105
+ <div key={field.path + ':' + field.type} className={`xeplr-factory-prop${errs.length ? ' has-error' : ''}`}>
101
106
  {field.type !== 'toggle' && field.type !== 'toggleValue' && field.type !== 'columns' && field.type !== 'actions' && <label className="xeplr-factory-prop-label" htmlFor={id}>{field.label}</label>}
102
107
  <Editor
103
108
  id={id}
104
109
  field={field}
105
- value={getAtPath(node, field.path)}
110
+ value={value}
106
111
  onChange={(v) => onChange(field.path, v)}
107
112
  tables={tables}
108
113
  screens={screens}
114
+ forms={forms}
109
115
  node={node}
110
116
  doc={doc}
111
117
  errors={errs}
@@ -568,6 +574,43 @@ function FormEditor({ id, value, onChange, screens, doc }) {
568
574
  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
575
  }
570
576
 
577
+ /**
578
+ * FILL IN FROM — a field's default as a BINDING, picked, never typed: what
579
+ * this screen is called with ({params.x}), or what a screen before it in a
580
+ * flow saved ({previous.<screen>.values.<field>}). Picking "nothing" clears
581
+ * only a binding; a typed default value is left alone.
582
+ */
583
+ function FillFromEditor({ id, value, onChange, doc, forms }) {
584
+ const bound = isBinding(value) ? value : ''
585
+ const inputs = doc ? screenParams(doc) : []
586
+ const others = (forms || []).filter((f) => !doc || f.key !== doc.id)
587
+ const offered = new Set([
588
+ ...inputs.map((p) => `{params.${p.name}}`),
589
+ ...others.flatMap((f) => f.fields.map((x) => previousBinding(f.key, x.name)))
590
+ ])
591
+ return (
592
+ <select
593
+ id={id}
594
+ className="xeplr-factory-prop-input"
595
+ value={bound}
596
+ onChange={(e) => { if (e.target.value || bound) onChange(e.target.value || undefined) }}
597
+ >
598
+ <option value="">Nothing — empty, or the default value</option>
599
+ {inputs.length > 0 && (
600
+ <optgroup label="What this screen is called with">
601
+ {inputs.map((p) => <option key={p.name} value={`{params.${p.name}}`}>{p.label || p.name}</option>)}
602
+ </optgroup>
603
+ )}
604
+ {others.map((f) => (
605
+ <optgroup key={f.key} label={`${f.name} — a screen before this one`}>
606
+ {f.fields.map((x) => <option key={x.name} value={previousBinding(f.key, x.name)}>{f.name} → {x.label}</option>)}
607
+ </optgroup>
608
+ ))}
609
+ {bound && !offered.has(bound) && <option value={bound}>{bound}</option>}
610
+ </select>
611
+ )
612
+ }
613
+
571
614
  const ACTION_LABELS = { new: 'New — add a record', edit: 'Edit — open a row for editing', delete: 'Delete — remove a row' }
572
615
 
573
616
  function ActionsEditor({ value, onChange }) {
@@ -751,6 +794,7 @@ export const EDITORS = {
751
794
  table: TableEditor,
752
795
  screen: ScreenEditor,
753
796
  form: FormEditor,
797
+ fillFrom: FillFromEditor,
754
798
  columns: ColumnsEditor,
755
799
  listTable: ListTableEditor,
756
800
  actions: ActionsEditor,
package/src/index.js CHANGED
@@ -23,6 +23,8 @@ export { useFlowBuilder, FLOW_AUTOSAVE_DELAY } from './useFlowBuilder.js'
23
23
  export { useScreenEvents } from './useScreenEvents.js'
24
24
  export { useFunctionCatalog } from './useFunctionCatalog.js'
25
25
  export { useUrlTab } from './useUrlPlaces.js'
26
+ // The app's forms and their fields — what "Fill in from" offers.
27
+ export { useFormFields } from './useFormFields.js'
26
28
 
27
29
  // Designs
28
30
  export { BuilderSample, ScreenSample, ListView, ScreenModal, FlowRunnerSample, FlowBuilderSample, FlowDesignerSample, FLOW_DESIGNER_TABS, ScreenEditorSample, ControlView, VIEWS, Palette, DRAG_TYPE, PropertyPanel, EDITORS } from './designs/index.js'
package/src/model.js CHANGED
@@ -25,7 +25,7 @@ export {
25
25
  createStatements, addStatements, alterStatement, dropStatement, columnFromDatabase, planTableChange, conversionFor
26
26
  } from './tableSchema.js'
27
27
  // A value with a {params.x} token is a binding; a plain value is a literal.
28
- export { resolveBinding, isBinding } from './values.js'
28
+ export { resolveBinding, isBinding, previousBinding } from './values.js'
29
29
  // A code people can read out, unlike the record's own key.
30
30
  export { codeFields, countedFields, generateCode } from './values.js'
31
31
  export { FIELD_PRESETS, PATTERNS, INPUT_TYPES, presetFor, presetProps, suggestPreset, presetGroup, presetHints, convertField } from './presets.js'
package/src/pages.jsx CHANGED
@@ -3,6 +3,7 @@ import { useFactoryBuilder } from './useFactoryBuilder.js'
3
3
  import { useFactoryScreen } from './useFactoryScreen.js'
4
4
  import { useFlowRun } from './useFlowRun.js'
5
5
  import { useFlowBuilder } from './useFlowBuilder.js'
6
+ import { useFormFields } from './useFormFields.js'
6
7
  import { useScreenEvents } from './useScreenEvents.js'
7
8
  import { useFunctionCatalog } from './useFunctionCatalog.js'
8
9
  import { useUrlTab } from './useUrlPlaces.js'
@@ -27,9 +28,12 @@ import { BuilderSample, ScreenSample, ScreenModal, FlowRunnerSample, FlowBuilder
27
28
  */
28
29
  export function FactoryBuilder(props) {
29
30
  const ctrl = useFactoryBuilder(props)
31
+ // The app's forms and their fields, for a field's "Fill in from".
32
+ const forms = useFormFields(props.screenApi)
30
33
  return (
31
34
  <BuilderSample
32
35
  ctrl={ctrl}
36
+ forms={forms}
33
37
  className={props.className}
34
38
  style={props.style}
35
39
  // So a child table made here can be designed here — see the modal in
@@ -174,7 +178,10 @@ export function FlowRunner(props) {
174
178
  record: run.values || undefined,
175
179
  // What the flow called this screen with — its step's own input, resolved
176
180
  // by the engine. A field bound to {params.x} is filled from here.
177
- params: run.given || undefined
181
+ params: run.given || undefined,
182
+ // What the screens before this one saved, by screen key — a field bound to
183
+ // {previous.<screen>.values.<field>} is filled from here.
184
+ previous: run.previous || undefined
178
185
  })
179
186
  return (
180
187
  <FlowRunnerSample
@@ -67,10 +67,14 @@ import { useUrlSteps } from './useUrlPlaces.js'
67
67
  * the form from a list supplies nothing. A field whose default is
68
68
  * `{params.x}` is filled from here, and left empty (for the person to fill
69
69
  * in) when nobody said.
70
+ * @param previous in a flow, what the screens before this one saved, by
71
+ * screen key: { department_edit: { values: { id, name, … } } }. A field
72
+ * whose default is `{previous.department_edit.values.name}` is filled from
73
+ * here — empty when that screen was not on this run's path.
70
74
  */
71
75
  export function useFactoryScreen({
72
76
  document: doc, record, recordKey = 'id', onSave, fetchOptions, fetchRecords, fetchRecord, onDelete, onChange, screens, loadScreen, hooks, uploadFile, fileUrl,
73
- onOpenRecord, onDone, onDirtyChange, params, urlKey,
77
+ onOpenRecord, onDone, onDirtyChange, params, urlKey, previous,
74
78
  link, onReady,
75
79
  controls = CONTROLS
76
80
  } = {}) {
@@ -134,9 +138,9 @@ export function useFactoryScreen({
134
138
  // reopened wins, because the row is the truth once there is one.
135
139
  const startValues = useCallback(
136
140
  (rec) => (rec
137
- ? { ...initialValues(doc, params || {}, controls), ...recordValues(doc, rec, controls) }
138
- : initialValues(doc, params || {}, controls)),
139
- [doc, controls, params]
141
+ ? { ...initialValues(doc, params || {}, controls, previous), ...recordValues(doc, rec, controls) }
142
+ : initialValues(doc, params || {}, controls, previous)),
143
+ [doc, controls, params, previous]
140
144
  )
141
145
 
142
146
  const [values, setValues] = useState(() => (check.ok ? startValues(record) : {}))
package/src/useFlowRun.js CHANGED
@@ -15,7 +15,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
15
15
  * @param flowKey which flow to run (starting one, or picking up a run)
16
16
  * @param runId an existing run to continue — omit to start one
17
17
  * @param startRun async (flowKey) → { runId, status, stepKey, screen }
18
- * @param loadRun async (runId) → { runId, status, stepKey, screen, values, given }
18
+ * @param loadRun async (runId) → { runId, status, stepKey, screen, values, given, previous }
19
19
  * @param submitRun async (runId, values, recordId) → { status, stepKey, screen }
20
20
  * @param loadScreen async (key) → the screen document
21
21
  * @param onStep (step) → void — told each time the run moves, for the address bar
@@ -25,7 +25,7 @@ export function useFlowRun({ flowKey, runId, startRun, loadRun, submitRun, loadS
25
25
  // `given` is what the flow called this screen with — the step's own input,
26
26
  // resolved by the engine (see @xeplr/workflow's screen-show). A field bound
27
27
  // to {params.x} is filled from it.
28
- const [state, setState] = useState({ loading: true, error: null, runId: runId || null, status: null, stepKey: null, screen: null, document: null, values: null, given: null })
28
+ const [state, setState] = useState({ loading: true, error: null, runId: runId || null, status: null, stepKey: null, screen: null, document: null, values: null, given: null, previous: null })
29
29
  const [sending, setSending] = useState(false)
30
30
  const live = useRef({})
31
31
  live.current = { startRun, loadRun, submitRun, loadScreen, onStep, onFinish, flowKey }
@@ -43,7 +43,9 @@ export function useFlowRun({ flowKey, runId, startRun, loadRun, submitRun, loadS
43
43
  setState({
44
44
  loading: false, error: null,
45
45
  runId: run.runId, status: run.status || 'running', stepKey: run.stepKey, screen: run.screen,
46
- document: doc, values: run.values || null, given: run.given || null
46
+ // previous: what the screens before this one saved, by screen key —
47
+ // what {previous.<screen>.values.<field>} defaults fill from.
48
+ document: doc, values: run.values || null, given: run.given || null, previous: run.previous || null
47
49
  })
48
50
  if (live.current.onStep) live.current.onStep(run)
49
51
  }, [])
@@ -0,0 +1,37 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { inputNodes } from './document.js'
3
+
4
+ // THE APP'S FORMS AND THEIR FIELDS — what a field's "Fill in from" picker
5
+ // offers: in a flow, a screen before this one, and what it saved. No JSX.
6
+ //
7
+ // Every published screen that has fields of its own (a list screen has none),
8
+ // each with `id` — the row it saved — then its fields by the name they are
9
+ // saved under. Loaded once per builder, through the app's own API client.
10
+
11
+ /**
12
+ * @param api a createFactoryApi() instance (builderProps.screenApi)
13
+ * @returns [{ key, name, fields: [{ name, label }] }]
14
+ */
15
+ export function useFormFields(api) {
16
+ const [forms, setForms] = useState([])
17
+ useEffect(() => {
18
+ if (!api || !api.listScreens || !api.loadScreen) return undefined
19
+ let gone = false
20
+ Promise.resolve()
21
+ .then(() => api.listScreens())
22
+ .then((rows) => Promise.all((rows || []).filter((r) => r && r.version).map(async (r) => {
23
+ try {
24
+ const doc = await api.loadScreen(r.screenKey)
25
+ const fields = inputNodes(doc).map((n) => ({ name: n.props.name, label: n.props.label || n.props.name }))
26
+ if (!fields.length) return null
27
+ return { key: doc.id, name: doc.name || doc.id, fields: [{ name: 'id', label: 'the row it saved (id)' }, ...fields] }
28
+ } catch (err) {
29
+ return null
30
+ }
31
+ })))
32
+ .then((list) => { if (!gone) setForms(list.filter(Boolean)) })
33
+ .catch(() => { /* the picker then offers this screen's own inputs only */ })
34
+ return () => { gone = true }
35
+ }, [api])
36
+ return forms
37
+ }
@@ -15,6 +15,7 @@ import { CONTROLS, LABEL_VARIANTS, LIST_ACTIONS, STYLE_KEYS, SCREEN_STYLE_KEYS,
15
15
  import { DOCUMENT_KIND, DOCUMENT_VERSION } from './document.js'
16
16
  import { RESERVED_COLUMNS, MAX_IDENTIFIER } from './tableSchema.js'
17
17
  import { FIELD_PRESETS, INPUT_TYPES, presetFor } from './presets.js'
18
+ import { isBinding } from './values.js'
18
19
 
19
20
  const FIELD_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
20
21
  const TABLE_NAME = /^[A-Za-z_][A-Za-z0-9_.$-]*$/
@@ -210,6 +211,10 @@ function checkGeometry(node, at, err) {
210
211
  function checkDefault(node, def, at, err) {
211
212
  const v = node.props.default
212
213
  if (v === undefined) return
214
+ // A BINDING fills the field when the screen opens — {params.x} or
215
+ // {previous.<screen>.values.<field>} — so it is text whatever the field
216
+ // holds. A date or number field may be filled from one as well.
217
+ if (isBinding(v)) return
213
218
  const isDatetime = node.type === 'datetime'
214
219
  const ok = {
215
220
  string: () => typeof v === 'string',
package/src/values.js CHANGED
@@ -61,26 +61,43 @@ export function formSchema(doc, controls = CONTROLS) {
61
61
  * Whole-value only. `{params.a}-{params.b}` is not a value a form field takes,
62
62
  * and splicing text is a formula's job, not a form's.
63
63
  */
64
- export function resolveBinding(value, params) {
64
+ export function resolveBinding(value, params, previous) {
65
65
  if (typeof value !== 'string') return value
66
- const m = /^\{params\.([A-Za-z_][\w]*)\}$/.exec(value.trim())
67
- if (!m) return value
68
- return params ? params[m[1]] : undefined
66
+ const v = value.trim()
67
+ const p = PARAM_BINDING.exec(v)
68
+ if (p) return params ? params[p[1]] : undefined
69
+ // WHAT A SCREEN BEFORE THIS ONE SAVED, in a flow — by that screen's key, the
70
+ // saved row as `id`. Handed over as plain JSON with no contract: a screen
71
+ // not reached on this run, or a field it does not have, is simply empty.
72
+ const q = PREVIOUS_BINDING.exec(v)
73
+ if (q) {
74
+ const screen = previous && previous[q[1]]
75
+ return screen && screen.values ? screen.values[q[2]] : undefined
76
+ }
77
+ return value
69
78
  }
70
79
 
71
- /** Is this what somebody typed, or a reference to an input? */
80
+ const PARAM_BINDING = /^\{params\.([A-Za-z_][\w]*)\}$/
81
+ const PREVIOUS_BINDING = /^\{previous\.([A-Za-z][A-Za-z0-9_]*)\.values\.([A-Za-z_][\w]*)\}$/
82
+
83
+ /** Is this what somebody typed, or a reference — to an input, or to a screen before this one? */
72
84
  export function isBinding(value) {
73
- return typeof value === 'string' && /^\{params\.[A-Za-z_][\w]*\}$/.test(value.trim())
85
+ return typeof value === 'string' && (PARAM_BINDING.test(value.trim()) || PREVIOUS_BINDING.test(value.trim()))
86
+ }
87
+
88
+ /** The binding to a field a screen before this one saved: `{previous.<screen>.values.<field>}`. */
89
+ export function previousBinding(screen, field) {
90
+ return `{previous.${screen}.values.${field}}`
74
91
  }
75
92
 
76
- export function initialValues(doc, provided = {}, controls = CONTROLS) {
93
+ export function initialValues(doc, provided = {}, controls = CONTROLS, previous = null) {
77
94
  const out = {}
78
95
  inputNodes(doc, controls).forEach((node) => {
79
96
  const name = node.props.name
80
97
  if (provided[name] !== undefined) out[name] = provided[name]
81
98
  else if (node.props.default !== undefined) {
82
99
  // A default may be a binding to one of the screen's own inputs.
83
- const resolved = resolveBinding(node.props.default, provided)
100
+ const resolved = resolveBinding(node.props.default, provided, previous)
84
101
  if (resolved !== undefined) out[name] = resolved
85
102
  else if (!isBinding(node.props.default)) out[name] = node.props.default
86
103
  else if (node.type === 'checkbox') out[name] = false