@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.
- package/.turbo/turbo-build.log +2 -2
- package/CHANGELOG.md +34 -0
- package/dist/elements/dispatchForm.d.ts +31 -0
- package/dist/elements/dispatchForm.d.ts.map +1 -1
- package/dist/elements/dispatchForm.js +31 -4
- package/dist/elements/dispatchForm.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/react/FormCollabBindingRegistry.d.ts +21 -0
- package/dist/react/FormCollabBindingRegistry.d.ts.map +1 -1
- package/dist/react/FormCollabBindingRegistry.js.map +1 -1
- package/dist/react/FormStateContext.d.ts.map +1 -1
- package/dist/react/FormStateContext.js +18 -0
- package/dist/react/FormStateContext.js.map +1 -1
- package/dist/react/fields/BuilderInput.js +16 -7
- package/dist/react/fields/BuilderInput.js.map +1 -1
- package/dist/react/fields/MarkdownInput.d.ts.map +1 -1
- package/dist/react/fields/MarkdownInput.js +30 -29
- package/dist/react/fields/MarkdownInput.js.map +1 -1
- package/dist/react/fields/RepeaterInput.d.ts.map +1 -1
- package/dist/react/fields/RepeaterInput.js +38 -22
- package/dist/react/fields/RepeaterInput.js.map +1 -1
- package/dist/react/fields/relationshipRenameDispatch.d.ts +64 -0
- package/dist/react/fields/relationshipRenameDispatch.d.ts.map +1 -0
- package/dist/react/fields/relationshipRenameDispatch.js +76 -0
- package/dist/react/fields/relationshipRenameDispatch.js.map +1 -0
- package/dist/react/fields/rowChromeButton.d.ts +18 -5
- package/dist/react/fields/rowChromeButton.d.ts.map +1 -1
- package/dist/react/fields/rowChromeButton.js +15 -6
- package/dist/react/fields/rowChromeButton.js.map +1 -1
- package/dist/react/schemaRenderer/form/FormRenderer.d.ts.map +1 -1
- package/dist/react/schemaRenderer/form/FormRenderer.js +15 -0
- package/dist/react/schemaRenderer/form/FormRenderer.js.map +1 -1
- package/dist/routes/globals.d.ts.map +1 -1
- package/dist/routes/globals.js +1 -1
- package/dist/routes/globals.js.map +1 -1
- package/dist/routes/helpers.d.ts +12 -4
- package/dist/routes/helpers.d.ts.map +1 -1
- package/dist/routes/helpers.js +13 -4
- package/dist/routes/helpers.js.map +1 -1
- package/dist/routes/pages.d.ts.map +1 -1
- package/dist/routes/pages.js +1 -1
- package/dist/routes/pages.js.map +1 -1
- package/dist/routes/relations.d.ts.map +1 -1
- package/dist/routes/relations.js +4 -4
- package/dist/routes/relations.js.map +1 -1
- package/dist/routes/resources.d.ts.map +1 -1
- package/dist/routes/resources.js +5 -2
- package/dist/routes/resources.js.map +1 -1
- package/package.json +1 -1
- package/src/elements/dispatchForm.ts +65 -6
- package/src/fields/RepeaterRelationship.test.ts +120 -0
- package/src/index.ts +1 -0
- package/src/react/FormCollabBindingRegistry.ts +22 -0
- package/src/react/FormStateContext.tsx +19 -0
- package/src/react/fields/BuilderInput.tsx +22 -13
- package/src/react/fields/MarkdownInput.tsx +31 -23
- package/src/react/fields/RepeaterInput.tsx +47 -26
- package/src/react/fields/relationshipRenameDispatch.test.ts +106 -0
- package/src/react/fields/relationshipRenameDispatch.ts +97 -0
- package/src/react/fields/rowChromeButton.tsx +19 -4
- package/src/react/schemaRenderer/form/FormRenderer.tsx +19 -0
- package/src/routes/globals.ts +3 -1
- package/src/routes/helpers.ts +15 -5
- package/src/routes/pages.ts +3 -1
- package/src/routes/relations.ts +12 -4
- 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.
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
|
|
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
|
|
89
|
-
// y-prosemirror's `RelativePosition`
|
|
90
|
-
// `Y.XmlFragment` replaces whole-string LWW.
|
|
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
|
-
//
|
|
815
|
-
//
|
|
816
|
-
//
|
|
817
|
-
//
|
|
818
|
-
//
|
|
819
|
-
//
|
|
820
|
-
|
|
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:
|
|
823
|
-
onDragStart
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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
|
-
{...
|
|
853
|
+
{...containerDropTargetProps}
|
|
845
854
|
>
|
|
846
855
|
<input type="hidden" name={`${prefix}.__id`} value={row.id} readOnly />
|
|
847
|
-
{reorderable && canReorder &&
|
|
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
|
-
{...
|
|
897
|
+
{...containerDropTargetProps}
|
|
886
898
|
>
|
|
887
899
|
<div className="flex items-center gap-2 border-b px-3 py-2">
|
|
888
|
-
{reorderable && canReorder &&
|
|
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
|
-
|
|
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
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
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
|
-
{...
|
|
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
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
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
|
package/src/routes/globals.ts
CHANGED
|
@@ -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
|
|
package/src/routes/helpers.ts
CHANGED
|
@@ -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? }`)
|
|
109
|
-
* asked for JSON, or flashes notifications to the next
|
|
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)
|
package/src/routes/pages.ts
CHANGED
|
@@ -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
|
}
|
package/src/routes/relations.ts
CHANGED
|
@@ -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 ──
|
package/src/routes/resources.ts
CHANGED
|
@@ -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,
|
|
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
|