@conduction/nextcloud-vue 2.96.0 → 2.97.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 (79) hide show
  1. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue.js +1 -1
  2. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js +33 -1
  3. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js.map +1 -1
  4. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js +383 -359
  5. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js.map +1 -1
  6. package/dist/esm/components/CnConversationThread/CnConversationThread.vue.js +1 -1
  7. package/dist/esm/components/CnConversationThread/CnConversationThread.vue2.js +17 -3
  8. package/dist/esm/components/CnConversationThread/CnConversationThread.vue2.js.map +1 -1
  9. package/dist/esm/components/CnConversationThread/CnConversationThread.vue3.js +61 -43
  10. package/dist/esm/components/CnConversationThread/CnConversationThread.vue3.js.map +1 -1
  11. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue.js +1 -1
  12. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue2.js +159 -18
  13. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue2.js.map +1 -1
  14. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue3.js +9 -7
  15. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue3.js.map +1 -1
  16. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue.js +1 -1
  17. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js +48 -3
  18. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js.map +1 -1
  19. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js +46 -12
  20. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js.map +1 -1
  21. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue.js +1 -1
  22. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js +144 -0
  23. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js.map +1 -1
  24. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue3.js +488 -399
  25. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue3.js.map +1 -1
  26. package/dist/esm/components/CnFilesBrowser/filesBrowserColumns.js +8 -2
  27. package/dist/esm/components/CnFilesBrowser/filesBrowserColumns.js.map +1 -1
  28. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +301 -18
  29. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  30. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +29 -7
  31. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  32. package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue.js +1 -1
  33. package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue2.js +2 -2
  34. package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue2.js.map +1 -1
  35. package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue3.js +2 -2
  36. package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue3.js.map +1 -1
  37. package/dist/esm/components/CnIndexPage/useNamedSource.js +1 -1
  38. package/dist/esm/components/CnIndexPage/useNamedSource.js.map +1 -1
  39. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue.js +1 -1
  40. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js +13 -0
  41. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js.map +1 -1
  42. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js +5 -1
  43. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js.map +1 -1
  44. package/dist/esm/composables/indexSources.js +182 -3
  45. package/dist/esm/composables/indexSources.js.map +1 -1
  46. package/dist/esm/l10n/en.json.js +7 -1
  47. package/dist/esm/l10n/en.json.js.map +1 -1
  48. package/dist/esm/l10n/nl.json.js +7 -1
  49. package/dist/esm/l10n/nl.json.js.map +1 -1
  50. package/dist/esm/library-style.js +1 -1
  51. package/dist/esm/nextcloud-vue.css +301 -177
  52. package/dist/esm/utils/queryPresets.js +13 -2
  53. package/dist/esm/utils/queryPresets.js.map +1 -1
  54. package/dist/esm/utils/scopeListLayout.js +37 -1
  55. package/dist/esm/utils/scopeListLayout.js.map +1 -1
  56. package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
  57. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  58. package/dist/nextcloud-vue.cjs.js +3009 -1907
  59. package/dist/nextcloud-vue.cjs.js.map +2 -2
  60. package/dist/nextcloud-vue.css +301 -177
  61. package/l10n/en.json +7 -1
  62. package/l10n/nl.json +7 -1
  63. package/package.json +1 -1
  64. package/src/components/CnCellRenderer/CnCellRenderer.vue +57 -1
  65. package/src/components/CnConversationThread/CnConversationThread.md +12 -0
  66. package/src/components/CnConversationThread/CnConversationThread.vue +30 -4
  67. package/src/components/CnConversationThread/CnConversationWidget.vue +167 -24
  68. package/src/components/CnFactsWidget/CnFactsWidget.vue +114 -3
  69. package/src/components/CnFilesBrowser/CnFilesBrowser.vue +508 -236
  70. package/src/components/CnFilesBrowser/filesBrowserColumns.js +8 -2
  71. package/src/components/CnIndexPage/CnIndexPage.vue +324 -24
  72. package/src/components/CnIndexPage/CnIndexPageWidgets.vue +13 -4
  73. package/src/components/CnIndexPage/useNamedSource.js +1 -1
  74. package/src/components/CnObjectSidebar/CnFilesTab.vue +17 -0
  75. package/src/composables/indexSources.js +182 -3
  76. package/src/schemas/app-manifest-v2.schema.json +11 -4
  77. package/src/utils/queryPresets.js +13 -2
  78. package/src/utils/scopeListLayout.js +37 -1
  79. package/src/utils/validateManifestV2.compiled.js +3 -3
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { translate as t } from '@nextcloud/l10n'
7
- import { generateUrl } from '@nextcloud/router'
7
+ import { generateOcsUrl, generateUrl } from '@nextcloud/router'
8
8
  import { reactive } from 'vue'
