@xeplr/ui-factory 1.1.0 → 1.2.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 +27 -2
- package/package.json +7 -2
- package/src/pages.jsx +3 -0
- package/src/urlState.js +71 -0
- package/src/useFactoryBuilder.js +32 -9
- package/src/useFactoryScreen.js +12 -9
- package/src/useFlowBuilder.js +7 -4
- package/src/useUrlPlaces.js +48 -0
package/README.md
CHANGED
|
@@ -44,10 +44,10 @@ Most internal apps are a long tail of forms, and every one of them is hand-built
|
|
|
44
44
|
## Install
|
|
45
45
|
|
|
46
46
|
```sh
|
|
47
|
-
npm i @xeplr/ui-factory @xeplr/ui-canvas @xeplr/ui-table @tanstack/react-table
|
|
47
|
+
npm i @xeplr/ui-factory @xeplr/ui-canvas @xeplr/ui-table @tanstack/react-table react-router-dom
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
-
Peer dependencies: `react ^18 || ^19`, [`@xeplr/ui-canvas`](https://www.npmjs.com/package/@xeplr/ui-canvas) (the canvas the builder places controls on)
|
|
50
|
+
Peer dependencies: `react ^18 || ^19`, [`@xeplr/ui-canvas`](https://www.npmjs.com/package/@xeplr/ui-canvas) (the canvas the builder places controls on), [`@xeplr/ui-table`](https://www.npmjs.com/package/@xeplr/ui-table) (lists of saved records) and `react-router-dom ^6 || ^7` (imported by [`@xeplr/ui-utils`](https://www.npmjs.com/package/@xeplr/ui-utils)' `useUrlState`, a dependency, for the URL state below; with no router above, everything stays in memory). Your bundler must compile JSX and import CSS from the package (Vite does both).
|
|
51
51
|
|
|
52
52
|
## The builder
|
|
53
53
|
|
|
@@ -486,6 +486,26 @@ npx xeplr-factory controls # controls, props and rules
|
|
|
486
486
|
|
|
487
487
|
Layout is **proportional**, like `@xeplr/ui-canvas` dashboards: `x` and `w` are fractions of the width, `y` and `h` fractions of a page (`width × aspect`). The screen is shown at its design `width`; on a narrower display everything — positions and text — scales down together.
|
|
488
488
|
|
|
489
|
+
## Where you are lives in the URL: `urlKey`
|
|
490
|
+
|
|
491
|
+
The control whose properties are open, the step a stepper is open at, and the flow step being edited are places a person navigates to. Give a controller (or its ready-made page) a `urlKey` and they are written to the URL's query, so a refresh or a shared link lands on them again:
|
|
492
|
+
|
|
493
|
+
| controller / page | key | holds | history |
|
|
494
|
+
|---|---|---|---|
|
|
495
|
+
| `useFactoryBuilder` / `<FactoryBuilder>` | `<urlKey>Selected` | the id of the one selected control | opening or closing its properties adds an entry (Back closes them); moving to another control replaces it |
|
|
496
|
+
| `useFactoryBuilder` / `<FactoryBuilder>` | `<urlKey>Steps` | the step each stepper is open at: `stepperId:index`, comma-separated, step 0 left out | replaces |
|
|
497
|
+
| `useFactoryScreen` / `<FactoryScreen>`, `<FlowRunner>` | `<urlKey>Steps` | as above | replaces |
|
|
498
|
+
| `useFlowBuilder` / `<FlowBuilder>` | `<urlKey>Selected` | the key of the selected flow step | as for a control |
|
|
499
|
+
|
|
500
|
+
```jsx
|
|
501
|
+
<FactoryBuilder urlKey="form" document={doc} onSave={save} /> // ?formSelected=email&formSteps=stepper:1
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
- **The key is the host's, never a default.** A builder, its Preview and a list's Edit popup can share a page, and the host has keys of its own, so nothing is written to the URL without a `urlKey` — give each its own. **Without one the state stays in memory**, as before. The Preview and the Edit popup are never given one.
|
|
505
|
+
- **A multi-selection is not a place.** Several controls selected is a working set for Delete and Duplicate, not somewhere a person goes, so it stays in memory and the URL then names nothing. Only a single selection — the one whose properties are open — is in the URL.
|
|
506
|
+
- **Ids, found again.** The selected control is looked up in the document; one that is not there selects nothing. A document handed in after mounting keeps the URL's control, so a host that loads the screen late still lands on it. A step past the last is pulled back to the last, and steppers not in the document (a screen still loading) are left alone.
|
|
507
|
+
- The rule is in `src/urlState.js` (`factoryUrlKeys`, `encodeSteps`, `decodeSteps`, `replacesHistory`, `selectionPlace`); the hooks that keep it are `src/useUrlPlaces.js`.
|
|
508
|
+
|
|
489
509
|
## Three ways to use it
|
|
490
510
|
|
|
491
511
|
1. **Ready-made** — `<FactoryBuilder>` and `<FactoryScreen>`.
|
|
@@ -523,6 +543,9 @@ src/
|
|
|
523
543
|
model.js ─ everything above, React-free
|
|
524
544
|
useFactoryBuilder.js ─ builder controller
|
|
525
545
|
useFactoryScreen.js ─ screen controller
|
|
546
|
+
useFlowBuilder.js ─ flow designer controller
|
|
547
|
+
urlState.js ─ the URL keys and values for selection and steps (React-free)
|
|
548
|
+
useUrlPlaces.js ─ useUrlSteps, useUrlSelection: those places, in the URL or in memory
|
|
526
549
|
useScreenSource.js ─ { screens, screenEditor } for @xeplr/ui-workflow's Screen step
|
|
527
550
|
designs/ ─ BuilderSample, ScreenSample, ScreenEditorSample, ControlView, ListView, Palette, PropertyPanel, styles.js, factory.css
|
|
528
551
|
pages.jsx ─ FactoryBuilder, FactoryScreen
|
|
@@ -537,6 +560,8 @@ dev/ ─ the stand-in app for `npm run dev` (not published)
|
|
|
537
560
|
npm test
|
|
538
561
|
```
|
|
539
562
|
|
|
563
|
+
`test/urlState.test.js` covers the URL state: the key format, and the three controllers reading their places back from a real `MemoryRouter` and being clicked through in memory, rendered with `react-dom/server`.
|
|
564
|
+
|
|
540
565
|
## License
|
|
541
566
|
|
|
542
567
|
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xeplr/ui-factory",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.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",
|
|
@@ -48,7 +48,8 @@
|
|
|
48
48
|
"@tanstack/react-table": "^8.0.0",
|
|
49
49
|
"@xeplr/ui-canvas": "^1.0.2",
|
|
50
50
|
"@xeplr/ui-table": "^1.0.6",
|
|
51
|
-
"react": "^18.0.0 || ^19.0.0"
|
|
51
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
52
|
+
"react-router-dom": "^6.0.0 || ^7.0.0"
|
|
52
53
|
},
|
|
53
54
|
"devDependencies": {
|
|
54
55
|
"@tanstack/react-table": "^8.21.3",
|
|
@@ -58,6 +59,10 @@
|
|
|
58
59
|
"@xeplr/ui-table": "^1.0.6",
|
|
59
60
|
"react": "^19.2.4",
|
|
60
61
|
"react-dom": "^19.2.4",
|
|
62
|
+
"react-router-dom": "^7.18.4",
|
|
61
63
|
"vite": "^7.3.1"
|
|
64
|
+
},
|
|
65
|
+
"dependencies": {
|
|
66
|
+
"@xeplr/ui-utils": "^1.1.2"
|
|
62
67
|
}
|
|
63
68
|
}
|
package/src/pages.jsx
CHANGED
|
@@ -19,6 +19,7 @@ import { BuilderSample, ScreenSample, ScreenModal, FlowRunnerSample, FlowBuilder
|
|
|
19
19
|
* @prop lockedNames field names that are already columns of the table — shown read-only
|
|
20
20
|
* @prop screens [{ id, name, document? }] — other screens: "Edit in" choices, and Preview's popups
|
|
21
21
|
* @prop controls control registry (default: built-ins)
|
|
22
|
+
* @prop urlKey keep the selected control and open steps in the URL (<urlKey>Selected, <urlKey>Steps)
|
|
22
23
|
*/
|
|
23
24
|
export function FactoryBuilder(props) {
|
|
24
25
|
const ctrl = useFactoryBuilder(props)
|
|
@@ -57,6 +58,7 @@ export function FactoryBuilder(props) {
|
|
|
57
58
|
* @prop loadScreen async (id) → document — for screens not in `screens`
|
|
58
59
|
* @prop controls control registry (default: built-ins)
|
|
59
60
|
* @prop hooks your FactoryHooks subclass (get / save / delete / actions) — also used by the popup
|
|
61
|
+
* @prop urlKey keep the open step of each stepper in the URL (<urlKey>Steps); never passed to the popup
|
|
60
62
|
*/
|
|
61
63
|
export function FactoryScreen(props) {
|
|
62
64
|
const ctrl = useFactoryScreen(props)
|
|
@@ -166,6 +168,7 @@ const EMPTY_SCREEN = {
|
|
|
166
168
|
* @prop onSave async (flow) → void
|
|
167
169
|
* @prop onPublish async (key) → void
|
|
168
170
|
* @prop onChange (flow) → void
|
|
171
|
+
* @prop urlKey keep the selected step in the URL (<urlKey>Selected)
|
|
169
172
|
*/
|
|
170
173
|
export function FlowBuilder(props) {
|
|
171
174
|
const ctrl = useFlowBuilder(props)
|
package/src/urlState.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// WHERE YOU ARE LIVES IN THE URL — the pure half, no React.
|
|
2
|
+
//
|
|
3
|
+
// The step a stepper is open at, and the control (or flow step) whose
|
|
4
|
+
// properties are open, are places a person navigates to: a refresh or a
|
|
5
|
+
// shared link must land on them again. This file is how they are named in the
|
|
6
|
+
// URL's query; useUrlPlaces.js reads and writes them.
|
|
7
|
+
//
|
|
8
|
+
// THE KEYS ARE THE HOST'S. A builder, a running screen and the preview inside
|
|
9
|
+
// the builder can share one page, and the host app has keys of its own, so a
|
|
10
|
+
// hook writes the URL only when given a `urlKey`. Without one it keeps the
|
|
11
|
+
// same values in memory, exactly as before.
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The query keys under a host's `urlKey`, or null (in memory).
|
|
15
|
+
* factoryUrlKeys('form') → { steps: 'formSteps', selected: 'formSelected' }
|
|
16
|
+
*/
|
|
17
|
+
export function factoryUrlKeys(urlKey) {
|
|
18
|
+
if (!urlKey) return null
|
|
19
|
+
return { steps: `${urlKey}Steps`, selected: `${urlKey}Selected` }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Which step each stepper is open at → one query value. Step 0 is where a
|
|
24
|
+
* stepper starts, so it is left out — a screen on its first steps has a
|
|
25
|
+
* clean URL. Ids are URI-encoded, so one holding ':' or ',' splits back.
|
|
26
|
+
* { s1: 2, s2: 0 } → 's1:2'
|
|
27
|
+
*/
|
|
28
|
+
export function encodeSteps(open) {
|
|
29
|
+
return Object.keys(open || {})
|
|
30
|
+
.filter((id) => Number.isInteger(open[id]) && open[id] > 0)
|
|
31
|
+
.map((id) => `${encodeURIComponent(id)}:${open[id]}`)
|
|
32
|
+
.join(',') || null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The query value back to { stepper id: index }; anything unreadable is dropped. */
|
|
36
|
+
export function decodeSteps(value) {
|
|
37
|
+
const open = {}
|
|
38
|
+
if (!value || typeof value !== 'string') return open
|
|
39
|
+
value.split(',').forEach((pair) => {
|
|
40
|
+
const at = pair.lastIndexOf(':')
|
|
41
|
+
if (at <= 0) return
|
|
42
|
+
if (!/^\d+$/.test(pair.slice(at + 1))) return
|
|
43
|
+
const index = Number(pair.slice(at + 1))
|
|
44
|
+
let id
|
|
45
|
+
try { id = decodeURIComponent(pair.slice(0, at)) } catch { return }
|
|
46
|
+
open[id] = index
|
|
47
|
+
})
|
|
48
|
+
return open
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Selecting adds a history entry when it opens or closes the properties
|
|
53
|
+
* (nothing → something, something → nothing), so Back closes them; moving
|
|
54
|
+
* from one selected thing to another is a switch and replaces the entry.
|
|
55
|
+
*/
|
|
56
|
+
export function replacesHistory(previous, next) {
|
|
57
|
+
const had = previous !== null && previous !== undefined && previous !== ''
|
|
58
|
+
const has = next !== null && next !== undefined && next !== ''
|
|
59
|
+
return had && has
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A multi-selection is NOT a place: it is a working set for Delete or
|
|
64
|
+
* Duplicate, not somewhere a person goes. Only a single selection — the one
|
|
65
|
+
* whose properties are open — is named in the URL.
|
|
66
|
+
* one id → that id none, or several → null
|
|
67
|
+
*/
|
|
68
|
+
export function selectionPlace(ids) {
|
|
69
|
+
const list = ids ? [...ids] : []
|
|
70
|
+
return list.length === 1 ? list[0] : null
|
|
71
|
+
}
|
package/src/useFactoryBuilder.js
CHANGED
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
} from './document.js'
|
|
8
8
|
import { validateDocument } from './validateDocument.js'
|
|
9
9
|
import { presetFor, presetProps, presetGroup, presetHints, convertField } from './presets.js'
|
|
10
|
+
import { useUrlSteps, useUrlSelection } from './useUrlPlaces.js'
|
|
11
|
+
import { selectionPlace } from './urlState.js'
|
|
10
12
|
|
|
11
13
|
// The builder's controller: the document being edited, what is selected, and
|
|
12
14
|
// every change the builder can make. No JSX — designs/BuilderSample.jsx draws
|
|
@@ -36,10 +38,29 @@ import { presetFor, presetProps, presetGroup, presetHints, convertField } from '
|
|
|
36
38
|
* shown); throw with `confirm: [{ column, records }]` when removed fields would drop
|
|
37
39
|
* columns — the builder asks, and calls again with confirmDrop naming them.
|
|
38
40
|
* @param controls the control registry (default: the built-ins)
|
|
41
|
+
* @param urlKey where the selected control and the open steps live in the URL:
|
|
42
|
+
* `<urlKey>Selected` and `<urlKey>Steps`, so a refresh or a shared
|
|
43
|
+
* link lands on the same control. Omitted → kept in memory.
|
|
39
44
|
*/
|
|
40
|
-
export function useFactoryBuilder({ document: given, name, onSave, onChange, onPublish, listTables, screens, lockedNames, controls = CONTROLS, autosaveDelay = 800 } = {}) {
|
|
45
|
+
export function useFactoryBuilder({ document: given, name, onSave, onChange, onPublish, listTables, screens, lockedNames, controls = CONTROLS, autosaveDelay = 800, urlKey } = {}) {
|
|
41
46
|
const [doc, setDoc] = useState(() => given || createScreen({ name }))
|
|
42
|
-
|
|
47
|
+
|
|
48
|
+
// ── selection ─────────────────────────────────────────────────────────
|
|
49
|
+
// ONE selected control is a place — its properties are open — so it is
|
|
50
|
+
// named in the URL (by id, found again in the document). Several selected
|
|
51
|
+
// is a working set for Delete or Duplicate, not a place: it stays here, and
|
|
52
|
+
// the URL then names nothing.
|
|
53
|
+
const [placeId, selectPlace] = useUrlSelection(urlKey)
|
|
54
|
+
const [several, setSeveral] = useState(null)
|
|
55
|
+
const placeNode = placeId === null ? null : doc.nodes.find((n) => String(n.id) === placeId) || null
|
|
56
|
+
const placeNodeId = placeNode ? placeNode.id : null
|
|
57
|
+
const selected = useMemo(() => several || new Set(placeNodeId === null ? [] : [placeNodeId]), [several, placeNodeId])
|
|
58
|
+
const selectedRef = useRef(selected); selectedRef.current = selected
|
|
59
|
+
const setSelected = useCallback((next) => {
|
|
60
|
+
const ids = [...((typeof next === 'function' ? next(selectedRef.current) : next) || [])]
|
|
61
|
+
setSeveral(ids.length > 1 ? new Set(ids) : null)
|
|
62
|
+
selectPlace(selectionPlace(ids))
|
|
63
|
+
}, [selectPlace])
|
|
43
64
|
const [dirty, setDirty] = useState(false)
|
|
44
65
|
const [saving, setSaving] = useState(false)
|
|
45
66
|
const [saveError, setSaveError] = useState(null)
|
|
@@ -57,7 +78,10 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
|
|
|
57
78
|
if (JSON.stringify(given) === JSON.stringify(docRef.current)) return
|
|
58
79
|
docRef.current = given
|
|
59
80
|
setDoc(given)
|
|
60
|
-
|
|
81
|
+
// A multi-selection belongs to the old document. The URL's control is
|
|
82
|
+
// kept: a host that loads the screen after mounting hands it in here, and
|
|
83
|
+
// the link's control is found in it — or, if it is not there, nothing is.
|
|
84
|
+
setSeveral(null)
|
|
61
85
|
setDirty(false)
|
|
62
86
|
}, [given])
|
|
63
87
|
|
|
@@ -104,7 +128,7 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
|
|
|
104
128
|
// Which step of each stepper is open on the canvas. Everything dropped while
|
|
105
129
|
// a step is open belongs to that step, and the canvas shows only that step —
|
|
106
130
|
// so one screen holds as many little screens as it has steps.
|
|
107
|
-
const [openSteps, setOpenSteps] =
|
|
131
|
+
const [openSteps, setOpenSteps] = useUrlSteps(urlKey)
|
|
108
132
|
const stepperNodes = useMemo(() => steppers(doc, controls), [doc, controls])
|
|
109
133
|
const [building, setBuilding] = useState(null) // the stepper being built
|
|
110
134
|
const activeStepper = stepperNodes.find((n) => n.id === building) || stepperNodes[stepperNodes.length - 1] || null
|
|
@@ -113,7 +137,7 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
|
|
|
113
137
|
const goToStep = useCallback((id, index) => {
|
|
114
138
|
setBuilding(id)
|
|
115
139
|
setOpenSteps((o) => ({ ...o, [id]: index }))
|
|
116
|
-
}, [])
|
|
140
|
+
}, [setOpenSteps])
|
|
117
141
|
|
|
118
142
|
/** The step a new control lands on: the one open on the stepper being built. */
|
|
119
143
|
const dropStep = useCallback(() => (
|
|
@@ -142,7 +166,7 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
|
|
|
142
166
|
})
|
|
143
167
|
if (added) setSelected(new Set([added.id]))
|
|
144
168
|
return added
|
|
145
|
-
}, [update, controls, dropStep])
|
|
169
|
+
}, [update, controls, dropStep, setSelected])
|
|
146
170
|
|
|
147
171
|
const moveControl = useCallback((id, patch) => update((d) => moveNode(d, id, patch)), [update])
|
|
148
172
|
|
|
@@ -164,13 +188,12 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
|
|
|
164
188
|
return dropped
|
|
165
189
|
}, [update, controls])
|
|
166
190
|
|
|
167
|
-
const selectedRef = useRef(selected); selectedRef.current = selected
|
|
168
191
|
const removeSelected = useCallback(() => {
|
|
169
192
|
const ids = [...selectedRef.current]
|
|
170
193
|
if (!ids.length) return
|
|
171
194
|
update((d) => removeNodes(d, ids))
|
|
172
195
|
setSelected(new Set())
|
|
173
|
-
}, [update])
|
|
196
|
+
}, [update, setSelected])
|
|
174
197
|
|
|
175
198
|
/** Everything selected moves to a copy, just below the original. */
|
|
176
199
|
const duplicateSelected = useCallback(() => {
|
|
@@ -191,7 +214,7 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
|
|
|
191
214
|
return next
|
|
192
215
|
})
|
|
193
216
|
setSelected(new Set(created))
|
|
194
|
-
}, [update, controls])
|
|
217
|
+
}, [update, controls, setSelected])
|
|
195
218
|
|
|
196
219
|
// ── validation + save ─────────────────────────────────────────────────
|
|
197
220
|
const validation = useMemo(() => validateDocument(doc, controls), [doc, controls])
|
package/src/useFactoryScreen.js
CHANGED
|
@@ -4,6 +4,7 @@ import { inputNodes, nodesForSteps, steppers, stepsOf, stepOf, resolveStep, reac
|
|
|
4
4
|
import { validateDocument } from './validateDocument.js'
|
|
5
5
|
import { initialValues, parseInput, saveState, fieldError, optionValue, recordValues, listSource } from './values.js'
|
|
6
6
|
import { hookMethod } from './hooks.js'
|
|
7
|
+
import { useUrlSteps } from './useUrlPlaces.js'
|
|
7
8
|
|
|
8
9
|
// A saved screen, running. No JSX — designs/ScreenSample.jsx draws it.
|
|
9
10
|
//
|
|
@@ -48,10 +49,12 @@ import { hookMethod } from './hooks.js'
|
|
|
48
49
|
* @param onDirtyChange (dirty) → void — whether there are unsaved changes, as that changes
|
|
49
50
|
* @param controls the control registry (default: the built-ins)
|
|
50
51
|
* @param hooks a FactoryHooks (or an object with some of its methods) — get / save / delete / actions
|
|
52
|
+
* @param urlKey where the open step of each stepper lives in the URL (`<urlKey>Steps`),
|
|
53
|
+
* so a refresh or a shared link lands on the same step. Omitted → in memory.
|
|
51
54
|
*/
|
|
52
55
|
export function useFactoryScreen({
|
|
53
56
|
document: doc, record, recordKey = 'id', onSave, fetchOptions, fetchRecords, fetchRecord, onDelete, onChange, screens, loadScreen, hooks, uploadFile, fileUrl,
|
|
54
|
-
onOpenRecord, onDone, onDirtyChange,
|
|
57
|
+
onOpenRecord, onDone, onDirtyChange, urlKey,
|
|
55
58
|
controls = CONTROLS
|
|
56
59
|
} = {}) {
|
|
57
60
|
const check = useMemo(() => validateDocument(doc, controls), [doc, controls])
|
|
@@ -161,7 +164,7 @@ export function useFactoryScreen({
|
|
|
161
164
|
// A stepper shows one step at a time. Which step each is open at lives here;
|
|
162
165
|
// the fields of the steps not showing are still filled in, still checked and
|
|
163
166
|
// still saved — a step is what the person sees, not a separate form.
|
|
164
|
-
const [openSteps, setOpenSteps] =
|
|
167
|
+
const [openSteps, setOpenSteps] = useUrlSteps(urlKey)
|
|
165
168
|
// Steps the app has switched off — { stepper id: [index, …] }. A screen may
|
|
166
169
|
// have branches; which of them apply is the app's to say, from its hooks.
|
|
167
170
|
const [disabledSteps, setDisabledSteps] = useState({})
|
|
@@ -169,20 +172,20 @@ export function useFactoryScreen({
|
|
|
169
172
|
const visibleNodes = useMemo(() => (check.ok ? nodesForSteps(doc, openSteps, controls) : []), [check.ok, doc, openSteps, controls])
|
|
170
173
|
|
|
171
174
|
// A screen whose steps changed under it (a field moved to another step, a
|
|
172
|
-
// step removed) must not stay open at a step that is gone.
|
|
175
|
+
// step removed) must not stay open at a step that is gone. Steppers this
|
|
176
|
+
// document does not have are left alone: a screen still loading has none,
|
|
177
|
+
// and dropping them would lose the step a link was opened at.
|
|
173
178
|
useEffect(() => {
|
|
174
179
|
setOpenSteps((open) => {
|
|
175
|
-
|
|
176
|
-
const next = {}
|
|
180
|
+
const next = { ...open }
|
|
177
181
|
stepperNodes.forEach((n) => {
|
|
178
182
|
const count = stepsOf(n).length
|
|
179
183
|
const at = Number.isInteger(open[n.id]) ? open[n.id] : 0
|
|
180
184
|
next[n.id] = Math.min(Math.max(at, 0), Math.max(count - 1, 0))
|
|
181
|
-
if (next[n.id] !== open[n.id]) changed = true
|
|
182
185
|
})
|
|
183
|
-
return
|
|
186
|
+
return next
|
|
184
187
|
})
|
|
185
|
-
}, [stepperNodes])
|
|
188
|
+
}, [stepperNodes, setOpenSteps])
|
|
186
189
|
|
|
187
190
|
const stepControl = useMemo(() => {
|
|
188
191
|
const at = (id) => (Number.isInteger(openSteps[id]) ? openSteps[id] : 0)
|
|
@@ -274,7 +277,7 @@ export function useFactoryScreen({
|
|
|
274
277
|
})
|
|
275
278
|
}
|
|
276
279
|
return control
|
|
277
|
-
}, [stepperNodes, openSteps, disabledSteps, doc, controls])
|
|
280
|
+
}, [stepperNodes, openSteps, setOpenSteps, disabledSteps, doc, controls])
|
|
278
281
|
|
|
279
282
|
live.current.steps = stepControl
|
|
280
283
|
|
package/src/useFlowBuilder.js
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
addTransition, setTransition, removeTransition, validateFlow, firstStep, layoutFlow, FLOW_END
|
|
5
5
|
} from './flow.js'
|
|
6
6
|
import { inputNodes } from './document.js'
|
|
7
|
+
import { useUrlSelection } from './useUrlPlaces.js'
|
|
7
8
|
|
|
8
9
|
// DESIGNING a flow: which screens, in what order, and what decides the order.
|
|
9
10
|
//
|
|
@@ -23,10 +24,12 @@ export const FLOW_AUTOSAVE_DELAY = 800
|
|
|
23
24
|
* @param onSave async (flow) → void — called after edits, only with a flow that makes sense
|
|
24
25
|
* @param onPublish async (key) → void — shows a Publish button
|
|
25
26
|
* @param onChange (flow) → void — every edit
|
|
27
|
+
* @param urlKey where the selected step lives in the URL (`<urlKey>Selected`), so a
|
|
28
|
+
* refresh or a shared link opens the same step. Omitted → in memory.
|
|
26
29
|
*/
|
|
27
|
-
export function useFlowBuilder({ flow: given, name, screens = [], loadScreen, onSave, onPublish, onChange } = {}) {
|
|
30
|
+
export function useFlowBuilder({ flow: given, name, screens = [], loadScreen, onSave, onPublish, onChange, urlKey } = {}) {
|
|
28
31
|
const [flow, setFlow] = useState(() => given || createFlow({ name }))
|
|
29
|
-
const [selected, setSelected] =
|
|
32
|
+
const [selected, setSelected] = useUrlSelection(urlKey) // a step key
|
|
30
33
|
const [dirty, setDirty] = useState(false)
|
|
31
34
|
const [saving, setSaving] = useState(false)
|
|
32
35
|
const [saveError, setSaveError] = useState(null)
|
|
@@ -102,13 +105,13 @@ export function useFlowBuilder({ flow: given, name, screens = [], loadScreen, on
|
|
|
102
105
|
})
|
|
103
106
|
if (added) setSelected(added.stepKey)
|
|
104
107
|
return added
|
|
105
|
-
}, [update])
|
|
108
|
+
}, [update, setSelected])
|
|
106
109
|
const move = useCallback((stepKey, at) => update((f) => moveStep(f, stepKey, at)), [update])
|
|
107
110
|
const change = useCallback((stepKey, patch) => update((f) => setStep(f, stepKey, patch)), [update])
|
|
108
111
|
const remove = useCallback((stepKey) => {
|
|
109
112
|
update((f) => removeStep(f, stepKey))
|
|
110
113
|
setSelected((s) => (s === stepKey ? null : s))
|
|
111
|
-
}, [update])
|
|
114
|
+
}, [update, setSelected])
|
|
112
115
|
const connect = useCallback((fromKey, target, when = null) => update((f) => addTransition(f, fromKey, target, when)), [update])
|
|
113
116
|
const changeTransition = useCallback((fromKey, index, patch) => update((f) => setTransition(f, fromKey, index, patch)), [update])
|
|
114
117
|
const disconnect = useCallback((fromKey, index) => update((f) => removeTransition(f, fromKey, index)), [update])
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { useCallback, useMemo, useRef } from 'react'
|
|
2
|
+
import { useUrlState } from '@xeplr/ui-utils/src/url/useUrlState.js'
|
|
3
|
+
import { factoryUrlKeys, encodeSteps, decodeSteps, replacesHistory } from './urlState.js'
|
|
4
|
+
|
|
5
|
+
// The two places the factory's controllers keep, each in useState's shape so
|
|
6
|
+
// a controller swaps one line. In the URL under the host's `urlKey`; without
|
|
7
|
+
// one, in memory with the same rules (see urlState.js).
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* [openSteps, setOpenSteps] — which step each stepper is open at,
|
|
11
|
+
* { stepper id: index }. Moving between steps is a switch, so it replaces the
|
|
12
|
+
* history entry rather than adding one.
|
|
13
|
+
*/
|
|
14
|
+
export function useUrlSteps(urlKey) {
|
|
15
|
+
const keys = factoryUrlKeys(urlKey)
|
|
16
|
+
const [value, setValue] = useUrlState(keys ? keys.steps : 'steps', { inUrl: Boolean(keys), replace: true })
|
|
17
|
+
const open = useMemo(() => decodeSteps(value), [value])
|
|
18
|
+
// Two moves in one event build on each other, as useState's updater would.
|
|
19
|
+
const openRef = useRef(open); openRef.current = open
|
|
20
|
+
const setOpen = useCallback((next) => {
|
|
21
|
+
const resolved = typeof next === 'function' ? next(openRef.current) : next
|
|
22
|
+
const encoded = encodeSteps(resolved)
|
|
23
|
+
if (encoded === encodeSteps(openRef.current)) return
|
|
24
|
+
openRef.current = decodeSteps(encoded)
|
|
25
|
+
setValue(encoded)
|
|
26
|
+
}, [setValue])
|
|
27
|
+
return [open, setOpen]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* [selected, setSelected] — the one thing whose properties are open (a
|
|
32
|
+
* control's id, a flow step's key), or null. Opening or closing the
|
|
33
|
+
* properties adds a history entry; moving to another thing replaces it.
|
|
34
|
+
* Read back from the URL it is a string.
|
|
35
|
+
*/
|
|
36
|
+
export function useUrlSelection(urlKey) {
|
|
37
|
+
const keys = factoryUrlKeys(urlKey)
|
|
38
|
+
const [value, setValue] = useUrlState(keys ? keys.selected : 'selected', { inUrl: Boolean(keys) })
|
|
39
|
+
const valueRef = useRef(value); valueRef.current = value
|
|
40
|
+
const select = useCallback((next) => {
|
|
41
|
+
const resolved = typeof next === 'function' ? next(valueRef.current) : next
|
|
42
|
+
const v = resolved === undefined || resolved === '' ? null : resolved
|
|
43
|
+
if ((v === null ? null : String(v)) === valueRef.current) return
|
|
44
|
+
setValue(v, { replace: replacesHistory(valueRef.current, v) })
|
|
45
|
+
valueRef.current = v === null ? null : String(v)
|
|
46
|
+
}, [setValue])
|
|
47
|
+
return [value, select]
|
|
48
|
+
}
|