@pilotiq/pilotiq 0.24.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 +33 -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/package.json +6 -1
- package/.turbo/turbo-build.log +0 -8
- package/CLAUDE.md +0 -265
- 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 -225
- 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 -194
- 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/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 -704
- package/src/routes/pages.ts +0 -175
- package/src/routes/panel.ts +0 -204
- package/src/routes/relations.ts +0 -1243
- 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
package/src/pageData.test.ts
DELETED
|
@@ -1,1545 +0,0 @@
|
|
|
1
|
-
import { describe, it } from 'node:test'
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
|
|
4
|
-
import { Form } from './elements/Form.js'
|
|
5
|
-
import { ListTab } from './Tab.js'
|
|
6
|
-
import { ListTabs } from './elements/ListTabs.js'
|
|
7
|
-
import {
|
|
8
|
-
applyEditPageHydrators,
|
|
9
|
-
applyFillPipeline,
|
|
10
|
-
formCreateOptionData,
|
|
11
|
-
formStateData,
|
|
12
|
-
formWizardData,
|
|
13
|
-
mentionResolveData,
|
|
14
|
-
panelInfo,
|
|
15
|
-
resolveActiveTab,
|
|
16
|
-
tagFormStateUrls,
|
|
17
|
-
tagRichTextMentionUrls,
|
|
18
|
-
tagSelectCreateOptionUrls,
|
|
19
|
-
tagTableReorderUrls,
|
|
20
|
-
tagCellEditUrls,
|
|
21
|
-
} from './pageData.js'
|
|
22
|
-
import { Element } from './schema/Element.js'
|
|
23
|
-
import { Table } from './elements/Table.js'
|
|
24
|
-
import { Column } from './Column.js'
|
|
25
|
-
import { TextInputColumn, ToggleColumn } from './columns/index.js'
|
|
26
|
-
import { Pilotiq } from './Pilotiq.js'
|
|
27
|
-
import { Resource } from './Resource.js'
|
|
28
|
-
import { Global } from './Global.js'
|
|
29
|
-
import { Page } from './Page.js'
|
|
30
|
-
import { TextField } from './fields/TextField.js'
|
|
31
|
-
import { SelectField } from './fields/SelectField.js'
|
|
32
|
-
import { ToggleField } from './fields/ToggleField.js'
|
|
33
|
-
import { Section } from './schema/Section.js'
|
|
34
|
-
import { Wizard, Step } from './schema/Wizard.js'
|
|
35
|
-
import { Repeater } from './fields/RepeaterField.js'
|
|
36
|
-
import { Builder } from './fields/BuilderField.js'
|
|
37
|
-
import { Block } from './schema/Block.js'
|
|
38
|
-
|
|
39
|
-
describe('applyFillPipeline', () => {
|
|
40
|
-
it('defaults to a shallow record copy when nothing is configured', async () => {
|
|
41
|
-
const form = Form.make()
|
|
42
|
-
const record = { id: 1, title: 'Hello' }
|
|
43
|
-
const values = await applyFillPipeline(form, record)
|
|
44
|
-
assert.deepEqual(values, { id: 1, title: 'Hello' })
|
|
45
|
-
assert.notEqual(values, record)
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
it('runs mutateFormDataBeforeFill before fillFromRecord', async () => {
|
|
49
|
-
const order: string[] = []
|
|
50
|
-
const form = Form.make<{ id: number; tags: string[] }>()
|
|
51
|
-
.mutateFormDataBeforeFill(v => { order.push('before'); return { ...v, tagsCsv: '' } })
|
|
52
|
-
.fillFromRecord(r => { order.push('fill'); return { id: r.id, tagsCsv: r.tags.join(',') } })
|
|
53
|
-
|
|
54
|
-
const values = await applyFillPipeline(form, { id: 1, tags: ['a', 'b'] })
|
|
55
|
-
assert.deepEqual(order, ['before', 'fill'])
|
|
56
|
-
assert.deepEqual(values, { id: 1, tagsCsv: 'a,b' })
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
it('runs mutateFormDataAfterFill after fillFromRecord', async () => {
|
|
60
|
-
const form = Form.make<{ id: number; title: string }>()
|
|
61
|
-
.fillFromRecord(r => ({ id: r.id, title: r.title }))
|
|
62
|
-
.mutateFormDataAfterFill(v => ({ ...v, title: String(v['title']).toUpperCase() }))
|
|
63
|
-
|
|
64
|
-
const values = await applyFillPipeline(form, { id: 1, title: 'hello' })
|
|
65
|
-
assert.deepEqual(values, { id: 1, title: 'HELLO' })
|
|
66
|
-
})
|
|
67
|
-
|
|
68
|
-
it('passes the loaded record on ctx.record to both mutators', async () => {
|
|
69
|
-
const seen: { before?: unknown; after?: unknown } = {}
|
|
70
|
-
const form = Form.make<{ id: number; secret: string }>()
|
|
71
|
-
.mutateFormDataBeforeFill((v, ctx) => { seen.before = ctx.record; return v })
|
|
72
|
-
.mutateFormDataAfterFill((v, ctx) => { seen.after = ctx.record; return v })
|
|
73
|
-
|
|
74
|
-
const record = { id: 1, secret: 'hidden' }
|
|
75
|
-
await applyFillPipeline(form, record)
|
|
76
|
-
assert.equal(seen.before, record)
|
|
77
|
-
assert.equal(seen.after, record)
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
it('supports async mutators', async () => {
|
|
81
|
-
const form = Form.make<{ id: number }>()
|
|
82
|
-
.mutateFormDataAfterFill(async v => ({ ...v, async: true }))
|
|
83
|
-
const values = await applyFillPipeline(form, { id: 1 })
|
|
84
|
-
assert.deepEqual(values, { id: 1, async: true })
|
|
85
|
-
})
|
|
86
|
-
|
|
87
|
-
it('parses JSON-string values on Repeater slots into arrays', async () => {
|
|
88
|
-
const form = Form.make().schema([
|
|
89
|
-
TextField.make('title'),
|
|
90
|
-
Repeater.make('metadata').schema([TextField.make('heading')]),
|
|
91
|
-
])
|
|
92
|
-
const record = {
|
|
93
|
-
id: 1,
|
|
94
|
-
title: 'Hello',
|
|
95
|
-
metadata: '[{"__id":"row-1","heading":"a"},{"__id":"row-2","heading":"b"}]',
|
|
96
|
-
}
|
|
97
|
-
const values = await applyFillPipeline(form, record)
|
|
98
|
-
assert.deepEqual(values['metadata'], [
|
|
99
|
-
{ __id: 'row-1', heading: 'a' },
|
|
100
|
-
{ __id: 'row-2', heading: 'b' },
|
|
101
|
-
])
|
|
102
|
-
assert.equal(values['title'], 'Hello')
|
|
103
|
-
})
|
|
104
|
-
|
|
105
|
-
it('parses JSON-string values on Builder slots into arrays', async () => {
|
|
106
|
-
const form = Form.make().schema([
|
|
107
|
-
Builder.make('content').blocks([
|
|
108
|
-
Block.make('heading').schema([TextField.make('text')]),
|
|
109
|
-
]),
|
|
110
|
-
])
|
|
111
|
-
const record = {
|
|
112
|
-
content: '[{"__id":"row-1","type":"heading","data":{"text":"hi"}}]',
|
|
113
|
-
}
|
|
114
|
-
const values = await applyFillPipeline(form, record)
|
|
115
|
-
assert.deepEqual(values['content'], [
|
|
116
|
-
{ __id: 'row-1', type: 'heading', data: { text: 'hi' } },
|
|
117
|
-
])
|
|
118
|
-
})
|
|
119
|
-
|
|
120
|
-
it('leaves non-JSON strings on array-field slots untouched', async () => {
|
|
121
|
-
const form = Form.make().schema([
|
|
122
|
-
Repeater.make('tags').schema([TextField.make('label')]),
|
|
123
|
-
])
|
|
124
|
-
const record = { tags: 'not-json' }
|
|
125
|
-
const values = await applyFillPipeline(form, record)
|
|
126
|
-
assert.equal(values['tags'], 'not-json')
|
|
127
|
-
})
|
|
128
|
-
|
|
129
|
-
it('leaves JSON strings that deserialize to non-arrays untouched', async () => {
|
|
130
|
-
const form = Form.make().schema([
|
|
131
|
-
Repeater.make('tags').schema([TextField.make('label')]),
|
|
132
|
-
])
|
|
133
|
-
const record = { tags: '{"not":"an-array"}' }
|
|
134
|
-
const values = await applyFillPipeline(form, record)
|
|
135
|
-
assert.equal(values['tags'], '{"not":"an-array"}')
|
|
136
|
-
})
|
|
137
|
-
|
|
138
|
-
it('passes through already-parsed arrays unchanged', async () => {
|
|
139
|
-
const form = Form.make().schema([
|
|
140
|
-
Repeater.make('metadata').schema([TextField.make('heading')]),
|
|
141
|
-
])
|
|
142
|
-
const rows = [{ __id: 'row-1', heading: 'a' }]
|
|
143
|
-
const values = await applyFillPipeline(form, { metadata: rows })
|
|
144
|
-
assert.equal(values['metadata'], rows)
|
|
145
|
-
})
|
|
146
|
-
|
|
147
|
-
it('ignores top-level non-array fields whose value happens to be a JSON-string', async () => {
|
|
148
|
-
const form = Form.make().schema([TextField.make('title')])
|
|
149
|
-
const record = { title: '[1,2,3]' }
|
|
150
|
-
const values = await applyFillPipeline(form, record)
|
|
151
|
-
assert.equal(values['title'], '[1,2,3]')
|
|
152
|
-
})
|
|
153
|
-
})
|
|
154
|
-
|
|
155
|
-
describe('resolveActiveTab', () => {
|
|
156
|
-
it('is a no-op when the schema has no ListTabs', async () => {
|
|
157
|
-
// Just shouldn't throw; nothing to assert besides the absence of side effects.
|
|
158
|
-
await resolveActiveTab([Form.make()], {}, '/admin/articles')
|
|
159
|
-
})
|
|
160
|
-
|
|
161
|
-
it('marks the first tab active when ?tab= is absent and no tab is .default()', async () => {
|
|
162
|
-
const all = ListTab.make('all').label('All')
|
|
163
|
-
const drafts = ListTab.make('drafts').label('Drafts')
|
|
164
|
-
const tabs = ListTabs.make().tabs([all, drafts])
|
|
165
|
-
|
|
166
|
-
await resolveActiveTab([tabs], {}, '/admin/articles')
|
|
167
|
-
assert.equal(all.isActive(), true)
|
|
168
|
-
assert.equal(drafts.isActive(), false)
|
|
169
|
-
})
|
|
170
|
-
|
|
171
|
-
it('honors .default() over the first-tab fallback', async () => {
|
|
172
|
-
const all = ListTab.make('all').label('All')
|
|
173
|
-
const drafts = ListTab.make('drafts').label('Drafts').default()
|
|
174
|
-
const tabs = ListTabs.make().tabs([all, drafts])
|
|
175
|
-
|
|
176
|
-
await resolveActiveTab([tabs], {}, '/admin/articles')
|
|
177
|
-
assert.equal(all.isActive(), false)
|
|
178
|
-
assert.equal(drafts.isActive(), true)
|
|
179
|
-
})
|
|
180
|
-
|
|
181
|
-
it('resolves the URL-supplied ?tab=name to the matching tab', async () => {
|
|
182
|
-
const all = ListTab.make('all').default()
|
|
183
|
-
const drafts = ListTab.make('drafts')
|
|
184
|
-
const tabs = ListTabs.make().tabs([all, drafts])
|
|
185
|
-
|
|
186
|
-
await resolveActiveTab([tabs], { tab: 'drafts' }, '/admin/articles')
|
|
187
|
-
assert.equal(all.isActive(), false)
|
|
188
|
-
assert.equal(drafts.isActive(), true)
|
|
189
|
-
})
|
|
190
|
-
|
|
191
|
-
it('falls through to default when ?tab= names a non-existent tab', async () => {
|
|
192
|
-
const all = ListTab.make('all').default()
|
|
193
|
-
const drafts = ListTab.make('drafts')
|
|
194
|
-
const tabs = ListTabs.make().tabs([all, drafts])
|
|
195
|
-
|
|
196
|
-
await resolveActiveTab([tabs], { tab: 'bogus' }, '/admin/articles')
|
|
197
|
-
assert.equal(all.isActive(), true)
|
|
198
|
-
assert.equal(drafts.isActive(), false)
|
|
199
|
-
})
|
|
200
|
-
|
|
201
|
-
it('stamps per-tab URLs that carry forward search/sort/filters but reset page', async () => {
|
|
202
|
-
const all = ListTab.make('all')
|
|
203
|
-
const drafts = ListTab.make('drafts')
|
|
204
|
-
const tabs = ListTabs.make().tabs([all, drafts])
|
|
205
|
-
|
|
206
|
-
await resolveActiveTab(
|
|
207
|
-
[tabs],
|
|
208
|
-
{ search: 'hi', sort: 'title:desc', page: '3', status: 'published' },
|
|
209
|
-
'/admin/articles',
|
|
210
|
-
)
|
|
211
|
-
|
|
212
|
-
const allUrl = all.toMeta().url
|
|
213
|
-
const draftsUrl = drafts.toMeta().url
|
|
214
|
-
// Tab name + carry-forward params, no `page`.
|
|
215
|
-
for (const url of [allUrl, draftsUrl]) {
|
|
216
|
-
assert.ok(url.startsWith('/admin/articles?'), `${url} should be absolute under the index path`)
|
|
217
|
-
assert.ok(url.includes('search=hi'), 'search carries forward')
|
|
218
|
-
assert.ok(url.includes('sort=title%3Adesc') || url.includes('sort=title:desc'), 'sort carries forward')
|
|
219
|
-
assert.ok(url.includes('status=published'), 'filter values carry forward')
|
|
220
|
-
assert.ok(!url.includes('page='), 'page resets on tab change')
|
|
221
|
-
}
|
|
222
|
-
// `all` is the implicit default tab (first, none marked `.default()`)
|
|
223
|
-
// — its canonical URL omits `?tab=`. Non-default tabs include it.
|
|
224
|
-
assert.ok(!allUrl.includes('tab='), 'default tab URL omits ?tab=')
|
|
225
|
-
assert.ok(draftsUrl.includes('tab=drafts'), 'non-default tab URL includes ?tab=')
|
|
226
|
-
})
|
|
227
|
-
|
|
228
|
-
it('default tab URL is the bare path when no other params are present', async () => {
|
|
229
|
-
const all = ListTab.make('all')
|
|
230
|
-
const drafts = ListTab.make('drafts')
|
|
231
|
-
const tabs = ListTabs.make().tabs([all, drafts])
|
|
232
|
-
|
|
233
|
-
await resolveActiveTab([tabs], {}, '/admin/articles')
|
|
234
|
-
|
|
235
|
-
// Default tab → no query string at all.
|
|
236
|
-
assert.equal(all.toMeta().url, '/admin/articles')
|
|
237
|
-
// Non-default still names itself.
|
|
238
|
-
assert.equal(drafts.toMeta().url, '/admin/articles?tab=drafts')
|
|
239
|
-
})
|
|
240
|
-
|
|
241
|
-
it('explicitly-marked .default() tab gets the paramless URL even when not first', async () => {
|
|
242
|
-
const all = ListTab.make('all')
|
|
243
|
-
const drafts = ListTab.make('drafts').default()
|
|
244
|
-
const tabs = ListTabs.make().tabs([all, drafts])
|
|
245
|
-
|
|
246
|
-
await resolveActiveTab([tabs], {}, '/admin/articles')
|
|
247
|
-
|
|
248
|
-
assert.equal(drafts.toMeta().url, '/admin/articles', 'marked-default tab → bare path')
|
|
249
|
-
assert.equal(all.toMeta().url, '/admin/articles?tab=all')
|
|
250
|
-
})
|
|
251
|
-
|
|
252
|
-
it('resolves badge handlers in parallel and stamps the result on each tab', async () => {
|
|
253
|
-
const order: string[] = []
|
|
254
|
-
const a = ListTab.make('a').badge(async () => {
|
|
255
|
-
order.push('a-start')
|
|
256
|
-
await new Promise(r => setTimeout(r, 10))
|
|
257
|
-
order.push('a-end')
|
|
258
|
-
return 1
|
|
259
|
-
})
|
|
260
|
-
const b = ListTab.make('b').badge(async () => {
|
|
261
|
-
order.push('b-start')
|
|
262
|
-
await new Promise(r => setTimeout(r, 5))
|
|
263
|
-
order.push('b-end')
|
|
264
|
-
return 2
|
|
265
|
-
})
|
|
266
|
-
const tabs = ListTabs.make().tabs([a, b])
|
|
267
|
-
|
|
268
|
-
await resolveActiveTab([tabs], {}, '/admin/articles')
|
|
269
|
-
assert.equal(a.toMeta().badge, '1')
|
|
270
|
-
assert.equal(b.toMeta().badge, '2')
|
|
271
|
-
// Both started before either finished — confirms Promise.all parallelism.
|
|
272
|
-
assert.equal(order.indexOf('a-start') < order.indexOf('b-end'), true)
|
|
273
|
-
assert.equal(order.indexOf('b-start') < order.indexOf('a-end'), true)
|
|
274
|
-
})
|
|
275
|
-
|
|
276
|
-
it('swallows errors thrown by badge handlers', async () => {
|
|
277
|
-
const broken = ListTab.make('broken').badge(async () => { throw new Error('oops') })
|
|
278
|
-
const tabs = ListTabs.make().tabs([broken])
|
|
279
|
-
await resolveActiveTab([tabs], {}, '/admin/articles')
|
|
280
|
-
// No badge stamped; meta has no `badge` key.
|
|
281
|
-
assert.equal(broken.toMeta().badge, undefined)
|
|
282
|
-
})
|
|
283
|
-
|
|
284
|
-
it('badge handler returning undefined leaves the badge unset', async () => {
|
|
285
|
-
const tab = ListTab.make('drafts').badge(async () => undefined)
|
|
286
|
-
const tabs = ListTabs.make().tabs([tab])
|
|
287
|
-
await resolveActiveTab([tabs], {}, '/admin/articles')
|
|
288
|
-
assert.equal(tab.toMeta().badge, undefined)
|
|
289
|
-
})
|
|
290
|
-
|
|
291
|
-
it('static badge survives unchanged when no handler is set', async () => {
|
|
292
|
-
const tab = ListTab.make('drafts').badge('5')
|
|
293
|
-
const tabs = ListTabs.make().tabs([tab])
|
|
294
|
-
await resolveActiveTab([tabs], {}, '/admin/articles')
|
|
295
|
-
assert.equal(tab.toMeta().badge, '5')
|
|
296
|
-
})
|
|
297
|
-
})
|
|
298
|
-
|
|
299
|
-
describe('panelInfo — icon serialization', () => {
|
|
300
|
-
it('ships string-typed Resource.icon as-is', async () => {
|
|
301
|
-
class StringIconResource extends Resource {
|
|
302
|
-
static override label = 'Things'
|
|
303
|
-
static override icon = 'newspaper'
|
|
304
|
-
}
|
|
305
|
-
const panel = Pilotiq.make('T').path('/admin').resources([StringIconResource])
|
|
306
|
-
const info = await panelInfo(panel)
|
|
307
|
-
const r = info.navigation[0]!
|
|
308
|
-
assert.equal(r.icon, 'newspaper')
|
|
309
|
-
assert.equal(r.name, 'StringIconResource')
|
|
310
|
-
})
|
|
311
|
-
|
|
312
|
-
it('ships component-typed Resource.icon as { class: ownerName }', async () => {
|
|
313
|
-
const FakeIcon = () => null
|
|
314
|
-
class CmpIconResource extends Resource {
|
|
315
|
-
static override label = 'Things'
|
|
316
|
-
static override icon = FakeIcon as unknown as string
|
|
317
|
-
}
|
|
318
|
-
const panel = Pilotiq.make('T').path('/admin').resources([CmpIconResource])
|
|
319
|
-
const info = await panelInfo(panel)
|
|
320
|
-
const r = info.navigation[0]!
|
|
321
|
-
assert.deepEqual(r.icon, { class: 'CmpIconResource' })
|
|
322
|
-
assert.equal(r.name, 'CmpIconResource')
|
|
323
|
-
})
|
|
324
|
-
|
|
325
|
-
it('serializes Global.icon and Page.icon the same way', async () => {
|
|
326
|
-
const FakeIcon = () => null
|
|
327
|
-
class CmpIconGlobal extends Global {
|
|
328
|
-
static override label = 'Settings'
|
|
329
|
-
static override icon = FakeIcon as unknown as string
|
|
330
|
-
}
|
|
331
|
-
const panel = Pilotiq.make('T').path('/admin').globals([CmpIconGlobal])
|
|
332
|
-
const info = await panelInfo(panel)
|
|
333
|
-
assert.deepEqual(info.navigation[0]!.icon, { class: 'CmpIconGlobal' })
|
|
334
|
-
})
|
|
335
|
-
})
|
|
336
|
-
|
|
337
|
-
describe('panelInfo — navigation tree (Plan #9)', () => {
|
|
338
|
-
it('builds a flat tree when no group / sort / parent metadata is set', async () => {
|
|
339
|
-
class Articles extends Resource { static override label = 'Articles' }
|
|
340
|
-
class Users extends Resource { static override label = 'Users' }
|
|
341
|
-
const panel = Pilotiq.make('T').path('/admin').resources([Articles, Users])
|
|
342
|
-
const info = await panelInfo(panel)
|
|
343
|
-
assert.equal(info.navigation.length, 2)
|
|
344
|
-
assert.equal(info.navigation[0]!.name, 'Articles')
|
|
345
|
-
assert.equal(info.navigation[0]!.url, '/admin/articles')
|
|
346
|
-
assert.equal(info.navigation[0]!.group, undefined)
|
|
347
|
-
assert.equal(info.navigation[0]!.children, undefined)
|
|
348
|
-
assert.equal(info.navigation[1]!.name, 'Users')
|
|
349
|
-
})
|
|
350
|
-
|
|
351
|
-
it('uses navigationLabel + navigationIcon when set, otherwise label + icon', async () => {
|
|
352
|
-
const Pencil = () => null
|
|
353
|
-
class Posts extends Resource {
|
|
354
|
-
static override label = 'Articles'
|
|
355
|
-
static override icon = 'newspaper'
|
|
356
|
-
static override navigationLabel = 'Posts'
|
|
357
|
-
static override navigationIcon = Pencil as unknown as string
|
|
358
|
-
}
|
|
359
|
-
const info = await panelInfo(Pilotiq.make('T').path('/admin').resources([Posts]))
|
|
360
|
-
assert.equal(info.navigation[0]!.label, 'Posts')
|
|
361
|
-
assert.deepEqual(info.navigation[0]!.icon, { class: 'Posts' })
|
|
362
|
-
})
|
|
363
|
-
|
|
364
|
-
it('Globals default navigationGroup to "Settings"; explicit null opts out', async () => {
|
|
365
|
-
class Brand extends Global { static override label = 'Brand' }
|
|
366
|
-
class Site extends Global {
|
|
367
|
-
static override label = 'Site'
|
|
368
|
-
static override navigationGroup = null
|
|
369
|
-
}
|
|
370
|
-
const info = await panelInfo(Pilotiq.make('T').path('/admin').globals([Brand, Site]))
|
|
371
|
-
const brand = info.navigation.find(n => n.name === 'Brand')!
|
|
372
|
-
const site = info.navigation.find(n => n.name === 'Site')!
|
|
373
|
-
assert.equal(brand.group, 'Settings')
|
|
374
|
-
assert.equal(site.group, undefined)
|
|
375
|
-
})
|
|
376
|
-
|
|
377
|
-
it('preserves group order based on first appearance in registration', async () => {
|
|
378
|
-
class A extends Resource { static override label = 'A'; static override navigationGroup = 'Beta' }
|
|
379
|
-
class B extends Resource { static override label = 'B'; static override navigationGroup = 'Alpha' }
|
|
380
|
-
class C extends Resource { static override label = 'C'; static override navigationGroup = 'Beta' }
|
|
381
|
-
const info = await panelInfo(Pilotiq.make('T').path('/admin').resources([A, B, C]))
|
|
382
|
-
// Items live flat on the tree carrying `group`; later code groups by it.
|
|
383
|
-
// Order is A (Beta), B (Alpha), C (Beta) — Beta appeared first.
|
|
384
|
-
assert.deepEqual(info.navigation.map(n => n.group), ['Beta', 'Alpha', 'Beta'])
|
|
385
|
-
})
|
|
386
|
-
|
|
387
|
-
it('sorts within siblings by navigationSort (asc), then registration order; sorted before unsorted', async () => {
|
|
388
|
-
class A extends Resource { static override label = 'A'; static override navigationSort = 30 }
|
|
389
|
-
class B extends Resource { static override label = 'B'; static override navigationSort = 10 }
|
|
390
|
-
class C extends Resource { static override label = 'C' /* no sort */ }
|
|
391
|
-
class D extends Resource { static override label = 'D'; static override navigationSort = 20 }
|
|
392
|
-
class E extends Resource { static override label = 'E' /* no sort, comes after C */ }
|
|
393
|
-
const info = await panelInfo(Pilotiq.make('T').path('/admin').resources([A, B, C, D, E]))
|
|
394
|
-
assert.deepEqual(info.navigation.map(n => n.name), ['B', 'D', 'A', 'C', 'E'])
|
|
395
|
-
})
|
|
396
|
-
|
|
397
|
-
it('nests under navigationParentItem (class-name reference)', async () => {
|
|
398
|
-
class Parent extends Resource { static override label = 'Parent' }
|
|
399
|
-
class Child extends Resource {
|
|
400
|
-
static override label = 'Child'
|
|
401
|
-
static override navigationParentItem = 'Parent'
|
|
402
|
-
}
|
|
403
|
-
const info = await panelInfo(Pilotiq.make('T').path('/admin').resources([Parent, Child]))
|
|
404
|
-
assert.equal(info.navigation.length, 1)
|
|
405
|
-
assert.equal(info.navigation[0]!.name, 'Parent')
|
|
406
|
-
assert.equal(info.navigation[0]!.children?.length, 1)
|
|
407
|
-
assert.equal(info.navigation[0]!.children![0]!.name, 'Child')
|
|
408
|
-
})
|
|
409
|
-
|
|
410
|
-
it('renders dangling parent references at top level (no console error)', async () => {
|
|
411
|
-
class Orphan extends Resource {
|
|
412
|
-
static override label = 'Orphan'
|
|
413
|
-
static override navigationParentItem = 'DoesNotExist'
|
|
414
|
-
}
|
|
415
|
-
const info = await panelInfo(Pilotiq.make('T').path('/admin').resources([Orphan]))
|
|
416
|
-
assert.equal(info.navigation.length, 1)
|
|
417
|
-
assert.equal(info.navigation[0]!.name, 'Orphan')
|
|
418
|
-
assert.equal(info.navigation[0]!.children, undefined)
|
|
419
|
-
})
|
|
420
|
-
|
|
421
|
-
it('breaks parent cycles: A → B → A both render at top level', async () => {
|
|
422
|
-
class A extends Resource { static override label = 'A'; static override navigationParentItem = 'B' }
|
|
423
|
-
class B extends Resource { static override label = 'B'; static override navigationParentItem = 'A' }
|
|
424
|
-
// Suppress the dev warning.
|
|
425
|
-
const origWarn = console.warn
|
|
426
|
-
console.warn = () => {}
|
|
427
|
-
try {
|
|
428
|
-
const info = await panelInfo(Pilotiq.make('T').path('/admin').resources([A, B]))
|
|
429
|
-
const names = info.navigation.map(n => n.name).sort()
|
|
430
|
-
assert.deepEqual(names, ['A', 'B'])
|
|
431
|
-
} finally {
|
|
432
|
-
console.warn = origWarn
|
|
433
|
-
}
|
|
434
|
-
})
|
|
435
|
-
|
|
436
|
-
it('resolves navigationBadge handlers in parallel and stamps the result', async () => {
|
|
437
|
-
const order: string[] = []
|
|
438
|
-
class Slow extends Resource {
|
|
439
|
-
static override label = 'Slow'
|
|
440
|
-
static override navigationBadge = async () => {
|
|
441
|
-
order.push('slow-start')
|
|
442
|
-
await new Promise(r => setTimeout(r, 10))
|
|
443
|
-
order.push('slow-end')
|
|
444
|
-
return 1
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
class Fast extends Resource {
|
|
448
|
-
static override label = 'Fast'
|
|
449
|
-
static override navigationBadge = async () => {
|
|
450
|
-
order.push('fast-start')
|
|
451
|
-
await new Promise(r => setTimeout(r, 5))
|
|
452
|
-
order.push('fast-end')
|
|
453
|
-
return 2
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
const info = await panelInfo(Pilotiq.make('T').path('/admin').resources([Slow, Fast]))
|
|
457
|
-
const slow = info.navigation.find(n => n.name === 'Slow')!
|
|
458
|
-
const fast = info.navigation.find(n => n.name === 'Fast')!
|
|
459
|
-
assert.equal(slow.badge, '1')
|
|
460
|
-
assert.equal(fast.badge, '2')
|
|
461
|
-
// Both started before either finished — confirms Promise.all parallelism.
|
|
462
|
-
assert.equal(order.indexOf('slow-start') < order.indexOf('fast-end'), true)
|
|
463
|
-
assert.equal(order.indexOf('fast-start') < order.indexOf('slow-end'), true)
|
|
464
|
-
})
|
|
465
|
-
|
|
466
|
-
it('swallows badge handler errors so the page still renders', async () => {
|
|
467
|
-
class Broken extends Resource {
|
|
468
|
-
static override label = 'Broken'
|
|
469
|
-
static override navigationBadge = async () => { throw new Error('boom') }
|
|
470
|
-
}
|
|
471
|
-
const info = await panelInfo(Pilotiq.make('T').path('/admin').resources([Broken]))
|
|
472
|
-
assert.equal(info.navigation[0]!.badge, undefined)
|
|
473
|
-
})
|
|
474
|
-
|
|
475
|
-
it('omits badge when handler returns undefined or null', async () => {
|
|
476
|
-
class Empty extends Resource {
|
|
477
|
-
static override label = 'Empty'
|
|
478
|
-
static override navigationBadge = async () => undefined
|
|
479
|
-
}
|
|
480
|
-
const info = await panelInfo(Pilotiq.make('T').path('/admin').resources([Empty]))
|
|
481
|
-
assert.equal(info.navigation[0]!.badge, undefined)
|
|
482
|
-
})
|
|
483
|
-
|
|
484
|
-
it('exposes navigationBadgeColor when not "default"', async () => {
|
|
485
|
-
class Drafty extends Resource {
|
|
486
|
-
static override label = 'Drafty'
|
|
487
|
-
static override navigationBadge = () => 3
|
|
488
|
-
static override navigationBadgeColor = 'warning' as const
|
|
489
|
-
}
|
|
490
|
-
const info = await panelInfo(Pilotiq.make('T').path('/admin').resources([Drafty]))
|
|
491
|
-
assert.equal(info.navigation[0]!.badge, '3')
|
|
492
|
-
assert.equal(info.navigation[0]!.badgeColor, 'warning')
|
|
493
|
-
})
|
|
494
|
-
})
|
|
495
|
-
|
|
496
|
-
describe('tagFormStateUrls (Plan #5)', () => {
|
|
497
|
-
it('stamps stateUrl on forms that have at least one live() field', () => {
|
|
498
|
-
const form = Form.make().formId('f1').schema([
|
|
499
|
-
TextField.make('a'),
|
|
500
|
-
TextField.make('b').live(),
|
|
501
|
-
])
|
|
502
|
-
tagFormStateUrls([form], (id) => `/admin/x/_form/${id}/state`)
|
|
503
|
-
assert.equal(form.getStateUrl(), '/admin/x/_form/f1/state')
|
|
504
|
-
})
|
|
505
|
-
|
|
506
|
-
it('skips forms whose descendants are not live', () => {
|
|
507
|
-
const form = Form.make().formId('f2').schema([TextField.make('a')])
|
|
508
|
-
tagFormStateUrls([form], (id) => `/admin/x/_form/${id}/state`)
|
|
509
|
-
assert.equal(form.getStateUrl(), undefined)
|
|
510
|
-
})
|
|
511
|
-
|
|
512
|
-
it('walks nested containers to detect live fields', () => {
|
|
513
|
-
const form = Form.make().formId('f3').schema([
|
|
514
|
-
Section.make('s').schema([ToggleField.make('flag').live()]),
|
|
515
|
-
])
|
|
516
|
-
tagFormStateUrls([form], (id) => `/admin/x/_form/${id}/state`)
|
|
517
|
-
assert.equal(form.getStateUrl(), '/admin/x/_form/f3/state')
|
|
518
|
-
})
|
|
519
|
-
|
|
520
|
-
it('handles multiple forms independently', () => {
|
|
521
|
-
const live = Form.make().formId('live').schema([TextField.make('a').live()])
|
|
522
|
-
const inert = Form.make().formId('inert').schema([TextField.make('b')])
|
|
523
|
-
tagFormStateUrls([live, inert], (id) => `/x/${id}`)
|
|
524
|
-
assert.equal(live.getStateUrl(), '/x/live')
|
|
525
|
-
assert.equal(inert.getStateUrl(), undefined)
|
|
526
|
-
})
|
|
527
|
-
|
|
528
|
-
it('also stamps stateUrl on forms with afterStateUpdatedJs but no live()', () => {
|
|
529
|
-
// JS-only forms still need FormStateProvider mounted (so $get/$set
|
|
530
|
-
// can read + write the values map). The endpoint URL is unused for
|
|
531
|
-
// these — the client never POSTs unless a field is `live()`.
|
|
532
|
-
const form = Form.make().formId('js').schema([
|
|
533
|
-
TextField.make('title').afterStateUpdatedJs(`$set('slug', $state)`),
|
|
534
|
-
])
|
|
535
|
-
tagFormStateUrls([form], (id) => `/admin/x/_form/${id}/state`)
|
|
536
|
-
assert.equal(form.getStateUrl(), '/admin/x/_form/js/state')
|
|
537
|
-
})
|
|
538
|
-
})
|
|
539
|
-
|
|
540
|
-
describe('tagSelectCreateOptionUrls (audit row 2026-05-07 cont\'d⁸)', () => {
|
|
541
|
-
it('stamps url on SelectFields configured with createOptionForm', () => {
|
|
542
|
-
const sel = SelectField.make('categoryId').options([])
|
|
543
|
-
.createOptionForm([TextField.make('name')])
|
|
544
|
-
.createOptionUsing(async () => ({ value: '1', label: 'x' }))
|
|
545
|
-
const form = Form.make().formId('post-create').schema([sel])
|
|
546
|
-
|
|
547
|
-
tagSelectCreateOptionUrls(
|
|
548
|
-
[form],
|
|
549
|
-
(formId, fieldName) => `/admin/posts/_form/${formId}/create-option/${fieldName}`,
|
|
550
|
-
)
|
|
551
|
-
assert.equal(sel.getCreateOptionUrl(), '/admin/posts/_form/post-create/create-option/categoryId')
|
|
552
|
-
})
|
|
553
|
-
|
|
554
|
-
it('skips bare SelectFields with no createOptionForm', () => {
|
|
555
|
-
const sel = SelectField.make('status').options([{ value: 'a', label: 'A' }])
|
|
556
|
-
const form = Form.make().formId('f').schema([sel])
|
|
557
|
-
tagSelectCreateOptionUrls([form], (id, name) => `/x/${id}/${name}`)
|
|
558
|
-
assert.equal(sel.getCreateOptionUrl(), undefined)
|
|
559
|
-
})
|
|
560
|
-
|
|
561
|
-
it('walks nested layout containers', () => {
|
|
562
|
-
const sel = SelectField.make('tagId').options([])
|
|
563
|
-
.createOptionForm([TextField.make('name')])
|
|
564
|
-
.createOptionUsing(async () => ({ value: '1', label: 'x' }))
|
|
565
|
-
const form = Form.make().formId('nest').schema([
|
|
566
|
-
Section.make('Meta').schema([sel]),
|
|
567
|
-
])
|
|
568
|
-
tagSelectCreateOptionUrls(
|
|
569
|
-
[form],
|
|
570
|
-
(id, name) => `/p/_form/${id}/create-option/${name}`,
|
|
571
|
-
)
|
|
572
|
-
assert.equal(sel.getCreateOptionUrl(), '/p/_form/nest/create-option/tagId')
|
|
573
|
-
})
|
|
574
|
-
|
|
575
|
-
it('does not overwrite an already-stamped url', () => {
|
|
576
|
-
const sel = SelectField.make('a').options([])
|
|
577
|
-
.createOptionForm([TextField.make('name')])
|
|
578
|
-
.createOptionUsing(async () => ({ value: '1', label: 'x' }))
|
|
579
|
-
.withCreateOptionUrl('/preset')
|
|
580
|
-
const form = Form.make().formId('f').schema([sel])
|
|
581
|
-
tagSelectCreateOptionUrls([form], () => '/clobber')
|
|
582
|
-
assert.equal(sel.getCreateOptionUrl(), '/preset')
|
|
583
|
-
})
|
|
584
|
-
|
|
585
|
-
it('stamps url on multiple selects independently', () => {
|
|
586
|
-
const a = SelectField.make('a').options([])
|
|
587
|
-
.createOptionForm([TextField.make('n')])
|
|
588
|
-
.createOptionUsing(async () => ({ value: '1', label: 'x' }))
|
|
589
|
-
const b = SelectField.make('b').options([])
|
|
590
|
-
.createOptionForm([TextField.make('n')])
|
|
591
|
-
.createOptionUsing(async () => ({ value: '2', label: 'y' }))
|
|
592
|
-
const form = Form.make().formId('multi').schema([a, b])
|
|
593
|
-
tagSelectCreateOptionUrls(
|
|
594
|
-
[form],
|
|
595
|
-
(id, name) => `/p/${id}/${name}`,
|
|
596
|
-
)
|
|
597
|
-
assert.equal(a.getCreateOptionUrl(), '/p/multi/a')
|
|
598
|
-
assert.equal(b.getCreateOptionUrl(), '/p/multi/b')
|
|
599
|
-
})
|
|
600
|
-
|
|
601
|
-
it('stops at Repeater boundaries — inside-row SelectFields are not stamped', () => {
|
|
602
|
-
const innerSel = SelectField.make('childCat').options([])
|
|
603
|
-
.createOptionForm([TextField.make('n')])
|
|
604
|
-
.createOptionUsing(async () => ({ value: '1', label: 'x' }))
|
|
605
|
-
const outerSel = SelectField.make('rootCat').options([])
|
|
606
|
-
.createOptionForm([TextField.make('n')])
|
|
607
|
-
.createOptionUsing(async () => ({ value: '1', label: 'x' }))
|
|
608
|
-
const form = Form.make().formId('rep').schema([
|
|
609
|
-
outerSel,
|
|
610
|
-
Repeater.make('rows').schema([innerSel]),
|
|
611
|
-
])
|
|
612
|
-
tagSelectCreateOptionUrls([form], (id, name) => `/p/${id}/${name}`)
|
|
613
|
-
assert.equal(outerSel.getCreateOptionUrl(), '/p/rep/rootCat')
|
|
614
|
-
assert.equal(innerSel.getCreateOptionUrl(), undefined)
|
|
615
|
-
})
|
|
616
|
-
})
|
|
617
|
-
|
|
618
|
-
describe('formCreateOptionData (audit row 2026-05-07 cont\'d⁸)', () => {
|
|
619
|
-
function makePanelWithCreateOption(opts?: {
|
|
620
|
-
handler?: (values: Record<string, unknown>) => Promise<{ value: string; label: string }>
|
|
621
|
-
authorize?: import('./actions/Action.js').VisibilityRule
|
|
622
|
-
createForm?: Element[]
|
|
623
|
-
}) {
|
|
624
|
-
const handler = opts?.handler ?? (async (v: Record<string, unknown>) => ({ value: 'new-id', label: String(v['name']) }))
|
|
625
|
-
const createForm = opts?.createForm ?? [TextField.make('name')]
|
|
626
|
-
|
|
627
|
-
class DemoPage extends Page {
|
|
628
|
-
static override slug = 'demo'
|
|
629
|
-
static override async schema() {
|
|
630
|
-
const sel = SelectField.make('categoryId').options([])
|
|
631
|
-
.createOptionForm(createForm)
|
|
632
|
-
.createOptionUsing(handler)
|
|
633
|
-
if (opts?.authorize !== undefined) sel.createOptionAuthorize(opts.authorize)
|
|
634
|
-
// Two forms on the page so `selectFormById` doesn't fall back to
|
|
635
|
-
// the only form (single-form fallback is intentional for SPA
|
|
636
|
-
// edge cases — strict-match tests need 2+ forms).
|
|
637
|
-
return [
|
|
638
|
-
Form.make().formId('the-form').schema([sel]),
|
|
639
|
-
Form.make().formId('other-form').schema([TextField.make('decoy')]),
|
|
640
|
-
]
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
return Pilotiq.make('T').path('/admin').pages([DemoPage])
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
it('returns null when route prefix does not resolve', async () => {
|
|
647
|
-
const panel = Pilotiq.make('T').path('/admin')
|
|
648
|
-
const result = await formCreateOptionData(
|
|
649
|
-
panel,
|
|
650
|
-
{ kind: 'page', pageSlug: 'no-such-page' },
|
|
651
|
-
{ formId: 'x', fieldName: 'y', values: {} },
|
|
652
|
-
)
|
|
653
|
-
assert.equal(result, null)
|
|
654
|
-
})
|
|
655
|
-
|
|
656
|
-
it('returns 404 when form is not found on page', async () => {
|
|
657
|
-
const panel = makePanelWithCreateOption()
|
|
658
|
-
const result = await formCreateOptionData(
|
|
659
|
-
panel,
|
|
660
|
-
{ kind: 'page', pageSlug: 'demo' },
|
|
661
|
-
{ formId: 'wrong-form', fieldName: 'categoryId', values: { name: 'X' } },
|
|
662
|
-
)
|
|
663
|
-
assert.deepEqual(result, { ok: false, status: 404, error: 'Form "wrong-form" not found on page' })
|
|
664
|
-
})
|
|
665
|
-
|
|
666
|
-
it('returns 404 when SelectField is not found on form', async () => {
|
|
667
|
-
const panel = makePanelWithCreateOption()
|
|
668
|
-
const result = await formCreateOptionData(
|
|
669
|
-
panel,
|
|
670
|
-
{ kind: 'page', pageSlug: 'demo' },
|
|
671
|
-
{ formId: 'the-form', fieldName: 'unknownField', values: {} },
|
|
672
|
-
)
|
|
673
|
-
const r = result as { ok: false; status: number; error: string }
|
|
674
|
-
assert.equal(r.ok, false)
|
|
675
|
-
assert.equal(r.status, 404)
|
|
676
|
-
assert.match(r.error, /not found on form/)
|
|
677
|
-
})
|
|
678
|
-
|
|
679
|
-
it('returns 403 when authorize rule rejects', async () => {
|
|
680
|
-
const panel = makePanelWithCreateOption({ authorize: false })
|
|
681
|
-
const result = await formCreateOptionData(
|
|
682
|
-
panel,
|
|
683
|
-
{ kind: 'page', pageSlug: 'demo' },
|
|
684
|
-
{ formId: 'the-form', fieldName: 'categoryId', values: { name: 'X' } },
|
|
685
|
-
)
|
|
686
|
-
assert.deepEqual(result, { ok: false, status: 403, error: 'createOptionAuthorize denied' })
|
|
687
|
-
})
|
|
688
|
-
|
|
689
|
-
it('returns 422 when validation fails', async () => {
|
|
690
|
-
const panel = makePanelWithCreateOption({
|
|
691
|
-
createForm: [TextField.make('name').required()],
|
|
692
|
-
})
|
|
693
|
-
const result = await formCreateOptionData(
|
|
694
|
-
panel,
|
|
695
|
-
{ kind: 'page', pageSlug: 'demo' },
|
|
696
|
-
{ formId: 'the-form', fieldName: 'categoryId', values: { name: '' } },
|
|
697
|
-
)
|
|
698
|
-
const r = result as { ok: false; status: number; errors: Record<string, string[]> }
|
|
699
|
-
assert.equal(r.ok, false)
|
|
700
|
-
assert.equal(r.status, 422)
|
|
701
|
-
assert.ok(r.errors['name'])
|
|
702
|
-
})
|
|
703
|
-
|
|
704
|
-
it('returns 200 + option on happy path', async () => {
|
|
705
|
-
const panel = makePanelWithCreateOption()
|
|
706
|
-
const result = await formCreateOptionData(
|
|
707
|
-
panel,
|
|
708
|
-
{ kind: 'page', pageSlug: 'demo' },
|
|
709
|
-
{ formId: 'the-form', fieldName: 'categoryId', values: { name: 'Tech' } },
|
|
710
|
-
)
|
|
711
|
-
assert.deepEqual(result, { ok: true, option: { value: 'new-id', label: 'Tech' } })
|
|
712
|
-
})
|
|
713
|
-
|
|
714
|
-
it('returns 500 when handler throws', async () => {
|
|
715
|
-
const panel = makePanelWithCreateOption({
|
|
716
|
-
handler: async () => { throw new Error('boom') },
|
|
717
|
-
})
|
|
718
|
-
const result = await formCreateOptionData(
|
|
719
|
-
panel,
|
|
720
|
-
{ kind: 'page', pageSlug: 'demo' },
|
|
721
|
-
{ formId: 'the-form', fieldName: 'categoryId', values: { name: 'X' } },
|
|
722
|
-
)
|
|
723
|
-
assert.deepEqual(result, { ok: false, status: 500, error: 'boom' })
|
|
724
|
-
})
|
|
725
|
-
|
|
726
|
-
it('returns 500 when handler returns malformed shape', async () => {
|
|
727
|
-
const panel = makePanelWithCreateOption({
|
|
728
|
-
// @ts-expect-error testing runtime shape guard
|
|
729
|
-
handler: async () => ({ value: 'x' /* missing label */ }),
|
|
730
|
-
})
|
|
731
|
-
const result = await formCreateOptionData(
|
|
732
|
-
panel,
|
|
733
|
-
{ kind: 'page', pageSlug: 'demo' },
|
|
734
|
-
{ formId: 'the-form', fieldName: 'categoryId', values: { name: 'X' } },
|
|
735
|
-
)
|
|
736
|
-
const r = result as { ok: false; status: number; error: string }
|
|
737
|
-
assert.equal(r.ok, false)
|
|
738
|
-
assert.equal(r.status, 500)
|
|
739
|
-
assert.match(r.error, /\{ value: string, label: string \}/)
|
|
740
|
-
})
|
|
741
|
-
})
|
|
742
|
-
|
|
743
|
-
/**
|
|
744
|
-
* Minimal duck-typed RichTextField stand-in. The walker uses
|
|
745
|
-
* `getType() === 'richtext'` + `hasAsyncMentions` + `withMentionsUrl` —
|
|
746
|
-
* matching the same shape `@pilotiq/tiptap`'s real `RichTextField`
|
|
747
|
-
* exposes. Pilotiq core never imports the adapter; the walker contract
|
|
748
|
-
* has to be testable with a plain `Element` subclass.
|
|
749
|
-
*/
|
|
750
|
-
class FakeRichTextField extends Element {
|
|
751
|
-
readonly name: string
|
|
752
|
-
private readonly _hasAsync: boolean
|
|
753
|
-
public stamped: string | undefined = undefined
|
|
754
|
-
|
|
755
|
-
constructor(name: string, hasAsync: boolean) {
|
|
756
|
-
super()
|
|
757
|
-
this.name = name
|
|
758
|
-
this._hasAsync = hasAsync
|
|
759
|
-
}
|
|
760
|
-
override getType(): string { return 'richtext' }
|
|
761
|
-
override toMeta(): Record<string, unknown> {
|
|
762
|
-
return {
|
|
763
|
-
type: 'field', fieldType: 'richtext', name: this.name,
|
|
764
|
-
...(this.stamped !== undefined ? { mentionsUrl: this.stamped } : {}),
|
|
765
|
-
}
|
|
766
|
-
}
|
|
767
|
-
hasAsyncMentions(): boolean { return this._hasAsync }
|
|
768
|
-
withMentionsUrl(url: string): this { this.stamped = url; return this }
|
|
769
|
-
async resolveMention(
|
|
770
|
-
trigger: string,
|
|
771
|
-
query: string,
|
|
772
|
-
_ctx: Record<string, unknown>,
|
|
773
|
-
): Promise<Array<{ id: string; label: string }> | null> {
|
|
774
|
-
if (trigger === '@') return [{ id: query, label: `User:${query}` }]
|
|
775
|
-
return null
|
|
776
|
-
}
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
describe('tagRichTextMentionUrls (async mention items)', () => {
|
|
780
|
-
it('stamps mentionsUrl on RichTextFields with async providers', () => {
|
|
781
|
-
const f = new FakeRichTextField('body', true)
|
|
782
|
-
const form = Form.make().formId('art').schema([f])
|
|
783
|
-
tagRichTextMentionUrls([form], (id) => `/admin/articles/_form/${id}/mentions`)
|
|
784
|
-
assert.equal(f.stamped, '/admin/articles/_form/art/mentions')
|
|
785
|
-
})
|
|
786
|
-
|
|
787
|
-
it('skips RichTextFields with only static providers', () => {
|
|
788
|
-
const staticField = new FakeRichTextField('body', false)
|
|
789
|
-
const form = Form.make().formId('art').schema([staticField])
|
|
790
|
-
tagRichTextMentionUrls([form], (id) => `/x/${id}`)
|
|
791
|
-
assert.equal(staticField.stamped, undefined)
|
|
792
|
-
})
|
|
793
|
-
|
|
794
|
-
it('walks nested containers to find rich-text fields', () => {
|
|
795
|
-
const inner = new FakeRichTextField('body', true)
|
|
796
|
-
const form = Form.make().formId('art').schema([
|
|
797
|
-
Section.make('s').schema([inner]),
|
|
798
|
-
])
|
|
799
|
-
tagRichTextMentionUrls([form], (id) => `/admin/_form/${id}/mentions`)
|
|
800
|
-
assert.equal(inner.stamped, '/admin/_form/art/mentions')
|
|
801
|
-
})
|
|
802
|
-
|
|
803
|
-
it('handles multiple forms — each gets its own URL', () => {
|
|
804
|
-
const a = new FakeRichTextField('body', true)
|
|
805
|
-
const b = new FakeRichTextField('body', true)
|
|
806
|
-
const formA = Form.make().formId('a').schema([a])
|
|
807
|
-
const formB = Form.make().formId('b').schema([b])
|
|
808
|
-
tagRichTextMentionUrls([formA, formB], (id) => `/x/_form/${id}/mentions`)
|
|
809
|
-
assert.equal(a.stamped, '/x/_form/a/mentions')
|
|
810
|
-
assert.equal(b.stamped, '/x/_form/b/mentions')
|
|
811
|
-
})
|
|
812
|
-
|
|
813
|
-
it('skips non-richtext elements that share method names by accident', () => {
|
|
814
|
-
// The fast filter `getType() === 'richtext'` keeps a coincidental
|
|
815
|
-
// duck-type collision (e.g. someone naming a custom element with
|
|
816
|
-
// `withMentionsUrl`) from being mistakenly stamped.
|
|
817
|
-
class WrongType extends Element {
|
|
818
|
-
stamped: string | undefined = undefined
|
|
819
|
-
override getType(): string { return 'custom' }
|
|
820
|
-
override toMeta(): Record<string, unknown> { return { type: 'custom' } }
|
|
821
|
-
hasAsyncMentions(): boolean { return true }
|
|
822
|
-
withMentionsUrl(url: string): this { this.stamped = url; return this }
|
|
823
|
-
}
|
|
824
|
-
const wrong = new WrongType()
|
|
825
|
-
const form = Form.make().formId('f').schema([wrong as unknown as Element])
|
|
826
|
-
tagRichTextMentionUrls([form], (id) => `/x/${id}`)
|
|
827
|
-
assert.equal(wrong.stamped, undefined)
|
|
828
|
-
})
|
|
829
|
-
})
|
|
830
|
-
|
|
831
|
-
describe('tagTableReorderUrls (reorderable rows)', () => {
|
|
832
|
-
it('stamps reorderUrl on tables with reorderable() opted in', () => {
|
|
833
|
-
const t = Table.make().reorderable('sort').columns([Column.make('id')])
|
|
834
|
-
tagTableReorderUrls([t], '/admin/posts/_reorder')
|
|
835
|
-
assert.equal(t.getReorderUrl(), '/admin/posts/_reorder')
|
|
836
|
-
})
|
|
837
|
-
|
|
838
|
-
it('skips tables without reorderable()', () => {
|
|
839
|
-
const t = Table.make().columns([Column.make('id')])
|
|
840
|
-
tagTableReorderUrls([t], '/admin/posts/_reorder')
|
|
841
|
-
assert.equal(t.getReorderUrl(), undefined)
|
|
842
|
-
})
|
|
843
|
-
|
|
844
|
-
it('preserves a previously stamped URL (idempotent)', () => {
|
|
845
|
-
const t = Table.make().reorderable('sort').withReorderUrl('/x/_reorder')
|
|
846
|
-
tagTableReorderUrls([t], '/y/_reorder')
|
|
847
|
-
assert.equal(t.getReorderUrl(), '/x/_reorder')
|
|
848
|
-
})
|
|
849
|
-
})
|
|
850
|
-
|
|
851
|
-
describe('tagCellEditUrls (editable columns)', () => {
|
|
852
|
-
it('stamps _cellEditUrls only on rows that already carry _cellEditable', () => {
|
|
853
|
-
const t = Table.make<Record<string, unknown>>()
|
|
854
|
-
.columns([Column.make('id'), TextInputColumn.make('title')])
|
|
855
|
-
.withRows([
|
|
856
|
-
{ id: '1', _cellEditable: { title: true } },
|
|
857
|
-
{ id: '2' /* canEdit was false — no editable map */ },
|
|
858
|
-
], 2)
|
|
859
|
-
|
|
860
|
-
tagCellEditUrls([t], '/admin/posts')
|
|
861
|
-
const rows = t.getRows() as Array<Record<string, unknown>>
|
|
862
|
-
assert.deepEqual(rows[0]!['_cellEditUrls'], { title: '/admin/posts/1/_cell/title' })
|
|
863
|
-
assert.equal(rows[1]!['_cellEditUrls'], undefined)
|
|
864
|
-
})
|
|
865
|
-
|
|
866
|
-
it('skips tables that have no editable columns', () => {
|
|
867
|
-
const t = Table.make<Record<string, unknown>>()
|
|
868
|
-
.columns([Column.make('id')])
|
|
869
|
-
.withRows([{ id: '1' }], 1)
|
|
870
|
-
|
|
871
|
-
tagCellEditUrls([t], '/admin/posts')
|
|
872
|
-
const rows = t.getRows() as Array<Record<string, unknown>>
|
|
873
|
-
assert.equal(rows[0]!['_cellEditUrls'], undefined)
|
|
874
|
-
})
|
|
875
|
-
|
|
876
|
-
it('builds a URL per editable column on the row', () => {
|
|
877
|
-
const t = Table.make<Record<string, unknown>>()
|
|
878
|
-
.columns([
|
|
879
|
-
Column.make('id'),
|
|
880
|
-
TextInputColumn.make('title'),
|
|
881
|
-
ToggleColumn.make('featured'),
|
|
882
|
-
])
|
|
883
|
-
.withRows([
|
|
884
|
-
{ id: '7', _cellEditable: { title: true, featured: true } },
|
|
885
|
-
], 1)
|
|
886
|
-
|
|
887
|
-
tagCellEditUrls([t], '/admin/posts')
|
|
888
|
-
const rows = t.getRows() as Array<Record<string, unknown>>
|
|
889
|
-
assert.deepEqual(rows[0]!['_cellEditUrls'], {
|
|
890
|
-
title: '/admin/posts/7/_cell/title',
|
|
891
|
-
featured: '/admin/posts/7/_cell/featured',
|
|
892
|
-
})
|
|
893
|
-
})
|
|
894
|
-
|
|
895
|
-
it('encodes the row id and column name', () => {
|
|
896
|
-
const t = Table.make<Record<string, unknown>>()
|
|
897
|
-
.columns([Column.make('id'), TextInputColumn.make('weird name')])
|
|
898
|
-
.withRows([
|
|
899
|
-
{ id: 'a/b', _cellEditable: { 'weird name': true } },
|
|
900
|
-
], 1)
|
|
901
|
-
|
|
902
|
-
tagCellEditUrls([t], '/admin/posts')
|
|
903
|
-
const rows = t.getRows() as Array<Record<string, unknown>>
|
|
904
|
-
assert.deepEqual(rows[0]!['_cellEditUrls'], {
|
|
905
|
-
'weird name': '/admin/posts/a%2Fb/_cell/weird%20name',
|
|
906
|
-
})
|
|
907
|
-
})
|
|
908
|
-
})
|
|
909
|
-
|
|
910
|
-
describe('formStateData (Plan #5)', () => {
|
|
911
|
-
it('returns null when the page-scope is unknown', async () => {
|
|
912
|
-
class Articles extends Resource {
|
|
913
|
-
static override label = 'Articles'
|
|
914
|
-
}
|
|
915
|
-
const panel = Pilotiq.make('T').path('/admin').resources([Articles])
|
|
916
|
-
const result = await formStateData(panel, { kind: 'resource-edit', slug: 'missing', recordId: '1' }, { formId: 'f', changed: 'x', values: {} })
|
|
917
|
-
assert.equal(result, null)
|
|
918
|
-
})
|
|
919
|
-
|
|
920
|
-
it('returns 404 when the form id misses on a multi-form page', async () => {
|
|
921
|
-
class TestPage extends Page {
|
|
922
|
-
static override slug = 'demo'
|
|
923
|
-
static override schema() {
|
|
924
|
-
return [
|
|
925
|
-
Form.make().formId('one').schema([TextField.make('x').live()]),
|
|
926
|
-
Form.make().formId('two').schema([TextField.make('y').live()]),
|
|
927
|
-
]
|
|
928
|
-
}
|
|
929
|
-
}
|
|
930
|
-
const panel = Pilotiq.make('T').path('/admin').pages([TestPage])
|
|
931
|
-
const result = await formStateData(panel, { kind: 'page', pageSlug: 'demo' }, { formId: 'wrong-id', changed: 'x', values: { x: 'v' } })
|
|
932
|
-
assert.notEqual(result, null)
|
|
933
|
-
assert.equal((result as { ok: false; status: number }).ok, false)
|
|
934
|
-
assert.equal((result as { ok: false; status: number }).status, 404)
|
|
935
|
-
})
|
|
936
|
-
|
|
937
|
-
it('falls back to the only form when the formId misses on a single-form page', async () => {
|
|
938
|
-
// Removes the auto-counter desync footgun for reactive demos —
|
|
939
|
-
// see selectFormById in elements/dispatchForm.ts.
|
|
940
|
-
class TestPage extends Page {
|
|
941
|
-
static override slug = 'demo'
|
|
942
|
-
static override schema() {
|
|
943
|
-
return [Form.make().formId('the-form').schema([TextField.make('x').live()])]
|
|
944
|
-
}
|
|
945
|
-
}
|
|
946
|
-
const panel = Pilotiq.make('T').path('/admin').pages([TestPage])
|
|
947
|
-
const result = await formStateData(panel, { kind: 'page', pageSlug: 'demo' }, { formId: 'mismatched-counter', changed: 'x', values: { x: 'v' } })
|
|
948
|
-
assert.notEqual(result, null)
|
|
949
|
-
assert.equal((result as { ok: true }).ok, true)
|
|
950
|
-
})
|
|
951
|
-
|
|
952
|
-
it('returns 422 when the changed field does not exist on the form', async () => {
|
|
953
|
-
class TestPage extends Page {
|
|
954
|
-
static override slug = 'demo'
|
|
955
|
-
static override schema() {
|
|
956
|
-
return [Form.make().formId('the-form').schema([TextField.make('x').live()])]
|
|
957
|
-
}
|
|
958
|
-
}
|
|
959
|
-
const panel = Pilotiq.make('T').path('/admin').pages([TestPage])
|
|
960
|
-
const result = await formStateData(panel, { kind: 'page', pageSlug: 'demo' }, { formId: 'the-form', changed: 'missing', values: {} })
|
|
961
|
-
assert.notEqual(result, null)
|
|
962
|
-
assert.equal((result as { ok: false; status: number }).ok, false)
|
|
963
|
-
assert.equal((result as { ok: false; status: number }).status, 422)
|
|
964
|
-
})
|
|
965
|
-
|
|
966
|
-
it('runs afterStateUpdated and returns the resolved form meta', async () => {
|
|
967
|
-
class TestPage extends Page {
|
|
968
|
-
static override slug = 'demo'
|
|
969
|
-
static override schema() {
|
|
970
|
-
return [Form.make().formId('the-form').schema([
|
|
971
|
-
TextField.make('title').live().afterStateUpdated((value, { $set }) => {
|
|
972
|
-
$set('slug', String(value).toLowerCase().replace(/\s+/g, '-'))
|
|
973
|
-
}),
|
|
974
|
-
TextField.make('slug'),
|
|
975
|
-
])]
|
|
976
|
-
}
|
|
977
|
-
}
|
|
978
|
-
const panel = Pilotiq.make('T').path('/admin').pages([TestPage])
|
|
979
|
-
const result = await formStateData(
|
|
980
|
-
panel,
|
|
981
|
-
{ kind: 'page', pageSlug: 'demo' },
|
|
982
|
-
{ formId: 'the-form', changed: 'title', values: { title: 'Hello World', slug: 'old' } },
|
|
983
|
-
)
|
|
984
|
-
assert.notEqual(result, null)
|
|
985
|
-
if (result === null || !result.ok) throw new Error('expected ok result')
|
|
986
|
-
assert.deepEqual(result.dirty.sort(), ['slug', 'title'])
|
|
987
|
-
const formMeta = result.form as { values?: Record<string, unknown> }
|
|
988
|
-
assert.equal(formMeta.values?.['slug'], 'hello-world')
|
|
989
|
-
})
|
|
990
|
-
|
|
991
|
-
// Regression lock — reactive `itemHidden` end-to-end. Server-side resolve
|
|
992
|
-
// alone is covered in `RepeaterField.test.ts` / `BuilderField.test.ts`, and
|
|
993
|
-
// the client-side row-gate sync is covered in `syncRowGates.test.ts`. This
|
|
994
|
-
// covers the wire between them: applyStateUpdate of a row-leaf dotted
|
|
995
|
-
// path, then full resolveSchema, with the `itemHidden` rule reading the
|
|
996
|
-
// updated row value. If this regresses, peer A typing into a `live()`
|
|
997
|
-
// inner field would never flip the row's chrome on a real form.
|
|
998
|
-
|
|
999
|
-
it('re-evaluates Repeater itemHidden after a live() inner-leaf cycle', async () => {
|
|
1000
|
-
class TestPage extends Page {
|
|
1001
|
-
static override slug = 'demo'
|
|
1002
|
-
static override schema() {
|
|
1003
|
-
return [Form.make().formId('the-form').schema([
|
|
1004
|
-
Repeater.make('items')
|
|
1005
|
-
.schema([
|
|
1006
|
-
TextField.make('mode').live(),
|
|
1007
|
-
TextField.make('label'),
|
|
1008
|
-
])
|
|
1009
|
-
.itemHidden(({ values }) => (values as Record<string, unknown>)['mode'] === 'hidden'),
|
|
1010
|
-
])]
|
|
1011
|
-
}
|
|
1012
|
-
}
|
|
1013
|
-
const panel = Pilotiq.make('T').path('/admin').pages([TestPage])
|
|
1014
|
-
|
|
1015
|
-
// Before: row is visible.
|
|
1016
|
-
const visible = await formStateData(
|
|
1017
|
-
panel,
|
|
1018
|
-
{ kind: 'page', pageSlug: 'demo' },
|
|
1019
|
-
{ formId: 'the-form', changed: 'items.0.mode', values: { items: [{ mode: 'visible', label: 'one' }] } },
|
|
1020
|
-
)
|
|
1021
|
-
if (visible === null || !visible.ok) throw new Error('expected ok result')
|
|
1022
|
-
const visibleMeta = visible.form as { children: Array<{ rows: Array<{ id: string; hidden?: boolean }> }> }
|
|
1023
|
-
assert.equal(visibleMeta.children[0]?.rows[0]?.hidden, undefined)
|
|
1024
|
-
|
|
1025
|
-
// After: same row, `mode` flipped to `'hidden'` — itemHidden re-evaluates.
|
|
1026
|
-
const hidden = await formStateData(
|
|
1027
|
-
panel,
|
|
1028
|
-
{ kind: 'page', pageSlug: 'demo' },
|
|
1029
|
-
{ formId: 'the-form', changed: 'items.0.mode', values: { items: [{ mode: 'hidden', label: 'one' }] } },
|
|
1030
|
-
)
|
|
1031
|
-
if (hidden === null || !hidden.ok) throw new Error('expected ok result')
|
|
1032
|
-
const hiddenMeta = hidden.form as { children: Array<{ rows: Array<{ id: string; hidden?: boolean }> }> }
|
|
1033
|
-
assert.equal(hiddenMeta.children[0]?.rows[0]?.hidden, true)
|
|
1034
|
-
// Row id stays stable across the cycle — syncRowGates on the client
|
|
1035
|
-
// matches on `id`, so an unstable id would silently skip the hidden
|
|
1036
|
-
// flip.
|
|
1037
|
-
assert.equal(hiddenMeta.children[0]?.rows[0]?.id, visibleMeta.children[0]?.rows[0]?.id)
|
|
1038
|
-
})
|
|
1039
|
-
|
|
1040
|
-
it('re-evaluates Builder itemHidden after a live() block-leaf cycle', async () => {
|
|
1041
|
-
class TestPage extends Page {
|
|
1042
|
-
static override slug = 'demo'
|
|
1043
|
-
static override schema() {
|
|
1044
|
-
return [Form.make().formId('the-form').schema([
|
|
1045
|
-
Builder.make('content')
|
|
1046
|
-
.blocks([
|
|
1047
|
-
Block.make('heading').schema([
|
|
1048
|
-
TextField.make('text').live(),
|
|
1049
|
-
TextField.make('anchor'),
|
|
1050
|
-
]),
|
|
1051
|
-
])
|
|
1052
|
-
.itemHidden(({ values }) => (values as Record<string, unknown>)['text'] === 'skip'),
|
|
1053
|
-
])]
|
|
1054
|
-
}
|
|
1055
|
-
}
|
|
1056
|
-
const panel = Pilotiq.make('T').path('/admin').pages([TestPage])
|
|
1057
|
-
|
|
1058
|
-
const keep = await formStateData(
|
|
1059
|
-
panel,
|
|
1060
|
-
{ kind: 'page', pageSlug: 'demo' },
|
|
1061
|
-
{
|
|
1062
|
-
formId: 'the-form',
|
|
1063
|
-
changed: 'content.0.data.text',
|
|
1064
|
-
values: { content: [{ type: 'heading', data: { text: 'keep', anchor: '' } }] },
|
|
1065
|
-
},
|
|
1066
|
-
)
|
|
1067
|
-
if (keep === null || !keep.ok) throw new Error('expected ok result')
|
|
1068
|
-
const keepMeta = keep.form as { children: Array<{ rows: Array<{ id: string; hidden?: boolean }> }> }
|
|
1069
|
-
assert.equal(keepMeta.children[0]?.rows[0]?.hidden, undefined)
|
|
1070
|
-
|
|
1071
|
-
const skip = await formStateData(
|
|
1072
|
-
panel,
|
|
1073
|
-
{ kind: 'page', pageSlug: 'demo' },
|
|
1074
|
-
{
|
|
1075
|
-
formId: 'the-form',
|
|
1076
|
-
changed: 'content.0.data.text',
|
|
1077
|
-
values: { content: [{ type: 'heading', data: { text: 'skip', anchor: '' } }] },
|
|
1078
|
-
},
|
|
1079
|
-
)
|
|
1080
|
-
if (skip === null || !skip.ok) throw new Error('expected ok result')
|
|
1081
|
-
const skipMeta = skip.form as { children: Array<{ rows: Array<{ id: string; hidden?: boolean }> }> }
|
|
1082
|
-
assert.equal(skipMeta.children[0]?.rows[0]?.hidden, true)
|
|
1083
|
-
assert.equal(skipMeta.children[0]?.rows[0]?.id, keepMeta.children[0]?.rows[0]?.id)
|
|
1084
|
-
})
|
|
1085
|
-
})
|
|
1086
|
-
|
|
1087
|
-
describe('mentionResolveData (async mention items)', () => {
|
|
1088
|
-
it('returns null when the page scope misses', async () => {
|
|
1089
|
-
class Articles extends Resource {
|
|
1090
|
-
static override label = 'Articles'
|
|
1091
|
-
}
|
|
1092
|
-
const panel = Pilotiq.make('T').path('/admin').resources([Articles])
|
|
1093
|
-
const result = await mentionResolveData(
|
|
1094
|
-
panel,
|
|
1095
|
-
{ kind: 'resource-edit', slug: 'missing', recordId: '1' },
|
|
1096
|
-
{ formId: 'f', field: 'body', trigger: '@', query: 'a' },
|
|
1097
|
-
)
|
|
1098
|
-
assert.equal(result, null)
|
|
1099
|
-
})
|
|
1100
|
-
|
|
1101
|
-
it('returns 404 when the form id misses on a multi-form page', async () => {
|
|
1102
|
-
class TestPage extends Page {
|
|
1103
|
-
static override slug = 'demo'
|
|
1104
|
-
static override schema() {
|
|
1105
|
-
return [
|
|
1106
|
-
Form.make().formId('one').schema([new FakeRichTextField('body', true)]),
|
|
1107
|
-
Form.make().formId('two').schema([TextField.make('a')]),
|
|
1108
|
-
]
|
|
1109
|
-
}
|
|
1110
|
-
}
|
|
1111
|
-
const panel = Pilotiq.make('T').path('/admin').pages([TestPage])
|
|
1112
|
-
const result = await mentionResolveData(
|
|
1113
|
-
panel,
|
|
1114
|
-
{ kind: 'page', pageSlug: 'demo' },
|
|
1115
|
-
{ formId: 'wrong', field: 'body', trigger: '@', query: 'a' },
|
|
1116
|
-
)
|
|
1117
|
-
assert.notEqual(result, null)
|
|
1118
|
-
if (result === null) throw new Error('expected non-null result')
|
|
1119
|
-
assert.equal((result as { ok: false; status: number }).ok, false)
|
|
1120
|
-
assert.equal((result as { ok: false; status: number }).status, 404)
|
|
1121
|
-
})
|
|
1122
|
-
|
|
1123
|
-
it('returns 404 when the field is not on the form', async () => {
|
|
1124
|
-
class TestPage extends Page {
|
|
1125
|
-
static override slug = 'demo'
|
|
1126
|
-
static override schema() {
|
|
1127
|
-
return [Form.make().formId('the-form').schema([new FakeRichTextField('intro', true)])]
|
|
1128
|
-
}
|
|
1129
|
-
}
|
|
1130
|
-
const panel = Pilotiq.make('T').path('/admin').pages([TestPage])
|
|
1131
|
-
const result = await mentionResolveData(
|
|
1132
|
-
panel,
|
|
1133
|
-
{ kind: 'page', pageSlug: 'demo' },
|
|
1134
|
-
{ formId: 'the-form', field: 'body', trigger: '@', query: 'a' },
|
|
1135
|
-
)
|
|
1136
|
-
assert.notEqual(result, null)
|
|
1137
|
-
assert.equal((result as { ok: false; status: number }).ok, false)
|
|
1138
|
-
assert.equal((result as { ok: false; status: number }).status, 404)
|
|
1139
|
-
})
|
|
1140
|
-
|
|
1141
|
-
it('returns 404 when the trigger has no provider', async () => {
|
|
1142
|
-
class TestPage extends Page {
|
|
1143
|
-
static override slug = 'demo'
|
|
1144
|
-
static override schema() {
|
|
1145
|
-
return [Form.make().formId('the-form').schema([new FakeRichTextField('body', true)])]
|
|
1146
|
-
}
|
|
1147
|
-
}
|
|
1148
|
-
const panel = Pilotiq.make('T').path('/admin').pages([TestPage])
|
|
1149
|
-
const result = await mentionResolveData(
|
|
1150
|
-
panel,
|
|
1151
|
-
{ kind: 'page', pageSlug: 'demo' },
|
|
1152
|
-
{ formId: 'the-form', field: 'body', trigger: '!', query: 'a' },
|
|
1153
|
-
)
|
|
1154
|
-
assert.notEqual(result, null)
|
|
1155
|
-
if (result === null) throw new Error('expected non-null result')
|
|
1156
|
-
assert.equal((result as { ok: false; status: number }).ok, false)
|
|
1157
|
-
assert.equal((result as { ok: false; status: number }).status, 404)
|
|
1158
|
-
})
|
|
1159
|
-
|
|
1160
|
-
it('returns the resolved items for a known trigger', async () => {
|
|
1161
|
-
class TestPage extends Page {
|
|
1162
|
-
static override slug = 'demo'
|
|
1163
|
-
static override schema() {
|
|
1164
|
-
return [Form.make().formId('the-form').schema([new FakeRichTextField('body', true)])]
|
|
1165
|
-
}
|
|
1166
|
-
}
|
|
1167
|
-
const panel = Pilotiq.make('T').path('/admin').pages([TestPage])
|
|
1168
|
-
const result = await mentionResolveData(
|
|
1169
|
-
panel,
|
|
1170
|
-
{ kind: 'page', pageSlug: 'demo' },
|
|
1171
|
-
{ formId: 'the-form', field: 'body', trigger: '@', query: 'sleman' },
|
|
1172
|
-
)
|
|
1173
|
-
assert.notEqual(result, null)
|
|
1174
|
-
if (result === null || !result.ok) throw new Error('expected ok result')
|
|
1175
|
-
assert.equal(result.items.length, 1)
|
|
1176
|
-
assert.equal(result.items[0]!.id, 'sleman')
|
|
1177
|
-
assert.equal(result.items[0]!.label, 'User:sleman')
|
|
1178
|
-
})
|
|
1179
|
-
|
|
1180
|
-
it('resolves a RichTextField nested inside a Repeater row via dotted path', async () => {
|
|
1181
|
-
class TestPage extends Page {
|
|
1182
|
-
static override slug = 'demo'
|
|
1183
|
-
static override schema() {
|
|
1184
|
-
return [Form.make().formId('the-form').schema([
|
|
1185
|
-
Repeater.make('items').schema([new FakeRichTextField('body', true)]),
|
|
1186
|
-
])]
|
|
1187
|
-
}
|
|
1188
|
-
}
|
|
1189
|
-
const panel = Pilotiq.make('T').path('/admin').pages([TestPage])
|
|
1190
|
-
const result = await mentionResolveData(
|
|
1191
|
-
panel,
|
|
1192
|
-
{ kind: 'page', pageSlug: 'demo' },
|
|
1193
|
-
{ formId: 'the-form', field: 'items.0.body', trigger: '@', query: 'sleman' },
|
|
1194
|
-
)
|
|
1195
|
-
assert.notEqual(result, null)
|
|
1196
|
-
if (result === null || !result.ok) throw new Error('expected ok result')
|
|
1197
|
-
assert.equal(result.items[0]!.id, 'sleman')
|
|
1198
|
-
})
|
|
1199
|
-
|
|
1200
|
-
it('resolves a RichTextField nested inside a Builder block via dotted path', async () => {
|
|
1201
|
-
class TestPage extends Page {
|
|
1202
|
-
static override slug = 'demo'
|
|
1203
|
-
static override schema() {
|
|
1204
|
-
return [Form.make().formId('the-form').schema([
|
|
1205
|
-
Builder.make('blocks').blocks([
|
|
1206
|
-
Block.make('callout').schema([new FakeRichTextField('body', true)]),
|
|
1207
|
-
]),
|
|
1208
|
-
])]
|
|
1209
|
-
}
|
|
1210
|
-
}
|
|
1211
|
-
const panel = Pilotiq.make('T').path('/admin').pages([TestPage])
|
|
1212
|
-
const result = await mentionResolveData(
|
|
1213
|
-
panel,
|
|
1214
|
-
{ kind: 'page', pageSlug: 'demo' },
|
|
1215
|
-
{ formId: 'the-form', field: 'blocks.0.data.body', trigger: '@', query: 'sleman' },
|
|
1216
|
-
)
|
|
1217
|
-
assert.notEqual(result, null)
|
|
1218
|
-
if (result === null || !result.ok) throw new Error('expected ok result')
|
|
1219
|
-
assert.equal(result.items[0]!.id, 'sleman')
|
|
1220
|
-
})
|
|
1221
|
-
|
|
1222
|
-
it('returns 404 when a Repeater dotted path does not match any inner field', async () => {
|
|
1223
|
-
class TestPage extends Page {
|
|
1224
|
-
static override slug = 'demo'
|
|
1225
|
-
static override schema() {
|
|
1226
|
-
return [Form.make().formId('the-form').schema([
|
|
1227
|
-
Repeater.make('items').schema([new FakeRichTextField('body', true)]),
|
|
1228
|
-
])]
|
|
1229
|
-
}
|
|
1230
|
-
}
|
|
1231
|
-
const panel = Pilotiq.make('T').path('/admin').pages([TestPage])
|
|
1232
|
-
const result = await mentionResolveData(
|
|
1233
|
-
panel,
|
|
1234
|
-
{ kind: 'page', pageSlug: 'demo' },
|
|
1235
|
-
{ formId: 'the-form', field: 'items.0.missing', trigger: '@', query: 'a' },
|
|
1236
|
-
)
|
|
1237
|
-
assert.notEqual(result, null)
|
|
1238
|
-
assert.equal((result as { ok: false; status: number }).ok, false)
|
|
1239
|
-
assert.equal((result as { ok: false; status: number }).status, 404)
|
|
1240
|
-
})
|
|
1241
|
-
|
|
1242
|
-
it('returns 404 for a Builder path missing the literal `data` segment', async () => {
|
|
1243
|
-
class TestPage extends Page {
|
|
1244
|
-
static override slug = 'demo'
|
|
1245
|
-
static override schema() {
|
|
1246
|
-
return [Form.make().formId('the-form').schema([
|
|
1247
|
-
Builder.make('blocks').blocks([
|
|
1248
|
-
Block.make('callout').schema([new FakeRichTextField('body', true)]),
|
|
1249
|
-
]),
|
|
1250
|
-
])]
|
|
1251
|
-
}
|
|
1252
|
-
}
|
|
1253
|
-
const panel = Pilotiq.make('T').path('/admin').pages([TestPage])
|
|
1254
|
-
// Repeater-shaped path doesn't reach a Builder leaf.
|
|
1255
|
-
const result = await mentionResolveData(
|
|
1256
|
-
panel,
|
|
1257
|
-
{ kind: 'page', pageSlug: 'demo' },
|
|
1258
|
-
{ formId: 'the-form', field: 'blocks.0.body', trigger: '@', query: 'a' },
|
|
1259
|
-
)
|
|
1260
|
-
assert.notEqual(result, null)
|
|
1261
|
-
assert.equal((result as { ok: false; status: number }).ok, false)
|
|
1262
|
-
assert.equal((result as { ok: false; status: number }).status, 404)
|
|
1263
|
-
})
|
|
1264
|
-
})
|
|
1265
|
-
|
|
1266
|
-
describe('formWizardData — Step.beforeValidation / afterValidation hooks', () => {
|
|
1267
|
-
function panelWithWizard(steps: Step[]) {
|
|
1268
|
-
class TestPage extends Page {
|
|
1269
|
-
static override slug = 'demo'
|
|
1270
|
-
static override schema() {
|
|
1271
|
-
return [Form.make().formId('the-form').schema([Wizard.make().steps(steps)])]
|
|
1272
|
-
}
|
|
1273
|
-
}
|
|
1274
|
-
return Pilotiq.make('T').path('/admin').pages([TestPage])
|
|
1275
|
-
}
|
|
1276
|
-
|
|
1277
|
-
const dispatch = (panel: ReturnType<typeof Pilotiq.make>, body: { formId: string; step: number; values: Record<string, unknown> }) =>
|
|
1278
|
-
formWizardData(panel, { kind: 'page', pageSlug: 'demo' }, body)
|
|
1279
|
-
|
|
1280
|
-
it('returns ok:true when no hooks are set and validation passes', async () => {
|
|
1281
|
-
const panel = panelWithWizard([Step.make('a').schema([TextField.make('x')])])
|
|
1282
|
-
const result = await dispatch(panel, { formId: 'the-form', step: 0, values: { x: 'v' } })
|
|
1283
|
-
assert.deepEqual(result, { ok: true })
|
|
1284
|
-
})
|
|
1285
|
-
|
|
1286
|
-
it('runs beforeValidation before validators and lets it mutate values in place', async () => {
|
|
1287
|
-
const seen: string[] = []
|
|
1288
|
-
const panel = panelWithWizard([
|
|
1289
|
-
Step.make('a').schema([TextField.make('email').required()])
|
|
1290
|
-
.beforeValidation((values) => {
|
|
1291
|
-
seen.push('before')
|
|
1292
|
-
values['email'] = 'auto@example.com'
|
|
1293
|
-
}),
|
|
1294
|
-
])
|
|
1295
|
-
const result = await dispatch(panel, { formId: 'the-form', step: 0, values: {} })
|
|
1296
|
-
assert.deepEqual(result, { ok: true })
|
|
1297
|
-
assert.deepEqual(seen, ['before'])
|
|
1298
|
-
})
|
|
1299
|
-
|
|
1300
|
-
it('throwing from beforeValidation halts with 422 under the _step key', async () => {
|
|
1301
|
-
const panel = panelWithWizard([
|
|
1302
|
-
Step.make('a').schema([TextField.make('x')])
|
|
1303
|
-
.beforeValidation(async () => { throw new Error('email already in use') }),
|
|
1304
|
-
])
|
|
1305
|
-
const result = await dispatch(panel, { formId: 'the-form', step: 0, values: { x: 'v' } })
|
|
1306
|
-
assert.equal((result as { ok: false; status: number }).ok, false)
|
|
1307
|
-
assert.equal((result as { ok: false; status: number }).status, 422)
|
|
1308
|
-
assert.deepEqual((result as { errors: Record<string, string[]> }).errors, { _step: ['email already in use'] })
|
|
1309
|
-
})
|
|
1310
|
-
|
|
1311
|
-
it('runs afterValidation only when validators pass', async () => {
|
|
1312
|
-
let afterRan = false
|
|
1313
|
-
const panel = panelWithWizard([
|
|
1314
|
-
Step.make('a').schema([TextField.make('x').required()])
|
|
1315
|
-
.afterValidation(() => { afterRan = true }),
|
|
1316
|
-
])
|
|
1317
|
-
// Failing field validators short-circuit before afterValidation fires.
|
|
1318
|
-
const failed = await dispatch(panel, { formId: 'the-form', step: 0, values: { x: '' } })
|
|
1319
|
-
assert.equal((failed as { ok: false }).ok, false)
|
|
1320
|
-
assert.equal(afterRan, false)
|
|
1321
|
-
// Passing values let afterValidation run.
|
|
1322
|
-
const passed = await dispatch(panel, { formId: 'the-form', step: 0, values: { x: 'v' } })
|
|
1323
|
-
assert.deepEqual(passed, { ok: true })
|
|
1324
|
-
assert.equal(afterRan, true)
|
|
1325
|
-
})
|
|
1326
|
-
|
|
1327
|
-
it('throwing from afterValidation halts with 422 under the _step key', async () => {
|
|
1328
|
-
const panel = panelWithWizard([
|
|
1329
|
-
Step.make('a').schema([TextField.make('x')])
|
|
1330
|
-
.afterValidation(() => { throw new Error('cross-field invariant failed') }),
|
|
1331
|
-
])
|
|
1332
|
-
const result = await dispatch(panel, { formId: 'the-form', step: 0, values: { x: 'v' } })
|
|
1333
|
-
assert.equal((result as { ok: false; status: number }).status, 422)
|
|
1334
|
-
assert.deepEqual((result as { errors: Record<string, string[]> }).errors, { _step: ['cross-field invariant failed'] })
|
|
1335
|
-
})
|
|
1336
|
-
|
|
1337
|
-
it('non-Error throws still produce a usable message', async () => {
|
|
1338
|
-
const panel = panelWithWizard([
|
|
1339
|
-
Step.make('a').schema([TextField.make('x')])
|
|
1340
|
-
.beforeValidation(() => { throw 'plain string failure' as unknown as Error }),
|
|
1341
|
-
])
|
|
1342
|
-
const result = await dispatch(panel, { formId: 'the-form', step: 0, values: { x: 'v' } })
|
|
1343
|
-
assert.deepEqual((result as { errors: Record<string, string[]> }).errors, { _step: ['plain string failure'] })
|
|
1344
|
-
})
|
|
1345
|
-
})
|
|
1346
|
-
|
|
1347
|
-
describe('tagRichTextMentionUrls — nested Repeater + Builder rows', () => {
|
|
1348
|
-
it('stamps a Repeater template field via the form-level URL', () => {
|
|
1349
|
-
const inner = new FakeRichTextField('body', true)
|
|
1350
|
-
const form = Form.make().formId('art').schema([
|
|
1351
|
-
Repeater.make('items').schema([inner]),
|
|
1352
|
-
])
|
|
1353
|
-
tagRichTextMentionUrls([form], (id) => `/admin/_form/${id}/mentions`)
|
|
1354
|
-
assert.equal(inner.stamped, '/admin/_form/art/mentions')
|
|
1355
|
-
})
|
|
1356
|
-
|
|
1357
|
-
it('stamps a Builder block leaf even though Builder.getChildren() is undefined', () => {
|
|
1358
|
-
const inner = new FakeRichTextField('body', true)
|
|
1359
|
-
const form = Form.make().formId('art').schema([
|
|
1360
|
-
Builder.make('blocks').blocks([
|
|
1361
|
-
Block.make('callout').schema([inner]),
|
|
1362
|
-
]),
|
|
1363
|
-
])
|
|
1364
|
-
tagRichTextMentionUrls([form], (id) => `/admin/_form/${id}/mentions`)
|
|
1365
|
-
assert.equal(inner.stamped, '/admin/_form/art/mentions')
|
|
1366
|
-
})
|
|
1367
|
-
})
|
|
1368
|
-
|
|
1369
|
-
describe('applyEditPageHydrators (Pilotiq.editPageHydrator)', () => {
|
|
1370
|
-
class Posts extends Resource { static override label = 'Posts' }
|
|
1371
|
-
const ctx = (currentValues: Record<string, unknown> = {}) => ({
|
|
1372
|
-
resource: Posts,
|
|
1373
|
-
recordId: '42',
|
|
1374
|
-
currentValues,
|
|
1375
|
-
})
|
|
1376
|
-
|
|
1377
|
-
it('empty hydrators array → empty overlay', async () => {
|
|
1378
|
-
const overlay = await applyEditPageHydrators([], ctx())
|
|
1379
|
-
assert.deepEqual(overlay, {})
|
|
1380
|
-
})
|
|
1381
|
-
|
|
1382
|
-
it('hydrator returning null → empty overlay', async () => {
|
|
1383
|
-
const overlay = await applyEditPageHydrators([
|
|
1384
|
-
async () => null,
|
|
1385
|
-
], ctx())
|
|
1386
|
-
assert.deepEqual(overlay, {})
|
|
1387
|
-
})
|
|
1388
|
-
|
|
1389
|
-
it('hydrator returning a partial → overlay carries the keys', async () => {
|
|
1390
|
-
const overlay = await applyEditPageHydrators([
|
|
1391
|
-
async () => ({ title: 'Y-Title', body: 'Y-Body' }),
|
|
1392
|
-
], ctx({ title: 'DB-Title', body: 'DB-Body', author: 'DB-Author' }))
|
|
1393
|
-
assert.deepEqual(overlay, { title: 'Y-Title', body: 'Y-Body' })
|
|
1394
|
-
})
|
|
1395
|
-
|
|
1396
|
-
it('two hydrators merge in registration order (later wins on conflict)', async () => {
|
|
1397
|
-
const overlay = await applyEditPageHydrators([
|
|
1398
|
-
async () => ({ title: 'first', shared: 'first-shared' }),
|
|
1399
|
-
async () => ({ body: 'second', shared: 'second-shared' }),
|
|
1400
|
-
], ctx())
|
|
1401
|
-
assert.deepEqual(overlay, {
|
|
1402
|
-
title: 'first',
|
|
1403
|
-
body: 'second',
|
|
1404
|
-
shared: 'second-shared',
|
|
1405
|
-
})
|
|
1406
|
-
})
|
|
1407
|
-
|
|
1408
|
-
it('hydrator that throws is swallowed; siblings still contribute', async () => {
|
|
1409
|
-
// Stub console.warn so the test output stays clean; restore after.
|
|
1410
|
-
const originalWarn = console.warn
|
|
1411
|
-
let warned = false
|
|
1412
|
-
console.warn = (..._args: unknown[]) => { warned = true }
|
|
1413
|
-
try {
|
|
1414
|
-
const overlay = await applyEditPageHydrators([
|
|
1415
|
-
async () => { throw new Error('boom') },
|
|
1416
|
-
async () => ({ title: 'sibling-survived' }),
|
|
1417
|
-
], ctx())
|
|
1418
|
-
assert.deepEqual(overlay, { title: 'sibling-survived' })
|
|
1419
|
-
assert.equal(warned, true, 'console.warn should fire for thrown hydrators')
|
|
1420
|
-
} finally {
|
|
1421
|
-
console.warn = originalWarn
|
|
1422
|
-
}
|
|
1423
|
-
})
|
|
1424
|
-
|
|
1425
|
-
it('hydrator returning a non-object is skipped', async () => {
|
|
1426
|
-
const overlay = await applyEditPageHydrators([
|
|
1427
|
-
// @ts-expect-error — deliberately exercising the runtime guard
|
|
1428
|
-
async () => 'not-an-object',
|
|
1429
|
-
async () => ({ title: 'real-result' }),
|
|
1430
|
-
], ctx())
|
|
1431
|
-
assert.deepEqual(overlay, { title: 'real-result' })
|
|
1432
|
-
})
|
|
1433
|
-
|
|
1434
|
-
it('hydrator receives current fill-pipeline values via ctx.currentValues', async () => {
|
|
1435
|
-
let seen: Record<string, unknown> | undefined
|
|
1436
|
-
await applyEditPageHydrators([
|
|
1437
|
-
async (ctx) => { seen = ctx.currentValues; return null },
|
|
1438
|
-
], ctx({ title: 'DB-Title', body: 'DB-Body' }))
|
|
1439
|
-
assert.deepEqual(seen, { title: 'DB-Title', body: 'DB-Body' })
|
|
1440
|
-
})
|
|
1441
|
-
|
|
1442
|
-
it('hydrator receives resource class + recordId in ctx', async () => {
|
|
1443
|
-
let seenResource: unknown
|
|
1444
|
-
let seenRecordId: unknown
|
|
1445
|
-
await applyEditPageHydrators([
|
|
1446
|
-
async (ctx) => { seenResource = ctx.resource; seenRecordId = ctx.recordId; return null },
|
|
1447
|
-
], ctx())
|
|
1448
|
-
assert.equal(seenResource, Posts)
|
|
1449
|
-
assert.equal(seenRecordId, '42')
|
|
1450
|
-
})
|
|
1451
|
-
})
|
|
1452
|
-
|
|
1453
|
-
describe('Pilotiq.editPageHydrator builder method', () => {
|
|
1454
|
-
it('stores hydrators on the config in registration order', () => {
|
|
1455
|
-
const fn1 = async () => ({ a: 1 })
|
|
1456
|
-
const fn2 = async () => ({ b: 2 })
|
|
1457
|
-
const panel = Pilotiq.make('Admin')
|
|
1458
|
-
.editPageHydrator(fn1)
|
|
1459
|
-
.editPageHydrator(fn2)
|
|
1460
|
-
assert.deepEqual(panel.getConfig().editPageHydrators, [fn1, fn2])
|
|
1461
|
-
})
|
|
1462
|
-
|
|
1463
|
-
it('absent when no hydrator registered', () => {
|
|
1464
|
-
const panel = Pilotiq.make('Admin')
|
|
1465
|
-
assert.equal(panel.getConfig().editPageHydrators, undefined)
|
|
1466
|
-
})
|
|
1467
|
-
})
|
|
1468
|
-
|
|
1469
|
-
describe('panelInfo — recordCollab map (resource collab opt-in)', () => {
|
|
1470
|
-
it('absent when no resource opts in', async () => {
|
|
1471
|
-
class Posts extends Resource { static override label = 'Posts' }
|
|
1472
|
-
const info = await panelInfo(Pilotiq.make('T').path('/admin').resources([Posts]))
|
|
1473
|
-
assert.equal((info as { recordCollab?: unknown }).recordCollab, undefined)
|
|
1474
|
-
})
|
|
1475
|
-
|
|
1476
|
-
it('emits an entry for each opted-in resource keyed by URL slug', async () => {
|
|
1477
|
-
class Posts extends Resource {
|
|
1478
|
-
static override label = 'Posts'
|
|
1479
|
-
static override collab = true as const
|
|
1480
|
-
}
|
|
1481
|
-
class Users extends Resource {
|
|
1482
|
-
static override label = 'Users'
|
|
1483
|
-
// No collab — should NOT appear in the map.
|
|
1484
|
-
}
|
|
1485
|
-
const info = await panelInfo(Pilotiq.make('T').path('/admin').resources([Posts, Users]))
|
|
1486
|
-
const map = (info as { recordCollab?: Record<string, unknown> }).recordCollab
|
|
1487
|
-
assert.deepEqual(map, {
|
|
1488
|
-
posts: { pages: ['edit'], presence: true },
|
|
1489
|
-
})
|
|
1490
|
-
})
|
|
1491
|
-
|
|
1492
|
-
it('honors object form of static collab (pages + presence override defaults)', async () => {
|
|
1493
|
-
class Posts extends Resource {
|
|
1494
|
-
static override label = 'Posts'
|
|
1495
|
-
static override collab = { pages: ['edit', 'view'] as const, presence: false }
|
|
1496
|
-
}
|
|
1497
|
-
const info = await panelInfo(Pilotiq.make('T').path('/admin').resources([Posts]))
|
|
1498
|
-
const map = (info as { recordCollab?: Record<string, unknown> }).recordCollab
|
|
1499
|
-
assert.deepEqual(map, {
|
|
1500
|
-
posts: { pages: ['edit', 'view'], presence: false },
|
|
1501
|
-
})
|
|
1502
|
-
})
|
|
1503
|
-
})
|
|
1504
|
-
|
|
1505
|
-
describe('panelInfo — pageCollab map (custom-page collab opt-in)', () => {
|
|
1506
|
-
it('absent when no page opts in', async () => {
|
|
1507
|
-
class Analytics extends Page {
|
|
1508
|
-
static override slug = 'analytics'
|
|
1509
|
-
static override label = 'Analytics'
|
|
1510
|
-
}
|
|
1511
|
-
const info = await panelInfo(Pilotiq.make('T').path('/admin').pages([Analytics]))
|
|
1512
|
-
assert.equal((info as { pageCollab?: unknown }).pageCollab, undefined)
|
|
1513
|
-
})
|
|
1514
|
-
|
|
1515
|
-
it('emits an entry per opted-in custom page keyed by URL slug', async () => {
|
|
1516
|
-
class Settings extends Page {
|
|
1517
|
-
static override slug = 'settings'
|
|
1518
|
-
static override label = 'Settings'
|
|
1519
|
-
static override collab = { room: 'settings-general' }
|
|
1520
|
-
}
|
|
1521
|
-
class Analytics extends Page {
|
|
1522
|
-
static override slug = 'analytics'
|
|
1523
|
-
static override label = 'Analytics'
|
|
1524
|
-
// No collab — should NOT appear in the map.
|
|
1525
|
-
}
|
|
1526
|
-
const info = await panelInfo(Pilotiq.make('T').path('/admin').pages([Settings, Analytics]))
|
|
1527
|
-
const map = (info as { pageCollab?: Record<string, unknown> }).pageCollab
|
|
1528
|
-
assert.deepEqual(map, {
|
|
1529
|
-
settings: { room: 'settings-general', presence: true },
|
|
1530
|
-
})
|
|
1531
|
-
})
|
|
1532
|
-
|
|
1533
|
-
it('object form can suppress presence', async () => {
|
|
1534
|
-
class Settings extends Page {
|
|
1535
|
-
static override slug = 'settings'
|
|
1536
|
-
static override label = 'Settings'
|
|
1537
|
-
static override collab = { room: 'settings', presence: false }
|
|
1538
|
-
}
|
|
1539
|
-
const info = await panelInfo(Pilotiq.make('T').path('/admin').pages([Settings]))
|
|
1540
|
-
const map = (info as { pageCollab?: Record<string, unknown> }).pageCollab
|
|
1541
|
-
assert.deepEqual(map, {
|
|
1542
|
-
settings: { room: 'settings', presence: false },
|
|
1543
|
-
})
|
|
1544
|
-
})
|
|
1545
|
-
})
|