@conduction/nextcloud-vue 2.79.0 → 2.80.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 (197) hide show
  1. package/dist/esm/components/CnActionButtons/CnActionButtons.vue2.js.map +1 -1
  2. package/dist/esm/components/CnActionButtons/CnActionButtons.vue3.js +3 -1
  3. package/dist/esm/components/CnActionButtons/CnActionButtons.vue3.js.map +1 -1
  4. package/dist/esm/components/CnActionsBar/CnActionsBar.vue2.js +32 -3
  5. package/dist/esm/components/CnActionsBar/CnActionsBar.vue2.js.map +1 -1
  6. package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js +10 -8
  7. package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js.map +1 -1
  8. package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue2.js +39 -3
  9. package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue2.js.map +1 -1
  10. package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue3.js.map +1 -1
  11. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js +79 -1
  12. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js.map +1 -1
  13. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js +133 -96
  14. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js.map +1 -1
  15. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  16. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js +1 -0
  17. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  18. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +62 -4
  19. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  20. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  21. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js +24 -0
  22. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js.map +1 -1
  23. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js +43 -4
  24. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js.map +1 -1
  25. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue.js +11 -0
  26. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue.js.map +1 -0
  27. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js +736 -0
  28. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js.map +1 -0
  29. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js +238 -0
  30. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js.map +1 -0
  31. package/dist/esm/components/CnFactsWidget/index.js +30 -0
  32. package/dist/esm/components/CnFactsWidget/index.js.map +1 -0
  33. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue.js +10 -0
  34. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue.js.map +1 -0
  35. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue2.js +551 -0
  36. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue2.js.map +1 -0
  37. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue3.js +183 -0
  38. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue3.js.map +1 -0
  39. package/dist/esm/components/CnFieldListWidget/index.js +29 -0
  40. package/dist/esm/components/CnFieldListWidget/index.js.map +1 -0
  41. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +65 -17
  42. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
  43. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js +4 -4
  44. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
  45. package/dist/esm/components/CnFormPage/CnFormPage.vue2.js +18 -3
  46. package/dist/esm/components/CnFormPage/CnFormPage.vue2.js.map +1 -1
  47. package/dist/esm/components/CnFormPage/CnFormPage.vue3.js.map +1 -1
  48. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +305 -72
  49. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  50. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +31 -13
  51. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  52. package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue.js +10 -0
  53. package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue.js.map +1 -0
  54. package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue2.js +99 -0
  55. package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue2.js.map +1 -0
  56. package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue3.js +48 -0
  57. package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue3.js.map +1 -0
  58. package/dist/esm/components/CnPageRenderer/CnPageRenderer.vue2.js +29 -1
  59. package/dist/esm/components/CnPageRenderer/CnPageRenderer.vue2.js.map +1 -1
  60. package/dist/esm/components/CnPageRenderer/CnPageRenderer.vue3.js.map +1 -1
  61. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue.js +10 -0
  62. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue.js.map +1 -0
  63. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js +387 -0
  64. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js.map +1 -0
  65. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js +169 -0
  66. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js.map +1 -0
  67. package/dist/esm/components/CnRecordListWidget/index.js +31 -0
  68. package/dist/esm/components/CnRecordListWidget/index.js.map +1 -0
  69. package/dist/esm/components/CnSectionsWidget/CnSectionsWidget.vue.js +10 -0
  70. package/dist/esm/components/CnSectionsWidget/CnSectionsWidget.vue.js.map +1 -0
  71. package/dist/esm/components/CnSectionsWidget/CnSectionsWidget.vue2.js +351 -0
  72. package/dist/esm/components/CnSectionsWidget/CnSectionsWidget.vue2.js.map +1 -0
  73. package/dist/esm/components/CnSectionsWidget/CnSectionsWidget.vue3.js +71 -0
  74. package/dist/esm/components/CnSectionsWidget/CnSectionsWidget.vue3.js.map +1 -0
  75. package/dist/esm/components/CnSectionsWidget/index.js +35 -0
  76. package/dist/esm/components/CnSectionsWidget/index.js.map +1 -0
  77. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js +81 -2
  78. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js.map +1 -1
  79. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js.map +1 -1
  80. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js.map +1 -1
  81. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js +1 -0
  82. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js.map +1 -1
  83. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +122 -7
  84. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
  85. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js +84 -23
  86. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
  87. package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js +4 -0
  88. package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js.map +1 -1
  89. package/dist/esm/components/CnWorkspaceFilterWidget/CnWorkspaceFilterWidget.vue2.js +106 -2
  90. package/dist/esm/components/CnWorkspaceFilterWidget/CnWorkspaceFilterWidget.vue2.js.map +1 -1
  91. package/dist/esm/components/CnWorkspaceFilterWidget/CnWorkspaceFilterWidget.vue3.js +105 -53
  92. package/dist/esm/components/CnWorkspaceFilterWidget/CnWorkspaceFilterWidget.vue3.js.map +1 -1
  93. package/dist/esm/composables/useFormDraft.js +140 -9
  94. package/dist/esm/composables/useFormDraft.js.map +1 -1
  95. package/dist/esm/index.js +4 -0
  96. package/dist/esm/index.js.map +1 -1
  97. package/dist/esm/integrations/builtin/field-inspection/CnFieldInspectionCard.vue2.js +28 -20
  98. package/dist/esm/integrations/builtin/field-inspection/CnFieldInspectionCard.vue2.js.map +1 -1
  99. package/dist/esm/integrations/builtin/field-inspection/CnFieldInspectionCard.vue3.js +9 -0
  100. package/dist/esm/integrations/builtin/field-inspection/CnFieldInspectionCard.vue3.js.map +1 -1
  101. package/dist/esm/integrations/builtin/field-inspection.js +21 -0
  102. package/dist/esm/integrations/builtin/field-inspection.js.map +1 -1
  103. package/dist/esm/integrations/offline/checklistSubmission.js +141 -0
  104. package/dist/esm/integrations/offline/checklistSubmission.js.map +1 -0
  105. package/dist/esm/integrations/offline/fieldCollectionHelpers.js +70 -5
  106. package/dist/esm/integrations/offline/fieldCollectionHelpers.js.map +1 -1
  107. package/dist/esm/integrations/offline/index.js +2 -1
  108. package/dist/esm/integrations/offline/index.js.map +1 -1
  109. package/dist/esm/integrations/offline/offlineDb.js +6 -1
  110. package/dist/esm/integrations/offline/offlineDb.js.map +1 -1
  111. package/dist/esm/integrations/offline/syncReplayService.js +8 -2
  112. package/dist/esm/integrations/offline/syncReplayService.js.map +1 -1
  113. package/dist/esm/l10n/en.json.js +12 -1
  114. package/dist/esm/l10n/en.json.js.map +1 -1
  115. package/dist/esm/l10n/nl.json.js +16 -2
  116. package/dist/esm/l10n/nl.json.js.map +1 -1
  117. package/dist/esm/nextcloud-vue.css +1000 -59
  118. package/dist/esm/utils/detailActionModel.js +60 -3
  119. package/dist/esm/utils/detailActionModel.js.map +1 -1
  120. package/dist/esm/utils/fillTemplate.js +114 -0
  121. package/dist/esm/utils/fillTemplate.js.map +1 -0
  122. package/dist/esm/utils/libraryWidgetKeys.js +4 -0
  123. package/dist/esm/utils/libraryWidgetKeys.js.map +1 -1
  124. package/dist/esm/utils/queryPresets.js +96 -0
  125. package/dist/esm/utils/queryPresets.js.map +1 -0
  126. package/dist/esm/utils/resolveFilterTokens.js +11 -0
  127. package/dist/esm/utils/resolveFilterTokens.js.map +1 -1
  128. package/dist/esm/utils/schema.js +21 -0
  129. package/dist/esm/utils/schema.js.map +1 -1
  130. package/dist/esm/utils/sentinelTokens.js +3 -3
  131. package/dist/esm/utils/sentinelTokens.js.map +1 -1
  132. package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
  133. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  134. package/dist/esm/utils/widgetDispatch.js +7 -1
  135. package/dist/esm/utils/widgetDispatch.js.map +1 -1
  136. package/dist/nextcloud-vue.cjs.js +12368 -7717
  137. package/dist/nextcloud-vue.cjs.js.map +2 -2
  138. package/dist/nextcloud-vue.css +1000 -59
  139. package/l10n/en.json +12 -1
  140. package/l10n/nl.json +16 -2
  141. package/package.json +1 -1
  142. package/src/components/CnActionButtons/CnActionButtons.vue +2 -0
  143. package/src/components/CnActionsBar/CnActionsBar.md +4 -0
  144. package/src/components/CnActionsBar/CnActionsBar.vue +42 -10
  145. package/src/components/CnBannerWidget/CnBannerWidget.vue +39 -3
  146. package/src/components/CnCellRenderer/CnCellRenderer.vue +188 -1
  147. package/src/components/CnDataTable/CnDataTable.vue +1 -0
  148. package/src/components/CnDetailPage/CnDetailPage.md +1 -1
  149. package/src/components/CnDetailPage/CnDetailPage.vue +63 -3
  150. package/src/components/CnDetailWidgetHost/CnDetailWidgetHost.vue +66 -0
  151. package/src/components/CnFactsWidget/CnFactsWidget.vue +1050 -0
  152. package/src/components/CnFactsWidget/index.js +29 -0
  153. package/src/components/CnFieldListWidget/CnFieldListWidget.vue +746 -0
  154. package/src/components/CnFieldListWidget/index.js +28 -0
  155. package/src/components/CnFormDialog/CnFormDialog.md +9 -3
  156. package/src/components/CnFormDialog/CnFormDialog.vue +69 -21
  157. package/src/components/CnFormPage/CnFormPage.vue +18 -3
  158. package/src/components/CnIndexPage/CnIndexPage.vue +254 -6
  159. package/src/components/CnIndexPage/CnIndexPageWidgets.vue +169 -0
  160. package/src/components/CnPageRenderer/CnPageRenderer.vue +29 -1
  161. package/src/components/CnRecordListWidget/CnRecordListWidget.vue +603 -0
  162. package/src/components/CnRecordListWidget/index.js +30 -0
  163. package/src/components/CnSectionsWidget/CnSectionsWidget.vue +489 -0
  164. package/src/components/CnSectionsWidget/index.js +34 -0
  165. package/src/components/CnStagesWidget/CnStagesWidget.vue +81 -2
  166. package/src/components/CnStatWidget/CnStatWidget.vue +1 -1
  167. package/src/components/CnTabsWidget/CnTabsWidget.vue +184 -8
  168. package/src/components/CnWidgetGrid/registerDashboardWidgets.js +4 -0
  169. package/src/components/CnWorkspaceFilterWidget/CnWorkspaceFilterWidget.vue +188 -4
  170. package/src/components/index.js +4 -0
  171. package/src/composables/__tests__/useFormDraft.spec.js +69 -0
  172. package/src/composables/useFormDraft.js +139 -8
  173. package/src/css/form-field.css +14 -0
  174. package/src/css/index-page.css +71 -0
  175. package/src/css/kpi-card.css +32 -0
  176. package/src/css/look-board-detail.css +77 -0
  177. package/src/css/look-board-index.css +51 -0
  178. package/src/css/look-board.css +37 -2
  179. package/src/css/table.css +19 -0
  180. package/src/index.js +4 -0
  181. package/src/integrations/builtin/field-inspection/CnFieldInspectionCard.vue +35 -20
  182. package/src/integrations/builtin/field-inspection.js +21 -0
  183. package/src/integrations/offline/checklistSubmission.js +137 -0
  184. package/src/integrations/offline/fieldCollectionHelpers.js +69 -4
  185. package/src/integrations/offline/index.js +3 -0
  186. package/src/integrations/offline/offlineDb.js +6 -1
  187. package/src/integrations/offline/syncReplayService.js +8 -2
  188. package/src/schemas/app-manifest-v2.schema.json +104 -5
  189. package/src/utils/detailActionModel.js +59 -2
  190. package/src/utils/fillTemplate.js +110 -0
  191. package/src/utils/libraryWidgetKeys.js +4 -0
  192. package/src/utils/queryPresets.js +93 -0
  193. package/src/utils/resolveFilterTokens.js +11 -0
  194. package/src/utils/schema.js +21 -0
  195. package/src/utils/sentinelTokens.js +3 -3
  196. package/src/utils/validateManifestV2.compiled.js +3 -3
  197. package/src/utils/widgetDispatch.js +7 -1
