@pilotiq/pilotiq 0.15.0 → 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 (68) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/CHANGELOG.md +34 -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/MarkdownInput.d.ts.map +1 -1
  19. package/dist/react/fields/MarkdownInput.js +30 -29
  20. package/dist/react/fields/MarkdownInput.js.map +1 -1
  21. package/dist/react/fields/RepeaterInput.d.ts.map +1 -1
  22. package/dist/react/fields/RepeaterInput.js +38 -22
  23. package/dist/react/fields/RepeaterInput.js.map +1 -1
  24. package/dist/react/fields/relationshipRenameDispatch.d.ts +64 -0
  25. package/dist/react/fields/relationshipRenameDispatch.d.ts.map +1 -0
  26. package/dist/react/fields/relationshipRenameDispatch.js +76 -0
  27. package/dist/react/fields/relationshipRenameDispatch.js.map +1 -0
  28. package/dist/react/fields/rowChromeButton.d.ts +18 -5
  29. package/dist/react/fields/rowChromeButton.d.ts.map +1 -1
  30. package/dist/react/fields/rowChromeButton.js +15 -6
  31. package/dist/react/fields/rowChromeButton.js.map +1 -1
  32. package/dist/react/schemaRenderer/form/FormRenderer.d.ts.map +1 -1
  33. package/dist/react/schemaRenderer/form/FormRenderer.js +15 -0
  34. package/dist/react/schemaRenderer/form/FormRenderer.js.map +1 -1
  35. package/dist/routes/globals.d.ts.map +1 -1
  36. package/dist/routes/globals.js +1 -1
  37. package/dist/routes/globals.js.map +1 -1
  38. package/dist/routes/helpers.d.ts +12 -4
  39. package/dist/routes/helpers.d.ts.map +1 -1
  40. package/dist/routes/helpers.js +13 -4
  41. package/dist/routes/helpers.js.map +1 -1
  42. package/dist/routes/pages.d.ts.map +1 -1
  43. package/dist/routes/pages.js +1 -1
  44. package/dist/routes/pages.js.map +1 -1
  45. package/dist/routes/relations.d.ts.map +1 -1
  46. package/dist/routes/relations.js +4 -4
  47. package/dist/routes/relations.js.map +1 -1
  48. package/dist/routes/resources.d.ts.map +1 -1
  49. package/dist/routes/resources.js +5 -2
  50. package/dist/routes/resources.js.map +1 -1
  51. package/package.json +1 -1
  52. package/src/elements/dispatchForm.ts +65 -6
  53. package/src/fields/RepeaterRelationship.test.ts +120 -0
  54. package/src/index.ts +1 -0
  55. package/src/react/FormCollabBindingRegistry.ts +22 -0
  56. package/src/react/FormStateContext.tsx +19 -0
  57. package/src/react/fields/BuilderInput.tsx +22 -13
  58. package/src/react/fields/MarkdownInput.tsx +31 -23
  59. package/src/react/fields/RepeaterInput.tsx +47 -26
  60. package/src/react/fields/relationshipRenameDispatch.test.ts +106 -0
  61. package/src/react/fields/relationshipRenameDispatch.ts +97 -0
  62. package/src/react/fields/rowChromeButton.tsx +19 -4
  63. package/src/react/schemaRenderer/form/FormRenderer.tsx +19 -0
  64. package/src/routes/globals.ts +3 -1
  65. package/src/routes/helpers.ts +15 -5
  66. package/src/routes/pages.ts +3 -1
  67. package/src/routes/relations.ts +12 -4
  68. package/src/routes/resources.ts +7 -2
