@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.
@@ -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: generateRowId(),
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
- return reorderRows(prev, idx, target)
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
- let target = idx + 1
332
- while (target < prev.length && prev[target]?.hidden) target++
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
- return reorderRows(prev, fromIdx, at)
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
+ })