@pilotiq/pilotiq 0.15.1 → 0.17.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 (70) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/CHANGELOG.md +40 -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/index.d.ts +2 -1
  30. package/dist/react/index.d.ts.map +1 -1
  31. package/dist/react/index.js +2 -1
  32. package/dist/react/index.js.map +1 -1
  33. package/dist/react/schemaRenderer/form/FormRenderer.d.ts.map +1 -1
  34. package/dist/react/schemaRenderer/form/FormRenderer.js +15 -0
  35. package/dist/react/schemaRenderer/form/FormRenderer.js.map +1 -1
  36. package/dist/routes/globals.d.ts.map +1 -1
  37. package/dist/routes/globals.js +1 -1
  38. package/dist/routes/globals.js.map +1 -1
  39. package/dist/routes/helpers.d.ts +12 -4
  40. package/dist/routes/helpers.d.ts.map +1 -1
  41. package/dist/routes/helpers.js +13 -4
  42. package/dist/routes/helpers.js.map +1 -1
  43. package/dist/routes/pages.d.ts.map +1 -1
  44. package/dist/routes/pages.js +1 -1
  45. package/dist/routes/pages.js.map +1 -1
  46. package/dist/routes/relations.d.ts.map +1 -1
  47. package/dist/routes/relations.js +4 -4
  48. package/dist/routes/relations.js.map +1 -1
  49. package/dist/routes/resources.d.ts.map +1 -1
  50. package/dist/routes/resources.js +5 -2
  51. package/dist/routes/resources.js.map +1 -1
  52. package/package.json +1 -1
  53. package/src/elements/dispatchForm.ts +65 -6
  54. package/src/fields/RepeaterRelationship.test.ts +120 -0
  55. package/src/index.ts +1 -0
  56. package/src/pageData.test.ts +95 -0
  57. package/src/react/FormCollabBindingRegistry.ts +22 -0
  58. package/src/react/FormStateContext.tsx +19 -0
  59. package/src/react/fields/BuilderInput.tsx +22 -13
  60. package/src/react/fields/RepeaterInput.tsx +47 -26
  61. package/src/react/fields/relationshipRenameDispatch.test.ts +106 -0
  62. package/src/react/fields/relationshipRenameDispatch.ts +97 -0
  63. package/src/react/fields/rowChromeButton.tsx +19 -4
  64. package/src/react/index.ts +2 -1
  65. package/src/react/schemaRenderer/form/FormRenderer.tsx +19 -0
  66. package/src/routes/globals.ts +3 -1
  67. package/src/routes/helpers.ts +15 -5
  68. package/src/routes/pages.ts +3 -1
  69. package/src/routes/relations.ts +12 -4
  70. package/src/routes/resources.ts +7 -2
