@conduction/nextcloud-vue 2.74.0 → 2.76.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 (126) hide show
  1. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js +19 -14
  2. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js.map +1 -1
  3. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js +2 -2
  4. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js.map +1 -1
  5. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js +77 -26
  6. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js.map +1 -1
  7. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js +22 -14
  8. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js.map +1 -1
  9. package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue2.js +23 -4
  10. package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue2.js.map +1 -1
  11. package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue3.js.map +1 -1
  12. package/dist/esm/components/CnBrandBar/CnBrandBar.vue.js +9 -0
  13. package/dist/esm/components/CnBrandBar/CnBrandBar.vue.js.map +1 -0
  14. package/dist/esm/components/CnBrandBar/CnBrandBar.vue2.js +107 -0
  15. package/dist/esm/components/CnBrandBar/CnBrandBar.vue2.js.map +1 -0
  16. package/dist/esm/components/CnBrandBar/CnBrandBar.vue3.js +79 -0
  17. package/dist/esm/components/CnBrandBar/CnBrandBar.vue3.js.map +1 -0
  18. package/dist/esm/components/CnCardGrid/CnCardGrid.vue2.js +6 -0
  19. package/dist/esm/components/CnCardGrid/CnCardGrid.vue2.js.map +1 -1
  20. package/dist/esm/components/CnCardGrid/CnCardGrid.vue3.js +3 -2
  21. package/dist/esm/components/CnCardGrid/CnCardGrid.vue3.js.map +1 -1
  22. package/dist/esm/components/CnCredentials/CnCredentials.vue2.js +11 -10
  23. package/dist/esm/components/CnCredentials/CnCredentials.vue2.js.map +1 -1
  24. package/dist/esm/components/CnCredentials/CnCredentials.vue3.js.map +1 -1
  25. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +8 -5
  26. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  27. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  28. package/dist/esm/components/CnEnvironmentBanner/CnEnvironmentBanner.vue2.js +4 -1
  29. package/dist/esm/components/CnEnvironmentBanner/CnEnvironmentBanner.vue2.js.map +1 -1
  30. package/dist/esm/components/CnEnvironmentBanner/CnEnvironmentBanner.vue3.js.map +1 -1
  31. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +7 -0
  32. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
  33. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js +14 -7
  34. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
  35. package/dist/esm/components/CnFormPage/CnFormPage.vue2.js +32 -0
  36. package/dist/esm/components/CnFormPage/CnFormPage.vue2.js.map +1 -1
  37. package/dist/esm/components/CnFormPage/CnFormPage.vue3.js +6 -3
  38. package/dist/esm/components/CnFormPage/CnFormPage.vue3.js.map +1 -1
  39. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +144 -6
  40. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  41. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +14 -13
  42. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  43. package/dist/esm/components/CnIndexPage/selfModeActions.js +90 -14
  44. package/dist/esm/components/CnIndexPage/selfModeActions.js.map +1 -1
  45. package/dist/esm/components/CnIndexPage/useSelfFetchList.js +15 -3
  46. package/dist/esm/components/CnIndexPage/useSelfFetchList.js.map +1 -1
  47. package/dist/esm/components/CnObjectCard/CnObjectCard.vue2.js +26 -0
  48. package/dist/esm/components/CnObjectCard/CnObjectCard.vue2.js.map +1 -1
  49. package/dist/esm/components/CnObjectCard/CnObjectCard.vue3.js +26 -9
  50. package/dist/esm/components/CnObjectCard/CnObjectCard.vue3.js.map +1 -1
  51. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js +19 -3
  52. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js.map +1 -1
  53. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js.map +1 -1
  54. package/dist/esm/components/CnQuickFilterBar/CnQuickFilterBar.vue2.js +18 -2
  55. package/dist/esm/components/CnQuickFilterBar/CnQuickFilterBar.vue2.js.map +1 -1
  56. package/dist/esm/components/CnQuickFilterBar/CnQuickFilterBar.vue3.js +2 -2
  57. package/dist/esm/components/CnQuickFilterBar/CnQuickFilterBar.vue3.js.map +1 -1
  58. package/dist/esm/components/CnRelatedObjectsWidget/CnRelatedObjectsWidget.vue2.js +153 -38
  59. package/dist/esm/components/CnRelatedObjectsWidget/CnRelatedObjectsWidget.vue2.js.map +1 -1
  60. package/dist/esm/components/CnRelatedObjectsWidget/CnRelatedObjectsWidget.vue3.js +74 -56
  61. package/dist/esm/components/CnRelatedObjectsWidget/CnRelatedObjectsWidget.vue3.js.map +1 -1
  62. package/dist/esm/components/CnStatsBlock/CnStatsBlock.vue2.js +16 -0
  63. package/dist/esm/components/CnStatsBlock/CnStatsBlock.vue2.js.map +1 -1
  64. package/dist/esm/components/CnStatsBlock/CnStatsBlock.vue3.js +2 -2
  65. package/dist/esm/components/CnStatsBlock/CnStatsBlock.vue3.js.map +1 -1
  66. package/dist/esm/composables/useObjectLock.js +41 -10
  67. package/dist/esm/composables/useObjectLock.js.map +1 -1
  68. package/dist/esm/directives/cnSelectAria.js +55 -0
  69. package/dist/esm/directives/cnSelectAria.js.map +1 -0
  70. package/dist/esm/index.js +1 -0
  71. package/dist/esm/index.js.map +1 -1
  72. package/dist/esm/l10n/en.json.js +7 -1
  73. package/dist/esm/l10n/en.json.js.map +1 -1
  74. package/dist/esm/l10n/nl.json.js +8 -2
  75. package/dist/esm/l10n/nl.json.js.map +1 -1
  76. package/dist/esm/nextcloud-vue.css +154 -2
  77. package/dist/esm/store/useObjectStore.js +37 -9
  78. package/dist/esm/store/useObjectStore.js.map +1 -1
  79. package/dist/esm/utils/cnFetch.js +17 -2
  80. package/dist/esm/utils/cnFetch.js.map +1 -1
  81. package/dist/esm/utils/objectSignals.js +44 -0
  82. package/dist/esm/utils/objectSignals.js.map +1 -0
  83. package/dist/esm/utils/resolveBrand.js +38 -0
  84. package/dist/esm/utils/resolveBrand.js.map +1 -0
  85. package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
  86. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  87. package/dist/nextcloud-vue.cjs.js +9640 -8649
  88. package/dist/nextcloud-vue.cjs.js.map +2 -2
  89. package/dist/nextcloud-vue.css +154 -2
  90. package/l10n/en.json +7 -1
  91. package/l10n/nl.json +8 -2
  92. package/package.json +1 -1
  93. package/src/components/CnAppNav/CnAppNav.vue +21 -16
  94. package/src/components/CnAppRoot/CnAppRoot.vue +53 -0
  95. package/src/components/CnBannerWidget/CnBannerWidget.vue +23 -4
  96. package/src/components/CnBrandBar/CnBrandBar.vue +230 -0
  97. package/src/components/CnCardGrid/CnCardGrid.md +1 -0
  98. package/src/components/CnCardGrid/CnCardGrid.vue +7 -0
  99. package/src/components/CnCredentials/CnCredentials.vue +17 -16
  100. package/src/components/CnDetailPage/CnDetailPage.vue +8 -5
  101. package/src/components/CnEnvironmentBanner/CnEnvironmentBanner.vue +4 -1
  102. package/src/components/CnFormDialog/CnFormDialog.vue +10 -0
  103. package/src/components/CnFormPage/CnFormPage.vue +33 -0
  104. package/src/components/CnIndexPage/CnIndexPage.md +15 -0
  105. package/src/components/CnIndexPage/CnIndexPage.vue +157 -18
  106. package/src/components/CnIndexPage/selfModeActions.js +89 -13
  107. package/src/components/CnIndexPage/useSelfFetchList.js +15 -3
  108. package/src/components/CnObjectCard/CnObjectCard.md +1 -0
  109. package/src/components/CnObjectCard/CnObjectCard.vue +89 -1
  110. package/src/components/CnObjectListWidget/CnObjectListWidget.vue +19 -3
  111. package/src/components/CnQuickFilterBar/CnQuickFilterBar.vue +20 -4
  112. package/src/components/CnRelatedObjectsWidget/CnRelatedObjectsWidget.vue +181 -38
  113. package/src/components/CnStatsBlock/CnStatsBlock.vue +18 -2
  114. package/src/composables/__tests__/useObjectLock.endpoints.spec.js +83 -0
  115. package/src/composables/useObjectLock.js +41 -10
  116. package/src/css/form-field.css +1 -1
  117. package/src/css/look-board-index.css +35 -1
  118. package/src/directives/cnSelectAria.js +54 -0
  119. package/src/index.js +1 -0
  120. package/src/schemas/app-manifest-v2.schema.json +6 -1
  121. package/src/store/useObjectStore.js +37 -9
  122. package/src/utils/cnFetch.js +16 -1
  123. package/src/utils/index.js +1 -0
  124. package/src/utils/objectSignals.js +41 -0
  125. package/src/utils/resolveBrand.js +37 -0
  126. package/src/utils/validateManifestV2.compiled.js +2 -2
