@pilotiq/pilotiq 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) hide show
  1. package/.turbo/turbo-build.log +6 -2
  2. package/CHANGELOG.md +614 -0
  3. package/CLAUDE.md +6 -5
  4. package/dist/Column.d.ts +35 -0
  5. package/dist/Column.d.ts.map +1 -1
  6. package/dist/Column.js +41 -0
  7. package/dist/Column.js.map +1 -1
  8. package/dist/Page.d.ts +13 -4
  9. package/dist/Page.d.ts.map +1 -1
  10. package/dist/Page.js +9 -2
  11. package/dist/Page.js.map +1 -1
  12. package/dist/Pilotiq.d.ts +84 -0
  13. package/dist/Pilotiq.d.ts.map +1 -1
  14. package/dist/Pilotiq.js +66 -0
  15. package/dist/Pilotiq.js.map +1 -1
  16. package/dist/Resource.d.ts +26 -0
  17. package/dist/Resource.d.ts.map +1 -1
  18. package/dist/Resource.js +9 -0
  19. package/dist/Resource.js.map +1 -1
  20. package/dist/actions/exportFactory.js +1 -1
  21. package/dist/actions/exportFactory.js.map +1 -1
  22. package/dist/columns/SelectColumn.d.ts +32 -5
  23. package/dist/columns/SelectColumn.d.ts.map +1 -1
  24. package/dist/columns/SelectColumn.js +37 -7
  25. package/dist/columns/SelectColumn.js.map +1 -1
  26. package/dist/defaultPages.d.ts.map +1 -1
  27. package/dist/defaultPages.js +3 -0
  28. package/dist/defaultPages.js.map +1 -1
  29. package/dist/elements/Form.d.ts +17 -0
  30. package/dist/elements/Form.d.ts.map +1 -1
  31. package/dist/elements/Form.js +17 -0
  32. package/dist/elements/Form.js.map +1 -1
  33. package/dist/elements/Table.d.ts +26 -0
  34. package/dist/elements/Table.d.ts.map +1 -1
  35. package/dist/elements/Table.js +15 -1
  36. package/dist/elements/Table.js.map +1 -1
  37. package/dist/elements/TableGroup.d.ts +84 -0
  38. package/dist/elements/TableGroup.d.ts.map +1 -1
  39. package/dist/elements/TableGroup.js +103 -0
  40. package/dist/elements/TableGroup.js.map +1 -1
  41. package/dist/elements/dispatchForm.d.ts.map +1 -1
  42. package/dist/elements/dispatchForm.js +36 -6
  43. package/dist/elements/dispatchForm.js.map +1 -1
  44. package/dist/elements/dispatchTable.d.ts +12 -0
  45. package/dist/elements/dispatchTable.d.ts.map +1 -1
  46. package/dist/elements/dispatchTable.js +104 -29
  47. package/dist/elements/dispatchTable.js.map +1 -1
  48. package/dist/fields/Field.d.ts +7 -2
  49. package/dist/fields/Field.d.ts.map +1 -1
  50. package/dist/fields/Field.js +8 -3
  51. package/dist/fields/Field.js.map +1 -1
  52. package/dist/fields/RepeaterField.d.ts +65 -0
  53. package/dist/fields/RepeaterField.d.ts.map +1 -1
  54. package/dist/fields/RepeaterField.js +48 -0
  55. package/dist/fields/RepeaterField.js.map +1 -1
  56. package/dist/orm/modelDefaults.d.ts.map +1 -1
  57. package/dist/orm/modelDefaults.js +19 -0
  58. package/dist/orm/modelDefaults.js.map +1 -1
  59. package/dist/pageData.d.ts +20 -0
  60. package/dist/pageData.d.ts.map +1 -1
  61. package/dist/pageData.js +242 -34
  62. package/dist/pageData.js.map +1 -1
  63. package/dist/react/AppShell.d.ts +17 -1
  64. package/dist/react/AppShell.d.ts.map +1 -1
  65. package/dist/react/AppShell.js +34 -3
  66. package/dist/react/AppShell.js.map +1 -1
  67. package/dist/react/PendingSuggestionApplierRegistry.d.ts +34 -0
  68. package/dist/react/PendingSuggestionApplierRegistry.d.ts.map +1 -0
  69. package/dist/react/PendingSuggestionApplierRegistry.js +51 -0
  70. package/dist/react/PendingSuggestionApplierRegistry.js.map +1 -0
  71. package/dist/react/PendingSuggestionOverlayRegistry.d.ts +46 -0
  72. package/dist/react/PendingSuggestionOverlayRegistry.d.ts.map +1 -0
  73. package/dist/react/PendingSuggestionOverlayRegistry.js +16 -0
  74. package/dist/react/PendingSuggestionOverlayRegistry.js.map +1 -0
  75. package/dist/react/PendingSuggestionsContext.d.ts +153 -0
  76. package/dist/react/PendingSuggestionsContext.d.ts.map +1 -0
  77. package/dist/react/PendingSuggestionsContext.js +46 -0
  78. package/dist/react/PendingSuggestionsContext.js.map +1 -0
  79. package/dist/react/SchemaRenderer.d.ts.map +1 -1
  80. package/dist/react/SchemaRenderer.js +312 -39
  81. package/dist/react/SchemaRenderer.js.map +1 -1
  82. package/dist/react/cells/EditableCell.d.ts +8 -0
  83. package/dist/react/cells/EditableCell.d.ts.map +1 -1
  84. package/dist/react/cells/EditableCell.js +6 -2
  85. package/dist/react/cells/EditableCell.js.map +1 -1
  86. package/dist/react/fields/CheckboxListInput.d.ts.map +1 -1
  87. package/dist/react/fields/CheckboxListInput.js +29 -2
  88. package/dist/react/fields/CheckboxListInput.js.map +1 -1
  89. package/dist/react/fields/ColorInput.d.ts.map +1 -1
  90. package/dist/react/fields/ColorInput.js +28 -2
  91. package/dist/react/fields/ColorInput.js.map +1 -1
  92. package/dist/react/fields/DateTimeInput.d.ts.map +1 -1
  93. package/dist/react/fields/DateTimeInput.js +28 -2
  94. package/dist/react/fields/DateTimeInput.js.map +1 -1
  95. package/dist/react/fields/FieldShell.d.ts.map +1 -1
  96. package/dist/react/fields/FieldShell.js +161 -3
  97. package/dist/react/fields/FieldShell.js.map +1 -1
  98. package/dist/react/fields/FileUploadInput.d.ts.map +1 -1
  99. package/dist/react/fields/FileUploadInput.js +27 -2
  100. package/dist/react/fields/FileUploadInput.js.map +1 -1
  101. package/dist/react/fields/KeyValueInput.d.ts.map +1 -1
  102. package/dist/react/fields/KeyValueInput.js +33 -2
  103. package/dist/react/fields/KeyValueInput.js.map +1 -1
  104. package/dist/react/fields/RadioInput.d.ts.map +1 -1
  105. package/dist/react/fields/RadioInput.js +28 -2
  106. package/dist/react/fields/RadioInput.js.map +1 -1
  107. package/dist/react/fields/SelectFieldInput.d.ts.map +1 -1
  108. package/dist/react/fields/SelectFieldInput.js +31 -2
  109. package/dist/react/fields/SelectFieldInput.js.map +1 -1
  110. package/dist/react/fields/SliderInput.d.ts.map +1 -1
  111. package/dist/react/fields/SliderInput.js +26 -2
  112. package/dist/react/fields/SliderInput.js.map +1 -1
  113. package/dist/react/fields/TagsInput.d.ts.map +1 -1
  114. package/dist/react/fields/TagsInput.js +26 -2
  115. package/dist/react/fields/TagsInput.js.map +1 -1
  116. package/dist/react/fields/ToggleFieldInput.d.ts.map +1 -1
  117. package/dist/react/fields/ToggleFieldInput.js +29 -2
  118. package/dist/react/fields/ToggleFieldInput.js.map +1 -1
  119. package/dist/react/index.d.ts +3 -0
  120. package/dist/react/index.d.ts.map +1 -1
  121. package/dist/react/index.js +3 -0
  122. package/dist/react/index.js.map +1 -1
  123. package/dist/routes.d.ts.map +1 -1
  124. package/dist/routes.js +55 -2
  125. package/dist/routes.js.map +1 -1
  126. package/dist/schema/Html.d.ts +2 -2
  127. package/dist/schema/Html.d.ts.map +1 -1
  128. package/dist/schema/Html.js +2 -2
  129. package/dist/schema/Html.js.map +1 -1
  130. package/dist/schema/Markdown.d.ts +2 -2
  131. package/dist/schema/Markdown.d.ts.map +1 -1
  132. package/dist/schema/Markdown.js +2 -2
  133. package/dist/schema/Markdown.js.map +1 -1
  134. package/dist/schema/Section.d.ts +16 -0
  135. package/dist/schema/Section.d.ts.map +1 -1
  136. package/dist/schema/Section.js +16 -0
  137. package/dist/schema/Section.js.map +1 -1
  138. package/dist/schema/Wizard.d.ts +45 -0
  139. package/dist/schema/Wizard.d.ts.map +1 -1
  140. package/dist/schema/Wizard.js +50 -0
  141. package/dist/schema/Wizard.js.map +1 -1
  142. package/dist/schema/resolveSchema.d.ts +8 -0
  143. package/dist/schema/resolveSchema.d.ts.map +1 -1
  144. package/dist/schema/resolveSchema.js +70 -1
  145. package/dist/schema/resolveSchema.js.map +1 -1
  146. package/dist/schema/sanitize.d.ts +3 -3
  147. package/dist/schema/sanitize.d.ts.map +1 -1
  148. package/dist/schema/sanitize.js +10 -3
  149. package/dist/schema/sanitize.js.map +1 -1
  150. package/dist/sessionFilters.d.ts.map +1 -1
  151. package/dist/sessionFilters.js +12 -1
  152. package/dist/sessionFilters.js.map +1 -1
  153. package/dist/styles/file-upload.css +13 -0
  154. package/dist/vite.d.ts.map +1 -1
  155. package/dist/vite.js +9 -2
  156. package/dist/vite.js.map +1 -1
  157. package/package.json +6 -4
  158. package/src/Column.test.ts +36 -0
  159. package/src/Column.ts +54 -0
  160. package/src/Page.ts +13 -4
  161. package/src/Pilotiq.ts +109 -0
  162. package/src/Resource.ts +29 -0
  163. package/src/actions/exportFactory.ts +1 -1
  164. package/src/columns/SelectColumn.ts +46 -8
  165. package/src/columns/editableColumns.test.ts +45 -0
  166. package/src/defaultPages.ts +3 -0
  167. package/src/elements/Form.ts +19 -0
  168. package/src/elements/Table.ts +35 -1
  169. package/src/elements/TableGroup.test.ts +111 -0
  170. package/src/elements/TableGroup.ts +135 -0
  171. package/src/elements/dispatchForm.ts +34 -7
  172. package/src/elements/dispatchTable.test.ts +267 -0
  173. package/src/elements/dispatchTable.ts +112 -33
  174. package/src/fields/Field.test.ts +15 -0
  175. package/src/fields/Field.ts +8 -3
  176. package/src/fields/RepeaterField.ts +104 -0
  177. package/src/fields/RepeaterRelationship.test.ts +173 -0
  178. package/src/nestedRelationManagerData.test.ts +21 -0
  179. package/src/orm/modelDefaults.ts +21 -0
  180. package/src/pageData.ts +267 -47
  181. package/src/react/AppShell.tsx +55 -4
  182. package/src/react/PendingSuggestionApplierRegistry.ts +80 -0
  183. package/src/react/PendingSuggestionOverlayRegistry.ts +54 -0
  184. package/src/react/PendingSuggestionsContext.tsx +172 -0
  185. package/src/react/SchemaRenderer.tsx +504 -95
  186. package/src/react/cells/EditableCell.tsx +11 -2
  187. package/src/react/fields/CheckboxListInput.tsx +23 -2
  188. package/src/react/fields/ColorInput.tsx +22 -2
  189. package/src/react/fields/DateTimeInput.tsx +22 -2
  190. package/src/react/fields/FieldShell.tsx +167 -3
  191. package/src/react/fields/FileUploadInput.tsx +21 -2
  192. package/src/react/fields/KeyValueInput.tsx +32 -2
  193. package/src/react/fields/RadioInput.tsx +23 -2
  194. package/src/react/fields/SelectFieldInput.tsx +25 -2
  195. package/src/react/fields/SliderInput.tsx +20 -2
  196. package/src/react/fields/TagsInput.tsx +20 -2
  197. package/src/react/fields/ToggleFieldInput.tsx +23 -2
  198. package/src/react/index.ts +18 -0
  199. package/src/relationManagerData.test.ts +451 -2
  200. package/src/routes.ts +58 -2
  201. package/src/schema/Html.ts +2 -2
  202. package/src/schema/Markdown.ts +2 -2
  203. package/src/schema/Section.ts +17 -0
  204. package/src/schema/Wizard.ts +67 -0
  205. package/src/schema/containers.test.ts +90 -0
  206. package/src/schema/resolveSchema.test.ts +50 -0
  207. package/src/schema/resolveSchema.ts +79 -1
  208. package/src/schema/sanitize.ts +13 -4
  209. package/src/sessionFilters.test.ts +23 -0
  210. package/src/sessionFilters.ts +11 -1
  211. package/src/styles/file-upload.css +13 -0
  212. package/src/vite.ts +9 -2
@@ -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. Static options only in v1 — async per-row
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. Accepts either `{ value: label }` or
33
- * `[{ value, label }]`. Re-calling replaces the previous set. */
34
- options(opts: SelectColumnOptionsInput): this {
35
- if (Array.isArray(opts)) {
36
- this._options = opts.map(o => ({ value: o.value, label: o.label }))
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 = Object.entries(opts).map(([value, label]) => ({ value, label }))
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
  })
@@ -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
  }
@@ -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. */
@@ -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 — Filament-style covariant `TableGroup<R>[]` ergonomics
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, _row] of existingByPk) {
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
- void _row
1692
+ if (afterDelete) await afterDelete(removedRow, buildRowCtx(-1))
1666
1693
  }
1667
1694
  }
1668
1695