@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
@@ -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