@conduction/nextcloud-vue 2.100.0 → 2.101.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 (127) hide show
  1. package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue.js +1 -1
  2. package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue2.js +22 -1
  3. package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue2.js.map +1 -1
  4. package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue3.js +26 -16
  5. package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue3.js.map +1 -1
  6. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue.js +1 -1
  7. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +51 -7
  8. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  9. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +104 -73
  10. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  11. package/dist/esm/components/CnExportsWidget/CnExportsWidget.vue.js +9 -0
  12. package/dist/esm/components/CnExportsWidget/CnExportsWidget.vue.js.map +1 -0
  13. package/dist/esm/components/CnExportsWidget/CnExportsWidget.vue2.js +139 -0
  14. package/dist/esm/components/CnExportsWidget/CnExportsWidget.vue2.js.map +1 -0
  15. package/dist/esm/components/CnExportsWidget/CnExportsWidget.vue3.js +76 -0
  16. package/dist/esm/components/CnExportsWidget/CnExportsWidget.vue3.js.map +1 -0
  17. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue.js +1 -1
  18. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js +160 -2
  19. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js.map +1 -1
  20. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue3.js +206 -94
  21. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue3.js.map +1 -1
  22. package/dist/esm/components/CnFilesBrowser/filesReview.js +61 -0
  23. package/dist/esm/components/CnFilesBrowser/filesReview.js.map +1 -0
  24. package/dist/esm/components/CnGroupedCardsWidget/CnGroupedCardsWidget.vue.js +9 -0
  25. package/dist/esm/components/CnGroupedCardsWidget/CnGroupedCardsWidget.vue.js.map +1 -0
  26. package/dist/esm/components/CnGroupedCardsWidget/CnGroupedCardsWidget.vue2.js +279 -0
  27. package/dist/esm/components/CnGroupedCardsWidget/CnGroupedCardsWidget.vue2.js.map +1 -0
  28. package/dist/esm/components/CnGroupedCardsWidget/CnGroupedCardsWidget.vue3.js +122 -0
  29. package/dist/esm/components/CnGroupedCardsWidget/CnGroupedCardsWidget.vue3.js.map +1 -0
  30. package/dist/esm/components/CnGroupedCardsWidget/index.js +29 -0
  31. package/dist/esm/components/CnGroupedCardsWidget/index.js.map +1 -0
  32. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +251 -71
  33. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  34. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +317 -209
  35. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  36. package/dist/esm/components/CnIndexPage/useNamedSource.js +4 -1
  37. package/dist/esm/components/CnIndexPage/useNamedSource.js.map +1 -1
  38. package/dist/esm/components/CnMapWidget/CnMapWidget.vue.js +1 -1
  39. package/dist/esm/components/CnMapWidget/CnMapWidget.vue2.js +15 -0
  40. package/dist/esm/components/CnMapWidget/CnMapWidget.vue2.js.map +1 -1
  41. package/dist/esm/components/CnMapWidget/CnMapWidget.vue3.js.map +1 -1
  42. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue.js +1 -1
  43. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js +14 -1
  44. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js.map +1 -1
  45. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js +14 -6
  46. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js.map +1 -1
  47. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue.js +1 -1
  48. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js +10 -0
  49. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js.map +1 -1
  50. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js +6 -1
  51. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js.map +1 -1
  52. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue.js +1 -1
  53. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue2.js +91 -4
  54. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue2.js.map +1 -1
  55. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue3.js +63 -38
  56. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue3.js.map +1 -1
  57. package/dist/esm/components/CnSavedViewAlertWidget/CnSavedViewAlertWidget.vue.js +9 -0
  58. package/dist/esm/components/CnSavedViewAlertWidget/CnSavedViewAlertWidget.vue.js.map +1 -0
  59. package/dist/esm/components/CnSavedViewAlertWidget/CnSavedViewAlertWidget.vue2.js +175 -0
  60. package/dist/esm/components/CnSavedViewAlertWidget/CnSavedViewAlertWidget.vue2.js.map +1 -0
  61. package/dist/esm/components/CnSavedViewAlertWidget/CnSavedViewAlertWidget.vue3.js +77 -0
  62. package/dist/esm/components/CnSavedViewAlertWidget/CnSavedViewAlertWidget.vue3.js.map +1 -0
  63. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue.js +1 -1
  64. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js +35 -2
  65. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js.map +1 -1
  66. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue3.js.map +1 -1
  67. package/dist/esm/components/CnWidgetGrid/builtInWidgets.js +4 -0
  68. package/dist/esm/components/CnWidgetGrid/builtInWidgets.js.map +1 -1
  69. package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js +1 -0
  70. package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js.map +1 -1
  71. package/dist/esm/composables/indexSources.js +167 -20
  72. package/dist/esm/composables/indexSources.js.map +1 -1
  73. package/dist/esm/composables/useEndpointSource.js +28 -6
  74. package/dist/esm/composables/useEndpointSource.js.map +1 -1
  75. package/dist/esm/index.js +1 -0
  76. package/dist/esm/index.js.map +1 -1
  77. package/dist/esm/l10n/en.json.js +37 -1
  78. package/dist/esm/l10n/en.json.js.map +1 -1
  79. package/dist/esm/l10n/nl.json.js +35 -1
  80. package/dist/esm/l10n/nl.json.js.map +1 -1
  81. package/dist/esm/library-style.js +1 -1
  82. package/dist/esm/nextcloud-vue.css +769 -251
  83. package/dist/esm/utils/languageMapText.js +50 -0
  84. package/dist/esm/utils/languageMapText.js.map +1 -0
  85. package/dist/esm/utils/libraryWidgetKeys.js +3 -0
  86. package/dist/esm/utils/libraryWidgetKeys.js.map +1 -1
  87. package/dist/esm/utils/mapList.js +71 -0
  88. package/dist/esm/utils/mapList.js.map +1 -0
  89. package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
  90. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  91. package/dist/nextcloud-vue.cjs.js +8977 -6843
  92. package/dist/nextcloud-vue.cjs.js.map +2 -2
  93. package/dist/nextcloud-vue.css +769 -251
  94. package/l10n/en.json +37 -1
  95. package/l10n/nl.json +35 -1
  96. package/package.json +1 -1
  97. package/src/components/CnChoiceCards/CnChoiceCards.md +39 -0
  98. package/src/components/CnChoiceCards/CnChoiceCards.vue +93 -16
  99. package/src/components/CnDashboardPage/CnDashboardPage.vue +89 -20
  100. package/src/components/CnExportsWidget/CnExportsWidget.vue +233 -0
  101. package/src/components/CnExportsWidget/index.js +7 -0
  102. package/src/components/CnFilesBrowser/CnFilesBrowser.vue +324 -0
  103. package/src/components/CnFilesBrowser/filesReview.js +57 -0
  104. package/src/components/CnGroupedCardsWidget/CnGroupedCardsWidget.vue +433 -0
  105. package/src/components/CnGroupedCardsWidget/index.js +28 -0
  106. package/src/components/CnIndexPage/CnIndexPage.vue +240 -4
  107. package/src/components/CnIndexPage/useNamedSource.js +4 -1
  108. package/src/components/CnMapWidget/CnMapWidget.vue +37 -0
  109. package/src/components/CnObjectListWidget/CnObjectListWidget.vue +29 -1
  110. package/src/components/CnObjectSidebar/CnFilesTab.vue +15 -0
  111. package/src/components/CnOrderedListField/CnOrderedListField.vue +151 -5
  112. package/src/components/CnSavedViewAlertWidget/CnSavedViewAlertWidget.vue +272 -0
  113. package/src/components/CnSavedViewAlertWidget/index.js +7 -0
  114. package/src/components/CnSetupWizard/CnSetupWizard.vue +35 -2
  115. package/src/components/CnWidgetGrid/builtInWidgets.js +4 -0
  116. package/src/components/CnWidgetGrid/registerDashboardWidgets.js +1 -0
  117. package/src/components/index.js +3 -0
  118. package/src/composables/indexSources.js +166 -19
  119. package/src/composables/useEndpointSource.js +28 -6
  120. package/src/css/index-page.css +117 -0
  121. package/src/css/look-board.css +44 -0
  122. package/src/index.js +1 -0
  123. package/src/schemas/app-manifest-v2.schema.json +31 -1
  124. package/src/utils/languageMapText.js +46 -0
  125. package/src/utils/libraryWidgetKeys.js +3 -0
  126. package/src/utils/mapList.js +67 -0
  127. package/src/utils/validateManifestV2.compiled.js +3 -3
