@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
|
@@ -1,972 +0,0 @@
|
|
|
1
|
-
import { describe, it, beforeEach } from 'node:test'
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
|
|
4
|
-
import { Router } from '@rudderjs/router'
|
|
5
|
-
|
|
6
|
-
import { Pilotiq } from './Pilotiq.js'
|
|
7
|
-
import { Resource } from './Resource.js'
|
|
8
|
-
import { RelationManager } from './RelationManager.js'
|
|
9
|
-
import { Form } from './elements/Form.js'
|
|
10
|
-
import { Table } from './elements/Table.js'
|
|
11
|
-
import { Column } from './Column.js'
|
|
12
|
-
import { TextField } from './fields/TextField.js'
|
|
13
|
-
import { registerPilotiqRoutes } from './routes.js'
|
|
14
|
-
import type { ModelLike, ModelQuery } from './orm/modelDefaults.js'
|
|
15
|
-
import { Action } from './actions/Action.js'
|
|
16
|
-
|
|
17
|
-
// ── Test doubles ─────────────────────────────────────────────────
|
|
18
|
-
|
|
19
|
-
interface Row extends Record<string, unknown> { id: string | number }
|
|
20
|
-
|
|
21
|
-
class StubQuery implements ModelQuery {
|
|
22
|
-
private filters: Array<{ col: string; val: unknown }> = []
|
|
23
|
-
constructor(private rows: Row[]) {}
|
|
24
|
-
where(col: string, ...rest: unknown[]): ModelQuery {
|
|
25
|
-
const val = rest.length === 1 ? rest[0] : rest[1]
|
|
26
|
-
this.filters.push({ col, val })
|
|
27
|
-
return this
|
|
28
|
-
}
|
|
29
|
-
orWhere(...args: unknown[]): ModelQuery { return this.where(args[0] as string, ...args.slice(1)) }
|
|
30
|
-
orderBy(_c: string, _d?: 'ASC' | 'DESC'): ModelQuery { return this }
|
|
31
|
-
async paginate() {
|
|
32
|
-
let data = this.rows
|
|
33
|
-
for (const f of this.filters) data = data.filter(r => r[f.col] === f.val)
|
|
34
|
-
return { data, total: data.length }
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function fakeReq(overrides: Partial<{
|
|
39
|
-
params: Record<string, string>
|
|
40
|
-
body: unknown
|
|
41
|
-
query: Record<string, string>
|
|
42
|
-
headers: Record<string, string>
|
|
43
|
-
}> = {}): any {
|
|
44
|
-
return {
|
|
45
|
-
params: overrides.params ?? {},
|
|
46
|
-
body: overrides.body ?? null,
|
|
47
|
-
query: overrides.query ?? {},
|
|
48
|
-
headers: overrides.headers ?? {},
|
|
49
|
-
raw: {},
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
interface FakeRes {
|
|
54
|
-
statusCode: number
|
|
55
|
-
redirectedTo?: { url: string; code: number }
|
|
56
|
-
sentBody?: unknown
|
|
57
|
-
status(code: number): FakeRes
|
|
58
|
-
redirect(url: string, code?: number): FakeRes
|
|
59
|
-
send(body: unknown): FakeRes
|
|
60
|
-
json(body: unknown): FakeRes
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function fakeRes(): FakeRes {
|
|
64
|
-
const r: FakeRes = {
|
|
65
|
-
statusCode: 200,
|
|
66
|
-
status(code) { this.statusCode = code; return this },
|
|
67
|
-
redirect(url, code = 302) { this.redirectedTo = { url, code }; return this },
|
|
68
|
-
send(body) { this.sentBody = body; return this },
|
|
69
|
-
json(body) { this.sentBody = body; return this },
|
|
70
|
-
}
|
|
71
|
-
return r
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
async function callHandler(handler: (...args: any[]) => unknown, req: any = fakeReq(), res: any = fakeRes()) {
|
|
75
|
-
const result = await handler(req, res)
|
|
76
|
-
return { result, res: res as FakeRes }
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Adapt a stub `find(id)` to the `query().where(pk, id).paginate(1, 1)`
|
|
81
|
-
* shape that pilotiq's `findRecord(R, id, ctx)` now drives. Lets these
|
|
82
|
-
* tests keep their `find(id)` map-backed stubs without rewriting them
|
|
83
|
-
* into row arrays.
|
|
84
|
-
*/
|
|
85
|
-
function findAdapter(find: (id: string) => Promise<unknown>): ModelQuery {
|
|
86
|
-
let captured: unknown
|
|
87
|
-
const q: ModelQuery = {
|
|
88
|
-
where(...args: unknown[]): ModelQuery {
|
|
89
|
-
captured = args.length === 2 ? args[1] : args[2]
|
|
90
|
-
return q
|
|
91
|
-
},
|
|
92
|
-
orWhere(...args: unknown[]): ModelQuery {
|
|
93
|
-
captured = args.length === 2 ? args[1] : args[2]
|
|
94
|
-
return q
|
|
95
|
-
},
|
|
96
|
-
orderBy(): ModelQuery { return q },
|
|
97
|
-
async paginate() {
|
|
98
|
-
const r = await find(String(captured))
|
|
99
|
-
return { data: r ? [r] : [], total: r ? 1 : 0 }
|
|
100
|
-
},
|
|
101
|
-
}
|
|
102
|
-
return q
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// ── World builder ─────────────────────────────────────────────────
|
|
106
|
-
|
|
107
|
-
function buildWorld() {
|
|
108
|
-
const postRows: Row[] = [
|
|
109
|
-
{ id: 'p1', parentId: 'u1', title: 'Post One' },
|
|
110
|
-
{ id: 'p2', parentId: 'u1', title: 'Post Two' },
|
|
111
|
-
{ id: 'p3', parentId: 'u2', title: 'Other Post' },
|
|
112
|
-
]
|
|
113
|
-
const PostModel: ModelLike = {
|
|
114
|
-
async find(id) { return postRows.find(r => r['id'] === id || String(r['id']) === String(id)) ?? null },
|
|
115
|
-
async create(data) { const n: Row = { id: `p${postRows.length + 1}`, ...data }; postRows.push(n); return n },
|
|
116
|
-
async update(id, data) { const r = postRows.find(r => r['id'] === id); if (r) Object.assign(r, data); return r ?? null },
|
|
117
|
-
async delete(id) { const i = postRows.findIndex(r => r['id'] === id); if (i >= 0) postRows.splice(i, 1) },
|
|
118
|
-
query() { return new StubQuery(postRows) },
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const parents = new Map<string, { id: string; related: (n: string) => ModelQuery }>([
|
|
122
|
-
['u1', { id: 'u1', related: (_n) => new StubQuery(postRows.filter(r => r['parentId'] === 'u1')) }],
|
|
123
|
-
['u2', { id: 'u2', related: (_n) => new StubQuery(postRows.filter(r => r['parentId'] === 'u2')) }],
|
|
124
|
-
])
|
|
125
|
-
const ParentModel: ModelLike = {
|
|
126
|
-
async find(id) { return parents.get(String(id)) ?? null },
|
|
127
|
-
async create() { throw new Error('not used') },
|
|
128
|
-
async update() { throw new Error('not used') },
|
|
129
|
-
async delete() { /* no-op */ },
|
|
130
|
-
query(): ModelQuery { return findAdapter((this as ModelLike).find as (id: string) => Promise<unknown>) },
|
|
131
|
-
}
|
|
132
|
-
Object.assign(ParentModel as object, { relations: { posts: { model: () => PostModel } } })
|
|
133
|
-
|
|
134
|
-
class PostResource extends Resource {
|
|
135
|
-
static override label = 'Posts'
|
|
136
|
-
static override labelSingular = 'Post'
|
|
137
|
-
static override slug = 'posts'
|
|
138
|
-
static override get model() { return PostModel }
|
|
139
|
-
static override form(form: Form): Form { return form.schema([TextField.make('title').required()]) }
|
|
140
|
-
}
|
|
141
|
-
class PostsManager extends RelationManager {
|
|
142
|
-
static override relationship = 'posts'
|
|
143
|
-
static override table(t: Table): Table { return t.columns([Column.make('title').sortable()]) }
|
|
144
|
-
static override form(f: Form): Form { return f.schema([TextField.make('title').required()]) }
|
|
145
|
-
}
|
|
146
|
-
class UserResource extends Resource {
|
|
147
|
-
static override label = 'Users'
|
|
148
|
-
static override slug = 'users'
|
|
149
|
-
static override get model() { return ParentModel }
|
|
150
|
-
static override relations() { return [PostsManager] }
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const panel = Pilotiq.make('T').path('/admin').resources([UserResource, PostResource])
|
|
154
|
-
return { panel, UserResource, PostResource, PostsManager, postRows, ParentModel, PostModel }
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// ── Tests ─────────────────────────────────────────────────────────
|
|
158
|
-
|
|
159
|
-
describe('relation routes — registration', () => {
|
|
160
|
-
let router: Router
|
|
161
|
-
beforeEach(() => { router = new Router() })
|
|
162
|
-
|
|
163
|
-
it('registers list/create/view/edit/delete per manager', () => {
|
|
164
|
-
const { panel } = buildWorld()
|
|
165
|
-
registerPilotiqRoutes(router, panel)
|
|
166
|
-
const paths = router.list().map(r => `${r.method} ${r.path}`)
|
|
167
|
-
|
|
168
|
-
assert.ok(paths.includes('GET /admin/users/:id/posts'), 'list')
|
|
169
|
-
assert.ok(paths.includes('GET /admin/users/:id/posts/create'), 'create-get')
|
|
170
|
-
assert.ok(paths.includes('POST /admin/users/:id/posts/create'), 'create-post')
|
|
171
|
-
assert.ok(paths.includes('GET /admin/users/:id/posts/:childId'), 'view-get')
|
|
172
|
-
assert.ok(paths.includes('GET /admin/users/:id/posts/:childId/edit'), 'edit-get')
|
|
173
|
-
assert.ok(paths.includes('POST /admin/users/:id/posts/:childId/edit'), 'edit-post')
|
|
174
|
-
assert.ok(paths.includes('POST /admin/users/:id/posts/:childId/delete'), 'delete')
|
|
175
|
-
})
|
|
176
|
-
|
|
177
|
-
it('throws at boot when a manager uses a reserved relationship', () => {
|
|
178
|
-
class BadM extends RelationManager {
|
|
179
|
-
static override relationship = 'edit'
|
|
180
|
-
}
|
|
181
|
-
class WithBad extends Resource {
|
|
182
|
-
static override slug = 'things'
|
|
183
|
-
static override relations() { return [BadM] }
|
|
184
|
-
}
|
|
185
|
-
const panel = Pilotiq.make('T').path('/admin').resources([WithBad])
|
|
186
|
-
assert.throws(
|
|
187
|
-
() => registerPilotiqRoutes(new Router(), panel),
|
|
188
|
-
/uses reserved relationship "edit"/,
|
|
189
|
-
)
|
|
190
|
-
})
|
|
191
|
-
})
|
|
192
|
-
|
|
193
|
-
describe('relation routes — list handler', () => {
|
|
194
|
-
let router: Router
|
|
195
|
-
beforeEach(() => { router = new Router() })
|
|
196
|
-
|
|
197
|
-
it('returns relation-list view with parent-scoped table rows', async () => {
|
|
198
|
-
const { panel } = buildWorld()
|
|
199
|
-
registerPilotiqRoutes(router, panel)
|
|
200
|
-
const route = router.list().find(r => r.path === '/admin/users/:id/posts' && r.method === 'GET')!
|
|
201
|
-
const { result } = await callHandler(route.handler, fakeReq({ params: { id: 'u1' } }))
|
|
202
|
-
|
|
203
|
-
const view = result as { id: string; props: Record<string, unknown> }
|
|
204
|
-
assert.equal(view.id, 'pilotiq.relation-list')
|
|
205
|
-
const schema = view.props['schemaData'] as Array<Record<string, unknown>>
|
|
206
|
-
const tableMeta = schema.find(s => s['type'] === 'table')
|
|
207
|
-
assert.ok(tableMeta, 'expected a table element')
|
|
208
|
-
const rows = tableMeta['rows'] as Array<Record<string, unknown>>
|
|
209
|
-
assert.deepEqual(rows.map(r => r['id']).sort(), ['p1', 'p2'])
|
|
210
|
-
})
|
|
211
|
-
|
|
212
|
-
it('404s when the parent record is missing', async () => {
|
|
213
|
-
const { panel } = buildWorld()
|
|
214
|
-
registerPilotiqRoutes(router, panel)
|
|
215
|
-
const route = router.list().find(r => r.path === '/admin/users/:id/posts' && r.method === 'GET')!
|
|
216
|
-
const { res } = await callHandler(route.handler, fakeReq({ params: { id: 'unknown' } }))
|
|
217
|
-
assert.equal(res.statusCode, 404)
|
|
218
|
-
})
|
|
219
|
-
|
|
220
|
-
it('403s when manager.canViewAny denies', async () => {
|
|
221
|
-
const { panel } = buildWorld()
|
|
222
|
-
// Patch the manager class registered with the panel.
|
|
223
|
-
const R = panel.getConfig().resources[0]!
|
|
224
|
-
const M = R.relations()[0]!
|
|
225
|
-
;(M as unknown as { canViewAny: (...a: unknown[]) => Promise<boolean> }).canViewAny = async () => false
|
|
226
|
-
|
|
227
|
-
registerPilotiqRoutes(router, panel)
|
|
228
|
-
const route = router.list().find(r => r.path === '/admin/users/:id/posts' && r.method === 'GET')!
|
|
229
|
-
const { res } = await callHandler(route.handler, fakeReq({ params: { id: 'u1' } }))
|
|
230
|
-
assert.equal(res.statusCode, 403)
|
|
231
|
-
})
|
|
232
|
-
})
|
|
233
|
-
|
|
234
|
-
describe('relation routes — view GET (Phase A)', () => {
|
|
235
|
-
let router: Router
|
|
236
|
-
beforeEach(() => { router = new Router() })
|
|
237
|
-
|
|
238
|
-
it('returns relation-view for a child that belongs to the parent', async () => {
|
|
239
|
-
const { panel } = buildWorld()
|
|
240
|
-
registerPilotiqRoutes(router, panel)
|
|
241
|
-
const route = router.list().find(r => r.path === '/admin/users/:id/posts/:childId' && r.method === 'GET')!
|
|
242
|
-
const { result, res } = await callHandler(
|
|
243
|
-
route.handler,
|
|
244
|
-
fakeReq({ params: { id: 'u1', childId: 'p1' } }),
|
|
245
|
-
)
|
|
246
|
-
assert.equal(res.statusCode, 200)
|
|
247
|
-
const view = result as { id: string; props: Record<string, unknown> }
|
|
248
|
-
assert.equal(view.id, 'pilotiq.relation-view')
|
|
249
|
-
assert.equal(view.props['mode'], 'view')
|
|
250
|
-
assert.equal(view.props['childId'], 'p1')
|
|
251
|
-
})
|
|
252
|
-
|
|
253
|
-
it('404s under IDOR (child belongs to a different parent)', async () => {
|
|
254
|
-
const { panel } = buildWorld()
|
|
255
|
-
registerPilotiqRoutes(router, panel)
|
|
256
|
-
const route = router.list().find(r => r.path === '/admin/users/:id/posts/:childId' && r.method === 'GET')!
|
|
257
|
-
const { res } = await callHandler(
|
|
258
|
-
route.handler,
|
|
259
|
-
fakeReq({ params: { id: 'u1', childId: 'p3' } }), // p3 is u2's
|
|
260
|
-
)
|
|
261
|
-
assert.equal(res.statusCode, 404)
|
|
262
|
-
})
|
|
263
|
-
|
|
264
|
-
it('404s when childId is the literal "create" reserved token', async () => {
|
|
265
|
-
const { panel } = buildWorld()
|
|
266
|
-
registerPilotiqRoutes(router, panel)
|
|
267
|
-
const route = router.list().find(r => r.path === '/admin/users/:id/posts/:childId' && r.method === 'GET')!
|
|
268
|
-
const { res } = await callHandler(
|
|
269
|
-
route.handler,
|
|
270
|
-
fakeReq({ params: { id: 'u1', childId: 'create' } }),
|
|
271
|
-
)
|
|
272
|
-
assert.equal(res.statusCode, 404)
|
|
273
|
-
})
|
|
274
|
-
|
|
275
|
-
it('403s when manager.canView denies', async () => {
|
|
276
|
-
const { panel } = buildWorld()
|
|
277
|
-
const R = panel.getConfig().resources[0]!
|
|
278
|
-
const M = R.relations()[0]!
|
|
279
|
-
;(M as unknown as { canView: (...a: unknown[]) => Promise<boolean> }).canView = async () => false
|
|
280
|
-
|
|
281
|
-
registerPilotiqRoutes(router, panel)
|
|
282
|
-
const route = router.list().find(r => r.path === '/admin/users/:id/posts/:childId' && r.method === 'GET')!
|
|
283
|
-
const { res } = await callHandler(
|
|
284
|
-
route.handler,
|
|
285
|
-
fakeReq({ params: { id: 'u1', childId: 'p1' } }),
|
|
286
|
-
)
|
|
287
|
-
assert.equal(res.statusCode, 403)
|
|
288
|
-
})
|
|
289
|
-
})
|
|
290
|
-
|
|
291
|
-
describe('relation routes — create POST', () => {
|
|
292
|
-
let router: Router
|
|
293
|
-
beforeEach(() => { router = new Router() })
|
|
294
|
-
|
|
295
|
-
it('creates a child and redirects to the list', async () => {
|
|
296
|
-
const { panel, postRows } = buildWorld()
|
|
297
|
-
registerPilotiqRoutes(router, panel)
|
|
298
|
-
const route = router.list().find(r => r.path === '/admin/users/:id/posts/create' && r.method === 'POST')!
|
|
299
|
-
const { res } = await callHandler(
|
|
300
|
-
route.handler,
|
|
301
|
-
fakeReq({ params: { id: 'u1' }, body: { title: 'Fresh post' } }),
|
|
302
|
-
)
|
|
303
|
-
|
|
304
|
-
assert.equal(res.redirectedTo?.url, '/admin/users/u1/posts')
|
|
305
|
-
assert.equal(res.redirectedTo?.code, 303)
|
|
306
|
-
assert.ok(postRows.some(r => r['title'] === 'Fresh post'))
|
|
307
|
-
})
|
|
308
|
-
|
|
309
|
-
it('422 on validation failure with prefilled values', async () => {
|
|
310
|
-
const { panel } = buildWorld()
|
|
311
|
-
registerPilotiqRoutes(router, panel)
|
|
312
|
-
const route = router.list().find(r => r.path === '/admin/users/:id/posts/create' && r.method === 'POST')!
|
|
313
|
-
const { result, res } = await callHandler(
|
|
314
|
-
route.handler,
|
|
315
|
-
fakeReq({ params: { id: 'u1' }, body: { title: '' } }), // required violated
|
|
316
|
-
)
|
|
317
|
-
assert.equal(res.statusCode, 422)
|
|
318
|
-
const view = result as { id: string; props: Record<string, unknown> }
|
|
319
|
-
assert.equal(view.id, 'pilotiq.relation-create')
|
|
320
|
-
assert.equal(view.props['hasErrors'], true)
|
|
321
|
-
})
|
|
322
|
-
})
|
|
323
|
-
|
|
324
|
-
describe('relation routes — edit + delete POST', () => {
|
|
325
|
-
let router: Router
|
|
326
|
-
beforeEach(() => { router = new Router() })
|
|
327
|
-
|
|
328
|
-
it('edit POST updates the child and redirects', async () => {
|
|
329
|
-
const { panel, postRows } = buildWorld()
|
|
330
|
-
registerPilotiqRoutes(router, panel)
|
|
331
|
-
const route = router.list().find(r => r.path === '/admin/users/:id/posts/:childId/edit' && r.method === 'POST')!
|
|
332
|
-
const { res } = await callHandler(
|
|
333
|
-
route.handler,
|
|
334
|
-
fakeReq({ params: { id: 'u1', childId: 'p1' }, body: { title: 'Renamed' } }),
|
|
335
|
-
)
|
|
336
|
-
assert.equal(res.redirectedTo?.code, 303)
|
|
337
|
-
assert.equal(postRows.find(r => r['id'] === 'p1')!['title'], 'Renamed')
|
|
338
|
-
})
|
|
339
|
-
|
|
340
|
-
it('edit POST 404s when the child belongs to a different parent (IDOR)', async () => {
|
|
341
|
-
const { panel } = buildWorld()
|
|
342
|
-
registerPilotiqRoutes(router, panel)
|
|
343
|
-
const route = router.list().find(r => r.path === '/admin/users/:id/posts/:childId/edit' && r.method === 'POST')!
|
|
344
|
-
// p3 belongs to u2, not u1 — this MUST not edit anything.
|
|
345
|
-
const { res } = await callHandler(
|
|
346
|
-
route.handler,
|
|
347
|
-
fakeReq({ params: { id: 'u1', childId: 'p3' }, body: { title: 'Hacked' } }),
|
|
348
|
-
)
|
|
349
|
-
assert.equal(res.statusCode, 404)
|
|
350
|
-
})
|
|
351
|
-
|
|
352
|
-
it('delete POST removes the child and redirects to the list', async () => {
|
|
353
|
-
const { panel, postRows } = buildWorld()
|
|
354
|
-
registerPilotiqRoutes(router, panel)
|
|
355
|
-
const route = router.list().find(r => r.path === '/admin/users/:id/posts/:childId/delete' && r.method === 'POST')!
|
|
356
|
-
const { res } = await callHandler(
|
|
357
|
-
route.handler,
|
|
358
|
-
fakeReq({ params: { id: 'u1', childId: 'p2' } }),
|
|
359
|
-
)
|
|
360
|
-
assert.equal(res.redirectedTo?.url, '/admin/users/u1/posts')
|
|
361
|
-
assert.equal(res.redirectedTo?.code, 303)
|
|
362
|
-
assert.ok(!postRows.some(r => r['id'] === 'p2'), 'child p2 should be deleted')
|
|
363
|
-
})
|
|
364
|
-
|
|
365
|
-
it('delete POST 404s under IDOR', async () => {
|
|
366
|
-
const { panel, postRows } = buildWorld()
|
|
367
|
-
registerPilotiqRoutes(router, panel)
|
|
368
|
-
const route = router.list().find(r => r.path === '/admin/users/:id/posts/:childId/delete' && r.method === 'POST')!
|
|
369
|
-
const { res } = await callHandler(
|
|
370
|
-
route.handler,
|
|
371
|
-
fakeReq({ params: { id: 'u1', childId: 'p3' } }),
|
|
372
|
-
)
|
|
373
|
-
assert.equal(res.statusCode, 404)
|
|
374
|
-
// p3 still in store untouched
|
|
375
|
-
assert.ok(postRows.some(r => r['id'] === 'p3'))
|
|
376
|
-
})
|
|
377
|
-
})
|
|
378
|
-
|
|
379
|
-
// ── M2M follow-up: manager-scoped _action + _detach routes ──────────
|
|
380
|
-
|
|
381
|
-
/** World builder for a M2M Article ↔ Tag relation. Defaults to
|
|
382
|
-
* `belongsToMany`; pass `'morphToMany'` or `'morphedByMany'` to flip the
|
|
383
|
-
* relations-map type so `getRelationType` resolves to the polymorphic
|
|
384
|
-
* variant. The runtime accessor surface (where, paginate, attach,
|
|
385
|
-
* detach) is identical across all three — the rudder ORM stamps +
|
|
386
|
-
* filters the polymorphic discriminator on the morph variants
|
|
387
|
-
* automatically, so pilotiq's plumbing is mode-agnostic beyond the
|
|
388
|
-
* detach 404 gate + visibility predicates. */
|
|
389
|
-
function buildM2MWorld(morphMode: 'belongsToMany' | 'morphToMany' | 'morphedByMany' = 'belongsToMany') {
|
|
390
|
-
const tagRows: Row[] = [
|
|
391
|
-
{ id: 't1', name: 'red' },
|
|
392
|
-
{ id: 't2', name: 'blue' },
|
|
393
|
-
{ id: 't3', name: 'green' },
|
|
394
|
-
]
|
|
395
|
-
const TagModel: ModelLike = {
|
|
396
|
-
async find(id) { return tagRows.find(r => String(r['id']) === String(id)) ?? null },
|
|
397
|
-
async create() { throw new Error('not used') },
|
|
398
|
-
async update() { throw new Error('not used') },
|
|
399
|
-
async delete() { /* no-op */ },
|
|
400
|
-
query() { return new StubQuery(tagRows) },
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
// Mutable pivot store: which tag ids are attached to each article.
|
|
404
|
-
const pivot = new Map<string, Set<string>>([
|
|
405
|
-
['a1', new Set(['t1', 't2'])],
|
|
406
|
-
['a2', new Set([])],
|
|
407
|
-
])
|
|
408
|
-
|
|
409
|
-
function makeRelatedAccessor(articleId: string) {
|
|
410
|
-
return {
|
|
411
|
-
where(_col: string, _op: string, val: unknown) {
|
|
412
|
-
return {
|
|
413
|
-
paginate: async (_p: number, _pp: number) => {
|
|
414
|
-
const id = String(val)
|
|
415
|
-
const attached = pivot.get(articleId) ?? new Set()
|
|
416
|
-
const data = attached.has(id) ? [tagRows.find(r => String(r['id']) === id)!] : []
|
|
417
|
-
return { data, total: data.length }
|
|
418
|
-
},
|
|
419
|
-
}
|
|
420
|
-
},
|
|
421
|
-
paginate: async (_p: number, _pp: number) => {
|
|
422
|
-
const attached = pivot.get(articleId) ?? new Set()
|
|
423
|
-
const data = tagRows.filter(r => attached.has(String(r['id'])))
|
|
424
|
-
return { data, total: data.length }
|
|
425
|
-
},
|
|
426
|
-
attach: async (input: unknown) => {
|
|
427
|
-
const ids = Array.isArray(input) ? input.map(String) : [String(input)]
|
|
428
|
-
const set = pivot.get(articleId) ?? new Set()
|
|
429
|
-
for (const id of ids) set.add(id)
|
|
430
|
-
pivot.set(articleId, set)
|
|
431
|
-
},
|
|
432
|
-
detach: async (input: unknown) => {
|
|
433
|
-
const ids = Array.isArray(input) ? input.map(String) : input === undefined ? [] : [String(input)]
|
|
434
|
-
const set = pivot.get(articleId) ?? new Set()
|
|
435
|
-
let n = 0
|
|
436
|
-
for (const id of ids) { if (set.delete(id)) n++ }
|
|
437
|
-
return n
|
|
438
|
-
},
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
const ArticleModel: ModelLike = {
|
|
443
|
-
async find(id) {
|
|
444
|
-
const articleId = String(id)
|
|
445
|
-
if (!pivot.has(articleId)) return null
|
|
446
|
-
return {
|
|
447
|
-
id: articleId,
|
|
448
|
-
related: (_n: string) => makeRelatedAccessor(articleId) as never,
|
|
449
|
-
}
|
|
450
|
-
},
|
|
451
|
-
async create() { throw new Error('not used') },
|
|
452
|
-
async update() { throw new Error('not used') },
|
|
453
|
-
async delete() { /* no-op */ },
|
|
454
|
-
query(): ModelQuery { return findAdapter((this as ModelLike).find as (id: string) => Promise<unknown>) },
|
|
455
|
-
}
|
|
456
|
-
// Tag the Article-side relations map with the M2M discriminator so
|
|
457
|
-
// `getRelationType` flips the manager mode to the requested variant.
|
|
458
|
-
Object.assign(ArticleModel as object, {
|
|
459
|
-
relations: { tags: { type: morphMode, model: () => TagModel } },
|
|
460
|
-
})
|
|
461
|
-
|
|
462
|
-
class TagResource extends Resource {
|
|
463
|
-
static override label = 'Tags'
|
|
464
|
-
static override labelSingular = 'Tag'
|
|
465
|
-
static override slug = 'tags'
|
|
466
|
-
static override get model() { return TagModel }
|
|
467
|
-
}
|
|
468
|
-
class TagsManager extends RelationManager {
|
|
469
|
-
static override relationship = 'tags'
|
|
470
|
-
static override table(t: Table): Table { return t.columns([Column.make('name').sortable()]) }
|
|
471
|
-
}
|
|
472
|
-
class ArticleResource extends Resource {
|
|
473
|
-
static override label = 'Articles'
|
|
474
|
-
static override slug = 'articles'
|
|
475
|
-
static override get model() { return ArticleModel }
|
|
476
|
-
static override relations() { return [TagsManager] }
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
const panel = Pilotiq.make('T').path('/admin').resources([ArticleResource, TagResource])
|
|
480
|
-
return { panel, ArticleResource, TagResource, TagsManager, pivot, tagRows }
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
describe('relation routes — M2M registration', () => {
|
|
484
|
-
let router: Router
|
|
485
|
-
beforeEach(() => { router = new Router() })
|
|
486
|
-
|
|
487
|
-
it('mounts manager-scoped _action and _detach routes for every manager', () => {
|
|
488
|
-
const { panel } = buildWorld() // hasMany world — _action still mounts unconditionally
|
|
489
|
-
registerPilotiqRoutes(router, panel)
|
|
490
|
-
const paths = router.list().map(r => `${r.method} ${r.path}`)
|
|
491
|
-
assert.ok(paths.includes('POST /admin/users/:id/posts/_action/:actionName'))
|
|
492
|
-
assert.ok(paths.includes('POST /admin/users/:id/posts/:childId/_detach'))
|
|
493
|
-
})
|
|
494
|
-
|
|
495
|
-
it('mounts the same manager-scoped routes for M2M managers', () => {
|
|
496
|
-
const { panel } = buildM2MWorld()
|
|
497
|
-
registerPilotiqRoutes(router, panel)
|
|
498
|
-
const paths = router.list().map(r => `${r.method} ${r.path}`)
|
|
499
|
-
assert.ok(paths.includes('POST /admin/articles/:id/tags/_action/:actionName'))
|
|
500
|
-
assert.ok(paths.includes('POST /admin/articles/:id/tags/:childId/_detach'))
|
|
501
|
-
})
|
|
502
|
-
})
|
|
503
|
-
|
|
504
|
-
describe('relation routes — _detach (M2M)', () => {
|
|
505
|
-
let router: Router
|
|
506
|
-
beforeEach(() => { router = new Router() })
|
|
507
|
-
|
|
508
|
-
it('detaches an attached tag and redirects to the list', async () => {
|
|
509
|
-
const { panel, pivot } = buildM2MWorld()
|
|
510
|
-
registerPilotiqRoutes(router, panel)
|
|
511
|
-
const route = router.list().find(r => r.path === '/admin/articles/:id/tags/:childId/_detach' && r.method === 'POST')!
|
|
512
|
-
const { res } = await callHandler(
|
|
513
|
-
route.handler,
|
|
514
|
-
fakeReq({ params: { id: 'a1', childId: 't1' } }),
|
|
515
|
-
)
|
|
516
|
-
assert.equal(res.redirectedTo?.url, '/admin/articles/a1/tags')
|
|
517
|
-
assert.equal(res.redirectedTo?.code, 303)
|
|
518
|
-
assert.equal(pivot.get('a1')?.has('t1'), false)
|
|
519
|
-
assert.equal(pivot.get('a1')?.has('t2'), true)
|
|
520
|
-
})
|
|
521
|
-
|
|
522
|
-
it('IDOR-404s when the tag is not attached to this article', async () => {
|
|
523
|
-
const { panel, pivot } = buildM2MWorld()
|
|
524
|
-
registerPilotiqRoutes(router, panel)
|
|
525
|
-
const route = router.list().find(r => r.path === '/admin/articles/:id/tags/:childId/_detach' && r.method === 'POST')!
|
|
526
|
-
const { res } = await callHandler(
|
|
527
|
-
route.handler,
|
|
528
|
-
fakeReq({ params: { id: 'a1', childId: 't3' } }), // t3 isn't attached
|
|
529
|
-
)
|
|
530
|
-
assert.equal(res.statusCode, 404)
|
|
531
|
-
// Pivot untouched.
|
|
532
|
-
assert.equal(pivot.get('a1')?.size, 2)
|
|
533
|
-
})
|
|
534
|
-
|
|
535
|
-
it('404s when the manager mode is hasMany (not M2M)', async () => {
|
|
536
|
-
const { panel, postRows } = buildWorld() // hasMany world
|
|
537
|
-
registerPilotiqRoutes(router, panel)
|
|
538
|
-
const route = router.list().find(r => r.path === '/admin/users/:id/posts/:childId/_detach' && r.method === 'POST')!
|
|
539
|
-
const { res } = await callHandler(
|
|
540
|
-
route.handler,
|
|
541
|
-
fakeReq({ params: { id: 'u1', childId: 'p1' } }),
|
|
542
|
-
)
|
|
543
|
-
assert.equal(res.statusCode, 404)
|
|
544
|
-
// Error message lists every M2M mode pilotiq accepts so the user
|
|
545
|
-
// knows what to declare on the parent's `static relations` map.
|
|
546
|
-
assert.match(String(res.sentBody), /belongsToMany/)
|
|
547
|
-
assert.match(String(res.sentBody), /morphToMany/)
|
|
548
|
-
assert.match(String(res.sentBody), /morphedByMany/)
|
|
549
|
-
// Underlying record unchanged.
|
|
550
|
-
assert.ok(postRows.some(r => r['id'] === 'p1'))
|
|
551
|
-
})
|
|
552
|
-
|
|
553
|
-
it('403s when manager.canDetach denies', async () => {
|
|
554
|
-
const { panel } = buildM2MWorld()
|
|
555
|
-
const M = panel.getConfig().resources[0]!.relations()[0]!
|
|
556
|
-
;(M as unknown as { canDetach: () => Promise<boolean> }).canDetach = async () => false
|
|
557
|
-
registerPilotiqRoutes(router, panel)
|
|
558
|
-
const route = router.list().find(r => r.path === '/admin/articles/:id/tags/:childId/_detach' && r.method === 'POST')!
|
|
559
|
-
const { res } = await callHandler(
|
|
560
|
-
route.handler,
|
|
561
|
-
fakeReq({ params: { id: 'a1', childId: 't1' } }),
|
|
562
|
-
)
|
|
563
|
-
assert.equal(res.statusCode, 403)
|
|
564
|
-
})
|
|
565
|
-
})
|
|
566
|
-
|
|
567
|
-
describe('relation routes — _action (manager-scoped)', () => {
|
|
568
|
-
let router: Router
|
|
569
|
-
beforeEach(() => { router = new Router() })
|
|
570
|
-
|
|
571
|
-
it('dispatches a handler-style action with ctx.relation stamped', async () => {
|
|
572
|
-
const { panel, pivot } = buildM2MWorld()
|
|
573
|
-
// Wire up `relationAttach` on the manager's table so we have a
|
|
574
|
-
// dispatchable handler-style action to fire. Using the real factory
|
|
575
|
-
// exercises the full pipeline.
|
|
576
|
-
const TagsManager = panel.getConfig().resources[0]!.relations()[0]!
|
|
577
|
-
const originalTable = TagsManager.table.bind(TagsManager)
|
|
578
|
-
;(TagsManager as unknown as { table: typeof TagsManager.table }).table = (t, ctx) => {
|
|
579
|
-
return originalTable(t, ctx).headerActions([Action.relationAttach(TagsManager, ctx)])
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
registerPilotiqRoutes(router, panel)
|
|
583
|
-
const route = router.list().find(r => r.path === '/admin/articles/:id/tags/_action/:actionName' && r.method === 'POST')!
|
|
584
|
-
const { res } = await callHandler(
|
|
585
|
-
route.handler,
|
|
586
|
-
fakeReq({
|
|
587
|
-
params: { id: 'a2', actionName: 'relationAttach' },
|
|
588
|
-
body: { _attachId: 't3', ids: [] },
|
|
589
|
-
headers: { accept: 'application/json' },
|
|
590
|
-
}),
|
|
591
|
-
)
|
|
592
|
-
|
|
593
|
-
const body = res.sentBody as { ok: boolean; redirect?: string; notifications?: Array<{ title: string }> }
|
|
594
|
-
assert.equal(body.ok, true)
|
|
595
|
-
// Pivot state mutated by the handler — proves ctx.relation was stamped.
|
|
596
|
-
assert.equal(pivot.get('a2')?.has('t3'), true)
|
|
597
|
-
assert.match(body.notifications?.[0]?.title ?? '', /attached/)
|
|
598
|
-
})
|
|
599
|
-
|
|
600
|
-
it('404s when the named action is not registered on the manager', async () => {
|
|
601
|
-
const { panel } = buildM2MWorld()
|
|
602
|
-
registerPilotiqRoutes(router, panel)
|
|
603
|
-
const route = router.list().find(r => r.path === '/admin/articles/:id/tags/_action/:actionName' && r.method === 'POST')!
|
|
604
|
-
const { res } = await callHandler(
|
|
605
|
-
route.handler,
|
|
606
|
-
fakeReq({
|
|
607
|
-
params: { id: 'a1', actionName: 'unknownAction' },
|
|
608
|
-
body: {},
|
|
609
|
-
headers: { accept: 'application/json' },
|
|
610
|
-
}),
|
|
611
|
-
)
|
|
612
|
-
assert.equal(res.statusCode, 404)
|
|
613
|
-
})
|
|
614
|
-
|
|
615
|
-
it('403s when parent canEdit denies', async () => {
|
|
616
|
-
const { panel } = buildM2MWorld()
|
|
617
|
-
const R = panel.getConfig().resources[0]!
|
|
618
|
-
;(R as unknown as { canEdit: () => Promise<boolean> }).canEdit = async () => false
|
|
619
|
-
registerPilotiqRoutes(router, panel)
|
|
620
|
-
const route = router.list().find(r => r.path === '/admin/articles/:id/tags/_action/:actionName' && r.method === 'POST')!
|
|
621
|
-
const { res } = await callHandler(
|
|
622
|
-
route.handler,
|
|
623
|
-
fakeReq({
|
|
624
|
-
params: { id: 'a1', actionName: 'relationAttach' },
|
|
625
|
-
headers: { accept: 'application/json' },
|
|
626
|
-
}),
|
|
627
|
-
)
|
|
628
|
-
assert.equal(res.statusCode, 403)
|
|
629
|
-
})
|
|
630
|
-
})
|
|
631
|
-
|
|
632
|
-
// ── Polymorphic M2M follow-up: morphToMany / morphedByMany ──────────
|
|
633
|
-
//
|
|
634
|
-
// Both modes share the `belongsToMany` accessor surface (attach /
|
|
635
|
-
// detach / sync). Pilotiq's plumbing is mode-agnostic beyond the
|
|
636
|
-
// `_detach` 404 gate + visibility predicates — the tests below confirm
|
|
637
|
-
// the same routes and stub accessors that worked for `belongsToMany`
|
|
638
|
-
// also work for the morph variants.
|
|
639
|
-
|
|
640
|
-
describe('relation routes — morphToMany (owning polymorphic side)', () => {
|
|
641
|
-
let router: Router
|
|
642
|
-
beforeEach(() => { router = new Router() })
|
|
643
|
-
|
|
644
|
-
it('mounts the manager-scoped routes for morphToMany managers', () => {
|
|
645
|
-
const { panel } = buildM2MWorld('morphToMany')
|
|
646
|
-
registerPilotiqRoutes(router, panel)
|
|
647
|
-
const paths = router.list().map(r => `${r.method} ${r.path}`)
|
|
648
|
-
assert.ok(paths.includes('POST /admin/articles/:id/tags/_action/:actionName'))
|
|
649
|
-
assert.ok(paths.includes('POST /admin/articles/:id/tags/:childId/_detach'))
|
|
650
|
-
})
|
|
651
|
-
|
|
652
|
-
it('detaches an attached tag and redirects to the list (morphToMany)', async () => {
|
|
653
|
-
const { panel, pivot } = buildM2MWorld('morphToMany')
|
|
654
|
-
registerPilotiqRoutes(router, panel)
|
|
655
|
-
const route = router.list().find(r => r.path === '/admin/articles/:id/tags/:childId/_detach' && r.method === 'POST')!
|
|
656
|
-
const { res } = await callHandler(
|
|
657
|
-
route.handler,
|
|
658
|
-
fakeReq({ params: { id: 'a1', childId: 't1' } }),
|
|
659
|
-
)
|
|
660
|
-
assert.equal(res.redirectedTo?.url, '/admin/articles/a1/tags')
|
|
661
|
-
assert.equal(res.redirectedTo?.code, 303)
|
|
662
|
-
assert.equal(pivot.get('a1')?.has('t1'), false)
|
|
663
|
-
assert.equal(pivot.get('a1')?.has('t2'), true)
|
|
664
|
-
})
|
|
665
|
-
|
|
666
|
-
it('IDOR-404s when the tag is not attached (morphToMany)', async () => {
|
|
667
|
-
const { panel, pivot } = buildM2MWorld('morphToMany')
|
|
668
|
-
registerPilotiqRoutes(router, panel)
|
|
669
|
-
const route = router.list().find(r => r.path === '/admin/articles/:id/tags/:childId/_detach' && r.method === 'POST')!
|
|
670
|
-
const { res } = await callHandler(
|
|
671
|
-
route.handler,
|
|
672
|
-
fakeReq({ params: { id: 'a1', childId: 't3' } }),
|
|
673
|
-
)
|
|
674
|
-
assert.equal(res.statusCode, 404)
|
|
675
|
-
assert.equal(pivot.get('a1')?.size, 2)
|
|
676
|
-
})
|
|
677
|
-
|
|
678
|
-
it('dispatches relationAttach with ctx.relation stamped (morphToMany)', async () => {
|
|
679
|
-
const { panel, pivot } = buildM2MWorld('morphToMany')
|
|
680
|
-
const TagsManager = panel.getConfig().resources[0]!.relations()[0]!
|
|
681
|
-
const originalTable = TagsManager.table.bind(TagsManager)
|
|
682
|
-
;(TagsManager as unknown as { table: typeof TagsManager.table }).table = (t, ctx) => {
|
|
683
|
-
return originalTable(t, ctx).headerActions([Action.relationAttach(TagsManager, ctx)])
|
|
684
|
-
}
|
|
685
|
-
registerPilotiqRoutes(router, panel)
|
|
686
|
-
const route = router.list().find(r => r.path === '/admin/articles/:id/tags/_action/:actionName' && r.method === 'POST')!
|
|
687
|
-
const { res } = await callHandler(
|
|
688
|
-
route.handler,
|
|
689
|
-
fakeReq({
|
|
690
|
-
params: { id: 'a2', actionName: 'relationAttach' },
|
|
691
|
-
body: { _attachId: 't3', ids: [] },
|
|
692
|
-
headers: { accept: 'application/json' },
|
|
693
|
-
}),
|
|
694
|
-
)
|
|
695
|
-
const body = res.sentBody as { ok: boolean }
|
|
696
|
-
assert.equal(body.ok, true)
|
|
697
|
-
assert.equal(pivot.get('a2')?.has('t3'), true)
|
|
698
|
-
})
|
|
699
|
-
})
|
|
700
|
-
|
|
701
|
-
describe('relation routes — morphedByMany (inverse polymorphic side)', () => {
|
|
702
|
-
let router: Router
|
|
703
|
-
beforeEach(() => { router = new Router() })
|
|
704
|
-
|
|
705
|
-
it('mounts the manager-scoped routes for morphedByMany managers', () => {
|
|
706
|
-
const { panel } = buildM2MWorld('morphedByMany')
|
|
707
|
-
registerPilotiqRoutes(router, panel)
|
|
708
|
-
const paths = router.list().map(r => `${r.method} ${r.path}`)
|
|
709
|
-
assert.ok(paths.includes('POST /admin/articles/:id/tags/_action/:actionName'))
|
|
710
|
-
assert.ok(paths.includes('POST /admin/articles/:id/tags/:childId/_detach'))
|
|
711
|
-
})
|
|
712
|
-
|
|
713
|
-
it('detaches an attached tag and redirects to the list (morphedByMany)', async () => {
|
|
714
|
-
const { panel, pivot } = buildM2MWorld('morphedByMany')
|
|
715
|
-
registerPilotiqRoutes(router, panel)
|
|
716
|
-
const route = router.list().find(r => r.path === '/admin/articles/:id/tags/:childId/_detach' && r.method === 'POST')!
|
|
717
|
-
const { res } = await callHandler(
|
|
718
|
-
route.handler,
|
|
719
|
-
fakeReq({ params: { id: 'a1', childId: 't1' } }),
|
|
720
|
-
)
|
|
721
|
-
assert.equal(res.redirectedTo?.url, '/admin/articles/a1/tags')
|
|
722
|
-
assert.equal(res.redirectedTo?.code, 303)
|
|
723
|
-
assert.equal(pivot.get('a1')?.has('t1'), false)
|
|
724
|
-
})
|
|
725
|
-
|
|
726
|
-
it('dispatches relationAttach with ctx.relation stamped (morphedByMany)', async () => {
|
|
727
|
-
const { panel, pivot } = buildM2MWorld('morphedByMany')
|
|
728
|
-
const TagsManager = panel.getConfig().resources[0]!.relations()[0]!
|
|
729
|
-
const originalTable = TagsManager.table.bind(TagsManager)
|
|
730
|
-
;(TagsManager as unknown as { table: typeof TagsManager.table }).table = (t, ctx) => {
|
|
731
|
-
return originalTable(t, ctx).headerActions([Action.relationAttach(TagsManager, ctx)])
|
|
732
|
-
}
|
|
733
|
-
registerPilotiqRoutes(router, panel)
|
|
734
|
-
const route = router.list().find(r => r.path === '/admin/articles/:id/tags/_action/:actionName' && r.method === 'POST')!
|
|
735
|
-
const { res } = await callHandler(
|
|
736
|
-
route.handler,
|
|
737
|
-
fakeReq({
|
|
738
|
-
params: { id: 'a2', actionName: 'relationAttach' },
|
|
739
|
-
body: { _attachId: 't3', ids: [] },
|
|
740
|
-
headers: { accept: 'application/json' },
|
|
741
|
-
}),
|
|
742
|
-
)
|
|
743
|
-
const body = res.sentBody as { ok: boolean }
|
|
744
|
-
assert.equal(body.ok, true)
|
|
745
|
-
assert.equal(pivot.get('a2')?.has('t3'), true)
|
|
746
|
-
})
|
|
747
|
-
})
|
|
748
|
-
|
|
749
|
-
// ── Polymorphic follow-up: morphMany auto-injection ─────────────────
|
|
750
|
-
|
|
751
|
-
/** World builder for a polymorphic `morphMany` relation:
|
|
752
|
-
* Post.comments → Comment.commentable
|
|
753
|
-
* Video.comments → Comment.commentable
|
|
754
|
-
* Children carry `commentableId` + `commentableType`. The discriminator
|
|
755
|
-
* defaults to the parent's `class.morphAlias ?? class.name`. */
|
|
756
|
-
function buildMorphWorld() {
|
|
757
|
-
const commentRows: Row[] = [
|
|
758
|
-
{ id: 'c1', commentableId: 'p1', commentableType: 'Post', body: 'Existing on post' },
|
|
759
|
-
{ id: 'c2', commentableId: 'v1', commentableType: 'Video', body: 'Existing on video' },
|
|
760
|
-
]
|
|
761
|
-
|
|
762
|
-
const CommentModel: ModelLike = {
|
|
763
|
-
async find(id) { return commentRows.find(r => String(r['id']) === String(id)) ?? null },
|
|
764
|
-
async create(data) {
|
|
765
|
-
const n: Row = { id: `c${commentRows.length + 1}`, ...data }
|
|
766
|
-
commentRows.push(n)
|
|
767
|
-
return n
|
|
768
|
-
},
|
|
769
|
-
async update(id, data) {
|
|
770
|
-
const r = commentRows.find(r => String(r['id']) === String(id))
|
|
771
|
-
if (r) Object.assign(r, data)
|
|
772
|
-
return r ?? null
|
|
773
|
-
},
|
|
774
|
-
async delete(id) {
|
|
775
|
-
const i = commentRows.findIndex(r => String(r['id']) === String(id))
|
|
776
|
-
if (i >= 0) commentRows.splice(i, 1)
|
|
777
|
-
},
|
|
778
|
-
query() { return new StubQuery(commentRows) },
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
// Parent factory — returns a record whose constructor.name doubles as
|
|
782
|
-
// the morph discriminator (mirrors rudder's runtime where the live
|
|
783
|
-
// record is an instance of `class Post extends Model {}`). We fake the
|
|
784
|
-
// class identity via `Object.setPrototypeOf`.
|
|
785
|
-
function makeParentRecord(klass: { name: string; morphAlias?: string; primaryKey?: string }, id: string) {
|
|
786
|
-
const rec = {
|
|
787
|
-
[klass.primaryKey ?? 'id']: id,
|
|
788
|
-
related(_n: string) {
|
|
789
|
-
return new StubQuery(commentRows.filter(r => r['commentableId'] === id && r['commentableType'] === (klass.morphAlias ?? klass.name)))
|
|
790
|
-
},
|
|
791
|
-
}
|
|
792
|
-
Object.setPrototypeOf(rec, { constructor: klass })
|
|
793
|
-
return rec
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
const PostClass = { name: 'Post', primaryKey: 'id' }
|
|
797
|
-
const VideoClass = { name: 'Video', primaryKey: 'id' }
|
|
798
|
-
|
|
799
|
-
const PostModel: ModelLike = {
|
|
800
|
-
async find(id) {
|
|
801
|
-
if (String(id) === 'p1') return makeParentRecord(PostClass, 'p1')
|
|
802
|
-
return null
|
|
803
|
-
},
|
|
804
|
-
async create() { throw new Error('not used') },
|
|
805
|
-
async update() { throw new Error('not used') },
|
|
806
|
-
async delete() { /* no-op */ },
|
|
807
|
-
query(): ModelQuery { return findAdapter((this as ModelLike).find as (id: string) => Promise<unknown>) },
|
|
808
|
-
}
|
|
809
|
-
Object.assign(PostModel as object, {
|
|
810
|
-
relations: { comments: { type: 'morphMany', model: () => CommentModel, morphName: 'commentable' } },
|
|
811
|
-
})
|
|
812
|
-
|
|
813
|
-
const VideoModel: ModelLike = {
|
|
814
|
-
async find(id) {
|
|
815
|
-
if (String(id) === 'v1') return makeParentRecord(VideoClass, 'v1')
|
|
816
|
-
return null
|
|
817
|
-
},
|
|
818
|
-
async create() { throw new Error('not used') },
|
|
819
|
-
async update() { throw new Error('not used') },
|
|
820
|
-
async delete() { /* no-op */ },
|
|
821
|
-
query(): ModelQuery { return findAdapter((this as ModelLike).find as (id: string) => Promise<unknown>) },
|
|
822
|
-
}
|
|
823
|
-
Object.assign(VideoModel as object, {
|
|
824
|
-
relations: { comments: { type: 'morphMany', model: () => CommentModel, morphName: 'commentable' } },
|
|
825
|
-
})
|
|
826
|
-
|
|
827
|
-
class CommentResource extends Resource {
|
|
828
|
-
static override label = 'Comments'
|
|
829
|
-
static override labelSingular = 'Comment'
|
|
830
|
-
static override slug = 'comments'
|
|
831
|
-
static override get model() { return CommentModel }
|
|
832
|
-
static override form(form: Form): Form { return form.schema([TextField.make('body').required()]) }
|
|
833
|
-
}
|
|
834
|
-
class CommentsManager extends RelationManager {
|
|
835
|
-
static override relationship = 'comments'
|
|
836
|
-
static override table(t: Table): Table { return t.columns([Column.make('body')]) }
|
|
837
|
-
static override form(f: Form): Form { return f.schema([TextField.make('body').required()]) }
|
|
838
|
-
}
|
|
839
|
-
class PostResource extends Resource {
|
|
840
|
-
static override label = 'Posts'
|
|
841
|
-
static override slug = 'posts'
|
|
842
|
-
static override get model() { return PostModel }
|
|
843
|
-
static override relations() { return [CommentsManager] }
|
|
844
|
-
}
|
|
845
|
-
class VideoResource extends Resource {
|
|
846
|
-
static override label = 'Videos'
|
|
847
|
-
static override slug = 'videos'
|
|
848
|
-
static override get model() { return VideoModel }
|
|
849
|
-
static override relations() { return [CommentsManager] }
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
const panel = Pilotiq.make('T').path('/admin').resources([PostResource, VideoResource, CommentResource])
|
|
853
|
-
return { panel, PostResource, VideoResource, CommentResource, CommentsManager, commentRows }
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
describe('relation routes — polymorphic morphMany', () => {
|
|
857
|
-
let router: Router
|
|
858
|
-
beforeEach(() => { router = new Router() })
|
|
859
|
-
|
|
860
|
-
it('auto-injects commentableId / commentableType on create POST', async () => {
|
|
861
|
-
const { panel, commentRows } = buildMorphWorld()
|
|
862
|
-
registerPilotiqRoutes(router, panel)
|
|
863
|
-
const route = router.list().find(r => r.path === '/admin/posts/:id/comments/create' && r.method === 'POST')!
|
|
864
|
-
const { res } = await callHandler(
|
|
865
|
-
route.handler,
|
|
866
|
-
fakeReq({ params: { id: 'p1' }, body: { body: 'Polymorphic child' } }),
|
|
867
|
-
)
|
|
868
|
-
assert.equal(res.redirectedTo?.code, 303)
|
|
869
|
-
const created = commentRows.find(r => r['body'] === 'Polymorphic child')
|
|
870
|
-
assert.ok(created, 'expected the new comment to be persisted')
|
|
871
|
-
assert.equal(created['commentableId'], 'p1')
|
|
872
|
-
assert.equal(created['commentableType'], 'Post')
|
|
873
|
-
})
|
|
874
|
-
|
|
875
|
-
it('uses the parent class.name (Video) as the discriminator for the second parent', async () => {
|
|
876
|
-
const { panel, commentRows } = buildMorphWorld()
|
|
877
|
-
registerPilotiqRoutes(router, panel)
|
|
878
|
-
const route = router.list().find(r => r.path === '/admin/videos/:id/comments/create' && r.method === 'POST')!
|
|
879
|
-
await callHandler(
|
|
880
|
-
route.handler,
|
|
881
|
-
fakeReq({ params: { id: 'v1' }, body: { body: 'On a video' } }),
|
|
882
|
-
)
|
|
883
|
-
const created = commentRows.find(r => r['body'] === 'On a video')
|
|
884
|
-
assert.ok(created)
|
|
885
|
-
assert.equal(created['commentableId'], 'v1')
|
|
886
|
-
assert.equal(created['commentableType'], 'Video')
|
|
887
|
-
})
|
|
888
|
-
|
|
889
|
-
it('overwrites tampered commentableId / commentableType in the body (anti-tamper)', async () => {
|
|
890
|
-
const { panel, commentRows } = buildMorphWorld()
|
|
891
|
-
registerPilotiqRoutes(router, panel)
|
|
892
|
-
const route = router.list().find(r => r.path === '/admin/posts/:id/comments/create' && r.method === 'POST')!
|
|
893
|
-
await callHandler(
|
|
894
|
-
route.handler,
|
|
895
|
-
fakeReq({
|
|
896
|
-
params: { id: 'p1' },
|
|
897
|
-
// Attacker tries to redirect ownership to v1/Video.
|
|
898
|
-
body: { body: 'Hijacked', commentableId: 'v1', commentableType: 'Video' },
|
|
899
|
-
}),
|
|
900
|
-
)
|
|
901
|
-
const created = commentRows.find(r => r['body'] === 'Hijacked')
|
|
902
|
-
assert.ok(created)
|
|
903
|
-
// Framework wins — child still owned by the URL-scoped parent.
|
|
904
|
-
assert.equal(created['commentableId'], 'p1')
|
|
905
|
-
assert.equal(created['commentableType'], 'Post')
|
|
906
|
-
})
|
|
907
|
-
|
|
908
|
-
it('composes with a user-supplied mutateDataBeforeCreate (user runs first, framework wins last)', async () => {
|
|
909
|
-
const { panel, commentRows } = buildMorphWorld()
|
|
910
|
-
// Mutate the registered manager's form to add a default body via user hook.
|
|
911
|
-
const M = panel.getConfig().resources[0]!.relations()[0]!
|
|
912
|
-
;(M as unknown as { form: (f: Form) => Form }).form = (f: Form) =>
|
|
913
|
-
f.schema([TextField.make('body').required()])
|
|
914
|
-
.mutateDataBeforeCreate(async (data) => ({ ...data, audited: true, commentableType: 'Tampered' }))
|
|
915
|
-
|
|
916
|
-
registerPilotiqRoutes(router, panel)
|
|
917
|
-
const route = router.list().find(r => r.path === '/admin/posts/:id/comments/create' && r.method === 'POST')!
|
|
918
|
-
await callHandler(
|
|
919
|
-
route.handler,
|
|
920
|
-
fakeReq({ params: { id: 'p1' }, body: { body: 'Composed' } }),
|
|
921
|
-
)
|
|
922
|
-
const created = commentRows.find(r => r['body'] === 'Composed')
|
|
923
|
-
assert.ok(created)
|
|
924
|
-
// User hook ran (audited stamped) AND framework morph injection won
|
|
925
|
-
// for the morph columns themselves.
|
|
926
|
-
assert.equal(created['audited'], true)
|
|
927
|
-
assert.equal(created['commentableId'], 'p1')
|
|
928
|
-
assert.equal(created['commentableType'], 'Post')
|
|
929
|
-
})
|
|
930
|
-
|
|
931
|
-
it('honors parent.constructor.morphAlias when set', async () => {
|
|
932
|
-
const { panel, commentRows } = buildMorphWorld()
|
|
933
|
-
// Replace PostModel.find to return a record whose ctor exposes morphAlias.
|
|
934
|
-
const PostR = panel.getConfig().resources[0]!
|
|
935
|
-
const PostM = PostR.model!
|
|
936
|
-
const original = PostM.find.bind(PostM)
|
|
937
|
-
;(PostM as unknown as { find: (id: unknown) => Promise<unknown> }).find = async (id: unknown) => {
|
|
938
|
-
const rec = await original(id as string)
|
|
939
|
-
if (!rec) return rec
|
|
940
|
-
const klass = { name: 'Post', morphAlias: 'post', primaryKey: 'id' }
|
|
941
|
-
Object.setPrototypeOf(rec as object, { constructor: klass })
|
|
942
|
-
return rec
|
|
943
|
-
}
|
|
944
|
-
|
|
945
|
-
registerPilotiqRoutes(router, panel)
|
|
946
|
-
const route = router.list().find(r => r.path === '/admin/posts/:id/comments/create' && r.method === 'POST')!
|
|
947
|
-
await callHandler(
|
|
948
|
-
route.handler,
|
|
949
|
-
fakeReq({ params: { id: 'p1' }, body: { body: 'Aliased' } }),
|
|
950
|
-
)
|
|
951
|
-
const created = commentRows.find(r => r['body'] === 'Aliased')
|
|
952
|
-
assert.ok(created)
|
|
953
|
-
assert.equal(created['commentableType'], 'post') // alias, not class name
|
|
954
|
-
})
|
|
955
|
-
|
|
956
|
-
it('re-stamps morph columns on edit POST so a tampered body cannot reassign ownership', async () => {
|
|
957
|
-
const { panel, commentRows } = buildMorphWorld()
|
|
958
|
-
registerPilotiqRoutes(router, panel)
|
|
959
|
-
const route = router.list().find(r => r.path === '/admin/posts/:id/comments/:childId/edit' && r.method === 'POST')!
|
|
960
|
-
await callHandler(
|
|
961
|
-
route.handler,
|
|
962
|
-
fakeReq({
|
|
963
|
-
params: { id: 'p1', childId: 'c1' },
|
|
964
|
-
body: { body: 'Edited', commentableId: 'v1', commentableType: 'Video' },
|
|
965
|
-
}),
|
|
966
|
-
)
|
|
967
|
-
const c1 = commentRows.find(r => r['id'] === 'c1')!
|
|
968
|
-
assert.equal(c1['body'], 'Edited')
|
|
969
|
-
assert.equal(c1['commentableId'], 'p1')
|
|
970
|
-
assert.equal(c1['commentableType'], 'Post')
|
|
971
|
-
})
|
|
972
|
-
})
|