@conduction/nextcloud-vue 2.100.0 → 2.102.0

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 (168) hide show
  1. package/dist/esm/components/CnActionsBar/CnActionsBar.vue2.js +22 -0
  2. package/dist/esm/components/CnActionsBar/CnActionsBar.vue2.js.map +1 -1
  3. package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js +8 -6
  4. package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js.map +1 -1
  5. package/dist/esm/components/CnChartWidget/CnChartWidget.vue.js +1 -1
  6. package/dist/esm/components/CnChartWidget/CnChartWidget.vue2.js +29 -4
  7. package/dist/esm/components/CnChartWidget/CnChartWidget.vue2.js.map +1 -1
  8. package/dist/esm/components/CnChartWidget/CnChartWidget.vue3.js +9 -1
  9. package/dist/esm/components/CnChartWidget/CnChartWidget.vue3.js.map +1 -1
  10. package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue.js +1 -1
  11. package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue2.js +22 -1
  12. package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue2.js.map +1 -1
  13. package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue3.js +26 -16
  14. package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue3.js.map +1 -1
  15. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue.js +1 -1
  16. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +70 -6
  17. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  18. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +105 -74
  19. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  20. package/dist/esm/components/CnExportsWidget/CnExportsWidget.vue.js +9 -0
  21. package/dist/esm/components/CnExportsWidget/CnExportsWidget.vue.js.map +1 -0
  22. package/dist/esm/components/CnExportsWidget/CnExportsWidget.vue2.js +139 -0
  23. package/dist/esm/components/CnExportsWidget/CnExportsWidget.vue2.js.map +1 -0
  24. package/dist/esm/components/CnExportsWidget/CnExportsWidget.vue3.js +76 -0
  25. package/dist/esm/components/CnExportsWidget/CnExportsWidget.vue3.js.map +1 -0
  26. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue.js +1 -1
  27. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js +160 -2
  28. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js.map +1 -1
  29. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue3.js +206 -94
  30. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue3.js.map +1 -1
  31. package/dist/esm/components/CnFilesBrowser/filesReview.js +61 -0
  32. package/dist/esm/components/CnFilesBrowser/filesReview.js.map +1 -0
  33. package/dist/esm/components/CnGroupedCardsWidget/CnGroupedCardsWidget.vue.js +9 -0
  34. package/dist/esm/components/CnGroupedCardsWidget/CnGroupedCardsWidget.vue.js.map +1 -0
  35. package/dist/esm/components/CnGroupedCardsWidget/CnGroupedCardsWidget.vue2.js +279 -0
  36. package/dist/esm/components/CnGroupedCardsWidget/CnGroupedCardsWidget.vue2.js.map +1 -0
  37. package/dist/esm/components/CnGroupedCardsWidget/CnGroupedCardsWidget.vue3.js +122 -0
  38. package/dist/esm/components/CnGroupedCardsWidget/CnGroupedCardsWidget.vue3.js.map +1 -0
  39. package/dist/esm/components/CnGroupedCardsWidget/index.js +29 -0
  40. package/dist/esm/components/CnGroupedCardsWidget/index.js.map +1 -0
  41. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +320 -74
  42. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  43. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +323 -213
  44. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  45. package/dist/esm/components/CnIndexPage/useNamedSource.js +30 -3
  46. package/dist/esm/components/CnIndexPage/useNamedSource.js.map +1 -1
  47. package/dist/esm/components/CnMapWidget/CnMapWidget.vue.js +1 -1
  48. package/dist/esm/components/CnMapWidget/CnMapWidget.vue2.js +15 -0
  49. package/dist/esm/components/CnMapWidget/CnMapWidget.vue2.js.map +1 -1
  50. package/dist/esm/components/CnMapWidget/CnMapWidget.vue3.js.map +1 -1
  51. package/dist/esm/components/CnMenuWidget/CnMenuWidget.vue.js +1 -1
  52. package/dist/esm/components/CnMenuWidget/CnMenuWidget.vue2.js +25 -1
  53. package/dist/esm/components/CnMenuWidget/CnMenuWidget.vue2.js.map +1 -1
  54. package/dist/esm/components/CnMenuWidget/CnMenuWidget.vue3.js.map +1 -1
  55. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue.js +1 -1
  56. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js +14 -1
  57. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js.map +1 -1
  58. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js +14 -6
  59. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js.map +1 -1
  60. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue.js +1 -1
  61. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js +10 -0
  62. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js.map +1 -1
  63. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js +6 -1
  64. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js.map +1 -1
  65. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue.js +1 -1
  66. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue2.js +91 -4
  67. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue2.js.map +1 -1
  68. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue3.js +63 -38
  69. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue3.js.map +1 -1
  70. package/dist/esm/components/CnQuickFilterBar/CnQuickFilterBar.vue.js +1 -1
  71. package/dist/esm/components/CnQuickFilterBar/CnQuickFilterBar.vue2.js +106 -4
  72. package/dist/esm/components/CnQuickFilterBar/CnQuickFilterBar.vue2.js.map +1 -1
  73. package/dist/esm/components/CnQuickFilterBar/CnQuickFilterBar.vue3.js +70 -25
  74. package/dist/esm/components/CnQuickFilterBar/CnQuickFilterBar.vue3.js.map +1 -1
  75. package/dist/esm/components/CnSavedViewAlertWidget/CnSavedViewAlertWidget.vue.js +9 -0
  76. package/dist/esm/components/CnSavedViewAlertWidget/CnSavedViewAlertWidget.vue.js.map +1 -0
  77. package/dist/esm/components/CnSavedViewAlertWidget/CnSavedViewAlertWidget.vue2.js +175 -0
  78. package/dist/esm/components/CnSavedViewAlertWidget/CnSavedViewAlertWidget.vue2.js.map +1 -0
  79. package/dist/esm/components/CnSavedViewAlertWidget/CnSavedViewAlertWidget.vue3.js +77 -0
  80. package/dist/esm/components/CnSavedViewAlertWidget/CnSavedViewAlertWidget.vue3.js.map +1 -0
  81. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue.js +1 -1
  82. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js +35 -2
  83. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js.map +1 -1
  84. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue3.js.map +1 -1
  85. package/dist/esm/components/CnWidgetGrid/builtInWidgets.js +4 -0
  86. package/dist/esm/components/CnWidgetGrid/builtInWidgets.js.map +1 -1
  87. package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js +1 -0
  88. package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js.map +1 -1
  89. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue.js +1 -1
  90. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue2.js +2 -23
  91. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue2.js.map +1 -1
  92. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue3.js.map +1 -1
  93. package/dist/esm/composables/indexSources.js +176 -20
  94. package/dist/esm/composables/indexSources.js.map +1 -1
  95. package/dist/esm/composables/useEndpointSource.js +28 -6
  96. package/dist/esm/composables/useEndpointSource.js.map +1 -1
  97. package/dist/esm/index.js +1 -0
  98. package/dist/esm/index.js.map +1 -1
  99. package/dist/esm/l10n/en.json.js +41 -1
  100. package/dist/esm/l10n/en.json.js.map +1 -1
  101. package/dist/esm/l10n/nl.json.js +39 -1
  102. package/dist/esm/l10n/nl.json.js.map +1 -1
  103. package/dist/esm/library-style.js +1 -1
  104. package/dist/esm/nextcloud-vue.css +939 -376
  105. package/dist/esm/utils/builtInFormatters.js +24 -4
  106. package/dist/esm/utils/builtInFormatters.js.map +1 -1
  107. package/dist/esm/utils/languageMapText.js +50 -0
  108. package/dist/esm/utils/languageMapText.js.map +1 -0
  109. package/dist/esm/utils/libraryWidgetKeys.js +3 -0
  110. package/dist/esm/utils/libraryWidgetKeys.js.map +1 -1
  111. package/dist/esm/utils/mapList.js +71 -0
  112. package/dist/esm/utils/mapList.js.map +1 -0
  113. package/dist/esm/utils/rowClassRules.js +43 -0
  114. package/dist/esm/utils/rowClassRules.js.map +1 -0
  115. package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
  116. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  117. package/dist/esm/utils/visibleWhen.js +19 -2
  118. package/dist/esm/utils/visibleWhen.js.map +1 -1
  119. package/dist/nextcloud-vue.cjs.js +9681 -7136
  120. package/dist/nextcloud-vue.cjs.js.map +2 -2
  121. package/dist/nextcloud-vue.css +939 -376
  122. package/l10n/en.json +41 -1
  123. package/l10n/nl.json +39 -1
  124. package/package.json +1 -1
  125. package/src/components/CnActionsBar/CnActionsBar.md +1 -0
  126. package/src/components/CnActionsBar/CnActionsBar.vue +31 -4
  127. package/src/components/CnChartWidget/CnChartWidget.md +3 -2
  128. package/src/components/CnChartWidget/CnChartWidget.vue +42 -4
  129. package/src/components/CnChoiceCards/CnChoiceCards.md +39 -0
  130. package/src/components/CnChoiceCards/CnChoiceCards.vue +93 -16
  131. package/src/components/CnDashboardPage/CnDashboardPage.vue +110 -21
  132. package/src/components/CnExportsWidget/CnExportsWidget.vue +233 -0
  133. package/src/components/CnExportsWidget/index.js +7 -0
  134. package/src/components/CnFilesBrowser/CnFilesBrowser.vue +324 -0
  135. package/src/components/CnFilesBrowser/filesReview.js +57 -0
  136. package/src/components/CnGroupedCardsWidget/CnGroupedCardsWidget.vue +433 -0
  137. package/src/components/CnGroupedCardsWidget/index.js +28 -0
  138. package/src/components/CnIndexPage/CnIndexPage.md +5 -1
  139. package/src/components/CnIndexPage/CnIndexPage.vue +313 -9
  140. package/src/components/CnIndexPage/useNamedSource.js +30 -3
  141. package/src/components/CnMapWidget/CnMapWidget.vue +37 -0
  142. package/src/components/CnMenuWidget/CnMenuWidget.vue +25 -1
  143. package/src/components/CnObjectListWidget/CnObjectListWidget.vue +29 -1
  144. package/src/components/CnObjectSidebar/CnFilesTab.vue +15 -0
  145. package/src/components/CnOrderedListField/CnOrderedListField.vue +151 -5
  146. package/src/components/CnQuickFilterBar/CnQuickFilterBar.md +1 -0
  147. package/src/components/CnQuickFilterBar/CnQuickFilterBar.vue +159 -5
  148. package/src/components/CnSavedViewAlertWidget/CnSavedViewAlertWidget.vue +272 -0
  149. package/src/components/CnSavedViewAlertWidget/index.js +7 -0
  150. package/src/components/CnSetupWizard/CnSetupWizard.vue +35 -2
  151. package/src/components/CnWidgetGrid/builtInWidgets.js +4 -0
  152. package/src/components/CnWidgetGrid/registerDashboardWidgets.js +1 -0
  153. package/src/components/CnWidgetObjectTable/CnWidgetObjectTable.vue +2 -23
  154. package/src/components/index.js +3 -0
  155. package/src/composables/indexSources.js +175 -19
  156. package/src/composables/useEndpointSource.js +28 -6
  157. package/src/css/index-page.css +117 -0
  158. package/src/css/look-board-index.css +10 -0
  159. package/src/css/look-board.css +44 -0
  160. package/src/index.js +1 -0
  161. package/src/schemas/app-manifest-v2.schema.json +85 -3
  162. package/src/utils/builtInFormatters.js +24 -4
  163. package/src/utils/languageMapText.js +46 -0
  164. package/src/utils/libraryWidgetKeys.js +3 -0
  165. package/src/utils/mapList.js +67 -0
  166. package/src/utils/rowClassRules.js +39 -0
  167. package/src/utils/validateManifestV2.compiled.js +3 -3
  168. package/src/utils/visibleWhen.js +19 -2
