@pilotiq/pilotiq 0.6.2 → 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 (197) hide show
  1. package/.turbo/turbo-build.log +6 -2
  2. package/CHANGELOG.md +608 -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 +103 -28
  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/Section.d.ts +16 -0
  127. package/dist/schema/Section.d.ts.map +1 -1
  128. package/dist/schema/Section.js +16 -0
  129. package/dist/schema/Section.js.map +1 -1
  130. package/dist/schema/Wizard.d.ts +45 -0
  131. package/dist/schema/Wizard.d.ts.map +1 -1
  132. package/dist/schema/Wizard.js +50 -0
  133. package/dist/schema/Wizard.js.map +1 -1
  134. package/dist/schema/resolveSchema.d.ts +8 -0
  135. package/dist/schema/resolveSchema.d.ts.map +1 -1
  136. package/dist/schema/resolveSchema.js +70 -1
  137. package/dist/schema/resolveSchema.js.map +1 -1
  138. package/dist/sessionFilters.d.ts.map +1 -1
  139. package/dist/sessionFilters.js +12 -1
  140. package/dist/sessionFilters.js.map +1 -1
  141. package/dist/styles/file-upload.css +13 -0
  142. package/dist/vite.d.ts.map +1 -1
  143. package/dist/vite.js +9 -2
  144. package/dist/vite.js.map +1 -1
  145. package/package.json +6 -4
  146. package/src/Column.test.ts +36 -0
  147. package/src/Column.ts +54 -0
  148. package/src/Page.ts +13 -4
  149. package/src/Pilotiq.ts +109 -0
  150. package/src/Resource.ts +29 -0
  151. package/src/actions/exportFactory.ts +1 -1
  152. package/src/columns/SelectColumn.ts +46 -8
  153. package/src/columns/editableColumns.test.ts +45 -0
  154. package/src/defaultPages.ts +3 -0
  155. package/src/elements/Form.ts +19 -0
  156. package/src/elements/Table.ts +35 -1
  157. package/src/elements/TableGroup.test.ts +111 -0
  158. package/src/elements/TableGroup.ts +135 -0
  159. package/src/elements/dispatchForm.ts +34 -7
  160. package/src/elements/dispatchTable.test.ts +267 -0
  161. package/src/elements/dispatchTable.ts +111 -32
  162. package/src/fields/Field.test.ts +15 -0
  163. package/src/fields/Field.ts +8 -3
  164. package/src/fields/RepeaterField.ts +104 -0
  165. package/src/fields/RepeaterRelationship.test.ts +173 -0
  166. package/src/nestedRelationManagerData.test.ts +21 -0
  167. package/src/orm/modelDefaults.ts +21 -0
  168. package/src/pageData.ts +267 -47
  169. package/src/react/AppShell.tsx +55 -4
  170. package/src/react/PendingSuggestionApplierRegistry.ts +80 -0
  171. package/src/react/PendingSuggestionOverlayRegistry.ts +54 -0
  172. package/src/react/PendingSuggestionsContext.tsx +172 -0
  173. package/src/react/SchemaRenderer.tsx +504 -95
  174. package/src/react/cells/EditableCell.tsx +11 -2
  175. package/src/react/fields/CheckboxListInput.tsx +23 -2
  176. package/src/react/fields/ColorInput.tsx +22 -2
  177. package/src/react/fields/DateTimeInput.tsx +22 -2
  178. package/src/react/fields/FieldShell.tsx +167 -3
  179. package/src/react/fields/FileUploadInput.tsx +21 -2
  180. package/src/react/fields/KeyValueInput.tsx +32 -2
  181. package/src/react/fields/RadioInput.tsx +23 -2
  182. package/src/react/fields/SelectFieldInput.tsx +25 -2
  183. package/src/react/fields/SliderInput.tsx +20 -2
  184. package/src/react/fields/TagsInput.tsx +20 -2
  185. package/src/react/fields/ToggleFieldInput.tsx +23 -2
  186. package/src/react/index.ts +18 -0
  187. package/src/relationManagerData.test.ts +451 -2
  188. package/src/routes.ts +58 -2
  189. package/src/schema/Section.ts +17 -0
  190. package/src/schema/Wizard.ts +67 -0
  191. package/src/schema/containers.test.ts +90 -0
  192. package/src/schema/resolveSchema.test.ts +50 -0
  193. package/src/schema/resolveSchema.ts +79 -1
  194. package/src/sessionFilters.test.ts +23 -0
  195. package/src/sessionFilters.ts +11 -1
  196. package/src/styles/file-upload.css +13 -0
  197. package/src/vite.ts +9 -2
@@ -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