9
9
  import CalendarClock from 'vue-material-design-icons/CalendarClock.vue'
10
10
  import HandBackRight from 'vue-material-design-icons/HandBackRight.vue'
@@ -12,6 +12,7 @@ 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
14
  import { FLOW_RUN_ERROR_STATUSES, flowRunStatuses } from '../utils/flowRunStatus.js'
15
+ import { readPath } from '../utils/readPath.js'
15
16
  import { useFlowStore } from './useFlowStore.js'
16
17
  import { useTaskInboxStore } from './useTaskInboxStore.js'
17
18
 
@@ -714,6 +715,182 @@ function endpointSource() {
714
715
  }
715
716
  }
716
717
 
718
+ /**
719
+ * Fill `{field}` placeholders in a link template from a row.
720
+ *
721
+ * @param {string} template A path such as `/apps/dossiq/cases/{id}`.
722
+ * @param {object} row The row.
723
+ * @return {string} The path.
724
+ */
725
+ function fillRowTemplate(template, row) {
726
+ return String(template).replace(/\{([\w.@]+)\}/g, (match, path) => {
727
+ const value = readPath(row, path)
728
+ return value === undefined || value === null ? '' : encodeURIComponent(String(value))
729
+ })
730
+ }
731
+
732
+ /**
733
+ * The search source: one search term over several OpenRegister schemas and
734
+ * the reader's files (`entitySource: "search"`), with a quick filter per kind
735
+ * and its count (DqZoekresultaten).
736
+ *
737
+ * `sourceConfig.kinds` lists what is searched:
738
+ * `{ id, label, register, schema, titleField?, subtitleField?, statusField?, href?, limit? }`
739
+ * for objects (OpenRegister's `_search`, so the reader sees what they may
740
+ * read), or `{ id, label, files: true, limit? }` for the reader's files
741
+ * (Nextcloud's unified search). The term is `sourceConfig.term`, else the
742
+ * address's `?q=` (or `sourceConfig.termQuery`). A kind that fails shows no
743
+ * rows and no count; the others still answer.
744
+ *
745
+ * @param {object} [config] The page's source config.
746
+ * @return {object} The source adapter.
747
+ * @spec openspec/changes/index-search-source/specs/index-page/spec.md#requirement-an-index-page-can-search-several-schemas-and-files
748
+ */
749
+ function searchSource(config = {}) {
750
+ const kinds = (Array.isArray(config && config.kinds) ? config.kinds : [])
751
+ .filter((kind) => kind && typeof kind.id === 'string' && kind.id !== '' && (kind.files === true || (kind.register && kind.schema)))
752
+ const state = reactive({ rows: [], totals: {}, loading: false, kind: 'all', term: '' })
753
+ let seq = 0
754
+
755
+ const searchObjects = async (axios, kind, term) => {
756
+ const url = generateUrl('/apps/openregister/api/objects/{register}/{schema}', { register: kind.register, schema: kind.schema })
757
+ const res = await axios.get(url, { params: { _search: term, _limit: kind.limit || 20 } })
758
+ const body = (res && res.data) || {}
759
+ const results = Array.isArray(body.results) ? body.results : (Array.isArray(body) ? body : [])
760
+ return {
761
+ total: typeof body.total === 'number' ? body.total : results.length,
762
+ rows: results.map((object) => {
763
+ const id = object.id || object.uuid || (object['@self'] && object['@self'].id)
764
+ return {
765
+ id: `${kind.id}:${id}`,
766
+ kind: kind.id,
767
+ kindLabel: kind.label,
768
+ title: String(readPath(object, kind.titleField || 'title') ?? object.name ?? id ?? ''),
769
+ subtitle: kind.subtitleField ? String(readPath(object, kind.subtitleField) ?? '') : '',
770
+ status: kind.statusField ? String(readPath(object, kind.statusField) ?? '') : '',
771
+ href: kind.href ? generateUrl(fillRowTemplate(kind.href, { ...object, id })) : '',
772
+ object,
773
+ }
774
+ }),
775
+ }
776
+ }
777
+
778
+ const searchFiles = async (axios, kind, term) => {
779
+ const res = await axios.get(generateOcsUrl('search/providers/files/search'), {
780
+ params: { term, limit: kind.limit || 20, format: 'json' },
781
+ headers: { 'OCS-APIRequest': 'true' },
782
+ })
783
+ const entries = (res && res.data && res.data.ocs && res.data.ocs.data && res.data.ocs.data.entries) || []
784
+ return {
785
+ total: entries.length,
786
+ rows: entries.map((entry, index) => ({
787
+ id: `${kind.id}:${entry.resourceUrl || index}`,
788
+ kind: kind.id,
789
+ kindLabel: kind.label,
790
+ title: entry.title || '',
791
+ subtitle: entry.subline || '',
792
+ status: '',
793
+ href: entry.resourceUrl || '',
794
+ })),
795
+ }
796
+ }
797
+
798
+ return {
799
+ /**
800
+ * Run the search over every kind, in parallel.
801
+ *
802
+ * @param {object} cfg The page's source config with the active tab's `kind` merged in.
803
+ * @return {Promise<void>} Resolves when every kind answered or failed.
804
+ */
805
+ load: async (cfg) => {
806
+ state.kind = cfg && typeof cfg.kind === 'string' ? cfg.kind : 'all'
807
+ const query = typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : null
808
+ const term = String((cfg && cfg.term) || (query && query.get((cfg && cfg.termQuery) || 'q')) || '').trim()
809
+ const mine = ++seq
810
+ state.term = term
811
+ if (term === '' || kinds.length === 0) {
812
+ state.rows = []
813
+ state.totals = {}
814
+ return
815
+ }
816
+ state.loading = true
817
+ const { default: axios } = await import('@nextcloud/axios')
818
+ const answers = await Promise.all(kinds.map((kind) => (kind.files === true ? searchFiles(axios, kind, term) : searchObjects(axios, kind, term)).catch(() => null)))
819
+ if (mine !== seq) {
820
+ return
821
+ }
822
+ const totals = {}
823
+ const rows = []
824
+ answers.forEach((answer, index) => {
825
+ if (answer) {
826
+ totals[kinds[index].id] = answer.total
827
+ rows.push(...answer.rows)
828
+ }
829
+ })
830
+ state.totals = totals
831
+ state.rows = rows
832
+ state.loading = false
833
+ },
834
+
835
+ /**
836
+ * @return {Array<object>} The results of the active kind, or all of them.
837
+ */
838
+ rows: () => (state.kind === 'all' ? state.rows : state.rows.filter((row) => row.kind === state.kind)),
839
+
840
+ /**
841
+ * @return {boolean} Whether a search is in flight.
842
+ */
843
+ loading: () => state.loading,
844
+
845
+ columns: [
846
+ { key: 'title', label: t('nextcloud-vue', 'Result'), secondary: 'subtitle' },
847
+ { key: 'kindLabel', label: t('nextcloud-vue', 'Kind') },
848
+ { key: 'status', label: t('nextcloud-vue', 'Status') },
849
+ ],
850
+
851
+ quickFilters: kinds.length > 0
852
+ ? [
853
+ { label: t('nextcloud-vue', 'All'), filter: { kind: 'all' }, default: true },
854
+ ...kinds.map((kind) => ({ label: kind.label || kind.id, filter: { kind: kind.id } })),
855
+ ]
856
+ : null,
857
+
858
+ /**
859
+ * The number on each kind's quick filter: the total each search
860
+ * reported, and their sum on All.
861
+ *
862
+ * @return {Record<number, number>} The counts by tab index.
863
+ */
864
+ tabCounts: () => {
865
+ const counts = { 0: Object.values(state.totals).reduce((sum, value) => sum + value, 0) }
866
+ kinds.forEach((kind, index) => {
867
+ if (typeof state.totals[kind.id] === 'number') {
868
+ counts[index + 1] = state.totals[kind.id]
869
+ }
870
+ })
871
+ return counts
872
+ },
873
+
874
+ /**
875
+ * Open a result where it lives.
876
+ *
877
+ * @param {object} row The clicked row.
878
+ * @return {void}
879
+ */
880
+ openRow: (row) => {
881
+ if (row && row.href) {
882
+ window.location.assign(row.href)
883
+ }
884
+ },
885
+
886
+ /**
887
+ * @param {object} row A row.
888
+ * @return {string|null} The URL the row opens, for a new tab.
889
+ */
890
+ rowTarget: (row) => (row && row.href) || null,
891
+ }
892
+ }
893
+
717
894
  /**
718
895
  * The registered sources, by the name a manifest uses.
719
896
  *
@@ -726,6 +903,7 @@ export const indexSources = {
726
903
  flowRuns: flowRunsSource,
727
904
  tasks: tasksSource,
728
905
  endpoint: endpointSource,
906
+ search: searchSource,
729
907
  }
730
908
 
731
909
  /**
@@ -738,10 +916,11 @@ export const indexSources = {
738
916
  * indistinguishable from a source that genuinely has no rows.
739
917
  *
740
918
  * @param {string} name The source name from `config.source`.
919
+ * @param {object} [config] The page's source config, for a source that builds its tabs from it.
741
920
  *
742
921
  * @return {object|null} The adapter, or null when the name is not registered.
743
922
  */