@@ -0,0 +1,230 @@
1
+ <!--
2
+ CnBrandBar — the brand block in Nextcloud's own header (screens-brand-block-top-bar).
3
+
4
+ Draws the 237px brand block (emblem, organisation, app name, and a 1px
5
+ divider at its end) at the START of `#header`, before the app menu, under the
6
+ board look. CnAppRoot mounts it when a brand is declared and `#header` exists.
7
+
8
+ `#header` is Nextcloud's and offers no extension point for apps, so this is
9
+ the one place the library reaches into it, and it does so defensively:
10
+ - it adds ONE node of its own (a host element) as the first child of the
11
+ header, and teleports the block into it; it never moves, edits or removes
12
+ a node Nextcloud drew;
13
+ - it re-inserts its own host when Nextcloud re-renders the header (a
14
+ MutationObserver on the header and its parent), never a second one;
15
+ - with no `#header` it renders nothing and emits `unavailable`, so
16
+ CnAppRoot lets the navigation draw the block instead.
17
+
18
+ Sizes come from the board: `dossiq/DqKop`.
19
+ -->
20
+ <template>
21
+ <Teleport v-if="host" :to="host">
22
+ <div class="cn-brand-bar" data-testid="cn-brand-bar">
23
+ <div class="cn-brand-bar__brand" data-testid="cn-brand-bar-brand">
24
+ <!-- Decorative beside a name: the name already says whose app this is. -->
25
+ <img
26
+ v-if="typeof brand.emblem === 'string' && brand.emblem !== ''"
27
+ class="cn-brand-bar__emblem"
28
+ data-testid="cn-brand-bar-emblem"
29
+ :src="brand.emblem"
30
+ :alt="brand.name ? '' : brand.alt">
31
+ <span
32
+ v-else-if="brand.emblem === true"
33
+ class="cn-brand-bar__emblem cn-brand-bar__emblem--theme"
34
+ data-testid="cn-brand-bar-emblem"
35
+ aria-hidden="true" />
36
+ <img
37
+ v-else-if="brand.logo"
38
+ class="cn-brand-bar__emblem"
39
+ :src="brand.logo"
40
+ :alt="brand.name ? '' : brand.alt">
41
+ <span v-if="brand.name || brand.caption" class="cn-brand-bar__text">
42
+ <span v-if="brand.caption" class="cn-brand-bar__caption">{{ brand.caption }}</span>
43
+ <strong v-if="brand.name" class="cn-brand-bar__name">{{ brand.name }}</strong>
44
+ </span>
45
+ </div>
46
+ <span class="cn-brand-bar__divider" aria-hidden="true" />
47
+ </div>
48
+ </Teleport>
49
+ </template>
50
+
51
+ <script>
52
+ import { markRaw } from 'vue'
53
+
54
+ const HOST_CLASS = 'cn-brand-bar-host'
55
+
56
+ /**
57
+ * The brand block in Nextcloud's header.
58
+ *
59
+ * @event unavailable Emitted when there is no `#header` to put the block in (or it disappears), so the host lets the navigation draw it.
60
+ *
61
+ * @spec openspec/changes/screens-brand-block-top-bar/specs/layout-components/spec.md#requirement-the-board-look-draws-the-brand-block-in-the-nextcloud-header
62
+ */
63
+ export default {
64
+ name: 'CnBrandBar',
65
+
66
+ props: {
67
+ /**
68
+ * The resolved brand: `{ logo, emblem, name, caption, alt }`. `emblem`
69
+ * is a URL, or `true` for the theme's `--nldesign-emblem-url`.
70
+ *
71
+ * @type {{logo?: string, emblem?: (string|boolean), name?: string, caption?: string, alt?: string}}
72
+ */
73
+ brand: {
74
+ type: Object,
75
+ required: true,
76
+ },
77
+ },
78
+
79
+ emits: ['unavailable'],
80
+
81
+ data() {
82
+ return { host: null, observer: null }
83
+ },
84
+
85
+ mounted() {
86
+ this.attach()
87
+ },
88
+
89
+ beforeUnmount() {
90
+ this.detach()
91
+ },
92
+
93
+ methods: {
94
+ /**
95
+ * Insert the host as the first child of `#header` and watch the header.
96
+ * Emits `unavailable` when there is no header.
97
+ *
98
+ * @return {void}
99
+ */
100
+ attach() {
101
+ const header = document.getElementById('header')
102
+ if (!header) {
103
+ /**
104
+ * @event unavailable Emitted when there is no `#header` to put the block in.
105
+ */
106
+ this.$emit('unavailable')
107
+ return
108
+ }
109
+ this.host = markRaw(document.createElement('div'))
110
+ this.host.className = HOST_CLASS
111
+ header.insertBefore(this.host, header.firstChild)
112
+ if (typeof MutationObserver === 'function') {
113
+ this.observer = new MutationObserver(() => this.reattach())
114
+ this.observer.observe(header, { childList: true })
115
+ if (header.parentNode) {
116
+ this.observer.observe(header.parentNode, { childList: true })
117
+ }
118
+ }
119
+ },
120
+
121
+ /**
122
+ * Put the same host back when Nextcloud re-rendered the header and took
123
+ * it out. Never creates a second host; when the header is gone for good
124
+ * it hands the block back to the navigation.
125
+ *
126
+ * @return {void}
127
+ */
128
+ reattach() {
129
+ if (!this.host) {
130
+ return
131
+ }
132
+ const header = document.getElementById('header')
133
+ if (!header) {
134
+ this.$emit('unavailable')
135
+ return
136
+ }
137
+ if (this.host.parentNode !== header) {
138
+ header.insertBefore(this.host, header.firstChild)
139
+ this.observer?.observe(header, { childList: true })
140
+ }
141
+ },
142
+
143
+ /**
144
+ * Stop watching and remove only the host this component added.
145
+ *
146
+ * @return {void}
147
+ */
148
+ detach() {
149
+ this.observer?.disconnect()
150
+ this.observer = null
151
+ this.host?.remove()
152
+ this.host = null
153
+ },
154
+ },
155
+ }
156
+ </script>
157
+
158
+ <style>
159
+ /* The host is one flex item at the start of Nextcloud's header. */
160
+ .cn-brand-bar-host {
161
+ flex: none;
162
+ display: flex;
163
+ align-items: center;
164
+ height: 100%;
165
+ margin-inline-end: 12px;
166
+ }
167
+
168
+ .cn-brand-bar {
169
+ box-sizing: border-box;
170
+ display: flex;
171
+ align-items: center;
172
+ gap: 12px;
173
+ width: var(--cn-board-brand-width, 237px);
174
+ }
175
+
176
+ .cn-brand-bar__brand {
177
+ flex: 1 1 0;
178
+ box-sizing: border-box;
179
+ display: flex;
180
+ align-items: center;
181
+ gap: 10px;
182
+ min-width: 0;
183
+ min-height: 48px;
184
+ max-height: 100%;
185
+ padding: 0 6px;
186
+ border-radius: 8px;
187
+ color: var(--color-background-plain-text, var(--color-main-text));
188
+ }
189
+
190
+ .cn-brand-bar__emblem {
191
+ flex: none;
192
+ height: var(--cn-nav-emblem-size, 34px);
193
+ width: auto;
194
+ object-fit: contain;
195
+ }
196
+
197
+ .cn-brand-bar__emblem--theme {
198
+ display: inline-block;
199
+ width: var(--cn-nav-emblem-size, 34px);
200
+ background: var(--nldesign-emblem-url) center / contain no-repeat;
201
+ }
202
+
203
+ .cn-brand-bar__text {
204
+ display: flex;
205
+ flex-direction: column;
206
+ line-height: 1.15;
207
+ min-width: 0;
208
+ }
209
+
210
+ .cn-brand-bar__caption {
211
+ font-size: 13px;
212
+ white-space: nowrap;
213
+ opacity: 0.8;
214
+ }
215
+
216
+ .cn-brand-bar__name {
217
+ font-size: 18px;
218
+ font-weight: 700;
219
+ white-space: nowrap;
220
+ }
221
+
222
+ .cn-brand-bar__divider {
223
+ flex: none;
224
+ width: 1px;
225
+ height: 28px;
226
+ background: currentColor;
227
+ opacity: 0.25;
228
+ color: var(--color-background-plain-text, var(--color-main-text));
229
+ }
230
+ </style>
@@ -106,6 +106,7 @@ Custom card slot — replace the default CnObjectCard with your own rendering:
106
106
  |---|---|---|---|
