@pilotiq/pilotiq 0.6.2 → 0.7.1
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 +6 -2
- package/CHANGELOG.md +614 -0
- package/CLAUDE.md +6 -5
- package/dist/Column.d.ts +35 -0
- package/dist/Column.d.ts.map +1 -1
- package/dist/Column.js +41 -0
- package/dist/Column.js.map +1 -1
- package/dist/Page.d.ts +13 -4
- package/dist/Page.d.ts.map +1 -1
- package/dist/Page.js +9 -2
- package/dist/Page.js.map +1 -1
- package/dist/Pilotiq.d.ts +84 -0
- package/dist/Pilotiq.d.ts.map +1 -1
- package/dist/Pilotiq.js +66 -0
- package/dist/Pilotiq.js.map +1 -1
- package/dist/Resource.d.ts +26 -0
- package/dist/Resource.d.ts.map +1 -1
- package/dist/Resource.js +9 -0
- package/dist/Resource.js.map +1 -1
- package/dist/actions/exportFactory.js +1 -1
- package/dist/actions/exportFactory.js.map +1 -1
- package/dist/columns/SelectColumn.d.ts +32 -5
- package/dist/columns/SelectColumn.d.ts.map +1 -1
- package/dist/columns/SelectColumn.js +37 -7
- package/dist/columns/SelectColumn.js.map +1 -1
- package/dist/defaultPages.d.ts.map +1 -1
- package/dist/defaultPages.js +3 -0
- package/dist/defaultPages.js.map +1 -1
- package/dist/elements/Form.d.ts +17 -0
- package/dist/elements/Form.d.ts.map +1 -1
- package/dist/elements/Form.js +17 -0
- package/dist/elements/Form.js.map +1 -1
- package/dist/elements/Table.d.ts +26 -0
- package/dist/elements/Table.d.ts.map +1 -1
- package/dist/elements/Table.js +15 -1
- package/dist/elements/Table.js.map +1 -1
- package/dist/elements/TableGroup.d.ts +84 -0
- package/dist/elements/TableGroup.d.ts.map +1 -1
- package/dist/elements/TableGroup.js +103 -0
- package/dist/elements/TableGroup.js.map +1 -1
- package/dist/elements/dispatchForm.d.ts.map +1 -1
- package/dist/elements/dispatchForm.js +36 -6
- package/dist/elements/dispatchForm.js.map +1 -1
- package/dist/elements/dispatchTable.d.ts +12 -0
- package/dist/elements/dispatchTable.d.ts.map +1 -1
- package/dist/elements/dispatchTable.js +103 -28
- package/dist/elements/dispatchTable.js.map +1 -1
- package/dist/fields/Field.d.ts +7 -2
- package/dist/fields/Field.d.ts.map +1 -1
- package/dist/fields/Field.js +8 -3
- package/dist/fields/Field.js.map +1 -1
- package/dist/fields/RepeaterField.d.ts +65 -0
- package/dist/fields/RepeaterField.d.ts.map +1 -1
- package/dist/fields/RepeaterField.js +48 -0
- package/dist/fields/RepeaterField.js.map +1 -1
- package/dist/orm/modelDefaults.d.ts.map +1 -1
- package/dist/orm/modelDefaults.js +19 -0
- package/dist/orm/modelDefaults.js.map +1 -1
- package/dist/pageData.d.ts +20 -0
- package/dist/pageData.d.ts.map +1 -1
- package/dist/pageData.js +242 -34
- package/dist/pageData.js.map +1 -1
- package/dist/react/AppShell.d.ts +17 -1
- package/dist/react/AppShell.d.ts.map +1 -1
- package/dist/react/AppShell.js +34 -3
- package/dist/react/AppShell.js.map +1 -1
- package/dist/react/PendingSuggestionApplierRegistry.d.ts +34 -0
- package/dist/react/PendingSuggestionApplierRegistry.d.ts.map +1 -0
- package/dist/react/PendingSuggestionApplierRegistry.js +51 -0
- package/dist/react/PendingSuggestionApplierRegistry.js.map +1 -0
- package/dist/react/PendingSuggestionOverlayRegistry.d.ts +46 -0
- package/dist/react/PendingSuggestionOverlayRegistry.d.ts.map +1 -0
- package/dist/react/PendingSuggestionOverlayRegistry.js +16 -0
- package/dist/react/PendingSuggestionOverlayRegistry.js.map +1 -0
- package/dist/react/PendingSuggestionsContext.d.ts +153 -0
- package/dist/react/PendingSuggestionsContext.d.ts.map +1 -0
- package/dist/react/PendingSuggestionsContext.js +46 -0
- package/dist/react/PendingSuggestionsContext.js.map +1 -0
- package/dist/react/SchemaRenderer.d.ts.map +1 -1
- package/dist/react/SchemaRenderer.js +312 -39
- package/dist/react/SchemaRenderer.js.map +1 -1
- package/dist/react/cells/EditableCell.d.ts +8 -0
- package/dist/react/cells/EditableCell.d.ts.map +1 -1
- package/dist/react/cells/EditableCell.js +6 -2
- package/dist/react/cells/EditableCell.js.map +1 -1
- package/dist/react/fields/CheckboxListInput.d.ts.map +1 -1
- package/dist/react/fields/CheckboxListInput.js +29 -2
- package/dist/react/fields/CheckboxListInput.js.map +1 -1
- package/dist/react/fields/ColorInput.d.ts.map +1 -1
- package/dist/react/fields/ColorInput.js +28 -2
- package/dist/react/fields/ColorInput.js.map +1 -1
- package/dist/react/fields/DateTimeInput.d.ts.map +1 -1
- package/dist/react/fields/DateTimeInput.js +28 -2
- package/dist/react/fields/DateTimeInput.js.map +1 -1
- package/dist/react/fields/FieldShell.d.ts.map +1 -1
- package/dist/react/fields/FieldShell.js +161 -3
- package/dist/react/fields/FieldShell.js.map +1 -1
- package/dist/react/fields/FileUploadInput.d.ts.map +1 -1
- package/dist/react/fields/FileUploadInput.js +27 -2
- package/dist/react/fields/FileUploadInput.js.map +1 -1
- package/dist/react/fields/KeyValueInput.d.ts.map +1 -1
- package/dist/react/fields/KeyValueInput.js +33 -2
- package/dist/react/fields/KeyValueInput.js.map +1 -1
- package/dist/react/fields/RadioInput.d.ts.map +1 -1
- package/dist/react/fields/RadioInput.js +28 -2
- package/dist/react/fields/RadioInput.js.map +1 -1
- package/dist/react/fields/SelectFieldInput.d.ts.map +1 -1
- package/dist/react/fields/SelectFieldInput.js +31 -2
- package/dist/react/fields/SelectFieldInput.js.map +1 -1
- package/dist/react/fields/SliderInput.d.ts.map +1 -1
- package/dist/react/fields/SliderInput.js +26 -2
- package/dist/react/fields/SliderInput.js.map +1 -1
- package/dist/react/fields/TagsInput.d.ts.map +1 -1
- package/dist/react/fields/TagsInput.js +26 -2
- package/dist/react/fields/TagsInput.js.map +1 -1
- package/dist/react/fields/ToggleFieldInput.d.ts.map +1 -1
- package/dist/react/fields/ToggleFieldInput.js +29 -2
- package/dist/react/fields/ToggleFieldInput.js.map +1 -1
- package/dist/react/index.d.ts +3 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +3 -0
- package/dist/react/index.js.map +1 -1
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +55 -2
- package/dist/routes.js.map +1 -1
- package/dist/schema/Section.d.ts +16 -0
- package/dist/schema/Section.d.ts.map +1 -1
- package/dist/schema/Section.js +16 -0
- package/dist/schema/Section.js.map +1 -1
- package/dist/schema/Wizard.d.ts +45 -0
- package/dist/schema/Wizard.d.ts.map +1 -1
- package/dist/schema/Wizard.js +50 -0
- package/dist/schema/Wizard.js.map +1 -1
- package/dist/schema/resolveSchema.d.ts +8 -0
- package/dist/schema/resolveSchema.d.ts.map +1 -1
- package/dist/schema/resolveSchema.js +70 -1
- package/dist/schema/resolveSchema.js.map +1 -1
- package/dist/sessionFilters.d.ts.map +1 -1
- package/dist/sessionFilters.js +12 -1
- package/dist/sessionFilters.js.map +1 -1
- package/dist/styles/file-upload.css +13 -0
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +19 -12
- package/dist/vite.js.map +1 -1
- package/package.json +6 -4
- package/src/Column.test.ts +36 -0
- package/src/Column.ts +54 -0
- package/src/Page.ts +13 -4
- package/src/Pilotiq.ts +109 -0
- package/src/Resource.ts +29 -0
- package/src/actions/exportFactory.ts +1 -1
- package/src/columns/SelectColumn.ts +46 -8
- package/src/columns/editableColumns.test.ts +45 -0
- package/src/defaultPages.ts +3 -0
- package/src/elements/Form.ts +19 -0
- package/src/elements/Table.ts +35 -1
- package/src/elements/TableGroup.test.ts +111 -0
- package/src/elements/TableGroup.ts +135 -0
- package/src/elements/dispatchForm.ts +34 -7
- package/src/elements/dispatchTable.test.ts +267 -0
- package/src/elements/dispatchTable.ts +111 -32
- package/src/fields/Field.test.ts +15 -0
- package/src/fields/Field.ts +8 -3
- package/src/fields/RepeaterField.ts +104 -0
- package/src/fields/RepeaterRelationship.test.ts +173 -0
- package/src/nestedRelationManagerData.test.ts +21 -0
- package/src/orm/modelDefaults.ts +21 -0
- package/src/pageData.ts +267 -47
- package/src/react/AppShell.tsx +55 -4
- package/src/react/PendingSuggestionApplierRegistry.ts +80 -0
- package/src/react/PendingSuggestionOverlayRegistry.ts +54 -0
- package/src/react/PendingSuggestionsContext.tsx +172 -0
- package/src/react/SchemaRenderer.tsx +504 -95
- package/src/react/cells/EditableCell.tsx +11 -2
- package/src/react/fields/CheckboxListInput.tsx +23 -2
- package/src/react/fields/ColorInput.tsx +22 -2
- package/src/react/fields/DateTimeInput.tsx +22 -2
- package/src/react/fields/FieldShell.tsx +167 -3
- package/src/react/fields/FileUploadInput.tsx +21 -2
- package/src/react/fields/KeyValueInput.tsx +32 -2
- package/src/react/fields/RadioInput.tsx +23 -2
- package/src/react/fields/SelectFieldInput.tsx +25 -2
- package/src/react/fields/SliderInput.tsx +20 -2
- package/src/react/fields/TagsInput.tsx +20 -2
- package/src/react/fields/ToggleFieldInput.tsx +23 -2
- package/src/react/index.ts +18 -0
- package/src/relationManagerData.test.ts +451 -2
- package/src/routes.ts +58 -2
- package/src/schema/Section.ts +17 -0
- package/src/schema/Wizard.ts +67 -0
- package/src/schema/containers.test.ts +90 -0
- package/src/schema/resolveSchema.test.ts +50 -0
- package/src/schema/resolveSchema.ts +79 -1
- package/src/sessionFilters.test.ts +23 -0
- package/src/sessionFilters.ts +11 -1
- package/src/styles/file-upload.css +13 -0
- package/src/vite.ts +19 -12
|
@@ -51,6 +51,52 @@ export interface RepeaterRelationshipMeta {
|
|
|
51
51
|
orderColumn?: string
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Mode of the underlying relation, derived from the parent's `static
|
|
56
|
+
* relations` map at submit time. Surfaced on `RepeaterRowContext.mode`
|
|
57
|
+
* so per-row hooks can branch when needed (e.g. an `afterDelete` that
|
|
58
|
+
* runs cleanup only when the child record was actually removed, not
|
|
59
|
+
* when a pivot row was detached).
|
|
60
|
+
*/
|
|
61
|
+
export type RepeaterRelationMode =
|
|
62
|
+
| 'hasMany'
|
|
63
|
+
| 'morphMany'
|
|
64
|
+
| 'belongsToMany'
|
|
65
|
+
| 'morphToMany'
|
|
66
|
+
| 'morphedByMany'
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Context passed to `Repeater.afterCreate / afterUpdate / afterDelete`
|
|
70
|
+
* hooks. One invocation per row; `parent` is the post-save parent record
|
|
71
|
+
* (PK already set), `record` carries the persisted child.
|
|
72
|
+
*
|
|
73
|
+
* For M2M modes (`belongsToMany / morphToMany / morphedByMany`)
|
|
74
|
+
* `afterDelete` fires after `accessor.detach(...)` — the child record
|
|
75
|
+
* may still exist (other parents may link to it). Branch on `ctx.mode`
|
|
76
|
+
* if your cleanup depends on physical deletion vs detach.
|
|
77
|
+
*/
|
|
78
|
+
export interface RepeaterRowContext<P = unknown> {
|
|
79
|
+
/** Post-save parent record (the same `record` the surrounding form's
|
|
80
|
+
* `afterSave` would see). */
|
|
81
|
+
parent: P
|
|
82
|
+
/** Convenience — `parent[primaryKey]`. */
|
|
83
|
+
parentId: string | number
|
|
84
|
+
/** The Repeater field's `name`. */
|
|
85
|
+
field: string
|
|
86
|
+
/** 0-based index of the row in the submitted set; `-1` for `afterDelete`
|
|
87
|
+
* since deleted rows aren't in the submitted set. */
|
|
88
|
+
index: number
|
|
89
|
+
/** Underlying relation mode — see above for `afterDelete` semantics
|
|
90
|
+
* on M2M. */
|
|
91
|
+
mode: RepeaterRelationMode
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Handler signature shared by the three after-hooks. */
|
|
95
|
+
export type RepeaterRowAfterHandler<C = unknown, P = unknown> = (
|
|
96
|
+
record: C,
|
|
97
|
+
ctx: RepeaterRowContext<P>,
|
|
98
|
+
) => void | Promise<void>
|
|
99
|
+
|
|
54
100
|
/**
|
|
55
101
|
* Function evaluated once per row at meta-build to derive a human-readable
|
|
56
102
|
* label for the collapsed-row header. Called with the row's submitted values
|
|
@@ -295,6 +341,9 @@ export class RepeaterField extends Field {
|
|
|
295
341
|
private _tableColumns?: RepeaterTableColumn[]
|
|
296
342
|
private _buttons: { [K in RowButtonKind]?: RowButton } = {}
|
|
297
343
|
private _relationship?: RepeaterRelationshipConfig
|
|
344
|
+
private _afterCreate?: RepeaterRowAfterHandler
|
|
345
|
+
private _afterUpdate?: RepeaterRowAfterHandler
|
|
346
|
+
private _afterDelete?: RepeaterRowAfterHandler
|
|
298
347
|
|
|
299
348
|
private constructor(name: string) {
|
|
300
349
|
super(name, 'repeater')
|
|
@@ -560,6 +609,57 @@ export class RepeaterField extends Field {
|
|
|
560
609
|
return this
|
|
561
610
|
}
|
|
562
611
|
|
|
612
|
+
/**
|
|
613
|
+
* Per-row hook that fires after each newly created child record is
|
|
614
|
+
* persisted in `relationship()` mode. Receives the created record
|
|
615
|
+
* (with its primary key set) and a `RepeaterRowContext` carrying
|
|
616
|
+
* the parent record + row index + relation mode. Errors propagate
|
|
617
|
+
* — a throwing handler aborts the rest of the persist diff (the
|
|
618
|
+
* parent + any earlier rows have already saved; v1 is non-
|
|
619
|
+
* transactional).
|
|
620
|
+
*
|
|
621
|
+
* No-op outside `relationship()` mode. Use `Form.afterCreate(...)`
|
|
622
|
+
* for hooks that fire on the parent record's create.
|
|
623
|
+
*/
|
|
624
|
+
afterCreate(fn: RepeaterRowAfterHandler): this {
|
|
625
|
+
if (!this._relationship) {
|
|
626
|
+
throw new Error(
|
|
627
|
+
`[Pilotiq] Repeater "${this.name}": afterCreate() requires relationship() to be configured first.`,
|
|
628
|
+
)
|
|
629
|
+
}
|
|
630
|
+
this._afterCreate = fn
|
|
631
|
+
return this
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** Per-row hook that fires after each existing child record is
|
|
635
|
+
* updated. Receives the updated record from the child model's
|
|
636
|
+
* `update()` return (or the post-update reload when `update`
|
|
637
|
+
* returns void). See `afterCreate` notes for error semantics. */
|
|
638
|
+
afterUpdate(fn: RepeaterRowAfterHandler): this {
|
|
639
|
+
if (!this._relationship) {
|
|
640
|
+
throw new Error(
|
|
641
|
+
`[Pilotiq] Repeater "${this.name}": afterUpdate() requires relationship() to be configured first.`,
|
|
642
|
+
)
|
|
643
|
+
}
|
|
644
|
+
this._afterUpdate = fn
|
|
645
|
+
return this
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** Per-row hook that fires after each removed row is processed.
|
|
649
|
+
* For `hasMany / morphMany` modes the child record was physically
|
|
650
|
+
* deleted via `model.delete()`; for M2M modes only the pivot row
|
|
651
|
+
* was detached and the child may still exist. Branch on
|
|
652
|
+
* `ctx.mode` when that distinction matters. */
|
|
653
|
+
afterDelete(fn: RepeaterRowAfterHandler): this {
|
|
654
|
+
if (!this._relationship) {
|
|
655
|
+
throw new Error(
|
|
656
|
+
`[Pilotiq] Repeater "${this.name}": afterDelete() requires relationship() to be configured first.`,
|
|
657
|
+
)
|
|
658
|
+
}
|
|
659
|
+
this._afterDelete = fn
|
|
660
|
+
return this
|
|
661
|
+
}
|
|
662
|
+
|
|
563
663
|
/**
|
|
564
664
|
* Customize the bottom Add button's chrome (label / icon / color /
|
|
565
665
|
* tooltip). Equivalent to `addActionLabel()` plus icon + color
|
|
@@ -692,6 +792,10 @@ export class RepeaterField extends Field {
|
|
|
692
792
|
/** Resolved relationship config (`undefined` when not configured). */
|
|
693
793
|
getRelationship(): RepeaterRelationshipConfig | undefined { return this._relationship }
|
|
694
794
|
isRelationship(): boolean { return this._relationship !== undefined }
|
|
795
|
+
|
|
796
|
+
getAfterCreate(): RepeaterRowAfterHandler | undefined { return this._afterCreate }
|
|
797
|
+
getAfterUpdate(): RepeaterRowAfterHandler | undefined { return this._afterUpdate }
|
|
798
|
+
getAfterDelete(): RepeaterRowAfterHandler | undefined { return this._afterDelete }
|
|
695
799
|
/** The configured customizer for a given slot, or `undefined`. */
|
|
696
800
|
getButton(kind: RowButtonKind): RowButton | undefined { return this._buttons[kind] }
|
|
697
801
|
/**
|
|
@@ -1628,3 +1628,176 @@ describe('Repeater.relationship — pivotColumns', () => {
|
|
|
1628
1628
|
assert.deepEqual(rows, [])
|
|
1629
1629
|
})
|
|
1630
1630
|
})
|
|
1631
|
+
|
|
1632
|
+
describe('Repeater.relationship — afterCreate / afterUpdate / afterDelete hooks', () => {
|
|
1633
|
+
it('afterCreate fires once per created child with parent + index + mode in ctx', async () => {
|
|
1634
|
+
const child = makeFakeChildModel([])
|
|
1635
|
+
const parent = makeFakeParentModel({
|
|
1636
|
+
childModel: child.model,
|
|
1637
|
+
childRows: child.rows,
|
|
1638
|
+
relationName: 'items',
|
|
1639
|
+
foreignKey: 'orderId',
|
|
1640
|
+
})
|
|
1641
|
+
|
|
1642
|
+
const calls: Array<{ record: unknown; ctx: Record<string, unknown> }> = []
|
|
1643
|
+
const form = Form.make()
|
|
1644
|
+
.schema([
|
|
1645
|
+
RepeaterField.make('items')
|
|
1646
|
+
.relationship('items')
|
|
1647
|
+
.schema([TextField.make('label').required()])
|
|
1648
|
+
.afterCreate((record, ctx) => {
|
|
1649
|
+
calls.push({ record, ctx: { ...ctx } as Record<string, unknown> })
|
|
1650
|
+
}),
|
|
1651
|
+
])
|
|
1652
|
+
.save(async () => ({ id: 'p1', title: 'Order' }))
|
|
1653
|
+
|
|
1654
|
+
await dispatchFormSubmit(
|
|
1655
|
+
form,
|
|
1656
|
+
{ items: [{ label: 'A' }, { label: 'B' }] },
|
|
1657
|
+
{ values: { items: [{ label: 'A' }, { label: 'B' }] }, parentModel: parent },
|
|
1658
|
+
)
|
|
1659
|
+
|
|
1660
|
+
assert.equal(calls.length, 2)
|
|
1661
|
+
assert.equal((calls[0]!.record as Record<string, unknown>)['label'], 'A')
|
|
1662
|
+
assert.equal((calls[1]!.record as Record<string, unknown>)['label'], 'B')
|
|
1663
|
+
assert.equal(calls[0]!.ctx['index'], 0)
|
|
1664
|
+
assert.equal(calls[1]!.ctx['index'], 1)
|
|
1665
|
+
assert.equal(calls[0]!.ctx['field'], 'items')
|
|
1666
|
+
assert.equal(calls[0]!.ctx['mode'], 'hasMany')
|
|
1667
|
+
assert.equal(calls[0]!.ctx['parentId'], 'p1')
|
|
1668
|
+
assert.deepEqual(calls[0]!.ctx['parent'], { id: 'p1', title: 'Order' })
|
|
1669
|
+
})
|
|
1670
|
+
|
|
1671
|
+
it('afterUpdate fires per updated child (skipping pure-create rows)', async () => {
|
|
1672
|
+
const child = makeFakeChildModel([
|
|
1673
|
+
{ id: 'c1', orderId: 'p1', label: 'old A' },
|
|
1674
|
+
])
|
|
1675
|
+
const parent = makeFakeParentModel({
|
|
1676
|
+
childModel: child.model,
|
|
1677
|
+
childRows: child.rows,
|
|
1678
|
+
relationName: 'items',
|
|
1679
|
+
foreignKey: 'orderId',
|
|
1680
|
+
})
|
|
1681
|
+
|
|
1682
|
+
const updates: Array<{ record: unknown; index: number }> = []
|
|
1683
|
+
const creates: Array<{ record: unknown; index: number }> = []
|
|
1684
|
+
const form = Form.make()
|
|
1685
|
+
.schema([
|
|
1686
|
+
RepeaterField.make('items')
|
|
1687
|
+
.relationship('items')
|
|
1688
|
+
.schema([TextField.make('label').required()])
|
|
1689
|
+
.afterCreate((record, ctx) => { creates.push({ record, index: ctx.index }) })
|
|
1690
|
+
.afterUpdate((record, ctx) => { updates.push({ record, index: ctx.index }) }),
|
|
1691
|
+
])
|
|
1692
|
+
.save(async () => ({ id: 'p1' }))
|
|
1693
|
+
|
|
1694
|
+
await dispatchFormSubmit(
|
|
1695
|
+
form,
|
|
1696
|
+
{ items: [{ __id: 'c1', label: 'new A' }, { label: 'fresh B' }] },
|
|
1697
|
+
{
|
|
1698
|
+
values: { items: [{ __id: 'c1', label: 'new A' }, { label: 'fresh B' }] },
|
|
1699
|
+
record: { id: 'p1' },
|
|
1700
|
+
parentModel: parent,
|
|
1701
|
+
},
|
|
1702
|
+
)
|
|
1703
|
+
|
|
1704
|
+
assert.equal(updates.length, 1)
|
|
1705
|
+
assert.equal((updates[0]!.record as Record<string, unknown>)['label'], 'new A')
|
|
1706
|
+
assert.equal(updates[0]!.index, 0)
|
|
1707
|
+
assert.equal(creates.length, 1)
|
|
1708
|
+
assert.equal((creates[0]!.record as Record<string, unknown>)['label'], 'fresh B')
|
|
1709
|
+
assert.equal(creates[0]!.index, 1)
|
|
1710
|
+
})
|
|
1711
|
+
|
|
1712
|
+
it('afterDelete fires once per removed child with the previous row data', async () => {
|
|
1713
|
+
const child = makeFakeChildModel([
|
|
1714
|
+
{ id: 'c1', orderId: 'p1', label: 'A' },
|
|
1715
|
+
{ id: 'c2', orderId: 'p1', label: 'B' },
|
|
1716
|
+
{ id: 'c3', orderId: 'p1', label: 'C' },
|
|
1717
|
+
])
|
|
1718
|
+
const parent = makeFakeParentModel({
|
|
1719
|
+
childModel: child.model,
|
|
1720
|
+
childRows: child.rows,
|
|
1721
|
+
relationName: 'items',
|
|
1722
|
+
foreignKey: 'orderId',
|
|
1723
|
+
})
|
|
1724
|
+
|
|
1725
|
+
const removed: Array<{ record: unknown; ctx: Record<string, unknown> }> = []
|
|
1726
|
+
const form = Form.make()
|
|
1727
|
+
.schema([
|
|
1728
|
+
RepeaterField.make('items')
|
|
1729
|
+
.relationship('items')
|
|
1730
|
+
.schema([TextField.make('label').required()])
|
|
1731
|
+
.afterDelete((record, ctx) => {
|
|
1732
|
+
removed.push({ record, ctx: { ...ctx } as Record<string, unknown> })
|
|
1733
|
+
}),
|
|
1734
|
+
])
|
|
1735
|
+
.save(async () => ({ id: 'p1' }))
|
|
1736
|
+
|
|
1737
|
+
// Submit only c1 — c2 and c3 disappear.
|
|
1738
|
+
await dispatchFormSubmit(
|
|
1739
|
+
form,
|
|
1740
|
+
{ items: [{ __id: 'c1', label: 'A' }] },
|
|
1741
|
+
{
|
|
1742
|
+
values: { items: [{ __id: 'c1', label: 'A' }] },
|
|
1743
|
+
record: { id: 'p1' },
|
|
1744
|
+
parentModel: parent,
|
|
1745
|
+
},
|
|
1746
|
+
)
|
|
1747
|
+
|
|
1748
|
+
assert.equal(removed.length, 2)
|
|
1749
|
+
const labels = removed.map(r => (r.record as Record<string, unknown>)['label']).sort()
|
|
1750
|
+
assert.deepEqual(labels, ['B', 'C'])
|
|
1751
|
+
assert.equal(removed[0]!.ctx['index'], -1)
|
|
1752
|
+
assert.equal(removed[0]!.ctx['mode'], 'hasMany')
|
|
1753
|
+
assert.equal(removed[0]!.ctx['parentId'], 'p1')
|
|
1754
|
+
})
|
|
1755
|
+
|
|
1756
|
+
it('hooks are no-op outside relationship() mode (throw at config time)', () => {
|
|
1757
|
+
assert.throws(() =>
|
|
1758
|
+
RepeaterField.make('json').afterCreate(() => {}),
|
|
1759
|
+
/requires relationship/,
|
|
1760
|
+
)
|
|
1761
|
+
assert.throws(() =>
|
|
1762
|
+
RepeaterField.make('json').afterUpdate(() => {}),
|
|
1763
|
+
/requires relationship/,
|
|
1764
|
+
)
|
|
1765
|
+
assert.throws(() =>
|
|
1766
|
+
RepeaterField.make('json').afterDelete(() => {}),
|
|
1767
|
+
/requires relationship/,
|
|
1768
|
+
)
|
|
1769
|
+
})
|
|
1770
|
+
|
|
1771
|
+
it('throwing handler propagates and aborts the rest of the persist diff', async () => {
|
|
1772
|
+
const child = makeFakeChildModel([])
|
|
1773
|
+
const parent = makeFakeParentModel({
|
|
1774
|
+
childModel: child.model,
|
|
1775
|
+
childRows: child.rows,
|
|
1776
|
+
relationName: 'items',
|
|
1777
|
+
foreignKey: 'orderId',
|
|
1778
|
+
})
|
|
1779
|
+
|
|
1780
|
+
const form = Form.make()
|
|
1781
|
+
.schema([
|
|
1782
|
+
RepeaterField.make('items')
|
|
1783
|
+
.relationship('items')
|
|
1784
|
+
.schema([TextField.make('label').required()])
|
|
1785
|
+
.afterCreate((record) => {
|
|
1786
|
+
const r = record as Record<string, unknown>
|
|
1787
|
+
if (r['label'] === 'B') throw new Error('reject B')
|
|
1788
|
+
}),
|
|
1789
|
+
])
|
|
1790
|
+
.save(async () => ({ id: 'p1' }))
|
|
1791
|
+
|
|
1792
|
+
await assert.rejects(
|
|
1793
|
+
() => dispatchFormSubmit(
|
|
1794
|
+
form,
|
|
1795
|
+
{ items: [{ label: 'A' }, { label: 'B' }, { label: 'C' }] },
|
|
1796
|
+
{ values: { items: [{ label: 'A' }, { label: 'B' }, { label: 'C' }] }, parentModel: parent },
|
|
1797
|
+
),
|
|
1798
|
+
/reject B/,
|
|
1799
|
+
)
|
|
1800
|
+
// Two creates fired before the throw — no rollback (v1 isn't transactional).
|
|
1801
|
+
assert.equal(child.calls.filter(c => c.kind === 'create').length, 2)
|
|
1802
|
+
})
|
|
1803
|
+
})
|
|
@@ -446,6 +446,27 @@ describe('Phase A relation-view — surfaces nested-manager tabs (Phase B polish
|
|
|
446
446
|
const strips = schema.filter(s => s['type'] === 'relation-tabs')
|
|
447
447
|
assert.equal(strips.length, 1, 'expected only the post-scope strip; the comment-scope strip should be absent')
|
|
448
448
|
})
|
|
449
|
+
|
|
450
|
+
it('hides a nested sibling tab when N.canViewAny returns false', async () => {
|
|
451
|
+
const { panel } = buildNestedWorld({
|
|
452
|
+
nestedOverrides: {
|
|
453
|
+
async canViewAny() { return false },
|
|
454
|
+
} as unknown as Partial<typeof RelationManager>,
|
|
455
|
+
})
|
|
456
|
+
const out = await relationManagerData(panel, {
|
|
457
|
+
kind: 'relation-view', slug: 'posts',
|
|
458
|
+
recordId: 'po1',
|
|
459
|
+
relationship:'comments',
|
|
460
|
+
childId: 'c1',
|
|
461
|
+
})
|
|
462
|
+
const schema = (out as Record<string, unknown>)['schemaData'] as Array<Record<string, unknown>>
|
|
463
|
+
const strips = schema.filter(s => s['type'] === 'relation-tabs') as Array<Record<string, unknown>>
|
|
464
|
+
// Post-scope strip still here; comment-scope strip is gone because
|
|
465
|
+
// the only nested manager (CommentRepliesManager) was gated away,
|
|
466
|
+
// collapsing the strip to just the back-link `__view` — under the
|
|
467
|
+
// empty-strip drop threshold.
|
|
468
|
+
assert.equal(strips.length, 1, 'expected only the post-scope strip after sibling gating')
|
|
469
|
+
})
|
|
449
470
|
})
|
|
450
471
|
|
|
451
472
|
describe('nestedRelationManagerData (Phase B) — RelationTabs strip', () => {
|
package/src/orm/modelDefaults.ts
CHANGED
|
@@ -298,6 +298,19 @@ export function modelTableRecords(R: ResourceLike, table: Table): TableRecordsHa
|
|
|
298
298
|
// and lets a tab's narrower `where` win on collision.
|
|
299
299
|
if (ctx.tabQuery) q = ctx.tabQuery(q)
|
|
300
300
|
|
|
301
|
+
// Apply group drill-in scoper when the user clicked a banded heading.
|
|
302
|
+
// Runs after filters + tab so the bucket narrowing composes on top of
|
|
303
|
+
// whatever scope the page already had. User-supplied
|
|
304
|
+
// `TableGroup.scopeQueryByKey(fn)` wins over the framework default
|
|
305
|
+
// (exact-match `where(column, '=', key)` / whole-day range for date
|
|
306
|
+
// groups); throwing scopers propagate so a config bug surfaces loudly
|
|
307
|
+
// rather than silently rendering every row.
|
|
308
|
+
if (ctx.groupScope) {
|
|
309
|
+
const scope = ctx.groupScope
|
|
310
|
+
const scoper = scope.group.resolveScoper<ModelQuery>()
|
|
311
|
+
q = scoper(q, scope.key)
|
|
312
|
+
}
|
|
313
|
+
|
|
301
314
|
if (ctx.sort) {
|
|
302
315
|
q = q.orderBy(ctx.sort.column, ctx.sort.direction === 'desc' ? 'DESC' : 'ASC')
|
|
303
316
|
}
|
|
@@ -619,6 +632,14 @@ export function modelRelationTableRecords(
|
|
|
619
632
|
|
|
620
633
|
if (ctx.tabQuery) q = ctx.tabQuery(q)
|
|
621
634
|
|
|
635
|
+
// Group drill-in scoper — same shape as `modelTableRecords`. Composes
|
|
636
|
+
// with `relatedQuery` since the scoper just chains another `where`.
|
|
637
|
+
if (ctx.groupScope) {
|
|
638
|
+
const scope = ctx.groupScope
|
|
639
|
+
const scoper = scope.group.resolveScoper<ModelQuery>()
|
|
640
|
+
q = scoper(q, scope.key)
|
|
641
|
+
}
|
|
642
|
+
|
|
622
643
|
if (ctx.sort) {
|
|
623
644
|
q = q.orderBy(ctx.sort.column, ctx.sort.direction === 'desc' ? 'DESC' : 'ASC')
|
|
624
645
|
}
|