@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,1923 +0,0 @@
|
|
|
1
|
-
import { describe, it } from 'node:test'
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
|
|
4
|
-
import { RepeaterField } from './RepeaterField.js'
|
|
5
|
-
import { TextField } from './TextField.js'
|
|
6
|
-
import { Form } from '../elements/Form.js'
|
|
7
|
-
import { dispatchFormSubmit, extractRelationshipRepeaters, loadRelationRows } from '../elements/dispatchForm.js'
|
|
8
|
-
import { applyRelationshipRepeaterFill } from '../pageData.js'
|
|
9
|
-
import type { ModelLike, ModelQuery } from '../orm/modelDefaults.js'
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Test harness: a tiny in-memory ModelLike with a `paginate`-shaped
|
|
13
|
-
* query and basic CRUD methods that record their calls. Lets the
|
|
14
|
-
* relationship tests assert the exact sequence of create / update /
|
|
15
|
-
* delete operations against a shared fake without spinning up a
|
|
16
|
-
* database.
|
|
17
|
-
*/
|
|
18
|
-
interface FakeRecord extends Record<string, unknown> {
|
|
19
|
-
id?: string | number
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function makeFakeChildModel(initial: FakeRecord[] = []) {
|
|
23
|
-
let nextId = 1
|
|
24
|
-
const rows: FakeRecord[] = initial.map(r => ({ ...r }))
|
|
25
|
-
const calls: Array<
|
|
26
|
-
| { kind: 'create'; data: Record<string, unknown> }
|
|
27
|
-
| { kind: 'update'; id: string | number; data: Record<string, unknown> }
|
|
28
|
-
| { kind: 'delete'; id: string | number }
|
|
29
|
-
> = []
|
|
30
|
-
|
|
31
|
-
const model: ModelLike = {
|
|
32
|
-
primaryKey: 'id',
|
|
33
|
-
find: async (id) => rows.find(r => String(r.id) === String(id)) ?? null,
|
|
34
|
-
create: async (data) => {
|
|
35
|
-
calls.push({ kind: 'create', data: { ...data } })
|
|
36
|
-
const id = (data['id'] as string | number | undefined) ?? `c${nextId++}`
|
|
37
|
-
const fresh: FakeRecord = { ...data, id }
|
|
38
|
-
rows.push(fresh)
|
|
39
|
-
return fresh
|
|
40
|
-
},
|
|
41
|
-
update: async (id, data) => {
|
|
42
|
-
calls.push({ kind: 'update', id, data: { ...data } })
|
|
43
|
-
const idx = rows.findIndex(r => String(r.id) === String(id))
|
|
44
|
-
if (idx >= 0) {
|
|
45
|
-
rows[idx] = { ...rows[idx], ...data, id }
|
|
46
|
-
}
|
|
47
|
-
return rows[idx]
|
|
48
|
-
},
|
|
49
|
-
delete: async (id) => {
|
|
50
|
-
calls.push({ kind: 'delete', id })
|
|
51
|
-
const idx = rows.findIndex(r => String(r.id) === String(id))
|
|
52
|
-
if (idx >= 0) rows.splice(idx, 1)
|
|
53
|
-
},
|
|
54
|
-
query: () => makeQuery(rows),
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
return { model, rows, calls }
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** Fake `ModelQuery` — only `paginate` is wired since that's all the
|
|
61
|
-
* relationship pipeline calls. Other methods chain back to the same
|
|
62
|
-
* query so `where` / `orderBy` are silently ignored. */
|
|
63
|
-
function makeQuery(rows: FakeRecord[]): ModelQuery {
|
|
64
|
-
const q: ModelQuery = {
|
|
65
|
-
where: () => q,
|
|
66
|
-
orWhere: () => q,
|
|
67
|
-
orderBy: () => q,
|
|
68
|
-
paginate: async () => ({ data: rows.slice(), total: rows.length }),
|
|
69
|
-
}
|
|
70
|
-
return q
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Fake parent model with a `relations` map matching the rudder ORM
|
|
75
|
-
* convention. The `relatedQuery` override pipes through to the child
|
|
76
|
-
* model's rows filtered by FK so calls behave like a real hasMany.
|
|
77
|
-
*/
|
|
78
|
-
function makeFakeParentModel(opts: {
|
|
79
|
-
childModel: ModelLike
|
|
80
|
-
childRows: FakeRecord[]
|
|
81
|
-
relationName: string
|
|
82
|
-
foreignKey: string
|
|
83
|
-
}): ModelLike {
|
|
84
|
-
const { childModel, childRows, relationName, foreignKey } = opts
|
|
85
|
-
const parent: ModelLike & { relations: Record<string, unknown> } = {
|
|
86
|
-
primaryKey: 'id',
|
|
87
|
-
find: async () => null,
|
|
88
|
-
create: async () => ({}),
|
|
89
|
-
update: async () => ({}),
|
|
90
|
-
delete: async () => {},
|
|
91
|
-
query: () => makeQuery([]),
|
|
92
|
-
relatedQuery: (parentRecord) => {
|
|
93
|
-
const parentId = (parentRecord as Record<string, unknown>)['id']
|
|
94
|
-
const filtered = childRows.filter(r => String(r[foreignKey]) === String(parentId))
|
|
95
|
-
return makeQuery(filtered)
|
|
96
|
-
},
|
|
97
|
-
relations: {
|
|
98
|
-
[relationName]: { type: 'hasMany', model: () => childModel, foreignKey },
|
|
99
|
-
},
|
|
100
|
-
}
|
|
101
|
-
return parent
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
describe('Repeater.relationship — extraction', () => {
|
|
105
|
-
it('extractRelationshipRepeaters pulls the field value out of data', () => {
|
|
106
|
-
const repeater = RepeaterField.make('items')
|
|
107
|
-
.relationship('items')
|
|
108
|
-
.schema([TextField.make('label').required()])
|
|
109
|
-
const data: Record<string, unknown> = {
|
|
110
|
-
title: 'Order #1',
|
|
111
|
-
items: [{ __id: '1', label: 'A' }, { label: 'B' }],
|
|
112
|
-
otherJsonRepeater: [{ x: 1 }],
|
|
113
|
-
}
|
|
114
|
-
const deferrals = extractRelationshipRepeaters([repeater], data)
|
|
115
|
-
assert.equal(deferrals.length, 1)
|
|
116
|
-
assert.equal(deferrals[0]!.cfg.name, 'items')
|
|
117
|
-
assert.deepEqual(deferrals[0]!.rows, [{ __id: '1', label: 'A' }, { label: 'B' }])
|
|
118
|
-
// Pulled out of `data`.
|
|
119
|
-
assert.equal('items' in data, false)
|
|
120
|
-
// Non-relationship keys untouched.
|
|
121
|
-
assert.equal(data['title'], 'Order #1')
|
|
122
|
-
assert.deepEqual(data['otherJsonRepeater'], [{ x: 1 }])
|
|
123
|
-
})
|
|
124
|
-
|
|
125
|
-
it('extractRelationshipRepeaters skips non-relationship Repeaters', () => {
|
|
126
|
-
const json = RepeaterField.make('jsonItems').schema([TextField.make('x')])
|
|
127
|
-
const rel = RepeaterField.make('relItems').relationship('relItems').schema([TextField.make('y')])
|
|
128
|
-
const data: Record<string, unknown> = { jsonItems: [{ x: 1 }], relItems: [{ y: 2 }] }
|
|
129
|
-
const deferrals = extractRelationshipRepeaters([json, rel], data)
|
|
130
|
-
assert.equal(deferrals.length, 1)
|
|
131
|
-
assert.equal(deferrals[0]!.cfg.name, 'relItems')
|
|
132
|
-
assert.equal('jsonItems' in data, true)
|
|
133
|
-
assert.equal('relItems' in data, false)
|
|
134
|
-
})
|
|
135
|
-
})
|
|
136
|
-
|
|
137
|
-
describe('Repeater.relationship — full pipeline', () => {
|
|
138
|
-
it('create — submits new rows with FK stamped, no existing rows', async () => {
|
|
139
|
-
const child = makeFakeChildModel([])
|
|
140
|
-
const parent = makeFakeParentModel({
|
|
141
|
-
childModel: child.model,
|
|
142
|
-
childRows: child.rows,
|
|
143
|
-
relationName: 'items',
|
|
144
|
-
foreignKey: 'orderId',
|
|
145
|
-
})
|
|
146
|
-
|
|
147
|
-
const form = Form.make()
|
|
148
|
-
.schema([
|
|
149
|
-
TextField.make('title'),
|
|
150
|
-
RepeaterField.make('items').relationship('items').schema([
|
|
151
|
-
TextField.make('label').required(),
|
|
152
|
-
]),
|
|
153
|
-
])
|
|
154
|
-
.save(async (data) => {
|
|
155
|
-
// Parent never sees the relationship key — extracted before save.
|
|
156
|
-
assert.equal('items' in data, false)
|
|
157
|
-
return { id: 'p1', title: data['title'] }
|
|
158
|
-
})
|
|
159
|
-
|
|
160
|
-
const result = await dispatchFormSubmit(
|
|
161
|
-
form,
|
|
162
|
-
{ title: 'Order', items: [{ label: 'A' }, { label: 'B' }] },
|
|
163
|
-
{ values: { title: 'Order', items: [{ label: 'A' }, { label: 'B' }] }, parentModel: parent },
|
|
164
|
-
)
|
|
165
|
-
|
|
166
|
-
assert.equal(result.ok, true)
|
|
167
|
-
// Two creates, no updates, no deletes.
|
|
168
|
-
assert.equal(child.calls.filter(c => c.kind === 'create').length, 2)
|
|
169
|
-
assert.equal(child.calls.filter(c => c.kind === 'update').length, 0)
|
|
170
|
-
assert.equal(child.calls.filter(c => c.kind === 'delete').length, 0)
|
|
171
|
-
// FK stamped on each create payload.
|
|
172
|
-
const creates = child.calls.filter(c => c.kind === 'create') as Array<{ kind: 'create'; data: Record<string, unknown> }>
|
|
173
|
-
assert.equal(creates[0]!.data['orderId'], 'p1')
|
|
174
|
-
assert.equal(creates[1]!.data['orderId'], 'p1')
|
|
175
|
-
assert.equal(creates[0]!.data['label'], 'A')
|
|
176
|
-
assert.equal(creates[1]!.data['label'], 'B')
|
|
177
|
-
})
|
|
178
|
-
|
|
179
|
-
it('update — submits __id matching existing PK; routed through update without overwriting FK', async () => {
|
|
180
|
-
const child = makeFakeChildModel([
|
|
181
|
-
{ id: 'c1', orderId: 'p1', label: 'old A' },
|
|
182
|
-
{ id: 'c2', orderId: 'p1', label: 'old B' },
|
|
183
|
-
])
|
|
184
|
-
const parent = makeFakeParentModel({
|
|
185
|
-
childModel: child.model,
|
|
186
|
-
childRows: child.rows,
|
|
187
|
-
relationName: 'items',
|
|
188
|
-
foreignKey: 'orderId',
|
|
189
|
-
})
|
|
190
|
-
|
|
191
|
-
const form = Form.make()
|
|
192
|
-
.schema([
|
|
193
|
-
RepeaterField.make('items').relationship('items').schema([
|
|
194
|
-
TextField.make('label').required(),
|
|
195
|
-
]),
|
|
196
|
-
])
|
|
197
|
-
.save(async () => ({ id: 'p1' }))
|
|
198
|
-
|
|
199
|
-
const result = await dispatchFormSubmit(
|
|
200
|
-
form,
|
|
201
|
-
{ items: [{ __id: 'c1', label: 'new A' }, { __id: 'c2', label: 'new B' }] },
|
|
202
|
-
{
|
|
203
|
-
values: { items: [{ __id: 'c1', label: 'new A' }, { __id: 'c2', label: 'new B' }] },
|
|
204
|
-
record: { id: 'p1' },
|
|
205
|
-
parentModel: parent,
|
|
206
|
-
},
|
|
207
|
-
)
|
|
208
|
-
assert.equal(result.ok, true)
|
|
209
|
-
const updates = child.calls.filter(c => c.kind === 'update') as Array<{ kind: 'update'; id: string | number; data: Record<string, unknown> }>
|
|
210
|
-
assert.equal(updates.length, 2)
|
|
211
|
-
// FK NOT in update payload — stays as it was on the existing row.
|
|
212
|
-
assert.equal('orderId' in updates[0]!.data, false)
|
|
213
|
-
assert.equal('orderId' in updates[1]!.data, false)
|
|
214
|
-
assert.equal(updates[0]!.data['label'], 'new A')
|
|
215
|
-
assert.equal(updates[1]!.data['label'], 'new B')
|
|
216
|
-
assert.equal(child.calls.filter(c => c.kind === 'create').length, 0)
|
|
217
|
-
assert.equal(child.calls.filter(c => c.kind === 'delete').length, 0)
|
|
218
|
-
})
|
|
219
|
-
|
|
220
|
-
it('delete — existing PK omitted from submitted set is deleted', async () => {
|
|
221
|
-
const child = makeFakeChildModel([
|
|
222
|
-
{ id: 'c1', orderId: 'p1', label: 'A' },
|
|
223
|
-
{ id: 'c2', orderId: 'p1', label: 'B' },
|
|
224
|
-
{ id: 'c3', orderId: 'p1', label: 'C' },
|
|
225
|
-
])
|
|
226
|
-
const parent = makeFakeParentModel({
|
|
227
|
-
childModel: child.model,
|
|
228
|
-
childRows: child.rows,
|
|
229
|
-
relationName: 'items',
|
|
230
|
-
foreignKey: 'orderId',
|
|
231
|
-
})
|
|
232
|
-
|
|
233
|
-
const form = Form.make()
|
|
234
|
-
.schema([
|
|
235
|
-
RepeaterField.make('items').relationship('items').schema([
|
|
236
|
-
TextField.make('label').required(),
|
|
237
|
-
]),
|
|
238
|
-
])
|
|
239
|
-
.save(async () => ({ id: 'p1' }))
|
|
240
|
-
|
|
241
|
-
const result = await dispatchFormSubmit(
|
|
242
|
-
form,
|
|
243
|
-
{ items: [{ __id: 'c1', label: 'A' }] },
|
|
244
|
-
{
|
|
245
|
-
values: { items: [{ __id: 'c1', label: 'A' }] },
|
|
246
|
-
record: { id: 'p1' },
|
|
247
|
-
parentModel: parent,
|
|
248
|
-
},
|
|
249
|
-
)
|
|
250
|
-
assert.equal(result.ok, true)
|
|
251
|
-
const deletes = child.calls.filter(c => c.kind === 'delete') as Array<{ kind: 'delete'; id: string | number }>
|
|
252
|
-
assert.deepEqual(deletes.map(c => String(c.id)).sort(), ['c2', 'c3'])
|
|
253
|
-
})
|
|
254
|
-
|
|
255
|
-
it('mixed — single submit performs creates, updates, and deletes in one diff', async () => {
|
|
256
|
-
const child = makeFakeChildModel([
|
|
257
|
-
{ id: 'c1', orderId: 'p1', label: 'A' },
|
|
258
|
-
{ id: 'c2', orderId: 'p1', label: 'B' },
|
|
259
|
-
])
|
|
260
|
-
const parent = makeFakeParentModel({
|
|
261
|
-
childModel: child.model,
|
|
262
|
-
childRows: child.rows,
|
|
263
|
-
relationName: 'items',
|
|
264
|
-
foreignKey: 'orderId',
|
|
265
|
-
})
|
|
266
|
-
|
|
267
|
-
const form = Form.make()
|
|
268
|
-
.schema([
|
|
269
|
-
RepeaterField.make('items').relationship('items').schema([
|
|
270
|
-
TextField.make('label').required(),
|
|
271
|
-
]),
|
|
272
|
-
])
|
|
273
|
-
.save(async () => ({ id: 'p1' }))
|
|
274
|
-
|
|
275
|
-
// c1 stays (renamed), c2 gone, plus a new row.
|
|
276
|
-
const result = await dispatchFormSubmit(
|
|
277
|
-
form,
|
|
278
|
-
{ items: [{ __id: 'c1', label: 'A renamed' }, { label: 'fresh' }] },
|
|
279
|
-
{
|
|
280
|
-
values: { items: [{ __id: 'c1', label: 'A renamed' }, { label: 'fresh' }] },
|
|
281
|
-
record: { id: 'p1' },
|
|
282
|
-
parentModel: parent,
|
|
283
|
-
},
|
|
284
|
-
)
|
|
285
|
-
assert.equal(result.ok, true)
|
|
286
|
-
assert.equal(child.calls.filter(c => c.kind === 'create').length, 1)
|
|
287
|
-
assert.equal(child.calls.filter(c => c.kind === 'update').length, 1)
|
|
288
|
-
assert.equal(child.calls.filter(c => c.kind === 'delete').length, 1)
|
|
289
|
-
// Spot-check the create stamps the FK.
|
|
290
|
-
const created = child.calls.find(c => c.kind === 'create') as { kind: 'create'; data: Record<string, unknown> }
|
|
291
|
-
assert.equal(created.data['orderId'], 'p1')
|
|
292
|
-
assert.equal(created.data['label'], 'fresh')
|
|
293
|
-
})
|
|
294
|
-
|
|
295
|
-
it('orderColumn writes 0-based index on every create + update', async () => {
|
|
296
|
-
const child = makeFakeChildModel([
|
|
297
|
-
{ id: 'c1', orderId: 'p1', label: 'A', sort: 5 },
|
|
298
|
-
])
|
|
299
|
-
const parent = makeFakeParentModel({
|
|
300
|
-
childModel: child.model,
|
|
301
|
-
childRows: child.rows,
|
|
302
|
-
relationName: 'items',
|
|
303
|
-
foreignKey: 'orderId',
|
|
304
|
-
})
|
|
305
|
-
|
|
306
|
-
const form = Form.make()
|
|
307
|
-
.schema([
|
|
308
|
-
RepeaterField.make('items')
|
|
309
|
-
.relationship('items')
|
|
310
|
-
.orderColumn('sort')
|
|
311
|
-
.schema([TextField.make('label').required()]),
|
|
312
|
-
])
|
|
313
|
-
.save(async () => ({ id: 'p1' }))
|
|
314
|
-
|
|
315
|
-
const result = await dispatchFormSubmit(
|
|
316
|
-
form,
|
|
317
|
-
{ items: [{ label: 'fresh first' }, { __id: 'c1', label: 'A second' }] },
|
|
318
|
-
{
|
|
319
|
-
values: { items: [{ label: 'fresh first' }, { __id: 'c1', label: 'A second' }] },
|
|
320
|
-
record: { id: 'p1' },
|
|
321
|
-
parentModel: parent,
|
|
322
|
-
},
|
|
323
|
-
)
|
|
324
|
-
assert.equal(result.ok, true)
|
|
325
|
-
const create = child.calls.find(c => c.kind === 'create') as { kind: 'create'; data: Record<string, unknown> }
|
|
326
|
-
const update = child.calls.find(c => c.kind === 'update') as { kind: 'update'; id: string | number; data: Record<string, unknown> }
|
|
327
|
-
assert.equal(create.data['sort'], 0)
|
|
328
|
-
assert.equal(update.data['sort'], 1)
|
|
329
|
-
})
|
|
330
|
-
|
|
331
|
-
it('throws when parentModel is missing on the FormContext', async () => {
|
|
332
|
-
const form = Form.make()
|
|
333
|
-
.schema([
|
|
334
|
-
RepeaterField.make('items').relationship('items').schema([TextField.make('label')]),
|
|
335
|
-
])
|
|
336
|
-
.save(async () => ({ id: 'p1' }))
|
|
337
|
-
|
|
338
|
-
await assert.rejects(
|
|
339
|
-
() => dispatchFormSubmit(
|
|
340
|
-
form,
|
|
341
|
-
{ items: [{ label: 'A' }] },
|
|
342
|
-
{ values: { items: [{ label: 'A' }] } },
|
|
343
|
-
),
|
|
344
|
-
/parentModel on the FormContext/,
|
|
345
|
-
)
|
|
346
|
-
})
|
|
347
|
-
|
|
348
|
-
it('throws when descriptor lookup fails and no override is set', async () => {
|
|
349
|
-
const child = makeFakeChildModel()
|
|
350
|
-
// Parent missing the `relations` map entry for 'phantom'.
|
|
351
|
-
const parent: ModelLike = {
|
|
352
|
-
find: async () => null,
|
|
353
|
-
create: async () => ({}),
|
|
354
|
-
update: async () => ({}),
|
|
355
|
-
delete: async () => {},
|
|
356
|
-
query: () => makeQuery([]),
|
|
357
|
-
relatedQuery: () => makeQuery([]),
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
const form = Form.make()
|
|
361
|
-
.schema([
|
|
362
|
-
RepeaterField.make('phantom').relationship('phantom').schema([TextField.make('x').required()]),
|
|
363
|
-
])
|
|
364
|
-
.save(async () => ({ id: 'p1' }))
|
|
365
|
-
|
|
366
|
-
await assert.rejects(
|
|
367
|
-
() => dispatchFormSubmit(
|
|
368
|
-
form,
|
|
369
|
-
{ phantom: [{ x: 'a' }] },
|
|
370
|
-
{
|
|
371
|
-
values: { phantom: [{ x: 'a' }] },
|
|
372
|
-
parentModel: parent,
|
|
373
|
-
},
|
|
374
|
-
),
|
|
375
|
-
/could not resolve the child model/,
|
|
376
|
-
)
|
|
377
|
-
void child
|
|
378
|
-
})
|
|
379
|
-
|
|
380
|
-
it('honors explicit model + foreignKey overrides on the field config (no descriptor needed)', async () => {
|
|
381
|
-
const child = makeFakeChildModel()
|
|
382
|
-
// Parent with NO relations map — overrides have to carry the day.
|
|
383
|
-
const parent: ModelLike = {
|
|
384
|
-
primaryKey: 'id',
|
|
385
|
-
find: async () => null,
|
|
386
|
-
create: async () => ({}),
|
|
387
|
-
update: async () => ({}),
|
|
388
|
-
delete: async () => {},
|
|
389
|
-
query: () => makeQuery([]),
|
|
390
|
-
relatedQuery: () => makeQuery(child.rows),
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
const form = Form.make()
|
|
394
|
-
.schema([
|
|
395
|
-
RepeaterField.make('items')
|
|
396
|
-
.relationship({ name: 'items', model: child.model, foreignKey: 'orderId' })
|
|
397
|
-
.schema([TextField.make('label').required()]),
|
|
398
|
-
])
|
|
399
|
-
.save(async () => ({ id: 'p1' }))
|
|
400
|
-
|
|
401
|
-
const result = await dispatchFormSubmit(
|
|
402
|
-
form,
|
|
403
|
-
{ items: [{ label: 'A' }] },
|
|
404
|
-
{
|
|
405
|
-
values: { items: [{ label: 'A' }] },
|
|
406
|
-
parentModel: parent,
|
|
407
|
-
},
|
|
408
|
-
)
|
|
409
|
-
assert.equal(result.ok, true)
|
|
410
|
-
assert.equal(child.calls.length, 1)
|
|
411
|
-
const created = child.calls[0] as { kind: 'create'; data: Record<string, unknown> }
|
|
412
|
-
assert.equal(created.data['orderId'], 'p1')
|
|
413
|
-
})
|
|
414
|
-
})
|
|
415
|
-
|
|
416
|
-
describe('Repeater.relationship — PK-switch renames (Phase B)', () => {
|
|
417
|
-
it('emits a rename for each create when submitted __id differs from new PK', async () => {
|
|
418
|
-
const child = makeFakeChildModel([])
|
|
419
|
-
const parent = makeFakeParentModel({
|
|
420
|
-
childModel: child.model,
|
|
421
|
-
childRows: child.rows,
|
|
422
|
-
relationName: 'items',
|
|
423
|
-
foreignKey: 'orderId',
|
|
424
|
-
})
|
|
425
|
-
|
|
426
|
-
const form = Form.make()
|
|
427
|
-
.schema([
|
|
428
|
-
RepeaterField.make('items').relationship('items').schema([
|
|
429
|
-
TextField.make('label').required(),
|
|
430
|
-
]),
|
|
431
|
-
])
|
|
432
|
-
.save(async () => ({ id: 'p1' }))
|
|
433
|
-
|
|
434
|
-
const result = await dispatchFormSubmit(
|
|
435
|
-
form,
|
|
436
|
-
// Renderer-minted UUIDs on the two new rows — Fake model assigns
|
|
437
|
-
// `c1` / `c2` so the post-save renames swap the UUIDs to those.
|
|
438
|
-
{ items: [{ __id: 'uuid-A', label: 'A' }, { __id: 'uuid-B', label: 'B' }] },
|
|
439
|
-
{
|
|
440
|
-
values: { items: [{ __id: 'uuid-A', label: 'A' }, { __id: 'uuid-B', label: 'B' }] },
|
|
441
|
-
parentModel: parent,
|
|
442
|
-
},
|
|
443
|
-
)
|
|
444
|
-
assert.equal(result.ok, true)
|
|
445
|
-
if (!result.ok) return
|
|
446
|
-
assert.deepEqual(result.relationshipRenames, [
|
|
447
|
-
{ field: 'items', old: 'uuid-A', new: 'c1' },
|
|
448
|
-
{ field: 'items', old: 'uuid-B', new: 'c2' },
|
|
449
|
-
])
|
|
450
|
-
})
|
|
451
|
-
|
|
452
|
-
it('skips renames for rows that resolve as updates (submitted __id matches existing PK)', async () => {
|
|
453
|
-
const child = makeFakeChildModel([
|
|
454
|
-
{ id: 'c1', orderId: 'p1', label: 'old' },
|
|
455
|
-
])
|
|
456
|
-
const parent = makeFakeParentModel({
|
|
457
|
-
childModel: child.model,
|
|
458
|
-
childRows: child.rows,
|
|
459
|
-
relationName: 'items',
|
|
460
|
-
foreignKey: 'orderId',
|
|
461
|
-
})
|
|
462
|
-
|
|
463
|
-
const form = Form.make()
|
|
464
|
-
.schema([
|
|
465
|
-
RepeaterField.make('items').relationship('items').schema([
|
|
466
|
-
TextField.make('label').required(),
|
|
467
|
-
]),
|
|
468
|
-
])
|
|
469
|
-
.save(async () => ({ id: 'p1' }))
|
|
470
|
-
|
|
471
|
-
const result = await dispatchFormSubmit(
|
|
472
|
-
form,
|
|
473
|
-
{ items: [{ __id: 'c1', label: 'new' }, { __id: 'uuid-X', label: 'fresh' }] },
|
|
474
|
-
{
|
|
475
|
-
values: { items: [{ __id: 'c1', label: 'new' }, { __id: 'uuid-X', label: 'fresh' }] },
|
|
476
|
-
record: { id: 'p1' },
|
|
477
|
-
parentModel: parent,
|
|
478
|
-
},
|
|
479
|
-
)
|
|
480
|
-
assert.equal(result.ok, true)
|
|
481
|
-
if (!result.ok) return
|
|
482
|
-
// Update of c1 emits no rename; create from uuid-X resolves to a new id.
|
|
483
|
-
assert.equal(result.relationshipRenames.length, 1)
|
|
484
|
-
assert.equal(result.relationshipRenames[0]?.field, 'items')
|
|
485
|
-
assert.equal(result.relationshipRenames[0]?.old, 'uuid-X')
|
|
486
|
-
})
|
|
487
|
-
|
|
488
|
-
it('skips the rename when the consumer pre-assigned the DB PK', async () => {
|
|
489
|
-
const child = makeFakeChildModel([])
|
|
490
|
-
// Pre-assign id `c1` on the submitted row — the fake model honors data.id.
|
|
491
|
-
const parent = makeFakeParentModel({
|
|
492
|
-
childModel: child.model,
|
|
493
|
-
childRows: child.rows,
|
|
494
|
-
relationName: 'items',
|
|
495
|
-
foreignKey: 'orderId',
|
|
496
|
-
})
|
|
497
|
-
|
|
498
|
-
const form = Form.make()
|
|
499
|
-
.schema([
|
|
500
|
-
RepeaterField.make('items').relationship('items').schema([
|
|
501
|
-
TextField.make('label').required(),
|
|
502
|
-
]),
|
|
503
|
-
])
|
|
504
|
-
.save(async () => ({ id: 'p1' }))
|
|
505
|
-
|
|
506
|
-
const result = await dispatchFormSubmit(
|
|
507
|
-
form,
|
|
508
|
-
{ items: [{ __id: 'c1', label: 'A', id: 'c1' }] },
|
|
509
|
-
{
|
|
510
|
-
values: { items: [{ __id: 'c1', label: 'A', id: 'c1' }] },
|
|
511
|
-
parentModel: parent,
|
|
512
|
-
},
|
|
513
|
-
)
|
|
514
|
-
assert.equal(result.ok, true)
|
|
515
|
-
if (!result.ok) return
|
|
516
|
-
// Submitted __id already matches the PK — no rename to emit.
|
|
517
|
-
assert.equal(result.relationshipRenames.length, 0)
|
|
518
|
-
})
|
|
519
|
-
|
|
520
|
-
it('returns an empty array on a parent save with no relationship fields', async () => {
|
|
521
|
-
const form = Form.make()
|
|
522
|
-
.schema([TextField.make('title')])
|
|
523
|
-
.save(async () => ({ id: 'p1' }))
|
|
524
|
-
|
|
525
|
-
const result = await dispatchFormSubmit(
|
|
526
|
-
form,
|
|
527
|
-
{ title: 'Hello' },
|
|
528
|
-
{ values: { title: 'Hello' } },
|
|
529
|
-
)
|
|
530
|
-
assert.equal(result.ok, true)
|
|
531
|
-
if (!result.ok) return
|
|
532
|
-
assert.deepEqual(result.relationshipRenames, [])
|
|
533
|
-
})
|
|
534
|
-
})
|
|
535
|
-
|
|
536
|
-
describe('Repeater.relationship — load (applyRelationshipRepeaterFill)', () => {
|
|
537
|
-
it('stamps __id from PK and strips PK + FK from each row', async () => {
|
|
538
|
-
const child = makeFakeChildModel([
|
|
539
|
-
{ id: 'c1', orderId: 'p1', label: 'A' },
|
|
540
|
-
{ id: 'c2', orderId: 'p1', label: 'B' },
|
|
541
|
-
])
|
|
542
|
-
const parent = makeFakeParentModel({
|
|
543
|
-
childModel: child.model,
|
|
544
|
-
childRows: child.rows,
|
|
545
|
-
relationName: 'items',
|
|
546
|
-
foreignKey: 'orderId',
|
|
547
|
-
})
|
|
548
|
-
|
|
549
|
-
const form = Form.make().schema([
|
|
550
|
-
TextField.make('title'),
|
|
551
|
-
RepeaterField.make('items').relationship('items').schema([
|
|
552
|
-
TextField.make('label').required(),
|
|
553
|
-
]),
|
|
554
|
-
])
|
|
555
|
-
|
|
556
|
-
const out = await applyRelationshipRepeaterFill(
|
|
557
|
-
form,
|
|
558
|
-
{ title: 'Order' },
|
|
559
|
-
{ id: 'p1' },
|
|
560
|
-
parent,
|
|
561
|
-
)
|
|
562
|
-
assert.deepEqual(out['items'], [
|
|
563
|
-
{ __id: 'c1', label: 'A' },
|
|
564
|
-
{ __id: 'c2', label: 'B' },
|
|
565
|
-
])
|
|
566
|
-
// Non-relationship values untouched.
|
|
567
|
-
assert.equal(out['title'], 'Order')
|
|
568
|
-
})
|
|
569
|
-
|
|
570
|
-
it('no-op when record is null, parentModel is missing, or there are no relationship Repeaters', async () => {
|
|
571
|
-
const form = Form.make().schema([
|
|
572
|
-
RepeaterField.make('items').relationship('items').schema([TextField.make('label')]),
|
|
573
|
-
])
|
|
574
|
-
const parent = makeFakeParentModel({
|
|
575
|
-
childModel: makeFakeChildModel().model,
|
|
576
|
-
childRows: [],
|
|
577
|
-
relationName: 'items',
|
|
578
|
-
foreignKey: 'orderId',
|
|
579
|
-
})
|
|
580
|
-
// null record
|
|
581
|
-
assert.deepEqual(
|
|
582
|
-
await applyRelationshipRepeaterFill(form, { x: 1 }, null, parent),
|
|
583
|
-
{ x: 1 },
|
|
584
|
-
)
|
|
585
|
-
// missing parentModel
|
|
586
|
-
assert.deepEqual(
|
|
587
|
-
await applyRelationshipRepeaterFill(form, { x: 1 }, { id: 'p1' }, undefined),
|
|
588
|
-
{ x: 1 },
|
|
589
|
-
)
|
|
590
|
-
// form without relationship Repeaters
|
|
591
|
-
const plain = Form.make().schema([TextField.make('title')])
|
|
592
|
-
assert.deepEqual(
|
|
593
|
-
await applyRelationshipRepeaterFill(plain, { title: 't' }, { id: 'p1' }, parent),
|
|
594
|
-
{ title: 't' },
|
|
595
|
-
)
|
|
596
|
-
})
|
|
597
|
-
|
|
598
|
-
it('loadRelationRows reads through resolveRelatedQuery (paginate)', async () => {
|
|
599
|
-
const child = makeFakeChildModel([
|
|
600
|
-
{ id: 'c1', orderId: 'p1', label: 'A' },
|
|
601
|
-
])
|
|
602
|
-
const parent = makeFakeParentModel({
|
|
603
|
-
childModel: child.model,
|
|
604
|
-
childRows: child.rows,
|
|
605
|
-
relationName: 'items',
|
|
606
|
-
foreignKey: 'orderId',
|
|
607
|
-
})
|
|
608
|
-
const rows = await loadRelationRows(parent, { id: 'p1' }, 'items')
|
|
609
|
-
assert.equal(rows.length, 1)
|
|
610
|
-
assert.equal((rows[0] as Record<string, unknown>)['label'], 'A')
|
|
611
|
-
})
|
|
612
|
-
})
|
|
613
|
-
|
|
614
|
-
describe('Repeater.relationship — morphMany', () => {
|
|
615
|
-
// Parent shape: `Order.items: morphMany(Item, 'itemable')` — child
|
|
616
|
-
// carries `itemableId` + `itemableType` instead of an FK column.
|
|
617
|
-
// `computeMorphPayload(parent, descriptor)` reads the discriminator off
|
|
618
|
-
// the parent **record**'s `constructor.morphAlias ?? constructor.name`,
|
|
619
|
-
// so the parent record returned by `Form.save()` has to be a class
|
|
620
|
-
// instance (not a plain object literal).
|
|
621
|
-
function makeMorphParentSetup(opts: {
|
|
622
|
-
childModel: ModelLike
|
|
623
|
-
childRows: FakeRecord[]
|
|
624
|
-
relationName: string
|
|
625
|
-
morphName: string
|
|
626
|
-
}) {
|
|
627
|
-
const { childModel, childRows, relationName, morphName } = opts
|
|
628
|
-
const idCol = `${morphName}Id`
|
|
629
|
-
const typeCol = `${morphName}Type`
|
|
630
|
-
|
|
631
|
-
class Order {
|
|
632
|
-
id?: string
|
|
633
|
-
constructor(init?: Partial<{ id: string }>) { Object.assign(this, init) }
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
const parentModel: ModelLike & { relations: Record<string, unknown> } = {
|
|
637
|
-
primaryKey: 'id',
|
|
638
|
-
find: async () => null,
|
|
639
|
-
create: async () => ({}),
|
|
640
|
-
update: async () => ({}),
|
|
641
|
-
delete: async () => {},
|
|
642
|
-
query: () => makeQuery([]),
|
|
643
|
-
relatedQuery: (parentRecord) => {
|
|
644
|
-
const parentId = (parentRecord as Record<string, unknown>)['id']
|
|
645
|
-
const parentType = (parentRecord as { constructor?: { morphAlias?: string; name?: string } })
|
|
646
|
-
.constructor?.morphAlias
|
|
647
|
-
?? (parentRecord as { constructor?: { name?: string } }).constructor?.name
|
|
648
|
-
const filtered = childRows.filter(r =>
|
|
649
|
-
String(r[idCol]) === String(parentId) && r[typeCol] === parentType,
|
|
650
|
-
)
|
|
651
|
-
return makeQuery(filtered)
|
|
652
|
-
},
|
|
653
|
-
relations: {
|
|
654
|
-
[relationName]: { type: 'morphMany', morphName, model: () => childModel },
|
|
655
|
-
},
|
|
656
|
-
}
|
|
657
|
-
return { parentModel, makeRecord: (id: string) => new Order({ id }) }
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
it('create — stamps <morphName>Id + <morphName>Type instead of an FK column', async () => {
|
|
661
|
-
const child = makeFakeChildModel([])
|
|
662
|
-
const { parentModel, makeRecord } = makeMorphParentSetup({
|
|
663
|
-
childModel: child.model, childRows: child.rows,
|
|
664
|
-
relationName: 'items', morphName: 'itemable',
|
|
665
|
-
})
|
|
666
|
-
|
|
667
|
-
const form = Form.make()
|
|
668
|
-
.schema([
|
|
669
|
-
RepeaterField.make('items')
|
|
670
|
-
.relationship('items')
|
|
671
|
-
.schema([TextField.make('label').required()]),
|
|
672
|
-
])
|
|
673
|
-
.save(async () => makeRecord('p1'))
|
|
674
|
-
|
|
675
|
-
const submittedRows = [{ label: 'A' }, { label: 'B' }]
|
|
676
|
-
const result = await dispatchFormSubmit(
|
|
677
|
-
form,
|
|
678
|
-
{ items: submittedRows },
|
|
679
|
-
{ values: { items: submittedRows }, parentModel },
|
|
680
|
-
)
|
|
681
|
-
assert.equal(result.ok, true)
|
|
682
|
-
const creates = child.calls.filter(c => c.kind === 'create') as Array<{ kind: 'create'; data: Record<string, unknown> }>
|
|
683
|
-
assert.equal(creates.length, 2)
|
|
684
|
-
for (const c of creates) {
|
|
685
|
-
assert.equal(c.data['itemableId'], 'p1')
|
|
686
|
-
assert.equal(c.data['itemableType'], 'Order')
|
|
687
|
-
assert.equal('orderId' in c.data, false)
|
|
688
|
-
}
|
|
689
|
-
assert.equal(creates[0]!.data['label'], 'A')
|
|
690
|
-
assert.equal(creates[1]!.data['label'], 'B')
|
|
691
|
-
})
|
|
692
|
-
|
|
693
|
-
it('update — does not overwrite morph cols on update (defense against re-link)', async () => {
|
|
694
|
-
const child = makeFakeChildModel([
|
|
695
|
-
{ id: 'c1', itemableId: 'p1', itemableType: 'Order', label: 'A' },
|
|
696
|
-
{ id: 'c2', itemableId: 'p1', itemableType: 'Order', label: 'B' },
|
|
697
|
-
])
|
|
698
|
-
const { parentModel, makeRecord } = makeMorphParentSetup({
|
|
699
|
-
childModel: child.model, childRows: child.rows,
|
|
700
|
-
relationName: 'items', morphName: 'itemable',
|
|
701
|
-
})
|
|
702
|
-
|
|
703
|
-
const form = Form.make()
|
|
704
|
-
.schema([
|
|
705
|
-
RepeaterField.make('items')
|
|
706
|
-
.relationship('items')
|
|
707
|
-
.schema([TextField.make('label')]),
|
|
708
|
-
])
|
|
709
|
-
.save(async () => makeRecord('p1'))
|
|
710
|
-
|
|
711
|
-
const submittedRows = [
|
|
712
|
-
// Tampered client tries to send itemableType=Invoice; framework wins last.
|
|
713
|
-
{ __id: 'c1', label: 'A2', itemableType: 'Invoice' },
|
|
714
|
-
{ __id: 'c2', label: 'B2' },
|
|
715
|
-
]
|
|
716
|
-
const result = await dispatchFormSubmit(
|
|
717
|
-
form,
|
|
718
|
-
{ items: submittedRows },
|
|
719
|
-
{ values: { items: submittedRows }, record: makeRecord('p1'), parentModel },
|
|
720
|
-
)
|
|
721
|
-
assert.equal(result.ok, true)
|
|
722
|
-
const updates = child.calls.filter(c => c.kind === 'update') as Array<{ kind: 'update'; id: string | number; data: Record<string, unknown> }>
|
|
723
|
-
assert.equal(updates.length, 2)
|
|
724
|
-
for (const u of updates) {
|
|
725
|
-
assert.equal('itemableId' in u.data, false)
|
|
726
|
-
assert.equal('itemableType' in u.data, false)
|
|
727
|
-
}
|
|
728
|
-
})
|
|
729
|
-
|
|
730
|
-
it('delete — existing PKs missing from submitted set are deleted (same shape as hasMany)', async () => {
|
|
731
|
-
const child = makeFakeChildModel([
|
|
732
|
-
{ id: 'c1', itemableId: 'p1', itemableType: 'Order', label: 'A' },
|
|
733
|
-
{ id: 'c2', itemableId: 'p1', itemableType: 'Order', label: 'B' },
|
|
734
|
-
{ id: 'c3', itemableId: 'p1', itemableType: 'Order', label: 'C' },
|
|
735
|
-
])
|
|
736
|
-
const { parentModel, makeRecord } = makeMorphParentSetup({
|
|
737
|
-
childModel: child.model, childRows: child.rows,
|
|
738
|
-
relationName: 'items', morphName: 'itemable',
|
|
739
|
-
})
|
|
740
|
-
|
|
741
|
-
const form = Form.make()
|
|
742
|
-
.schema([
|
|
743
|
-
RepeaterField.make('items')
|
|
744
|
-
.relationship('items')
|
|
745
|
-
.schema([TextField.make('label')]),
|
|
746
|
-
])
|
|
747
|
-
.save(async () => makeRecord('p1'))
|
|
748
|
-
|
|
749
|
-
const submittedRows = [{ __id: 'c1', label: 'A' }]
|
|
750
|
-
const result = await dispatchFormSubmit(
|
|
751
|
-
form,
|
|
752
|
-
{ items: submittedRows },
|
|
753
|
-
{ values: { items: submittedRows }, record: makeRecord('p1'), parentModel },
|
|
754
|
-
)
|
|
755
|
-
assert.equal(result.ok, true)
|
|
756
|
-
const deletes = child.calls.filter(c => c.kind === 'delete') as Array<{ kind: 'delete'; id: string | number }>
|
|
757
|
-
assert.deepEqual(deletes.map(c => String(c.id)).sort(), ['c2', 'c3'])
|
|
758
|
-
})
|
|
759
|
-
|
|
760
|
-
it('orderColumn writes 0-based index on every morph create + update', async () => {
|
|
761
|
-
const child = makeFakeChildModel([
|
|
762
|
-
{ id: 'c1', itemableId: 'p1', itemableType: 'Order', label: 'A', sort: 5 },
|
|
763
|
-
])
|
|
764
|
-
const { parentModel, makeRecord } = makeMorphParentSetup({
|
|
765
|
-
childModel: child.model, childRows: child.rows,
|
|
766
|
-
relationName: 'items', morphName: 'itemable',
|
|
767
|
-
})
|
|
768
|
-
|
|
769
|
-
const form = Form.make()
|
|
770
|
-
.schema([
|
|
771
|
-
RepeaterField.make('items')
|
|
772
|
-
.relationship('items')
|
|
773
|
-
.orderColumn('sort')
|
|
774
|
-
.schema([TextField.make('label')]),
|
|
775
|
-
])
|
|
776
|
-
.save(async () => makeRecord('p1'))
|
|
777
|
-
|
|
778
|
-
const submittedRows = [
|
|
779
|
-
{ label: 'first' },
|
|
780
|
-
{ __id: 'c1', label: 'second' },
|
|
781
|
-
]
|
|
782
|
-
const result = await dispatchFormSubmit(
|
|
783
|
-
form,
|
|
784
|
-
{ items: submittedRows },
|
|
785
|
-
{ values: { items: submittedRows }, record: makeRecord('p1'), parentModel },
|
|
786
|
-
)
|
|
787
|
-
assert.equal(result.ok, true)
|
|
788
|
-
const create = child.calls.find(c => c.kind === 'create') as { kind: 'create'; data: Record<string, unknown> }
|
|
789
|
-
const update = child.calls.find(c => c.kind === 'update') as { kind: 'update'; id: string | number; data: Record<string, unknown> }
|
|
790
|
-
assert.equal(create.data['sort'], 0)
|
|
791
|
-
assert.equal(update.data['sort'], 1)
|
|
792
|
-
})
|
|
793
|
-
|
|
794
|
-
it('morphType — explicit override on the relation entry wins over constructor name', async () => {
|
|
795
|
-
const child = makeFakeChildModel([])
|
|
796
|
-
class Order {
|
|
797
|
-
id?: string
|
|
798
|
-
constructor(init?: Partial<{ id: string }>) { Object.assign(this, init) }
|
|
799
|
-
}
|
|
800
|
-
const parentModel: ModelLike & { relations: Record<string, unknown> } = {
|
|
801
|
-
primaryKey: 'id',
|
|
802
|
-
find: async () => null,
|
|
803
|
-
create: async () => ({}),
|
|
804
|
-
update: async () => ({}),
|
|
805
|
-
delete: async () => {},
|
|
806
|
-
query: () => makeQuery([]),
|
|
807
|
-
relatedQuery: () => makeQuery([]),
|
|
808
|
-
relations: {
|
|
809
|
-
items: { type: 'morphMany', morphName: 'itemable', morphType: 'CustomDiscriminator', model: () => child.model },
|
|
810
|
-
},
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
const form = Form.make()
|
|
814
|
-
.schema([
|
|
815
|
-
RepeaterField.make('items')
|
|
816
|
-
.relationship('items')
|
|
817
|
-
.schema([TextField.make('label')]),
|
|
818
|
-
])
|
|
819
|
-
.save(async () => new Order({ id: 'p1' }))
|
|
820
|
-
|
|
821
|
-
const submittedRows = [{ label: 'A' }]
|
|
822
|
-
const result = await dispatchFormSubmit(
|
|
823
|
-
form,
|
|
824
|
-
{ items: submittedRows },
|
|
825
|
-
{ values: { items: submittedRows }, parentModel },
|
|
826
|
-
)
|
|
827
|
-
assert.equal(result.ok, true)
|
|
828
|
-
const create = child.calls.find(c => c.kind === 'create') as { kind: 'create'; data: Record<string, unknown> }
|
|
829
|
-
assert.equal(create.data['itemableType'], 'CustomDiscriminator')
|
|
830
|
-
})
|
|
831
|
-
|
|
832
|
-
it('load — applyRelationshipRepeaterFill strips morph cols from rendered rows', async () => {
|
|
833
|
-
const child = makeFakeChildModel([
|
|
834
|
-
{ id: 'c1', itemableId: 'p1', itemableType: 'Order', label: 'A' },
|
|
835
|
-
{ id: 'c2', itemableId: 'p1', itemableType: 'Order', label: 'B' },
|
|
836
|
-
])
|
|
837
|
-
const { parentModel, makeRecord } = makeMorphParentSetup({
|
|
838
|
-
childModel: child.model, childRows: child.rows,
|
|
839
|
-
relationName: 'items', morphName: 'itemable',
|
|
840
|
-
})
|
|
841
|
-
|
|
842
|
-
const form = Form.make().schema([
|
|
843
|
-
TextField.make('title'),
|
|
844
|
-
RepeaterField.make('items')
|
|
845
|
-
.relationship('items')
|
|
846
|
-
.schema([TextField.make('label')]),
|
|
847
|
-
])
|
|
848
|
-
|
|
849
|
-
const out = await applyRelationshipRepeaterFill(form, { title: 'P' }, makeRecord('p1'), parentModel)
|
|
850
|
-
assert.deepEqual(out['items'], [
|
|
851
|
-
{ __id: 'c1', label: 'A' },
|
|
852
|
-
{ __id: 'c2', label: 'B' },
|
|
853
|
-
])
|
|
854
|
-
// Morph cols should NOT leak into the rendered row payload.
|
|
855
|
-
for (const row of out['items'] as Array<Record<string, unknown>>) {
|
|
856
|
-
assert.equal('itemableId' in row, false)
|
|
857
|
-
assert.equal('itemableType' in row, false)
|
|
858
|
-
assert.equal('id' in row, false)
|
|
859
|
-
}
|
|
860
|
-
})
|
|
861
|
-
|
|
862
|
-
it('morphMany config without the model thunk surfaces a clear error', async () => {
|
|
863
|
-
const child = makeFakeChildModel([])
|
|
864
|
-
class Order {
|
|
865
|
-
id?: string
|
|
866
|
-
constructor(init?: Partial<{ id: string }>) { Object.assign(this, init) }
|
|
867
|
-
}
|
|
868
|
-
const parentModel: ModelLike & { relations: Record<string, unknown> } = {
|
|
869
|
-
primaryKey: 'id',
|
|
870
|
-
find: async () => null,
|
|
871
|
-
create: async () => ({}),
|
|
872
|
-
update: async () => ({}),
|
|
873
|
-
delete: async () => {},
|
|
874
|
-
query: () => makeQuery([]),
|
|
875
|
-
relatedQuery: () => makeQuery([]),
|
|
876
|
-
relations: {
|
|
877
|
-
// No `model` thunk — getMorphRelationDescriptor returns
|
|
878
|
-
// undefined, so the resolver falls through to the hasMany
|
|
879
|
-
// branch which then asks for foreignKey. The user-facing fix
|
|
880
|
-
// is the same: configure-the-relation.
|
|
881
|
-
items: { type: 'morphMany', morphName: 'itemable' },
|
|
882
|
-
},
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
const form = Form.make()
|
|
886
|
-
.schema([
|
|
887
|
-
RepeaterField.make('items')
|
|
888
|
-
.relationship('items')
|
|
889
|
-
.schema([TextField.make('label')]),
|
|
890
|
-
])
|
|
891
|
-
.save(async () => new Order({ id: 'p1' }))
|
|
892
|
-
|
|
893
|
-
const submittedRows = [{ label: 'A' }]
|
|
894
|
-
await assert.rejects(
|
|
895
|
-
() => dispatchFormSubmit(
|
|
896
|
-
form,
|
|
897
|
-
{ items: submittedRows },
|
|
898
|
-
{ values: { items: submittedRows }, parentModel },
|
|
899
|
-
),
|
|
900
|
-
/could not resolve the child model/,
|
|
901
|
-
)
|
|
902
|
-
void child
|
|
903
|
-
})
|
|
904
|
-
})
|
|
905
|
-
|
|
906
|
-
/**
|
|
907
|
-
* Test harness for M2M relations — `parent[rel]()` returns a recorded
|
|
908
|
-
* accessor with `attach` / `detach` / `sync`. Mirrors `_makeBelongsToManyAccessor`
|
|
909
|
-
* from the rudder ORM (the per-relation accessor returned by
|
|
910
|
-
* `Model.belongsToMany`). Tests assert against `pivotCalls` directly so
|
|
911
|
-
* we can verify the exact sequence of operations against the pivot.
|
|
912
|
-
*/
|
|
913
|
-
function makeM2MParentSetup(opts: {
|
|
914
|
-
childModel: ModelLike
|
|
915
|
-
childRows: FakeRecord[]
|
|
916
|
-
relationName: string
|
|
917
|
-
/** When set, the related rows on load are filtered to those whose
|
|
918
|
-
* PK appears in the pivot. Lets `applyRelationshipRepeaterFill`
|
|
919
|
-
* return the right slice. */
|
|
920
|
-
attachedIds?: Set<string | number>
|
|
921
|
-
}) {
|
|
922
|
-
const { childModel, childRows, relationName } = opts
|
|
923
|
-
const attachedIds = opts.attachedIds ?? new Set<string | number>(childRows.map(r => r['id'] as string | number))
|
|
924
|
-
const pivotCalls: Array<
|
|
925
|
-
| { kind: 'attach'; ids: Array<string | number> }
|
|
926
|
-
| { kind: 'detach'; ids: Array<string | number> | undefined }
|
|
927
|
-
| { kind: 'sync'; desired: Array<string | number> }
|
|
928
|
-
> = []
|
|
929
|
-
|
|
930
|
-
class Parent {
|
|
931
|
-
id?: string
|
|
932
|
-
constructor(init?: Partial<{ id: string }>) { Object.assign(this, init) }
|
|
933
|
-
[relationName]() {
|
|
934
|
-
return {
|
|
935
|
-
attach: async (input: ReadonlyArray<string | number> | Record<string, Record<string, unknown>>) => {
|
|
936
|
-
const ids = Array.isArray(input)
|
|
937
|
-
? [...input]
|
|
938
|
-
: Object.keys(input).map(k => /^\d+$/.test(k) ? Number(k) : k)
|
|
939
|
-
for (const id of ids) attachedIds.add(id)
|
|
940
|
-
pivotCalls.push({ kind: 'attach', ids })
|
|
941
|
-
},
|
|
942
|
-
detach: async (ids?: ReadonlyArray<string | number>) => {
|
|
943
|
-
if (ids === undefined) {
|
|
944
|
-
const removed = [...attachedIds]
|
|
945
|
-
attachedIds.clear()
|
|
946
|
-
pivotCalls.push({ kind: 'detach', ids: undefined })
|
|
947
|
-
return removed.length
|
|
948
|
-
}
|
|
949
|
-
for (const id of ids) attachedIds.delete(id)
|
|
950
|
-
pivotCalls.push({ kind: 'detach', ids: [...ids] })
|
|
951
|
-
return ids.length
|
|
952
|
-
},
|
|
953
|
-
sync: async (desiredIds: ReadonlyArray<string | number>) => {
|
|
954
|
-
pivotCalls.push({ kind: 'sync', desired: [...desiredIds] })
|
|
955
|
-
return { attached: [], detached: [] }
|
|
956
|
-
},
|
|
957
|
-
}
|
|
958
|
-
}
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
const parentModel: ModelLike & { relations: Record<string, unknown> } = {
|
|
962
|
-
primaryKey: 'id',
|
|
963
|
-
find: async () => null,
|
|
964
|
-
create: async () => ({}),
|
|
965
|
-
update: async () => ({}),
|
|
966
|
-
delete: async () => {},
|
|
967
|
-
query: () => makeQuery([]),
|
|
968
|
-
// Resolve "currently attached" rows for the parent — read from the
|
|
969
|
-
// pivot snapshot. Drives both `loadRelationRows` (in the diff loop)
|
|
970
|
-
// and `applyRelationshipRepeaterFill` (in load mode).
|
|
971
|
-
relatedQuery: () => makeQuery(childRows.filter(r => attachedIds.has(r['id'] as string | number))),
|
|
972
|
-
relations: {
|
|
973
|
-
[relationName]: { type: 'belongsToMany', model: () => childModel, pivotTable: 'pivot' },
|
|
974
|
-
},
|
|
975
|
-
}
|
|
976
|
-
return {
|
|
977
|
-
parentModel,
|
|
978
|
-
pivotCalls,
|
|
979
|
-
attachedIds,
|
|
980
|
-
makeRecord: (id: string) => new Parent({ id }),
|
|
981
|
-
}
|
|
982
|
-
}
|
|
983
|
-
|
|
984
|
-
describe('Repeater.relationship — belongsToMany', () => {
|
|
985
|
-
// Parent shape: `Article.tags: belongsToMany(Tag, pivotTable: 'article_tag')`.
|
|
986
|
-
// Child Tag rows have NO FK column — pivot table holds the link.
|
|
987
|
-
// Submit semantics: create-row → M.create + accessor.attach; update-row
|
|
988
|
-
// → M.update (pivot untouched); delete-row → accessor.detach (no
|
|
989
|
-
// M.delete, child may be attached to other parents).
|
|
990
|
-
|
|
991
|
-
it('create — M.create the related child then attach via accessor (no FK on payload)', async () => {
|
|
992
|
-
const child = makeFakeChildModel([])
|
|
993
|
-
const setup = makeM2MParentSetup({
|
|
994
|
-
childModel: child.model,
|
|
995
|
-
childRows: child.rows,
|
|
996
|
-
relationName: 'tags',
|
|
997
|
-
attachedIds: new Set(),
|
|
998
|
-
})
|
|
999
|
-
|
|
1000
|
-
const form = Form.make()
|
|
1001
|
-
.schema([
|
|
1002
|
-
RepeaterField.make('tags')
|
|
1003
|
-
.relationship('tags')
|
|
1004
|
-
.schema([TextField.make('name').required()]),
|
|
1005
|
-
])
|
|
1006
|
-
.save(async () => setup.makeRecord('a1'))
|
|
1007
|
-
|
|
1008
|
-
const submittedRows = [{ name: 'red' }, { name: 'blue' }]
|
|
1009
|
-
const result = await dispatchFormSubmit(
|
|
1010
|
-
form,
|
|
1011
|
-
{ tags: submittedRows },
|
|
1012
|
-
{ values: { tags: submittedRows }, parentModel: setup.parentModel },
|
|
1013
|
-
)
|
|
1014
|
-
assert.equal(result.ok, true)
|
|
1015
|
-
const creates = child.calls.filter(c => c.kind === 'create') as Array<{ kind: 'create'; data: Record<string, unknown> }>
|
|
1016
|
-
assert.equal(creates.length, 2)
|
|
1017
|
-
assert.equal(creates[0]!.data['name'], 'red')
|
|
1018
|
-
assert.equal(creates[1]!.data['name'], 'blue')
|
|
1019
|
-
// No FK / morph cols stamped on the related child — pivot covers it.
|
|
1020
|
-
for (const c of creates) {
|
|
1021
|
-
assert.equal('articleId' in c.data, false)
|
|
1022
|
-
assert.equal('taggableId' in c.data, false)
|
|
1023
|
-
assert.equal('taggableType' in c.data, false)
|
|
1024
|
-
}
|
|
1025
|
-
// One attach per new row, in row order.
|
|
1026
|
-
const attachCalls = setup.pivotCalls.filter(c => c.kind === 'attach') as Array<{ kind: 'attach'; ids: Array<string | number> }>
|
|
1027
|
-
assert.equal(attachCalls.length, 2)
|
|
1028
|
-
assert.equal(attachCalls[0]!.ids.length, 1)
|
|
1029
|
-
assert.equal(attachCalls[1]!.ids.length, 1)
|
|
1030
|
-
// No pivot detach.
|
|
1031
|
-
assert.equal(setup.pivotCalls.filter(c => c.kind === 'detach').length, 0)
|
|
1032
|
-
})
|
|
1033
|
-
|
|
1034
|
-
it('update — __id matching an attached PK routes through M.update; pivot untouched', async () => {
|
|
1035
|
-
const child = makeFakeChildModel([
|
|
1036
|
-
{ id: 'c1', name: 'red' },
|
|
1037
|
-
{ id: 'c2', name: 'blue' },
|
|
1038
|
-
])
|
|
1039
|
-
const setup = makeM2MParentSetup({
|
|
1040
|
-
childModel: child.model,
|
|
1041
|
-
childRows: child.rows,
|
|
1042
|
-
relationName: 'tags',
|
|
1043
|
-
attachedIds: new Set(['c1', 'c2']),
|
|
1044
|
-
})
|
|
1045
|
-
|
|
1046
|
-
const form = Form.make()
|
|
1047
|
-
.schema([
|
|
1048
|
-
RepeaterField.make('tags')
|
|
1049
|
-
.relationship('tags')
|
|
1050
|
-
.schema([TextField.make('name')]),
|
|
1051
|
-
])
|
|
1052
|
-
.save(async () => setup.makeRecord('a1'))
|
|
1053
|
-
|
|
1054
|
-
const submittedRows = [
|
|
1055
|
-
{ __id: 'c1', name: 'crimson' },
|
|
1056
|
-
{ __id: 'c2', name: 'navy' },
|
|
1057
|
-
]
|
|
1058
|
-
const result = await dispatchFormSubmit(
|
|
1059
|
-
form,
|
|
1060
|
-
{ tags: submittedRows },
|
|
1061
|
-
{ values: { tags: submittedRows }, record: setup.makeRecord('a1'), parentModel: setup.parentModel },
|
|
1062
|
-
)
|
|
1063
|
-
assert.equal(result.ok, true)
|
|
1064
|
-
const updates = child.calls.filter(c => c.kind === 'update') as Array<{ kind: 'update'; id: string | number; data: Record<string, unknown> }>
|
|
1065
|
-
assert.equal(updates.length, 2)
|
|
1066
|
-
assert.equal(updates[0]!.data['name'], 'crimson')
|
|
1067
|
-
assert.equal(updates[1]!.data['name'], 'navy')
|
|
1068
|
-
// No pivot operations — update doesn't touch attach/detach.
|
|
1069
|
-
assert.equal(setup.pivotCalls.length, 0)
|
|
1070
|
-
})
|
|
1071
|
-
|
|
1072
|
-
it('delete — existing attached PK omitted from submitted set is detached only (no M.delete)', async () => {
|
|
1073
|
-
const child = makeFakeChildModel([
|
|
1074
|
-
{ id: 'c1', name: 'red' },
|
|
1075
|
-
{ id: 'c2', name: 'blue' },
|
|
1076
|
-
{ id: 'c3', name: 'green' },
|
|
1077
|
-
])
|
|
1078
|
-
const setup = makeM2MParentSetup({
|
|
1079
|
-
childModel: child.model,
|
|
1080
|
-
childRows: child.rows,
|
|
1081
|
-
relationName: 'tags',
|
|
1082
|
-
attachedIds: new Set(['c1', 'c2', 'c3']),
|
|
1083
|
-
})
|
|
1084
|
-
|
|
1085
|
-
const form = Form.make()
|
|
1086
|
-
.schema([
|
|
1087
|
-
RepeaterField.make('tags')
|
|
1088
|
-
.relationship('tags')
|
|
1089
|
-
.schema([TextField.make('name')]),
|
|
1090
|
-
])
|
|
1091
|
-
.save(async () => setup.makeRecord('a1'))
|
|
1092
|
-
|
|
1093
|
-
const submittedRows = [{ __id: 'c1', name: 'red' }]
|
|
1094
|
-
const result = await dispatchFormSubmit(
|
|
1095
|
-
form,
|
|
1096
|
-
{ tags: submittedRows },
|
|
1097
|
-
{ values: { tags: submittedRows }, record: setup.makeRecord('a1'), parentModel: setup.parentModel },
|
|
1098
|
-
)
|
|
1099
|
-
assert.equal(result.ok, true)
|
|
1100
|
-
// No M.delete on the related child — only pivot detach.
|
|
1101
|
-
assert.equal(child.calls.filter(c => c.kind === 'delete').length, 0)
|
|
1102
|
-
const detachCalls = setup.pivotCalls.filter(c => c.kind === 'detach') as Array<{ kind: 'detach'; ids: Array<string | number> | undefined }>
|
|
1103
|
-
// Each missing PK gets its own detach call.
|
|
1104
|
-
const detachedIds = detachCalls
|
|
1105
|
-
.flatMap(c => c.ids ?? [])
|
|
1106
|
-
.map(id => String(id))
|
|
1107
|
-
.sort()
|
|
1108
|
-
assert.deepEqual(detachedIds, ['c2', 'c3'])
|
|
1109
|
-
})
|
|
1110
|
-
|
|
1111
|
-
it('mixed — single submit performs create+attach, update, and detach in one diff', async () => {
|
|
1112
|
-
const child = makeFakeChildModel([
|
|
1113
|
-
{ id: 'c1', name: 'red' },
|
|
1114
|
-
{ id: 'c2', name: 'blue' },
|
|
1115
|
-
])
|
|
1116
|
-
const setup = makeM2MParentSetup({
|
|
1117
|
-
childModel: child.model,
|
|
1118
|
-
childRows: child.rows,
|
|
1119
|
-
relationName: 'tags',
|
|
1120
|
-
attachedIds: new Set(['c1', 'c2']),
|
|
1121
|
-
})
|
|
1122
|
-
|
|
1123
|
-
const form = Form.make()
|
|
1124
|
-
.schema([
|
|
1125
|
-
RepeaterField.make('tags')
|
|
1126
|
-
.relationship('tags')
|
|
1127
|
-
.schema([TextField.make('name')]),
|
|
1128
|
-
])
|
|
1129
|
-
.save(async () => setup.makeRecord('a1'))
|
|
1130
|
-
|
|
1131
|
-
const submittedRows = [
|
|
1132
|
-
{ __id: 'c1', name: 'crimson' },
|
|
1133
|
-
{ name: 'fresh' },
|
|
1134
|
-
]
|
|
1135
|
-
const result = await dispatchFormSubmit(
|
|
1136
|
-
form,
|
|
1137
|
-
{ tags: submittedRows },
|
|
1138
|
-
{ values: { tags: submittedRows }, record: setup.makeRecord('a1'), parentModel: setup.parentModel },
|
|
1139
|
-
)
|
|
1140
|
-
assert.equal(result.ok, true)
|
|
1141
|
-
assert.equal(child.calls.filter(c => c.kind === 'create').length, 1)
|
|
1142
|
-
assert.equal(child.calls.filter(c => c.kind === 'update').length, 1)
|
|
1143
|
-
assert.equal(child.calls.filter(c => c.kind === 'delete').length, 0)
|
|
1144
|
-
assert.equal(setup.pivotCalls.filter(c => c.kind === 'attach').length, 1)
|
|
1145
|
-
const detachCalls = setup.pivotCalls.filter(c => c.kind === 'detach') as Array<{ kind: 'detach'; ids: Array<string | number> | undefined }>
|
|
1146
|
-
const detachedIds = detachCalls.flatMap(c => c.ids ?? []).map(id => String(id))
|
|
1147
|
-
assert.deepEqual(detachedIds, ['c2'])
|
|
1148
|
-
})
|
|
1149
|
-
|
|
1150
|
-
it('descriptor lookup — explicit cfg.model wins over the relation entry thunk', async () => {
|
|
1151
|
-
const child = makeFakeChildModel([])
|
|
1152
|
-
const otherChild = makeFakeChildModel([])
|
|
1153
|
-
const setup = makeM2MParentSetup({
|
|
1154
|
-
childModel: child.model,
|
|
1155
|
-
childRows: child.rows,
|
|
1156
|
-
relationName: 'tags',
|
|
1157
|
-
attachedIds: new Set(),
|
|
1158
|
-
})
|
|
1159
|
-
|
|
1160
|
-
const form = Form.make()
|
|
1161
|
-
.schema([
|
|
1162
|
-
RepeaterField.make('tags')
|
|
1163
|
-
.relationship({ name: 'tags', model: otherChild.model })
|
|
1164
|
-
.schema([TextField.make('name')]),
|
|
1165
|
-
])
|
|
1166
|
-
.save(async () => setup.makeRecord('a1'))
|
|
1167
|
-
|
|
1168
|
-
const submittedRows = [{ name: 'red' }]
|
|
1169
|
-
const result = await dispatchFormSubmit(
|
|
1170
|
-
form,
|
|
1171
|
-
{ tags: submittedRows },
|
|
1172
|
-
{ values: { tags: submittedRows }, parentModel: setup.parentModel },
|
|
1173
|
-
)
|
|
1174
|
-
assert.equal(result.ok, true)
|
|
1175
|
-
// Override model received the create, NOT the descriptor's model.
|
|
1176
|
-
assert.equal(otherChild.calls.filter(c => c.kind === 'create').length, 1)
|
|
1177
|
-
assert.equal(child.calls.filter(c => c.kind === 'create').length, 0)
|
|
1178
|
-
})
|
|
1179
|
-
|
|
1180
|
-
it('orderColumn — rejected under M2M v1 with a clear error', async () => {
|
|
1181
|
-
const child = makeFakeChildModel([])
|
|
1182
|
-
const setup = makeM2MParentSetup({
|
|
1183
|
-
childModel: child.model,
|
|
1184
|
-
childRows: child.rows,
|
|
1185
|
-
relationName: 'tags',
|
|
1186
|
-
attachedIds: new Set(),
|
|
1187
|
-
})
|
|
1188
|
-
|
|
1189
|
-
const form = Form.make()
|
|
1190
|
-
.schema([
|
|
1191
|
-
RepeaterField.make('tags')
|
|
1192
|
-
.relationship('tags')
|
|
1193
|
-
.orderColumn('sort')
|
|
1194
|
-
.schema([TextField.make('name')]),
|
|
1195
|
-
])
|
|
1196
|
-
.save(async () => setup.makeRecord('a1'))
|
|
1197
|
-
|
|
1198
|
-
const submittedRows = [{ name: 'red' }]
|
|
1199
|
-
await assert.rejects(
|
|
1200
|
-
() => dispatchFormSubmit(
|
|
1201
|
-
form,
|
|
1202
|
-
{ tags: submittedRows },
|
|
1203
|
-
{ values: { tags: submittedRows }, parentModel: setup.parentModel },
|
|
1204
|
-
),
|
|
1205
|
-
/orderColumn\(\) is not supported under 'belongsToMany'/,
|
|
1206
|
-
)
|
|
1207
|
-
})
|
|
1208
|
-
|
|
1209
|
-
it('missing accessor — clear error when parent exposes neither parent[rel]() nor a legacy related() shape', async () => {
|
|
1210
|
-
const child = makeFakeChildModel([])
|
|
1211
|
-
// Parent missing the prototype `tags()` method AND missing `related`.
|
|
1212
|
-
const parentModel: ModelLike & { relations: Record<string, unknown> } = {
|
|
1213
|
-
primaryKey: 'id',
|
|
1214
|
-
find: async () => null,
|
|
1215
|
-
create: async () => ({}),
|
|
1216
|
-
update: async () => ({}),
|
|
1217
|
-
delete: async () => {},
|
|
1218
|
-
query: () => makeQuery([]),
|
|
1219
|
-
relatedQuery: () => makeQuery([]),
|
|
1220
|
-
relations: {
|
|
1221
|
-
tags: { type: 'belongsToMany', model: () => child.model, pivotTable: 'pivot' },
|
|
1222
|
-
},
|
|
1223
|
-
}
|
|
1224
|
-
|
|
1225
|
-
const form = Form.make()
|
|
1226
|
-
.schema([
|
|
1227
|
-
RepeaterField.make('tags')
|
|
1228
|
-
.relationship('tags')
|
|
1229
|
-
.schema([TextField.make('name')]),
|
|
1230
|
-
])
|
|
1231
|
-
.save(async () => ({ id: 'a1' }))
|
|
1232
|
-
|
|
1233
|
-
const submittedRows = [{ name: 'red' }]
|
|
1234
|
-
await assert.rejects(
|
|
1235
|
-
() => dispatchFormSubmit(
|
|
1236
|
-
form,
|
|
1237
|
-
{ tags: submittedRows },
|
|
1238
|
-
{ values: { tags: submittedRows }, parentModel },
|
|
1239
|
-
),
|
|
1240
|
-
/could not resolve the pivot-mutation accessor/,
|
|
1241
|
-
)
|
|
1242
|
-
})
|
|
1243
|
-
})
|
|
1244
|
-
|
|
1245
|
-
describe('Repeater.relationship — morphToMany', () => {
|
|
1246
|
-
// Parent shape: `Post.tags: morphToMany(Tag, pivotTable: 'taggable',
|
|
1247
|
-
// morphName: 'taggable')`. The accessor handles the polymorphic stamp
|
|
1248
|
-
// on the pivot row internally — pilotiq doesn't see the morph cols.
|
|
1249
|
-
// Behavior is identical to belongsToMany from pilotiq's perspective.
|
|
1250
|
-
|
|
1251
|
-
it('create — same path as belongsToMany; the accessor handles polymorphic stamping internally', async () => {
|
|
1252
|
-
const child = makeFakeChildModel([])
|
|
1253
|
-
const attachedIds = new Set<string | number>()
|
|
1254
|
-
const pivotCalls: Array<{ kind: 'attach'; ids: Array<string | number> }> = []
|
|
1255
|
-
class Post {
|
|
1256
|
-
id?: string
|
|
1257
|
-
constructor(init?: Partial<{ id: string }>) { Object.assign(this, init) }
|
|
1258
|
-
tags() {
|
|
1259
|
-
return {
|
|
1260
|
-
attach: async (input: ReadonlyArray<string | number>) => {
|
|
1261
|
-
for (const id of input) attachedIds.add(id)
|
|
1262
|
-
pivotCalls.push({ kind: 'attach', ids: [...input] })
|
|
1263
|
-
},
|
|
1264
|
-
detach: async () => 0,
|
|
1265
|
-
sync: async () => ({ attached: [], detached: [] }),
|
|
1266
|
-
}
|
|
1267
|
-
}
|
|
1268
|
-
}
|
|
1269
|
-
const parentModel: ModelLike & { relations: Record<string, unknown> } = {
|
|
1270
|
-
primaryKey: 'id',
|
|
1271
|
-
find: async () => null,
|
|
1272
|
-
create: async () => ({}),
|
|
1273
|
-
update: async () => ({}),
|
|
1274
|
-
delete: async () => {},
|
|
1275
|
-
query: () => makeQuery([]),
|
|
1276
|
-
relatedQuery: () => makeQuery([]),
|
|
1277
|
-
relations: {
|
|
1278
|
-
tags: { type: 'morphToMany', model: () => child.model, pivotTable: 'taggable', morphName: 'taggable' },
|
|
1279
|
-
},
|
|
1280
|
-
}
|
|
1281
|
-
|
|
1282
|
-
const form = Form.make()
|
|
1283
|
-
.schema([
|
|
1284
|
-
RepeaterField.make('tags')
|
|
1285
|
-
.relationship('tags')
|
|
1286
|
-
.schema([TextField.make('name').required()]),
|
|
1287
|
-
])
|
|
1288
|
-
.save(async () => new Post({ id: 'p1' }))
|
|
1289
|
-
|
|
1290
|
-
const submittedRows = [{ name: 'red' }, { name: 'blue' }]
|
|
1291
|
-
const result = await dispatchFormSubmit(
|
|
1292
|
-
form,
|
|
1293
|
-
{ tags: submittedRows },
|
|
1294
|
-
{ values: { tags: submittedRows }, parentModel },
|
|
1295
|
-
)
|
|
1296
|
-
assert.equal(result.ok, true)
|
|
1297
|
-
assert.equal(child.calls.filter(c => c.kind === 'create').length, 2)
|
|
1298
|
-
assert.equal(pivotCalls.filter(c => c.kind === 'attach').length, 2)
|
|
1299
|
-
})
|
|
1300
|
-
})
|
|
1301
|
-
|
|
1302
|
-
describe('Repeater.relationship — morphedByMany', () => {
|
|
1303
|
-
// Parent shape: `Tag.posts: morphedByMany(Post, pivotTable: 'taggable',
|
|
1304
|
-
// morphName: 'taggable')`. Inverse polymorphic side. Same accessor surface.
|
|
1305
|
-
|
|
1306
|
-
it('detach-only on row removal (parallel to belongsToMany / morphToMany)', async () => {
|
|
1307
|
-
const child = makeFakeChildModel([
|
|
1308
|
-
{ id: 'c1', title: 'first' },
|
|
1309
|
-
{ id: 'c2', title: 'second' },
|
|
1310
|
-
])
|
|
1311
|
-
const attachedIds = new Set<string | number>(['c1', 'c2'])
|
|
1312
|
-
const pivotCalls: Array<{ kind: 'detach'; ids: Array<string | number> | undefined }> = []
|
|
1313
|
-
class Tag {
|
|
1314
|
-
id?: string
|
|
1315
|
-
constructor(init?: Partial<{ id: string }>) { Object.assign(this, init) }
|
|
1316
|
-
posts() {
|
|
1317
|
-
return {
|
|
1318
|
-
attach: async () => {},
|
|
1319
|
-
detach: async (ids?: ReadonlyArray<string | number>) => {
|
|
1320
|
-
if (ids === undefined) { attachedIds.clear(); pivotCalls.push({ kind: 'detach', ids: undefined }); return 0 }
|
|
1321
|
-
for (const id of ids) attachedIds.delete(id)
|
|
1322
|
-
pivotCalls.push({ kind: 'detach', ids: [...ids] })
|
|
1323
|
-
return ids.length
|
|
1324
|
-
},
|
|
1325
|
-
sync: async () => ({ attached: [], detached: [] }),
|
|
1326
|
-
}
|
|
1327
|
-
}
|
|
1328
|
-
}
|
|
1329
|
-
const parentModel: ModelLike & { relations: Record<string, unknown> } = {
|
|
1330
|
-
primaryKey: 'id',
|
|
1331
|
-
find: async () => null,
|
|
1332
|
-
create: async () => ({}),
|
|
1333
|
-
update: async () => ({}),
|
|
1334
|
-
delete: async () => {},
|
|
1335
|
-
query: () => makeQuery([]),
|
|
1336
|
-
relatedQuery: () => makeQuery(child.rows.filter(r => attachedIds.has(r['id'] as string | number))),
|
|
1337
|
-
relations: {
|
|
1338
|
-
posts: { type: 'morphedByMany', model: () => child.model, pivotTable: 'taggable', morphName: 'taggable' },
|
|
1339
|
-
},
|
|
1340
|
-
}
|
|
1341
|
-
|
|
1342
|
-
const form = Form.make()
|
|
1343
|
-
.schema([
|
|
1344
|
-
RepeaterField.make('posts')
|
|
1345
|
-
.relationship('posts')
|
|
1346
|
-
.schema([TextField.make('title')]),
|
|
1347
|
-
])
|
|
1348
|
-
.save(async () => new Tag({ id: 't1' }))
|
|
1349
|
-
|
|
1350
|
-
const submittedRows = [{ __id: 'c1', title: 'first' }]
|
|
1351
|
-
const result = await dispatchFormSubmit(
|
|
1352
|
-
form,
|
|
1353
|
-
{ posts: submittedRows },
|
|
1354
|
-
{ values: { posts: submittedRows }, record: new Tag({ id: 't1' }), parentModel },
|
|
1355
|
-
)
|
|
1356
|
-
assert.equal(result.ok, true)
|
|
1357
|
-
// Detach c2; never touch M.delete.
|
|
1358
|
-
assert.equal(child.calls.filter(c => c.kind === 'delete').length, 0)
|
|
1359
|
-
const detachedIds = pivotCalls.flatMap(c => c.ids ?? []).map(id => String(id))
|
|
1360
|
-
assert.deepEqual(detachedIds, ['c2'])
|
|
1361
|
-
})
|
|
1362
|
-
})
|
|
1363
|
-
|
|
1364
|
-
/**
|
|
1365
|
-
* Test harness for M2M pivot-extras — `withPivot(...cols)` projection on
|
|
1366
|
-
* the load query + `updatePivot(id, data)` + per-id-pivot `attach({ id:
|
|
1367
|
-
* data })` on the accessor. Mirrors rudder ORM's
|
|
1368
|
-
* `feat(orm): pivot-extras read/update + per-id sync` (PR #251).
|
|
1369
|
-
*
|
|
1370
|
-
* Each child row has an associated pivot row keyed by the child's PK.
|
|
1371
|
-
* `withPivot` stamps the listed pivot columns onto each row under
|
|
1372
|
-
* `row.pivot = { … }`. `updatePivot` patches the matching pivot row.
|
|
1373
|
-
*/
|
|
1374
|
-
function makeM2MParentSetupWithPivot(opts: {
|
|
1375
|
-
childModel: ModelLike
|
|
1376
|
-
childRows: FakeRecord[]
|
|
1377
|
-
relationName: string
|
|
1378
|
-
/** Pivot rows keyed by child PK. Each entry holds the extra columns. */
|
|
1379
|
-
pivot: Map<string, Record<string, unknown>>
|
|
1380
|
-
attachedIds?: Set<string | number>
|
|
1381
|
-
}) {
|
|
1382
|
-
const { childModel, childRows, relationName, pivot } = opts
|
|
1383
|
-
const attachedIds = opts.attachedIds
|
|
1384
|
-
?? new Set<string | number>(childRows.map(r => r['id'] as string | number))
|
|
1385
|
-
const pivotCalls: Array<
|
|
1386
|
-
| { kind: 'attach'; ids: Array<string | number>; pivot?: Record<string, Record<string, unknown>> }
|
|
1387
|
-
| { kind: 'detach'; ids: Array<string | number> | undefined }
|
|
1388
|
-
| { kind: 'updatePivot'; id: string | number; data: Record<string, unknown> }
|
|
1389
|
-
> = []
|
|
1390
|
-
|
|
1391
|
-
function projectPivot(rows: FakeRecord[], cols: string[]): FakeRecord[] {
|
|
1392
|
-
return rows.map(r => {
|
|
1393
|
-
const pk = String(r['id'])
|
|
1394
|
-
const pe = pivot.get(pk) ?? {}
|
|
1395
|
-
const proj: Record<string, unknown> = {}
|
|
1396
|
-
for (const c of cols) proj[c] = pe[c] ?? null
|
|
1397
|
-
return { ...r, pivot: proj }
|
|
1398
|
-
})
|
|
1399
|
-
}
|
|
1400
|
-
|
|
1401
|
-
/** Pivot-aware fake query — adds `withPivot` to the chain. */
|
|
1402
|
-
function makePivotAwareQuery(rows: FakeRecord[]): ModelQuery {
|
|
1403
|
-
let pivotCols: string[] | undefined
|
|
1404
|
-
const q: ModelQuery = {
|
|
1405
|
-
where: () => q,
|
|
1406
|
-
orWhere: () => q,
|
|
1407
|
-
orderBy: () => q,
|
|
1408
|
-
withPivot(...cols: string[]) {
|
|
1409
|
-
pivotCols = cols
|
|
1410
|
-
return q
|
|
1411
|
-
},
|
|
1412
|
-
paginate: async () => {
|
|
1413
|
-
const projected = pivotCols ? projectPivot(rows, pivotCols) : rows.slice()
|
|
1414
|
-
return { data: projected, total: projected.length }
|
|
1415
|
-
},
|
|
1416
|
-
}
|
|
1417
|
-
return q
|
|
1418
|
-
}
|
|
1419
|
-
|
|
1420
|
-
class Parent {
|
|
1421
|
-
id?: string
|
|
1422
|
-
constructor(init?: Partial<{ id: string }>) { Object.assign(this, init) }
|
|
1423
|
-
[relationName]() {
|
|
1424
|
-
return {
|
|
1425
|
-
attach: async (input: ReadonlyArray<string | number> | Record<string, Record<string, unknown>>) => {
|
|
1426
|
-
if (Array.isArray(input)) {
|
|
1427
|
-
const ids = [...input]
|
|
1428
|
-
for (const id of ids) attachedIds.add(id)
|
|
1429
|
-
pivotCalls.push({ kind: 'attach', ids })
|
|
1430
|
-
} else {
|
|
1431
|
-
const map = input as Record<string, Record<string, unknown>>
|
|
1432
|
-
const ids = Object.keys(map).map(k => /^\d+$/.test(k) ? Number(k) : k) as Array<string | number>
|
|
1433
|
-
for (const id of ids) {
|
|
1434
|
-
attachedIds.add(id)
|
|
1435
|
-
pivot.set(String(id), { ...(pivot.get(String(id)) ?? {}), ...map[String(id)] })
|
|
1436
|
-
}
|
|
1437
|
-
pivotCalls.push({ kind: 'attach', ids, pivot: map })
|
|
1438
|
-
}
|
|
1439
|
-
},
|
|
1440
|
-
detach: async (ids?: ReadonlyArray<string | number>) => {
|
|
1441
|
-
if (ids === undefined) {
|
|
1442
|
-
const removed = [...attachedIds]
|
|
1443
|
-
attachedIds.clear()
|
|
1444
|
-
for (const id of removed) pivot.delete(String(id))
|
|
1445
|
-
pivotCalls.push({ kind: 'detach', ids: undefined })
|
|
1446
|
-
return removed.length
|
|
1447
|
-
}
|
|
1448
|
-
for (const id of ids) {
|
|
1449
|
-
attachedIds.delete(id)
|
|
1450
|
-
pivot.delete(String(id))
|
|
1451
|
-
}
|
|
1452
|
-
pivotCalls.push({ kind: 'detach', ids: [...ids] })
|
|
1453
|
-
return ids.length
|
|
1454
|
-
},
|
|
1455
|
-
updatePivot: async (id: string | number, data: Record<string, unknown>): Promise<number> => {
|
|
1456
|
-
pivotCalls.push({ kind: 'updatePivot', id, data: { ...data } })
|
|
1457
|
-
const key = String(id)
|
|
1458
|
-
if (!pivot.has(key)) return 0
|
|
1459
|
-
pivot.set(key, { ...pivot.get(key), ...data })
|
|
1460
|
-
return 1
|
|
1461
|
-
},
|
|
1462
|
-
}
|
|
1463
|
-
}
|
|
1464
|
-
}
|
|
1465
|
-
|
|
1466
|
-
const parentModel: ModelLike & { relations: Record<string, unknown> } = {
|
|
1467
|
-
primaryKey: 'id',
|
|
1468
|
-
find: async () => null,
|
|
1469
|
-
create: async () => ({}),
|
|
1470
|
-
update: async () => ({}),
|
|
1471
|
-
delete: async () => {},
|
|
1472
|
-
query: () => makeQuery([]),
|
|
1473
|
-
relatedQuery: () => makePivotAwareQuery(
|
|
1474
|
-
childRows.filter(r => attachedIds.has(r['id'] as string | number)),
|
|
1475
|
-
),
|
|
1476
|
-
relations: {
|
|
1477
|
-
[relationName]: { type: 'belongsToMany', model: () => childModel, pivotTable: 'pivot' },
|
|
1478
|
-
},
|
|
1479
|
-
}
|
|
1480
|
-
return {
|
|
1481
|
-
parentModel,
|
|
1482
|
-
pivotCalls,
|
|
1483
|
-
pivotState: pivot,
|
|
1484
|
-
attachedIds,
|
|
1485
|
-
makeRecord: (id: string) => new Parent({ id }),
|
|
1486
|
-
}
|
|
1487
|
-
}
|
|
1488
|
-
|
|
1489
|
-
describe('Repeater.relationship — pivotColumns', () => {
|
|
1490
|
-
// Surface check: setter requires .relationship() first; round-trips into cfg.
|
|
1491
|
-
it('Repeater.pivotColumns([…]) requires .relationship() first', () => {
|
|
1492
|
-
assert.throws(
|
|
1493
|
-
() => RepeaterField.make('tags').pivotColumns(['role']),
|
|
1494
|
-
/requires relationship\(\) to be configured first/,
|
|
1495
|
-
)
|
|
1496
|
-
})
|
|
1497
|
-
|
|
1498
|
-
it('Repeater.pivotColumns([…]) writes to the relationship cfg', () => {
|
|
1499
|
-
const r = RepeaterField.make('tags')
|
|
1500
|
-
.relationship('tags')
|
|
1501
|
-
.pivotColumns(['role', 'assignedAt'])
|
|
1502
|
-
assert.deepEqual(r.getRelationship()?.pivotColumns, ['role', 'assignedAt'])
|
|
1503
|
-
})
|
|
1504
|
-
|
|
1505
|
-
it('load — withPivot ferries the configured columns; row values flatten onto the form data', async () => {
|
|
1506
|
-
const child = makeFakeChildModel([
|
|
1507
|
-
{ id: 'c1', name: 'red' },
|
|
1508
|
-
{ id: 'c2', name: 'blue' },
|
|
1509
|
-
])
|
|
1510
|
-
const setup = makeM2MParentSetupWithPivot({
|
|
1511
|
-
childModel: child.model,
|
|
1512
|
-
childRows: child.rows,
|
|
1513
|
-
relationName: 'tags',
|
|
1514
|
-
pivot: new Map([
|
|
1515
|
-
['c1', { role: 'owner' }],
|
|
1516
|
-
['c2', { role: 'editor' }],
|
|
1517
|
-
]),
|
|
1518
|
-
attachedIds: new Set(['c1', 'c2']),
|
|
1519
|
-
})
|
|
1520
|
-
|
|
1521
|
-
const form = Form.make().schema([
|
|
1522
|
-
RepeaterField.make('tags')
|
|
1523
|
-
.relationship('tags')
|
|
1524
|
-
.pivotColumns(['role'])
|
|
1525
|
-
.schema([TextField.make('name'), TextField.make('role')]),
|
|
1526
|
-
])
|
|
1527
|
-
|
|
1528
|
-
const filled = await applyRelationshipRepeaterFill(
|
|
1529
|
-
form, {}, setup.makeRecord('a1'), setup.parentModel,
|
|
1530
|
-
)
|
|
1531
|
-
|
|
1532
|
-
const rows = filled['tags'] as Array<Record<string, unknown>>
|
|
1533
|
-
assert.equal(rows.length, 2)
|
|
1534
|
-
assert.equal(rows[0]?.['name'], 'red')
|
|
1535
|
-
assert.equal(rows[0]?.['role'], 'owner')
|
|
1536
|
-
assert.equal(rows[0]?.['__id'], 'c1')
|
|
1537
|
-
assert.equal(rows[1]?.['name'], 'blue')
|
|
1538
|
-
assert.equal(rows[1]?.['role'], 'editor')
|
|
1539
|
-
// pivot envelope is dropped — it's an internal carrier, not form data.
|
|
1540
|
-
assert.equal('pivot' in (rows[0] ?? {}), false)
|
|
1541
|
-
})
|
|
1542
|
-
|
|
1543
|
-
it('save (existing row) — pivot extras route through updatePivot, child fields through M.update', async () => {
|
|
1544
|
-
const child = makeFakeChildModel([
|
|
1545
|
-
{ id: 'c1', name: 'red' },
|
|
1546
|
-
])
|
|
1547
|
-
const setup = makeM2MParentSetupWithPivot({
|
|
1548
|
-
childModel: child.model,
|
|
1549
|
-
childRows: child.rows,
|
|
1550
|
-
relationName: 'tags',
|
|
1551
|
-
pivot: new Map([['c1', { role: 'editor' }]]),
|
|
1552
|
-
attachedIds: new Set(['c1']),
|
|
1553
|
-
})
|
|
1554
|
-
|
|
1555
|
-
const form = Form.make()
|
|
1556
|
-
.schema([
|
|
1557
|
-
RepeaterField.make('tags')
|
|
1558
|
-
.relationship('tags')
|
|
1559
|
-
.pivotColumns(['role'])
|
|
1560
|
-
.schema([TextField.make('name'), TextField.make('role')]),
|
|
1561
|
-
])
|
|
1562
|
-
.save(async () => setup.makeRecord('a1'))
|
|
1563
|
-
|
|
1564
|
-
const submittedRows = [{ __id: 'c1', name: 'crimson', role: 'owner' }]
|
|
1565
|
-
const result = await dispatchFormSubmit(
|
|
1566
|
-
form,
|
|
1567
|
-
{ tags: submittedRows },
|
|
1568
|
-
{ values: { tags: submittedRows }, record: setup.makeRecord('a1'), parentModel: setup.parentModel },
|
|
1569
|
-
)
|
|
1570
|
-
assert.equal(result.ok, true)
|
|
1571
|
-
|
|
1572
|
-
// Child row got the non-pivot field; pivot col was NOT smuggled through.
|
|
1573
|
-
const updates = child.calls.filter(c => c.kind === 'update') as Array<{ kind: 'update'; id: string | number; data: Record<string, unknown> }>
|
|
1574
|
-
assert.equal(updates.length, 1)
|
|
1575
|
-
assert.equal(updates[0]?.data['name'], 'crimson')
|
|
1576
|
-
assert.equal('role' in (updates[0]?.data ?? {}), false)
|
|
1577
|
-
|
|
1578
|
-
// Pivot row patched via updatePivot.
|
|
1579
|
-
const pivotUpdates = setup.pivotCalls.filter(c => c.kind === 'updatePivot') as Array<{ kind: 'updatePivot'; id: string | number; data: Record<string, unknown> }>
|
|
1580
|
-
assert.equal(pivotUpdates.length, 1)
|
|
1581
|
-
assert.equal(pivotUpdates[0]?.id, 'c1')
|
|
1582
|
-
assert.deepEqual(pivotUpdates[0]?.data, { role: 'owner' })
|
|
1583
|
-
})
|
|
1584
|
-
|
|
1585
|
-
it('save (new row) — attach uses the per-id-pivot map shape', async () => {
|
|
1586
|
-
const child = makeFakeChildModel([])
|
|
1587
|
-
const setup = makeM2MParentSetupWithPivot({
|
|
1588
|
-
childModel: child.model,
|
|
1589
|
-
childRows: child.rows,
|
|
1590
|
-
relationName: 'tags',
|
|
1591
|
-
pivot: new Map(),
|
|
1592
|
-
attachedIds: new Set(),
|
|
1593
|
-
})
|
|
1594
|
-
|
|
1595
|
-
const form = Form.make()
|
|
1596
|
-
.schema([
|
|
1597
|
-
RepeaterField.make('tags')
|
|
1598
|
-
.relationship('tags')
|
|
1599
|
-
.pivotColumns(['role'])
|
|
1600
|
-
.schema([TextField.make('name'), TextField.make('role')]),
|
|
1601
|
-
])
|
|
1602
|
-
.save(async () => setup.makeRecord('a1'))
|
|
1603
|
-
|
|
1604
|
-
const submittedRows = [{ name: 'red', role: 'owner' }]
|
|
1605
|
-
const result = await dispatchFormSubmit(
|
|
1606
|
-
form,
|
|
1607
|
-
{ tags: submittedRows },
|
|
1608
|
-
{ values: { tags: submittedRows }, parentModel: setup.parentModel },
|
|
1609
|
-
)
|
|
1610
|
-
assert.equal(result.ok, true)
|
|
1611
|
-
|
|
1612
|
-
// Child created without `role` (pivot column).
|
|
1613
|
-
const creates = child.calls.filter(c => c.kind === 'create') as Array<{ kind: 'create'; data: Record<string, unknown> }>
|
|
1614
|
-
assert.equal(creates.length, 1)
|
|
1615
|
-
assert.equal(creates[0]?.data['name'], 'red')
|
|
1616
|
-
assert.equal('role' in (creates[0]?.data ?? {}), false)
|
|
1617
|
-
|
|
1618
|
-
// attach received the per-id-pivot map.
|
|
1619
|
-
const attachCalls = setup.pivotCalls.filter(c => c.kind === 'attach') as Array<{ kind: 'attach'; pivot?: Record<string, Record<string, unknown>> }>
|
|
1620
|
-
assert.equal(attachCalls.length, 1)
|
|
1621
|
-
assert.ok(attachCalls[0]?.pivot, 'attach should have received a per-id pivot map')
|
|
1622
|
-
const map = attachCalls[0]!.pivot!
|
|
1623
|
-
const onlyKey = Object.keys(map)[0]!
|
|
1624
|
-
assert.deepEqual(map[onlyKey], { role: 'owner' })
|
|
1625
|
-
})
|
|
1626
|
-
|
|
1627
|
-
it('save (existing row, no pivot edit) — skips updatePivot when payload has no pivot keys', async () => {
|
|
1628
|
-
const child = makeFakeChildModel([
|
|
1629
|
-
{ id: 'c1', name: 'red' },
|
|
1630
|
-
])
|
|
1631
|
-
const setup = makeM2MParentSetupWithPivot({
|
|
1632
|
-
childModel: child.model,
|
|
1633
|
-
childRows: child.rows,
|
|
1634
|
-
relationName: 'tags',
|
|
1635
|
-
pivot: new Map([['c1', { role: 'editor' }]]),
|
|
1636
|
-
attachedIds: new Set(['c1']),
|
|
1637
|
-
})
|
|
1638
|
-
|
|
1639
|
-
const form = Form.make()
|
|
1640
|
-
.schema([
|
|
1641
|
-
RepeaterField.make('tags')
|
|
1642
|
-
.relationship('tags')
|
|
1643
|
-
.pivotColumns(['role'])
|
|
1644
|
-
.schema([TextField.make('name'), TextField.make('role')]),
|
|
1645
|
-
])
|
|
1646
|
-
.save(async () => setup.makeRecord('a1'))
|
|
1647
|
-
|
|
1648
|
-
// Submit only changes the child column; role omitted entirely.
|
|
1649
|
-
const submittedRows = [{ __id: 'c1', name: 'crimson' }]
|
|
1650
|
-
const result = await dispatchFormSubmit(
|
|
1651
|
-
form,
|
|
1652
|
-
{ tags: submittedRows },
|
|
1653
|
-
{ values: { tags: submittedRows }, record: setup.makeRecord('a1'), parentModel: setup.parentModel },
|
|
1654
|
-
)
|
|
1655
|
-
assert.equal(result.ok, true)
|
|
1656
|
-
assert.equal(setup.pivotCalls.filter(c => c.kind === 'updatePivot').length, 0)
|
|
1657
|
-
})
|
|
1658
|
-
|
|
1659
|
-
it('save — throws a clear error when accessor lacks updatePivot but pivot extras changed', async () => {
|
|
1660
|
-
const child = makeFakeChildModel([{ id: 'c1', name: 'red' }])
|
|
1661
|
-
// Build a parent whose accessor does NOT expose updatePivot.
|
|
1662
|
-
const accessorWithoutUpdate = {
|
|
1663
|
-
attach: async () => {},
|
|
1664
|
-
detach: async () => 0,
|
|
1665
|
-
}
|
|
1666
|
-
class Parent {
|
|
1667
|
-
id?: string
|
|
1668
|
-
constructor(init?: Partial<{ id: string }>) { Object.assign(this, init) }
|
|
1669
|
-
tags() { return accessorWithoutUpdate }
|
|
1670
|
-
}
|
|
1671
|
-
const parentModel: ModelLike & { relations: Record<string, unknown> } = {
|
|
1672
|
-
primaryKey: 'id',
|
|
1673
|
-
find: async () => null,
|
|
1674
|
-
create: async () => ({}),
|
|
1675
|
-
update: async () => ({}),
|
|
1676
|
-
delete: async () => {},
|
|
1677
|
-
query: () => makeQuery([]),
|
|
1678
|
-
relatedQuery: () => makeQuery([{ id: 'c1', name: 'red' }]),
|
|
1679
|
-
relations: {
|
|
1680
|
-
tags: { type: 'belongsToMany', model: () => child.model, pivotTable: 'pivot' },
|
|
1681
|
-
},
|
|
1682
|
-
}
|
|
1683
|
-
|
|
1684
|
-
const form = Form.make()
|
|
1685
|
-
.schema([
|
|
1686
|
-
RepeaterField.make('tags')
|
|
1687
|
-
.relationship('tags')
|
|
1688
|
-
.pivotColumns(['role'])
|
|
1689
|
-
.schema([TextField.make('name'), TextField.make('role')]),
|
|
1690
|
-
])
|
|
1691
|
-
.save(async () => new Parent({ id: 'a1' }))
|
|
1692
|
-
|
|
1693
|
-
const submittedRows = [{ __id: 'c1', name: 'red', role: 'owner' }]
|
|
1694
|
-
await assert.rejects(
|
|
1695
|
-
() => dispatchFormSubmit(
|
|
1696
|
-
form,
|
|
1697
|
-
{ tags: submittedRows },
|
|
1698
|
-
{ values: { tags: submittedRows }, record: new Parent({ id: 'a1' }), parentModel },
|
|
1699
|
-
),
|
|
1700
|
-
/requires a rudder ORM with `updatePivot`/,
|
|
1701
|
-
)
|
|
1702
|
-
})
|
|
1703
|
-
|
|
1704
|
-
it('loadRelationRows — passes pivotColumns into withPivot when supported', async () => {
|
|
1705
|
-
let seenCols: string[] | undefined
|
|
1706
|
-
const q: ModelQuery = {
|
|
1707
|
-
where: () => q,
|
|
1708
|
-
orWhere: () => q,
|
|
1709
|
-
orderBy: () => q,
|
|
1710
|
-
withPivot(...cols: string[]) {
|
|
1711
|
-
seenCols = cols
|
|
1712
|
-
return q
|
|
1713
|
-
},
|
|
1714
|
-
paginate: async () => ({ data: [], total: 0 }),
|
|
1715
|
-
}
|
|
1716
|
-
const M: ModelLike = {
|
|
1717
|
-
primaryKey: 'id',
|
|
1718
|
-
find: async () => null,
|
|
1719
|
-
create: async () => ({}),
|
|
1720
|
-
update: async () => ({}),
|
|
1721
|
-
delete: async () => {},
|
|
1722
|
-
query: () => q,
|
|
1723
|
-
relatedQuery: () => q,
|
|
1724
|
-
}
|
|
1725
|
-
await loadRelationRows(M, {}, 'tags', ['role', 'assignedAt'])
|
|
1726
|
-
assert.deepEqual(seenCols, ['role', 'assignedAt'])
|
|
1727
|
-
})
|
|
1728
|
-
|
|
1729
|
-
it('loadRelationRows — silently skips withPivot on a model that does not implement it', async () => {
|
|
1730
|
-
// A model whose query has no `withPivot` method — pilotiq should
|
|
1731
|
-
// call paginate without throwing.
|
|
1732
|
-
const q: ModelQuery = {
|
|
1733
|
-
where: () => q,
|
|
1734
|
-
orWhere: () => q,
|
|
1735
|
-
orderBy: () => q,
|
|
1736
|
-
paginate: async () => ({ data: [], total: 0 }),
|
|
1737
|
-
}
|
|
1738
|
-
const M: ModelLike = {
|
|
1739
|
-
primaryKey: 'id',
|
|
1740
|
-
find: async () => null,
|
|
1741
|
-
create: async () => ({}),
|
|
1742
|
-
update: async () => ({}),
|
|
1743
|
-
delete: async () => {},
|
|
1744
|
-
query: () => q,
|
|
1745
|
-
relatedQuery: () => q,
|
|
1746
|
-
}
|
|
1747
|
-
const rows = await loadRelationRows(M, {}, 'tags', ['role'])
|
|
1748
|
-
assert.deepEqual(rows, [])
|
|
1749
|
-
})
|
|
1750
|
-
})
|
|
1751
|
-
|
|
1752
|
-
describe('Repeater.relationship — afterCreate / afterUpdate / afterDelete hooks', () => {
|
|
1753
|
-
it('afterCreate fires once per created child with parent + index + mode in ctx', async () => {
|
|
1754
|
-
const child = makeFakeChildModel([])
|
|
1755
|
-
const parent = makeFakeParentModel({
|
|
1756
|
-
childModel: child.model,
|
|
1757
|
-
childRows: child.rows,
|
|
1758
|
-
relationName: 'items',
|
|
1759
|
-
foreignKey: 'orderId',
|
|
1760
|
-
})
|
|
1761
|
-
|
|
1762
|
-
const calls: Array<{ record: unknown; ctx: Record<string, unknown> }> = []
|
|
1763
|
-
const form = Form.make()
|
|
1764
|
-
.schema([
|
|
1765
|
-
RepeaterField.make('items')
|
|
1766
|
-
.relationship('items')
|
|
1767
|
-
.schema([TextField.make('label').required()])
|
|
1768
|
-
.afterCreate((record, ctx) => {
|
|
1769
|
-
calls.push({ record, ctx: { ...ctx } as Record<string, unknown> })
|
|
1770
|
-
}),
|
|
1771
|
-
])
|
|
1772
|
-
.save(async () => ({ id: 'p1', title: 'Order' }))
|
|
1773
|
-
|
|
1774
|
-
await dispatchFormSubmit(
|
|
1775
|
-
form,
|
|
1776
|
-
{ items: [{ label: 'A' }, { label: 'B' }] },
|
|
1777
|
-
{ values: { items: [{ label: 'A' }, { label: 'B' }] }, parentModel: parent },
|
|
1778
|
-
)
|
|
1779
|
-
|
|
1780
|
-
assert.equal(calls.length, 2)
|
|
1781
|
-
assert.equal((calls[0]!.record as Record<string, unknown>)['label'], 'A')
|
|
1782
|
-
assert.equal((calls[1]!.record as Record<string, unknown>)['label'], 'B')
|
|
1783
|
-
assert.equal(calls[0]!.ctx['index'], 0)
|
|
1784
|
-
assert.equal(calls[1]!.ctx['index'], 1)
|
|
1785
|
-
assert.equal(calls[0]!.ctx['field'], 'items')
|
|
1786
|
-
assert.equal(calls[0]!.ctx['mode'], 'hasMany')
|
|
1787
|
-
assert.equal(calls[0]!.ctx['parentId'], 'p1')
|
|
1788
|
-
assert.deepEqual(calls[0]!.ctx['parent'], { id: 'p1', title: 'Order' })
|
|
1789
|
-
})
|
|
1790
|
-
|
|
1791
|
-
it('afterUpdate fires per updated child (skipping pure-create rows)', async () => {
|
|
1792
|
-
const child = makeFakeChildModel([
|
|
1793
|
-
{ id: 'c1', orderId: 'p1', label: 'old A' },
|
|
1794
|
-
])
|
|
1795
|
-
const parent = makeFakeParentModel({
|
|
1796
|
-
childModel: child.model,
|
|
1797
|
-
childRows: child.rows,
|
|
1798
|
-
relationName: 'items',
|
|
1799
|
-
foreignKey: 'orderId',
|
|
1800
|
-
})
|
|
1801
|
-
|
|
1802
|
-
const updates: Array<{ record: unknown; index: number }> = []
|
|
1803
|
-
const creates: Array<{ record: unknown; index: number }> = []
|
|
1804
|
-
const form = Form.make()
|
|
1805
|
-
.schema([
|
|
1806
|
-
RepeaterField.make('items')
|
|
1807
|
-
.relationship('items')
|
|
1808
|
-
.schema([TextField.make('label').required()])
|
|
1809
|
-
.afterCreate((record, ctx) => { creates.push({ record, index: ctx.index }) })
|
|
1810
|
-
.afterUpdate((record, ctx) => { updates.push({ record, index: ctx.index }) }),
|
|
1811
|
-
])
|
|
1812
|
-
.save(async () => ({ id: 'p1' }))
|
|
1813
|
-
|
|
1814
|
-
await dispatchFormSubmit(
|
|
1815
|
-
form,
|
|
1816
|
-
{ items: [{ __id: 'c1', label: 'new A' }, { label: 'fresh B' }] },
|
|
1817
|
-
{
|
|
1818
|
-
values: { items: [{ __id: 'c1', label: 'new A' }, { label: 'fresh B' }] },
|
|
1819
|
-
record: { id: 'p1' },
|
|
1820
|
-
parentModel: parent,
|
|
1821
|
-
},
|
|
1822
|
-
)
|
|
1823
|
-
|
|
1824
|
-
assert.equal(updates.length, 1)
|
|
1825
|
-
assert.equal((updates[0]!.record as Record<string, unknown>)['label'], 'new A')
|
|
1826
|
-
assert.equal(updates[0]!.index, 0)
|
|
1827
|
-
assert.equal(creates.length, 1)
|
|
1828
|
-
assert.equal((creates[0]!.record as Record<string, unknown>)['label'], 'fresh B')
|
|
1829
|
-
assert.equal(creates[0]!.index, 1)
|
|
1830
|
-
})
|
|
1831
|
-
|
|
1832
|
-
it('afterDelete fires once per removed child with the previous row data', async () => {
|
|
1833
|
-
const child = makeFakeChildModel([
|
|
1834
|
-
{ id: 'c1', orderId: 'p1', label: 'A' },
|
|
1835
|
-
{ id: 'c2', orderId: 'p1', label: 'B' },
|
|
1836
|
-
{ id: 'c3', orderId: 'p1', label: 'C' },
|
|
1837
|
-
])
|
|
1838
|
-
const parent = makeFakeParentModel({
|
|
1839
|
-
childModel: child.model,
|
|
1840
|
-
childRows: child.rows,
|
|
1841
|
-
relationName: 'items',
|
|
1842
|
-
foreignKey: 'orderId',
|
|
1843
|
-
})
|
|
1844
|
-
|
|
1845
|
-
const removed: Array<{ record: unknown; ctx: Record<string, unknown> }> = []
|
|
1846
|
-
const form = Form.make()
|
|
1847
|
-
.schema([
|
|
1848
|
-
RepeaterField.make('items')
|
|
1849
|
-
.relationship('items')
|
|
1850
|
-
.schema([TextField.make('label').required()])
|
|
1851
|
-
.afterDelete((record, ctx) => {
|
|
1852
|
-
removed.push({ record, ctx: { ...ctx } as Record<string, unknown> })
|
|
1853
|
-
}),
|
|
1854
|
-
])
|
|
1855
|
-
.save(async () => ({ id: 'p1' }))
|
|
1856
|
-
|
|
1857
|
-
// Submit only c1 — c2 and c3 disappear.
|
|
1858
|
-
await dispatchFormSubmit(
|
|
1859
|
-
form,
|
|
1860
|
-
{ items: [{ __id: 'c1', label: 'A' }] },
|
|
1861
|
-
{
|
|
1862
|
-
values: { items: [{ __id: 'c1', label: 'A' }] },
|
|
1863
|
-
record: { id: 'p1' },
|
|
1864
|
-
parentModel: parent,
|
|
1865
|
-
},
|
|
1866
|
-
)
|
|
1867
|
-
|
|
1868
|
-
assert.equal(removed.length, 2)
|
|
1869
|
-
const labels = removed.map(r => (r.record as Record<string, unknown>)['label']).sort()
|
|
1870
|
-
assert.deepEqual(labels, ['B', 'C'])
|
|
1871
|
-
assert.equal(removed[0]!.ctx['index'], -1)
|
|
1872
|
-
assert.equal(removed[0]!.ctx['mode'], 'hasMany')
|
|
1873
|
-
assert.equal(removed[0]!.ctx['parentId'], 'p1')
|
|
1874
|
-
})
|
|
1875
|
-
|
|
1876
|
-
it('hooks are no-op outside relationship() mode (throw at config time)', () => {
|
|
1877
|
-
assert.throws(() =>
|
|
1878
|
-
RepeaterField.make('json').afterCreate(() => {}),
|
|
1879
|
-
/requires relationship/,
|
|
1880
|
-
)
|
|
1881
|
-
assert.throws(() =>
|
|
1882
|
-
RepeaterField.make('json').afterUpdate(() => {}),
|
|
1883
|
-
/requires relationship/,
|
|
1884
|
-
)
|
|
1885
|
-
assert.throws(() =>
|
|
1886
|
-
RepeaterField.make('json').afterDelete(() => {}),
|
|
1887
|
-
/requires relationship/,
|
|
1888
|
-
)
|
|
1889
|
-
})
|
|
1890
|
-
|
|
1891
|
-
it('throwing handler propagates and aborts the rest of the persist diff', async () => {
|
|
1892
|
-
const child = makeFakeChildModel([])
|
|
1893
|
-
const parent = makeFakeParentModel({
|
|
1894
|
-
childModel: child.model,
|
|
1895
|
-
childRows: child.rows,
|
|
1896
|
-
relationName: 'items',
|
|
1897
|
-
foreignKey: 'orderId',
|
|
1898
|
-
})
|
|
1899
|
-
|
|
1900
|
-
const form = Form.make()
|
|
1901
|
-
.schema([
|
|
1902
|
-
RepeaterField.make('items')
|
|
1903
|
-
.relationship('items')
|
|
1904
|
-
.schema([TextField.make('label').required()])
|
|
1905
|
-
.afterCreate((record) => {
|
|
1906
|
-
const r = record as Record<string, unknown>
|
|
1907
|
-
if (r['label'] === 'B') throw new Error('reject B')
|
|
1908
|
-
}),
|
|
1909
|
-
])
|
|
1910
|
-
.save(async () => ({ id: 'p1' }))
|
|
1911
|
-
|
|
1912
|
-
await assert.rejects(
|
|
1913
|
-
() => dispatchFormSubmit(
|
|
1914
|
-
form,
|
|
1915
|
-
{ items: [{ label: 'A' }, { label: 'B' }, { label: 'C' }] },
|
|
1916
|
-
{ values: { items: [{ label: 'A' }, { label: 'B' }, { label: 'C' }] }, parentModel: parent },
|
|
1917
|
-
),
|
|
1918
|
-
/reject B/,
|
|
1919
|
-
)
|
|
1920
|
-
// Two creates fired before the throw — no rollback (v1 isn't transactional).
|
|
1921
|
-
assert.equal(child.calls.filter(c => c.kind === 'create').length, 2)
|
|
1922
|
-
})
|
|
1923
|
-
})
|