107
107
  | `selectable` | `Boolean` | `false` | When `true`, each card shows a selection checkbox and the grid emits `select` events |
108
108
  | `selectedIds` | `Array` | `[]` | Array of currently selected object IDs (matched against `object[rowKey]`) |
109
+ | `accentOf` | `Function` | `null` | `(object) => { variant, icon?, label? } \| null`, each card's status accent (see CnObjectCard `accent`) |
109
110
 
110
111
  ## Board look props
111
112
 
@@ -40,6 +40,7 @@
40
40
  :status="statusOf ? statusOf(object) : null"
41
41
  :leading="leadingOf ? leadingOf(object) : null"
42
42
  :footerAction="footerActionOf ? footerActionOf(object) : null"
43
+ :accent="accentOf ? accentOf(object) : null"
43
44
  v-on="cardListeners(object)">
44
45
  <template v-if="$slots['card-actions']" #actions="{ object: obj }">
45
46
  <slot name="card-actions" :object="obj" />
@@ -182,6 +183,12 @@ export default {
182
183
  default: null,
183
184
  },
184
185
 
186
+ /** `(object) => { variant, icon?, label? }` for each card's status accent. See CnObjectCard `accent`. */
187
+ accentOf: {
188
+ type: Function,
189
+ default: null,
190
+ },
191
+
185
192
  /** Text shown when there are no objects */
