@pilotiq/pilotiq 0.10.0 → 0.11.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/.turbo/turbo-build.log +2 -2
- package/CHANGELOG.md +120 -0
- package/dist/react/FormCollabBindingRegistry.d.ts +130 -0
- package/dist/react/FormCollabBindingRegistry.d.ts.map +1 -1
- package/dist/react/FormCollabBindingRegistry.js.map +1 -1
- package/dist/react/FormStateContext.d.ts +39 -1
- package/dist/react/FormStateContext.d.ts.map +1 -1
- package/dist/react/FormStateContext.js +126 -33
- package/dist/react/FormStateContext.js.map +1 -1
- package/dist/react/fields/BuilderInput.d.ts.map +1 -1
- package/dist/react/fields/BuilderInput.js +112 -10
- package/dist/react/fields/BuilderInput.js.map +1 -1
- package/dist/react/fields/RepeaterInput.d.ts.map +1 -1
- package/dist/react/fields/RepeaterInput.js +113 -10
- package/dist/react/fields/RepeaterInput.js.map +1 -1
- package/dist/react/formStateHelpers.d.ts +102 -0
- package/dist/react/formStateHelpers.d.ts.map +1 -1
- package/dist/react/formStateHelpers.js +234 -0
- package/dist/react/formStateHelpers.js.map +1 -1
- package/dist/react/index.d.ts +2 -2
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +1 -1
- package/dist/react/index.js.map +1 -1
- package/package.json +1 -1
- package/src/react/FormCollabBindingRegistry.ts +129 -0
- package/src/react/FormStateContext.tsx +157 -34
- package/src/react/fields/BuilderInput.tsx +97 -8
- package/src/react/fields/RepeaterInput.tsx +97 -8
- package/src/react/formStateHelpers.test.ts +312 -0
- package/src/react/formStateHelpers.ts +246 -0
- package/src/react/index.ts +3 -0
|
@@ -82,8 +82,137 @@ export interface FormCollabBinding {
|
|
|
82
82
|
getTextBinding?(name: string): TextBinding | null
|
|
83
83
|
/** Cleanup hook called when the form unmounts. */
|
|
84
84
|
destroy(): void
|
|
85
|
+
|
|
86
|
+
// ── Phase F.5 — Repeater / Builder row-identity surface (all optional) ──
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Add a row to a Repeater or Builder field. `rowId` is the stable
|
|
90
|
+
* `__id` the renderer already mints (UUID for new rows / DB PK for
|
|
91
|
+
* relationship-backed rows) — the binding indexes the row's CRDT
|
|
92
|
+
* surface by this id so concurrent inserts from peers both survive.
|
|
93
|
+
*
|
|
94
|
+
* Idempotent — calling with a `rowId` that already exists in the
|
|
95
|
+
* Repeater's array is a no-op. `initial` may carry pre-filled values
|
|
96
|
+
* for the new row (e.g. the inner fields' `default()` from schema
|
|
97
|
+
* resolution); the binding seeds them onto the row's storage under
|
|
98
|
+
* the same idempotent posture as top-level `set` (first writer wins
|
|
99
|
+
* across concurrent first-mounters).
|
|
100
|
+
*
|
|
101
|
+
* Renderer call sites: `RepeaterInput.addRow()` + `BuilderInput.addBlock()`.
|
|
102
|
+
* When absent, F.5 row-level CRDT degrades to v1 behaviour (whole-array
|
|
103
|
+
* LWW under the top-level Y.Map).
|
|
104
|
+
*/
|
|
105
|
+
addRow?(arrayName: string, rowId: string, initial: Record<string, unknown>): void
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Remove a row from a Repeater/Builder field by stable id. Idempotent
|
|
109
|
+
* — a missing `rowId` is a no-op. Triggered by the renderer's row
|
|
110
|
+
* delete button.
|
|
111
|
+
*/
|
|
112
|
+
removeRow?(arrayName: string, rowId: string): void
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Apply a new row order. `newOrder` is the full list of row ids in
|
|
116
|
+
* their final positions; the binding computes the minimal CRDT move
|
|
117
|
+
* sequence and applies it inside a single transaction (peers see one
|
|
118
|
+
* coalesced update, not N intermediate states).
|
|
119
|
+
*
|
|
120
|
+
* Called by drag-and-drop / up-down move handlers in
|
|
121
|
+
* `RepeaterInput` + `BuilderInput`.
|
|
122
|
+
*/
|
|
123
|
+
reorderRows?(arrayName: string, newOrder: string[]): void
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Write a single field on a row. Replaces the dotted-path `set` for
|
|
127
|
+
* row leaves (`tags.0.label` → `setRow('tags', rowId, 'label', value)`).
|
|
128
|
+
*
|
|
129
|
+
* Binding routes by allowlist same as top-level `set`: text-shaped
|
|
130
|
+
* fields go through the row's `TextBinding` when a `Y.Text` exists
|
|
131
|
+
* (see `getRowTextBinding`); non-text fields land on the row's
|
|
132
|
+
* scalar field-map under LWW.
|
|
133
|
+
*
|
|
134
|
+
* `FormStateProvider` calls this on every local edit when both a
|
|
135
|
+
* dotted-path name is being written AND `setRow` is implemented;
|
|
136
|
+
* otherwise the v1 skip-on-dot path runs.
|
|
137
|
+
*/
|
|
138
|
+
setRow?(arrayName: string, rowId: string, fieldName: string, value: unknown): void
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Per-row text-CRDT handle. Composes with F.6's `BoundTextInput`:
|
|
142
|
+
* the renderer asks for one of these when a row leaf is text-shaped
|
|
143
|
+
* AND not opted out via `.collab(false)`, then threads it through
|
|
144
|
+
* the existing `TextBinding` plumbing inside the row.
|
|
145
|
+
*
|
|
146
|
+
* Returns `null` for non-text fields, fields opted out via collab,
|
|
147
|
+
* rows not yet present in the binding's index (e.g. before
|
|
148
|
+
* `addRow` has propagated), or when the binding doesn't yet
|
|
149
|
+
* implement per-row text CRDT (deferred to F.5c).
|
|
150
|
+
*/
|
|
151
|
+
getRowTextBinding?(arrayName: string, rowId: string, fieldName: string): TextBinding | null
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Subscribe to row-lifecycle events for a Repeater/Builder array.
|
|
155
|
+
* Fires on remote add / remove / move; the renderer reconciles its
|
|
156
|
+
* `rows` state by `__id`. Local mutations fire too — the renderer
|
|
157
|
+
* deduplicates by checking whether the event's `rowId` matches one
|
|
158
|
+
* it just dispatched itself (the binding's roundtrip + the
|
|
159
|
+
* renderer's optimistic update converge).
|
|
160
|
+
*/
|
|
161
|
+
subscribeRows?(arrayName: string, fn: (event: RowsEvent) => void): () => void
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Phase F.5 — array-scoped imperative API exposed to Repeater / Builder
|
|
166
|
+
* renderers through `useRowBinding(arrayName)`. Each method's first arg
|
|
167
|
+
* (`arrayName`) is pre-bound by the hook so call sites read naturally.
|
|
168
|
+
*
|
|
169
|
+
* Returned by the hook only when the active binding implements all four
|
|
170
|
+
* F.5 row methods (`addRow + removeRow + reorderRows`); partial impls
|
|
171
|
+
* read as null so renderers can do a single existence check before
|
|
172
|
+
* proceeding. `setRow` is intentionally NOT exposed here — it's invoked
|
|
173
|
+
* implicitly via `FormStateProvider.setValue`'s dotted-path routing,
|
|
174
|
+
* so renderers never need to touch it directly.
|
|
175
|
+
*/
|
|
176
|
+
export interface RowBindingApi {
|
|
177
|
+
/** Register a new row in the array's CRDT surface. `initial` may
|
|
178
|
+
* carry pre-seeded values (e.g. clone of a sibling); pass `{}` (or
|
|
179
|
+
* omit) for empty rows. Idempotent under existing rowId. */
|
|
180
|
+
add(rowId: string, initial?: Record<string, unknown>): void
|
|
181
|
+
/** Remove the row with the given id. Idempotent for unknown ids. */
|
|
182
|
+
remove(rowId: string): void
|
|
183
|
+
/** Replace the array's row order. `newOrder` is the full list of row
|
|
184
|
+
* ids in their post-reorder positions; the binding emits the minimal
|
|
185
|
+
* CRDT move sequence in one transaction so peers observe a single
|
|
186
|
+
* coalesced update. */
|
|
187
|
+
reorder(newOrder: string[]): void
|
|
188
|
+
/** Subscribe to remote row-lifecycle events on this array. Returns
|
|
189
|
+
* an unsubscribe fn the renderer's `useEffect` cleanup hangs on.
|
|
190
|
+
* Local mutations may also surface here (Yjs observers fire on
|
|
191
|
+
* local transactions); the renderer is expected to dedupe by
|
|
192
|
+
* rowId presence in its current state. Optional on the underlying
|
|
193
|
+
* contract — `null` when the binding lacks `subscribeRows`. */
|
|
194
|
+
subscribe(fn: (event: RowsEvent) => void): () => void
|
|
85
195
|
}
|
|
86
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Phase F.5 — row-lifecycle event surfaced through `subscribeRows`.
|
|
199
|
+
*
|
|
200
|
+
* - `add` — a new row was inserted at `index`. `values` carries
|
|
201
|
+
* the row's seeded field values (mirrors `addRow.initial`).
|
|
202
|
+
* - `remove` — a row was removed; `index` is its position at the
|
|
203
|
+
* moment of removal.
|
|
204
|
+
* - `move` — a row shifted positions. `from` and `to` are 0-based
|
|
205
|
+
* indices in the post-reorder layout (i.e. the row at
|
|
206
|
+
* position `from` ended up at `to`).
|
|
207
|
+
*
|
|
208
|
+
* Pilotiq core stays Yjs-free — the binding impl in `@pilotiq-pro/collab`
|
|
209
|
+
* translates `Y.Array<Y.Map>` `observe` deltas into these events.
|
|
210
|
+
*/
|
|
211
|
+
export type RowsEvent =
|
|
212
|
+
| { kind: 'add'; rowId: string; index: number; values: Record<string, unknown> }
|
|
213
|
+
| { kind: 'remove'; rowId: string; index: number }
|
|
214
|
+
| { kind: 'move'; rowId: string; from: number; to: number }
|
|
215
|
+
|
|
87
216
|
export interface FormCollabBindingFactoryArgs {
|
|
88
217
|
/** Active collab room — provides `ydoc`, `provider`, `user`. Opaque to pilotiq core. */
|
|
89
218
|
room: CollabRoom
|
|
@@ -10,9 +10,14 @@ import React, {
|
|
|
10
10
|
import type { ElementMeta } from '../schema/Element.js'
|
|
11
11
|
import {
|
|
12
12
|
collectFieldDefaults,
|
|
13
|
+
collectRowArrayFieldNames,
|
|
14
|
+
collectRowTextLeavesByArray,
|
|
13
15
|
findFieldMeta,
|
|
14
16
|
parseFormDataToNested,
|
|
17
|
+
parseRowFieldPath,
|
|
15
18
|
readNestedValue,
|
|
19
|
+
routeBindingWrite,
|
|
20
|
+
rowIdAtIndex,
|
|
16
21
|
writeNestedValue,
|
|
17
22
|
} from './formStateHelpers.js'
|
|
18
23
|
import { runJsHandler } from './fieldJsHandler.js'
|
|
@@ -21,6 +26,7 @@ import { useCollabRoom } from './CollabRoomContext.js'
|
|
|
21
26
|
import {
|
|
22
27
|
getFormCollabBinding,
|
|
23
28
|
type FormCollabBinding,
|
|
29
|
+
type RowBindingApi,
|
|
24
30
|
type TextBinding,
|
|
25
31
|
} from './FormCollabBindingRegistry.js'
|
|
26
32
|
|
|
@@ -51,6 +57,29 @@ export interface FormStateApi {
|
|
|
51
57
|
* non-null answers, so a `Map.get()` hit means the binding has opted
|
|
52
58
|
* this field into the character-level path. */
|
|
53
59
|
textBindings: ReadonlyMap<string, TextBinding> | null
|
|
60
|
+
/** Phase F.5 — per-Repeater/Builder row-array bindings. `null` outside a
|
|
61
|
+
* collab room or when the binding doesn't implement F.5 row methods.
|
|
62
|
+
* Each entry's API methods are pre-bound to the array name so renderers
|
|
63
|
+
* call `.add(rowId, initial)` rather than `binding.addRow(name, …)`. */
|
|
64
|
+
rowBindings: ReadonlyMap<string, RowBindingApi> | null
|
|
65
|
+
/**
|
|
66
|
+
* Phase F.5c — per-array set of inner-field names that should route
|
|
67
|
+
* through `Y.Text` (character-level CRDT) instead of row-level Y.Map
|
|
68
|
+
* LWW. Built from a single meta walk at binding mount. Read by
|
|
69
|
+
* `useFieldState(dottedName).textBinding` to decide whether to call
|
|
70
|
+
* `getRowTextBinding`. Sparse — only arrays with at least one
|
|
71
|
+
* text-shaped row leaf appear; absence on a key means "no text leaves
|
|
72
|
+
* in this Repeater/Builder".
|
|
73
|
+
*/
|
|
74
|
+
rowTextLeaves: ReadonlyMap<string, ReadonlySet<string>> | null
|
|
75
|
+
/**
|
|
76
|
+
* Phase F.5c — resolve a per-row `TextBinding`. Pre-bound to the
|
|
77
|
+
* active F.5 binding so consumers don't reach for `bindingRef`
|
|
78
|
+
* directly. Returns `null` when no binding implements F.5c OR the
|
|
79
|
+
* row+field doesn't qualify (renderer caller should fall back to
|
|
80
|
+
* `defaultValue` like a non-collab form).
|
|
81
|
+
*/
|
|
82
|
+
getRowTextBinding: ((arrayName: string, rowId: string, fieldName: string) => TextBinding | null) | null
|
|
54
83
|
}
|
|
55
84
|
|
|
56
85
|
const FormStateContext = createContext<FormStateApi | null>(null)
|
|
@@ -71,19 +100,6 @@ export function useFormState(): FormStateApi | null {
|
|
|
71
100
|
*/
|
|
72
101
|
export const FormIdContext = createContext<string>('')
|
|
73
102
|
|
|
74
|
-
/**
|
|
75
|
-
* Phase F2 — returns `true` iff the named field has explicitly opted out
|
|
76
|
-
* of realtime collab via `Field.collab(false)`. Sparse meta — absent =
|
|
77
|
-
* inherit the panel default (collab on). Walks the form meta tree the
|
|
78
|
-
* same way `findFieldMeta` does; cheap because it only runs on the
|
|
79
|
-
* per-write path (already a hot path, but every check is one map
|
|
80
|
-
* lookup + one boolean compare).
|
|
81
|
-
*/
|
|
82
|
-
function fieldOptsOutOfCollab(formMeta: ElementMeta, name: string): boolean {
|
|
83
|
-
const meta = findFieldMeta(formMeta, name) as { collab?: boolean } | undefined
|
|
84
|
-
return meta?.collab === false
|
|
85
|
-
}
|
|
86
|
-
|
|
87
103
|
export interface UseFieldStateResult {
|
|
88
104
|
/** True when the field is inside a controlled form (live fields enabled).
|
|
89
105
|
* Renderers should fall back to their `defaultValue` path when false.
|
|
@@ -115,6 +131,26 @@ export interface UseFieldStateResult {
|
|
|
115
131
|
textBinding: TextBinding | null
|
|
116
132
|
}
|
|
117
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Phase F.5 — return the row-array CRDT API for a Repeater/Builder field.
|
|
136
|
+
* Returns `null` when:
|
|
137
|
+
*
|
|
138
|
+
* - No `FormStateProvider` is mounted (e.g. uncontrolled form path).
|
|
139
|
+
* - No `<RecordCollabRoom>` is up-tree (no binding registered).
|
|
140
|
+
* - The active binding doesn't implement F.5's row methods.
|
|
141
|
+
* - The named field opted out via `.collab(false)` (skipped at meta walk).
|
|
142
|
+
* - The named field isn't a Repeater/Builder.
|
|
143
|
+
*
|
|
144
|
+
* RepeaterInput + BuilderInput call this once per render and proceed
|
|
145
|
+
* with the v1 local-only behaviour when null. The returned API methods
|
|
146
|
+
* are pre-bound to the array name so consumers don't repeat it.
|
|
147
|
+
*/
|
|
148
|
+
export function useRowBinding(arrayName: string): RowBindingApi | null {
|
|
149
|
+
const ctx = useContext(FormStateContext)
|
|
150
|
+
if (!ctx?.rowBindings) return null
|
|
151
|
+
return ctx.rowBindings.get(arrayName) ?? null
|
|
152
|
+
}
|
|
153
|
+
|
|
118
154
|
/** Per-field accessor. Inside a `FormStateProvider` it returns the controlled
|
|
119
155
|
* value + setter + live trigger; outside, it returns sentinels and callers
|
|
120
156
|
* should fall back to `defaultValue` (uncontrolled inputs). */
|
|
@@ -141,14 +177,35 @@ export function useFieldState(name: string): UseFieldStateResult {
|
|
|
141
177
|
triggerLive: (valueOverride?: unknown) => ctx.triggerLive(name, valueOverride),
|
|
142
178
|
pending: ctx.fieldStatus(name) === 'pending',
|
|
143
179
|
errors: ctx.errors[name] ?? [],
|
|
144
|
-
// Phase F.6 —
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
|
|
180
|
+
// Phase F.6 — top-level text fields resolve from the binding-mount
|
|
181
|
+
// text stash. Phase F.5c — dotted-path row leaves resolve through
|
|
182
|
+
// `getRowTextBinding` when the field is text-shaped AND the row's
|
|
183
|
+
// `__id` is already stamped in the values map. Outside a collab
|
|
184
|
+
// room, for non-text fields, or before `addRow` has settled the
|
|
185
|
+
// row's id, the lookup returns null and `BoundTextInput` falls
|
|
186
|
+
// back to today's uncontrolled-input path.
|
|
187
|
+
textBinding: dotted
|
|
188
|
+
? resolveRowTextBinding(ctx, name)
|
|
189
|
+
: (ctx.textBindings?.get(name) ?? null),
|
|
149
190
|
}
|
|
150
191
|
}
|
|
151
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Phase F.5c — dotted-name `TextBinding` resolver. Returns `null`
|
|
195
|
+
* whenever any precondition fails so the caller's renderer can take a
|
|
196
|
+
* single branch on null vs non-null.
|
|
197
|
+
*/
|
|
198
|
+
function resolveRowTextBinding(ctx: FormStateApi, dottedName: string): TextBinding | null {
|
|
199
|
+
if (!ctx.rowTextLeaves || !ctx.getRowTextBinding) return null
|
|
200
|
+
const parsed = parseRowFieldPath(dottedName)
|
|
201
|
+
if (!parsed) return null
|
|
202
|
+
const set = ctx.rowTextLeaves.get(parsed.arrayName)
|
|
203
|
+
if (!set?.has(parsed.fieldName)) return null
|
|
204
|
+
const rowId = rowIdAtIndex(ctx.values, parsed.arrayName, parsed.index)
|
|
205
|
+
if (!rowId) return null
|
|
206
|
+
return ctx.getRowTextBinding(parsed.arrayName, rowId, parsed.fieldName)
|
|
207
|
+
}
|
|
208
|
+
|
|
152
209
|
/** Response shape from `POST {base}/.../_form/:formId/state`. */
|
|
153
210
|
interface FormStateResponse {
|
|
154
211
|
ok: boolean
|
|
@@ -203,6 +260,15 @@ export function FormStateProvider({
|
|
|
203
260
|
// existing `setValuesState` overlay below already triggers one when the
|
|
204
261
|
// room has pre-existing state.
|
|
205
262
|
const [textBindings, setTextBindings] = useState<ReadonlyMap<string, TextBinding> | null>(null)
|
|
263
|
+
// Phase F.5 — per-Repeater/Builder row-array stash. Same lifecycle as
|
|
264
|
+
// `textBindings`: populated on collab mount when the binding implements
|
|
265
|
+
// F.5 row methods, cleared on unmount.
|
|
266
|
+
const [rowBindings, setRowBindings] = useState<ReadonlyMap<string, RowBindingApi> | null>(null)
|
|
267
|
+
// Phase F.5c — per-array set of text-shaped row leaves + a pre-bound
|
|
268
|
+
// resolver. Both populated at binding mount when the active binding
|
|
269
|
+
// implements `getRowTextBinding`; cleared on unmount.
|
|
270
|
+
const [rowTextLeaves, setRowTextLeaves] = useState<ReadonlyMap<string, ReadonlySet<string>> | null>(null)
|
|
271
|
+
const [getRowTextBinding, setGetRowTextBinding] = useState<((arrayName: string, rowId: string, fieldName: string) => TextBinding | null) | null>(null)
|
|
206
272
|
|
|
207
273
|
const { notify } = useToast()
|
|
208
274
|
|
|
@@ -285,6 +351,55 @@ export function FormStateProvider({
|
|
|
285
351
|
if (textStash.size > 0) setTextBindings(textStash)
|
|
286
352
|
}
|
|
287
353
|
|
|
354
|
+
// Phase F.5 — build a `RowBindingApi` per top-level Repeater/Builder
|
|
355
|
+
// field when the binding implements all three lifecycle methods. The
|
|
356
|
+
// walk reads from formMeta (structural — fields exist regardless of
|
|
357
|
+
// whether the form has any rows yet); the API is then pre-bound to
|
|
358
|
+
// the array name so `RepeaterInput` calls `rb.add(rowId, …)` rather
|
|
359
|
+
// than `binding.addRow(name, rowId, …)`. Partial F.5 impls (e.g. a
|
|
360
|
+
// binding that has addRow but not reorderRows) skip the stash — the
|
|
361
|
+
// contract says all three or nothing. F.5c's per-row text path is
|
|
362
|
+
// exposed separately via `getRowTextBinding` and stays optional.
|
|
363
|
+
if (binding.addRow && binding.removeRow && binding.reorderRows) {
|
|
364
|
+
const { addRow, removeRow, reorderRows, subscribeRows } = binding
|
|
365
|
+
const arrayNames = collectRowArrayFieldNames(formMetaRef.current)
|
|
366
|
+
if (arrayNames.length > 0) {
|
|
367
|
+
const rowStash = new Map<string, RowBindingApi>()
|
|
368
|
+
for (const arrayName of arrayNames) {
|
|
369
|
+
rowStash.set(arrayName, {
|
|
370
|
+
add: (rowId, initial = {}) => addRow.call(binding, arrayName, rowId, initial),
|
|
371
|
+
remove: (rowId) => removeRow.call(binding, arrayName, rowId),
|
|
372
|
+
reorder: (newOrder) => reorderRows.call(binding, arrayName, newOrder),
|
|
373
|
+
// Partial F.5 impl: a binding may ship add/remove/reorder
|
|
374
|
+
// without `subscribeRows` (e.g. tests). Substitute a no-op
|
|
375
|
+
// subscription so renderer code stays uniform — the cleanup
|
|
376
|
+
// fn is still called on unmount but no events ever arrive.
|
|
377
|
+
subscribe: subscribeRows
|
|
378
|
+
? (fn) => subscribeRows.call(binding, arrayName, fn)
|
|
379
|
+
: () => () => {},
|
|
380
|
+
})
|
|
381
|
+
}
|
|
382
|
+
setRowBindings(rowStash)
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Phase F.5c — capture the per-array text-leaf allowlist + bind the
|
|
387
|
+
// row-text resolver to the active binding. `getRowTextBinding` may
|
|
388
|
+
// be absent on partial F.5 impls; we expose the resolver as null in
|
|
389
|
+
// that case so `useFieldState` short-circuits cleanly.
|
|
390
|
+
if (binding.getRowTextBinding) {
|
|
391
|
+
const leaves = collectRowTextLeavesByArray(formMetaRef.current)
|
|
392
|
+
if (leaves.size > 0) {
|
|
393
|
+
setRowTextLeaves(leaves)
|
|
394
|
+
const bound = binding.getRowTextBinding
|
|
395
|
+
// useState's functional-updater overload would invoke the
|
|
396
|
+
// stored function during set; wrapping in a fresh closure keeps
|
|
397
|
+
// React's setState path from confusing it for an updater fn.
|
|
398
|
+
setGetRowTextBinding(() => (arrayName: string, rowId: string, fieldName: string) =>
|
|
399
|
+
bound.call(binding, arrayName, rowId, fieldName))
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
288
403
|
// Subscribe to remote changes. Local writes ALSO trigger this
|
|
289
404
|
// (Yjs observers fire on local transactions too) — the per-key
|
|
290
405
|
// Object.is short-circuit below collapses them into no-op renders.
|
|
@@ -307,6 +422,9 @@ export function FormStateProvider({
|
|
|
307
422
|
binding.destroy()
|
|
308
423
|
bindingRef.current = null
|
|
309
424
|
setTextBindings(null)
|
|
425
|
+
setRowBindings(null)
|
|
426
|
+
setRowTextLeaves(null)
|
|
427
|
+
setGetRowTextBinding(null)
|
|
310
428
|
}
|
|
311
429
|
// `valuesRef.current` is intentionally read once at mount — initial
|
|
312
430
|
// values seed the binding; subsequent edits flow through `setValue`
|
|
@@ -367,14 +485,13 @@ export function FormStateProvider({
|
|
|
367
485
|
if (Object.is(prev[name], value)) return prev
|
|
368
486
|
return { ...prev, [name]: value }
|
|
369
487
|
})
|
|
370
|
-
// Phase F2 — proxy the write through the collab binding when
|
|
371
|
-
// AND the field hasn't opted out via `.collab(false)`.
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
}
|
|
488
|
+
// Phase F2 / F.5 — proxy the write through the collab binding when
|
|
489
|
+
// active AND the field hasn't opted out via `.collab(false)`. Top-level
|
|
490
|
+
// fields ride `binding.set`. Row leaves (dotted paths matching
|
|
491
|
+
// `parseRowFieldPath`) route through `binding.setRow` when the
|
|
492
|
+
// binding implements F.5 — otherwise stay local-only (same posture
|
|
493
|
+
// as pre-F.5).
|
|
494
|
+
routeBindingWrite(bindingRef.current, formMetaRef.current, valuesRef.current, name, value)
|
|
378
495
|
// Fire the client-side JS hook synchronously after the state write.
|
|
379
496
|
// Dotted-name fields don't go through here (their setter is a no-op
|
|
380
497
|
// in `useFieldState`); they fire JS via `triggerLive` instead so we
|
|
@@ -448,16 +565,19 @@ export function FormStateProvider({
|
|
|
448
565
|
const serverValues = (data.form as { values?: Record<string, unknown> }).values
|
|
449
566
|
if (serverValues) {
|
|
450
567
|
setValuesState((prev) => ({ ...prev, ...serverValues }))
|
|
451
|
-
// Phase F2 (Q2) — derived fields propagate to peers via the
|
|
452
|
-
// collab binding so every client sees the auto-`slug` / etc.
|
|
453
|
-
//
|
|
454
|
-
//
|
|
568
|
+
// Phase F2 (Q2) / F.5 — derived fields propagate to peers via the
|
|
569
|
+
// collab binding so every client sees the auto-`slug` / etc. without
|
|
570
|
+
// each peer roundtripping the server. Row leaves route through
|
|
571
|
+
// `setRow` when the binding implements F.5; top-level fields ride
|
|
572
|
+
// `set`. The rowId lookup needs the freshest values — merge
|
|
573
|
+
// `valuesRef.current` with the server overlay so a row-id stamped
|
|
574
|
+
// by this very server-resolve response is visible to `rowIdAtIndex`.
|
|
455
575
|
const binding = bindingRef.current
|
|
456
576
|
if (binding) {
|
|
577
|
+
const lookupValues = { ...valuesRef.current, ...serverValues }
|
|
457
578
|
for (const [k, v] of Object.entries(serverValues)) {
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
binding.set(k, v)
|
|
579
|
+
// routeBindingWrite handles `.collab(false)` opt-out internally.
|
|
580
|
+
routeBindingWrite(binding, data.form, lookupValues, k, v)
|
|
461
581
|
}
|
|
462
582
|
}
|
|
463
583
|
}
|
|
@@ -538,7 +658,10 @@ export function FormStateProvider({
|
|
|
538
658
|
inFlight,
|
|
539
659
|
fieldStatus,
|
|
540
660
|
textBindings,
|
|
541
|
-
|
|
661
|
+
rowBindings,
|
|
662
|
+
rowTextLeaves,
|
|
663
|
+
getRowTextBinding,
|
|
664
|
+
}), [values, setValue, triggerLive, errors, applyErrors, formMeta, inFlight, fieldStatus, textBindings, rowBindings, rowTextLeaves, getRowTextBinding])
|
|
542
665
|
|
|
543
666
|
return (
|
|
544
667
|
<FormStateContext.Provider value={api}>
|
|
@@ -3,7 +3,7 @@ import { ChevronDownIcon, PlusIcon } from 'lucide-react'
|
|
|
3
3
|
import type { ElementMeta } from '../../schema/Element.js'
|
|
4
4
|
import { Button } from '../ui/button.js'
|
|
5
5
|
import { SchemaRenderer } from '../SchemaRenderer.js'
|
|
6
|
-
import { FormIdContext, useFormState } from '../FormStateContext.js'
|
|
6
|
+
import { FormIdContext, useFormState, useRowBinding } from '../FormStateContext.js'
|
|
7
7
|
import { findFieldMeta } from '../formStateHelpers.js'
|
|
8
8
|
import { useIconFor } from '../icon-context.js'
|
|
9
9
|
import { reorderRows, ExtraActionStrip, buildGridContainer } from './RepeaterInput.js'
|
|
@@ -185,6 +185,74 @@ export function BuilderInput({
|
|
|
185
185
|
if (!metaRows) return
|
|
186
186
|
setRows(prev => syncRowGates(prev, metaRows))
|
|
187
187
|
}, [metaRows])
|
|
188
|
+
// Phase F.5 — row-array CRDT binding (mirrors `RepeaterInput`). The
|
|
189
|
+
// initial-row payload that lands on `add()` carries the block's `type`
|
|
190
|
+
// alongside the empty field map so peers see the discriminator from
|
|
191
|
+
// the first event — without it, the picker dropdown choice doesn't
|
|
192
|
+
// propagate until the user makes their first inner-field edit.
|
|
193
|
+
const rowBinding = useRowBinding(name)
|
|
194
|
+
// Phase F.5c — mirror row identities into the form's values map so dotted
|
|
195
|
+
// row-leaf consumers (`useFieldState('${name}.${i}.data.text').textBinding`)
|
|
196
|
+
// can resolve the row's `__id` via `rowIdAtIndex(ctx.values, name, i)`.
|
|
197
|
+
// Without this stamp the F.5c per-row Y.Text path stays null on Builder
|
|
198
|
+
// and inner text fields never sync. Mirrors the same fix in RepeaterInput.
|
|
199
|
+
const formStateForIds = useFormState()
|
|
200
|
+
const ctxSetValue = formStateForIds?.setValue
|
|
201
|
+
useEffect(() => {
|
|
202
|
+
if (!ctxSetValue) return
|
|
203
|
+
for (let i = 0; i < rows.length; i++) {
|
|
204
|
+
const row = rows[i]
|
|
205
|
+
if (!row) continue
|
|
206
|
+
ctxSetValue(`${name}.${i}.__id`, row.id)
|
|
207
|
+
}
|
|
208
|
+
}, [rows, name, ctxSetValue])
|
|
209
|
+
// Phase F.5 — reconcile remote row events. Builder mirrors
|
|
210
|
+
// RepeaterInput but reads `event.values.type` to pick the block whose
|
|
211
|
+
// template seeds the new row's children. Falls back to the first
|
|
212
|
+
// registered block when the remote `type` doesn't match a known one;
|
|
213
|
+
// the row still mounts so the user sees the change rather than a
|
|
214
|
+
// silent drop (matches the server-side `unknownType` fallback).
|
|
215
|
+
useEffect(() => {
|
|
216
|
+
if (!rowBinding) return
|
|
217
|
+
return rowBinding.subscribe((event) => {
|
|
218
|
+
if (event.kind === 'add') {
|
|
219
|
+
setRows((prev) => {
|
|
220
|
+
if (prev.some(r => r.id === event.rowId)) return prev
|
|
221
|
+
const blockType = typeof event.values['type'] === 'string'
|
|
222
|
+
? event.values['type'] as string
|
|
223
|
+
: (meta.blocks?.[0]?.name ?? '')
|
|
224
|
+
const block = blocksByName.get(blockType)
|
|
225
|
+
const incoming: RowState = {
|
|
226
|
+
id: event.rowId,
|
|
227
|
+
type: blockType,
|
|
228
|
+
children: block?.template ?? [],
|
|
229
|
+
}
|
|
230
|
+
const next = prev.slice()
|
|
231
|
+
const at = Math.max(0, Math.min(event.index, next.length))
|
|
232
|
+
next.splice(at, 0, incoming)
|
|
233
|
+
return next
|
|
234
|
+
})
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
if (event.kind === 'remove') {
|
|
238
|
+
setRows((prev) => {
|
|
239
|
+
if (!prev.some(r => r.id === event.rowId)) return prev
|
|
240
|
+
return prev.filter(r => r.id !== event.rowId)
|
|
241
|
+
})
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
setRows((prev) => {
|
|
245
|
+
const fromIdx = prev.findIndex(r => r.id === event.rowId)
|
|
246
|
+
if (fromIdx < 0) return prev
|
|
247
|
+
if (fromIdx === event.to) return prev
|
|
248
|
+
const next = prev.slice()
|
|
249
|
+
const [moved] = next.splice(fromIdx, 1)
|
|
250
|
+
if (!moved) return prev
|
|
251
|
+
next.splice(event.to, 0, moved)
|
|
252
|
+
return next
|
|
253
|
+
})
|
|
254
|
+
})
|
|
255
|
+
}, [rowBinding, blocksByName, meta.blocks])
|
|
188
256
|
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(() =>
|
|
189
257
|
accordion ? {} : initSeedCollapsed(initialRows, formId, name, defaultCollapsed, collapsible),
|
|
190
258
|
)
|
|
@@ -232,6 +300,9 @@ export function BuilderInput({
|
|
|
232
300
|
const i = Math.max(0, Math.min(atIndex, prev.length))
|
|
233
301
|
return [...prev.slice(0, i), newRow, ...prev.slice(i)]
|
|
234
302
|
})
|
|
303
|
+
// F.5 — seed the new block's discriminator on the CRDT side so peers
|
|
304
|
+
// pick the right inner schema without waiting for the user's first edit.
|
|
305
|
+
rowBinding?.add(newRow.id, { type: block.name })
|
|
235
306
|
if (accordion) {
|
|
236
307
|
setAccordionOpenId(newRow.id)
|
|
237
308
|
writeAccordionToStorage(formId, name, newRow.id)
|
|
@@ -246,6 +317,7 @@ export function BuilderInput({
|
|
|
246
317
|
const removeRow = (id: string): void => {
|
|
247
318
|
if (atMin) return
|
|
248
319
|
setRows(prev => prev.filter(r => r.id !== id))
|
|
320
|
+
rowBinding?.remove(id)
|
|
249
321
|
if (accordion) {
|
|
250
322
|
if (accordionOpenId === id) {
|
|
251
323
|
setAccordionOpenId(null)
|
|
@@ -262,6 +334,8 @@ export function BuilderInput({
|
|
|
262
334
|
|
|
263
335
|
const cloneRow = (id: string): void => {
|
|
264
336
|
if (atMax) return
|
|
337
|
+
let cloneId: string | null = null
|
|
338
|
+
let cloneType: string | null = null
|
|
265
339
|
setRows(prev => {
|
|
266
340
|
const idx = prev.findIndex(r => r.id === id)
|
|
267
341
|
if (idx < 0) return prev
|
|
@@ -270,8 +344,10 @@ export function BuilderInput({
|
|
|
270
344
|
const block = blocksByName.get(source.type)
|
|
271
345
|
const cap = block?.maxItems
|
|
272
346
|
if (cap !== undefined && (typeCounts.get(source.type) ?? 0) >= cap) return prev
|
|
347
|
+
cloneId = generateRowId()
|
|
348
|
+
cloneType = source.type
|
|
273
349
|
const clone: RowState = {
|
|
274
|
-
id:
|
|
350
|
+
id: cloneId,
|
|
275
351
|
type: source.type,
|
|
276
352
|
children: source.children,
|
|
277
353
|
...(source.itemLabel !== undefined ? { itemLabel: source.itemLabel } : {}),
|
|
@@ -280,23 +356,32 @@ export function BuilderInput({
|
|
|
280
356
|
next.splice(idx + 1, 0, clone)
|
|
281
357
|
return next
|
|
282
358
|
})
|
|
359
|
+
if (cloneId !== null && cloneType !== null) {
|
|
360
|
+
rowBinding?.add(cloneId, { type: cloneType })
|
|
361
|
+
}
|
|
283
362
|
}
|
|
284
363
|
|
|
285
364
|
const moveRow = (id: string, dir: -1 | 1): void => {
|
|
365
|
+
let newOrder: string[] | null = null
|
|
286
366
|
setRows(prev => {
|
|
287
367
|
const idx = prev.findIndex(r => r.id === id)
|
|
288
368
|
if (idx < 0) return prev
|
|
369
|
+
let next: RowState[]
|
|
289
370
|
if (dir === -1) {
|
|
290
371
|
let target = idx - 1
|
|
291
372
|
while (target >= 0 && prev[target]?.hidden) target--
|
|
292
373
|
if (target < 0) return prev
|
|
293
|
-
|
|
374
|
+
next = reorderRows(prev, idx, target)
|
|
375
|
+
} else {
|
|
376
|
+
let target = idx + 1
|
|
377
|
+
while (target < prev.length && prev[target]?.hidden) target++
|
|
378
|
+
if (target >= prev.length) return prev
|
|
379
|
+
next = reorderRows(prev, idx, target + 1)
|
|
294
380
|
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
if (target >= prev.length) return prev
|
|
298
|
-
return reorderRows(prev, idx, target + 1)
|
|
381
|
+
if (next !== prev) newOrder = next.map(r => r.id)
|
|
382
|
+
return next
|
|
299
383
|
})
|
|
384
|
+
if (newOrder !== null) rowBinding?.reorder(newOrder)
|
|
300
385
|
}
|
|
301
386
|
|
|
302
387
|
// ── DnD state (skipped when buttonsOnly) ────────────────
|
|
@@ -310,11 +395,15 @@ export function BuilderInput({
|
|
|
310
395
|
} = useRowReorderDnd({
|
|
311
396
|
enabled: dndEnabled,
|
|
312
397
|
onDrop: (fromId, at) => {
|
|
398
|
+
let newOrder: string[] | null = null
|
|
313
399
|
setRows(prev => {
|
|
314
400
|
const fromIdx = prev.findIndex(r => r.id === fromId)
|
|
315
401
|
if (fromIdx < 0) return prev
|
|
316
|
-
|
|
402
|
+
const next = reorderRows(prev, fromIdx, at)
|
|
403
|
+
if (next !== prev) newOrder = next.map(r => r.id)
|
|
404
|
+
return next
|
|
317
405
|
})
|
|
406
|
+
if (newOrder !== null) rowBinding?.reorder(newOrder)
|
|
318
407
|
},
|
|
319
408
|
})
|
|
320
409
|
|