@conduction/nextcloud-vue 2.56.0-beta.6 → 2.56.0-beta.8

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 (254) hide show
  1. package/dist/esm/components/CnActionsMenu/CnActionsMenu.vue2.js +14 -1
  2. package/dist/esm/components/CnActionsMenu/CnActionsMenu.vue2.js.map +1 -1
  3. package/dist/esm/components/CnActionsMenu/CnActionsMenu.vue3.js.map +1 -1
  4. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js +80 -1
  5. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js.map +1 -1
  6. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js +46 -4
  7. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js.map +1 -1
  8. package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue2.js +232 -10
  9. package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue2.js.map +1 -1
  10. package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue3.js +79 -25
  11. package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue3.js.map +1 -1
  12. package/dist/esm/components/CnBannerWidgetForm/CnBannerWidgetForm.vue2.js +42 -3
  13. package/dist/esm/components/CnBannerWidgetForm/CnBannerWidgetForm.vue2.js.map +1 -1
  14. package/dist/esm/components/CnBannerWidgetForm/CnBannerWidgetForm.vue3.js +39 -6
  15. package/dist/esm/components/CnBannerWidgetForm/CnBannerWidgetForm.vue3.js.map +1 -1
  16. package/dist/esm/components/CnBoardView/CnBoardView.vue2.js +46 -0
  17. package/dist/esm/components/CnBoardView/CnBoardView.vue2.js.map +1 -1
  18. package/dist/esm/components/CnBoardView/CnBoardView.vue3.js +11 -4
  19. package/dist/esm/components/CnBoardView/CnBoardView.vue3.js.map +1 -1
  20. package/dist/esm/components/CnBrandStripe/CnBrandStripe.vue.js +10 -0
  21. package/dist/esm/components/CnBrandStripe/CnBrandStripe.vue.js.map +1 -0
  22. package/dist/esm/components/CnBrandStripe/CnBrandStripe.vue2.js +39 -0
  23. package/dist/esm/components/CnBrandStripe/CnBrandStripe.vue2.js.map +1 -0
  24. package/dist/esm/components/CnBrandStripe/CnBrandStripe.vue3.js +16 -0
  25. package/dist/esm/components/CnBrandStripe/CnBrandStripe.vue3.js.map +1 -0
  26. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js +117 -1
  27. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js.map +1 -1
  28. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js +147 -89
  29. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js.map +1 -1
  30. package/dist/esm/components/CnConversationThread/CnConversationThread.vue.js +10 -0
  31. package/dist/esm/components/CnConversationThread/CnConversationThread.vue.js.map +1 -0
  32. package/dist/esm/components/CnConversationThread/CnConversationThread.vue2.js +259 -0
  33. package/dist/esm/components/CnConversationThread/CnConversationThread.vue2.js.map +1 -0
  34. package/dist/esm/components/CnConversationThread/CnConversationThread.vue3.js +131 -0
  35. package/dist/esm/components/CnConversationThread/CnConversationThread.vue3.js.map +1 -0
  36. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue.js +10 -0
  37. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue.js.map +1 -0
  38. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue2.js +116 -0
  39. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue2.js.map +1 -0
  40. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue3.js +24 -0
  41. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue3.js.map +1 -0
  42. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +10 -2
  43. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  44. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  45. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +503 -28
  46. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  47. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +303 -144
  48. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  49. package/dist/esm/components/CnDocumentReviewList/CnDocumentReviewList.vue.js +10 -0
  50. package/dist/esm/components/CnDocumentReviewList/CnDocumentReviewList.vue.js.map +1 -0
  51. package/dist/esm/components/CnDocumentReviewList/CnDocumentReviewList.vue2.js +237 -0
  52. package/dist/esm/components/CnDocumentReviewList/CnDocumentReviewList.vue2.js.map +1 -0
  53. package/dist/esm/components/CnDocumentReviewList/CnDocumentReviewList.vue3.js +120 -0
  54. package/dist/esm/components/CnDocumentReviewList/CnDocumentReviewList.vue3.js.map +1 -0
  55. package/dist/esm/components/CnDocumentReviewList/CnDocumentReviewWidget.vue.js +10 -0
  56. package/dist/esm/components/CnDocumentReviewList/CnDocumentReviewWidget.vue.js.map +1 -0
  57. package/dist/esm/components/CnDocumentReviewList/CnDocumentReviewWidget.vue2.js +77 -0
  58. package/dist/esm/components/CnDocumentReviewList/CnDocumentReviewWidget.vue2.js.map +1 -0
  59. package/dist/esm/components/CnDocumentReviewList/CnDocumentReviewWidget.vue3.js +27 -0
  60. package/dist/esm/components/CnDocumentReviewList/CnDocumentReviewWidget.vue3.js.map +1 -0
  61. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js +171 -12
  62. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js.map +1 -1
  63. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js +15 -7
  64. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js.map +1 -1
  65. package/dist/esm/components/CnHeaderWidgetForm/CnHeaderWidgetForm.vue2.js +37 -3
  66. package/dist/esm/components/CnHeaderWidgetForm/CnHeaderWidgetForm.vue2.js.map +1 -1
  67. package/dist/esm/components/CnHeaderWidgetForm/CnHeaderWidgetForm.vue3.js +44 -16
  68. package/dist/esm/components/CnHeaderWidgetForm/CnHeaderWidgetForm.vue3.js.map +1 -1
  69. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +132 -3
  70. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  71. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +6 -3
  72. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  73. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue.js +10 -0
  74. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue.js.map +1 -0
  75. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue2.js +316 -0
  76. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue2.js.map +1 -0
  77. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue3.js +105 -0
  78. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue3.js.map +1 -0
  79. package/dist/esm/components/CnNextStepCard/CnNextStepCard.vue.js +10 -0
  80. package/dist/esm/components/CnNextStepCard/CnNextStepCard.vue.js.map +1 -0
  81. package/dist/esm/components/CnNextStepCard/CnNextStepCard.vue2.js +150 -0
  82. package/dist/esm/components/CnNextStepCard/CnNextStepCard.vue2.js.map +1 -0
  83. package/dist/esm/components/CnNextStepCard/CnNextStepCard.vue3.js +106 -0
  84. package/dist/esm/components/CnNextStepCard/CnNextStepCard.vue3.js.map +1 -0
  85. package/dist/esm/components/CnNextStepCard/CnNextStepWidget.vue.js +9 -0
  86. package/dist/esm/components/CnNextStepCard/CnNextStepWidget.vue.js.map +1 -0
  87. package/dist/esm/components/CnNextStepCard/CnNextStepWidget.vue2.js +47 -0
  88. package/dist/esm/components/CnNextStepCard/CnNextStepWidget.vue2.js.map +1 -0
  89. package/dist/esm/components/CnNextStepCard/CnNextStepWidget.vue3.js +17 -0
  90. package/dist/esm/components/CnNextStepCard/CnNextStepWidget.vue3.js.map +1 -0
  91. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue2.js +49 -0
  92. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue2.js.map +1 -1
  93. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue3.js.map +1 -1
  94. package/dist/esm/components/CnObjectDataWidgetForm/CnObjectDataWidgetForm.vue2.js +6 -0
  95. package/dist/esm/components/CnObjectDataWidgetForm/CnObjectDataWidgetForm.vue2.js.map +1 -1
  96. package/dist/esm/components/CnObjectDataWidgetForm/CnObjectDataWidgetForm.vue3.js.map +1 -1
  97. package/dist/esm/components/CnObjectKanban/CnObjectKanban.vue2.js +46 -0
  98. package/dist/esm/components/CnObjectKanban/CnObjectKanban.vue2.js.map +1 -1
  99. package/dist/esm/components/CnObjectKanban/CnObjectKanban.vue3.js +15 -3
  100. package/dist/esm/components/CnObjectKanban/CnObjectKanban.vue3.js.map +1 -1
  101. package/dist/esm/components/CnPageRenderer/pageTypes.js +1 -0
  102. package/dist/esm/components/CnPageRenderer/pageTypes.js.map +1 -1
  103. package/dist/esm/components/CnQuickFilterBar/CnQuickFilterBar.vue2.js +37 -1
  104. package/dist/esm/components/CnQuickFilterBar/CnQuickFilterBar.vue2.js.map +1 -1
  105. package/dist/esm/components/CnQuickFilterBar/CnQuickFilterBar.vue3.js +29 -13
  106. package/dist/esm/components/CnQuickFilterBar/CnQuickFilterBar.vue3.js.map +1 -1
  107. package/dist/esm/components/CnSavedViewsControl/CnSavedViewsControl.vue2.js +50 -3
  108. package/dist/esm/components/CnSavedViewsControl/CnSavedViewsControl.vue2.js.map +1 -1
  109. package/dist/esm/components/CnSavedViewsControl/CnSavedViewsControl.vue3.js.map +1 -1
  110. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue.js +10 -0
  111. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue.js.map +1 -0
  112. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue2.js +186 -0
  113. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue2.js.map +1 -0
  114. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue3.js +46 -0
  115. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue3.js.map +1 -0
  116. package/dist/esm/components/CnStackedBarWidget/CnStackedBarWidget.vue.js +11 -0
  117. package/dist/esm/components/CnStackedBarWidget/CnStackedBarWidget.vue.js.map +1 -0
  118. package/dist/esm/components/CnStackedBarWidget/CnStackedBarWidget.vue2.js +298 -0
  119. package/dist/esm/components/CnStackedBarWidget/CnStackedBarWidget.vue2.js.map +1 -0
  120. package/dist/esm/components/CnStackedBarWidget/CnStackedBarWidget.vue3.js +77 -0
  121. package/dist/esm/components/CnStackedBarWidget/CnStackedBarWidget.vue3.js.map +1 -0
  122. package/dist/esm/components/CnStackedBarWidgetForm/CnStackedBarWidgetForm.vue.js +10 -0
  123. package/dist/esm/components/CnStackedBarWidgetForm/CnStackedBarWidgetForm.vue.js.map +1 -0
  124. package/dist/esm/components/CnStackedBarWidgetForm/CnStackedBarWidgetForm.vue2.js +169 -0
  125. package/dist/esm/components/CnStackedBarWidgetForm/CnStackedBarWidgetForm.vue2.js.map +1 -0
  126. package/dist/esm/components/CnStackedBarWidgetForm/CnStackedBarWidgetForm.vue3.js +48 -0
  127. package/dist/esm/components/CnStackedBarWidgetForm/CnStackedBarWidgetForm.vue3.js.map +1 -0
  128. package/dist/esm/components/CnTabs/CnTab.vue2.js +35 -0
  129. package/dist/esm/components/CnTabs/CnTab.vue2.js.map +1 -1
  130. package/dist/esm/components/CnTabs/CnTab.vue3.js.map +1 -1
  131. package/dist/esm/components/CnTabs/CnTabs.vue2.js +120 -7
  132. package/dist/esm/components/CnTabs/CnTabs.vue2.js.map +1 -1
  133. package/dist/esm/components/CnTabs/CnTabs.vue3.js +57 -5
  134. package/dist/esm/components/CnTabs/CnTabs.vue3.js.map +1 -1
  135. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +59 -4
  136. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
  137. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js +6 -2
  138. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
  139. package/dist/esm/components/CnWeekStripWidget/CnWeekStripWidget.vue.js +10 -0
  140. package/dist/esm/components/CnWeekStripWidget/CnWeekStripWidget.vue.js.map +1 -0
  141. package/dist/esm/components/CnWeekStripWidget/CnWeekStripWidget.vue2.js +484 -0
  142. package/dist/esm/components/CnWeekStripWidget/CnWeekStripWidget.vue2.js.map +1 -0
  143. package/dist/esm/components/CnWeekStripWidget/CnWeekStripWidget.vue3.js +115 -0
  144. package/dist/esm/components/CnWeekStripWidget/CnWeekStripWidget.vue3.js.map +1 -0
  145. package/dist/esm/components/CnWeekStripWidgetForm/CnWeekStripWidgetForm.vue.js +10 -0
  146. package/dist/esm/components/CnWeekStripWidgetForm/CnWeekStripWidgetForm.vue.js.map +1 -0
  147. package/dist/esm/components/CnWeekStripWidgetForm/CnWeekStripWidgetForm.vue2.js +189 -0
  148. package/dist/esm/components/CnWeekStripWidgetForm/CnWeekStripWidgetForm.vue2.js.map +1 -0
  149. package/dist/esm/components/CnWeekStripWidgetForm/CnWeekStripWidgetForm.vue3.js +74 -0
  150. package/dist/esm/components/CnWeekStripWidgetForm/CnWeekStripWidgetForm.vue3.js.map +1 -0
  151. package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js +82 -8
  152. package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js.map +1 -1
  153. package/dist/esm/index.js +16 -0
  154. package/dist/esm/index.js.map +1 -1
  155. package/dist/esm/l10n/en.json.js +55 -1
  156. package/dist/esm/l10n/en.json.js.map +1 -1
  157. package/dist/esm/l10n/nl.json.js +55 -1
  158. package/dist/esm/l10n/nl.json.js.map +1 -1
  159. package/dist/esm/nextcloud-vue.css +1036 -19
  160. package/dist/esm/utils/dateVariant.js +124 -0
  161. package/dist/esm/utils/dateVariant.js.map +1 -0
  162. package/dist/esm/utils/detailActionModel.js +260 -0
  163. package/dist/esm/utils/detailActionModel.js.map +1 -0
  164. package/dist/esm/utils/dueRule.js +84 -0
  165. package/dist/esm/utils/dueRule.js.map +1 -0
  166. package/dist/esm/utils/fetchAggregate.js +37 -1
  167. package/dist/esm/utils/fetchAggregate.js.map +1 -1
  168. package/dist/esm/utils/fetchFilterCounts.js +186 -0
  169. package/dist/esm/utils/fetchFilterCounts.js.map +1 -0
  170. package/dist/esm/utils/headers.js +28 -2
  171. package/dist/esm/utils/headers.js.map +1 -1
  172. package/dist/esm/utils/libraryWidgetKeys.js +5 -0
  173. package/dist/esm/utils/libraryWidgetKeys.js.map +1 -1
  174. package/dist/esm/utils/readPath.js +30 -0
  175. package/dist/esm/utils/readPath.js.map +1 -0
  176. package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
  177. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  178. package/dist/nextcloud-vue.cjs.js +16019 -9528
  179. package/dist/nextcloud-vue.cjs.js.map +2 -2
  180. package/dist/nextcloud-vue.css +1036 -19
  181. package/l10n/en.json +55 -1
  182. package/l10n/nl.json +55 -1
  183. package/package.json +1 -1
  184. package/scripts/.jsdoc-baselines.json +4 -0
  185. package/src/components/CnActionsMenu/CnActionsMenu.vue +14 -1
  186. package/src/components/CnAppNav/CnAppNav.md +26 -0
  187. package/src/components/CnAppNav/CnAppNav.vue +145 -2
  188. package/src/components/CnBannerWidget/CnBannerWidget.vue +373 -11
  189. package/src/components/CnBannerWidgetForm/CnBannerWidgetForm.vue +65 -3
  190. package/src/components/CnBoardView/CnBoardView.vue +77 -0
  191. package/src/components/CnBrandStripe/CnBrandStripe.md +22 -0
  192. package/src/components/CnBrandStripe/CnBrandStripe.vue +100 -0
  193. package/src/components/CnBrandStripe/index.js +4 -0
  194. package/src/components/CnCellRenderer/CnCellRenderer.vue +207 -1
  195. package/src/components/CnConversationThread/CnConversationThread.md +51 -0
  196. package/src/components/CnConversationThread/CnConversationThread.vue +457 -0
  197. package/src/components/CnConversationThread/CnConversationWidget.vue +144 -0
  198. package/src/components/CnConversationThread/index.js +4 -0
  199. package/src/components/CnDashboardPage/CnDashboardPage.vue +10 -2
  200. package/src/components/CnDetailPage/CnDetailPage.md +16 -0
  201. package/src/components/CnDetailPage/CnDetailPage.vue +687 -49
  202. package/src/components/CnDocumentReviewList/CnDocumentReviewList.md +55 -0
  203. package/src/components/CnDocumentReviewList/CnDocumentReviewList.vue +384 -0
  204. package/src/components/CnDocumentReviewList/CnDocumentReviewWidget.vue +108 -0
  205. package/src/components/CnDocumentReviewList/index.js +4 -0
  206. package/src/components/CnHeaderWidget/CnHeaderWidget.vue +191 -14
  207. package/src/components/CnHeaderWidgetForm/CnHeaderWidgetForm.vue +53 -3
  208. package/src/components/CnIndexPage/CnIndexPage.vue +135 -3
  209. package/src/components/CnLinkCardsPage/CnLinkCardsPage.vue +484 -0
  210. package/src/components/CnLinkCardsPage/index.js +4 -0
  211. package/src/components/CnNextStepCard/CnNextStepCard.md +40 -0
  212. package/src/components/CnNextStepCard/CnNextStepCard.vue +319 -0
  213. package/src/components/CnNextStepCard/CnNextStepWidget.vue +59 -0
  214. package/src/components/CnNextStepCard/index.js +4 -0
  215. package/src/components/CnObjectDataWidget/CnObjectDataWidget.vue +49 -0
  216. package/src/components/CnObjectDataWidgetForm/CnObjectDataWidgetForm.vue +6 -0
  217. package/src/components/CnObjectKanban/CnObjectKanban.vue +86 -2
  218. package/src/components/CnPageRenderer/pageTypes.js +1 -0
  219. package/src/components/CnQuickFilterBar/CnQuickFilterBar.md +3 -0
  220. package/src/components/CnQuickFilterBar/CnQuickFilterBar.vue +60 -1
  221. package/src/components/CnSavedViewsControl/CnSavedViewsControl.vue +50 -3
  222. package/src/components/CnSegmentedControl/CnSegmentedControl.md +46 -0
  223. package/src/components/CnSegmentedControl/CnSegmentedControl.vue +291 -0
  224. package/src/components/CnSegmentedControl/index.js +4 -0
  225. package/src/components/CnStackedBarWidget/CnStackedBarWidget.md +15 -0
  226. package/src/components/CnStackedBarWidget/CnStackedBarWidget.vue +414 -0
  227. package/src/components/CnStackedBarWidget/index.js +14 -0
  228. package/src/components/CnStackedBarWidgetForm/CnStackedBarWidgetForm.vue +224 -0
  229. package/src/components/CnStackedBarWidgetForm/index.js +4 -0
  230. package/src/components/CnTabs/CnTab.vue +35 -0
  231. package/src/components/CnTabs/CnTabs.vue +244 -10
  232. package/src/components/CnTabsWidget/CnTabsWidget.vue +63 -4
  233. package/src/components/CnWeekStripWidget/CnWeekStripWidget.md +31 -0
  234. package/src/components/CnWeekStripWidget/CnWeekStripWidget.vue +668 -0
  235. package/src/components/CnWeekStripWidget/index.js +14 -0
  236. package/src/components/CnWeekStripWidgetForm/CnWeekStripWidgetForm.vue +265 -0
  237. package/src/components/CnWeekStripWidgetForm/index.js +4 -0
  238. package/src/components/CnWidgetGrid/registerDashboardWidgets.js +74 -0
  239. package/src/components/index.js +12 -0
  240. package/src/css/detail-page.css +57 -2
  241. package/src/index.js +16 -0
  242. package/src/schemas/app-manifest-v2.schema.json +433 -3
  243. package/src/schemas/app-manifest.schema.json +2 -1
  244. package/src/types/manifest.d.ts +1 -1
  245. package/src/utils/dateVariant.js +120 -0
  246. package/src/utils/detailActionModel.js +256 -0
  247. package/src/utils/dueRule.js +94 -0
  248. package/src/utils/fetchAggregate.js +36 -0
  249. package/src/utils/fetchFilterCounts.js +182 -0
  250. package/src/utils/headers.js +28 -2
  251. package/src/utils/index.js +1 -0
  252. package/src/utils/libraryWidgetKeys.js +5 -0
  253. package/src/utils/readPath.js +27 -0
  254. package/src/utils/validateManifestV2.compiled.js +2 -2