@@ -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
+ })
@@ -0,0 +1,97 @@
1
+ /**
2
+ * PK-switch Phase B — client-side dispatcher.
3
+ *
4
+ * The pilotiq server returns `relationshipRenames: { field, old, new }[]`
5
+ * in the JSON form-submit response whenever a `Repeater.relationship` /
6
+ * `Builder.relationship` create persisted under a DB-assigned PK that
7
+ * differs from the submitter's pre-assigned `__id` (see
8
+ * `pilotiq-pro/docs/plans/repeater-relationship-pk-switch.md`,
9
+ * `pilotiq/src/elements/dispatchForm.ts` for the wire shape).
10
+ *
11
+ * The renames need to land on the form's collab binding so other peers
12
+ * see the CRDT row re-keyed from UUID → PK without reloading. But the
13
+ * binding lives inside `FormStateProvider` (it owns `bindingRef`), while
14
+ * the JSON success path lives in `FormRenderer`'s `onSubmit` — a sibling
15
+ * component, not a context consumer. We bridge them with a per-`formId`
16
+ * module-level registry: `FormStateProvider` registers a handler when
17
+ * its binding mounts; `FormRenderer` dispatches against it after a
18
+ * successful submit.
19
+ *
20
+ * Pattern parallels `repeaterReconcile.ts`'s sessionStorage flag — same
21
+ * formId-keyed seam, different storage. SessionStorage was right for
22
+ * Phase A (the flag has to survive a navigation between submit and
23
+ * next mount); Phase B has to fire BEFORE the navigation, so a plain
24
+ * in-memory Map is the right shape (no SSR / cross-tab concerns).
25
+ *
26
+ * No-op when no handler is registered (consumer has no collab plugin,
27
+ * or the active binding doesn't implement `renameRow`).
28
+ */
29
+
30
+ /**
31
+ * One UUID → PK rename emitted by the server. Shape mirrors
32
+ * `pilotiq/src/elements/dispatchForm.ts` `RelationshipRename`; we
33
+ * duplicate the shape here so this module stays free of server-only
34
+ * imports (would otherwise pull the form-submit pipeline into the
35
+ * client bundle).
36
+ */
37
+ export interface RelationshipRenameEntry {
38
+ field: string
39
+ old: string
40
+ new: string
41
+ }
42
+
43
+ export type RelationshipRenameHandler = (
44
+ renames: ReadonlyArray<RelationshipRenameEntry>,
45
+ ) => void
46
+
47
+ const handlers = new Map<string, RelationshipRenameHandler>()
48
+
49
+ /**
50
+ * Called by `FormStateProvider` when its `FormCollabBinding` mounts AND
51
+ * implements `renameRow`. Returns the unsubscribe fn to call on
52
+ * unmount. Idempotent under re-registration on the same `formId` —
53
+ * later writers replace earlier handlers (Forms only mount one
54
+ * provider per id; a second mount means the first unmounted without
55
+ * firing its cleanup, which is acceptable to overwrite).
56
+ */
57
+ export function registerRelationshipRenameHandler(
58
+ formId: string,
59
+ fn: RelationshipRenameHandler,
60
+ ): () => void {
61
+ if (!formId) return () => {}
62
+ handlers.set(formId, fn)
63
+ return () => {
64
+ // Only clear when the current handler is still ours — protects
65
+ // against StrictMode dev double-mount where the cleanup of the
66
+ // first mount fires AFTER the second mount has installed its
67
+ // handler. Without this guard, the second mount's handler would
68
+ // be wiped before any submit completes.
69
+ if (handlers.get(formId) === fn) handlers.delete(formId)
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Called by `FormRenderer`'s `onSubmit` success path. Invokes the
75
+ * formId's registered handler with the rename list, or no-ops when
76
+ * either side is empty.
77
+ *
78
+ * Errors thrown by the handler propagate — pilotiq's submit path
79
+ * already catches in a `try`, so a misbehaving binding fails the
80
+ * navigate cleanly with a toast rather than wedging the form.
81
+ */
82
+ export function applyRelationshipRenames(
83
+ formId: string,
84
+ renames: ReadonlyArray<RelationshipRenameEntry> | undefined,
85
+ ): void {
86
+ if (!formId) return
87
+ if (!renames || renames.length === 0) return
88
+ const fn = handlers.get(formId)
89
+ if (!fn) return
90
+ fn(renames)
91
+ }
92
+
93
+ /** Test seam — drop every registered handler. Not exported from the
94
+ * react barrel. */
95
+ export function _resetRelationshipRenameRegistryForTests(): void {
96
+ handlers.clear()
97
+ }
@@ -131,17 +131,31 @@ export function RowChromeIconButton({
131
131
  }
132
132
 
133
133
  /**
134
- * Drag grip — a `<span>` not a `<button>`, since native HTML5 DnD only
135
- * fires `dragstart` on parents with `draggable=true`. Honors the
136
- * `reorderAction` customizer for icon / label / tooltip / color so users
137
- * can swap the glyph or copy without owning the drag wiring.
134
+ * Drag grip — a `<span>` not a `<button>`. Honors the `reorderAction`
135
+ * customizer for icon / label / tooltip / color so users can swap the
136
+ * glyph or copy without owning the drag wiring.
137
+ *
138
+ * When `dragHandleProps` is passed, the grip carries `draggable=true` +
139
+ * `onDragStart` and becomes the HTML5 drag source — required for row
140
+ * layouts whose body hosts a contenteditable (Tiptap-backed fields).
141
+ * If `draggable=true` lives on the row container instead, a dragstart
142
+ * that initiates over the contenteditable is absorbed by the text-
143
+ * selection handler and the row drag never fires. Moving the source to
144
+ * the grip sidesteps that. Drop-target handlers (`onDragOver/onDrop/
145
+ * onDragEnd`) stay on the row container — `dragend` bubbles so source-
146
+ * side cleanup still reaches it.
138
147
  */
139
148
  export function ReorderGrip({
140
149
  disabled,
141
150
  buttons,
151
+ dragHandleProps,
142
152
  }: {
143
153
  disabled: boolean
144
154
  buttons: RowButtonsMeta | undefined
155
+ dragHandleProps?: {
156
+ draggable: true
157
+ onDragStart: (e: React.DragEvent<HTMLElement>) => void
158
+ } | undefined
145
159
  }): React.ReactElement {
146
160
  const { Icon, label, tooltip, colorClass } = resolveRowChromeFor('reorder', DEFAULT_REORDER, buttons)
147
161
  return (
@@ -149,6 +163,7 @@ export function ReorderGrip({
149
163
  aria-label={label}
150
164
  title={tooltip}
151
165
  className={`${colorClass} ${disabled ? 'opacity-30' : 'cursor-grab active:cursor-grabbing'}`}
166
+ {...dragHandleProps}
152
167
  >
153
168
  <Icon className="size-4" />
154
169
  </span>
@@ -117,7 +117,8 @@ export {
117
117
  type UseFieldStateResult,
118
118
  } from './FormStateContext.js'
119
119
 
120
- export { parseFormDataToNested } from './formStateHelpers.js'
120
+ export { parseFormDataToNested, parseRowFieldPath, type ParsedRowFieldPath } from './formStateHelpers.js'
121
+ export { RowCoordsContext, useRowCoords, type RowCoords } from './RowCoordsContext.js'
121
122
 
122
123
  export { NavigateProvider, useNavigate, type NavigateFn } from './navigate.js'
123
124
 
@@ -7,6 +7,10 @@ import { getFormCollabBinding } from '../../FormCollabBindingRegistry.js'
7
7
  import { useNavigate } from '../../navigate.js'
8
8
  import { useToast } from '../../Toaster.js'
9
9
  import { markSubmitForReconcile } from '../../fields/repeaterReconcile.js'
10
+ import {
11
+ applyRelationshipRenames,
12
+ type RelationshipRenameEntry,
13
+ } from '../../fields/relationshipRenameDispatch.js'
10
14
  import { renderField } from './renderField.js'
11
15
 
12
16
  // ─── Form ───────────────────────────────────────────────────
@@ -113,6 +117,21 @@ export function FormRenderer({
113
117
 
114
118
  // Success — drain notifications and SPA-navigate to the redirect.
115
119
  //
120
+ // Apply PK-switch Phase B renames against the active form's collab
121
+ // binding FIRST (synchronous Yjs transact under the hood). The
122
+ // server emits `relationshipRenames: { field, old, new }[]` for
123
+ // every relationship-backed row that persisted under a new PK; we
124
+ // forward each through the formId-keyed dispatch so the binding's
125
+ // `renameRow` runs before the navigate destroys the provider that
126
+ // owns it. No-op when no binding is registered (no collab plugin)
127
+ // OR when the array is empty (no relationship-backed creates this
128
+ // submit). The submitter-only Phase A reconciler still fires below
129
+ // — Phase B closes the multi-peer gap WITHOUT obviating the
130
+ // single-peer cleanup path.
131
+ const renames = (data as { relationshipRenames?: RelationshipRenameEntry[] })
132
+ .relationshipRenames
133
+ applyRelationshipRenames(formId, renames)
134
+
116
135
  // Before navigating, mark this tab for the relationship-backed
117
136
  // Repeater/Builder PK-switch reconciler. The next mount of any
118
137
  // child Repeater/Builder under this formId will run a one-shot
@@ -137,7 +137,9 @@ export function registerGlobalRoutes(
137
137
  }
138
138
 
139
139
  const redirect = normalizeRedirect(result.redirect, base) ?? editUrl
140
- return sendRedirectResponse(req, res, json, redirect, result.notifications)
140
+ return sendRedirectResponse(req, res, json, redirect, result.notifications,
141
+ result.relationshipRenames.length > 0 ? { relationshipRenames: result.relationshipRenames } : undefined,
142
+ )
141
143
  })
142
144
  }
143
145
 
@@ -1,6 +1,7 @@
1
1
  import type { AppRequest, AppResponse } from '@rudderjs/contracts'
2
2
  import type { Pilotiq } from '../Pilotiq.js'
3
3
  import { findActions, findRowExtraActions, type DispatchActionResult } from '../elements/dispatchAction.js'
4
+ import type { RelationshipRename } from '../elements/dispatchForm.js'
4
5
  import { flashNotifications } from '../notifications/flash.js'
5
6
  import type { NotificationMeta } from '../notifications/Notification.js'
6
7
  import { findRecord, type ModelQuery } from '../orm/modelDefaults.js'
@@ -105,18 +106,24 @@ export function sendDownload(
105
106
  }
106
107
 
107
108
  /** Low-level success-or-redirect responder. Either emits a JSON envelope
108
- * (`{ ok: true, redirect, notifications?, force? }`) when the client
109
- * asked for JSON, or flashes notifications to the next request and
110
- * issues a 303 redirect. Shared by `sendActionResult`,
109
+ * (`{ ok: true, redirect, notifications?, force?, relationshipRenames? }`)
110
+ * when the client asked for JSON, or flashes notifications to the next
111
+ * request and issues a 303 redirect. Shared by `sendActionResult`,
111
112
  * `sendMutationSuccess`, and the form-submit success branches in
112
- * resources / globals / pages / relations. */
113
+ * resources / globals / pages / relations.
114
+ *
115
+ * `relationshipRenames` are emitted only on the JSON path — they're a
116
+ * collab-side concern (per-row UUID → PK renames from
117
+ * `Repeater.relationship` / `Builder.relationship` creates) consumed
118
+ * by client-side adapters that own a CRDT binding. The 303 path drops
119
+ * them silently; non-collab flows are unaffected. */
113
120
  export function sendRedirectResponse(
114
121
  req: AppRequest,
115
122
  res: AppResponse,
116
123
  json: boolean,
117
124
  redirect: string,
118
125
  notifications: ReadonlyArray<NotificationMeta> | undefined,
119
- extras?: { force?: boolean },
126
+ extras?: { force?: boolean; relationshipRenames?: ReadonlyArray<RelationshipRename> },
120
127
  ): unknown {
121
128
  if (json) {
122
129
  return res.json({
@@ -124,6 +131,9 @@ export function sendRedirectResponse(
124
131
  redirect,
125
132
  ...(extras?.force ? { force: true } : {}),
126
133
  ...(notifications && notifications.length > 0 ? { notifications } : {}),
134
+ ...(extras?.relationshipRenames && extras.relationshipRenames.length > 0
135
+ ? { relationshipRenames: extras.relationshipRenames }
136
+ : {}),
127
137
  })
128
138
  }
129
139
  flashNotifications(req, notifications)
@@ -168,6 +168,8 @@ export function registerCustomPageRoutes(
168
168
  }
169
169
 
170
170
  const redirect = normalizeRedirect(result.redirect, base) ?? pageUrl
171
- return sendRedirectResponse(req, res, json, redirect, result.notifications)
171
+ return sendRedirectResponse(req, res, json, redirect, result.notifications,
172
+ result.relationshipRenames.length > 0 ? { relationshipRenames: result.relationshipRenames } : undefined,
173
+ )
172
174
  })
173
175
  }
@@ -215,7 +215,9 @@ export function registerRelationRoutes(
215
215
  }
216
216
 
217
217
  const redirect = normalizeRedirect(result.redirect, base) ?? listUrl
218
- return sendRedirectResponse(req, res, json, redirect, result.notifications)
218
+ return sendRedirectResponse(req, res, json, redirect, result.notifications,
219
+ result.relationshipRenames.length > 0 ? { relationshipRenames: result.relationshipRenames } : undefined,
220
+ )
219
221
  })
220
222
 
221
223
  // View — GET ${resourceBase}/:id/${rel}/:childId (Phase A nested
@@ -332,7 +334,9 @@ export function registerRelationRoutes(
332
334
  }
333
335
 
334
336
  const redirect = normalizeRedirect(result.redirect, base) ?? editUrl
335
- return sendRedirectResponse(req, res, json, redirect, result.notifications)
337
+ return sendRedirectResponse(req, res, json, redirect, result.notifications,
338
+ result.relationshipRenames.length > 0 ? { relationshipRenames: result.relationshipRenames } : undefined,
339
+ )
336
340
  })
337
341
 
338
342
  // Delete — POST ${resourceBase}/:id/${rel}/:childId/delete
@@ -773,7 +777,9 @@ export function registerRelationRoutes(
773
777
  }
774
778
 
775
779
  const redirect = normalizeRedirect(result.redirect, base) ?? listUrl
776
- return sendRedirectResponse(req, res, json, redirect, result.notifications)
780
+ return sendRedirectResponse(req, res, json, redirect, result.notifications,
781
+ result.relationshipRenames.length > 0 ? { relationshipRenames: result.relationshipRenames } : undefined,
782
+ )
777
783
  })
