@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
@@ -80,10 +80,64 @@
80
80
  </span>
81
81
  </template>
82
82
 
83
+ <!-- A stack (`widgetIds`): every widget is a titled card; the
84
+ first joins the open tab, the next ones stand below it. -->
85
+ <div
86
+ v-if="entry.widget && entry.stack.length"
87
+ class="cn-tabs-widget__stack"
88
+ data-testid="cn-tabs-widget-stack">
89
+ <div class="cn-tabs-widget__stack-first">
90
+ <CnDetailWidgetHost
91
+ :widget="entry.widget"
92
+ chrome="card"
93
+ :showCardTitle="true"
94
+ :showActions="false"
95
+ :availableWidgets="availableWidgets"
96
+ :objectId="objectId"
97
+ :object="objectData"
98
+ :objectType="objectType"
99
+ :schemaObject="schemaObject"
100
+ :register="register"
101
+ :schema="schema"
102
+ :store="store"
103
+ :surface="surface"
104
+ :integrationContext="integrationContext"
105
+ :cnRegistry="cnRegistry"
106
+ @geoSaved="onGeoSaved"
107
+ @openIntegration="onOpenIntegration"
108
+ @selectObject="onSelectObject" />
109
+ </div>
110
+ <div
111
+ v-for="child in entry.stack"
112
+ :key="`${entry.key}-${child.id}`"
113
+ class="cn-tabs-widget__stack-item"
114
+ :data-testid="`cn-tabs-widget-stack-${child.id}`">
115
+ <CnDetailWidgetHost
116
+ :widget="child"
117
+ chrome="card"
118
+ :showCardTitle="true"
119
+ :showActions="false"
120
+ :availableWidgets="availableWidgets"
121
+ :objectId="objectId"
122
+ :object="objectData"
123
+ :objectType="objectType"
124
+ :schemaObject="schemaObject"
125
+ :register="register"
126
+ :schema="schema"
127
+ :store="store"
128
+ :surface="surface"
129
+ :integrationContext="integrationContext"
130
+ :cnRegistry="cnRegistry"
131
+ @geoSaved="onGeoSaved"
132
+ @openIntegration="onOpenIntegration"
133
+ @selectObject="onSelectObject" />
134
+ </div>
135
+ </div>
83
136
  <CnDetailWidgetHost
84
- v-if="entry.widget"
137
+ v-else-if="entry.widget"
85
138
  :widget="entry.widget"
86
139
  chrome="bare"
140
+ :availableWidgets="availableWidgets"
87
141
  :objectId="objectId"
88
142
  :object="objectData"
89
143
  :objectType="objectType"
@@ -114,6 +168,7 @@ import CnTab from '../CnTabs/CnTab.vue'
114
168
  import CnTabs from '../CnTabs/CnTabs.vue'
115
169
  import { normalizeLook } from '../../composables/useLook.js'
116
170
  import { resolveTabCount } from '../../utils/detailActionModel.js'
171
+ import { fetchListTotal } from '../../utils/fetchFilterCounts.js'
117
172
  import { isActivityWidget } from '../../utils/headerMeta.js'
118
173
  import { PANEL_ACTION_SINK } from '../../utils/panelActions.js'
119
174
  import { evaluateVisibleWhen, evaluateVisibleWhenLocal, isLocallyDecidableVisibleWhen } from '../../utils/visibleWhen.js'
