@conduction/nextcloud-vue 2.115.0 → 2.116.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 (117) hide show
  1. package/dist/esm/components/CnActionButtons/CnActionButtons.vue.js +1 -1
  2. package/dist/esm/components/CnActionButtons/CnActionButtons.vue2.js +92 -0
  3. package/dist/esm/components/CnActionButtons/CnActionButtons.vue2.js.map +1 -1
  4. package/dist/esm/components/CnActionButtons/CnActionButtons.vue3.js +2 -2
  5. package/dist/esm/components/CnActionButtons/CnActionButtons.vue3.js.map +1 -1
  6. package/dist/esm/components/CnChecklistWidget/CnChecklistWidget.vue.js +9 -0
  7. package/dist/esm/components/CnChecklistWidget/CnChecklistWidget.vue.js.map +1 -0
  8. package/dist/esm/components/CnChecklistWidget/CnChecklistWidget.vue2.js +145 -0
  9. package/dist/esm/components/CnChecklistWidget/CnChecklistWidget.vue2.js.map +1 -0
  10. package/dist/esm/components/CnChecklistWidget/CnChecklistWidget.vue3.js +58 -0
  11. package/dist/esm/components/CnChecklistWidget/CnChecklistWidget.vue3.js.map +1 -0
  12. package/dist/esm/components/CnChecklistWidget/index.js +28 -0
  13. package/dist/esm/components/CnChecklistWidget/index.js.map +1 -0
  14. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue.js +1 -1
  15. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +25 -0
  16. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  17. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +1 -1
  18. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  19. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +376 -51
  20. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  21. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +364 -285
  22. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  23. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue.js +1 -1
  24. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js +11 -0
  25. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js.map +1 -1
  26. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js +6 -4
  27. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js.map +1 -1
  28. package/dist/esm/components/CnGroupedCardsWidget/CnGroupedCardsWidget.vue.js +1 -1
  29. package/dist/esm/components/CnGroupedCardsWidget/CnGroupedCardsWidget.vue2.js +48 -1
  30. package/dist/esm/components/CnGroupedCardsWidget/CnGroupedCardsWidget.vue2.js.map +1 -1
  31. package/dist/esm/components/CnGroupedCardsWidget/CnGroupedCardsWidget.vue3.js +11 -2
  32. package/dist/esm/components/CnGroupedCardsWidget/CnGroupedCardsWidget.vue3.js.map +1 -1
  33. package/dist/esm/components/CnMacrosWidget/CnMacrosWidget.vue.js +1 -1
  34. package/dist/esm/components/CnMacrosWidget/CnMacrosWidget.vue2.js +11 -3
  35. package/dist/esm/components/CnMacrosWidget/CnMacrosWidget.vue2.js.map +1 -1
  36. package/dist/esm/components/CnMacrosWidget/CnMacrosWidget.vue3.js +8 -3
  37. package/dist/esm/components/CnMacrosWidget/CnMacrosWidget.vue3.js.map +1 -1
  38. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue.js +1 -1
  39. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js +55 -2
  40. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js.map +1 -1
  41. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js.map +1 -1
  42. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue.js +1 -1
  43. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js +94 -4
  44. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js.map +1 -1
  45. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js +248 -239
  46. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js.map +1 -1
  47. package/dist/esm/components/CnRecordListWidget/CnRecordNoteRows.vue.js +9 -0
  48. package/dist/esm/components/CnRecordListWidget/CnRecordNoteRows.vue.js.map +1 -0
  49. package/dist/esm/components/CnRecordListWidget/CnRecordNoteRows.vue2.js +282 -0
  50. package/dist/esm/components/CnRecordListWidget/CnRecordNoteRows.vue2.js.map +1 -0
  51. package/dist/esm/components/CnRecordListWidget/CnRecordNoteRows.vue3.js +202 -0
  52. package/dist/esm/components/CnRecordListWidget/CnRecordNoteRows.vue3.js.map +1 -0
  53. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue.js +1 -1
  54. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +10 -5
  55. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
  56. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
  57. package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js +1 -0
  58. package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js.map +1 -1
  59. package/dist/esm/composables/indexSources.js +64 -0
  60. package/dist/esm/composables/indexSources.js.map +1 -1
  61. package/dist/esm/composables/useTaskInboxStore.js +85 -0
  62. package/dist/esm/composables/useTaskInboxStore.js.map +1 -1
  63. package/dist/esm/dialogs/CnAccessPurposeDialog.vue.js +9 -0
  64. package/dist/esm/dialogs/CnAccessPurposeDialog.vue.js.map +1 -0
  65. package/dist/esm/dialogs/CnAccessPurposeDialog.vue2.js +107 -0
  66. package/dist/esm/dialogs/CnAccessPurposeDialog.vue2.js.map +1 -0
  67. package/dist/esm/dialogs/CnAccessPurposeDialog.vue3.js +110 -0
  68. package/dist/esm/dialogs/CnAccessPurposeDialog.vue3.js.map +1 -0
  69. package/dist/esm/index.js +1 -0
  70. package/dist/esm/index.js.map +1 -1
  71. package/dist/esm/l10n/en.json.js +19 -1
  72. package/dist/esm/l10n/en.json.js.map +1 -1
  73. package/dist/esm/l10n/nl.json.js +23 -1
  74. package/dist/esm/l10n/nl.json.js.map +1 -1
  75. package/dist/esm/library-style.js +1 -1
  76. package/dist/esm/nextcloud-vue.css +1054 -911
  77. package/dist/esm/utils/actionsDispatcher.js +10 -5
  78. package/dist/esm/utils/actionsDispatcher.js.map +1 -1
  79. package/dist/esm/utils/detailActionModel.js +6 -2
  80. package/dist/esm/utils/detailActionModel.js.map +1 -1
  81. package/dist/esm/utils/libraryWidgetKeys.js +1 -0
  82. package/dist/esm/utils/libraryWidgetKeys.js.map +1 -1
  83. package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
  84. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  85. package/dist/esm/utils/widgetDispatch.js +1 -1
  86. package/dist/esm/utils/widgetDispatch.js.map +1 -1
  87. package/dist/nextcloud-vue.cjs.js +17078 -15224
  88. package/dist/nextcloud-vue.cjs.js.map +2 -2
  89. package/dist/nextcloud-vue.css +1054 -911
  90. package/l10n/en.json +19 -1
  91. package/l10n/nl.json +23 -1
  92. package/package.json +1 -1
  93. package/src/components/CnActionButtons/CnActionButtons.vue +93 -1
  94. package/src/components/CnChecklistWidget/CnChecklistWidget.vue +206 -0
  95. package/src/components/CnChecklistWidget/index.js +27 -0
  96. package/src/components/CnDashboardPage/CnDashboardPage.vue +26 -1
  97. package/src/components/CnDetailPage/CnDetailPage.md +2 -0
  98. package/src/components/CnDetailPage/CnDetailPage.vue +377 -10
  99. package/src/components/CnDetailWidgetHost/CnDetailWidgetHost.vue +15 -2
  100. package/src/components/CnGroupedCardsWidget/CnGroupedCardsWidget.vue +59 -1
  101. package/src/components/CnMacrosWidget/CnMacrosWidget.vue +16 -6
  102. package/src/components/CnObjectListWidget/CnObjectListWidget.vue +55 -2
  103. package/src/components/CnRecordListWidget/CnRecordListWidget.vue +101 -3
  104. package/src/components/CnRecordListWidget/CnRecordNoteRows.vue +508 -0
  105. package/src/components/CnTabsWidget/CnTabsWidget.vue +10 -5
  106. package/src/components/CnWidgetGrid/registerDashboardWidgets.js +1 -0
  107. package/src/components/index.js +1 -0
  108. package/src/composables/indexSources.js +64 -0
  109. package/src/composables/useTaskInboxStore.js +85 -0
  110. package/src/dialogs/CnAccessPurposeDialog.vue +183 -0
  111. package/src/index.js +1 -0
  112. package/src/schemas/app-manifest-v2.schema.json +49 -3
  113. package/src/utils/actionsDispatcher.js +10 -5
  114. package/src/utils/detailActionModel.js +6 -2
  115. package/src/utils/libraryWidgetKeys.js +1 -0
  116. package/src/utils/validateManifestV2.compiled.js +3 -3
  117. package/src/utils/widgetDispatch.js +1 -1
