@conduction/nextcloud-vue 2.56.0-beta.62 → 2.56.0-beta.63

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 (155) hide show
  1. package/dist/esm/components/CnAddressSearch/CnAddressSearch.vue.js +10 -0
  2. package/dist/esm/components/CnAddressSearch/CnAddressSearch.vue.js.map +1 -0
  3. package/dist/esm/components/CnAddressSearch/CnAddressSearch.vue2.js +305 -0
  4. package/dist/esm/components/CnAddressSearch/CnAddressSearch.vue2.js.map +1 -0
  5. package/dist/esm/components/CnAddressSearch/CnAddressSearch.vue3.js +38 -0
  6. package/dist/esm/components/CnAddressSearch/CnAddressSearch.vue3.js.map +1 -0
  7. package/dist/esm/components/CnAssignDialog/CnAssignDialog.vue.js +10 -0
  8. package/dist/esm/components/CnAssignDialog/CnAssignDialog.vue.js.map +1 -0
  9. package/dist/esm/components/CnAssignDialog/CnAssignDialog.vue2.js +398 -0
  10. package/dist/esm/components/CnAssignDialog/CnAssignDialog.vue2.js.map +1 -0
  11. package/dist/esm/components/CnAssignDialog/CnAssignDialog.vue3.js +155 -0
  12. package/dist/esm/components/CnAssignDialog/CnAssignDialog.vue3.js.map +1 -0
  13. package/dist/esm/components/CnDecisionListWidget/CnDecisionListWidget.vue.js +9 -0
  14. package/dist/esm/components/CnDecisionListWidget/CnDecisionListWidget.vue.js.map +1 -0
  15. package/dist/esm/components/CnDecisionListWidget/CnDecisionListWidget.vue2.js +281 -0
  16. package/dist/esm/components/CnDecisionListWidget/CnDecisionListWidget.vue2.js.map +1 -0
  17. package/dist/esm/components/CnDecisionListWidget/CnDecisionListWidget.vue3.js +137 -0
  18. package/dist/esm/components/CnDecisionListWidget/CnDecisionListWidget.vue3.js.map +1 -0
  19. package/dist/esm/components/CnDuplicateWarning/CnDuplicateWarning.vue.js +9 -0
  20. package/dist/esm/components/CnDuplicateWarning/CnDuplicateWarning.vue.js.map +1 -0
  21. package/dist/esm/components/CnDuplicateWarning/CnDuplicateWarning.vue2.js +225 -0
  22. package/dist/esm/components/CnDuplicateWarning/CnDuplicateWarning.vue2.js.map +1 -0
  23. package/dist/esm/components/CnDuplicateWarning/CnDuplicateWarning.vue3.js +110 -0
  24. package/dist/esm/components/CnDuplicateWarning/CnDuplicateWarning.vue3.js.map +1 -0
  25. package/dist/esm/components/CnFormDialog/CnFormDialog.vue.js +1 -1
  26. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +19 -3
  27. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
  28. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
  29. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +30 -0
  30. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  31. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +513 -481
  32. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  33. package/dist/esm/components/CnNcDashboardList/CnNcDashboardList.vue.js +9 -0
  34. package/dist/esm/components/CnNcDashboardList/CnNcDashboardList.vue.js.map +1 -0
  35. package/dist/esm/components/CnNcDashboardList/CnNcDashboardList.vue2.js +181 -0
  36. package/dist/esm/components/CnNcDashboardList/CnNcDashboardList.vue2.js.map +1 -0
  37. package/dist/esm/components/CnNcDashboardList/CnNcDashboardList.vue3.js +50 -0
  38. package/dist/esm/components/CnNcDashboardList/CnNcDashboardList.vue3.js.map +1 -0
  39. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue.js +1 -1
  40. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js +102 -1
  41. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js.map +1 -1
  42. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js +6 -6
  43. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js.map +1 -1
  44. package/dist/esm/components/CnPlanWidget/CnPlanWidget.vue.js +9 -0
  45. package/dist/esm/components/CnPlanWidget/CnPlanWidget.vue.js.map +1 -0
  46. package/dist/esm/components/CnPlanWidget/CnPlanWidget.vue2.js +190 -0
  47. package/dist/esm/components/CnPlanWidget/CnPlanWidget.vue2.js.map +1 -0
  48. package/dist/esm/components/CnPlanWidget/CnPlanWidget.vue3.js +91 -0
  49. package/dist/esm/components/CnPlanWidget/CnPlanWidget.vue3.js.map +1 -0
  50. package/dist/esm/components/CnPlanWidget/CnPlanWidgetRow.vue.js +9 -0
  51. package/dist/esm/components/CnPlanWidget/CnPlanWidgetRow.vue.js.map +1 -0
  52. package/dist/esm/components/CnPlanWidget/CnPlanWidgetRow.vue2.js +64 -0
  53. package/dist/esm/components/CnPlanWidget/CnPlanWidgetRow.vue2.js.map +1 -0
  54. package/dist/esm/components/CnPlanWidget/CnPlanWidgetRow.vue3.js +49 -0
  55. package/dist/esm/components/CnPlanWidget/CnPlanWidgetRow.vue3.js.map +1 -0
  56. package/dist/esm/components/CnRedactionEditor/CnRedactionEditor.vue.js +9 -0
  57. package/dist/esm/components/CnRedactionEditor/CnRedactionEditor.vue.js.map +1 -0
  58. package/dist/esm/components/CnRedactionEditor/CnRedactionEditor.vue2.js +382 -0
  59. package/dist/esm/components/CnRedactionEditor/CnRedactionEditor.vue2.js.map +1 -0
  60. package/dist/esm/components/CnRedactionEditor/CnRedactionEditor.vue3.js +285 -0
  61. package/dist/esm/components/CnRedactionEditor/CnRedactionEditor.vue3.js.map +1 -0
  62. package/dist/esm/components/CnRetentionWidget/CnRetentionWidget.vue.js +9 -0
  63. package/dist/esm/components/CnRetentionWidget/CnRetentionWidget.vue.js.map +1 -0
  64. package/dist/esm/components/CnRetentionWidget/CnRetentionWidget.vue2.js +277 -0
  65. package/dist/esm/components/CnRetentionWidget/CnRetentionWidget.vue2.js.map +1 -0
  66. package/dist/esm/components/CnRetentionWidget/CnRetentionWidget.vue3.js +183 -0
  67. package/dist/esm/components/CnRetentionWidget/CnRetentionWidget.vue3.js.map +1 -0
  68. package/dist/esm/components/CnWidgetGrid/builtInWidgets.js +16 -14
  69. package/dist/esm/components/CnWidgetGrid/builtInWidgets.js.map +1 -1
  70. package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js +53 -22
  71. package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js.map +1 -1
  72. package/dist/esm/components/CnWorkQueue/CnWorkQueue.vue.js +9 -0
  73. package/dist/esm/components/CnWorkQueue/CnWorkQueue.vue.js.map +1 -0
  74. package/dist/esm/components/CnWorkQueue/CnWorkQueue.vue2.js +246 -0
  75. package/dist/esm/components/CnWorkQueue/CnWorkQueue.vue2.js.map +1 -0
  76. package/dist/esm/components/CnWorkQueue/CnWorkQueue.vue3.js +197 -0
  77. package/dist/esm/components/CnWorkQueue/CnWorkQueue.vue3.js.map +1 -0
  78. package/dist/esm/index.js +12 -0
  79. package/dist/esm/index.js.map +1 -1
  80. package/dist/esm/l10n/en.json.js +106 -1
  81. package/dist/esm/l10n/en.json.js.map +1 -1
  82. package/dist/esm/l10n/nl.json.js +105 -1
  83. package/dist/esm/l10n/nl.json.js.map +1 -1
  84. package/dist/esm/library-style.js +1 -1
  85. package/dist/esm/nextcloud-vue.css +495 -61
  86. package/dist/esm/utils/actionsDispatcher.js +6 -1
  87. package/dist/esm/utils/actionsDispatcher.js.map +1 -1
  88. package/dist/esm/utils/builtInModals.js +3 -1
  89. package/dist/esm/utils/builtInModals.js.map +1 -1
  90. package/dist/esm/utils/duplicateWarning.js +62 -0
  91. package/dist/esm/utils/duplicateWarning.js.map +1 -0
  92. package/dist/esm/utils/errors.js +4 -0
  93. package/dist/esm/utils/errors.js.map +1 -1
  94. package/dist/esm/utils/libraryWidgetKeys.js +4 -0
  95. package/dist/esm/utils/libraryWidgetKeys.js.map +1 -1
  96. package/dist/esm/utils/openDuplicateWarning.js +39 -0
  97. package/dist/esm/utils/openDuplicateWarning.js.map +1 -0
  98. package/dist/esm/utils/optionsFrom.js +69 -2
  99. package/dist/esm/utils/optionsFrom.js.map +1 -1
  100. package/dist/esm/utils/planTree.js +57 -0
  101. package/dist/esm/utils/planTree.js.map +1 -0
  102. package/dist/esm/utils/redactionSegments.js +53 -0
  103. package/dist/esm/utils/redactionSegments.js.map +1 -0
  104. package/dist/esm/utils/registerNcDashboardWidgets.js +61 -0
  105. package/dist/esm/utils/registerNcDashboardWidgets.js.map +1 -0
  106. package/dist/esm/utils/searchRefusal.js +62 -0
  107. package/dist/esm/utils/searchRefusal.js.map +1 -0
  108. package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
  109. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  110. package/dist/nextcloud-vue.cjs.js +17409 -12832
  111. package/dist/nextcloud-vue.cjs.js.map +2 -2
  112. package/dist/nextcloud-vue.css +495 -61
  113. package/l10n/en.json +106 -1
  114. package/l10n/nl.json +105 -1
  115. package/package.json +1 -1
  116. package/src/components/CnAddressSearch/CnAddressSearch.vue +343 -0
  117. package/src/components/CnAddressSearch/index.js +12 -0
  118. package/src/components/CnAssignDialog/CnAssignDialog.vue +525 -0
  119. package/src/components/CnAssignDialog/index.js +12 -0
  120. package/src/components/CnDecisionListWidget/CnDecisionListWidget.vue +388 -0
  121. package/src/components/CnDecisionListWidget/index.js +12 -0
  122. package/src/components/CnDuplicateWarning/CnDuplicateWarning.vue +318 -0
  123. package/src/components/CnDuplicateWarning/index.js +12 -0
  124. package/src/components/CnFormDialog/CnFormDialog.vue +19 -3
  125. package/src/components/CnIndexPage/CnIndexPage.vue +52 -0
  126. package/src/components/CnNcDashboardList/CnNcDashboardList.vue +218 -0
  127. package/src/components/CnNcDashboardList/index.js +12 -0
  128. package/src/components/CnObjectListWidget/CnObjectListWidget.vue +107 -6
  129. package/src/components/CnPlanWidget/CnPlanWidget.vue +246 -0
  130. package/src/components/CnPlanWidget/CnPlanWidgetRow.vue +117 -0
  131. package/src/components/CnPlanWidget/index.js +12 -0
  132. package/src/components/CnRedactionEditor/CnRedactionEditor.vue +655 -0
  133. package/src/components/CnRedactionEditor/index.js +13 -0
  134. package/src/components/CnRetentionWidget/CnRetentionWidget.vue +413 -0
  135. package/src/components/CnRetentionWidget/index.js +12 -0
  136. package/src/components/CnWidgetGrid/builtInWidgets.js +2 -0
  137. package/src/components/CnWidgetGrid/registerDashboardWidgets.js +31 -0
  138. package/src/components/CnWorkQueue/CnWorkQueue.vue +456 -0
  139. package/src/components/CnWorkQueue/index.js +12 -0
  140. package/src/components/index.js +9 -0
  141. package/src/css/index-page.css +24 -0
  142. package/src/index.js +12 -0
  143. package/src/schemas/app-manifest-v2.schema.json +119 -3
  144. package/src/utils/actionsDispatcher.js +6 -1
  145. package/src/utils/builtInModals.js +3 -1
  146. package/src/utils/duplicateWarning.js +59 -0
  147. package/src/utils/errors.js +4 -0
  148. package/src/utils/libraryWidgetKeys.js +4 -0
  149. package/src/utils/openDuplicateWarning.js +35 -0
  150. package/src/utils/optionsFrom.js +68 -1
  151. package/src/utils/planTree.js +54 -0
  152. package/src/utils/redactionSegments.js +50 -0
  153. package/src/utils/registerNcDashboardWidgets.js +57 -0
  154. package/src/utils/searchRefusal.js +59 -0
  155. package/src/utils/validateManifestV2.compiled.js +3 -3
