@pilotiq/pilotiq 0.12.0 → 0.13.1
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 +19 -0
- package/dist/pageData/helpers.d.ts +16 -0
- package/dist/pageData/helpers.d.ts.map +1 -1
- package/dist/pageData/helpers.js +61 -1
- package/dist/pageData/helpers.js.map +1 -1
- package/dist/pageData.d.ts +1 -1
- package/dist/pageData.d.ts.map +1 -1
- package/dist/pageData.js +1 -1
- package/dist/pageData.js.map +1 -1
- package/dist/react/FormCollabBindingRegistry.d.ts +33 -98
- package/dist/react/FormCollabBindingRegistry.d.ts.map +1 -1
- package/dist/react/FormCollabBindingRegistry.js.map +1 -1
- package/dist/react/FormStateContext.d.ts +1 -35
- package/dist/react/FormStateContext.d.ts.map +1 -1
- package/dist/react/FormStateContext.js +15 -92
- package/dist/react/FormStateContext.js.map +1 -1
- package/dist/react/RowCoordsContext.d.ts +19 -0
- package/dist/react/RowCoordsContext.d.ts.map +1 -0
- package/dist/react/RowCoordsContext.js +6 -0
- package/dist/react/RowCoordsContext.js.map +1 -0
- package/dist/react/fields/BuilderInput.d.ts.map +1 -1
- package/dist/react/fields/BuilderInput.js +78 -49
- package/dist/react/fields/BuilderInput.js.map +1 -1
- package/dist/react/fields/MarkdownInput.d.ts.map +1 -1
- package/dist/react/fields/MarkdownInput.js +35 -125
- package/dist/react/fields/MarkdownInput.js.map +1 -1
- package/dist/react/fields/RepeaterInput.d.ts.map +1 -1
- package/dist/react/fields/RepeaterInput.js +104 -60
- package/dist/react/fields/RepeaterInput.js.map +1 -1
- package/dist/react/fields/TextLikeInput.d.ts +11 -9
- package/dist/react/fields/TextLikeInput.d.ts.map +1 -1
- package/dist/react/fields/TextLikeInput.js +59 -189
- package/dist/react/fields/TextLikeInput.js.map +1 -1
- package/dist/react/fields/repeaterReconcile.d.ts +66 -0
- package/dist/react/fields/repeaterReconcile.d.ts.map +1 -0
- package/dist/react/fields/repeaterReconcile.js +96 -0
- package/dist/react/fields/repeaterReconcile.js.map +1 -0
- package/dist/react/formStateHelpers.d.ts +0 -15
- package/dist/react/formStateHelpers.d.ts.map +1 -1
- package/dist/react/formStateHelpers.js +0 -91
- package/dist/react/formStateHelpers.js.map +1 -1
- package/dist/react/index.d.ts +1 -1
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js.map +1 -1
- package/dist/react/schemaRenderer/form/FormRenderer.d.ts.map +1 -1
- package/dist/react/schemaRenderer/form/FormRenderer.js +10 -0
- package/dist/react/schemaRenderer/form/FormRenderer.js.map +1 -1
- package/package.json +1 -1
- package/src/pageData/helpers.ts +55 -1
- package/src/pageData.test.ts +67 -0
- package/src/pageData.ts +1 -0
- package/src/react/FormCollabBindingRegistry.ts +34 -91
- package/src/react/FormStateContext.tsx +14 -126
- package/src/react/RowCoordsContext.tsx +23 -0
- package/src/react/fields/BuilderInput.tsx +75 -39
- package/src/react/fields/MarkdownInput.tsx +42 -129
- package/src/react/fields/RepeaterInput.tsx +107 -48
- package/src/react/fields/TextLikeInput.tsx +67 -225
- package/src/react/fields/repeaterReconcile.test.ts +114 -0
- package/src/react/fields/repeaterReconcile.ts +104 -0
- package/src/react/formStateHelpers.test.ts +0 -99
- package/src/react/formStateHelpers.ts +0 -83
- package/src/react/index.ts +0 -2
- package/src/react/schemaRenderer/form/FormRenderer.tsx +10 -0
- package/dist/react/fields/textDelta.d.ts +0 -44
- package/dist/react/fields/textDelta.d.ts.map +0 -1
- package/dist/react/fields/textDelta.js +0 -80
- package/dist/react/fields/textDelta.js.map +0 -1
- package/src/react/fields/textDelta.test.ts +0 -141
- package/src/react/fields/textDelta.ts +0 -86
|
@@ -11,13 +11,10 @@ import type { ElementMeta } from '../schema/Element.js'
|
|
|
11
11
|
import {
|
|
12
12
|
collectFieldDefaults,
|
|
13
13
|
collectRowArrayFieldNames,
|
|
14
|
-
collectRowTextLeavesByArray,
|
|
15
14
|
findFieldMeta,
|
|
16
15
|
parseFormDataToNested,
|
|
17
|
-
parseRowFieldPath,
|
|
18
16
|
readNestedValue,
|
|
19
17
|
routeBindingWrite,
|
|
20
|
-
rowIdAtIndex,
|
|
21
18
|
writeNestedValue,
|
|
22
19
|
} from './formStateHelpers.js'
|
|
23
20
|
import { runJsHandler } from './fieldJsHandler.js'
|
|
@@ -27,7 +24,6 @@ import {
|
|
|
27
24
|
getFormCollabBinding,
|
|
28
25
|
type FormCollabBinding,
|
|
29
26
|
type RowBindingApi,
|
|
30
|
-
type TextBinding,
|
|
31
27
|
} from './FormCollabBindingRegistry.js'
|
|
32
28
|
|
|
33
29
|
export type FieldStatus = 'idle' | 'pending'
|
|
@@ -50,36 +46,11 @@ export interface FormStateApi {
|
|
|
50
46
|
formMeta: ElementMeta
|
|
51
47
|
inFlight: boolean
|
|
52
48
|
fieldStatus: (name: string) => FieldStatus
|
|
53
|
-
/** Phase F.6 — per-field text-CRDT handles stashed at collab-room mount.
|
|
54
|
-
* `null` outside a room or before the binding effect has populated the
|
|
55
|
-
* map. The text/non-text allowlist lives in the binding impl —
|
|
56
|
-
* `FormStateProvider` asks for every top-level field and only stashes
|
|
57
|
-
* non-null answers, so a `Map.get()` hit means the binding has opted
|
|
58
|
-
* this field into the character-level path. */
|
|
59
|
-
textBindings: ReadonlyMap<string, TextBinding> | null
|
|
60
49
|
/** Phase F.5 — per-Repeater/Builder row-array bindings. `null` outside a
|
|
61
50
|
* collab room or when the binding doesn't implement F.5 row methods.
|
|
62
51
|
* Each entry's API methods are pre-bound to the array name so renderers
|
|
63
52
|
* call `.add(rowId, initial)` rather than `binding.addRow(name, …)`. */
|
|
64
53
|
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
|
|
83
54
|
}
|
|
84
55
|
|
|
85
56
|
const FormStateContext = createContext<FormStateApi | null>(null)
|
|
@@ -120,15 +91,6 @@ export interface UseFieldStateResult {
|
|
|
120
91
|
/** True while a live re-resolve POST is in flight for this field. */
|
|
121
92
|
pending: boolean
|
|
122
93
|
errors: string[]
|
|
123
|
-
/** Phase F.6 — character-level CRDT handle for text-shaped fields when
|
|
124
|
-
* a collab room is mounted up-tree AND the binding strategy applies
|
|
125
|
-
* (allowlist + `.collab() !== false`). Null in every other case —
|
|
126
|
-
* outside a `FormStateProvider`, outside a `<RecordCollabRoom>`, on
|
|
127
|
-
* non-text fields, on dotted-path inner-Repeater rows (deferred to
|
|
128
|
-
* F.5), and on text fields opted out via `.collab(false)`. Text input
|
|
129
|
-
* renderers branch on this: non-null → character-level path with
|
|
130
|
-
* `applyDelta + observe`; null → today's whole-string LWW path. */
|
|
131
|
-
textBinding: TextBinding | null
|
|
132
94
|
}
|
|
133
95
|
|
|
134
96
|
/**
|
|
@@ -164,7 +126,6 @@ export function useFieldState(name: string): UseFieldStateResult {
|
|
|
164
126
|
triggerLive: () => {},
|
|
165
127
|
pending: false,
|
|
166
128
|
errors: [],
|
|
167
|
-
textBinding: null,
|
|
168
129
|
}
|
|
169
130
|
}
|
|
170
131
|
// Dotted-path fields (inner Repeater rows) always render uncontrolled
|
|
@@ -177,35 +138,9 @@ export function useFieldState(name: string): UseFieldStateResult {
|
|
|
177
138
|
triggerLive: (valueOverride?: unknown) => ctx.triggerLive(name, valueOverride),
|
|
178
139
|
pending: ctx.fieldStatus(name) === 'pending',
|
|
179
140
|
errors: ctx.errors[name] ?? [],
|
|
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),
|
|
190
141
|
}
|
|
191
142
|
}
|
|
192
143
|
|
|
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
|
-
|
|
209
144
|
/** Response shape from `POST {base}/.../_form/:formId/state`. */
|
|
210
145
|
interface FormStateResponse {
|
|
211
146
|
ok: boolean
|
|
@@ -253,22 +188,11 @@ export function FormStateProvider({
|
|
|
253
188
|
const [errors, setErrors] = useState<Record<string, string[]>>(initialErrors)
|
|
254
189
|
const [pendingNames, setPendingNames] = useState<Set<string>>(() => new Set())
|
|
255
190
|
const [inFlight, setInFlight] = useState(false)
|
|
256
|
-
// Phase F.
|
|
257
|
-
//
|
|
258
|
-
// a ref) so consumers of
|
|
259
|
-
//
|
|
260
|
-
// existing `setValuesState` overlay below already triggers one when the
|
|
261
|
-
// room has pre-existing state.
|
|
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.
|
|
191
|
+
// Phase F.5 — per-Repeater/Builder row-array stash. Populated on
|
|
192
|
+
// collab mount when the binding implements F.5 row methods, cleared
|
|
193
|
+
// on unmount. Stored in state (not a ref) so consumers of
|
|
194
|
+
// `useRowBinding` re-render once the bindings land.
|
|
266
195
|
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)
|
|
272
196
|
|
|
273
197
|
const { notify } = useToast()
|
|
274
198
|
|
|
@@ -332,25 +256,6 @@ export function FormStateProvider({
|
|
|
332
256
|
setValuesState((prev) => ({ ...prev, ...synced }))
|
|
333
257
|
}
|
|
334
258
|
|
|
335
|
-
// Phase F.6 — ask the binding for a `TextBinding` on every top-level
|
|
336
|
-
// field name. The text/non-text allowlist lives in the binding impl,
|
|
337
|
-
// not in core: the binding returns `null` for non-text fields and
|
|
338
|
-
// text fields opted out via `.collab(false)`. `getTextBinding` is
|
|
339
|
-
// optional on the contract — F1-era plugins that haven't implemented
|
|
340
|
-
// it short-circuit the whole stash and every text field stays on the
|
|
341
|
-
// LWW path. We stash only the non-null answers. Cleanup is owned by
|
|
342
|
-
// `binding.destroy()` (expected to cascade into every issued
|
|
343
|
-
// `TextBinding`).
|
|
344
|
-
if (binding.getTextBinding) {
|
|
345
|
-
const textStash = new Map<string, TextBinding>()
|
|
346
|
-
for (const fieldName of Object.keys(valuesRef.current)) {
|
|
347
|
-
if (fieldName.includes('.')) continue
|
|
348
|
-
const tb = binding.getTextBinding(fieldName)
|
|
349
|
-
if (tb) textStash.set(fieldName, tb)
|
|
350
|
-
}
|
|
351
|
-
if (textStash.size > 0) setTextBindings(textStash)
|
|
352
|
-
}
|
|
353
|
-
|
|
354
259
|
// Phase F.5 — build a `RowBindingApi` per top-level Repeater/Builder
|
|
355
260
|
// field when the binding implements all three lifecycle methods. The
|
|
356
261
|
// walk reads from formMeta (structural — fields exist regardless of
|
|
@@ -358,10 +263,9 @@ export function FormStateProvider({
|
|
|
358
263
|
// the array name so `RepeaterInput` calls `rb.add(rowId, …)` rather
|
|
359
264
|
// than `binding.addRow(name, rowId, …)`. Partial F.5 impls (e.g. a
|
|
360
265
|
// binding that has addRow but not reorderRows) skip the stash — the
|
|
361
|
-
// contract says all three or nothing.
|
|
362
|
-
// exposed separately via `getRowTextBinding` and stays optional.
|
|
266
|
+
// contract says all three or nothing.
|
|
363
267
|
if (binding.addRow && binding.removeRow && binding.reorderRows) {
|
|
364
|
-
const { addRow, removeRow, reorderRows, subscribeRows } = binding
|
|
268
|
+
const { addRow, removeRow, reorderRows, subscribeRows, getRowOrder } = binding
|
|
365
269
|
const arrayNames = collectRowArrayFieldNames(formMetaRef.current)
|
|
366
270
|
if (arrayNames.length > 0) {
|
|
367
271
|
const rowStash = new Map<string, RowBindingApi>()
|
|
@@ -377,29 +281,19 @@ export function FormStateProvider({
|
|
|
377
281
|
subscribe: subscribeRows
|
|
378
282
|
? (fn) => subscribeRows.call(binding, arrayName, fn)
|
|
379
283
|
: () => () => {},
|
|
284
|
+
// `getRowOrder` is optional — bindings that ship row CRUD
|
|
285
|
+
// without a snapshot read (test stubs, older plugins) get a
|
|
286
|
+
// `[]` substitute. The renderer's reconciler treats empty as
|
|
287
|
+
// "no orphans known" and no-ops, which is the safest fallback.
|
|
288
|
+
current: getRowOrder
|
|
289
|
+
? () => getRowOrder.call(binding, arrayName)
|
|
290
|
+
: () => [],
|
|
380
291
|
})
|
|
381
292
|
}
|
|
382
293
|
setRowBindings(rowStash)
|
|
383
294
|
}
|
|
384
295
|
}
|
|
385
296
|
|
|
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
|
-
|
|
403
297
|
// Subscribe to remote changes. Local writes ALSO trigger this
|
|
404
298
|
// (Yjs observers fire on local transactions too) — the per-key
|
|
405
299
|
// Object.is short-circuit below collapses them into no-op renders.
|
|
@@ -421,10 +315,7 @@ export function FormStateProvider({
|
|
|
421
315
|
unsubscribe()
|
|
422
316
|
binding.destroy()
|
|
423
317
|
bindingRef.current = null
|
|
424
|
-
setTextBindings(null)
|
|
425
318
|
setRowBindings(null)
|
|
426
|
-
setRowTextLeaves(null)
|
|
427
|
-
setGetRowTextBinding(null)
|
|
428
319
|
}
|
|
429
320
|
// `valuesRef.current` is intentionally read once at mount — initial
|
|
430
321
|
// values seed the binding; subsequent edits flow through `setValue`
|
|
@@ -657,11 +548,8 @@ export function FormStateProvider({
|
|
|
657
548
|
formMeta,
|
|
658
549
|
inFlight,
|
|
659
550
|
fieldStatus,
|
|
660
|
-
textBindings,
|
|
661
551
|
rowBindings,
|
|
662
|
-
|
|
663
|
-
getRowTextBinding,
|
|
664
|
-
}), [values, setValue, triggerLive, errors, applyErrors, formMeta, inFlight, fieldStatus, textBindings, rowBindings, rowTextLeaves, getRowTextBinding])
|
|
552
|
+
}), [values, setValue, triggerLive, errors, applyErrors, formMeta, inFlight, fieldStatus, rowBindings])
|
|
665
553
|
|
|
666
554
|
return (
|
|
667
555
|
<FormStateContext.Provider value={api}>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import React, { createContext, useContext } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Phase 1 — row-text Tiptap-backed collab plan
|
|
5
|
+
* (`pilotiq-pro/docs/plans/collab-row-text-tiptap-backed.md`).
|
|
6
|
+
*
|
|
7
|
+
* Each Repeater / Builder row mounts a `<RowCoordsContext.Provider>`
|
|
8
|
+
* around its children so dotted-path text leaves can compose a
|
|
9
|
+
* fragment-key that includes the stable `rowId` (survives reorders).
|
|
10
|
+
* Top-level fields see `null` and fall through to bare-name fragment
|
|
11
|
+
* routing.
|
|
12
|
+
*/
|
|
13
|
+
export interface RowCoords {
|
|
14
|
+
arrayName: string
|
|
15
|
+
rowIndex: number
|
|
16
|
+
rowId: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const RowCoordsContext = createContext<RowCoords | null>(null)
|
|
20
|
+
|
|
21
|
+
export function useRowCoords(): RowCoords | null {
|
|
22
|
+
return useContext(RowCoordsContext)
|
|
23
|
+
}
|
|
@@ -5,9 +5,11 @@ import { Button } from '../ui/button.js'
|
|
|
5
5
|
import { SchemaRenderer } from '../SchemaRenderer.js'
|
|
6
6
|
import { FormIdContext, useFormState, useRowBinding } from '../FormStateContext.js'
|
|
7
7
|
import { findFieldMeta } from '../formStateHelpers.js'
|
|
8
|
+
import { RowCoordsContext } from '../RowCoordsContext.js'
|
|
8
9
|
import { useIconFor } from '../icon-context.js'
|
|
9
10
|
import { reorderRows, ExtraActionStrip, buildGridContainer } from './RepeaterInput.js'
|
|
10
11
|
import { syncRowGates } from './syncRowGates.js'
|
|
12
|
+
import { consumeReconcileFlag, computeReconcilePlan } from './repeaterReconcile.js'
|
|
11
13
|
import type { RowButtonsMeta } from '../../fields/RowButton.js'
|
|
12
14
|
import {
|
|
13
15
|
RowChromeIconButton,
|
|
@@ -191,11 +193,9 @@ export function BuilderInput({
|
|
|
191
193
|
// the first event — without it, the picker dropdown choice doesn't
|
|
192
194
|
// propagate until the user makes their first inner-field edit.
|
|
193
195
|
const rowBinding = useRowBinding(name)
|
|
194
|
-
//
|
|
195
|
-
// row
|
|
196
|
-
//
|
|
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.
|
|
196
|
+
// Mirror row identities into the form's values map so dotted row-leaf
|
|
197
|
+
// consumers can resolve the row's `__id` via `rowIdAtIndex(ctx.values,
|
|
198
|
+
// name, i)`. Mirrors the same plumbing in RepeaterInput.
|
|
199
199
|
const formStateForIds = useFormState()
|
|
200
200
|
const ctxSetValue = formStateForIds?.setValue
|
|
201
201
|
useEffect(() => {
|
|
@@ -253,6 +253,31 @@ export function BuilderInput({
|
|
|
253
253
|
})
|
|
254
254
|
})
|
|
255
255
|
}, [rowBinding, blocksByName, meta.blocks])
|
|
256
|
+
|
|
257
|
+
// Phase A reconciliation for `Builder.relationship` PK-switch — mirrors
|
|
258
|
+
// the effect in `RepeaterInput`. See its comment + the plan doc:
|
|
259
|
+
// `pilotiq-pro/docs/plans/repeater-relationship-pk-switch.md`.
|
|
260
|
+
useEffect(() => {
|
|
261
|
+
if (!rowBinding) return
|
|
262
|
+
if (!consumeReconcileFlag(formId)) return
|
|
263
|
+
const timer = setTimeout(() => {
|
|
264
|
+
const plan = computeReconcilePlan({
|
|
265
|
+
current: rowBinding.current(),
|
|
266
|
+
authoritative: initialRows.map(r => r.id),
|
|
267
|
+
})
|
|
268
|
+
for (const id of plan.toRemove) rowBinding.remove(id)
|
|
269
|
+
// For Builder, the row carries a block `type` discriminator; seed
|
|
270
|
+
// it on the add path so peers' picker dropdowns see the right
|
|
271
|
+
// block (matches the existing add path in `addBlock`). The block
|
|
272
|
+
// type comes from initialRows when the row is server-rendered.
|
|
273
|
+
for (const id of plan.toAdd) {
|
|
274
|
+
const row = initialRows.find(r => r.id === id)
|
|
275
|
+
rowBinding.add(id, row?.type ? { type: row.type } : {})
|
|
276
|
+
}
|
|
277
|
+
}, 1500)
|
|
278
|
+
return () => clearTimeout(timer)
|
|
279
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
280
|
+
}, [rowBinding, formId])
|
|
256
281
|
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(() =>
|
|
257
282
|
accordion ? {} : initSeedCollapsed(initialRows, formId, name, defaultCollapsed, collapsible),
|
|
258
283
|
)
|
|
@@ -362,26 +387,23 @@ export function BuilderInput({
|
|
|
362
387
|
}
|
|
363
388
|
|
|
364
389
|
const moveRow = (id: string, dir: -1 | 1): void => {
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
let
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
return next
|
|
383
|
-
})
|
|
384
|
-
if (newOrder !== null) rowBinding?.reorder(newOrder)
|
|
390
|
+
const idx = rows.findIndex(r => r.id === id)
|
|
391
|
+
if (idx < 0) return
|
|
392
|
+
let next: RowState[]
|
|
393
|
+
if (dir === -1) {
|
|
394
|
+
let target = idx - 1
|
|
395
|
+
while (target >= 0 && rows[target]?.hidden) target--
|
|
396
|
+
if (target < 0) return
|
|
397
|
+
next = reorderRows(rows, idx, target)
|
|
398
|
+
} else {
|
|
399
|
+
let target = idx + 1
|
|
400
|
+
while (target < rows.length && rows[target]?.hidden) target++
|
|
401
|
+
if (target >= rows.length) return
|
|
402
|
+
next = reorderRows(rows, idx, target + 1)
|
|
403
|
+
}
|
|
404
|
+
if (next === rows) return
|
|
405
|
+
setRows(next)
|
|
406
|
+
rowBinding?.reorder(next.map(r => r.id))
|
|
385
407
|
}
|
|
386
408
|
|
|
387
409
|
// ── DnD state (skipped when buttonsOnly) ────────────────
|
|
@@ -395,15 +417,16 @@ export function BuilderInput({
|
|
|
395
417
|
} = useRowReorderDnd({
|
|
396
418
|
enabled: dndEnabled,
|
|
397
419
|
onDrop: (fromId, at) => {
|
|
398
|
-
|
|
399
|
-
setRows
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
420
|
+
// See RepeaterInput's matching onDrop comment — closure-mutation
|
|
421
|
+
// through setRows's updater is unreliable when other state updates
|
|
422
|
+
// are batched (useRowReorderDnd nulls dragId/dropAt right before
|
|
423
|
+
// calling this).
|
|
424
|
+
const fromIdx = rows.findIndex(r => r.id === fromId)
|
|
425
|
+
if (fromIdx < 0) return
|
|
426
|
+
const next = reorderRows(rows, fromIdx, at)
|
|
427
|
+
if (next === rows) return
|
|
428
|
+
setRows(next)
|
|
429
|
+
rowBinding?.reorder(next.map(r => r.id))
|
|
407
430
|
},
|
|
408
431
|
})
|
|
409
432
|
|
|
@@ -870,6 +893,15 @@ function BuilderRow({
|
|
|
870
893
|
() => row.children.map(c => prefixFieldNames(c, dataPrefix)),
|
|
871
894
|
[row.children, dataPrefix],
|
|
872
895
|
)
|
|
896
|
+
// Row coords for dotted-path text leaves under this row — composes
|
|
897
|
+
// fragment-key `${arrayName}.${rowId}.${fieldName}` (Phase 1 of
|
|
898
|
+
// collab-row-text-tiptap-backed.md). `parseRowFieldPath` strips the
|
|
899
|
+
// Builder-specific `data` segment, so the coords use the array name +
|
|
900
|
+
// the row's stable id without referencing the dialect.
|
|
901
|
+
const rowCoords = useMemo(
|
|
902
|
+
() => ({ arrayName: name, rowIndex: index, rowId: row.id }),
|
|
903
|
+
[name, index, row.id],
|
|
904
|
+
)
|
|
873
905
|
|
|
874
906
|
const RowIcon = useIconFor(showIcons ? block?.icon : undefined)
|
|
875
907
|
const blockLabel = block?.label ?? row.type ?? 'Block'
|
|
@@ -878,11 +910,13 @@ function BuilderRow({
|
|
|
878
910
|
|
|
879
911
|
if (row.hidden) {
|
|
880
912
|
return (
|
|
881
|
-
<
|
|
882
|
-
<
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
913
|
+
<RowCoordsContext.Provider value={rowCoords}>
|
|
914
|
+
<div style={{ display: 'none' }} data-pilotiq-builder-row="hidden">
|
|
915
|
+
<input type="hidden" name={`${name}.${index}.__id`} value={row.id} readOnly />
|
|
916
|
+
<input type="hidden" name={`${name}.${index}.type`} value={row.type} readOnly />
|
|
917
|
+
<SchemaRenderer elements={namespaced} />
|
|
918
|
+
</div>
|
|
919
|
+
</RowCoordsContext.Provider>
|
|
886
920
|
)
|
|
887
921
|
}
|
|
888
922
|
|
|
@@ -921,6 +955,7 @@ function BuilderRow({
|
|
|
921
955
|
const innerColumns = block.columns && block.columns > 1 ? block.columns : 1
|
|
922
956
|
|
|
923
957
|
return (
|
|
958
|
+
<RowCoordsContext.Provider value={rowCoords}>
|
|
924
959
|
<div
|
|
925
960
|
className={`rounded-md border bg-card transition-opacity ${isDragging ? 'opacity-50' : ''}`}
|
|
926
961
|
data-pilotiq-builder-row=""
|
|
@@ -999,6 +1034,7 @@ function BuilderRow({
|
|
|
999
1034
|
: <SchemaRenderer elements={namespaced} />}
|
|
1000
1035
|
</div>
|
|
1001
1036
|
</div>
|
|
1037
|
+
</RowCoordsContext.Provider>
|
|
1002
1038
|
)
|
|
1003
1039
|
}
|
|
1004
1040
|
|