@@ -0,0 +1,508 @@
1
+ <!--
2
+ - SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ - SPDX-License-Identifier: EUPL-1.2
4
+ -->
5
+
6
+ <template>
7
+ <div class="cn-record-notes" data-testid="cn-record-list-notes">
8
+ <p class="cn-record-notes__ready" data-testid="cn-record-list-notes-ready">
9
+ <span class="cn-record-notes__ready-figure">{{ readyCount }}</span>
10
+ <span class="cn-record-notes__ready-label">{{ readyLabel }}</span>
11
+ </p>
12
+ <div
13
+ v-for="item in items"
14
+ :key="item.key"
15
+ class="cn-record-notes__row"
16
+ data-testid="cn-record-list-item">
17
+ <div class="cn-record-notes__head">
18
+ <CnStatusBadge
19
+ v-if="item.pill"
20
+ :label="item.pill.label"
21
+ :variant="item.pill.variant"
22
+ size="small" />
23
+ <component
24
+ :is="item.tag"
25
+ v-if="item.title"
26
+ class="cn-record-notes__title"
27
+ :class="{ 'cn-record-notes__title--link': item.tag !== 'div' }"
28
+ v-bind="item.attrs">
29
+ {{ item.title }}
30
+ </component>
31
+ <span v-if="item.meta" class="cn-record-notes__meta">{{ item.meta }}</span>
32
+ <span class="cn-record-notes__spacer" />
33
+ <span
34
+ v-if="item.due"
35
+ class="cn-record-notes__due"
36
+ :class="`cn-record-notes__due--${dueToneOf(item.due)}`"
37
+ data-testid="cn-record-list-due">{{ item.due.text }}</span>
38
+ </div>
39
+ <div class="cn-record-notes__fields">
40
+ <NcTextField
41
+ class="cn-record-notes__field"
42
+ :modelValue="drafts[item.key] || ''"
43
+ :label="fieldLabel"
44
+ :labelOutside="false"
45
+ :placeholder="placeholder"
46
+ :disabled="busy"
47
+ :data-testid="`cn-record-list-note-${item.key}`"
48
+ @update:modelValue="setDraft(item.key, $event)" />
49
+ <div v-if="leaf && leafProvider" class="cn-record-notes__leaf" :data-testid="`cn-record-list-leaf-${item.key}`">
50
+ <CnLeafMountHost
51
+ v-if="leafIsMount"
52
+ :provider="leafProvider"
53
+ :mountProps="leafProps(item.row)" />
54
+ <component :is="leafComponent" v-else-if="leafComponent" v-bind="leafProps(item.row)" />
55
+ </div>
56
+ <NcButton
57
+ variant="secondary"
58
+ :disabled="busy || !hasDraft(item.key)"
59
+ :data-testid="`cn-record-list-record-${item.key}`"
60
+ @click="record(item)">
61
+ {{ recordLabel }}
62
+ </NcButton>
63
+ </div>
64
+ <p
65
+ v-if="leafUnavailable"
66
+ class="cn-record-notes__leaf-missing"
67
+ :data-testid="`cn-record-list-leaf-missing-${item.key}`">
68
+ {{ leafUnavailableText }}
69
+ </p>
70
+ </div>
71
+ <p v-if="error"
72
+ class="cn-record-notes__error"
73
+ role="alert"
74
+ data-testid="cn-record-list-notes-error">
75
+ {{ error }}
76
+ </p>
77
+ <div v-if="recordAll" class="cn-record-notes__all" data-testid="cn-record-list-record-all">
78
+ <div class="cn-record-notes__all-text">
79
+ <h3 v-if="recordAll.title" class="cn-record-notes__all-title">
80
+ {{ recordAll.title }}
81
+ </h3>
82
+ <p v-if="recordAll.text" class="cn-record-notes__all-sentence">
83
+ {{ recordAll.text }}
84
+ </p>
85
+ </div>
86
+ <div class="cn-record-notes__all-actions">
87
+ <NcButton v-if="recordAll.cancel" variant="secondary" :to="recordAll.cancel.to">
88
+ {{ recordAll.cancel.label }}
89
+ </NcButton>
90
+ <NcButton
91
+ variant="primary"
92
+ :disabled="busy || readyCount === 0"
93
+ data-testid="cn-record-list-record-all-button"
94
+ @click="recordEvery">
95
+ <template v-if="recordAll.icon" #icon>
96
+ <CnIcon :name="recordAll.icon" :size="20" />
97
+ </template>
98
+ {{ recordAll.label }}
99
+ </NcButton>
100
+ </div>
101
+ </div>
102
+ </div>
103
+ </template>
104
+
105
+ <script>
106
+ import { emit } from '@nextcloud/event-bus'
107
+ import { translate as t } from '@nextcloud/l10n'
108
+ import { NcButton, NcTextField } from '@nextcloud/vue'
109
+ import CnIcon from '../CnIcon/CnIcon.vue'
110
+ import CnLeafMountHost from '../CnLeafMountHost/CnLeafMountHost.vue'
111
+ import CnStatusBadge from '../CnStatusBadge/CnStatusBadge.vue'
112
+ import { useIntegrationRegistry } from '../../composables/useIntegrationRegistry.js'
113
+ import { isAppInstalled } from '../../utils/appInstalled.js'
114
+ import { prefixUrl } from '../../utils/headers.js'
115
+ import { readPath } from '../../utils/readPath.js'
116
+
117
+ /** The bus channel every endpoint-bound widget on the page refetches on. */
118
+ const PAGE_REFRESH_CHANNEL = 'cn:page:refresh'
119
+
120
+ /**
121
+ * The rows of a record list drawn as notes to record (`presentation:
122
+ * "notes"`, DqDagAfsluiten "Nog open van vandaag"): per row its pill, title,
123
+ * meta and due text, a field for what happened, an optional integration leaf
124
+ * (humaniq's hours) and a button that records the note on the row's own
125
+ * notes endpoint. What is typed and not yet recorded stays in this browser
126
+ * (session storage) and is counted as the "ready to record" figure; one
127
+ * button records every filled row. After a note is recorded the page's
128
+ * endpoint widgets refetch, so the row moves to whatever lists the server
129
+ * keeps of recorded work.
130
+ *
131
+ * @spec openspec/changes/record-list-notes/specs/record-list-notes/spec.md#requirement-a-record-list-can-collect-and-record-a-note-per-row
132
+ */
133
+ export default {
134
+ name: 'CnRecordNoteRows',
135
+
136
+ components: { CnIcon, CnLeafMountHost, CnStatusBadge, NcButton, NcTextField },
137
+
138
+ props: {
139
+ /** The list's rows as the record list builds them (`key`, `row`, `tag`, `attrs`, `title`, `meta`, `pill`, `due`). */
140
+ items: {
141
+ type: Array,
142
+ default: () => [],
143
+ },
144
+
145
+ /**
146
+ * `noteAction`, already translated: `{ urlField?, url?, bodyField?,
147
+ * label, fieldLabel, placeholder?, readyLabel, draftsKey?, errorText? }`.
148
+ */
149
+ action: {
150
+ type: Object,
151
+ required: true,
152
+ },
153
+
154
+ /** `recordAll`, already translated: `{ label, title?, text?, icon?, cancel?: { label, to } }`, or null. */
155
+ recordAll: {
156
+ type: Object,
157
+ default: null,
158
+ },
159
+
160
+ /** `rowLeaf`: `{ id, app?, register?, objectIdField, schemaField?, schema?, unavailableText? }`, or null. */
161
+ leaf: {
162
+ type: Object,
163
+ default: null,
164
+ },
165
+ },
166
+
167
+ setup() {
168
+ const { getById } = useIntegrationRegistry()
169
+ return { getProvider: getById }
170
+ },
171
+
172
+ data() {
173
+ return { drafts: this.readDrafts(), busy: false, error: '' }
174
+ },
175
+
176
+ computed: {
177
+ /** @return {string} The session storage key of this list's drafts. */
178
+ draftsKey() {
179
+ return `cn-record-list-notes:${this.action.draftsKey || 'default'}`
180
+ },
181
+
182
+ /** @return {number} The rows with a note typed and not yet recorded. */
183
+ readyCount() {
184
+ return this.items.filter((item) => this.hasDraft(item.key)).length
185
+ },
186
+
187
+ /** @return {string} The label of the ready figure. */
188
+ readyLabel() {
189
+ return this.action.readyLabel || t('nextcloud-vue', 'filled in, not recorded yet')
190
+ },
191
+
192
+ /** @return {string} */
193
+ recordLabel() {
194
+ return this.action.label || t('nextcloud-vue', 'Record')
195
+ },
196
+
197
+ /** @return {string} */
198
+ fieldLabel() {
199
+ return this.action.fieldLabel || t('nextcloud-vue', 'Note')
200
+ },
201
+
202
+ /** @return {string} */
203
+ placeholder() {
204
+ return this.action.placeholder || ''
205
+ },
206
+
207
+ /** @return {object|null} The leaf's integration provider, when one is registered. */
208
+ leafProvider() {
209
+ return this.leaf && this.leaf.id ? (this.getProvider(this.leaf.id) || null) : null
210
+ },
211
+
212
+ /** @return {boolean} Whether the leaf mounts itself (`renderMode: "mount"`). */
213
+ leafIsMount() {
214
+ const provider = this.leafProvider
215
+ return Boolean(provider) && provider.renderMode === 'mount' && typeof provider.mount === 'function'
216
+ },
217
+
218
+ /** @return {object|null} The leaf's component, in component mode. */
219
+ leafComponent() {
220
+ const provider = this.leafProvider
221
+ return provider ? (provider.tab || provider.widget || null) : null
222
+ },
223
+
224
+ /**
225
+ * Whether the leaf's app is installed but the leaf is not available, so
226
+ * the row says so instead of silently showing no field.
227
+ *
228
+ * @return {boolean}
229
+ */
230
+ leafUnavailable() {
231
+ return Boolean(this.leaf && this.leaf.app && !this.leafProvider && isAppInstalled(this.leaf.app))
232
+ },
233
+
234
+ /** @return {string} */
235
+ leafUnavailableText() {
236
+ return this.leaf?.unavailableText || t('nextcloud-vue', 'This field is not available here.')
237
+ },
238
+ },
239
+
240
+ watch: {
241
+ drafts: {
242
+ deep: true,
243
+ handler() {
244
+ this.saveDrafts()
245
+ },
246
+ },
247
+ },
248
+
249
+ methods: {
250
+ /**
251
+ * The drafts this browser session kept, by row key.
252
+ *
253
+ * @return {Object<string, string>}
254
+ */
255
+ readDrafts() {
256
+ try {
257
+ const raw = window.sessionStorage.getItem(`cn-record-list-notes:${this.action?.draftsKey || 'default'}`)
258
+ const parsed = raw ? JSON.parse(raw) : {}
259
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
260
+ } catch {
261
+ return {}
262
+ }
263
+ },
264
+
265
+ /** @return {void} */
266
+ saveDrafts() {
267
+ try {
268
+ window.sessionStorage.setItem(this.draftsKey, JSON.stringify(this.drafts))
269
+ } catch {
270
+ // Without session storage the drafts live as long as the page.
271
+ }
272
+ },
273
+
274
+ /**
275
+ * The colour of a due text: a term that ended or ends today in the
276
+ * error colour, one still to come in the warning colour.
277
+ *
278
+ * @param {{tone: string}} due The due text.
279
+ * @return {string} `error`, `warning` or `muted`.
280
+ */
281
+ dueToneOf(due) {
282
+ if (due.tone === 'danger' || due.tone === 'today') {
283
+ return 'error'
284
+ }
285
+ return due.tone === 'upcoming' ? 'warning' : 'muted'
286
+ },
287
+
288
+ /**
289
+ * @param {string} key The row key.
290
+ * @param {string} value The typed note.
291
+ * @return {void}
292
+ */
293
+ setDraft(key, value) {
294
+ this.drafts = { ...this.drafts, [key]: String(value ?? '') }
295
+ },
296
+
297
+ /**
298
+ * @param {string} key The row key.
299
+ * @return {boolean} Whether the row has a note typed.
300
+ */
301
+ hasDraft(key) {
302
+ return String(this.drafts[key] ?? '').trim() !== ''
303
+ },
304
+
305
+ /**
306
+ * The props the row's leaf gets: the row's object, register and schema.
307
+ *
308
+ * @param {object} row The row.
309
+ * @return {object}
310
+ */
311
+ leafProps(row) {
312
+ const leaf = this.leaf || {}
313
+ return {
314
+ objectId: String(readPath(row, leaf.objectIdField || 'id') ?? ''),
315
+ register: leaf.register || '',
316
+ schema: leaf.schemaField ? String(readPath(row, leaf.schemaField) ?? '') : (leaf.schema || ''),
317
+ surface: 'row',
318
+ }
319
+ },
320
+
321
+ /**
322
+ * The row's notes endpoint: the row's `urlField`, else `url` with
323
+ * `{field}` filled from the row.
324
+ *
325
+ * @param {object} row The row.
326
+ * @return {string} The URL, or ''.
327
+ */
328
+ urlOf(row) {
329
+ if (this.action.urlField) {
330
+ return String(readPath(row, this.action.urlField) ?? '')
331
+ }
332
+ const template = String(this.action.url || '')
333
+ return template.replace(/\{([A-Za-z0-9_.]+)\}/g, (_, path) => encodeURIComponent(String(readPath(row, path) ?? '')))
334
+ },
335
+
336
+ /**
337
+ * Record one row's note, then let the page refetch.
338
+ *
339
+ * @param {object} item The row item.
340
+ * @param {boolean} [refresh] Whether to refresh the page after it.
341
+ * @return {Promise<boolean>} Whether it was recorded.
342
+ */
343
+ async record(item, refresh = true) {
344
+ const text = String(this.drafts[item.key] ?? '').trim()
345
+ const url = this.urlOf(item.row)
346
+ if (text === '' || url === '') {
347
+ return false
348
+ }
349
+ this.busy = true
350
+ this.error = ''
351
+ try {
352
+ const { default: axios } = await import('@nextcloud/axios')
353
+ await axios.post(prefixUrl(url), { [this.action.bodyField || 'message']: text })
354
+ const { [item.key]: done, ...rest } = this.drafts
355
+ this.drafts = rest
356
+ if (refresh) {
357
+ emit(PAGE_REFRESH_CHANNEL, {})
358
+ }
359
+ return true
360
+ } catch {
361
+ this.error = this.action.errorText || t('nextcloud-vue', 'The note could not be recorded. It is kept here; try again.')
362
+ return false
363
+ } finally {
364
+ this.busy = false
365
+ }
366
+ },
367
+
368
+ /**
369
+ * Record every row with a note, one after another, then refresh once.
370
+ *
371
+ * @return {Promise<void>}
372
+ */
373
+ async recordEvery() {
374
+ const filled = this.items.filter((item) => this.hasDraft(item.key))
375
+ let any = false
376
+ for (const item of filled) {
377
+ any = (await this.record(item, false)) || any
378
+ }
379
+ if (any) {
380
+ emit(PAGE_REFRESH_CHANNEL, {})
381
+ }
382
+ },
383
+ },
384
+ }
385
+ </script>
386
+
387
+ <style scoped>
388
+ .cn-record-notes {
389
+ display: flex;
390
+ flex-direction: column;
391
+ gap: 0;
392
+ }
393
+
394
+ .cn-record-notes__ready {
395
+ display: flex;
396
+ align-items: baseline;
397
+ gap: 6px;
398
+ margin: 0 0 8px;
399
+ color: var(--color-text-maxcontrast);
400
+ }
401
+
402
+ .cn-record-notes__ready-figure {
403
+ font-weight: 700;
404
+ color: var(--color-main-text);
405
+ }
406
+
407
+ .cn-record-notes__row {
408
+ display: flex;
409
+ flex-direction: column;
410
+ gap: 10px;
411
+ padding: 14px 0;
412
+ border-top: 1px solid var(--color-border);
413
+ }
414
+
415
+ .cn-record-notes__head {
416
+ display: flex;
417
+ flex-wrap: wrap;
418
+ align-items: center;
419
+ gap: 8px;
420
+ }
421
+
422
+ .cn-record-notes__title {
423
+ font-weight: 600;
424
+ color: var(--color-main-text);
425
+ }
426
+
427
+ .cn-record-notes__title--link {
428
+ text-decoration: underline;
429
+ }
430
+
431
+ .cn-record-notes__meta {
432
+ color: var(--color-text-maxcontrast);
433
+ font-size: 13px;
434
+ }
435
+
436
+ .cn-record-notes__spacer {
437
+ flex: 1 1 auto;
438
+ }
439
+
440
+ .cn-record-notes__due {
441
+ font-weight: 600;
442
+ }
443
+
444
+ .cn-record-notes__due--error {
445
+ color: var(--color-error-text);
446
+ }
447
+
448
+ .cn-record-notes__due--warning {
449
+ color: var(--color-warning-text);
450
+ }
451
+
452
+ .cn-record-notes__due--muted {
453
+ color: var(--color-text-maxcontrast);
454
+ font-weight: 400;
455
+ }
456
+
457
+ .cn-record-notes__fields {
458
+ display: flex;
459
+ flex-wrap: wrap;
460
+ align-items: flex-end;
461
+ gap: 10px;
462
+ }
463
+
464
+ .cn-record-notes__field {
465
+ flex: 1 1 260px;
466
+ }
467
+
468
+ .cn-record-notes__leaf {
469
+ flex: 0 0 auto;
470
+ }
471
+
472
+ .cn-record-notes__leaf-missing,
473
+ .cn-record-notes__error {
474
+ margin: 0;
475
+ color: var(--color-text-maxcontrast);
476
+ font-size: 13px;
477
+ }
478
+
479
+ .cn-record-notes__error {
480
+ color: var(--color-error-text);
481
+ }
482
+
483
+ .cn-record-notes__all {
484
+ display: flex;
485
+ flex-wrap: wrap;
486
+ align-items: center;
487
+ justify-content: space-between;
488
+ gap: 12px;
489
+ margin-top: 8px;
490
+ padding-top: 14px;
491
+ border-top: 1px solid var(--color-border);
492
+ }
493
+
494
+ .cn-record-notes__all-title {
495
+ margin: 0;
496
+ font-size: 16px;
497
+ }
498
+
499
+ .cn-record-notes__all-sentence {
500
+ margin: 4px 0 0;
501
+ color: var(--color-text-maxcontrast);
502
+ }
503
+
504
+ .cn-record-notes__all-actions {
505
+ display: flex;
506
+ gap: 8px;
507
+ }
508
+ </style>
@@ -400,13 +400,16 @@ export default {
400
400
  * `layout: [{ widgetId, gridWidth }]` sets a width (of 12) per widget, as
401
401
  * the page grid does. Widgets wrap onto the next row.
402
402
  *
403
+ * A tab with `default: true` is the one the strip opens on, unless a
404
+ * route hash names another tab or the marked tab sits under "More".
405
+ *
403
406
  * `maxVisibleTabs` caps the strip: later tabs go under "More" (in the
404
407
  * board look: into the Actions menu, with History kept in the strip).
405
408
  * `hideEmpty: true` moves a tab whose count is 0 there too.
406
409
  * `moreLabel` names that menu. `variant: "segmented"` draws the strip
407
410
  * as a pill switch (CnTabs' segmented variant); `line` is the default.
408
411
  *
409
- * @type {{ tabs?: Array<{widgetId?: string, widgetIds?: string[], label?: string, icon?: string, count?: number, countField?: string, countFrom?: ('widget'|{register: string, schema: string, filter?: object}), overflow?: boolean, columns?: number, layout?: Array<{widgetId: string, gridWidth: number}>}>, ariaLabel?: string, maxVisibleTabs?: number, hideEmpty?: boolean, moreLabel?: string, variant?: ('line'|'segmented') }}
412
+ * @type {{ tabs?: Array<{widgetId?: string, widgetIds?: string[], label?: string, icon?: string, count?: number, countField?: string, countFrom?: ('widget'|{register: string, schema: string, filter?: object}), overflow?: boolean, default?: boolean, columns?: number, layout?: Array<{widgetId: string, gridWidth: number}>}>, ariaLabel?: string, maxVisibleTabs?: number, hideEmpty?: boolean, moreLabel?: string, variant?: ('line'|'segmented') }}
410
413
  */
411
414
  content: {
412
415
  type: Object,
@@ -696,6 +699,7 @@ export default {
696
699
  count,
697
700
  overflow,
698
701
  pending: visibility === 'pending',
702
+ opensFirst: Boolean(tab && typeof tab === 'object' && tab.default === true),
699
703
  })
700
704
  })
