@pilotiq/pilotiq 0.15.1 → 0.16.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.
Files changed (64) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/CHANGELOG.md +28 -0
  3. package/dist/elements/dispatchForm.d.ts +31 -0
  4. package/dist/elements/dispatchForm.d.ts.map +1 -1
  5. package/dist/elements/dispatchForm.js +31 -4
  6. package/dist/elements/dispatchForm.js.map +1 -1
  7. package/dist/index.d.ts +1 -1
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js.map +1 -1
  10. package/dist/react/FormCollabBindingRegistry.d.ts +21 -0
  11. package/dist/react/FormCollabBindingRegistry.d.ts.map +1 -1
  12. package/dist/react/FormCollabBindingRegistry.js.map +1 -1
  13. package/dist/react/FormStateContext.d.ts.map +1 -1
  14. package/dist/react/FormStateContext.js +18 -0
  15. package/dist/react/FormStateContext.js.map +1 -1
  16. package/dist/react/fields/BuilderInput.js +16 -7
  17. package/dist/react/fields/BuilderInput.js.map +1 -1
  18. package/dist/react/fields/RepeaterInput.d.ts.map +1 -1
  19. package/dist/react/fields/RepeaterInput.js +38 -22
  20. package/dist/react/fields/RepeaterInput.js.map +1 -1
  21. package/dist/react/fields/relationshipRenameDispatch.d.ts +64 -0
  22. package/dist/react/fields/relationshipRenameDispatch.d.ts.map +1 -0
  23. package/dist/react/fields/relationshipRenameDispatch.js +76 -0
  24. package/dist/react/fields/relationshipRenameDispatch.js.map +1 -0
  25. package/dist/react/fields/rowChromeButton.d.ts +18 -5
  26. package/dist/react/fields/rowChromeButton.d.ts.map +1 -1
  27. package/dist/react/fields/rowChromeButton.js +15 -6
  28. package/dist/react/fields/rowChromeButton.js.map +1 -1
  29. package/dist/react/schemaRenderer/form/FormRenderer.d.ts.map +1 -1
  30. package/dist/react/schemaRenderer/form/FormRenderer.js +15 -0
  31. package/dist/react/schemaRenderer/form/FormRenderer.js.map +1 -1
  32. package/dist/routes/globals.d.ts.map +1 -1
  33. package/dist/routes/globals.js +1 -1
  34. package/dist/routes/globals.js.map +1 -1
  35. package/dist/routes/helpers.d.ts +12 -4
  36. package/dist/routes/helpers.d.ts.map +1 -1
  37. package/dist/routes/helpers.js +13 -4
  38. package/dist/routes/helpers.js.map +1 -1
  39. package/dist/routes/pages.d.ts.map +1 -1
  40. package/dist/routes/pages.js +1 -1
  41. package/dist/routes/pages.js.map +1 -1
  42. package/dist/routes/relations.d.ts.map +1 -1
  43. package/dist/routes/relations.js +4 -4
  44. package/dist/routes/relations.js.map +1 -1
  45. package/dist/routes/resources.d.ts.map +1 -1
  46. package/dist/routes/resources.js +5 -2
  47. package/dist/routes/resources.js.map +1 -1
  48. package/package.json +1 -1
  49. package/src/elements/dispatchForm.ts +65 -6
  50. package/src/fields/RepeaterRelationship.test.ts +120 -0
  51. package/src/index.ts +1 -0
  52. package/src/react/FormCollabBindingRegistry.ts +22 -0
  53. package/src/react/FormStateContext.tsx +19 -0
  54. package/src/react/fields/BuilderInput.tsx +22 -13
  55. package/src/react/fields/RepeaterInput.tsx +47 -26
  56. package/src/react/fields/relationshipRenameDispatch.test.ts +106 -0
  57. package/src/react/fields/relationshipRenameDispatch.ts +97 -0
  58. package/src/react/fields/rowChromeButton.tsx +19 -4
  59. package/src/react/schemaRenderer/form/FormRenderer.tsx +19 -0
  60. package/src/routes/globals.ts +3 -1
  61. package/src/routes/helpers.ts +15 -5
  62. package/src/routes/pages.ts +3 -1
  63. package/src/routes/relations.ts +12 -4
  64. package/src/routes/resources.ts +7 -2
@@ -19,6 +19,32 @@ import {
19
19
  } from '../orm/modelDefaults.js'
20
20
  import { resolveM2MAccessor } from '../orm/m2mAccessor.js'
21
21
 
