@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
@@ -17,6 +17,7 @@ import {
17
17
  import {
18
18
  parseTableQuery,
19
19
  parseActiveGroup,
20
+ parseActiveGroupKey,
20
21
  findTables,
21
22
  loadTableRecords,
22
23
  } from './dispatchTable.js'
@@ -790,6 +791,190 @@ describe('loadTableRecords', () => {
790
791
  })
791
792
  })
792
793
 
794
+ describe('TableGroup.scopeQueryByKey — drill-in', () => {
795
+ it('parseActiveGroupKey: returns the key when group is scopable', () => {
796
+ const t = Table.make()
797
+ .groups([TableGroup.make('status').scopable()])
798
+ .defaultGroup('status')
799
+ assert.equal(parseActiveGroupKey({ groupKey: 'draft' }, t, 'status'), 'draft')
800
+ })
801
+
802
+ it('parseActiveGroupKey: drops silently when group is not scopable', () => {
803
+ const t = Table.make()
804
+ .groups([TableGroup.make('status')]) // not scopable
805
+ .defaultGroup('status')
806
+ assert.equal(parseActiveGroupKey({ groupKey: 'draft' }, t, 'status'), undefined)
807
+ })
808
+
809
+ it('parseActiveGroupKey: drops silently when no active group', () => {
810
+ const t = Table.make()
811
+ assert.equal(parseActiveGroupKey({ groupKey: 'draft' }, t, undefined), undefined)
812
+ })
813
+
814
+ it('parseActiveGroupKey: drops silently when group not registered (bare-column form)', () => {
815
+ const t = Table.make().defaultGroup('status') // bare; no scopable() call
816
+ assert.equal(parseActiveGroupKey({ groupKey: 'draft' }, t, 'status'), undefined)
817
+ })
818
+
819
+ it('parseActiveGroupKey: empty string explicitly clears', () => {
820
+ const t = Table.make()
821
+ .groups([TableGroup.make('status').scopable()])
822
+ .defaultGroup('status')
823
+ assert.equal(parseActiveGroupKey({ groupKey: '' }, t, 'status'), undefined)
824
+ })
825
+
826
+ it('suppresses _groupValue banding when drilled in', async () => {
827
+ const t = Table.make<{ id: string; status: string }>()
828
+ .columns([Column.make('status')])
829
+ .groups([TableGroup.make('status').scopable()])
830
+ .defaultGroup('status')
831
+ .records(async () => [
832
+ { id: '1', status: 'draft' },
833
+ { id: '2', status: 'draft' },
834
+ ])
835
+
836
+ await loadTableRecords([t], { groupKey: 'draft' })
837
+ const meta = (await resolveSchema([t]))[0]!
838
+ const rows = meta['rows'] as Array<Record<string, unknown>>
839
+ // No banding when drilled in — the rows are already filtered to
840
+ // the bucket, so heading rows would be redundant.
841
+ assert.equal(rows[0]!['_groupValue'], undefined)
842
+ assert.equal(rows[1]!['_groupValue'], undefined)
843
+ assert.equal(meta['activeGroupKey'], 'draft')
844
+ assert.equal(meta['defaultGroup'], 'status') // active group preserved
845
+ })
846
+
847
+ it('resets the visible page to 1 on drill-in', async () => {
848
+ const t = Table.make<{ id: string }>()
849
+ .columns([Column.make('id')])
850
+ .groups([TableGroup.make('status').scopable()])
851
+ .defaultGroup('status')
852
+ .records(async () => [{ id: '1' }])
853
+
854
+ await loadTableRecords([t], { groupKey: 'draft', page: '5' })
855
+ const meta = (await resolveSchema([t]))[0]!
856
+ assert.equal(meta['currentPage'], 1)
857
+ })
858
+
859
+ it('threads ctx.groupScope into the records handler', async () => {
860
+ let received: unknown
861
+ const t = Table.make<{ id: string }>()
862
+ .columns([Column.make('id')])
863
+ .groups([TableGroup.make('status').scopable()])
864
+ .defaultGroup('status')
865
+ .records(async (ctx) => {
866
+ received = (ctx as { groupScope?: unknown }).groupScope
867
+ return []
868
+ })
869
+
870
+ await loadTableRecords([t], { groupKey: 'draft' })
871
+ const scope = received as { group?: { getColumn: () => string }; key?: string }
872
+ assert.equal(scope?.group?.getColumn(), 'status')
873
+ assert.equal(scope?.key, 'draft')
874
+ })
875
+
876
+ it('omits ctx.groupScope when not drilled in', async () => {
877
+ let received: unknown = 'unset'
878
+ const t = Table.make<{ id: string }>()
879
+ .columns([Column.make('id')])
880
+ .groups([TableGroup.make('status').scopable()])
881
+ .defaultGroup('status')
882
+ .records(async (ctx) => {
883
+ received = (ctx as { groupScope?: unknown }).groupScope
884
+ return [{ id: '1' }]
885
+ })
886
+
887
+ await loadTableRecords([t], {})
888
+ assert.equal(received, undefined)
889
+ })
890
+
891
+ it('suppresses groupSummaries when drilled in', async () => {
892
+ const t = Table.make<{ id: string; status: string; price: number }>()
893
+ .columns([
894
+ Column.make('status'),
895
+ Column.make('price').summarize([Sum.make()]),
896
+ ])
897
+ .groups([TableGroup.make('status').scopable()])
898
+ .defaultGroup('status')
899
+ .records(async () => [
900
+ { id: '1', status: 'draft', price: 100 },
901
+ { id: '2', status: 'draft', price: 200 },
902
+ ])
903
+
904
+ await loadTableRecords([t], { groupKey: 'draft' })
905
+ const meta = (await resolveSchema([t]))[0]!
906
+ // Global summary still computed across the bucket — it's the only
907
+ // total that makes sense once you've drilled in.
908
+ assert.ok(meta['summaries'] !== undefined)
909
+ // Per-group summary block is gone because banding is gone.
910
+ assert.equal(meta['groupSummaries'], undefined)
911
+ })
912
+
913
+ it('queryStringIdentifier — drilled key reads from <id>_groupKey', async () => {
914
+ const t = Table.make<{ id: string; status: string }>()
915
+ .queryStringIdentifier('orders')
916
+ .columns([Column.make('status')])
917
+ .groups([TableGroup.make('status').scopable()])
918
+ .defaultGroup('status')
919
+ .records(async () => [{ id: '1', status: 'draft' }])
920
+
921
+ // Bare `?groupKey=` shouldn't drill in when the table is prefixed.
922
+ await loadTableRecords([t], { groupKey: 'wat' })
923
+ let meta = (await resolveSchema([t]))[0]!
924
+ assert.equal(meta['activeGroupKey'], undefined)
925
+
926
+ await loadTableRecords([t], { orders_group: 'status', orders_groupKey: 'draft' })
927
+ meta = (await resolveSchema([t]))[0]!
928
+ assert.equal(meta['activeGroupKey'], 'draft')
929
+ })
930
+
931
+ it('date() group — drill-in threads bucket key through scope', async () => {
932
+ let calls: Array<[string, string, unknown]> = []
933
+ const t = Table.make<{ id: string; createdAt: string }>()
934
+ .columns([Column.make('createdAt')])
935
+ .groups([TableGroup.make('createdAt').date().scopable()])
936
+ .defaultGroup('createdAt')
937
+ .records(async (ctx) => {
938
+ const scope = (ctx as { groupScope?: { group: { resolveScoper: <Q>() => (q: Q, k: string) => Q }; key: string } }).groupScope
939
+ if (scope) {
940
+ const q = { where: (col: string, op: string, val: unknown) => { calls.push([col, op, val]); return q } }
941
+ scope.group.resolveScoper<typeof q>()(q, scope.key)
942
+ }
943
+ return [] as { id: string; createdAt: string }[]
944
+ })
945
+
946
+ calls = []
947
+ await loadTableRecords([t], { groupKey: '2026-05-04' })
948
+ assert.deepEqual(calls, [
949
+ ['createdAt', '>=', '2026-05-04 00:00:00'],
950
+ ['createdAt', '<=', '2026-05-04 23:59:59'],
951
+ ])
952
+ })
953
+
954
+ it('user-supplied scopeQueryByKey wins over default', async () => {
955
+ let captured = ''
956
+ const t = Table.make<{ id: string; status: string }>()
957
+ .columns([Column.make('status')])
958
+ .groups([
959
+ TableGroup.make('status').scopeQueryByKey<{ where: (...a: unknown[]) => unknown }>(
960
+ (q, key) => { captured = `custom:${key}`; return q },
961
+ ),
962
+ ])
963
+ .defaultGroup('status')
964
+ .records(async (ctx) => {
965
+ const scope = (ctx as { groupScope?: { group: { resolveScoper: <Q>() => (q: Q, k: string) => Q }; key: string } }).groupScope
966
+ if (scope) {
967
+ const q = { where: () => q }
968
+ scope.group.resolveScoper<typeof q>()(q, scope.key)
969
+ }
970
+ return []
971
+ })
972
+
973
+ await loadTableRecords([t], { groupKey: 'draft' })
974
+ assert.equal(captured, 'custom:draft')
975
+ })
976
+ })
977
+
793
978
  describe('Table.recordClasses', () => {
794
979
  it('stamps _recordClasses on each row when a handler is set', async () => {
795
980
  const t = Table.make<{ id: string; status: string }>()
@@ -1061,6 +1246,88 @@ describe('loadTableRecords', () => {
1061
1246
  const rows = t.getRows() as Array<Record<string, unknown>>
1062
1247
  assert.equal(rows[0]!['_cellEditable'], undefined)
1063
1248
  })
1249
+
1250
+ describe('SelectColumn.options(record => …) per-row resolver', () => {
1251
+ it('stamps _cellSelectOptions per row when canEdit allows', async () => {
1252
+ const t = Table.make<{ id: string; teamId: string }>()
1253
+ .columns([
1254
+ Column.make('id'),
1255
+ SelectColumn.make('assigneeId').options((row) => {
1256
+ const r = row as { teamId: string }
1257
+ return r.teamId === 'red'
1258
+ ? { alice: 'Alice', bob: 'Bob' }
1259
+ : { carol: 'Carol' }
1260
+ }),
1261
+ ])
1262
+ .records(async () => ({
1263
+ rows: [{ id: '1', teamId: 'red' }, { id: '2', teamId: 'blue' }],
1264
+ total: 2,
1265
+ }))
1266
+
1267
+ await loadTableRecords([t], {}, undefined, undefined, { canEdit: () => true })
1268
+ const rows = t.getRows() as Array<Record<string, unknown>>
1269
+ assert.deepEqual(rows[0]!['_cellSelectOptions'], {
1270
+ assigneeId: [
1271
+ { value: 'alice', label: 'Alice' },
1272
+ { value: 'bob', label: 'Bob' },
1273
+ ],
1274
+ })
1275
+ assert.deepEqual(rows[1]!['_cellSelectOptions'], {
1276
+ assigneeId: [{ value: 'carol', label: 'Carol' }],
1277
+ })
1278
+ })
1279
+
1280
+ it('does not stamp when canEdit denies the row (cell stays read-only)', async () => {
1281
+ const t = Table.make<{ id: string }>()
1282
+ .columns([
1283
+ Column.make('id'),
1284
+ SelectColumn.make('assigneeId').options(() => ({ a: 'A' })),
1285
+ ])
1286
+ .records(async () => ({ rows: [{ id: '1' }], total: 1 }))
1287
+
1288
+ await loadTableRecords([t], {}, undefined, undefined, { canEdit: () => false })
1289
+ const rows = t.getRows() as Array<Record<string, unknown>>
1290
+ assert.equal(rows[0]!['_cellEditable'], undefined)
1291
+ assert.equal(rows[0]!['_cellSelectOptions'], undefined)
1292
+ })
1293
+
1294
+ it('throwing resolver leaves the slot unset on that row only — others still stamp', async () => {
1295
+ const t = Table.make<{ id: string; bad: boolean }>()
1296
+ .columns([
1297
+ Column.make('id'),
1298
+ SelectColumn.make('assigneeId').options((row) => {
1299
+ const r = row as { bad: boolean }
1300
+ if (r.bad) throw new Error('lookup failed')
1301
+ return { x: 'X' }
1302
+ }),
1303
+ ])
1304
+ .records(async () => ({
1305
+ rows: [{ id: '1', bad: true }, { id: '2', bad: false }],
1306
+ total: 2,
1307
+ }))
1308
+
1309
+ await loadTableRecords([t], {}, undefined, undefined, { canEdit: () => true })
1310
+ const rows = t.getRows() as Array<Record<string, unknown>>
1311
+ assert.equal(rows[0]!['_cellSelectOptions'], undefined)
1312
+ assert.deepEqual(rows[1]!['_cellSelectOptions'], {
1313
+ assigneeId: [{ value: 'x', label: 'X' }],
1314
+ })
1315
+ })
1316
+
1317
+ it('skips _cellSelectOptions entirely when no resolver is configured', async () => {
1318
+ const t = Table.make<{ id: string }>()
1319
+ .columns([
1320
+ Column.make('id'),
1321
+ SelectColumn.make('assigneeId').options({ a: 'A' }),
1322
+ ])
1323
+ .records(async () => ({ rows: [{ id: '1' }], total: 1 }))
1324
+
1325
+ await loadTableRecords([t], {}, undefined, undefined, { canEdit: () => true })
1326
+ const rows = t.getRows() as Array<Record<string, unknown>>
1327
+ assert.equal(rows[0]!['_cellEditable']?.['assigneeId' as never], true)
1328
+ assert.equal(rows[0]!['_cellSelectOptions'], undefined)
1329
+ })
1330
+ })
1064
1331
  })
1065
1332
  })