@@ -0,0 +1,318 @@
1
+ <!--
2
+ - SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ - SPDX-License-Identifier: EUPL-1.2
4
+ -->
5
+ <template>
6
+ <CnDialog
7
+ :name="text(title) || t('nextcloud-vue', 'This may already exist')"
8
+ width="form"
9
+ data-testid="cn-duplicate-warning"
10
+ @closing="close">
11
+ <div class="cn-duplicate-warning">
12
+ <NcNoteCard :type="affordances.blocked ? 'error' : 'warning'" data-testid="cn-duplicate-warning-note">
13
+ {{ headlineText }}
14
+ </NcNoteCard>
15
+
16
+ <ul class="cn-duplicate-warning__matches">
17
+ <li v-for="row in rows"
18
+ :key="row.uuid"
19
+ class="cn-duplicate-warning__match"
20
+ data-testid="cn-duplicate-warning-match">
21
+ <a v-if="row.href" :href="row.href" class="cn-duplicate-warning__title">{{ row.title }}</a>
22
+ <span v-else class="cn-duplicate-warning__title">{{ row.title }}</span>
23
+ <span v-if="row.meta" class="cn-duplicate-warning__meta">{{ row.meta }}</span>
24
+ <span class="cn-duplicate-warning__meta">{{ row.matchedOn }} ({{ row.percentage }}%)</span>
25
+ </li>
26
+ </ul>
27
+
28
+ <NcTextField v-if="affordances.needsReason"
29
+ :label="text(reasonLabel) || t('nextcloud-vue', 'Why file it anyway')"
30
+ :modelValue="reason"
31
+ data-testid="cn-duplicate-warning-reason"
32
+ @update:modelValue="reason = $event" />
33
+
34
+ <p v-if="affordances.blocked && !affordances.mayContinue"
35
+ class="cn-duplicate-warning__hint"
36
+ data-testid="cn-duplicate-warning-blocked">
37
+ {{ text(blockedText) || t('nextcloud-vue', 'This cannot be filed twice. Open the one it matches, or ask a coordinator.') }}
38
+ </p>
39
+ </div>
40
+
41
+ <template #actions>
42
+ <NcButton variant="tertiary" data-testid="cn-duplicate-warning-cancel" @click="close">
43
+ {{ t('nextcloud-vue', 'Cancel') }}
44
+ </NcButton>
45
+ <NcButton v-if="affordances.mayContinue"
46
+ variant="primary"
47
+ :disabled="!canFile"
48
+ data-testid="cn-duplicate-warning-continue"
49
+ @click="fileAnyway">
50
+ {{ text(continueLabel) || t('nextcloud-vue', 'File it anyway') }}
51
+ </NcButton>
52
+ </template>
53
+ </CnDialog>
54
+ </template>
55
+
56
+ <script>
57
+ import { translatePlural as n, translate as t } from '@nextcloud/l10n'
58
+ import { generateUrl } from '@nextcloud/router'
59
+ import { NcButton, NcNoteCard, NcTextField } from '@nextcloud/vue'
60
+ import { duplicateAffordances } from '../../utils/duplicateWarning.js'
61
+ import { fillTemplate } from '../../utils/fillTemplate.js'
62
+ import { CnDialog } from '../CnDialog/index.js'
63
+
64
+ /**
65
+ * CnDuplicateWarning — "this may already exist", before a record is filed.
66
+ * It renders OpenRegister's scored matches (`{ uuid, score, matchedOn }`),
67
+ * never scoring anything itself, with each matched record's title, a meta
68
+ * line, what it matched on and how strongly. Under a `warn` policy anyone may
69
+ * file it anyway; under `block` only a user who `mayOverride`, and they say
70
+ * why. A match the user may not read is reported without disclosing it.
71
+ * dossiq's case intake is the first consumer; pipelinq contacts share it.
72
+ *
73
+ * Open it in a template, or from code with `openDuplicateWarning(props)`,
74
+ * which resolves with `{ reason, over }` or null.
75
+ *
76
+ * ```vue
77
+ * <CnDuplicateWarning
78
+ * :matches="matches"
79
+ * :records="readableCases"
80
+ * policy="block"
81
+ * :may-override="isCoordinator"
82
+ * record-url="/apps/dossiq/cases/{uuid}"
83
+ * :field-labels="{ requester: 'Requester', title: 'Subject' }"
84
+ * @file="save"
85
+ * @close="cancel" />
86
+ * ```
87
+ */
88
+ export default {
89
+ name: 'CnDuplicateWarning',
90
+
91
+ components: { CnDialog, NcButton, NcNoteCard, NcTextField },
92
+
93
+ inject: {
94
+ /** The host app's translate (CnAppRoot `cnTranslate`). */
95
+ cnTranslate: { default: () => (key) => key },
96
+ },
97
+
98
+ props: {
99
+ /**
100
+ * The scored matches, as OpenRegister answered: `{ uuid, score, matchedOn }`.
101
+ *
102
+ * @type {Array<object>}
103
+ */
104
+ matches: {
105
+ type: Array,
106
+ default: () => [],
107
+ },
108
+
109
+ /**
110
+ * The matched records the user may read, matched by `id`, `uuid` or `@self.id`.
111
+ *
112
+ * @type {Array<object>}
113
+ */
114
+ records: {
115
+ type: Array,
116
+ default: () => [],
117
+ },
118
+
119
+ /** `warn` lets anyone file it anyway; `block` only a user who `mayOverride`. */
120
+ policy: {
121
+ type: String,
122
+ default: 'warn',
123
+ },
124
+
125
+ /** Whether this user may file over a block (and must say why). */
126
+ mayOverride: {
127
+ type: Boolean,
128
+ default: false,
129
+ },
130
+
131
+ /** A record's title, a template filled from it. */
132
+ rowTitle: {
133
+ type: String,
134
+ default: '{title}',
135
+ },
136
+
137
+ /** A record's meta line, a template filled from it. */
138
+ rowMeta: {
139
+ type: String,
140
+ default: '{identifier}',
141
+ },
142
+
143
+ /** A record's page, a template with `{uuid}`; empty draws no link. */
144
+ recordUrl: {
145
+ type: String,
146
+ default: '',
147
+ },
148
+
149
+ /**
150
+ * Words for the matched fields (`{ requester: "Requester" }`), through the host translate.
151
+ *
152
+ * @type {object}
153
+ */
154
+ fieldLabels: {
155
+ type: Object,
156
+ default: () => ({}),
157
+ },
158
+
159
+ /** The dialog title; defaults to "This may already exist". */
160
+ title: {
161
+ type: String,
162
+ default: '',
163
+ },
164
+
165
+ /** The note's sentence, `{count}` filled; defaults to "%n existing record looks like this one.". */
166
+ headline: {
167
+ type: String,
168
+ default: '',
169
+ },
170
+
171
+ /** The sentence under a block the user may not pass. */
172
+ blockedText: {
173
+ type: String,
174
+ default: '',
175
+ },
176
+
177
+ /** The reason field's label. */
178
+ reasonLabel: {
179
+ type: String,
180
+ default: '',
181
+ },
182
+
183
+ /** The continue button's label; defaults to "File it anyway". */
184
+ continueLabel: {
185
+ type: String,
186
+ default: '',
187
+ },
188
+ },
189
+
190
+ emits: ['close', 'file'],
191
+
192
+ data() {
193
+ return {
194
+ reason: '',
195
+ }
196
+ },
197
+
198
+ computed: {
199
+ /**
200
+ * What to draw and offer.
201
+ *
202
+ * @spec openspec/changes/duplicate-warning/specs/duplicate-warning/spec.md#requirement-a-generic-duplicate-warning
203
+ * @return {{show: boolean, blocked: boolean, mayContinue: boolean, needsReason: boolean}}
204
+ */
205
+ affordances() {
206
+ return duplicateAffordances({ matches: this.matches, policy: this.policy, mayOverride: this.mayOverride })
207
+ },
208
+
209
+ /** @return {boolean} Whether "file it anyway" may be pressed. */
210
+ canFile() {
211
+ return this.affordances.mayContinue && (!this.affordances.needsReason || this.reason.trim() !== '')
212
+ },
213
+
214
+ /** @return {string} The note's sentence. */
215
+ headlineText() {
216
+ const count = this.matches.length
217
+ if (this.headline) {
218
+ return fillTemplate(this.cnTranslate(this.headline), { count })
219
+ }
220
+ return n('nextcloud-vue', '%n existing record looks like this one.', '%n existing records look like this one.', count)
221
+ },
222
+
223
+ /**
224
+ * One row per match: the readable record's title and meta and link, or
225
+ * a neutral name for one the user may not read.
226
+ *
227
+ * @spec openspec/changes/duplicate-warning/specs/duplicate-warning/spec.md#requirement-a-generic-duplicate-warning
228
+ * @return {Array<object>}
229
+ */
230
+ rows() {
231
+ return this.matches.map((match) => {
232
+ const uuid = String((match && match.uuid) || '')
233
+ const record = this.records.find((row) => String((row && (row.id || row.uuid || (row['@self'] && row['@self'].id))) || '') === uuid)
234
+ const fields = Array.isArray(match && match.matchedOn) ? match.matchedOn : []
235
+ const score = Number(match && match.score)
236
+ return {
237
+ uuid,
238
+ title: record ? (fillTemplate(this.rowTitle, record).trim() || uuid) : t('nextcloud-vue', 'A record you may not open'),
239
+ meta: record ? fillTemplate(this.rowMeta, record).trim() : '',
240
+ href: record && this.recordUrl ? generateUrl(fillTemplate(this.recordUrl, { uuid: encodeURIComponent(uuid) })) : '',
241
+ matchedOn: fields.length
242
+ ? fields.map((field) => (this.fieldLabels[field] ? this.cnTranslate(this.fieldLabels[field]) : field)).join(', ')
243
+ : t('nextcloud-vue', 'Matched on the score, not on a single field'),
244
+ percentage: Number.isFinite(score) ? Math.max(0, Math.min(100, Math.round(score * 100))) : 0,
245
+ }
246
+ })
247
+ },
248
+ },
249
+
250
+ methods: {
251
+ t,
252
+
253
+ /**
254
+ * A prop text through the host translate.
255
+ *
256
+ * @param {string} value The text.
257
+ * @return {string}
258
+ */
259
+ text(value) {
260
+ return value ? this.cnTranslate(value) : ''
261
+ },
262
+
263
+ /** File it over the warning, carrying the reason and what it was filed over. */
264
+ fileAnyway() {
265
+ if (!this.canFile) {
266
+ return
267
+ }
268
+ /** @event file The user files it anyway. Payload: `{ reason, over }`, `over` the matched uuids. */
269
+ this.$emit('file', { reason: this.reason.trim(), over: this.matches.map((m) => String((m && m.uuid) || '')).filter(Boolean) })
270
+ },
271
+
272
+ /** Go back without filing. */
273
+ close() {
274
+ /** @event close The user went back without filing. */
275
+ this.$emit('close')
276
+ },
277
+ },
278
+ }
279
+ </script>
280
+
281
+ <style scoped>
282
+ .cn-duplicate-warning {
283
+ display: flex;
284
+ flex-direction: column;
285
+ gap: 12px;
286
+ }
287
+
288
+ .cn-duplicate-warning__matches {
289
+ display: flex;
290
+ flex-direction: column;
291
+ gap: 8px;
292
+ margin: 0;
293
+ padding: 0;
294
+ list-style: none;
295
+ }
296
+
297
+ .cn-duplicate-warning__match {
298
+ display: flex;
299
+ flex-wrap: wrap;
300
+ gap: 4px 12px;
301
+ padding: 8px 12px;
302
+ border: 1px solid var(--color-border);
303
+ border-radius: var(--border-radius-large, 8px);
304
+ }
305
+
306
+ .cn-duplicate-warning__title {
307
+ font-weight: 600;
308
+ }
309
+
310
+ .cn-duplicate-warning__meta,
311
+ .cn-duplicate-warning__hint {
312
+ color: var(--color-text-maxcontrast);
313
+ }
314
+
315
+ .cn-duplicate-warning__hint {
316
+ margin: 0;
317
+ }
318
+ </style>
@@ -0,0 +1,12 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ * SPDX-License-Identifier: EUPL-1.2
4
+ *
5
+ * CnDuplicateWarning — "this may already exist", before a record is filed,
6
+ * from OpenRegister's scored matches.
7
+ */
8
+
9
+ import CnDuplicateWarning from './CnDuplicateWarning.vue'
10
+
11
+ export { CnDuplicateWarning }
12
+ export default CnDuplicateWarning
@@ -961,7 +961,7 @@ import { computeAssignments } from '../../utils/formAssign.js'
961
961
  import { resolveNextcloudGroup, searchNextcloudGroups } from '../../utils/groupAutocomplete.js'
