@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.
Files changed (480) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/boost/guidelines.md +566 -0
  3. package/boost/skills/pilotiq-fields/SKILL.md +47 -0
  4. package/boost/skills/pilotiq-fields/rules/field-catalog.md +288 -0
  5. package/boost/skills/pilotiq-fields/rules/reactive-fields.md +199 -0
  6. package/boost/skills/pilotiq-fields/rules/validation.md +198 -0
  7. package/boost/skills/pilotiq-relations/SKILL.md +47 -0
  8. package/boost/skills/pilotiq-relations/rules/relation-managers.md +256 -0
  9. package/boost/skills/pilotiq-relations/rules/repeater-relationship.md +177 -0
  10. package/boost/skills/pilotiq-resource/SKILL.md +61 -0
  11. package/boost/skills/pilotiq-resource/rules/authorization.md +242 -0
  12. package/boost/skills/pilotiq-resource/rules/defining-resources.md +228 -0
  13. package/boost/skills/pilotiq-resource/rules/page-overrides.md +296 -0
  14. package/package.json +6 -1
  15. package/.turbo/turbo-build.log +0 -8
  16. package/CLAUDE.md +0 -265
  17. package/src/Cluster.test.ts +0 -283
  18. package/src/Cluster.ts +0 -83
  19. package/src/Column.test.ts +0 -199
  20. package/src/Column.ts +0 -710
  21. package/src/Global.test.ts +0 -367
  22. package/src/Global.ts +0 -169
  23. package/src/Page.test.ts +0 -114
  24. package/src/Page.ts +0 -208
  25. package/src/Pilotiq.perf.test.ts +0 -252
  26. package/src/Pilotiq.test.ts +0 -129
  27. package/src/Pilotiq.ts +0 -1158
  28. package/src/PilotiqRegistry.ts +0 -36
  29. package/src/PilotiqServiceProvider.ts +0 -121
  30. package/src/RelationManager.test.ts +0 -400
  31. package/src/RelationManager.ts +0 -527
  32. package/src/RenderHook.test.ts +0 -252
  33. package/src/RenderHook.ts +0 -242
  34. package/src/Resource.test.ts +0 -284
  35. package/src/Resource.ts +0 -526
  36. package/src/RightPanel.test.ts +0 -202
  37. package/src/RightPanel.ts +0 -132
  38. package/src/Tab.test.ts +0 -91
  39. package/src/Tab.ts +0 -156
  40. package/src/UserMenuItem.ts +0 -145
  41. package/src/actions/Action.test.ts +0 -2526
  42. package/src/actions/Action.ts +0 -1515
  43. package/src/actions/ActionGroup.test.ts +0 -112
  44. package/src/actions/ActionGroup.ts +0 -173
  45. package/src/actions/attachFactory.ts +0 -172
  46. package/src/actions/bulkFactories.ts +0 -168
  47. package/src/actions/crudFactories.ts +0 -220
  48. package/src/actions/exportFactory.ts +0 -225
  49. package/src/actions/factoryHelpers.ts +0 -177
  50. package/src/actions/importFactory.ts +0 -243
  51. package/src/actions/index.ts +0 -17
  52. package/src/actions/m2mFactories.ts +0 -193
  53. package/src/actions/relationFactories.ts +0 -372
  54. package/src/applyPageHooks.test.ts +0 -463
  55. package/src/applyPageHooks.ts +0 -330
  56. package/src/authorization.test.ts +0 -483
  57. package/src/breadcrumbs.test.ts +0 -238
  58. package/src/cells/coerce.test.ts +0 -85
  59. package/src/cells/coerce.ts +0 -84
  60. package/src/clusterPaths.ts +0 -35
  61. package/src/columns/BadgeColumn.test.ts +0 -54
  62. package/src/columns/BadgeColumn.ts +0 -32
  63. package/src/columns/BooleanColumn.test.ts +0 -41
  64. package/src/columns/BooleanColumn.ts +0 -18
  65. package/src/columns/ColorColumn.test.ts +0 -37
  66. package/src/columns/ColorColumn.ts +0 -38
  67. package/src/columns/IconColumn.test.ts +0 -54
  68. package/src/columns/IconColumn.ts +0 -37
  69. package/src/columns/ImageColumn.test.ts +0 -41
  70. package/src/columns/ImageColumn.ts +0 -28
  71. package/src/columns/SelectColumn.ts +0 -98
  72. package/src/columns/TextColumn.test.ts +0 -190
  73. package/src/columns/TextColumn.ts +0 -20
  74. package/src/columns/TextInputColumn.ts +0 -68
  75. package/src/columns/ToggleColumn.ts +0 -46
  76. package/src/columns/editableColumns.test.ts +0 -238
  77. package/src/columns/index.ts +0 -9
  78. package/src/defaultGlobalPages.ts +0 -95
  79. package/src/defaultPages.test.ts +0 -634
  80. package/src/defaultPages.ts +0 -617
  81. package/src/defaultViewPage.test.ts +0 -147
  82. package/src/elements/Form.test.ts +0 -223
  83. package/src/elements/Form.ts +0 -416
  84. package/src/elements/ListTabs.ts +0 -28
  85. package/src/elements/Table.test.ts +0 -422
  86. package/src/elements/Table.ts +0 -850
  87. package/src/elements/TableGroup.test.ts +0 -260
  88. package/src/elements/TableGroup.ts +0 -334
  89. package/src/elements/dispatchAction.test.ts +0 -463
  90. package/src/elements/dispatchAction.ts +0 -355
  91. package/src/elements/dispatchForm.test.ts +0 -477
  92. package/src/elements/dispatchForm.ts +0 -1993
  93. package/src/elements/dispatchTable.test.ts +0 -1514
  94. package/src/elements/dispatchTable.ts +0 -745
  95. package/src/elements/index.ts +0 -21
  96. package/src/entries/BadgeEntry.ts +0 -39
  97. package/src/entries/CodeEntry.test.ts +0 -40
  98. package/src/entries/CodeEntry.ts +0 -52
  99. package/src/entries/ColorEntry.ts +0 -63
  100. package/src/entries/ComponentEntry.test.ts +0 -173
  101. package/src/entries/ComponentEntry.ts +0 -95
  102. package/src/entries/Entry.ts +0 -304
  103. package/src/entries/IconEntry.ts +0 -49
  104. package/src/entries/ImageEntry.ts +0 -61
  105. package/src/entries/KeyValueEntry.ts +0 -47
  106. package/src/entries/RepeatableEntry.test.ts +0 -239
  107. package/src/entries/RepeatableEntry.ts +0 -173
  108. package/src/entries/TextEntry.test.ts +0 -394
  109. package/src/entries/TextEntry.ts +0 -60
  110. package/src/entries/index.ts +0 -12
  111. package/src/entries/leaves.test.ts +0 -306
  112. package/src/entries/registry.ts +0 -54
  113. package/src/fields/BuilderField.test.ts +0 -1188
  114. package/src/fields/BuilderField.ts +0 -605
  115. package/src/fields/BuilderRelationship.test.ts +0 -811
  116. package/src/fields/CheckboxField.test.ts +0 -44
  117. package/src/fields/CheckboxField.ts +0 -27
  118. package/src/fields/CheckboxListField.test.ts +0 -99
  119. package/src/fields/CheckboxListField.ts +0 -66
  120. package/src/fields/ColorPickerField.test.ts +0 -33
  121. package/src/fields/ColorPickerField.ts +0 -25
  122. package/src/fields/DateField.ts +0 -54
  123. package/src/fields/DateTimeField.test.ts +0 -55
  124. package/src/fields/EmailField.ts +0 -16
  125. package/src/fields/Field.test.ts +0 -654
  126. package/src/fields/Field.ts +0 -817
  127. package/src/fields/FileUploadField.test.ts +0 -143
  128. package/src/fields/FileUploadField.ts +0 -159
  129. package/src/fields/HiddenField.test.ts +0 -27
  130. package/src/fields/HiddenField.ts +0 -28
  131. package/src/fields/KeyValueField.test.ts +0 -105
  132. package/src/fields/KeyValueField.ts +0 -55
  133. package/src/fields/MarkdownField.test.ts +0 -167
  134. package/src/fields/MarkdownField.ts +0 -162
  135. package/src/fields/NumberField.ts +0 -33
  136. package/src/fields/RadioField.test.ts +0 -94
  137. package/src/fields/RadioField.ts +0 -67
  138. package/src/fields/RepeaterField.test.ts +0 -1806
  139. package/src/fields/RepeaterField.ts +0 -939
  140. package/src/fields/RepeaterRelationship.test.ts +0 -1923
  141. package/src/fields/RepeaterSimple.test.ts +0 -248
  142. package/src/fields/RowButton.test.ts +0 -219
  143. package/src/fields/RowButton.ts +0 -135
  144. package/src/fields/SelectField.test.ts +0 -192
  145. package/src/fields/SelectField.ts +0 -235
  146. package/src/fields/SliderField.test.ts +0 -50
  147. package/src/fields/SliderField.ts +0 -53
  148. package/src/fields/SlugField.ts +0 -24
  149. package/src/fields/TagsInputField.test.ts +0 -154
  150. package/src/fields/TagsInputField.ts +0 -133
  151. package/src/fields/TextField.test.ts +0 -213
  152. package/src/fields/TextField.ts +0 -177
  153. package/src/fields/TextareaField.test.ts +0 -58
  154. package/src/fields/TextareaField.ts +0 -59
  155. package/src/fields/ToggleButtonsField.test.ts +0 -106
  156. package/src/fields/ToggleButtonsField.ts +0 -59
  157. package/src/fields/ToggleField.ts +0 -16
  158. package/src/fields/disableOptionsWhenSelectedInSiblingRepeaterItems.test.ts +0 -319
  159. package/src/fields/optionsResolver.ts +0 -95
  160. package/src/fields/resolveField.ts +0 -28
  161. package/src/filters/BooleanFilter.ts +0 -35
  162. package/src/filters/DateRangeFilter.test.ts +0 -194
  163. package/src/filters/DateRangeFilter.ts +0 -148
  164. package/src/filters/Filter.test.ts +0 -268
  165. package/src/filters/Filter.ts +0 -184
  166. package/src/filters/FormFilter.test.ts +0 -238
  167. package/src/filters/FormFilter.ts +0 -215
  168. package/src/filters/MultiSelectFilter.test.ts +0 -119
  169. package/src/filters/MultiSelectFilter.ts +0 -78
  170. package/src/filters/QueryBuilderFilter.test.ts +0 -662
  171. package/src/filters/QueryBuilderFilter.ts +0 -398
  172. package/src/filters/SelectFilter.ts +0 -46
  173. package/src/filters/TernaryFilter.test.ts +0 -160
  174. package/src/filters/TernaryFilter.ts +0 -72
  175. package/src/filters/TrashedFilter.test.ts +0 -149
  176. package/src/filters/TrashedFilter.ts +0 -55
  177. package/src/filters/queryBuilder/BooleanConstraint.ts +0 -31
  178. package/src/filters/queryBuilder/Constraint.ts +0 -115
  179. package/src/filters/queryBuilder/DateConstraint.ts +0 -69
  180. package/src/filters/queryBuilder/NumberConstraint.ts +0 -66
  181. package/src/filters/queryBuilder/SelectConstraint.ts +0 -72
  182. package/src/filters/queryBuilder/TextConstraint.ts +0 -64
  183. package/src/filters/queryBuilder/index.ts +0 -12
  184. package/src/icons/index.ts +0 -2
  185. package/src/icons/lucide.ts +0 -204
  186. package/src/icons/registry.test.ts +0 -56
  187. package/src/icons/registry.ts +0 -41
  188. package/src/icons/types.ts +0 -47
  189. package/src/index.ts +0 -525
  190. package/src/io/csv.test.ts +0 -142
  191. package/src/io/csv.ts +0 -170
  192. package/src/nestedRelationManagerData.test.ts +0 -547
  193. package/src/notifications/Notification.test.ts +0 -210
  194. package/src/notifications/Notification.ts +0 -354
  195. package/src/notifications/broadcast.test.ts +0 -110
  196. package/src/notifications/broadcast.ts +0 -95
  197. package/src/notifications/database.test.ts +0 -383
  198. package/src/notifications/database.ts +0 -398
  199. package/src/notifications/databaseNotifications.test.ts +0 -187
  200. package/src/notifications/dispatchNotificationAction.test.ts +0 -341
  201. package/src/notifications/dispatchNotificationAction.ts +0 -142
  202. package/src/notifications/flash.test.ts +0 -89
  203. package/src/notifications/flash.ts +0 -71
  204. package/src/notifications/index.ts +0 -45
  205. package/src/notifications/registerBroadcastAuth.test.ts +0 -134
  206. package/src/notifications/registerBroadcastAuth.ts +0 -100
  207. package/src/notifications/resolveSavedNotification.test.ts +0 -82
  208. package/src/notifications/resolveSavedNotification.ts +0 -59
  209. package/src/notifications/types.ts +0 -93
  210. package/src/orm/m2mAccessor.ts +0 -66
  211. package/src/orm/modelDefaults.test.ts +0 -633
  212. package/src/orm/modelDefaults.ts +0 -666
  213. package/src/pageData/breadcrumbs.ts +0 -288
  214. package/src/pageData/forms.ts +0 -578
  215. package/src/pageData/helpers.ts +0 -857
  216. package/src/pageData/misc.ts +0 -347
  217. package/src/pageData/navigation.ts +0 -842
  218. package/src/pageData/relationPages.ts +0 -1248
  219. package/src/pageData/relationTabs.ts +0 -286
  220. package/src/pageData/resourcePages.ts +0 -609
  221. package/src/pageData.test.ts +0 -1545
  222. package/src/pageData.ts +0 -341
  223. package/src/plugins/index.ts +0 -8
  224. package/src/plugins/themeEditor.test.ts +0 -36
  225. package/src/plugins/themeEditor.ts +0 -45
  226. package/src/react/AppShell.tsx +0 -251
  227. package/src/react/CollabExtensionFactoryRegistry.ts +0 -55
  228. package/src/react/CollabRoomContext.ts +0 -98
  229. package/src/react/CollabTextRendererRegistry.ts +0 -102
  230. package/src/react/CommandPalette.tsx +0 -375
  231. package/src/react/CurrentUserContext.tsx +0 -50
  232. package/src/react/CustomPageWrapperGate.tsx +0 -69
  233. package/src/react/CustomPageWrapperRegistry.ts +0 -45
  234. package/src/react/FieldFocusReporterRegistry.ts +0 -37
  235. package/src/react/FieldLabelSlotRegistry.ts +0 -30
  236. package/src/react/FieldPresenceRegistry.ts +0 -46
  237. package/src/react/FormCollabBindingRegistry.ts +0 -242
  238. package/src/react/FormStateContext.tsx +0 -591
  239. package/src/react/HeadHooks.tsx +0 -126
  240. package/src/react/MarkdownEditorRegistry.test.ts +0 -38
  241. package/src/react/MarkdownEditorRegistry.ts +0 -107
  242. package/src/react/NotificationActionStrip.tsx +0 -263
  243. package/src/react/NotificationBell.tsx +0 -426
  244. package/src/react/PendingSuggestionApplierRegistry.test.ts +0 -97
  245. package/src/react/PendingSuggestionApplierRegistry.ts +0 -98
  246. package/src/react/PendingSuggestionOverlayRegistry.ts +0 -54
  247. package/src/react/PendingSuggestionsContext.tsx +0 -172
  248. package/src/react/RecordWrapperGate.tsx +0 -58
  249. package/src/react/RecordWrapperRegistry.ts +0 -39
  250. package/src/react/RenderHookSlot.tsx +0 -32
  251. package/src/react/RightSidebar.tsx +0 -257
  252. package/src/react/RightSidebarContext.tsx +0 -234
  253. package/src/react/RightSidebarTrigger.tsx +0 -53
  254. package/src/react/RowCoordsContext.tsx +0 -23
  255. package/src/react/SchemaRenderer.tsx +0 -549
  256. package/src/react/SearchTrigger.tsx +0 -46
  257. package/src/react/ThemeProvider.tsx +0 -93
  258. package/src/react/ThemeSettingsPage.tsx +0 -579
  259. package/src/react/ThemeToggle.tsx +0 -20
  260. package/src/react/Toaster.tsx +0 -158
  261. package/src/react/UserMenu.tsx +0 -196
  262. package/src/react/WidgetDataContext.tsx +0 -157
  263. package/src/react/cells/EditableCell.tsx +0 -389
  264. package/src/react/component-slots.test.ts +0 -103
  265. package/src/react/component-slots.ts +0 -116
  266. package/src/react/fieldJsHandler.test.ts +0 -166
  267. package/src/react/fieldJsHandler.ts +0 -79
  268. package/src/react/fields/BuilderInput.tsx +0 -1078
  269. package/src/react/fields/CheckboxInput.tsx +0 -39
  270. package/src/react/fields/CheckboxListInput.tsx +0 -102
  271. package/src/react/fields/ColorInput.tsx +0 -71
  272. package/src/react/fields/DateFieldInput.tsx +0 -70
  273. package/src/react/fields/DateTimeInput.tsx +0 -62
  274. package/src/react/fields/FieldShell.tsx +0 -348
  275. package/src/react/fields/FileUploadInput.tsx +0 -639
  276. package/src/react/fields/HiddenInput.tsx +0 -17
  277. package/src/react/fields/KeyValueInput.tsx +0 -230
  278. package/src/react/fields/MarkdownInput.tsx +0 -560
  279. package/src/react/fields/RadioInput.tsx +0 -81
  280. package/src/react/fields/RepeaterInput.test.ts +0 -116
  281. package/src/react/fields/RepeaterInput.tsx +0 -1420
  282. package/src/react/fields/SelectFieldInput.tsx +0 -280
  283. package/src/react/fields/SliderInput.tsx +0 -81
  284. package/src/react/fields/TagsInput.tsx +0 -283
  285. package/src/react/fields/TextLikeInput.tsx +0 -256
  286. package/src/react/fields/ToggleButtonsInput.tsx +0 -60
  287. package/src/react/fields/ToggleFieldInput.tsx +0 -56
  288. package/src/react/fields/relationshipRenameDispatch.test.ts +0 -106
  289. package/src/react/fields/relationshipRenameDispatch.ts +0 -97
  290. package/src/react/fields/repeaterReconcile.test.ts +0 -114
  291. package/src/react/fields/repeaterReconcile.ts +0 -104
  292. package/src/react/fields/rowChromeButton.tsx +0 -336
  293. package/src/react/fields/rowState.ts +0 -106
  294. package/src/react/fields/syncRowGates.test.ts +0 -202
  295. package/src/react/fields/syncRowGates.ts +0 -66
  296. package/src/react/fields/textInputControls.tsx +0 -238
  297. package/src/react/fields/useRowReorderDnd.ts +0 -78
  298. package/src/react/formStateHelpers.test.ts +0 -508
  299. package/src/react/formStateHelpers.ts +0 -381
  300. package/src/react/hooks/use-mobile.ts +0 -19
  301. package/src/react/icon-context.tsx +0 -60
  302. package/src/react/index.ts +0 -194
  303. package/src/react/layouts/SidebarLayout.tsx +0 -250
  304. package/src/react/layouts/TopbarLayout.tsx +0 -258
  305. package/src/react/navigate.tsx +0 -37
  306. package/src/react/onProviderSynced.test.ts +0 -90
  307. package/src/react/parseRecordEditUrl.test.ts +0 -122
  308. package/src/react/parseRecordEditUrl.ts +0 -94
  309. package/src/react/persistedState.ts +0 -40
  310. package/src/react/registry.ts +0 -48
  311. package/src/react/right-panel-registry.tsx +0 -47
  312. package/src/react/schemaRenderer/AlertRenderer.tsx +0 -112
  313. package/src/react/schemaRenderer/EntryRenderer.tsx +0 -501
  314. package/src/react/schemaRenderer/SectionRenderer.tsx +0 -120
  315. package/src/react/schemaRenderer/SimpleElements.tsx +0 -306
  316. package/src/react/schemaRenderer/TabsRenderer.tsx +0 -62
  317. package/src/react/schemaRenderer/WizardRenderer.tsx +0 -338
  318. package/src/react/schemaRenderer/action/ActionGroupTrigger.tsx +0 -177
  319. package/src/react/schemaRenderer/action/ActionModalDialog.tsx +0 -273
  320. package/src/react/schemaRenderer/action/ConfirmActionDialog.tsx +0 -61
  321. package/src/react/schemaRenderer/action/HandlerActionButton.tsx +0 -43
  322. package/src/react/schemaRenderer/action/MethodActionButton.tsx +0 -64
  323. package/src/react/schemaRenderer/action/buttons.tsx +0 -99
  324. package/src/react/schemaRenderer/action/helpers.ts +0 -140
  325. package/src/react/schemaRenderer/action/renderAction.tsx +0 -245
  326. package/src/react/schemaRenderer/columnFormat.ts +0 -65
  327. package/src/react/schemaRenderer/constants.ts +0 -50
  328. package/src/react/schemaRenderer/form/FormRenderer.tsx +0 -274
  329. package/src/react/schemaRenderer/form/renderField.tsx +0 -511
  330. package/src/react/schemaRenderer/helpers.tsx +0 -81
  331. package/src/react/schemaRenderer/table/CardsLayoutBody.tsx +0 -308
  332. package/src/react/schemaRenderer/table/TableRenderer.tsx +0 -123
  333. package/src/react/schemaRenderer/table/TableRendererBody.tsx +0 -974
  334. package/src/react/schemaRenderer/table/filters.tsx +0 -1233
  335. package/src/react/schemaRenderer/table/formatCell.tsx +0 -264
  336. package/src/react/schemaRenderer/table/links.tsx +0 -112
  337. package/src/react/schemaRenderer/table/renderRowActions.tsx +0 -52
  338. package/src/react/schemaRenderer/table/url.tsx +0 -143
  339. package/src/react/theme-preview/apply.ts +0 -99
  340. package/src/react/theme-preview/build-html.ts +0 -436
  341. package/src/react/ui/button.tsx +0 -51
  342. package/src/react/ui/calendar.tsx +0 -67
  343. package/src/react/ui/checkbox.tsx +0 -29
  344. package/src/react/ui/dialog.tsx +0 -108
  345. package/src/react/ui/dropdown-menu.tsx +0 -97
  346. package/src/react/ui/input.tsx +0 -20
  347. package/src/react/ui/label.tsx +0 -21
  348. package/src/react/ui/popover.tsx +0 -50
  349. package/src/react/ui/select.tsx +0 -169
  350. package/src/react/ui/separator.tsx +0 -25
  351. package/src/react/ui/sheet.tsx +0 -136
  352. package/src/react/ui/sidebar.tsx +0 -723
  353. package/src/react/ui/skeleton.tsx +0 -13
  354. package/src/react/ui/slider.tsx +0 -34
  355. package/src/react/ui/switch.tsx +0 -28
  356. package/src/react/ui/table.tsx +0 -105
  357. package/src/react/ui/tabs.tsx +0 -63
  358. package/src/react/ui/textarea.tsx +0 -18
  359. package/src/react/ui/tooltip.tsx +0 -64
  360. package/src/react/useResizableWidth.ts +0 -139
  361. package/src/react/utils.ts +0 -6
  362. package/src/react/widgetRegistry.test.ts +0 -43
  363. package/src/react/widgetRegistry.ts +0 -50
  364. package/src/react/widgets/StatsOverviewRenderer.tsx +0 -232
  365. package/src/react/widgets/TableWidgetRenderer.tsx +0 -231
  366. package/src/react/widgets/ViewRenderer.tsx +0 -71
  367. package/src/relationManagerData.test.ts +0 -1595
  368. package/src/richtext/index.ts +0 -8
  369. package/src/richtext/registry.ts +0 -89
  370. package/src/routes/globals.ts +0 -148
  371. package/src/routes/guard.test.ts +0 -325
  372. package/src/routes/helpers.ts +0 -704
  373. package/src/routes/pages.ts +0 -175
  374. package/src/routes/panel.ts +0 -204
  375. package/src/routes/relations.ts +0 -1243
  376. package/src/routes/resources.ts +0 -781
  377. package/src/routes/theme.ts +0 -91
  378. package/src/routes-nested-relations.test.ts +0 -676
  379. package/src/routes-relations.test.ts +0 -972
  380. package/src/routes.test.ts +0 -2027
  381. package/src/routes.ts +0 -303
  382. package/src/schema/Alert.test.ts +0 -109
  383. package/src/schema/Alert.ts +0 -131
  384. package/src/schema/Block.ts +0 -169
  385. package/src/schema/Breadcrumbs.ts +0 -40
  386. package/src/schema/Card.ts +0 -35
  387. package/src/schema/Divider.ts +0 -20
  388. package/src/schema/Element.ts +0 -219
  389. package/src/schema/EmptyState.test.ts +0 -37
  390. package/src/schema/EmptyState.ts +0 -63
  391. package/src/schema/Fieldset.ts +0 -43
  392. package/src/schema/Grid.ts +0 -43
  393. package/src/schema/Group.ts +0 -30
  394. package/src/schema/Heading.ts +0 -39
  395. package/src/schema/Html.ts +0 -67
  396. package/src/schema/Icon.ts +0 -54
  397. package/src/schema/Image.ts +0 -57
  398. package/src/schema/LinkTag.ts +0 -41
  399. package/src/schema/Markdown.ts +0 -85
  400. package/src/schema/MetaTag.ts +0 -41
  401. package/src/schema/RelationTabs.ts +0 -71
  402. package/src/schema/ScriptTag.ts +0 -55
  403. package/src/schema/Section.ts +0 -160
  404. package/src/schema/ServerDataElement.test.ts +0 -140
  405. package/src/schema/ServerDataElement.ts +0 -156
  406. package/src/schema/SlotComponent.test.ts +0 -77
  407. package/src/schema/SlotComponent.ts +0 -71
  408. package/src/schema/Split.ts +0 -50
  409. package/src/schema/Stat.test.ts +0 -118
  410. package/src/schema/Stat.ts +0 -154
  411. package/src/schema/StatsOverview.test.ts +0 -141
  412. package/src/schema/StatsOverview.ts +0 -119
  413. package/src/schema/StyleTag.ts +0 -35
  414. package/src/schema/TableWidget.test.ts +0 -297
  415. package/src/schema/TableWidget.ts +0 -289
  416. package/src/schema/Tabs.ts +0 -79
  417. package/src/schema/Text.ts +0 -58
  418. package/src/schema/UnorderedList.ts +0 -49
  419. package/src/schema/View.test.ts +0 -111
  420. package/src/schema/View.ts +0 -127
  421. package/src/schema/Wizard.ts +0 -220
  422. package/src/schema/containers.test.ts +0 -564
  423. package/src/schema/headTags.test.ts +0 -134
  424. package/src/schema/index.ts +0 -40
  425. package/src/schema/primes.test.ts +0 -269
  426. package/src/schema/resolveSchema.test.ts +0 -379
  427. package/src/schema/resolveSchema.ts +0 -917
  428. package/src/schema/sanitize.ts +0 -58
  429. package/src/search.test.ts +0 -446
  430. package/src/search.ts +0 -178
  431. package/src/sessionFilters.test.ts +0 -375
  432. package/src/sessionFilters.ts +0 -143
  433. package/src/slot-components/index.ts +0 -10
  434. package/src/slot-components/registry.ts +0 -56
  435. package/src/styles/file-upload.css +0 -13
  436. package/src/summarizers/Summarizer.test.ts +0 -84
  437. package/src/summarizers/Summarizer.ts +0 -123
  438. package/src/summarizers/index.ts +0 -11
  439. package/src/theme/base-colors.ts +0 -68
  440. package/src/theme/chart-colors.ts +0 -50
  441. package/src/theme/colors.ts +0 -447
  442. package/src/theme/generate-css.test.ts +0 -139
  443. package/src/theme/generate-css.ts +0 -44
  444. package/src/theme/generate-scale.test.ts +0 -106
  445. package/src/theme/generate-scale.ts +0 -97
  446. package/src/theme/icon-map.ts +0 -42
  447. package/src/theme/index.ts +0 -34
  448. package/src/theme/migrate.test.ts +0 -178
  449. package/src/theme/migrate.ts +0 -81
  450. package/src/theme/presets.ts +0 -135
  451. package/src/theme/radius.ts +0 -18
  452. package/src/theme/resolve.test.ts +0 -238
  453. package/src/theme/resolve.ts +0 -96
  454. package/src/theme/spacing.ts +0 -18
  455. package/src/theme/storage.test.ts +0 -126
  456. package/src/theme/storage.ts +0 -106
  457. package/src/theme/theme-colors.ts +0 -88
  458. package/src/theme/types.ts +0 -125
  459. package/src/uploads/UploadAdapter.ts +0 -35
  460. package/src/uploads/index.ts +0 -2
  461. package/src/uploads/localUpload.test.ts +0 -70
  462. package/src/uploads/localUpload.ts +0 -84
  463. package/src/validation/Validator.ts +0 -49
  464. package/src/validation/index.ts +0 -28
  465. package/src/validation/rules.ts +0 -78
  466. package/src/validation/runValidators.ts +0 -435
  467. package/src/validation/uniqueValidator.test.ts +0 -196
  468. package/src/validation/uniqueValidator.ts +0 -133
  469. package/src/validation/validators.test.ts +0 -268
  470. package/src/vite.test.ts +0 -184
  471. package/src/vite.ts +0 -787
  472. package/src/widgets/index.ts +0 -10
  473. package/src/widgets/registry.ts +0 -45
  474. package/src/widgets.test.ts +0 -592
  475. package/tsconfig.build.json +0 -11
  476. package/tsconfig.json +0 -4
  477. package/tsconfig.test.json +0 -10
  478. package/views/react/Dashboard.tsx +0 -27
  479. package/views/react/Resources/Form.tsx +0 -102
  480. package/views/react/Resources/Index.tsx +0 -49