186
193
  emptyText: {
187
194
  type: String,
@@ -2,25 +2,25 @@
2
2
  - SPDX-License-Identifier: EUPL-1.2
3
3
  - SPDX-FileCopyrightText: 2026 Conduction B.V.
4
4
  -
5
- - CnCredentials — the settings surface for the OpenRegister credential
5
+ - CnCredentials: the settings surface for the OpenRegister credential
6
6
  - broker. Apps occasionally need to act on your behalf against an external
7
7
  - service (GitHub, GitLab, …). Rather than hand the secret to the app, you
8
- - give it once to OpenRegister, which stores it in Keepiq — Nextcloud's
8
+ - give it once to OpenRegister, which stores it in Keepiq, Nextcloud's
9
9
  - native, encrypted credential vault. Apps then make the outbound call
10
10
  - THROUGH OpenRegister and never see the secret itself. You decide which
11
11
  - apps may use each credential, so one credential can be shared across apps
12
12
  - or kept dedicated to one.
13
13
  -
14
14
  - Two scopes render from this one component:
15
- - • scope="personal" (default) — the signed-in user's own
15
+ - • scope="personal" (default), the signed-in user's own
16
16
  - credentials, shown in an app's *personal* settings. From here you may
17
17
  - only add/remove *the app you are currently in* to/from a credential,
18
18
  - or add a new credential (the current app is auto-added). You never
19
19
  - manage the full app list of another app from here.
20
- - • scope="organisation" — organisation-wide credentials, shown in an
20
+ - • scope="organisation" , organisation-wide credentials, shown in an
21
21
  - app's *admin* settings. An admin manages the full allowed-app list.
22
22
  -
23
- - Talks to OpenRegister's credential endpoints (metadata only — a secret is
23
+ - Talks to OpenRegister's credential endpoints (metadata only, a secret is
24
24
  - write-only and is NEVER returned or displayed):