962
962
  import { durationSeconds } from '../../utils/isoDuration.js'
963
963
  import { objectDisplayName } from '../../utils/objectName.js'
964
- import { fieldWithOptionsFrom, filterOptions, isOptionsFrom, loadOptionsFrom } from '../../utils/optionsFrom.js'
964
+ import { fieldWithOptionsFrom, filterOptions, formTokenKeys, isOptionsFrom, loadOptionsFrom, resolveFormTokens } from '../../utils/optionsFrom.js'
965
965
  import { languageOptions, resolveDefaultToken, timezoneOptions } from '../../utils/pickerOptions.js'
966
966
  import { getDotted, planPropertySourceFill } from '../../utils/propertySourceFill.js'
967
967
  import { pruneRows } from '../../utils/pruneRows.js'
@@ -1573,6 +1573,8 @@ export default {
1573
1573
  sourcedOptions: {},
1574
1574
  // Whether a field's `optionsFrom` failed or gave no options, by field key: it falls back to text.
1575
1575
  sourcedFailed: {},
1576
+ // The resolved source each field's options were read from, by field key.
1577
+ sourcedSignatures: {},
1576
1578
  // Set while a save that passed its uniqueness check goes on.
1577
1579
  uniqueChecked: false,
1578
1580
  // The sections folded away (`sectionCollapsed` on one of their fields opens them folded).
@@ -1938,6 +1940,9 @@ export default {
1938
1940
  visibleFields() {
1939
1941
  return this.resolvedFields
1940
1942
  .filter((field) => shouldShow(field, this.formData))
1943
+ // `optionsFrom.hideWhenEmpty`: a select whose source gives nothing
1944
+ // for the current values is left out (a type without formats).
1945
+ .filter((field) => !(field.optionsFrom && field.optionsFrom.hideWhenEmpty === true && Array.isArray(this.sourcedOptions[field.key]) && this.sourcedOptions[field.key].length === 0))
1941
1946
  // `requiredWhen`: required while its condition holds, so the
1942
1947
  // asterisk, the board look's aria-required and validation follow.
1943
1948
  .map((field) => (field.requiredWhen ? { ...field, required: conditionHolds(field.requiredWhen, this.formData, field.key) } : field))
@@ -2664,13 +2669,18 @@ export default {
2664
2669
  * @spec openspec/changes/form-field-options-from/specs/form-dialog/spec.md#requirement-a-select-field-can-read-its-options-from-a-source
2665
2670
  */
2666
2671
  async fetchSourcedOptions(field, query) {
2667
- let all = this.sourcedOptions[field.key]
2672
+ // A source that reads another field (`@answer.<key>`) is read again
2673
+ // when that value changes: the cache is kept per resolved source.
2674
+ const from = resolveFormTokens(field.optionsFrom, this.formData)
2675
+ const signature = JSON.stringify(from)
2676
+ let all = this.sourcedSignatures[field.key] === signature ? this.sourcedOptions[field.key] : null
2668
2677
  if (!all) {
2669
2678
  try {
2670
- all = await loadOptionsFrom(field.optionsFrom, resolveObjectTokenContext(this.cnObjectContext, this.cnDetailObjectContext) || {})
2679
+ all = await loadOptionsFrom(from, resolveObjectTokenContext(this.cnObjectContext, this.cnDetailObjectContext) || {})
2671
2680
  } catch {
2672
2681
  all = []
2673
2682
  }
2683
+ this.sourcedSignatures = { ...this.sourcedSignatures, [field.key]: signature }
2674
2684
  this.sourcedOptions = { ...this.sourcedOptions, [field.key]: all }
2675
2685
  this.sourcedFailed = { ...this.sourcedFailed, [field.key]: all.length === 0 }
2676
2686
  const labels = {}
@@ -2942,6 +2952,12 @@ export default {
2942
2952
  // A hand edit ends this field's own rules; other fields may follow it.
2943
2953
  delete this.assignedFrom[key]
2944
2954
  this.runAssignments([key])
2955
+ // A select whose source reads this field (`@answer.<key>`) reads it again.
2956
+ for (const field of this.resolvedFields) {
2957
+ if (isOptionsFrom(field.optionsFrom) && formTokenKeys(field.optionsFrom).includes(key)) {
2958
+ this.loadAsyncOptions(field, '')
2959
+ }
2960
+ }
2945
2961
  },
2946
2962
 
2947
2963
  /**
@@ -744,6 +744,28 @@
744
744
  <NcLoadingIcon :size="32" :name="loadingText" />
745
745
  </div>
746
746
 
747
+ <!-- A search the server refused for its grammar: the term with the
748
+ character it stopped at, the server's reason, and what to do. -->
749
+ <div v-else-if="searchRefusal"
750
+ class="cn-index-page__search-refusal"
751
+ role="status"
752
+ data-testid="cn-index-page-search-refusal">
753
+ <NcNoteCard type="warning">
754
+ <p class="cn-index-page__search-refusal-headline">
755
+ {{ t('nextcloud-vue', 'This search could not be read from character {position}.', { position: searchRefusal.position }) }}
756
+ </p>
757
+ <p class="cn-index-page__search-refusal-term" data-testid="cn-index-page-search-refusal-term">
758
+ <span>{{ searchRefusal.before }}</span><mark v-if="searchRefusal.at">{{ searchRefusal.at }}</mark><span>{{ searchRefusal.after }}</span>
759
+ </p>
760
+ <p class="cn-index-page__search-refusal-muted">
761
+ {{ searchRefusal.message }}
762
+ </p>
763
+ <p class="cn-index-page__search-refusal-muted">
764
+ {{ searchRefusalHintText }}
765
+ </p>
766
+ </NcNoteCard>
767
+ </div>
768
+
747
769
  <!-- Error state: the latest self-fetch failed (e.g. a search term the server rejects) -->
748
770
  <div v-else-if="selfFetchFailed"
749
771
  class="cn-index-page__empty"
@@ -1361,6 +1383,7 @@ import { buildRouteQueryFromViewState, buildViewCreatePayload, defaultFilterView
1361
1383
  import { savedViewBadge, savedViewOverrides } from '../../utils/savedViewOverrides.js'
1362
1384
  import { columnsFromSchema, fieldsFromSchema } from '../../utils/schema.js'
1363
1385
  import { resolveScopeLayout, resolveScopePresentation } from '../../utils/scopeListLayout.js'
1386
+ import { readSearchRefusal } from '../../utils/searchRefusal.js'
1364
1387
  import { orderToSortKeys } from '../../utils/sortOrder.js'
1365
1388
  import { annotateRows, filterSourceRows, pageSourceRows, rowsFromSourceBody } from '../../utils/sortSourceRows.js'
1366
1389
  import { dispatchObjectCreated } from '../../utils/walkthroughSignals.js'
@@ -2825,6 +2848,17 @@ export default {
2825
2848
  default: null,
2826
2849
  },
2827
2850
 
2851
+ /**
2852
+ * What to do after a refused search, through the app's translate. The
2853
+ * default explains AND, OR, NOT and quotes.
2854
+ *
2855
+ * @spec openspec/changes/index-search-refusal/specs/index-search-refusal/spec.md#requirement-a-refused-search-is-said-where-it-was-typed
2856
+ */
2857
+ searchRefusalHint: {
2858
+ type: String,
2859
+ default: '',
2860
+ },
2861
+
2828
2862
  /** Accessible label for the loading spinner (NcLoadingIcon aria-label) */
2829
2863
  loadingText: {
2830
2864
  type: String,
@@ -4417,6 +4451,24 @@ export default {
4417
4451
  return this.isSelfFetchMode && !!this.list.error?.value
4418
4452
  },
4419
4453
 
4454
+ /**
4455
+ * The failed fetch as a refused search, or null when it is not one.
4456
+ *
4457
+ * @spec openspec/changes/index-search-refusal/specs/index-search-refusal/spec.md#requirement-a-refused-search-is-said-where-it-was-typed
4458
+ * @return {object|null}
4459
+ */
4460
+ searchRefusal() {
4461
+ return this.selfFetchFailed ? readSearchRefusal(this.list.error.value, this.effectiveSearchValue) : null
4462
+ },
4463
+
4464
+ /** @return {string} What to do after a refused search. */
4465
+ searchRefusalHintText() {
4466
+ if (this.searchRefusalHint) {
4467
+ return typeof this.cnTranslate === 'function' ? this.cnTranslate(this.searchRefusalHint) : this.searchRefusalHint
4468
+ }
4469
+ return t('nextcloud-vue', 'Correct that character, or search for plain words. AND, OR and NOT work in capitals, and quotes make a phrase.')
4470
+ },
4471
+
4420
4472
  /** Rows: store collection in self-fetch mode, else the `objects` prop. */
4421
4473
  effectiveObjects() {
4422
4474
  if (this.isNamedSource) {
@@ -0,0 +1,218 @@
1
+ <!--
2
+ - SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ - SPDX-License-Identifier: EUPL-1.2
4
+ -->
5
+ <template>
6
+ <div class="cn-nc-dashboard-list" data-testid="cn-nc-dashboard-list">
7
+ <p v-if="error"
8
+ class="cn-nc-dashboard-list__error"
9
+ role="status"
10
+ data-testid="cn-nc-dashboard-list-error">
11
+ {{ t('nextcloud-vue', 'This list could not be read.') }}
12
+ </p>
13
+ <CnDataTable v-else
14
+ :rows="rows"
15
+ :columns="columns"
16
+ :loading="loading"
17
+ hideHeader
18
+ borderless
19
+ :emptyText="text(config.emptyText) || t('nextcloud-vue', 'Nothing to show')"
20
+ @rowClick="onRowClick"
21
+ @rowAuxClick="onRowClick">
22
+ <template v-if="viewAllHref" #footer>
23
+ <a class="cn-data-table__view-all" :href="viewAllHref" data-testid="cn-nc-dashboard-list-view-all">
24
+ {{ text(config.viewAll.label) || t('nextcloud-vue', 'View all') }} →
25
+ </a>
26
+ </template>
27
+ </CnDataTable>
28
+ </div>
29
+ </template>
30
+
31
+ <script>
32
+ import { translate as t } from '@nextcloud/l10n'
33
+ import { generateUrl } from '@nextcloud/router'
34
+ import { fetchEndpointSource } from '../../composables/useEndpointSource.js'
35
+ import { fillTemplate } from '../../utils/fillTemplate.js'
36
+ import { openRowTarget } from '../../utils/linkNavigation.js'
37
+ import { readPath } from '../../utils/readPath.js'
38
+ import { resolveDeepTokens } from '../../utils/resolveFilterTokens.js'
39
+ import { CnDataTable } from '../CnDataTable/index.js'
40
+
41
+ /** A name and a muted, right-aligned trailing line. */
42
+ const COLUMNS = [
43
+ { key: 'mainText', cellClass: 'cn-cell--strong' },
44
+ { key: 'subText', cellClass: 'cn-cell--muted cn-cell--end' },
45
+ ]
46
+
47
+ /**
48
+ * A url for an anchor: absolute and app-root paths as given, else through `generateUrl`.
49
+ *
50
+ * @param {string} url The url.
51
+ * @return {string}
52
+ */
53
+ function appUrl(url) {
54
+ return /^(https?:)?\/\//i.test(url) || url.startsWith('/index.php') ? url : generateUrl(url)
55
+ }
56
+
57
+ /**
58
+ * CnNcDashboardList — the compact list a Nextcloud Dashboard widget shows:
59
+ * a bold name, a muted trailing line, and a "View all" link, read from
60
+ * OpenRegister or any endpoint. Each row opens its record in place (a
61
+ * modifier or middle click opens a new tab), because the Nextcloud Dashboard
62
+ * has no app router. `registerNcDashboardWidgets` mounts it per widget id, so
63
+ * an app's dashboard widgets are configuration rather than one component each.
64
+ *
65
+ * `config`:
66
+ * - `source`: `{ register, schema, params? }` (OpenRegister objects, rows in
67
+ * `results`) or an endpointSource `{ url, params?, responsePath? }`. Filter
68
+ * tokens (`@me`, `@today`, `@today-7d`) resolve in `params`, and an operator
69
+ * map (`{ deadline: { lt: '@today' } }`) goes out as `deadline[lt]=…`.
70
+ * - `itemsPath`: the rows' path in the response, when not `results`.
71
+ * - `limit`: the most rows shown (default 7).
72
+ * - `title`, `subtitle`: templates filled from a row (default `{title}`, none).
73
+ * - `href`: a template for a row's page (`/apps/dossiq/cases/{id}`).
74
+ * - `viewAll`: `{ href, label? }`.
75
+ * - `emptyText`: text for an empty list.
76
+ *
77
+ * Texts go through the host translate.
78
+ */
79
+ export default {
80
+ name: 'CnNcDashboardList',
81
+
82
+ components: { CnDataTable },
83
+
84
+ inject: {
85
+ /** The host app's translate (provided by `registerNcDashboardWidgets`). */
86
+ cnTranslate: { default: () => (key) => key },
87
+ },
88
+
89
+ props: {
90
+ /**
91
+ * The list's configuration (see the component docs).
92
+ *
93
+ * @type {object}
94
+ */
95
+ config: {
96
+ type: Object,
97
+ default: () => ({}),
98
+ },
99
+ },
100
+
101
+ data() {
102
+ return {
103
+ items: [],
104
+ loading: true,
105
+ error: false,
106
+ columns: COLUMNS,
107
+ }
108
+ },
109
+
110
+ computed: {
111
+ /** @return {number} The most rows shown. */
112
+ limit() {
113
+ return Number(this.config.limit) > 0 ? Number(this.config.limit) : 7
114
+ },
115
+
116
+ /**
117
+ * The rows as the table draws them.
118
+ *
119
+ * @spec openspec/changes/nc-dashboard-widgets/specs/nc-dashboard-widgets/spec.md#requirement-dashboard-widgets-are-configuration
120
+ * @return {Array<{id: string, mainText: string, subText: string, href: string}>}
121
+ */
122
+ rows() {
123
+ return this.items.slice(0, this.limit).map((item, index) => {
124
+ const id = item.id ?? (item['@self'] && item['@self'].id) ?? index
125
+ return {
126
+ id: String(id),
127
+ mainText: this.fill(this.config.title || '{title}', item) || String(id),
128
+ subText: this.fill(this.config.subtitle, item),
129
+ href: this.config.href ? appUrl(fillTemplate(this.config.href, { ...item, id })) : '',
130
+ }
131
+ })
132
+ },
133
+
134
+ /** @return {string} The "View all" href, or ''. */
135
+ viewAllHref() {
136
+ const viewAll = this.config.viewAll
137
+ return viewAll && viewAll.href ? appUrl(viewAll.href) : ''
138
+ },
139
+ },
140
+
141
+ mounted() {
142
+ this.load()
143
+ },
144
+
145
+ methods: {
146
+ t,
147
+
148
+ /**
149
+ * A config text through the host translate.
150
+ *
151
+ * @param {unknown} value The text.
152
+ * @return {string}
153
+ */
154
+ text(value) {
155
+ return typeof value === 'string' && value !== '' ? this.cnTranslate(value) : ''
156
+ },
157
+
158
+ /**
159
+ * A template filled from a row, translated first.
160
+ *
161
+ * @param {unknown} template The template.
162
+ * @param {object} item The row.
163
+ * @return {string}
164
+ */
165
+ fill(template, item) {
166
+ return typeof template === 'string' && template !== '' ? fillTemplate(this.cnTranslate(template), item).trim() : ''
167
+ },
168
+
169
+ /**
170
+ * Read the rows: OpenRegister objects or an endpoint.
171
+ *
172
+ * @spec openspec/changes/nc-dashboard-widgets/specs/nc-dashboard-widgets/spec.md#requirement-dashboard-widgets-are-configuration
173
+ * @return {Promise<void>}
174
+ */
175
+ async load() {
176
+ const source = this.config.source || {}
177
+ this.loading = true
178
+ this.error = false
179
+ try {
180
+ // Tokens nested below an operator map (`'@self': { lastUserActivity: { lt: '@today-7d' } }`)
181
+ // resolve here; the endpoint path resolves one level only.
182
+ const params = resolveDeepTokens(source.params || {}, {})
183
+ const request = source.register && source.schema
184
+ ? { url: `/apps/openregister/api/objects/${source.register}/${source.schema}`, params: { _limit: this.limit, ...params } }
185
+ : { ...source, params }
186
+ const data = await fetchEndpointSource(request, {}, { fresh: true })
187
+ const list = readPath(data, this.config.itemsPath || (source.register ? 'results' : '')) ?? data
188
+ this.items = Array.isArray(list) ? list.filter((item) => item && typeof item === 'object') : []
189
+ } catch {
190
+ this.error = true
191
+ this.items = []
192
+ } finally {
193
+ this.loading = false
194
+ }
195
+ },
196
+
197
+ /**
198
+ * Open a row's page.
199
+ *
200
+ * @param {object} row The row.
201
+ * @param {MouseEvent} event The click.
202
+ */
203
+ onRowClick(row, event) {
204
+ if (row && row.href) {
205
+ openRowTarget(event, row.href)
206
+ }
207
+ },
208
+ },
209
+ }
210
+ </script>
211
+
212
+ <style scoped>
213
+ .cn-nc-dashboard-list__error {
214
+ margin: 0;
215
+ padding: 8px 0;
216
+ color: var(--color-text-maxcontrast);
217
+ }
218
+ </style>