@@ -11,6 +11,8 @@ import HandBackRight from 'vue-material-design-icons/HandBackRight.vue'
11
11
  import LightningBolt from 'vue-material-design-icons/LightningBolt.vue'
12
12
  import Pencil from 'vue-material-design-icons/Pencil.vue'
13
13
  import { formatRelativeDate } from '../utils/boardDate.js'
14
+ import { fetchReferenceObjects } from '../utils/boardReferenceColumns.js'
15
+ import { fillTemplate } from '../utils/fillTemplate.js'
14
16
  import { FLOW_RUN_ERROR_STATUSES, flowRunStatuses } from '../utils/flowRunStatus.js'
15
17
  import { fetchGroupMembers, fetchPresence, fetchStatuses } from '../utils/nextcloudDirectory.js'
16
18
  import { readPath } from '../utils/readPath.js'
@@ -528,6 +530,15 @@ function tasksSource() {
528
530
  */
529
531
  loading: () => !!store.loading,
530
532
 
533
+ // The inbox pages: the page sends `limit` and `offset` and draws the
534
+ // pager from the datastore total.
535
+ pages: true,
536
+
537
+ /**
538
+ * @return {number} The datastore total, independent of the page size.
539
+ */
540
+ total: () => Number(store.total) || 0,
541
+
531
542
  /**
532
543
  * Count the inbox for a config without touching the rows on screen,
533
544
  * for a quick-filter tab with `showCount`.
@@ -648,6 +659,10 @@ function readRowsPath(body, path) {
648
659
  * requested: page-config keys must never become query parameters. The list
649
660
  * stays empty and one console error names the page.
650
661
  *
662
+ * A quick filter with `showCount` is counted with `count(config)`: the same
663
+ * request with that tab's filter, its total read at `sourceConfig.totalPath`
664
+ * (`total` by default), else the number of rows the answer holds.
665
+ *
651
666
  * @return {object} The source adapter.
652
667
  * @spec openspec/changes/index-endpoint-source/specs/index-page/spec.md#requirement-an-index-page-can-list-rows-from-an-app-endpoint
653
668
  */
@@ -656,6 +671,36 @@ function endpointSource() {
656
671
  let seq = 0
657
672
  let warned = false
658
673
 
674
+ /**
675
+ * The query parameters for a config: every key but the url and the paths,
676
+ * empty values left out, a list joined by commas.
677
+ *
678
+ * @param {object} rest The config without `url`, `rowsPath` and `totalPath`.
679
+ * @return {object} The parameters.
680
+ */
681
+ const paramsOf = (rest) => {
682
+ const params = {}
683
+ for (const [key, value] of Object.entries(rest)) {
684
+ if (value === undefined || value === null || value === '') {
685
+ continue
686
+ }
687
+ params[key] = Array.isArray(value) ? value.join(',') : (typeof value === 'object' ? JSON.stringify(value) : value)
688
+ }
689
+ return params
690
+ }
691
+
692
+ /**
693
+ * Whether a config can be requested: it names a url and is not the page
694
+ * config handed over in its place.
695
+ *
696
+ * @param {object} config The config.
697
+ * @return {boolean}
698
+ */
699
+ const requestable = (config) => {
700
+ const url = config && config.url
701
+ return typeof url === 'string' && url !== '' && !Object.hasOwn(config, 'entitySource')
702
+ }
703
+
659
704
  return {
660
705
  /**
661
706
  * Fetch the rows.
@@ -664,10 +709,9 @@ function endpointSource() {
664
709
  * @return {Promise<void>} Resolves when loaded; a failed request leaves no rows and rethrows.
665
710
  */
666
711
  load: async (config) => {
667
- const { url, rowsPath, ...rest } = config || {}
712
+ const { url, rowsPath, totalPath, ...rest } = config || {}
668
713
  const mine = ++seq
669
- const pageConfig = Boolean(config && Object.hasOwn(config, 'entitySource'))
670
- if (typeof url !== 'string' || url === '' || pageConfig) {
714
+ if (!requestable(config)) {
671
715
  state.rows = []
672
716
  if (!warned) {
673
717
  warned = true
@@ -677,13 +721,7 @@ function endpointSource() {
677
721
  }
678
722
  return
679
723
  }
680
- const params = {}
681
- for (const [key, value] of Object.entries(rest)) {
682
- if (value === undefined || value === null || value === '') {
683
- continue
684
- }
685
- params[key] = Array.isArray(value) ? value.join(',') : (typeof value === 'object' ? JSON.stringify(value) : value)
686
- }
724
+ const params = paramsOf(rest)
687
725
  state.loading = true
688
726
  try {
689
727
  const { default: axios } = await import('@nextcloud/axios')
@@ -705,6 +743,29 @@ function endpointSource() {
705
743
  }
706
744
  },
707
745
 
746
+ /**
747
+ * Count the rows for a config without touching the rows on screen, for
748
+ * a quick-filter tab with `showCount`.
749
+ *
750
+ * @param {object} config The page's source config with the tab's filter merged in.
751
+ * @return {Promise<number>} The total, or NaN when nothing can be requested.
752
+ */
753
+ count: async (config) => {
754
+ if (!requestable(config)) {
755
+ return Number.NaN
756
+ }
757
+ const { url, rowsPath, totalPath, ...rest } = config
758
+ const { default: axios } = await import('@nextcloud/axios')
759
+ const res = await axios.get(generateUrl(url), { params: paramsOf(rest) })
760
+ const body = res ? res.data : null
761
+ const total = body && typeof body === 'object' && !Array.isArray(body) ? readRowsPath(body, totalPath || 'total') : undefined
762
+ if (typeof total === 'number' && Number.isFinite(total)) {
763
+ return total
764
+ }
765
+ const rows = Array.isArray(body) ? body : readRowsPath(body, rowsPath || 'results')
766
+ return Array.isArray(rows) ? rows.length : Number.NaN
767
+ },
768
+
708
769
  /**
709
770
  * @return {Array<object>} The rows the endpoint returned.
710
771
  */
@@ -737,39 +798,102 @@ function fillRowTemplate(template, row) {
737
798
  * and its count (DqZoekresultaten).
738
799
  *
739
800
  * `sourceConfig.kinds` lists what is searched:
740
- * `{ id, label, register, schema, titleField?, subtitleField?, statusField?, href?, limit? }`
801
+ * `{ id, label, register, schema, titleField?, subtitleField?, statusField?, statusResolve?, linkedField?, linkedTemplate?, href?, limit? }`
741
802
  * for objects (OpenRegister's `_search`, so the reader sees what they may
742
803
  * read), or `{ id, label, files: true, limit? }` for the reader's files
743
804
  * (Nextcloud's unified search). The term is `sourceConfig.term`, else the
744
805
  * address's `?q=` (or `sourceConfig.termQuery`). A kind that fails shows no
745
806
  * rows and no count; the others still answer.
746
807
  *
808
+ * The kind's `label` goes through the app's translate (the "Soort" cell and
809
+ * the quick filters). `statusResolve: { register?, schema, labelField? }`
810
+ * reads `statusField` as a reference and shows the referenced object's label
811
+ * (`labelField`, else `title`, then `name`); the status column is a pill,
812
+ * coloured by `sourceConfig.statusColorMap`. `linkedTemplate` (a field
813
+ * template, "{case.title}") or `linkedField` (a path) fills the "Linked"
814
+ * column, drawn when a kind declares one; `sourceConfig.linkedLabel` names it.
815
+ * The page's title and count line can name the term as `{term}`, and the
816
+ * count is the active kind's total. Kind, link and status are filterable.
817
+ *
747
818
  * @param {object} [config] The page's source config.
819
+ * @param {object} [context] The page's helpers.
820
+ * @param {Function} [context.translate] The app's translate.
748
821
  * @return {object} The source adapter.
749
822
  * @spec openspec/changes/index-search-source/specs/index-page/spec.md#requirement-an-index-page-can-search-several-schemas-and-files
750
823
  */
751
- function searchSource(config = {}) {
824
+ function searchSource(config = {}, context = {}) {
825
+ const translate = typeof context.translate === 'function' ? context.translate : (text) => text
752
826
  const kinds = (Array.isArray(config && config.kinds) ? config.kinds : [])
753
827
  .filter((kind) => kind && typeof kind.id === 'string' && kind.id !== '' && (kind.files === true || (kind.register && kind.schema)))
754
- const state = reactive({ rows: [], totals: {}, loading: false, kind: 'all', term: '' })
828
+ .map((kind) => ({ ...kind, label: translate(String(kind.label || kind.id)) }))
829
+ const hasLinked = kinds.some((kind) => kind.linkedTemplate || kind.linkedField)
830
+ const state = reactive({ rows: [], totals: {}, loading: false, kind: 'all', term: '', filters: {} })
755
831
  let seq = 0
756
832
 
833
+ /**
834
+ * The labels of the objects a kind's status points at, by id: the
835
+ * referenced schema read once per search.
836
+ *
837
+ * @param {object} kind The kind with `statusResolve`.
838
+ * @return {Promise<Map<string, string>>} Labels by id; empty when it cannot be read.
839
+ */
840
+ const statusLabels = async (kind) => {
841
+ const resolve = kind.statusResolve
842
+ if (!resolve || !resolve.schema) {
843
+ return new Map()
844
+ }
845
+ const objects = await fetchReferenceObjects({ register: resolve.register || kind.register, schema: resolve.schema }).catch(() => [])
846
+ const labels = new Map()
847
+ for (const object of objects) {
848
+ const id = object.id || object.uuid || (object['@self'] && object['@self'].id)
849
+ const label = readPath(object, resolve.labelField || 'title') ?? object.name
850
+ if (id !== undefined && label !== undefined && label !== null) {
851
+ labels.set(String(id), String(label))
852
+ }
853
+ }
854
+ return labels
855
+ }
856
+
857
+ /**
858
+ * What a row shows in the Linked column.
859
+ *
860
+ * @param {object} kind The row's kind.
861
+ * @param {object} object The found object.
862
+ * @return {string}
863
+ */
864
+ const linkedOf = (kind, object) => {
865
+ if (kind.linkedTemplate) {
866
+ return fillTemplate(translate(kind.linkedTemplate), object)
867
+ }
868
+ if (kind.linkedField) {
869
+ const value = readPath(object, kind.linkedField)
870
+ return value === undefined || value === null ? '' : String(value)
871
+ }
872
+ return ''
873
+ }
874
+
757
875
  const searchObjects = async (axios, kind, term) => {
758
876
  const url = generateUrl('/apps/openregister/api/objects/{register}/{schema}', { register: kind.register, schema: kind.schema })
759
877
  const res = await axios.get(url, { params: { _search: term, _limit: kind.limit || 20 } })
760
878
  const body = (res && res.data) || {}
761
879
  const results = Array.isArray(body.results) ? body.results : (Array.isArray(body) ? body : [])
880
+ const labels = kind.statusResolve && kind.statusField ? await statusLabels(kind) : null
762
881
  return {
763
882
  total: typeof body.total === 'number' ? body.total : results.length,
764
883
  rows: results.map((object) => {
765
884
  const id = object.id || object.uuid || (object['@self'] && object['@self'].id)
885
+ const rawStatus = kind.statusField ? readPath(object, kind.statusField) : undefined
886
+ const status = rawStatus === undefined || rawStatus === null
887
+ ? ''
888
+ : (labels ? (labels.get(String(rawStatus)) || '') : String(rawStatus))
766
889
  return {
767
890
  id: `${kind.id}:${id}`,
768
891
  kind: kind.id,
769
892
  kindLabel: kind.label,
770
893
  title: String(readPath(object, kind.titleField || 'title') ?? object.name ?? id ?? ''),
771
894
  subtitle: kind.subtitleField ? String(readPath(object, kind.subtitleField) ?? '') : '',
772
- status: kind.statusField ? String(readPath(object, kind.statusField) ?? '') : '',
895
+ status,
896
+ linked: linkedOf(kind, object),
773
897
  href: kind.href ? generateUrl(fillRowTemplate(kind.href, { ...object, id })) : '',
774
898
  object,
775
899
  }
@@ -792,6 +916,7 @@ function searchSource(config = {}) {
792
916
  title: entry.title || '',
793
917
  subtitle: entry.subline || '',
794
918
  status: '',
919
+ linked: '',
795
920
  href: entry.resourceUrl || '',
796
921
  })),
797
922
  }
@@ -806,6 +931,17 @@ function searchSource(config = {}) {
806
931
  */
807
932
  load: async (cfg) => {
808
933
  state.kind = cfg && typeof cfg.kind === 'string' ? cfg.kind : 'all'
934
+ // The header filters on Kind, Linked and Status narrow the answer
935
+ // already fetched; they do not search again.
936
+ const filters = {}
937
+ for (const key of ['kindLabel', 'linked', 'status']) {
938
+ const value = cfg && cfg[key]
939
+ const values = (Array.isArray(value) ? value : (value === undefined || value === null || value === '' ? [] : [value])).map(String)
940
+ if (values.length > 0) {
941
+ filters[key] = values
942
+ }
943
+ }
944
+ state.filters = filters
809
945
  const query = typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : null
810
946
  const term = String((cfg && cfg.term) || (query && query.get((cfg && cfg.termQuery) || 'q')) || '').trim()
811
947
  const mine = ++seq
@@ -837,7 +973,20 @@ function searchSource(config = {}) {
837
973
  /**
838
974
  * @return {Array<object>} The results of the active kind, or all of them.
839
975
  */
840
- rows: () => (state.kind === 'all' ? state.rows : state.rows.filter((row) => row.kind === state.kind)),
976
+ rows: () => (state.kind === 'all' ? state.rows : state.rows.filter((row) => row.kind === state.kind))
977
+ .filter((row) => Object.entries(state.filters).every(([key, values]) => values.includes(String(row[key])))),
978
+
979
+ /**
980
+ * @return {string} The term searched for, for a `{term}` in the title or count line.
981
+ */
982
+ term: () => state.term,
983
+
984
+ /**
985
+ * @return {number} The active kind's total, or every kind's together on All.
986
+ */
987
+ total: () => (state.kind === 'all'
988
+ ? Object.values(state.totals).reduce((sum, value) => sum + value, 0)
989
+ : (state.totals[state.kind] || 0)),
841
990
 
842
991
  /**
843
992
  * @return {boolean} Whether a search is in flight.
@@ -846,8 +995,14 @@ function searchSource(config = {}) {
846
995
 
847
996
  columns: [
848
997
  { key: 'title', label: t('nextcloud-vue', 'Result'), secondary: 'subtitle' },
849
- { key: 'kindLabel', label: t('nextcloud-vue', 'Kind') },
850
- { key: 'status', label: t('nextcloud-vue', 'Status') },
998
+ { key: 'kindLabel', label: t('nextcloud-vue', 'Kind'), enum: kinds.map((kind) => kind.label) },
999
+ ...(hasLinked ? [{ key: 'linked', label: config.linkedLabel ? translate(config.linkedLabel) : t('nextcloud-vue', 'Linked') }] : []),
1000
+ {
1001
+ key: 'status',
1002
+ label: t('nextcloud-vue', 'Status'),
1003
+ widget: 'badge',
1004
+ widgetProps: { colorMap: (config && config.statusColorMap) || {} },
1005
+ },
851
1006
  ],
852
1007
 
853
1008
  quickFilters: kinds.length > 0
@@ -1125,10 +1280,11 @@ export const indexSources = {
1125
1280
  *
1126
1281
  * @param {string} name The source name from `config.source`.
1127
1282
  * @param {object} [config] The page's source config, for a source that builds its tabs from it.
1283
+ * @param {object} [context] The page's helpers a source may use, such as `translate` (the app's catalogue).
1128
1284
  *
1129
1285
  * @return {object|null} The adapter, or null when the name is not registered.
1130
1286
  */
1131
- export function resolveIndexSource(name, config) {
1287
+ export function resolveIndexSource(name, config, context) {
1132
1288
  const key = String(name || '')
1133
1289
  if (!key) {
1134
1290
  return null
@@ -1141,7 +1297,7 @@ export function resolveIndexSource(name, config) {
1141
1297
  return null
1142
1298
  }
1143
1299
 
1144
- return factory(config || {})
1300
+ return factory(config || {}, context || {})
1145
1301
  }
1146
1302
 
1147
1303
  /**
@@ -155,7 +155,7 @@ export function interpolateUrlTokens(str, ctx) {
155
155
  *
156
156
  * @param {{url: string, method?: string, params?: object}} config The endpointSource block.
157
157
  * @param {{objectId?: (string|number), object?: object, workspace?: object, config?: object, range?: object}} [ctx] The token context.
158
- * @return {{url: string, method: ('GET'|'POST'), params: object, blocked: boolean}} The resolved request.
158
+ * @return {{url: string, method: ('GET'|'POST'), params: object, blocked: boolean, responsePath: string}} The resolved request.
159
159
  */
160
160
  export function resolveEndpointRequest(config, ctx) {
161
161
  const cfg = config || {}
@@ -163,8 +163,30 @@ export function resolveEndpointRequest(config, ctx) {
163
163
  const resolved = resolveFilterTokens(cfg.params || {}, ctx)
164
164
  const params = dropOptionalUnresolved(resolved)
165
165
  const method = String(cfg.method || 'GET').toUpperCase() === 'POST' ? 'POST' : 'GET'
166
- const blocked = hasUnresolvedTokens(params) || hasEmptyPathSegment(cfg.url || '', url)
167
- return { url, method, params, blocked }
166
+ const responsePath = resolveResponsePath(cfg.responsePath, ctx)
167
+ const blocked = hasUnresolvedTokens(params) || hasEmptyPathSegment(cfg.url || '', url) || responsePath === null
168
+ return { url, method, params, blocked, responsePath: responsePath || '' }
169
+ }
170
+
171
+ /**
172
+ * Resolve the tokens in a `responsePath` (`phaseDays.@workspace.measure`), so a
173
+ * page control can choose which part of one response a widget reads. Null when
174
+ * a token resolved to nothing and left an empty path segment: the read waits
175
+ * instead of plucking the wrong part.
176
+ *
177
+ * @param {string|undefined} path The configured path.
178
+ * @param {object} [ctx] The token context.
179
+ * @return {string|null} The resolved path, '' for none, null when blocked.
180
+ */
181
+ function resolveResponsePath(path, ctx) {
182
+ if (typeof path !== 'string' || path === '') {
183
+ return ''
184
+ }
185
+ if (path.indexOf('@') === -1) {
186
+ return path
187
+ }
188
+ const resolved = interpolateUrlTokens(path, ctx)
189
+ return resolved.split('.').some((segment) => segment === '') ? null : resolved
168
190
  }
169
191
 
170
192
  /**
@@ -309,7 +331,7 @@ export async function fetchEndpointSource(config, ctx, opts) {
309
331
  return null
310
332
  }
311
333
  const body = await fetchSharedResponse(request, opts)
312
- const payload = getByPath(body, config.responsePath)
334
+ const payload = getByPath(body, request.responsePath)
313
335
  return payload === undefined ? null : payload
314
336
  }
315
337
 
@@ -378,7 +400,7 @@ export function useEndpointSource(source, options) {
378
400
  u: request.url,
379
401
  m: request.method,
380
402
  p: request.params,
381
- r: cfg.responsePath || '',
403
+ r: request.responsePath,
382
404
  b: request.blocked,
383
405
  })
384
406
  })
@@ -418,7 +440,7 @@ export function useEndpointSource(source, options) {
418
440
  if (seq !== fetchSeq) {
419
441
  return
420
442
  }
421
- const payload = getByPath(body, cfg.responsePath)
443
+ const payload = getByPath(body, request.responsePath)
422
444
  data.value = payload === undefined ? null : payload
423
445
  } catch (e) {
424
446
  if (seq !== fetchSeq) {
@@ -73,6 +73,123 @@
73
73
  min-height: 300px;
74
74
  }
75
75
 
76
+ /* The map with its numbered list (`mapConfig.list`, DqZakenKaart): one framed
77
+ card, the map taking the larger share and the list beside it, stacking
78
+ under a narrow width. */
79
+ .cn-index-page__map-card {
80
+ display: flex;
81
+ flex: 1;
82
+ flex-wrap: wrap;
83
+ gap: 16px;
84
+ min-height: 0;
85
+ padding: 16px;
86
+ border: 1px solid var(--color-border);
87
+ border-radius: var(--cn-board-card-radius, 12px);
88
+ background: var(--color-main-background);
89
+ }
90
+
91
+ .cn-index-page__map-card .cn-index-page__map {
92
+ flex: 3 1 420px;
93
+ min-width: 0;
94
+ }
95
+
96
+ .cn-index-page__map-side {
97
+ display: flex;
98
+ flex: 2 1 320px;
99
+ flex-direction: column;
100
+ gap: 12px;
101
+ min-width: 0;
102
+ overflow-y: auto;
103
+ }
104
+
105
+ .cn-index-page__map-list {
106
+ display: flex;
107
+ flex-direction: column;
108
+ margin: 0;
109
+ padding: 0;
110
+ border: 1px solid var(--color-border);
111
+ border-radius: 10px;
112
+ list-style: none;
113
+ }
114
+
115
+ .cn-index-page__map-item {
116
+ display: flex;
117
+ gap: 12px;
118
+ padding: 12px 14px;
119
+ }
120
+
121
+ .cn-index-page__map-item + .cn-index-page__map-item {
122
+ border-top: 1px solid var(--color-border);
123
+ }
124
+
125
+ .cn-index-page__map-item--active {
126
+ background: var(--color-primary-element-light);
127
+ }
128
+
129
+ .cn-index-page__map-number {
130
+ display: inline-flex;
131
+ flex: none;
132
+ align-items: center;
133
+ justify-content: center;
134
+ width: 26px;
135
+ height: 26px;
136
+ border-radius: 13px;
137
+ background: var(--color-primary-element);
138
+ color: var(--color-primary-element-text);
139
+ font-size: 13px;
140
+ font-weight: 700;
141
+ }
142
+
143
+ .cn-index-page__map-item--active .cn-index-page__map-number {
144
+ background: var(--color-primary-element-light-text);
145
+ }
146
+
147
+ .cn-index-page__map-text {
148
+ display: flex;
149
+ flex: 1;
150
+ flex-direction: column;
151
+ gap: 4px;
152
+ min-width: 0;
153
+ }
154
+
155
+ .cn-index-page__map-title {
156
+ margin: 0;
157
+ padding: 0;
158
+ border: 0;
159
+ background: none;
160
+ color: var(--color-main-text);
161
+ font: inherit;
162
+ font-size: 15px;
163
+ font-weight: 600;
164
+ line-height: 1.35;
165
+ text-align: start;
166
+ cursor: pointer;
167
+ }
168
+
169
+ .cn-index-page__map-sub,
170
+ .cn-index-page__map-due {
171
+ color: var(--color-text-maxcontrast);
172
+ font-size: 13px;
173
+ }
174
+
175
+ .cn-index-page__map-pills {
176
+ display: flex;
177
+ flex-wrap: wrap;
178
+ align-items: center;
179
+ gap: 6px;
180
+ }
181
+
182
+ .cn-index-page__map-missing {
183
+ display: flex;
184
+ flex-direction: column;
185
+ align-items: flex-start;
186
+ gap: 8px;
187
+ padding: 14px;
188
+ border: 1px solid var(--color-border);
189
+ border-radius: 10px;
190
+ font-size: 14px;
191
+ }
192
+
76
193
  /* Opt-in folder navigation pane (left of the list). */
77
194
  .cn-index-page__folder-pane {
78
195
  flex: 0 0 240px;
@@ -128,6 +128,16 @@
128
128
  gap: 8px;
129
129
  }
130
130
 
131
+ /* `quickFilterPlacement: "toolbar"` (DqTeamwachtrij): the chips take the room
132
+ the Filter button and the view switch leave on row 1, so the three stay on
133
+ one row in the column beside a side panel; the chips that do not fit move
134
+ into the overflow chip (CnQuickFilterBar `fit`) instead of pushing the
135
+ buttons onto a row of their own. */
136
+ .cn-look-board.cn-index-page--quick-filters-toolbar .cn-actions-bar--board > .cn-actions-bar__row--views > .cn-quick-filter-bar {
137
+ flex: 1 1 0;
138
+ min-width: 0;
139
+ }
140
+
131
141
  /* Row 2: the search field and the active filters on a line of their own. */
132
142
  .cn-look-board .cn-actions-bar--board > .cn-actions-bar__row--search {
133
143
  display: flex;
@@ -99,6 +99,50 @@
99
99
  padding-inline-start: 56px;
100
100
  }
101
101
 
102
+ /* The dashboard header as the board draws it (DqTermijnen): the actions on
103
+ the title's row, the description and its segmented period under both, at
104
+ the header's full width. As two flex columns the description sat in the
105
+ title's column only, so a long period group wrapped under its text and the
106
+ actions dropped to a line of their own. The title column never shrinks
107
+ below its longest word; the actions wrap their buttons first.
108
+ @spec openspec/changes/dashboard-board-header-row/specs/dashboard-page/spec.md#requirement-the-board-dashboard-header-keeps-its-actions-on-the-title-row */
109
+ .cn-look-board .cn-dashboard-page:not(.cn-look-nextcloud *) .cn-dashboard-page__header {
110
+ display: grid;
111
+ grid-template-columns: minmax(min-content, 1fr) auto;
112
+ align-items: start;
113
+ column-gap: 16px;
114
+ row-gap: 0;
115
+ }
116
+
117
+ .cn-look-board .cn-dashboard-page:not(.cn-look-nextcloud *) .cn-dashboard-page__header-left {
118
+ display: contents;
119
+ }
120
+
121
+ .cn-look-board .cn-dashboard-page:not(.cn-look-nextcloud *) .cn-dashboard-page__title-row {
122
+ grid-column: 1;
123
+ grid-row: 1;
124
+ align-self: center;
125
+ }
126
+
127
+ .cn-look-board .cn-dashboard-page:not(.cn-look-nextcloud *) .cn-dashboard-page__header-left > :not(.cn-dashboard-page__title-row) {
128
+ grid-column: 1 / -1;
129
+ }
130
+
131
+ .cn-look-board .cn-dashboard-page:not(.cn-look-nextcloud *) .cn-dashboard-page__header > .cn-dashboard-page__header-actions {
132
+ grid-column: 2;
133
+ grid-row: 1;
134
+ justify-content: flex-end;
135
+ margin-inline-start: 0;
136
+ }
137
+
138
+ /* A stat tile in the board's KPI row carries the board's own 18px 20px inset
139
+ (`cn-kpi-card--board`), so the card-fit cell adds none of its own: with
140
+ both the label sat 29px from the tile's top and 37px from its side, and
141
+ the row stood about 1.4 times the board's height. */
142
+ .cn-look-board .cn-dashboard-page:not(.cn-look-nextcloud *) .cn-dashboard-page__kpi-cell .cn-dashboard-page__card-fit .cn-widget-wrapper__content:has(> .cn-kpi-card--board.cn-kpi-card--stacked) {
143
+ padding: 0;
144
+ }
145
+
102
146
  /* ---- The header button ------------------------------------------------- */
103
147
  .cn-look-board .cn-index-page__header-buttons:not(.cn-look-nextcloud *),
104
148
  .cn-look-board .cn-detail-page__header-actions:not(.cn-look-nextcloud *),
package/src/index.js CHANGED
@@ -158,6 +158,7 @@ export {
158
158
  CnGaugeWidget,
159
159
  CnGaugeWidgetForm,
160
160
  CnGraphCanvas,
161
+ CnGroupedCardsWidget,
161
162
  CnGuardianHome,
162
163
  CnHeaderWidget,
163
164
  CnHeaderWidgetForm,