@pilotiq/pilotiq 0.23.1 → 0.24.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (500) hide show
  1. package/CHANGELOG.md +91 -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/dist/actions/exportFactory.d.ts +10 -0
  15. package/dist/actions/exportFactory.d.ts.map +1 -1
  16. package/dist/actions/exportFactory.js +10 -0
  17. package/dist/actions/exportFactory.js.map +1 -1
  18. package/dist/react/CollabRoomContext.d.ts +5 -5
  19. package/dist/react/index.d.ts +0 -1
  20. package/dist/react/index.d.ts.map +1 -1
  21. package/dist/react/index.js +0 -1
  22. package/dist/react/index.js.map +1 -1
  23. package/dist/routes/helpers.d.ts.map +1 -1
  24. package/dist/routes/helpers.js +6 -2
  25. package/dist/routes/helpers.js.map +1 -1
  26. package/dist/routes/relations.d.ts.map +1 -1
  27. package/dist/routes/relations.js +12 -0
  28. package/dist/routes/relations.js.map +1 -1
  29. package/package.json +6 -1
  30. package/.turbo/turbo-build.log +0 -8
  31. package/CLAUDE.md +0 -265
  32. package/dist/react/useCollabSeed.d.ts +0 -23
  33. package/dist/react/useCollabSeed.d.ts.map +0 -1
  34. package/dist/react/useCollabSeed.js +0 -82
  35. package/dist/react/useCollabSeed.js.map +0 -1
  36. package/src/Cluster.test.ts +0 -283
  37. package/src/Cluster.ts +0 -83
  38. package/src/Column.test.ts +0 -199
  39. package/src/Column.ts +0 -710
  40. package/src/Global.test.ts +0 -367
  41. package/src/Global.ts +0 -169
  42. package/src/Page.test.ts +0 -114
  43. package/src/Page.ts +0 -208
  44. package/src/Pilotiq.perf.test.ts +0 -252
  45. package/src/Pilotiq.test.ts +0 -129
  46. package/src/Pilotiq.ts +0 -1158
  47. package/src/PilotiqRegistry.ts +0 -36
  48. package/src/PilotiqServiceProvider.ts +0 -121
  49. package/src/RelationManager.test.ts +0 -400
  50. package/src/RelationManager.ts +0 -527
  51. package/src/RenderHook.test.ts +0 -252
  52. package/src/RenderHook.ts +0 -242
  53. package/src/Resource.test.ts +0 -284
  54. package/src/Resource.ts +0 -526
  55. package/src/RightPanel.test.ts +0 -202
  56. package/src/RightPanel.ts +0 -132
  57. package/src/Tab.test.ts +0 -91
  58. package/src/Tab.ts +0 -156
  59. package/src/UserMenuItem.ts +0 -145
  60. package/src/actions/Action.test.ts +0 -2526
  61. package/src/actions/Action.ts +0 -1515
  62. package/src/actions/ActionGroup.test.ts +0 -112
  63. package/src/actions/ActionGroup.ts +0 -173
  64. package/src/actions/attachFactory.ts +0 -172
  65. package/src/actions/bulkFactories.ts +0 -168
  66. package/src/actions/crudFactories.ts +0 -220
  67. package/src/actions/exportFactory.ts +0 -215
  68. package/src/actions/factoryHelpers.ts +0 -177
  69. package/src/actions/importFactory.ts +0 -243
  70. package/src/actions/index.ts +0 -17
  71. package/src/actions/m2mFactories.ts +0 -193
  72. package/src/actions/relationFactories.ts +0 -372
  73. package/src/applyPageHooks.test.ts +0 -463
  74. package/src/applyPageHooks.ts +0 -330
  75. package/src/authorization.test.ts +0 -483
  76. package/src/breadcrumbs.test.ts +0 -238
  77. package/src/cells/coerce.test.ts +0 -85
  78. package/src/cells/coerce.ts +0 -84
  79. package/src/clusterPaths.ts +0 -35
  80. package/src/columns/BadgeColumn.test.ts +0 -54
  81. package/src/columns/BadgeColumn.ts +0 -32
  82. package/src/columns/BooleanColumn.test.ts +0 -41
  83. package/src/columns/BooleanColumn.ts +0 -18
  84. package/src/columns/ColorColumn.test.ts +0 -37
  85. package/src/columns/ColorColumn.ts +0 -38
  86. package/src/columns/IconColumn.test.ts +0 -54
  87. package/src/columns/IconColumn.ts +0 -37
  88. package/src/columns/ImageColumn.test.ts +0 -41
  89. package/src/columns/ImageColumn.ts +0 -28
  90. package/src/columns/SelectColumn.ts +0 -98
  91. package/src/columns/TextColumn.test.ts +0 -190
  92. package/src/columns/TextColumn.ts +0 -20
  93. package/src/columns/TextInputColumn.ts +0 -68
  94. package/src/columns/ToggleColumn.ts +0 -46
  95. package/src/columns/editableColumns.test.ts +0 -238
  96. package/src/columns/index.ts +0 -9
  97. package/src/defaultGlobalPages.ts +0 -95
  98. package/src/defaultPages.test.ts +0 -634
  99. package/src/defaultPages.ts +0 -617
  100. package/src/defaultViewPage.test.ts +0 -147
  101. package/src/elements/Form.test.ts +0 -223
  102. package/src/elements/Form.ts +0 -416
  103. package/src/elements/ListTabs.ts +0 -28
  104. package/src/elements/Table.test.ts +0 -422
  105. package/src/elements/Table.ts +0 -850
  106. package/src/elements/TableGroup.test.ts +0 -260
  107. package/src/elements/TableGroup.ts +0 -334
  108. package/src/elements/dispatchAction.test.ts +0 -463
  109. package/src/elements/dispatchAction.ts +0 -355
  110. package/src/elements/dispatchForm.test.ts +0 -477
  111. package/src/elements/dispatchForm.ts +0 -1993
  112. package/src/elements/dispatchTable.test.ts +0 -1514
  113. package/src/elements/dispatchTable.ts +0 -745
  114. package/src/elements/index.ts +0 -21
  115. package/src/entries/BadgeEntry.ts +0 -39
  116. package/src/entries/CodeEntry.test.ts +0 -40
  117. package/src/entries/CodeEntry.ts +0 -52
  118. package/src/entries/ColorEntry.ts +0 -63
  119. package/src/entries/ComponentEntry.test.ts +0 -173
  120. package/src/entries/ComponentEntry.ts +0 -95
  121. package/src/entries/Entry.ts +0 -304
  122. package/src/entries/IconEntry.ts +0 -49
  123. package/src/entries/ImageEntry.ts +0 -61
  124. package/src/entries/KeyValueEntry.ts +0 -47
  125. package/src/entries/RepeatableEntry.test.ts +0 -239
  126. package/src/entries/RepeatableEntry.ts +0 -173
  127. package/src/entries/TextEntry.test.ts +0 -394
  128. package/src/entries/TextEntry.ts +0 -60
  129. package/src/entries/index.ts +0 -12
  130. package/src/entries/leaves.test.ts +0 -306
  131. package/src/entries/registry.ts +0 -54
  132. package/src/fields/BuilderField.test.ts +0 -1188
  133. package/src/fields/BuilderField.ts +0 -605
  134. package/src/fields/BuilderRelationship.test.ts +0 -811
  135. package/src/fields/CheckboxField.test.ts +0 -44
  136. package/src/fields/CheckboxField.ts +0 -27
  137. package/src/fields/CheckboxListField.test.ts +0 -99
  138. package/src/fields/CheckboxListField.ts +0 -66
  139. package/src/fields/ColorPickerField.test.ts +0 -33
  140. package/src/fields/ColorPickerField.ts +0 -25
  141. package/src/fields/DateField.ts +0 -54
  142. package/src/fields/DateTimeField.test.ts +0 -55
  143. package/src/fields/EmailField.ts +0 -16
  144. package/src/fields/Field.test.ts +0 -654
  145. package/src/fields/Field.ts +0 -817
  146. package/src/fields/FileUploadField.test.ts +0 -143
  147. package/src/fields/FileUploadField.ts +0 -159
  148. package/src/fields/HiddenField.test.ts +0 -27
  149. package/src/fields/HiddenField.ts +0 -28
  150. package/src/fields/KeyValueField.test.ts +0 -105
  151. package/src/fields/KeyValueField.ts +0 -55
  152. package/src/fields/MarkdownField.test.ts +0 -167
  153. package/src/fields/MarkdownField.ts +0 -162
  154. package/src/fields/NumberField.ts +0 -33
  155. package/src/fields/RadioField.test.ts +0 -94
  156. package/src/fields/RadioField.ts +0 -67
  157. package/src/fields/RepeaterField.test.ts +0 -1806
  158. package/src/fields/RepeaterField.ts +0 -939
  159. package/src/fields/RepeaterRelationship.test.ts +0 -1923
  160. package/src/fields/RepeaterSimple.test.ts +0 -248
  161. package/src/fields/RowButton.test.ts +0 -219
  162. package/src/fields/RowButton.ts +0 -135
  163. package/src/fields/SelectField.test.ts +0 -192
  164. package/src/fields/SelectField.ts +0 -235
  165. package/src/fields/SliderField.test.ts +0 -50
  166. package/src/fields/SliderField.ts +0 -53
  167. package/src/fields/SlugField.ts +0 -24
  168. package/src/fields/TagsInputField.test.ts +0 -154
  169. package/src/fields/TagsInputField.ts +0 -133
  170. package/src/fields/TextField.test.ts +0 -213
  171. package/src/fields/TextField.ts +0 -177
  172. package/src/fields/TextareaField.test.ts +0 -58
  173. package/src/fields/TextareaField.ts +0 -59
  174. package/src/fields/ToggleButtonsField.test.ts +0 -106
  175. package/src/fields/ToggleButtonsField.ts +0 -59
  176. package/src/fields/ToggleField.ts +0 -16
  177. package/src/fields/disableOptionsWhenSelectedInSiblingRepeaterItems.test.ts +0 -319
  178. package/src/fields/optionsResolver.ts +0 -95
  179. package/src/fields/resolveField.ts +0 -28
  180. package/src/filters/BooleanFilter.ts +0 -35
  181. package/src/filters/DateRangeFilter.test.ts +0 -194
  182. package/src/filters/DateRangeFilter.ts +0 -148
  183. package/src/filters/Filter.test.ts +0 -268
  184. package/src/filters/Filter.ts +0 -184
  185. package/src/filters/FormFilter.test.ts +0 -238
  186. package/src/filters/FormFilter.ts +0 -215
  187. package/src/filters/MultiSelectFilter.test.ts +0 -119
  188. package/src/filters/MultiSelectFilter.ts +0 -78
  189. package/src/filters/QueryBuilderFilter.test.ts +0 -662
  190. package/src/filters/QueryBuilderFilter.ts +0 -398
  191. package/src/filters/SelectFilter.ts +0 -46
  192. package/src/filters/TernaryFilter.test.ts +0 -160
  193. package/src/filters/TernaryFilter.ts +0 -72
  194. package/src/filters/TrashedFilter.test.ts +0 -149
  195. package/src/filters/TrashedFilter.ts +0 -55
  196. package/src/filters/queryBuilder/BooleanConstraint.ts +0 -31
  197. package/src/filters/queryBuilder/Constraint.ts +0 -115
  198. package/src/filters/queryBuilder/DateConstraint.ts +0 -69
  199. package/src/filters/queryBuilder/NumberConstraint.ts +0 -66
  200. package/src/filters/queryBuilder/SelectConstraint.ts +0 -72
  201. package/src/filters/queryBuilder/TextConstraint.ts +0 -64
  202. package/src/filters/queryBuilder/index.ts +0 -12
  203. package/src/icons/index.ts +0 -2
  204. package/src/icons/lucide.ts +0 -204
  205. package/src/icons/registry.test.ts +0 -56
  206. package/src/icons/registry.ts +0 -41
  207. package/src/icons/types.ts +0 -47
  208. package/src/index.ts +0 -525
  209. package/src/io/csv.test.ts +0 -142
  210. package/src/io/csv.ts +0 -170
  211. package/src/nestedRelationManagerData.test.ts +0 -547
  212. package/src/notifications/Notification.test.ts +0 -210
  213. package/src/notifications/Notification.ts +0 -354
  214. package/src/notifications/broadcast.test.ts +0 -110
  215. package/src/notifications/broadcast.ts +0 -95
  216. package/src/notifications/database.test.ts +0 -383
  217. package/src/notifications/database.ts +0 -398
  218. package/src/notifications/databaseNotifications.test.ts +0 -187
  219. package/src/notifications/dispatchNotificationAction.test.ts +0 -341
  220. package/src/notifications/dispatchNotificationAction.ts +0 -142
  221. package/src/notifications/flash.test.ts +0 -89
  222. package/src/notifications/flash.ts +0 -71
  223. package/src/notifications/index.ts +0 -45
  224. package/src/notifications/registerBroadcastAuth.test.ts +0 -134
  225. package/src/notifications/registerBroadcastAuth.ts +0 -100
  226. package/src/notifications/resolveSavedNotification.test.ts +0 -82
  227. package/src/notifications/resolveSavedNotification.ts +0 -59
  228. package/src/notifications/types.ts +0 -93
  229. package/src/orm/m2mAccessor.ts +0 -66
  230. package/src/orm/modelDefaults.test.ts +0 -633
  231. package/src/orm/modelDefaults.ts +0 -666
  232. package/src/pageData/breadcrumbs.ts +0 -288
  233. package/src/pageData/forms.ts +0 -578
  234. package/src/pageData/helpers.ts +0 -857
  235. package/src/pageData/misc.ts +0 -347
  236. package/src/pageData/navigation.ts +0 -842
  237. package/src/pageData/relationPages.ts +0 -1248
  238. package/src/pageData/relationTabs.ts +0 -286
  239. package/src/pageData/resourcePages.ts +0 -609
  240. package/src/pageData.test.ts +0 -1545
  241. package/src/pageData.ts +0 -341
  242. package/src/plugins/index.ts +0 -8
  243. package/src/plugins/themeEditor.test.ts +0 -36
  244. package/src/plugins/themeEditor.ts +0 -45
  245. package/src/react/AppShell.tsx +0 -251
  246. package/src/react/CollabExtensionFactoryRegistry.ts +0 -55
  247. package/src/react/CollabRoomContext.ts +0 -98
  248. package/src/react/CollabTextRendererRegistry.ts +0 -102
  249. package/src/react/CommandPalette.tsx +0 -375
  250. package/src/react/CurrentUserContext.tsx +0 -50
  251. package/src/react/CustomPageWrapperGate.tsx +0 -69
  252. package/src/react/CustomPageWrapperRegistry.ts +0 -45
  253. package/src/react/FieldFocusReporterRegistry.ts +0 -37
  254. package/src/react/FieldLabelSlotRegistry.ts +0 -30
  255. package/src/react/FieldPresenceRegistry.ts +0 -46
  256. package/src/react/FormCollabBindingRegistry.ts +0 -242
  257. package/src/react/FormStateContext.tsx +0 -591
  258. package/src/react/HeadHooks.tsx +0 -126
  259. package/src/react/MarkdownEditorRegistry.test.ts +0 -38
  260. package/src/react/MarkdownEditorRegistry.ts +0 -107
  261. package/src/react/NotificationActionStrip.tsx +0 -263
  262. package/src/react/NotificationBell.tsx +0 -426
  263. package/src/react/PendingSuggestionApplierRegistry.test.ts +0 -97
  264. package/src/react/PendingSuggestionApplierRegistry.ts +0 -98
  265. package/src/react/PendingSuggestionOverlayRegistry.ts +0 -54
  266. package/src/react/PendingSuggestionsContext.tsx +0 -172
  267. package/src/react/RecordWrapperGate.tsx +0 -58
  268. package/src/react/RecordWrapperRegistry.ts +0 -39
  269. package/src/react/RenderHookSlot.tsx +0 -32
  270. package/src/react/RightSidebar.tsx +0 -257
  271. package/src/react/RightSidebarContext.tsx +0 -234
  272. package/src/react/RightSidebarTrigger.tsx +0 -53
  273. package/src/react/RowCoordsContext.tsx +0 -23
  274. package/src/react/SchemaRenderer.tsx +0 -549
  275. package/src/react/SearchTrigger.tsx +0 -46
  276. package/src/react/ThemeProvider.tsx +0 -93
  277. package/src/react/ThemeSettingsPage.tsx +0 -579
  278. package/src/react/ThemeToggle.tsx +0 -20
  279. package/src/react/Toaster.tsx +0 -158
  280. package/src/react/UserMenu.tsx +0 -196
  281. package/src/react/WidgetDataContext.tsx +0 -157
  282. package/src/react/cells/EditableCell.tsx +0 -389
  283. package/src/react/component-slots.test.ts +0 -103
  284. package/src/react/component-slots.ts +0 -116
  285. package/src/react/fieldJsHandler.test.ts +0 -166
  286. package/src/react/fieldJsHandler.ts +0 -79
  287. package/src/react/fields/BuilderInput.tsx +0 -1078
  288. package/src/react/fields/CheckboxInput.tsx +0 -39
  289. package/src/react/fields/CheckboxListInput.tsx +0 -102
  290. package/src/react/fields/ColorInput.tsx +0 -71
  291. package/src/react/fields/DateFieldInput.tsx +0 -70
  292. package/src/react/fields/DateTimeInput.tsx +0 -62
  293. package/src/react/fields/FieldShell.tsx +0 -348
  294. package/src/react/fields/FileUploadInput.tsx +0 -639
  295. package/src/react/fields/HiddenInput.tsx +0 -17
  296. package/src/react/fields/KeyValueInput.tsx +0 -230
  297. package/src/react/fields/MarkdownInput.tsx +0 -560
  298. package/src/react/fields/RadioInput.tsx +0 -81
  299. package/src/react/fields/RepeaterInput.test.ts +0 -116
  300. package/src/react/fields/RepeaterInput.tsx +0 -1420
  301. package/src/react/fields/SelectFieldInput.tsx +0 -280
  302. package/src/react/fields/SliderInput.tsx +0 -81
  303. package/src/react/fields/TagsInput.tsx +0 -283
  304. package/src/react/fields/TextLikeInput.tsx +0 -256
  305. package/src/react/fields/ToggleButtonsInput.tsx +0 -60
  306. package/src/react/fields/ToggleFieldInput.tsx +0 -56
  307. package/src/react/fields/relationshipRenameDispatch.test.ts +0 -106
  308. package/src/react/fields/relationshipRenameDispatch.ts +0 -97
  309. package/src/react/fields/repeaterReconcile.test.ts +0 -114
  310. package/src/react/fields/repeaterReconcile.ts +0 -104
  311. package/src/react/fields/rowChromeButton.tsx +0 -336
  312. package/src/react/fields/rowState.ts +0 -106
  313. package/src/react/fields/syncRowGates.test.ts +0 -202
  314. package/src/react/fields/syncRowGates.ts +0 -66
  315. package/src/react/fields/textInputControls.tsx +0 -238
  316. package/src/react/fields/useRowReorderDnd.ts +0 -78
  317. package/src/react/formStateHelpers.test.ts +0 -508
  318. package/src/react/formStateHelpers.ts +0 -381
  319. package/src/react/hooks/use-mobile.ts +0 -19
  320. package/src/react/icon-context.tsx +0 -60
  321. package/src/react/index.ts +0 -195
  322. package/src/react/layouts/SidebarLayout.tsx +0 -250
  323. package/src/react/layouts/TopbarLayout.tsx +0 -258
  324. package/src/react/navigate.tsx +0 -37
  325. package/src/react/onProviderSynced.test.ts +0 -90
  326. package/src/react/parseRecordEditUrl.test.ts +0 -122
  327. package/src/react/parseRecordEditUrl.ts +0 -94
  328. package/src/react/persistedState.ts +0 -40
  329. package/src/react/registry.ts +0 -48
  330. package/src/react/right-panel-registry.tsx +0 -47
  331. package/src/react/schemaRenderer/AlertRenderer.tsx +0 -112
  332. package/src/react/schemaRenderer/EntryRenderer.tsx +0 -501
  333. package/src/react/schemaRenderer/SectionRenderer.tsx +0 -120
  334. package/src/react/schemaRenderer/SimpleElements.tsx +0 -306
  335. package/src/react/schemaRenderer/TabsRenderer.tsx +0 -62
  336. package/src/react/schemaRenderer/WizardRenderer.tsx +0 -338
  337. package/src/react/schemaRenderer/action/ActionGroupTrigger.tsx +0 -177
  338. package/src/react/schemaRenderer/action/ActionModalDialog.tsx +0 -273
  339. package/src/react/schemaRenderer/action/ConfirmActionDialog.tsx +0 -61
  340. package/src/react/schemaRenderer/action/HandlerActionButton.tsx +0 -43
  341. package/src/react/schemaRenderer/action/MethodActionButton.tsx +0 -64
  342. package/src/react/schemaRenderer/action/buttons.tsx +0 -99
  343. package/src/react/schemaRenderer/action/helpers.ts +0 -140
  344. package/src/react/schemaRenderer/action/renderAction.tsx +0 -245
  345. package/src/react/schemaRenderer/columnFormat.ts +0 -65
  346. package/src/react/schemaRenderer/constants.ts +0 -50
  347. package/src/react/schemaRenderer/form/FormRenderer.tsx +0 -274
  348. package/src/react/schemaRenderer/form/renderField.tsx +0 -511
  349. package/src/react/schemaRenderer/helpers.tsx +0 -81
  350. package/src/react/schemaRenderer/table/CardsLayoutBody.tsx +0 -308
  351. package/src/react/schemaRenderer/table/TableRenderer.tsx +0 -123
  352. package/src/react/schemaRenderer/table/TableRendererBody.tsx +0 -974
  353. package/src/react/schemaRenderer/table/filters.tsx +0 -1233
  354. package/src/react/schemaRenderer/table/formatCell.tsx +0 -264
  355. package/src/react/schemaRenderer/table/links.tsx +0 -112
  356. package/src/react/schemaRenderer/table/renderRowActions.tsx +0 -52
  357. package/src/react/schemaRenderer/table/url.tsx +0 -143
  358. package/src/react/theme-preview/apply.ts +0 -99
  359. package/src/react/theme-preview/build-html.ts +0 -436
  360. package/src/react/ui/button.tsx +0 -51
  361. package/src/react/ui/calendar.tsx +0 -67
  362. package/src/react/ui/checkbox.tsx +0 -29
  363. package/src/react/ui/dialog.tsx +0 -108
  364. package/src/react/ui/dropdown-menu.tsx +0 -97
  365. package/src/react/ui/input.tsx +0 -20
  366. package/src/react/ui/label.tsx +0 -21
  367. package/src/react/ui/popover.tsx +0 -50
  368. package/src/react/ui/select.tsx +0 -169
  369. package/src/react/ui/separator.tsx +0 -25
  370. package/src/react/ui/sheet.tsx +0 -136
  371. package/src/react/ui/sidebar.tsx +0 -723
  372. package/src/react/ui/skeleton.tsx +0 -13
  373. package/src/react/ui/slider.tsx +0 -34
  374. package/src/react/ui/switch.tsx +0 -28
  375. package/src/react/ui/table.tsx +0 -105
  376. package/src/react/ui/tabs.tsx +0 -63
  377. package/src/react/ui/textarea.tsx +0 -18
  378. package/src/react/ui/tooltip.tsx +0 -64
  379. package/src/react/useCollabSeed.ts +0 -86
  380. package/src/react/useResizableWidth.ts +0 -139
  381. package/src/react/utils.ts +0 -6
  382. package/src/react/widgetRegistry.test.ts +0 -43
  383. package/src/react/widgetRegistry.ts +0 -50
  384. package/src/react/widgets/StatsOverviewRenderer.tsx +0 -232
  385. package/src/react/widgets/TableWidgetRenderer.tsx +0 -231
  386. package/src/react/widgets/ViewRenderer.tsx +0 -71
  387. package/src/relationManagerData.test.ts +0 -1595
  388. package/src/richtext/index.ts +0 -8
  389. package/src/richtext/registry.ts +0 -89
  390. package/src/routes/globals.ts +0 -148
  391. package/src/routes/guard.test.ts +0 -325
  392. package/src/routes/helpers.ts +0 -700
  393. package/src/routes/pages.ts +0 -175
  394. package/src/routes/panel.ts +0 -204
  395. package/src/routes/relations.ts +0 -1227
  396. package/src/routes/resources.ts +0 -781
  397. package/src/routes/theme.ts +0 -91
  398. package/src/routes-nested-relations.test.ts +0 -676
  399. package/src/routes-relations.test.ts +0 -972
  400. package/src/routes.test.ts +0 -2027
  401. package/src/routes.ts +0 -303
  402. package/src/schema/Alert.test.ts +0 -109
  403. package/src/schema/Alert.ts +0 -131
  404. package/src/schema/Block.ts +0 -169
  405. package/src/schema/Breadcrumbs.ts +0 -40
  406. package/src/schema/Card.ts +0 -35
  407. package/src/schema/Divider.ts +0 -20
  408. package/src/schema/Element.ts +0 -219
  409. package/src/schema/EmptyState.test.ts +0 -37
  410. package/src/schema/EmptyState.ts +0 -63
  411. package/src/schema/Fieldset.ts +0 -43
  412. package/src/schema/Grid.ts +0 -43
  413. package/src/schema/Group.ts +0 -30
  414. package/src/schema/Heading.ts +0 -39
  415. package/src/schema/Html.ts +0 -67
  416. package/src/schema/Icon.ts +0 -54
  417. package/src/schema/Image.ts +0 -57
  418. package/src/schema/LinkTag.ts +0 -41
  419. package/src/schema/Markdown.ts +0 -85
  420. package/src/schema/MetaTag.ts +0 -41
  421. package/src/schema/RelationTabs.ts +0 -71
  422. package/src/schema/ScriptTag.ts +0 -55
  423. package/src/schema/Section.ts +0 -160
  424. package/src/schema/ServerDataElement.test.ts +0 -140
  425. package/src/schema/ServerDataElement.ts +0 -156
  426. package/src/schema/SlotComponent.test.ts +0 -77
  427. package/src/schema/SlotComponent.ts +0 -71
  428. package/src/schema/Split.ts +0 -50
  429. package/src/schema/Stat.test.ts +0 -118
  430. package/src/schema/Stat.ts +0 -154
  431. package/src/schema/StatsOverview.test.ts +0 -141
  432. package/src/schema/StatsOverview.ts +0 -119
  433. package/src/schema/StyleTag.ts +0 -35
  434. package/src/schema/TableWidget.test.ts +0 -297
  435. package/src/schema/TableWidget.ts +0 -289
  436. package/src/schema/Tabs.ts +0 -79
  437. package/src/schema/Text.ts +0 -58
  438. package/src/schema/UnorderedList.ts +0 -49
  439. package/src/schema/View.test.ts +0 -111
  440. package/src/schema/View.ts +0 -127
  441. package/src/schema/Wizard.ts +0 -220
  442. package/src/schema/containers.test.ts +0 -564
  443. package/src/schema/headTags.test.ts +0 -134
  444. package/src/schema/index.ts +0 -40
  445. package/src/schema/primes.test.ts +0 -269
  446. package/src/schema/resolveSchema.test.ts +0 -379
  447. package/src/schema/resolveSchema.ts +0 -917
  448. package/src/schema/sanitize.ts +0 -58
  449. package/src/search.test.ts +0 -446
  450. package/src/search.ts +0 -178
  451. package/src/sessionFilters.test.ts +0 -375
  452. package/src/sessionFilters.ts +0 -143
  453. package/src/slot-components/index.ts +0 -10
  454. package/src/slot-components/registry.ts +0 -56
  455. package/src/styles/file-upload.css +0 -13
  456. package/src/summarizers/Summarizer.test.ts +0 -84
  457. package/src/summarizers/Summarizer.ts +0 -123
  458. package/src/summarizers/index.ts +0 -11
  459. package/src/theme/base-colors.ts +0 -68
  460. package/src/theme/chart-colors.ts +0 -50
  461. package/src/theme/colors.ts +0 -447
  462. package/src/theme/generate-css.test.ts +0 -139
  463. package/src/theme/generate-css.ts +0 -44
  464. package/src/theme/generate-scale.test.ts +0 -106
  465. package/src/theme/generate-scale.ts +0 -97
  466. package/src/theme/icon-map.ts +0 -42
  467. package/src/theme/index.ts +0 -34
  468. package/src/theme/migrate.test.ts +0 -178
  469. package/src/theme/migrate.ts +0 -81
  470. package/src/theme/presets.ts +0 -135
  471. package/src/theme/radius.ts +0 -18
  472. package/src/theme/resolve.test.ts +0 -238
  473. package/src/theme/resolve.ts +0 -96
  474. package/src/theme/spacing.ts +0 -18
  475. package/src/theme/storage.test.ts +0 -126
  476. package/src/theme/storage.ts +0 -106
  477. package/src/theme/theme-colors.ts +0 -88
  478. package/src/theme/types.ts +0 -125
  479. package/src/uploads/UploadAdapter.ts +0 -35
  480. package/src/uploads/index.ts +0 -2
  481. package/src/uploads/localUpload.test.ts +0 -70
  482. package/src/uploads/localUpload.ts +0 -84
  483. package/src/validation/Validator.ts +0 -49
  484. package/src/validation/index.ts +0 -28
  485. package/src/validation/rules.ts +0 -78
  486. package/src/validation/runValidators.ts +0 -435
  487. package/src/validation/uniqueValidator.test.ts +0 -196
  488. package/src/validation/uniqueValidator.ts +0 -133
  489. package/src/validation/validators.test.ts +0 -268
  490. package/src/vite.test.ts +0 -184
  491. package/src/vite.ts +0 -787
  492. package/src/widgets/index.ts +0 -10
  493. package/src/widgets/registry.ts +0 -45
  494. package/src/widgets.test.ts +0 -592
  495. package/tsconfig.build.json +0 -11
  496. package/tsconfig.json +0 -4
  497. package/tsconfig.test.json +0 -10
  498. package/views/react/Dashboard.tsx +0 -27
  499. package/views/react/Resources/Form.tsx +0 -102
  500. 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
- })