@@ -268,7 +323,11 @@ export default {
268
323
  * `tabs[]` entries are `{ widgetId, label?, icon?, count?, countField?, overflow? }`.
269
324
  * `label` and `icon` fall back to the referenced widget's own title and
270
325
  * icon. `count` is a number shown after the label; `countField` reads
271
- * it off the record instead (a list counts its items). `overflow: true`
326
+ * it off the record instead (a list counts its items). `countFrom`
327
+ * counts a list on the server: `"widget"` counts the child widget's
328
+ * own list (`content.register`, `schema`, `filter`, tokens such as
329
+ * `@objectId` resolved), an object `{ register, schema, filter? }`
330
+ * names the list; `count` and `countField` win over it. `overflow: true`
272
331
  * lists the tab under "More".
273
332
  *
274
333
  * `maxVisibleTabs` caps the strip: later tabs go under "More".
@@ -276,7 +335,7 @@ export default {
276
335
  * `moreLabel` names that menu. `variant: "segmented"` draws the strip
277
336
  * as a pill switch (CnTabs' segmented variant); `line` is the default.
278
337
  *
279
- * @type {{ tabs?: Array<{widgetId: string, label?: string, icon?: string, count?: number, countField?: string, overflow?: boolean}>, ariaLabel?: string, maxVisibleTabs?: number, hideEmpty?: boolean, moreLabel?: string, variant?: ('line'|'segmented') }}
338
+ * @type {{ tabs?: Array<{widgetId?: string, widgetIds?: string[], label?: string, icon?: string, count?: number, countField?: string, countFrom?: ('widget'|{register: string, schema: string, filter?: object}), overflow?: boolean}>, ariaLabel?: string, maxVisibleTabs?: number, hideEmpty?: boolean, moreLabel?: string, variant?: ('line'|'segmented') }}
280
339
  */
281
340
  content: {
282
341
  type: Object,
@@ -396,6 +455,9 @@ export default {
396
455
  sourceVisibility: {},
397
456
  // Bumped per evaluation round, so a slow answer from an earlier round is dropped.
398
457
  visibilitySeq: 0,
458
+ // Totals counted for tabs with `countFrom`, by position in `content.tabs`.
459
+ widgetCounts: {},
460
+ countSeq: 0,
399
461
  // Tab id named by the route hash that has not appeared yet (still pending).
400
462
  hashTargetId: '',
401
463
  // Items published by the panels, keyed by widget id. Keyed rather
@@ -438,7 +500,7 @@ export default {
438
500
  // whatever its place in the manifest. `index` stays the position in
439
501
  // `content.tabs`, which the source-mode conditions are keyed on.
440
502
  const isActivityTab = (tab) => {
441
- const id = typeof tab === 'string' ? tab : tab?.widgetId
503
+ const id = this.tabWidgetIds(tab)[0]
442
504
  return isActivityWidget(this.availableWidgets.find((w) => w && w.id === id))
443
505
  }
444
506
  const ordered = authored.map((tab, index) => ({ tab, index }))
@@ -460,9 +522,19 @@ export default {
460
522
  if (visibility === 'hidden') {
461
523
  return
462
524
  }
463
- const widgetId = typeof tab === 'string' ? tab : tab?.widgetId
464
- const widget = this.availableWidgets.find((w) => w && w.id === widgetId) || null
465
- const count = resolveTabCount(typeof tab === 'object' ? tab : null, this.objectData)
525
+ // A stack (`widgetIds`) resolves every id; one that names no
526
+ // widget is skipped. The first that resolves is the panel's.
527
+ const ids = this.tabWidgetIds(tab)
528
+ const widgets = ids.length > 1
529
+ ? ids.map((id) => this.availableWidgets.find((w) => w && w.id === id) || null).filter(Boolean)
530
+ : []
531
+ const widget = ids.length > 1
532
+ ? (widgets[0] || null)
533
+ : (this.availableWidgets.find((w) => w && w.id === ids[0]) || null)
534
+ const widgetId = widget ? widget.id : ids[0]
535
+ const declared = resolveTabCount(typeof tab === 'object' ? tab : null, this.objectData)
536
+ const counted = this.widgetCounts[index]
537
+ const count = declared === null && typeof counted === 'number' ? counted : declared
466
538
  // The board strip has no "More": every tab is in the strip.
467
539
  let overflow = !this.isBoard && ((tab && tab.overflow === true) || (hideEmpty && count === 0))
468
540
  if (!overflow && !this.isBoard) {
@@ -479,6 +551,7 @@ export default {
479
551
  label: this.tabLabel(tab, widget, widgetId),
480
552
  icon: (tab && tab.icon) || widget?.icon || '',
481
553
  widget,
554
+ stack: widgets.slice(1),
482
555
  count,
483
556
  overflow,
484
557
  pending: visibility === 'pending',
@@ -497,6 +570,17 @@ export default {
497
570
  return t('nextcloud-vue', 'Loading …')
498
571
  },
499
572
 
573
+ /**
574
+ * The lists the tabs count (`countFrom`), serialised, to count again
575
+ * when they change.
576
+ *
577
+ * @return {string} The key.
578
+ */
579
+ countSourcesKey() {
580
+ const tabs = Array.isArray(this.content?.tabs) ? this.content.tabs : []
581
+ return JSON.stringify(tabs.map((tab) => this.countSourceOf(tab)))
582
+ },
583
+
500
584
  /** @return {string} The source-mode conditions, serialised, to re-evaluate when they change. */
501
585
  sourceConditionsKey() {
502
586
  const tabs = Array.isArray(this.content?.tabs) ? this.content.tabs : []
@@ -609,6 +693,12 @@ export default {
609
693
  objectId() {
610
694
  this.sourceVisibility = {}
611
695
  this.refreshSourceVisibility()
696
+ this.widgetCounts = {}
697
+ this.refreshWidgetCounts()
698
+ },
699
+
700
+ countSourcesKey() {
701
+ this.refreshWidgetCounts()
612
702
  },
613
703
 
614
704
  sourceConditionsKey() {
@@ -643,10 +733,14 @@ export default {
643
733
  }
644
734
  this.activeKey = this.resolvedTabs[this.activeIndex]?.key || ''
645
735
  this.refreshSourceVisibility()
736
+ this.refreshWidgetCounts()
646
737
  },
647
738
 
648
739
  mounted() {
649
- this._onRefresh = () => this.refreshSourceVisibility()
740
+ this._onRefresh = () => {
741
+ this.refreshSourceVisibility()
742
+ this.refreshWidgetCounts()
743
+ }
650
744
  subscribe('cn:page:refresh', this._onRefresh)
651
745
  subscribe('cn:widget:refresh', this._onRefresh)
652
746
  },
@@ -657,6 +751,27 @@ export default {
657
751
  },
658
752
 
659
753
  methods: {
754
+ /**
755
+ * The widget ids a tab names: `widgetIds` (a stack) when it holds at
756
+ * least one id, else `widgetId`, or the bare string.
757
+ *
758
+ * @param {object|string} tab The `content.tabs[]` entry.
759
+ * @return {string[]} The ids, at least one entry (possibly undefined).
760
+ * @spec openspec/changes/screens-relation-pages/specs/relation-pages/spec.md#requirement-a-tab-can-hold-a-stack-of-widgets
761
+ */
762
+ tabWidgetIds(tab) {
763
+ if (typeof tab === 'string') {
764
+ return [tab]
765
+ }
766
+ if (tab && Array.isArray(tab.widgetIds)) {
767
+ const ids = tab.widgetIds.filter((id) => typeof id === 'string' && id !== '')
768
+ if (ids.length) {
769
+ return ids
770
+ }
771
+ }
772
+ return [tab?.widgetId]
773
+ },
774
+
660
775
  /**
661
776
  * The label a tab shows: the authored `label`, else (board look) History
662
777
  * for the activity widget, else the child widget's title, else its id.
@@ -733,6 +848,60 @@ export default {
733
848
  }))
734
849
  },
735
850
 
851
+ /**
852
+ * The list a tab counts (`countFrom`): `"widget"` counts the child
853
+ * widget's own list (its `content.register`, `content.schema` and
854
+ * `content.filter`); an object `{ register, schema, filter? }` names
855
+ * the list itself. Null for a tab that does not ask, or a child with
856
+ * no list.
857
+ *
858
+ * @param {object|string} tab The `content.tabs[]` entry.
859
+ * @return {{register: string, schema: string, filter: object}|null} The list.
860
+ * @spec openspec/changes/screens-tab-counts-parity/specs/tab-counts/spec.md#requirement-a-tab-can-count-its-list
861
+ */
862
+ countSourceOf(tab) {
863
+ const from = tab && typeof tab === 'object' ? tab.countFrom : null
864
+ let source = null
865
+ if (from === 'widget') {
866
+ // A stack counts its first widget that has a list.
867
+ const lists = this.tabWidgetIds(tab)
868
+ .map((id) => this.availableWidgets.find((w) => w && w.id === id))
869
+ .filter((w) => w && w.content && typeof w.content === 'object' && w.content.register && w.content.schema)
870
+ source = lists.length ? lists[0].content : null
871
+ } else if (from && typeof from === 'object') {
872
+ source = from
873
+ }
874
+ if (!source || typeof source.register !== 'string' || source.register === '' || typeof source.schema !== 'string' || source.schema === '') {
875
+ return null
876
+ }
877
+ const filter = source.filter && typeof source.filter === 'object' && !Array.isArray(source.filter) ? source.filter : {}
878
+ return { register: source.register, schema: source.schema, filter }
879
+ },
880
+
881
+ /**
882
+ * Count the list behind every tab with `countFrom`: once on mount,
883
+ * when the record changes and after a refresh. A tab keeps its last
884
+ * total while the new one is on its way; a failed count shows none.
885
+ *
886
+ * @return {Promise<void>}
887
+ * @spec openspec/changes/screens-tab-counts-parity/specs/tab-counts/spec.md#requirement-a-tab-can-count-its-list
888
+ */
889
+ async refreshWidgetCounts() {
890
+ const tabs = Array.isArray(this.content?.tabs) ? this.content.tabs : []
891
+ const seq = ++this.countSeq
892
+ const ctx = { objectId: this.objectId, object: this.objectData || undefined }
893
+ await Promise.all(tabs.map(async (tab, index) => {
894
+ const source = this.countSourceOf(tab)
895
+ if (!source) {
896
+ return
897
+ }
898
+ const total = await fetchListTotal(source.register, source.schema, source.filter, ctx).catch(() => null)
899
+ if (seq === this.countSeq) {
900
+ this.widgetCounts = { ...this.widgetCounts, [index]: total }
901
+ }
902
+ }))
903
+ },
904
+
736
905
  /** @return {string} The route hash without its `#`, or ''. */
737
906
  currentHash() {
738
907
  const fromRoute = this.$route && typeof this.$route.hash === 'string' ? this.$route.hash : ''
@@ -823,6 +992,13 @@ export default {
823
992
  </script>
824
993
 
825
994
  <style scoped>
995
+ /* A stack (`widgetIds`): the panel's widget, then cards 20px apart. */
996
+ .cn-tabs-widget__stack {
997
+ display: flex;
998
+ flex-direction: column;
999
+ gap: 20px;
1000
+ }
1001
+
826
1002
  /* The panel is the card; the strip is not inside it. There is no title bar
827
1003
  above the tabs either: the open tab names the panel, so a title row would
828
1004
  say the same thing twice and cost a row of height on a card that is mostly
@@ -72,6 +72,10 @@ import '../CnDeltaWidget/index.js'
72
72
  import '../CnGaugeWidget/index.js'
73
73
  import '../CnFlowRunsWidget/index.js'
74
74
  import '../CnTasksWidget/index.js'
75
+ import '../CnSectionsWidget/index.js'
76
+ import '../CnFactsWidget/index.js'
77
+ import '../CnFieldListWidget/index.js'
78
+ import '../CnRecordListWidget/index.js'
75
79
  import '../CnNotepadWidget/index.js'
76
80
  import '../CnObjectDataWidget/dashboardRegistration.js'
77
81
  import '../CnObjectGeoWidget/dashboardRegistration.js'
@@ -4,12 +4,43 @@
4
4
  -->
5
5
 
6
6
  <template>
7
- <div class="cn-workspace-filter-widget" data-testid="cn-workspace-filter-widget">
7
+ <div
8
+ class="cn-workspace-filter-widget"
9
+ :class="{ 'cn-workspace-filter-widget--start': isStart }"
10
+ data-testid="cn-workspace-filter-widget">
11
+ <!-- The start card (`layout: "start"`): a title and one line of
12
+ guidance beside the control, as the "Contact starten" card. -->
13
+ <div v-if="isStart && (title || description)" class="cn-workspace-filter-widget__text">
14
+ <h3 v-if="title" class="cn-workspace-filter-widget__title">
15
+ {{ title }}
16
+ </h3>
17
+ <p v-if="description" class="cn-workspace-filter-widget__description">
18
+ {{ description }}
19
+ </p>
20
+ </div>
21
+
8
22
  <p v-if="label" class="cn-workspace-filter-widget__label">
9
23
  {{ label }}
10
24
  </p>
11
25
 
12
- <div v-if="loading" class="cn-workspace-filter-widget__status">
26
+ <!-- An object picker (`objectSource`): searches OpenRegister on the
27
+ server, so it reaches every object, not a fetched page. -->
28
+ <CnResourceSelect
29
+ v-if="objectSource"
30
+ class="cn-workspace-filter-widget__control"
31
+ :register="objectSource.register"
32
+ :schema="objectSource.schema"
33
+ :labelField="objectSource.labelField || 'title'"
34
+ :modelValue="selected === undefined || selected === null ? '' : String(selected)"
35
+ :placeholder="tr(objectSource.placeholder || '')"
36
+ :ariaLabelCombobox="tr(objectSource.ariaLabel || '') || title || label || writeKey"
37
+ :preload="objectSource.preload !== false"
38
+ :clearable="Boolean(content.allLabel)"
39
+ :allowCreate="false"
40
+ data-testid="cn-workspace-filter-object"
41
+ @update:modelValue="applySelection($event || '')" />
42
+
43
+ <div v-else-if="loading" class="cn-workspace-filter-widget__status">
13
44
  {{ loadingLabel }}
14
45
  </div>
15
46
 
@@ -47,12 +78,23 @@
47
78
  </NcCheckboxRadioSwitch>
48
79
  </li>
49
80
  </ul>
81
+
82
+ <component
83
+ :is="startLink.to ? 'router-link' : 'a'"
84
+ v-if="startLink"
85
+ :to="startLink.to || undefined"
86
+ :href="startLink.href || undefined"
87
+ class="cn-workspace-filter-widget__link"
88
+ data-testid="cn-workspace-filter-link">
89
+ {{ startLink.label }}
90
+ </component>
50
91
  </div>
51
92
  </template>
52
93
 
53
94
  <script>
54
95
  import { translate as t } from '@nextcloud/l10n'
55
96
  import { NcCheckboxRadioSwitch, NcSelect } from '@nextcloud/vue'
97
+ import CnResourceSelect from '../CnResourceSelect/CnResourceSelect.vue'
56
98
  import { fetchEndpointSource } from '../../composables/useEndpointSource.js'
57
99
  import { resolveFilterTokens } from '../../utils/resolveFilterTokens.js'
58
100
 
@@ -94,7 +136,7 @@ import { resolveFilterTokens } from '../../utils/resolveFilterTokens.js'
94
136
  export default {
95
137
  name: 'CnWorkspaceFilterWidget',
96
138
 
97
- components: { NcSelect, NcCheckboxRadioSwitch },
139
+ components: { CnResourceSelect, NcSelect, NcCheckboxRadioSwitch },
98
140
 
99
141
  inject: {
100
142
  /**
@@ -104,13 +146,23 @@ export default {
104
146
  * Null on pages without one (the widget then just emits `@change`).
105
147
  */
106
148
  cnWorkspaceContext: { default: null },
149
+ /** Host translate function, for the start card's texts. */
150
+ cnTranslate: { default: () => (key) => key },
107
151
  },
108
152
 
109
153
  props: {
110
154
  /**
111
155
  * Persisted configuration blob.
112
156
  *
113
- * @type {{label?: string, writes?: string, style?: string, allLabel?: string, showCounts?: boolean, options?: Array, source?: object, endpointSource?: object, default?: (string|number)}}
157
+ * Start card keys (abstract-start-card): `layout: "start"` draws
158
+ * `title` and `description` beside the control, and `link`
159
+ * (`{ label, route? | href? }`) after it. `objectSource`
160
+ * (`{ register, schema, labelField?, placeholder?, ariaLabel?, preload? }`)
161
+ * makes the control an OpenRegister object picker that searches on the
162
+ * server and writes the picked object's id; without a selection and
163
+ * without `allLabel` it selects the first object.
164
+ *
165
+ * @type {{label?: string, writes?: string, style?: string, allLabel?: string, showCounts?: boolean, options?: Array, source?: object, endpointSource?: object, objectSource?: object, default?: (string|number), layout?: string, title?: string, description?: string, link?: object}}
114
166
  */
115
167
  content: {
116
168
  type: Object,
@@ -137,6 +189,50 @@ export default {
137
189
  return this.content.label || ''
138
190
  },
139
191
 
192
+ /** Whether the widget draws the start card. */
193
+ isStart() {
194
+ return this.content.layout === 'start'
195
+ },
196
+
197
+ /** The start card's title, translated. */
198
+ title() {
199
+ return this.tr(this.content.title || '')
200
+ },
201
+
202
+ /** The start card's guidance line, translated. */
203
+ description() {
204
+ return this.tr(this.content.description || '')
205
+ },
206
+
207
+ /**
208
+ * The link after the control, or null.
209
+ *
210
+ * @return {{label: string, to?: object, href?: string}|null}
211
+ */
212
+ startLink() {
213
+ const link = this.content.link
214
+ if (!link || typeof link !== 'object' || !link.label) {
215
+ return null
216
+ }
217
+ if (link.route && typeof link.route === 'object' && link.route.name) {
218
+ return { label: this.tr(link.label), to: link.route }
219
+ }
220
+ if (typeof link.href === 'string' && link.href !== '') {
221
+ return { label: this.tr(link.label), href: link.href }
222
+ }
223
+ return null
224
+ },
225
+
226
+ /**
227
+ * The object picker declaration, or null.
228
+ *
229
+ * @return {{register: string, schema: string, labelField?: string}|null}
230
+ */
231
+ objectSource() {
232
+ const src = this.content.objectSource
233
+ return src && typeof src === 'object' && src.register && src.schema ? src : null
234
+ },
235
+
140
236
  /** Choice style: 'select' or 'radio' (default). */
141
237
  style() {
142
238
  return this.content.style === 'select' ? 'select' : 'radio'
@@ -263,6 +359,10 @@ export default {
263
359
  * @return {Promise<void>}
264
360
  */
265
361
  async loadOptions() {
362
+ if (this.objectSource) {
363
+ await this.seedFirstObject()
364
+ return
365
+ }
266
366
  if (this.content.endpointSource && this.content.endpointSource.url) {
267
367
  await this.loadFromEndpoint()
268
368
  } else if (this.content.source && this.content.source.schema) {
@@ -281,6 +381,51 @@ export default {
281
381
  }
282
382
  },
283
383
 
384
+ /**
385
+ * Translate a manifest string through the host app.
386
+ *
387
+ * @param {string} text The text.
388
+ * @return {string}
389
+ */
390
+ tr(text) {
391
+ if (typeof text !== 'string' || text === '') {
392
+ return ''
393
+ }
394
+ return typeof this.cnTranslate === 'function' ? this.cnTranslate(text) : text
395
+ },
396
+
397
+ /**
398
+ * The object picker's default: with nothing selected, no `default` and
399
+ * no `allLabel`, select the first object the source lists, so the page
400
+ * always has one in focus. Fail-safe: a failed read selects nothing.
401
+ *
402
+ * @return {Promise<void>}
403
+ */
404
+ async seedFirstObject() {
405
+ if ((this.selected !== undefined && this.selected !== '') || this.content.allLabel || this.content.default !== undefined) {
406
+ return
407
+ }
408
+ const src = this.objectSource
409
+ try {
410
+ const [{ default: axios }, { generateUrl }] = await Promise.all([
411
+ import('@nextcloud/axios'),
412
+ import('@nextcloud/router'),
413
+ ])
414
+ const url = generateUrl('/apps/openregister/api/objects/{register}/{schema}', { register: src.register, schema: src.schema })
415
+ const res = await axios.get(url, { params: { _limit: 1 } })
416
+ const data = res && res.data
417
+ const rows = Array.isArray(data) ? data : ((data && data.results) || [])
418
+ const first = rows[0]
419
+ const id = first && (first.id || (first['@self'] && first['@self'].id))
420
+ if (id && (this.selected === undefined || this.selected === '') && this.readWorkspace() === undefined) {
421
+ this.selected = String(id)
422
+ this.writeWorkspace(String(id))
423
+ }
424
+ } catch {
425
+ // Nothing selected: the siblings wait for a pick.
426
+ }
427
+ },
428
+
284
429
  /**
285
430
  * Fetch options from an `endpointSource` (array of options at
286
431
  * responsePath). Fail-safe: a failed fetch leaves an empty list.
@@ -465,6 +610,45 @@ export default {
465
610
  gap: 2px;
466
611
  }
467
612
 
613
+ /* The start card: text left, control (and link) right, as PqDashboard's "Contact starten". */
614
+ .cn-workspace-filter-widget--start {
615
+ flex-direction: row;
616
+ flex-wrap: wrap;
617
+ align-items: center;
618
+ gap: 16px 24px;
619
+ }
620
+
621
+ .cn-workspace-filter-widget__text {
622
+ flex: 1 1 320px;
623
+ min-width: 0;
624
+ }
625
+
626
+ .cn-workspace-filter-widget__title {
627
+ margin: 0 0 4px;
628
+ font-size: 17px;
629
+ font-weight: 700;
630
+ color: var(--color-main-text);
631
+ }
632
+
633
+ .cn-workspace-filter-widget__description {
634
+ margin: 0;
635
+ max-width: 420px;
636
+ color: var(--color-text-maxcontrast);
637
+ }
638
+
639
+ .cn-workspace-filter-widget--start .cn-workspace-filter-widget__control,
640
+ .cn-workspace-filter-widget--start > .v-select {
641
+ flex: 0 1 480px;
642
+ min-width: 240px;
643
+ }
644
+
645
+ .cn-workspace-filter-widget__link {
646
+ font-weight: 600;
647
+ color: var(--color-primary-element);
648
+ text-decoration: underline;
649
+ white-space: nowrap;
650
+ }
651
+
468
652
  .cn-workspace-filter-widget__count {
469
653
  margin-inline-start: 6px;
470
654
  padding: 0 6px;
@@ -216,6 +216,10 @@ export { CnFlowStepOutcomes } from './CnFlowStepOutcomes/index.js'
216
216
  export { CnFlowRunsWidgetForm } from './CnFlowRunsWidgetForm/index.js'
217
217
  export { CnTasksWidget } from './CnTasksWidget/index.js'
218
218
  export { CnTasksWidgetForm } from './CnTasksWidgetForm/index.js'
219
+ export { CnSectionsWidget } from './CnSectionsWidget/index.js'
220
+ export { CnFactsWidget } from './CnFactsWidget/index.js'
221
+ export { CnFieldListWidget } from './CnFieldListWidget/index.js'
222
+ export { CnRecordListWidget } from './CnRecordListWidget/index.js'
219
223
  export { CnFilePreview } from './CnFilePreview/index.js'
220
224
  export { CnTaskFormDialog } from './CnTaskFormDialog/index.js'
221
225
  export { CnChildRecordsField } from './CnChildRecordsField/index.js'
@@ -17,11 +17,21 @@
17
17
  * one, and it would be this library's fault in somebody else's app.
18
18
  */
19
19
 
20
+ jest.mock('@nextcloud/auth', () => ({
21
+ ...jest.requireActual('@nextcloud/auth'),
22
+ getCurrentUser: jest.fn(() => null),
23
+ }))
24
+
25
+ import { getCurrentUser } from '@nextcloud/auth'
20
26
  import {
27
+ clearAnonymousDrafts,
21
28
  clearDraft,
22
29
  DRAFT_MAX_AGE_MS,
23
30
  draftKey,
31
+ hasDraftChanges,
24
32
  readDraft,
33
+ resolveDraftUserId,
34
+ sameDraftValue,
25
35
  writeDraft,
26
36
  } from '../useFormDraft.js'
27
37
 
@@ -170,3 +180,62 @@ describe('when storage is not available', () => {
170
180
  expect(() => clearDraft('cn-form-draft:a:s:new:u')).not.toThrow()
171
181
  })
172
182
  })
183
+
184
+ // @spec openspec/changes/form-draft-on-change-and-help-text-spacing/specs/form-draft-on-change-and-help-text-spacing/spec.md#requirement-a-draft-belongs-to-the-signed-in-user
185
+ describe('resolveDraftUserId', () => {
186
+ afterEach(() => getCurrentUser.mockReturnValue(null))
187
+
188
+ it('prefers the id the host passed', () => {
189
+ getCurrentUser.mockReturnValue({ uid: 'cluade' })
190
+ expect(resolveDraftUserId('anne')).toBe('anne')
191
+ })
192
+
193
+ it('falls back to the signed-in user', () => {
194
+ getCurrentUser.mockReturnValue({ uid: 'cluade' })
195
+ expect(resolveDraftUserId('')).toBe('cluade')
196
+ })
197
+
198
+ it('is empty when nobody is signed in, and when the lookup throws', () => {
199
+ expect(resolveDraftUserId()).toBe('')
200
+ getCurrentUser.mockImplementationOnce(() => {
201
+ throw new Error('no head')
202
+ })
203
+ expect(resolveDraftUserId()).toBe('')
204
+ })
205
+ })
206
+
207
+ describe('clearAnonymousDrafts', () => {
208
+ beforeEach(() => window.localStorage.clear())
209
+
210
+ it('removes only drafts stored under anonymous', () => {
211
+ const anon = draftKey({ appId: 'pipelinq', schema: 'crmTask', objectId: 't1' })
212
+ const anne = draftKey({ appId: 'pipelinq', schema: 'crmTask', objectId: 't1', userId: 'anne' })
213
+ writeDraft(anon, { title: 'x' })
214
+ writeDraft(anne, { title: 'y' })
215
+ window.localStorage.setItem('unrelated:anonymous', 'keep')
216
+
217
+ expect(clearAnonymousDrafts()).toBe(1)
218
+ expect(window.localStorage.getItem(anon)).toBeNull()
219
+ expect(window.localStorage.getItem(anne)).not.toBeNull()
220
+ expect(window.localStorage.getItem('unrelated:anonymous')).toBe('keep')
221
+ })
222
+ })
223
+
224
+ // @spec openspec/changes/form-draft-on-change-and-help-text-spacing/specs/form-draft-on-change-and-help-text-spacing/spec.md#requirement-a-draft-is-written-only-after-a-person-changes-a-field
225
+ describe('hasDraftChanges', () => {
226
+ it('treats empty values as equal', () => {
227
+ expect(sameDraftValue(null, '')).toBe(true)
228
+ expect(sameDraftValue(undefined, [])).toBe(true)
229
+ expect(sameDraftValue('a', 'b')).toBe(false)
230
+ expect(sameDraftValue({ a: 1 }, { a: 1 })).toBe(true)
231
+ })
232
+
233
+ it('counts only fields a person edited', () => {
234
+ const baseline = { title: 'Call back', notes: '' }
235
+ // The form changed `notes` by itself; nobody edited it.
236
+ expect(hasDraftChanges({ title: 'Call back', notes: 'filled in' }, baseline, [])).toBe(false)
237
+ // Edited, but back to what it was.
238
+ expect(hasDraftChanges({ title: 'Call back' }, baseline, ['title'])).toBe(false)
239
+ expect(hasDraftChanges({ title: 'Call back tomorrow' }, baseline, ['title'])).toBe(true)
240
+ })
241
+ })