744
- export function resolveIndexSource(name) {
923
+ export function resolveIndexSource(name, config) {
745
924
  const key = String(name || '')
746
925
  if (!key) {
747
926
  return null
@@ -754,5 +933,5 @@ export function resolveIndexSource(name) {
754
933
  return null
755
934
  }
756
935
 
757
- return factory()
936
+ return factory(config || {})
758
937
  }
@@ -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.120.0",
6
+ "version": "2.121.0",
7
7
  "type": "object",
8
8
  "required": ["$schema", "version"],
9
9
  "allOf": [
@@ -597,7 +597,7 @@
597
597
  "folderSidebarScope": {
598
598
  "type": "object",
599
599
  "additionalProperties": true,
600
- "description": "One folder in `config.folderSidebar.folders[]`, which doubles as a SCOPE: the three optional list-layout keys below let one index page show each folder the way that folder needs. Every other key (id, name, icon, ...) stays free-form, so an existing folder list validates unchanged. The page's `config.columns` still decides which columns this page HAS; a scope only decides which of them are shown, in what order, sorted by what and searched over what. A scope naming a column the page does not declare is refused by validateManifestV2, not silently rendered.",
600
+ "description": "One folder in `config.folderSidebar.folders[]`, which doubles as a SCOPE: the three optional list-layout keys below let one index page show each folder the way that folder needs. Every other key (id, name, icon, ...) stays free-form, so an existing folder list validates unchanged. A scope (or the `x-index` block of the row a register-derived folder comes from) may also carry the presentation keys a query preset sets, under the same names: title, quickFilters, cardFields, countText, searchPlaceholder, footerNote, viewSwitch, addLabel, headerButtons, breadcrumb, headerMeta and afterList (index-scope-presentation); a scope's value wins over a preset's. The page's `config.columns` still decides which columns this page HAS; a scope only decides which of them are shown, in what order, sorted by what and searched over what. A scope naming a column the page does not declare is refused by validateManifestV2, not silently rendered.",
601
601
  "properties": {
602
602
  "columns": {
603
603
  "type": "array",
@@ -3702,9 +3702,10 @@
3702
3702
  },
3703
3703
  "entitySource": {
3704
3704
  "type": "string",
3705
- "enum": ["flows", "tasks"],
3705
+ "enum": ["flows", "flowRuns", "tasks", "endpoint", "search", "users"],
3706
3706
  "description": "Name a registered NON-OBJECT entity collection for a type='index' page, instead of register+schema. Deliberately NOT called `source`: that key is already taken on page config and is polymorphic (a URL string in some manifests, an object with `params` in others), so reusing it would give one key two meanings and break six deployed shillinq pages. A flow definition is deliberately not an OpenRegister object, so an index had nothing to bind to and such lists became bespoke type='custom' pages. With a source the index loads the list itself. Takes precedence over register/schema; non-empty `objects` still wins over both. Registered sources live in src/composables/indexSources.js. `tasks` lists the viewer's task inbox from OpenRegister's flow-tasks read (cn-tasks-entity-source)."
3707
3707
  },
3708
+ "sourceConfig": { "type": "object", "additionalProperties": true, "description": "type='index' with `entitySource`: what the named source loads with. `endpoint`: `url` (an app path, required) and `rowsPath` (default `results`); its other keys go along as query parameters. `search`: `kinds` (`{ id, label, register, schema, titleField?, subtitleField?, statusField?, href?, limit? }` or `{ id, label, files: true }`), `term` and `termQuery` (default `q`). `users`: `defaultGroup`, `functionField`, `rolesField`. `flows` / `tasks`: `app`, `scope`. Maps to CnIndexPage `sourceConfig`.", "properties": { "url": { "type": "string", "minLength": 1 }, "rowsPath": { "type": "string", "minLength": 1 }, "kinds": { "type": "array", "items": { "type": "object", "required": ["id"], "additionalProperties": true, "properties": { "id": { "type": "string", "minLength": 1 }, "label": { "type": "string" }, "register": { "type": "string" }, "schema": { "type": "string" }, "files": { "type": "boolean" } } } }, "term": { "type": "string" }, "termQuery": { "type": "string", "minLength": 1 }, "defaultGroup": { "type": "string", "minLength": 1 } } },
3708
3709
  "app": {
3709
3710
  "type": "string",
3710
3711
  "description": "Scopes a named `entitySource` to one app (e.g. entitySource='flows' with app='dossiq' lists that app's flows)."
@@ -4427,7 +4428,12 @@
4427
4428
  "searchPlaceholder": { "type": "string", "minLength": 1, "description": "The search placeholder while the preset applies." },
4428
4429
  "footerNote": { "type": "string", "minLength": 1, "description": "The footer note while the preset applies." },
4429
4430
  "defaultSort": { "type": "array", "description": "The default sort while the preset applies; same shape as config.defaultSort." },
4430
- "viewSwitch": { "type": "array", "items": { "type": "string", "enum": ["table", "cards", "board", "map"] }, "description": "The drawn view segments while the preset applies." }
4431
+ "viewSwitch": { "type": "array", "items": { "type": "string", "enum": ["table", "cards", "board", "map"] }, "description": "The drawn view segments while the preset applies." },
4432
+ "addLabel": { "type": "string", "minLength": 1, "description": "The add button label while the preset applies (index-scope-presentation)." },
4433
+ "headerButtons": { "type": "array", "description": "The header buttons while the preset applies; same shape as config.headerButtons (index-scope-presentation)." },
4434
+ "breadcrumb": { "description": "The breadcrumb while the preset applies; same shape as config.breadcrumb (index-scope-presentation).", "anyOf": [{ "$ref": "#/$defs/breadcrumbCrumb" }, { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/breadcrumbCrumb" } }] },
4435
+ "headerMeta": { "type": "string", "minLength": 1, "description": "The note after the breadcrumb while the preset applies (index-scope-presentation)." },
4436
+ "afterList": { "type": "array", "description": "The cards after the list while the preset applies; same shape as config.afterList (index-scope-presentation).", "items": { "type": "object", "required": ["id", "type"], "properties": { "id": { "type": "string", "minLength": 1 }, "type": { "type": "string", "minLength": 1 }, "title": { "type": "string" }, "subtitle": { "type": "string" }, "content": { "type": "object" }, "headerLink": { "type": "object" } } } }
4431
4437
  }
4432
4438
  }
4433
4439
  },
@@ -4478,6 +4484,7 @@
4478
4484
  "description": "type='index': the property keys the cards view shows as the facts list of each card, in order; defaults to the first four list columns under the board look (screens-card-parity). Maps to CnIndexPage `cardFields`.",
4479
4485
  "items": { "type": "string", "minLength": 1 }
4480
4486
  },
4487
+ "afterList": { "type": "array", "description": "type='index': cards after the list, stacked full width in the listed order, the counterpart of statRow above it (index-scope-presentation). Each entry is a widget definition resolved like statRow's. A query preset or a folder scope can set its own. Maps to CnIndexPage `afterList`.", "items": { "type": "object", "required": ["id", "type"], "properties": { "id": { "type": "string", "minLength": 1 }, "type": { "type": "string", "minLength": 1 }, "title": { "type": "string" }, "subtitle": { "type": "string" }, "content": { "type": "object" }, "headerLink": { "type": "object" } } } },
4481
4488
  "statRow": {
4482
4489
  "type": "array",
4483
4490
  "default": [],
@@ -30,6 +30,11 @@ export const QUERY_PRESET_KEYS = Object.freeze([
30
30
  'footerNote',
31
31
  'defaultSort',
32
32
  'viewSwitch',
33
+ 'addLabel',
34
+ 'headerButtons',
35
+ 'breadcrumb',
36
+ 'headerMeta',
37
+ 'afterList',
33
38
  ])
34
39
 
35
40
  /**
@@ -71,8 +76,9 @@ export function matchQueryPreset(presets, query) {
71
76
  }
72
77
 
73
78
  /**
74
- * The page config with the preset's allowed keys laid over it, and without
75
- * `queryPresets` itself (the page component has no such prop).
79
+ * The page config with the preset's allowed keys laid over it, its `match`
80
+ * as `fixedFilters`, and without `queryPresets` itself (the page component
81
+ * has no such prop).
76
82
  *
77
83
  * @param {object} config The page config.
78
84
  * @param {object|null} preset The matched preset, or null.
@@ -89,5 +95,10 @@ export function applyQueryPreset(config, preset) {
89
95
  out[key] = preset[key]
90
96
  }
91
97
  }
98
+ // The query pairs that chose the preset scope the list: the page shows
99
+ // them as no removable filter chip.
100
+ if (preset.match && typeof preset.match === 'object') {
101
+ out.fixedFilters = { ...preset.match }
102
+ }
92
103
  return out
93
104
  }
@@ -3,6 +3,8 @@
3
3
  * SPDX-License-Identifier: AGPL-3.0-or-later
4
4
  */
5
5
 
6
+ import { QUERY_PRESET_KEYS } from './queryPresets.js'
7
+
6
8
  /**
7
9
  * The three list-layout keys a `folderSidebar` scope may carry.
8
10
  *
@@ -10,6 +12,15 @@
10
12
  */
11
13
  const SCOPE_LAYOUT_KEYS = ['columns', 'defaultSort', 'searchFields']
12
14
 
15
+ /**
16
+ * The presentation keys a scope may carry beside its list layout: the keys a
17
+ * query preset may set (`QUERY_PRESET_KEYS`), less the two the list layout
18
+ * resolves itself (`columns`, `defaultSort`).
19
+ *
20
+ * @type {ReadonlyArray<string>}
21
+ */
22
+ const SCOPE_PRESENTATION_KEYS = Object.freeze(QUERY_PRESET_KEYS.filter((key) => key !== 'columns' && key !== 'defaultSort'))
23
+
13
24
  /**
14
25
  * The key of a column entry, in either of the two shapes the manifest accepts:
15
26
  * a bare string (legacy shorthand) or a `{ key, label, ... }` object.
@@ -149,4 +160,29 @@ export function resolveScopeLayout({ scope = null, row = null, pageColumns = []
149
160
  return { columns, sortKeys, searchFields }
150
161
  }
151
162
 
152
- export { SCOPE_LAYOUT_KEYS }
163
+ /**
164
+ * The presentation a scope asks for: each of `SCOPE_PRESENTATION_KEYS` the
165
+ * folder entry declares, else the one its schema row's `x-index` declares.
166
+ * Only declared keys are in the result, so the page's own value stands for
167
+ * every other key. These are the keys a query preset sets, under the same
168
+ * names, and a scope's value wins over a preset's because it is applied on
169
+ * top of the page config the preset produced.
170
+ *
171
+ * @param {object} options Resolution inputs.
172
+ * @param {(object|null)} [options.scope] The folder entry for the active scope.
173
+ * @param {(object|null)} [options.row] The schema row the folder derives from.
174
+ * @return {object} The declared presentation keys.
175
+ * @spec openspec/changes/index-scope-presentation/specs/index-page/spec.md#requirement-a-preset-or-a-scope-sets-the-list-s-presentation
176
+ */
177
+ export function resolveScopePresentation({ scope = null, row = null } = {}) {
178
+ const out = {}
179
+ for (const key of SCOPE_PRESENTATION_KEYS) {
180
+ const value = declaredOrRow(scope, row, key)
181
+ if (value !== undefined) {
182
+ out[key] = value
183
+ }
184
+ }
185
+ return out
186
+ }
187
+
188
+ export { SCOPE_LAYOUT_KEYS, SCOPE_PRESENTATION_KEYS }