@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
|
@@ -6,10 +6,25 @@ export type SelectColumnOptionsInput =
|
|
|
6
6
|
| Record<string, string>
|
|
7
7
|
| Array<ColumnSelectOption>
|
|
8
8
|
|
|
9
|
+
/** Per-row resolver — runs once per visible row at table-data time and
|
|
10
|
+
* stamps the resolved options on the row under `_cellSelectOptions[col]`
|
|
11
|
+
* so the inline `<select>` shows row-specific values (e.g. assignee
|
|
12
|
+
* candidates filtered by record team). May return a Promise. */
|
|
13
|
+
export type SelectColumnOptionsResolver =
|
|
14
|
+
(record: unknown) => SelectColumnOptionsInput | Promise<SelectColumnOptionsInput>
|
|
15
|
+
|
|
16
|
+
/** Normalize either input shape to the canonical array form. Shared
|
|
17
|
+
* between the static `.options()` setter and the per-row resolver path
|
|
18
|
+
* inside `dispatchTable.ts`. */
|
|
19
|
+
export function normalizeSelectOptions(opts: SelectColumnOptionsInput): ColumnSelectOption[] {
|
|
20
|
+
return Array.isArray(opts)
|
|
21
|
+
? opts.map(o => ({ value: o.value, label: o.label }))
|
|
22
|
+
: Object.entries(opts).map(([value, label]) => ({ value, label }))
|
|
23
|
+
}
|
|
24
|
+
|
|
9
25
|
/**
|
|
10
26
|
* Inline-edit select. Renders a `<select>` in the cell; each change
|
|
11
|
-
* fires an immediate PATCH.
|
|
12
|
-
* resolvers are deferred until a consumer hits the case.
|
|
27
|
+
* fires an immediate PATCH.
|
|
13
28
|
*
|
|
14
29
|
* SelectColumn.make('status')
|
|
15
30
|
* .options({ draft: 'Draft', published: 'Published', archived: 'Archived' })
|
|
@@ -17,9 +32,24 @@ export type SelectColumnOptionsInput =
|
|
|
17
32
|
*
|
|
18
33
|
* Pair with `Column.disabled(record => …)` for per-row gating
|
|
19
34
|
* (e.g. forbid changing status once archived).
|
|
35
|
+
*
|
|
36
|
+
* Per-row options resolve via `.options(record => …)`:
|
|
37
|
+
*
|
|
38
|
+
* SelectColumn.make('assigneeId')
|
|
39
|
+
* .options(async (row) => {
|
|
40
|
+
* const team = await Team.find(row.teamId)
|
|
41
|
+
* return team.members.map(m => ({ value: String(m.id), label: m.name }))
|
|
42
|
+
* })
|
|
43
|
+
*
|
|
44
|
+
* The resolver runs once per visible row in `loadTableRecords` and
|
|
45
|
+
* stamps the resolved option list on `row._cellSelectOptions[col.name]`.
|
|
46
|
+
* Failed resolvers stay silent — the cell falls back to whatever was
|
|
47
|
+
* passed as a static `.options(...)` (or an empty list) so a single bad
|
|
48
|
+
* row doesn't break the whole table.
|
|
20
49
|
*/
|
|
21
50
|
export class SelectColumn extends Column {
|
|
22
51
|
protected _options: ColumnSelectOption[] = []
|
|
52
|
+
protected _optionsResolver?: SelectColumnOptionsResolver
|
|
23
53
|
protected _nullable = false
|
|
24
54
|
protected _selectablePlaceholder = true
|
|
25
55
|
|
|
@@ -29,13 +59,18 @@ export class SelectColumn extends Column {
|
|
|
29
59
|
return c
|
|
30
60
|
}
|
|
31
61
|
|
|
32
|
-
/** Static options
|
|
33
|
-
*
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
62
|
+
/** Static options OR a per-row resolver. Function form receives the
|
|
63
|
+
* raw record and may return a Promise — runs once per row in
|
|
64
|
+
* `loadTableRecords`. Re-calling replaces the previous set; mixing
|
|
65
|
+
* static + resolver replaces both because the renderer reads the
|
|
66
|
+
* per-row stamp first and falls back to the static list. */
|
|
67
|
+
options(opts: SelectColumnOptionsInput | SelectColumnOptionsResolver): this {
|
|
68
|
+
if (typeof opts === 'function') {
|
|
69
|
+
this._optionsResolver = opts
|
|
70
|
+
this._options = []
|
|
37
71
|
} else {
|
|
38
|
-
this._options =
|
|
72
|
+
this._options = normalizeSelectOptions(opts)
|
|
73
|
+
delete this._optionsResolver
|
|
39
74
|
}
|
|
40
75
|
return this
|
|
41
76
|
}
|
|
@@ -51,8 +86,11 @@ export class SelectColumn extends Column {
|
|
|
51
86
|
selectablePlaceholder(v = true): this { this._selectablePlaceholder = v; return this }
|
|
52
87
|
|
|
53
88
|
getOptions(): ReadonlyArray<ColumnSelectOption> { return this._options }
|
|
89
|
+
getOptionsResolver(): SelectColumnOptionsResolver | undefined { return this._optionsResolver }
|
|
54
90
|
|
|
55
91
|
protected override serializeExtras(meta: ColumnMeta): void {
|
|
92
|
+
// The static list still serializes when set so the renderer has a
|
|
93
|
+
// fallback if the per-row resolver throws or stamps no options.
|
|
56
94
|
if (this._options.length > 0) meta.selectOptions = this._options.slice()
|
|
57
95
|
if (this._nullable) meta.selectNullable = true
|
|
58
96
|
if (!this._selectablePlaceholder) meta.selectablePlaceholder = false
|
|
@@ -190,4 +190,49 @@ describe('SelectColumn', () => {
|
|
|
190
190
|
const meta = SelectColumn.make('status').options({ a: 'A' }).toMeta()
|
|
191
191
|
assert.equal(meta.selectablePlaceholder, undefined)
|
|
192
192
|
})
|
|
193
|
+
|
|
194
|
+
describe('options(record => …) per-row resolver', () => {
|
|
195
|
+
it('stores the resolver and clears any prior static options', () => {
|
|
196
|
+
const col = SelectColumn.make('assigneeId')
|
|
197
|
+
.options({ a: 'Alice', b: 'Bob' })
|
|
198
|
+
.options(_row => ({ x: 'X' }))
|
|
199
|
+
assert.equal(col.getOptions().length, 0)
|
|
200
|
+
assert.equal(typeof col.getOptionsResolver(), 'function')
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('switching back to a static list clears the resolver', () => {
|
|
204
|
+
const col = SelectColumn.make('assigneeId')
|
|
205
|
+
.options(_row => ({ x: 'X' }))
|
|
206
|
+
.options({ a: 'Alice' })
|
|
207
|
+
assert.equal(col.getOptionsResolver(), undefined)
|
|
208
|
+
assert.deepEqual(col.getOptions().slice(), [{ value: 'a', label: 'Alice' }])
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('static options still serialize when only static is set', () => {
|
|
212
|
+
const meta = SelectColumn.make('status').options({ a: 'A' }).toMeta()
|
|
213
|
+
assert.deepEqual(meta.selectOptions, [{ value: 'a', label: 'A' }])
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('resolver-only column omits selectOptions from meta (per-row stamp wins)', () => {
|
|
217
|
+
const meta = SelectColumn.make('assigneeId').options(_row => ({ x: 'X' })).toMeta()
|
|
218
|
+
assert.equal(meta.selectOptions, undefined)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('mixing a static fallback with a resolver — resolver wins, static stays in meta', () => {
|
|
222
|
+
const col = SelectColumn.make('assigneeId')
|
|
223
|
+
.options({ unknown: 'Unassigned' })
|
|
224
|
+
// Re-assign as resolver — resolver wipes the static list per the
|
|
225
|
+
// "re-calling replaces the previous set" contract. To keep the
|
|
226
|
+
// static fallback, set the resolver first then add static.
|
|
227
|
+
col.options(_row => ({ x: 'X' }))
|
|
228
|
+
assert.equal(col.getOptions().length, 0)
|
|
229
|
+
|
|
230
|
+
// The other ordering: static after resolver clears the resolver.
|
|
231
|
+
const col2 = SelectColumn.make('assigneeId')
|
|
232
|
+
.options(_row => ({ x: 'X' }))
|
|
233
|
+
.options({ unknown: 'Unassigned' })
|
|
234
|
+
assert.equal(col2.getOptionsResolver(), undefined)
|
|
235
|
+
assert.deepEqual(col2.getOptions().slice(), [{ value: 'unknown', label: 'Unassigned' }])
|
|
236
|
+
})
|
|
237
|
+
})
|
|
193
238
|
})
|
package/src/defaultPages.ts
CHANGED
|
@@ -610,5 +610,8 @@ export function defaultPages(R: ResourceClass): Required<ResourcePages> {
|
|
|
610
610
|
create: defaultCreatePage(R),
|
|
611
611
|
edit: defaultEditPage(R),
|
|
612
612
|
view: defaultViewPage(R),
|
|
613
|
+
// Record sub-pages have no framework defaults — users register them
|
|
614
|
+
// explicitly via `static pages() { return { record: { … } } }`.
|
|
615
|
+
record: {},
|
|
613
616
|
}
|
|
614
617
|
}
|
package/src/elements/Form.ts
CHANGED
|
@@ -147,6 +147,15 @@ export class Form<R = unknown> extends Element {
|
|
|
147
147
|
private _values?: Record<string, unknown>
|
|
148
148
|
private _errors?: ValidationErrors
|
|
149
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Cascading `inlineLabel` default for every descendant `Field`. Read
|
|
152
|
+
* by the resolver in `deriveChildContext` and pushed down via
|
|
153
|
+
* `RenderContext.inlineLabelDefault`. Per-field `Field.inlineLabel(...)`
|
|
154
|
+
* overrides; nested `Section.inlineLabel(...)` overrides for its
|
|
155
|
+
* subtree.
|
|
156
|
+
*/
|
|
157
|
+
private _inlineLabel?: boolean
|
|
158
|
+
|
|
150
159
|
// Plan #5 — partial-resolve endpoint. Stamped by the route handler at
|
|
151
160
|
// render time when any descendant field is `live()`; emits as
|
|
152
161
|
// (Plan #8) `wizardUrl` for step validation endpoint. Stamped only when
|
|
@@ -176,6 +185,16 @@ export class Form<R = unknown> extends Element {
|
|
|
176
185
|
method(m: FormMethod): this { this._method = m; return this }
|
|
177
186
|
action(url: string): this { this._action = url; return this }
|
|
178
187
|
|
|
188
|
+
/**
|
|
189
|
+
* Cascade `inlineLabel` to every descendant `Field` whose own
|
|
190
|
+
* `.inlineLabel(...)` hasn't been called. Pass `false` explicitly to
|
|
191
|
+
* cascade label-above when a parent (rare) had set it. Read by the
|
|
192
|
+
* resolver and pushed down via `RenderContext.inlineLabelDefault`.
|
|
193
|
+
*/
|
|
194
|
+
inlineLabel(v = true): this { this._inlineLabel = v; return this }
|
|
195
|
+
/** Internal — read by `resolveSchema.deriveChildContext`. */
|
|
196
|
+
getInlineLabel(): boolean | undefined { return this._inlineLabel }
|
|
197
|
+
|
|
179
198
|
// ─── Lifecycle setters ────────────────────────────────
|
|
180
199
|
|
|
181
200
|
/** Form-level validators run after field-level ones. Useful for cross-field rules. */
|
package/src/elements/Table.ts
CHANGED
|
@@ -32,6 +32,18 @@ export interface TableContext<R = unknown> {
|
|
|
32
32
|
* filter `where` clauses in `modelTableRecords`. Set by the framework;
|
|
33
33
|
* users configure it via `ListTab.modifyQuery(fn)`. */
|
|
34
34
|
tabQuery?: (q: import('../orm/modelDefaults.js').ModelQuery) => import('../orm/modelDefaults.js').ModelQuery
|
|
35
|
+
/** Drill-in scope when the user clicked a group heading. Carries the
|
|
36
|
+
* resolved `TableGroup` instance + the bucket key (the same value
|
|
37
|
+
* `getKeyFromRecordUsing` would produce). Set by `loadTableRecords`
|
|
38
|
+
* after reconciling `?<prefix>groupKey=`. The model adapter calls
|
|
39
|
+
* `group.resolveScoper()(q, key)` after filters but before pagination;
|
|
40
|
+
* user-supplied `Table.records(fn)` handlers can branch on this for
|
|
41
|
+
* cross-table joins or non-default narrowing. */
|
|
42
|
+
groupScope?: {
|
|
43
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
44
|
+
group: import('./TableGroup.js').TableGroup<any>
|
|
45
|
+
key: string
|
|
46
|
+
}
|
|
35
47
|
/** Whatever `Pilotiq.user(req => …)` returned for the current request.
|
|
36
48
|
* Forwarded into `Resource.query(ctx)` by `modelTableRecords` so user-
|
|
37
49
|
* installed scopes (tenant filters, etc.) see the same opaque user
|
|
@@ -210,6 +222,15 @@ export interface TableMeta extends ElementMeta {
|
|
|
210
222
|
* (`Table.defaultGroup('col')` only). */
|
|
211
223
|
groups?: TableGroupMeta[]
|
|
212
224
|
|
|
225
|
+
/** The drilled-in group key for this request. When set, the renderer
|
|
226
|
+
* suppresses group banding (no heading rows, no per-group summaries),
|
|
227
|
+
* shows a "Drilled into <Label>: <Key>" chip above the table with an
|
|
228
|
+
* × to clear, and the records have already been narrowed to that
|
|
229
|
+
* bucket server-side via `TableGroup.scopeQueryByKey`. Sparse —
|
|
230
|
+
* omitted unless `?<prefix>groupKey=<value>` is present AND the named
|
|
231
|
+
* group exists AND is `scopable`. */
|
|
232
|
+
activeGroupKey?: string
|
|
233
|
+
|
|
213
234
|
/** Per-column summary results — keyed by column name, each entry is the
|
|
214
235
|
* computed `SummaryResult[]` for that column's `summarize([…])`. Filled
|
|
215
236
|
* in by `loadTableRecords` after `records()` runs. Renderer emits a
|
|
@@ -319,10 +340,11 @@ export class Table<R = unknown, Q = unknown> extends Element {
|
|
|
319
340
|
private _recordClasses?: RecordClassesHandler<R>
|
|
320
341
|
private _pollInterval?: number
|
|
321
342
|
private _defaultGroup?: string
|
|
322
|
-
// Variance-relaxed —
|
|
343
|
+
// Variance-relaxed — covariant `TableGroup<R>[]` ergonomics
|
|
323
344
|
// matter more than tight invariance against the table's `R` parameter.
|
|
324
345
|
private _groups: TableGroup<any>[] = [] // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
325
346
|
private _activeGroup?: string
|
|
347
|
+
private _activeGroupKey?: string
|
|
326
348
|
private _summaries?: Record<string, SummaryResult[]>
|
|
327
349
|
private _groupSummaries?: Record<string, Record<string, SummaryResult[]>>
|
|
328
350
|
private _reorderableColumn?: string
|
|
@@ -682,6 +704,16 @@ export class Table<R = unknown, Q = unknown> extends Element {
|
|
|
682
704
|
return this
|
|
683
705
|
}
|
|
684
706
|
|
|
707
|
+
/** Render-time setter — the drilled-in group key for this request,
|
|
708
|
+
* after `?<prefix>groupKey=` was reconciled against a `scopable`
|
|
709
|
+
* group. Set by `loadTableRecords`. Empty string / undefined both
|
|
710
|
+
* clear, since drill-in needs an actual key to scope against. */
|
|
711
|
+
withActiveGroupKey(key: string | undefined): this {
|
|
712
|
+
if (key === undefined || key === '') delete this._activeGroupKey
|
|
713
|
+
else this._activeGroupKey = key
|
|
714
|
+
return this
|
|
715
|
+
}
|
|
716
|
+
|
|
685
717
|
// ─── Getters ──────────────────────────────────────────
|
|
686
718
|
|
|
687
719
|
getQuery(): TableQueryHandler<Q> | undefined { return this._query }
|
|
@@ -701,6 +733,7 @@ export class Table<R = unknown, Q = unknown> extends Element {
|
|
|
701
733
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
702
734
|
getGroups(): TableGroup<any>[] { return this._groups }
|
|
703
735
|
getActiveGroup(): string | undefined { return this._activeGroup }
|
|
736
|
+
getActiveGroupKey(): string | undefined { return this._activeGroupKey }
|
|
704
737
|
/** Resolve the active column to a `TableGroup` instance. Returns the
|
|
705
738
|
* matching registered group, or — when the active column is set but
|
|
706
739
|
* not registered (bare-column form) — synthesizes a no-metadata group
|
|
@@ -782,6 +815,7 @@ export class Table<R = unknown, Q = unknown> extends Element {
|
|
|
782
815
|
: {}),
|
|
783
816
|
...(this._summaries !== undefined ? { summaries: this._summaries } : {}),
|
|
784
817
|
...(this._groupSummaries !== undefined ? { groupSummaries: this._groupSummaries } : {}),
|
|
818
|
+
...(this._activeGroupKey !== undefined ? { activeGroupKey: this._activeGroupKey } : {}),
|
|
785
819
|
...(this._reorderableColumn !== undefined ? { reorderable: true as const, reorderableColumn: this._reorderableColumn } : {}),
|
|
786
820
|
...(this._reorderUrl !== undefined ? { reorderUrl: this._reorderUrl } : {}),
|
|
787
821
|
...(this._deferred ? { deferred: true as const } : {}),
|
|
@@ -146,4 +146,115 @@ describe('TableGroup', () => {
|
|
|
146
146
|
assert.equal(formatDateBucketTitle('blob'), 'blob')
|
|
147
147
|
})
|
|
148
148
|
})
|
|
149
|
+
|
|
150
|
+
describe('scopable + scopeQueryByKey + getKeyFromRecordUsing', () => {
|
|
151
|
+
it('default — not scopable, no meta emit', () => {
|
|
152
|
+
assert.equal(TableGroup.make('status').isScopable(), false)
|
|
153
|
+
assert.equal(TableGroup.make('status').toMeta().scopable, undefined)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('scopable() flips the meta flag', () => {
|
|
157
|
+
assert.equal(TableGroup.make('s').scopable().toMeta().scopable, true)
|
|
158
|
+
assert.equal(TableGroup.make('s').scopable(false).toMeta().scopable, undefined)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('scopeQueryByKey() auto-arms scopable', () => {
|
|
162
|
+
const g = TableGroup.make('status').scopeQueryByKey((q, _k) => q)
|
|
163
|
+
assert.equal(g.isScopable(), true)
|
|
164
|
+
assert.equal(g.toMeta().scopable, true)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('getKeyFromRecordUsing() auto-arms scopable', () => {
|
|
168
|
+
const g = TableGroup.make<{ s: string }>('status')
|
|
169
|
+
.getKeyFromRecordUsing(r => r.s.toUpperCase())
|
|
170
|
+
assert.equal(g.isScopable(), true)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('scopable(false) after auto-arm opts back out', () => {
|
|
174
|
+
const g = TableGroup.make('status').scopeQueryByKey((q, _k) => q).scopable(false)
|
|
175
|
+
assert.equal(g.isScopable(), false)
|
|
176
|
+
assert.equal(g.toMeta().scopable, undefined)
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
describe('resolveKey', () => {
|
|
181
|
+
it('default — raw column value as string', () => {
|
|
182
|
+
const g = TableGroup.make('status')
|
|
183
|
+
assert.equal(g.resolveKey({ status: 'draft' }), 'draft')
|
|
184
|
+
assert.equal(g.resolveKey({ status: 42 }), '42')
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('empty / null collapses to ""', () => {
|
|
188
|
+
const g = TableGroup.make('status')
|
|
189
|
+
assert.equal(g.resolveKey({ status: null }), '')
|
|
190
|
+
assert.equal(g.resolveKey({ status: undefined }), '')
|
|
191
|
+
assert.equal(g.resolveKey({ status: '' }), '')
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('date() returns the YYYY-MM-DD bucket', () => {
|
|
195
|
+
const g = TableGroup.make('createdAt').date()
|
|
196
|
+
assert.equal(g.resolveKey({ createdAt: '2026-05-04T12:00:00.000Z' }), '2026-05-04')
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('user handler wins', () => {
|
|
200
|
+
const g = TableGroup.make<{ s: string }>('status')
|
|
201
|
+
.getKeyFromRecordUsing(r => `K_${r.s}`)
|
|
202
|
+
assert.equal(g.resolveKey({ s: 'a' }), 'K_a')
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('handler returning undefined collapses to ""', () => {
|
|
206
|
+
const g = TableGroup.make<{ s: string | undefined }>('status')
|
|
207
|
+
.getKeyFromRecordUsing(r => r.s)
|
|
208
|
+
assert.equal(g.resolveKey({ s: undefined }), '')
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('throwing handler fails soft to ""', () => {
|
|
212
|
+
const g = TableGroup.make('status')
|
|
213
|
+
.getKeyFromRecordUsing(() => { throw new Error('boom') })
|
|
214
|
+
assert.equal(g.resolveKey({ status: 'draft' }), '')
|
|
215
|
+
})
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
describe('resolveScoper defaults', () => {
|
|
219
|
+
it('plain group — exact match where(col, "=", key)', () => {
|
|
220
|
+
const calls: Array<[string, string, unknown]> = []
|
|
221
|
+
const q = { where: (col: string, op: string, val: unknown) => { calls.push([col, op, val]); return q } }
|
|
222
|
+
const g = TableGroup.make('status')
|
|
223
|
+
const scoper = g.resolveScoper<typeof q>()
|
|
224
|
+
scoper(q, 'draft')
|
|
225
|
+
assert.deepEqual(calls, [['status', '=', 'draft']])
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('date group — whole-day range', () => {
|
|
229
|
+
const calls: Array<[string, string, unknown]> = []
|
|
230
|
+
const q = { where: (col: string, op: string, val: unknown) => { calls.push([col, op, val]); return q } }
|
|
231
|
+
const g = TableGroup.make('createdAt').date()
|
|
232
|
+
const scoper = g.resolveScoper<typeof q>()
|
|
233
|
+
scoper(q, '2026-05-04')
|
|
234
|
+
assert.deepEqual(calls, [
|
|
235
|
+
['createdAt', '>=', '2026-05-04 00:00:00'],
|
|
236
|
+
['createdAt', '<=', '2026-05-04 23:59:59'],
|
|
237
|
+
])
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('date group — empty key is a no-op (no where call)', () => {
|
|
241
|
+
let calls = 0
|
|
242
|
+
const q = { where: (..._args: unknown[]) => { calls++; return q } }
|
|
243
|
+
const g = TableGroup.make('createdAt').date()
|
|
244
|
+
const scoper = g.resolveScoper<typeof q>()
|
|
245
|
+
scoper(q, '')
|
|
246
|
+
assert.equal(calls, 0)
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('user scoper wins over date() default', () => {
|
|
250
|
+
const calls: string[] = []
|
|
251
|
+
const q = { where: (col: string, op: string, val: unknown) => { calls.push(`${col} ${op} ${val}`); return q } }
|
|
252
|
+
const g = TableGroup.make<unknown>('createdAt')
|
|
253
|
+
.date()
|
|
254
|
+
.scopeQueryByKey<typeof q>((qq, key) => qq.where('createdAt', '=', key))
|
|
255
|
+
const scoper = g.resolveScoper<typeof q>()
|
|
256
|
+
scoper(q, '2026-05-04')
|
|
257
|
+
assert.deepEqual(calls, ['createdAt = 2026-05-04'])
|
|
258
|
+
})
|
|
259
|
+
})
|
|
149
260
|
})
|
|
@@ -18,6 +18,30 @@ export type TableGroupDescriptionHandler<R = unknown> = (
|
|
|
18
18
|
record: R,
|
|
19
19
|
) => string | undefined
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Per-record key resolver. The returned string is what `scopeQueryByKey`
|
|
23
|
+
* receives at drill-in time. Default resolution = the raw column value
|
|
24
|
+
* cast to string (or `YYYY-MM-DD` when `.date()` is on). Override when the
|
|
25
|
+
* stable bucket key differs from what the column literally stores —
|
|
26
|
+
* e.g. when grouping by an enum object whose `.value` is the persisted
|
|
27
|
+
* column.
|
|
28
|
+
*/
|
|
29
|
+
export type TableGroupKeyHandler<R = unknown> = (
|
|
30
|
+
record: R,
|
|
31
|
+
) => string | undefined
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Query scoper applied when the user clicks a group heading to drill into
|
|
35
|
+
* a single group. Receives the raw model query and the resolved group key
|
|
36
|
+
* (the same value `getKeyFromRecordUsing` produced). Should narrow the
|
|
37
|
+
* query to records belonging to that group. Default narrows by exact-match
|
|
38
|
+
* `where(column, '=', key)`; date groups install a whole-day range default.
|
|
39
|
+
*/
|
|
40
|
+
export type TableGroupQueryScoper<Q = unknown> = (
|
|
41
|
+
query: Q,
|
|
42
|
+
key: string,
|
|
43
|
+
) => Q
|
|
44
|
+
|
|
21
45
|
/**
|
|
22
46
|
* Comparator on resolved group keys. Receives the same string values that
|
|
23
47
|
* the dispatcher stamps onto `_groupValue` (so for `date()` groups the keys
|
|
@@ -38,6 +62,12 @@ export interface TableGroupMeta {
|
|
|
38
62
|
* already arrives as `YYYY-MM-DD` and `_groupTitle` carries the
|
|
39
63
|
* formatted display text. */
|
|
40
64
|
date?: true
|
|
65
|
+
/** Heading is clickable — renderer wraps the title text in a real
|
|
66
|
+
* `<a href>` that sets `?<prefix>groupKey=<value>` to drill into a
|
|
67
|
+
* single group. Sparse: omitted unless the user opted in (directly
|
|
68
|
+
* via `.scopable(true)` or implicitly by calling `.scopeQueryByKey()`
|
|
69
|
+
* / `.getKeyFromRecordUsing()`). */
|
|
70
|
+
scopable?: true
|
|
41
71
|
}
|
|
42
72
|
|
|
43
73
|
export class TableGroup<R = unknown> {
|
|
@@ -49,6 +79,9 @@ export class TableGroup<R = unknown> {
|
|
|
49
79
|
private _descriptionFn?: TableGroupDescriptionHandler<R>
|
|
50
80
|
private _date = false
|
|
51
81
|
private _keyComparator?: TableGroupKeyComparator
|
|
82
|
+
private _scopable = false
|
|
83
|
+
private _scopeFn?: TableGroupQueryScoper<unknown>
|
|
84
|
+
private _keyFn?: TableGroupKeyHandler<R>
|
|
52
85
|
|
|
53
86
|
private constructor(column: string) {
|
|
54
87
|
this._column = column
|
|
@@ -111,6 +144,52 @@ export class TableGroup<R = unknown> {
|
|
|
111
144
|
return this
|
|
112
145
|
}
|
|
113
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Make the group heading clickable — clicking it drills the table into
|
|
149
|
+
* just that group's rows, suppressing the banded layout. Opt-in: most
|
|
150
|
+
* tables stay in the banded view. Auto-armed whenever the user calls
|
|
151
|
+
* `.scopeQueryByKey(fn)` or `.getKeyFromRecordUsing(fn)` since neither
|
|
152
|
+
* is meaningful without the drill-in affordance; pass `.scopable(false)`
|
|
153
|
+
* explicitly to opt back out after the fact.
|
|
154
|
+
*/
|
|
155
|
+
scopable(v: boolean = true): this {
|
|
156
|
+
this._scopable = v
|
|
157
|
+
return this
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Narrow the query to a single group's rows when the user drills in.
|
|
162
|
+
* Receives the raw model query and the resolved group key (same value
|
|
163
|
+
* `getKeyFromRecordUsing` produces). Default narrows by exact-match
|
|
164
|
+
* `where(column, '=', key)`; date groups install a whole-day range
|
|
165
|
+
* default. Auto-arms `.scopable(true)` since a custom scoper without
|
|
166
|
+
* a clickable heading would never fire.
|
|
167
|
+
*
|
|
168
|
+
* ```ts
|
|
169
|
+
* TableGroup.make('status').scopeQueryByKey((q, key) =>
|
|
170
|
+
* q.where('status', '=', key).where('archived', '=', false),
|
|
171
|
+
* )
|
|
172
|
+
* ```
|
|
173
|
+
*/
|
|
174
|
+
scopeQueryByKey<Q = unknown>(fn: TableGroupQueryScoper<Q>): this {
|
|
175
|
+
this._scopeFn = fn as TableGroupQueryScoper<unknown>
|
|
176
|
+
this._scopable = true
|
|
177
|
+
return this
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Override the per-record key resolver. The returned string is the
|
|
182
|
+
* stable bucket key — it round-trips through `?<prefix>groupKey=` on
|
|
183
|
+
* drill-in and lands as the second arg of `scopeQueryByKey`. Default
|
|
184
|
+
* = the raw column value cast to string (or `YYYY-MM-DD` when
|
|
185
|
+
* `.date()` is on). Auto-arms `.scopable(true)`.
|
|
186
|
+
*/
|
|
187
|
+
getKeyFromRecordUsing(fn: TableGroupKeyHandler<R>): this {
|
|
188
|
+
this._keyFn = fn
|
|
189
|
+
this._scopable = true
|
|
190
|
+
return this
|
|
191
|
+
}
|
|
192
|
+
|
|
114
193
|
// ─── Getters ──────────────────────────────────────────
|
|
115
194
|
|
|
116
195
|
getColumn(): string { return this._column }
|
|
@@ -122,6 +201,61 @@ export class TableGroup<R = unknown> {
|
|
|
122
201
|
getDescriptionHandler(): TableGroupDescriptionHandler<R> | undefined { return this._descriptionFn }
|
|
123
202
|
isDate(): boolean { return this._date }
|
|
124
203
|
getKeyComparator(): TableGroupKeyComparator | undefined { return this._keyComparator }
|
|
204
|
+
isScopable(): boolean { return this._scopable }
|
|
205
|
+
getKeyHandler(): TableGroupKeyHandler<R> | undefined { return this._keyFn }
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Resolve the active scoper. Returns the user-supplied function when
|
|
209
|
+
* set; otherwise installs a default:
|
|
210
|
+
*
|
|
211
|
+
* - **Date groups** (`.date()`): whole-day range over the column —
|
|
212
|
+
* `(q, key) => q.where(col, '>=', key + ' 00:00:00').where(col, '<=', key + ' 23:59:59')`.
|
|
213
|
+
* Strings are picked over `Date` instances so the default composes
|
|
214
|
+
* with ORMs that accept date-string literals; consumers wanting
|
|
215
|
+
* sub-day buckets / timezone-aware ranges supply their own scoper.
|
|
216
|
+
* - **Plain groups**: exact-match — `(q, key) => q.where(col, '=', key)`.
|
|
217
|
+
*
|
|
218
|
+
* Note: the default uses `where(col, '>=', …)` 3-arg form. Adapter that
|
|
219
|
+
* only supports 2-arg `where(col, value)` need to detect the comparison
|
|
220
|
+
* argument shape — every ORM pilotiq ships against today supports the
|
|
221
|
+
* 3-arg form via `ModelQuery.where`.
|
|
222
|
+
*/
|
|
223
|
+
resolveScoper<Q = unknown>(): TableGroupQueryScoper<Q> {
|
|
224
|
+
if (this._scopeFn) return this._scopeFn as TableGroupQueryScoper<Q>
|
|
225
|
+
const col = this._column
|
|
226
|
+
if (this._date) {
|
|
227
|
+
return ((q: { where: (...args: unknown[]) => unknown }, key: string) => {
|
|
228
|
+
// Empty key clears the bucket — fall back to a no-op so a stale
|
|
229
|
+
// groupKey doesn't accidentally filter the table to zero rows.
|
|
230
|
+
if (key === '') return q
|
|
231
|
+
return (q
|
|
232
|
+
.where(col, '>=', `${key} 00:00:00`) as typeof q)
|
|
233
|
+
.where(col, '<=', `${key} 23:59:59`)
|
|
234
|
+
}) as unknown as TableGroupQueryScoper<Q>
|
|
235
|
+
}
|
|
236
|
+
return ((q: { where: (...args: unknown[]) => unknown }, key: string) =>
|
|
237
|
+
q.where(col, '=', key)) as unknown as TableGroupQueryScoper<Q>
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Derive the stable bucket key for a record. User-supplied handler
|
|
242
|
+
* wins; otherwise falls back to the column's raw value cast to string
|
|
243
|
+
* (or the `YYYY-MM-DD` bucket when `.date()` is on). Unparseable /
|
|
244
|
+
* null values resolve to `''` so they cluster under the empty bucket.
|
|
245
|
+
*/
|
|
246
|
+
resolveKey(record: R): string {
|
|
247
|
+
if (this._keyFn) {
|
|
248
|
+
try {
|
|
249
|
+
const k = this._keyFn(record)
|
|
250
|
+
return k === undefined ? '' : String(k)
|
|
251
|
+
} catch {
|
|
252
|
+
return ''
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const raw = (record as Record<string, unknown>)[this._column]
|
|
256
|
+
if (this._date) return bucketDateValue(raw)
|
|
257
|
+
return raw == null || raw === '' ? '' : String(raw)
|
|
258
|
+
}
|
|
125
259
|
|
|
126
260
|
toMeta(): TableGroupMeta {
|
|
127
261
|
return {
|
|
@@ -130,6 +264,7 @@ export class TableGroup<R = unknown> {
|
|
|
130
264
|
...(this._collapsible ? { collapsible: true as const } : {}),
|
|
131
265
|
...(this._collapsed ? { collapsed: true as const } : {}),
|
|
132
266
|
...(this._date ? { date: true as const } : {}),
|
|
267
|
+
...(this._scopable ? { scopable: true as const } : {}),
|
|
133
268
|
}
|
|
134
269
|
}
|
|
135
270
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Element } from '../schema/Element.js'
|
|
2
2
|
import { Field, type AfterStateUpdatedContext } from '../fields/Field.js'
|
|
3
3
|
import { RepeaterField, isRepeaterField } from '../fields/RepeaterField.js'
|
|
4
|
-
import type { RepeaterRelationshipConfig } from '../fields/RepeaterField.js'
|
|
4
|
+
import type { RepeaterRelationshipConfig, RepeaterRowContext } from '../fields/RepeaterField.js'
|
|
5
5
|
import { BuilderField, isBuilderField } from '../fields/BuilderField.js'
|
|
6
6
|
import type { BuilderRelationshipConfig } from '../fields/BuilderField.js'
|
|
7
7
|
import { Form, type FormContext } from './Form.js'
|
|
@@ -1527,6 +1527,21 @@ async function persistRelationshipRows(
|
|
|
1527
1527
|
)
|
|
1528
1528
|
}
|
|
1529
1529
|
|
|
1530
|
+
// Per-row hooks — fire after each create / update / delete completes.
|
|
1531
|
+
// No-op when the field hasn't registered the corresponding handler.
|
|
1532
|
+
// Errors propagate; v1 isn't transactional so a throwing handler
|
|
1533
|
+
// leaves earlier rows persisted.
|
|
1534
|
+
const afterCreate = field.getAfterCreate()
|
|
1535
|
+
const afterUpdate = field.getAfterUpdate()
|
|
1536
|
+
const afterDelete = field.getAfterDelete()
|
|
1537
|
+
const buildRowCtx = (index: number): RepeaterRowContext => ({
|
|
1538
|
+
parent,
|
|
1539
|
+
parentId: parentPk as string | number,
|
|
1540
|
+
field: field.name,
|
|
1541
|
+
index,
|
|
1542
|
+
mode: attachment.kind,
|
|
1543
|
+
})
|
|
1544
|
+
|
|
1530
1545
|
// Compute the morph stamp once — `computeMorphPayload` is pure.
|
|
1531
1546
|
const morphStamp = attachment.kind === 'morphMany'
|
|
1532
1547
|
? computeMorphPayload(parent, attachment.morph)
|
|
@@ -1606,8 +1621,17 @@ async function persistRelationshipRows(
|
|
|
1606
1621
|
// child-row update (user may have edited the child's own
|
|
1607
1622
|
// columns through the Repeater). Skip the child write only
|
|
1608
1623
|
// when the payload would be empty (M2M + pivot-only edits).
|
|
1624
|
+
let updatedRecord: unknown = existingByPk.get(submittedId!)
|
|
1609
1625
|
if (Object.keys(payload).length > 0) {
|
|
1610
|
-
await model.update(submittedId!, payload)
|
|
1626
|
+
const ret = await model.update(submittedId!, payload)
|
|
1627
|
+
// ModelLike.update may return the updated record OR void; fall
|
|
1628
|
+
// back to the existing snapshot merged with the payload so the
|
|
1629
|
+
// hook always receives a usable record shape.
|
|
1630
|
+
if (ret !== undefined && ret !== null) {
|
|
1631
|
+
updatedRecord = ret
|
|
1632
|
+
} else {
|
|
1633
|
+
updatedRecord = { ...(existingByPk.get(submittedId!) ?? {}), ...payload }
|
|
1634
|
+
}
|
|
1611
1635
|
}
|
|
1612
1636
|
if (hasPivotPayload) {
|
|
1613
1637
|
if (typeof m2mAccessor!.updatePivot !== 'function') {
|
|
@@ -1620,13 +1644,15 @@ async function persistRelationshipRows(
|
|
|
1620
1644
|
await m2mAccessor!.updatePivot(submittedId!, pivotPayload)
|
|
1621
1645
|
}
|
|
1622
1646
|
keptPks.add(submittedId!)
|
|
1647
|
+
if (afterUpdate) await afterUpdate(updatedRecord, buildRowCtx(idx))
|
|
1623
1648
|
} else {
|
|
1649
|
+
let createdRecord: unknown = undefined
|
|
1624
1650
|
if (attachment.kind === 'hasMany') {
|
|
1625
1651
|
payload[attachment.foreignKey] = parentPk
|
|
1626
|
-
await model.create(payload)
|
|
1652
|
+
createdRecord = await model.create(payload)
|
|
1627
1653
|
} else if (attachment.kind === 'morphMany') {
|
|
1628
1654
|
Object.assign(payload, morphStamp)
|
|
1629
|
-
await model.create(payload)
|
|
1655
|
+
createdRecord = await model.create(payload)
|
|
1630
1656
|
} else {
|
|
1631
1657
|
// M2M: create the related record first, then attach via the
|
|
1632
1658
|
// pivot accessor. The accessor handles polymorphic stamping
|
|
@@ -1647,12 +1673,13 @@ async function persistRelationshipRows(
|
|
|
1647
1673
|
} else {
|
|
1648
1674
|
await m2mAccessor!.attach!([newPk as string | number])
|
|
1649
1675
|
}
|
|
1676
|
+
createdRecord = created
|
|
1650
1677
|
}
|
|
1678
|
+
if (afterCreate) await afterCreate(createdRecord, buildRowCtx(idx))
|
|
1651
1679
|
}
|
|
1652
|
-
void field
|
|
1653
1680
|
}
|
|
1654
1681
|
|
|
1655
|
-
for (const [pkVal,
|
|
1682
|
+
for (const [pkVal, removedRow] of existingByPk) {
|
|
1656
1683
|
if (keptPks.has(pkVal)) continue
|
|
1657
1684
|
if (isM2M) {
|
|
1658
1685
|
// Detach the pivot link only — the related record may still be
|
|
@@ -1662,7 +1689,7 @@ async function persistRelationshipRows(
|
|
|
1662
1689
|
} else {
|
|
1663
1690
|
await model.delete(pkVal)
|
|
1664
1691
|
}
|
|
1665
|
-
|
|
1692
|
+
if (afterDelete) await afterDelete(removedRow, buildRowCtx(-1))
|
|
1666
1693
|
}
|
|
1667
1694
|
}
|
|
1668
1695
|
|