1066
1333
 
@@ -3,7 +3,8 @@ import { Table, type TableContext, type SortDirection } from './Table.js'
3
3
  import { TableGroup, bucketDateValue, formatDateBucketTitle } from './TableGroup.js'
4
4
  import type { Filter } from '../filters/Filter.js'
5
5
  import { Action } from '../actions/Action.js'
6
- import { Column } from '../Column.js'
6
+ import { Column, type ColumnSelectOption } from '../Column.js'
7
+ import { SelectColumn, normalizeSelectOptions, type SelectColumnOptionsResolver } from '../columns/SelectColumn.js'
7
8
  import { ListTab } from '../Tab.js'
8
9
  import { isRepeaterField } from '../fields/RepeaterField.js'
9
10
  import { isBuilderField } from '../fields/BuilderField.js'
@@ -23,7 +24,7 @@ export interface QueryParams {
23
24
  [key: string]: unknown
24
25
  }
25
26
 
26
- const RESERVED_QUERY_KEYS = new Set(['search', 'sort', 'page', 'perPage', 'group'])
27
+ const RESERVED_QUERY_KEYS = new Set(['search', 'sort', 'page', 'perPage', 'group', 'groupKey'])
27
28
 
28
29
  export function prefixedKey(prefix: string | undefined, key: string): string {
29
30
  return prefix === undefined || prefix === '' ? key : `${prefix}_${key}`
@@ -147,6 +148,35 @@ export function parseActiveGroup(
147
148
  return table.getDefaultGroup()
148
149
  }
149
150
 
151
+ /**
152
+ * Read the drilled-in group key from the URL. Returns the key string when
153
+ * the URL carries a value AND the active group exists AND is `scopable`;
154
+ * `undefined` otherwise (stale bookmark, group dropped, or never set).
155
+ *
156
+ * The active column resolution runs first so the drill-in state can be
157
+ * silently dropped without affecting the parent group's reconciliation —
158
+ * a stale `groupKey` from a renamed-column or non-scopable group falls
159
+ * through to "no drill-in", which is the same as the user not having
160
+ * clicked a heading.
161
+ */
162
+ export function parseActiveGroupKey(
163
+ query: QueryParams,
164
+ table: Table,
165
+ activeColumn: string | undefined,
166
+ prefix?: string,
167
+ ): string | undefined {
168
+ if (activeColumn === undefined) return undefined
169
+ const raw = query[prefixedKey(prefix, 'groupKey')]
170
+ if (typeof raw !== 'string') return undefined
171
+ if (raw === '') return undefined // explicit clear
172
+ const group = table.getGroups().find(g => g.getColumn() === activeColumn)
173
+ // Bare-column groups (synthesized from defaultGroup with no entry in
174
+ // `groups([…])`) can't be scopable — `.scopeQueryByKey()` is a builder
175
+ // call. Drop the drill-in silently.
176
+ if (!group || !group.isScopable()) return undefined
177
+ return raw
178
+ }
179
+
150
180
  /** Walk an Element tree and return every `Table` instance in document order. */
151
181
  export function findTables(elements: ReadonlyArray<Element>): Table[] {
152
182
  const tables: Table[] = []
@@ -224,6 +254,20 @@ export async function loadTableRecords(
224
254
  // `ctx.tabQuery` to splice the predicate into its ORM query chain.
225
255
  const activeTab = findActiveTab(elements)
226
256
 
257
+ // Reconcile group + drill-in BEFORE building ctx so `groupScope` can
258
+ // be threaded into the records handler (model adapter splices the
259
+ // scoper after filters). A live drill-in also resets the page to 1
260
+ // server-side — clicking a heading should reliably land on the first
261
+ // page of the bucket regardless of where the user was paginated to.
262
+ const activeGroupCol = parseActiveGroup(query, table, prefix)
263
+ // Stamp the reconciled column onto the table early so
264
+ // `getActiveGroupInstance()` can resolve to a registered group (or
265
+ // synthesize a bare-column instance) consistently across the file.
266
+ table.withActiveGroup(activeGroupCol ?? '')
267
+ const activeGroup = table.getActiveGroupInstance()
268
+ const drilledKey = parseActiveGroupKey(query, table, activeGroupCol, prefix)
269
+ const effectivePageWithDrill = drilledKey !== undefined ? 1 : effectivePage
270
+
227
271
  const ctx: TableContext = {
228
272
  ...(search !== undefined ? { search } : {}),
229
273
  ...(effectiveSort !== undefined ? { sort: effectiveSort } : {}),
@@ -232,7 +276,10 @@ export async function loadTableRecords(
232
276
  ...(activeTab ? { tab: activeTab.name } : {}),
233
277
  ...(activeTab?.getQuery() ? { tabQuery: activeTab.getQuery()! } : {}),
234
278
  ...(user != null ? { user } : {}),
235
- page: effectivePage,
279
+ ...(drilledKey !== undefined && activeGroup !== undefined
280
+ ? { groupScope: { group: activeGroup, key: drilledKey } }
281
+ : {}),
282
+ page: effectivePageWithDrill,
236
283
  }
237
284
 
238
285
  // Apply the tab's TableContext customizer last (escape hatch — wins
@@ -305,25 +352,31 @@ export async function loadTableRecords(
305
352
  .filter((c): c is Column => c instanceof Column && c.isEditable())
306
353
  const canEditEditableColumns = editableColumns.length > 0 && hooks?.canEdit !== undefined
307
354
 
355
+ // SelectColumns with a per-row options resolver — pre-filtered so the
356
+ // row loop doesn't re-walk `editableColumns` to find them. Stamps
357
+ // `row._cellSelectOptions[col.name]` with the resolved option list.
358
+ const selectColumnsWithResolver: Array<{ name: string; resolve: SelectColumnOptionsResolver }> =
359
+ editableColumns
360
+ .filter((c): c is SelectColumn => c instanceof SelectColumn)
361
+ .map(c => ({ name: c.name, resolve: c.getOptionsResolver() }))
362
+ .filter((c): c is { name: string; resolve: SelectColumnOptionsResolver } => c.resolve !== undefined)
363
+
308
364
  const recordUrlFn = table.getRecordUrl()
309
365
  const recordClassesFn = table.getRecordClasses()
310
366
  const cardSchemaFn = table.isCardsLayout() ? table.getCardSchema() : undefined
311
- // Reconcile `?group=` against the configured groups + defaultGroup.
312
- // Stamp the resolved column back onto the table so `toMeta()`
313
- // emits it as the meta's `defaultGroup` (the renderer reads from
314
- // there). Empty-string "explicit clear" is distinct from `undefined`
315
- // ("never reconciled") so we can tell tests-skipping-loadTableRecords
316
- // apart from URL-cleared.
317
- const activeGroupCol = parseActiveGroup(query, table, prefix)
318
- table.withActiveGroup(activeGroupCol ?? '')
319
- // Resolve to a TableGroup instance synth a no-metadata instance
320
- // for bare-column / unregistered columns so per-row stamping has
321
- // a uniform shape.
322
- const activeGroup = activeGroupCol === undefined
323
- ? undefined
324
- : (table.getGroups().find(g => g.getColumn() === activeGroupCol)
325
- ?? TableGroup.make(activeGroupCol))
326
- const groupColumn = activeGroup?.getColumn()
367
+ // `activeGroupCol` + `activeGroup` are reconciled at the top of
368
+ // the table-async callback. Stamp the drilled key back onto the
369
+ // table here so `toMeta()` emits `activeGroupKey` alongside the
370
+ // rest of the render-time state. When drilled in, the rendered
371
+ // set is already filtered to one bucket — the renderer doesn't
372
+ // want heading rows or per-group summaries. `bandingActive`
373
+ // gates every banding-side decision (`_groupValue` stamping,
374
+ // stable-sort, per-group summaries); `activeGroup` stays bound
375
+ // so the chip + scope wiring can still reach the resolved
376
+ // group instance.
377
+ table.withActiveGroupKey(drilledKey)
378
+ const bandingActive = activeGroup !== undefined && drilledKey === undefined
379
+ const groupColumn = bandingActive ? activeGroup!.getColumn() : undefined
327
380
 
328
381
  const needsRowMutation =
329
382
  rowActionsWithRules.length > 0 ||
@@ -419,7 +472,7 @@ export async function loadTableRecords(
419
472
  const sanitizeOpt = col.getSanitize()
420
473
  const finalHtml = sanitizeOpt === false
421
474
  ? html
422
- : sanitizeHtml(html, sanitizeOpt === true ? undefined : sanitizeOpt)
475
+ : await sanitizeHtml(html, sanitizeOpt === true ? undefined : sanitizeOpt)
423
476
  formatted[col.name] = finalHtml
424
477
  rich[col.name] = true
425
478
  }
@@ -448,30 +501,28 @@ export async function loadTableRecords(
448
501
  }
449
502
  }
450
503
 
451
- if (activeGroup !== undefined) {
452
- const raw = recordObj[activeGroup.getColumn()]
504
+ if (bandingActive) {
505
+ const raw = recordObj[activeGroup!.getColumn()]
453
506
  // Date-bucketed groups use `YYYY-MM-DD` as the stable-sort
454
507
  // key so all rows from the same day cluster together;
455
508
  // unparseable values bucket to '' (bottom).
456
- const value = activeGroup.isDate()
457
- ? bucketDateValue(raw)
458
- : (raw == null || raw === '' ? '' : String(raw))
509
+ const value = activeGroup!.resolveKey(row)
459
510
  out['_groupValue'] = value
460
511
 
461
512
  // Per-row resolved title. User-supplied handler wins over
462
513
  // the date() default formatter. Throwing handler stays
463
514
  // silent — falls back to the raw `_groupValue`.
464
- const titleFn = activeGroup.getTitleHandler()
515
+ const titleFn = activeGroup!.getTitleHandler()
465
516
  if (titleFn) {
466
517
  try {
467
518
  const t = titleFn(row)
468
519
  if (t !== undefined) out['_groupTitle'] = String(t)
469
520
  } catch { /* silent */ }
470
- } else if (activeGroup.isDate() && value !== '') {
521
+ } else if (activeGroup!.isDate() && value !== '') {
471
522
  out['_groupTitle'] = formatDateBucketTitle(raw)
472
523
  }
473
524
 
474
- const descFn = activeGroup.getDescriptionHandler()
525
+ const descFn = activeGroup!.getDescriptionHandler()
475
526
  if (descFn) {
476
527
  try {
477
528
  const d = descFn(row)
@@ -531,6 +582,30 @@ export async function loadTableRecords(
531
582
  if (Object.keys(disabledMap).length > 0) {
532
583
  out['_cellDisabled'] = disabledMap
533
584
  }
585
+
586
+ // Per-row select options. Resolvers run in parallel so a
587
+ // table with several SelectColumn resolvers doesn't stall
588
+ // on serial awaits. A throwing resolver leaves the slot
589
+ // unset — the renderer falls back to the column-level
590
+ // static `selectOptions` (or an empty list) so one bad
591
+ // row doesn't break the whole table.
592
+ if (selectColumnsWithResolver.length > 0) {
593
+ const resolvedEntries = await Promise.all(selectColumnsWithResolver.map(async (c) => {
594
+ try {
595
+ const opts = await c.resolve(recordObj)
596
+ return [c.name, normalizeSelectOptions(opts)] as const
597
+ } catch {
598
+ return [c.name, undefined] as const
599
+ }
600
+ }))
601
+ const optionsMap: Record<string, ColumnSelectOption[]> = {}
602
+ for (const [name, opts] of resolvedEntries) {
603
+ if (opts !== undefined) optionsMap[name] = opts
604
+ }
605
+ if (Object.keys(optionsMap).length > 0) {
606
+ out['_cellSelectOptions'] = optionsMap
607
+ }
608
+ }
534
609
  }
535
610
  }
536
611
 
@@ -566,11 +641,13 @@ export async function loadTableRecords(
566
641
  table.withSummaries(summaries)
567
642
 
568
643
  // Per-group summaries — one summary set per `_groupValue`. Only
569
- // computed when a group is active; otherwise the global tfoot
644
+ // computed when a group is banded; otherwise the global tfoot
570
645
  // is the only summary row. Empty-group bucket ('') still gets
571
646
  // its own row when present (mirrors the bucket-to-bottom rule
572
- // already used for sorting).
573
- if (activeGroup !== undefined) {
647
+ // already used for sorting). Drill-in mode (`bandingActive=false`)
648
+ // intentionally skips per-group summaries — the visible rows are
649
+ // already one bucket.
650
+ if (bandingActive) {
574
651
  const buckets: Record<string, Array<Record<string, unknown>>> = {}
575
652
  for (const r of finalRows as Array<Record<string, unknown>>) {
576
653
  const key = String(r['_groupValue'] ?? '')
@@ -594,10 +671,12 @@ export async function loadTableRecords(
594
671
  }
595
672
 
596
673
  // Mirror the resolved context back onto the table so the renderer can
597
- // produce sort/search/page links without re-parsing the URL.
674
+ // produce sort/search/page links without re-parsing the URL. Drilled-in
675
+ // requests reset the visible page to 1 — keep the mirror in sync so
676
+ // pagination chrome doesn't claim page N on a single-bucket render.
598
677
  if (effectiveSort) table.withSort(effectiveSort.column, effectiveSort.direction)
599
678
  if (search !== undefined) table.withSearch(search)
600
- table.withPage(effectivePage)
679
+ table.withPage(effectivePageWithDrill)
601
680
  if (pathname) table.withCurrentPath(pathname)
602
681
  }))
603
682
  }
@@ -393,6 +393,21 @@ describe('Field cross-field plumbing (Plan #6)', () => {
393
393
  const meta = TextField.make('amount').inlineLabel().inlineLabel(false).toMeta()
394
394
  assert.equal('inlineLabel' in meta, false)
395
395
  })
396
+
397
+ it('reads RenderContext.inlineLabelDefault as a fallback when unset', () => {
398
+ const meta = TextField.make('amount').toMeta({ inlineLabelDefault: true })
399
+ assert.equal(meta.inlineLabel, true)
400
+ })
401
+
402
+ it('explicit inlineLabel(false) wins over RenderContext.inlineLabelDefault', () => {
403
+ const meta = TextField.make('amount').inlineLabel(false).toMeta({ inlineLabelDefault: true })
404
+ assert.equal('inlineLabel' in meta, false)
405
+ })
406
+
407
+ it('explicit inlineLabel(true) emits regardless of ctx', () => {
408
+ const meta = TextField.make('amount').inlineLabel(true).toMeta({ inlineLabelDefault: false })
409
+ assert.equal(meta.inlineLabel, true)
410
+ })
396
411
  })
397
412
 
398
413
  describe('default()', () => {
@@ -250,7 +250,7 @@ export abstract class Field extends Element {
250
250
  protected _prefix?: FieldDecoration
251
251
  protected _suffix?: FieldDecoration
252
252
  protected _helperText?: string
253
- protected _inlineLabel = false
253
+ protected _inlineLabel?: boolean
254
254
  protected _default?: unknown
255
255
  protected _dehydrated = true
256
256
  protected _formatStateUsing?: FormatStateUsingHandler
@@ -452,7 +452,12 @@ export abstract class Field extends Element {
452
452
  /**
453
453
  * Render the label to the left of the input rather than above it.
454
454
  * Mirrors `Entry.inlineLabel()`. Default is label-above; pass `false`
455
- * to clear the flag.
455
+ * to clear the flag — including overriding a cascading default set
456
+ * via `Form.inlineLabel()` / `Section.inlineLabel()`.
457
+ *
458
+ * Resolution at meta-build time: explicit field setting wins; when
459
+ * unset, the nearest ancestor `Form` / `Section` cascade kicks in
460
+ * (`RenderContext.inlineLabelDefault`); when neither is set, label-above.
456
461
  */
457
462
  inlineLabel(v = true): this { this._inlineLabel = v; return this }
458
463
 
@@ -756,7 +761,7 @@ export abstract class Field extends Element {
756
761
  ...(this._prefix !== undefined ? { prefix: this._prefix } : {}),
757
762
  ...(this._suffix !== undefined ? { suffix: this._suffix } : {}),
758
763
  ...(this._helperText !== undefined ? { helperText: this._helperText } : {}),
759
- ...(this._inlineLabel ? { inlineLabel: true } : {}),
764
+ ...(this._inlineLabel === true || (this._inlineLabel === undefined && ctx?.inlineLabelDefault === true) ? { inlineLabel: true } : {}),
760
765
  ...(this._default !== undefined ? { defaultValue: this._default } : {}),
761
766
  ...(formattedValue !== undefined ? { formattedValue } : {}),
762
767
  ...(this._autofocus ? { autofocus: true } : {}),