@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.
- package/.turbo/turbo-build.log +2 -2
- package/CHANGELOG.md +28 -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/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/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
|
@@ -19,6 +19,32 @@ import {
|
|
|
19
19
|
} from '../orm/modelDefaults.js'
|
|
20
20
|
import { resolveM2MAccessor } from '../orm/m2mAccessor.js'
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Server-emitted rename of a `Repeater.relationship` / `Builder.relationship`
|
|
24
|
+
* row's stable id. When a brand-new row is submitted with a renderer-minted
|
|
25
|
+
* UUID `__id`, `persistRelationshipRows` calls `model.create(...)` and the
|
|
26
|
+
* DB assigns a real primary key — the row's identity then switches from
|
|
27
|
+
* the UUID to `String(pk)`. The submitter learns the new id from the
|
|
28
|
+
* reloaded form's `initialRows`; other collab peers don't, leaving their
|
|
29
|
+
* Y.Doc row state keyed by the orphan UUID. Phase B (see
|
|
30
|
+
* `pilotiq-pro/docs/plans/repeater-relationship-pk-switch.md`) lets a
|
|
31
|
+
* collab adapter subscribe to these renames from the form-submit JSON
|
|
32
|
+
* response and rename the row in the shared CRDT so other peers converge
|
|
33
|
+
* without reloading. Carries no opinion about transport — emitted unconditionally
|
|
34
|
+
* on every relationship-backed row create; consumers without a collab
|
|
35
|
+
* binding ignore the field.
|
|
36
|
+
*/
|
|
37
|
+
export interface RelationshipRename {
|
|
38
|
+
/** Field name on the form (the `Repeater.make(...)` / `Builder.make(...)` name). */
|
|
39
|
+
field: string
|
|
40
|
+
/** The id the renderer submitted — usually a UUID, occasionally a numeric string
|
|
41
|
+
* when the consumer pre-assigned an id. May equal `new` when the consumer's
|
|
42
|
+
* pre-assigned id matched the DB-assigned PK; consumers can no-op in that case. */
|
|
43
|
+
old: string
|
|
44
|
+
/** The DB-assigned primary key, stringified. */
|
|
45
|
+
new: string
|
|
46
|
+
}
|
|
47
|
+
|
|
22
48
|
export interface DispatchSuccess<R> {
|
|
23
49
|
ok: true
|
|
24
50
|
record: R
|
|
@@ -30,6 +56,12 @@ export interface DispatchSuccess<R> {
|
|
|
30
56
|
* path drops them until a flash mechanism lands.
|
|
31
57
|
*/
|
|
32
58
|
notifications: NotificationMeta[]
|
|
59
|
+
/**
|
|
60
|
+
* Per-row UUID → PK renames emitted by `Repeater.relationship` /
|
|
61
|
+
* `Builder.relationship` creates. Empty when the submitted form had
|
|
62
|
+
* no relationship-backed fields or no new rows. See {@link RelationshipRename}.
|
|
63
|
+
*/
|
|
64
|
+
relationshipRenames: RelationshipRename[]
|
|
33
65
|
}
|
|
34
66
|
|
|
35
67
|
export interface DispatchFailure {
|
|
@@ -130,6 +162,7 @@ export async function dispatchFormSubmit<R = unknown>(
|
|
|
130
162
|
// Persist the relationship-backed Repeater diffs against the saved
|
|
131
163
|
// parent. Runs BEFORE `afterCreate / afterUpdate` so user hooks can
|
|
132
164
|
// observe the fully-saved tree (parent + children).
|
|
165
|
+
const relationshipRenames: RelationshipRename[] = []
|
|
133
166
|
if (relationshipDeferrals.length > 0 || builderRelationshipDeferrals.length > 0) {
|
|
134
167
|
const parentModel = (ctx as { parentModel?: ModelLike }).parentModel
|
|
135
168
|
if (!parentModel) {
|
|
@@ -139,10 +172,12 @@ export async function dispatchFormSubmit<R = unknown>(
|
|
|
139
172
|
)
|
|
140
173
|
}
|
|
141
174
|
for (const deferral of relationshipDeferrals) {
|
|
142
|
-
await persistRelationshipRows(record, deferral, parentModel)
|
|
175
|
+
const renames = await persistRelationshipRows(record, deferral, parentModel)
|
|
176
|
+
relationshipRenames.push(...renames)
|
|
143
177
|
}
|
|
144
178
|
for (const deferral of builderRelationshipDeferrals) {
|
|
145
|
-
await persistRelationshipBuilderRows(record, deferral, parentModel)
|
|
179
|
+
const renames = await persistRelationshipBuilderRows(record, deferral, parentModel)
|
|
180
|
+
relationshipRenames.push(...renames)
|
|
146
181
|
}
|
|
147
182
|
}
|
|
148
183
|
|
|
@@ -163,7 +198,7 @@ export async function dispatchFormSubmit<R = unknown>(
|
|
|
163
198
|
)
|
|
164
199
|
const notifications = notification ? [notification] : []
|
|
165
200
|
|
|
166
|
-
return { ok: true, record, redirect, notifications }
|
|
201
|
+
return { ok: true, record, redirect, notifications, relationshipRenames }
|
|
167
202
|
}
|
|
168
203
|
|
|
169
204
|
/**
|
|
@@ -1513,7 +1548,8 @@ async function persistRelationshipRows(
|
|
|
1513
1548
|
parent: unknown,
|
|
1514
1549
|
deferral: RelationshipDeferral,
|
|
1515
1550
|
parentModel: ModelLike,
|
|
1516
|
-
): Promise<
|
|
1551
|
+
): Promise<RelationshipRename[]> {
|
|
1552
|
+
const renames: RelationshipRename[] = []
|
|
1517
1553
|
const { rows, cfg, field } = deferral
|
|
1518
1554
|
const attachment = resolveChildAndAttachment(parentModel, cfg)
|
|
1519
1555
|
const { model } = attachment
|
|
@@ -1675,6 +1711,18 @@ async function persistRelationshipRows(
|
|
|
1675
1711
|
}
|
|
1676
1712
|
createdRecord = created
|
|
1677
1713
|
}
|
|
1714
|
+
// Phase B PK-switch — emit the rename so a collab adapter can swap
|
|
1715
|
+
// the row's id in the shared CRDT. Skipped when the submitter didn't
|
|
1716
|
+
// pass an `__id` (rare: only happens when consumer code constructs
|
|
1717
|
+
// a row server-side); skipped when old === new (consumer pre-assigned
|
|
1718
|
+
// the DB PK on the row).
|
|
1719
|
+
const createdPk = (createdRecord as Record<string, unknown> | null | undefined)?.[pk]
|
|
1720
|
+
if (submittedId !== undefined && createdPk !== undefined && createdPk !== null) {
|
|
1721
|
+
const newId = String(createdPk)
|
|
1722
|
+
if (submittedId !== newId) {
|
|
1723
|
+
renames.push({ field: cfg.name, old: submittedId, new: newId })
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1678
1726
|
if (afterCreate) await afterCreate(createdRecord, buildRowCtx(idx))
|
|
1679
1727
|
}
|
|
1680
1728
|
}
|
|
@@ -1691,6 +1739,7 @@ async function persistRelationshipRows(
|
|
|
1691
1739
|
}
|
|
1692
1740
|
if (afterDelete) await afterDelete(removedRow, buildRowCtx(-1))
|
|
1693
1741
|
}
|
|
1742
|
+
return renames
|
|
1694
1743
|
}
|
|
1695
1744
|
|
|
1696
1745
|
/**
|
|
@@ -1863,7 +1912,8 @@ async function persistRelationshipBuilderRows(
|
|
|
1863
1912
|
parent: unknown,
|
|
1864
1913
|
deferral: BuilderRelationshipDeferral,
|
|
1865
1914
|
parentModel: ModelLike,
|
|
1866
|
-
): Promise<
|
|
1915
|
+
): Promise<RelationshipRename[]> {
|
|
1916
|
+
const renames: RelationshipRename[] = []
|
|
1867
1917
|
const { rows, cfg } = deferral
|
|
1868
1918
|
const attachment = resolveBuilderChildAndAttachment(parentModel, cfg)
|
|
1869
1919
|
const { model } = attachment
|
|
@@ -1923,7 +1973,15 @@ async function persistRelationshipBuilderRows(
|
|
|
1923
1973
|
} else {
|
|
1924
1974
|
Object.assign(payload, morphStamp)
|
|
1925
1975
|
}
|
|
1926
|
-
await model.create(payload)
|
|
1976
|
+
const createdRecord = await model.create(payload)
|
|
1977
|
+
// Phase B PK-switch — see persistRelationshipRows for the contract.
|
|
1978
|
+
const createdPk = (createdRecord as Record<string, unknown> | null | undefined)?.[pk]
|
|
1979
|
+
if (submittedId !== undefined && createdPk !== undefined && createdPk !== null) {
|
|
1980
|
+
const newId = String(createdPk)
|
|
1981
|
+
if (submittedId !== newId) {
|
|
1982
|
+
renames.push({ field: cfg.name, old: submittedId, new: newId })
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1927
1985
|
}
|
|
1928
1986
|
}
|
|
1929
1987
|
|
|
@@ -1931,4 +1989,5 @@ async function persistRelationshipBuilderRows(
|
|
|
1931
1989
|
if (keptPks.has(pkVal)) continue
|
|
1932
1990
|
await model.delete(pkVal)
|
|
1933
1991
|
}
|
|
1992
|
+
return renames
|
|
1934
1993
|
}
|
|
@@ -413,6 +413,126 @@ describe('Repeater.relationship — full pipeline', () => {
|
|
|
413
413
|
})
|
|
414
414
|
})
|
|
415
415
|
|
|
416
|
+
describe('Repeater.relationship — PK-switch renames (Phase B)', () => {
|
|
417
|
+
it('emits a rename for each create when submitted __id differs from new PK', async () => {
|
|
418
|
+
const child = makeFakeChildModel([])
|
|
419
|
+
const parent = makeFakeParentModel({
|
|
420
|
+
childModel: child.model,
|
|
421
|
+
childRows: child.rows,
|
|
422
|
+
relationName: 'items',
|
|
423
|
+
foreignKey: 'orderId',
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
const form = Form.make()
|
|
427
|
+
.schema([
|
|
428
|
+
RepeaterField.make('items').relationship('items').schema([
|
|
429
|
+
TextField.make('label').required(),
|
|
430
|
+
]),
|
|
431
|
+
])
|
|
432
|
+
.save(async () => ({ id: 'p1' }))
|
|
433
|
+
|
|
434
|
+
const result = await dispatchFormSubmit(
|
|
435
|
+
form,
|
|
436
|
+
// Renderer-minted UUIDs on the two new rows — Fake model assigns
|
|
437
|
+
// `c1` / `c2` so the post-save renames swap the UUIDs to those.
|
|
438
|
+
{ items: [{ __id: 'uuid-A', label: 'A' }, { __id: 'uuid-B', label: 'B' }] },
|
|
439
|
+
{
|
|
440
|
+
values: { items: [{ __id: 'uuid-A', label: 'A' }, { __id: 'uuid-B', label: 'B' }] },
|
|
441
|
+
parentModel: parent,
|
|
442
|
+
},
|
|
443
|
+
)
|
|
444
|
+
assert.equal(result.ok, true)
|
|
445
|
+
if (!result.ok) return
|
|
446
|
+
assert.deepEqual(result.relationshipRenames, [
|
|
447
|
+
{ field: 'items', old: 'uuid-A', new: 'c1' },
|
|
448
|
+
{ field: 'items', old: 'uuid-B', new: 'c2' },
|
|
449
|
+
])
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
it('skips renames for rows that resolve as updates (submitted __id matches existing PK)', async () => {
|
|
453
|
+
const child = makeFakeChildModel([
|
|
454
|
+
{ id: 'c1', orderId: 'p1', label: 'old' },
|
|
455
|
+
])
|
|
456
|
+
const parent = makeFakeParentModel({
|
|
457
|
+
childModel: child.model,
|
|
458
|
+
childRows: child.rows,
|
|
459
|
+
relationName: 'items',
|
|
460
|
+
foreignKey: 'orderId',
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
const form = Form.make()
|
|
464
|
+
.schema([
|
|
465
|
+
RepeaterField.make('items').relationship('items').schema([
|
|
466
|
+
TextField.make('label').required(),
|
|
467
|
+
]),
|
|
468
|
+
])
|
|
469
|
+
.save(async () => ({ id: 'p1' }))
|
|
470
|
+
|
|
471
|
+
const result = await dispatchFormSubmit(
|
|
472
|
+
form,
|
|
473
|
+
{ items: [{ __id: 'c1', label: 'new' }, { __id: 'uuid-X', label: 'fresh' }] },
|
|
474
|
+
{
|
|
475
|
+
values: { items: [{ __id: 'c1', label: 'new' }, { __id: 'uuid-X', label: 'fresh' }] },
|
|
476
|
+
record: { id: 'p1' },
|
|
477
|
+
parentModel: parent,
|
|
478
|
+
},
|
|
479
|
+
)
|
|
480
|
+
assert.equal(result.ok, true)
|
|
481
|
+
if (!result.ok) return
|
|
482
|
+
// Update of c1 emits no rename; create from uuid-X resolves to a new id.
|
|
483
|
+
assert.equal(result.relationshipRenames.length, 1)
|
|
484
|
+
assert.equal(result.relationshipRenames[0]?.field, 'items')
|
|
485
|
+
assert.equal(result.relationshipRenames[0]?.old, 'uuid-X')
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
it('skips the rename when the consumer pre-assigned the DB PK', async () => {
|
|
489
|
+
const child = makeFakeChildModel([])
|
|
490
|
+
// Pre-assign id `c1` on the submitted row — the fake model honors data.id.
|
|
491
|
+
const parent = makeFakeParentModel({
|
|
492
|
+
childModel: child.model,
|
|
493
|
+
childRows: child.rows,
|
|
494
|
+
relationName: 'items',
|
|
495
|
+
foreignKey: 'orderId',
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
const form = Form.make()
|
|
499
|
+
.schema([
|
|
500
|
+
RepeaterField.make('items').relationship('items').schema([
|
|
501
|
+
TextField.make('label').required(),
|
|
502
|
+
]),
|
|
503
|
+
])
|
|
504
|
+
.save(async () => ({ id: 'p1' }))
|
|
505
|
+
|
|
506
|
+
const result = await dispatchFormSubmit(
|
|
507
|
+
form,
|
|
508
|
+
{ items: [{ __id: 'c1', label: 'A', id: 'c1' }] },
|
|
509
|
+
{
|
|
510
|
+
values: { items: [{ __id: 'c1', label: 'A', id: 'c1' }] },
|
|
511
|
+
parentModel: parent,
|
|
512
|
+
},
|
|
513
|
+
)
|
|
514
|
+
assert.equal(result.ok, true)
|
|
515
|
+
if (!result.ok) return
|
|
516
|
+
// Submitted __id already matches the PK — no rename to emit.
|
|
517
|
+
assert.equal(result.relationshipRenames.length, 0)
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
it('returns an empty array on a parent save with no relationship fields', async () => {
|
|
521
|
+
const form = Form.make()
|
|
522
|
+
.schema([TextField.make('title')])
|
|
523
|
+
.save(async () => ({ id: 'p1' }))
|
|
524
|
+
|
|
525
|
+
const result = await dispatchFormSubmit(
|
|
526
|
+
form,
|
|
527
|
+
{ title: 'Hello' },
|
|
528
|
+
{ values: { title: 'Hello' } },
|
|
529
|
+
)
|
|
530
|
+
assert.equal(result.ok, true)
|
|
531
|
+
if (!result.ok) return
|
|
532
|
+
assert.deepEqual(result.relationshipRenames, [])
|
|
533
|
+
})
|
|
534
|
+
})
|
|
535
|
+
|
|
416
536
|
describe('Repeater.relationship — load (applyRelationshipRepeaterFill)', () => {
|
|
417
537
|
it('stamps __id from PK and strips PK + FK from each row', async () => {
|
|
418
538
|
const child = makeFakeChildModel([
|
package/src/index.ts
CHANGED
|
@@ -113,6 +113,28 @@ export interface FormCollabBinding {
|
|
|
113
113
|
* Empty array when the binding has no state for `arrayName` yet.
|
|
114
114
|
*/
|
|
115
115
|
getRowOrder?(arrayName: string): string[]
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* PK-switch Phase B — re-key a row from `oldId` to `newId` across the
|
|
119
|
+
* binding's row CRDT surface. Called by `FormRenderer` after a successful
|
|
120
|
+
* form submit that emitted `relationshipRenames` in the JSON response;
|
|
121
|
+
* the relevant `Repeater.relationship` / `Builder.relationship` field
|
|
122
|
+
* pre-assigned the submitter's UUID and the server returned the DB PK
|
|
123
|
+
* each row was actually persisted under. Renaming inside CRDT means
|
|
124
|
+
* other peers converge on the DB PK without reloading.
|
|
125
|
+
*
|
|
126
|
+
* Idempotent: bindings should no-op on `oldId === newId`, on unknown
|
|
127
|
+
* `oldId`, on unknown `arrayName`, AND on collisions where `newId`
|
|
128
|
+
* already exists (clobbering an unrelated row's content is worse than
|
|
129
|
+
* leaving the orphan UUID for the mount-time reconciler to drop).
|
|
130
|
+
*
|
|
131
|
+
* When absent, the dispatch step silently skips — the submitting
|
|
132
|
+
* peer's Phase A reconciler still drops orphans on next mount, but
|
|
133
|
+
* other peers stay on the UUID until they reload (the documented
|
|
134
|
+
* pre-Phase-B posture). See
|
|
135
|
+
* `pilotiq-pro/docs/plans/repeater-relationship-pk-switch.md`.
|
|
136
|
+
*/
|
|
137
|
+
renameRow?(arrayName: string, oldId: string, newId: string): void
|
|
116
138
|
}
|
|
117
139
|
|
|
118
140
|
/**
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
type FormCollabBinding,
|
|
26
26
|
type RowBindingApi,
|
|
27
27
|
} from './FormCollabBindingRegistry.js'
|
|
28
|
+
import { registerRelationshipRenameHandler } from './fields/relationshipRenameDispatch.js'
|
|
28
29
|
|
|
29
30
|
export type FieldStatus = 'idle' | 'pending'
|
|
30
31
|
|
|
@@ -311,8 +312,26 @@ export function FormStateProvider({
|
|
|
311
312
|
})
|
|
312
313
|
})
|
|
313
314
|
|
|
315
|
+
// PK-switch Phase B — register a per-formId rename handler so
|
|
316
|
+
// `FormRenderer`'s submit-success path can re-key newly persisted
|
|
317
|
+
// relationship rows on the CRDT without us having to expose the
|
|
318
|
+
// binding through React context (FormRenderer lives outside this
|
|
319
|
+
// provider). No-op when the active binding skipped the optional
|
|
320
|
+
// `renameRow` method; the documented fallback is the submitter-only
|
|
321
|
+
// Phase A reconciler (other peers reload to converge). See
|
|
322
|
+
// `pilotiq-pro/docs/plans/repeater-relationship-pk-switch.md`.
|
|
323
|
+
const renameRow = binding.renameRow
|
|
324
|
+
const unregisterRename = renameRow
|
|
325
|
+
? registerRelationshipRenameHandler(formId, (renames) => {
|
|
326
|
+
for (const r of renames) {
|
|
327
|
+
renameRow.call(binding, r.field, r.old, r.new)
|
|
328
|
+
}
|
|
329
|
+
})
|
|
330
|
+
: () => {}
|
|
331
|
+
|
|
314
332
|
return () => {
|
|
315
333
|
unsubscribe()
|
|
334
|
+
unregisterRename()
|
|
316
335
|
binding.destroy()
|
|
317
336
|
bindingRef.current = null
|
|
318
337
|
setRowBindings(null)
|
|
@@ -879,10 +879,10 @@ function BuilderRow({
|
|
|
879
879
|
onClone: () => void
|
|
880
880
|
onRemove: () => void
|
|
881
881
|
onToggleCollapse: () => void
|
|
882
|
-
onDragStart: (e: React.DragEvent<
|
|
883
|
-
onDragOver: (e: React.DragEvent<
|
|
884
|
-
onDrop: (e: React.DragEvent<
|
|
885
|
-
onDragEnd: (e: React.DragEvent<
|
|
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
|
-
|
|
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:
|
|
948
|
-
onDragStart
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
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
|
-
{...
|
|
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
|
-
//
|
|
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
|
+
})
|