@conduction/nextcloud-vue 2.97.0 → 2.99.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 (160) hide show
  1. package/dist/esm/components/CnActionsMenu/CnActionsMenu.vue2.js +12 -0
  2. package/dist/esm/components/CnActionsMenu/CnActionsMenu.vue2.js.map +1 -1
  3. package/dist/esm/components/CnActionsMenu/CnActionsMenu.vue3.js +11 -2
  4. package/dist/esm/components/CnActionsMenu/CnActionsMenu.vue3.js.map +1 -1
  5. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js +17 -0
  6. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js.map +1 -1
  7. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js.map +1 -1
  8. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue.js +1 -1
  9. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue2.js.map +1 -1
  10. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue3.js +1 -1
  11. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue3.js.map +1 -1
  12. package/dist/esm/components/CnChartWidget/CnChartWidget.vue.js +1 -1
  13. package/dist/esm/components/CnChartWidget/CnChartWidget.vue2.js +106 -2
  14. package/dist/esm/components/CnChartWidget/CnChartWidget.vue2.js.map +1 -1
  15. package/dist/esm/components/CnChartWidget/CnChartWidget.vue3.js.map +1 -1
  16. package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue.js +9 -0
  17. package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue.js.map +1 -0
  18. package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue2.js +964 -0
  19. package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue2.js.map +1 -0
  20. package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue3.js +282 -0
  21. package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue3.js.map +1 -0
  22. package/dist/esm/components/CnChecklistRunWidget/index.js +28 -0
  23. package/dist/esm/components/CnChecklistRunWidget/index.js.map +1 -0
  24. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue.js +1 -1
  25. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +1 -0
  26. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  27. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  28. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +26 -8
  29. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  30. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  31. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +188 -5
  32. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  33. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +2 -1
  34. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  35. package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue.js +1 -1
  36. package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue2.js +3 -3
  37. package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue2.js.map +1 -1
  38. package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue3.js.map +1 -1
  39. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue.js +1 -1
  40. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue2.js +27 -0
  41. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue2.js.map +1 -1
  42. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue3.js +16 -2
  43. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue3.js.map +1 -1
  44. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +43 -3
  45. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  46. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  47. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue.js +9 -0
  48. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue.js.map +1 -0
  49. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue2.js +239 -0
  50. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue2.js.map +1 -0
  51. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue3.js +182 -0
  52. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue3.js.map +1 -0
  53. package/dist/esm/components/CnSettingsField/CnSettingsField.vue.js +9 -0
  54. package/dist/esm/components/CnSettingsField/CnSettingsField.vue.js.map +1 -0
  55. package/dist/esm/components/CnSettingsField/CnSettingsField.vue2.js +265 -0
  56. package/dist/esm/components/CnSettingsField/CnSettingsField.vue2.js.map +1 -0
  57. package/dist/esm/components/CnSettingsField/CnSettingsField.vue3.js +191 -0
  58. package/dist/esm/components/CnSettingsField/CnSettingsField.vue3.js.map +1 -0
  59. package/dist/esm/components/CnSettingsFieldsSection/CnSettingsFieldsSection.vue.js +9 -0
  60. package/dist/esm/components/CnSettingsFieldsSection/CnSettingsFieldsSection.vue.js.map +1 -0
  61. package/dist/esm/components/CnSettingsFieldsSection/CnSettingsFieldsSection.vue2.js +508 -0
  62. package/dist/esm/components/CnSettingsFieldsSection/CnSettingsFieldsSection.vue2.js.map +1 -0
  63. package/dist/esm/components/CnSettingsFieldsSection/CnSettingsFieldsSection.vue3.js +99 -0
  64. package/dist/esm/components/CnSettingsFieldsSection/CnSettingsFieldsSection.vue3.js.map +1 -0
  65. package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue.js +1 -1
  66. package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue2.js +18 -70
  67. package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue2.js.map +1 -1
  68. package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue3.js +6 -44
  69. package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue3.js.map +1 -1
  70. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue.js +1 -1
  71. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue2.js +83 -9
  72. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue2.js.map +1 -1
  73. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue3.js +80 -11
  74. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue3.js.map +1 -1
  75. package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js +2 -0
  76. package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js.map +1 -1
  77. package/dist/esm/components/CnWorkingCalendarWidget/CnWorkingCalendarWidget.vue.js +9 -0
  78. package/dist/esm/components/CnWorkingCalendarWidget/CnWorkingCalendarWidget.vue.js.map +1 -0
  79. package/dist/esm/components/CnWorkingCalendarWidget/CnWorkingCalendarWidget.vue2.js +472 -0
  80. package/dist/esm/components/CnWorkingCalendarWidget/CnWorkingCalendarWidget.vue2.js.map +1 -0
  81. package/dist/esm/components/CnWorkingCalendarWidget/CnWorkingCalendarWidget.vue3.js +231 -0
  82. package/dist/esm/components/CnWorkingCalendarWidget/CnWorkingCalendarWidget.vue3.js.map +1 -0
  83. package/dist/esm/components/CnWorkingCalendarWidget/index.js +27 -0
  84. package/dist/esm/components/CnWorkingCalendarWidget/index.js.map +1 -0
  85. package/dist/esm/composables/indexSources.js +238 -2
  86. package/dist/esm/composables/indexSources.js.map +1 -1
  87. package/dist/esm/composables/useFlowStore.js +2 -1
  88. package/dist/esm/composables/useFlowStore.js.map +1 -1
  89. package/dist/esm/composables/useOrganisationStore.js +115 -0
  90. package/dist/esm/composables/useOrganisationStore.js.map +1 -0
  91. package/dist/esm/index.js +5 -0
  92. package/dist/esm/index.js.map +1 -1
  93. package/dist/esm/l10n/en.json.js +109 -1
  94. package/dist/esm/l10n/en.json.js.map +1 -1
  95. package/dist/esm/l10n/nl.json.js +138 -1
  96. package/dist/esm/l10n/nl.json.js.map +1 -1
  97. package/dist/esm/library-style.js +1 -1
  98. package/dist/esm/nextcloud-vue.css +1071 -274
  99. package/dist/esm/utils/builtInFormatters.js +34 -1
  100. package/dist/esm/utils/builtInFormatters.js.map +1 -1
  101. package/dist/esm/utils/libraryWidgetKeys.js +2 -0
  102. package/dist/esm/utils/libraryWidgetKeys.js.map +1 -1
  103. package/dist/esm/utils/matrixCells.js +158 -0
  104. package/dist/esm/utils/matrixCells.js.map +1 -0
  105. package/dist/esm/utils/nextcloudDirectory.js +121 -0
  106. package/dist/esm/utils/nextcloudDirectory.js.map +1 -0
  107. package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
  108. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  109. package/dist/esm/utils/workingCalendarMonth.js +85 -0
  110. package/dist/esm/utils/workingCalendarMonth.js.map +1 -0
  111. package/dist/nextcloud-vue.cjs.js +14888 -10014
  112. package/dist/nextcloud-vue.cjs.js.map +2 -2
  113. package/dist/nextcloud-vue.css +1071 -274
  114. package/l10n/en.json +109 -1
  115. package/l10n/nl.json +138 -1
  116. package/package.json +2 -2
  117. package/scripts/merge-l10n.js +196 -0
  118. package/src/components/CnActionsMenu/CnActionsMenu.vue +14 -1
  119. package/src/components/CnAppRoot/CnAppRoot.md +1 -0
  120. package/src/components/CnAppRoot/CnAppRoot.vue +17 -0
  121. package/src/components/CnBreadcrumbs/CnBreadcrumbs.vue +1 -0
  122. package/src/components/CnChartWidget/CnChartWidget.md +2 -1
  123. package/src/components/CnChartWidget/CnChartWidget.vue +106 -2
  124. package/src/components/CnChecklistRunWidget/CnChecklistRunWidget.vue +1269 -0
  125. package/src/components/CnChecklistRunWidget/index.js +27 -0
  126. package/src/components/CnDashboardPage/CnDashboardPage.vue +1 -0
  127. package/src/components/CnDataTable/CnDataTable.vue +26 -8
  128. package/src/components/CnDetailPage/CnDetailPage.md +48 -0
  129. package/src/components/CnDetailPage/CnDetailPage.vue +189 -5
  130. package/src/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue +3 -3
  131. package/src/components/CnFeaturesBoard/CnFeaturesBoard.vue +38 -2
  132. package/src/components/CnIndexPage/CnIndexPage.md +28 -0
  133. package/src/components/CnIndexPage/CnIndexPage.vue +43 -3
  134. package/src/components/CnOrderedListField/CnOrderedListField.vue +458 -0
  135. package/src/components/CnOrderedListField/index.js +7 -0
  136. package/src/components/CnSettingsField/CnSettingsField.vue +449 -0
  137. package/src/components/CnSettingsField/index.js +4 -0
  138. package/src/components/CnSettingsFieldsSection/CnSettingsFieldsSection.vue +613 -0
  139. package/src/components/CnSettingsFieldsSection/index.js +4 -0
  140. package/src/components/CnSettingsPage/CnSettingsPage.vue +6 -86
  141. package/src/components/CnUserSettingsDialog/CnUserSettingsDialog.vue +139 -10
  142. package/src/components/CnWidgetGrid/registerDashboardWidgets.js +2 -0
  143. package/src/components/CnWorkingCalendarWidget/CnWorkingCalendarWidget.vue +725 -0
  144. package/src/components/CnWorkingCalendarWidget/index.js +26 -0
  145. package/src/components/index.js +5 -0
  146. package/src/composables/indexSources.js +237 -1
  147. package/src/composables/useFlowStore.js +2 -1
  148. package/src/composables/useOrganisationStore.js +111 -0
  149. package/src/css/badge.css +32 -16
  150. package/src/css/look-board-detail.css +80 -8
  151. package/src/css/look-board-index.css +52 -8
  152. package/src/css/look-board.css +219 -1
  153. package/src/index.js +5 -0
  154. package/src/schemas/app-manifest-v2.schema.json +33 -3
  155. package/src/utils/builtInFormatters.js +33 -0
  156. package/src/utils/libraryWidgetKeys.js +2 -0
  157. package/src/utils/matrixCells.js +154 -0
  158. package/src/utils/nextcloudDirectory.js +117 -0
  159. package/src/utils/validateManifestV2.compiled.js +3 -3
  160. package/src/utils/workingCalendarMonth.js +82 -0