@@ -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'
@@ -648,6 +650,10 @@ function readRowsPath(body, path) {
648
650
  * requested: page-config keys must never become query parameters. The list
649
651
  * stays empty and one console error names the page.
650
652
  *
653
+ * A quick filter with `showCount` is counted with `count(config)`: the same
654
+ * request with that tab's filter, its total read at `sourceConfig.totalPath`
655
+ * (`total` by default), else the number of rows the answer holds.
656
+ *
651
657
  * @return {object} The source adapter.
652
658
  * @spec openspec/changes/index-endpoint-source/specs/index-page/spec.md#requirement-an-index-page-can-list-rows-from-an-app-endpoint
653
659
  */
@@ -656,6 +662,36 @@ function endpointSource() {
656
662
  let seq = 0
657
663
  let warned = false
658
664
 
665
+ /**
666
+ * The query parameters for a config: every key but the url and the paths,
667
+ * empty values left out, a list joined by commas.
668
+ *
669
+ * @param {object} rest The config without `url`, `rowsPath` and `totalPath`.
670
+ * @return {object} The parameters.
671
+ */
672
+ const paramsOf = (rest) => {
673
+ const params = {}
674
+ for (const [key, value] of Object.entries(rest)) {
675
+ if (value === undefined || value === null || value === '') {
676
+ continue
677
+ }
678
+ params[key] = Array.isArray(value) ? value.join(',') : (typeof value === 'object' ? JSON.stringify(value) : value)
679
+ }
680
+ return params
681
+ }
682
+
683
+ /**
684
+ * Whether a config can be requested: it names a url and is not the page
685
+ * config handed over in its place.
686
+ *
687
+ * @param {object} config The config.
688
+ * @return {boolean}
689
+ */
690
+ const requestable = (config) => {
691
+ const url = config && config.url
692
+ return typeof url === 'string' && url !== '' && !Object.hasOwn(config, 'entitySource')
693
+ }
694
+
659
695
  return {
660
696
  /**
661
697
  * Fetch the rows.
@@ -664,10 +700,9 @@ function endpointSource() {
664
700
  * @return {Promise<void>} Resolves when loaded; a failed request leaves no rows and rethrows.
665
701
  */
666
702
  load: async (config) => {
667
- const { url, rowsPath, ...rest } = config || {}
703
+ const { url, rowsPath, totalPath, ...rest } = config || {}
668
704
  const mine = ++seq
669
- const pageConfig = Boolean(config && Object.hasOwn(config, 'entitySource'))
670
- if (typeof url !== 'string' || url === '' || pageConfig) {
705
+ if (!requestable(config)) {
671
706
  state.rows = []
672
707
  if (!warned) {
673
708
  warned = true
@@ -677,13 +712,7 @@ function endpointSource() {
677
712
  }
678
713
  return
679
714
  }
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
- }
715
+ const params = paramsOf(rest)
687
716
  state.loading = true
688
717
  try {
689
718
  const { default: axios } = await import('@nextcloud/axios')
@@ -705,6 +734,29 @@ function endpointSource() {
705
734
  }
706
735
  },
707
736
 
737
+ /**
738
+ * Count the rows for a config without touching the rows on screen, for
739
+ * a quick-filter tab with `showCount`.
740
+ *
741
+ * @param {object} config The page's source config with the tab's filter merged in.
742
+ * @return {Promise<number>} The total, or NaN when nothing can be requested.
743
+ */
744
+ count: async (config) => {
745
+ if (!requestable(config)) {
746
+ return Number.NaN
747
+ }
748
+ const { url, rowsPath, totalPath, ...rest } = config
749
+ const { default: axios } = await import('@nextcloud/axios')
750
+ const res = await axios.get(generateUrl(url), { params: paramsOf(rest) })
751
+ const body = res ? res.data : null
752
+ const total = body && typeof body === 'object' && !Array.isArray(body) ? readRowsPath(body, totalPath || 'total') : undefined
753
+ if (typeof total === 'number' && Number.isFinite(total)) {
754
+ return total
755
+ }
756
+ const rows = Array.isArray(body) ? body : readRowsPath(body, rowsPath || 'results')
757
+ return Array.isArray(rows) ? rows.length : Number.NaN
758
+ },
759
+
708
760
  /**
709
761
  * @return {Array<object>} The rows the endpoint returned.
710
762
  */
@@ -737,39 +789,102 @@ function fillRowTemplate(template, row) {
737
789
  * and its count (DqZoekresultaten).
738
790
  *
739
791
  * `sourceConfig.kinds` lists what is searched:
740
- * `{ id, label, register, schema, titleField?, subtitleField?, statusField?, href?, limit? }`
792
+ * `{ id, label, register, schema, titleField?, subtitleField?, statusField?, statusResolve?, linkedField?, linkedTemplate?, href?, limit? }`
741
793
  * for objects (OpenRegister's `_search`, so the reader sees what they may
742
794
  * read), or `{ id, label, files: true, limit? }` for the reader's files
743
795
  * (Nextcloud's unified search). The term is `sourceConfig.term`, else the
744
796
  * address's `?q=` (or `sourceConfig.termQuery`). A kind that fails shows no
745
797
  * rows and no count; the others still answer.
746
798
  *
799
+ * The kind's `label` goes through the app's translate (the "Soort" cell and
800
+ * the quick filters). `statusResolve: { register?, schema, labelField? }`
801
+ * reads `statusField` as a reference and shows the referenced object's label
802
+ * (`labelField`, else `title`, then `name`); the status column is a pill,
803
+ * coloured by `sourceConfig.statusColorMap`. `linkedTemplate` (a field
804
+ * template, "{case.title}") or `linkedField` (a path) fills the "Linked"
805
+ * column, drawn when a kind declares one; `sourceConfig.linkedLabel` names it.
806
+ * The page's title and count line can name the term as `{term}`, and the
807
+ * count is the active kind's total. Kind, link and status are filterable.
808
+ *
747
809
  * @param {object} [config] The page's source config.
810
+ * @param {object} [context] The page's helpers.
811
+ * @param {Function} [context.translate] The app's translate.
748
812
  * @return {object} The source adapter.
749
813
  * @spec openspec/changes/index-search-source/specs/index-page/spec.md#requirement-an-index-page-can-search-several-schemas-and-files
750
814
  */
751
- function searchSource(config = {}) {
815
+ function searchSource(config = {}, context = {}) {
816
+ const translate = typeof context.translate === 'function' ? context.translate : (text) => text
752
817
  const kinds = (Array.isArray(config && config.kinds) ? config.kinds : [])
753
818
  .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: '' })
819
+ .map((kind) => ({ ...kind, label: translate(String(kind.label || kind.id)) }))
820
+ const hasLinked = kinds.some((kind) => kind.linkedTemplate || kind.linkedField)
821
+ const state = reactive({ rows: [], totals: {}, loading: false, kind: 'all', term: '', filters: {} })
755
822
  let seq = 0
756
823
 
824
+ /**
825
+ * The labels of the objects a kind's status points at, by id: the
826
+ * referenced schema read once per search.
827
+ *
828
+ * @param {object} kind The kind with `statusResolve`.
829
+ * @return {Promise<Map<string, string>>} Labels by id; empty when it cannot be read.
830
+ */
831
+ const statusLabels = async (kind) => {
832
+ const resolve = kind.statusResolve
833
+ if (!resolve || !resolve.schema) {
834
+ return new Map()
835
+ }
836
+ const objects = await fetchReferenceObjects({ register: resolve.register || kind.register, schema: resolve.schema }).catch(() => [])
837
+ const labels = new Map()
838
+ for (const object of objects) {
839
+ const id = object.id || object.uuid || (object['@self'] && object['@self'].id)
840
+ const label = readPath(object, resolve.labelField || 'title') ?? object.name
841
+ if (id !== undefined && label !== undefined && label !== null) {
842
+ labels.set(String(id), String(label))
843
+ }
844
+ }
845
+ return labels
846
+ }
847
+
848
+ /**
849
+ * What a row shows in the Linked column.
850
+ *
851
+ * @param {object} kind The row's kind.
852
+ * @param {object} object The found object.
853
+ * @return {string}
854
+ */
855
+ const linkedOf = (kind, object) => {
856
+ if (kind.linkedTemplate) {
857
+ return fillTemplate(translate(kind.linkedTemplate), object)
858
+ }
859
+ if (kind.linkedField) {
860
+ const value = readPath(object, kind.linkedField)
861
+ return value === undefined || value === null ? '' : String(value)
862
+ }
863
+ return ''
864
+ }
865
+
757
866
  const searchObjects = async (axios, kind, term) => {
758
867
  const url = generateUrl('/apps/openregister/api/objects/{register}/{schema}', { register: kind.register, schema: kind.schema })
759
868
  const res = await axios.get(url, { params: { _search: term, _limit: kind.limit || 20 } })
760
869
  const body = (res && res.data) || {}
761
870
  const results = Array.isArray(body.results) ? body.results : (Array.isArray(body) ? body : [])
871
+ const labels = kind.statusResolve && kind.statusField ? await statusLabels(kind) : null
762
872
  return {
763
873
  total: typeof body.total === 'number' ? body.total : results.length,
764
874
  rows: results.map((object) => {
765
875
  const id = object.id || object.uuid || (object['@self'] && object['@self'].id)
876
+ const rawStatus = kind.statusField ? readPath(object, kind.statusField) : undefined
877
+ const status = rawStatus === undefined || rawStatus === null
878
+ ? ''
879
+ : (labels ? (labels.get(String(rawStatus)) || '') : String(rawStatus))
766
880
  return {
767
881
  id: `${kind.id}:${id}`,
768
882
  kind: kind.id,
769
883
  kindLabel: kind.label,
770
884
  title: String(readPath(object, kind.titleField || 'title') ?? object.name ?? id ?? ''),
771
885
  subtitle: kind.subtitleField ? String(readPath(object, kind.subtitleField) ?? '') : '',
772
- status: kind.statusField ? String(readPath(object, kind.statusField) ?? '') : '',
886
+ status,
887
+ linked: linkedOf(kind, object),
773
888
  href: kind.href ? generateUrl(fillRowTemplate(kind.href, { ...object, id })) : '',
774
889
  object,
775
890
  }
@@ -792,6 +907,7 @@ function searchSource(config = {}) {
792
907
  title: entry.title || '',
793
908
  subtitle: entry.subline || '',
794
909
  status: '',
910
+ linked: '',
795
911
  href: entry.resourceUrl || '',
796
912
  })),
797
913
  }
@@ -806,6 +922,17 @@ function searchSource(config = {}) {
806
922
  */
807
923
  load: async (cfg) => {
808
924
  state.kind = cfg && typeof cfg.kind === 'string' ? cfg.kind : 'all'
925
+ // The header filters on Kind, Linked and Status narrow the answer
926
+ // already fetched; they do not search again.
927
+ const filters = {}
928
+ for (const key of ['kindLabel', 'linked', 'status']) {
929
+ const value = cfg && cfg[key]
930
+ const values = (Array.isArray(value) ? value : (value === undefined || value === null || value === '' ? [] : [value])).map(String)
931
+ if (values.length > 0) {
932
+ filters[key] = values
933
+ }
934
+ }
935
+ state.filters = filters
809
936
  const query = typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : null
810
937
  const term = String((cfg && cfg.term) || (query && query.get((cfg && cfg.termQuery) || 'q')) || '').trim()
811
938
  const mine = ++seq
@@ -837,7 +964,20 @@ function searchSource(config = {}) {
837
964
  /**
838
965
  * @return {Array<object>} The results of the active kind, or all of them.
839
966
  */
840
- rows: () => (state.kind === 'all' ? state.rows : state.rows.filter((row) => row.kind === state.kind)),
967
+ rows: () => (state.kind === 'all' ? state.rows : state.rows.filter((row) => row.kind === state.kind))
968
+ .filter((row) => Object.entries(state.filters).every(([key, values]) => values.includes(String(row[key])))),
969
+
970
+ /**
971
+ * @return {string} The term searched for, for a `{term}` in the title or count line.
972
+ */
973
+ term: () => state.term,
974
+
975
+ /**
976
+ * @return {number} The active kind's total, or every kind's together on All.
977
+ */
978
+ total: () => (state.kind === 'all'
979
+ ? Object.values(state.totals).reduce((sum, value) => sum + value, 0)
980
+ : (state.totals[state.kind] || 0)),
841
981
 
842
982
  /**
843
983
  * @return {boolean} Whether a search is in flight.
@@ -846,8 +986,14 @@ function searchSource(config = {}) {
846
986
 
847
987
  columns: [
848
988
  { 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') },
989
+ { key: 'kindLabel', label: t('nextcloud-vue', 'Kind'), enum: kinds.map((kind) => kind.label) },
990
+ ...(hasLinked ? [{ key: 'linked', label: config.linkedLabel ? translate(config.linkedLabel) : t('nextcloud-vue', 'Linked') }] : []),
991
+ {
992
+ key: 'status',
993
+ label: t('nextcloud-vue', 'Status'),
994
+ widget: 'badge',
995
+ widgetProps: { colorMap: (config && config.statusColorMap) || {} },
996
+ },
851
997
  ],
852
998
 
853
999
  quickFilters: kinds.length > 0
@@ -1125,10 +1271,11 @@ export const indexSources = {
1125
1271
  *
1126
1272
  * @param {string} name The source name from `config.source`.
1127
1273
  * @param {object} [config] The page's source config, for a source that builds its tabs from it.
1274
+ * @param {object} [context] The page's helpers a source may use, such as `translate` (the app's catalogue).
1128
1275
  *
1129
1276
  * @return {object|null} The adapter, or null when the name is not registered.
1130
1277
  */
1131
- export function resolveIndexSource(name, config) {
1278
+ export function resolveIndexSource(name, config, context) {
1132
1279
  const key = String(name || '')
1133
1280
  if (!key) {
1134
1281
  return null
@@ -1141,7 +1288,7 @@ export function resolveIndexSource(name, config) {
1141
1288
  return null
1142
1289
  }
1143
1290
 
1144
- return factory(config || {})
1291
+ return factory(config || {}, context || {})
1145
1292
  }
1146
1293
 
1147
1294
  /**
@@ -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;
@@ -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,
@@ -3,7 +3,7 @@
3
3
  "$id": "https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest-v2.schema.json",
4
4
  "title": "Conduction App Manifest v2",
5
5
  "description": "v2 schema for the JSON-driven page and navigation manifest consumed by @conduction/nextcloud-vue. Introduces a uniform widgets[] array on every page type with a per-slot grid coordinate system, a typed actions[] discriminator, and the required $schema field for version detection. v1 manifests continue to validate against app-manifest.schema.json (unchanged).",
6
- "version": "2.123.0",
6
+ "version": "2.124.0",
7
7
  "type": "object",
8
8
  "required": ["$schema", "version"],
9
9
  "allOf": [
@@ -98,6 +98,7 @@
98
98
  "title": { "type": "string" },
99
99
  "body": { "type": "string", "description": "info: explanatory copy." },
100
100
  "note": { "type": "string", "minLength": 1, "description": "choice: a footnote under the options, translated like body." },
101
+ "visibleWhen": { "description": "Offer the step only when the setup status says so: a dotted path into the setup status document whose value must be truthy, or { path, equals } for an exact value. Hidden while the status loads.", "oneOf": [ { "type": "string", "minLength": 1 }, { "type": "object", "required": ["path"], "additionalProperties": false, "properties": { "path": { "type": "string", "minLength": 1 }, "equals": {} } } ] },
101
102
  "footnote": { "type": "string", "minLength": 1, "description": "choice: the same footnote under another name; note wins when both are set." },
102
103
  "required": { "type": "boolean", "default": false, "description": "When true the step gates the app until done." },
103
104
  "schema": { "description": "config-fields: a JSON Schema ref (string) or inline object rendered via fieldsFromSchema." },
@@ -4372,6 +4373,35 @@
4372
4373
  "default": true,
4373
4374
  "description": "type='dashboard' and type='detail': `false` drops the overflow Actions menu from every widget that does not set `showActions` itself, for a dashboard whose widget headers carry a header link and nothing else (zuiddrecht-pixel-gaps-2), or a record page whose data cards carry none (zuiddrecht-pixel-gaps-3; a card with an Add action keeps it). When the key is not set a detail page keeps the menu, except under the board look, where side cards carry none (screens-detail-page-parity). Maps to CnDashboardPage / CnDetailPage `showWidgetActions`."
4374
4375
  },
4376
+ "pageFilters": {
4377
+ "type": "array",
4378
+ "description": "type='dashboard': page-level filter controls in the header. Each selection is written into the page's workspace context, so a widget reads it through a `@page.<key>` / `@workspace.<key>` token (an endpoint widget also in its `responsePath`). Maps to the CnDashboardPage `pageFilters` prop.",
4379
+ "items": {
4380
+ "type": "object",
4381
+ "required": ["key", "options"],
4382
+ "additionalProperties": false,
4383
+ "properties": {
4384
+ "key": { "type": "string", "minLength": 1, "description": "Workspace-context key written on change (the `@page.<key>` / `@workspace.<key>` token name)." },
4385
+ "label": { "type": "string", "description": "Control label (i18n key or plain text)." },
4386
+ "type": { "type": "string", "enum": ["select"], "default": "select", "description": "Control type, carried over from v1." },
4387
+ "control": { "type": "string", "enum": ["select", "segmented"], "default": "select", "description": "How the filter is drawn: a select, or a compact segmented group." },
4388
+ "placement": { "type": "string", "enum": ["row", "description"], "default": "row", "description": "Where the filter sits: in the header's filter row, or (a segmented group) on the description line after the period group (DqProcesanalyse)." },
4389
+ "default": { "type": ["string", "number"], "description": "Initial value seeded into the context on mount; the first option's value when omitted." },
4390
+ "options": {
4391
+ "type": "array",
4392
+ "items": {
4393
+ "type": "object",
4394
+ "required": ["value", "label"],
4395
+ "additionalProperties": false,
4396
+ "properties": {
4397
+ "value": { "type": ["string", "number"], "description": "Value written into the context when picked." },
4398
+ "label": { "type": "string", "description": "Display text (i18n key or plain text)." }
4399
+ }
4400
+ }
4401
+ }
4402
+ }
4403
+ }
4404
+ },
4375
4405
  "kpiRow": {
4376
4406
  "type": "array",
4377
4407
  "items": { "type": "string" },
@@ -0,0 +1,46 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ * SPDX-License-Identifier: EUPL-1.2
4
+ */
5
+
6
+ import { getLanguage } from '@nextcloud/l10n'
7
+
8
+ /**
9
+ * The user's Nextcloud language as a bare code (`nl_NL` reads `nl`), or ''.
10
+ *
11
+ * @return {string} The code.
12
+ */
13
+ function userLanguage() {
14
+ try {
15
+ return String(getLanguage() || '').split(/[-_]/)[0].toLowerCase()
16
+ } catch {
17
+ return ''
18
+ }
19
+ }
20
+
21
+ /**
22
+ * The text of a value that may be a language map (`{ nl: 'Ontvangst', en:
23
+ * 'Intake' }`), in the user's Nextcloud language, then Dutch, then English,
24
+ * then the first text it holds. A plain value comes back as text; anything
25
+ * else as ''.
26
+ *
27
+ * @param {(string|number|object|null|undefined)} value The value.
28
+ * @return {string} The text.
29
+ * @spec openspec/changes/grouped-cards-widget/specs/grouped-cards-widget/spec.md#requirement-a-widget-draws-groups-of-cards-from-an-endpoint
30
+ */
31
+ export function languageMapText(value) {
32
+ if (typeof value === 'string' || typeof value === 'number') {
33
+ return String(value)
34
+ }
35
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
36
+ return ''
37
+ }
38
+ const language = userLanguage()
39
+ for (const key of [language, 'nl', 'en']) {
40
+ if (key && typeof value[key] === 'string' && value[key] !== '') {
41
+ return value[key]
42
+ }
43
+ }
44
+ const first = Object.values(value).find((text) => typeof text === 'string' && text !== '')
45
+ return first || ''
46
+ }
@@ -71,6 +71,8 @@ export const BUILT_IN_WIDGET_KEYS = Object.freeze([
71
71
  'header',
72
72
  'text',
73
73
  'divider',
74
+ 'saved-view-alert',
75
+ 'exports',
74
76
  ])
75
77
 
76
78
  /**
@@ -109,6 +111,7 @@ export const DASHBOARD_CATALOG_WIDGET_KEYS = Object.freeze([
109
111
  'files',
110
112
  'flow-runs',
111
113
  'gauge',
114
+ 'grouped-cards',
112
115
  'header',
113
116
  'image',
114
117
  'interaction-form',