@@ -134,6 +134,13 @@
134
134
  (an invalid `ButtonType`, so the visual primary style never applied
135
135
  either). See CnFormPage.vue's inline comment for the submit-button
136
136
  half of this bug in detail. -->
137
+ <p v-if="saveError"
138
+ class="cn-field-inspection__error"
139
+ role="alert"
140
+ data-testid="cn-fi-save-error">
141
+ {{ saveError }}
142
+ </p>
143
+
137
144
  <NcButton type="submit"
138
145
  variant="primary"
139
146
  data-testid="cn-fi-save"
@@ -157,7 +164,8 @@ import { NcButton, NcEmptyContent, NcLoadingIcon } from '@nextcloud/vue'
157
164
  import ClipboardCheckOutline from 'vue-material-design-icons/ClipboardCheckOutline.vue'
158
165
  import CnDetailCard from '../../../components/CnDetailCard/CnDetailCard.vue'
159
166
  import CnOfflineQueue from '../../../components/CnOfflineQueue/index.js'
160
- import { checklistProgress, classifyGps, syncIndicator, validateChecklistAnswers } from '../../offline/fieldCollectionHelpers.js'
167
+ import { buildChecklistSubmission } from '../../offline/checklistSubmission.js'
168
+ import { checklistProgress, classifyGps, normaliseChecklistTemplate, syncIndicator, validateChecklistAnswers } from '../../offline/fieldCollectionHelpers.js'
161
169
  import {
162
170
  countPending,
163
171
  countStuck,
@@ -236,6 +244,8 @@ export default {
236
244
  activeTemplate: null,
237
245
  answers: {},
238
246
  errors: [],
247
+ /** Why the last save could not be queued, in words. */
248
+ saveError: '',
239
249
  }
240
250
  },