@@ -0,0 +1,1269 @@
1
+ <!--
2
+ - SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ - SPDX-License-Identifier: EUPL-1.2
4
+ -->
5
+ <template>
6
+ <div class="cn-checklist-run" data-testid="cn-checklist-run">
7
+ <p v-if="error"
8
+ class="cn-checklist-run__error"
9
+ role="alert"
10
+ data-testid="cn-checklist-run-error">
11
+ {{ error }}
12
+ </p>
13
+ <div v-else-if="loading" class="cn-checklist-run__loading">
14
+ <NcLoadingIcon :size="20" :name="loadingLabel" />
15
+ </div>
16
+ <p v-else-if="questions.length === 0" class="cn-checklist-run__empty" data-testid="cn-checklist-run-empty">
17
+ {{ emptyLabel }}
18
+ </p>
19
+ <form v-else
20
+ class="cn-checklist-run__form"
21
+ novalidate
22
+ @submit.prevent="next">
23
+ <p
24
+ ref="position"
25
+ class="cn-checklist-run__position"
26
+ tabindex="-1"
27
+ data-testid="cn-checklist-run-position">
28
+ {{ positionText }}
29
+ </p>
30
+ <span
31
+ class="cn-checklist-run__progress"
32
+ role="progressbar"
33
+ :aria-label="progressLabel"
34
+ aria-valuemin="0"
35
+ :aria-valuemax="questions.length"
36
+ :aria-valuenow="answeredCount"
37
+ data-testid="cn-checklist-run-progress">
38
+ <span class="cn-checklist-run__progress-fill" :style="{ width: `${progressShare}%` }" />
39
+ </span>
40
+ <span class="cn-checklist-run__live" aria-live="polite">{{ liveText }}</span>
41
+
42
+ <p v-if="pageGroup" class="cn-checklist-run__group" data-testid="cn-checklist-run-group">
43
+ {{ pageGroup }}
44
+ </p>
45
+ <fieldset
46
+ v-for="question in pageQuestions"
47
+ :key="question.id"
48
+ class="cn-checklist-run__question"
49
+ :aria-describedby="question.help ? `${uid}-help-${question.id}` : null"
50
+ data-testid="cn-checklist-run-question">
51
+ <legend class="cn-checklist-run__legend">
52
+ {{ question.text }}<span v-if="question.required" class="cn-checklist-run__required">
53
+ <span aria-hidden="true"> *</span><span class="cn-checklist-run__sr">{{ requiredLabel }}</span>
54
+ </span>
55
+ </legend>
56
+ <p v-if="question.help" :id="`${uid}-help-${question.id}`" class="cn-checklist-run__help">
57
+ {{ question.help }}
58
+ </p>
59
+ <template v-if="question.choices.length">
60
+ <label
61
+ v-for="choice in question.choices"
62
+ :key="choice.value"
63
+ class="cn-checklist-run__choice">
64
+ <input
65
+ v-model="draft[question.id].value"
66
+ type="radio"
67
+ :name="`${uid}-${question.id}`"
68
+ :value="choice.value"
69
+ :required="question.required"
70
+ data-testid="cn-checklist-run-choice"
71
+ @change="markDirty(question.id)">
72
+ {{ choice.label }}
73
+ </label>
74
+ </template>
75
+ <input
76
+ v-else-if="question.kind === 'number'"
77
+ v-model="draft[question.id].value"
78
+ class="cn-checklist-run__number"
79
+ type="number"
80
+ inputmode="decimal"
81
+ :aria-label="question.text"
82
+ :required="question.required"
83
+ data-testid="cn-checklist-run-number"
84
+ @input="markDirty(question.id)">
85
+ <p v-else-if="question.kind === 'photo'" class="cn-checklist-run__help">
86
+ {{ photoOnlyLabel }}
87
+ </p>
88
+ <textarea
89
+ v-else
90
+ v-model="draft[question.id].value"
91
+ class="cn-checklist-run__text"
92
+ rows="3"
93
+ :aria-label="question.text"
94
+ :required="question.required"
95
+ data-testid="cn-checklist-run-text"
96
+ @input="markDirty(question.id)" />
97
+ <label v-if="allowNote" class="cn-checklist-run__note-label">
98
+ {{ noteLabel }}
99
+ <textarea
100
+ v-model="draft[question.id].note"
101
+ class="cn-checklist-run__text"
102
+ rows="2"
103
+ data-testid="cn-checklist-run-note"
104
+ @input="markDirty(question.id)" />
105
+ </label>
106
+ <p
107
+ v-if="missing.includes(question.id)"
108
+ class="cn-checklist-run__error"
109
+ role="alert"
110
+ data-testid="cn-checklist-run-missing">
111
+ {{ missingLabel }}
112
+ </p>
113
+ </fieldset>
114
+
115
+ <div
116
+ v-for="question in photoQuestions"
117
+ :key="`photos-${question.id}`"
118
+ class="cn-checklist-run__photos"
119
+ data-testid="cn-checklist-run-photos">
120
+ <strong class="cn-checklist-run__photos-title">{{ photoTitle(question) }}</strong>
121
+ <ul v-if="photoNamesFor(question).length" class="cn-checklist-run__photo-list">
122
+ <li v-for="name in photoNamesFor(question)" :key="name" data-testid="cn-checklist-run-photo">
123
+ {{ name }}
124
+ </li>
125
+ </ul>
126
+ <label class="cn-checklist-run__photo-button">
127
+ <input
128
+ class="cn-checklist-run__photo-input"
129
+ type="file"
130
+ accept="image/*"
131
+ capture="environment"
132
+ :aria-describedby="missingPhoto.includes(question.id) ? `${uid}-photo-${question.id}` : null"
133
+ data-testid="cn-checklist-run-photo-input"
134
+ @change="addPhotos($event, question.id)">
135
+ {{ photoNamesFor(question).length || (storedPhotos[question.id] || []).length ? morePhotoLabel : addPhotoLabel }}
136
+ </label>
137
+ <p
138
+ v-if="missingPhoto.includes(question.id)"
139
+ :id="`${uid}-photo-${question.id}`"
140
+ class="cn-checklist-run__error"
141
+ role="alert"
142
+ data-testid="cn-checklist-run-missing-photo">
143
+ {{ missingPhotoLabel }}
144
+ </p>
145
+ </div>
146
+
147
+ <div class="cn-checklist-run__nav">
148
+ <NcButton
149
+ variant="secondary"
150
+ :disabled="page === 0 || saving"
151
+ data-testid="cn-checklist-run-previous"
152
+ @click="previous">
153
+ {{ previousLabel }}
154
+ </NcButton>
155
+ <NcButton
156
+ variant="primary"
157
+ type="submit"
158
+ :disabled="saving"
159
+ data-testid="cn-checklist-run-next">
160
+ {{ isLastPage ? finishLabel : nextLabel }}
161
+ </NcButton>
162
+ </div>
163
+ <p v-if="saveError"
164
+ class="cn-checklist-run__error"
165
+ role="alert"
166
+ data-testid="cn-checklist-run-save-error">
167
+ {{ saveError }}
168
+ </p>
169
+ </form>
170
+ </div>
171
+ </template>
172
+
173
+ <script>
174
+ import axios from '@nextcloud/axios'
175
+ import { translate as t } from '@nextcloud/l10n'
176
+ import { generateUrl } from '@nextcloud/router'
177
+ import { NcButton, NcLoadingIcon } from '@nextcloud/vue'
178
+ import { uploadHeldFiles } from '../../composables/useHeldFileUpload.js'
179
+ import { dispatchAction } from '../../utils/actionsDispatcher.js'
180
+ import { fetchReferenceObjects } from '../../utils/boardReferenceColumns.js'
181
+ import { readPath } from '../../utils/readPath.js'
182
+ import { hasUnresolvedTokens, resolveDeepTokens, resolveFilterTokens } from '../../utils/resolveFilterTokens.js'
183
+
184
+ const OBJECT_API = '/apps/openregister/api/objects'
185
+
186
+ let instanceCounter = 0
187
+
188
+ /**
189
+ * The choices a question type offers, before translation.
190
+ *
191
+ * @type {Record<string, Array<{value: string, label: string}>>}
192
+ */
193
+ const TYPE_CHOICES = Object.freeze({
194
+ yes_no: [{ key: 'yes', label: 'Yes' }, { key: 'no', label: 'No' }],
195
+ yes_no_na: [{ key: 'yes', label: 'Yes' }, { key: 'no', label: 'No' }, { key: 'na', label: 'Not applicable' }],
196
+ })
197
+
198
+ /** The kinds of question the widget draws. */
199
+ const KINDS = Object.freeze(['yes_no', 'yes_no_na', 'choice', 'text', 'number', 'photo'])
200
+
201
+ /**
202
+ * Sort by a numeric field, lowest first, keeping the given order for ties and
203
+ * for entries without the field.
204
+ *
205
+ * @param {Array<object>} list The entries.
206
+ * @param {string} field The field.
207
+ * @return {Array<object>} A sorted copy.
208
+ */
209
+ function byOrder(list, field) {
210
+ if (!field) {
211
+ return [...list]
212
+ }
213
+ const rank = (entry) => {
214
+ const n = Number(readPath(entry, field))
215
+ return Number.isFinite(n) ? n : Infinity
216
+ }
217
+ return list.map((entry, index) => ({ entry, index })).sort((a, b) => (rank(a.entry) - rank(b.entry)) || (a.index - b.index)).map(({ entry }) => entry)
218
+ }
219
+
220
+ /**
221
+ * A register or schema as the object API path takes it.
222
+ *
223
+ * @param {string|object} value A slug, or a register/schema object.
224
+ * @return {string} The slug or id.
225
+ */
226
+ function slugOf(value) {
227
+ if (value && typeof value === 'object') {
228
+ return String(value.slug ?? value.id ?? '')
229
+ }
230
+ return value ? String(value) : ''
231
+ }
232
+
233
+ /**
234
+ * The id of an object, wherever OpenRegister carries it.
235
+ *
236
+ * @param {object|null} object The object.
237
+ * @return {string} The id, or ''.
238
+ */
239
+ function idOf(object) {
240
+ return String(object?.id ?? object?.['@self']?.id ?? object?.uuid ?? '')
241
+ }
242
+
243
+ /**
244
+ * CnChecklistRunWidget — the `checklist-run` widget: a checklist answered a
245
+ * page of questions at a time (board DqInspectie, the middle phone frame).
246
+ *
247
+ * The questions are records of a register, listed with a token-resolved
248
+ * filter. Each page shows `pageSize` questions (one by default) as radio
249
+ * choices or a text field, an optional note, and photos for the first
250
+ * question of the page, then Previous and Next. The answers of a page are
251
+ * saved when the user moves on.
252
+ *
253
+ * Answers are stored one record per question through the OpenRegister object
254
+ * API, under the reader's own rights: an existing answer is updated, a new
255
+ * one created with the `answers.create` fields. Photos go up through the
256
+ * object's own file upload (`uploadHeldFiles`) and are attached to the
257
+ * answer's `photosField`. An app that stores answers its own way names a
258
+ * `saveAction` instead; it is dispatched per answer with the answer as its
259
+ * last argument, and photos are not uploaded then.
260
+ *
261
+ * ```json
262
+ * {
263
+ * "type": "checklist-run",
264
+ * "content": {
265
+ * "questions": { "register": "app", "schema": "question", "filter": { "checklist": "@object.checklist" }, "orderField": "position" },
266
+ * "fields": { "text": "text", "type": "type", "options": "options", "required": "required", "help": "help" },
267
+ * "answers": {
268
+ * "register": "app", "schema": "answer", "filter": { "run": "@objectId" },
269
+ * "questionField": "question", "valueField": "answer", "noteField": "note", "photosField": "photos",
270
+ * "create": { "run": "@objectId" }
271
+ * },
272
+ * "pageSize": 1,
273
+ * "caption": "{title} version {version}"
274
+ * }
275
+ * }
276
+ * ```
277
+ *
278
+ * The questions and answers can also live inside the open record:
279
+ * `questionsPath` (with `itemsKey` for groups, both sorted by `orderField`)
280
+ * reads the questions off it, and `answersPath` writes the answers into an
281
+ * array on it, one entry per question keyed by `answerKey`, by updating the
282
+ * record through the object API. Photos then go to the record's own files
283
+ * (`recordPhotosField`) and their references into the answer.
284
+ *
285
+ * A question can require a photo: `fields.photoRequired` names its rule,
286
+ * `photoRequiredValues` maps the stored values onto `never`, `always` or
287
+ * `{ whenAnswer }`, and the page holds until the photo is there. On a page
288
+ * with no bound record, `record: { register, schema, filter, sort }` picks
289
+ * the open record: the first match, under the reader's rights.
290
+ *
291
+ * Question kinds: `yes_no`, `yes_no_na`, `choice` (the question's `options`,
292
+ * a list of strings or `{ value, label }`), `number`, `photo` and `text`.
293
+ * `typeMap` maps the app's type values onto them, `choiceValues` the stored
294
+ * values of yes / no / not applicable.
295
+ * Keyboard: the choices are native radio buttons in a fieldset whose legend is
296
+ * the question; on a new page focus moves to the position line and a polite
297
+ * live region reads it.
298
+ */
299
+ export default {
300
+ name: 'CnChecklistRunWidget',
301
+
302
+ components: { NcButton, NcLoadingIcon },
303
+
304
+ inject: {
305
+ /** The detail page's object context (`{ objectId, object }`), for tokens. */
306
+ cnObjectContext: { default: null },
307
+ /** The page's workspace values, for `@workspace.<key>` tokens. */
308
+ cnWorkspaceContext: { default: null },
309
+ /** The host's translate, for manifest texts and question texts. */
310
+ cnTranslate: { default: () => (key) => key },
311
+ /** The page's pre-bound action dispatcher, for `saveAction` / `finishAction`. */
312
+ cnDispatchAction: { default: null },
313
+ },
314
+
315
+ props: {
316
+ /**
317
+ * The widget config: `{ questions | questionsPath, itemsKey?,
318
+ * groupLabelField?, orderField?, fields?, typeMap?, choiceValues?,
319
+ * answers | answersPath, answerKey?, answerFields?, recordPhotosField?,
320
+ * record?, saveAction?, finishAction?, pageSize?, allowNote?,
321
+ * allowPhotos?, caption?, emptyText? }`, as in the component
322
+ * description and docs/components/cn-checklist-run-widget.md.
323
+ *
324
+ * @type {object}
325
+ */
326
+ content: {
327
+ type: Object,
328
+ default: () => ({}),
329
+ },
330
+
331
+ /** The bound record's id, for `@objectId`. */
332
+ objectId: {
333
+ type: [String, Number],
334
+ default: '',
335
+ },
336
+
337
+ /** The bound record, for `@object.<field>`, the caption and `questionsPath` / `answersPath`. */
338
+ objectData: {
339
+ type: Object,
340
+ default: null,
341
+ },
342
+
343
+ /** The bound record's register, for writing `answersPath` (`content.record` wins). */
344
+ register: {
345
+ type: [String, Object],
346
+ default: '',
347
+ },
348
+
349
+ /** The bound record's schema, for writing `answersPath` (`content.record` wins). */
350
+ schema: {
351
+ type: [String, Object],
352
+ default: '',
353
+ },
354
+ },
355
+
356
+ emits: ['finished'],
357
+
358
+ data() {
359
+ instanceCounter += 1
360
+ return {
361
+ uid: `cn-checklist-run-${instanceCounter}`,
362
+ loading: false,
363
+ error: '',
364
+ saving: false,
365
+ saveError: '',
366
+ questions: [],
367
+ answerIds: {},
368
+ // Photos an answer already holds, kept when new ones are attached.
369
+ storedPhotos: {},
370
+ // `answersPath` mode: the answer entries as last read or written.
371
+ recordAnswers: [],
372
+ draft: {},
373
+ dirty: {},
374
+ photos: {},
375
+ page: 0,
376
+ missing: [],
377
+ // Questions on this page that still need their photo.
378
+ missingPhoto: [],
379
+ // `content.record` with a filter: the record it picked, or null.
380
+ pickedRecord: null,
381
+ liveText: '',
382
+ }
383
+ },
384
+
385
+ computed: {
386
+ /** @return {object} What the tokens resolve against. */
387
+ tokenCtx() {
388
+ const unwrap = (c) => (c && typeof c === 'object' && 'value' in c ? c.value : c)
389
+ const injected = unwrap(this.cnObjectContext) || {}
390
+ return {
391
+ ...injected,
392
+ objectId: (this.pickedRecord && idOf(this.pickedRecord)) || this.objectId || injected.objectId || this.objectData?.id || null,
393
+ object: this.pickedRecord || this.objectData || injected.object || null,
394
+ workspace: unwrap(this.cnWorkspaceContext) || {},
395
+ }
396
+ },
397
+
398
+ /** @return {object} The question field paths, with their defaults. */
399
+ fields() {
400
+ return { id: 'id', text: 'text', type: 'type', options: 'options', required: 'required', help: 'help', ...(this.content?.fields || {}) }
401
+ },
402
+
403
+ /** @return {number} Questions per page, at least 1. */
404
+ pageSize() {
405
+ const n = Math.floor(Number(this.content?.pageSize))
406
+ return Number.isFinite(n) && n >= 1 ? n : 1
407
+ },
408
+
409
+ /** @return {number} The number of pages. */
410
+ pageCount() {
411
+ return Math.max(1, Math.ceil(this.questions.length / this.pageSize))
412
+ },
413
+
414
+ /** @return {boolean} Whether this is the last page. */
415
+ isLastPage() {
416
+ return this.page >= this.pageCount - 1
417
+ },
418
+
419
+ /** @return {object[]} The questions on this page. */
420
+ pageQuestions() {
421
+ return this.questions.slice(this.page * this.pageSize, (this.page + 1) * this.pageSize)
422
+ },
423
+
424
+ /** @return {object|null} The first question on this page, the one photos belong to. */
425
+ currentQuestion() {
426
+ return this.pageQuestions[0] || null
427
+ },
428
+
429
+ /** @return {number} Questions with an answer. */
430
+ answeredCount() {
431
+ return this.questions.filter((q) => this.isAnswered(q)).length
432
+ },
433
+
434
+ /** @return {number} The share answered, 0 to 100. */
435
+ progressShare() {
436
+ return this.questions.length ? Math.round((this.answeredCount / this.questions.length) * 100) : 0
437
+ },
438
+
439
+ /** @return {string} "Question 7 of 24", with the caption after it. */
440
+ positionText() {
441
+ const first = this.page * this.pageSize + 1
442
+ const position = t('nextcloud-vue', 'Question {n} of {total}', { n: first, total: this.questions.length })
443
+ const caption = this.caption
444
+ return caption ? `${position} · ${caption}` : position
445
+ },
446
+
447
+ /** @return {string} `content.caption` with `{field}` filled from the record. */
448
+ caption() {
449
+ const template = this.content?.caption
450
+ if (typeof template !== 'string' || template === '') {
451
+ return ''
452
+ }
453
+ const object = this.tokenCtx.object || {}
454
+ return this.translate(template).replace(/\{([A-Za-z0-9_.]+)\}/g, (_m, path) => {
455
+ const value = readPath(object, path)
456
+ return value === null || value === undefined ? '' : String(value)
457
+ }).trim()
458
+ },
459
+
460
+ /** @return {boolean} Whether a note field shows (default true). */
461
+ allowNote() {
462
+ return this.content?.allowNote !== false
463
+ },
464
+
465
+ /** @return {boolean} Whether the answers are written into an array on the open record. */
466
+ inRecord() {
467
+ return typeof this.content?.answersPath === 'string' && this.content.answersPath !== ''
468
+ },
469
+
470
+ /** @return {object} `answersPath` field names, with their defaults. */
471
+ answerFields() {
472
+ return { value: 'value', number: 'value', choice: 'value', note: 'note', photos: 'photos', ...(this.content?.answerFields || {}) }
473
+ },
474
+
475
+ /** @return {{register: string, schema: string}} Where the open record lives. */
476
+ recordRef() {
477
+ const own = this.content?.record || {}
478
+ return { register: slugOf(own.register || this.register), schema: slugOf(own.schema || this.schema) }
479
+ },
480
+
481
+ /**
482
+ * Whether photos can be added: allowed, and somewhere to put them.
483
+ *
484
+ * @return {boolean}
485
+ * @spec openspec/changes/checklist-run-widget/specs/checklist-run-widget/spec.md#requirement-questions-and-answers-can-live-inside-the-open-record
486
+ */
487
+ photosEnabled() {
488
+ if (this.content?.allowPhotos === false || this.content?.saveAction) {
489
+ return false
490
+ }
491
+ if (this.inRecord) {
492
+ return Boolean(this.content?.recordPhotosField && this.answerFields.photos)
493
+ }
494
+ return Boolean(this.content?.answers?.photosField)
495
+ },
496
+
497
+ /** @return {string} The group (section) of this page's first question, or ''. */
498
+ pageGroup() {
499
+ return this.currentQuestion?.group || ''
500
+ },
501
+
502
+ /**
503
+ * The questions of this page that get a photo control: with a
504
+ * `photoRequired` field, those whose rule is not never (and photo
505
+ * questions); without one, the first question of the page.
506
+ *
507
+ * @return {object[]} The questions.
508
+ * @spec openspec/changes/checklist-run-photo-rule/specs/checklist-run-photo-rule/spec.md#requirement-a-question-can-require-a-photo
509
+ */
510
+ photoQuestions() {
511
+ if (!this.photosEnabled) {
512
+ return []
513
+ }
514
+ if (this.fields.photoRequired) {
515
+ return this.pageQuestions.filter((q) => q.kind === 'photo' || q.photoRule !== 'never')
516
+ }
517
+ const first = this.currentQuestion
518
+ const extra = this.pageQuestions.filter((q) => q.kind === 'photo' && q !== first)
519
+ return first ? [first, ...extra] : extra
520
+ },
521
+
522
+ /**
523
+ * `content.record` with a `filter`: the list to pick the open record
524
+ * from (first match of `sort`), `'blocked'` while a token is
525
+ * unresolved, or null without a filter.
526
+ *
527
+ * @return {object|string|null}
528
+ * @spec openspec/changes/checklist-run-photo-rule/specs/checklist-run-photo-rule/spec.md#requirement-a-checklist-run-can-pick-its-record-by-filter
529
+ */
530
+ recordPicker() {
531
+ const own = this.content?.record
532
+ if (!own || typeof own !== 'object' || !own.filter || typeof own.filter !== 'object') {
533
+ return null
534
+ }
535
+ const unwrap = (c) => (c && typeof c === 'object' && 'value' in c ? c.value : c)
536
+ const filter = resolveFilterTokens(own.filter, { objectId: this.objectId || null, object: this.objectData, workspace: unwrap(this.cnWorkspaceContext) || {} })
537
+ if (hasUnresolvedTokens(filter)) {
538
+ return 'blocked'
539
+ }
540
+ const sort = own.sort && typeof own.sort === 'object' && own.sort.field ? { [`_order[${own.sort.field}]`]: own.sort.dir === 'asc' ? 'asc' : 'desc' } : {}
541
+ return { register: slugOf(own.register), schema: slugOf(own.schema), filter: { ...filter, ...sort } }
542
+ },
543
+
544
+ /** @return {object|null} The record the run lives in: the picked one, else the bound one. */
545
+ openRecord() {
546
+ return this.recordPicker ? this.pickedRecord : this.tokenCtx.object
547
+ },
548
+
549
+ /** @return {string} The key of the source lists, to read them again when they change. */
550
+ sourceKey() {
551
+ const picker = this.recordPicker
552
+ if (picker === 'blocked') {
553
+ return ''
554
+ }
555
+ if (picker && typeof this.content?.questionsPath === 'string' && this.content.questionsPath !== '') {
556
+ return JSON.stringify({ pick: picker, path: this.content.questionsPath })
557
+ }
558
+ if (typeof this.content?.questionsPath === 'string' && this.content.questionsPath !== '') {
559
+ const object = this.tokenCtx.object
560
+ return object ? JSON.stringify({ path: this.content.questionsPath, id: idOf(object), at: object['@self']?.updated ?? null }) : ''
561
+ }
562
+ const q = this.content?.questions
563
+ if (!q || !q.register || !q.schema) {
564
+ return ''
565
+ }
566
+ const qFilter = resolveFilterTokens(q.filter || {}, this.tokenCtx)
567
+ const a = this.content?.answers
568
+ const aFilter = a && a.register && a.schema ? resolveFilterTokens(a.filter || {}, this.tokenCtx) : null
569
+ if (hasUnresolvedTokens(qFilter) || (aFilter && hasUnresolvedTokens(aFilter))) {
570
+ return ''
571
+ }
572
+ return JSON.stringify({ q: [q.register, q.schema, qFilter], a: aFilter ? [a.register, a.schema, aFilter] : null })
573
+ },
574
+
575
+ loadingLabel: () => t('nextcloud-vue', 'Loading …'),
576
+ progressLabel: () => t('nextcloud-vue', 'Questions answered'),
577
+ requiredLabel: () => ` ${t('nextcloud-vue', '(required)')}`,
578
+ missingLabel: () => t('nextcloud-vue', 'Answer this question to go on.'),
579
+ previousLabel: () => t('nextcloud-vue', 'Previous'),
580
+ nextLabel: () => t('nextcloud-vue', 'Next'),
581
+ finishLabel: () => t('nextcloud-vue', 'Finish'),
582
+ addPhotoLabel: () => t('nextcloud-vue', 'Take or add a photo'),
583
+ missingPhotoLabel: () => t('nextcloud-vue', 'This answer needs a photo. Add one to go on.'),
584
+ photoOnlyLabel: () => t('nextcloud-vue', 'Answer with a photo.'),
585
+ morePhotoLabel: () => t('nextcloud-vue', 'Another photo'),
586
+
587
+ /** @return {string} The note field's label. */
588
+ noteLabel() {
589
+ return this.content?.noteLabel ? this.translate(this.content.noteLabel) : t('nextcloud-vue', 'Note')
590
+ },
591
+
592
+ /** @return {string} The empty-state text. */
593
+ emptyLabel() {
594
+ return this.content?.emptyText ? this.translate(this.content.emptyText) : t('nextcloud-vue', 'This checklist has no questions.')
595
+ },
596
+ },
597
+
598
+ watch: {
599
+ sourceKey: {
600
+ handler() {
601
+ this.load()
602
+ },
603
+
604
+ immediate: true,
605
+ },
606
+ },
607
+
608
+ methods: {
609
+ /**
610
+ * The host's translate, or the text unchanged.
611
+ *
612
+ * @param {string} text The text.
613
+ * @return {string} The translation.
614
+ */
615
+ translate(text) {
616
+ return typeof this.cnTranslate === 'function' ? this.cnTranslate(text) : text
617
+ },
618
+
619
+ /**
620
+ * Whether a value counts as an answer.
621
+ *
622
+ * @param {unknown} value The value.
623
+ * @return {boolean} True for a non-empty value.
624
+ */
625
+ hasValue(value) {
626
+ return value !== null && value !== undefined && String(value).trim() !== ''
627
+ },
628
+
629
+ /**
630
+ * Whether a question has an answer: a value, or for a photo question
631
+ * a photo stored or held.
632
+ *
633
+ * @param {object} question The question.
634
+ * @return {boolean} True when answered.
635
+ */
636
+ isAnswered(question) {
637
+ if (question.kind === 'photo') {
638
+ return this.hasPhoto(question)
639
+ }
640
+ return this.hasValue(this.draft[question.id]?.value)
641
+ },
642
+
643
+ /**
644
+ * A question record as the widget draws it.
645
+ *
646
+ * @param {object} record The question record.
647
+ * @param {string} [group] The group (section) it belongs to.
648
+ * @return {{id: string, text: string, kind: string, required: boolean, help: string, choices: Array<{value: string, label: string}>, group: string}} The question.
649
+ */
650
+ toQuestion(record, group = '') {
651
+ const f = this.fields
652
+ const raw = String(readPath(record, f.type) ?? '')
653
+ const mapped = this.content?.typeMap && typeof this.content.typeMap === 'object' ? this.content.typeMap[raw] : undefined
654
+ const kind = KINDS.includes(mapped) ? mapped : (KINDS.includes(raw) ? raw : 'text')
655
+ const stored = { yes: 'yes', no: 'no', na: 'na', ...(this.content?.choiceValues || {}) }
656
+ let choices = (TYPE_CHOICES[kind] || []).map((c) => ({ value: String(stored[c.key]), label: t('nextcloud-vue', c.label) }))
657
+ if (kind === 'choice') {
658
+ const options = readPath(record, f.options)
659
+ choices = (Array.isArray(options) ? options : []).map((o) => (o && typeof o === 'object'
660
+ ? { value: String(o.value ?? o.label ?? ''), label: this.translate(String(o.label ?? o.value ?? '')) }
661
+ : { value: String(o), label: this.translate(String(o)) })).filter((c) => c.value !== '')
662
+ }
663
+ const help = readPath(record, f.help)
664
+ return {
665
+ id: String(readPath(record, f.id) ?? idOf(record)),
666
+ text: this.translate(String(readPath(record, f.text) ?? '')),
667
+ kind,
668
+ required: Boolean(readPath(record, f.required)),
669
+ help: typeof help === 'string' && help !== '' ? this.translate(help) : '',
670
+ choices,
671
+ group,
672
+ photoRule: this.photoRuleOf(record),
673
+ }
674
+ },
675
+
676
+ /**
677
+ * A question's photo rule, from its `photoRequired` field mapped
678
+ * through `photoRequiredValues`: `'never'`, `'always'` or
679
+ * `{ whenAnswer }` (a photo is needed when the answer is that value).
680
+ *
681
+ * @param {object} record The question record.
682
+ * @return {('never'|'always'|{whenAnswer: string})} The rule.
683
+ * @spec openspec/changes/checklist-run-photo-rule/specs/checklist-run-photo-rule/spec.md#requirement-a-question-can-require-a-photo
684
+ */
685
+ photoRuleOf(record) {
686
+ const path = this.fields.photoRequired
687
+ if (!path) {
688
+ return 'never'
689
+ }
690
+ const raw = readPath(record, path)
691
+ const map = { true: 'always', false: 'never', always: 'always', never: 'never', ...(this.content?.photoRequiredValues || {}) }
692
+ const rule = raw === null || raw === undefined ? 'never' : map[String(raw)]
693
+ if (rule && typeof rule === 'object' && rule.whenAnswer !== undefined) {
694
+ return { whenAnswer: String(rule.whenAnswer) }
695
+ }
696
+ return rule === 'always' ? 'always' : 'never'
697
+ },
698
+
699
+ /**
700
+ * Whether a question needs a photo with its current answer.
701
+ *
702
+ * @param {object} question The question.
703
+ * @return {boolean} True when a photo is required now.
704
+ */
705
+ needsPhoto(question) {
706
+ const rule = question.photoRule
707
+ if (rule === 'always') {
708
+ return true
709
+ }
710
+ return Boolean(rule && typeof rule === 'object' && String(this.draft[question.id]?.value ?? '') === rule.whenAnswer)
711
+ },
712
+
713
+ /**
714
+ * Whether a question has a photo, stored or held.
715
+ *
716
+ * @param {object} question The question.
717
+ * @return {boolean} True with at least one photo.
718
+ */
719
+ hasPhoto(question) {
720
+ return (this.storedPhotos[question.id] || []).length > 0 || (this.photos[question.id] || []).length > 0
721
+ },
722
+
723
+ /**
724
+ * The photo card title for a question: its position in the run.
725
+ *
726
+ * @param {object} question The question.
727
+ * @return {string} The title.
728
+ */
729
+ photoTitle(question) {
730
+ const n = this.questions.indexOf(question) + 1
731
+ return t('nextcloud-vue', 'Photo for question {n}', { n })
732
+ },
733
+
734
+ /**
735
+ * The names of the photos held for a question.
736
+ *
737
+ * @param {object} question The question.
738
+ * @return {string[]} The file names.
739
+ */
740
+ photoNamesFor(question) {
741
+ return (this.photos[question.id] || []).map((file) => file.name)
742
+ },
743
+
744
+ /**
745
+ * The questions on the open record (`questionsPath`): a list of
746
+ * questions, or of groups whose `itemsKey` holds them, both levels
747
+ * sorted by `orderField`.
748
+ *
749
+ * @return {object[]} The questions.
750
+ * @spec openspec/changes/checklist-run-widget/specs/checklist-run-widget/spec.md#requirement-questions-and-answers-can-live-inside-the-open-record
751
+ */
752
+ questionsFromRecord() {
753
+ const c = this.content
754
+ const list = readPath(this.openRecord, c.questionsPath)
755
+ const orderField = c.orderField === undefined ? 'order' : c.orderField
756
+ const top = byOrder(Array.isArray(list) ? list : [], orderField)
757
+ if (!c.itemsKey) {
758
+ return top.map((record) => this.toQuestion(record))
759
+ }
760
+ const labelField = c.groupLabelField || 'name'
761
+ return top.flatMap((group) => {
762
+ const items = readPath(group, c.itemsKey)
763
+ const label = readPath(group, labelField)
764
+ return byOrder(Array.isArray(items) ? items : [], orderField).map((record) => this.toQuestion(record, typeof label === 'string' ? this.translate(label) : ''))
765
+ })
766
+ },
767
+
768
+ /**
769
+ * The draft of one answer entry from the open record's `answersPath`.
770
+ *
771
+ * @param {object} question The question.
772
+ * @param {object} entry The stored entry.
773
+ * @return {{value: string, note: string}} The draft.
774
+ */
775
+ draftFromEntry(question, entry) {
776
+ const af = this.answerFields
777
+ const field = question.kind === 'number' ? af.number : (question.kind === 'choice' ? af.choice : af.value)
778
+ const value = readPath(entry, field)
779
+ const note = readPath(entry, af.note)
780
+ return { value: value === null || value === undefined ? '' : String(value), note: note === null || note === undefined ? '' : String(note) }
781
+ },
782
+
783
+ /**
784
+ * Read the questions and the answers given so far.
785
+ *
786
+ * @return {Promise<void>}
787
+ * @spec openspec/changes/checklist-run-widget/specs/checklist-run-widget/spec.md#requirement-a-checklist-run-shows-a-page-of-questions-at-a-time
788
+ */
789
+ async load() {
790
+ const key = this.sourceKey
791
+ if (key === '') {
792
+ return
793
+ }
794
+ this.loading = true
795
+ this.error = ''
796
+ try {
797
+ const picker = this.recordPicker
798
+ if (picker) {
799
+ const found = await fetchReferenceObjects({ register: picker.register, schema: picker.schema, filter: picker.filter, limit: 1 })
800
+ if (key !== this.sourceKey) {
801
+ return
802
+ }
803
+ this.pickedRecord = found[0] || null
804
+ }
805
+ let questions
806
+ if (picker && !this.pickedRecord) {
807
+ questions = []
808
+ } else if (this.content.questionsPath) {
809
+ questions = this.questionsFromRecord()
810
+ } else {
811
+ const q = this.content.questions
812
+ const records = await fetchReferenceObjects({ register: q.register, schema: q.schema, filter: resolveFilterTokens(q.filter || {}, this.tokenCtx), limit: q.limit || 500 })
813
+ questions = byOrder(records, q.orderField).map((r) => this.toQuestion(r))
814
+ }
815
+ questions = questions.filter((x) => x.id !== '' && x.text !== '')
816
+ const a = this.inRecord ? null : this.content.answers
817
+ const answers = a && a.register && a.schema
818
+ ? await fetchReferenceObjects({ register: a.register, schema: a.schema, filter: resolveFilterTokens(a.filter || {}, this.tokenCtx), limit: a.limit || 500 })
819
+ : []
820
+ if (key !== this.sourceKey) {
821
+ return
822
+ }
823
+ const draft = {}
824
+ const answerIds = {}
825
+ const storedPhotos = {}
826
+ for (const question of questions) {
827
+ draft[question.id] = { value: '', note: '' }
828
+ }
829
+ if (this.inRecord) {
830
+ const entries = readPath(this.openRecord, this.content.answersPath)
831
+ this.recordAnswers = Array.isArray(entries) ? entries : []
832
+ const key = this.content.answerKey || 'questionId'
833
+ for (const entry of this.recordAnswers) {
834
+ const qid = String(readPath(entry, key) ?? '')
835
+ const question = questions.find((x) => x.id === qid)
836
+ if (question && !answerIds[qid]) {
837
+ draft[qid] = this.draftFromEntry(question, entry)
838
+ answerIds[qid] = qid
839
+ const photos = readPath(entry, this.answerFields.photos)
840
+ storedPhotos[qid] = Array.isArray(photos) ? photos : []
841
+ }
842
+ }
843
+ }
844
+ for (const answer of answers) {
845
+ const qid = String(readPath(answer, a.questionField || 'question') ?? '')
846
+ if (draft[qid] && !answerIds[qid]) {
847
+ draft[qid] = {
848
+ value: String(readPath(answer, a.valueField || 'answer') ?? ''),
849
+ note: String(readPath(answer, a.noteField || 'note') ?? ''),
850
+ }
851
+ answerIds[qid] = idOf(answer)
852
+ const stored = a.photosField ? readPath(answer, a.photosField) : null
853
+ storedPhotos[qid] = Array.isArray(stored) ? stored : []
854
+ }
855
+ }
856
+ this.questions = questions
857
+ this.draft = draft
858
+ this.answerIds = answerIds
859
+ this.storedPhotos = storedPhotos
860
+ this.dirty = {}
861
+ this.page = Math.min(this.page, Math.max(0, Math.ceil(questions.length / this.pageSize) - 1))
862
+ } catch {
863
+ this.error = t('nextcloud-vue', 'The checklist could not be loaded.')
864
+ } finally {
865
+ this.loading = false
866
+ }
867
+ },
868
+
869
+ /**
870
+ * Note that a question's answer changed, so the page saves it.
871
+ *
872
+ * @param {string} id The question id.
873
+ * @return {void}
874
+ */
875
+ markDirty(id) {
876
+ this.dirty = { ...this.dirty, [id]: true }
877
+ this.missing = this.missing.filter((m) => m !== id)
878
+ },
879
+
880
+ /**
881
+ * Hold the photos picked for a question until the page saves.
882
+ *
883
+ * @param {Event} event The file input's change event.
884
+ * @param {string} [questionId] The question, else the first of the page.
885
+ * @return {void}
886
+ */
887
+ addPhotos(event, questionId) {
888
+ const id = questionId || this.currentQuestion?.id
889
+ const files = Array.from(event?.target?.files || [])
890
+ if (!id || files.length === 0) {
891
+ return
892
+ }
893
+ this.photos = { ...this.photos, [id]: [...(this.photos[id] || []), ...files] }
894
+ this.dirty = { ...this.dirty, [id]: true }
895
+ this.missingPhoto = this.missingPhoto.filter((m) => m !== id)
896
+ if (event.target) {
897
+ event.target.value = ''
898
+ }
899
+ },
900
+
901
+ /**
902
+ * Save the changed answers of this page: through `saveAction` when
903
+ * set, else one answer record per question through the object API,
904
+ * then its held photos.
905
+ *
906
+ * @return {Promise<boolean>} True when everything saved.
907
+ * @spec openspec/changes/checklist-run-widget/specs/checklist-run-widget/spec.md#requirement-answers-are-saved-under-the-readers-own-rights
908
+ */
909
+ async savePage() {
910
+ const changed = this.pageQuestions.filter((q) => this.dirty[q.id])
911
+ if (changed.length === 0) {
912
+ return true
913
+ }
914
+ this.saving = true
915
+ this.saveError = ''
916
+ try {
917
+ if (this.inRecord && !this.content?.saveAction) {
918
+ await this.saveIntoRecord(changed)
919
+ this.dirty = Object.fromEntries(Object.entries(this.dirty).filter(([id]) => !changed.some((q) => q.id === id)))
920
+ return true
921
+ }
922
+ for (const question of changed) {
923
+ await this.saveAnswer(question)
924
+ const { [question.id]: _done, ...rest } = this.dirty
925
+ this.dirty = rest
926
+ }
927
+ return true
928
+ } catch {
929
+ this.saveError = t('nextcloud-vue', 'The answer could not be saved. Try again.')
930
+ return false
931
+ } finally {
932
+ this.saving = false
933
+ }
934
+ },
935
+
936
+ /**
937
+ * Write the changed answers of this page into the open record's
938
+ * `answersPath` array (one entry per question, keyed by `answerKey`)
939
+ * with one update of the record through the object API. Held photos
940
+ * go first to the record's own files (`recordPhotosField`), and their
941
+ * references (`photoRefField`, default `id`) into the answer.
942
+ *
943
+ * @param {object[]} changed The changed questions.
944
+ * @return {Promise<void>}
945
+ * @spec openspec/changes/checklist-run-widget/specs/checklist-run-widget/spec.md#requirement-questions-and-answers-can-live-inside-the-open-record
946
+ */
947
+ async saveIntoRecord(changed) {
948
+ const { register, schema } = this.recordRef
949
+ const objectId = idOf(this.openRecord) || String(this.openRecordId || '')
950
+ if (!register || !schema || !objectId) {
951
+ throw new Error('no record to write to')
952
+ }
953
+ const c = this.content
954
+ const af = this.answerFields
955
+ const key = c.answerKey || 'questionId'
956
+ const refField = c.photoRefField || 'id'
957
+ let recordPhotos = Array.isArray(readPath(this.openRecord, c.recordPhotosField)) ? readPath(this.openRecord, c.recordPhotosField) : []
958
+ const entries = [...this.recordAnswers]
959
+ for (const question of changed) {
960
+ const draft = this.draft[question.id]
961
+ const index = entries.findIndex((entry) => String(readPath(entry, key) ?? '') === question.id)
962
+ const entry = { ...(index >= 0 ? entries[index] : { [key]: question.id }) }
963
+ if (question.kind === 'number') {
964
+ entry[af.number] = draft.value === '' ? null : Number(draft.value)
965
+ } else if (question.kind === 'choice') {
966
+ entry[af.choice] = draft.value
967
+ } else if (question.kind !== 'photo') {
968
+ entry[af.value] = draft.value
969
+ }
970
+ if (this.allowNote) {
971
+ entry[af.note] = draft.note
972
+ }
973
+ if (af.at) {
974
+ entry[af.at] = new Date().toISOString()
975
+ }
976
+ const held = this.photos[question.id] || []
977
+ if (this.photosEnabled && held.length) {
978
+ const field = c.recordPhotosField
979
+ const { failed, attached } = await uploadHeldFiles({ register, schema, objectId, held: { [field]: held }, kept: { [field]: recordPhotos } })
980
+ if (failed.length) {
981
+ throw new Error('photo upload failed')
982
+ }
983
+ const now = Array.isArray(attached[field]) ? attached[field] : recordPhotos
984
+ const added = now.slice(recordPhotos.length).map((ref) => (ref && typeof ref === 'object' ? ref[refField] ?? ref : ref))
985
+ recordPhotos = now
986
+ const existing = Array.isArray(entry[af.photos]) ? entry[af.photos] : []
987
+ entry[af.photos] = [...existing, ...added]
988
+ this.storedPhotos = { ...this.storedPhotos, [question.id]: entry[af.photos] }
989
+ const { [question.id]: _sent, ...rest } = this.photos
990
+ this.photos = rest
991
+ }
992
+ if (index >= 0) {
993
+ entries[index] = entry
994
+ } else {
995
+ entries.push(entry)
996
+ }
997
+ }
998
+ const url = generateUrl(`${OBJECT_API}/${encodeURIComponent(register)}/${encodeURIComponent(schema)}/${encodeURIComponent(objectId)}`)
999
+ await axios.patch(url, { [c.answersPath]: entries })
1000
+ this.recordAnswers = entries
1001
+ },
1002
+
1003
+ /**
1004
+ * Save one answer.
1005
+ *
1006
+ * @param {object} question The question.
1007
+ * @return {Promise<void>}
1008
+ */
1009
+ async saveAnswer(question) {
1010
+ const entry = this.draft[question.id]
1011
+ const answer = { questionId: question.id, value: entry.value, note: entry.note }
1012
+ const action = this.content?.saveAction
1013
+ if (action && typeof action === 'object') {
1014
+ const resolved = resolveDeepTokens(action, this.tokenCtx)
1015
+ const wrapped = { ...resolved, args: [...(Array.isArray(resolved.args) ? resolved.args : []), answer] }
1016
+ const run = typeof this.cnDispatchAction === 'function' ? this.cnDispatchAction(wrapped) : dispatchAction(wrapped, { router: this.$router || null, tokenCtx: this.tokenCtx })
1017
+ await run
1018
+ return
1019
+ }
1020
+ const a = this.content?.answers
1021
+ if (!a || !a.register || !a.schema) {
1022
+ return
1023
+ }
1024
+ const body = {
1025
+ [a.questionField || 'question']: question.id,
1026
+ [a.valueField || 'answer']: entry.value,
1027
+ }
1028
+ if (this.allowNote) {
1029
+ body[a.noteField || 'note'] = entry.note
1030
+ }
1031
+ const base = generateUrl(`${OBJECT_API}/${encodeURIComponent(a.register)}/${encodeURIComponent(a.schema)}`)
1032
+ let id = this.answerIds[question.id]
1033
+ if (id) {
1034
+ await axios.patch(`${base}/${encodeURIComponent(id)}`, body)
1035
+ } else {
1036
+ const create = resolveDeepTokens(a.create && typeof a.create === 'object' ? a.create : {}, this.tokenCtx)
1037
+ const response = await axios.post(base, { ...create, ...body })
1038
+ id = idOf(response?.data)
1039
+ this.answerIds = { ...this.answerIds, [question.id]: id }
1040
+ }
1041
+ const held = this.photos[question.id] || []
1042
+ if (this.photosEnabled && held.length && id) {
1043
+ const { failed, attached } = await uploadHeldFiles({ register: a.register, schema: a.schema, objectId: id, held: { [a.photosField]: held }, kept: { [a.photosField]: this.storedPhotos[question.id] || [] } })
1044
+ if (failed.length) {
1045
+ throw new Error('photo upload failed')
1046
+ }
1047
+ if (Array.isArray(attached[a.photosField])) {
1048
+ this.storedPhotos = { ...this.storedPhotos, [question.id]: attached[a.photosField] }
1049
+ }
1050
+ const { [question.id]: _sent, ...rest } = this.photos
1051
+ this.photos = rest
1052
+ }
1053
+ },
1054
+
1055
+ /**
1056
+ * The required questions on this page without an answer.
1057
+ *
1058
+ * @return {string[]} Their ids.
1059
+ */
1060
+ unansweredRequired() {
1061
+ return this.pageQuestions.filter((q) => q.required && !this.isAnswered(q)).map((q) => q.id)
1062
+ },
1063
+
1064
+ /**
1065
+ * Save this page and go to the next one; on the last page, finish.
1066
+ *
1067
+ * @return {Promise<void>}
1068
+ * @spec openspec/changes/checklist-run-widget/specs/checklist-run-widget/spec.md#requirement-a-checklist-run-shows-a-page-of-questions-at-a-time
1069
+ */
1070
+ async next() {
1071
+ this.missing = this.unansweredRequired()
1072
+ this.missingPhoto = this.photosEnabled ? this.pageQuestions.filter((q) => this.needsPhoto(q) && !this.hasPhoto(q)).map((q) => q.id) : []
1073
+ if (this.missing.length || this.missingPhoto.length) {
1074
+ return
1075
+ }
1076
+ if (!(await this.savePage())) {
1077
+ return
1078
+ }
1079
+ if (this.isLastPage) {
1080
+ await this.finish()
1081
+ return
1082
+ }
1083
+ this.goTo(this.page + 1)
1084
+ },
1085
+
1086
+ /**
1087
+ * Save this page and go back one.
1088
+ *
1089
+ * @return {Promise<void>}
1090
+ */
1091
+ async previous() {
1092
+ if (this.page === 0 || !(await this.savePage())) {
1093
+ return
1094
+ }
1095
+ this.missing = []
1096
+ this.missingPhoto = []
1097
+ this.goTo(this.page - 1)
1098
+ },
1099
+
1100
+ /**
1101
+ * Show a page, move focus to its position line and announce it.
1102
+ *
1103
+ * @param {number} page The page.
1104
+ * @return {void}
1105
+ */
1106
+ goTo(page) {
1107
+ this.page = page
1108
+ this.$nextTick(() => {
1109
+ this.liveText = this.positionText
1110
+ this.$refs.position?.focus?.()
1111
+ })
1112
+ },
1113
+
1114
+ /**
1115
+ * The run is done: dispatch `finishAction` when set, and emit `finished`.
1116
+ *
1117
+ * @return {Promise<void>}
1118
+ */
1119
+ async finish() {
1120
+ const action = this.content?.finishAction
1121
+ if (action && typeof action === 'object') {
1122
+ const resolved = resolveDeepTokens(action, this.tokenCtx)
1123
+ const run = typeof this.cnDispatchAction === 'function' ? this.cnDispatchAction(resolved) : dispatchAction(resolved, { router: this.$router || null, tokenCtx: this.tokenCtx })
1124
+ await run
1125
+ }
1126
+ this.liveText = t('nextcloud-vue', 'Checklist finished.')
1127
+ /**
1128
+ * @event finished The last page was saved.
1129
+ * @type {{answered: number, total: number}}
1130
+ */
1131
+ this.$emit('finished', { answered: this.answeredCount, total: this.questions.length })
1132
+ },
1133
+ },
1134
+ }
1135
+ </script>
1136
+
1137
+ <style scoped>
1138
+ .cn-checklist-run__form {
1139
+ display: flex;
1140
+ flex-direction: column;
1141
+ gap: 12px;
1142
+ }
1143
+
1144
+ .cn-checklist-run__position {
1145
+ color: var(--color-text-maxcontrast);
1146
+ margin: 0;
1147
+ }
1148
+
1149
+ .cn-checklist-run__progress {
1150
+ background: var(--color-border);
1151
+ border-radius: 3px;
1152
+ display: block;
1153
+ height: 6px;
1154
+ overflow: hidden;
1155
+ }
1156
+
1157
+ .cn-checklist-run__progress-fill {
1158
+ background: var(--color-primary-element);
1159
+ display: block;
1160
+ height: 100%;
1161
+ }
1162
+
1163
+ .cn-checklist-run__live,
1164
+ .cn-checklist-run__sr {
1165
+ clip-path: inset(50%);
1166
+ height: 1px;
1167
+ overflow: hidden;
1168
+ position: absolute;
1169
+ white-space: nowrap;
1170
+ width: 1px;
1171
+ }
1172
+
1173
+ .cn-checklist-run__question,
1174
+ .cn-checklist-run__photos {
1175
+ background: var(--color-main-background);
1176
+ border: 1px solid var(--color-border);
1177
+ border-radius: var(--border-radius-large, 12px);
1178
+ display: flex;
1179
+ flex-direction: column;
1180
+ gap: 8px;
1181
+ margin: 0;
1182
+ padding: 12px 14px;
1183
+ }
1184
+
1185
+ .cn-checklist-run__legend {
1186
+ font-weight: 600;
1187
+ padding: 0 4px;
1188
+ }
1189
+
1190
+ .cn-checklist-run__help {
1191
+ color: var(--color-text-maxcontrast);
1192
+ margin: 0;
1193
+ }
1194
+
1195
+ .cn-checklist-run__choice {
1196
+ align-items: center;
1197
+ display: flex;
1198
+ gap: 10px;
1199
+ min-height: 34px;
1200
+ }
1201
+
1202
+ .cn-checklist-run__choice input {
1203
+ accent-color: var(--color-primary-element);
1204
+ height: 20px;
1205
+ margin: 0;
1206
+ width: 20px;
1207
+ }
1208
+
1209
+ .cn-checklist-run__text,
1210
+ .cn-checklist-run__number {
1211
+ box-sizing: border-box;
1212
+ width: 100%;
1213
+ }
1214
+
1215
+ .cn-checklist-run__group {
1216
+ font-weight: 600;
1217
+ margin: 0;
1218
+ }
1219
+
1220
+ .cn-checklist-run__note-label {
1221
+ display: flex;
1222
+ flex-direction: column;
1223
+ gap: 4px;
1224
+ }
1225
+
1226
+ .cn-checklist-run__photo-list {
1227
+ margin: 0;
1228
+ padding-inline-start: 18px;
1229
+ }
1230
+
1231
+ .cn-checklist-run__photo-button {
1232
+ align-items: center;
1233
+ align-self: flex-start;
1234
+ border: 1px solid var(--color-border-dark);
1235
+ border-radius: var(--border-radius-element, 8px);
1236
+ cursor: pointer;
1237
+ display: inline-flex;
1238
+ font-weight: 600;
1239
+ min-height: 34px;
1240
+ padding: 0 14px;
1241
+ }
1242
+
1243
+ .cn-checklist-run__photo-button:focus-within {
1244
+ outline: 2px solid var(--color-primary-element);
1245
+ outline-offset: 2px;
1246
+ }
1247
+
1248
+ .cn-checklist-run__photo-input {
1249
+ clip-path: inset(50%);
1250
+ height: 1px;
1251
+ overflow: hidden;
1252
+ position: absolute;
1253
+ width: 1px;
1254
+ }
1255
+
1256
+ .cn-checklist-run__nav {
1257
+ display: flex;
1258
+ justify-content: space-between;
1259
+ }
1260
+
1261
+ .cn-checklist-run__error {
1262
+ color: var(--color-error-text, var(--color-error));
1263
+ margin: 0;
1264
+ }
1265
+
1266
+ .cn-checklist-run__empty {
1267
+ color: var(--color-text-maxcontrast);
1268
+ }
1269
+ </style>