@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,1595 +0,0 @@
|
|
|
1
|
-
import { describe, it, beforeEach } from 'node:test'
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
|
|
4
|
-
import { Pilotiq } from './Pilotiq.js'
|
|
5
|
-
import { Resource } from './Resource.js'
|
|
6
|
-
import { Page } from './Page.js'
|
|
7
|
-
import { RelationManager } from './RelationManager.js'
|
|
8
|
-
import { Form } from './elements/Form.js'
|
|
9
|
-
import { Table } from './elements/Table.js'
|
|
10
|
-
import { Column } from './Column.js'
|
|
11
|
-
import { TextField } from './fields/TextField.js'
|
|
12
|
-
import { Heading } from './schema/Heading.js'
|
|
13
|
-
import { findRelatedResource, relationManagerData, dispatchPageData, resourceEditData, resourceViewData, resourceRecordPageData, safeManagerPolicy } from './pageData.js'
|
|
14
|
-
import { PilotiqRegistry } from './PilotiqRegistry.js'
|
|
15
|
-
import type { ModelLike, ModelQuery } from './orm/modelDefaults.js'
|
|
16
|
-
|
|
17
|
-
// ── Test doubles ───────────────────────────────────────────────────
|
|
18
|
-
|
|
19
|
-
interface QueryRow extends Record<string, unknown> { id: string | number }
|
|
20
|
-
|
|
21
|
-
class StubQuery implements ModelQuery {
|
|
22
|
-
private filters: Array<{ col: string; op?: string; val: unknown }> = []
|
|
23
|
-
constructor(private rows: QueryRow[]) {}
|
|
24
|
-
|
|
25
|
-
where(...args: unknown[]): ModelQuery {
|
|
26
|
-
if (args.length === 2) this.filters.push({ col: args[0] as string, val: args[1] })
|
|
27
|
-
else this.filters.push({ col: args[0] as string, op: args[1] as string, val: args[2] })
|
|
28
|
-
return this
|
|
29
|
-
}
|
|
30
|
-
orWhere(...args: unknown[]): ModelQuery {
|
|
31
|
-
return this.where(...args)
|
|
32
|
-
}
|
|
33
|
-
orderBy(_c: string, _d?: 'ASC' | 'DESC'): ModelQuery { return this }
|
|
34
|
-
|
|
35
|
-
async paginate(_page: number, _perPage?: number) {
|
|
36
|
-
let data = this.rows
|
|
37
|
-
for (const f of this.filters) {
|
|
38
|
-
if (f.op === '=' || f.op === undefined) {
|
|
39
|
-
data = data.filter(r => r[f.col] === f.val)
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
return { data, total: data.length }
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function stubModel(opts: { rows?: QueryRow[]; primaryKey?: string } = {}): ModelLike {
|
|
47
|
-
const rows = opts.rows ?? []
|
|
48
|
-
const M: ModelLike = {
|
|
49
|
-
async find(id) { return rows.find(r => r['id'] === id || String(r['id']) === String(id)) ?? null },
|
|
50
|
-
async create(data) { const next = { id: rows.length + 1, ...data } as QueryRow; rows.push(next); return next },
|
|
51
|
-
async update(id, data) { const r = rows.find(r => r['id'] === id); if (r) Object.assign(r, data); return r ?? null },
|
|
52
|
-
async delete(id) { const i = rows.findIndex(r => r['id'] === id); if (i >= 0) rows.splice(i, 1) },
|
|
53
|
-
query() { return new StubQuery(rows) },
|
|
54
|
-
}
|
|
55
|
-
if (opts.primaryKey !== undefined) M.primaryKey = opts.primaryKey
|
|
56
|
-
return M
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** Build a parent record that exposes `.related(name)` (rudder convention)
|
|
60
|
-
* yielding a StubQuery filtered by the foreign key. */
|
|
61
|
-
function makeParentWithChildren(parentId: string | number, childRows: QueryRow[], fk = 'parentId') {
|
|
62
|
-
return {
|
|
63
|
-
id: parentId,
|
|
64
|
-
related(_name: string): ModelQuery {
|
|
65
|
-
// Return a StubQuery pre-filtered to just this parent's children.
|
|
66
|
-
return new StubQuery(childRows.filter(r => r[fk] === parentId))
|
|
67
|
-
},
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Adapt a stub `find(id)` to the `query().where(pk, id).paginate(1, 1)`
|
|
73
|
-
* shape that pilotiq's `findRecord(R, id, ctx)` now drives. Returns a
|
|
74
|
-
* `ModelQuery` that captures the last where-clause value and resolves
|
|
75
|
-
* via the supplied finder on `paginate()`. Lets these tests keep their
|
|
76
|
-
* `find(id)` stub data without rewriting fixtures into row arrays.
|
|
77
|
-
*/
|
|
78
|
-
function findAdapter(find: (id: string) => Promise<unknown>): ModelQuery {
|
|
79
|
-
let captured: unknown
|
|
80
|
-
const q: ModelQuery = {
|
|
81
|
-
where(...args: unknown[]): ModelQuery {
|
|
82
|
-
captured = args.length === 2 ? args[1] : args[2]
|
|
83
|
-
return q
|
|
84
|
-
},
|
|
85
|
-
orWhere(...args: unknown[]): ModelQuery {
|
|
86
|
-
captured = args.length === 2 ? args[1] : args[2]
|
|
87
|
-
return q
|
|
88
|
-
},
|
|
89
|
-
orderBy(): ModelQuery { return q },
|
|
90
|
-
async paginate() {
|
|
91
|
-
const r = await find(String(captured))
|
|
92
|
-
return { data: r ? [r] : [], total: r ? 1 : 0 }
|
|
93
|
-
},
|
|
94
|
-
}
|
|
95
|
-
return q
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// ── findRelatedResource — discovery via override + rudder convention ──
|
|
99
|
-
|
|
100
|
-
describe('findRelatedResource (Plan #11)', () => {
|
|
101
|
-
it('returns the explicit relatedResource override without touching ORM metadata', () => {
|
|
102
|
-
class TargetResource extends Resource {
|
|
103
|
-
static override slug = 'targets'
|
|
104
|
-
}
|
|
105
|
-
class M extends RelationManager {
|
|
106
|
-
static override relationship = 'targets'
|
|
107
|
-
static override relatedResource = TargetResource
|
|
108
|
-
}
|
|
109
|
-
class Parent extends Resource {
|
|
110
|
-
static override slug = 'parents'
|
|
111
|
-
static override relations() { return [M] }
|
|
112
|
-
}
|
|
113
|
-
const panel = Pilotiq.make('T').path('/admin').resources([Parent, TargetResource])
|
|
114
|
-
const got = findRelatedResource(M, Parent, panel.getConfig())
|
|
115
|
-
assert.equal(got, TargetResource)
|
|
116
|
-
})
|
|
117
|
-
|
|
118
|
-
it('discovers via rudder relations[name].model() match against cfg.resources', () => {
|
|
119
|
-
const ChildModel = stubModel()
|
|
120
|
-
const ParentModel = {
|
|
121
|
-
...stubModel(),
|
|
122
|
-
relations: { posts: { model: () => ChildModel } },
|
|
123
|
-
} as ModelLike
|
|
124
|
-
|
|
125
|
-
class PostResource extends Resource {
|
|
126
|
-
static override slug = 'posts'
|
|
127
|
-
static override get model() { return ChildModel }
|
|
128
|
-
}
|
|
129
|
-
class PostsManager extends RelationManager {
|
|
130
|
-
static override relationship = 'posts'
|
|
131
|
-
}
|
|
132
|
-
class UserResource extends Resource {
|
|
133
|
-
static override slug = 'users'
|
|
134
|
-
static override get model() { return ParentModel }
|
|
135
|
-
static override relations() { return [PostsManager] }
|
|
136
|
-
}
|
|
137
|
-
const panel = Pilotiq.make('T').path('/admin').resources([UserResource, PostResource])
|
|
138
|
-
const got = findRelatedResource(PostsManager, UserResource, panel.getConfig())
|
|
139
|
-
assert.equal(got, PostResource)
|
|
140
|
-
})
|
|
141
|
-
|
|
142
|
-
it('returns undefined when neither override nor rudder metadata locates a Resource', () => {
|
|
143
|
-
class M extends RelationManager {
|
|
144
|
-
static override relationship = 'orphans'
|
|
145
|
-
}
|
|
146
|
-
class Parent extends Resource {
|
|
147
|
-
static override slug = 'parents'
|
|
148
|
-
static override relations() { return [M] }
|
|
149
|
-
}
|
|
150
|
-
const panel = Pilotiq.make('T').path('/admin').resources([Parent])
|
|
151
|
-
const got = findRelatedResource(M, Parent, panel.getConfig())
|
|
152
|
-
assert.equal(got, undefined)
|
|
153
|
-
})
|
|
154
|
-
})
|
|
155
|
-
|
|
156
|
-
// ── relationManagerData — the three scopes ────────────────────────
|
|
157
|
-
|
|
158
|
-
describe('relationManagerData (Plan #11)', () => {
|
|
159
|
-
/** Build a User → Posts test world. Returns the panel + key models so
|
|
160
|
-
* individual tests can poke records or override hooks. */
|
|
161
|
-
function buildWorld(opts: { managerOverrides?: Partial<typeof RelationManager> } = {}) {
|
|
162
|
-
const postRows: QueryRow[] = [
|
|
163
|
-
{ id: 'p1', parentId: 'u1', title: 'Post One' },
|
|
164
|
-
{ id: 'p2', parentId: 'u1', title: 'Post Two' },
|
|
165
|
-
{ id: 'p3', parentId: 'u2', title: 'Other User Post' },
|
|
166
|
-
]
|
|
167
|
-
const PostModel = stubModel({ rows: postRows })
|
|
168
|
-
|
|
169
|
-
// Parent records carry their own .related() so relation-list works
|
|
170
|
-
// without touching ParentModel.relations metadata; for relation-edit
|
|
171
|
-
// we ALSO need ParentModel.relations[].model() to discover Related
|
|
172
|
-
// Resource.
|
|
173
|
-
const parents = new Map<string, ReturnType<typeof makeParentWithChildren>>([
|
|
174
|
-
['u1', makeParentWithChildren('u1', postRows)],
|
|
175
|
-
['u2', makeParentWithChildren('u2', postRows)],
|
|
176
|
-
])
|
|
177
|
-
// `query()` drives the new `findRecord(R, id, ctx)` path used to load
|
|
178
|
-
// parent records (and policy-record lookups). Build a StubQuery over
|
|
179
|
-
// the parents-as-rows so `where('id', '=', X).paginate(1, 1)` resolves
|
|
180
|
-
// the same shape `find()` historically returned.
|
|
181
|
-
const parentRows: QueryRow[] = [...parents.values()].map(p => p as unknown as QueryRow)
|
|
182
|
-
const ParentModel: ModelLike = {
|
|
183
|
-
async find(id) { return parents.get(String(id)) ?? null },
|
|
184
|
-
async create() { throw new Error('not used') },
|
|
185
|
-
async update() { throw new Error('not used') },
|
|
186
|
-
async delete() { /* ok */ },
|
|
187
|
-
query() { return new StubQuery(parentRows) },
|
|
188
|
-
}
|
|
189
|
-
Object.assign(ParentModel as object, {
|
|
190
|
-
relations: { posts: { model: () => PostModel } },
|
|
191
|
-
})
|
|
192
|
-
|
|
193
|
-
class PostResource extends Resource {
|
|
194
|
-
static override label = 'Posts'
|
|
195
|
-
static override labelSingular = 'Post'
|
|
196
|
-
static override slug = 'posts'
|
|
197
|
-
static override get model() { return PostModel }
|
|
198
|
-
static override form(form: Form): Form {
|
|
199
|
-
return form.schema([TextField.make('title').required()])
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
class PostsManager extends RelationManager {
|
|
204
|
-
static override relationship = 'posts'
|
|
205
|
-
static override label = 'Posts'
|
|
206
|
-
|
|
207
|
-
static override table(table: Table): Table {
|
|
208
|
-
return table.columns([Column.make('title').sortable()])
|
|
209
|
-
}
|
|
210
|
-
static override form(form: Form): Form {
|
|
211
|
-
return form.schema([TextField.make('title').required()])
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
if (opts.managerOverrides) {
|
|
215
|
-
Object.assign(PostsManager, opts.managerOverrides)
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
class UserResource extends Resource {
|
|
219
|
-
static override label = 'Users'
|
|
220
|
-
static override slug = 'users'
|
|
221
|
-
static override recordTitleAttribute = 'name'
|
|
222
|
-
static override get model() { return ParentModel }
|
|
223
|
-
static override relations() { return [PostsManager] }
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
const panel = Pilotiq.make('T').path('/admin').resources([UserResource, PostResource])
|
|
227
|
-
return { panel, UserResource, PostResource, PostsManager, ParentModel, PostModel, postRows, parents }
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
it('returns null when the parent slug is unknown', async () => {
|
|
231
|
-
const { panel } = buildWorld()
|
|
232
|
-
const out = await relationManagerData(panel, {
|
|
233
|
-
kind: 'relation-list', slug: 'missing', recordId: 'u1', relationship: 'posts',
|
|
234
|
-
})
|
|
235
|
-
assert.equal(out, null)
|
|
236
|
-
})
|
|
237
|
-
|
|
238
|
-
it('returns null when the manager relationship is unknown on the resource', async () => {
|
|
239
|
-
const { panel } = buildWorld()
|
|
240
|
-
const out = await relationManagerData(panel, {
|
|
241
|
-
kind: 'relation-list', slug: 'users', recordId: 'u1', relationship: 'comments',
|
|
242
|
-
})
|
|
243
|
-
assert.equal(out, null)
|
|
244
|
-
})
|
|
245
|
-
|
|
246
|
-
it('returns null when the parent record cannot be loaded', async () => {
|
|
247
|
-
const { panel } = buildWorld()
|
|
248
|
-
const out = await relationManagerData(panel, {
|
|
249
|
-
kind: 'relation-list', slug: 'users', recordId: 'unknown', relationship: 'posts',
|
|
250
|
-
})
|
|
251
|
-
assert.equal(out, null)
|
|
252
|
-
})
|
|
253
|
-
|
|
254
|
-
it('throws when the parent has relations() but no static model', () => {
|
|
255
|
-
class M extends RelationManager {
|
|
256
|
-
static override relationship = 'posts'
|
|
257
|
-
}
|
|
258
|
-
class Bare extends Resource {
|
|
259
|
-
static override slug = 'bare'
|
|
260
|
-
static override relations() { return [M] }
|
|
261
|
-
}
|
|
262
|
-
const panel = Pilotiq.make('T').path('/admin').resources([Bare])
|
|
263
|
-
return assert.rejects(
|
|
264
|
-
() => relationManagerData(panel, {
|
|
265
|
-
kind: 'relation-list', slug: 'bare', recordId: '1', relationship: 'posts',
|
|
266
|
-
}),
|
|
267
|
-
/has relations\(.*\) but no static model/,
|
|
268
|
-
)
|
|
269
|
-
})
|
|
270
|
-
|
|
271
|
-
describe('authorization gating', () => {
|
|
272
|
-
it('403 when parent canAccess fails', async () => {
|
|
273
|
-
class Locked extends Resource {
|
|
274
|
-
static override slug = 'users'
|
|
275
|
-
static override async canAccess() { return false }
|
|
276
|
-
static override relations() { return [class extends RelationManager {
|
|
277
|
-
static override relationship = 'posts'
|
|
278
|
-
}] }
|
|
279
|
-
}
|
|
280
|
-
const panel = Pilotiq.make('T').path('/admin').resources([Locked])
|
|
281
|
-
const out = await relationManagerData(panel, {
|
|
282
|
-
kind: 'relation-list', slug: 'users', recordId: 'u1', relationship: 'posts',
|
|
283
|
-
})
|
|
284
|
-
assert.deepEqual(out, { ok: false, status: 403 })
|
|
285
|
-
})
|
|
286
|
-
|
|
287
|
-
it('403 when parent canEdit fails', async () => {
|
|
288
|
-
const { ParentModel, PostModel } = buildWorld()
|
|
289
|
-
class M extends RelationManager {
|
|
290
|
-
static override relationship = 'posts'
|
|
291
|
-
}
|
|
292
|
-
class R2 extends Resource {
|
|
293
|
-
static override slug = 'users'
|
|
294
|
-
static override get model() { return ParentModel }
|
|
295
|
-
static override async canEdit() { return false }
|
|
296
|
-
static override relations() { return [M] }
|
|
297
|
-
}
|
|
298
|
-
class Posts extends Resource {
|
|
299
|
-
static override slug = 'posts'
|
|
300
|
-
static override get model() { return PostModel }
|
|
301
|
-
}
|
|
302
|
-
const panel = Pilotiq.make('T').path('/admin').resources([R2, Posts])
|
|
303
|
-
const out = await relationManagerData(panel, {
|
|
304
|
-
kind: 'relation-list', slug: 'users', recordId: 'u1', relationship: 'posts',
|
|
305
|
-
})
|
|
306
|
-
assert.deepEqual(out, { ok: false, status: 403 })
|
|
307
|
-
})
|
|
308
|
-
|
|
309
|
-
it('403 on relation-list when manager.canViewAny fails', async () => {
|
|
310
|
-
const { panel } = buildWorld({
|
|
311
|
-
managerOverrides: {
|
|
312
|
-
canViewAny: async () => false,
|
|
313
|
-
} as Partial<typeof RelationManager>,
|
|
314
|
-
})
|
|
315
|
-
const out = await relationManagerData(panel, {
|
|
316
|
-
kind: 'relation-list', slug: 'users', recordId: 'u1', relationship: 'posts',
|
|
317
|
-
})
|
|
318
|
-
assert.deepEqual(out, { ok: false, status: 403 })
|
|
319
|
-
})
|
|
320
|
-
|
|
321
|
-
it('403 on relation-create when manager.canCreate fails', async () => {
|
|
322
|
-
const { panel } = buildWorld({
|
|
323
|
-
managerOverrides: {
|
|
324
|
-
canCreate: async () => false,
|
|
325
|
-
} as Partial<typeof RelationManager>,
|
|
326
|
-
})
|
|
327
|
-
const out = await relationManagerData(panel, {
|
|
328
|
-
kind: 'relation-create', slug: 'users', recordId: 'u1', relationship: 'posts',
|
|
329
|
-
})
|
|
330
|
-
assert.deepEqual(out, { ok: false, status: 403 })
|
|
331
|
-
})
|
|
332
|
-
|
|
333
|
-
it('403 on relation-edit when manager.canEdit fails', async () => {
|
|
334
|
-
const { panel } = buildWorld({
|
|
335
|
-
managerOverrides: {
|
|
336
|
-
canEdit: async () => false,
|
|
337
|
-
} as Partial<typeof RelationManager>,
|
|
338
|
-
})
|
|
339
|
-
const out = await relationManagerData(panel, {
|
|
340
|
-
kind: 'relation-edit', slug: 'users', recordId: 'u1', relationship: 'posts', childId: 'p1',
|
|
341
|
-
})
|
|
342
|
-
assert.deepEqual(out, { ok: false, status: 403 })
|
|
343
|
-
})
|
|
344
|
-
|
|
345
|
-
it('403 on relation-view when manager.canView fails', async () => {
|
|
346
|
-
const { panel } = buildWorld({
|
|
347
|
-
managerOverrides: {
|
|
348
|
-
canView: async () => false,
|
|
349
|
-
} as Partial<typeof RelationManager>,
|
|
350
|
-
})
|
|
351
|
-
const out = await relationManagerData(panel, {
|
|
352
|
-
kind: 'relation-view', slug: 'users', recordId: 'u1', relationship: 'posts', childId: 'p1',
|
|
353
|
-
})
|
|
354
|
-
assert.deepEqual(out, { ok: false, status: 403 })
|
|
355
|
-
})
|
|
356
|
-
})
|
|
357
|
-
|
|
358
|
-
describe('relation-list scope', () => {
|
|
359
|
-
it('returns schemaData with the manager table, parent-scoped via .related()', async () => {
|
|
360
|
-
const { panel } = buildWorld()
|
|
361
|
-
const out = await relationManagerData(panel, {
|
|
362
|
-
kind: 'relation-list', slug: 'users', recordId: 'u1', relationship: 'posts',
|
|
363
|
-
})
|
|
364
|
-
assert.notEqual(out, null)
|
|
365
|
-
assert.notEqual((out as { ok?: boolean }).ok, false)
|
|
366
|
-
const data = out as Record<string, unknown>
|
|
367
|
-
assert.equal(data['pageType'], 'relation-list')
|
|
368
|
-
const relation = data['relation'] as Record<string, unknown>
|
|
369
|
-
assert.equal(relation['relationship'], 'posts')
|
|
370
|
-
assert.equal(relation['relatedSlug'], 'posts')
|
|
371
|
-
const parent = data['parent'] as Record<string, unknown>
|
|
372
|
-
assert.equal(parent['id'], 'u1')
|
|
373
|
-
|
|
374
|
-
// Auto-wired records loader produced rows scoped to u1's children.
|
|
375
|
-
const schema = data['schemaData'] as Array<Record<string, unknown>>
|
|
376
|
-
const tableMeta = schema.find(s => s['type'] === 'table')
|
|
377
|
-
assert.ok(tableMeta, 'expected a table element in schemaData')
|
|
378
|
-
const rows = (tableMeta['rows'] as Array<Record<string, unknown>>) ?? []
|
|
379
|
-
assert.equal(rows.length, 2) // u1 has p1 + p2 only, never p3
|
|
380
|
-
assert.deepEqual(rows.map(r => r['id']).sort(), ['p1', 'p2'])
|
|
381
|
-
})
|
|
382
|
-
})
|
|
383
|
-
|
|
384
|
-
describe('relation-create scope', () => {
|
|
385
|
-
it('returns schemaData with form + create url stamped', async () => {
|
|
386
|
-
const { panel } = buildWorld()
|
|
387
|
-
const out = await relationManagerData(panel, {
|
|
388
|
-
kind: 'relation-create', slug: 'users', recordId: 'u1', relationship: 'posts',
|
|
389
|
-
})
|
|
390
|
-
assert.notEqual(out, null)
|
|
391
|
-
const data = out as Record<string, unknown>
|
|
392
|
-
assert.equal(data['pageType'], 'relation-create')
|
|
393
|
-
assert.equal(data['mode'], 'create')
|
|
394
|
-
|
|
395
|
-
const schema = data['schemaData'] as Array<Record<string, unknown>>
|
|
396
|
-
const formMeta = schema.find(s => s['type'] === 'form')
|
|
397
|
-
assert.ok(formMeta, 'expected a form element in schemaData')
|
|
398
|
-
assert.equal(formMeta['action'], '/admin/users/u1/posts/create')
|
|
399
|
-
})
|
|
400
|
-
|
|
401
|
-
it('honors prefill values and errors', async () => {
|
|
402
|
-
const { panel } = buildWorld()
|
|
403
|
-
const out = await relationManagerData(panel, {
|
|
404
|
-
kind: 'relation-create', slug: 'users', recordId: 'u1', relationship: 'posts',
|
|
405
|
-
prefill: { values: { title: 'Draft' }, errors: { title: ['Required'] } },
|
|
406
|
-
})
|
|
407
|
-
const data = out as Record<string, unknown>
|
|
408
|
-
assert.equal(data['hasErrors'], true)
|
|
409
|
-
const schema = data['schemaData'] as Array<Record<string, unknown>>
|
|
410
|
-
const formMeta = schema.find(s => s['type'] === 'form') as Record<string, unknown>
|
|
411
|
-
assert.equal((formMeta['values'] as Record<string, unknown>)['title'], 'Draft')
|
|
412
|
-
assert.deepEqual((formMeta['errors'] as Record<string, string[]>)['title'], ['Required'])
|
|
413
|
-
})
|
|
414
|
-
})
|
|
415
|
-
|
|
416
|
-
describe('relation-view scope', () => {
|
|
417
|
-
it('loads child + verifies it belongs to the parent (anti-IDOR)', async () => {
|
|
418
|
-
const { panel } = buildWorld({
|
|
419
|
-
managerOverrides: {
|
|
420
|
-
// Override detail() so we can assert the child + parent reach the
|
|
421
|
-
// schema. Heading text echoes the child's title.
|
|
422
|
-
detail(record: unknown, parentRecord: unknown) {
|
|
423
|
-
const child = record as Record<string, unknown>
|
|
424
|
-
const parent = parentRecord as Record<string, unknown>
|
|
425
|
-
return [Heading.make(`${parent['id']}: ${child['title']}`)]
|
|
426
|
-
},
|
|
427
|
-
} as Partial<typeof RelationManager>,
|
|
428
|
-
})
|
|
429
|
-
const out = await relationManagerData(panel, {
|
|
430
|
-
kind: 'relation-view', slug: 'users', recordId: 'u1', relationship: 'posts', childId: 'p1',
|
|
431
|
-
})
|
|
432
|
-
assert.notEqual(out, null)
|
|
433
|
-
const data = out as Record<string, unknown>
|
|
434
|
-
assert.equal(data['pageType'], 'relation-view')
|
|
435
|
-
assert.equal(data['mode'], 'view')
|
|
436
|
-
assert.equal(data['childId'], 'p1')
|
|
437
|
-
|
|
438
|
-
const schema = data['schemaData'] as Array<Record<string, unknown>>
|
|
439
|
-
const heading = schema.find(s => s['type'] === 'heading') as Record<string, unknown>
|
|
440
|
-
// detail(child, parent) was invoked with both records.
|
|
441
|
-
assert.equal(heading['content'], 'u1: Post One')
|
|
442
|
-
})
|
|
443
|
-
|
|
444
|
-
it('returns null when the child belongs to a different parent (IDOR)', async () => {
|
|
445
|
-
const { panel } = buildWorld()
|
|
446
|
-
// p3 is u2's post — trying to view it under u1 must fail.
|
|
447
|
-
const out = await relationManagerData(panel, {
|
|
448
|
-
kind: 'relation-view', slug: 'users', recordId: 'u1', relationship: 'posts', childId: 'p3',
|
|
449
|
-
})
|
|
450
|
-
assert.equal(out, null)
|
|
451
|
-
})
|
|
452
|
-
|
|
453
|
-
it('returns null when the child does not exist at all', async () => {
|
|
454
|
-
const { panel } = buildWorld()
|
|
455
|
-
const out = await relationManagerData(panel, {
|
|
456
|
-
kind: 'relation-view', slug: 'users', recordId: 'u1', relationship: 'posts', childId: 'nonexistent',
|
|
457
|
-
})
|
|
458
|
-
assert.equal(out, null)
|
|
459
|
-
})
|
|
460
|
-
|
|
461
|
-
it('renders an empty schema (RelationTabs only) when the manager does not override detail()', async () => {
|
|
462
|
-
const { panel } = buildWorld()
|
|
463
|
-
const out = await relationManagerData(panel, {
|
|
464
|
-
kind: 'relation-view', slug: 'users', recordId: 'u1', relationship: 'posts', childId: 'p1',
|
|
465
|
-
})
|
|
466
|
-
const data = out as Record<string, unknown>
|
|
467
|
-
const schema = data['schemaData'] as Array<Record<string, unknown>>
|
|
468
|
-
// Default Manager.detail() returns []; the page surfaces only the
|
|
469
|
-
// breadcrumbs (Phase C) + the RelationTabs strip — no detail body.
|
|
470
|
-
assert.deepEqual(
|
|
471
|
-
schema.map(s => s['type']),
|
|
472
|
-
['breadcrumbs', 'relation-tabs'],
|
|
473
|
-
)
|
|
474
|
-
})
|
|
475
|
-
|
|
476
|
-
it('marks the manager tab active in the RelationTabs strip', async () => {
|
|
477
|
-
const { panel } = buildWorld()
|
|
478
|
-
const out = await relationManagerData(panel, {
|
|
479
|
-
kind: 'relation-view', slug: 'users', recordId: 'u1', relationship: 'posts', childId: 'p1',
|
|
480
|
-
})
|
|
481
|
-
const data = out as Record<string, unknown>
|
|
482
|
-
const schema = data['schemaData'] as Array<Record<string, unknown>>
|
|
483
|
-
const tabs = schema.find(s => s['type'] === 'relation-tabs') as Record<string, unknown>
|
|
484
|
-
const tabList = tabs['tabs'] as Array<Record<string, unknown>>
|
|
485
|
-
const postsTab = tabList.find(t => t['key'] === 'posts')
|
|
486
|
-
assert.ok(postsTab, 'posts manager tab should be present')
|
|
487
|
-
assert.equal(postsTab!['active'], true)
|
|
488
|
-
// Sibling parent tabs render but are inactive.
|
|
489
|
-
const viewTab = tabList.find(t => t['key'] === '__view')
|
|
490
|
-
assert.equal(viewTab?.['active'], false)
|
|
491
|
-
})
|
|
492
|
-
|
|
493
|
-
it('breadcrumb leaf reads RelationManager.recordTitleAttribute over Resource fallback', async () => {
|
|
494
|
-
// Manager picks `parentId` for the leaf title; the related Resource
|
|
495
|
-
// doesn't set recordTitleAttribute, so without the manager override
|
|
496
|
-
// the fallback chain would land on `title` ("Post One").
|
|
497
|
-
const { panel } = buildWorld({
|
|
498
|
-
managerOverrides: { recordTitleAttribute: 'parentId' } as Partial<typeof RelationManager>,
|
|
499
|
-
})
|
|
500
|
-
const out = await relationManagerData(panel, {
|
|
501
|
-
kind: 'relation-view', slug: 'users', recordId: 'u1', relationship: 'posts', childId: 'p1',
|
|
502
|
-
})
|
|
503
|
-
const data = out as Record<string, unknown>
|
|
504
|
-
const schema = data['schemaData'] as Array<Record<string, unknown>>
|
|
505
|
-
const crumbs = schema.find(s => s['type'] === 'breadcrumbs') as Record<string, unknown>
|
|
506
|
-
const items = crumbs['items'] as Array<Record<string, unknown>>
|
|
507
|
-
assert.equal(items.at(-1)!['label'], 'u1')
|
|
508
|
-
})
|
|
509
|
-
})
|
|
510
|
-
|
|
511
|
-
describe('relation-edit scope', () => {
|
|
512
|
-
it('loads child + verifies it belongs to the parent (anti-IDOR)', async () => {
|
|
513
|
-
const { panel } = buildWorld()
|
|
514
|
-
const out = await relationManagerData(panel, {
|
|
515
|
-
kind: 'relation-edit', slug: 'users', recordId: 'u1', relationship: 'posts', childId: 'p1',
|
|
516
|
-
})
|
|
517
|
-
assert.notEqual(out, null)
|
|
518
|
-
const data = out as Record<string, unknown>
|
|
519
|
-
assert.equal(data['pageType'], 'relation-edit')
|
|
520
|
-
assert.equal(data['mode'], 'edit')
|
|
521
|
-
assert.equal(data['childId'], 'p1')
|
|
522
|
-
|
|
523
|
-
const schema = data['schemaData'] as Array<Record<string, unknown>>
|
|
524
|
-
const formMeta = schema.find(s => s['type'] === 'form') as Record<string, unknown>
|
|
525
|
-
// Child p1 belongs to u1 → its title should be filled in.
|
|
526
|
-
assert.equal((formMeta['values'] as Record<string, unknown>)['title'], 'Post One')
|
|
527
|
-
assert.equal(formMeta['action'], '/admin/users/u1/posts/p1/edit')
|
|
528
|
-
})
|
|
529
|
-
|
|
530
|
-
it('returns null when the child belongs to a different parent (IDOR)', async () => {
|
|
531
|
-
const { panel } = buildWorld()
|
|
532
|
-
// p3 is u2's post — trying to edit it under u1 must fail.
|
|
533
|
-
const out = await relationManagerData(panel, {
|
|
534
|
-
kind: 'relation-edit', slug: 'users', recordId: 'u1', relationship: 'posts', childId: 'p3',
|
|
535
|
-
})
|
|
536
|
-
assert.equal(out, null)
|
|
537
|
-
})
|
|
538
|
-
|
|
539
|
-
it('returns null when the child does not exist at all', async () => {
|
|
540
|
-
const { panel } = buildWorld()
|
|
541
|
-
const out = await relationManagerData(panel, {
|
|
542
|
-
kind: 'relation-edit', slug: 'users', recordId: 'u1', relationship: 'posts', childId: 'nonexistent',
|
|
543
|
-
})
|
|
544
|
-
assert.equal(out, null)
|
|
545
|
-
})
|
|
546
|
-
|
|
547
|
-
it('honors prefill on a 422 re-render', async () => {
|
|
548
|
-
const { panel } = buildWorld()
|
|
549
|
-
const out = await relationManagerData(panel, {
|
|
550
|
-
kind: 'relation-edit', slug: 'users', recordId: 'u1', relationship: 'posts', childId: 'p1',
|
|
551
|
-
prefill: { values: { title: 'User-typed value' }, errors: { title: ['Too short'] } },
|
|
552
|
-
})
|
|
553
|
-
const data = out as Record<string, unknown>
|
|
554
|
-
const schema = data['schemaData'] as Array<Record<string, unknown>>
|
|
555
|
-
const formMeta = schema.find(s => s['type'] === 'form') as Record<string, unknown>
|
|
556
|
-
assert.equal((formMeta['values'] as Record<string, unknown>)['title'], 'User-typed value')
|
|
557
|
-
assert.deepEqual((formMeta['errors'] as Record<string, string[]>)['title'], ['Too short'])
|
|
558
|
-
assert.equal(data['hasErrors'], true)
|
|
559
|
-
})
|
|
560
|
-
})
|
|
561
|
-
})
|
|
562
|
-
|
|
563
|
-
// ── Plan #11 — safeManagerPolicy related-resource fall-through (Step 8) ─
|
|
564
|
-
|
|
565
|
-
describe('safeManagerPolicy (Plan #11 step 8)', () => {
|
|
566
|
-
it('runs the manager predicate when overridden', async () => {
|
|
567
|
-
let called = false
|
|
568
|
-
class M extends RelationManager {
|
|
569
|
-
static override relationship = 'posts'
|
|
570
|
-
static override async canCreate(_u: unknown, _p: unknown) { called = true; return true }
|
|
571
|
-
}
|
|
572
|
-
const result = await safeManagerPolicy(M, 'canCreate', undefined, 'user', { id: 1 })
|
|
573
|
-
assert.equal(result, true)
|
|
574
|
-
assert.equal(called, true, 'overridden manager predicate should be invoked')
|
|
575
|
-
})
|
|
576
|
-
|
|
577
|
-
it('falls through to Related.canX when the manager predicate is the default', async () => {
|
|
578
|
-
let managerCalled = false
|
|
579
|
-
let relatedCalled = false
|
|
580
|
-
class M extends RelationManager {
|
|
581
|
-
static override relationship = 'posts'
|
|
582
|
-
// NOT overridden — inherits from RelationManager
|
|
583
|
-
}
|
|
584
|
-
class Related extends Resource {
|
|
585
|
-
static override slug = 'posts'
|
|
586
|
-
static override async canCreate(_u: unknown) { relatedCalled = true; return false }
|
|
587
|
-
}
|
|
588
|
-
// Spy on the inherited default to ensure we DIDN'T call it.
|
|
589
|
-
const origDefault = RelationManager.canCreate
|
|
590
|
-
const spy: typeof RelationManager.canCreate = async () => { managerCalled = true; return true }
|
|
591
|
-
RelationManager.canCreate = spy
|
|
592
|
-
try {
|
|
593
|
-
const result = await safeManagerPolicy(M, 'canCreate', Related, 'user', { id: 1 })
|
|
594
|
-
assert.equal(result, false)
|
|
595
|
-
assert.equal(managerCalled, false, 'default manager predicate should be skipped when Related is configured')
|
|
596
|
-
assert.equal(relatedCalled, true, 'Related predicate should run when manager is default')
|
|
597
|
-
} finally {
|
|
598
|
-
RelationManager.canCreate = origDefault
|
|
599
|
-
}
|
|
600
|
-
})
|
|
601
|
-
|
|
602
|
-
it('strips the parent argument when calling the related Resource predicate', async () => {
|
|
603
|
-
const captured: unknown[][] = []
|
|
604
|
-
class M extends RelationManager {
|
|
605
|
-
static override relationship = 'posts'
|
|
606
|
-
}
|
|
607
|
-
class Related extends Resource {
|
|
608
|
-
static override slug = 'posts'
|
|
609
|
-
static override async canEdit(...args: unknown[]) { captured.push(args); return true }
|
|
610
|
-
}
|
|
611
|
-
await safeManagerPolicy(M, 'canEdit', Related, 'user', { id: 'parent-1' }, { id: 'child-1' })
|
|
612
|
-
// Resource.canEdit signature is (user, record) — the parent arg is dropped.
|
|
613
|
-
assert.deepEqual(captured, [['user', { id: 'child-1' }]])
|
|
614
|
-
})
|
|
615
|
-
|
|
616
|
-
it('allows when both manager and Related are default', async () => {
|
|
617
|
-
class M extends RelationManager { static override relationship = 'posts' }
|
|
618
|
-
const result = await safeManagerPolicy(M, 'canCreate', undefined, 'user', { id: 1 })
|
|
619
|
-
assert.equal(result, true)
|
|
620
|
-
})
|
|
621
|
-
|
|
622
|
-
it('fails closed when an overridden predicate throws', async () => {
|
|
623
|
-
class M extends RelationManager {
|
|
624
|
-
static override relationship = 'posts'
|
|
625
|
-
static override async canCreate(): Promise<boolean> { throw new Error('boom') }
|
|
626
|
-
}
|
|
627
|
-
const result = await safeManagerPolicy(M, 'canCreate', undefined, 'user', { id: 1 })
|
|
628
|
-
assert.equal(result, false)
|
|
629
|
-
})
|
|
630
|
-
|
|
631
|
-
it('integrates: relation-list 403 when Related.canViewAny denies and manager is default', async () => {
|
|
632
|
-
const postRows: QueryRow[] = [{ id: 'p1', parentId: 'u1' }]
|
|
633
|
-
const PostModel = stubModel({ rows: postRows })
|
|
634
|
-
const ParentModel: ModelLike = {
|
|
635
|
-
async find(_id) { return makeParentWithChildren('u1', postRows) },
|
|
636
|
-
async create() { throw new Error('not used') },
|
|
637
|
-
async update() { throw new Error('not used') },
|
|
638
|
-
async delete() { /* ok */ },
|
|
639
|
-
query(): ModelQuery { return findAdapter((this as ModelLike).find as (id: string) => Promise<unknown>) },
|
|
640
|
-
}
|
|
641
|
-
Object.assign(ParentModel as object, { relations: { posts: { model: () => PostModel } } })
|
|
642
|
-
|
|
643
|
-
class PostResource extends Resource {
|
|
644
|
-
static override slug = 'posts'
|
|
645
|
-
static override get model() { return PostModel }
|
|
646
|
-
// Related denies — manager is default → fall-through must propagate.
|
|
647
|
-
static override async canViewAny() { return false }
|
|
648
|
-
}
|
|
649
|
-
class PostsManager extends RelationManager {
|
|
650
|
-
static override relationship = 'posts'
|
|
651
|
-
static override table(t: Table): Table { return t.columns([Column.make('title')]) }
|
|
652
|
-
}
|
|
653
|
-
class UserResource extends Resource {
|
|
654
|
-
static override slug = 'users'
|
|
655
|
-
static override get model() { return ParentModel }
|
|
656
|
-
static override relations() { return [PostsManager] }
|
|
657
|
-
}
|
|
658
|
-
const panel = Pilotiq.make('FT-' + Math.random()).path('/admin').resources([UserResource, PostResource])
|
|
659
|
-
const out = await relationManagerData(panel, {
|
|
660
|
-
kind: 'relation-list', slug: 'users', recordId: 'u1', relationship: 'posts',
|
|
661
|
-
})
|
|
662
|
-
assert.deepEqual(out, { ok: false, status: 403 })
|
|
663
|
-
})
|
|
664
|
-
|
|
665
|
-
it('integrates: manager override beats Related — even when Related allows', async () => {
|
|
666
|
-
const postRows: QueryRow[] = [{ id: 'p1', parentId: 'u1' }]
|
|
667
|
-
const PostModel = stubModel({ rows: postRows })
|
|
668
|
-
const ParentModel: ModelLike = {
|
|
669
|
-
async find(_id) { return makeParentWithChildren('u1', postRows) },
|
|
670
|
-
async create() { throw new Error('not used') },
|
|
671
|
-
async update() { throw new Error('not used') },
|
|
672
|
-
async delete() { /* ok */ },
|
|
673
|
-
query(): ModelQuery { return findAdapter((this as ModelLike).find as (id: string) => Promise<unknown>) },
|
|
674
|
-
}
|
|
675
|
-
Object.assign(ParentModel as object, { relations: { posts: { model: () => PostModel } } })
|
|
676
|
-
|
|
677
|
-
class PostResource extends Resource {
|
|
678
|
-
static override slug = 'posts'
|
|
679
|
-
static override get model() { return PostModel }
|
|
680
|
-
static override async canViewAny() { return true } // Related allows
|
|
681
|
-
}
|
|
682
|
-
class PostsManager extends RelationManager {
|
|
683
|
-
static override relationship = 'posts'
|
|
684
|
-
static override async canViewAny() { return false } // manager denies — wins
|
|
685
|
-
static override table(t: Table): Table { return t.columns([Column.make('title')]) }
|
|
686
|
-
}
|
|
687
|
-
class UserResource extends Resource {
|
|
688
|
-
static override slug = 'users'
|
|
689
|
-
static override get model() { return ParentModel }
|
|
690
|
-
static override relations() { return [PostsManager] }
|
|
691
|
-
}
|
|
692
|
-
const panel = Pilotiq.make('FT2-' + Math.random()).path('/admin').resources([UserResource, PostResource])
|
|
693
|
-
const out = await relationManagerData(panel, {
|
|
694
|
-
kind: 'relation-list', slug: 'users', recordId: 'u1', relationship: 'posts',
|
|
695
|
-
})
|
|
696
|
-
assert.deepEqual(out, { ok: false, status: 403 })
|
|
697
|
-
})
|
|
698
|
-
})
|
|
699
|
-
|
|
700
|
-
// ── Plan #11 — auto-mounted RelationTabs strip (Step 7) ─────────────
|
|
701
|
-
|
|
702
|
-
describe('relation tabs auto-mount (Plan #11)', () => {
|
|
703
|
-
it('relation-list page prepends RelationTabs with the manager tab active', async () => {
|
|
704
|
-
const postRows: QueryRow[] = [{ id: 'p1', parentId: 'u1', title: 'Post One' }]
|
|
705
|
-
const PostModel = stubModel({ rows: postRows })
|
|
706
|
-
const ParentModel: ModelLike = {
|
|
707
|
-
async find(_id) { return makeParentWithChildren('u1', postRows) },
|
|
708
|
-
async create() { throw new Error('not used') },
|
|
709
|
-
async update() { throw new Error('not used') },
|
|
710
|
-
async delete() { /* ok */ },
|
|
711
|
-
query(): ModelQuery { return findAdapter((this as ModelLike).find as (id: string) => Promise<unknown>) },
|
|
712
|
-
}
|
|
713
|
-
Object.assign(ParentModel as object, { relations: { posts: { model: () => PostModel } } })
|
|
714
|
-
|
|
715
|
-
class PostResource extends Resource {
|
|
716
|
-
static override slug = 'posts'
|
|
717
|
-
static override get model() { return PostModel }
|
|
718
|
-
}
|
|
719
|
-
class PostsManager extends RelationManager {
|
|
720
|
-
static override relationship = 'posts'
|
|
721
|
-
static override label = 'Posts'
|
|
722
|
-
static override table(t: Table): Table { return t.columns([Column.make('title')]) }
|
|
723
|
-
}
|
|
724
|
-
class CommentsManager extends RelationManager {
|
|
725
|
-
static override relationship = 'comments'
|
|
726
|
-
static override label = 'Comments'
|
|
727
|
-
static override table(t: Table): Table { return t.columns([Column.make('body')]) }
|
|
728
|
-
}
|
|
729
|
-
class UserResource extends Resource {
|
|
730
|
-
static override slug = 'users'
|
|
731
|
-
static override get model() { return ParentModel }
|
|
732
|
-
static override relations() { return [PostsManager, CommentsManager] }
|
|
733
|
-
}
|
|
734
|
-
const panel = Pilotiq.make('TabsT-' + Math.random()).path('/admin').resources([UserResource, PostResource])
|
|
735
|
-
|
|
736
|
-
const out = await relationManagerData(panel, {
|
|
737
|
-
kind: 'relation-list', slug: 'users', recordId: 'u1', relationship: 'posts',
|
|
738
|
-
})
|
|
739
|
-
const data = out as Record<string, unknown>
|
|
740
|
-
const schema = data['schemaData'] as Array<Record<string, unknown>>
|
|
741
|
-
const tabsMeta = schema.find(s => s['type'] === 'relation-tabs') as Record<string, unknown>
|
|
742
|
-
assert.ok(tabsMeta, 'expected relation-tabs strip prepended')
|
|
743
|
-
|
|
744
|
-
const tabs = tabsMeta['tabs'] as Array<{ key: string; label: string; url: string; active: boolean }>
|
|
745
|
-
// Sub-nav follow-up: View + Edit are now sibling tabs, so the
|
|
746
|
-
// strip is `[View, Edit, Posts, Comments]` rather than the prior
|
|
747
|
-
// `[Edit, Posts, Comments]`.
|
|
748
|
-
assert.equal(tabs.length, 4)
|
|
749
|
-
assert.equal(tabs[0]?.key, '__view')
|
|
750
|
-
assert.equal(tabs[0]?.label, 'View')
|
|
751
|
-
assert.equal(tabs[0]?.url, '/admin/users/u1')
|
|
752
|
-
assert.equal(tabs[0]?.active, false)
|
|
753
|
-
assert.equal(tabs[1]?.key, '__edit')
|
|
754
|
-
assert.equal(tabs[1]?.label, 'Edit')
|
|
755
|
-
assert.equal(tabs[1]?.url, '/admin/users/u1/edit')
|
|
756
|
-
assert.equal(tabs[1]?.active, false)
|
|
757
|
-
assert.equal(tabs[2]?.key, 'posts')
|
|
758
|
-
assert.equal(tabs[2]?.url, '/admin/users/u1/posts')
|
|
759
|
-
assert.equal(tabs[2]?.active, true) // posts is the active tab
|
|
760
|
-
assert.equal(tabs[3]?.key, 'comments')
|
|
761
|
-
assert.equal(tabs[3]?.active, false)
|
|
762
|
-
})
|
|
763
|
-
|
|
764
|
-
it('skips the strip entirely when the resource has no relation managers', async () => {
|
|
765
|
-
class OnlyR extends Resource {
|
|
766
|
-
static override slug = 'only'
|
|
767
|
-
}
|
|
768
|
-
const panel = Pilotiq.make('NoRel-' + Math.random()).path('/admin').resources([OnlyR])
|
|
769
|
-
// Touch resourceIndex/resourceCreate; we only care about Edit which depends
|
|
770
|
-
// on R.model and pages. Easier: assert directly that buildRelationTabs would
|
|
771
|
-
// not run by checking a manager-less relation-list call returns null (no
|
|
772
|
-
// manager named 'whatever' exists), which is the expected guard.
|
|
773
|
-
const out = await relationManagerData(panel, {
|
|
774
|
-
kind: 'relation-list', slug: 'only', recordId: '1', relationship: 'whatever',
|
|
775
|
-
})
|
|
776
|
-
assert.equal(out, null)
|
|
777
|
-
})
|
|
778
|
-
|
|
779
|
-
it('resource-edit page prepends RelationTabs with the Edit tab active', async () => {
|
|
780
|
-
const postRows: QueryRow[] = [{ id: 'p1', parentId: 'u1', title: 'Post One' }]
|
|
781
|
-
const PostModel = stubModel({ rows: postRows })
|
|
782
|
-
const ParentModel: ModelLike = stubModel({
|
|
783
|
-
rows: [{ id: 'u1', name: 'Alice' }, { id: 'u2', name: 'Bob' }],
|
|
784
|
-
})
|
|
785
|
-
Object.assign(ParentModel as object, { relations: { posts: { model: () => PostModel } } })
|
|
786
|
-
|
|
787
|
-
class PostResource extends Resource {
|
|
788
|
-
static override slug = 'posts'
|
|
789
|
-
static override get model() { return PostModel }
|
|
790
|
-
}
|
|
791
|
-
class PostsManager extends RelationManager {
|
|
792
|
-
static override relationship = 'posts'
|
|
793
|
-
static override label = 'Posts'
|
|
794
|
-
static override table(t: Table): Table { return t.columns([Column.make('title')]) }
|
|
795
|
-
}
|
|
796
|
-
class UserResource extends Resource {
|
|
797
|
-
static override slug = 'users'
|
|
798
|
-
static override recordTitleAttribute = 'name'
|
|
799
|
-
static override get model() { return ParentModel }
|
|
800
|
-
static override form(form: Form): Form { return form.schema([TextField.make('name')]) }
|
|
801
|
-
static override relations() { return [PostsManager] }
|
|
802
|
-
}
|
|
803
|
-
const panel = Pilotiq.make('EditTab-' + Math.random()).path('/admin').resources([UserResource, PostResource])
|
|
804
|
-
const out = await resourceEditData(panel, 'users', 'u1')
|
|
805
|
-
const schema = (out as Record<string, unknown>)['schemaData'] as Array<Record<string, unknown>>
|
|
806
|
-
const tabsMeta = schema.find(s => s['type'] === 'relation-tabs') as Record<string, unknown>
|
|
807
|
-
assert.ok(tabsMeta, 'resource-edit should auto-mount RelationTabs')
|
|
808
|
-
const tabs = tabsMeta['tabs'] as Array<{ key: string; active: boolean; url: string }>
|
|
809
|
-
// Sub-nav: View tab now sits ahead of Edit. Edit stays the active
|
|
810
|
-
// tab on the resource-edit page.
|
|
811
|
-
assert.equal(tabs[0]?.key, '__view')
|
|
812
|
-
assert.equal(tabs[0]?.active, false)
|
|
813
|
-
assert.equal(tabs[1]?.key, '__edit')
|
|
814
|
-
assert.equal(tabs[1]?.active, true)
|
|
815
|
-
assert.equal(tabs[2]?.key, 'posts')
|
|
816
|
-
assert.equal(tabs[2]?.active, false)
|
|
817
|
-
})
|
|
818
|
-
|
|
819
|
-
it('resource-view page prepends RelationTabs with the View tab active', async () => {
|
|
820
|
-
const postRows: QueryRow[] = []
|
|
821
|
-
const PostModel = stubModel({ rows: postRows })
|
|
822
|
-
const ParentModel: ModelLike = stubModel({ rows: [{ id: 'u1', name: 'Alice' }] })
|
|
823
|
-
Object.assign(ParentModel as object, { relations: { posts: { model: () => PostModel } } })
|
|
824
|
-
|
|
825
|
-
class PostResource extends Resource {
|
|
826
|
-
static override slug = 'posts'
|
|
827
|
-
static override get model() { return PostModel }
|
|
828
|
-
}
|
|
829
|
-
class PostsManager extends RelationManager {
|
|
830
|
-
static override relationship = 'posts'
|
|
831
|
-
}
|
|
832
|
-
class UserResource extends Resource {
|
|
833
|
-
static override slug = 'users'
|
|
834
|
-
static override get model() { return ParentModel }
|
|
835
|
-
static override detail() { return [] }
|
|
836
|
-
static override relations() { return [PostsManager] }
|
|
837
|
-
}
|
|
838
|
-
const panel = Pilotiq.make('ViewTab-' + Math.random()).path('/admin').resources([UserResource, PostResource])
|
|
839
|
-
const out = await resourceViewData(panel, 'users', 'u1')
|
|
840
|
-
const schema = (out as Record<string, unknown>)['schemaData'] as Array<Record<string, unknown>>
|
|
841
|
-
const tabsMeta = schema.find(s => s['type'] === 'relation-tabs') as Record<string, unknown> | undefined
|
|
842
|
-
assert.ok(tabsMeta, 'resource-view should auto-mount RelationTabs')
|
|
843
|
-
const tabs = tabsMeta['tabs'] as Array<{ key: string; label: string; url: string; active: boolean }>
|
|
844
|
-
assert.equal(tabs[0]?.key, '__view')
|
|
845
|
-
assert.equal(tabs[0]?.label, 'View')
|
|
846
|
-
assert.equal(tabs[0]?.url, '/admin/users/u1')
|
|
847
|
-
assert.equal(tabs[0]?.active, true)
|
|
848
|
-
// Edit tab is now a sibling on the View page too.
|
|
849
|
-
assert.equal(tabs[1]?.key, '__edit')
|
|
850
|
-
assert.equal(tabs[1]?.label, 'Edit')
|
|
851
|
-
assert.equal(tabs[1]?.url, '/admin/users/u1/edit')
|
|
852
|
-
assert.equal(tabs[1]?.active, false)
|
|
853
|
-
})
|
|
854
|
-
|
|
855
|
-
it('drops the View tab when ViewPage is pruned via static pages()', async () => {
|
|
856
|
-
const postRows: QueryRow[] = [{ id: 'p1', parentId: 'u1', title: 'Post One' }]
|
|
857
|
-
const PostModel = stubModel({ rows: postRows })
|
|
858
|
-
const ParentModel: ModelLike = {
|
|
859
|
-
async find(_id) { return makeParentWithChildren('u1', postRows) },
|
|
860
|
-
async create() { throw new Error('not used') },
|
|
861
|
-
async update() { throw new Error('not used') },
|
|
862
|
-
async delete() { /* ok */ },
|
|
863
|
-
query(): ModelQuery { return findAdapter((this as ModelLike).find as (id: string) => Promise<unknown>) },
|
|
864
|
-
}
|
|
865
|
-
Object.assign(ParentModel as object, { relations: { posts: { model: () => PostModel } } })
|
|
866
|
-
|
|
867
|
-
class PostResource extends Resource {
|
|
868
|
-
static override slug = 'posts'
|
|
869
|
-
static override get model() { return PostModel }
|
|
870
|
-
}
|
|
871
|
-
class PostsManager extends RelationManager {
|
|
872
|
-
static override relationship = 'posts'
|
|
873
|
-
static override table(t: Table): Table { return t.columns([Column.make('title')]) }
|
|
874
|
-
}
|
|
875
|
-
class UserResource extends Resource {
|
|
876
|
-
static override slug = 'users'
|
|
877
|
-
static override get model() { return ParentModel }
|
|
878
|
-
static override relations() { return [PostsManager] }
|
|
879
|
-
// Prune ViewPage — defaults shipped one but the user opted out.
|
|
880
|
-
static override pages() { return { view: undefined as never } }
|
|
881
|
-
}
|
|
882
|
-
const panel = Pilotiq.make('NoView-' + Math.random()).path('/admin').resources([UserResource, PostResource])
|
|
883
|
-
|
|
884
|
-
const out = await relationManagerData(panel, {
|
|
885
|
-
kind: 'relation-list', slug: 'users', recordId: 'u1', relationship: 'posts',
|
|
886
|
-
})
|
|
887
|
-
const schema = (out as Record<string, unknown>)['schemaData'] as Array<Record<string, unknown>>
|
|
888
|
-
const tabsMeta = schema.find(s => s['type'] === 'relation-tabs') as Record<string, unknown>
|
|
889
|
-
const tabs = tabsMeta['tabs'] as Array<{ key: string }>
|
|
890
|
-
// No __view, just __edit + the manager.
|
|
891
|
-
assert.deepEqual(tabs.map(t => t.key), ['__edit', 'posts'])
|
|
892
|
-
})
|
|
893
|
-
|
|
894
|
-
it('drops the Edit tab when EditPage is pruned via static pages()', async () => {
|
|
895
|
-
const postRows: QueryRow[] = [{ id: 'p1', parentId: 'u1', title: 'Post One' }]
|
|
896
|
-
const PostModel = stubModel({ rows: postRows })
|
|
897
|
-
const ParentModel: ModelLike = {
|
|
898
|
-
async find(_id) { return makeParentWithChildren('u1', postRows) },
|
|
899
|
-
async create() { throw new Error('not used') },
|
|
900
|
-
async update() { throw new Error('not used') },
|
|
901
|
-
async delete() { /* ok */ },
|
|
902
|
-
query(): ModelQuery { return findAdapter((this as ModelLike).find as (id: string) => Promise<unknown>) },
|
|
903
|
-
}
|
|
904
|
-
Object.assign(ParentModel as object, { relations: { posts: { model: () => PostModel } } })
|
|
905
|
-
|
|
906
|
-
class PostResource extends Resource {
|
|
907
|
-
static override slug = 'posts'
|
|
908
|
-
static override get model() { return PostModel }
|
|
909
|
-
}
|
|
910
|
-
class PostsManager extends RelationManager {
|
|
911
|
-
static override relationship = 'posts'
|
|
912
|
-
static override table(t: Table): Table { return t.columns([Column.make('title')]) }
|
|
913
|
-
}
|
|
914
|
-
class UserResource extends Resource {
|
|
915
|
-
static override slug = 'users'
|
|
916
|
-
static override get model() { return ParentModel }
|
|
917
|
-
static override relations() { return [PostsManager] }
|
|
918
|
-
static override pages() { return { edit: undefined as never } }
|
|
919
|
-
}
|
|
920
|
-
const panel = Pilotiq.make('NoEdit-' + Math.random()).path('/admin').resources([UserResource, PostResource])
|
|
921
|
-
|
|
922
|
-
const out = await relationManagerData(panel, {
|
|
923
|
-
kind: 'relation-list', slug: 'users', recordId: 'u1', relationship: 'posts',
|
|
924
|
-
})
|
|
925
|
-
const schema = (out as Record<string, unknown>)['schemaData'] as Array<Record<string, unknown>>
|
|
926
|
-
const tabsMeta = schema.find(s => s['type'] === 'relation-tabs') as Record<string, unknown>
|
|
927
|
-
const tabs = tabsMeta['tabs'] as Array<{ key: string }>
|
|
928
|
-
assert.deepEqual(tabs.map(t => t.key), ['__view', 'posts'])
|
|
929
|
-
})
|
|
930
|
-
|
|
931
|
-
// ── Per-tab canX gating ──────────────────────────────────
|
|
932
|
-
|
|
933
|
-
it('hides the View tab when R.canView returns false for this record', async () => {
|
|
934
|
-
const postRows: QueryRow[] = [{ id: 'p1', parentId: 'u1', title: 'Post One' }]
|
|
935
|
-
const PostModel = stubModel({ rows: postRows })
|
|
936
|
-
const ParentModel: ModelLike = stubModel({ rows: [{ id: 'u1', name: 'Alice' }] })
|
|
937
|
-
Object.assign(ParentModel as object, { relations: { posts: { model: () => PostModel } } })
|
|
938
|
-
|
|
939
|
-
class PostResource extends Resource {
|
|
940
|
-
static override slug = 'posts'
|
|
941
|
-
static override get model() { return PostModel }
|
|
942
|
-
}
|
|
943
|
-
class PostsManager extends RelationManager {
|
|
944
|
-
static override relationship = 'posts'
|
|
945
|
-
}
|
|
946
|
-
class UserResource extends Resource {
|
|
947
|
-
static override slug = 'users'
|
|
948
|
-
static override get model() { return ParentModel }
|
|
949
|
-
static override detail() { return [] }
|
|
950
|
-
static override relations() { return [PostsManager] }
|
|
951
|
-
static override async canView(): Promise<boolean> { return false }
|
|
952
|
-
}
|
|
953
|
-
const panel = Pilotiq.make('NoCanView-' + Math.random()).path('/admin').resources([UserResource, PostResource])
|
|
954
|
-
|
|
955
|
-
// Use resource-edit so the route doesn't 403 before we render — we
|
|
956
|
-
// want to see the strip itself drop the View tab.
|
|
957
|
-
const out = await resourceEditData(panel, 'users', 'u1')
|
|
958
|
-
const schema = (out as Record<string, unknown>)['schemaData'] as Array<Record<string, unknown>>
|
|
959
|
-
const tabsMeta = schema.find(s => s['type'] === 'relation-tabs') as Record<string, unknown>
|
|
960
|
-
const tabs = tabsMeta['tabs'] as Array<{ key: string }>
|
|
961
|
-
assert.deepEqual(tabs.map(t => t.key), ['__edit', 'posts'])
|
|
962
|
-
})
|
|
963
|
-
|
|
964
|
-
it('hides the Edit tab when R.canEdit returns false for this record', async () => {
|
|
965
|
-
const postRows: QueryRow[] = []
|
|
966
|
-
const PostModel = stubModel({ rows: postRows })
|
|
967
|
-
const ParentModel: ModelLike = stubModel({ rows: [{ id: 'u1', name: 'Alice' }] })
|
|
968
|
-
Object.assign(ParentModel as object, { relations: { posts: { model: () => PostModel } } })
|
|
969
|
-
|
|
970
|
-
class PostResource extends Resource {
|
|
971
|
-
static override slug = 'posts'
|
|
972
|
-
static override get model() { return PostModel }
|
|
973
|
-
}
|
|
974
|
-
class PostsManager extends RelationManager {
|
|
975
|
-
static override relationship = 'posts'
|
|
976
|
-
}
|
|
977
|
-
class UserResource extends Resource {
|
|
978
|
-
static override slug = 'users'
|
|
979
|
-
static override get model() { return ParentModel }
|
|
980
|
-
static override detail() { return [] }
|
|
981
|
-
static override relations() { return [PostsManager] }
|
|
982
|
-
static override async canEdit(): Promise<boolean> { return false }
|
|
983
|
-
}
|
|
984
|
-
const panel = Pilotiq.make('NoCanEdit-' + Math.random()).path('/admin').resources([UserResource, PostResource])
|
|
985
|
-
|
|
986
|
-
const out = await resourceViewData(panel, 'users', 'u1')
|
|
987
|
-
const schema = (out as Record<string, unknown>)['schemaData'] as Array<Record<string, unknown>>
|
|
988
|
-
const tabsMeta = schema.find(s => s['type'] === 'relation-tabs') as Record<string, unknown>
|
|
989
|
-
const tabs = tabsMeta['tabs'] as Array<{ key: string }>
|
|
990
|
-
assert.deepEqual(tabs.map(t => t.key), ['__view', 'posts'])
|
|
991
|
-
})
|
|
992
|
-
|
|
993
|
-
it('hides a manager tab when M.canViewAny returns false', async () => {
|
|
994
|
-
const postRows: QueryRow[] = []
|
|
995
|
-
const commentRows: QueryRow[] = []
|
|
996
|
-
const PostModel = stubModel({ rows: postRows })
|
|
997
|
-
const CommentModel = stubModel({ rows: commentRows })
|
|
998
|
-
const ParentModel: ModelLike = stubModel({ rows: [{ id: 'u1', name: 'Alice' }] })
|
|
999
|
-
Object.assign(ParentModel as object, { relations: {
|
|
1000
|
-
posts: { model: () => PostModel },
|
|
1001
|
-
comments: { model: () => CommentModel },
|
|
1002
|
-
} })
|
|
1003
|
-
|
|
1004
|
-
class PostResource extends Resource {
|
|
1005
|
-
static override slug = 'posts'
|
|
1006
|
-
static override get model() { return PostModel }
|
|
1007
|
-
}
|
|
1008
|
-
class CommentResource extends Resource {
|
|
1009
|
-
static override slug = 'comments'
|
|
1010
|
-
static override get model() { return CommentModel }
|
|
1011
|
-
}
|
|
1012
|
-
class PostsManager extends RelationManager {
|
|
1013
|
-
static override relationship = 'posts'
|
|
1014
|
-
}
|
|
1015
|
-
class CommentsManager extends RelationManager {
|
|
1016
|
-
static override relationship = 'comments'
|
|
1017
|
-
static override async canViewAny(): Promise<boolean> { return false }
|
|
1018
|
-
}
|
|
1019
|
-
class UserResource extends Resource {
|
|
1020
|
-
static override slug = 'users'
|
|
1021
|
-
static override get model() { return ParentModel }
|
|
1022
|
-
static override detail() { return [] }
|
|
1023
|
-
static override relations() { return [PostsManager, CommentsManager] }
|
|
1024
|
-
}
|
|
1025
|
-
const panel = Pilotiq.make('GatedMgr-' + Math.random()).path('/admin').resources([UserResource, PostResource, CommentResource])
|
|
1026
|
-
|
|
1027
|
-
const out = await resourceViewData(panel, 'users', 'u1')
|
|
1028
|
-
const schema = (out as Record<string, unknown>)['schemaData'] as Array<Record<string, unknown>>
|
|
1029
|
-
const tabsMeta = schema.find(s => s['type'] === 'relation-tabs') as Record<string, unknown>
|
|
1030
|
-
const tabs = tabsMeta['tabs'] as Array<{ key: string }>
|
|
1031
|
-
// CommentsManager is gone — Posts survives because it inherits the
|
|
1032
|
-
// default `canViewAny → true`.
|
|
1033
|
-
assert.deepEqual(tabs.map(t => t.key), ['__view', '__edit', 'posts'])
|
|
1034
|
-
})
|
|
1035
|
-
|
|
1036
|
-
it('falls through to Related.canViewAny when manager has not overridden', async () => {
|
|
1037
|
-
const postRows: QueryRow[] = []
|
|
1038
|
-
const PostModel = stubModel({ rows: postRows })
|
|
1039
|
-
const ParentModel: ModelLike = stubModel({ rows: [{ id: 'u1', name: 'Alice' }] })
|
|
1040
|
-
Object.assign(ParentModel as object, { relations: { posts: { model: () => PostModel } } })
|
|
1041
|
-
|
|
1042
|
-
class PostResource extends Resource {
|
|
1043
|
-
static override slug = 'posts'
|
|
1044
|
-
static override get model() { return PostModel }
|
|
1045
|
-
// Related-side gate fires through safeManagerPolicy fall-through
|
|
1046
|
-
// since PostsManager doesn't override canViewAny.
|
|
1047
|
-
static override async canViewAny(): Promise<boolean> { return false }
|
|
1048
|
-
}
|
|
1049
|
-
class PostsManager extends RelationManager {
|
|
1050
|
-
static override relationship = 'posts'
|
|
1051
|
-
static override relatedResource = PostResource
|
|
1052
|
-
}
|
|
1053
|
-
class UserResource extends Resource {
|
|
1054
|
-
static override slug = 'users'
|
|
1055
|
-
static override get model() { return ParentModel }
|
|
1056
|
-
static override detail() { return [] }
|
|
1057
|
-
static override relations() { return [PostsManager] }
|
|
1058
|
-
}
|
|
1059
|
-
const panel = Pilotiq.make('RelatedGate-' + Math.random()).path('/admin').resources([UserResource, PostResource])
|
|
1060
|
-
|
|
1061
|
-
const out = await resourceViewData(panel, 'users', 'u1')
|
|
1062
|
-
const schema = (out as Record<string, unknown>)['schemaData'] as Array<Record<string, unknown>>
|
|
1063
|
-
const tabsMeta = schema.find(s => s['type'] === 'relation-tabs') as Record<string, unknown> | undefined
|
|
1064
|
-
// With Posts gone, only the parent View+Edit tabs survive. The
|
|
1065
|
-
// strip drops to under 2 manager-able entries so it stays mounted
|
|
1066
|
-
// (View+Edit isn't worth-it; the depth-1 code path keeps the strip
|
|
1067
|
-
// because the dropped tab was a manager, not a parent tab).
|
|
1068
|
-
const tabs = (tabsMeta?.['tabs'] as Array<{ key: string }>) ?? []
|
|
1069
|
-
assert.equal(tabs.find(t => t.key === 'posts'), undefined)
|
|
1070
|
-
})
|
|
1071
|
-
|
|
1072
|
-
it('throwing canX predicate fails closed (tab hidden)', async () => {
|
|
1073
|
-
const postRows: QueryRow[] = []
|
|
1074
|
-
const PostModel = stubModel({ rows: postRows })
|
|
1075
|
-
const ParentModel: ModelLike = stubModel({ rows: [{ id: 'u1', name: 'Alice' }] })
|
|
1076
|
-
Object.assign(ParentModel as object, { relations: { posts: { model: () => PostModel } } })
|
|
1077
|
-
|
|
1078
|
-
class PostResource extends Resource {
|
|
1079
|
-
static override slug = 'posts'
|
|
1080
|
-
static override get model() { return PostModel }
|
|
1081
|
-
}
|
|
1082
|
-
class PostsManager extends RelationManager {
|
|
1083
|
-
static override relationship = 'posts'
|
|
1084
|
-
}
|
|
1085
|
-
class UserResource extends Resource {
|
|
1086
|
-
static override slug = 'users'
|
|
1087
|
-
static override get model() { return ParentModel }
|
|
1088
|
-
static override detail() { return [] }
|
|
1089
|
-
static override relations() { return [PostsManager] }
|
|
1090
|
-
static override async canView(): Promise<boolean> { throw new Error('boom') }
|
|
1091
|
-
}
|
|
1092
|
-
const panel = Pilotiq.make('ThrowCanView-' + Math.random()).path('/admin').resources([UserResource, PostResource])
|
|
1093
|
-
|
|
1094
|
-
const out = await resourceEditData(panel, 'users', 'u1')
|
|
1095
|
-
const schema = (out as Record<string, unknown>)['schemaData'] as Array<Record<string, unknown>>
|
|
1096
|
-
const tabsMeta = schema.find(s => s['type'] === 'relation-tabs') as Record<string, unknown>
|
|
1097
|
-
const tabs = tabsMeta['tabs'] as Array<{ key: string }>
|
|
1098
|
-
// canView threw → fail closed (hidden). canEdit + Posts survive.
|
|
1099
|
-
assert.deepEqual(tabs.map(t => t.key), ['__edit', 'posts'])
|
|
1100
|
-
})
|
|
1101
|
-
|
|
1102
|
-
it('drops the strip entirely when every manager tab is gated away on the View page', async () => {
|
|
1103
|
-
const postRows: QueryRow[] = []
|
|
1104
|
-
const PostModel = stubModel({ rows: postRows })
|
|
1105
|
-
const ParentModel: ModelLike = stubModel({ rows: [{ id: 'u1', name: 'Alice' }] })
|
|
1106
|
-
Object.assign(ParentModel as object, { relations: { posts: { model: () => PostModel } } })
|
|
1107
|
-
|
|
1108
|
-
class PostResource extends Resource {
|
|
1109
|
-
static override slug = 'posts'
|
|
1110
|
-
static override get model() { return PostModel }
|
|
1111
|
-
}
|
|
1112
|
-
class PostsManager extends RelationManager {
|
|
1113
|
-
static override relationship = 'posts'
|
|
1114
|
-
static override async canViewAny(): Promise<boolean> { return false }
|
|
1115
|
-
}
|
|
1116
|
-
class UserResource extends Resource {
|
|
1117
|
-
static override slug = 'users'
|
|
1118
|
-
static override get model() { return ParentModel }
|
|
1119
|
-
static override detail() { return [] }
|
|
1120
|
-
static override relations() { return [PostsManager] }
|
|
1121
|
-
static override async canView(): Promise<boolean> { return false }
|
|
1122
|
-
static override async canEdit(): Promise<boolean> { return false }
|
|
1123
|
-
}
|
|
1124
|
-
const panel = Pilotiq.make('AllGated-' + Math.random()).path('/admin').resources([UserResource, PostResource])
|
|
1125
|
-
|
|
1126
|
-
const out = await resourceEditData(panel, 'users', 'u1')
|
|
1127
|
-
const schema = (out as Record<string, unknown>)['schemaData'] as Array<Record<string, unknown>>
|
|
1128
|
-
const tabsMeta = schema.find(s => s['type'] === 'relation-tabs')
|
|
1129
|
-
// No tabs survive — strip omitted entirely.
|
|
1130
|
-
assert.equal(tabsMeta, undefined)
|
|
1131
|
-
})
|
|
1132
|
-
})
|
|
1133
|
-
|
|
1134
|
-
// ── Plan #11 — dispatchPageData wiring (Vike +data SPA path) ────────
|
|
1135
|
-
|
|
1136
|
-
describe('dispatchPageData → relation pages (Plan #11)', () => {
|
|
1137
|
-
function buildPanel() {
|
|
1138
|
-
const postRows: QueryRow[] = [{ id: 'p1', parentId: 'u1', title: 'Post One' }]
|
|
1139
|
-
const PostModel = stubModel({ rows: postRows })
|
|
1140
|
-
const parents = new Map([
|
|
1141
|
-
['u1', makeParentWithChildren('u1', postRows)],
|
|
1142
|
-
])
|
|
1143
|
-
const ParentModel: ModelLike = {
|
|
1144
|
-
async find(id) { return parents.get(String(id)) ?? null },
|
|
1145
|
-
async create() { throw new Error('not used') },
|
|
1146
|
-
async update() { throw new Error('not used') },
|
|
1147
|
-
async delete() { /* no-op */ },
|
|
1148
|
-
query(): ModelQuery { return findAdapter((this as ModelLike).find as (id: string) => Promise<unknown>) },
|
|
1149
|
-
}
|
|
1150
|
-
Object.assign(ParentModel as object, { relations: { posts: { model: () => PostModel } } })
|
|
1151
|
-
|
|
1152
|
-
class PostResource extends Resource {
|
|
1153
|
-
static override slug = 'posts'
|
|
1154
|
-
static override get model() { return PostModel }
|
|
1155
|
-
static override form(form: Form): Form { return form.schema([TextField.make('title').required()]) }
|
|
1156
|
-
}
|
|
1157
|
-
class PostsManager extends RelationManager {
|
|
1158
|
-
static override relationship = 'posts'
|
|
1159
|
-
static override table(t: Table): Table { return t.columns([Column.make('title')]) }
|
|
1160
|
-
static override form(f: Form): Form { return f.schema([TextField.make('title').required()]) }
|
|
1161
|
-
}
|
|
1162
|
-
class UserResource extends Resource {
|
|
1163
|
-
static override slug = 'users'
|
|
1164
|
-
static override get model() { return ParentModel }
|
|
1165
|
-
static override relations() { return [PostsManager] }
|
|
1166
|
-
}
|
|
1167
|
-
|
|
1168
|
-
PilotiqRegistry.reset()
|
|
1169
|
-
const panel = Pilotiq.make('TestPanel-' + Math.random()).path('/admin').resources([UserResource, PostResource])
|
|
1170
|
-
PilotiqRegistry.register(panel)
|
|
1171
|
-
return panel
|
|
1172
|
-
}
|
|
1173
|
-
|
|
1174
|
-
it('routes relation-list page id through to relationManagerData', async () => {
|
|
1175
|
-
buildPanel()
|
|
1176
|
-
const out = await dispatchPageData({
|
|
1177
|
-
pageId: '/pages/(pilotiq)/relation-list',
|
|
1178
|
-
routeParams: { basePath: 'admin', slug: 'users', id: 'u1', relationship: 'posts' },
|
|
1179
|
-
urlParsed: { search: {} },
|
|
1180
|
-
})
|
|
1181
|
-
assert.notEqual(out, null)
|
|
1182
|
-
assert.equal((out as Record<string, unknown>)['pageType'], 'relation-list')
|
|
1183
|
-
})
|
|
1184
|
-
|
|
1185
|
-
it('routes relation-create page id through', async () => {
|
|
1186
|
-
buildPanel()
|
|
1187
|
-
const out = await dispatchPageData({
|
|
1188
|
-
pageId: '/pages/(pilotiq)/relation-create',
|
|
1189
|
-
routeParams: { basePath: 'admin', slug: 'users', id: 'u1', relationship: 'posts' },
|
|
1190
|
-
urlParsed: { search: {} },
|
|
1191
|
-
})
|
|
1192
|
-
assert.equal((out as Record<string, unknown>)['pageType'], 'relation-create')
|
|
1193
|
-
})
|
|
1194
|
-
|
|
1195
|
-
it('routes relation-edit page id through', async () => {
|
|
1196
|
-
buildPanel()
|
|
1197
|
-
const out = await dispatchPageData({
|
|
1198
|
-
pageId: '/pages/(pilotiq)/relation-edit',
|
|
1199
|
-
routeParams: { basePath: 'admin', slug: 'users', id: 'u1', relationship: 'posts', childId: 'p1' },
|
|
1200
|
-
urlParsed: { search: {} },
|
|
1201
|
-
})
|
|
1202
|
-
assert.equal((out as Record<string, unknown>)['pageType'], 'relation-edit')
|
|
1203
|
-
})
|
|
1204
|
-
|
|
1205
|
-
it('returns null when the panel base path is unknown', async () => {
|
|
1206
|
-
PilotiqRegistry.reset()
|
|
1207
|
-
const out = await dispatchPageData({
|
|
1208
|
-
pageId: '/pages/(pilotiq)/relation-list',
|
|
1209
|
-
routeParams: { basePath: 'nonexistent', slug: 'users', id: 'u1', relationship: 'posts' },
|
|
1210
|
-
urlParsed: { search: {} },
|
|
1211
|
-
})
|
|
1212
|
-
assert.equal(out, null)
|
|
1213
|
-
})
|
|
1214
|
-
|
|
1215
|
-
it('returns null when route params are incomplete', async () => {
|
|
1216
|
-
buildPanel()
|
|
1217
|
-
const out = await dispatchPageData({
|
|
1218
|
-
pageId: '/pages/(pilotiq)/relation-edit',
|
|
1219
|
-
routeParams: { basePath: 'admin', slug: 'users', id: 'u1' }, // missing relationship + childId
|
|
1220
|
-
urlParsed: { search: {} },
|
|
1221
|
-
})
|
|
1222
|
-
assert.equal(out, null)
|
|
1223
|
-
})
|
|
1224
|
-
})
|
|
1225
|
-
|
|
1226
|
-
// ── Plan #13 polish — manager TrashedFilter auto-injection ──────────
|
|
1227
|
-
|
|
1228
|
-
describe('relation-list TrashedFilter auto-inject (Plan #13 polish)', () => {
|
|
1229
|
-
/** Build a User → Posts world where the related Resource opts into
|
|
1230
|
-
* soft deletes. */
|
|
1231
|
-
function buildSoftDeleteWorld(opts: {
|
|
1232
|
-
relatedSoftDeletes?: boolean
|
|
1233
|
-
} = {}) {
|
|
1234
|
-
const postRows: QueryRow[] = [{ id: 'p1', parentId: 'u1', title: 'Live' }]
|
|
1235
|
-
const PostModel = stubModel({ rows: postRows })
|
|
1236
|
-
const ParentModel: ModelLike = {
|
|
1237
|
-
async find(_id) { return makeParentWithChildren('u1', postRows) },
|
|
1238
|
-
async create() { throw new Error('not used') },
|
|
1239
|
-
async update() { throw new Error('not used') },
|
|
1240
|
-
async delete() { /* ok */ },
|
|
1241
|
-
query(): ModelQuery { return findAdapter((this as ModelLike).find as (id: string) => Promise<unknown>) },
|
|
1242
|
-
}
|
|
1243
|
-
Object.assign(ParentModel as object, { relations: { posts: { model: () => PostModel } } })
|
|
1244
|
-
|
|
1245
|
-
class PostResource extends Resource {
|
|
1246
|
-
static override slug = 'posts'
|
|
1247
|
-
static override softDeletes = opts.relatedSoftDeletes ?? false
|
|
1248
|
-
static override get model() { return PostModel }
|
|
1249
|
-
}
|
|
1250
|
-
|
|
1251
|
-
class PostsManager extends RelationManager {
|
|
1252
|
-
static override relationship = 'posts'
|
|
1253
|
-
static override table(t: Table): Table {
|
|
1254
|
-
return t.columns([Column.make('title')])
|
|
1255
|
-
}
|
|
1256
|
-
}
|
|
1257
|
-
class UserResource extends Resource {
|
|
1258
|
-
static override slug = 'users'
|
|
1259
|
-
static override get model() { return ParentModel }
|
|
1260
|
-
static override relations() { return [PostsManager] }
|
|
1261
|
-
}
|
|
1262
|
-
|
|
1263
|
-
const panel = Pilotiq.make('TF-' + Math.random()).path('/admin').resources([UserResource, PostResource])
|
|
1264
|
-
return { panel, PostsManager, PostResource }
|
|
1265
|
-
}
|
|
1266
|
-
|
|
1267
|
-
/** Helper — pull filter children from a resolved Table meta. Filters
|
|
1268
|
-
* serialize as children with a `kind` field (Filter.toMeta) so we
|
|
1269
|
-
* filter on `kind in c` to distinguish them from columns. */
|
|
1270
|
-
function tableFilterChildren(tableMeta: Record<string, unknown>): Array<Record<string, unknown>> {
|
|
1271
|
-
const children = (tableMeta['children'] as Array<Record<string, unknown>>) ?? []
|
|
1272
|
-
return children.filter(c => c['type'] === 'filter')
|
|
1273
|
-
}
|
|
1274
|
-
|
|
1275
|
-
it('auto-injects TrashedFilter when the related Resource has softDeletes=true', async () => {
|
|
1276
|
-
const { panel } = buildSoftDeleteWorld({ relatedSoftDeletes: true })
|
|
1277
|
-
const out = await relationManagerData(panel, {
|
|
1278
|
-
kind: 'relation-list', slug: 'users', recordId: 'u1', relationship: 'posts',
|
|
1279
|
-
})
|
|
1280
|
-
const data = out as Record<string, unknown>
|
|
1281
|
-
const schema = data['schemaData'] as Array<Record<string, unknown>>
|
|
1282
|
-
const tableMeta = schema.find(s => s['type'] === 'table') as Record<string, unknown>
|
|
1283
|
-
const filters = tableFilterChildren(tableMeta)
|
|
1284
|
-
const trashed = filters.find(f => f['name'] === 'trashed')
|
|
1285
|
-
assert.ok(trashed, 'expected an auto-injected TrashedFilter on the manager table')
|
|
1286
|
-
assert.equal(trashed!['kind'], 'select')
|
|
1287
|
-
})
|
|
1288
|
-
|
|
1289
|
-
it('does NOT inject TrashedFilter when the related Resource has softDeletes=false (default)', async () => {
|
|
1290
|
-
const { panel } = buildSoftDeleteWorld({ relatedSoftDeletes: false })
|
|
1291
|
-
const out = await relationManagerData(panel, {
|
|
1292
|
-
kind: 'relation-list', slug: 'users', recordId: 'u1', relationship: 'posts',
|
|
1293
|
-
})
|
|
1294
|
-
const data = out as Record<string, unknown>
|
|
1295
|
-
const schema = data['schemaData'] as Array<Record<string, unknown>>
|
|
1296
|
-
const tableMeta = schema.find(s => s['type'] === 'table') as Record<string, unknown>
|
|
1297
|
-
const filters = tableFilterChildren(tableMeta)
|
|
1298
|
-
const trashed = filters.find(f => f['name'] === 'trashed')
|
|
1299
|
-
assert.equal(trashed, undefined)
|
|
1300
|
-
})
|
|
1301
|
-
|
|
1302
|
-
it('does not double-inject when the manager already attached a TrashedFilter', async () => {
|
|
1303
|
-
const { TrashedFilter } = await import('./filters/TrashedFilter.js')
|
|
1304
|
-
|
|
1305
|
-
const postRows: QueryRow[] = [{ id: 'p1', parentId: 'u1' }]
|
|
1306
|
-
const PostModel = stubModel({ rows: postRows })
|
|
1307
|
-
const ParentModel: ModelLike = {
|
|
1308
|
-
async find(_id) { return makeParentWithChildren('u1', postRows) },
|
|
1309
|
-
async create() { throw new Error('not used') },
|
|
1310
|
-
async update() { throw new Error('not used') },
|
|
1311
|
-
async delete() { /* ok */ },
|
|
1312
|
-
query(): ModelQuery { return findAdapter((this as ModelLike).find as (id: string) => Promise<unknown>) },
|
|
1313
|
-
}
|
|
1314
|
-
Object.assign(ParentModel as object, { relations: { posts: { model: () => PostModel } } })
|
|
1315
|
-
|
|
1316
|
-
class PostResource extends Resource {
|
|
1317
|
-
static override slug = 'posts'
|
|
1318
|
-
static override softDeletes = true
|
|
1319
|
-
static override get model() { return PostModel }
|
|
1320
|
-
}
|
|
1321
|
-
|
|
1322
|
-
class PostsManager extends RelationManager {
|
|
1323
|
-
static override relationship = 'posts'
|
|
1324
|
-
static override table(t: Table): Table {
|
|
1325
|
-
return t
|
|
1326
|
-
.columns([Column.make('title')])
|
|
1327
|
-
.filters([TrashedFilter.make().label('Custom trashed label')])
|
|
1328
|
-
}
|
|
1329
|
-
}
|
|
1330
|
-
class UserResource extends Resource {
|
|
1331
|
-
static override slug = 'users'
|
|
1332
|
-
static override get model() { return ParentModel }
|
|
1333
|
-
static override relations() { return [PostsManager] }
|
|
1334
|
-
}
|
|
1335
|
-
const panel = Pilotiq.make('TF2-' + Math.random()).path('/admin').resources([UserResource, PostResource])
|
|
1336
|
-
|
|
1337
|
-
const out = await relationManagerData(panel, {
|
|
1338
|
-
kind: 'relation-list', slug: 'users', recordId: 'u1', relationship: 'posts',
|
|
1339
|
-
})
|
|
1340
|
-
const data = out as Record<string, unknown>
|
|
1341
|
-
const schema = data['schemaData'] as Array<Record<string, unknown>>
|
|
1342
|
-
const tableMeta = schema.find(s => s['type'] === 'table') as Record<string, unknown>
|
|
1343
|
-
const children = (tableMeta['children'] as Array<Record<string, unknown>>) ?? []
|
|
1344
|
-
const trashedFilters = children.filter(c => c['type'] === 'filter' && c['name'] === 'trashed')
|
|
1345
|
-
assert.equal(trashedFilters.length, 1, 'should not double-inject')
|
|
1346
|
-
assert.equal(trashedFilters[0]?.['label'], 'Custom trashed label',
|
|
1347
|
-
'user-supplied filter should win over the auto-injected default')
|
|
1348
|
-
})
|
|
1349
|
-
})
|
|
1350
|
-
|
|
1351
|
-
// ── Record sub-pages ─────────────────────────────────────
|
|
1352
|
-
|
|
1353
|
-
describe('record sub-pages (pages().record)', () => {
|
|
1354
|
-
class ActivityPage extends Page {
|
|
1355
|
-
static override slug = 'activity'
|
|
1356
|
-
static override label = 'Activity'
|
|
1357
|
-
static override schema() {
|
|
1358
|
-
return [Heading.make('Activity heading')]
|
|
1359
|
-
}
|
|
1360
|
-
}
|
|
1361
|
-
class ProfilePage extends Page {
|
|
1362
|
-
static override slug = 'profile'
|
|
1363
|
-
static override label = 'Profile'
|
|
1364
|
-
static override schema() {
|
|
1365
|
-
return [Heading.make('Profile heading')]
|
|
1366
|
-
}
|
|
1367
|
-
}
|
|
1368
|
-
|
|
1369
|
-
// ActivityPage / ProfilePage are module-scope so tests can reference
|
|
1370
|
-
// them inline. `canAccess` is monkey-patched by individual tests via
|
|
1371
|
-
// `buildPanel({ activityCanAccess })`; reset to the default-true
|
|
1372
|
-
// predicate before every test so order of execution stays
|
|
1373
|
-
// independent.
|
|
1374
|
-
beforeEach(() => {
|
|
1375
|
-
;(ActivityPage as unknown as { canAccess: () => Promise<boolean> }).canAccess =
|
|
1376
|
-
async () => true
|
|
1377
|
-
;(ProfilePage as unknown as { canAccess: () => Promise<boolean> }).canAccess =
|
|
1378
|
-
async () => true
|
|
1379
|
-
})
|
|
1380
|
-
|
|
1381
|
-
function buildPanel(opts: {
|
|
1382
|
-
activityCanAccess?: () => boolean | Promise<boolean>
|
|
1383
|
-
userCanView?: () => boolean | Promise<boolean>
|
|
1384
|
-
} = {}) {
|
|
1385
|
-
const ParentModel: ModelLike = stubModel({ rows: [{ id: 'u1', name: 'Alice' }] })
|
|
1386
|
-
class UserResource extends Resource {
|
|
1387
|
-
static override slug = 'users'
|
|
1388
|
-
static override recordTitleAttribute = 'name'
|
|
1389
|
-
static override get model() { return ParentModel }
|
|
1390
|
-
static override detail() { return [] }
|
|
1391
|
-
static override pages() {
|
|
1392
|
-
return { record: { activity: ActivityPage, profile: ProfilePage } }
|
|
1393
|
-
}
|
|
1394
|
-
}
|
|
1395
|
-
if (opts.userCanView) {
|
|
1396
|
-
(UserResource as unknown as { canView: () => unknown }).canView = opts.userCanView
|
|
1397
|
-
}
|
|
1398
|
-
if (opts.activityCanAccess) {
|
|
1399
|
-
(ActivityPage as unknown as { canAccess: () => unknown }).canAccess = opts.activityCanAccess
|
|
1400
|
-
} else {
|
|
1401
|
-
;(ActivityPage as unknown as { canAccess: () => Promise<boolean> }).canAccess =
|
|
1402
|
-
async () => true
|
|
1403
|
-
}
|
|
1404
|
-
const panel = Pilotiq.make('RecPg-' + Math.random()).path('/admin').resources([UserResource])
|
|
1405
|
-
return { panel, UserResource }
|
|
1406
|
-
}
|
|
1407
|
-
|
|
1408
|
-
// ── ResourcePages.record widening ──────────────────
|
|
1409
|
-
|
|
1410
|
-
it('Resource.getRecordPages() returns the record map', () => {
|
|
1411
|
-
const { UserResource } = buildPanel()
|
|
1412
|
-
const recordPages = UserResource.getRecordPages()
|
|
1413
|
-
assert.equal(recordPages['activity'], ActivityPage)
|
|
1414
|
-
assert.equal(recordPages['profile'], ProfilePage)
|
|
1415
|
-
})
|
|
1416
|
-
|
|
1417
|
-
it('Resource.getRecordPages() returns {} when no record map is declared', () => {
|
|
1418
|
-
class R extends Resource { static override slug = 'r' }
|
|
1419
|
-
assert.deepEqual(R.getRecordPages(), {})
|
|
1420
|
-
})
|
|
1421
|
-
|
|
1422
|
-
// ── Data builder ──────────────────────────────────
|
|
1423
|
-
|
|
1424
|
-
it('resourceRecordPageData returns null when slug not found', async () => {
|
|
1425
|
-
const { panel } = buildPanel()
|
|
1426
|
-
const out = await resourceRecordPageData(panel, 'nope', 'u1', 'activity')
|
|
1427
|
-
assert.equal(out, null)
|
|
1428
|
-
})
|
|
1429
|
-
|
|
1430
|
-
it('resourceRecordPageData returns null when sub-page slug not registered', async () => {
|
|
1431
|
-
const { panel } = buildPanel()
|
|
1432
|
-
const out = await resourceRecordPageData(panel, 'users', 'u1', 'nope')
|
|
1433
|
-
assert.equal(out, null)
|
|
1434
|
-
})
|
|
1435
|
-
|
|
1436
|
-
it('resourceRecordPageData renders the sub-page schema on success', async () => {
|
|
1437
|
-
const { panel } = buildPanel()
|
|
1438
|
-
const out = await resourceRecordPageData(panel, 'users', 'u1', 'activity')
|
|
1439
|
-
const data = out as Record<string, unknown>
|
|
1440
|
-
assert.equal(data['pageType'], 'record-page')
|
|
1441
|
-
assert.equal(data['mode'], 'record')
|
|
1442
|
-
assert.equal((data['subPage'] as Record<string, unknown>)['slug'], 'activity')
|
|
1443
|
-
assert.equal((data['subPage'] as Record<string, unknown>)['label'], 'Activity')
|
|
1444
|
-
const schema = data['schemaData'] as Array<Record<string, unknown>>
|
|
1445
|
-
// Activity heading lives inside the page body, prepended by tabs strip.
|
|
1446
|
-
const heading = schema.find(s => s['type'] === 'heading')
|
|
1447
|
-
assert.ok(heading, 'expected the sub-page heading to render')
|
|
1448
|
-
assert.equal(heading!['content'], 'Activity heading')
|
|
1449
|
-
})
|
|
1450
|
-
|
|
1451
|
-
it('resourceRecordPageData 403s when R.canView returns false', async () => {
|
|
1452
|
-
const { panel } = buildPanel({ userCanView: async () => false })
|
|
1453
|
-
const out = await resourceRecordPageData(panel, 'users', 'u1', 'activity')
|
|
1454
|
-
assert.deepEqual(out, { ok: false, status: 403 })
|
|
1455
|
-
})
|
|
1456
|
-
|
|
1457
|
-
it('resourceRecordPageData 403s when SubPage.canAccess returns false', async () => {
|
|
1458
|
-
const { panel } = buildPanel({ activityCanAccess: async () => false })
|
|
1459
|
-
const out = await resourceRecordPageData(panel, 'users', 'u1', 'activity')
|
|
1460
|
-
assert.deepEqual(out, { ok: false, status: 403 })
|
|
1461
|
-
})
|
|
1462
|
-
|
|
1463
|
-
it('resourceRecordPageData fails closed when SubPage.canAccess throws', async () => {
|
|
1464
|
-
const { panel } = buildPanel({ activityCanAccess: async () => { throw new Error('boom') } })
|
|
1465
|
-
const out = await resourceRecordPageData(panel, 'users', 'u1', 'activity')
|
|
1466
|
-
assert.deepEqual(out, { ok: false, status: 403 })
|
|
1467
|
-
})
|
|
1468
|
-
|
|
1469
|
-
// ── RelationTabs insertion ────────────────────────
|
|
1470
|
-
|
|
1471
|
-
it('RelationTabs inserts a tab per sub-page between Edit and managers', async () => {
|
|
1472
|
-
const ParentModel: ModelLike = stubModel({ rows: [{ id: 'u1', name: 'Alice' }] })
|
|
1473
|
-
Object.assign(ParentModel as object, { relations: { posts: { model: () => stubModel({ rows: [] }) } } })
|
|
1474
|
-
|
|
1475
|
-
class PostsManager extends RelationManager {
|
|
1476
|
-
static override relationship = 'posts'
|
|
1477
|
-
}
|
|
1478
|
-
class UserResource extends Resource {
|
|
1479
|
-
static override slug = 'users'
|
|
1480
|
-
static override get model() { return ParentModel }
|
|
1481
|
-
static override detail() { return [] }
|
|
1482
|
-
static override relations() { return [PostsManager] }
|
|
1483
|
-
static override pages() {
|
|
1484
|
-
return { record: { activity: ActivityPage } }
|
|
1485
|
-
}
|
|
1486
|
-
}
|
|
1487
|
-
const panel = Pilotiq.make('RecPgTabs-' + Math.random()).path('/admin').resources([UserResource])
|
|
1488
|
-
|
|
1489
|
-
const out = await resourceViewData(panel, 'users', 'u1')
|
|
1490
|
-
const schema = (out as Record<string, unknown>)['schemaData'] as Array<Record<string, unknown>>
|
|
1491
|
-
const tabsMeta = schema.find(s => s['type'] === 'relation-tabs') as Record<string, unknown>
|
|
1492
|
-
const tabs = tabsMeta['tabs'] as Array<{ key: string; url: string; active: boolean }>
|
|
1493
|
-
assert.deepEqual(tabs.map(t => t.key), ['__view', '__edit', 'activity', 'posts'])
|
|
1494
|
-
assert.equal(tabs.find(t => t.key === 'activity')?.url, '/admin/users/u1/activity')
|
|
1495
|
-
})
|
|
1496
|
-
|
|
1497
|
-
it('RelationTabs marks the sub-page tab active when rendering through the sub-page', async () => {
|
|
1498
|
-
const { panel } = buildPanel()
|
|
1499
|
-
const out = await resourceRecordPageData(panel, 'users', 'u1', 'activity')
|
|
1500
|
-
const schema = (out as Record<string, unknown>)['schemaData'] as Array<Record<string, unknown>>
|
|
1501
|
-
const tabsMeta = schema.find(s => s['type'] === 'relation-tabs') as Record<string, unknown>
|
|
1502
|
-
const tabs = tabsMeta['tabs'] as Array<{ key: string; active: boolean }>
|
|
1503
|
-
const activity = tabs.find(t => t.key === 'activity')
|
|
1504
|
-
assert.equal(activity?.active, true)
|
|
1505
|
-
})
|
|
1506
|
-
|
|
1507
|
-
it('RelationTabs hides a sub-page tab when its canAccess returns false', async () => {
|
|
1508
|
-
const { panel } = buildPanel({ activityCanAccess: async () => false })
|
|
1509
|
-
// resourceViewData renders __view-active strip; activity sub-page
|
|
1510
|
-
// should drop. profile (default canAccess=true) survives.
|
|
1511
|
-
const out = await resourceViewData(panel, 'users', 'u1')
|
|
1512
|
-
const schema = (out as Record<string, unknown>)['schemaData'] as Array<Record<string, unknown>>
|
|
1513
|
-
const tabsMeta = schema.find(s => s['type'] === 'relation-tabs') as Record<string, unknown>
|
|
1514
|
-
const tabs = tabsMeta['tabs'] as Array<{ key: string }>
|
|
1515
|
-
assert.equal(tabs.find(t => t.key === 'activity'), undefined)
|
|
1516
|
-
assert.ok(tabs.find(t => t.key === 'profile'), 'profile sub-page should remain visible')
|
|
1517
|
-
})
|
|
1518
|
-
|
|
1519
|
-
it('RelationTabs mounts the strip even when only sub-pages exist (no relations)', async () => {
|
|
1520
|
-
// No relation managers — pre-feature, the strip would not mount.
|
|
1521
|
-
// With record sub-pages, the strip mounts to surface them.
|
|
1522
|
-
const { panel } = buildPanel()
|
|
1523
|
-
const out = await resourceViewData(panel, 'users', 'u1')
|
|
1524
|
-
const schema = (out as Record<string, unknown>)['schemaData'] as Array<Record<string, unknown>>
|
|
1525
|
-
const tabsMeta = schema.find(s => s['type'] === 'relation-tabs')
|
|
1526
|
-
assert.ok(tabsMeta, 'strip should mount when sub-pages are registered')
|
|
1527
|
-
})
|
|
1528
|
-
|
|
1529
|
-
// ── dispatchPageData fallthrough ──────────────────
|
|
1530
|
-
|
|
1531
|
-
it('dispatchPageData routes a known sub-page slug through resourceRecordPageData', async () => {
|
|
1532
|
-
PilotiqRegistry.reset()
|
|
1533
|
-
const { panel } = buildPanel()
|
|
1534
|
-
PilotiqRegistry.register(panel)
|
|
1535
|
-
const out = await dispatchPageData({
|
|
1536
|
-
pageId: '/pages/(pilotiq)/relation-list',
|
|
1537
|
-
urlPathname: '/admin/users/u1/activity',
|
|
1538
|
-
routeParams: { basePath: 'admin', slug: 'users', id: 'u1', relationship: 'activity' },
|
|
1539
|
-
urlParsed: { search: {} as Record<string, string> } as never,
|
|
1540
|
-
} as never)
|
|
1541
|
-
const data = out as Record<string, unknown>
|
|
1542
|
-
assert.equal(data['pageType'], 'record-page')
|
|
1543
|
-
})
|
|
1544
|
-
|
|
1545
|
-
it('dispatchPageData still returns null when neither manager nor sub-page matches', async () => {
|
|
1546
|
-
PilotiqRegistry.reset()
|
|
1547
|
-
const { panel } = buildPanel()
|
|
1548
|
-
PilotiqRegistry.register(panel)
|
|
1549
|
-
const out = await dispatchPageData({
|
|
1550
|
-
pageId: '/pages/(pilotiq)/relation-list',
|
|
1551
|
-
urlPathname: '/admin/users/u1/nope',
|
|
1552
|
-
routeParams: { basePath: 'admin', slug: 'users', id: 'u1', relationship: 'nope' },
|
|
1553
|
-
urlParsed: { search: {} as Record<string, string> } as never,
|
|
1554
|
-
} as never)
|
|
1555
|
-
assert.equal(out, null)
|
|
1556
|
-
})
|
|
1557
|
-
|
|
1558
|
-
// ── Boot validation ──────────────────────────────
|
|
1559
|
-
|
|
1560
|
-
it('boot rejects a record sub-page slug colliding with a relation manager', () => {
|
|
1561
|
-
class CollideManager extends RelationManager {
|
|
1562
|
-
static override relationship = 'activity'
|
|
1563
|
-
}
|
|
1564
|
-
class UserResource extends Resource {
|
|
1565
|
-
static override slug = 'users'
|
|
1566
|
-
static override relations() { return [CollideManager] }
|
|
1567
|
-
static override pages() {
|
|
1568
|
-
return { record: { activity: ActivityPage } }
|
|
1569
|
-
}
|
|
1570
|
-
}
|
|
1571
|
-
// Boot validation runs inside `registerPilotiqRoutes`; emulate by
|
|
1572
|
-
// calling it through the test plumbing if available. For now we
|
|
1573
|
-
// assert the validation by reading the slugs and confirming the
|
|
1574
|
-
// collision is detectable — full route-registration runs in the
|
|
1575
|
-
// integration test below.
|
|
1576
|
-
const managerSlugs = new Set(UserResource.relations().map(M => M.getRelationship()))
|
|
1577
|
-
const recordSlugs = Object.keys(UserResource.getRecordPages())
|
|
1578
|
-
const collisions = recordSlugs.filter(s => managerSlugs.has(s))
|
|
1579
|
-
assert.deepEqual(collisions, ['activity'])
|
|
1580
|
-
})
|
|
1581
|
-
|
|
1582
|
-
it('boot rejects a record sub-page slug with invalid characters', () => {
|
|
1583
|
-
class UserResource extends Resource {
|
|
1584
|
-
static override slug = 'users'
|
|
1585
|
-
static override pages() {
|
|
1586
|
-
return { record: { 'bad slug!': ActivityPage } }
|
|
1587
|
-
}
|
|
1588
|
-
}
|
|
1589
|
-
const slugs = Object.keys(UserResource.getRecordPages())
|
|
1590
|
-
// Pattern validation lives in `registerPilotiqRoutes`; here we just
|
|
1591
|
-
// assert the recorded slug round-trips so the validator's input is
|
|
1592
|
-
// what the user typed.
|
|
1593
|
-
assert.deepEqual(slugs, ['bad slug!'])
|
|
1594
|
-
})
|
|
1595
|
-
})
|