@pilotiq/pilotiq 0.23.1 → 0.24.2
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/CHANGELOG.md +91 -0
- package/boost/guidelines.md +566 -0
- package/boost/skills/pilotiq-fields/SKILL.md +47 -0
- package/boost/skills/pilotiq-fields/rules/field-catalog.md +288 -0
- package/boost/skills/pilotiq-fields/rules/reactive-fields.md +199 -0
- package/boost/skills/pilotiq-fields/rules/validation.md +198 -0
- package/boost/skills/pilotiq-relations/SKILL.md +47 -0
- package/boost/skills/pilotiq-relations/rules/relation-managers.md +256 -0
- package/boost/skills/pilotiq-relations/rules/repeater-relationship.md +177 -0
- package/boost/skills/pilotiq-resource/SKILL.md +61 -0
- package/boost/skills/pilotiq-resource/rules/authorization.md +242 -0
- package/boost/skills/pilotiq-resource/rules/defining-resources.md +228 -0
- package/boost/skills/pilotiq-resource/rules/page-overrides.md +296 -0
- package/dist/actions/exportFactory.d.ts +10 -0
- package/dist/actions/exportFactory.d.ts.map +1 -1
- package/dist/actions/exportFactory.js +10 -0
- package/dist/actions/exportFactory.js.map +1 -1
- package/dist/react/CollabRoomContext.d.ts +5 -5
- package/dist/react/index.d.ts +0 -1
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +0 -1
- package/dist/react/index.js.map +1 -1
- package/dist/routes/helpers.d.ts.map +1 -1
- package/dist/routes/helpers.js +6 -2
- package/dist/routes/helpers.js.map +1 -1
- package/dist/routes/relations.d.ts.map +1 -1
- package/dist/routes/relations.js +12 -0
- package/dist/routes/relations.js.map +1 -1
- package/package.json +6 -1
- package/.turbo/turbo-build.log +0 -8
- package/CLAUDE.md +0 -265
- package/dist/react/useCollabSeed.d.ts +0 -23
- package/dist/react/useCollabSeed.d.ts.map +0 -1
- package/dist/react/useCollabSeed.js +0 -82
- package/dist/react/useCollabSeed.js.map +0 -1
- package/src/Cluster.test.ts +0 -283
- package/src/Cluster.ts +0 -83
- package/src/Column.test.ts +0 -199
- package/src/Column.ts +0 -710
- package/src/Global.test.ts +0 -367
- package/src/Global.ts +0 -169
- package/src/Page.test.ts +0 -114
- package/src/Page.ts +0 -208
- package/src/Pilotiq.perf.test.ts +0 -252
- package/src/Pilotiq.test.ts +0 -129
- package/src/Pilotiq.ts +0 -1158
- package/src/PilotiqRegistry.ts +0 -36
- package/src/PilotiqServiceProvider.ts +0 -121
- package/src/RelationManager.test.ts +0 -400
- package/src/RelationManager.ts +0 -527
- package/src/RenderHook.test.ts +0 -252
- package/src/RenderHook.ts +0 -242
- package/src/Resource.test.ts +0 -284
- package/src/Resource.ts +0 -526
- package/src/RightPanel.test.ts +0 -202
- package/src/RightPanel.ts +0 -132
- package/src/Tab.test.ts +0 -91
- package/src/Tab.ts +0 -156
- package/src/UserMenuItem.ts +0 -145
- package/src/actions/Action.test.ts +0 -2526
- package/src/actions/Action.ts +0 -1515
- package/src/actions/ActionGroup.test.ts +0 -112
- package/src/actions/ActionGroup.ts +0 -173
- package/src/actions/attachFactory.ts +0 -172
- package/src/actions/bulkFactories.ts +0 -168
- package/src/actions/crudFactories.ts +0 -220
- package/src/actions/exportFactory.ts +0 -215
- package/src/actions/factoryHelpers.ts +0 -177
- package/src/actions/importFactory.ts +0 -243
- package/src/actions/index.ts +0 -17
- package/src/actions/m2mFactories.ts +0 -193
- package/src/actions/relationFactories.ts +0 -372
- package/src/applyPageHooks.test.ts +0 -463
- package/src/applyPageHooks.ts +0 -330
- package/src/authorization.test.ts +0 -483
- package/src/breadcrumbs.test.ts +0 -238
- package/src/cells/coerce.test.ts +0 -85
- package/src/cells/coerce.ts +0 -84
- package/src/clusterPaths.ts +0 -35
- package/src/columns/BadgeColumn.test.ts +0 -54
- package/src/columns/BadgeColumn.ts +0 -32
- package/src/columns/BooleanColumn.test.ts +0 -41
- package/src/columns/BooleanColumn.ts +0 -18
- package/src/columns/ColorColumn.test.ts +0 -37
- package/src/columns/ColorColumn.ts +0 -38
- package/src/columns/IconColumn.test.ts +0 -54
- package/src/columns/IconColumn.ts +0 -37
- package/src/columns/ImageColumn.test.ts +0 -41
- package/src/columns/ImageColumn.ts +0 -28
- package/src/columns/SelectColumn.ts +0 -98
- package/src/columns/TextColumn.test.ts +0 -190
- package/src/columns/TextColumn.ts +0 -20
- package/src/columns/TextInputColumn.ts +0 -68
- package/src/columns/ToggleColumn.ts +0 -46
- package/src/columns/editableColumns.test.ts +0 -238
- package/src/columns/index.ts +0 -9
- package/src/defaultGlobalPages.ts +0 -95
- package/src/defaultPages.test.ts +0 -634
- package/src/defaultPages.ts +0 -617
- package/src/defaultViewPage.test.ts +0 -147
- package/src/elements/Form.test.ts +0 -223
- package/src/elements/Form.ts +0 -416
- package/src/elements/ListTabs.ts +0 -28
- package/src/elements/Table.test.ts +0 -422
- package/src/elements/Table.ts +0 -850
- package/src/elements/TableGroup.test.ts +0 -260
- package/src/elements/TableGroup.ts +0 -334
- package/src/elements/dispatchAction.test.ts +0 -463
- package/src/elements/dispatchAction.ts +0 -355
- package/src/elements/dispatchForm.test.ts +0 -477
- package/src/elements/dispatchForm.ts +0 -1993
- package/src/elements/dispatchTable.test.ts +0 -1514
- package/src/elements/dispatchTable.ts +0 -745
- package/src/elements/index.ts +0 -21
- package/src/entries/BadgeEntry.ts +0 -39
- package/src/entries/CodeEntry.test.ts +0 -40
- package/src/entries/CodeEntry.ts +0 -52
- package/src/entries/ColorEntry.ts +0 -63
- package/src/entries/ComponentEntry.test.ts +0 -173
- package/src/entries/ComponentEntry.ts +0 -95
- package/src/entries/Entry.ts +0 -304
- package/src/entries/IconEntry.ts +0 -49
- package/src/entries/ImageEntry.ts +0 -61
- package/src/entries/KeyValueEntry.ts +0 -47
- package/src/entries/RepeatableEntry.test.ts +0 -239
- package/src/entries/RepeatableEntry.ts +0 -173
- package/src/entries/TextEntry.test.ts +0 -394
- package/src/entries/TextEntry.ts +0 -60
- package/src/entries/index.ts +0 -12
- package/src/entries/leaves.test.ts +0 -306
- package/src/entries/registry.ts +0 -54
- package/src/fields/BuilderField.test.ts +0 -1188
- package/src/fields/BuilderField.ts +0 -605
- package/src/fields/BuilderRelationship.test.ts +0 -811
- package/src/fields/CheckboxField.test.ts +0 -44
- package/src/fields/CheckboxField.ts +0 -27
- package/src/fields/CheckboxListField.test.ts +0 -99
- package/src/fields/CheckboxListField.ts +0 -66
- package/src/fields/ColorPickerField.test.ts +0 -33
- package/src/fields/ColorPickerField.ts +0 -25
- package/src/fields/DateField.ts +0 -54
- package/src/fields/DateTimeField.test.ts +0 -55
- package/src/fields/EmailField.ts +0 -16
- package/src/fields/Field.test.ts +0 -654
- package/src/fields/Field.ts +0 -817
- package/src/fields/FileUploadField.test.ts +0 -143
- package/src/fields/FileUploadField.ts +0 -159
- package/src/fields/HiddenField.test.ts +0 -27
- package/src/fields/HiddenField.ts +0 -28
- package/src/fields/KeyValueField.test.ts +0 -105
- package/src/fields/KeyValueField.ts +0 -55
- package/src/fields/MarkdownField.test.ts +0 -167
- package/src/fields/MarkdownField.ts +0 -162
- package/src/fields/NumberField.ts +0 -33
- package/src/fields/RadioField.test.ts +0 -94
- package/src/fields/RadioField.ts +0 -67
- package/src/fields/RepeaterField.test.ts +0 -1806
- package/src/fields/RepeaterField.ts +0 -939
- package/src/fields/RepeaterRelationship.test.ts +0 -1923
- package/src/fields/RepeaterSimple.test.ts +0 -248
- package/src/fields/RowButton.test.ts +0 -219
- package/src/fields/RowButton.ts +0 -135
- package/src/fields/SelectField.test.ts +0 -192
- package/src/fields/SelectField.ts +0 -235
- package/src/fields/SliderField.test.ts +0 -50
- package/src/fields/SliderField.ts +0 -53
- package/src/fields/SlugField.ts +0 -24
- package/src/fields/TagsInputField.test.ts +0 -154
- package/src/fields/TagsInputField.ts +0 -133
- package/src/fields/TextField.test.ts +0 -213
- package/src/fields/TextField.ts +0 -177
- package/src/fields/TextareaField.test.ts +0 -58
- package/src/fields/TextareaField.ts +0 -59
- package/src/fields/ToggleButtonsField.test.ts +0 -106
- package/src/fields/ToggleButtonsField.ts +0 -59
- package/src/fields/ToggleField.ts +0 -16
- package/src/fields/disableOptionsWhenSelectedInSiblingRepeaterItems.test.ts +0 -319
- package/src/fields/optionsResolver.ts +0 -95
- package/src/fields/resolveField.ts +0 -28
- package/src/filters/BooleanFilter.ts +0 -35
- package/src/filters/DateRangeFilter.test.ts +0 -194
- package/src/filters/DateRangeFilter.ts +0 -148
- package/src/filters/Filter.test.ts +0 -268
- package/src/filters/Filter.ts +0 -184
- package/src/filters/FormFilter.test.ts +0 -238
- package/src/filters/FormFilter.ts +0 -215
- package/src/filters/MultiSelectFilter.test.ts +0 -119
- package/src/filters/MultiSelectFilter.ts +0 -78
- package/src/filters/QueryBuilderFilter.test.ts +0 -662
- package/src/filters/QueryBuilderFilter.ts +0 -398
- package/src/filters/SelectFilter.ts +0 -46
- package/src/filters/TernaryFilter.test.ts +0 -160
- package/src/filters/TernaryFilter.ts +0 -72
- package/src/filters/TrashedFilter.test.ts +0 -149
- package/src/filters/TrashedFilter.ts +0 -55
- package/src/filters/queryBuilder/BooleanConstraint.ts +0 -31
- package/src/filters/queryBuilder/Constraint.ts +0 -115
- package/src/filters/queryBuilder/DateConstraint.ts +0 -69
- package/src/filters/queryBuilder/NumberConstraint.ts +0 -66
- package/src/filters/queryBuilder/SelectConstraint.ts +0 -72
- package/src/filters/queryBuilder/TextConstraint.ts +0 -64
- package/src/filters/queryBuilder/index.ts +0 -12
- package/src/icons/index.ts +0 -2
- package/src/icons/lucide.ts +0 -204
- package/src/icons/registry.test.ts +0 -56
- package/src/icons/registry.ts +0 -41
- package/src/icons/types.ts +0 -47
- package/src/index.ts +0 -525
- package/src/io/csv.test.ts +0 -142
- package/src/io/csv.ts +0 -170
- package/src/nestedRelationManagerData.test.ts +0 -547
- package/src/notifications/Notification.test.ts +0 -210
- package/src/notifications/Notification.ts +0 -354
- package/src/notifications/broadcast.test.ts +0 -110
- package/src/notifications/broadcast.ts +0 -95
- package/src/notifications/database.test.ts +0 -383
- package/src/notifications/database.ts +0 -398
- package/src/notifications/databaseNotifications.test.ts +0 -187
- package/src/notifications/dispatchNotificationAction.test.ts +0 -341
- package/src/notifications/dispatchNotificationAction.ts +0 -142
- package/src/notifications/flash.test.ts +0 -89
- package/src/notifications/flash.ts +0 -71
- package/src/notifications/index.ts +0 -45
- package/src/notifications/registerBroadcastAuth.test.ts +0 -134
- package/src/notifications/registerBroadcastAuth.ts +0 -100
- package/src/notifications/resolveSavedNotification.test.ts +0 -82
- package/src/notifications/resolveSavedNotification.ts +0 -59
- package/src/notifications/types.ts +0 -93
- package/src/orm/m2mAccessor.ts +0 -66
- package/src/orm/modelDefaults.test.ts +0 -633
- package/src/orm/modelDefaults.ts +0 -666
- package/src/pageData/breadcrumbs.ts +0 -288
- package/src/pageData/forms.ts +0 -578
- package/src/pageData/helpers.ts +0 -857
- package/src/pageData/misc.ts +0 -347
- package/src/pageData/navigation.ts +0 -842
- package/src/pageData/relationPages.ts +0 -1248
- package/src/pageData/relationTabs.ts +0 -286
- package/src/pageData/resourcePages.ts +0 -609
- package/src/pageData.test.ts +0 -1545
- package/src/pageData.ts +0 -341
- package/src/plugins/index.ts +0 -8
- package/src/plugins/themeEditor.test.ts +0 -36
- package/src/plugins/themeEditor.ts +0 -45
- package/src/react/AppShell.tsx +0 -251
- package/src/react/CollabExtensionFactoryRegistry.ts +0 -55
- package/src/react/CollabRoomContext.ts +0 -98
- package/src/react/CollabTextRendererRegistry.ts +0 -102
- package/src/react/CommandPalette.tsx +0 -375
- package/src/react/CurrentUserContext.tsx +0 -50
- package/src/react/CustomPageWrapperGate.tsx +0 -69
- package/src/react/CustomPageWrapperRegistry.ts +0 -45
- package/src/react/FieldFocusReporterRegistry.ts +0 -37
- package/src/react/FieldLabelSlotRegistry.ts +0 -30
- package/src/react/FieldPresenceRegistry.ts +0 -46
- package/src/react/FormCollabBindingRegistry.ts +0 -242
- package/src/react/FormStateContext.tsx +0 -591
- package/src/react/HeadHooks.tsx +0 -126
- package/src/react/MarkdownEditorRegistry.test.ts +0 -38
- package/src/react/MarkdownEditorRegistry.ts +0 -107
- package/src/react/NotificationActionStrip.tsx +0 -263
- package/src/react/NotificationBell.tsx +0 -426
- package/src/react/PendingSuggestionApplierRegistry.test.ts +0 -97
- package/src/react/PendingSuggestionApplierRegistry.ts +0 -98
- package/src/react/PendingSuggestionOverlayRegistry.ts +0 -54
- package/src/react/PendingSuggestionsContext.tsx +0 -172
- package/src/react/RecordWrapperGate.tsx +0 -58
- package/src/react/RecordWrapperRegistry.ts +0 -39
- package/src/react/RenderHookSlot.tsx +0 -32
- package/src/react/RightSidebar.tsx +0 -257
- package/src/react/RightSidebarContext.tsx +0 -234
- package/src/react/RightSidebarTrigger.tsx +0 -53
- package/src/react/RowCoordsContext.tsx +0 -23
- package/src/react/SchemaRenderer.tsx +0 -549
- package/src/react/SearchTrigger.tsx +0 -46
- package/src/react/ThemeProvider.tsx +0 -93
- package/src/react/ThemeSettingsPage.tsx +0 -579
- package/src/react/ThemeToggle.tsx +0 -20
- package/src/react/Toaster.tsx +0 -158
- package/src/react/UserMenu.tsx +0 -196
- package/src/react/WidgetDataContext.tsx +0 -157
- package/src/react/cells/EditableCell.tsx +0 -389
- package/src/react/component-slots.test.ts +0 -103
- package/src/react/component-slots.ts +0 -116
- package/src/react/fieldJsHandler.test.ts +0 -166
- package/src/react/fieldJsHandler.ts +0 -79
- package/src/react/fields/BuilderInput.tsx +0 -1078
- package/src/react/fields/CheckboxInput.tsx +0 -39
- package/src/react/fields/CheckboxListInput.tsx +0 -102
- package/src/react/fields/ColorInput.tsx +0 -71
- package/src/react/fields/DateFieldInput.tsx +0 -70
- package/src/react/fields/DateTimeInput.tsx +0 -62
- package/src/react/fields/FieldShell.tsx +0 -348
- package/src/react/fields/FileUploadInput.tsx +0 -639
- package/src/react/fields/HiddenInput.tsx +0 -17
- package/src/react/fields/KeyValueInput.tsx +0 -230
- package/src/react/fields/MarkdownInput.tsx +0 -560
- package/src/react/fields/RadioInput.tsx +0 -81
- package/src/react/fields/RepeaterInput.test.ts +0 -116
- package/src/react/fields/RepeaterInput.tsx +0 -1420
- package/src/react/fields/SelectFieldInput.tsx +0 -280
- package/src/react/fields/SliderInput.tsx +0 -81
- package/src/react/fields/TagsInput.tsx +0 -283
- package/src/react/fields/TextLikeInput.tsx +0 -256
- package/src/react/fields/ToggleButtonsInput.tsx +0 -60
- package/src/react/fields/ToggleFieldInput.tsx +0 -56
- package/src/react/fields/relationshipRenameDispatch.test.ts +0 -106
- package/src/react/fields/relationshipRenameDispatch.ts +0 -97
- package/src/react/fields/repeaterReconcile.test.ts +0 -114
- package/src/react/fields/repeaterReconcile.ts +0 -104
- package/src/react/fields/rowChromeButton.tsx +0 -336
- package/src/react/fields/rowState.ts +0 -106
- package/src/react/fields/syncRowGates.test.ts +0 -202
- package/src/react/fields/syncRowGates.ts +0 -66
- package/src/react/fields/textInputControls.tsx +0 -238
- package/src/react/fields/useRowReorderDnd.ts +0 -78
- package/src/react/formStateHelpers.test.ts +0 -508
- package/src/react/formStateHelpers.ts +0 -381
- package/src/react/hooks/use-mobile.ts +0 -19
- package/src/react/icon-context.tsx +0 -60
- package/src/react/index.ts +0 -195
- package/src/react/layouts/SidebarLayout.tsx +0 -250
- package/src/react/layouts/TopbarLayout.tsx +0 -258
- package/src/react/navigate.tsx +0 -37
- package/src/react/onProviderSynced.test.ts +0 -90
- package/src/react/parseRecordEditUrl.test.ts +0 -122
- package/src/react/parseRecordEditUrl.ts +0 -94
- package/src/react/persistedState.ts +0 -40
- package/src/react/registry.ts +0 -48
- package/src/react/right-panel-registry.tsx +0 -47
- package/src/react/schemaRenderer/AlertRenderer.tsx +0 -112
- package/src/react/schemaRenderer/EntryRenderer.tsx +0 -501
- package/src/react/schemaRenderer/SectionRenderer.tsx +0 -120
- package/src/react/schemaRenderer/SimpleElements.tsx +0 -306
- package/src/react/schemaRenderer/TabsRenderer.tsx +0 -62
- package/src/react/schemaRenderer/WizardRenderer.tsx +0 -338
- package/src/react/schemaRenderer/action/ActionGroupTrigger.tsx +0 -177
- package/src/react/schemaRenderer/action/ActionModalDialog.tsx +0 -273
- package/src/react/schemaRenderer/action/ConfirmActionDialog.tsx +0 -61
- package/src/react/schemaRenderer/action/HandlerActionButton.tsx +0 -43
- package/src/react/schemaRenderer/action/MethodActionButton.tsx +0 -64
- package/src/react/schemaRenderer/action/buttons.tsx +0 -99
- package/src/react/schemaRenderer/action/helpers.ts +0 -140
- package/src/react/schemaRenderer/action/renderAction.tsx +0 -245
- package/src/react/schemaRenderer/columnFormat.ts +0 -65
- package/src/react/schemaRenderer/constants.ts +0 -50
- package/src/react/schemaRenderer/form/FormRenderer.tsx +0 -274
- package/src/react/schemaRenderer/form/renderField.tsx +0 -511
- package/src/react/schemaRenderer/helpers.tsx +0 -81
- package/src/react/schemaRenderer/table/CardsLayoutBody.tsx +0 -308
- package/src/react/schemaRenderer/table/TableRenderer.tsx +0 -123
- package/src/react/schemaRenderer/table/TableRendererBody.tsx +0 -974
- package/src/react/schemaRenderer/table/filters.tsx +0 -1233
- package/src/react/schemaRenderer/table/formatCell.tsx +0 -264
- package/src/react/schemaRenderer/table/links.tsx +0 -112
- package/src/react/schemaRenderer/table/renderRowActions.tsx +0 -52
- package/src/react/schemaRenderer/table/url.tsx +0 -143
- package/src/react/theme-preview/apply.ts +0 -99
- package/src/react/theme-preview/build-html.ts +0 -436
- package/src/react/ui/button.tsx +0 -51
- package/src/react/ui/calendar.tsx +0 -67
- package/src/react/ui/checkbox.tsx +0 -29
- package/src/react/ui/dialog.tsx +0 -108
- package/src/react/ui/dropdown-menu.tsx +0 -97
- package/src/react/ui/input.tsx +0 -20
- package/src/react/ui/label.tsx +0 -21
- package/src/react/ui/popover.tsx +0 -50
- package/src/react/ui/select.tsx +0 -169
- package/src/react/ui/separator.tsx +0 -25
- package/src/react/ui/sheet.tsx +0 -136
- package/src/react/ui/sidebar.tsx +0 -723
- package/src/react/ui/skeleton.tsx +0 -13
- package/src/react/ui/slider.tsx +0 -34
- package/src/react/ui/switch.tsx +0 -28
- package/src/react/ui/table.tsx +0 -105
- package/src/react/ui/tabs.tsx +0 -63
- package/src/react/ui/textarea.tsx +0 -18
- package/src/react/ui/tooltip.tsx +0 -64
- package/src/react/useCollabSeed.ts +0 -86
- package/src/react/useResizableWidth.ts +0 -139
- package/src/react/utils.ts +0 -6
- package/src/react/widgetRegistry.test.ts +0 -43
- package/src/react/widgetRegistry.ts +0 -50
- package/src/react/widgets/StatsOverviewRenderer.tsx +0 -232
- package/src/react/widgets/TableWidgetRenderer.tsx +0 -231
- package/src/react/widgets/ViewRenderer.tsx +0 -71
- package/src/relationManagerData.test.ts +0 -1595
- package/src/richtext/index.ts +0 -8
- package/src/richtext/registry.ts +0 -89
- package/src/routes/globals.ts +0 -148
- package/src/routes/guard.test.ts +0 -325
- package/src/routes/helpers.ts +0 -700
- package/src/routes/pages.ts +0 -175
- package/src/routes/panel.ts +0 -204
- package/src/routes/relations.ts +0 -1227
- package/src/routes/resources.ts +0 -781
- package/src/routes/theme.ts +0 -91
- package/src/routes-nested-relations.test.ts +0 -676
- package/src/routes-relations.test.ts +0 -972
- package/src/routes.test.ts +0 -2027
- package/src/routes.ts +0 -303
- package/src/schema/Alert.test.ts +0 -109
- package/src/schema/Alert.ts +0 -131
- package/src/schema/Block.ts +0 -169
- package/src/schema/Breadcrumbs.ts +0 -40
- package/src/schema/Card.ts +0 -35
- package/src/schema/Divider.ts +0 -20
- package/src/schema/Element.ts +0 -219
- package/src/schema/EmptyState.test.ts +0 -37
- package/src/schema/EmptyState.ts +0 -63
- package/src/schema/Fieldset.ts +0 -43
- package/src/schema/Grid.ts +0 -43
- package/src/schema/Group.ts +0 -30
- package/src/schema/Heading.ts +0 -39
- package/src/schema/Html.ts +0 -67
- package/src/schema/Icon.ts +0 -54
- package/src/schema/Image.ts +0 -57
- package/src/schema/LinkTag.ts +0 -41
- package/src/schema/Markdown.ts +0 -85
- package/src/schema/MetaTag.ts +0 -41
- package/src/schema/RelationTabs.ts +0 -71
- package/src/schema/ScriptTag.ts +0 -55
- package/src/schema/Section.ts +0 -160
- package/src/schema/ServerDataElement.test.ts +0 -140
- package/src/schema/ServerDataElement.ts +0 -156
- package/src/schema/SlotComponent.test.ts +0 -77
- package/src/schema/SlotComponent.ts +0 -71
- package/src/schema/Split.ts +0 -50
- package/src/schema/Stat.test.ts +0 -118
- package/src/schema/Stat.ts +0 -154
- package/src/schema/StatsOverview.test.ts +0 -141
- package/src/schema/StatsOverview.ts +0 -119
- package/src/schema/StyleTag.ts +0 -35
- package/src/schema/TableWidget.test.ts +0 -297
- package/src/schema/TableWidget.ts +0 -289
- package/src/schema/Tabs.ts +0 -79
- package/src/schema/Text.ts +0 -58
- package/src/schema/UnorderedList.ts +0 -49
- package/src/schema/View.test.ts +0 -111
- package/src/schema/View.ts +0 -127
- package/src/schema/Wizard.ts +0 -220
- package/src/schema/containers.test.ts +0 -564
- package/src/schema/headTags.test.ts +0 -134
- package/src/schema/index.ts +0 -40
- package/src/schema/primes.test.ts +0 -269
- package/src/schema/resolveSchema.test.ts +0 -379
- package/src/schema/resolveSchema.ts +0 -917
- package/src/schema/sanitize.ts +0 -58
- package/src/search.test.ts +0 -446
- package/src/search.ts +0 -178
- package/src/sessionFilters.test.ts +0 -375
- package/src/sessionFilters.ts +0 -143
- package/src/slot-components/index.ts +0 -10
- package/src/slot-components/registry.ts +0 -56
- package/src/styles/file-upload.css +0 -13
- package/src/summarizers/Summarizer.test.ts +0 -84
- package/src/summarizers/Summarizer.ts +0 -123
- package/src/summarizers/index.ts +0 -11
- package/src/theme/base-colors.ts +0 -68
- package/src/theme/chart-colors.ts +0 -50
- package/src/theme/colors.ts +0 -447
- package/src/theme/generate-css.test.ts +0 -139
- package/src/theme/generate-css.ts +0 -44
- package/src/theme/generate-scale.test.ts +0 -106
- package/src/theme/generate-scale.ts +0 -97
- package/src/theme/icon-map.ts +0 -42
- package/src/theme/index.ts +0 -34
- package/src/theme/migrate.test.ts +0 -178
- package/src/theme/migrate.ts +0 -81
- package/src/theme/presets.ts +0 -135
- package/src/theme/radius.ts +0 -18
- package/src/theme/resolve.test.ts +0 -238
- package/src/theme/resolve.ts +0 -96
- package/src/theme/spacing.ts +0 -18
- package/src/theme/storage.test.ts +0 -126
- package/src/theme/storage.ts +0 -106
- package/src/theme/theme-colors.ts +0 -88
- package/src/theme/types.ts +0 -125
- package/src/uploads/UploadAdapter.ts +0 -35
- package/src/uploads/index.ts +0 -2
- package/src/uploads/localUpload.test.ts +0 -70
- package/src/uploads/localUpload.ts +0 -84
- package/src/validation/Validator.ts +0 -49
- package/src/validation/index.ts +0 -28
- package/src/validation/rules.ts +0 -78
- package/src/validation/runValidators.ts +0 -435
- package/src/validation/uniqueValidator.test.ts +0 -196
- package/src/validation/uniqueValidator.ts +0 -133
- package/src/validation/validators.test.ts +0 -268
- package/src/vite.test.ts +0 -184
- package/src/vite.ts +0 -787
- package/src/widgets/index.ts +0 -10
- package/src/widgets/registry.ts +0 -45
- package/src/widgets.test.ts +0 -592
- package/tsconfig.build.json +0 -11
- package/tsconfig.json +0 -4
- package/tsconfig.test.json +0 -10
- package/views/react/Dashboard.tsx +0 -27
- package/views/react/Resources/Form.tsx +0 -102
- package/views/react/Resources/Index.tsx +0 -49
|
@@ -1,1514 +0,0 @@
|
|
|
1
|
-
import { describe, it, beforeEach, afterEach } from 'node:test'
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
|
|
4
|
-
import { Table } from './Table.js'
|
|
5
|
-
import { TableGroup, orderByKeys } from './TableGroup.js'
|
|
6
|
-
import { Column } from '../Column.js'
|
|
7
|
-
import { Section } from '../schema/Section.js'
|
|
8
|
-
import { Heading } from '../schema/Heading.js'
|
|
9
|
-
import { Text } from '../schema/Text.js'
|
|
10
|
-
import { resolveSchema } from '../schema/resolveSchema.js'
|
|
11
|
-
import { Sum, Average, Count, Range } from '../summarizers/Summarizer.js'
|
|
12
|
-
import { TextInputColumn, ToggleColumn, SelectColumn } from '../columns/index.js'
|
|
13
|
-
import {
|
|
14
|
-
registerRichTextRenderer,
|
|
15
|
-
_resetRichTextRegistryForTests,
|
|
16
|
-
} from '../richtext/registry.js'
|
|
17
|
-
import {
|
|
18
|
-
parseTableQuery,
|
|
19
|
-
parseActiveGroup,
|
|
20
|
-
parseActiveGroupKey,
|
|
21
|
-
findTables,
|
|
22
|
-
loadTableRecords,
|
|
23
|
-
} from './dispatchTable.js'
|
|
24
|
-
|
|
25
|
-
describe('parseTableQuery', () => {
|
|
26
|
-
it('returns all-undefined for an empty input', () => {
|
|
27
|
-
assert.deepEqual(parseTableQuery({}), {
|
|
28
|
-
search: undefined, sort: undefined, page: undefined, perPage: undefined,
|
|
29
|
-
})
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
it('parses search and trims whitespace', () => {
|
|
33
|
-
assert.equal(parseTableQuery({ search: ' hello ' }).search, 'hello')
|
|
34
|
-
assert.equal(parseTableQuery({ search: ' ' }).search, undefined)
|
|
35
|
-
})
|
|
36
|
-
|
|
37
|
-
it('parses sort with explicit direction', () => {
|
|
38
|
-
assert.deepEqual(parseTableQuery({ sort: 'title:desc' }).sort, { column: 'title', direction: 'desc' })
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
it('defaults sort direction to asc', () => {
|
|
42
|
-
assert.deepEqual(parseTableQuery({ sort: 'title' }).sort, { column: 'title', direction: 'asc' })
|
|
43
|
-
assert.deepEqual(parseTableQuery({ sort: 'title:bogus' }).sort, { column: 'title', direction: 'asc' })
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
it('floors page to integer ≥ 1', () => {
|
|
47
|
-
assert.equal(parseTableQuery({ page: '3' }).page, 3)
|
|
48
|
-
assert.equal(parseTableQuery({ page: '-2' }).page, 1)
|
|
49
|
-
assert.equal(parseTableQuery({ page: 'abc' }).page, 1)
|
|
50
|
-
assert.equal(parseTableQuery({ page: '0' }).page, 1)
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
it('skips non-positive perPage', () => {
|
|
54
|
-
assert.equal(parseTableQuery({ perPage: '25' }).perPage, 25)
|
|
55
|
-
assert.equal(parseTableQuery({ perPage: '0' }).perPage, undefined)
|
|
56
|
-
assert.equal(parseTableQuery({ perPage: '-5' }).perPage, undefined)
|
|
57
|
-
})
|
|
58
|
-
})
|
|
59
|
-
|
|
60
|
-
describe('findTables', () => {
|
|
61
|
-
it('returns every Table in document order, including nested', () => {
|
|
62
|
-
const inner = Table.make().columns([Column.make('a')])
|
|
63
|
-
const outer = Table.make().schema([
|
|
64
|
-
Column.make('x'),
|
|
65
|
-
Section.make('s').schema([inner]),
|
|
66
|
-
])
|
|
67
|
-
const top = Table.make()
|
|
68
|
-
const found = findTables([top, outer])
|
|
69
|
-
assert.equal(found.length, 3)
|
|
70
|
-
assert.equal(found[0], top)
|
|
71
|
-
assert.equal(found[1], outer)
|
|
72
|
-
assert.equal(found[2], inner)
|
|
73
|
-
})
|
|
74
|
-
})
|
|
75
|
-
|
|
76
|
-
describe('loadTableRecords', () => {
|
|
77
|
-
it('passes parsed sort/search/page into the records handler', async () => {
|
|
78
|
-
let seen: Record<string, unknown> | null = null
|
|
79
|
-
const t = Table.make<{ id: string }>()
|
|
80
|
-
.columns([Column.make('id')])
|
|
81
|
-
.records(async (ctx) => { seen = { ...ctx }; return { rows: [{ id: '1' }], total: 1 } })
|
|
82
|
-
|
|
83
|
-
await loadTableRecords([t], { sort: 'name:desc', search: ' q ', page: '2' })
|
|
84
|
-
|
|
85
|
-
assert.deepEqual(seen, {
|
|
86
|
-
sort: { column: 'name', direction: 'desc' },
|
|
87
|
-
search: 'q',
|
|
88
|
-
page: 2,
|
|
89
|
-
} satisfies Record<string, unknown>)
|
|
90
|
-
})
|
|
91
|
-
|
|
92
|
-
it('falls back to Table.defaultSort when URL sort is absent', async () => {
|
|
93
|
-
let seenSort: unknown = null
|
|
94
|
-
const t = Table.make()
|
|
95
|
-
.columns([Column.make('createdAt')])
|
|
96
|
-
.defaultSort('createdAt', 'desc')
|
|
97
|
-
.records(async (ctx) => { seenSort = ctx.sort; return [] })
|
|
98
|
-
|
|
99
|
-
await loadTableRecords([t], {})
|
|
100
|
-
assert.deepEqual(seenSort, { column: 'createdAt', direction: 'desc' })
|
|
101
|
-
})
|
|
102
|
-
|
|
103
|
-
it('falls back to (reorderableColumn, asc) when reorderable is set and no defaultSort', async () => {
|
|
104
|
-
let seenSort: unknown = null
|
|
105
|
-
const t = Table.make()
|
|
106
|
-
.columns([Column.make('sort')])
|
|
107
|
-
.reorderable('sort')
|
|
108
|
-
.records(async (ctx) => { seenSort = ctx.sort; return [] })
|
|
109
|
-
|
|
110
|
-
await loadTableRecords([t], {})
|
|
111
|
-
assert.deepEqual(seenSort, { column: 'sort', direction: 'asc' })
|
|
112
|
-
})
|
|
113
|
-
|
|
114
|
-
it('explicit defaultSort wins over reorderable fallback', async () => {
|
|
115
|
-
let seenSort: unknown = null
|
|
116
|
-
const t = Table.make()
|
|
117
|
-
.columns([Column.make('createdAt')])
|
|
118
|
-
.reorderable('rank')
|
|
119
|
-
.defaultSort('createdAt', 'desc')
|
|
120
|
-
.records(async (ctx) => { seenSort = ctx.sort; return [] })
|
|
121
|
-
|
|
122
|
-
await loadTableRecords([t], {})
|
|
123
|
-
assert.deepEqual(seenSort, { column: 'createdAt', direction: 'desc' })
|
|
124
|
-
})
|
|
125
|
-
|
|
126
|
-
it('URL ?sort= still wins over the reorderable fallback', async () => {
|
|
127
|
-
let seenSort: unknown = null
|
|
128
|
-
const t = Table.make()
|
|
129
|
-
.columns([Column.make('title').sortable()])
|
|
130
|
-
.reorderable('sort')
|
|
131
|
-
.records(async (ctx) => { seenSort = ctx.sort; return [] })
|
|
132
|
-
|
|
133
|
-
await loadTableRecords([t], { sort: 'title:desc' })
|
|
134
|
-
assert.deepEqual(seenSort, { column: 'title', direction: 'desc' })
|
|
135
|
-
})
|
|
136
|
-
|
|
137
|
-
it('attaches rows + total to the table for serialization', async () => {
|
|
138
|
-
const t = Table.make().columns([Column.make('id')])
|
|
139
|
-
.records(async () => ({ rows: [{ id: 'a' }, { id: 'b' }], total: 42 }))
|
|
140
|
-
|
|
141
|
-
await loadTableRecords([t], {})
|
|
142
|
-
const meta = (await resolveSchema([t]))[0]!
|
|
143
|
-
assert.deepEqual(meta['rows'], [{ id: 'a' }, { id: 'b' }])
|
|
144
|
-
assert.equal(meta['total'], 42)
|
|
145
|
-
})
|
|
146
|
-
|
|
147
|
-
it('treats a bare row array as { rows, total: rows.length }', async () => {
|
|
148
|
-
const t = Table.make().columns([Column.make('id')])
|
|
149
|
-
.records(async () => [{ id: 'x' }, { id: 'y' }, { id: 'z' }])
|
|
150
|
-
|
|
151
|
-
await loadTableRecords([t], {})
|
|
152
|
-
const meta = (await resolveSchema([t]))[0]!
|
|
153
|
-
assert.equal((meta['rows'] as unknown[]).length, 3)
|
|
154
|
-
assert.equal(meta['total'], 3)
|
|
155
|
-
})
|
|
156
|
-
|
|
157
|
-
it('mirrors search/sort/page state back onto toMeta even with no records handler', async () => {
|
|
158
|
-
const t = Table.make().columns([Column.make('title').sortable()])
|
|
159
|
-
await loadTableRecords([t], { search: 'hi', sort: 'title:asc', page: '3' })
|
|
160
|
-
const meta = (await resolveSchema([t]))[0]!
|
|
161
|
-
assert.equal(meta['search'], 'hi')
|
|
162
|
-
assert.deepEqual(meta['currentSort'], { column: 'title', direction: 'asc' })
|
|
163
|
-
assert.equal(meta['currentPage'], 3)
|
|
164
|
-
assert.equal(meta['rows'], undefined) // never ran a handler
|
|
165
|
-
})
|
|
166
|
-
|
|
167
|
-
it('runs every table on the page in parallel', async () => {
|
|
168
|
-
const calls: string[] = []
|
|
169
|
-
const a = Table.make().records(async () => { calls.push('a'); return [] })
|
|
170
|
-
const b = Table.make().records(async () => { calls.push('b'); return [] })
|
|
171
|
-
await loadTableRecords([a, b], {})
|
|
172
|
-
assert.equal(calls.length, 2)
|
|
173
|
-
assert.ok(calls.includes('a') && calls.includes('b'))
|
|
174
|
-
})
|
|
175
|
-
|
|
176
|
-
it('is a no-op when there are no Tables', async () => {
|
|
177
|
-
await loadTableRecords([Column.make('x')], {}) // no throw
|
|
178
|
-
})
|
|
179
|
-
|
|
180
|
-
describe('per-row action visibility', () => {
|
|
181
|
-
it('stamps _visibleActions / _disabledActions when row actions have rules', async () => {
|
|
182
|
-
const { Action } = await import('../actions/Action.js')
|
|
183
|
-
const t = Table.make()
|
|
184
|
-
.columns([Column.make('id')])
|
|
185
|
-
.recordActions([
|
|
186
|
-
Action.make('archive').visible(({ record }) => (record as { archived?: boolean }).archived === false),
|
|
187
|
-
Action.make('lock').disabled(({ record }) => (record as { locked?: boolean }).locked === true),
|
|
188
|
-
])
|
|
189
|
-
.records(async () => [
|
|
190
|
-
{ id: '1', archived: false, locked: false },
|
|
191
|
-
{ id: '2', archived: true, locked: true },
|
|
192
|
-
])
|
|
193
|
-
|
|
194
|
-
await loadTableRecords([t], {})
|
|
195
|
-
const meta = (await resolveSchema([t]))[0]!
|
|
196
|
-
const rows = meta['rows'] as Array<Record<string, unknown>>
|
|
197
|
-
// Row 0: archive visible (not archived), lock visible (default — no
|
|
198
|
-
// visibility rule, only a disabled rule), lock not disabled.
|
|
199
|
-
assert.deepEqual(rows[0]!['_visibleActions'], ['archive', 'lock'])
|
|
200
|
-
assert.deepEqual(rows[0]!['_disabledActions'], [])
|
|
201
|
-
// Row 1: archive hidden (already archived), lock still visible but
|
|
202
|
-
// disabled.
|
|
203
|
-
assert.deepEqual(rows[1]!['_visibleActions'], ['lock'])
|
|
204
|
-
assert.deepEqual(rows[1]!['_disabledActions'], ['lock'])
|
|
205
|
-
})
|
|
206
|
-
|
|
207
|
-
it('runs formatStateUsing per row and stamps _formatted on each row', async () => {
|
|
208
|
-
const t = Table.make()
|
|
209
|
-
.columns([
|
|
210
|
-
Column.make('title'),
|
|
211
|
-
Column.make('priority').formatStateUsing(
|
|
212
|
-
(v) => `★ ${(v as number ?? 0)}`,
|
|
213
|
-
),
|
|
214
|
-
])
|
|
215
|
-
.records(async () => [
|
|
216
|
-
{ id: '1', title: 'one', priority: 5 },
|
|
217
|
-
{ id: '2', title: 'two', priority: 9 },
|
|
218
|
-
])
|
|
219
|
-
|
|
220
|
-
await loadTableRecords([t], {})
|
|
221
|
-
const meta = (await resolveSchema([t]))[0]!
|
|
222
|
-
const rows = meta['rows'] as Array<Record<string, unknown>>
|
|
223
|
-
assert.deepEqual(rows[0]!['_formatted'], { priority: '★ 5' })
|
|
224
|
-
assert.deepEqual(rows[1]!['_formatted'], { priority: '★ 9' })
|
|
225
|
-
})
|
|
226
|
-
|
|
227
|
-
it('swallows errors thrown by a formatStateUsing handler', async () => {
|
|
228
|
-
const t = Table.make()
|
|
229
|
-
.columns([
|
|
230
|
-
Column.make('priority').formatStateUsing(() => { throw new Error('oops') }),
|
|
231
|
-
])
|
|
232
|
-
.records(async () => [{ id: '1', priority: 0 }])
|
|
233
|
-
|
|
234
|
-
await loadTableRecords([t], {}) // no throw
|
|
235
|
-
const meta = (await resolveSchema([t]))[0]!
|
|
236
|
-
const rows = meta['rows'] as Array<Record<string, unknown>>
|
|
237
|
-
// _formatted is present but the broken column's key is absent.
|
|
238
|
-
const formatted = rows[0]!['_formatted'] as Record<string, string>
|
|
239
|
-
assert.equal(formatted['priority'], undefined)
|
|
240
|
-
})
|
|
241
|
-
|
|
242
|
-
describe('richtext columns', () => {
|
|
243
|
-
beforeEach(() => _resetRichTextRegistryForTests())
|
|
244
|
-
afterEach(() => _resetRichTextRegistryForTests())
|
|
245
|
-
|
|
246
|
-
it('skips per-row work when no renderer is registered', async () => {
|
|
247
|
-
const t = Table.make()
|
|
248
|
-
.columns([Column.make('body')])
|
|
249
|
-
.records(async () => [
|
|
250
|
-
{ id: '1', body: { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'a' }] }] } },
|
|
251
|
-
])
|
|
252
|
-
|
|
253
|
-
await loadTableRecords([t], {})
|
|
254
|
-
const meta = (await resolveSchema([t]))[0]!
|
|
255
|
-
const rows = meta['rows'] as Array<Record<string, unknown>>
|
|
256
|
-
assert.equal(rows[0]!['_formatted'], undefined)
|
|
257
|
-
assert.equal(rows[0]!['_richtextCells'], undefined)
|
|
258
|
-
})
|
|
259
|
-
|
|
260
|
-
it('stamps _formatted + _richtextCells when registered renderer matches', async () => {
|
|
261
|
-
registerRichTextRenderer(
|
|
262
|
-
() => '<p>auto</p>',
|
|
263
|
-
(v) => typeof v === 'object' && v !== null && (v as { type?: unknown }).type === 'doc',
|
|
264
|
-
)
|
|
265
|
-
const t = Table.make()
|
|
266
|
-
.columns([Column.make('body'), Column.make('title')])
|
|
267
|
-
.records(async () => [
|
|
268
|
-
{ id: '1', title: 'untouched', body: { type: 'doc', content: [] } },
|
|
269
|
-
{ id: '2', title: 'plain', body: 'plain text' },
|
|
270
|
-
])
|
|
271
|
-
|
|
272
|
-
await loadTableRecords([t], {})
|
|
273
|
-
const meta = (await resolveSchema([t]))[0]!
|
|
274
|
-
const rows = meta['rows'] as Array<Record<string, unknown>>
|
|
275
|
-
const r0 = rows[0] as Record<string, unknown>
|
|
276
|
-
assert.deepEqual(r0['_formatted'], { body: '<p>auto</p>' })
|
|
277
|
-
assert.deepEqual(r0['_richtextCells'], { body: true })
|
|
278
|
-
// Plain text rows skip the stamp entirely.
|
|
279
|
-
const r1 = rows[1] as Record<string, unknown>
|
|
280
|
-
assert.equal(r1['_formatted'], undefined)
|
|
281
|
-
assert.equal(r1['_richtextCells'], undefined)
|
|
282
|
-
})
|
|
283
|
-
|
|
284
|
-
it('skips columns with formatStateUsing (user formatter wins)', async () => {
|
|
285
|
-
registerRichTextRenderer(() => '<p>auto</p>', () => true)
|
|
286
|
-
const t = Table.make()
|
|
287
|
-
.columns([Column.make('body').formatStateUsing(() => 'manual')])
|
|
288
|
-
.records(async () => [{ id: '1', body: { type: 'doc', content: [] } }])
|
|
289
|
-
|
|
290
|
-
await loadTableRecords([t], {})
|
|
291
|
-
const meta = (await resolveSchema([t]))[0]!
|
|
292
|
-
const rows = meta['rows'] as Array<Record<string, unknown>>
|
|
293
|
-
assert.deepEqual(rows[0]!['_formatted'], { body: 'manual' })
|
|
294
|
-
assert.equal(rows[0]!['_richtextCells'], undefined)
|
|
295
|
-
})
|
|
296
|
-
|
|
297
|
-
it('skips columns with built-in format', async () => {
|
|
298
|
-
registerRichTextRenderer(() => '<p>auto</p>', () => true)
|
|
299
|
-
const t = Table.make()
|
|
300
|
-
.columns([Column.make('publishedAt').dateTime()])
|
|
301
|
-
.records(async () => [{ id: '1', publishedAt: '2026-01-01T00:00:00Z' }])
|
|
302
|
-
|
|
303
|
-
await loadTableRecords([t], {})
|
|
304
|
-
const meta = (await resolveSchema([t]))[0]!
|
|
305
|
-
const rows = meta['rows'] as Array<Record<string, unknown>>
|
|
306
|
-
assert.equal(rows[0]!['_richtextCells'], undefined)
|
|
307
|
-
})
|
|
308
|
-
})
|
|
309
|
-
|
|
310
|
-
it('does not stamp _visibleActions when no row actions have rules', async () => {
|
|
311
|
-
const { Action } = await import('../actions/Action.js')
|
|
312
|
-
const t = Table.make()
|
|
313
|
-
.columns([Column.make('id')])
|
|
314
|
-
.recordActions([Action.make('edit')]) // no rules
|
|
315
|
-
.records(async () => [{ id: '1' }])
|
|
316
|
-
|
|
317
|
-
await loadTableRecords([t], {})
|
|
318
|
-
const meta = (await resolveSchema([t]))[0]!
|
|
319
|
-
const rows = meta['rows'] as Array<Record<string, unknown>>
|
|
320
|
-
assert.equal(rows[0]!['_visibleActions'], undefined)
|
|
321
|
-
assert.equal(rows[0]!['_disabledActions'], undefined)
|
|
322
|
-
})
|
|
323
|
-
})
|
|
324
|
-
|
|
325
|
-
describe('Table.recordUrl', () => {
|
|
326
|
-
it('stamps _recordUrl on each row when a recordUrl handler is set', async () => {
|
|
327
|
-
const t = Table.make<{ id: string }>()
|
|
328
|
-
.columns([Column.make('id')])
|
|
329
|
-
.records(async () => [{ id: 'a' }, { id: 'b' }])
|
|
330
|
-
.recordUrl((r) => `/admin/posts/${r.id}/edit`)
|
|
331
|
-
|
|
332
|
-
await loadTableRecords([t], {})
|
|
333
|
-
const meta = (await resolveSchema([t]))[0]!
|
|
334
|
-
assert.equal(meta['recordUrl'], true, 'meta.recordUrl flag set')
|
|
335
|
-
const rows = meta['rows'] as Array<Record<string, unknown>>
|
|
336
|
-
assert.equal(rows[0]!['_recordUrl'], '/admin/posts/a/edit')
|
|
337
|
-
assert.equal(rows[1]!['_recordUrl'], '/admin/posts/b/edit')
|
|
338
|
-
})
|
|
339
|
-
|
|
340
|
-
it('skips _recordUrl when handler returns undefined for that row', async () => {
|
|
341
|
-
const t = Table.make<{ id: string; status?: string }>()
|
|
342
|
-
.columns([Column.make('id')])
|
|
343
|
-
.records(async () => [{ id: 'a', status: 'archived' }, { id: 'b' }])
|
|
344
|
-
.recordUrl((r) => r.status === 'archived' ? undefined : `/admin/posts/${r.id}`)
|
|
345
|
-
|
|
346
|
-
await loadTableRecords([t], {})
|
|
347
|
-
const meta = (await resolveSchema([t]))[0]!
|
|
348
|
-
const rows = meta['rows'] as Array<Record<string, unknown>>
|
|
349
|
-
assert.equal(rows[0]!['_recordUrl'], undefined)
|
|
350
|
-
assert.equal(rows[1]!['_recordUrl'], '/admin/posts/b')
|
|
351
|
-
})
|
|
352
|
-
|
|
353
|
-
it('does not stamp recordUrl flag or _recordUrl when handler is unset', async () => {
|
|
354
|
-
const t = Table.make()
|
|
355
|
-
.columns([Column.make('id')])
|
|
356
|
-
.records(async () => [{ id: 'a' }])
|
|
357
|
-
|
|
358
|
-
await loadTableRecords([t], {})
|
|
359
|
-
const meta = (await resolveSchema([t]))[0]!
|
|
360
|
-
assert.equal(meta['recordUrl'], undefined)
|
|
361
|
-
const rows = meta['rows'] as Array<Record<string, unknown>>
|
|
362
|
-
assert.equal(rows[0]!['_recordUrl'], undefined)
|
|
363
|
-
})
|
|
364
|
-
})
|
|
365
|
-
|
|
366
|
-
describe('Table.defaultGroup + summaries', () => {
|
|
367
|
-
it('stamps _groupValue on each row when defaultGroup(col) is set', async () => {
|
|
368
|
-
const t = Table.make<{ id: string; status: string }>()
|
|
369
|
-
.columns([Column.make('status'), Column.make('id')])
|
|
370
|
-
.records(async () => [
|
|
371
|
-
{ id: '1', status: 'draft' },
|
|
372
|
-
{ id: '2', status: 'published' },
|
|
373
|
-
{ id: '3', status: 'draft' },
|
|
374
|
-
])
|
|
375
|
-
.defaultGroup('status')
|
|
376
|
-
|
|
377
|
-
await loadTableRecords([t], {})
|
|
378
|
-
const meta = (await resolveSchema([t]))[0]!
|
|
379
|
-
assert.equal(meta['defaultGroup'], 'status')
|
|
380
|
-
const rows = meta['rows'] as Array<Record<string, unknown>>
|
|
381
|
-
// Stable sort clusters drafts together.
|
|
382
|
-
assert.deepEqual(rows.map(r => r['id']), ['1', '3', '2'])
|
|
383
|
-
assert.equal(rows[0]!['_groupValue'], 'draft')
|
|
384
|
-
assert.equal(rows[1]!['_groupValue'], 'draft')
|
|
385
|
-
assert.equal(rows[2]!['_groupValue'], 'published')
|
|
386
|
-
})
|
|
387
|
-
|
|
388
|
-
it('preserves original sub-order within each group (stable sort)', async () => {
|
|
389
|
-
const t = Table.make<{ id: string; team: string }>()
|
|
390
|
-
.columns([Column.make('team'), Column.make('id')])
|
|
391
|
-
.records(async () => [
|
|
392
|
-
{ id: 'a', team: 'red' },
|
|
393
|
-
{ id: 'b', team: 'blue' },
|
|
394
|
-
{ id: 'c', team: 'red' },
|
|
395
|
-
{ id: 'd', team: 'blue' },
|
|
396
|
-
])
|
|
397
|
-
.defaultGroup('team')
|
|
398
|
-
|
|
399
|
-
await loadTableRecords([t], {})
|
|
400
|
-
const meta = (await resolveSchema([t]))[0]!
|
|
401
|
-
const rows = meta['rows'] as Array<Record<string, unknown>>
|
|
402
|
-
assert.deepEqual(rows.map(r => r['id']), ['b', 'd', 'a', 'c'])
|
|
403
|
-
})
|
|
404
|
-
|
|
405
|
-
it('moves rows with empty/null group values to the end', async () => {
|
|
406
|
-
const t = Table.make<{ id: string; status: string | null }>()
|
|
407
|
-
.columns([Column.make('status')])
|
|
408
|
-
.records(async () => [
|
|
409
|
-
{ id: '1', status: null },
|
|
410
|
-
{ id: '2', status: 'active' },
|
|
411
|
-
{ id: '3', status: '' },
|
|
412
|
-
])
|
|
413
|
-
.defaultGroup('status')
|
|
414
|
-
|
|
415
|
-
await loadTableRecords([t], {})
|
|
416
|
-
const meta = (await resolveSchema([t]))[0]!
|
|
417
|
-
const rows = meta['rows'] as Array<Record<string, unknown>>
|
|
418
|
-
assert.deepEqual(rows.map(r => r['id']), ['2', '1', '3'])
|
|
419
|
-
assert.equal(rows[1]!['_groupValue'], '')
|
|
420
|
-
assert.equal(rows[2]!['_groupValue'], '')
|
|
421
|
-
})
|
|
422
|
-
|
|
423
|
-
it('TableGroup.orderUsing() pins group order', async () => {
|
|
424
|
-
// Without orderUsing, alphabetic order would put 'archived' first.
|
|
425
|
-
// With orderByKeys(['draft', 'published', 'archived']), drafts come
|
|
426
|
-
// first regardless. Empty bucket still sinks to the bottom.
|
|
427
|
-
const status = TableGroup.make('status').orderUsing(
|
|
428
|
-
orderByKeys(['draft', 'published', 'archived']),
|
|
429
|
-
)
|
|
430
|
-
const t = Table.make<{ id: string; status: string | null }>()
|
|
431
|
-
.columns([Column.make('status')])
|
|
432
|
-
.records(async () => [
|
|
433
|
-
{ id: 'a', status: 'archived' },
|
|
434
|
-
{ id: 'd', status: 'draft' },
|
|
435
|
-
{ id: 'p', status: 'published' },
|
|
436
|
-
{ id: 'n', status: null },
|
|
437
|
-
{ id: 'd2', status: 'draft' },
|
|
438
|
-
])
|
|
439
|
-
.defaultGroup(status)
|
|
440
|
-
|
|
441
|
-
await loadTableRecords([t], {})
|
|
442
|
-
const meta = (await resolveSchema([t]))[0]!
|
|
443
|
-
const rows = meta['rows'] as Array<Record<string, unknown>>
|
|
444
|
-
assert.deepEqual(
|
|
445
|
-
rows.map(r => r['id']),
|
|
446
|
-
['d', 'd2', 'p', 'a', 'n'],
|
|
447
|
-
)
|
|
448
|
-
})
|
|
449
|
-
|
|
450
|
-
it('orderUsing() composes with the empty-bucket-last rule', async () => {
|
|
451
|
-
// Comparator that would put '' at the top alphabetically — the
|
|
452
|
-
// structural empty-last rule still wins.
|
|
453
|
-
const status = TableGroup.make('status').orderUsing(
|
|
454
|
-
(a, b) => a.localeCompare(b),
|
|
455
|
-
)
|
|
456
|
-
const t = Table.make<{ id: string; status: string | null }>()
|
|
457
|
-
.columns([Column.make('status')])
|
|
458
|
-
.records(async () => [
|
|
459
|
-
{ id: 'n', status: null },
|
|
460
|
-
{ id: 'b', status: 'beta' },
|
|
461
|
-
{ id: 'a', status: 'alpha'},
|
|
462
|
-
])
|
|
463
|
-
.defaultGroup(status)
|
|
464
|
-
|
|
465
|
-
await loadTableRecords([t], {})
|
|
466
|
-
const meta = (await resolveSchema([t]))[0]!
|
|
467
|
-
const rows = meta['rows'] as Array<Record<string, unknown>>
|
|
468
|
-
assert.deepEqual(rows.map(r => r['id']), ['a', 'b', 'n'])
|
|
469
|
-
})
|
|
470
|
-
|
|
471
|
-
it('computes per-column summaries over the rendered rows', async () => {
|
|
472
|
-
const t = Table.make<{ amount: number; tax: number }>()
|
|
473
|
-
.columns([
|
|
474
|
-
Column.make('amount').summarize([
|
|
475
|
-
Sum.make().label('Total'),
|
|
476
|
-
Average.make().label('Avg'),
|
|
477
|
-
]),
|
|
478
|
-
Column.make('tax').summarize([
|
|
479
|
-
Range.make(),
|
|
480
|
-
Count.make().label('Rows'),
|
|
481
|
-
]),
|
|
482
|
-
])
|
|
483
|
-
.records(async () => [
|
|
484
|
-
{ amount: 100, tax: 10 },
|
|
485
|
-
{ amount: 200, tax: 25 },
|
|
486
|
-
{ amount: 300, tax: 5 },
|
|
487
|
-
])
|
|
488
|
-
|
|
489
|
-
await loadTableRecords([t], {})
|
|
490
|
-
const meta = (await resolveSchema([t]))[0]!
|
|
491
|
-
const summaries = meta['summaries'] as Record<string, Array<{ kind: string; value: string; label?: string }>>
|
|
492
|
-
assert.deepEqual(summaries['amount'], [
|
|
493
|
-
{ kind: 'sum', label: 'Total', value: '600' },
|
|
494
|
-
{ kind: 'average', label: 'Avg', value: '200' },
|
|
495
|
-
])
|
|
496
|
-
assert.deepEqual(summaries['tax'], [
|
|
497
|
-
{ kind: 'range', value: '5..25' },
|
|
498
|
-
{ kind: 'count', label: 'Rows', value: '3' },
|
|
499
|
-
])
|
|
500
|
-
})
|
|
501
|
-
|
|
502
|
-
it('skips summaries when no column has summarizers', async () => {
|
|
503
|
-
const t = Table.make()
|
|
504
|
-
.columns([Column.make('id')])
|
|
505
|
-
.records(async () => [{ id: '1' }])
|
|
506
|
-
|
|
507
|
-
await loadTableRecords([t], {})
|
|
508
|
-
const meta = (await resolveSchema([t]))[0]!
|
|
509
|
-
assert.equal(meta['summaries'], undefined)
|
|
510
|
-
})
|
|
511
|
-
|
|
512
|
-
it('summaries respect the grouped row order (compute over final rendered rows)', async () => {
|
|
513
|
-
const t = Table.make<{ amount: number; status: string }>()
|
|
514
|
-
.columns([
|
|
515
|
-
Column.make('status'),
|
|
516
|
-
Column.make('amount').summarize([Sum.make()]),
|
|
517
|
-
])
|
|
518
|
-
.records(async () => [
|
|
519
|
-
{ amount: 50, status: 'draft' },
|
|
520
|
-
{ amount: 100, status: 'published' },
|
|
521
|
-
])
|
|
522
|
-
.defaultGroup('status')
|
|
523
|
-
|
|
524
|
-
await loadTableRecords([t], {})
|
|
525
|
-
const meta = (await resolveSchema([t]))[0]!
|
|
526
|
-
const summaries = meta['summaries'] as Record<string, Array<{ kind: string; value: string }>>
|
|
527
|
-
// Sum is order-independent; this also confirms grouping doesn't
|
|
528
|
-
// accidentally drop rows from the summary input.
|
|
529
|
-
assert.equal(summaries['amount']![0]!.value, '150')
|
|
530
|
-
})
|
|
531
|
-
})
|
|
532
|
-
|
|
533
|
-
describe('Table.groups + parseActiveGroup', () => {
|
|
534
|
-
it('parseActiveGroup: ?group=col picks a registered group', () => {
|
|
535
|
-
const t = Table.make()
|
|
536
|
-
.groups([TableGroup.make('status'), TableGroup.make('author')])
|
|
537
|
-
assert.equal(parseActiveGroup({ group: 'status' }, t), 'status')
|
|
538
|
-
assert.equal(parseActiveGroup({ group: 'author' }, t), 'author')
|
|
539
|
-
})
|
|
540
|
-
|
|
541
|
-
it('parseActiveGroup: ?group= (empty) explicitly clears', () => {
|
|
542
|
-
const t = Table.make().defaultGroup('status')
|
|
543
|
-
assert.equal(parseActiveGroup({ group: '' }, t), undefined)
|
|
544
|
-
})
|
|
545
|
-
|
|
546
|
-
it('parseActiveGroup: absent ?group falls back to defaultGroup', () => {
|
|
547
|
-
const t = Table.make().defaultGroup('status')
|
|
548
|
-
assert.equal(parseActiveGroup({}, t), 'status')
|
|
549
|
-
})
|
|
550
|
-
|
|
551
|
-
it('parseActiveGroup: unknown column falls back to no grouping', () => {
|
|
552
|
-
const t = Table.make()
|
|
553
|
-
.groups([TableGroup.make('status')])
|
|
554
|
-
.defaultGroup('status')
|
|
555
|
-
assert.equal(parseActiveGroup({ group: 'wat' }, t), undefined)
|
|
556
|
-
})
|
|
557
|
-
|
|
558
|
-
it('parseActiveGroup: bare-column form works (no groups([…]) registered)', () => {
|
|
559
|
-
const t = Table.make().defaultGroup('status')
|
|
560
|
-
assert.equal(parseActiveGroup({ group: 'status' }, t), 'status')
|
|
561
|
-
})
|
|
562
|
-
|
|
563
|
-
it('?group=col switches the active group at load time', async () => {
|
|
564
|
-
const t = Table.make<{ id: string; status: string; author: string }>()
|
|
565
|
-
.columns([Column.make('status'), Column.make('author')])
|
|
566
|
-
.groups([TableGroup.make('status'), TableGroup.make('author')])
|
|
567
|
-
.defaultGroup('status')
|
|
568
|
-
.records(async () => [
|
|
569
|
-
{ id: '1', status: 'draft', author: 'a' },
|
|
570
|
-
{ id: '2', status: 'published', author: 'b' },
|
|
571
|
-
{ id: '3', status: 'draft', author: 'a' },
|
|
572
|
-
])
|
|
573
|
-
|
|
574
|
-
await loadTableRecords([t], { group: 'author' })
|
|
575
|
-
const meta = (await resolveSchema([t]))[0]!
|
|
576
|
-
assert.equal(meta['defaultGroup'], 'author')
|
|
577
|
-
const rows = meta['rows'] as Array<Record<string, unknown>>
|
|
578
|
-
// Stable-sort clusters by author now.
|
|
579
|
-
assert.deepEqual(rows.map(r => r['_groupValue']), ['a', 'a', 'b'])
|
|
580
|
-
})
|
|
581
|
-
|
|
582
|
-
it('?group= explicitly disables grouping (overrides defaultGroup)', async () => {
|
|
583
|
-
const t = Table.make<{ id: string; status: string }>()
|
|
584
|
-
.columns([Column.make('status')])
|
|
585
|
-
.defaultGroup('status')
|
|
586
|
-
.records(async () => [
|
|
587
|
-
{ id: '1', status: 'draft' },
|
|
588
|
-
{ id: '2', status: 'published' },
|
|
589
|
-
])
|
|
590
|
-
|
|
591
|
-
await loadTableRecords([t], { group: '' })
|
|
592
|
-
const meta = (await resolveSchema([t]))[0]!
|
|
593
|
-
assert.equal(meta['defaultGroup'], undefined)
|
|
594
|
-
const rows = meta['rows'] as Array<Record<string, unknown>>
|
|
595
|
-
assert.equal(rows[0]!['_groupValue'], undefined)
|
|
596
|
-
})
|
|
597
|
-
|
|
598
|
-
it('getTitleFromRecordUsing stamps _groupTitle per row', async () => {
|
|
599
|
-
const t = Table.make<{ id: string; status: string }>()
|
|
600
|
-
.columns([Column.make('status')])
|
|
601
|
-
.groups([
|
|
602
|
-
TableGroup.make<{ id: string; status: string }>('status').getTitleFromRecordUsing(
|
|
603
|
-
(r) => r.status === 'draft' ? 'Drafts' : 'Live',
|
|
604
|
-
),
|
|
605
|
-
])
|
|
606
|
-
.defaultGroup('status')
|
|
607
|
-
.records(async () => [
|
|
608
|
-
{ id: '1', status: 'draft' },
|
|
609
|
-
{ id: '2', status: 'published' },
|
|
610
|
-
])
|
|
611
|
-
|
|
612
|
-
await loadTableRecords([t], {})
|
|
613
|
-
const meta = (await resolveSchema([t]))[0]!
|
|
614
|
-
const rows = meta['rows'] as Array<Record<string, unknown>>
|
|
615
|
-
assert.equal(rows[0]!['_groupTitle'], 'Drafts')
|
|
616
|
-
assert.equal(rows[1]!['_groupTitle'], 'Live')
|
|
617
|
-
})
|
|
618
|
-
|
|
619
|
-
it('getDescriptionFromRecordUsing stamps _groupDescription per row', async () => {
|
|
620
|
-
const t = Table.make<{ id: string; status: string }>()
|
|
621
|
-
.columns([Column.make('status')])
|
|
622
|
-
.groups([
|
|
623
|
-
TableGroup.make<{ id: string; status: string }>('status').getDescriptionFromRecordUsing(
|
|
624
|
-
(r) => `${r.status} band`,
|
|
625
|
-
),
|
|
626
|
-
])
|
|
627
|
-
.defaultGroup('status')
|
|
628
|
-
.records(async () => [{ id: '1', status: 'draft' }])
|
|
629
|
-
|
|
630
|
-
await loadTableRecords([t], {})
|
|
631
|
-
const meta = (await resolveSchema([t]))[0]!
|
|
632
|
-
const rows = meta['rows'] as Array<Record<string, unknown>>
|
|
633
|
-
assert.equal(rows[0]!['_groupDescription'], 'draft band')
|
|
634
|
-
})
|
|
635
|
-
|
|
636
|
-
it('throwing title handler stays silent (falls back to bare _groupValue)', async () => {
|
|
637
|
-
const t = Table.make<{ id: string; status: string }>()
|
|
638
|
-
.columns([Column.make('status')])
|
|
639
|
-
.groups([
|
|
640
|
-
TableGroup.make('status').getTitleFromRecordUsing(() => {
|
|
641
|
-
throw new Error('boom')
|
|
642
|
-
}),
|
|
643
|
-
])
|
|
644
|
-
.defaultGroup('status')
|
|
645
|
-
.records(async () => [{ id: '1', status: 'draft' }])
|
|
646
|
-
|
|
647
|
-
await loadTableRecords([t], {})
|
|
648
|
-
const meta = (await resolveSchema([t]))[0]!
|
|
649
|
-
const rows = meta['rows'] as Array<Record<string, unknown>>
|
|
650
|
-
assert.equal(rows[0]!['_groupTitle'], undefined)
|
|
651
|
-
assert.equal(rows[0]!['_groupValue'], 'draft')
|
|
652
|
-
})
|
|
653
|
-
|
|
654
|
-
it('date() bucketing stamps _groupValue as YYYY-MM-DD + a default _groupTitle', async () => {
|
|
655
|
-
const t = Table.make<{ id: string; createdAt: string }>()
|
|
656
|
-
.columns([Column.make('createdAt')])
|
|
657
|
-
.groups([TableGroup.make('createdAt').date()])
|
|
658
|
-
.defaultGroup('createdAt')
|
|
659
|
-
.records(async () => [
|
|
660
|
-
{ id: '1', createdAt: '2026-05-04T10:00:00.000Z' },
|
|
661
|
-
{ id: '2', createdAt: '2026-05-04T22:30:00.000Z' },
|
|
662
|
-
{ id: '3', createdAt: '2026-04-15T08:00:00.000Z' },
|
|
663
|
-
])
|
|
664
|
-
|
|
665
|
-
await loadTableRecords([t], {})
|
|
666
|
-
const meta = (await resolveSchema([t]))[0]!
|
|
667
|
-
const rows = meta['rows'] as Array<Record<string, unknown>>
|
|
668
|
-
// Two rows on 2026-05-04 cluster, then 2026-04-15 — but stable-sort
|
|
669
|
-
// is alphabetical on the bucket string, so 04-15 sorts before 05-04.
|
|
670
|
-
assert.deepEqual(rows.map(r => r['_groupValue']), ['2026-04-15', '2026-05-04', '2026-05-04'])
|
|
671
|
-
// Default title formatter kicks in (locale text containing the year).
|
|
672
|
-
assert.match(String(rows[0]!['_groupTitle']), /2026/)
|
|
673
|
-
})
|
|
674
|
-
|
|
675
|
-
it('date() with a user title handler — the user handler wins over the default', async () => {
|
|
676
|
-
const t = Table.make<{ id: string; createdAt: string }>()
|
|
677
|
-
.columns([Column.make('createdAt')])
|
|
678
|
-
.groups([
|
|
679
|
-
TableGroup.make('createdAt').date()
|
|
680
|
-
.getTitleFromRecordUsing(() => 'CUSTOM'),
|
|
681
|
-
])
|
|
682
|
-
.defaultGroup('createdAt')
|
|
683
|
-
.records(async () => [{ id: '1', createdAt: '2026-05-04T00:00:00.000Z' }])
|
|
684
|
-
|
|
685
|
-
await loadTableRecords([t], {})
|
|
686
|
-
const meta = (await resolveSchema([t]))[0]!
|
|
687
|
-
const rows = meta['rows'] as Array<Record<string, unknown>>
|
|
688
|
-
assert.equal(rows[0]!['_groupTitle'], 'CUSTOM')
|
|
689
|
-
})
|
|
690
|
-
|
|
691
|
-
it('per-group summaries: stamps groupSummaries[value][col] when grouping is active', async () => {
|
|
692
|
-
const t = Table.make<{ amount: number; status: string }>()
|
|
693
|
-
.columns([
|
|
694
|
-
Column.make('status'),
|
|
695
|
-
Column.make('amount').summarize([
|
|
696
|
-
Sum.make().label('Total'),
|
|
697
|
-
Count.make().label('Rows'),
|
|
698
|
-
]),
|
|
699
|
-
])
|
|
700
|
-
.defaultGroup('status')
|
|
701
|
-
.records(async () => [
|
|
702
|
-
{ amount: 50, status: 'draft' },
|
|
703
|
-
{ amount: 75, status: 'draft' },
|
|
704
|
-
{ amount: 200, status: 'published' },
|
|
705
|
-
])
|
|
706
|
-
|
|
707
|
-
await loadTableRecords([t], {})
|
|
708
|
-
const meta = (await resolveSchema([t]))[0]!
|
|
709
|
-
const groupSummaries = meta['groupSummaries'] as
|
|
710
|
-
Record<string, Record<string, Array<{ kind: string; value: string; label?: string }>>>
|
|
711
|
-
assert.equal(groupSummaries['draft']!['amount']![0]!.value, '125')
|
|
712
|
-
assert.equal(groupSummaries['draft']!['amount']![0]!.label, 'Total')
|
|
713
|
-
assert.equal(groupSummaries['draft']!['amount']![1]!.value, '2')
|
|
714
|
-
assert.equal(groupSummaries['published']!['amount']![0]!.value, '200')
|
|
715
|
-
assert.equal(groupSummaries['published']!['amount']![1]!.value, '1')
|
|
716
|
-
})
|
|
717
|
-
|
|
718
|
-
it('per-group summaries: omits the meta key entirely when grouping is OFF', async () => {
|
|
719
|
-
const t = Table.make<{ amount: number }>()
|
|
720
|
-
.columns([Column.make('amount').summarize([Sum.make()])])
|
|
721
|
-
.records(async () => [{ amount: 50 }, { amount: 75 }])
|
|
722
|
-
|
|
723
|
-
await loadTableRecords([t], {})
|
|
724
|
-
const meta = (await resolveSchema([t]))[0]!
|
|
725
|
-
// Global summary still computes; per-group is absent.
|
|
726
|
-
assert.equal((meta['summaries'] as Record<string, unknown>)['amount'] !== undefined, true)
|
|
727
|
-
assert.equal(meta['groupSummaries'], undefined)
|
|
728
|
-
})
|
|
729
|
-
|
|
730
|
-
it('per-group summaries: omits when no column has summarizers', async () => {
|
|
731
|
-
const t = Table.make<{ status: string }>()
|
|
732
|
-
.columns([Column.make('status')])
|
|
733
|
-
.defaultGroup('status')
|
|
734
|
-
.records(async () => [{ status: 'draft' }])
|
|
735
|
-
|
|
736
|
-
await loadTableRecords([t], {})
|
|
737
|
-
const meta = (await resolveSchema([t]))[0]!
|
|
738
|
-
assert.equal(meta['groupSummaries'], undefined)
|
|
739
|
-
})
|
|
740
|
-
|
|
741
|
-
it('per-group summaries: ?group= override (clears grouping) → no group summaries', async () => {
|
|
742
|
-
const t = Table.make<{ amount: number; status: string }>()
|
|
743
|
-
.columns([Column.make('amount').summarize([Sum.make()])])
|
|
744
|
-
.defaultGroup('status')
|
|
745
|
-
.records(async () => [
|
|
746
|
-
{ amount: 50, status: 'draft' },
|
|
747
|
-
{ amount: 75, status: 'published' },
|
|
748
|
-
])
|
|
749
|
-
|
|
750
|
-
await loadTableRecords([t], { group: '' })
|
|
751
|
-
const meta = (await resolveSchema([t]))[0]!
|
|
752
|
-
assert.equal(meta['groupSummaries'], undefined)
|
|
753
|
-
// Global summary still computes.
|
|
754
|
-
assert.equal((meta['summaries'] as Record<string, Array<{ value: string }>>)['amount']![0]!.value, '125')
|
|
755
|
-
})
|
|
756
|
-
|
|
757
|
-
it('per-group summaries: empty-group bucket gets its own row when present', async () => {
|
|
758
|
-
const t = Table.make<{ amount: number; status: string | null }>()
|
|
759
|
-
.columns([Column.make('amount').summarize([Sum.make()])])
|
|
760
|
-
.defaultGroup('status')
|
|
761
|
-
.records(async () => [
|
|
762
|
-
{ amount: 50, status: 'draft' },
|
|
763
|
-
{ amount: 25, status: null },
|
|
764
|
-
])
|
|
765
|
-
|
|
766
|
-
await loadTableRecords([t], {})
|
|
767
|
-
const meta = (await resolveSchema([t]))[0]!
|
|
768
|
-
const groupSummaries = meta['groupSummaries'] as
|
|
769
|
-
Record<string, Record<string, Array<{ value: string }>>>
|
|
770
|
-
assert.equal(groupSummaries['draft']!['amount']![0]!.value, '50')
|
|
771
|
-
assert.equal(groupSummaries['']! ['amount']![0]!.value, '25')
|
|
772
|
-
})
|
|
773
|
-
|
|
774
|
-
it('toMeta emits groups[] for the renderer dropdown', async () => {
|
|
775
|
-
const t = Table.make()
|
|
776
|
-
.columns([Column.make('status'), Column.make('author')])
|
|
777
|
-
.groups([
|
|
778
|
-
TableGroup.make('status').label('Status').collapsible(),
|
|
779
|
-
TableGroup.make('author').label('Author'),
|
|
780
|
-
])
|
|
781
|
-
.defaultGroup('status')
|
|
782
|
-
.records(async () => [])
|
|
783
|
-
|
|
784
|
-
await loadTableRecords([t], {})
|
|
785
|
-
const meta = (await resolveSchema([t]))[0]!
|
|
786
|
-
const groups = meta['groups'] as Array<Record<string, unknown>>
|
|
787
|
-
assert.equal(groups.length, 2)
|
|
788
|
-
assert.equal(groups[0]!['column'], 'status')
|
|
789
|
-
assert.equal(groups[0]!['label'], 'Status')
|
|
790
|
-
assert.equal(groups[0]!['collapsible'], true)
|
|
791
|
-
})
|
|
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
|
-
|
|
978
|
-
describe('Table.recordClasses', () => {
|
|
979
|
-
it('stamps _recordClasses on each row when a handler is set', async () => {
|
|
980
|
-
const t = Table.make<{ id: string; status: string }>()
|
|
981
|
-
.columns([Column.make('id')])
|
|
982
|
-
.records(async () => [
|
|
983
|
-
{ id: 'a', status: 'active' },
|
|
984
|
-
{ id: 'b', status: 'archived' },
|
|
985
|
-
])
|
|
986
|
-
.recordClasses((r) => r.status === 'archived' ? 'opacity-50' : 'bg-success/5')
|
|
987
|
-
|
|
988
|
-
await loadTableRecords([t], {})
|
|
989
|
-
const meta = (await resolveSchema([t]))[0]!
|
|
990
|
-
assert.equal(meta['recordClasses'], true)
|
|
991
|
-
const rows = meta['rows'] as Array<Record<string, unknown>>
|
|
992
|
-
assert.equal(rows[0]!['_recordClasses'], 'bg-success/5')
|
|
993
|
-
assert.equal(rows[1]!['_recordClasses'], 'opacity-50')
|
|
994
|
-
})
|
|
995
|
-
|
|
996
|
-
it('skips _recordClasses when handler returns undefined or empty', async () => {
|
|
997
|
-
const t = Table.make<{ id: string }>()
|
|
998
|
-
.columns([Column.make('id')])
|
|
999
|
-
.records(async () => [{ id: 'a' }, { id: 'b' }])
|
|
1000
|
-
.recordClasses((r) => r.id === 'a' ? '' : undefined)
|
|
1001
|
-
|
|
1002
|
-
await loadTableRecords([t], {})
|
|
1003
|
-
const meta = (await resolveSchema([t]))[0]!
|
|
1004
|
-
const rows = meta['rows'] as Array<Record<string, unknown>>
|
|
1005
|
-
assert.equal(rows[0]!['_recordClasses'], undefined)
|
|
1006
|
-
assert.equal(rows[1]!['_recordClasses'], undefined)
|
|
1007
|
-
})
|
|
1008
|
-
|
|
1009
|
-
it('swallows errors thrown by the recordClasses handler', async () => {
|
|
1010
|
-
const t = Table.make<{ id: string }>()
|
|
1011
|
-
.columns([Column.make('id')])
|
|
1012
|
-
.records(async () => [{ id: 'a' }])
|
|
1013
|
-
.recordClasses(() => { throw new Error('boom') })
|
|
1014
|
-
|
|
1015
|
-
await loadTableRecords([t], {})
|
|
1016
|
-
const meta = (await resolveSchema([t]))[0]!
|
|
1017
|
-
const rows = meta['rows'] as Array<Record<string, unknown>>
|
|
1018
|
-
assert.equal(rows[0]!['_recordClasses'], undefined)
|
|
1019
|
-
})
|
|
1020
|
-
|
|
1021
|
-
it('does not stamp recordClasses flag when handler is unset', async () => {
|
|
1022
|
-
const t = Table.make()
|
|
1023
|
-
.columns([Column.make('id')])
|
|
1024
|
-
.records(async () => [{ id: 'a' }])
|
|
1025
|
-
|
|
1026
|
-
await loadTableRecords([t], {})
|
|
1027
|
-
const meta = (await resolveSchema([t]))[0]!
|
|
1028
|
-
assert.equal(meta['recordClasses'], undefined)
|
|
1029
|
-
})
|
|
1030
|
-
})
|
|
1031
|
-
|
|
1032
|
-
describe('Column.recordUrl per-column override', () => {
|
|
1033
|
-
it('stamps _columnRecordUrls[name] when a column has its own recordUrl handler', async () => {
|
|
1034
|
-
const t = Table.make<{ id: string; slug: string }>()
|
|
1035
|
-
.columns([
|
|
1036
|
-
Column.make('title').recordUrl((r) => `/posts/${(r as { id?: string }).id}/edit`),
|
|
1037
|
-
Column.make('slug').recordUrl((r) => `/posts/${(r as { slug?: string }).slug}`),
|
|
1038
|
-
])
|
|
1039
|
-
.records(async () => [
|
|
1040
|
-
{ id: '1', slug: 'one' },
|
|
1041
|
-
{ id: '2', slug: 'two' },
|
|
1042
|
-
])
|
|
1043
|
-
|
|
1044
|
-
await loadTableRecords([t], {})
|
|
1045
|
-
const meta = (await resolveSchema([t]))[0]!
|
|
1046
|
-
const rows = meta['rows'] as Array<Record<string, unknown>>
|
|
1047
|
-
assert.deepEqual(rows[0]!['_columnRecordUrls'], {
|
|
1048
|
-
title: '/posts/1/edit',
|
|
1049
|
-
slug: '/posts/one',
|
|
1050
|
-
})
|
|
1051
|
-
assert.deepEqual(rows[1]!['_columnRecordUrls'], {
|
|
1052
|
-
title: '/posts/2/edit',
|
|
1053
|
-
slug: '/posts/two',
|
|
1054
|
-
})
|
|
1055
|
-
})
|
|
1056
|
-
|
|
1057
|
-
it('skips a column-specific URL when its handler returns undefined for that row', async () => {
|
|
1058
|
-
const t = Table.make<{ id: string; status?: string }>()
|
|
1059
|
-
.columns([
|
|
1060
|
-
Column.make('title').recordUrl((r) =>
|
|
1061
|
-
(r as { status?: string }).status === 'archived'
|
|
1062
|
-
? undefined
|
|
1063
|
-
: `/posts/${(r as { id?: string }).id}/edit`),
|
|
1064
|
-
])
|
|
1065
|
-
.records(async () => [
|
|
1066
|
-
{ id: '1', status: 'archived' },
|
|
1067
|
-
{ id: '2' },
|
|
1068
|
-
])
|
|
1069
|
-
|
|
1070
|
-
await loadTableRecords([t], {})
|
|
1071
|
-
const meta = (await resolveSchema([t]))[0]!
|
|
1072
|
-
const rows = meta['rows'] as Array<Record<string, unknown>>
|
|
1073
|
-
assert.deepEqual(rows[0]!['_columnRecordUrls'], {})
|
|
1074
|
-
assert.deepEqual(rows[1]!['_columnRecordUrls'], { title: '/posts/2/edit' })
|
|
1075
|
-
})
|
|
1076
|
-
|
|
1077
|
-
it('swallows errors thrown by a column recordUrl handler', async () => {
|
|
1078
|
-
const t = Table.make<{ id: string }>()
|
|
1079
|
-
.columns([
|
|
1080
|
-
Column.make('title').recordUrl(() => { throw new Error('oops') }),
|
|
1081
|
-
])
|
|
1082
|
-
.records(async () => [{ id: '1' }])
|
|
1083
|
-
|
|
1084
|
-
await loadTableRecords([t], {}) // no throw
|
|
1085
|
-
const meta = (await resolveSchema([t]))[0]!
|
|
1086
|
-
const rows = meta['rows'] as Array<Record<string, unknown>>
|
|
1087
|
-
// Bucket exists but the broken column's key is absent.
|
|
1088
|
-
assert.deepEqual(rows[0]!['_columnRecordUrls'], {})
|
|
1089
|
-
})
|
|
1090
|
-
|
|
1091
|
-
it('Column.recordUrl(false) leaves the column meta marked as opted-out (no per-row stamp needed)', async () => {
|
|
1092
|
-
const t = Table.make<{ id: string }>()
|
|
1093
|
-
.columns([
|
|
1094
|
-
Column.make('id'),
|
|
1095
|
-
Column.make('actions').recordUrl(false),
|
|
1096
|
-
])
|
|
1097
|
-
.records(async () => [{ id: 'a' }])
|
|
1098
|
-
.recordUrl((r) => `/posts/${(r as { id?: string }).id}`)
|
|
1099
|
-
|
|
1100
|
-
await loadTableRecords([t], {})
|
|
1101
|
-
const meta = (await resolveSchema([t]))[0]!
|
|
1102
|
-
const cols = (meta['children'] as ElementMetaLike[] | undefined) ?? []
|
|
1103
|
-
const actions = cols.find(c => c['name'] === 'actions')
|
|
1104
|
-
assert.equal(actions?.['recordUrl'], false)
|
|
1105
|
-
const rows = meta['rows'] as Array<Record<string, unknown>>
|
|
1106
|
-
assert.equal(rows[0]!['_recordUrl'], '/posts/a')
|
|
1107
|
-
})
|
|
1108
|
-
})
|
|
1109
|
-
|
|
1110
|
-
describe('list-page tabs (active tab → TableContext)', () => {
|
|
1111
|
-
it('passes ctx.tab + ctx.tabQuery through when the active tab has modifyQuery', async () => {
|
|
1112
|
-
const { ListTab } = await import('../Tab.js')
|
|
1113
|
-
const { ListTabs } = await import('./ListTabs.js')
|
|
1114
|
-
|
|
1115
|
-
const queryFn = (q: { _filters: string[] }) => ({ ...q, _filters: [...q._filters, 'status=draft'] })
|
|
1116
|
-
|
|
1117
|
-
const drafts = ListTab.make('drafts').modifyQuery(queryFn as never)
|
|
1118
|
-
drafts.withActive()
|
|
1119
|
-
|
|
1120
|
-
let seenTab: string | undefined
|
|
1121
|
-
let seenTabQuery: unknown
|
|
1122
|
-
const t = Table.make()
|
|
1123
|
-
.columns([Column.make('id')])
|
|
1124
|
-
.records(async (ctx) => {
|
|
1125
|
-
seenTab = ctx.tab
|
|
1126
|
-
seenTabQuery = ctx.tabQuery
|
|
1127
|
-
return []
|
|
1128
|
-
})
|
|
1129
|
-
|
|
1130
|
-
await loadTableRecords([t, ListTabs.make().tabs([drafts])], {})
|
|
1131
|
-
assert.equal(seenTab, 'drafts')
|
|
1132
|
-
assert.equal(seenTabQuery, queryFn)
|
|
1133
|
-
})
|
|
1134
|
-
|
|
1135
|
-
it('runs the active tab modifyContext as a final transform on the TableContext', async () => {
|
|
1136
|
-
const { ListTab } = await import('../Tab.js')
|
|
1137
|
-
const { ListTabs } = await import('./ListTabs.js')
|
|
1138
|
-
|
|
1139
|
-
const drafts = ListTab.make('drafts').modifyContext((ctx) => ({ ...ctx, customFlag: 42 }))
|
|
1140
|
-
drafts.withActive()
|
|
1141
|
-
|
|
1142
|
-
let seen: Record<string, unknown> | null = null
|
|
1143
|
-
const t = Table.make()
|
|
1144
|
-
.columns([Column.make('id')])
|
|
1145
|
-
.records(async (ctx) => { seen = { ...ctx }; return [] })
|
|
1146
|
-
|
|
1147
|
-
await loadTableRecords([t, ListTabs.make().tabs([drafts])], {})
|
|
1148
|
-
assert.equal(seen!['customFlag'], 42)
|
|
1149
|
-
assert.equal(seen!['tab'], 'drafts')
|
|
1150
|
-
})
|
|
1151
|
-
|
|
1152
|
-
it('does not set ctx.tab / tabQuery when no tab is active', async () => {
|
|
1153
|
-
let seen: Record<string, unknown> | null = null
|
|
1154
|
-
const t = Table.make()
|
|
1155
|
-
.columns([Column.make('id')])
|
|
1156
|
-
.records(async (ctx) => { seen = { ...ctx }; return [] })
|
|
1157
|
-
|
|
1158
|
-
await loadTableRecords([t], {})
|
|
1159
|
-
assert.equal(seen!['tab'], undefined)
|
|
1160
|
-
assert.equal(seen!['tabQuery'], undefined)
|
|
1161
|
-
})
|
|
1162
|
-
})
|
|
1163
|
-
|
|
1164
|
-
describe('editable cell columns', () => {
|
|
1165
|
-
it('stamps _cellEditable on every row when canEdit hook is supplied + returns true', async () => {
|
|
1166
|
-
const t = Table.make<{ id: string; status: string }>()
|
|
1167
|
-
.columns([
|
|
1168
|
-
Column.make('id'),
|
|
1169
|
-
SelectColumn.make('status').options({ a: 'A', b: 'B' }),
|
|
1170
|
-
])
|
|
1171
|
-
.records(async () => ({ rows: [{ id: '1', status: 'a' }, { id: '2', status: 'b' }], total: 2 }))
|
|
1172
|
-
|
|
1173
|
-
await loadTableRecords([t], {}, undefined, undefined, {
|
|
1174
|
-
canEdit: () => true,
|
|
1175
|
-
})
|
|
1176
|
-
const rows = t.getRows() as Array<Record<string, unknown>>
|
|
1177
|
-
assert.deepEqual(rows[0]!['_cellEditable'], { status: true })
|
|
1178
|
-
assert.deepEqual(rows[1]!['_cellEditable'], { status: true })
|
|
1179
|
-
assert.equal(rows[0]!['_cellDisabled'], undefined)
|
|
1180
|
-
})
|
|
1181
|
-
|
|
1182
|
-
it('skips _cellEditable on rows where canEdit returns false', async () => {
|
|
1183
|
-
const t = Table.make<{ id: string; archived: boolean }>()
|
|
1184
|
-
.columns([
|
|
1185
|
-
Column.make('id'),
|
|
1186
|
-
ToggleColumn.make('featured'),
|
|
1187
|
-
])
|
|
1188
|
-
.records(async () => ({ rows: [
|
|
1189
|
-
{ id: '1', archived: false },
|
|
1190
|
-
{ id: '2', archived: true },
|
|
1191
|
-
], total: 2 }))
|
|
1192
|
-
|
|
1193
|
-
await loadTableRecords([t], {}, undefined, undefined, {
|
|
1194
|
-
canEdit: (_user, record) => record['archived'] !== true,
|
|
1195
|
-
})
|
|
1196
|
-
const rows = t.getRows() as Array<Record<string, unknown>>
|
|
1197
|
-
assert.deepEqual(rows[0]!['_cellEditable'], { featured: true })
|
|
1198
|
-
assert.equal(rows[1]!['_cellEditable'], undefined)
|
|
1199
|
-
})
|
|
1200
|
-
|
|
1201
|
-
it('stamps _cellDisabled when the column predicate flags the row', async () => {
|
|
1202
|
-
const t = Table.make<{ id: string; archived: boolean }>()
|
|
1203
|
-
.columns([
|
|
1204
|
-
Column.make('id'),
|
|
1205
|
-
SelectColumn.make('status')
|
|
1206
|
-
.options({ a: 'A', b: 'B' })
|
|
1207
|
-
.disabled(record => record['archived'] === true),
|
|
1208
|
-
])
|
|
1209
|
-
.records(async () => ({ rows: [
|
|
1210
|
-
{ id: '1', archived: false },
|
|
1211
|
-
{ id: '2', archived: true },
|
|
1212
|
-
], total: 2 }))
|
|
1213
|
-
|
|
1214
|
-
await loadTableRecords([t], {}, undefined, undefined, {
|
|
1215
|
-
canEdit: () => true,
|
|
1216
|
-
})
|
|
1217
|
-
const rows = t.getRows() as Array<Record<string, unknown>>
|
|
1218
|
-
assert.equal(rows[0]!['_cellDisabled'], undefined)
|
|
1219
|
-
assert.deepEqual(rows[1]!['_cellDisabled'], { status: true })
|
|
1220
|
-
// The flag is independent — disabled rows are still stamped editable.
|
|
1221
|
-
assert.deepEqual(rows[1]!['_cellEditable'], { status: true })
|
|
1222
|
-
})
|
|
1223
|
-
|
|
1224
|
-
it('skips per-row mutation entirely when no canEdit hook is supplied', async () => {
|
|
1225
|
-
const t = Table.make<{ id: string }>()
|
|
1226
|
-
.columns([
|
|
1227
|
-
Column.make('id'),
|
|
1228
|
-
TextInputColumn.make('title'),
|
|
1229
|
-
])
|
|
1230
|
-
.records(async () => ({ rows: [{ id: '1' }], total: 1 }))
|
|
1231
|
-
|
|
1232
|
-
await loadTableRecords([t], {})
|
|
1233
|
-
const rows = t.getRows() as Array<Record<string, unknown>>
|
|
1234
|
-
assert.equal(rows[0]!['_cellEditable'], undefined)
|
|
1235
|
-
assert.equal(rows[0]!['_cellDisabled'], undefined)
|
|
1236
|
-
})
|
|
1237
|
-
|
|
1238
|
-
it('treats canEdit throwing as denial (closed posture)', async () => {
|
|
1239
|
-
const t = Table.make<{ id: string }>()
|
|
1240
|
-
.columns([Column.make('id'), ToggleColumn.make('on')])
|
|
1241
|
-
.records(async () => ({ rows: [{ id: '1' }], total: 1 }))
|
|
1242
|
-
|
|
1243
|
-
await loadTableRecords([t], {}, undefined, undefined, {
|
|
1244
|
-
canEdit: () => { throw new Error('boom') },
|
|
1245
|
-
})
|
|
1246
|
-
const rows = t.getRows() as Array<Record<string, unknown>>
|
|
1247
|
-
assert.equal(rows[0]!['_cellEditable'], undefined)
|
|
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
|
-
})
|
|
1331
|
-
})
|
|
1332
|
-
})
|
|
1333
|
-
|
|
1334
|
-
type ElementMetaLike = Record<string, unknown>
|
|
1335
|
-
|
|
1336
|
-
// ─── queryStringIdentifier (Tier-3) ────────────────────────────
|
|
1337
|
-
|
|
1338
|
-
import { SelectFilter } from '../filters/SelectFilter.js'
|
|
1339
|
-
import { parseFilterValues } from './dispatchTable.js'
|
|
1340
|
-
|
|
1341
|
-
describe('Table.queryStringIdentifier', () => {
|
|
1342
|
-
it('round-trips into the resolved meta', async () => {
|
|
1343
|
-
const t = Table.make().columns([Column.make('id')]).queryStringIdentifier('orders')
|
|
1344
|
-
const meta = (await resolveSchema([t]))[0]!
|
|
1345
|
-
assert.equal(meta['queryStringIdentifier'], 'orders')
|
|
1346
|
-
})
|
|
1347
|
-
|
|
1348
|
-
it('rejects empty / invalid identifiers at config time', () => {
|
|
1349
|
-
assert.throws(() => Table.make().queryStringIdentifier(''), /invalid id/)
|
|
1350
|
-
assert.throws(() => Table.make().queryStringIdentifier('a b'), /invalid id/)
|
|
1351
|
-
assert.throws(() => Table.make().queryStringIdentifier('a/b'), /invalid id/)
|
|
1352
|
-
})
|
|
1353
|
-
|
|
1354
|
-
it('parseTableQuery reads namespaced keys when prefix is set', () => {
|
|
1355
|
-
assert.deepEqual(parseTableQuery({
|
|
1356
|
-
orders_search: ' hi ',
|
|
1357
|
-
orders_sort: 'date:desc',
|
|
1358
|
-
orders_page: '3',
|
|
1359
|
-
orders_perPage: '25',
|
|
1360
|
-
// Bare keys belong to some other table on the page — must not leak.
|
|
1361
|
-
search: 'noise',
|
|
1362
|
-
sort: 'noise:asc',
|
|
1363
|
-
}, 'orders'), {
|
|
1364
|
-
search: 'hi',
|
|
1365
|
-
sort: { column: 'date', direction: 'desc' },
|
|
1366
|
-
page: 3,
|
|
1367
|
-
perPage: 25,
|
|
1368
|
-
})
|
|
1369
|
-
})
|
|
1370
|
-
|
|
1371
|
-
it('parseFilterValues respects prefix + filter-name match', () => {
|
|
1372
|
-
const filters = [SelectFilter.make('status').options([
|
|
1373
|
-
{ value: 'draft', label: 'Draft' },
|
|
1374
|
-
{ value: 'published', label: 'Published' },
|
|
1375
|
-
])]
|
|
1376
|
-
const out = parseFilterValues({
|
|
1377
|
-
orders_status: 'draft',
|
|
1378
|
-
orders_other: 'ignored', // not a registered filter
|
|
1379
|
-
status: 'noise', // bare keys belong to another table
|
|
1380
|
-
}, filters, 'orders')
|
|
1381
|
-
assert.deepEqual(out, { status: 'draft' })
|
|
1382
|
-
})
|
|
1383
|
-
|
|
1384
|
-
it('parseActiveGroup reads the prefixed ?<id>_group key', () => {
|
|
1385
|
-
const t = Table.make().columns([Column.make('status')]).defaultGroup('status')
|
|
1386
|
-
assert.equal(parseActiveGroup({ orders_group: 'status' }, t, 'orders'), 'status')
|
|
1387
|
-
// Bare ?group= no longer applies when prefix is set.
|
|
1388
|
-
assert.equal(parseActiveGroup({ group: 'status' }, t, 'orders'), 'status' /* falls through to defaultGroup */)
|
|
1389
|
-
assert.equal(parseActiveGroup({ orders_group: '' }, t, 'orders'), undefined)
|
|
1390
|
-
})
|
|
1391
|
-
|
|
1392
|
-
it('two tables on one page parse independent prefixed slices', async () => {
|
|
1393
|
-
let ordersCtx: Record<string, unknown> | null = null
|
|
1394
|
-
let invoicesCtx: Record<string, unknown> | null = null
|
|
1395
|
-
const orders = Table.make<{ id: string }>()
|
|
1396
|
-
.queryStringIdentifier('orders')
|
|
1397
|
-
.columns([Column.make('id')])
|
|
1398
|
-
.records(async (ctx) => { ordersCtx = { ...ctx }; return [] })
|
|
1399
|
-
const invoices = Table.make<{ id: string }>()
|
|
1400
|
-
.queryStringIdentifier('invoices')
|
|
1401
|
-
.columns([Column.make('id')])
|
|
1402
|
-
.records(async (ctx) => { invoicesCtx = { ...ctx }; return [] })
|
|
1403
|
-
|
|
1404
|
-
await loadTableRecords([orders, invoices], {
|
|
1405
|
-
orders_search: 'pizza',
|
|
1406
|
-
orders_sort: 'date:desc',
|
|
1407
|
-
invoices_page: '4',
|
|
1408
|
-
invoices_sort: 'amount:asc',
|
|
1409
|
-
})
|
|
1410
|
-
|
|
1411
|
-
assert.equal((ordersCtx as ElementMetaLike | null)?.['search'], 'pizza')
|
|
1412
|
-
assert.deepEqual((ordersCtx as ElementMetaLike | null)?.['sort'], { column: 'date', direction: 'desc' })
|
|
1413
|
-
assert.equal((invoicesCtx as ElementMetaLike | null)?.['search'], undefined)
|
|
1414
|
-
assert.deepEqual((invoicesCtx as ElementMetaLike | null)?.['sort'], { column: 'amount', direction: 'asc' })
|
|
1415
|
-
assert.equal((invoicesCtx as ElementMetaLike | null)?.['page'], 4)
|
|
1416
|
-
})
|
|
1417
|
-
|
|
1418
|
-
it('without prefix, bare keys still apply (back-compat)', async () => {
|
|
1419
|
-
let seen: Record<string, unknown> | null = null
|
|
1420
|
-
const t = Table.make<{ id: string }>()
|
|
1421
|
-
.columns([Column.make('id')])
|
|
1422
|
-
.records(async (ctx) => { seen = { ...ctx }; return [] })
|
|
1423
|
-
|
|
1424
|
-
await loadTableRecords([t], { search: 'q', sort: 'name:asc', page: '2' })
|
|
1425
|
-
assert.equal((seen as ElementMetaLike | null)?.['search'], 'q')
|
|
1426
|
-
assert.deepEqual((seen as ElementMetaLike | null)?.['sort'], { column: 'name', direction: 'asc' })
|
|
1427
|
-
assert.equal((seen as ElementMetaLike | null)?.['page'], 2)
|
|
1428
|
-
})
|
|
1429
|
-
})
|
|
1430
|
-
|
|
1431
|
-
describe('loadTableRecords — cards layout per-row schema', () => {
|
|
1432
|
-
it('stamps `_cardChildren` per row resolved from cardSchema(record, ctx)', async () => {
|
|
1433
|
-
type Row = { id: number; title: string; subtitle: string }
|
|
1434
|
-
let receivedSearch: string | undefined
|
|
1435
|
-
const t = Table.make<Row>()
|
|
1436
|
-
.cards()
|
|
1437
|
-
.columns([Column.make('title')])
|
|
1438
|
-
.records(() => [
|
|
1439
|
-
{ id: 1, title: 'First', subtitle: 'A subtitle' },
|
|
1440
|
-
{ id: 2, title: 'Second', subtitle: 'Another' },
|
|
1441
|
-
])
|
|
1442
|
-
.cardSchema((row, ctx) => {
|
|
1443
|
-
receivedSearch = ctx.search
|
|
1444
|
-
return [
|
|
1445
|
-
Heading.make(row.title).level(3),
|
|
1446
|
-
Text.make(row.subtitle),
|
|
1447
|
-
]
|
|
1448
|
-
})
|
|
1449
|
-
|
|
1450
|
-
await loadTableRecords([t], { search: 'foo' })
|
|
1451
|
-
|
|
1452
|
-
assert.equal(receivedSearch, 'foo')
|
|
1453
|
-
const rows = t.getRows() as Array<Record<string, unknown>>
|
|
1454
|
-
assert.equal(rows.length, 2)
|
|
1455
|
-
const firstChildren = rows[0]!['_cardChildren'] as Array<Record<string, unknown>>
|
|
1456
|
-
assert.equal(firstChildren.length, 2)
|
|
1457
|
-
assert.equal(firstChildren[0]!['type'], 'heading')
|
|
1458
|
-
assert.equal(firstChildren[0]!['content'], 'First')
|
|
1459
|
-
assert.equal(firstChildren[1]!['type'], 'text')
|
|
1460
|
-
|
|
1461
|
-
const secondChildren = rows[1]!['_cardChildren'] as Array<Record<string, unknown>>
|
|
1462
|
-
assert.equal(secondChildren[0]!['content'], 'Second')
|
|
1463
|
-
})
|
|
1464
|
-
|
|
1465
|
-
it('does NOT stamp `_cardChildren` when contentLayout is the default "table"', async () => {
|
|
1466
|
-
type Row = { id: number; title: string }
|
|
1467
|
-
const t = Table.make<Row>()
|
|
1468
|
-
.columns([Column.make('title')])
|
|
1469
|
-
.records(() => [{ id: 1, title: 'First' }])
|
|
1470
|
-
// cardSchema is set but layout is still 'table' — should be ignored.
|
|
1471
|
-
.cardSchema((row) => [Heading.make(row.title)])
|
|
1472
|
-
|
|
1473
|
-
await loadTableRecords([t], {})
|
|
1474
|
-
const rows = t.getRows() as Array<Record<string, unknown>>
|
|
1475
|
-
assert.equal(rows[0]!['_cardChildren'], undefined)
|
|
1476
|
-
})
|
|
1477
|
-
|
|
1478
|
-
it('a throwing cardSchema does not abort the row — empty children stamped + warning', async () => {
|
|
1479
|
-
const warnings: unknown[] = []
|
|
1480
|
-
const origWarn = console.warn
|
|
1481
|
-
console.warn = (...args: unknown[]) => { warnings.push(args) }
|
|
1482
|
-
try {
|
|
1483
|
-
type Row = { id: number; title: string }
|
|
1484
|
-
const t = Table.make<Row>()
|
|
1485
|
-
.cards()
|
|
1486
|
-
.columns([Column.make('title')])
|
|
1487
|
-
.records(() => [{ id: 1, title: 'A' }])
|
|
1488
|
-
.cardSchema(() => { throw new Error('boom') })
|
|
1489
|
-
|
|
1490
|
-
await loadTableRecords([t], {})
|
|
1491
|
-
const rows = t.getRows() as Array<Record<string, unknown>>
|
|
1492
|
-
assert.equal(rows.length, 1)
|
|
1493
|
-
assert.deepEqual(rows[0]!['_cardChildren'], [])
|
|
1494
|
-
assert.equal(warnings.length, 1)
|
|
1495
|
-
} finally {
|
|
1496
|
-
console.warn = origWarn
|
|
1497
|
-
}
|
|
1498
|
-
})
|
|
1499
|
-
|
|
1500
|
-
it('cardSchema receives the row record verbatim (not a clone)', async () => {
|
|
1501
|
-
type Row = { id: number; tags: string[] }
|
|
1502
|
-
const original: Row = { id: 1, tags: ['a', 'b'] }
|
|
1503
|
-
let receivedRow: Row | null = null
|
|
1504
|
-
const t = Table.make<Row>()
|
|
1505
|
-
.cards()
|
|
1506
|
-
.columns([Column.make('id')])
|
|
1507
|
-
.records(() => [original])
|
|
1508
|
-
.cardSchema((row) => { receivedRow = row; return [] })
|
|
1509
|
-
|
|
1510
|
-
await loadTableRecords([t], {})
|
|
1511
|
-
// Row identity preserved so cardSchema can branch on instanceof / refs.
|
|
1512
|
-
assert.equal(receivedRow, original)
|
|
1513
|
-
})
|
|
1514
|
-
})
|