@@ -1,2526 +0,0 @@
1
- import { describe, it, beforeEach } from 'node:test'
2
- import assert from 'node:assert/strict'
3
-
4
- import { Action } from './Action.js'
5
- import { resolveSchema, _resetResolverRegistry } from '../schema/resolveSchema.js'
6
- import { Card } from '../schema/Card.js'
7
- import { Alert } from '../schema/Alert.js'
8
- import { Text } from '../schema/Text.js'
9
- import { RelationManager, type RelationManagerContext } from '../RelationManager.js'
10
-
11
- beforeEach(() => _resetResolverRegistry())
12
-
13
- describe('Action.toMeta', () => {
14
- it('emits required fields with sensible defaults', () => {
15
- const meta = Action.make('publish').toMeta()
16
- assert.equal(meta.type, 'action')
17
- assert.equal(meta.name, 'publish')
18
- assert.equal(meta.label, 'Publish') // auto-derived from name
19
- assert.equal(meta.placement, 'inline')
20
- assert.equal(meta.destructive, false)
21
- assert.equal(meta.icon, undefined)
22
- assert.equal(meta.confirm, undefined)
23
- })
24
-
25
- it('label() overrides the auto-derived label', () => {
26
- const meta = Action.make('publish').label('Publish Now').toMeta()
27
- assert.equal(meta.label, 'Publish Now')
28
- })
29
-
30
- it('icon() emits the icon string', () => {
31
- const meta = Action.make('save').icon('check').toMeta()
32
- assert.equal(meta.icon, 'check')
33
- })
34
-
35
- it('destructive() flips the flag', () => {
36
- const meta = Action.make('delete').destructive().toMeta()
37
- assert.equal(meta.destructive, true)
38
- })
39
-
40
- describe('placement', () => {
41
- it('placement(p) sets it directly', () => {
42
- assert.equal(Action.make('a').placement('row').toMeta().placement, 'row')
43
- assert.equal(Action.make('a').placement('bulk').toMeta().placement, 'bulk')
44
- assert.equal(Action.make('a').placement('header').toMeta().placement, 'header')
45
- })
46
-
47
- it('shorthand setters .row() / .bulk() / .header() / .inline()', () => {
48
- assert.equal(Action.make('a').row().toMeta().placement, 'row')
49
- assert.equal(Action.make('a').bulk().toMeta().placement, 'bulk')
50
- assert.equal(Action.make('a').header().toMeta().placement, 'header')
51
- assert.equal(Action.make('a').row().inline().toMeta().placement, 'inline')
52
- })
53
- })
54
-
55
- describe('confirm', () => {
56
- it('string shorthand becomes { message }', () => {
57
- const meta = Action.make('delete').confirm('Are you sure?').toMeta()
58
- assert.deepEqual(meta.confirm, { message: 'Are you sure?' })
59
- })
60
-
61
- it('object form preserves all keys', () => {
62
- const meta = Action.make('delete').confirm({
63
- title: 'Delete user',
64
- message: 'This action cannot be undone.',
65
- confirmLabel: 'Yes, delete',
66
- }).toMeta()
67
- assert.deepEqual(meta.confirm, {
68
- title: 'Delete user',
69
- message: 'This action cannot be undone.',
70
- confirmLabel: 'Yes, delete',
71
- })
72
- })
73
-
74
- it('omitted when not set', () => {
75
- assert.equal('confirm' in Action.make('save').toMeta(), false)
76
- })
77
- })
78
-
79
- describe('handler', () => {
80
- it('is stored but does not appear in serialized meta', () => {
81
- const fn = async () => {}
82
- const a = Action.make('publish').handler(fn)
83
- assert.equal(a.getHandler(), fn)
84
- assert.equal('handler' in a.toMeta(), false)
85
- })
86
- })
87
- })
88
-
89
- describe('Action in the schema tree', () => {
90
- it('resolves with type=action via the unified resolver', async () => {
91
- const result = await resolveSchema([Action.make('save').icon('check')])
92
- assert.equal(result[0]!.type, 'action')
93
- assert.equal(result[0]!['name'], 'save')
94
- assert.equal(result[0]!['icon'], 'check')
95
- })
96
-
97
- it('appears as a child inside a container Element', async () => {
98
- const tree = [
99
- Card.make('Header').schema([
100
- Action.make('export').header(),
101
- Action.make('delete').row().destructive().confirm('Sure?'),
102
- ]),
103
- ]
104
- const result = await resolveSchema(tree)
105
- assert.equal(result[0]!.children?.length, 2)
106
- assert.equal(result[0]!.children![0]!.type, 'action')
107
- assert.equal(result[0]!.children![0]!['placement'], 'header')
108
- assert.equal(result[0]!.children![1]!['placement'], 'row')
109
- assert.deepEqual(result[0]!.children![1]!['confirm'], { message: 'Sure?' })
110
- })
111
- })
112
-
113
- describe('Action variants & cosmetics', () => {
114
- it('color() sets the visual color', () => {
115
- assert.equal(Action.make('a').color('success').toMeta().color, 'success')
116
- assert.equal(Action.make('a').color('warning').toMeta().color, 'warning')
117
- assert.equal(Action.make('a').color('ghost').toMeta().color, 'ghost')
118
- })
119
-
120
- it('destructive() implies color="destructive" when no explicit color', () => {
121
- const meta = Action.make('delete').destructive().toMeta()
122
- assert.equal(meta.destructive, true)
123
- assert.equal(meta.color, 'destructive')
124
- })
125
-
126
- it('explicit color() wins over destructive() flag', () => {
127
- const meta = Action.make('warn').destructive().color('warning').toMeta()
128
- assert.equal(meta.destructive, true)
129
- assert.equal(meta.color, 'warning')
130
- })
131
-
132
- it('size() sets the size preset', () => {
133
- assert.equal(Action.make('a').size('sm').toMeta().size, 'sm')
134
- assert.equal(Action.make('a').size('lg').toMeta().size, 'lg')
135
- })
136
-
137
- it('tooltip() round-trips', () => {
138
- const meta = Action.make('save').tooltip('Save changes').toMeta()
139
- assert.equal(meta.tooltip, 'Save changes')
140
- })
141
-
142
- it('outlined() emits the flag only when set', () => {
143
- assert.equal(Action.make('a').toMeta().outlined, undefined)
144
- assert.equal(Action.make('a').outlined().toMeta().outlined, true)
145
- })
146
-
147
- it('iconButton() emits iconOnly: true', () => {
148
- const meta = Action.make('refresh').icon('refresh').iconButton().toMeta()
149
- assert.equal(meta.iconOnly, true)
150
- })
151
-
152
- it('badge() / badgeColor() round-trip', () => {
153
- const meta = Action.make('inbox').badge(7).badgeColor('bg-red-500').toMeta()
154
- assert.equal(meta.badge, 7)
155
- assert.equal(meta.badgeColor, 'bg-red-500')
156
- })
157
-
158
- it('cosmetic builders are absent from meta when not called', () => {
159
- const meta = Action.make('plain').toMeta()
160
- assert.equal(meta.color, undefined)
161
- assert.equal(meta.size, undefined)
162
- assert.equal(meta.tooltip, undefined)
163
- assert.equal(meta.outlined, undefined)
164
- assert.equal(meta.iconOnly, undefined)
165
- assert.equal(meta.badge, undefined)
166
- })
167
- })
168
-
169
- describe('Action visibility evaluation', () => {
170
- it('default — no rules → visible:true, disabled:false', async () => {
171
- const a = Action.make('a')
172
- assert.deepEqual(await a.evaluate(), { visible: true, disabled: false })
173
- assert.equal(a.hasVisibilityRules(), false)
174
- })
175
-
176
- it('visible(false) hides the action', async () => {
177
- assert.equal((await Action.make('a').visible(false).evaluate()).visible, false)
178
- })
179
-
180
- it('hidden(true) hides the action', async () => {
181
- assert.equal((await Action.make('a').hidden(true).evaluate()).visible, false)
182
- })
183
-
184
- it('visible(fn) receives the context', async () => {
185
- const a = Action.make('a').visible(({ record }) => Boolean((record as { active?: boolean })?.active))
186
- assert.equal((await a.evaluate({ record: { active: true } })).visible, true)
187
- assert.equal((await a.evaluate({ record: { active: false } })).visible, false)
188
- assert.equal((await a.evaluate({ record: undefined })).visible, false)
189
- })
190
-
191
- it('disabled(fn) receives the context', async () => {
192
- const a = Action.make('a').disabled(({ record }) => Boolean((record as { locked?: boolean })?.locked))
193
- assert.equal((await a.evaluate({ record: { locked: true } })).disabled, true)
194
- assert.equal((await a.evaluate({ record: { locked: false } })).disabled, false)
195
- })
196
-
197
- it('combines visible and hidden via AND (visible && !hidden)', async () => {
198
- const a = Action.make('a').visible(true).hidden(({ record }) => (record as { trashed?: boolean })?.trashed === true)
199
- assert.equal((await a.evaluate({ record: { trashed: false } })).visible, true)
200
- assert.equal((await a.evaluate({ record: { trashed: true } })).visible, false)
201
- })
202
-
203
- it('authorize() is an alias for visible()', async () => {
204
- const a = Action.make('a').authorize(({ user }) => Boolean((user as { admin?: boolean })?.admin))
205
- assert.equal((await a.evaluate({ user: { admin: true } })).visible, true)
206
- assert.equal((await a.evaluate({ user: { admin: false } })).visible, false)
207
- })
208
-
209
- it('async visibility rule resolves a Promise<boolean>', async () => {
210
- const a = Action.make('a').visible(async ({ user }) => Boolean((user as { admin?: boolean })?.admin))
211
- assert.equal((await a.evaluate({ user: { admin: true } })).visible, true)
212
- assert.equal((await a.evaluate({ user: { admin: false } })).visible, false)
213
- })
214
-
215
- it('throwing visibility rule fails closed (not visible)', async () => {
216
- const a = Action.make('a').visible(() => { throw new Error('boom') })
217
- assert.equal((await a.evaluate()).visible, false)
218
- })
219
-
220
- it('hasVisibilityRules returns true when any rule is set', () => {
221
- assert.equal(Action.make('a').visible(true).hasVisibilityRules(), true)
222
- assert.equal(Action.make('a').hidden(false).hasVisibilityRules(), true)
223
- assert.equal(Action.make('a').disabled(false).hasVisibilityRules(), true)
224
- assert.equal(Action.make('a').authorize(true).hasVisibilityRules(), true)
225
- })
226
-
227
- it('toMeta emits conditional:true when rules exist', () => {
228
- assert.equal(Action.make('a').toMeta().conditional, undefined)
229
- assert.equal(Action.make('a').visible(true).toMeta().conditional, true)
230
- })
231
- })
232
-
233
- describe('Action.relation* factories (Plan #11 polish)', () => {
234
- /** Bare manager + ctx pair shared across the tests below. */
235
- class Posts extends RelationManager {
236
- static override relationship = 'posts'
237
- static override label = 'Posts'
238
- static override labelSingular = 'Post'
239
- }
240
-
241
- const ctx: RelationManagerContext = {
242
- basePath: '/admin',
243
- parentSlug: 'users',
244
- parentId: '42',
245
- relationship: 'posts',
246
- parentRecord: { id: '42' },
247
- mode: 'hasMany',
248
- }
249
-
250
- describe('relationCreate', () => {
251
- it('builds the create URL under the parent record', () => {
252
- const meta = Action.relationCreate(Posts, ctx).toMeta()
253
- assert.equal(meta.href, '/admin/users/42/posts/create')
254
- assert.equal(meta.label, 'New Post')
255
- assert.equal(meta.method, undefined) // link-style, not form-post
256
- })
257
-
258
- it('label uses the manager singular fallback when not pinned', () => {
259
- class Comments extends RelationManager { static override relationship = 'comments' }
260
- const meta = Action.relationCreate(Comments, { ...ctx, relationship: 'comments' }).toMeta()
261
- assert.equal(meta.label, 'New Comment')
262
- })
263
-
264
- it('visibility delegates to manager.canCreate when overridden', async () => {
265
- class Forbidden extends RelationManager {
266
- static override relationship = 'posts'
267
- static override async canCreate(): Promise<boolean> { return false }
268
- }
269
- const result = await Action.relationCreate(Forbidden, ctx).evaluate({})
270
- assert.equal(result.visible, false)
271
- })
272
-
273
- it('falls through to related Resource canCreate when manager unset', async () => {
274
- const Related = { canCreate: async () => false } as unknown as RelationManagerContext['related']
275
- const result = await Action.relationCreate(Posts, { ...ctx, related: Related }).evaluate({})
276
- assert.equal(result.visible, false)
277
- })
278
-
279
- it('allows when neither manager nor related Resource opts in', async () => {
280
- const result = await Action.relationCreate(Posts, ctx).evaluate({})
281
- assert.equal(result.visible, true)
282
- })
283
-
284
- it('auto-hides under belongsToMany mode (no per-pivot create)', async () => {
285
- const m2mCtx = { ...ctx, mode: 'belongsToMany' as const }
286
- const result = await Action.relationCreate(Posts, m2mCtx).evaluate({})
287
- assert.equal(result.visible, false)
288
- })
289
- })
290
-
291
- describe('relationEdit', () => {
292
- it('builds the edit URL with :id template for row context', () => {
293
- const meta = Action.relationEdit(Posts, ctx).toMeta()
294
- assert.equal(meta.href, '/admin/users/42/posts/:id/edit')
295
- assert.equal(meta.label, 'Edit')
296
- })
297
-
298
- it('bakes in an explicit recordId when provided', () => {
299
- const meta = Action.relationEdit(Posts, ctx, '7').toMeta()
300
- assert.equal(meta.href, '/admin/users/42/posts/7/edit')
301
- })
302
-
303
- it('visibility receives both the row record and the parentRecord via ctx', async () => {
304
- let seenChild: unknown
305
- let seenParent: unknown
306
- class WithEdit extends RelationManager {
307
- static override relationship = 'posts'
308
- static override async canEdit(_user: unknown, child: unknown, parent: unknown): Promise<boolean> {
309
- seenChild = child
310
- seenParent = parent
311
- return true
312
- }
313
- }
314
- const a = Action.relationEdit(WithEdit, ctx)
315
- await a.evaluate({ record: { id: '7', title: 'A' } })
316
- assert.deepEqual(seenChild, { id: '7', title: 'A' })
317
- assert.deepEqual(seenParent, { id: '42' })
318
- })
319
-
320
- it('auto-hides under belongsToMany mode (no per-pivot edit)', async () => {
321
- const m2mCtx = { ...ctx, mode: 'belongsToMany' as const }
322
- const result = await Action.relationEdit(Posts, m2mCtx).evaluate({ record: { id: '9' } })
323
- assert.equal(result.visible, false)
324
- })
325
- })
326
-
327
- describe('relationDelete', () => {
328
- it('builds a destructive POST to the delete URL with confirm prompt', () => {
329
- const meta = Action.relationDelete(Posts, ctx).toMeta()
330
- assert.equal(meta.method, 'post')
331
- assert.equal(meta.action, '/admin/users/42/posts/:id/delete')
332
- assert.equal(meta.destructive, true)
333
- assert.match(meta.confirm?.message ?? '', /post/)
334
- })
335
-
336
- it('honors an explicit recordId at config time', () => {
337
- const meta = Action.relationDelete(Posts, ctx, '7').toMeta()
338
- assert.equal(meta.action, '/admin/users/42/posts/7/delete')
339
- })
340
-
341
- it('visibility absorbs predicate throws as false (fail-closed)', async () => {
342
- class Throwing extends RelationManager {
343
- static override relationship = 'posts'
344
- static override async canDelete(): Promise<boolean> { throw new Error('boom') }
345
- }
346
- const result = await Action.relationDelete(Throwing, ctx).evaluate({ record: { id: '7' } })
347
- assert.equal(result.visible, false)
348
- })
349
-
350
- it('hides on already-trashed rows when related Resource has softDeletes=true', async () => {
351
- const Related = { softDeletes: true } as unknown as RelationManagerContext['related']
352
- const a = Action.relationDelete(Posts, { ...ctx, related: Related })
353
- assert.equal((await a.evaluate({ record: { id: '7', deletedAt: '2026-01-01' } })).visible, false)
354
- })
355
-
356
- it('still shows on live rows when related Resource has softDeletes=true', async () => {
357
- const Related = { softDeletes: true } as unknown as RelationManagerContext['related']
358
- const a = Action.relationDelete(Posts, { ...ctx, related: Related })
359
- assert.equal((await a.evaluate({ record: { id: '7' } })).visible, true)
360
- })
361
-
362
- it('honors a custom deletedAtColumn from the related Resource', async () => {
363
- const Related = { softDeletes: true, deletedAtColumn: 'archivedAt' } as unknown as RelationManagerContext['related']
364
- const a = Action.relationDelete(Posts, { ...ctx, related: Related })
365
- assert.equal((await a.evaluate({ record: { archivedAt: '2026-01-01' } })).visible, false)
366
- assert.equal((await a.evaluate({ record: { archivedAt: null } })).visible, true)
367
- })
368
-
369
- it('auto-hides under belongsToMany mode (use relationDetach instead)', async () => {
370
- const m2mCtx = { ...ctx, mode: 'belongsToMany' as const }
371
- const result = await Action.relationDelete(Posts, m2mCtx).evaluate({ record: { id: '9' } })
372
- assert.equal(result.visible, false)
373
- })
374
- })
375
-
376
- // ── Plan #13 polish — relationRestore / relationForceDelete ────
377
-
378
- describe('relationRestore', () => {
379
- const Related = { softDeletes: true } as unknown as RelationManagerContext['related']
380
- const softCtx: RelationManagerContext = { ...ctx, related: Related }
381
-
382
- it('builds the restore URL under the parent record with success color', () => {
383
- const meta = Action.relationRestore(Posts, softCtx).toMeta()
384
- assert.equal(meta.method, 'post')
385
- assert.equal(meta.action, '/admin/users/42/posts/:id/restore')
386
- assert.equal(meta.label, 'Restore')
387
- assert.equal(meta.color, 'success')
388
- })
389
-
390
- it('honors an explicit recordId at config time', () => {
391
- const meta = Action.relationRestore(Posts, softCtx, '7').toMeta()
392
- assert.equal(meta.action, '/admin/users/42/posts/7/restore')
393
- })
394
-
395
- it('hides on live (non-trashed) rows', async () => {
396
- const a = Action.relationRestore(Posts, softCtx)
397
- assert.equal((await a.evaluate({ record: { id: '7' } })).visible, false)
398
- })
399
-
400
- it('shows on trashed rows by default (manager default canRestore = true)', async () => {
401
- const a = Action.relationRestore(Posts, softCtx)
402
- assert.equal((await a.evaluate({ record: { deletedAt: '2026-01-01' } })).visible, true)
403
- })
404
-
405
- it('hides entirely when the related Resource does not opt into softDeletes', async () => {
406
- const NonSoft = { softDeletes: false } as unknown as RelationManagerContext['related']
407
- const a = Action.relationRestore(Posts, { ...ctx, related: NonSoft })
408
- assert.equal((await a.evaluate({ record: { deletedAt: '2026-01-01' } })).visible, false)
409
- })
410
-
411
- it('respects the manager canRestore override', async () => {
412
- class Locked extends RelationManager {
413
- static override relationship = 'posts'
414
- static override async canRestore(): Promise<boolean> { return false }
415
- }
416
- const a = Action.relationRestore(Locked, softCtx)
417
- assert.equal((await a.evaluate({ record: { deletedAt: '2026-01-01' } })).visible, false)
418
- })
419
-
420
- it('falls through to related Resource canRestore when manager unset', async () => {
421
- const RelatedDeny = {
422
- softDeletes: true,
423
- canRestore: async () => false,
424
- } as unknown as RelationManagerContext['related']
425
- const a = Action.relationRestore(Posts, { ...ctx, related: RelatedDeny })
426
- assert.equal((await a.evaluate({ record: { deletedAt: '2026-01-01' } })).visible, false)
427
- })
428
- })
429
-
430
- describe('relationForceDelete', () => {
431
- const Related = { softDeletes: true } as unknown as RelationManagerContext['related']
432
- const softCtx: RelationManagerContext = { ...ctx, related: Related }
433
-
434
- it('builds a destructive POST to the force-delete URL with permanence confirm', () => {
435
- const meta = Action.relationForceDelete(Posts, softCtx).toMeta()
436
- assert.equal(meta.method, 'post')
437
- assert.equal(meta.action, '/admin/users/42/posts/:id/force-delete')
438
- assert.equal(meta.label, 'Delete forever')
439
- assert.equal(meta.destructive, true)
440
- assert.match(meta.confirm?.message ?? '', /cannot be undone/i)
441
- })
442
-
443
- it('honors an explicit recordId at config time', () => {
444
- const meta = Action.relationForceDelete(Posts, softCtx, '7').toMeta()
445
- assert.equal(meta.action, '/admin/users/42/posts/7/force-delete')
446
- })
447
-
448
- it('hides on live (non-trashed) rows', async () => {
449
- const a = Action.relationForceDelete(Posts, softCtx)
450
- assert.equal((await a.evaluate({ record: { id: '7' } })).visible, false)
451
- })
452
-
453
- it('shows on trashed rows by default (canForceDelete inherits canDelete = true)', async () => {
454
- const a = Action.relationForceDelete(Posts, softCtx)
455
- assert.equal((await a.evaluate({ record: { deletedAt: '2026-01-01' } })).visible, true)
456
- })
457
-
458
- it('hides when the related Resource does not opt into softDeletes', async () => {
459
- const NonSoft = { softDeletes: false } as unknown as RelationManagerContext['related']
460
- const a = Action.relationForceDelete(Posts, { ...ctx, related: NonSoft })
461
- assert.equal((await a.evaluate({ record: { deletedAt: '2026-01-01' } })).visible, false)
462
- })
463
-
464
- it('inherits canDelete denial when canForceDelete is not overridden', async () => {
465
- class Locked extends RelationManager {
466
- static override relationship = 'posts'
467
- static override async canDelete(): Promise<boolean> { return false }
468
- // canForceDelete inherits its default which delegates to canDelete
469
- }
470
- const a = Action.relationForceDelete(Locked, softCtx)
471
- assert.equal((await a.evaluate({ record: { deletedAt: '2026-01-01' } })).visible, false)
472
- })
473
-
474
- it('respects an explicit canForceDelete override stricter than canDelete', async () => {
475
- class Stricter extends RelationManager {
476
- static override relationship = 'posts'
477
- // canDelete defaults to true (inherited)
478
- static override async canForceDelete(): Promise<boolean> { return false }
479
- }
480
- const a = Action.relationForceDelete(Stricter, softCtx)
481
- assert.equal((await a.evaluate({ record: { deletedAt: '2026-01-01' } })).visible, false)
482
- })
483
- })
484
-
485
- describe('M2M factories — relationAttach', () => {
486
- class Tags extends RelationManager {
487
- static override relationship = 'tags'
488
- static override label = 'Tags'
489
- static override labelSingular = 'Tag'
490
- }
491
-
492
- const m2mCtx: RelationManagerContext = {
493
- basePath: '/admin',
494
- parentSlug: 'articles',
495
- parentId: '5',
496
- relationship: 'tags',
497
- parentRecord: { id: '5' },
498
- mode: 'belongsToMany',
499
- }
500
-
501
- it('renders as a header action with an Attach label', () => {
502
- const meta = Action.relationAttach(Tags, m2mCtx).toMeta()
503
- assert.equal(meta.label, 'Attach Tag')
504
- assert.equal(meta.placement, 'header')
505
- })
506
-
507
- it('builds a modal-form schema only when M2M + Related.model are set', () => {
508
- // No related Resource → schema stays empty (action would still
509
- // mount but the modal has nothing to pick from). `.getSchema()`
510
- // returns the raw Element[] before resolver-side serialization;
511
- // `toMeta().children` is populated by the schema walker, not by
512
- // the action itself, so we check the unresolved tree directly.
513
- const noRelated = Action.relationAttach(Tags, m2mCtx)
514
- assert.equal(noRelated.getSchema().length, 0)
515
-
516
- // With a stub related model present, the schema gains a SelectField.
517
- const Related = {
518
- model: { query: () => ({ paginate: async () => ({ data: [], total: 0 }) }) },
519
- } as unknown as RelationManagerContext['related']
520
- const withRelated = Action.relationAttach(Tags, { ...m2mCtx, related: Related })
521
- assert.equal(withRelated.getSchema().length, 1)
522
- })
523
-
524
- it('auto-hides under hasMany mode (drop-in safety)', async () => {
525
- const hasManyCtx = { ...m2mCtx, mode: 'hasMany' as const }
526
- const result = await Action.relationAttach(Tags, hasManyCtx).evaluate({})
527
- assert.equal(result.visible, false)
528
- })
529
-
530
- it('visible when canAttach defaults true under M2M mode', async () => {
531
- const result = await Action.relationAttach(Tags, m2mCtx).evaluate({})
532
- assert.equal(result.visible, true)
533
- })
534
-
535
- it('manager canAttach=false hides the action even under M2M', async () => {
536
- class Locked extends RelationManager {
537
- static override relationship = 'tags'
538
- static override async canAttach(): Promise<boolean> { return false }
539
- }
540
- const result = await Action.relationAttach(Locked, m2mCtx).evaluate({})
541
- assert.equal(result.visible, false)
542
- })
543
- })
544
-
545
- describe('M2M factories — relationDetach', () => {
546
- class Tags extends RelationManager {
547
- static override relationship = 'tags'
548
- static override label = 'Tags'
549
- static override labelSingular = 'Tag'
550
- }
551
-
552
- const m2mCtx: RelationManagerContext = {
553
- basePath: '/admin',
554
- parentSlug: 'articles',
555
- parentId: '5',
556
- relationship: 'tags',
557
- parentRecord: { id: '5' },
558
- mode: 'belongsToMany',
559
- }
560
-
561
- it('builds the detach URL with :id template for row context', () => {
562
- const meta = Action.relationDetach(Tags, m2mCtx).toMeta()
563
- assert.equal(meta.action, '/admin/articles/5/tags/:id/_detach')
564
- assert.equal(meta.method, 'post')
565
- assert.equal(meta.label, 'Detach')
566
- })
567
-
568
- it('bakes in an explicit recordId when provided', () => {
569
- const meta = Action.relationDetach(Tags, m2mCtx, '9').toMeta()
570
- assert.equal(meta.action, '/admin/articles/5/tags/9/_detach')
571
- })
572
-
573
- it('auto-hides under hasMany mode', async () => {
574
- const hasManyCtx = { ...m2mCtx, mode: 'hasMany' as const }
575
- const result = await Action.relationDetach(Tags, hasManyCtx).evaluate({ record: { id: 9 } })
576
- assert.equal(result.visible, false)
577
- })
578
-
579
- it('visible when canDetach defaults true under M2M mode', async () => {
580
- const result = await Action.relationDetach(Tags, m2mCtx).evaluate({ record: { id: 9 } })
581
- assert.equal(result.visible, true)
582
- })
583
-
584
- it('confirm message frames the operation as detach (not delete)', () => {
585
- const meta = Action.relationDetach(Tags, m2mCtx).toMeta() as { confirm?: { message: string } }
586
- assert.match(meta.confirm?.message ?? '', /Detach/)
587
- assert.match(meta.confirm?.message ?? '', /stays in place/)
588
- })
589
- })
590
-
591
- describe('M2M factories — relationBulkDetach', () => {
592
- class Tags extends RelationManager {
593
- static override relationship = 'tags'
594
- static override label = 'Tags'
595
- static override labelSingular = 'Tag'
596
- }
597
-
598
- const m2mCtx: RelationManagerContext = {
599
- basePath: '/admin',
600
- parentSlug: 'articles',
601
- parentId: '5',
602
- relationship: 'tags',
603
- parentRecord: { id: '5' },
604
- mode: 'belongsToMany',
605
- }
606
-
607
- it('renders as a bulk action with destructive styling', () => {
608
- const meta = Action.relationBulkDetach(Tags, m2mCtx).toMeta()
609
- assert.equal(meta.placement, 'bulk')
610
- assert.equal(meta.color, 'destructive')
611
- })
612
-
613
- it('auto-hides under hasMany mode', async () => {
614
- const hasManyCtx = { ...m2mCtx, mode: 'hasMany' as const }
615
- const result = await Action.relationBulkDetach(Tags, hasManyCtx).evaluate({})
616
- assert.equal(result.visible, false)
617
- })
618
-
619
- it('visible when canAttach defaults true under M2M mode (bulk uses canAttach as gate)', async () => {
620
- const result = await Action.relationBulkDetach(Tags, m2mCtx).evaluate({})
621
- assert.equal(result.visible, true)
622
- })
623
- })
624
-
625
- // ─── Polymorphic M2M follow-up — morphToMany / morphedByMany ──────
626
- //
627
- // morphToMany (owning polymorphic side, e.g. `Post.tags()`) and
628
- // morphedByMany (inverse polymorphic side, e.g. `Tag.posts()`) share
629
- // the `belongsToMany` pivot-mutation shape. The three M2M factories
630
- // (`relationAttach / Detach / BulkDetach`) and the three "no per-pivot
631
- // surface" auto-hides on `relationCreate / Edit / Delete` should treat
632
- // all three modes identically.
633
-
634
- describe('M2M factories — morphToMany (owning polymorphic side)', () => {
635
- class Tags extends RelationManager {
636
- static override relationship = 'tags'
637
- static override label = 'Tags'
638
- static override labelSingular = 'Tag'
639
- }
640
-
641
- const morphToManyCtx: RelationManagerContext = {
642
- basePath: '/admin',
643
- parentSlug: 'posts',
644
- parentId: '5',
645
- relationship: 'tags',
646
- parentRecord: { id: '5' },
647
- mode: 'morphToMany',
648
- }
649
-
650
- it('relationAttach renders as header action under morphToMany mode', () => {
651
- const meta = Action.relationAttach(Tags, morphToManyCtx).toMeta()
652
- assert.equal(meta.label, 'Attach Tag')
653
- assert.equal(meta.placement, 'header')
654
- })
655
-
656
- it('relationAttach builds modal-form schema under morphToMany when Related.model is set', () => {
657
- const Related = {
658
- model: { query: () => ({ paginate: async () => ({ data: [], total: 0 }) }) },
659
- } as unknown as RelationManagerContext['related']
660
- const a = Action.relationAttach(Tags, { ...morphToManyCtx, related: Related })
661
- assert.equal(a.getSchema().length, 1)
662
- })
663
-
664
- it('relationAttach visible when canAttach defaults true under morphToMany mode', async () => {
665
- const result = await Action.relationAttach(Tags, morphToManyCtx).evaluate({})
666
- assert.equal(result.visible, true)
667
- })
668
-
669
- it('relationDetach builds detach URL under morphToMany mode', () => {
670
- const meta = Action.relationDetach(Tags, morphToManyCtx).toMeta()
671
- assert.equal(meta.action, '/admin/posts/5/tags/:id/_detach')
672
- assert.equal(meta.method, 'post')
673
- })
674
-
675
- it('relationDetach visible when canDetach defaults true under morphToMany mode', async () => {
676
- const result = await Action.relationDetach(Tags, morphToManyCtx).evaluate({ record: { id: 9 } })
677
- assert.equal(result.visible, true)
678
- })
679
-
680
- it('relationBulkDetach visible under morphToMany mode', async () => {
681
- const result = await Action.relationBulkDetach(Tags, morphToManyCtx).evaluate({})
682
- assert.equal(result.visible, true)
683
- })
684
-
685
- it('relationCreate auto-hides under morphToMany (no per-pivot create surface)', async () => {
686
- const result = await Action.relationCreate(Tags, morphToManyCtx).evaluate({})
687
- assert.equal(result.visible, false)
688
- })
689
-
690
- it('relationEdit auto-hides under morphToMany (no per-pivot edit surface)', async () => {
691
- const result = await Action.relationEdit(Tags, morphToManyCtx).evaluate({ record: { id: '9' } })
692
- assert.equal(result.visible, false)
693
- })
694
-
695
- it('relationDelete auto-hides under morphToMany (use relationDetach instead)', async () => {
696
- const result = await Action.relationDelete(Tags, morphToManyCtx).evaluate({ record: { id: '9' } })
697
- assert.equal(result.visible, false)
698
- })
699
- })
700
-
701
- describe('M2M factories — morphedByMany (inverse polymorphic side)', () => {
702
- class Posts extends RelationManager {
703
- static override relationship = 'posts'
704
- static override label = 'Posts'
705
- static override labelSingular = 'Post'
706
- }
707
-
708
- const morphedByManyCtx: RelationManagerContext = {
709
- basePath: '/admin',
710
- parentSlug: 'tags',
711
- parentId: '5',
712
- relationship: 'posts',
713
- parentRecord: { id: '5' },
714
- mode: 'morphedByMany',
715
- }
716
-
717
- it('relationAttach renders as header action under morphedByMany mode', () => {
718
- const meta = Action.relationAttach(Posts, morphedByManyCtx).toMeta()
719
- assert.equal(meta.label, 'Attach Post')
720
- assert.equal(meta.placement, 'header')
721
- })
722
-
723
- it('relationAttach builds modal-form schema under morphedByMany when Related.model is set', () => {
724
- const Related = {
725
- model: { query: () => ({ paginate: async () => ({ data: [], total: 0 }) }) },
726
- } as unknown as RelationManagerContext['related']
727
- const a = Action.relationAttach(Posts, { ...morphedByManyCtx, related: Related })
728
- assert.equal(a.getSchema().length, 1)
729
- })
730
-
731
- it('relationAttach visible when canAttach defaults true under morphedByMany mode', async () => {
732
- const result = await Action.relationAttach(Posts, morphedByManyCtx).evaluate({})
733
- assert.equal(result.visible, true)
734
- })
735
-
736
- it('relationDetach builds detach URL under morphedByMany mode', () => {
737
- const meta = Action.relationDetach(Posts, morphedByManyCtx).toMeta()
738
- assert.equal(meta.action, '/admin/tags/5/posts/:id/_detach')
739
- assert.equal(meta.method, 'post')
740
- })
741
-
742
- it('relationDetach visible when canDetach defaults true under morphedByMany mode', async () => {
743
- const result = await Action.relationDetach(Posts, morphedByManyCtx).evaluate({ record: { id: 9 } })
744
- assert.equal(result.visible, true)
745
- })
746
-
747
- it('relationBulkDetach visible under morphedByMany mode', async () => {
748
- const result = await Action.relationBulkDetach(Posts, morphedByManyCtx).evaluate({})
749
- assert.equal(result.visible, true)
750
- })
751
-
752
- it('relationCreate auto-hides under morphedByMany (no per-pivot create surface)', async () => {
753
- const result = await Action.relationCreate(Posts, morphedByManyCtx).evaluate({})
754
- assert.equal(result.visible, false)
755
- })
756
-
757
- it('relationEdit auto-hides under morphedByMany (no per-pivot edit surface)', async () => {
758
- const result = await Action.relationEdit(Posts, morphedByManyCtx).evaluate({ record: { id: '9' } })
759
- assert.equal(result.visible, false)
760
- })
761
-
762
- it('relationDelete auto-hides under morphedByMany (use relationDetach instead)', async () => {
763
- const result = await Action.relationDelete(Posts, morphedByManyCtx).evaluate({ record: { id: '9' } })
764
- assert.equal(result.visible, false)
765
- })
766
- })
767
- })
768
-
769
- describe('Action soft-delete factories (Plan #13)', () => {
770
- /** Minimal ResourceLike satisfying the Action factories. */
771
- function makeR(over: Partial<{
772
- softDeletes: boolean
773
- deletedAtColumn: string
774
- canDelete: (...args: unknown[]) => boolean | Promise<boolean>
775
- canRestore: (...args: unknown[]) => boolean | Promise<boolean>
776
- canForceDelete: (...args: unknown[]) => boolean | Promise<boolean>
777
- }> = {}) {
778
- return {
779
- labelSingular: 'Post',
780
- getSlug: () => 'posts',
781
- ...(over.softDeletes !== undefined ? { softDeletes: over.softDeletes } : {}),
782
- ...(over.deletedAtColumn !== undefined ? { deletedAtColumn: over.deletedAtColumn } : {}),
783
- ...(over.canDelete ? { canDelete: over.canDelete } : {}),
784
- ...(over.canRestore ? { canRestore: over.canRestore } : {}),
785
- ...(over.canForceDelete ? { canForceDelete: over.canForceDelete } : {}),
786
- }
787
- }
788
-
789
- describe('Action.delete trashed-row visibility', () => {
790
- it('hides on already-trashed rows when softDeletes=true', async () => {
791
- const R = makeR({ softDeletes: true })
792
- const a = Action.delete(R, '/admin')
793
- const r1 = await a.evaluate({ record: { id: '7', deletedAt: '2026-01-01' } })
794
- assert.equal(r1.visible, false)
795
- })
796
-
797
- it('shows on live rows when softDeletes=true and canDelete allows', async () => {
798
- const R = makeR({ softDeletes: true })
799
- const a = Action.delete(R, '/admin')
800
- const r1 = await a.evaluate({ record: { id: '7' } })
801
- assert.equal(r1.visible, true)
802
- })
803
-
804
- it('ignores deletedAt entirely when softDeletes is not set', async () => {
805
- const R = makeR()
806
- const a = Action.delete(R, '/admin')
807
- // Even with deletedAt set, the regular delete should show — non-soft-delete
808
- // resources don't gate on the column.
809
- const r1 = await a.evaluate({ record: { id: '7', deletedAt: '2026-01-01' } })
810
- assert.equal(r1.visible, true)
811
- })
812
-
813
- it('honors a custom deletedAtColumn', async () => {
814
- const R = makeR({ softDeletes: true, deletedAtColumn: 'archivedAt' })
815
- const a = Action.delete(R, '/admin')
816
- assert.equal((await a.evaluate({ record: { archivedAt: '2026-01-01' } })).visible, false)
817
- assert.equal((await a.evaluate({ record: { archivedAt: null } })).visible, true)
818
- })
819
- })
820
-
821
- describe('Action.restore', () => {
822
- it('builds the restore URL with :id template', () => {
823
- const meta = Action.restore(makeR({ softDeletes: true }), '/admin').toMeta()
824
- assert.equal(meta.method, 'post')
825
- assert.equal(meta.action, '/admin/posts/:id/restore')
826
- assert.equal(meta.label, 'Restore')
827
- assert.equal(meta.color, 'success')
828
- })
829
-
830
- it('hides on live rows', async () => {
831
- const a = Action.restore(makeR({ softDeletes: true }), '/admin')
832
- assert.equal((await a.evaluate({ record: { id: '7' } })).visible, false)
833
- })
834
-
835
- it('shows on trashed rows when canRestore allows', async () => {
836
- const a = Action.restore(makeR({ softDeletes: true }), '/admin')
837
- assert.equal((await a.evaluate({ record: { deletedAt: '2026-01-01' } })).visible, true)
838
- })
839
-
840
- it('hides on trashed rows when canRestore denies', async () => {
841
- const a = Action.restore(makeR({ softDeletes: true, canRestore: async () => false }), '/admin')
842
- assert.equal((await a.evaluate({ record: { deletedAt: '2026-01-01' } })).visible, false)
843
- })
844
-
845
- it('honors explicit recordId at config time', () => {
846
- const meta = Action.restore(makeR({ softDeletes: true }), '/admin', '7').toMeta()
847
- assert.equal(meta.action, '/admin/posts/7/restore')
848
- })
849
- })
850
-
851
- describe('Action.forceDelete', () => {
852
- it('builds the force-delete URL with destructive style + permanence confirm', () => {
853
- const meta = Action.forceDelete(makeR({ softDeletes: true }), '/admin').toMeta()
854
- assert.equal(meta.method, 'post')
855
- assert.equal(meta.action, '/admin/posts/:id/force-delete')
856
- assert.equal(meta.destructive, true)
857
- assert.match(meta.confirm?.message ?? '', /cannot be undone/i)
858
- })
859
-
860
- it('hides on live rows', async () => {
861
- const a = Action.forceDelete(makeR({ softDeletes: true }), '/admin')
862
- assert.equal((await a.evaluate({ record: { id: '7' } })).visible, false)
863
- })
864
-
865
- it('shows on trashed rows when canForceDelete allows', async () => {
866
- const a = Action.forceDelete(makeR({ softDeletes: true }), '/admin')
867
- assert.equal((await a.evaluate({ record: { deletedAt: '2026-01-01' } })).visible, true)
868
- })
869
-
870
- it('hides on trashed rows when canForceDelete denies', async () => {
871
- const a = Action.forceDelete(makeR({ softDeletes: true, canForceDelete: async () => false }), '/admin')
872
- assert.equal((await a.evaluate({ record: { deletedAt: '2026-01-01' } })).visible, false)
873
- })
874
-
875
- it('label is "Delete forever" — distinguishes from regular delete', () => {
876
- assert.equal(Action.forceDelete(makeR({ softDeletes: true }), '/admin').toMeta().label, 'Delete forever')
877
- })
878
- })
879
- })
880
-
881
- describe('Action bulk soft-delete factories (Plan #13)', () => {
882
- it('bulkDelete iterates records and calls deleteRecord, returns count notification', async () => {
883
- const deleted: string[] = []
884
- const R = {
885
- labelSingular: 'Post',
886
- getSlug: () => 'posts',
887
- softDeletes: true,
888
- deletedAtColumn: 'deletedAt',
889
- async deleteRecord(id: string) { deleted.push(id) },
890
- } as never
891
- const a = Action.bulkDelete(R, '/admin')
892
- const meta = a.toMeta()
893
- assert.equal(meta.placement, 'bulk')
894
- assert.equal(meta.destructive, true)
895
-
896
- const handler = a.getHandler()!
897
- const result = await handler({
898
- records: [{ id: '1' }, { id: '2' }, { id: '3' }],
899
- user: null,
900
- })
901
- assert.deepEqual(deleted.sort(), ['1', '2', '3'])
902
- const notify = (result as { notify: { title: string } }).notify
903
- assert.match(notify.title, /3 posts moved to trash/i)
904
- })
905
-
906
- it('bulkDelete uses "deleted" verb for non-soft-delete resources', async () => {
907
- const R = {
908
- labelSingular: 'Post',
909
- getSlug: () => 'posts',
910
- // softDeletes: false
911
- async deleteRecord() { /* no-op */ },
912
- } as never
913
- const handler = Action.bulkDelete(R, '/admin').getHandler()!
914
- const result = await handler({ records: [{ id: '1' }], user: null })
915
- // Count-aware singular: 1 → labelSingular, not the naive plural.
916
- assert.match((result as { notify: { title: string } }).notify.title, /1 post deleted/)
917
- })
918
-
919
- it('bulkDelete uses the count-aware singular form when n=1', async () => {
920
- const R = {
921
- labelSingular: 'Article',
922
- label: 'Articles', // explicit plural
923
- getSlug: () => 'articles',
924
- softDeletes: true,
925
- async deleteRecord() { /* no-op */ },
926
- } as never
927
- const handler = Action.bulkDelete(R, '/admin').getHandler()!
928
- const r1 = await handler({ records: [{ id: '1' }], user: null })
929
- assert.match((r1 as { notify: { title: string } }).notify.title, /^1 article moved to trash$/)
930
- const r5 = await handler({ records: Array.from({ length: 5 }, (_, i) => ({ id: String(i) })), user: null })
931
- assert.match((r5 as { notify: { title: string } }).notify.title, /^5 articles moved to trash$/)
932
- })
933
-
934
- it('bulkDelete falls back to naive ${labelSingular}s when no plural label is set', async () => {
935
- const R = {
936
- labelSingular: 'Post',
937
- // No `label` set — uses fallback.
938
- getSlug: () => 'posts',
939
- softDeletes: true,
940
- async deleteRecord() { /* no-op */ },
941
- } as never
942
- const handler = Action.bulkDelete(R, '/admin').getHandler()!
943
- const r5 = await handler({ records: Array.from({ length: 5 }, (_, i) => ({ id: String(i) })), user: null })
944
- assert.match((r5 as { notify: { title: string } }).notify.title, /5 posts moved to trash/)
945
- })
946
-
947
- it('bulkDelete skips rows whose canDelete returns false', async () => {
948
- const deleted: string[] = []
949
- const R = {
950
- labelSingular: 'Post',
951
- getSlug: () => 'posts',
952
- async canDelete(_user: unknown, record: unknown) {
953
- return (record as { id: string }).id !== '2' // deny id 2
954
- },
955
- async deleteRecord(id: string) { deleted.push(id) },
956
- } as never
957
- const handler = Action.bulkDelete(R, '/admin').getHandler()!
958
- const result = await handler({
959
- records: [{ id: '1' }, { id: '2' }, { id: '3' }],
960
- user: null,
961
- })
962
- assert.deepEqual(deleted.sort(), ['1', '3'])
963
- assert.match((result as { notify: { title: string } }).notify.title, /2 posts/)
964
- })
965
-
966
- it('bulkRestore calls model.restore on each row', async () => {
967
- const restored: string[] = []
968
- const R = {
969
- labelSingular: 'Post',
970
- getSlug: () => 'posts',
971
- softDeletes: true,
972
- deletedAtColumn: 'deletedAt',
973
- model: {
974
- async restore(id: string | number) { restored.push(String(id)); return {} },
975
- },
976
- } as never
977
- const handler = Action.bulkRestore(R, '/admin').getHandler()!
978
- const result = await handler({ records: [{ id: '1' }, { id: '2' }], user: null })
979
- assert.deepEqual(restored.sort(), ['1', '2'])
980
- assert.match((result as { notify: { title: string } }).notify.title, /2 posts restored/i)
981
- })
982
-
983
- it('bulkRestore returns an error notify when model.restore is missing', async () => {
984
- const R = {
985
- labelSingular: 'Post',
986
- getSlug: () => 'posts',
987
- softDeletes: true,
988
- model: {},
989
- } as never
990
- const handler = Action.bulkRestore(R, '/admin').getHandler()!
991
- const result = await handler({ records: [{ id: '1' }], user: null })
992
- const notify = (result as { notify: { title: string; type: string } }).notify
993
- assert.match(notify.title, /not configured/i)
994
- assert.equal(notify.type, 'error')
995
- })
996
-
997
- it('bulkForceDelete calls model.forceDelete on each row', async () => {
998
- const purged: string[] = []
999
- const R = {
1000
- labelSingular: 'Post',
1001
- getSlug: () => 'posts',
1002
- softDeletes: true,
1003
- deletedAtColumn: 'deletedAt',
1004
- model: {
1005
- async forceDelete(id: string | number) { purged.push(String(id)) },
1006
- },
1007
- } as never
1008
- const handler = Action.bulkForceDelete(R, '/admin').getHandler()!
1009
- const result = await handler({ records: [{ id: '1' }, { id: '2' }], user: null })
1010
- assert.deepEqual(purged.sort(), ['1', '2'])
1011
- assert.match((result as { notify: { title: string } }).notify.title, /2 posts permanently deleted/i)
1012
- })
1013
-
1014
- it('all three bulk factories ship the correct placement + destructive flags', () => {
1015
- const R = { labelSingular: 'Post', getSlug: () => 'posts' } as never
1016
- const del = Action.bulkDelete(R, '/admin').toMeta()
1017
- const restore = Action.bulkRestore(R, '/admin').toMeta()
1018
- const fdelete = Action.bulkForceDelete(R, '/admin').toMeta()
1019
-
1020
- assert.equal(del.placement, 'bulk')
1021
- assert.equal(restore.placement, 'bulk')
1022
- assert.equal(fdelete.placement, 'bulk')
1023
-
1024
- assert.equal(del.destructive, true)
1025
- assert.equal(restore.destructive, false)
1026
- assert.equal(fdelete.destructive, true)
1027
-
1028
- assert.equal(restore.color, 'success')
1029
- })
1030
- })
1031
-
1032
- describe('Action.replicate factory', () => {
1033
- function makeR(over: Partial<{
1034
- primaryKey: string
1035
- deletedAtColumn: string
1036
- canCreate: (...args: unknown[]) => boolean | Promise<boolean>
1037
- create: (data: Record<string, unknown>) => Promise<unknown>
1038
- }> = {}): never {
1039
- const created: Array<Record<string, unknown>> = []
1040
- const R = {
1041
- labelSingular: 'Post',
1042
- getSlug: () => 'posts',
1043
- ...(over.deletedAtColumn !== undefined ? { deletedAtColumn: over.deletedAtColumn } : {}),
1044
- ...(over.canCreate ? { canCreate: over.canCreate } : {}),
1045
- model: {
1046
- ...(over.primaryKey !== undefined ? { primaryKey: over.primaryKey } : {}),
1047
- async create(data: Record<string, unknown>) {
1048
- created.push(data)
1049
- return over.create ? await over.create(data) : { id: '99', ...data }
1050
- },
1051
- },
1052
- _created: created,
1053
- } as never
1054
- return R
1055
- }
1056
-
1057
- it('creates a duplicate via R.model.create with the source record minus PK', async () => {
1058
- const R = makeR()
1059
- const handler = Action.replicate(R, '/admin').getHandler()!
1060
- const result = await handler({
1061
- record: { id: '7', title: 'Hello', body: 'World', deletedAt: null },
1062
- user: null,
1063
- })
1064
- const created = (R as unknown as { _created: Array<Record<string, unknown>> })._created
1065
- assert.equal(created.length, 1)
1066
- assert.equal(created[0]!['title'], 'Hello')
1067
- assert.equal(created[0]!['body'], 'World')
1068
- assert.equal(created[0]!['id'], undefined, 'PK must be stripped')
1069
- assert.equal(created[0]!['deletedAt'], undefined, 'soft-delete column stripped')
1070
- const r = result as { redirect: string; notify: { title: string; type: string } }
1071
- assert.equal(r.redirect, '/admin/posts/99/edit')
1072
- assert.match(r.notify.title, /Post replicated/)
1073
- assert.equal(r.notify.type, 'success')
1074
- })
1075
-
1076
- it('honors a non-default primary key column', async () => {
1077
- const R = makeR({ primaryKey: 'uuid', create: async (d) => ({ uuid: 'abc-123', ...d }) })
1078
- const handler = Action.replicate(R, '/admin').getHandler()!
1079
- const result = await handler({
1080
- record: { uuid: 'src-1', title: 'Hello' },
1081
- user: null,
1082
- })
1083
- const created = (R as unknown as { _created: Array<Record<string, unknown>> })._created
1084
- assert.equal(created[0]!['uuid'], undefined)
1085
- assert.equal((result as { redirect: string }).redirect, '/admin/posts/abc-123/edit')
1086
- })
1087
-
1088
- it('honors a custom deletedAtColumn', async () => {
1089
- const R = makeR({ deletedAtColumn: 'archivedAt' })
1090
- const handler = Action.replicate(R, '/admin').getHandler()!
1091
- await handler({
1092
- record: { id: '1', title: 'X', archivedAt: '2026-01-01', deletedAt: 'should-pass' },
1093
- user: null,
1094
- })
1095
- const created = (R as unknown as { _created: Array<Record<string, unknown>> })._created
1096
- assert.equal(created[0]!['archivedAt'], undefined, 'custom soft-delete column stripped')
1097
- assert.equal(created[0]!['deletedAt'], 'should-pass', 'default name no longer special when custom is set')
1098
- })
1099
-
1100
- it('drops opts.excludeAttributes from the replica', async () => {
1101
- const R = makeR()
1102
- const handler = Action.replicate(R, '/admin', undefined, { excludeAttributes: ['slug', 'email'] }).getHandler()!
1103
- await handler({
1104
- record: { id: '1', title: 'Hello', slug: 'hello', email: 'a@b.co' },
1105
- user: null,
1106
- })
1107
- const created = (R as unknown as { _created: Array<Record<string, unknown>> })._created
1108
- assert.equal(created[0]!['slug'], undefined)
1109
- assert.equal(created[0]!['email'], undefined)
1110
- assert.equal(created[0]!['title'], 'Hello')
1111
- })
1112
-
1113
- it('runs opts.beforeReplicaSaved to mutate the prepared payload', async () => {
1114
- const R = makeR()
1115
- const handler = Action.replicate(R, '/admin', undefined, {
1116
- beforeReplicaSaved: (replica) => ({ ...replica, title: `Copy of ${replica['title']}` }),
1117
- }).getHandler()!
1118
- await handler({ record: { id: '1', title: 'Hello' }, user: null })
1119
- const created = (R as unknown as { _created: Array<Record<string, unknown>> })._created
1120
- assert.equal(created[0]!['title'], 'Copy of Hello')
1121
- })
1122
-
1123
- it('returns an error notify when R.model is missing', async () => {
1124
- const R = { labelSingular: 'Post', getSlug: () => 'posts' } as never
1125
- const handler = Action.replicate(R, '/admin').getHandler()!
1126
- const result = await handler({ record: { id: '1' }, user: null })
1127
- const notify = (result as { notify: { title: string; type: string } }).notify
1128
- assert.match(notify.title, /not configured/i)
1129
- assert.equal(notify.type, 'error')
1130
- })
1131
-
1132
- it('returns an error notify when ctx.record is missing', async () => {
1133
- const R = makeR()
1134
- const handler = Action.replicate(R, '/admin').getHandler()!
1135
- const result = await handler({ user: null })
1136
- const notify = (result as { notify: { title: string; type: string } }).notify
1137
- assert.match(notify.title, /source record missing/i)
1138
- })
1139
-
1140
- it('catches errors thrown by R.model.create and surfaces them', async () => {
1141
- const R = makeR({ create: async () => { throw new Error('unique constraint failed: posts.slug') } })
1142
- const handler = Action.replicate(R, '/admin').getHandler()!
1143
- const result = await handler({ record: { id: '1', slug: 'x' }, user: null })
1144
- const notify = (result as { notify: { title: string; type: string } }).notify
1145
- assert.match(notify.title, /unique constraint/i)
1146
- assert.equal(notify.type, 'error')
1147
- })
1148
-
1149
- it('visibility delegates to R.canCreate', async () => {
1150
- const R1 = makeR({ canCreate: () => true })
1151
- const R2 = makeR({ canCreate: () => false })
1152
- assert.equal((await Action.replicate(R1, '/admin').evaluate({})).visible, true)
1153
- assert.equal((await Action.replicate(R2, '/admin').evaluate({})).visible, false)
1154
- })
1155
-
1156
- it('falls back to list page when create() returns no PK on the new record', async () => {
1157
- const R = makeR({ create: async () => ({}) })
1158
- const handler = Action.replicate(R, '/admin').getHandler()!
1159
- const result = await handler({ record: { id: '1', title: 'Hello' }, user: null })
1160
- assert.equal((result as { redirect: string }).redirect, '/admin/posts')
1161
- })
1162
-
1163
- it('opts.getCreatedNotificationTitle overrides the default success title', async () => {
1164
- const R = makeR({ create: async (d) => ({ id: '99', ...d }) })
1165
- let seenReplica: unknown
1166
- let seenSource: unknown
1167
- const handler = Action.replicate(R, '/admin', undefined, {
1168
- getCreatedNotificationTitle: ({ replica, source }) => {
1169
- seenReplica = replica
1170
- seenSource = source
1171
- return `Cloned "${(source as { title?: string })?.title}"`
1172
- },
1173
- }).getHandler()!
1174
- const result = await handler({ record: { id: '7', title: 'Hello' }, user: null })
1175
- assert.equal((result as { notify: { title: string } }).notify.title, 'Cloned "Hello"')
1176
- assert.deepEqual(seenSource, { id: '7', title: 'Hello' })
1177
- assert.deepEqual(seenReplica, { id: '99', title: 'Hello' })
1178
- })
1179
-
1180
- it('opts.getCreatedNotificationTitle returning undefined falls back to default', async () => {
1181
- const R = makeR()
1182
- const handler = Action.replicate(R, '/admin', undefined, {
1183
- getCreatedNotificationTitle: () => undefined,
1184
- }).getHandler()!
1185
- const result = await handler({ record: { id: '1', title: 'X' }, user: null })
1186
- assert.match((result as { notify: { title: string } }).notify.title, /^Post replicated$/)
1187
- })
1188
-
1189
- it('opts.getRedirectUrl overrides the default new-record edit URL', async () => {
1190
- const R = makeR()
1191
- const handler = Action.replicate(R, '/admin', undefined, {
1192
- getRedirectUrl: ({ replica }) => `/admin/posts/${(replica as { id: string }).id}/preview`,
1193
- }).getHandler()!
1194
- const result = await handler({ record: { id: '1', title: 'Hello' }, user: null })
1195
- assert.equal((result as { redirect: string }).redirect, '/admin/posts/99/preview')
1196
- })
1197
-
1198
- it('opts.getRedirectUrl returning undefined falls back to default', async () => {
1199
- const R = makeR()
1200
- const handler = Action.replicate(R, '/admin', undefined, {
1201
- getRedirectUrl: () => undefined,
1202
- }).getHandler()!
1203
- const result = await handler({ record: { id: '1', title: 'Hello' }, user: null })
1204
- assert.equal((result as { redirect: string }).redirect, '/admin/posts/99/edit')
1205
- })
1206
-
1207
- it('opts.getRedirectUrl honors an explicit empty string (not swallowed by ??)', async () => {
1208
- const R = makeR()
1209
- const handler = Action.replicate(R, '/admin', undefined, {
1210
- getRedirectUrl: () => '',
1211
- }).getHandler()!
1212
- const result = await handler({ record: { id: '1', title: 'Hello' }, user: null })
1213
- assert.equal((result as { redirect: string }).redirect, '')
1214
- })
1215
-
1216
- it('overrides may be async', async () => {
1217
- const R = makeR()
1218
- const handler = Action.replicate(R, '/admin', undefined, {
1219
- getCreatedNotificationTitle: async () => 'async title',
1220
- getRedirectUrl: async () => '/admin/elsewhere',
1221
- }).getHandler()!
1222
- const result = await handler({ record: { id: '1', title: 'Hello' }, user: null }) as {
1223
- redirect: string
1224
- notify: { title: string }
1225
- }
1226
- assert.equal(result.notify.title, 'async title')
1227
- assert.equal(result.redirect, '/admin/elsewhere')
1228
- })
1229
- })
1230
-
1231
- describe('Action.bulkReplicate factory', () => {
1232
- function makeR(over: Partial<{
1233
- primaryKey: string
1234
- deletedAtColumn: string
1235
- canCreate: (...args: unknown[]) => boolean | Promise<boolean>
1236
- create: (data: Record<string, unknown>) => Promise<unknown>
1237
- }> = {}): never {
1238
- const created: Array<Record<string, unknown>> = []
1239
- const R = {
1240
- labelSingular: 'Post',
1241
- label: 'Posts',
1242
- getSlug: () => 'posts',
1243
- ...(over.deletedAtColumn !== undefined ? { deletedAtColumn: over.deletedAtColumn } : {}),
1244
- ...(over.canCreate ? { canCreate: over.canCreate } : {}),
1245
- model: {
1246
- ...(over.primaryKey !== undefined ? { primaryKey: over.primaryKey } : {}),
1247
- async create(data: Record<string, unknown>) {
1248
- created.push(data)
1249
- return over.create ? await over.create(data) : { id: String(created.length), ...data }
1250
- },
1251
- },
1252
- _created: created,
1253
- } as never
1254
- return R
1255
- }
1256
-
1257
- it('renders as a bulk action with confirm prompt', () => {
1258
- const R = makeR()
1259
- const meta = Action.bulkReplicate(R, '/admin').toMeta()
1260
- assert.equal(meta.placement, 'bulk')
1261
- assert.match(meta.confirm?.message ?? '', /Replicate the selected/)
1262
- })
1263
-
1264
- it('iterates ctx.records and creates one row per source', async () => {
1265
- const R = makeR()
1266
- const handler = Action.bulkReplicate(R, '/admin').getHandler()!
1267
- const result = await handler({
1268
- records: [
1269
- { id: '1', title: 'A' },
1270
- { id: '2', title: 'B' },
1271
- { id: '3', title: 'C' },
1272
- ],
1273
- user: null,
1274
- })
1275
- const created = (R as unknown as { _created: Array<Record<string, unknown>> })._created
1276
- assert.equal(created.length, 3)
1277
- assert.deepEqual(created.map(r => r['title']), ['A', 'B', 'C'])
1278
- assert.match((result as { notify: { title: string } }).notify.title, /3 posts replicated/)
1279
- })
1280
-
1281
- it('strips PK + soft-delete + excludeAttributes from each replica', async () => {
1282
- const R = makeR({ primaryKey: 'uuid', deletedAtColumn: 'archivedAt' })
1283
- const handler = Action.bulkReplicate(R, '/admin', {
1284
- excludeAttributes: ['slug'],
1285
- }).getHandler()!
1286
- await handler({
1287
- records: [
1288
- { uuid: 'a', title: 'X', slug: 'x', archivedAt: null },
1289
- { uuid: 'b', title: 'Y', slug: 'y', archivedAt: '2026-01-01' },
1290
- ],
1291
- user: null,
1292
- })
1293
- const created = (R as unknown as { _created: Array<Record<string, unknown>> })._created
1294
- for (const r of created) {
1295
- assert.equal(r['uuid'], undefined)
1296
- assert.equal(r['slug'], undefined)
1297
- assert.equal(r['archivedAt'], undefined)
1298
- }
1299
- })
1300
-
1301
- it('runs beforeReplicaSaved per row', async () => {
1302
- const R = makeR()
1303
- const handler = Action.bulkReplicate(R, '/admin', {
1304
- beforeReplicaSaved: (replica) => ({ ...replica, title: `Copy of ${replica['title']}` }),
1305
- }).getHandler()!
1306
- await handler({
1307
- records: [{ id: '1', title: 'A' }, { id: '2', title: 'B' }],
1308
- user: null,
1309
- })
1310
- const created = (R as unknown as { _created: Array<Record<string, unknown>> })._created
1311
- assert.deepEqual(created.map(r => r['title']), ['Copy of A', 'Copy of B'])
1312
- })
1313
-
1314
- it('skips rows whose canCreate returns false', async () => {
1315
- let calls = 0
1316
- const R = makeR({ canCreate: async () => { calls++; return calls !== 2 } })
1317
- const handler = Action.bulkReplicate(R, '/admin').getHandler()!
1318
- const result = await handler({
1319
- records: [{ id: '1', title: 'A' }, { id: '2', title: 'B' }, { id: '3', title: 'C' }],
1320
- user: null,
1321
- })
1322
- const created = (R as unknown as { _created: Array<Record<string, unknown>> })._created
1323
- assert.equal(created.length, 2)
1324
- assert.match((result as { notify: { title: string } }).notify.title, /^2 posts replicated$/)
1325
- })
1326
-
1327
- it('skips rows where create throws and reports only successful count', async () => {
1328
- let i = 0
1329
- const R = makeR({ create: async () => {
1330
- i++
1331
- if (i === 2) throw new Error('boom')
1332
- return { id: String(i) }
1333
- } })
1334
- const handler = Action.bulkReplicate(R, '/admin').getHandler()!
1335
- const result = await handler({
1336
- records: [{ id: '1' }, { id: '2' }, { id: '3' }],
1337
- user: null,
1338
- })
1339
- assert.match((result as { notify: { title: string } }).notify.title, /^2 posts replicated$/)
1340
- })
1341
-
1342
- it('returns an error notify when R.model.create is missing', async () => {
1343
- const R = { labelSingular: 'Post', getSlug: () => 'posts' } as never
1344
- const handler = Action.bulkReplicate(R, '/admin').getHandler()!
1345
- const result = await handler({ records: [{ id: '1' }], user: null })
1346
- const notify = (result as { notify: { title: string; type: string } }).notify
1347
- assert.match(notify.title, /not configured/i)
1348
- assert.equal(notify.type, 'error')
1349
- })
1350
-
1351
- it('count-aware singular form when n=1', async () => {
1352
- const R = makeR()
1353
- const handler = Action.bulkReplicate(R, '/admin').getHandler()!
1354
- const result = await handler({ records: [{ id: '1', title: 'A' }], user: null })
1355
- assert.match((result as { notify: { title: string } }).notify.title, /^1 post replicated$/)
1356
- })
1357
-
1358
- it('visibility delegates to R.canCreate', async () => {
1359
- const R1 = makeR({ canCreate: () => true })
1360
- const R2 = makeR({ canCreate: () => false })
1361
- assert.equal((await Action.bulkReplicate(R1, '/admin').evaluate({})).visible, true)
1362
- assert.equal((await Action.bulkReplicate(R2, '/admin').evaluate({})).visible, false)
1363
- })
1364
-
1365
- it('opts.getCreatedNotificationTitle receives count + sources and overrides default', async () => {
1366
- const R = makeR()
1367
- let seenCount: unknown
1368
- let seenRecords: unknown
1369
- const handler = Action.bulkReplicate(R, '/admin', {
1370
- getCreatedNotificationTitle: ({ count, records }) => {
1371
- seenCount = count
1372
- seenRecords = records
1373
- return `Duplicated ${count} of ${(records as unknown[]).length}`
1374
- },
1375
- }).getHandler()!
1376
- const result = await handler({
1377
- records: [{ id: '1' }, { id: '2' }, { id: '3' }],
1378
- user: null,
1379
- })
1380
- assert.equal((result as { notify: { title: string } }).notify.title, 'Duplicated 3 of 3')
1381
- assert.equal(seenCount, 3)
1382
- assert.deepEqual(seenRecords, [{ id: '1' }, { id: '2' }, { id: '3' }])
1383
- })
1384
-
1385
- it('opts.getCreatedNotificationTitle returning undefined falls back to default', async () => {
1386
- const R = makeR()
1387
- const handler = Action.bulkReplicate(R, '/admin', {
1388
- getCreatedNotificationTitle: () => undefined,
1389
- }).getHandler()!
1390
- const result = await handler({ records: [{ id: '1' }], user: null })
1391
- assert.match((result as { notify: { title: string } }).notify.title, /^1 post replicated$/)
1392
- })
1393
- })
1394
-
1395
- describe('Action visibility through resolveSchema (non-row placements)', () => {
1396
- it('drops a header action when visible() returns false', async () => {
1397
- const tree = [
1398
- Action.make('hidden').header().visible(false),
1399
- Action.make('shown').header(),
1400
- ]
1401
- const result = await resolveSchema(tree)
1402
- assert.equal(result.length, 1)
1403
- assert.equal(result[0]!['name'], 'shown')
1404
- })
1405
-
1406
- it('keeps row-placement actions in the tree even when hidden — per-row eval handles them', async () => {
1407
- const tree = [Action.make('rowAction').row().visible(false)]
1408
- const result = await resolveSchema(tree)
1409
- assert.equal(result.length, 1, 'row actions are always serialized; per-row eval filters at render time')
1410
- assert.equal(result[0]!['conditional'], true, 'conditional flag tells the row renderer to consult the lookup')
1411
- })
1412
-
1413
- it('stamps disabled:true on header action when disabled(true) is set', async () => {
1414
- const result = await resolveSchema([Action.make('a').header().disabled(true)])
1415
- assert.equal(result[0]!['disabled'], true)
1416
- })
1417
- })
1418
-
1419
- describe('Action.export factory', () => {
1420
- /** Lazily-resolved Column class — Column module imports Action, so a
1421
- * top-level static import would tighten the cycle. */
1422
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1423
- let ColumnClass: any
1424
- beforeEach(async () => {
1425
- if (!ColumnClass) ColumnClass = (await import('../Column.js')).Column
1426
- })
1427
-
1428
- /** Build a minimal Resource-like object with an opt-in `table()`
1429
- * configurator and visibility predicate. */
1430
- function makeR(over: {
1431
- canViewAny?: (user: unknown) => boolean | Promise<boolean>
1432
- rows?: Record<string, unknown>[]
1433
- perPageHint?: number
1434
- columns?: Array<string>
1435
- } = {}) {
1436
- const rows = over.rows ?? [
1437
- { id: 1, name: 'Ada', email: 'ada@example.com' },
1438
- { id: 2, name: 'Grace', email: 'grace@example.com' },
1439
- ]
1440
- const colNames = over.columns ?? ['id', 'name', 'email']
1441
-
1442
- return {
1443
- labelSingular: 'User',
1444
- label: 'Users',
1445
- getSlug: () => 'users',
1446
- ...(over.canViewAny ? { canViewAny: over.canViewAny } : {}),
1447
- // R.table is called inside the export handler with an empty Table.
1448
- // We mutate the passed table in-place, mirroring how user code does
1449
- // it via the fluent builder. Returning the same `t` matches the
1450
- // contract of `static table(t)`.
1451
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1452
- table(t: any) {
1453
- const cols = colNames.map(n => ColumnClass.make(n))
1454
- // Records handler — pages 1..N where N covers all rows. Honors
1455
- // the `perPage` we ask for (so chunking exits cleanly).
1456
- t.columns(cols)
1457
- t.records(({ page = 1, perPage = 1000 }: { page?: number; perPage?: number }) => {
1458
- const start = (page - 1) * perPage
1459
- return { rows: rows.slice(start, start + perPage), total: rows.length }
1460
- })
1461
- return t
1462
- },
1463
- }
1464
- }
1465
-
1466
- it('returns a CSV download envelope with default filename / contentType', async () => {
1467
- const a = Action.export(makeR(), '/admin')
1468
- const result = await a.getHandler()!({ values: {} })
1469
- const env = (result as { download?: { filename: string; contentType: string; body: string } }).download
1470
- assert.ok(env, 'expected download envelope')
1471
- assert.match(env!.filename, /^users-\d{4}-\d{2}-\d{2}\.csv$/)
1472
- assert.equal(env!.contentType, 'text/csv; charset=utf-8')
1473
- // Default label is Column.getLabel() which title-cases the key
1474
- // (`id` → `Id`, `email` → `Email`). Tests pin the title-cased shape
1475
- // so a Column.getLabel() change here is intentional.
1476
- assert.equal(env!.body.split('\r\n')[0], 'Id,Name,Email')
1477
- assert.ok(env!.body.includes('1,Ada,ada@example.com'))
1478
- assert.ok(env!.body.includes('2,Grace,grace@example.com'))
1479
- })
1480
-
1481
- it('honors explicit columns option (string + object form)', async () => {
1482
- const a = Action.export(makeR(), '/admin', {
1483
- columns: ['id', { key: 'name', label: 'Full name' }],
1484
- })
1485
- const result = await a.getHandler()!({ values: {} })
1486
- const body = (result as { download: { body: string } }).download.body
1487
- assert.equal(body.split('\r\n')[0], 'id,Full name')
1488
- assert.ok(body.includes('1,Ada'))
1489
- assert.ok(!body.includes('email'))
1490
- })
1491
-
1492
- it('column.format(value, record) maps the cell before write', async () => {
1493
- const a = Action.export(makeR(), '/admin', {
1494
- columns: [
1495
- { key: 'id' },
1496
- { key: 'name', format: (v) => `<<${v}>>` },
1497
- ],
1498
- })
1499
- const body = ((await a.getHandler()!({ values: {} })) as { download: { body: string } }).download.body
1500
- assert.ok(body.includes('1,<<Ada>>'))
1501
- })
1502
-
1503
- it('honors filename option as string and as function', async () => {
1504
- const a1 = Action.export(makeR(), '/admin', { filename: 'fixed.csv' })
1505
- const env1 = ((await a1.getHandler()!({ values: {} })) as { download: { filename: string } }).download
1506
- assert.equal(env1.filename, 'fixed.csv')
1507
-
1508
- const a2 = Action.export(makeR(), '/admin', {
1509
- filename: (ctx) => `dynamic-${(ctx.values as { v?: string }).v}.csv`,
1510
- })
1511
- const env2 = ((await a2.getHandler()!({ values: { v: '42' } })) as { download: { filename: string } }).download
1512
- assert.equal(env2.filename, 'dynamic-42.csv')
1513
- })
1514
-
1515
- it('format: "json" returns JSON body + JSON content-type', async () => {
1516
- const a = Action.export(makeR(), '/admin', { format: 'json' })
1517
- const env = ((await a.getHandler()!({ values: {} })) as { download: { body: string; contentType: string; filename: string } }).download
1518
- assert.equal(env.contentType, 'application/json')
1519
- assert.match(env.filename, /\.json$/)
1520
- const parsed = JSON.parse(env.body)
1521
- assert.equal(parsed.length, 2)
1522
- assert.equal(parsed[0].name, 'Ada')
1523
- })
1524
-
1525
- it('emits an error notification when maxRows is exceeded', async () => {
1526
- const big: Record<string, unknown>[] = Array.from({ length: 5 }, (_, i) => ({ id: i, name: `n${i}` }))
1527
- const a = Action.export(makeR({ rows: big, columns: ['id', 'name'] }), '/admin', { maxRows: 3, chunkSize: 2 })
1528
- const r = await a.getHandler()!({ values: {} })
1529
- const notify = (r as { notify?: { type: string; title: string } }).notify
1530
- assert.equal(notify?.type, 'error')
1531
- assert.match(notify!.title, /exceeded 3/)
1532
- })
1533
-
1534
- it('emits an error notification when the resource has no table()', async () => {
1535
- const R = { labelSingular: 'X', getSlug: () => 'x' }
1536
- const a = Action.export(R, '/admin')
1537
- const r = await a.getHandler()!({ values: {} })
1538
- const notify = (r as { notify?: { type: string; title: string } }).notify
1539
- assert.equal(notify?.type, 'error')
1540
- assert.match(notify!.title, /not configured/)
1541
- })
1542
-
1543
- it('emits an error notification when columns resolve to none', async () => {
1544
- const R = makeR({ columns: [] })
1545
- const a = Action.export(R, '/admin')
1546
- const r = await a.getHandler()!({ values: {} })
1547
- const notify = (r as { notify?: { type: string; title: string } }).notify
1548
- assert.equal(notify?.type, 'error')
1549
- assert.match(notify!.title, /no columns/)
1550
- })
1551
-
1552
- it('visibility delegates to R.canViewAny — denies hide the action', async () => {
1553
- const a = Action.export(makeR({ canViewAny: async () => false }), '/admin')
1554
- const ev = await a.evaluate({ user: { id: 'u' } })
1555
- assert.equal(ev.visible, false)
1556
- })
1557
-
1558
- it('visibility allows when canViewAny is unset (predicate defaults to true)', async () => {
1559
- const a = Action.export(makeR(), '/admin')
1560
- const ev = await a.evaluate({ user: { id: 'u' } })
1561
- assert.equal(ev.visible, true)
1562
- })
1563
- })
1564
-
1565
- describe('Action.bulkExport factory', () => {
1566
- function makeR(canViewAny?: (user: unknown) => boolean | Promise<boolean>) {
1567
- return {
1568
- labelSingular: 'User',
1569
- label: 'Users',
1570
- getSlug: () => 'users',
1571
- ...(canViewAny ? { canViewAny } : {}),
1572
- }
1573
- }
1574
-
1575
- it('exports ctx.records with default columns from the records themselves', async () => {
1576
- const a = Action.bulkExport(makeR(), '/admin', {
1577
- columns: ['id', 'name'],
1578
- })
1579
- const r = await a.getHandler()!({
1580
- values: {},
1581
- records: [{ id: 1, name: 'Ada' }, { id: 2, name: 'Grace' }],
1582
- })
1583
- const env = (r as { download: { filename: string; body: string; contentType: string } }).download
1584
- assert.equal(env.contentType, 'text/csv; charset=utf-8')
1585
- assert.equal(env.body.split('\r\n')[0], 'id,name')
1586
- assert.ok(env.body.includes('1,Ada'))
1587
- assert.ok(env.body.includes('2,Grace'))
1588
- })
1589
-
1590
- it('marks placement as bulk', () => {
1591
- const a = Action.bulkExport(makeR(), '/admin', { columns: ['id'] })
1592
- assert.equal(a.toMeta().placement, 'bulk')
1593
- })
1594
-
1595
- it('exceeds maxRows → error notification', async () => {
1596
- const a = Action.bulkExport(makeR(), '/admin', {
1597
- columns: ['id'],
1598
- maxRows: 1,
1599
- })
1600
- const r = await a.getHandler()!({
1601
- values: {},
1602
- records: [{ id: 1 }, { id: 2 }],
1603
- })
1604
- const notify = (r as { notify?: { type: string; title: string } }).notify
1605
- assert.equal(notify?.type, 'error')
1606
- assert.match(notify!.title, /exceeded 1/)
1607
- })
1608
-
1609
- it('format: "json" works for bulk too', async () => {
1610
- const a = Action.bulkExport(makeR(), '/admin', { columns: ['id'], format: 'json' })
1611
- const r = await a.getHandler()!({
1612
- values: {},
1613
- records: [{ id: 1 }],
1614
- })
1615
- const env = (r as { download: { body: string } }).download
1616
- assert.deepEqual(JSON.parse(env.body), [{ id: 1 }])
1617
- })
1618
-
1619
- it('honors empty records (still emits a header-only CSV)', async () => {
1620
- const a = Action.bulkExport(makeR(), '/admin', { columns: ['id', 'name'] })
1621
- const r = await a.getHandler()!({ values: {}, records: [] })
1622
- const env = (r as { download: { body: string } }).download
1623
- assert.equal(env.body, 'id,name')
1624
- })
1625
- })
1626
-
1627
- describe('Action.import factory', () => {
1628
- /** Build a stand-in `R.model` that records calls and lets tests
1629
- * control whether `query().where().paginate()` finds an existing
1630
- * row. */
1631
- function makeModel(over: { existingByKey?: Record<string, Record<string, unknown>> } = {}) {
1632
- const created: Array<Record<string, unknown>> = []
1633
- const updated: Array<{ id: string; payload: Record<string, unknown> }> = []
1634
- return {
1635
- created,
1636
- updated,
1637
- async create(payload: Record<string, unknown>) {
1638
- created.push(payload)
1639
- return { id: String(created.length), ...payload }
1640
- },
1641
- async update(id: string, payload: Record<string, unknown>) {
1642
- updated.push({ id, payload })
1643
- return { id, ...payload }
1644
- },
1645
- query() {
1646
- return {
1647
- where(col: string, val: unknown) {
1648
- return {
1649
- async paginate() {
1650
- const lookup = over.existingByKey ?? {}
1651
- const key = `${col}=${String(val)}`
1652
- const found = lookup[key]
1653
- return { data: found ? [found] : [] }
1654
- },
1655
- }
1656
- },
1657
- }
1658
- },
1659
- }
1660
- }
1661
-
1662
- function makeR(over: {
1663
- canCreate?: (user: unknown) => boolean | Promise<boolean>
1664
- model?: ReturnType<typeof makeModel>
1665
- } = {}) {
1666
- return {
1667
- labelSingular: 'User',
1668
- label: 'Users',
1669
- getSlug: () => 'users',
1670
- ...(over.canCreate ? { canCreate: over.canCreate } : {}),
1671
- model: over.model ?? makeModel(),
1672
- }
1673
- }
1674
-
1675
- /** Stub `globalThis.fetch` to return a static body, restore after the test. */
1676
- function withFetch(text: string, fn: () => Promise<void>): Promise<void> {
1677
- const original = globalThis.fetch
1678
- globalThis.fetch = (async () => ({
1679
- ok: true,
1680
- text: async () => text,
1681
- })) as unknown as typeof fetch
1682
- return fn().finally(() => { globalThis.fetch = original })
1683
- }
1684
-
1685
- it('auto-builds the modal schema with a FileUpload child by default', () => {
1686
- const a = Action.import(makeR(), '/admin')
1687
- assert.equal(a.toMeta().confirm, undefined, 'import is form-modal, not a confirm prompt')
1688
- const schema = a.getSchema()
1689
- assert.equal(schema.length, 1, 'default modal: just the FileUpload')
1690
- assert.equal((schema[0] as { name?: unknown }).name, 'file')
1691
- })
1692
-
1693
- it('appends a Mode select when upsertBy is set', async () => {
1694
- const a = Action.import(makeR(), '/admin', { upsertBy: 'email' })
1695
- // Modal heading preserved
1696
- const modal = (a.toMeta() as unknown as { modal?: { heading?: string } }).modal
1697
- assert.equal(modal?.heading, 'Import Users')
1698
- // Schema is set via .schema() — read it via the same mechanism dispatchAction uses.
1699
- const schema = a.getSchema()
1700
- const names = schema.map(el => (el as { name?: unknown }).name)
1701
- assert.deepEqual(names, ['file', 'mode'])
1702
- })
1703
-
1704
- it('CSV happy-path → all rows create through R.model.create', async () => {
1705
- const model = makeModel()
1706
- const R = makeR({ model })
1707
- const a = Action.import(R, '/admin')
1708
- await withFetch('name,email\r\nAda,ada@x\r\nGrace,grace@x\r\n', async () => {
1709
- const r = await a.getHandler()!({
1710
- values: { file: 'http://localhost/uploads/1.csv' },
1711
- })
1712
- assert.equal(model.created.length, 2)
1713
- assert.deepEqual(model.created[0], { name: 'Ada', email: 'ada@x' })
1714
- assert.deepEqual(model.created[1], { name: 'Grace', email: 'grace@x' })
1715
- const notify = (r as { notify?: { type: string; title: string } }).notify
1716
- assert.equal(notify?.type, 'success')
1717
- assert.match(notify!.title, /2 created/)
1718
- })
1719
- })
1720
-
1721
- it('upsertBy + matching row → R.model.update; non-matching → R.model.create', async () => {
1722
- const model = makeModel({
1723
- existingByKey: { 'email=ada@x': { id: '7', name: 'Old', email: 'ada@x' } },
1724
- })
1725
- const R = makeR({ model })
1726
- const a = Action.import(R, '/admin', { upsertBy: 'email' })
1727
- await withFetch('name,email\r\nAda,ada@x\r\nGrace,grace@x\r\n', async () => {
1728
- const r = await a.getHandler()!({
1729
- values: { file: 'http://localhost/uploads/1.csv', mode: 'upsert' },
1730
- })
1731
- assert.equal(model.updated.length, 1, 'one match → one update')
1732
- assert.equal(model.updated[0]!.id, '7')
1733
- assert.equal(model.created.length, 1, 'non-match → create')
1734
- const notify = (r as { notify?: { title: string } }).notify
1735
- assert.match(notify!.title, /1 created.*1 updated/)
1736
- })
1737
- })
1738
-
1739
- it('upsertBy with mode=create → never updates, always creates', async () => {
1740
- const model = makeModel({
1741
- existingByKey: { 'email=ada@x': { id: '7', name: 'Old', email: 'ada@x' } },
1742
- })
1743
- const R = makeR({ model })
1744
- const a = Action.import(R, '/admin', { upsertBy: 'email' })
1745
- await withFetch('name,email\r\nAda,ada@x\r\n', async () => {
1746
- await a.getHandler()!({
1747
- values: { file: 'http://localhost/uploads/1.csv', mode: 'create' },
1748
- })
1749
- assert.equal(model.updated.length, 0)
1750
- assert.equal(model.created.length, 1)
1751
- })
1752
- })
1753
-
1754
- it('per-row validate() returning a string skips the row + accumulates as error', async () => {
1755
- const model = makeModel()
1756
- const R = makeR({ model })
1757
- const a = Action.import(R, '/admin', {
1758
- validate: (row) => (row['name'] === 'Ada' ? 'ada is reserved' : null),
1759
- })
1760
- await withFetch('name,email\r\nAda,ada@x\r\nGrace,grace@x\r\n', async () => {
1761
- const r = await a.getHandler()!({
1762
- values: { file: 'http://localhost/uploads/1.csv' },
1763
- })
1764
- assert.equal(model.created.length, 1, 'only Grace makes it through')
1765
- const notify = (r as { notify?: { type: string; title: string; body?: string } }).notify
1766
- assert.equal(notify?.type, 'warning')
1767
- assert.match(notify!.title, /1 created/)
1768
- assert.match(notify!.title, /1 skipped/)
1769
- assert.match(notify!.body ?? '', /Row 1: ada is reserved/)
1770
- })
1771
- })
1772
-
1773
- it('thrown error during create → row counted as skipped + surfaced in notification body', async () => {
1774
- const failing = {
1775
- created: [] as Record<string, unknown>[],
1776
- async create() { throw new Error('db down') },
1777
- query() { return { where() { return { async paginate() { return { data: [] } } } } } },
1778
- }
1779
- const R = makeR({ model: failing as unknown as ReturnType<typeof makeModel> })
1780
- const a = Action.import(R, '/admin')
1781
- await withFetch('name\r\nAda\r\n', async () => {
1782
- const r = await a.getHandler()!({
1783
- values: { file: 'http://localhost/uploads/1.csv' },
1784
- })
1785
- const notify = (r as { notify?: { type: string; body?: string } }).notify
1786
- assert.equal(notify?.type, 'warning')
1787
- assert.match(notify!.body ?? '', /Row 1: db down/)
1788
- })
1789
- })
1790
-
1791
- it('opts.columns remap CSV headers to model attribute keys', async () => {
1792
- const model = makeModel()
1793
- const R = makeR({ model })
1794
- const a = Action.import(R, '/admin', {
1795
- columns: { 'Full Name': 'name', 'Email Address': 'email' },
1796
- })
1797
- await withFetch('Full Name,Email Address\r\nAda,ada@x\r\n', async () => {
1798
- await a.getHandler()!({
1799
- values: { file: 'http://localhost/uploads/1.csv' },
1800
- })
1801
- assert.deepEqual(model.created[0], { name: 'Ada', email: 'ada@x' })
1802
- })
1803
- })
1804
-
1805
- it('beforeCreate hook can mutate the row payload', async () => {
1806
- const model = makeModel()
1807
- const R = makeR({ model })
1808
- const a = Action.import(R, '/admin', {
1809
- beforeCreate: (row) => ({ ...row, source: 'csv-import' }),
1810
- })
1811
- await withFetch('name\r\nAda\r\n', async () => {
1812
- await a.getHandler()!({
1813
- values: { file: 'http://localhost/uploads/1.csv' },
1814
- })
1815
- assert.deepEqual(model.created[0], { name: 'Ada', source: 'csv-import' })
1816
- })
1817
- })
1818
-
1819
- it('emits an error notification when no file is uploaded', async () => {
1820
- const a = Action.import(makeR(), '/admin')
1821
- const r = await a.getHandler()!({ values: {} })
1822
- const notify = (r as { notify?: { type: string; title: string } }).notify
1823
- assert.equal(notify?.type, 'error')
1824
- assert.match(notify!.title, /No file uploaded/)
1825
- })
1826
-
1827
- it('emits an error notification when R.model.create is missing', async () => {
1828
- const R = { labelSingular: 'X', getSlug: () => 'x', model: {} }
1829
- const a = Action.import(R, '/admin')
1830
- const r = await a.getHandler()!({ values: { file: 'http://localhost/uploads/1.csv' } })
1831
- const notify = (r as { notify?: { type: string; title: string } }).notify
1832
- assert.equal(notify?.type, 'error')
1833
- assert.match(notify!.title, /no model.create/)
1834
- })
1835
-
1836
- it('emits an error notification when row count exceeds maxRows', async () => {
1837
- const model = makeModel()
1838
- const R = makeR({ model })
1839
- const a = Action.import(R, '/admin', { maxRows: 1 })
1840
- await withFetch('name\r\nA\r\nB\r\n', async () => {
1841
- const r = await a.getHandler()!({
1842
- values: { file: 'http://localhost/uploads/1.csv' },
1843
- })
1844
- const notify = (r as { notify?: { type: string; title: string } }).notify
1845
- assert.equal(notify?.type, 'error')
1846
- assert.match(notify!.title, /too large \(2 > 1\)/)
1847
- assert.equal(model.created.length, 0, 'no rows written when cap exceeded')
1848
- })
1849
- })
1850
-
1851
- it('JSON format imports parse arrays and single objects', async () => {
1852
- const model = makeModel()
1853
- const R = makeR({ model })
1854
- const a = Action.import(R, '/admin', { format: 'json' })
1855
- await withFetch('[{"name":"Ada"},{"name":"Grace"}]', async () => {
1856
- await a.getHandler()!({
1857
- values: { file: 'http://localhost/uploads/1.json' },
1858
- })
1859
- assert.equal(model.created.length, 2)
1860
- })
1861
- })
1862
-
1863
- it('format auto-detects from filename extension when not set', async () => {
1864
- const model = makeModel()
1865
- const R = makeR({ model })
1866
- const a = Action.import(R, '/admin')
1867
- await withFetch('{"name":"Ada"}', async () => {
1868
- await a.getHandler()!({
1869
- values: { file: 'http://localhost/uploads/x.json' },
1870
- })
1871
- assert.equal(model.created.length, 1)
1872
- assert.equal(model.created[0]!['name'], 'Ada')
1873
- })
1874
- })
1875
-
1876
- it('onComplete hook fires once after the import loop with the summary', async () => {
1877
- const seen: unknown[] = []
1878
- const a = Action.import(makeR(), '/admin', {
1879
- onComplete: (s) => { seen.push(s) },
1880
- })
1881
- await withFetch('name\r\nA\r\nB\r\n', async () => {
1882
- await a.getHandler()!({
1883
- values: { file: 'http://localhost/uploads/1.csv' },
1884
- })
1885
- })
1886
- assert.equal(seen.length, 1)
1887
- assert.equal((seen[0] as { created: number }).created, 2)
1888
- })
1889
-
1890
- it('visibility delegates to R.canCreate — denials hide the action', async () => {
1891
- const a = Action.import(makeR({ canCreate: async () => false }), '/admin')
1892
- const ev = await a.evaluate({ user: { id: 'u' } })
1893
- assert.equal(ev.visible, false)
1894
- })
1895
- })
1896
-
1897
- describe('Action.relationReplicate / relationBulkReplicate', () => {
1898
- // Parent model — exposes `static relations` so the FK descriptor
1899
- // resolves through `getParentRelationDescriptor`.
1900
- class UserModel {
1901
- id?: string
1902
- static primaryKey = 'id'
1903
- static relations = {
1904
- posts: { type: 'hasMany', foreignKey: 'userId', model: () => PostModel },
1905
- comments: { type: 'morphMany', morphName: 'commentable', model: () => CommentModel },
1906
- }
1907
- constructor(over: Partial<UserModel> = {}) { Object.assign(this, over) }
1908
- }
1909
-
1910
- // Child models. Test stubs that record every `create()` call's input
1911
- // payload (NOT the returned row) so assertions on stripping land on
1912
- // what the factory actually passed in.
1913
- class PostModel {
1914
- static primaryKey = 'id'
1915
- static created: Array<Record<string, unknown>> = []
1916
- static throwOnNextCreate = false
1917
- static async create(data: Record<string, unknown>) {
1918
- if (PostModel.throwOnNextCreate) {
1919
- PostModel.throwOnNextCreate = false
1920
- throw new Error('boom')
1921
- }
1922
- PostModel.created.push({ ...data })
1923
- return { id: String(PostModel.created.length), ...data }
1924
- }
1925
- }
1926
-
1927
- class CommentModel {
1928
- static primaryKey = 'id'
1929
- static created: Array<Record<string, unknown>> = []
1930
- static async create(data: Record<string, unknown>) {
1931
- CommentModel.created.push({ ...data })
1932
- return { id: String(CommentModel.created.length), ...data }
1933
- }
1934
- }
1935
-
1936
- class Posts extends RelationManager {
1937
- static override relationship = 'posts'
1938
- static override label = 'Posts'
1939
- static override labelSingular = 'Post'
1940
- }
1941
-
1942
- class Comments extends RelationManager {
1943
- static override relationship = 'comments'
1944
- static override label = 'Comments'
1945
- static override labelSingular = 'Comment'
1946
- }
1947
-
1948
- function freshHasManyCtx(): RelationManagerContext {
1949
- PostModel.created = []
1950
- PostModel.throwOnNextCreate = false
1951
- const Related = {
1952
- labelSingular: 'Post',
1953
- label: 'Posts',
1954
- getSlug: () => 'posts',
1955
- model: PostModel,
1956
- deletedAtColumn: 'deletedAt',
1957
- } as unknown as RelationManagerContext['related']
1958
- return {
1959
- basePath: '/admin',
1960
- parentSlug: 'users',
1961
- parentId: '42',
1962
- relationship: 'posts',
1963
- parentRecord: new UserModel({ id: '42' }),
1964
- related: Related,
1965
- mode: 'hasMany',
1966
- }
1967
- }
1968
-
1969
- function freshMorphManyCtx(): RelationManagerContext {
1970
- CommentModel.created = []
1971
- const Related = {
1972
- labelSingular: 'Comment',
1973
- label: 'Comments',
1974
- getSlug: () => 'comments',
1975
- model: CommentModel,
1976
- deletedAtColumn: 'deletedAt',
1977
- } as unknown as RelationManagerContext['related']
1978
- return {
1979
- basePath: '/admin',
1980
- parentSlug: 'users',
1981
- parentId: '42',
1982
- relationship: 'comments',
1983
- parentRecord: new UserModel({ id: '42' }),
1984
- related: Related,
1985
- mode: 'morphMany',
1986
- }
1987
- }
1988
-
1989
- describe('toMeta + placement', () => {
1990
- it('relationReplicate is a row-placement handler action', () => {
1991
- const meta = Action.relationReplicate(Posts, freshHasManyCtx()).toMeta()
1992
- assert.equal(meta.placement, 'row')
1993
- assert.equal(meta.label, 'Replicate')
1994
- assert.equal(meta.method, undefined) // handler-style, no form-post
1995
- assert.equal(meta.href, undefined)
1996
- })
1997
-
1998
- it('relationBulkReplicate is a bulk-placement handler action with confirm', () => {
1999
- const meta = Action.relationBulkReplicate(Posts, freshHasManyCtx()).toMeta()
2000
- assert.equal(meta.placement, 'bulk')
2001
- assert.match(meta.confirm?.message ?? '', /Replicate the selected/)
2002
- })
2003
- })
2004
-
2005
- describe('hasMany — clone preserves FK by force-pin', () => {
2006
- it('strips PK + soft-delete + excludeAttributes and re-pins userId from the parent', async () => {
2007
- const ctx = freshHasManyCtx()
2008
- const handler = Action.relationReplicate(Posts, ctx, undefined, {
2009
- excludeAttributes: ['slug'],
2010
- }).getHandler()!
2011
- await handler({
2012
- record: { id: '7', title: 'Hello', body: 'World', slug: 'hello', userId: '99', deletedAt: null },
2013
- user: null,
2014
- })
2015
- assert.equal(PostModel.created.length, 1)
2016
- const replica = PostModel.created[0]!
2017
- assert.equal(replica['id'], undefined, 'PK stripped')
2018
- assert.equal(replica['deletedAt'], undefined, 'soft-delete column stripped')
2019
- assert.equal(replica['slug'], undefined, 'excludeAttributes honored')
2020
- assert.equal(replica['title'], 'Hello')
2021
- assert.equal(replica['body'], 'World')
2022
- // The source row carried `userId: '99'` (a tampered value or
2023
- // a row from a different parent's children). The factory MUST
2024
- // overwrite it with the manager's parentId so the replica
2025
- // stays attached to the right parent.
2026
- assert.equal(replica['userId'], '42', 'FK re-pinned to ctx.parentId')
2027
- })
2028
-
2029
- it('beforeReplicaSaved runs after the FK pin and can mutate non-FK fields', async () => {
2030
- const ctx = freshHasManyCtx()
2031
- const handler = Action.relationReplicate(Posts, ctx, undefined, {
2032
- beforeReplicaSaved: (replica) => ({ ...replica, title: `Copy of ${replica['title']}` }),
2033
- }).getHandler()!
2034
- await handler({ record: { id: '1', title: 'Hello', userId: '42' }, user: null })
2035
- assert.equal(PostModel.created[0]!['title'], 'Copy of Hello')
2036
- assert.equal(PostModel.created[0]!['userId'], '42')
2037
- })
2038
-
2039
- it('returns a success notify with the manager singular label', async () => {
2040
- const ctx = freshHasManyCtx()
2041
- const handler = Action.relationReplicate(Posts, ctx).getHandler()!
2042
- const result = await handler({ record: { id: '1', title: 'Hello' }, user: null })
2043
- const r = result as { notify: { title: string; type: string } }
2044
- assert.match(r.notify.title, /^Post replicated$/)
2045
- assert.equal(r.notify.type, 'success')
2046
- })
2047
-
2048
- it('catches Related.model.create errors and surfaces an error notify', async () => {
2049
- const ctx = freshHasManyCtx()
2050
- PostModel.throwOnNextCreate = true
2051
- const handler = Action.relationReplicate(Posts, ctx).getHandler()!
2052
- const result = await handler({ record: { id: '1', title: 'X' }, user: null })
2053
- const notify = (result as { notify: { title: string; type: string } }).notify
2054
- assert.match(notify.title, /^Replicate failed: boom$/)
2055
- assert.equal(notify.type, 'error')
2056
- })
2057
- })
2058
-
2059
- describe('morphMany — clone re-stamps the morph payload', () => {
2060
- it('overwrites <morphName>Id + <morphName>Type from the parent record', async () => {
2061
- const ctx = freshMorphManyCtx()
2062
- const handler = Action.relationReplicate(Comments, ctx).getHandler()!
2063
- await handler({
2064
- record: { id: '7', body: 'Nice!', commentableId: 'WRONG', commentableType: 'WRONG' },
2065
- user: null,
2066
- })
2067
- const replica = CommentModel.created[0]!
2068
- assert.equal(replica['id'], undefined, 'PK stripped')
2069
- assert.equal(replica['body'], 'Nice!')
2070
- assert.equal(replica['commentableId'], '42', 'morph id re-stamped from parent')
2071
- assert.equal(replica['commentableType'], 'UserModel', 'morph type re-stamped from parent constructor')
2072
- })
2073
- })
2074
-
2075
- describe('visibility', () => {
2076
- it('hidden under belongsToMany mode', async () => {
2077
- const ctx = { ...freshHasManyCtx(), mode: 'belongsToMany' as const }
2078
- const ev = await Action.relationReplicate(Posts, ctx).evaluate({})
2079
- assert.equal(ev.visible, false)
2080
- })
2081
-
2082
- it('hidden under morphTo mode (no single owner to pin to)', async () => {
2083
- const ctx = { ...freshHasManyCtx(), mode: 'morphTo' as const }
2084
- const ev = await Action.relationReplicate(Posts, ctx).evaluate({})
2085
- assert.equal(ev.visible, false)
2086
- })
2087
-
2088
- it('delegates to manager.canCreate when overridden', async () => {
2089
- class Forbidden extends RelationManager {
2090
- static override relationship = 'posts'
2091
- static override async canCreate(): Promise<boolean> { return false }
2092
- }
2093
- const ev = await Action.relationReplicate(Forbidden, freshHasManyCtx()).evaluate({})
2094
- assert.equal(ev.visible, false)
2095
- })
2096
-
2097
- it('falls through to related Resource canCreate when manager unset', async () => {
2098
- const ctx = freshHasManyCtx()
2099
- ;(ctx.related as unknown as { canCreate: () => Promise<boolean> }).canCreate = async () => false
2100
- const ev = await Action.relationReplicate(Posts, ctx).evaluate({})
2101
- assert.equal(ev.visible, false)
2102
- })
2103
-
2104
- it('allows when neither manager nor related Resource opts in', async () => {
2105
- const ev = await Action.relationReplicate(Posts, freshHasManyCtx()).evaluate({})
2106
- assert.equal(ev.visible, true)
2107
- })
2108
- })
2109
-
2110
- describe('error paths', () => {
2111
- it('returns an error notify when ctx.record is missing', async () => {
2112
- const handler = Action.relationReplicate(Posts, freshHasManyCtx()).getHandler()!
2113
- const result = await handler({ user: null })
2114
- const notify = (result as { notify: { title: string; type: string } }).notify
2115
- assert.match(notify.title, /source record missing/i)
2116
- })
2117
-
2118
- it('returns an error notify when Related.model is missing', async () => {
2119
- const ctx: RelationManagerContext = { ...freshHasManyCtx(), related: { } as unknown as RelationManagerContext['related'] }
2120
- const handler = Action.relationReplicate(Posts, ctx).getHandler()!
2121
- const result = await handler({ record: { id: '1' }, user: null })
2122
- const notify = (result as { notify: { title: string; type: string } }).notify
2123
- assert.match(notify.title, /not configured/i)
2124
- })
2125
- })
2126
-
2127
- describe('bulk', () => {
2128
- it('iterates ctx.records — one create per source, FK re-pinned each time', async () => {
2129
- const ctx = freshHasManyCtx()
2130
- const handler = Action.relationBulkReplicate(Posts, ctx).getHandler()!
2131
- const result = await handler({
2132
- records: [
2133
- { id: '1', title: 'A', userId: 'wrong' },
2134
- { id: '2', title: 'B', userId: 'wrong' },
2135
- { id: '3', title: 'C' },
2136
- ],
2137
- user: null,
2138
- })
2139
- assert.equal(PostModel.created.length, 3)
2140
- assert.deepEqual(PostModel.created.map(r => r['title']), ['A', 'B', 'C'])
2141
- assert.deepEqual(PostModel.created.map(r => r['userId']), ['42', '42', '42'])
2142
- assert.match((result as { notify: { title: string } }).notify.title, /^3 posts replicated$/)
2143
- })
2144
-
2145
- it('skips rows where create throws and reports only successful count', async () => {
2146
- const ctx = freshHasManyCtx()
2147
- // Fail the second create.
2148
- let calls = 0
2149
- const orig = PostModel.create.bind(PostModel)
2150
- PostModel.create = async (d) => {
2151
- calls++
2152
- if (calls === 2) throw new Error('boom')
2153
- return orig(d)
2154
- }
2155
- try {
2156
- const handler = Action.relationBulkReplicate(Posts, ctx).getHandler()!
2157
- const result = await handler({
2158
- records: [{ id: '1' }, { id: '2' }, { id: '3' }],
2159
- user: null,
2160
- })
2161
- assert.match((result as { notify: { title: string } }).notify.title, /^2 posts replicated$/)
2162
- } finally {
2163
- PostModel.create = orig
2164
- }
2165
- })
2166
-
2167
- it('skips rows where per-row policy denies', async () => {
2168
- const ctx = freshHasManyCtx()
2169
- let i = 0
2170
- class GatedPosts extends RelationManager {
2171
- static override relationship = 'posts'
2172
- static override label = 'Posts'
2173
- static override labelSingular = 'Post'
2174
- static override async canCreate(): Promise<boolean> {
2175
- i++
2176
- return i !== 2
2177
- }
2178
- }
2179
- const handler = Action.relationBulkReplicate(GatedPosts, ctx).getHandler()!
2180
- const result = await handler({
2181
- records: [{ id: '1' }, { id: '2' }, { id: '3' }],
2182
- user: null,
2183
- })
2184
- assert.equal(PostModel.created.length, 2)
2185
- assert.match((result as { notify: { title: string } }).notify.title, /^2 posts replicated$/)
2186
- })
2187
-
2188
- it('singularises the count copy when exactly one row succeeds', async () => {
2189
- const ctx = freshHasManyCtx()
2190
- const handler = Action.relationBulkReplicate(Posts, ctx).getHandler()!
2191
- const result = await handler({
2192
- records: [{ id: '1', title: 'Solo' }],
2193
- user: null,
2194
- })
2195
- assert.match((result as { notify: { title: string } }).notify.title, /^1 post replicated$/)
2196
- })
2197
-
2198
- it('returns an error notify when Related.model.create is missing', async () => {
2199
- const ctx: RelationManagerContext = { ...freshHasManyCtx(), related: { } as unknown as RelationManagerContext['related'] }
2200
- const handler = Action.relationBulkReplicate(Posts, ctx).getHandler()!
2201
- const result = await handler({ records: [{ id: '1' }], user: null })
2202
- const notify = (result as { notify: { title: string; type: string } }).notify
2203
- assert.match(notify.title, /not configured/i)
2204
- assert.equal(notify.type, 'error')
2205
- })
2206
-
2207
- it('hidden under M2M / morphTo mode', async () => {
2208
- const m2m = await Action.relationBulkReplicate(Posts, { ...freshHasManyCtx(), mode: 'belongsToMany' }).evaluate({})
2209
- assert.equal(m2m.visible, false)
2210
- const mt = await Action.relationBulkReplicate(Posts, { ...freshHasManyCtx(), mode: 'morphTo' }).evaluate({})
2211
- assert.equal(mt.visible, false)
2212
- })
2213
- })
2214
-
2215
- describe('opts.getCreatedNotificationTitle / getRedirectUrl overrides', () => {
2216
- it('relationReplicate honors getCreatedNotificationTitle with replica + source', async () => {
2217
- const ctx = freshHasManyCtx()
2218
- let seenReplica: unknown
2219
- let seenSource: unknown
2220
- const handler = Action.relationReplicate(Posts, ctx, undefined, {
2221
- getCreatedNotificationTitle: ({ replica, source }) => {
2222
- seenReplica = replica
2223
- seenSource = source
2224
- return `Cloned post for user ${(replica as { userId: string }).userId}`
2225
- },
2226
- }).getHandler()!
2227
- const result = await handler({ record: { id: '7', title: 'A' }, user: null })
2228
- assert.equal((result as { notify: { title: string } }).notify.title, 'Cloned post for user 42')
2229
- assert.deepEqual(seenSource, { id: '7', title: 'A' })
2230
- // Replica is the model.create result (id stamped by the stub).
2231
- assert.equal((seenReplica as { userId: string })?.userId, '42')
2232
- })
2233
-
2234
- it('relationReplicate honors getRedirectUrl — emits result.redirect', async () => {
2235
- const ctx = freshHasManyCtx()
2236
- const handler = Action.relationReplicate(Posts, ctx, undefined, {
2237
- getRedirectUrl: ({ replica }) => `/admin/users/42/posts/${(replica as { id: string }).id}/preview`,
2238
- }).getHandler()!
2239
- const result = await handler({ record: { id: '7', title: 'A' }, user: null })
2240
- assert.equal((result as { redirect: string }).redirect, '/admin/users/42/posts/1/preview')
2241
- })
2242
-
2243
- it('relationReplicate without getRedirectUrl emits no redirect (route fallback)', async () => {
2244
- const ctx = freshHasManyCtx()
2245
- const handler = Action.relationReplicate(Posts, ctx).getHandler()!
2246
- const result = await handler({ record: { id: '7', title: 'A' }, user: null })
2247
- // Default behavior: handler doesn't set redirect; the route layer
2248
- // owns the fallback to the manager list URL. Asserting redirect
2249
- // is absent (not empty string).
2250
- assert.equal((result as { redirect?: string }).redirect, undefined)
2251
- })
2252
-
2253
- it('relationReplicate getCreatedNotificationTitle returning undefined falls back', async () => {
2254
- const ctx = freshHasManyCtx()
2255
- const handler = Action.relationReplicate(Posts, ctx, undefined, {
2256
- getCreatedNotificationTitle: () => undefined,
2257
- }).getHandler()!
2258
- const result = await handler({ record: { id: '7', title: 'A' }, user: null })
2259
- assert.match((result as { notify: { title: string } }).notify.title, /^Post replicated$/)
2260
- })
2261
-
2262
- it('relationBulkReplicate honors getCreatedNotificationTitle with count + records', async () => {
2263
- const ctx = freshHasManyCtx()
2264
- let seenCount: unknown
2265
- const handler = Action.relationBulkReplicate(Posts, ctx, {
2266
- getCreatedNotificationTitle: ({ count, records }) => {
2267
- seenCount = count
2268
- return `Duplicated ${count} of ${(records as unknown[]).length} into user 42`
2269
- },
2270
- }).getHandler()!
2271
- const result = await handler({
2272
- records: [{ id: '1' }, { id: '2' }],
2273
- user: null,
2274
- })
2275
- assert.equal((result as { notify: { title: string } }).notify.title, 'Duplicated 2 of 2 into user 42')
2276
- assert.equal(seenCount, 2)
2277
- })
2278
-
2279
- it('overrides may be async', async () => {
2280
- const ctx = freshHasManyCtx()
2281
- const handler = Action.relationReplicate(Posts, ctx, undefined, {
2282
- getCreatedNotificationTitle: async () => 'async title',
2283
- getRedirectUrl: async () => '/admin/elsewhere',
2284
- }).getHandler()!
2285
- const result = await handler({ record: { id: '1', title: 'X' }, user: null }) as {
2286
- redirect: string
2287
- notify: { title: string }
2288
- }
2289
- assert.equal(result.notify.title, 'async title')
2290
- assert.equal(result.redirect, '/admin/elsewhere')
2291
- })
2292
- })
2293
- })
2294
-
2295
- describe('Action.markAsRead factory', () => {
2296
- it('builds a method-POST action targeting the read endpoint', () => {
2297
- const meta = Action.markAsRead('/admin', 'n-7').toMeta()
2298
- assert.equal(meta.method, 'post')
2299
- assert.equal(meta.action, '/admin/_notifications/n-7/read')
2300
- assert.equal(meta.label, 'Mark as read')
2301
- assert.equal(meta.name, 'markAsRead')
2302
- })
2303
-
2304
- it('defaults the id to :id template for row context', () => {
2305
- const meta = Action.markAsRead('/admin').toMeta()
2306
- assert.equal(meta.action, '/admin/_notifications/:id/read')
2307
- })
2308
-
2309
- it('honors a non-default base path', () => {
2310
- const meta = Action.markAsRead('/dashboard', 'abc').toMeta()
2311
- assert.equal(meta.action, '/dashboard/_notifications/abc/read')
2312
- })
2313
-
2314
- it('has no built-in visibility — always shows by default', async () => {
2315
- const a = Action.markAsRead('/admin', 'n-7')
2316
- const r = await a.evaluate({ record: { id: 'n-7' } })
2317
- assert.equal(r.visible, true)
2318
- })
2319
-
2320
- it('composes with .visible(...) to hide already-read rows', async () => {
2321
- const a = Action.markAsRead('/admin', ':id')
2322
- .visible(({ record }) => !(record as { readAt?: string | null })?.readAt)
2323
- const unread = await a.evaluate({ record: { id: 'n-7', readAt: null } })
2324
- const read = await a.evaluate({ record: { id: 'n-8', readAt: '2026-05-07T12:00:00Z' } })
2325
- assert.equal(unread.visible, true)
2326
- assert.equal(read.visible, false)
2327
- })
2328
- })
2329
-
2330
- describe('Action modal chrome extras (audit gap #2)', () => {
2331
- // Helper: pull the `modal` slot off a built meta. Cast through unknown
2332
- // because `ActionMeta.modal` is sparse — the tests want a concrete map.
2333
- type ModalSlot = {
2334
- closeByClickingAway?: boolean
2335
- closeByEscaping?: boolean
2336
- stickyHeader?: boolean
2337
- stickyFooter?: boolean
2338
- autofocus?: boolean
2339
- alignment?: 'start'|'center'|'end'
2340
- iconColor?: string
2341
- closeButton?: boolean
2342
- submitLabel?: string
2343
- cancelLabel?: string
2344
- }
2345
- const modal = (a: Action): ModalSlot | undefined =>
2346
- (a.toMeta() as unknown as { modal?: ModalSlot }).modal
2347
-
2348
- it('emits no modal slot when no modal setter ran', () => {
2349
- assert.equal(modal(Action.make('save')), undefined)
2350
- })
2351
-
2352
- describe('closeModalByClickingAway / closeModalByEscaping', () => {
2353
- it('default (true) does NOT emit either flag', () => {
2354
- // Force a modal slot via .modalHeading so the rest of the meta builds,
2355
- // but don't touch the close-* flags. Defaults must round-trip as
2356
- // omissions (sparse meta) so existing modals stay byte-identical.
2357
- const a = Action.make('a').modalHeading('Hello')
2358
- const m = modal(a)
2359
- assert.equal(m?.closeByClickingAway, undefined)
2360
- assert.equal(m?.closeByEscaping, undefined)
2361
- })
2362
-
2363
- it('closeModalByClickingAway() with no arg disables (Filament shape)', () => {
2364
- const m = modal(Action.make('a').closeModalByClickingAway())
2365
- assert.equal(m?.closeByClickingAway, false)
2366
- })
2367
-
2368
- it('closeModalByEscaping(false) disables; closeModalByEscaping(true) re-arms', () => {
2369
- assert.equal(modal(Action.make('a').closeModalByEscaping(false))?.closeByEscaping, false)
2370
- // Re-arming back to default must not emit (sparse).
2371
- assert.equal(modal(Action.make('a').closeModalByEscaping(false).closeModalByEscaping(true))?.closeByEscaping, undefined)
2372
- })
2373
- })
2374
-
2375
- describe('sticky chrome', () => {
2376
- it('stickyModalHeader() arms by default; stickyModalHeader(false) disarms', () => {
2377
- assert.equal(modal(Action.make('a').stickyModalHeader())?.stickyHeader, true)
2378
- assert.equal(modal(Action.make('a').stickyModalHeader(false))?.stickyHeader, undefined)
2379
- })
2380
-
2381
- it('stickyModalFooter() round-trips independently', () => {
2382
- const m = modal(Action.make('a').stickyModalFooter())
2383
- assert.equal(m?.stickyHeader, undefined)
2384
- assert.equal(m?.stickyFooter, true)
2385
- })
2386
- })
2387
-
2388
- describe('modalAutofocus', () => {
2389
- it('omits when not set', () => {
2390
- assert.equal(modal(Action.make('a').modalHeading('h'))?.autofocus, undefined)
2391
- })
2392
-
2393
- it('emits true when called with no arg', () => {
2394
- assert.equal(modal(Action.make('a').modalAutofocus())?.autofocus, true)
2395
- })
2396
-
2397
- it('emits false when explicitly disarmed', () => {
2398
- assert.equal(modal(Action.make('a').modalAutofocus(false))?.autofocus, false)
2399
- })
2400
- })
2401
-
2402
- describe('modalAlignment + modalIconColor', () => {
2403
- it('emits the alignment string', () => {
2404
- assert.equal(modal(Action.make('a').modalAlignment('start'))?.alignment, 'start')
2405
- assert.equal(modal(Action.make('a').modalAlignment('end'))?.alignment, 'end')
2406
- })
2407
-
2408
- it('emits the icon color string', () => {
2409
- assert.equal(modal(Action.make('a').modalIconColor('warning'))?.iconColor, 'warning')
2410
- })
2411
- })
2412
-
2413
- describe('modalCloseButton', () => {
2414
- it('default (off) emits no flag', () => {
2415
- assert.equal(modal(Action.make('a').modalHeading('h'))?.closeButton, undefined)
2416
- })
2417
-
2418
- it('modalCloseButton() arms; modalCloseButton(false) disarms', () => {
2419
- assert.equal(modal(Action.make('a').modalCloseButton())?.closeButton, true)
2420
- assert.equal(modal(Action.make('a').modalCloseButton(false))?.closeButton, undefined)
2421
- })
2422
- })
2423
-
2424
- describe('modalContentFooter', () => {
2425
- it('round-trips Elements through resolveSchema as `meta.modalContentFooter`', async () => {
2426
- const result = await resolveSchema([
2427
- Action.make('save').modalContentFooter([
2428
- Alert.make('Heads up').warning(),
2429
- Text.make('Fine print'),
2430
- ]),
2431
- ])
2432
- const footer = result[0]!['modalContentFooter'] as Array<Record<string, unknown>> | undefined
2433
- assert.equal(footer?.length, 2)
2434
- assert.equal(footer![0]!['type'], 'alert')
2435
- assert.equal(footer![0]!['content'], 'Heads up')
2436
- assert.equal(footer![0]!['alertType'], 'warning')
2437
- assert.equal(footer![1]!['type'], 'text')
2438
- })
2439
-
2440
- it('omits the slot when the setter was not called', async () => {
2441
- const result = await resolveSchema([Action.make('save').modalHeading('h')])
2442
- assert.equal(result[0]!['modalContentFooter'], undefined)
2443
- })
2444
-
2445
- it('omits the slot when called with an empty array', async () => {
2446
- const result = await resolveSchema([Action.make('save').modalContentFooter([])])
2447
- assert.equal(result[0]!['modalContentFooter'], undefined)
2448
- })
2449
-
2450
- it('flips _hasModal so the modal slot emits even without other chrome', () => {
2451
- const a = Action.make('save').modalContentFooter([Text.make('Hi')])
2452
- assert.equal(a.hasModal(), true)
2453
- })
2454
-
2455
- it('inner Action `.visible(false)` rules drop the action from the resolved slot', async () => {
2456
- const result = await resolveSchema([
2457
- Action.make('save').modalContentFooter([
2458
- Action.make('learnMore').href('https://example.com').visible(true),
2459
- Action.make('hidden').href('/x').visible(false),
2460
- ]),
2461
- ])
2462
- const footer = result[0]!['modalContentFooter'] as Array<Record<string, unknown>> | undefined
2463
- assert.equal(footer?.length, 1)
2464
- assert.equal(footer![0]!['name'], 'learnMore')
2465
- })
2466
- })
2467
-
2468
- describe('Filament v5 alias setters', () => {
2469
- it('modalSubmitActionLabel routes to modalSubmitLabel', () => {
2470
- const m = modal(Action.make('a').modalSubmitActionLabel('Apply'))
2471
- assert.equal(m?.submitLabel, 'Apply')
2472
- })
2473
-
2474
- it('modalCancelActionLabel routes to modalCancelLabel', () => {
2475
- const m = modal(Action.make('a').modalCancelActionLabel('Never mind'))
2476
- assert.equal(m?.cancelLabel, 'Never mind')
2477
- })
2478
- })
2479
-
2480
- it('any modal setter flips _hasModal (modal slot present)', () => {
2481
- // Each setter must trigger `_hasModal = true` so the slot gets emitted
2482
- // even when the user only customizes one chrome detail (no schema, no
2483
- // heading). Otherwise `closeModalByClickingAway()` on a confirm-only
2484
- // action would silently drop on the floor.
2485
- for (const customise of [
2486
- (a: Action) => a.closeModalByClickingAway(),
2487
- (a: Action) => a.closeModalByEscaping(false),
2488
- (a: Action) => a.stickyModalHeader(),
2489
- (a: Action) => a.stickyModalFooter(),
2490
- (a: Action) => a.modalAutofocus(),
2491
- (a: Action) => a.modalAlignment('end'),
2492
- (a: Action) => a.modalIconColor('info'),
2493
- (a: Action) => a.modalCloseButton(),
2494
- (a: Action) => a.modalContentFooter([Text.make('x')]),
2495
- ]) {
2496
- const a = customise(Action.make('a'))
2497
- assert.notEqual(modal(a), undefined, 'modal slot must be present')
2498
- }
2499
- })
2500
-
2501
- it('chains across multiple chrome setters in one builder', () => {
2502
- const m = modal(
2503
- Action.make('save')
2504
- .modalHeading('Bulk update')
2505
- .modalAlignment('end')
2506
- .modalIconColor('primary')
2507
- .stickyModalHeader()
2508
- .stickyModalFooter()
2509
- .modalAutofocus(false)
2510
- .closeModalByClickingAway()
2511
- .closeModalByEscaping(false)
2512
- .modalCloseButton(),
2513
- )
2514
- assert.deepEqual(m, {
2515
- heading: 'Bulk update',
2516
- alignment: 'end',
2517
- iconColor: 'primary',
2518
- stickyHeader: true,
2519
- stickyFooter: true,
2520
- autofocus: false,
2521
- closeByClickingAway: false,
2522
- closeByEscaping: false,
2523
- closeButton: true,
2524
- })
2525
- })
2526
- })