@@ -56,21 +56,35 @@ export function MarkdownInput({
56
56
  const markdownEditor = getMarkdownEditor()
57
57
  const rowCoords = useRowCoords()
58
58
 
59
+ // Row-leaf fields ride a composite `arrayName.rowId.fieldName` key for
60
+ // collab anchoring so the Y.XmlFragment survives row reorders (the raw
61
+ // dotted `items.<index>.body` would follow the wrong row after a swap).
62
+ // Top-level fields pass through unchanged.
63
+ const fragmentKey: string | null = (() => {
64
+ if (!name.includes('.')) return name
65
+ if (!rowCoords) return null
66
+ const parsed = parseRowFieldPath(name)
67
+ if (!parsed) return null
68
+ if (parsed.arrayName !== rowCoords.arrayName) return null
69
+ if (parsed.index !== rowCoords.rowIndex) return null
70
+ return `${rowCoords.arrayName}.${rowCoords.rowId}.${parsed.fieldName}`
71
+ })()
72
+
59
73
  // Plug-supplied WYSIWYG markdown editor (typically `@pilotiq/tiptap`'s
60
74
  // Tiptap + tiptap-markdown integration). When registered, it replaces
61
75
  // BOTH the legacy non-collab textarea path AND the prior collab plain-text
62
76
  // path with a single rich editor that handles WYSIWYG editing, markdown
63
77
  // serialization, and collab binding (via its own `useCollabRoom()` read)
64
- // internally. Repeater/Builder row leaves still bypass it dotted-path
65
- // field names don't have a stable Y.XmlFragment key today; see
66
- // `MarkdownCollabInput` below for the prior row-aware path that stays as
67
- // the fallback inside Repeater/Builder rows.
68
- const isRowLeaf = name.includes('.')
69
- if (markdownEditor && !isRowLeaf) {
78
+ // internally. Row leaves pass the composite key as `collabKey` so the
79
+ // editor's collab factory anchors against the stable name while the
80
+ // hidden input + form-state keep using the original dotted path for
81
+ // submit routing.
82
+ if (markdownEditor && fragmentKey !== null) {
70
83
  return (
71
84
  <MarkdownEditorHost
72
85
  Editor={markdownEditor}
73
86
  name={name}
87
+ {...(fragmentKey !== name ? { collabKey: fragmentKey } : {})}
74
88
  defaultValue={defaultValue}
75
89
  disabled={disabled}
76
90
  {...(placeholder !== undefined ? { placeholder } : {})}
@@ -84,12 +98,10 @@ export function MarkdownInput({
84
98
  )
85
99
  }
86
100
 
87
- // Tiptap-backed plain-text editor for markdown source when collab is on.
88
- // Same architectural fix as `TextLikeInput`'s `CollabTextField`:
89
- // y-prosemirror's `RelativePosition` cursor anchoring against a
90
- // `Y.XmlFragment` replaces whole-string LWW. Row leaves get the
91
- // composite-key transform via `useRowCoords()` + `parseRowFieldPath`
92
- // (same shape as TextLikeInput) so the fragment survives row reorders.
101
+ // Tiptap-backed plain-text editor for markdown source when collab is on
102
+ // but no WYSIWYG adapter is registered. Same architectural fix as
103
+ // `TextLikeInput`'s `CollabTextField`: y-prosemirror's `RelativePosition`
104
+ // cursor anchoring against a `Y.XmlFragment` replaces whole-string LWW.
93
105
  //
94
106
  // Tradeoff: the markdown toolbar + Cmd-shortcuts + paste-image upload
95
107
  // all operate on a `<textarea>`'s DOM selection — they don't have a
@@ -98,15 +110,6 @@ export function MarkdownInput({
98
110
  // are write-mode-only on the native path; collab users type markdown
99
111
  // syntax directly (`**bold**`, `## heading`). The preview tab keeps
100
112
  // working since `MarkdownCollabInput` maintains a local mirror.
101
- const fragmentKey: string | null = (() => {
102
- if (!name.includes('.')) return name
103
- if (!rowCoords) return null
104
- const parsed = parseRowFieldPath(name)
105
- if (!parsed) return null
106
- if (parsed.arrayName !== rowCoords.arrayName) return null
107
- if (parsed.index !== rowCoords.rowIndex) return null
108
- return `${rowCoords.arrayName}.${rowCoords.rowId}.${parsed.fieldName}`
109
- })()
110
113
  if (room && collabRenderer && fragmentKey !== null) {
111
114
  return (
112
115
  <MarkdownCollabInput
@@ -498,12 +501,17 @@ function stringValue(v: unknown): string {
498
501
  * input so submit picks it up unchanged.
499
502
  */
500
503
  function MarkdownEditorHost({
501
- Editor, name, defaultValue, disabled, placeholder,
504
+ Editor, name, collabKey, defaultValue, disabled, placeholder,
502
505
  toolbarButtons, minHeight, maxHeight,
503
506
  fileAttachmentsDirectory, fileAttachmentsVisibility, uploadUrl,
504
507
  }: {
505
508
  Editor: MarkdownEditorComponent
506
509
  name: string
510
+ // Distinct from `name` only inside Repeater/Builder rows: the dotted
511
+ // form-input name (`items.0.body`) is unstable across reorders, so the
512
+ // editor's collab factory needs a row-id-anchored key. Defaults to
513
+ // `name` when unset (top-level fields).
514
+ collabKey?: string
507
515
  defaultValue: unknown
508
516
  disabled: boolean
509
517
  placeholder?: string
@@ -529,7 +537,7 @@ function MarkdownEditorHost({
529
537
  <>
530
538
  <input type="hidden" name={name} value={text} readOnly />
531
539
  <Editor
532
- name={name}
540
+ name={collabKey ?? name}
533
541
  defaultValue={initial}
534
542
  disabled={disabled}
535
543
  {...(placeholder !== undefined ? { placeholder } : {})}
@@ -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>
@@ -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