@@ -0,0 +1,256 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ * SPDX-License-Identifier: EUPL-1.2
4
+ *
5
+ * detailActionModel: the rules behind a detail page's four action levels and
6
+ * the case surfaces that read the record's stage.
7
+ *
8
+ * 1. the next step: one primary button, chosen by the record's stage,
9
+ * 2. quick actions: at most three always visible buttons,
10
+ * 3. the overflow menu: the rest, in named groups,
11
+ * 4. admin actions: the last group, for admins only.
12
+ *
13
+ * Pure functions, no Vue and no fetch, so CnDetailPage, the widgets and the
14
+ * tests all read one implementation.
15
+ *
16
+ * @module utils/detailActionModel
17
+ */
18
+
19
+ import { readPath } from './readPath.js'
20
+ import { evaluateVisibleWhenLocal } from './visibleWhen.js'
21
+
22
+ /** The most quick actions a page shows. Later entries are dropped. */
23
+ export const MAX_QUICK_ACTIONS = 3
24
+
25
+ /**
26
+ * The record's stage, as the string a stage map is keyed on.
27
+ *
28
+ * @param {object|null} object The record.
29
+ * @param {string} [stageField] Dot-path to the stage. Defaults to `status`.
30
+ * @return {string} The stage, or '' when the record has none.
31
+ * @spec openspec/changes/detail-action-model-and-case-surfaces/specs/detail-action-model/spec.md#requirement-primary-action-follows-the-stage
32
+ */
33
+ export function stageOf(object, stageField = 'status') {
34
+ if (!object || typeof object !== 'object') {
35
+ return ''
36
+ }
37
+ const value = readPath(object, stageField || 'status')
38
+ if (value === null || value === undefined || typeof value === 'object') {
39
+ return ''
40
+ }
41
+ return String(value)
42
+ }
43
+
44
+ /**
45
+ * Look a stage up in a stage-keyed map. An exact key wins; otherwise the
46
+ * match ignores case, because a status is stored as `in_behandeling` in one
47
+ * register and `In_Behandeling` in the next.
48
+ *
49
+ * @param {object|null} map The stage-keyed map.
50
+ * @param {string} stage The stage.
51
+ * @return {unknown} The entry, or undefined.
52
+ * @spec openspec/changes/detail-action-model-and-case-surfaces/specs/detail-action-model/spec.md#requirement-primary-action-follows-the-stage
53
+ */
54
+ export function stageEntry(map, stage) {
55
+ if (!map || typeof map !== 'object' || Array.isArray(map) || stage === '') {
56
+ return undefined
57
+ }
58
+ if (Object.hasOwn(map, stage)) {
59
+ return map[stage]
60
+ }
61
+ const wanted = stage.toLowerCase()
62
+ const key = Object.keys(map).find((candidate) => candidate.toLowerCase() === wanted)
63
+ return key === undefined ? undefined : map[key]
64
+ }
65
+
66
+ /**
67
+ * Turn a pinned action entry into a descriptor the page can dispatch.
68
+ *
69
+ * An entry is the id of a declared header action, or an action object of its
70
+ * own. An object without an id gets `fallbackId`, since every dispatched
71
+ * action is addressed by id.
72
+ *
73
+ * @param {string|object|null} entry The entry.
74
+ * @param {string} fallbackId Id for an inline action that names none.
75
+ * @return {{id: string, inline: object|null}|null} The id, plus the inline definition when the entry carried one.
76
+ * @spec openspec/changes/detail-action-model-and-case-surfaces/specs/detail-action-model/spec.md#requirement-quick-actions
77
+ */
78
+ export function normalisePinnedAction(entry, fallbackId) {
79
+ if (typeof entry === 'string') {
80
+ return entry === '' ? null : { id: entry, inline: null }
81
+ }
82
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
83
+ return null
84
+ }
85
+ if (typeof entry.label !== 'string' || entry.label === '') {
86
+ return null
87
+ }
88
+ const id = typeof entry.id === 'string' && entry.id !== '' ? entry.id : fallbackId
89
+ return { id, inline: { ...entry, id } }
90
+ }
91
+
92
+ /**
93
+ * Split menu entries into the groups the overflow menu draws.
94
+ *
95
+ * Ungrouped entries come first, under no caption. Named groups follow in the
96
+ * order they first appear. Admin only entries are dropped for everyone else
97
+ * and form the last group for admins, whatever `group` they also name.
98
+ *
99
+ * `adminOnly` hides a control. It is never an authorization decision: the
100
+ * endpoint behind the action decides who may call it.
101
+ *
102
+ * @param {Array<object>} entries Menu-ready entries, each with an `id`.
103
+ * @param {object} actionsById The declared actions by id, carrying `group` / `adminOnly`.
104
+ * @param {{isAdmin?: boolean, adminLabel?: string}} [options] Who is looking, and what the admin group is called.
105
+ * @return {Array<{key: string, label: string, entries: Array<object>}>} The non-empty groups, in render order.
106
+ * @spec openspec/changes/detail-action-model-and-case-surfaces/specs/detail-action-model/spec.md#requirement-grouped-and-admin-only-menu-actions
107
+ */
108
+ export function groupMenuEntries(entries, actionsById = {}, options = {}) {
109
+ const isAdmin = options.isAdmin === true
110
+ const plain = { key: '', label: '', entries: [] }
111
+ const named = []
112
+ const admin = { key: 'cn-admin', label: options.adminLabel || '', entries: [] }
113
+ for (const entry of Array.isArray(entries) ? entries : []) {
114
+ const declared = (entry && actionsById[entry.id]) || {}
115
+ if (declared.adminOnly === true) {
116
+ if (isAdmin) {
117
+ admin.entries.push(entry)
118
+ }
119
+ continue
120
+ }
121
+ const label = typeof declared.group === 'string' ? declared.group.trim() : ''
122
+ if (label === '') {
123
+ plain.entries.push(entry)
124
+ continue
125
+ }
126
+ let group = named.find((candidate) => candidate.label === label)
127
+ if (!group) {
128
+ group = { key: `cn-group-${named.length}`, label, entries: [] }
129
+ named.push(group)
130
+ }
131
+ group.entries.push(entry)
132
+ }
133
+ return [plain, ...named, admin].filter((group) => group.entries.length > 0)
134
+ }
135
+
136
+ /**
137
+ * Whether one checklist item is done for this record.
138
+ *
139
+ * `done` (a literal) wins, then `doneWhen` (a local visibleWhen condition),
140
+ * then `doneField` (truthy, and for a list: not empty).
141
+ *
142
+ * @param {object} item The checklist item.
143
+ * @param {object|null} object The record.
144
+ * @return {boolean} True when the item is done.
145
+ * @spec openspec/changes/detail-action-model-and-case-surfaces/specs/detail-action-model/spec.md#requirement-next-step-card
146
+ */
147
+ export function isChecklistItemDone(item, object) {
148
+ if (!item || typeof item !== 'object') {
149
+ return false
150
+ }
151
+ if (typeof item.done === 'boolean') {
152
+ return item.done
153
+ }
154
+ const data = object && typeof object === 'object' ? object : {}
155
+ if (item.doneWhen && typeof item.doneWhen === 'object') {
156
+ return evaluateVisibleWhenLocal(item.doneWhen, data)
157
+ }
158
+ if (typeof item.doneField === 'string' && item.doneField !== '') {
159
+ const value = readPath(data, item.doneField)
160
+ return Array.isArray(value) ? value.length > 0 : Boolean(value)
161
+ }
162
+ return false
163
+ }
164
+
165
+ /**
166
+ * Resolve the "what now" card for the record's stage.
167
+ *
168
+ * @param {{stages?: object, title?: string}|null} config The `nextStep` config.
169
+ * @param {object|null} object The record.
170
+ * @param {string} [stageField] Dot-path to the stage.
171
+ * @return {{stage: string, title: string, items: Array<{label: string, done: boolean, hint: string}>, after: string}|null} The card, or null when this stage declares no checklist.
172
+ * @spec openspec/changes/detail-action-model-and-case-surfaces/specs/detail-action-model/spec.md#requirement-next-step-card
173
+ */
174
+ export function resolveNextStep(config, object, stageField = 'status') {
175
+ if (!config || typeof config !== 'object') {
176
+ return null
177
+ }
178
+ const stage = stageOf(object, config.field || stageField)
179
+ const declared = stageEntry(config.stages, stage)
180
+ if (!declared || typeof declared !== 'object' || !Array.isArray(declared.checklist)) {
181
+ return null
182
+ }
183
+ const items = declared.checklist
184
+ .filter((item) => item && typeof item === 'object' && typeof item.label === 'string' && item.label !== '')
185
+ .map((item) => ({
186
+ label: item.label,
187
+ done: isChecklistItemDone(item, object),
188
+ hint: typeof item.hint === 'string' ? item.hint : '',
189
+ }))
190
+ if (items.length === 0) {
191
+ return null
192
+ }
193
+ return {
194
+ stage,
195
+ title: typeof declared.title === 'string' ? declared.title : (typeof config.title === 'string' ? config.title : ''),
196
+ items,
197
+ after: typeof declared.after === 'string' ? declared.after : '',
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Resolve a header pill from the record.
203
+ *
204
+ * @param {{field?: string, colorMap?: object, labels?: object, variant?: string}|null} config The pill config.
205
+ * @param {object|null} object The record.
206
+ * @return {{label: string, colorKey: string, colorMap: object|null, variant: string}|null} What CnStatusBadge needs, or null when the field is empty.
207
+ * @spec openspec/changes/detail-action-model-and-case-surfaces/specs/detail-action-model/spec.md#requirement-header-pills
208
+ */
209
+ export function resolvePill(config, object) {
210
+ if (!config || typeof config !== 'object' || typeof config.field !== 'string' || config.field === '') {
211
+ return null
212
+ }
213
+ const raw = stageOf(object, config.field)
214
+ if (raw === '') {
215
+ return null
216
+ }
217
+ const mapped = stageEntry(config.labels, raw)
218
+ return {
219
+ label: typeof mapped === 'string' && mapped !== '' ? mapped : raw,
220
+ colorKey: raw,
221
+ colorMap: config.colorMap && typeof config.colorMap === 'object' ? config.colorMap : null,
222
+ variant: typeof config.variant === 'string' && config.variant !== '' ? config.variant : 'default',
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Resolve a tab's count.
228
+ *
229
+ * `count` (a number) wins. Otherwise `countField` is read off the record: a
230
+ * list counts its items, a number is taken as is.
231
+ *
232
+ * @param {{count?: number, countField?: string}|null} tab The tab declaration.
233
+ * @param {object|null} object The record.
234
+ * @return {number|null} The count, or null when the tab declares none.
235
+ * @spec openspec/changes/detail-action-model-and-case-surfaces/specs/detail-action-model/spec.md#requirement-tab-counts-and-overflow
236
+ */
237
+ export function resolveTabCount(tab, object) {
238
+ if (!tab || typeof tab !== 'object') {
239
+ return null
240
+ }
241
+ if (typeof tab.count === 'number' && Number.isFinite(tab.count)) {
242
+ return tab.count
243
+ }
244
+ if (typeof tab.countField !== 'string' || tab.countField === '') {
245
+ return null
246
+ }
247
+ const value = readPath(object && typeof object === 'object' ? object : {}, tab.countField)
248
+ if (Array.isArray(value)) {
249
+ return value.length
250
+ }
251
+ if (value === null || value === undefined || value === '') {
252
+ return 0
253
+ }
254
+ const number = Number(value)
255
+ return Number.isFinite(number) ? number : null
256
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ * SPDX-License-Identifier: EUPL-1.2
4
+ *
5
+ * dueRule: decides whether a dated card is overdue, due soon or fine.
6
+ *
7
+ * A board's rule is `{ field, soonDays?, variantWhen? }`. `field` is a
8
+ * dot-path to the date on the row. `soonDays` (default 3) is how many days
9
+ * ahead still counts as "soon"; a date before today is overdue.
10
+ *
11
+ * The deciding is done by `utils/dateVariant.js`, the same rules a table's
12
+ * date cell uses, so a list and its board agree on what late means.
13
+ * `{ soonDays: 3 }` is shorthand for
14
+ * `variantWhen: [{ op: 'lt', value: 0, variant: 'error' }, { op: 'lte', value: 3, variant: 'warning' }]`,
15
+ * and a rule that carries its own `variantWhen` is used as written:
16
+ * `error` reads as overdue, `warning` as due soon.
17
+ *
18
+ * @module utils/dueRule
19
+ */
20
+
21
+ import { daysUntil, parseDateValue, resolveDateVariant } from './dateVariant.js'
22
+ import { readPath } from './readPath.js'
23
+
24
+ export { daysUntil }
25
+
26
+ /** How many days ahead counts as "soon" when the rule does not say. */
27
+ export const DEFAULT_SOON_DAYS = 3
28
+
29
+ /**
30
+ * Local midnight of a date value. A bare `YYYY-MM-DD` is a LOCAL day.
31
+ *
32
+ * @param {unknown} value A Date, a timestamp or a date string.
33
+ * @return {Date|null} Local midnight of that day, or null when unreadable.
34
+ * @spec openspec/changes/detail-action-model-and-case-surfaces/specs/detail-action-model/spec.md#requirement-late-marking-on-board-cards
35
+ */
36
+ export function toLocalDay(value) {
37
+ const date = parseDateValue(value)
38
+ return date === null ? null : new Date(date.getFullYear(), date.getMonth(), date.getDate())
39
+ }
40
+
41
+ /**
42
+ * The `variantWhen` rules a due rule stands for.
43
+ *
44
+ * @param {{soonDays?: number, variantWhen?: Array<object>}|null} [rule] The due rule.
45
+ * @return {Array<{op: string, value: number, variant: string}>} The rules `resolveDateVariant` takes.
46
+ * @spec openspec/changes/detail-action-model-and-case-surfaces/specs/detail-action-model/spec.md#requirement-late-marking-on-board-cards
47
+ */
48
+ export function dueRuleToVariantWhen(rule = {}) {
49
+ if (Array.isArray(rule?.variantWhen) && rule.variantWhen.length > 0) {
50
+ return rule.variantWhen
51
+ }
52
+ const given = rule?.soonDays
53
+ const soonDays = given !== null && given !== undefined && Number.isFinite(Number(given)) ? Number(given) : DEFAULT_SOON_DAYS
54
+ return [
55
+ { op: 'lt', value: 0, variant: 'error' },
56
+ { op: 'lte', value: soonDays, variant: 'warning' },
57
+ ]
58
+ }
59
+
60
+ /**
61
+ * The due state of one date under a rule.
62
+ *
63
+ * @param {unknown} value The date.
64
+ * @param {{soonDays?: number, variantWhen?: Array<object>}|null} [rule] The rule.
65
+ * @param {Date} [now] The moment to measure from.
66
+ * @return {'overdue'|'soon'|'ok'|null} The state, or null when there is no readable date.
67
+ * @spec openspec/changes/detail-action-model-and-case-surfaces/specs/detail-action-model/spec.md#requirement-late-marking-on-board-cards
68
+ */
69
+ export function dueStateOf(value, rule = {}, now = new Date()) {
70
+ if (daysUntil(value, now) === null) {
71
+ return null
72
+ }
73
+ const variant = resolveDateVariant(value, dueRuleToVariantWhen(rule), now)
74
+ if (variant === 'error') {
75
+ return 'overdue'
76
+ }
77
+ return variant === 'warning' ? 'soon' : 'ok'
78
+ }
79
+
80
+ /**
81
+ * The due state of a row under a `{ field, soonDays }` rule.
82
+ *
83
+ * @param {object} row The row or card.
84
+ * @param {{field?: string, soonDays?: number, variantWhen?: Array<object>}|null} rule The rule. Null or field-less means no marking.
85
+ * @param {Date} [now] The moment to measure from.
86
+ * @return {'overdue'|'soon'|'ok'|null} The state, or null when the rule does not apply.
87
+ * @spec openspec/changes/detail-action-model-and-case-surfaces/specs/detail-action-model/spec.md#requirement-late-marking-on-board-cards
88
+ */
89
+ export function dueStateForRow(row, rule, now = new Date()) {
90
+ if (!rule || typeof rule.field !== 'string' || rule.field === '' || !row) {
91
+ return null
92
+ }
93
+ return dueStateOf(readPath(row, rule.field), rule, now)
94
+ }
@@ -77,3 +77,39 @@ export async function fetchAggregateValue(source, ctx) {
77
77
  const res = await axios.get(url, { params })
78
78
  return (res && res.data && res.data.value !== undefined) ? res.data.value : null
79
79
  }
80
+
81
+ /**
82
+ * Fetch the object count per distinct value of one field, in ONE request,
83
+ * from OpenRegister's `/grouped` aggregation endpoint.
84
+ *
85
+ * @param {object} source The grouped descriptor.
86
+ * @param {string} source.register The register slug.
87
+ * @param {string} source.schema The schema slug.
88
+ * @param {string} source.groupBy The field to group on.
89
+ * @param {object} [source.filter] The filter map.
90
+ * @param {{objectId?: (string|number), object?: object, workspace?: object}} [ctx] Optional
91
+ * token-resolution context for the filter.
92
+ * @return {Promise<Array<{key: string, count: number}>>} One entry per group; [] when the source is incomplete.
93
+ * @spec openspec/changes/workplace-dashboard-primitives/specs/workplace-dashboard-primitives/spec.md#requirement-stacked-bar-widget
94
+ */
95
+ export async function fetchGroupedCounts(source, ctx) {
96
+ const s = source || {}
97
+ if (!s.register || !s.schema || !s.groupBy) {
98
+ return []
99
+ }
100
+ const [{ default: axios }, { generateUrl }] = await Promise.all([
101
+ import('@nextcloud/axios'),
102
+ import('@nextcloud/router'),
103
+ ])
104
+ const url = generateUrl(
105
+ '/apps/openregister/api/objects/aggregations/{register}/{schema}/grouped',
106
+ { register: s.register, schema: s.schema },
107
+ )
108
+ const params = { groupBy: s.groupBy, metric: 'count' }
109
+ flattenAggFilter(params, s.filter || {}, ctx)
110
+ const res = await axios.get(url, { params })
111
+ const groups = (res && res.data && Array.isArray(res.data.groups)) ? res.data.groups : []
112
+ return groups
113
+ .filter((g) => g && g.key !== null && g.key !== undefined && g.key !== '')
114
+ .map((g) => ({ key: String(g.key), count: Number(g.value) || 0 }))
115
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ * SPDX-License-Identifier: EUPL-1.2
4
+ *
5
+ * fetchFilterCounts: how many records each of a set of filters matches, in as
6
+ * few requests as the API allows.
7
+ *
8
+ * Quick filters and saved views usually differ in ONE field (`status: open`,
9
+ * `status: closed`, ...). Those are answered by one `/grouped` request on that
10
+ * field. A filter that narrows on something else (two fields, an operator, a
11
+ * list of values) cannot be read off a grouped result, so each of those costs
12
+ * one `/value` count. A filter that holds a LIST of values is counted through
13
+ * the list endpoint, because the aggregation endpoints cannot express it.
14
+ * Nothing is requested for an entry that did not ask.
15
+ *
16
+ * @module utils/fetchFilterCounts
17
+ */
18
+
19
+ import { fetchAggregateValue, fetchGroupedCounts } from './fetchAggregate.js'
20
+ import { buildHeaders, buildQueryString, prefixUrl } from './headers.js'
21
+ import { resolveFilterTokens } from './resolveFilterTokens.js'
22
+
23
+ /**
24
+ * Whether a filter holds a list of values (`{ status: ['open', 'hold'] }`).
25
+ *
26
+ * The aggregation endpoints have no spelling for "one of these values", so a
27
+ * filter like this is never sent to them. It is counted through the list
28
+ * endpoint instead, which is where the page's own request sends it.
29
+ *
30
+ * @param {object} filter A filter map.
31
+ * @return {boolean} True when any value is an array.
32
+ * @spec openspec/changes/workplace-dashboard-primitives/specs/workplace-dashboard-primitives/spec.md#requirement-counts-on-filters-and-views
33
+ */
34
+ export function hasListValue(filter) {
35
+ if (!filter || typeof filter !== 'object') {
36
+ return false
37
+ }
38
+ return Object.values(filter).some((value) => Array.isArray(value))
39
+ }
40
+
41
+ /**
42
+ * Count the records a filter matches by asking the LIST endpoint for one
43
+ * record and reading its `total`. The filter is sent the way the list request
44
+ * sends it: a list of values as repeated `field[]=`, an operator as
45
+ * `field[op]=`.
46
+ *
47
+ * @param {string} register The register slug.
48
+ * @param {string} schema The schema slug.
49
+ * @param {object} filter The filter map.
50
+ * @param {object} [ctx] Token-resolution context for the filter.
51
+ * @return {Promise<number|null>} The total, or null when the response carries none.
52
+ * @spec openspec/changes/workplace-dashboard-primitives/specs/workplace-dashboard-primitives/spec.md#requirement-counts-on-filters-and-views
53
+ */
54
+ export async function fetchListTotal(register, schema, filter, ctx) {
55
+ const resolved = resolveFilterTokens(filter || {}, ctx || {})
56
+ const params = {}
57
+ for (const [key, value] of Object.entries(resolved || {})) {
58
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
59
+ for (const [op, operand] of Object.entries(value)) {
60
+ params[`${key}[${op}]`] = operand
61
+ }
62
+ } else {
63
+ params[key] = value
64
+ }
65
+ }
66
+ const qs = buildQueryString({ ...params, _limit: 1 })
67
+ const url = prefixUrl(`/apps/openregister/api/objects/${encodeURIComponent(register)}/${encodeURIComponent(schema)}${qs}`)
68
+ const response = await fetch(url, { headers: buildHeaders() })
69
+ if (!response.ok) {
70
+ throw new Error(`list returned ${response.status}`)
71
+ }
72
+ const data = await response.json()
73
+ return typeof data.total === 'number' ? data.total : null
74
+ }
75
+
76
+ /**
77
+ * The single field a filter narrows on with a plain equality, or null when the
78
+ * filter is anything else.
79
+ *
80
+ * @param {object} filter A filter map.
81
+ * @return {{field: string, value: string}|null} The field and the value it must equal.
82
+ * @spec openspec/changes/workplace-dashboard-primitives/specs/workplace-dashboard-primitives/spec.md#requirement-counts-on-filters-and-views
83
+ */
84
+ export function singleEqualityOf(filter) {
85
+ if (!filter || typeof filter !== 'object' || Array.isArray(filter)) {
86
+ return null
87
+ }
88
+ const keys = Object.keys(filter)
89
+ if (keys.length !== 1) {
90
+ return null
91
+ }
92
+ const value = filter[keys[0]]
93
+ if (value === null || value === undefined || value === '' || typeof value === 'object') {
94
+ return null
95
+ }
96
+ // A token (`@me`, `@today`) resolves to a value the grouped keys are
97
+ // compared against only after resolution, which the count request does.
98
+ if (typeof value === 'string' && value.charAt(0) === '@') {
99
+ return null
100
+ }
101
+ return { field: keys[0], value: String(value) }
102
+ }
103
+
104
+ /**
105
+ * Count the records each entry's filter matches.
106
+ *
107
+ * @param {object} options The request.
108
+ * @param {string} options.register The register slug.
109
+ * @param {string} options.schema The schema slug.
110
+ * @param {Array<{key: (string|number), filter: object}>} options.entries The filters to count, each under its own key.
111
+ * @param {object} [options.baseFilter] A filter every count is narrowed by (the page's fixed filter).
112
+ * @param {object} [options.ctx] Token-resolution context for the filters.
113
+ * @return {Promise<{[key: string]: number}>} The count per entry key. An entry whose count failed is left out.
114
+ * @spec openspec/changes/workplace-dashboard-primitives/specs/workplace-dashboard-primitives/spec.md#requirement-counts-on-filters-and-views
115
+ */
116
+ export async function fetchFilterCounts({ register, schema, entries, baseFilter, ctx } = {}) {
117
+ const counts = {}
118
+ if (!register || !schema || !Array.isArray(entries) || entries.length === 0) {
119
+ return counts
120
+ }
121
+ const base = (baseFilter && typeof baseFilter === 'object') ? baseFilter : {}
122
+
123
+ // Entries that share one equality field go into one grouped request, but
124
+ // only when at least two do: a lone entry costs one request either way,
125
+ // and `/value` returns exactly its number.
126
+ // A list of values anywhere in a request rules the aggregation endpoints
127
+ // out for it. A base filter with one rules them out for every entry.
128
+ const baseHasList = hasListValue(base)
129
+ const byField = {}
130
+ const single = []
131
+ const viaList = []
132
+ for (const entry of entries) {
133
+ if (baseHasList || hasListValue(entry.filter)) {
134
+ viaList.push(entry)
135
+ continue
136
+ }
137
+ const eq = singleEqualityOf(entry.filter)
138
+ if (eq !== null && !(eq.field in base)) {
139
+ (byField[eq.field] = byField[eq.field] || []).push({ entry, value: eq.value })
140
+ } else {
141
+ single.push(entry)
142
+ }
143
+ }
144
+ const jobs = []
145
+ for (const [field, members] of Object.entries(byField)) {
146
+ if (members.length < 2) {
147
+ single.push(members[0].entry)
148
+ continue
149
+ }
150
+ jobs.push(fetchGroupedCounts({ register, schema, groupBy: field, filter: base }, ctx)
151
+ .then((groups) => {
152
+ const map = {}
153
+ for (const group of groups) {
154
+ map[group.key] = group.count
155
+ }
156
+ for (const member of members) {
157
+ counts[member.entry.key] = map[member.value] || 0
158
+ }
159
+ })
160
+ .catch(() => {}))
161
+ }
162
+ for (const entry of single) {
163
+ jobs.push(fetchAggregateValue({ register, schema, metric: 'count', filter: { ...base, ...(entry.filter || {}) } }, ctx)
164
+ .then((value) => {
165
+ if (value !== null && Number.isFinite(Number(value))) {
166
+ counts[entry.key] = Number(value)
167
+ }
168
+ })
169
+ .catch(() => {}))
170
+ }
171
+ for (const entry of viaList) {
172
+ jobs.push(fetchListTotal(register, schema, { ...base, ...(entry.filter || {}) }, ctx)
173
+ .then((total) => {
174
+ if (total !== null) {
175
+ counts[entry.key] = total
176
+ }
177
+ })
178
+ .catch(() => {}))
179
+ }
180
+ await Promise.all(jobs)
181
+ return counts
182
+ }
@@ -130,10 +130,15 @@ export function capitalize(str) {
130
130
  *
131
131
  * Array values are serialized with PHP bracket notation (`key[]=a&key[]=b`)
132
132
  * so the Nextcloud/OpenRegister backend parses them as an array — a plain
133
- * repeated `key=a&key=b` collapses to the last value in PHP. Also handles
134
- * _order serialization (JSON.stringify for objects) and skips
133
+ * repeated `key=a&key=b` collapses to the last value in PHP. A plain object
134
+ * under a filter key is a set of operators and goes out in bracket form
135
+ * (`{ deadline: { lt: 'x' } }` becomes `deadline[lt]=x`); a key already
136
+ * written that way (`'deadline[lt]'`) passes through unchanged. Reserved keys
137
+ * that start with `_` keep their JSON form (`_order`). Skips
135
138
  * null/undefined/empty values.
136
139
  *
140
+ * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-a-filter-operator-is-serialised-in-bracket-form
141
+ *
137
142
  * @param {object} params Key-value pairs for query parameters
138
143
  * @return {string} Query string including leading '?' or empty string
139
144
  */
@@ -159,6 +164,27 @@ export function buildQueryString(params = {}) {
159
164
  queryParams.append(arrayKey, String(item))
160
165
  }
161
166
  }
167
+ } else if (typeof value === 'object' && !key.startsWith('_')) {
168
+ // A filter operator: `{ deadline: { lt: '2026-10-06' } }` goes out
169
+ // as `deadline[lt]=2026-10-06`, the form OpenRegister reads. As
170
+ // JSON it reached the database as the literal value and a date
171
+ // column answered 500. Reserved `_` keys (`_order`) keep JSON.
172
+ for (const [op, operand] of Object.entries(value)) {
173
+ if (operand === undefined || operand === null || operand === '') {
174
+ continue
175
+ }
176
+ if (Array.isArray(operand)) {
177
+ for (const item of operand) {
178
+ if (item !== undefined && item !== null && item !== '') {
179
+ queryParams.append(`${key}[${op}][]`, String(item))
180
+ }
181
+ }
182
+ } else if (typeof operand === 'object') {
183
+ queryParams.set(`${key}[${op}]`, JSON.stringify(operand))
184
+ } else {
185
+ queryParams.set(`${key}[${op}]`, String(operand))
186
+ }
187
+ }
162
188
  } else if (typeof value === 'object') {
163
189
  queryParams.set(key, JSON.stringify(value))
164
190
  } else {
@@ -11,6 +11,7 @@ export { safeHref, safeImageSrc, safeSvgPath } from './safeHref.js'
11
11
  export { followLinkClick, isModifiedClick, openRowTarget, resolveHref } from './linkNavigation.js'
12
12
  export { isNewTabHandled, isRowMiddleClick } from './rowAuxClick.js'
13
13
  export { resolveImageUrl } from './resolveImageUrl.js'
14
+ export { resolveDateVariant } from './dateVariant.js'
14
15
  export { compareVisibleWhen, evaluateVisibleWhen, evaluateVisibleWhenLocal, isLocallyDecidableVisibleWhen, readVisibleWhenPath } from './visibleWhen.js'
15
16
  export { validateFieldValue } from './formValidation.js'
16
17
  export { computeObjectDiff } from './computeObjectDiff.js'
@@ -93,10 +93,12 @@ export const DASHBOARD_CATALOG_WIDGET_KEYS = Object.freeze([
93
93
  'calendar',
94
94
  'chart',
95
95
  'container',
96
+ 'conversation',
96
97
  'countdown',
97
98
  'data',
98
99
  'delta',
99
100
  'divider',
101
+ 'document-review',
100
102
  'files',
101
103
  'flow-runs',
102
104
  'gauge',
@@ -110,6 +112,7 @@ export const DASHBOARD_CATALOG_WIDGET_KEYS = Object.freeze([
110
112
  'map',
111
113
  'menu',
112
114
  'nc-widget',
115
+ 'next-step',
113
116
  // `news` was here until the widget moved to LaunchPad, which owns the feed
114
117
  // fetching. An app that renders one now gets it from LaunchPad's registry.
115
118
  'object-geo',
@@ -119,6 +122,7 @@ export const DASHBOARD_CATALOG_WIDGET_KEYS = Object.freeze([
119
122
  'quicklinks',
120
123
  'related',
121
124
  'spend-analytics',
125
+ 'stacked-bar',
122
126
  'stages',
123
127
  'stat',
124
128
  'stats-block',
@@ -128,6 +132,7 @@ export const DASHBOARD_CATALOG_WIDGET_KEYS = Object.freeze([
128
132
  'text',
129
133
  'tile',
130
134
  'video',
135
+ 'week-strip',
131
136
  'workspace-filter',
132
137
  ])
133
138