22
+ /**
23
+ * Server-emitted rename of a `Repeater.relationship` / `Builder.relationship`
24
+ * row's stable id. When a brand-new row is submitted with a renderer-minted
25
+ * UUID `__id`, `persistRelationshipRows` calls `model.create(...)` and the
26
+ * DB assigns a real primary key — the row's identity then switches from
27
+ * the UUID to `String(pk)`. The submitter learns the new id from the
28
+ * reloaded form's `initialRows`; other collab peers don't, leaving their
29
+ * Y.Doc row state keyed by the orphan UUID. Phase B (see
30
+ * `pilotiq-pro/docs/plans/repeater-relationship-pk-switch.md`) lets a
31
+ * collab adapter subscribe to these renames from the form-submit JSON
32
+ * response and rename the row in the shared CRDT so other peers converge
33
+ * without reloading. Carries no opinion about transport — emitted unconditionally
34
+ * on every relationship-backed row create; consumers without a collab
35
+ * binding ignore the field.
36
+ */
37
+ export interface RelationshipRename {
38
+ /** Field name on the form (the `Repeater.make(...)` / `Builder.make(...)` name). */
39
+ field: string
40
+ /** The id the renderer submitted — usually a UUID, occasionally a numeric string
41
+ * when the consumer pre-assigned an id. May equal `new` when the consumer's
42
+ * pre-assigned id matched the DB-assigned PK; consumers can no-op in that case. */
43
+ old: string
44
+ /** The DB-assigned primary key, stringified. */
45
+ new: string
46
+ }
47
+
22
48
  export interface DispatchSuccess<R> {
23
49
  ok: true
24
50
  record: R
@@ -30,6 +56,12 @@ export interface DispatchSuccess<R> {
30
56
  * path drops them until a flash mechanism lands.
31
57
  */
32
58
  notifications: NotificationMeta[]
59
+ /**
60
+ * Per-row UUID → PK renames emitted by `Repeater.relationship` /
61
+ * `Builder.relationship` creates. Empty when the submitted form had
62
+ * no relationship-backed fields or no new rows. See {@link RelationshipRename}.
63
+ */
64
+ relationshipRenames: RelationshipRename[]
33
65
  }
34
66
 
35
67
  export interface DispatchFailure {
@@ -130,6 +162,7 @@ export async function dispatchFormSubmit<R = unknown>(
130
162
  // Persist the relationship-backed Repeater diffs against the saved
131
163
  // parent. Runs BEFORE `afterCreate / afterUpdate` so user hooks can
132
164
  // observe the fully-saved tree (parent + children).
165
+ const relationshipRenames: RelationshipRename[] = []
133
166
  if (relationshipDeferrals.length > 0 || builderRelationshipDeferrals.length > 0) {
134
167
  const parentModel = (ctx as { parentModel?: ModelLike }).parentModel
135
168
  if (!parentModel) {
@@ -139,10 +172,12 @@ export async function dispatchFormSubmit<R = unknown>(
139
172
  )
140
173
  }
141
174
  for (const deferral of relationshipDeferrals) {
142
- await persistRelationshipRows(record, deferral, parentModel)
175
+ const renames = await persistRelationshipRows(record, deferral, parentModel)
176
+ relationshipRenames.push(...renames)
143
177
  }
144
178
  for (const deferral of builderRelationshipDeferrals) {
145
- await persistRelationshipBuilderRows(record, deferral, parentModel)
179
+ const renames = await persistRelationshipBuilderRows(record, deferral, parentModel)
180
+ relationshipRenames.push(...renames)
146
181
  }
147
182
  }
148
183
 
@@ -163,7 +198,7 @@ export async function dispatchFormSubmit<R = unknown>(
163
198
  )
164
199
  const notifications = notification ? [notification] : []
165
200
 
166
- return { ok: true, record, redirect, notifications }
201
+ return { ok: true, record, redirect, notifications, relationshipRenames }
167
202
  }
168
203
 
169
204
  /**
@@ -1513,7 +1548,8 @@ async function persistRelationshipRows(
1513
1548
  parent: unknown,
1514
1549
  deferral: RelationshipDeferral,
1515
1550
  parentModel: ModelLike,
1516
- ): Promise<void> {
1551
+ ): Promise<RelationshipRename[]> {
1552
+ const renames: RelationshipRename[] = []
1517
1553
  const { rows, cfg, field } = deferral
1518
1554
  const attachment = resolveChildAndAttachment(parentModel, cfg)
1519
1555
  const { model } = attachment
@@ -1675,6 +1711,18 @@ async function persistRelationshipRows(
1675
1711
  }
1676
1712
  createdRecord = created
1677
1713
  }
1714
+ // Phase B PK-switch — emit the rename so a collab adapter can swap
1715
+ // the row's id in the shared CRDT. Skipped when the submitter didn't
1716
+ // pass an `__id` (rare: only happens when consumer code constructs
1717
+ // a row server-side); skipped when old === new (consumer pre-assigned
1718
+ // the DB PK on the row).
1719
+ const createdPk = (createdRecord as Record<string, unknown> | null | undefined)?.[pk]
1720
+ if (submittedId !== undefined && createdPk !== undefined && createdPk !== null) {
1721
+ const newId = String(createdPk)
1722
+ if (submittedId !== newId) {
1723
+ renames.push({ field: cfg.name, old: submittedId, new: newId })
1724
+ }
1725
+ }
1678
1726
  if (afterCreate) await afterCreate(createdRecord, buildRowCtx(idx))
1679
1727
  }
1680
1728
  }
@@ -1691,6 +1739,7 @@ async function persistRelationshipRows(
1691
1739
  }
1692
1740
  if (afterDelete) await afterDelete(removedRow, buildRowCtx(-1))
1693
1741
  }
1742
+ return renames
1694
1743
  }
1695
1744
 
1696
1745
  /**
@@ -1863,7 +1912,8 @@ async function persistRelationshipBuilderRows(
1863
1912
  parent: unknown,
1864
1913
  deferral: BuilderRelationshipDeferral,
1865
1914
  parentModel: ModelLike,
1866
- ): Promise<void> {
1915
+ ): Promise<RelationshipRename[]> {
1916
+ const renames: RelationshipRename[] = []
1867
1917
  const { rows, cfg } = deferral
1868
1918
  const attachment = resolveBuilderChildAndAttachment(parentModel, cfg)
1869
1919
  const { model } = attachment
@@ -1923,7 +1973,15 @@ async function persistRelationshipBuilderRows(
1923
1973
  } else {
1924
1974
  Object.assign(payload, morphStamp)
1925
1975
  }
1926
- await model.create(payload)
1976
+ const createdRecord = await model.create(payload)
1977
+ // Phase B PK-switch — see persistRelationshipRows for the contract.
1978
+ const createdPk = (createdRecord as Record<string, unknown> | null | undefined)?.[pk]
1979
+ if (submittedId !== undefined && createdPk !== undefined && createdPk !== null) {
1980
+ const newId = String(createdPk)
1981
+ if (submittedId !== newId) {
1982
+ renames.push({ field: cfg.name, old: submittedId, new: newId })
1983
+ }
1984
+ }
1927
1985
  }
1928
1986
  }
1929
1987
 
@@ -1931,4 +1989,5 @@ async function persistRelationshipBuilderRows(
1931
1989
  if (keptPks.has(pkVal)) continue
1932
1990
  await model.delete(pkVal)
1933
1991
  }
1992
+ return renames
1934
1993
  }
@@ -413,6 +413,126 @@ describe('Repeater.relationship — full pipeline', () => {
413
413
  })
414
414
  })
415
415
 
416
+ describe('Repeater.relationship — PK-switch renames (Phase B)', () => {
417
+ it('emits a rename for each create when submitted __id differs from new PK', async () => {
418
+ const child = makeFakeChildModel([])
419
+ const parent = makeFakeParentModel({
420
+ childModel: child.model,
421
+ childRows: child.rows,
422
+ relationName: 'items',
423
+ foreignKey: 'orderId',
424
+ })
425
+
426
+ const form = Form.make()
427
+ .schema([
428
+ RepeaterField.make('items').relationship('items').schema([
429
+ TextField.make('label').required(),
430
+ ]),
431
+ ])
432
+ .save(async () => ({ id: 'p1' }))
433
+
434
+ const result = await dispatchFormSubmit(
435
+ form,
436
+ // Renderer-minted UUIDs on the two new rows — Fake model assigns
437
+ // `c1` / `c2` so the post-save renames swap the UUIDs to those.
438
+ { items: [{ __id: 'uuid-A', label: 'A' }, { __id: 'uuid-B', label: 'B' }] },
439
+ {
440
+ values: { items: [{ __id: 'uuid-A', label: 'A' }, { __id: 'uuid-B', label: 'B' }] },
441
+ parentModel: parent,
442
+ },
443
+ )
444
+ assert.equal(result.ok, true)
445
+ if (!result.ok) return
446
+ assert.deepEqual(result.relationshipRenames, [
447
+ { field: 'items', old: 'uuid-A', new: 'c1' },
448
+ { field: 'items', old: 'uuid-B', new: 'c2' },
449
+ ])
450
+ })
451
+
452
+ it('skips renames for rows that resolve as updates (submitted __id matches existing PK)', async () => {
453
+ const child = makeFakeChildModel([
454
+ { id: 'c1', orderId: 'p1', label: 'old' },
455
+ ])
456
+ const parent = makeFakeParentModel({
457
+ childModel: child.model,
458
+ childRows: child.rows,
459
+ relationName: 'items',
460
+ foreignKey: 'orderId',
461
+ })
462
+
463
+ const form = Form.make()
464
+ .schema([
465
+ RepeaterField.make('items').relationship('items').schema([
466
+ TextField.make('label').required(),
467
+ ]),
468
+ ])
469
+ .save(async () => ({ id: 'p1' }))
470
+
471
+ const result = await dispatchFormSubmit(
472
+ form,
473
+ { items: [{ __id: 'c1', label: 'new' }, { __id: 'uuid-X', label: 'fresh' }] },
474
+ {
475
+ values: { items: [{ __id: 'c1', label: 'new' }, { __id: 'uuid-X', label: 'fresh' }] },
476
+ record: { id: 'p1' },
477
+ parentModel: parent,
478
+ },
479
+ )
480
+ assert.equal(result.ok, true)
481
+ if (!result.ok) return
482
+ // Update of c1 emits no rename; create from uuid-X resolves to a new id.
483
+ assert.equal(result.relationshipRenames.length, 1)
484
+ assert.equal(result.relationshipRenames[0]?.field, 'items')
485
+ assert.equal(result.relationshipRenames[0]?.old, 'uuid-X')
486
+ })
487
+
488
+ it('skips the rename when the consumer pre-assigned the DB PK', async () => {
489
+ const child = makeFakeChildModel([])
490
+ // Pre-assign id `c1` on the submitted row — the fake model honors data.id.
491
+ const parent = makeFakeParentModel({
492
+ childModel: child.model,
493
+ childRows: child.rows,
494
+ relationName: 'items',
495
+ foreignKey: 'orderId',
496
+ })
497
+
498
+ const form = Form.make()
499
+ .schema([
500
+ RepeaterField.make('items').relationship('items').schema([
501
+ TextField.make('label').required(),
502
+ ]),
503
+ ])
504
+ .save(async () => ({ id: 'p1' }))
505
+
506
+ const result = await dispatchFormSubmit(
507
+ form,
508
+ { items: [{ __id: 'c1', label: 'A', id: 'c1' }] },
509
+ {
510
+ values: { items: [{ __id: 'c1', label: 'A', id: 'c1' }] },
511
+ parentModel: parent,
512
+ },
513
+ )
514
+ assert.equal(result.ok, true)
515
+ if (!result.ok) return
516
+ // Submitted __id already matches the PK — no rename to emit.
517
+ assert.equal(result.relationshipRenames.length, 0)
518
+ })
519
+
520
+ it('returns an empty array on a parent save with no relationship fields', async () => {
521
+ const form = Form.make()
522
+ .schema([TextField.make('title')])
523
+ .save(async () => ({ id: 'p1' }))
524
+
525
+ const result = await dispatchFormSubmit(
526
+ form,
527
+ { title: 'Hello' },
528
+ { values: { title: 'Hello' } },
529
+ )
530
+ assert.equal(result.ok, true)
531
+ if (!result.ok) return
532
+ assert.deepEqual(result.relationshipRenames, [])
533
+ })
534
+ })
535
+
416
536
  describe('Repeater.relationship — load (applyRelationshipRepeaterFill)', () => {
417
537
  it('stamps __id from PK and strips PK + FK from each row', async () => {
418
538
  const child = makeFakeChildModel([
package/src/index.ts CHANGED
@@ -359,6 +359,7 @@ export {
359
359
  type DispatchResult,
360
360
  type DispatchSuccess,
361
361
  type DispatchFailure,
362
+ type RelationshipRename,
362
363
  } from './elements/dispatchForm.js'
363
364
  export {
364
365
  dispatchAction,
@@ -113,6 +113,28 @@ export interface FormCollabBinding {
113
113
  * Empty array when the binding has no state for `arrayName` yet.
114
114
  */
115
115
  getRowOrder?(arrayName: string): string[]
116
+
117
+ /**
118
+ * PK-switch Phase B — re-key a row from `oldId` to `newId` across the
119
+ * binding's row CRDT surface. Called by `FormRenderer` after a successful
120
+ * form submit that emitted `relationshipRenames` in the JSON response;
121
+ * the relevant `Repeater.relationship` / `Builder.relationship` field
122
+ * pre-assigned the submitter's UUID and the server returned the DB PK
123
+ * each row was actually persisted under. Renaming inside CRDT means
124
+ * other peers converge on the DB PK without reloading.
125
+ *
126
+ * Idempotent: bindings should no-op on `oldId === newId`, on unknown
127
+ * `oldId`, on unknown `arrayName`, AND on collisions where `newId`
128
+ * already exists (clobbering an unrelated row's content is worse than
129
+ * leaving the orphan UUID for the mount-time reconciler to drop).
130
+ *
131
+ * When absent, the dispatch step silently skips — the submitting
132
+ * peer's Phase A reconciler still drops orphans on next mount, but
133
+ * other peers stay on the UUID until they reload (the documented
134
+ * pre-Phase-B posture). See
135
+ * `pilotiq-pro/docs/plans/repeater-relationship-pk-switch.md`.
136
+ */
137
+ renameRow?(arrayName: string, oldId: string, newId: string): void
116
138
  }
117
139
 
118
140
  /**
@@ -25,6 +25,7 @@ import {
25
25
  type FormCollabBinding,
26
26
  type RowBindingApi,
27
27
  } from './FormCollabBindingRegistry.js'
28
+ import { registerRelationshipRenameHandler } from './fields/relationshipRenameDispatch.js'
28
29
 
29
30
  export type FieldStatus = 'idle' | 'pending'
30
31
 
@@ -311,8 +312,26 @@ export function FormStateProvider({
311
312
  })
312
313
  })
313
314
 
315
+ // PK-switch Phase B — register a per-formId rename handler so
316
+ // `FormRenderer`'s submit-success path can re-key newly persisted
317
+ // relationship rows on the CRDT without us having to expose the
318
+ // binding through React context (FormRenderer lives outside this
319
+ // provider). No-op when the active binding skipped the optional
320
+ // `renameRow` method; the documented fallback is the submitter-only
321
+ // Phase A reconciler (other peers reload to converge). See
322
+ // `pilotiq-pro/docs/plans/repeater-relationship-pk-switch.md`.
323
+ const renameRow = binding.renameRow
324
+ const unregisterRename = renameRow
325
+ ? registerRelationshipRenameHandler(formId, (renames) => {
326
+ for (const r of renames) {
327
+ renameRow.call(binding, r.field, r.old, r.new)
328
+ }
329
+ })
330
+ : () => {}
331
+
314
332
  return () => {
315
333
  unsubscribe()
334
+ unregisterRename()
316
335
  binding.destroy()
317
336
  bindingRef.current = null
318
337
  setRowBindings(null)
@@ -879,10 +879,10 @@ function BuilderRow({
879
879
  onClone: () => void
880
880
  onRemove: () => void
881
881
  onToggleCollapse: () => void
882
- onDragStart: (e: React.DragEvent<HTMLDivElement>) => void
883
- onDragOver: (e: React.DragEvent<HTMLDivElement>) => void
884
- onDrop: (e: React.DragEvent<HTMLDivElement>) => void
885
- onDragEnd: (e: React.DragEvent<HTMLDivElement>) => void
882
+ onDragStart: (e: React.DragEvent<HTMLElement>) => void
883
+ onDragOver: (e: React.DragEvent<HTMLElement>) => void
884
+ onDrop: (e: React.DragEvent<HTMLElement>) => void
885
+ onDragEnd: (e: React.DragEvent<HTMLElement>) => void
886
886
  }): React.ReactElement {
887
887
  // Inner inputs sit under `name.<i>.data.*` so the {type, data}
888
888
  // envelope round-trips through FormData. Hidden envelope inputs
@@ -942,28 +942,37 @@ function BuilderRow({
942
942
  const canClone = row.canClone !== false
943
943
  const canReorder = row.canReorder !== false
944
944
 
945
- const dragProps = reorderable && !buttonsOnly && !disabled && canReorder
945
+ // Drag source on the grip `<span>`, drop target on the row container.
946
+ // See RepeaterInput's RepeaterRow for the rationale (lets the row body
947
+ // host a Tiptap contenteditable without losing reorder).
948
+ const rowRef = useRef<HTMLDivElement>(null)
949
+ const dragEnabled = reorderable && !buttonsOnly && !disabled && canReorder
950
+ const containerDropTargetProps = dragEnabled
951
+ ? { onDragOver, onDrop, onDragEnd }
952
+ : {}
953
+ const gripDragHandleProps = dragEnabled
946
954
  ? {
947
- draggable: true as const,
948
- onDragStart,
949
- onDragOver,
950
- onDrop,
951
- onDragEnd,
955
+ draggable: true as const,
956
+ onDragStart: (e: React.DragEvent<HTMLElement>): void => {
957
+ if (rowRef.current) e.dataTransfer.setDragImage(rowRef.current, 0, 0)
958
+ onDragStart(e)
959
+ },
952
960
  }
953
- : {}
961
+ : undefined
954
962
 
955
963
  const innerColumns = block.columns && block.columns > 1 ? block.columns : 1
956
964
 
957
965
  return (
958
966
  <RowCoordsContext.Provider value={rowCoords}>
959
967
  <div
968
+ ref={rowRef}
960
969
  className={`rounded-md border bg-card transition-opacity ${isDragging ? 'opacity-50' : ''}`}
961
970
  data-pilotiq-builder-row=""
962
- {...dragProps}
971
+ {...containerDropTargetProps}
963
972
  >
964
973
  <div className="flex items-center gap-2 border-b px-3 py-2">
965
974
  {reorderable && !buttonsOnly && canReorder && (
966
- <ReorderGrip disabled={disabled} buttons={buttons} />
975
+ <ReorderGrip disabled={disabled} buttons={buttons} dragHandleProps={gripDragHandleProps} />
967
976
  )}
968
977
  {collapsible && (
969
978
  <CollapseChevron
@@ -1,4 +1,4 @@
1
- import React, { useContext, useEffect, useId, useMemo, useState } from 'react'
1
+ import React, { useContext, useEffect, useId, useMemo, useRef, useState } from 'react'
2
2
  import { PlusIcon } from 'lucide-react'
3
3
  import type { ElementMeta } from '../../schema/Element.js'
4
4
  import { Button } from '../ui/button.js'
@@ -811,21 +811,29 @@ function RepeaterRow({
811
811
  const canClone = row.canClone !== false
812
812
  const canReorder = row.canReorder !== false
813
813
 
814
- // Native HTML5 DnD only fires `dragstart` from elements with `draggable=true`.
815
- // We attach it at the row container so the grip handle (and the empty
816
- // header gutter, for forgiving aim) both initiate a drag. The handle's
817
- // visual cursor + aria-label tell users where the affordance lives.
818
- // Pinned rows (`canReorder === false`) lose drag-start; other rows can
819
- // still drop next to them see itemCanReorder docstring.
820
- const dragProps = reorderable && !disabled && canReorder
814
+ // Drag source lives on the grip `<span>` (see `ReorderGrip`). The
815
+ // row container is only the drop target `dragend` bubbles, so source
816
+ // cleanup still reaches it. Splitting source from target this way lets
817
+ // row contents host a Tiptap contenteditable without the editor's
818
+ // text-selection handler swallowing the row's dragstart.
819
+ // Pinned rows (`canReorder === false`) lose the grip; others can still
820
+ // accept drops see itemCanReorder docstring.
821
+ const rowRef = useRef<HTMLDivElement>(null)
822
+ const dragEnabled = reorderable && !disabled && canReorder
823
+ const containerDropTargetProps = dragEnabled
824
+ ? { onDragOver, onDrop, onDragEnd }
825
+ : {}
826
+ const gripDragHandleProps = dragEnabled
821
827
  ? {
822
- draggable: true as const,
823
- onDragStart,
824
- onDragOver,
825
- onDrop,
826
- onDragEnd,
828
+ draggable: true as const,
829
+ onDragStart: (e: React.DragEvent<HTMLElement>): void => {
830
+ // Use the row element as the drag preview so the user still
831
+ // sees the whole row floating, not just the grip icon.
832
+ if (rowRef.current) e.dataTransfer.setDragImage(rowRef.current, 0, 0)
833
+ onDragStart(e)
834
+ },
827
835
  }
828
- : {}
836
+ : undefined
829
837
 
830
838
  // Simple-mode: flatten the row to one input + inline action strip — no
831
839
  // header, no border, no collapse (a single field has nothing to collapse).
@@ -839,12 +847,15 @@ function RepeaterRow({
839
847
  return (
840
848
  <RowCoordsContext.Provider value={rowCoords}>
841
849
  <div
850
+ ref={rowRef}
842
851
  className={`flex items-center gap-2 transition-opacity ${isDragging ? 'opacity-50' : ''}`}
843
852
  data-pilotiq-repeater-row="simple"
844
- {...dragProps}
853
+ {...containerDropTargetProps}
845
854
  >
846
855
  <input type="hidden" name={`${prefix}.__id`} value={row.id} readOnly />
847
- {reorderable && canReorder && <ReorderGrip disabled={disabled} buttons={buttons} />}
856
+ {reorderable && canReorder && (
857
+ <ReorderGrip disabled={disabled} buttons={buttons} dragHandleProps={gripDragHandleProps} />
858
+ )}
848
859
  <div className="flex-1 [&_label]:sr-only">
849
860
  <SchemaRenderer elements={namespaced} />
850
861
  </div>
@@ -880,12 +891,15 @@ function RepeaterRow({
880
891
  return (
881
892
  <RowCoordsContext.Provider value={rowCoords}>
882
893
  <div
894
+ ref={rowRef}
883
895
  className={`rounded-md border bg-card transition-opacity ${isDragging ? 'opacity-50' : ''}`}
884
896
  data-pilotiq-repeater-row=""
885
- {...dragProps}
897
+ {...containerDropTargetProps}
886
898
  >
887
899
  <div className="flex items-center gap-2 border-b px-3 py-2">
888
- {reorderable && canReorder && <ReorderGrip disabled={disabled} buttons={buttons} />}
900
+ {reorderable && canReorder && (
901
+ <ReorderGrip disabled={disabled} buttons={buttons} dragHandleProps={gripDragHandleProps} />
902
+ )}
889
903
  {collapsible && (
890
904
  <CollapseChevron
891
905
  isCollapsed={isCollapsed}
@@ -1255,22 +1269,29 @@ function RepeaterTableRow({
1255
1269
  const canClone = row.canClone !== false
1256
1270
  const canReorder = row.canReorder !== false
1257
1271
 
1258
- const dragProps = reorderable && !disabled && canReorder
1272
+ // Drag source on the grip, drop target on the `<tr>` — see RepeaterRow.
1273
+ const rowRef = useRef<HTMLTableRowElement>(null)
1274
+ const dragEnabled = reorderable && !disabled && canReorder
1275
+ const containerDropTargetProps = dragEnabled
1276
+ ? { onDragOver, onDrop, onDragEnd }
1277
+ : {}
1278
+ const gripDragHandleProps = dragEnabled
1259
1279
  ? {
1260
1280
  draggable: true as const,
1261
- onDragStart,
1262
- onDragOver,
1263
- onDrop,
1264
- onDragEnd,
1281
+ onDragStart: (e: React.DragEvent<HTMLElement>): void => {
1282
+ if (rowRef.current) e.dataTransfer.setDragImage(rowRef.current, 0, 0)
1283
+ onDragStart(e)
1284
+ },
1265
1285
  }
1266
- : {}
1286
+ : undefined
1267
1287
 
1268
1288
  return (
1269
1289
  <RowCoordsContext.Provider value={rowCoords}>
1270
1290
  <tr
1291
+ ref={rowRef}
1271
1292
  className={`border-t align-top ${isDragging ? 'opacity-50' : ''}`}
1272
1293
  data-pilotiq-repeater-row=""
1273
- {...dragProps}
1294
+ {...containerDropTargetProps}
1274
1295
  >
1275
1296
  <input type="hidden" name={`${prefix}.__id`} value={row.id} readOnly />
1276
1297
  {columns.map((c, i) => (
@@ -1282,7 +1303,7 @@ function RepeaterTableRow({
1282
1303
  <div className="inline-flex items-center gap-1">
1283
1304
  {reorderable && canReorder && (
1284
1305
  <>
1285
- <ReorderGrip disabled={disabled} buttons={buttons} />
1306
+ <ReorderGrip disabled={disabled} buttons={buttons} dragHandleProps={gripDragHandleProps} />
1286
1307
  <RowChromeIconButton
1287
1308
  defaults={DEFAULT_MOVE_UP}
1288
1309
  override={buttons?.moveUp}
@@ -0,0 +1,106 @@
1
+ import { describe, it, beforeEach } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import {
5
+ applyRelationshipRenames,
6
+ registerRelationshipRenameHandler,
7
+ _resetRelationshipRenameRegistryForTests,
8
+ type RelationshipRenameEntry,
9
+ type RelationshipRenameHandler,
10
+ } from './relationshipRenameDispatch.js'
11
+
12
+ describe('relationshipRenameDispatch', () => {
13
+ beforeEach(() => {
14
+ _resetRelationshipRenameRegistryForTests()
15
+ })
16
+
17
+ it('routes a rename through the registered handler for its formId', () => {
18
+ const seen: ReadonlyArray<RelationshipRenameEntry>[] = []
19
+ registerRelationshipRenameHandler('form-1', (renames) => { seen.push(renames) })
20
+
21
+ applyRelationshipRenames('form-1', [
22
+ { field: 'comments', old: 'uuid-foo', new: '42' },
23
+ ])
24
+
25
+ assert.equal(seen.length, 1)
26
+ assert.deepEqual(seen[0], [{ field: 'comments', old: 'uuid-foo', new: '42' }])
27
+ })
28
+
29
+ it('isolates handlers across formIds — multi-form pages do not cross-fire', () => {
30
+ const a: RelationshipRenameEntry[][] = []
31
+ const b: RelationshipRenameEntry[][] = []
32
+ registerRelationshipRenameHandler('form-a', (r) => { a.push([...r]) })
33
+ registerRelationshipRenameHandler('form-b', (r) => { b.push([...r]) })
34
+
35
+ applyRelationshipRenames('form-a', [{ field: 'x', old: 'u', new: '1' }])
36
+
37
+ assert.equal(a.length, 1)
38
+ assert.equal(b.length, 0)
39
+ })
40
+
41
+ it('cleanup unregisters the handler', () => {
42
+ let calls = 0
43
+ const off = registerRelationshipRenameHandler('form-1', () => { calls += 1 })
44
+ off()
45
+ applyRelationshipRenames('form-1', [{ field: 'x', old: 'u', new: '1' }])
46
+ assert.equal(calls, 0)
47
+ })
48
+
49
+ it("cleanup does NOT wipe a handler that another caller replaced (StrictMode-safe)", () => {
50
+ // StrictMode dev double-mount: provider A mounts, registers fn1; React
51
+ // schedules cleanup; provider A's effect re-runs and registers fn2; THEN
52
+ // the cleanup of the first effect fires. fn2 must survive.
53
+ let calls1 = 0
54
+ let calls2 = 0
55
+ const fn1: RelationshipRenameHandler = () => { calls1 += 1 }
56
+ const fn2: RelationshipRenameHandler = () => { calls2 += 1 }
57
+
58
+ const off1 = registerRelationshipRenameHandler('form-1', fn1)
59
+ registerRelationshipRenameHandler('form-1', fn2)
60
+ off1()
61
+
62
+ applyRelationshipRenames('form-1', [{ field: 'x', old: 'u', new: '1' }])
63
+ assert.equal(calls1, 0)
64
+ assert.equal(calls2, 1, 'second registration survived the first cleanup')
65
+ })
66
+
67
+ it('apply with no handler registered is a silent no-op', () => {
68
+ // The success path always fires apply; consumers without a collab
69
+ // plugin shouldn't see any error.
70
+ assert.doesNotThrow(() => {
71
+ applyRelationshipRenames('form-unknown', [{ field: 'x', old: 'u', new: '1' }])
72
+ })
73
+ })
74
+
75
+ it('apply with empty or undefined rename list short-circuits', () => {
76
+ let calls = 0
77
+ registerRelationshipRenameHandler('form-1', () => { calls += 1 })
78
+
79
+ applyRelationshipRenames('form-1', [])
80
+ applyRelationshipRenames('form-1', undefined)
81
+
82
+ assert.equal(calls, 0)
83
+ })
84
+
85
+ it('apply with empty formId is a no-op', () => {
86
+ let calls = 0
87
+ registerRelationshipRenameHandler('', () => { calls += 1 })
88
+ applyRelationshipRenames('', [{ field: 'x', old: 'u', new: '1' }])
89
+ assert.equal(calls, 0)
90
+ })
91
+
92
+ it('register returns a stub cleanup when formId is empty (no crash)', () => {
93
+ const off = registerRelationshipRenameHandler('', () => {})
94
+ assert.doesNotThrow(off)
95
+ })
96
+
97
+ it('handler errors propagate so FormRenderer can surface a save-failed toast', () => {
98
+ registerRelationshipRenameHandler('form-1', () => {
99
+ throw new Error('binding wedged')
100
+ })
101
+ assert.throws(
102
+ () => applyRelationshipRenames('form-1', [{ field: 'x', old: 'u', new: '1' }]),
103
+ /binding wedged/,
104
+ )
105
+ })
106
+ })