241
251
 
@@ -449,8 +459,13 @@ export default {
449
459
  this.errors = []
450
460
  const cfg = this.config
451
461
  const templateRef = item?.[cfg.templateRefField]
462
+ // The app's template shape (sections, its own item keys and type
463
+ // words) is read into the leaf's flat item list here, once.
452
464
  this.activeTemplate = templateRef
453
- ? await getCachedObject(this.effectiveRegister, cfg.referenceSchema, 'references', String(templateRef))
465
+ ? normaliseChecklistTemplate(
466
+ await getCachedObject(this.effectiveRegister, cfg.referenceSchema, 'references', String(templateRef)),
467
+ cfg,
468
+ )
454
469
  : null
455
470
  const initial = {}
456
471
  for (const q of (this.activeTemplate?.items || [])) {
@@ -464,6 +479,7 @@ export default {
464
479
  this.activeTemplate = null
465
480
  this.answers = {}
466
481
  this.errors = []
482
+ this.saveError = ''
467
483
  },
468
484
 
469
485
  /**
@@ -485,7 +501,9 @@ export default {
485
501
  },
486
502
 
487
503
  /**
488
- * Validate answers, then queue a `create` mutation for the result.
504
+ * Validate answers, then queue the run: an object create on
505
+ * `resultSchema`, or, when the config names a `resultEndpoint`, one
506
+ * `submit` to that endpoint.
489
507
  *
490
508
  * @return {Promise<void>}
491
509
  */
@@ -499,26 +517,22 @@ export default {
499
517
  try {
500
518
  const cfg = this.config
501
519
  const gps = await this.captureGps()
502
- const gpsClass = classifyGps(gps)
503
- const now = new Date().toISOString()
504
- const items = this.activeTemplate.items.map((q) => ({
505
- questionId: q.questionId,
506
- answer: this.answers[q.questionId].answer,
507
- evidenceRefs: this.answers[q.questionId].evidenceRefs,
508
- answeredAt: now,
509
- gpsAtAnswer: gps ? { ...gps, source: gpsClass.source } : { source: 'sensorless' },
510
- }))
511
- const payload = {
512
- inspectionRef: this.itemId(this.activeItem),
513
- checklistTemplateRef: this.activeTemplate.id ?? this.activeTemplate['@self']?.id,
514
- items,
515
- }
520
+ const submission = buildChecklistSubmission(cfg, {
521
+ plannedItem: this.activeItem,
522
+ template: this.activeTemplate,
523
+ answers: this.answers,
524
+ gps,
525
+ gpsSource: classifyGps(gps).source,
526
+ capturedAt: new Date().toISOString(),
527
+ capturedOffline: this.offline,
528
+ })
516
529
  await enqueueMutation({
517
530
  deviceId: this.deviceId,
518
- operationType: 'create',
531
+ operationType: submission.operationType,
519
532
  register: this.effectiveRegister,
520
- schema: cfg.resultSchema,
521
- payload,
533
+ schema: submission.schema,
534
+ endpoint: submission.endpoint,
535
+ payload: submission.payload,
522
536
  })
523
537
  this.pendingCount = await countPending(this.deviceId)
524
538
  this.stuckCount = await countStuck(this.deviceId)
@@ -527,6 +541,7 @@ export default {
527
541
  await this.drain()
528
542
  }
529
543
  } catch (e) {
544
+ this.saveError = e?.message || t('nextcloud-vue', 'The answers could not be saved on this device.')
530
545
  // eslint-disable-next-line no-console
531
546
  console.error('[CnFieldInspectionCard] saveChecklist failed', e)
532
547
  } finally {
@@ -56,6 +56,27 @@ export const DEFAULT_FIELD_INSPECTION_CONFIG = {
56
56
  // Schema holding conflict records. Empty keeps a conflict on the device,
57
57
  // and the queue row says so rather than pretending it was filed.
58
58
  conflictSchema: '',
59
+ // How a template's items are read (normaliseChecklistTemplate). Empty
60
+ // `sectionsField` reads a flat `items[]`; set it when the items sit inside
61
+ // ordered sections, each with its own `items[]`.
62
+ sectionsField: '',
63
+ itemKeyField: 'questionId',
64
+ itemTextField: 'text',
65
+ itemTypeField: 'type',
66
+ // The app's own item type words onto the leaf's (`yes_no`,
67
+ // `photo_required`; anything else renders as free text).
68
+ itemTypeMap: {},
69
+ // An item whose `photoRequiredField` equals `photoRequiredValue` needs a
70
+ // photo as well as an answer. Empty switches the gate off.
71
+ photoRequiredField: '',
72
+ photoRequiredValue: '',
73
+ // Where a finished run goes (checklistSubmission). Empty queues an object
74
+ // create on `resultSchema`. Set, it is a path under the Nextcloud root with
75
+ // `{field}` placeholders from the planned item, and the run replays as one
76
+ // POST there, naming the template and the planned item under these keys.
77
+ resultEndpoint: '',
78
+ resultTemplateParam: 'templateId',
79
+ resultPlannedItemParam: 'plannedItem',
59
80
  }
60
81
 
61
82
  /**
@@ -0,0 +1,137 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V. <info@conduction.nl>
3
+ * SPDX-License-Identifier: EUPL-1.2
4
+ *
5
+ * What a finished offline checklist run turns into in the queue.
6
+ *
7
+ * Two shapes, chosen by the leaf's `offlineConfig`:
8
+ *
9
+ * - `resultEndpoint` empty: an object create on `resultSchema`, the shape the
10
+ * leaf has always queued (`inspectionRef`, `checklistTemplateRef`,
11
+ * `items[]` with `questionId` / `answer` / `evidenceRefs`).
12
+ * - `resultEndpoint` set: a `submit` operation that replays the run as ONE
13
+ * POST to an app's own endpoint, for an app that stores a run as something
14
+ * other than a plain object (a completed task, say) and checks the run
15
+ * server side before it stores anything. The endpoint is a path under the
16
+ * Nextcloud root with `{field}` placeholders filled from the planned item,
17
+ * e.g. `/apps/myapp/api/cases/{caseRef}/checklist-run`.
18
+ *
19
+ * Pure: no axios, no IndexedDB, no DOM. The card queues what this returns and
20
+ * the replay service sends it.
21
+ *
22
+ * @module integrations/offline/checklistSubmission
23
+ */
24
+
25
+ import { translate as t } from '@nextcloud/l10n'
26
+
27
+ /**
28
+ * Fill a `{field}` endpoint template from a planned item.
29
+ *
30
+ * Each value is URL-encoded, so a planned item can never steer the request to
31
+ * another path. A placeholder the item does not fill is an error rather than
32
+ * an empty path segment: a run sent to the wrong case is worse than one that
33
+ * waits.
34
+ *
35
+ * @param {string} template The endpoint template.
36
+ * @param {object} plannedItem The planned item the run belongs to.
37
+ *
38
+ * @return {string} The filled path.
39
+ *
40
+ * @throws {Error} When a placeholder has no value on the item.
41
+ */
42
+ export function fillEndpoint(template, plannedItem) {
43
+ return String(template).replace(/\{([A-Za-z0-9_.]+)\}/g, (match, field) => {
44
+ const value = field.split('.').reduce((node, key) => (node === null || node === undefined ? undefined : node[key]), plannedItem)
45
+ if (value === undefined || value === null || String(value) === '') {
46
+ throw new Error(t('nextcloud-vue', 'This planned item has no {field}, so the answers have nowhere to go.', { field }))
47
+ }
48
+ return encodeURIComponent(String(value))
49
+ })
50
+ }
51
+
52
+ /**
53
+ * The id of an object, wherever OpenRegister carries it.
54
+ *
55
+ * @param {object|null} object The object.
56
+ *
57
+ * @return {string} The id, or an empty string.
58
+ */
59
+ function idOf(object) {
60
+ return String(object?.id ?? object?.['@self']?.id ?? object?.uuid ?? '')
61
+ }
62
+
63
+ /**
64
+ * Build the queue operation for a finished checklist run.
65
+ *
66
+ * @param {object} config The leaf's offline config.
67
+ * @param {object} run The run.
68
+ * @param {object} run.plannedItem The planned item.
69
+ * @param {object} run.template The normalised template (`items[]`).
70
+ * @param {object} run.answers Map of questionId to `{ answer, evidenceRefs }`.
71
+ * @param {object|null} [run.gps] The GPS fix at save, or null.
72
+ * @param {string} [run.gpsSource] `sensor` or `sensorless`.
73
+ * @param {string} [run.capturedAt] ISO time of the save.
74
+ * @param {boolean} [run.capturedOffline] Whether the device was offline at save.
75
+ *
76
+ * @return {{ operationType: string, schema: string, endpoint: (string|null), payload: object }}
77
+ * The operation fields for `enqueueMutation`.
78
+ *
79
+ * @throws {Error} When the endpoint cannot be filled from the planned item.
80
+ */
81
+ export function buildChecklistSubmission(config, run) {
82
+ const capturedAt = run.capturedAt || new Date().toISOString()
83
+ const gpsAtAnswer = run.gps ? { ...run.gps, source: run.gpsSource || 'sensor' } : { source: 'sensorless' }
84
+ const items = Array.isArray(run.template?.items) ? run.template.items : []
85
+ const answers = run.answers ?? {}
86
+ const plannedItemId = idOf(run.plannedItem)
87
+ const templateId = idOf(run.template)
88
+
89
+ if (!config.resultEndpoint) {
90
+ return {
91
+ operationType: 'create',
92
+ schema: config.resultSchema,
93
+ endpoint: null,
94
+ payload: {
95
+ inspectionRef: plannedItemId,
96
+ checklistTemplateRef: templateId,
97
+ items: items.map((q) => ({
98
+ questionId: q.questionId,
99
+ answer: answers[q.questionId]?.answer,
100
+ evidenceRefs: answers[q.questionId]?.evidenceRefs ?? [],
101
+ answeredAt: capturedAt,
102
+ gpsAtAnswer,
103
+ })),
104
+ },
105
+ }
106
+ }
107
+
108
+ const payload = {
109
+ [config.resultTemplateParam || 'templateId']: templateId,
110
+ [config.resultPlannedItemParam || 'plannedItem']: plannedItemId,
111
+ capturedAt,
112
+ capturedOffline: run.capturedOffline === true,
113
+ items: items
114
+ .filter((q) => {
115
+ const entry = answers[q.questionId] ?? {}
116
+ const hasAnswer = entry.answer !== undefined && entry.answer !== null && String(entry.answer).trim() !== ''
117
+ return hasAnswer || (Array.isArray(entry.evidenceRefs) && entry.evidenceRefs.length > 0)
118
+ })
119
+ .map((q) => ({
120
+ itemId: q.questionId,
121
+ value: answers[q.questionId]?.answer ?? '',
122
+ photos: answers[q.questionId]?.evidenceRefs ?? [],
123
+ answeredAt: capturedAt,
124
+ gpsAtAnswer,
125
+ })),
126
+ }
127
+ if (run.gps) {
128
+ payload.location = { ...run.gps, source: run.gpsSource || 'sensor' }
129
+ }
130
+
131
+ return {
132
+ operationType: 'submit',
133
+ schema: config.resultSchema || '',
134
+ endpoint: fillEndpoint(config.resultEndpoint, run.plannedItem),
135
+ payload,
136
+ }
137
+ }
@@ -46,11 +46,71 @@ export function classifyGps(fix, available = true) {
46
46
  return { quality: 'good', source: 'sensor', warning: null }
47
47
  }
48
48
 
49
+ /**
50
+ * Read a checklist template into the leaf's flat item contract.
51
+ *
52
+ * A consuming app's template does not have to carry `items[]` with
53
+ * `questionId` / `text` / `type`. Its `offlineConfig` names where those live:
54
+ * `sectionsField` when the items sit inside ordered sections, and the
55
+ * `item*Field` keys for the item's key, text and type. `itemTypeMap` translates
56
+ * the app's own type words onto the leaf's (`yes_no`, `photo_required`, or any
57
+ * other word, which renders as free text). `photoRequiredField` and
58
+ * `photoRequiredValue` mark an item whose answer also needs a photo.
59
+ *
60
+ * The template's own keys are kept, so the id and version stay readable; only
61
+ * `items` is replaced by the normalised list.
62
+ *
63
+ * @param {object|null} template The template as cached.
64
+ * @param {object} [config] The leaf's offline config.
65
+ *
66
+ * @return {object|null} The template with flat `items[]`, or null.
67
+ */
68
+ export function normaliseChecklistTemplate(template, config = {}) {
69
+ if (template === null || typeof template !== 'object') {
70
+ return null
71
+ }
72
+
73
+ const keyField = config.itemKeyField || 'questionId'
74
+ const textField = config.itemTextField || 'text'
75
+ const typeField = config.itemTypeField || 'type'
76
+ const typeMap = (config.itemTypeMap && typeof config.itemTypeMap === 'object') ? config.itemTypeMap : {}
77
+
78
+ const sections = config.sectionsField ? template[config.sectionsField] : null
79
+ const raw = Array.isArray(sections)
80
+ ? sections.flatMap((section) => (Array.isArray(section?.items) ? section.items : []))
81
+ : (Array.isArray(template.items) ? template.items : [])
82
+
83
+ const items = []
84
+ for (const item of raw) {
85
+ if (item === null || typeof item !== 'object') {
86
+ continue
87
+ }
88
+ const questionId = String(item[keyField] ?? '')
89
+ if (questionId === '') {
90
+ continue
91
+ }
92
+ const ownType = item[typeField]
93
+ const photoRequired = Boolean(config.photoRequiredField)
94
+ && item[config.photoRequiredField] === config.photoRequiredValue
95
+ items.push({
96
+ ...item,
97
+ questionId,
98
+ text: item[textField] ?? item.text ?? questionId,
99
+ type: typeMap[ownType] ?? ownType ?? 'text',
100
+ required: item.required === true,
101
+ photoRequired,
102
+ })
103
+ }
104
+
105
+ return { ...template, items }
106
+ }
107
+
49
108
  /**
50
109
  * Validate a set of checklist answers against the template's required items.
51
110
  *
52
- * A `required` item must have a non-empty answer; a `photo_required` item must
53
- * additionally have at least one evidence reference. Returns the per-question
111
+ * A `required` item must have a non-empty answer; a `photo_required` item, and
112
+ * any item flagged `photoRequired` by {@link normaliseChecklistTemplate}, must
113
+ * have at least one evidence reference. Returns the per-question
54
114
  * blocking errors so the UI can prevent save (and the engine never queues an
55
115
  * invalid result).
56
116
  *
@@ -66,7 +126,7 @@ export function validateChecklistAnswers(template, answersByQuestion) {
66
126
  const errors = []
67
127
 
68
128
  for (const item of items) {
69
- if (item?.required !== true) {
129
+ if (item?.required !== true && item?.photoRequired !== true) {
70
130
  continue
71
131
  }
72
132
  const entry = answers[item.questionId] ?? {}
@@ -80,7 +140,12 @@ export function validateChecklistAnswers(template, answersByQuestion) {
80
140
  continue
81
141
  }
82
142
 
83
- if (answer === undefined || answer === null || String(answer).trim() === '') {
143
+ if (item.photoRequired === true && evidenceRefs.length === 0) {
144
+ errors.push({ questionId: item.questionId, message: t('nextcloud-vue', 'Photo required for this question') })
145
+ continue
146
+ }
147
+
148
+ if (item.required === true && (answer === undefined || answer === null || String(answer).trim() === '')) {
84
149
  errors.push({ questionId: item.questionId, message: t('nextcloud-vue', 'This question is required') })
85
150
  }
86
151
  }
@@ -60,10 +60,13 @@ export {
60
60
  checklistProgress,
61
61
  classifyGps,
62
62
  GPS_POOR_ACCURACY_M,
63
+ normaliseChecklistTemplate,
63
64
  syncIndicator,
64
65
  validateChecklistAnswers,
65
66
  } from './fieldCollectionHelpers.js'
66
67
 
68
+ export { buildChecklistSubmission, fillEndpoint } from './checklistSubmission.js'
69
+
67
70
  export {
68
71
  CACHE_PREFIX,
69
72
  cacheNameFor,
@@ -257,10 +257,12 @@ export async function getPlanningMeta(register, schema, collection = 'planning')
257
257
  *
258
258
  * @param {object} operation The mutation operation.
259
259
  * @param {string} operation.deviceId Owning device id (IDOR scope).
260
- * @param {string} operation.operationType create / update / delete.
260
+ * @param {string} operation.operationType create / update / delete / submit.
261
261
  * @param {string} operation.register Target register slug or id.
262
262
  * @param {string} operation.schema Target schema slug or id.
263
263
  * @param {string} [operation.targetId] Existing object id (update/delete).
264
+ * @param {string} [operation.endpoint] Path under the Nextcloud root a
265
+ * `submit` row is POSTed to.
264
266
  * @param {object} [operation.payload] The object body (create/update).
265
267
  *
266
268
  * @return {Promise<string>} The queued operation id.
@@ -275,6 +277,9 @@ export async function enqueueMutation(operation) {
275
277
  register: operation.register,
276
278
  schema: operation.schema,
277
279
  targetId: operation.targetId ?? null,
280
+ // A `submit` operation replays to an app's own endpoint instead of the
281
+ // object API (see checklistSubmission.js); every other row has none.
282
+ endpoint: operation.endpoint ?? null,
278
283
  payload: operation.payload ?? {},
279
284
  queuedAt: operation.queuedAt || new Date().toISOString(),
280
285
  attemptCount: 0,
@@ -35,7 +35,8 @@ function objectsUrl(register, schema) {
35
35
  }
36
36
 
37
37
  /**
38
- * Replay one queued mutation against the OR object API.
38
+ * Replay one queued mutation against the OR object API, or, for a row that
39
+ * names an `endpoint`, as one POST to that endpoint.
39
40
  *
40
41
  * @param {object} operation The queue operation row.
41
42
  * @param {object} [offlineConfig] The leaf's offline config (`register`,
@@ -54,7 +55,12 @@ export async function replayOperation(operation, offlineConfig = {}) {
54
55
 
55
56
  try {
56
57
  let response
57
- if (operation.operationType === 'create' || operation.operationType === 'upload') {
58
+ if (typeof operation.endpoint === 'string' && operation.endpoint !== '') {
59
+ // A run the app checks and stores itself (a completed task, say):
60
+ // one POST to its endpoint under the Nextcloud root. The endpoint
61
+ // decides access from what it has stored, as the object API does.
62
+ response = await axios.post(generateUrl(operation.endpoint), payload)
63
+ } else if (operation.operationType === 'create' || operation.operationType === 'upload') {
58
64
  response = await axios.post(base, payload)
59
65
  } else if (operation.operationType === 'update') {
60
66
  response = await axios.put(`${base}/${operation.targetId}`, payload)
@@ -3,7 +3,7 @@
3
3
  "$id": "https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest-v2.schema.json",
4
4
  "title": "Conduction App Manifest v2",
5
5
  "description": "v2 schema for the JSON-driven page and navigation manifest consumed by @conduction/nextcloud-vue. Introduces a uniform widgets[] array on every page type with a per-slot grid coordinate system, a typed actions[] discriminator, and the required $schema field for version detection. v1 manifests continue to validate against app-manifest.schema.json (unchanged).",
6
- "version": "2.79.0",
6
+ "version": "2.85.0",
7
7
  "type": "object",
8
8
  "required": ["$schema", "version"],
9
9
  "allOf": [
@@ -760,8 +760,8 @@
760
760
  },
761
761
  "sentinelFilterToken": {
762
762
  "type": "string",
763
- "pattern": "^@(?:me(?:\\.(?:displayName|email))?|myGroups|now|today|monthStart|quarterStart|yearStart)$|^@today[+-][0-9]+d$",
764
- "description": "Filter-value context: relative fetch-time tokens resolved by resolveFilterTokens (@me, @me.displayName, @me.email, @myGroups (the current user's group ids, an IN list), @now, @today, @today±Nd, @monthStart, @quarterStart, @yearStart). Mirrors SENTINEL_TOKEN_PATTERNS.filter in src/utils/sentinelTokens.js (byte-equality enforced by a unit test)."
763
+ "pattern": "^@(?:me(?:\\.(?:displayName|email))?|myGroups|now|today|monthStart|monthEnd|nextMonthStart|quarterStart|yearStart)$|^@today[+-][0-9]+d$",
764
+ "description": "Filter-value context: relative fetch-time tokens resolved by resolveFilterTokens (@me, @me.displayName, @me.email, @myGroups (the current user's group ids, an IN list), @now, @today, @today±Nd, @monthStart, @monthEnd, @nextMonthStart, @quarterStart, @yearStart). Mirrors SENTINEL_TOKEN_PATTERNS.filter in src/utils/sentinelTokens.js (byte-equality enforced by a unit test)."
765
765
  },
766
766
  "sentinelConfigToken": {
767
767
  "type": "string",
@@ -1132,14 +1132,43 @@
1132
1132
  "headerPill": {
1133
1133
  "type": "object",
1134
1134
  "additionalProperties": false,
1135
- "required": ["field"],
1136
- "description": "A pill in a detail page header, rendered through CnStatusBadge from one field of the record. Nothing renders while the field is empty.",
1135
+ "anyOf": [
1136
+ { "required": ["field"] },
1137
+ { "required": ["text"] },
1138
+ { "required": ["countFrom"] }
1139
+ ],
1140
+ "description": "A pill in a detail page header, rendered through CnStatusBadge: from one field of the record (`field`; nothing renders while it is empty), a constant (`text`), or a count of a related list (`countFrom` with `countLabel`; nothing renders at 0, while counting or after a failed count). screens-relation-pages, 2.85.0.",
1137
1141
  "properties": {
1138
1142
  "field": {
1139
1143
  "type": "string",
1140
1144
  "minLength": 1,
1141
1145
  "description": "Dot-path to the value on the record."
1142
1146
  },
1147
+ "text": {
1148
+ "type": "string",
1149
+ "minLength": 1,
1150
+ "description": "A constant label (an i18n key), such as \"Inwoner\" or \"Klant\". Used when `field` is not set."
1151
+ },
1152
+ "countFrom": {
1153
+ "type": "object",
1154
+ "additionalProperties": false,
1155
+ "required": ["register", "schema"],
1156
+ "description": "The list the pill counts with one list request (`_limit=1`, the response's `total`). `filter` values take the `@objectId` and `@object.<field>` tokens. Used when `field` is not set.",
1157
+ "properties": {
1158
+ "register": { "type": "string", "minLength": 1 },
1159
+ "schema": { "type": "string", "minLength": 1 },
1160
+ "filter": { "type": "object", "additionalProperties": true }
1161
+ }
1162
+ },
1163
+ "countLabel": {
1164
+ "type": "object",
1165
+ "additionalProperties": false,
1166
+ "description": "The count pill's label for one and for more, i18n keys with `{count}`: `{ \"one\": \"{count} lopende zaak\", \"other\": \"{count} lopende zaken\" }`.",
1167
+ "properties": {
1168
+ "one": { "type": "string", "minLength": 1 },
1169
+ "other": { "type": "string", "minLength": 1 }
1170
+ }
1171
+ },
1143
1172
  "colorMap": {
1144
1173
  "type": "object",
1145
1174
  "description": "Raw value to badge variant. Keys are matched ignoring case.",
@@ -2582,6 +2611,14 @@
2582
2611
  "type": "string",
2583
2612
  "description": "open-form: overrides the create dialog's heading. Defaults to 'Create {schema title}'."
2584
2613
  },
2614
+ "formEyebrow": {
2615
+ "type": "string",
2616
+ "description": "open-form: the context line above the create dialog's heading (board look), e.g. the list the record joins. Through the host translate. Absent: no line."
2617
+ },
2618
+ "formSubtitle": {
2619
+ "type": "string",
2620
+ "description": "open-form: the sentence under the create dialog's heading (board look), e.g. which fields are required and what happens on save. Through the host translate. Absent: no sentence."
2621
+ },
2585
2622
  "size": {
2586
2623
  "type": "string",
2587
2624
  "enum": ["small", "normal", "large", "full"],
@@ -4072,6 +4109,36 @@
4072
4109
  "minLength": 1,
4073
4110
  "description": "type='index', board look: free text after the footer's count, e.g. \"click a column header to sort\" (screens-index-list-parity). Maps to CnIndexPage `footerNote`."
4074
4111
  },
4112
+ "queryPresets": {
4113
+ "type": "array",
4114
+ "minItems": 1,
4115
+ "description": "type='index': per-query overlays of this page (screens-index-query-presets). A menu entry that deep-links here with a query (`menu[].query`, e.g. { caseType: '<uuid>' }, the shape hydra gate-68 asks for instead of a second page) gets its own lenses, columns and copy: the first preset whose every `match` pair is in the route query replaces those keys of this config. Only the listed keys can be replaced; register, schema and permissions stay the page's. Switching preset remounts the list.",
4116
+ "items": {
4117
+ "type": "object",
4118
+ "required": ["match"],
4119
+ "additionalProperties": false,
4120
+ "properties": {
4121
+ "id": { "type": "string", "minLength": 1, "description": "A name for the preset, for people reading the manifest." },
4122
+ "match": { "type": "object", "minProperties": 1, "additionalProperties": { "type": ["string", "number", "boolean"] }, "description": "Route query pairs that must ALL be present, e.g. { \"caseType\": \"<uuid>\" }. A repeated parameter matches when it holds the value." },
4123
+ "title": { "type": "string", "minLength": 1, "description": "The page title while the preset applies." },
4124
+ "quickFilters": { "type": "array", "description": "The lenses while the preset applies; same shape as config.quickFilters." },
4125
+ "columns": { "type": "array", "description": "The columns while the preset applies; same shape as config.columns." },
4126
+ "cardFields": { "type": "array", "items": { "type": "string" }, "description": "The card facts while the preset applies." },
4127
+ "countText": { "type": "string", "minLength": 1, "description": "The count line while the preset applies." },
4128
+ "searchPlaceholder": { "type": "string", "minLength": 1, "description": "The search placeholder while the preset applies." },
4129
+ "footerNote": { "type": "string", "minLength": 1, "description": "The footer note while the preset applies." },
4130
+ "defaultSort": { "type": "array", "description": "The default sort while the preset applies; same shape as config.defaultSort." },
4131
+ "viewSwitch": { "type": "array", "items": { "type": "string", "enum": ["table", "cards", "board", "map"] }, "description": "The drawn view segments while the preset applies." }
4132
+ }
4133
+ }
4134
+ },
4135
+ "viewSwitch": {
4136
+ "type": "array",
4137
+ "minItems": 1,
4138
+ "uniqueItems": true,
4139
+ "items": { "type": "string", "enum": ["table", "cards", "board", "map"] },
4140
+ "description": "type='index', board look: the segments the view switch draws, e.g. [\"table\", \"cards\", \"board\", \"map\"] as the boards do. A listed mode the page can open is an ordinary segment; one it cannot (no board columns, no map) is drawn disabled and named \"<mode>: not available on this list\". Order is always table, cards, board, map. Ignored without the board look (screens-view-switch-parity). Maps to CnIndexPage `viewSwitch`."
4141
+ },
4075
4142
  "searchPlaceholder": {
4076
4143
  "type": "string",
4077
4144
  "minLength": 1,
@@ -4087,6 +4154,38 @@
4087
4154
  "description": "type='index': the property keys the cards view shows as the facts list of each card, in order; defaults to the first four list columns under the board look (screens-card-parity). Maps to CnIndexPage `cardFields`.",
4088
4155
  "items": { "type": "string", "minLength": 1 }
4089
4156
  },
4157
+ "statRow": {
4158
+ "type": "array",
4159
+ "default": [],
4160
+ "description": "type='index': KPI tiles above the list, between the header and the toolbar, in an auto-fit row (each at least 200px). Each entry is a widget definition, usually `type: \"stat\"`; the type resolves through the app's widget registry, then the dashboard catalog (screens-index-stat-row). Maps to CnIndexPage `statRow`.",
4161
+ "items": {
4162
+ "type": "object",
4163
+ "required": ["id", "type"],
4164
+ "properties": {
4165
+ "id": { "type": "string", "minLength": 1 },
4166
+ "type": { "type": "string", "minLength": 1 },
4167
+ "title": { "type": "string" },
4168
+ "content": { "type": "object" },
4169
+ "headerLink": { "type": "object" }
4170
+ }
4171
+ }
4172
+ },
4173
+ "sidePanel": {
4174
+ "type": "array",
4175
+ "default": [],
4176
+ "description": "type='index': titled cards in a 300px column right of the toolbar and the list (above them below 1024px). Each entry is a widget definition with an optional `title` (i18n key) and `headerLink`; the type resolves as for `statRow` (screens-index-stat-row). Maps to CnIndexPage `sidePanel`.",
4177
+ "items": {
4178
+ "type": "object",
4179
+ "required": ["id", "type"],
4180
+ "properties": {
4181
+ "id": { "type": "string", "minLength": 1 },
4182
+ "type": { "type": "string", "minLength": 1 },
4183
+ "title": { "type": "string" },
4184
+ "content": { "type": "object" },
4185
+ "headerLink": { "type": "object" }
4186
+ }
4187
+ }
4188
+ },
4090
4189
  "headerButtons": {
4091
4190
  "type": "array",
4092
4191
  "description": "type='index': buttons beside the page title, as the board's \"Export\" and \"New case\" (zuiddrecht-pixel-gaps-3). `action` is `add` (the Add flow; the label defaults to the Add label), `export` (the export leaf in `format`, else the export dialog; under the board look it reads \"Download\" by default), `actions-menu` (the page's header actions as one labelled menu button, absent when the page has none), `import`, `refresh`, or the id of a `headerActions` entry. Under the board look the buttons render in a fixed order whatever the manifest declares: export, actions-menu, other secondary buttons, the buildiq square, then the primary button. Shown only with `showTitle`; when they show, the actions bar drops its Views and Actions menus, and the Add button and Export menu when a button takes their action. Maps to CnIndexPage `headerButtons`.",