778
784
 
779
785
  // ── View ──
@@ -902,7 +908,9 @@ export function registerRelationRoutes(
902
908
  }
903
909
 
904
910
  const redirect = normalizeRedirect(result.redirect, base) ?? editUrl
905
- return sendRedirectResponse(req, res, json, redirect, result.notifications)
911
+ return sendRedirectResponse(req, res, json, redirect, result.notifications,
912
+ result.relationshipRenames.length > 0 ? { relationshipRenames: result.relationshipRenames } : undefined,
913
+ )
906
914
  })
907
915
 
908
916
  // ── Delete ──
@@ -451,7 +451,10 @@ export function registerResourceRoutes(
451
451
  const redirect = continueCreate
452
452
  ? createUrl
453
453
  : normalizeRedirect(result.redirect, base) ?? fallback
454
- return sendRedirectResponse(req, res, json, redirect, result.notifications, continueCreate ? { force: true } : undefined)
454
+ return sendRedirectResponse(req, res, json, redirect, result.notifications, {
455
+ ...(continueCreate ? { force: true as const } : {}),
456
+ ...(result.relationshipRenames.length > 0 ? { relationshipRenames: result.relationshipRenames } : {}),
457
+ })
455
458
  })
456
459
 
457
460
  // Action dispatch — POST ${createUrl}/_action/:actionName
@@ -702,7 +705,9 @@ export function registerResourceRoutes(
702
705
  }
703
706
 
704
707
  const redirect = normalizeRedirect(result.redirect, base) ?? editUrl
705
- return sendRedirectResponse(req, res, json, redirect, result.notifications)
708
+ return sendRedirectResponse(req, res, json, redirect, result.notifications,
709
+ result.relationshipRenames.length > 0 ? { relationshipRenames: result.relationshipRenames } : undefined,
710
+ )
706
711
  })
707
712
 
708
713
  // Action dispatch — POST ${editUrl}/_action/:actionName