@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
|
@@ -3,7 +3,7 @@ import { 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, dispatchHandlerAction } 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 { useNavigate } from '../navigate.js'
|
|
9
9
|
import { useToast } from '../Toaster.js'
|
|
@@ -236,6 +236,75 @@ export function RepeaterInput({
|
|
|
236
236
|
if (!metaRows) return
|
|
237
237
|
setRows(prev => syncRowGates(prev, metaRows))
|
|
238
238
|
}, [metaRows])
|
|
239
|
+
// Phase F.5 — row-array CRDT binding. `null` outside a collab room
|
|
240
|
+
// OR when the active binding doesn't implement F.5 row methods OR when
|
|
241
|
+
// this Repeater opted out via `.collab(false)`. The four row mutations
|
|
242
|
+
// (`addRow / cloneRow / removeRow / moveRow + DnD drop`) below call into
|
|
243
|
+
// it when present so peers see the same lifecycle events; absent =
|
|
244
|
+
// today's local-only behaviour, unchanged.
|
|
245
|
+
const rowBinding = useRowBinding(name)
|
|
246
|
+
// Phase F.5c — mirror row identities into the form's values map so dotted
|
|
247
|
+
// row-leaf consumers (`useFieldState('${name}.${i}.heading').textBinding`)
|
|
248
|
+
// can resolve the row's `__id` via `rowIdAtIndex(ctx.values, name, i)`.
|
|
249
|
+
// The renderer is the only source of truth for `(index → rowId)`; without
|
|
250
|
+
// this stamp the F.5c per-row Y.Text path stays null and row text fields
|
|
251
|
+
// never sync. Setting a `__id` key routes through `routeBindingWrite` →
|
|
252
|
+
// `parseRowFieldPath` which filters `__id` → no-op on the binding side
|
|
253
|
+
// (no Y.Text writes), so the only effect is a row in `valuesState`.
|
|
254
|
+
// `formStateForIds` mirrors `formState` below; we read via `useFormState()`
|
|
255
|
+
// here too instead of forward-referencing the later binding.
|
|
256
|
+
const formStateForIds = useFormState()
|
|
257
|
+
const ctxSetValue = formStateForIds?.setValue
|
|
258
|
+
useEffect(() => {
|
|
259
|
+
if (!ctxSetValue) return
|
|
260
|
+
for (let i = 0; i < rows.length; i++) {
|
|
261
|
+
const row = rows[i]
|
|
262
|
+
if (!row) continue
|
|
263
|
+
ctxSetValue(`${name}.${i}.__id`, row.id)
|
|
264
|
+
}
|
|
265
|
+
}, [rows, name, ctxSetValue])
|
|
266
|
+
// Phase F.5 — reconcile remote row events into the local `rows` state
|
|
267
|
+
// by `__id`. Local mutations also surface here (Yjs observers fire on
|
|
268
|
+
// local transactions); we dedupe by checking whether the rowId is
|
|
269
|
+
// already present in the current state. `template` seeds new rows so
|
|
270
|
+
// remote-added rows render with the same inner schema as locally-added
|
|
271
|
+
// ones.
|
|
272
|
+
useEffect(() => {
|
|
273
|
+
if (!rowBinding) return
|
|
274
|
+
const tpl = meta.template ?? []
|
|
275
|
+
return rowBinding.subscribe((event) => {
|
|
276
|
+
if (event.kind === 'add') {
|
|
277
|
+
setRows((prev) => {
|
|
278
|
+
if (prev.some(r => r.id === event.rowId)) return prev
|
|
279
|
+
const incoming: RowState = { id: event.rowId, children: tpl }
|
|
280
|
+
const next = prev.slice()
|
|
281
|
+
const at = Math.max(0, Math.min(event.index, next.length))
|
|
282
|
+
next.splice(at, 0, incoming)
|
|
283
|
+
return next
|
|
284
|
+
})
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
if (event.kind === 'remove') {
|
|
288
|
+
setRows((prev) => {
|
|
289
|
+
if (!prev.some(r => r.id === event.rowId)) return prev
|
|
290
|
+
return prev.filter(r => r.id !== event.rowId)
|
|
291
|
+
})
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
// move — recompute the local row order by lifting the row at `from`
|
|
295
|
+
// and re-inserting at `to`. No-op when local already matches.
|
|
296
|
+
setRows((prev) => {
|
|
297
|
+
const fromIdx = prev.findIndex(r => r.id === event.rowId)
|
|
298
|
+
if (fromIdx < 0) return prev
|
|
299
|
+
if (fromIdx === event.to) return prev
|
|
300
|
+
const next = prev.slice()
|
|
301
|
+
const [moved] = next.splice(fromIdx, 1)
|
|
302
|
+
if (!moved) return prev
|
|
303
|
+
next.splice(event.to, 0, moved)
|
|
304
|
+
return next
|
|
305
|
+
})
|
|
306
|
+
})
|
|
307
|
+
}, [rowBinding, meta.template])
|
|
239
308
|
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(() =>
|
|
240
309
|
accordion ? {} : initSeedCollapsed(initialRows, formId, name, defaultCollapsed, collapsible),
|
|
241
310
|
)
|
|
@@ -269,6 +338,7 @@ export function RepeaterInput({
|
|
|
269
338
|
children: meta.template ?? [],
|
|
270
339
|
}
|
|
271
340
|
setRows(prev => [...prev, newRow])
|
|
341
|
+
rowBinding?.add(newRow.id, {})
|
|
272
342
|
if (accordion) {
|
|
273
343
|
// New row should be the only one open — the user just asked for it.
|
|
274
344
|
setAccordionOpenId(newRow.id)
|
|
@@ -284,6 +354,7 @@ export function RepeaterInput({
|
|
|
284
354
|
const removeRow = (id: string): void => {
|
|
285
355
|
if (atMin) return
|
|
286
356
|
setRows(prev => prev.filter(r => r.id !== id))
|
|
357
|
+
rowBinding?.remove(id)
|
|
287
358
|
if (accordion) {
|
|
288
359
|
if (accordionOpenId === id) {
|
|
289
360
|
setAccordionOpenId(null)
|
|
@@ -300,12 +371,14 @@ export function RepeaterInput({
|
|
|
300
371
|
|
|
301
372
|
const cloneRow = (id: string): void => {
|
|
302
373
|
if (atMax) return
|
|
374
|
+
let cloneId: string | null = null
|
|
303
375
|
setRows(prev => {
|
|
304
376
|
const idx = prev.findIndex(r => r.id === id)
|
|
305
377
|
if (idx < 0) return prev
|
|
306
378
|
const source = prev[idx]!
|
|
379
|
+
cloneId = generateRowId()
|
|
307
380
|
const clone: RowState = {
|
|
308
|
-
id:
|
|
381
|
+
id: cloneId,
|
|
309
382
|
children: source.children,
|
|
310
383
|
...(source.itemLabel !== undefined ? { itemLabel: source.itemLabel } : {}),
|
|
311
384
|
}
|
|
@@ -313,26 +386,38 @@ export function RepeaterInput({
|
|
|
313
386
|
next.splice(idx + 1, 0, clone)
|
|
314
387
|
return next
|
|
315
388
|
})
|
|
389
|
+
// F.5 — register the clone's stable id on the binding. Per-field
|
|
390
|
+
// clone-of-source values flow through `setRow` on the user's next
|
|
391
|
+
// edit; v1 doesn't lift the source row's values onto the clone (the
|
|
392
|
+
// binding's empty seed combined with the DOM's defaultValue-copied
|
|
393
|
+
// inputs gives the local user the right visual state).
|
|
394
|
+
if (cloneId !== null) rowBinding?.add(cloneId, {})
|
|
316
395
|
}
|
|
317
396
|
|
|
318
397
|
const moveRow = (id: string, dir: -1 | 1): void => {
|
|
398
|
+
let newOrder: string[] | null = null
|
|
319
399
|
setRows(prev => {
|
|
320
400
|
const idx = prev.findIndex(r => r.id === id)
|
|
321
401
|
if (idx < 0) return prev
|
|
322
402
|
// Skip past hidden neighbours so reorder operates between visible
|
|
323
403
|
// rows. Hidden rows hold their absolute slot — the visible row hops
|
|
324
404
|
// over them.
|
|
405
|
+
let next: RowState[]
|
|
325
406
|
if (dir === -1) {
|
|
326
407
|
let target = idx - 1
|
|
327
408
|
while (target >= 0 && prev[target]?.hidden) target--
|
|
328
409
|
if (target < 0) return prev
|
|
329
|
-
|
|
410
|
+
next = reorderRows(prev, idx, target)
|
|
411
|
+
} else {
|
|
412
|
+
let target = idx + 1
|
|
413
|
+
while (target < prev.length && prev[target]?.hidden) target++
|
|
414
|
+
if (target >= prev.length) return prev
|
|
415
|
+
next = reorderRows(prev, idx, target + 1)
|
|
330
416
|
}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
if (target >= prev.length) return prev
|
|
334
|
-
return reorderRows(prev, idx, target + 1)
|
|
417
|
+
if (next !== prev) newOrder = next.map(r => r.id)
|
|
418
|
+
return next
|
|
335
419
|
})
|
|
420
|
+
if (newOrder !== null) rowBinding?.reorder(newOrder)
|
|
336
421
|
}
|
|
337
422
|
|
|
338
423
|
// ── DnD state ───────────────────────────────────────────
|
|
@@ -345,11 +430,15 @@ export function RepeaterInput({
|
|
|
345
430
|
} = useRowReorderDnd({
|
|
346
431
|
enabled: reorderable && !disabled,
|
|
347
432
|
onDrop: (fromId, at) => {
|
|
433
|
+
let newOrder: string[] | null = null
|
|
348
434
|
setRows(prev => {
|
|
349
435
|
const fromIdx = prev.findIndex(r => r.id === fromId)
|
|
350
436
|
if (fromIdx < 0) return prev
|
|
351
|
-
|
|
437
|
+
const next = reorderRows(prev, fromIdx, at)
|
|
438
|
+
if (next !== prev) newOrder = next.map(r => r.id)
|
|
439
|
+
return next
|
|
352
440
|
})
|
|
441
|
+
if (newOrder !== null) rowBinding?.reorder(newOrder)
|
|
353
442
|
},
|
|
354
443
|
})
|
|
355
444
|
|
|
@@ -3,12 +3,19 @@ import assert from 'node:assert/strict'
|
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
5
|
collectFieldDefaults,
|
|
6
|
+
collectRowArrayFieldNames,
|
|
7
|
+
collectRowTextLeavesByArray,
|
|
8
|
+
fieldOptsOutOfCollab,
|
|
6
9
|
findFieldMeta,
|
|
7
10
|
parseFormDataToNested,
|
|
11
|
+
parseRowFieldPath,
|
|
8
12
|
readNestedValue,
|
|
13
|
+
routeBindingWrite,
|
|
14
|
+
rowIdAtIndex,
|
|
9
15
|
writeNestedValue,
|
|
10
16
|
} from './formStateHelpers.js'
|
|
11
17
|
import type { ElementMeta } from '../schema/Element.js'
|
|
18
|
+
import type { FormCollabBinding } from './FormCollabBindingRegistry.js'
|
|
12
19
|
|
|
13
20
|
const field = (name: string, defaultValue?: unknown): ElementMeta => ({
|
|
14
21
|
type: 'field',
|
|
@@ -293,3 +300,308 @@ describe('readNestedValue', () => {
|
|
|
293
300
|
assert.equal(readNestedValue(root as Record<string, unknown>, 'name.length'), undefined)
|
|
294
301
|
})
|
|
295
302
|
})
|
|
303
|
+
|
|
304
|
+
describe('parseRowFieldPath', () => {
|
|
305
|
+
it('parses Repeater row leaves (3-segment dotted)', () => {
|
|
306
|
+
assert.deepEqual(parseRowFieldPath('tags.0.label'), {
|
|
307
|
+
arrayName: 'tags', index: 0, fieldName: 'label',
|
|
308
|
+
})
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
it('parses Builder row leaves (4-segment with data wrapper)', () => {
|
|
312
|
+
assert.deepEqual(parseRowFieldPath('blocks.0.data.body'), {
|
|
313
|
+
arrayName: 'blocks', index: 0, fieldName: 'body',
|
|
314
|
+
})
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('rejects top-level names', () => {
|
|
318
|
+
assert.equal(parseRowFieldPath('title'), null)
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it('rejects reserved row-metadata leaves (__id, type)', () => {
|
|
322
|
+
assert.equal(parseRowFieldPath('tags.0.__id'), null)
|
|
323
|
+
assert.equal(parseRowFieldPath('blocks.0.type'), null)
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
it('rejects non-integer indices', () => {
|
|
327
|
+
assert.equal(parseRowFieldPath('tags.NaN.label'), null)
|
|
328
|
+
assert.equal(parseRowFieldPath('tags.-1.label'), null)
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
it('rejects nested-Repeater paths (deferred to a future phase)', () => {
|
|
332
|
+
assert.equal(parseRowFieldPath('articles.0.comments.1.body'), null)
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
it('rejects 4-segment paths that lack the literal "data" wrapper', () => {
|
|
336
|
+
assert.equal(parseRowFieldPath('blocks.0.notdata.body'), null)
|
|
337
|
+
})
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
describe('rowIdAtIndex', () => {
|
|
341
|
+
it('reads the dotted __id at the given index', () => {
|
|
342
|
+
const values = { 'tags.0.__id': 'row-abc', 'tags.0.label': 'a' }
|
|
343
|
+
assert.equal(rowIdAtIndex(values, 'tags', 0), 'row-abc')
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
it('returns null when the row hasn\'t been stamped', () => {
|
|
347
|
+
assert.equal(rowIdAtIndex({}, 'tags', 0), null)
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
it('returns null when __id is not a non-empty string', () => {
|
|
351
|
+
assert.equal(rowIdAtIndex({ 'tags.0.__id': '' }, 'tags', 0), null)
|
|
352
|
+
assert.equal(rowIdAtIndex({ 'tags.0.__id': 123 }, 'tags', 0), null)
|
|
353
|
+
})
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
describe('collectRowArrayFieldNames', () => {
|
|
357
|
+
const arrayField = (name: string, fieldType: 'repeater' | 'builder', collab?: boolean): ElementMeta => ({
|
|
358
|
+
type: 'field',
|
|
359
|
+
fieldType,
|
|
360
|
+
name,
|
|
361
|
+
label: name,
|
|
362
|
+
required: false,
|
|
363
|
+
disabled: false,
|
|
364
|
+
...(collab === false ? { collab: false } : {}),
|
|
365
|
+
} as ElementMeta)
|
|
366
|
+
|
|
367
|
+
it('returns top-level Repeater and Builder field names', () => {
|
|
368
|
+
const meta = formMeta([
|
|
369
|
+
field('title'),
|
|
370
|
+
arrayField('tags', 'repeater'),
|
|
371
|
+
arrayField('blocks', 'builder'),
|
|
372
|
+
])
|
|
373
|
+
assert.deepEqual(collectRowArrayFieldNames(meta), ['tags', 'blocks'])
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
it('walks into layout containers', () => {
|
|
377
|
+
const meta = formMeta([
|
|
378
|
+
{
|
|
379
|
+
type: 'section',
|
|
380
|
+
children: [arrayField('tags', 'repeater')],
|
|
381
|
+
} as ElementMeta,
|
|
382
|
+
])
|
|
383
|
+
assert.deepEqual(collectRowArrayFieldNames(meta), ['tags'])
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
it('skips fields opted out via .collab(false)', () => {
|
|
387
|
+
const meta = formMeta([
|
|
388
|
+
arrayField('private', 'repeater', false),
|
|
389
|
+
arrayField('shared', 'repeater'),
|
|
390
|
+
])
|
|
391
|
+
assert.deepEqual(collectRowArrayFieldNames(meta), ['shared'])
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
it('does not descend into inner row schemas', () => {
|
|
395
|
+
const meta = formMeta([
|
|
396
|
+
{
|
|
397
|
+
...arrayField('outer', 'repeater'),
|
|
398
|
+
children: [arrayField('inner', 'repeater')],
|
|
399
|
+
} as ElementMeta,
|
|
400
|
+
])
|
|
401
|
+
assert.deepEqual(collectRowArrayFieldNames(meta), ['outer'])
|
|
402
|
+
})
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
describe('routeBindingWrite', () => {
|
|
406
|
+
interface RecordedCall {
|
|
407
|
+
kind: 'set' | 'setRow' | 'addRow' | 'removeRow' | 'reorderRows'
|
|
408
|
+
args: unknown[]
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function stub(opts: { withSetRow?: boolean } = {}): { binding: FormCollabBinding; calls: RecordedCall[] } {
|
|
412
|
+
const calls: RecordedCall[] = []
|
|
413
|
+
const binding: FormCollabBinding = {
|
|
414
|
+
get: () => ({}),
|
|
415
|
+
set: (...args) => { calls.push({ kind: 'set', args }) },
|
|
416
|
+
subscribe: () => () => {},
|
|
417
|
+
destroy: () => {},
|
|
418
|
+
...(opts.withSetRow ? {
|
|
419
|
+
setRow: (...args: unknown[]) => { calls.push({ kind: 'setRow', args }) },
|
|
420
|
+
} : {}),
|
|
421
|
+
} as FormCollabBinding
|
|
422
|
+
return { binding, calls }
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const formMetaWithRepeater = formMeta([
|
|
426
|
+
field('title'),
|
|
427
|
+
{
|
|
428
|
+
type: 'field',
|
|
429
|
+
fieldType: 'repeater',
|
|
430
|
+
name: 'tags',
|
|
431
|
+
} as ElementMeta,
|
|
432
|
+
])
|
|
433
|
+
|
|
434
|
+
it('routes top-level names through binding.set', () => {
|
|
435
|
+
const { binding, calls } = stub()
|
|
436
|
+
routeBindingWrite(binding, formMetaWithRepeater, {}, 'title', 'Hello')
|
|
437
|
+
assert.deepEqual(calls, [{ kind: 'set', args: ['title', 'Hello'] }])
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
it('routes row leaves through binding.setRow when implemented', () => {
|
|
441
|
+
const { binding, calls } = stub({ withSetRow: true })
|
|
442
|
+
const values = { 'tags.0.__id': 'row-a' }
|
|
443
|
+
routeBindingWrite(binding, formMetaWithRepeater, values, 'tags.0.label', 'Hi')
|
|
444
|
+
assert.deepEqual(calls, [{ kind: 'setRow', args: ['tags', 'row-a', 'label', 'Hi'] }])
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
it('drops row-leaf writes when the binding lacks setRow (pre-F.5)', () => {
|
|
448
|
+
const { binding, calls } = stub()
|
|
449
|
+
const values = { 'tags.0.__id': 'row-a' }
|
|
450
|
+
routeBindingWrite(binding, formMetaWithRepeater, values, 'tags.0.label', 'Hi')
|
|
451
|
+
assert.deepEqual(calls, [])
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
it('drops row-leaf writes when the row hasn\'t been stamped with __id yet', () => {
|
|
455
|
+
const { binding, calls } = stub({ withSetRow: true })
|
|
456
|
+
routeBindingWrite(binding, formMetaWithRepeater, {}, 'tags.0.label', 'Hi')
|
|
457
|
+
assert.deepEqual(calls, [])
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
it('skips fields opted out via .collab(false)', () => {
|
|
461
|
+
const optedOut = formMeta([
|
|
462
|
+
{ ...field('private'), collab: false } as ElementMeta,
|
|
463
|
+
])
|
|
464
|
+
const { binding, calls } = stub()
|
|
465
|
+
routeBindingWrite(binding, optedOut, {}, 'private', 'sensitive')
|
|
466
|
+
assert.deepEqual(calls, [])
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
it('is a no-op when no binding is registered', () => {
|
|
470
|
+
// No throw, no work — same posture as the v1 binding-absent path.
|
|
471
|
+
assert.doesNotThrow(() => routeBindingWrite(null, formMetaWithRepeater, {}, 'title', 'x'))
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
it('does NOT call setRow for nested-Repeater paths (out of scope v1)', () => {
|
|
475
|
+
const { binding, calls } = stub({ withSetRow: true })
|
|
476
|
+
const values = {
|
|
477
|
+
'articles.0.__id': 'a-1',
|
|
478
|
+
'articles.0.comments.0.__id': 'c-1',
|
|
479
|
+
}
|
|
480
|
+
routeBindingWrite(binding, formMetaWithRepeater, values, 'articles.0.comments.0.body', 'oops')
|
|
481
|
+
assert.deepEqual(calls, [])
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
it('handles Builder row leaves through the data wrapper', () => {
|
|
485
|
+
const builderMeta = formMeta([
|
|
486
|
+
{ type: 'field', fieldType: 'builder', name: 'blocks' } as ElementMeta,
|
|
487
|
+
])
|
|
488
|
+
const { binding, calls } = stub({ withSetRow: true })
|
|
489
|
+
const values = { 'blocks.0.__id': 'blk-1' }
|
|
490
|
+
routeBindingWrite(binding, builderMeta, values, 'blocks.0.data.body', 'Lorem')
|
|
491
|
+
assert.deepEqual(calls, [{ kind: 'setRow', args: ['blocks', 'blk-1', 'body', 'Lorem'] }])
|
|
492
|
+
})
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
describe('collectRowTextLeavesByArray', () => {
|
|
496
|
+
const textField = (name: string, fieldType: string = 'text', collab?: boolean): ElementMeta => ({
|
|
497
|
+
type: 'field',
|
|
498
|
+
fieldType,
|
|
499
|
+
name,
|
|
500
|
+
label: name,
|
|
501
|
+
required: false,
|
|
502
|
+
disabled: false,
|
|
503
|
+
...(collab === false ? { collab: false } : {}),
|
|
504
|
+
} as ElementMeta)
|
|
505
|
+
|
|
506
|
+
// Repeater meta carries the row schema under `template` (`children` is
|
|
507
|
+
// the per-resolved-row child list, not the field-level template). Tests
|
|
508
|
+
// must mirror what `RepeaterField.toMeta()` actually emits.
|
|
509
|
+
const repeater = (name: string, template: ElementMeta[], collab?: boolean): ElementMeta => ({
|
|
510
|
+
type: 'field',
|
|
511
|
+
fieldType: 'repeater',
|
|
512
|
+
name,
|
|
513
|
+
label: name,
|
|
514
|
+
required: false,
|
|
515
|
+
disabled: false,
|
|
516
|
+
template,
|
|
517
|
+
...(collab === false ? { collab: false } : {}),
|
|
518
|
+
} as ElementMeta)
|
|
519
|
+
|
|
520
|
+
it('collects text-shaped inner-field names per Repeater', () => {
|
|
521
|
+
const meta = formMeta([
|
|
522
|
+
repeater('tags', [
|
|
523
|
+
textField('label', 'text'),
|
|
524
|
+
textField('summary', 'textarea'),
|
|
525
|
+
textField('count', 'number'),
|
|
526
|
+
]),
|
|
527
|
+
])
|
|
528
|
+
const out = collectRowTextLeavesByArray(meta)
|
|
529
|
+
assert.equal(out.size, 1)
|
|
530
|
+
assert.deepEqual([...out.get('tags')!].sort(), ['label', 'summary'])
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
it('walks Builder block templates', () => {
|
|
534
|
+
const meta = formMeta([
|
|
535
|
+
{
|
|
536
|
+
type: 'field',
|
|
537
|
+
fieldType: 'builder',
|
|
538
|
+
name: 'blocks',
|
|
539
|
+
children: [],
|
|
540
|
+
blocks: [
|
|
541
|
+
{ name: 'heading', template: [textField('text', 'text')] },
|
|
542
|
+
{ name: 'paragraph', template: [textField('body', 'markdown')] },
|
|
543
|
+
],
|
|
544
|
+
} as unknown as ElementMeta,
|
|
545
|
+
])
|
|
546
|
+
const out = collectRowTextLeavesByArray(meta)
|
|
547
|
+
assert.deepEqual([...out.get('blocks')!].sort(), ['body', 'text'])
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
it('skips opted-out inner fields', () => {
|
|
551
|
+
const meta = formMeta([
|
|
552
|
+
repeater('tags', [
|
|
553
|
+
textField('label', 'text'),
|
|
554
|
+
textField('private', 'text', false), // .collab(false)
|
|
555
|
+
]),
|
|
556
|
+
])
|
|
557
|
+
const out = collectRowTextLeavesByArray(meta)
|
|
558
|
+
assert.deepEqual([...out.get('tags')!], ['label'])
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
it('omits opted-out top-level arrays', () => {
|
|
562
|
+
const meta = formMeta([
|
|
563
|
+
repeater('public', [textField('a', 'text')]),
|
|
564
|
+
repeater('private', [textField('b', 'text')], false),
|
|
565
|
+
])
|
|
566
|
+
const out = collectRowTextLeavesByArray(meta)
|
|
567
|
+
assert.equal(out.has('public'), true)
|
|
568
|
+
assert.equal(out.has('private'), false)
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
it('stops at nested array boundaries (no 5+ segment dotted paths)', () => {
|
|
572
|
+
const meta = formMeta([
|
|
573
|
+
repeater('outer', [
|
|
574
|
+
textField('outerLabel', 'text'),
|
|
575
|
+
repeater('inner', [textField('innerLabel', 'text')]),
|
|
576
|
+
]),
|
|
577
|
+
])
|
|
578
|
+
const out = collectRowTextLeavesByArray(meta)
|
|
579
|
+
assert.deepEqual([...out.get('outer')!], ['outerLabel'])
|
|
580
|
+
assert.equal(out.has('inner'), false, 'nested Repeater not surfaced as a top-level array')
|
|
581
|
+
})
|
|
582
|
+
|
|
583
|
+
it('returns empty map when no Repeater/Builder has text leaves', () => {
|
|
584
|
+
const meta = formMeta([
|
|
585
|
+
textField('top', 'text'),
|
|
586
|
+
repeater('numbers', [textField('count', 'number')]),
|
|
587
|
+
])
|
|
588
|
+
const out = collectRowTextLeavesByArray(meta)
|
|
589
|
+
assert.equal(out.size, 0)
|
|
590
|
+
})
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
describe('fieldOptsOutOfCollab', () => {
|
|
594
|
+
it('returns true only when the field carries an explicit collab=false', () => {
|
|
595
|
+
const meta = formMeta([
|
|
596
|
+
{ ...field('shared') } as ElementMeta,
|
|
597
|
+
{ ...field('hidden'), collab: false } as ElementMeta,
|
|
598
|
+
])
|
|
599
|
+
assert.equal(fieldOptsOutOfCollab(meta, 'shared'), false)
|
|
600
|
+
assert.equal(fieldOptsOutOfCollab(meta, 'hidden'), true)
|
|
601
|
+
})
|
|
602
|
+
|
|
603
|
+
it('returns false when the field is absent', () => {
|
|
604
|
+
const meta = formMeta([field('present')])
|
|
605
|
+
assert.equal(fieldOptsOutOfCollab(meta, 'ghost'), false)
|
|
606
|
+
})
|
|
607
|
+
})
|