25
25
  - GET /apps/openregister/api/credentials?scope={scope}
26
26
  - GET /apps/openregister/api/credentials/providers
@@ -148,7 +148,7 @@
148
148
  </ul>
149
149
  </section>
150
150
 
151
- <!-- Add credential — inline wizard: pick a provider, then fill it in. -->
151
+ <!-- Add credential: inline wizard: pick a provider, then fill it in. -->
152
152
  <section class="cn-credentials__section">
153
153
  <NcButton v-if="!adding"
154
154
  variant="secondary"
@@ -269,7 +269,7 @@ const PROVIDERS_PATH = '/apps/openregister/api/credentials/providers'
269
269
  * Hardcoded per-provider presentation. Keyed by the OpenRegister catalogue
270
270
  * identifier. `colour` drives the tile; `setupHelp`/`setupUrl` explain how to
271
271
  * obtain the secret; `secretLabel` names the field. Providers not listed here
272
- * still work — they render a neutral tile from their identifier.
272
+ * still work, they render a neutral tile from their identifier.
273
273
  */
274
274
  const PROVIDER_META = {
275
275
  github: {
@@ -339,7 +339,7 @@ export default {
339
339
  },
340
340
 
341
341
  /**
342
- * The current app's manifest `credentials[]` declarations — the
342
+ * The current app's manifest `credentials[]` declarations: the
343
343
  * providers this app can reach through the broker. Shape:
344
344
  * `[{ provider, reason, scopes }]`. Rendered read-only under the intro.
345
345
  *
@@ -404,15 +404,16 @@ export default {
404
404
  },
405
405
 
406
406
  /**
407
- * Reframed intro copy per scope.
407
+ * Reframed intro copy per scope. No em-dashes, sentences under 16 words.
408
408
  *
409
+ * @spec openspec/changes/r4-object-lock-url-and-credentials-copy/specs/credentials-settings/spec.md
409
410
  * @return {string} Intro text.
410
411
  */
411
412
  introText() {
412
413
  if (this.scope === 'organisation') {
413
- return t('nextcloud-vue', 'Organisation credentials let apps act on behalf of your organisation. The secret is stored in Keepiq — Nextcloud\'s native credential vault — never in the app. You choose which apps may use each credential.')
414
+ return t('nextcloud-vue', 'Organisation credentials let apps act for your organisation. Keepiq, the credential vault of Nextcloud, keeps the secret. The app never holds it. You choose which apps may use each credential.')
414
415
  }
415
- return t('nextcloud-vue', 'Apps sometimes need to act on your behalf against an external service. So they never hold your secrets, you give a secret to Nextcloud once and it is kept in Keepiq — a native, encrypted credential vault. Apps then make the call through Keepiq and never see the secret. You decide which apps may use each credential — share one across apps, or keep one per app.')
416
+ return t('nextcloud-vue', 'Apps sometimes act for you on an external service. You give the secret to Nextcloud once, and Keepiq keeps it in an encrypted vault. Apps make the call through Keepiq and never see the secret. You choose which apps may use each credential. Share one across apps, or keep one per app.')
416
417
  },
417
418
 
418
419
  /**
@@ -438,7 +439,7 @@ export default {
438
439
  const serverIds = this.providers.map((p) => p.identifier)
439
440
  let ids = serverIds.length ? serverIds : Object.keys(PROVIDER_META)
440
441
  // When the app declares which providers it uses (appCredentials),
441
- // only offer those — an app should not let you add a credential for
442
+ // only offer those, an app should not let you add a credential for
442
443
  // a provider it has no code path to use.
443
444
  if (this.supportedProviders.size > 0) {
444
445
  ids = ids.filter((id) => this.supportedProviders.has(id))
@@ -448,7 +449,7 @@ export default {
448
449
 
449
450
  /**
450
451
  * Provider identifiers the app declares it uses, derived from
451
- * `appCredentials`. Empty when the app declares nothing — filtering is
452
+ * `appCredentials`. Empty when the app declares nothing, filtering is
452
453
  * then a no-op, so consumers that don't declare providers are unchanged.
453
454
  *
454
455
  * @return {Set<string>} Supported provider identifiers.
@@ -460,7 +461,7 @@ export default {
460
461
  },
461
462
 
462
463
  /**
463
- * Stored credentials to display — filtered to the app's supported
464
+ * Stored credentials to display: filtered to the app's supported
464
465
  * providers when it declares any, so you can only see and authorise
465
466
  * credentials the app can actually use; otherwise all of them.
466
467
  *
@@ -503,7 +504,7 @@ export default {
503
504
  },
504
505
 
505
506
  /**
506
- * Whether the add form can be submitted — name, provider and a secret
507
+ * Whether the add form can be submitted: name, provider and a secret
507
508
  * are required (the whole point is to store a secret).
508
509
  *
509
510
  * @return {boolean} True when submittable.
@@ -727,7 +728,7 @@ export default {
727
728
  },
728
729
 
729
730
  /**
730
- * Human-readable provider title — hardcoded catalogue first, then the
731
+ * Human-readable provider title: hardcoded catalogue first, then the
731
732
  * server title, then the raw identifier.
732
733
  *
733
734
  * @param {string} identifier The provider identifier.
@@ -2727,18 +2727,21 @@ export default {
2727
2727
  () => props.objectId,
2728
2728
  { enabled: () => Boolean(resolveType() && props.objectId) },
2729
2729
  )
2730
- const sidebarReg = props.sidebarProps?.register || props.resolvedSidebar?.register || props.register || ''
2731
- const sidebarSchema = props.sidebarProps?.schema || props.resolvedSidebar?.schema || props.schema || ''
2730
+ // Getters, read at call time: a register or schema that arrives after
2731
+ // mount (route params, a resolved sidebar) must reach the lock URL. They
2732
+ // used to be consts captured once at setup.
2733
+ const sidebarReg = () => props.sidebarProps?.register || props.resolvedSidebar?.register || props.register || ''
2734
+ const sidebarSchema = () => props.sidebarProps?.schema || props.resolvedSidebar?.schema || props.schema || ''
2732
2735
  const lock = useObjectLock(
2733
2736
  subscriptionStore,
2734
- () => sidebarReg,
2735
- () => resolveType() || sidebarSchema,
2737
+ sidebarReg,
2738
+ () => resolveType() || sidebarSchema(),
2736
2739
  () => props.objectId,
2737
2740
  // The third argument is the OBJECT CACHE KEY (`<register>-<schema>`)
2738
2741
  // and the lock URL needs the schema SLUG. They were one string, and
2739
2742
  // the acquire went to `/api/objects/<register>/<register>-<schema>/`,
2740
2743
  // which no register answers.
2741
- { schemaSlug: () => props.schema || sidebarSchema },
2744
+ { schemaSlug: () => props.schema || sidebarSchema() },
2742
2745
  )
2743
2746
  return {
2744
2747
  ...registryExposed,
@@ -103,7 +103,10 @@ export default {
103
103
  return
104
104
  }
105
105
  const all = Object.values(ENVIRONMENT_TITLE_PREFIX)
106
- const bare = document.title.replace(new RegExp(`^(?:${all.map((p) => p.replace(/[[\]]/g, '\\$&')).join('|')}) `), '')
106
+ // Plain string comparison: no pattern is built from the prefixes, so
107
+ // there is nothing to escape.
108
+ const owned = all.find((p) => document.title.startsWith(`${p} `))
109
+ const bare = owned ? document.title.slice(owned.length + 1) : document.title
107
110
  const wanted = environment ? `${ENVIRONMENT_TITLE_PREFIX[environment]} ${bare}` : bare
108
111
  if (document.title !== wanted) {
109
112
  document.title = wanted
@@ -309,6 +309,7 @@
309
309
  and select machinery is unchanged either way. -->
310
310
  <component
311
311
  :is="isUserField(field) ? 'NcSelectUsers' : 'NcSelect'"
312
+ v-cn-select-aria="fieldAria(field)"
312
313
  :inputId="'cn-form-' + field.key"
313
314
  :inputLabel="fieldLabelText(field)"
314
315
  :labelOutside="isBoardLook"
@@ -358,6 +359,7 @@
358
359
  <!-- Multiselect (array enum items / $ref array / Nextcloud users, supports async function) -->
359
360
  <div v-else-if="field.widget === 'multiselect' || field.widget === 'user-multiselect' || field.widget === 'group-multiselect'" class="cn-form-dialog__select-wrapper">
360
361
  <NcSelect
362
+ v-cn-select-aria="fieldAria(field)"
361
363
  :inputId="'cn-form-' + field.key"
362
364
  :inputLabel="fieldLabelText(field)"
363
365
  :labelOutside="isBoardLook"
@@ -401,6 +403,7 @@
401
403
  <div v-else-if="field.widget === 'tags'" class="cn-form-dialog__select-wrapper">
402
404
  <!-- TODO: restore `:options` to `asyncState[field.key]?.options` once on Vue 3 (buble doesn't support optional chaining) -->
403
405
  <NcSelect
406
+ v-cn-select-aria="fieldAria(field)"
404
407
  :inputId="'cn-form-' + field.key"
405
408
  :inputLabel="fieldLabelText(field)"
406
409
  :labelOutside="isBoardLook"
@@ -789,6 +792,7 @@ import { heldFromFailures, splitHeldFiles, uploadHeldFiles } from '../../composa
789
792
  import { useIntegrationRegistry } from '../../composables/useIntegrationRegistry.js'
790
793
  import { TENANT_CONTEXT_KEY } from '../../composables/useTenantContext.js'
791
794
  import { useWriteFeedback } from '../../composables/useWriteFeedback.js'
795
+ import { cnSelectAria } from '../../directives/cnSelectAria.js'
792
796
  import { dialogBoardMixin } from '../../mixins/dialogBoard.js'
793
797
  import { useObjectStore } from '../../store/useObjectStore.js'
794
798
  import { resolveCreateOverrideHandler } from '../../utils/actionsDispatcher.js'
@@ -1009,6 +1013,8 @@ const SEMANTIC_RESOLVE_ENDPOINT = '/apps/openregister/api/schemas/resolve-by-imp
1009
1013
  export default {
1010
1014
  name: 'CnFormDialog',
1011
1015
 
1016
+ directives: { cnSelectAria },
1017
+
1012
1018
  components: {
1013
1019
  CnFileField,
1014
1020
  CnDurationField,
@@ -3618,6 +3624,10 @@ export default {
3618
3624
  return
3619
3625
  }
3620
3626
  const [head, ...rest] = key.split('.')
3627
+ const unsafe = ['__proto__', 'constructor', 'prototype']
3628
+ if (unsafe.includes(head) || rest.some((k) => unsafe.includes(k))) {
3629
+ return
3630
+ }
3621
3631
  const clone = JSON.parse(JSON.stringify(this.formData[head] ?? {}))
3622
3632
  let cur = clone
3623
3633
  rest.slice(0, -1).forEach((k) => {
@@ -217,6 +217,7 @@
217
217
  <component
218
218
  :is="resolveFieldRender(field).tag"
219
219
  v-if="resolveFieldRender(field)"
220
+ v-cn-select-aria="selectAria(field)"
220
221
  v-bind="fieldProps(field)"
221
222
  v-on="resolveFieldRender(field).listeners">
222
223
  <!-- NcCheckboxRadioSwitch puts its label in the slot -->
@@ -398,6 +399,7 @@ import CnStepper from '../CnStepper/CnStepper.vue'
398
399
  import { cnRenderFormField } from '../../composables/cnFormFieldRenderer.js'
399
400
  import { draftIndicatorText, draftKey, formDraftMixin, readDraft } from '../../composables/useFormDraft.js'
400
401
  import { normalizeLook } from '../../composables/useLook.js'
402
+ import { cnSelectAria } from '../../directives/cnSelectAria.js'
401
403
  import { loadCurrentUserProfile } from '../../utils/currentUserProfile.js'
402
404
  import { computeAssignments, resolveFieldDefaults } from '../../utils/formAssign.js'
403
405
  import { validateFieldValue } from '../../utils/formValidation.js'
@@ -441,6 +443,8 @@ function resolveParams(url, params) {
441
443
  export default {
442
444
  name: 'CnFormPage',
443
445
 
446
+ directives: { cnSelectAria },
447
+
444
448
  components: {
445
449
  CnFormErrorSummary,
446
450
  CnFormField,
@@ -1308,6 +1312,35 @@ export default {
1308
1312
  return props
1309
1313
  },
1310
1314
 
1315
+ /**
1316
+ * The accessibility attributes of an enum field's NcSelect. NcSelect
1317
+ * forwards no attributes to its input, so these go through
1318
+ * `v-cn-select-aria` onto the combobox input instead of `fieldProps`.
1319
+ * The error element exists for an enum in both looks (NcSelect has no
1320
+ * native error text), so an invalid value lists it before the hint.
1321
+ *
1322
+ * @param {object} field The formField shape.
1323
+ * @return {object|null} The attributes, or null for any other control.
1324
+ */
1325
+ selectAria(field) {
1326
+ const render = this.resolveFieldRender(field)
1327
+ if (!render || render.kind !== 'enum') {
1328
+ return null
1329
+ }
1330
+ const ids = []
1331
+ if (this.fieldErrors[field.key]) {
1332
+ ids.push(this.errorIdFor(field))
1333
+ }
1334
+ if (field.help) {
1335
+ ids.push(this.helpIdFor(field))
1336
+ }
1337
+ return {
1338
+ 'aria-invalid': this.fieldErrors[field.key] ? 'true' : null,
1339
+ 'aria-describedby': ids.length > 0 ? ids.join(' ') : null,
1340
+ 'aria-required': this.showsBoardHead(field) && this.isFieldRequired(field) ? 'true' : null,
1341
+ }
1342
+ },
1343
+
1311
1344
  /**
1312
1345
  * Cancel (board look footer): go to `cancelRoute` when set, and tell the
1313
1346
  * host.
@@ -434,6 +434,21 @@ list nested under a parent route (`/forms/:id/submissions`) is a fully
434
434
  declarative page. When `objects` **is** supplied (every existing consumer),
435
435
  nothing changes — no store is touched and `filter` has no effect.
436
436
 
437
+ ### Mixed-schema lists (`collectionUrl`)
438
+
439
+ Set `collectionUrl` to list from an endpoint that searches several
440
+ register/schema pairs at once. `register` and `schema` stay the page's own
441
+ pair (columns, Add, export, import); a row of another pair edits, saves and
442
+ deletes through its own `@self.register` / `@self.schema`.
443
+
444
+ ```js static
445
+ <CnIndexPage
446
+ title="Publications"
447
+ register="19"
448
+ schema="24"
449
+ :collection-url="generateUrl('/apps/opencatalogi/api/{slug}', { slug })" />
450
+ ```
451
+
437
452
  ## Named entity sources (`config.entitySource`) — lists that are not OpenRegister objects
438
453
 
439
454
  Self-fetch needs a `register` + `schema` pair. Some lists have neither: a flow