701
705
  return resolved
@@ -881,9 +885,9 @@ export default {
881
885
  },
882
886
 
883
887
  /**
884
- * Open on the first tab that is in the strip. Tab 0 is the default, and
885
- * when tab 0 sits under "More" the strip would open on a tab the author
886
- * chose to tuck away.
888
+ * Open on the tab marked `default: true`, else on the first tab that is in
889
+ * the strip. Tab 0 is not enough: when tab 0 sits under "More" the strip
890
+ * would open on a tab the author chose to tuck away.
887
891
  *
888
892
  * @spec openspec/changes/detail-action-model-and-case-surfaces/specs/detail-action-model/spec.md#requirement-tab-counts-and-overflow
889
893
  */
@@ -899,8 +903,9 @@ export default {
899
903
  if (named >= 0) {
900
904
  this.hashTargetId = wanted
901
905
  }
906
+ const marked = this.resolvedTabs.findIndex((tab) => tab.opensFirst && !tab.overflow && !tab.pending)
902
907
  const first = this.resolvedTabs.findIndex((tab) => !tab.overflow && !tab.pending)
903
- const fallback = first >= 0 ? first : this.resolvedTabs.findIndex((tab) => !tab.overflow)
908
+ const fallback = marked >= 0 ? marked : first >= 0 ? first : this.resolvedTabs.findIndex((tab) => !tab.overflow)
904
909
  if (fallback > 0) {
905
910
  this.activeIndex = fallback
906
911
  }
@@ -84,6 +84,7 @@ import '../CnLinkedWidget/index.js'
84
84
  import '../CnDeviceFramesWidget/index.js'
85
85
  import '../CnGroupedCardsWidget/index.js'
86
86
  import '../CnChecklistRunWidget/index.js'
87
+ import '../CnChecklistWidget/index.js'
87
88
  import '../CnMailTab/index.js'
88
89
  import '../CnWorkingCalendarWidget/index.js'
89
90
  import '../CnPartyActionsWidget/index.js'
@@ -243,6 +243,7 @@ export { CnLinkedWidget } from './CnLinkedWidget/index.js'
243
243
  export { CnDeviceFramesWidget } from './CnDeviceFramesWidget/index.js'
244
244
  export { CnGroupedCardsWidget } from './CnGroupedCardsWidget/index.js'
245
245
  export { CnChecklistRunWidget } from './CnChecklistRunWidget/index.js'
246
+ export { CnChecklistWidget } from './CnChecklistWidget/index.js'
246
247
  export { CnMailTab } from './CnMailTab/index.js'
247
248
  export { CnWorkingCalendarWidget } from './CnWorkingCalendarWidget/index.js'
248
249
  export { CnPartyActionsWidget } from './CnPartyActionsWidget/index.js'
@@ -7,9 +7,12 @@ import { getCanonicalLocale, translate as t } from '@nextcloud/l10n'
7
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
+ import Cancel from 'vue-material-design-icons/Cancel.vue'
11
+ import Check from 'vue-material-design-icons/Check.vue'
10
12
  import HandBackRight from 'vue-material-design-icons/HandBackRight.vue'
11
13
  import LightningBolt from 'vue-material-design-icons/LightningBolt.vue'
12
14
  import Pencil from 'vue-material-design-icons/Pencil.vue'
15
+ import Undo from 'vue-material-design-icons/Undo.vue'
13
16
  import { formatRelativeDate } from '../utils/boardDate.js'
14
17
  import { fetchReferenceObjects } from '../utils/boardReferenceColumns.js'
15
18
  import { fillTemplate } from '../utils/fillTemplate.js'
@@ -456,6 +459,13 @@ export function taskDueLabel(task) {
456
459
  : t('nextcloud-vue', 'Due in {days} days', { days: until })
457
460
  }
458
461
 
462
+ /**
463
+ * The CMMN states in which a task is finished: no verb applies to it any more.
464
+ *
465
+ * @type {string[]}
466
+ */
467
+ const TASK_TERMINAL_STATES = ['completed', 'terminated', 'disabled']
468
+
459
469
  /**
460
470
  * The tasks source: the caller's inbox on OpenRegister's one task store.
461
471
  *
@@ -557,6 +567,60 @@ function tasksSource() {
557
567
  */
558
568
  recordError: (id) => store.recordErrors?.[String(id)] ?? null,
559
569
 
570
+ /**
571
+ * The lifecycle verbs a detail page offers on this task, read off the
572
+ * row and never guessed at: claim while nobody holds it, hand back
573
+ * while somebody does, complete and cancel while it is open. A finished
574
+ * task offers none. Whether the viewer MAY run one is the engine's
575
+ * call; it refuses with a message the page shows as-is.
576
+ *
577
+ * @param {object|null} task The task on the page.
578
+ * @return {Array<{id: string, label: string, icon: object, menu: boolean, successMessage?: string}>} The verbs, buttons first.
579
+ * @spec openspec/changes/record-source-verbs/specs/record-source-verbs/spec.md#requirement-a-record-source-offers-header-verbs
580
+ */
581
+ verbs: (task) => {
582
+ if (!task || TASK_TERMINAL_STATES.includes(String(task.state || ''))) {
583
+ return []
584
+ }
585
+ const held = String(task.assignee || '').trim() !== ''
586
+ return [
587
+ { id: 'complete', label: t('nextcloud-vue', 'Finish'), icon: Check, menu: false, successMessage: t('nextcloud-vue', 'Task completed') },
588
+ held
589
+ ? { id: 'unclaim', label: t('nextcloud-vue', 'Hand back'), icon: Undo, menu: false }
590
+ : { id: 'claim', label: t('nextcloud-vue', 'Pick up'), icon: HandBackRight, menu: false },
591
+ { id: 'cancel', label: t('nextcloud-vue', 'Cancel task'), icon: Cancel, menu: true },
592
+ ]
593
+ },
594
+
595
+ /**
596
+ * Run a verb on a task and keep the page's record in step.
597
+ *
598
+ * @param {string} id The task uuid.
599
+ * @param {string} verb The verb id.
600
+ * @param {{url?: string}} [options] An app route to post the verb to instead of the engine's.
601
+ * @return {Promise<{ok: boolean, record: (object|null), message: string}>} The outcome.
602
+ */
603
+ runVerb: (id, verb, options) => store.runRecordVerb(id, verb, options || {}),
604
+
605
+ /**
606
+ * Whether this task's checklist can still be ticked: not once it is finished.
607
+ *
608
+ * @param {object|null} task The task on the page.
609
+ * @return {boolean}
610
+ */
611
+ canCheckItems: (task) => Boolean(task) && !TASK_TERMINAL_STATES.includes(String(task.state || '')),
612
+
613
+ /**
614
+ * Tick or untick one checklist item through the engine.
615
+ *
616
+ * @param {string} id The task uuid.
617
+ * @param {string} itemId The item id.
618
+ * @param {boolean} checked The new state.
619
+ * @return {Promise<{ok: boolean, record: (object|null), message: string}>} The outcome.
620
+ * @spec openspec/changes/record-checklist-widget/specs/record-checklist-widget/spec.md#requirement-a-checklist-widget-ticks-items-through-the-record-source
621
+ */
622
+ checkItem: (id, itemId, checked) => store.checkRecordItem(id, itemId, checked),
623
+
560
624
  /**
561
625
  * @return {boolean} Whether a load is in flight.
562
626
  */