@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.
- package/.turbo/turbo-build.log +6 -2
- package/CHANGELOG.md +608 -0
- package/CLAUDE.md +6 -5
- package/dist/Column.d.ts +35 -0
- package/dist/Column.d.ts.map +1 -1
- package/dist/Column.js +41 -0
- package/dist/Column.js.map +1 -1
- package/dist/Page.d.ts +13 -4
- package/dist/Page.d.ts.map +1 -1
- package/dist/Page.js +9 -2
- package/dist/Page.js.map +1 -1
- package/dist/Pilotiq.d.ts +84 -0
- package/dist/Pilotiq.d.ts.map +1 -1
- package/dist/Pilotiq.js +66 -0
- package/dist/Pilotiq.js.map +1 -1
- package/dist/Resource.d.ts +26 -0
- package/dist/Resource.d.ts.map +1 -1
- package/dist/Resource.js +9 -0
- package/dist/Resource.js.map +1 -1
- package/dist/actions/exportFactory.js +1 -1
- package/dist/actions/exportFactory.js.map +1 -1
- package/dist/columns/SelectColumn.d.ts +32 -5
- package/dist/columns/SelectColumn.d.ts.map +1 -1
- package/dist/columns/SelectColumn.js +37 -7
- package/dist/columns/SelectColumn.js.map +1 -1
- package/dist/defaultPages.d.ts.map +1 -1
- package/dist/defaultPages.js +3 -0
- package/dist/defaultPages.js.map +1 -1
- package/dist/elements/Form.d.ts +17 -0
- package/dist/elements/Form.d.ts.map +1 -1
- package/dist/elements/Form.js +17 -0
- package/dist/elements/Form.js.map +1 -1
- package/dist/elements/Table.d.ts +26 -0
- package/dist/elements/Table.d.ts.map +1 -1
- package/dist/elements/Table.js +15 -1
- package/dist/elements/Table.js.map +1 -1
- package/dist/elements/TableGroup.d.ts +84 -0
- package/dist/elements/TableGroup.d.ts.map +1 -1
- package/dist/elements/TableGroup.js +103 -0
- package/dist/elements/TableGroup.js.map +1 -1
- package/dist/elements/dispatchForm.d.ts.map +1 -1
- package/dist/elements/dispatchForm.js +36 -6
- package/dist/elements/dispatchForm.js.map +1 -1
- package/dist/elements/dispatchTable.d.ts +12 -0
- package/dist/elements/dispatchTable.d.ts.map +1 -1
- package/dist/elements/dispatchTable.js +103 -28
- package/dist/elements/dispatchTable.js.map +1 -1
- package/dist/fields/Field.d.ts +7 -2
- package/dist/fields/Field.d.ts.map +1 -1
- package/dist/fields/Field.js +8 -3
- package/dist/fields/Field.js.map +1 -1
- package/dist/fields/RepeaterField.d.ts +65 -0
- package/dist/fields/RepeaterField.d.ts.map +1 -1
- package/dist/fields/RepeaterField.js +48 -0
- package/dist/fields/RepeaterField.js.map +1 -1
- package/dist/orm/modelDefaults.d.ts.map +1 -1
- package/dist/orm/modelDefaults.js +19 -0
- package/dist/orm/modelDefaults.js.map +1 -1
- package/dist/pageData.d.ts +20 -0
- package/dist/pageData.d.ts.map +1 -1
- package/dist/pageData.js +242 -34
- package/dist/pageData.js.map +1 -1
- package/dist/react/AppShell.d.ts +17 -1
- package/dist/react/AppShell.d.ts.map +1 -1
- package/dist/react/AppShell.js +34 -3
- package/dist/react/AppShell.js.map +1 -1
- package/dist/react/PendingSuggestionApplierRegistry.d.ts +34 -0
- package/dist/react/PendingSuggestionApplierRegistry.d.ts.map +1 -0
- package/dist/react/PendingSuggestionApplierRegistry.js +51 -0
- package/dist/react/PendingSuggestionApplierRegistry.js.map +1 -0
- package/dist/react/PendingSuggestionOverlayRegistry.d.ts +46 -0
- package/dist/react/PendingSuggestionOverlayRegistry.d.ts.map +1 -0
- package/dist/react/PendingSuggestionOverlayRegistry.js +16 -0
- package/dist/react/PendingSuggestionOverlayRegistry.js.map +1 -0
- package/dist/react/PendingSuggestionsContext.d.ts +153 -0
- package/dist/react/PendingSuggestionsContext.d.ts.map +1 -0
- package/dist/react/PendingSuggestionsContext.js +46 -0
- package/dist/react/PendingSuggestionsContext.js.map +1 -0
- package/dist/react/SchemaRenderer.d.ts.map +1 -1
- package/dist/react/SchemaRenderer.js +312 -39
- package/dist/react/SchemaRenderer.js.map +1 -1
- package/dist/react/cells/EditableCell.d.ts +8 -0
- package/dist/react/cells/EditableCell.d.ts.map +1 -1
- package/dist/react/cells/EditableCell.js +6 -2
- package/dist/react/cells/EditableCell.js.map +1 -1
- package/dist/react/fields/CheckboxListInput.d.ts.map +1 -1
- package/dist/react/fields/CheckboxListInput.js +29 -2
- package/dist/react/fields/CheckboxListInput.js.map +1 -1
- package/dist/react/fields/ColorInput.d.ts.map +1 -1
- package/dist/react/fields/ColorInput.js +28 -2
- package/dist/react/fields/ColorInput.js.map +1 -1
- package/dist/react/fields/DateTimeInput.d.ts.map +1 -1
- package/dist/react/fields/DateTimeInput.js +28 -2
- package/dist/react/fields/DateTimeInput.js.map +1 -1
- package/dist/react/fields/FieldShell.d.ts.map +1 -1
- package/dist/react/fields/FieldShell.js +161 -3
- package/dist/react/fields/FieldShell.js.map +1 -1
- package/dist/react/fields/FileUploadInput.d.ts.map +1 -1
- package/dist/react/fields/FileUploadInput.js +27 -2
- package/dist/react/fields/FileUploadInput.js.map +1 -1
- package/dist/react/fields/KeyValueInput.d.ts.map +1 -1
- package/dist/react/fields/KeyValueInput.js +33 -2
- package/dist/react/fields/KeyValueInput.js.map +1 -1
- package/dist/react/fields/RadioInput.d.ts.map +1 -1
- package/dist/react/fields/RadioInput.js +28 -2
- package/dist/react/fields/RadioInput.js.map +1 -1
- package/dist/react/fields/SelectFieldInput.d.ts.map +1 -1
- package/dist/react/fields/SelectFieldInput.js +31 -2
- package/dist/react/fields/SelectFieldInput.js.map +1 -1
- package/dist/react/fields/SliderInput.d.ts.map +1 -1
- package/dist/react/fields/SliderInput.js +26 -2
- package/dist/react/fields/SliderInput.js.map +1 -1
- package/dist/react/fields/TagsInput.d.ts.map +1 -1
- package/dist/react/fields/TagsInput.js +26 -2
- package/dist/react/fields/TagsInput.js.map +1 -1
- package/dist/react/fields/ToggleFieldInput.d.ts.map +1 -1
- package/dist/react/fields/ToggleFieldInput.js +29 -2
- package/dist/react/fields/ToggleFieldInput.js.map +1 -1
- package/dist/react/index.d.ts +3 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +3 -0
- package/dist/react/index.js.map +1 -1
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +55 -2
- package/dist/routes.js.map +1 -1
- package/dist/schema/Section.d.ts +16 -0
- package/dist/schema/Section.d.ts.map +1 -1
- package/dist/schema/Section.js +16 -0
- package/dist/schema/Section.js.map +1 -1
- package/dist/schema/Wizard.d.ts +45 -0
- package/dist/schema/Wizard.d.ts.map +1 -1
- package/dist/schema/Wizard.js +50 -0
- package/dist/schema/Wizard.js.map +1 -1
- package/dist/schema/resolveSchema.d.ts +8 -0
- package/dist/schema/resolveSchema.d.ts.map +1 -1
- package/dist/schema/resolveSchema.js +70 -1
- package/dist/schema/resolveSchema.js.map +1 -1
- package/dist/sessionFilters.d.ts.map +1 -1
- package/dist/sessionFilters.js +12 -1
- package/dist/sessionFilters.js.map +1 -1
- package/dist/styles/file-upload.css +13 -0
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +9 -2
- package/dist/vite.js.map +1 -1
- package/package.json +6 -4
- package/src/Column.test.ts +36 -0
- package/src/Column.ts +54 -0
- package/src/Page.ts +13 -4
- package/src/Pilotiq.ts +109 -0
- package/src/Resource.ts +29 -0
- package/src/actions/exportFactory.ts +1 -1
- package/src/columns/SelectColumn.ts +46 -8
- package/src/columns/editableColumns.test.ts +45 -0
- package/src/defaultPages.ts +3 -0
- package/src/elements/Form.ts +19 -0
- package/src/elements/Table.ts +35 -1
- package/src/elements/TableGroup.test.ts +111 -0
- package/src/elements/TableGroup.ts +135 -0
- package/src/elements/dispatchForm.ts +34 -7
- package/src/elements/dispatchTable.test.ts +267 -0
- package/src/elements/dispatchTable.ts +111 -32
- package/src/fields/Field.test.ts +15 -0
- package/src/fields/Field.ts +8 -3
- package/src/fields/RepeaterField.ts +104 -0
- package/src/fields/RepeaterRelationship.test.ts +173 -0
- package/src/nestedRelationManagerData.test.ts +21 -0
- package/src/orm/modelDefaults.ts +21 -0
- package/src/pageData.ts +267 -47
- package/src/react/AppShell.tsx +55 -4
- package/src/react/PendingSuggestionApplierRegistry.ts +80 -0
- package/src/react/PendingSuggestionOverlayRegistry.ts +54 -0
- package/src/react/PendingSuggestionsContext.tsx +172 -0
- package/src/react/SchemaRenderer.tsx +504 -95
- package/src/react/cells/EditableCell.tsx +11 -2
- package/src/react/fields/CheckboxListInput.tsx +23 -2
- package/src/react/fields/ColorInput.tsx +22 -2
- package/src/react/fields/DateTimeInput.tsx +22 -2
- package/src/react/fields/FieldShell.tsx +167 -3
- package/src/react/fields/FileUploadInput.tsx +21 -2
- package/src/react/fields/KeyValueInput.tsx +32 -2
- package/src/react/fields/RadioInput.tsx +23 -2
- package/src/react/fields/SelectFieldInput.tsx +25 -2
- package/src/react/fields/SliderInput.tsx +20 -2
- package/src/react/fields/TagsInput.tsx +20 -2
- package/src/react/fields/ToggleFieldInput.tsx +23 -2
- package/src/react/index.ts +18 -0
- package/src/relationManagerData.test.ts +451 -2
- package/src/routes.ts +58 -2
- package/src/schema/Section.ts +17 -0
- package/src/schema/Wizard.ts +67 -0
- package/src/schema/containers.test.ts +90 -0
- package/src/schema/resolveSchema.test.ts +50 -0
- package/src/schema/resolveSchema.ts +79 -1
- package/src/sessionFilters.test.ts +23 -0
- package/src/sessionFilters.ts +11 -1
- package/src/styles/file-upload.css +13 -0
- package/src/vite.ts +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
|
-
|
|
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
|
-
//
|
|
312
|
-
// Stamp the
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
|
|
322
|
-
const
|
|
323
|
-
|
|
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 ||
|
|
@@ -448,30 +501,28 @@ export async function loadTableRecords(
|
|
|
448
501
|
}
|
|
449
502
|
}
|
|
450
503
|
|
|
451
|
-
if (
|
|
452
|
-
const raw = recordObj[activeGroup
|
|
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
|
|
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
|
|
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
|
|
521
|
+
} else if (activeGroup!.isDate() && value !== '') {
|
|
471
522
|
out['_groupTitle'] = formatDateBucketTitle(raw)
|
|
472
523
|
}
|
|
473
524
|
|
|
474
|
-
const descFn = activeGroup
|
|
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
|
|
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
|
-
|
|
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(
|
|
679
|
+
table.withPage(effectivePageWithDrill)
|
|
601
680
|
if (pathname) table.withCurrentPath(pathname)
|
|
602
681
|
}))
|
|
603
682
|
}
|
package/src/fields/Field.test.ts
CHANGED
|
@@ -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()', () => {
|
package/src/fields/Field.ts
CHANGED
|
@@ -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
|
|
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 } : {}),
|