@conduction/nextcloud-vue 2.56.0-beta.31 → 2.56.0-beta.32

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 (178) hide show
  1. package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue2.js +55 -4
  2. package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue2.js.map +1 -1
  3. package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue3.js +51 -15
  4. package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue3.js.map +1 -1
  5. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js +13 -2
  6. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js.map +1 -1
  7. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js +63 -52
  8. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js.map +1 -1
  9. package/dist/esm/components/CnChipsFieldWidget/CnChipsFieldWidget.vue.js +10 -0
  10. package/dist/esm/components/CnChipsFieldWidget/CnChipsFieldWidget.vue.js.map +1 -0
  11. package/dist/esm/components/CnChipsFieldWidget/CnChipsFieldWidget.vue2.js +252 -0
  12. package/dist/esm/components/CnChipsFieldWidget/CnChipsFieldWidget.vue2.js.map +1 -0
  13. package/dist/esm/components/CnChipsFieldWidget/CnChipsFieldWidget.vue3.js +140 -0
  14. package/dist/esm/components/CnChipsFieldWidget/CnChipsFieldWidget.vue3.js.map +1 -0
  15. package/dist/esm/components/CnChipsFieldWidget/index.js +26 -0
  16. package/dist/esm/components/CnChipsFieldWidget/index.js.map +1 -0
  17. package/dist/esm/components/CnChoicePickerWidget/CnChoicePickerWidget.vue.js +10 -0
  18. package/dist/esm/components/CnChoicePickerWidget/CnChoicePickerWidget.vue.js.map +1 -0
  19. package/dist/esm/components/CnChoicePickerWidget/CnChoicePickerWidget.vue2.js +354 -0
  20. package/dist/esm/components/CnChoicePickerWidget/CnChoicePickerWidget.vue2.js.map +1 -0
  21. package/dist/esm/components/CnChoicePickerWidget/CnChoicePickerWidget.vue3.js +172 -0
  22. package/dist/esm/components/CnChoicePickerWidget/CnChoicePickerWidget.vue3.js.map +1 -0
  23. package/dist/esm/components/CnChoicePickerWidget/index.js +27 -0
  24. package/dist/esm/components/CnChoicePickerWidget/index.js.map +1 -0
  25. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +25 -2
  26. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  27. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +2 -1
  28. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  29. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  30. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js +3 -2
  31. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  32. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +29 -0
  33. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  34. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +20 -3
  35. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  36. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js +7 -1
  37. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js.map +1 -1
  38. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js.map +1 -1
  39. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue2.js +77 -3
  40. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue2.js.map +1 -1
  41. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue3.js +282 -69
  42. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue3.js.map +1 -1
  43. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +0 -1
  44. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  45. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  46. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js +65 -2
  47. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js.map +1 -1
  48. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js +6 -2
  49. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js.map +1 -1
  50. package/dist/esm/components/CnRadioCardsWidget/CnRadioCardsWidget.vue.js +10 -0
  51. package/dist/esm/components/CnRadioCardsWidget/CnRadioCardsWidget.vue.js.map +1 -0
  52. package/dist/esm/components/CnRadioCardsWidget/CnRadioCardsWidget.vue2.js +174 -0
  53. package/dist/esm/components/CnRadioCardsWidget/CnRadioCardsWidget.vue2.js.map +1 -0
  54. package/dist/esm/components/CnRadioCardsWidget/CnRadioCardsWidget.vue3.js +75 -0
  55. package/dist/esm/components/CnRadioCardsWidget/CnRadioCardsWidget.vue3.js.map +1 -0
  56. package/dist/esm/components/CnRadioCardsWidget/index.js +28 -0
  57. package/dist/esm/components/CnRadioCardsWidget/index.js.map +1 -0
  58. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js +18 -1
  59. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js.map +1 -1
  60. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js +34 -25
  61. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js.map +1 -1
  62. package/dist/esm/components/CnSavedViewsWidget/CnSavedViewsWidget.vue.js +10 -0
  63. package/dist/esm/components/CnSavedViewsWidget/CnSavedViewsWidget.vue.js.map +1 -0
  64. package/dist/esm/components/CnSavedViewsWidget/CnSavedViewsWidget.vue2.js +222 -0
  65. package/dist/esm/components/CnSavedViewsWidget/CnSavedViewsWidget.vue2.js.map +1 -0
  66. package/dist/esm/components/CnSavedViewsWidget/CnSavedViewsWidget.vue3.js +64 -0
  67. package/dist/esm/components/CnSavedViewsWidget/CnSavedViewsWidget.vue3.js.map +1 -0
  68. package/dist/esm/components/CnSavedViewsWidget/index.js +30 -0
  69. package/dist/esm/components/CnSavedViewsWidget/index.js.map +1 -0
  70. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue2.js.map +1 -1
  71. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue3.js.map +1 -1
  72. package/dist/esm/components/CnSettingsFormWidget/CnSettingsFormWidget.vue.js +10 -0
  73. package/dist/esm/components/CnSettingsFormWidget/CnSettingsFormWidget.vue.js.map +1 -0
  74. package/dist/esm/components/CnSettingsFormWidget/CnSettingsFormWidget.vue2.js +347 -0
  75. package/dist/esm/components/CnSettingsFormWidget/CnSettingsFormWidget.vue2.js.map +1 -0
  76. package/dist/esm/components/CnSettingsFormWidget/CnSettingsFormWidget.vue3.js +184 -0
  77. package/dist/esm/components/CnSettingsFormWidget/CnSettingsFormWidget.vue3.js.map +1 -0
  78. package/dist/esm/components/CnSettingsFormWidget/index.js +27 -0
  79. package/dist/esm/components/CnSettingsFormWidget/index.js.map +1 -0
  80. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js +87 -6
  81. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js.map +1 -1
  82. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js.map +1 -1
  83. package/dist/esm/components/CnSwitchRowsWidget/CnSwitchRowsWidget.vue.js +10 -0
  84. package/dist/esm/components/CnSwitchRowsWidget/CnSwitchRowsWidget.vue.js.map +1 -0
  85. package/dist/esm/components/CnSwitchRowsWidget/CnSwitchRowsWidget.vue2.js +159 -0
  86. package/dist/esm/components/CnSwitchRowsWidget/CnSwitchRowsWidget.vue2.js.map +1 -0
  87. package/dist/esm/components/CnSwitchRowsWidget/CnSwitchRowsWidget.vue3.js +60 -0
  88. package/dist/esm/components/CnSwitchRowsWidget/CnSwitchRowsWidget.vue3.js.map +1 -0
  89. package/dist/esm/components/CnSwitchRowsWidget/index.js +26 -0
  90. package/dist/esm/components/CnSwitchRowsWidget/index.js.map +1 -0
  91. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +47 -1
  92. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
  93. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js +16 -14
  94. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
  95. package/dist/esm/components/CnTasksWidget/CnTasksWidget.vue2.js +80 -1
  96. package/dist/esm/components/CnTasksWidget/CnTasksWidget.vue2.js.map +1 -1
  97. package/dist/esm/components/CnTasksWidget/CnTasksWidget.vue3.js +75 -32
  98. package/dist/esm/components/CnTasksWidget/CnTasksWidget.vue3.js.map +1 -1
  99. package/dist/esm/components/CnToggleTableWidget/CnToggleTableWidget.vue.js +10 -0
  100. package/dist/esm/components/CnToggleTableWidget/CnToggleTableWidget.vue.js.map +1 -0
  101. package/dist/esm/components/CnToggleTableWidget/CnToggleTableWidget.vue2.js +269 -0
  102. package/dist/esm/components/CnToggleTableWidget/CnToggleTableWidget.vue2.js.map +1 -0
  103. package/dist/esm/components/CnToggleTableWidget/CnToggleTableWidget.vue3.js +106 -0
  104. package/dist/esm/components/CnToggleTableWidget/CnToggleTableWidget.vue3.js.map +1 -0
  105. package/dist/esm/components/CnToggleTableWidget/index.js +27 -0
  106. package/dist/esm/components/CnToggleTableWidget/index.js.map +1 -0
  107. package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js +7 -0
  108. package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js.map +1 -1
  109. package/dist/esm/components/CnWorkspaceFilterWidget/CnWorkspaceFilterWidget.vue2.js.map +1 -1
  110. package/dist/esm/components/CnWorkspaceFilterWidget/CnWorkspaceFilterWidget.vue3.js +4 -2
  111. package/dist/esm/components/CnWorkspaceFilterWidget/CnWorkspaceFilterWidget.vue3.js.map +1 -1
  112. package/dist/esm/index.js +7 -0
  113. package/dist/esm/index.js.map +1 -1
  114. package/dist/esm/l10n/en.json.js +1 -0
  115. package/dist/esm/l10n/en.json.js.map +1 -1
  116. package/dist/esm/l10n/nl.json.js +1 -0
  117. package/dist/esm/l10n/nl.json.js.map +1 -1
  118. package/dist/esm/nextcloud-vue.css +843 -4
  119. package/dist/esm/utils/contentHeaderLink.js +51 -0
  120. package/dist/esm/utils/contentHeaderLink.js.map +1 -0
  121. package/dist/esm/utils/fetchFilterCounts.js +40 -5
  122. package/dist/esm/utils/fetchFilterCounts.js.map +1 -1
  123. package/dist/esm/utils/initials.js +32 -0
  124. package/dist/esm/utils/initials.js.map +1 -0
  125. package/dist/esm/utils/libraryWidgetKeys.js +7 -0
  126. package/dist/esm/utils/libraryWidgetKeys.js.map +1 -1
  127. package/dist/esm/utils/objectWrite.js +59 -0
  128. package/dist/esm/utils/objectWrite.js.map +1 -0
  129. package/dist/esm/utils/widgetDispatch.js +1 -1
  130. package/dist/esm/utils/widgetDispatch.js.map +1 -1
  131. package/dist/nextcloud-vue.cjs.js +10510 -6830
  132. package/dist/nextcloud-vue.cjs.js.map +2 -2
  133. package/dist/nextcloud-vue.css +843 -4
  134. package/l10n/en.json +1 -0
  135. package/l10n/nl.json +1 -0
  136. package/package.json +1 -1
  137. package/src/components/CnBannerWidget/CnBannerWidget.vue +93 -13
  138. package/src/components/CnCellRenderer/CnCellRenderer.md +2 -0
  139. package/src/components/CnCellRenderer/CnCellRenderer.vue +43 -3
  140. package/src/components/CnChipsFieldWidget/CnChipsFieldWidget.vue +433 -0
  141. package/src/components/CnChipsFieldWidget/index.js +25 -0
  142. package/src/components/CnChoicePickerWidget/CnChoicePickerWidget.vue +556 -0
  143. package/src/components/CnChoicePickerWidget/index.js +26 -0
  144. package/src/components/CnDashboardPage/CnDashboardPage.vue +38 -2
  145. package/src/components/CnDataTable/CnDataTable.vue +2 -1
  146. package/src/components/CnDetailPage/CnDetailPage.vue +45 -2
  147. package/src/components/CnFactsWidget/CnFactsWidget.vue +18 -3
  148. package/src/components/CnFieldListWidget/CnFieldListWidget.vue +265 -4
  149. package/src/components/CnIndexPage/CnIndexPage.vue +0 -1
  150. package/src/components/CnObjectListWidget/CnObjectListWidget.vue +69 -2
  151. package/src/components/CnRadioCardsWidget/CnRadioCardsWidget.vue +313 -0
  152. package/src/components/CnRadioCardsWidget/index.js +27 -0
  153. package/src/components/CnRecordListWidget/CnRecordListWidget.vue +43 -1
  154. package/src/components/CnSavedViewsWidget/CnSavedViewsWidget.vue +296 -0
  155. package/src/components/CnSavedViewsWidget/index.js +29 -0
  156. package/src/components/CnSegmentedControl/CnSegmentedControl.vue +13 -0
  157. package/src/components/CnSettingsFormWidget/CnSettingsFormWidget.vue +555 -0
  158. package/src/components/CnSettingsFormWidget/index.js +26 -0
  159. package/src/components/CnStatWidget/CnStatWidget.vue +87 -6
  160. package/src/components/CnSwitchRowsWidget/CnSwitchRowsWidget.vue +226 -0
  161. package/src/components/CnSwitchRowsWidget/index.js +25 -0
  162. package/src/components/CnTabsWidget/CnTabsWidget.vue +82 -3
  163. package/src/components/CnTasksWidget/CnTasksWidget.vue +175 -15
  164. package/src/components/CnToggleTableWidget/CnToggleTableWidget.vue +386 -0
  165. package/src/components/CnToggleTableWidget/index.js +26 -0
  166. package/src/components/CnWidgetGrid/registerDashboardWidgets.js +7 -0
  167. package/src/components/CnWorkspaceFilterWidget/CnWorkspaceFilterWidget.vue +28 -1
  168. package/src/components/index.js +7 -0
  169. package/src/css/look-board-detail.css +36 -2
  170. package/src/css/look-board-index.css +23 -0
  171. package/src/css/look-board.css +72 -0
  172. package/src/index.js +7 -0
  173. package/src/utils/contentHeaderLink.js +48 -0
  174. package/src/utils/fetchFilterCounts.js +39 -4
  175. package/src/utils/initials.js +29 -0
  176. package/src/utils/libraryWidgetKeys.js +7 -0
  177. package/src/utils/objectWrite.js +55 -0
  178. package/src/utils/widgetDispatch.js +1 -1
@@ -0,0 +1,556 @@
1
+ <!--
2
+ - SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ - SPDX-License-Identifier: EUPL-1.2
4
+ -->
5
+
6
+ <template>
7
+ <div class="cn-choice-picker" data-testid="cn-choice-picker">
8
+ <p v-if="loading" class="cn-choice-picker__note" data-testid="cn-choice-picker-loading">
9
+ {{ tr('Loading') }}
10
+ </p>
11
+ <p v-else-if="options.length === 0" class="cn-choice-picker__note" data-testid="cn-choice-picker-empty">
12
+ {{ emptyLabel }}
13
+ </p>
14
+ <fieldset v-else class="cn-choice-picker__cards">
15
+ <legend class="cn-choice-picker__legend">
16
+ {{ legend }}
17
+ </legend>
18
+ <label
19
+ v-for="option in options"
20
+ :key="option.key"
21
+ class="cn-choice-picker__card"
22
+ :class="{
23
+ 'cn-choice-picker__card--selected': chosen === option.key,
24
+ 'cn-choice-picker__card--refused': option.refusal !== '',
25
+ }"
26
+ :data-testid="`cn-choice-picker-option-${option.key}`">
27
+ <input
28
+ class="cn-choice-picker__input"
29
+ type="radio"
30
+ :name="groupName"
31
+ :value="option.key"
32
+ :checked="chosen === option.key"
33
+ :disabled="option.refusal !== '' || saving"
34
+ @change="choose(option)">
35
+ <span class="cn-choice-picker__swatches" aria-hidden="true">
36
+ <span
37
+ v-for="(colour, index) in option.swatches"
38
+ :key="index"
39
+ class="cn-choice-picker__swatch"
40
+ :style="{ background: colour }" />
41
+ </span>
42
+ <span class="cn-choice-picker__text">
43
+ <strong class="cn-choice-picker__title">{{ option.title }}</strong>
44
+ <span v-if="option.refusal" class="cn-choice-picker__refusal" :data-testid="`cn-choice-picker-refusal-${option.key}`">
45
+ {{ option.refusal }}
46
+ </span>
47
+ </span>
48
+ <CnStatusBadge
49
+ v-if="option.pill"
50
+ :label="option.pill.label"
51
+ :variant="option.pill.variant"
52
+ size="small" />
53
+ </label>
54
+ </fieldset>
55
+
56
+ <NcNoteCard v-if="selected && selected.warning.length > 0" type="warning" data-testid="cn-choice-picker-warning">
57
+ <p v-if="warningIntro" class="cn-choice-picker__warning-intro">
58
+ {{ warningIntro }}
59
+ </p>
60
+ <ul class="cn-choice-picker__warning-list">
61
+ <li v-for="(line, index) in selected.warning" :key="index">
62
+ {{ line }}
63
+ </li>
64
+ </ul>
65
+ </NcNoteCard>
66
+
67
+ <NcNoteCard v-if="notice" :type="noticeType" data-testid="cn-choice-picker-notice">
68
+ {{ notice }}
69
+ </NcNoteCard>
70
+
71
+ <div v-if="options.length > 0 && !readOnly" class="cn-choice-picker__actions">
72
+ <NcButton
73
+ variant="primary"
74
+ :disabled="saving || !canSave"
75
+ data-testid="cn-choice-picker-save"
76
+ @click="save(false)">
77
+ {{ saveLabel }}
78
+ </NcButton>
79
+ <NcButton
80
+ v-if="selected && selected.warning.length > 0"
81
+ variant="secondary"
82
+ :disabled="saving"
83
+ data-testid="cn-choice-picker-confirm"
84
+ @click="save(true)">
85
+ {{ confirmLabel }}
86
+ </NcButton>
87
+ </div>
88
+ <p v-if="hint" class="cn-choice-picker__hint">
89
+ {{ hint }}
90
+ </p>
91
+ </div>
92
+ </template>
93
+
94
+ <script>
95
+ import { NcButton, NcNoteCard } from '@nextcloud/vue'
96
+ import CnStatusBadge from '../CnStatusBadge/CnStatusBadge.vue'
97
+ import { isBadgeVariant } from '../../utils/badgeVariants.js'
98
+ import { prefixUrl } from '../../utils/headers.js'
99
+ import { objectUrlOf } from '../../utils/objectWrite.js'
100
+ import { readPath } from '../../utils/readPath.js'
101
+ import { serverErrorMessage } from '../../utils/serverErrorMessage.js'
102
+
103
+ let uid = 0
104
+
105
+ /**
106
+ * CnChoicePickerWidget — the `choice-picker` widget: choice cards read from a
107
+ * source (or a static list), each with a radio, colour swatches read from the
108
+ * item, a title and a state pill (a verdict such as "Leesbaar" / "Slecht
109
+ * leesbaar"), bound to one field of the CURRENT object.
110
+ *
111
+ * Save is a button. A card that carries a warning (a list of lines read from
112
+ * the item) shows them in a warning band under the cards and adds a second
113
+ * button, the confirm action, so a risky choice is saved only on purpose: the
114
+ * request tells the server which one was pressed (`@confirmed`). A card the
115
+ * source marks as refused is listed with its reason and cannot be chosen.
116
+ *
117
+ * @spec openspec/changes/abstract-choice-picker/specs/choice-picker/spec.md
118
+ */
119
+ export default {
120
+ name: 'CnChoicePickerWidget',
121
+
122
+ components: { CnStatusBadge, NcButton, NcNoteCard },
123
+
124
+ inject: {
125
+ cnTranslate: { default: () => (key) => key },
126
+ },
127
+
128
+ props: {
129
+ /**
130
+ * The widget configuration.
131
+ *
132
+ * - `field` (required) — the top-level field of the object holding the chosen key.
133
+ * - `source` — `{ url, path?, key, label, swatches?, state?, refusal?, warning? }` (tokens `@objectId`, `@object.<field>` in the url), or `items` — the same item shape as a static list.
134
+ * - `swatches` — path to an array of colours (CSS colour strings).
135
+ * - `state` — `{ path, map?, variantMap?, empty?, emptyVariant? }`: the pill; `map` turns the value into text, `variantMap` into a badge variant, `empty` is the text when the value is missing.
136
+ * - `refusal` — path to a string: the card is listed with it and cannot be chosen.
137
+ * - `warning` — `{ list, item? }`: `list` is a path to an array; `item` a template with `{path}` placeholders for object items (strings are used as they are).
138
+ * - `legend`, `warningIntro`, `saveLabel`, `confirmLabel`, `emptyText`, `hint`, `readOnly`.
139
+ * - `save` — `{ method?, url?, payload? }`: a custom request (tokens `@value`, `@confirmed`, `@objectId`, `@object.<field>`); without it the field is PATCHed on the object.
140
+ *
141
+ * @type {object}
142
+ */
143
+ content: {
144
+ type: Object,
145
+ default: () => ({}),
146
+ },
147
+
148
+ /** The current detail-page object. */
149
+ objectData: {
150
+ type: Object,
151
+ default: null,
152
+ },
153
+
154
+ /** The current object id. */
155
+ objectId: {
156
+ type: [String, Number],
157
+ default: '',
158
+ },
159
+
160
+ /** OpenRegister register slug of the surface. */
161
+ register: {
162
+ type: [String, Object],
163
+ default: '',
164
+ },
165
+
166
+ /** OpenRegister schema slug of the surface. */
167
+ schema: {
168
+ type: [String, Object],
169
+ default: '',
170
+ },
171
+ },
172
+
173
+ emits: ['saved'],
174
+
175
+ data() {
176
+ uid += 1
177
+ return {
178
+ groupName: `cn-choice-picker-${uid}`,
179
+ sourced: [],
180
+ loading: false,
181
+ saved: '',
182
+ chosen: '',
183
+ saving: false,
184
+ notice: '',
185
+ noticeType: 'success',
186
+ }
187
+ },
188
+
189
+ computed: {
190
+ spec() {
191
+ return this.content.source || {}
192
+ },
193
+
194
+ /** The cards, from the static items then the source. */
195
+ options() {
196
+ const items = [...(Array.isArray(this.content.items) ? this.content.items : []), ...this.sourced]
197
+ return items
198
+ .filter((item) => item && readPath(item, this.spec.key || 'key') !== undefined)
199
+ .map((item) => {
200
+ const key = String(readPath(item, this.spec.key || 'key'))
201
+ const refusal = this.spec.refusal ? readPath(item, this.spec.refusal) : ''
202
+ return {
203
+ key,
204
+ title: String(readPath(item, this.spec.label || 'label') ?? key),
205
+ swatches: this.listOf(item, this.spec.swatches).filter((colour) => typeof colour === 'string'),
206
+ pill: this.pillOf(item),
207
+ refusal: typeof refusal === 'string' ? refusal : '',
208
+ warning: this.warningOf(item),
209
+ }
210
+ })
211
+ },
212
+
213
+ selected() {
214
+ return this.options.find((option) => option.key === this.chosen) || null
215
+ },
216
+
217
+ canSave() {
218
+ return this.chosen !== '' && this.chosen !== this.saved && (this.selected ? this.selected.refusal === '' : false)
219
+ },
220
+
221
+ legend() {
222
+ return this.tr(this.content.legend || 'Choose one')
223
+ },
224
+
225
+ warningIntro() {
226
+ return this.tr(this.content.warningIntro || '')
227
+ },
228
+
229
+ saveLabel() {
230
+ return this.tr(this.content.saveLabel || 'Save')
231
+ },
232
+
233
+ confirmLabel() {
234
+ return this.tr(this.content.confirmLabel || 'Use it anyway')
235
+ },
236
+
237
+ emptyLabel() {
238
+ return this.tr(this.content.emptyText || 'Nothing to choose from')
239
+ },
240
+
241
+ hint() {
242
+ return this.tr(this.content.hint || '')
243
+ },
244
+
245
+ readOnly() {
246
+ return this.content.readOnly === true
247
+ },
248
+
249
+ objectUrl() {
250
+ return objectUrlOf(this.register, this.schema, this.objectId || (this.objectData && this.objectData.id))
251
+ },
252
+ },
253
+
254
+ watch: {
255
+ objectData() {
256
+ this.adopt()
257
+ this.loadSource()
258
+ },
259
+ },
260
+
261
+ created() {
262
+ this.adopt()
263
+ this.loadSource()
264
+ },
265
+
266
+ methods: {
267
+ tr(text) {
268
+ if (typeof text !== 'string' || text === '') {
269
+ return ''
270
+ }
271
+ return typeof this.cnTranslate === 'function' ? this.cnTranslate(text) : text
272
+ },
273
+
274
+ /** Read the current choice off the object. */
275
+ adopt() {
276
+ const stored = readPath(this.objectData || {}, this.content.field)
277
+ this.saved = stored === undefined || stored === null ? '' : String(stored)
278
+ this.chosen = this.saved
279
+ },
280
+
281
+ /**
282
+ * A list off an item.
283
+ *
284
+ * @param {object} item The item.
285
+ * @param {string} path The path, or undefined.
286
+ * @return {Array}
287
+ */
288
+ listOf(item, path) {
289
+ const list = path ? readPath(item, path) : []
290
+ return Array.isArray(list) ? list : []
291
+ },
292
+
293
+ /**
294
+ * The state pill of an item.
295
+ *
296
+ * @param {object} item The item.
297
+ * @return {{label: string, variant: string}|null}
298
+ */
299
+ pillOf(item) {
300
+ const state = this.spec.state
301
+ if (!state || !state.path) {
302
+ return null
303
+ }
304
+ const raw = readPath(item, state.path)
305
+ const empty = raw === undefined || raw === null || raw === ''
306
+ if (empty) {
307
+ return state.empty ? { label: this.tr(state.empty), variant: isBadgeVariant(state.emptyVariant) ? state.emptyVariant : 'default' } : null
308
+ }
309
+ const label = state.map && Object.hasOwn(state.map, String(raw)) ? this.tr(state.map[String(raw)]) : String(raw)
310
+ const variant = state.variantMap && state.variantMap[String(raw)]
311
+ return { label, variant: isBadgeVariant(variant) ? variant : 'default' }
312
+ },
313
+
314
+ /**
315
+ * The warning lines of an item.
316
+ *
317
+ * @param {object} item The item.
318
+ * @return {string[]}
319
+ */
320
+ warningOf(item) {
321
+ const spec = this.spec.warning
322
+ if (!spec || !spec.list) {
323
+ return []
324
+ }
325
+ return this.listOf(item, spec.list)
326
+ .map((entry) => {
327
+ if (typeof entry === 'string') {
328
+ return entry
329
+ }
330
+ if (!entry || typeof entry !== 'object' || !spec.item) {
331
+ return ''
332
+ }
333
+ return this.tr(spec.item).replace(/\{([\w.]+)\}/g, (m, path) => String(readPath(entry, path) ?? ''))
334
+ })
335
+ .filter((line) => line !== '')
336
+ },
337
+
338
+ /**
339
+ * Resolve the tokens in a url or payload.
340
+ *
341
+ * @param {unknown} value The value.
342
+ * @param {object} vars `{ value, confirmed }`.
343
+ * @return {unknown}
344
+ */
345
+ resolve(value, vars) {
346
+ if (Array.isArray(value)) {
347
+ return value.map((v) => this.resolve(v, vars))
348
+ }
349
+ if (value && typeof value === 'object') {
350
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, this.resolve(v, vars)]))
351
+ }
352
+ if (typeof value !== 'string') {
353
+ return value
354
+ }
355
+ const object = this.objectData || {}
356
+ if (value === '@value') {
357
+ return vars.value
358
+ }
359
+ if (value === '@confirmed') {
360
+ return vars.confirmed
361
+ }
362
+ const whole = value.match(/^@object\.([\w.@]+)$/)
363
+ if (whole) {
364
+ return readPath(object, whole[1])
365
+ }
366
+ return value
367
+ .replace(/@objectId/g, () => encodeURIComponent(String(this.objectId || object.id || '')))
368
+ .replace(/@object\.([\w.@]+)/g, (m, path) => encodeURIComponent(String(readPath(object, path) ?? '')))
369
+ .replace(/@value/g, () => encodeURIComponent(String(vars.value)))
370
+ },
371
+
372
+ /** Read the cards of `source`. */
373
+ async loadSource() {
374
+ if (!this.spec.url) {
375
+ return
376
+ }
377
+ this.loading = true
378
+ try {
379
+ const { default: axios } = await import('@nextcloud/axios')
380
+ const { data } = await axios.get(prefixUrl(String(this.resolve(this.spec.url, { value: '', confirmed: false }))))
381
+ const list = this.spec.path ? readPath(data, this.spec.path) : data
382
+ this.sourced = Array.isArray(list) ? list : []
383
+ } catch (e) {
384
+ this.noticeType = 'error'
385
+ this.notice = serverErrorMessage(e)
386
+ } finally {
387
+ this.loading = false
388
+ }
389
+ },
390
+
391
+ /**
392
+ * Choose a card (nothing is written until Save).
393
+ *
394
+ * @param {object} option The card.
395
+ */
396
+ choose(option) {
397
+ this.chosen = option.key
398
+ this.notice = ''
399
+ },
400
+
401
+ /**
402
+ * Save the chosen card: the configured request, else a PATCH of the field.
403
+ *
404
+ * @param {boolean} confirmed Whether the confirm action was pressed.
405
+ * @return {Promise<void>}
406
+ * @spec openspec/changes/abstract-choice-picker/specs/choice-picker/spec.md#requirement-a-risky-choice-is-saved-only-on-purpose
407
+ */
408
+ async save(confirmed) {
409
+ if (!this.selected) {
410
+ return
411
+ }
412
+ const request = this.content.save
413
+ if (!request && !this.objectUrl) {
414
+ this.noticeType = 'error'
415
+ this.notice = this.tr('This choice cannot be saved here.')
416
+ return
417
+ }
418
+ this.saving = true
419
+ this.notice = ''
420
+ try {
421
+ const { default: axios } = await import('@nextcloud/axios')
422
+ const vars = { value: this.selected.key, confirmed }
423
+ if (request && request.url) {
424
+ const method = String(request.method || 'POST').toLowerCase()
425
+ await axios[method](prefixUrl(String(this.resolve(request.url, vars))), this.resolve(request.payload || {}, vars))
426
+ } else {
427
+ await axios.patch(prefixUrl(this.objectUrl), { [this.content.field]: this.selected.key })
428
+ }
429
+ this.saved = this.selected.key
430
+ this.noticeType = 'success'
431
+ this.notice = this.tr(this.content.savedText || 'Saved.')
432
+ /**
433
+ * @event saved The choice was written.
434
+ * @type {{field: string, value: string, confirmed: boolean}}
435
+ */
436
+ this.$emit('saved', { field: this.content.field, value: this.saved, confirmed })
437
+ } catch (e) {
438
+ this.noticeType = 'error'
439
+ this.notice = serverErrorMessage(e)
440
+ } finally {
441
+ this.saving = false
442
+ }
443
+ },
444
+ },
445
+ }
446
+ </script>
447
+
448
+ <style scoped>
449
+ .cn-choice-picker {
450
+ display: flex;
451
+ flex-direction: column;
452
+ gap: 12px;
453
+ min-width: 0;
454
+ }
455
+
456
+ .cn-choice-picker__cards {
457
+ display: flex;
458
+ flex-wrap: wrap;
459
+ gap: 10px;
460
+ margin: 0;
461
+ padding: 0;
462
+ border: 0;
463
+ }
464
+
465
+ .cn-choice-picker__legend {
466
+ position: absolute;
467
+ width: 1px;
468
+ height: 1px;
469
+ overflow: hidden;
470
+ clip-path: inset(50%);
471
+ }
472
+
473
+ .cn-choice-picker__card {
474
+ position: relative;
475
+ display: flex;
476
+ flex: 1 1 220px;
477
+ align-items: center;
478
+ gap: 12px;
479
+ padding: 10px 12px;
480
+ border: 1px solid var(--cn-board-hairline, var(--color-border));
481
+ border-radius: 10px;
482
+ background: var(--color-main-background);
483
+ cursor: pointer;
484
+ }
485
+
486
+ .cn-choice-picker__card--selected {
487
+ padding: 9px 11px;
488
+ border: 2px solid var(--color-primary-element);
489
+ }
490
+
491
+ .cn-choice-picker__card--refused {
492
+ cursor: not-allowed;
493
+ opacity: 0.7;
494
+ }
495
+
496
+ .cn-choice-picker__card:focus-within {
497
+ outline: 2px solid var(--color-primary-element);
498
+ outline-offset: 2px;
499
+ }
500
+
501
+ .cn-choice-picker__input {
502
+ flex: 0 0 auto;
503
+ width: 18px;
504
+ height: 18px;
505
+ margin: 0;
506
+ accent-color: var(--color-primary-element);
507
+ }
508
+
509
+ .cn-choice-picker__swatches {
510
+ display: flex;
511
+ gap: 4px;
512
+ }
513
+
514
+ .cn-choice-picker__swatch {
515
+ width: 18px;
516
+ height: 18px;
517
+ box-sizing: border-box;
518
+ border: 1px solid var(--color-border);
519
+ border-radius: 4px;
520
+ }
521
+
522
+ .cn-choice-picker__text {
523
+ display: flex;
524
+ flex: 1 1 auto;
525
+ flex-direction: column;
526
+ gap: 2px;
527
+ min-width: 0;
528
+ }
529
+
530
+ .cn-choice-picker__title {
531
+ font-size: 15px;
532
+ }
533
+
534
+ .cn-choice-picker__refusal,
535
+ .cn-choice-picker__hint,
536
+ .cn-choice-picker__note {
537
+ margin: 0;
538
+ font-size: 13px;
539
+ color: var(--color-text-maxcontrast);
540
+ }
541
+
542
+ .cn-choice-picker__warning-intro {
543
+ margin: 0 0 4px;
544
+ }
545
+
546
+ .cn-choice-picker__warning-list {
547
+ margin: 0;
548
+ padding-inline-start: 18px;
549
+ }
550
+
551
+ .cn-choice-picker__actions {
552
+ display: flex;
553
+ flex-wrap: wrap;
554
+ gap: 8px;
555
+ }
556
+ </style>
@@ -0,0 +1,26 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ * SPDX-License-Identifier: EUPL-1.2
4
+ *
5
+ * CnChoicePickerWidget — self-registers the `choice-picker` widget type (choice
6
+ * cards with colour swatches and a state pill, and a confirm-with-warning save)
7
+ * into the shared dashboardWidgetRegistry at module load.
8
+ */
9
+
10
+ import CnChoicePickerWidget from './CnChoicePickerWidget.vue'
11
+ import { registerDashboardWidget } from '../CnWidgetGrid/dashboardWidgetRegistry.js'
12
+
13
+ registerDashboardWidget('choice-picker', {
14
+ renderer: CnChoicePickerWidget,
15
+ form: null,
16
+ defaultContent: {
17
+ field: '',
18
+ items: [],
19
+ },
20
+ displayName: 'Choice picker',
21
+ icon: 'Palette',
22
+ surfaces: ['detail-page'],
23
+ })
24
+
25
+ export { CnChoicePickerWidget }
26
+ export default CnChoicePickerWidget
@@ -452,6 +452,7 @@
452
452
  :layout="section.layout"
453
453
  :editable="section.editable"
454
454
  :columns="columns"
455
+ :columnOpts="gridColumnOpts"
455
456
  :cellHeight="cellHeight"
456
457
  :margin="gridMargin"
457
458
  :float="gridFloat"
@@ -933,6 +934,7 @@ import { normalizeLook } from '../../composables/useLook.js'
933
934
  import { pageViews } from '../../mixins/pageViews.js'
934
935
  import { mergeUserLayout, resolveUserLayoutApi } from '../../store/plugins/dashboardLayouts.js'
935
936
  import { isAppInstalled } from '../../utils/appInstalled.js'
937
+ import { contentHeaderLink } from '../../utils/contentHeaderLink.js'
936
938
  import { compareVisibleWhen, evaluateWorkspaceCondition, isWorkspaceCondition, readVisibleWhenValue } from '../../utils/visibleWhen.js'
937
939
  import { canonicalWidgetType } from '../../utils/widgetTypeAliases.js'
938
940
  import { CnActionButtons } from '../CnActionButtons/index.js'
@@ -2082,6 +2084,22 @@ export default {
2082
2084
  return this.compactDisplayLayout(visible)
2083
2085
  },
2084
2086
 
2087
+ /**
2088
+ * The responsive columns of the grid. Under the board look a phone gets
2089
+ * one column: below 640px the widgets stack in reading order (top to
2090
+ * bottom, then left to right) instead of squeezing six columns into
2091
+ * 150px. Without the board look the grid keeps its fixed columns.
2092
+ *
2093
+ * @return {object|null} GridStack `columnOpts`, or null.
2094
+ * @spec openspec/changes/screens-dashboard-phone/specs/dashboard-phone/spec.md#requirement-a-board-dashboard-stacks-on-a-phone
2095
+ */
2096
+ gridColumnOpts() {
2097
+ if (!this.isBoardLook) {
2098
+ return null
2099
+ }
2100
+ return { columnMax: this.columns, breakpoints: [{ w: 640, c: 1 }], layout: 'list', breakpointForWindow: true }
2101
+ },
2102
+
2085
2103
  /**
2086
2104
  * The layout this page renders: the user's arrangement when they have
2087
2105
  * one, the manifest otherwise.
@@ -3405,7 +3423,8 @@ export default {
3405
3423
  const layout = content.layout || props.layout || ''
3406
3424
  const title = layout === 'attention' ? (content.title || props.title || '') : ''
3407
3425
  return {
3408
- text: content.text || props.text || title || '',
3426
+ // A banner with only a bold `lead` has words to show too.
3427
+ text: content.text || props.text || content.lead || props.lead || title || '',
3409
3428
  visibleWhen: (def && def.visibleWhen) || content.visibleWhen || props.visibleWhen || null,
3410
3429
  layout,
3411
3430
  }
@@ -4130,7 +4149,12 @@ export default {
4130
4149
  return item.headerLink
4131
4150
  }
4132
4151
  const def = this.getWidgetDef(item.widgetId)
4133
- return (def?.headerLink && typeof def.headerLink === 'object') ? def.headerLink : null
4152
+ if (def?.headerLink && typeof def.headerLink === 'object') {
4153
+ return def.headerLink
4154
+ }
4155
+ // Last, what the widget's own content asks for (`headerLink`, or the
4156
+ // `viewAllRoute` / `viewAllLabel` of the tasks widget).
4157
+ return contentHeaderLink(item?.content) || contentHeaderLink(def?.content)
4134
4158
  },
4135
4159
 
4136
4160
  /**
@@ -4879,6 +4903,18 @@ export default {
4879
4903
  .cn-dashboard-page__content-item {
4880
4904
  width: 100%;
4881
4905
  }
4906
+ /* On a phone (screens-dashboard-phone) the touch targets of a board dashboard
4907
+ are 44px: the widget Actions menu and the KPI tiles. */
4908
+ @media (max-width: 640px) {
4909
+ .cn-dashboard-page--board :deep(.cn-widget-wrapper__actions .action-item__menutoggle),
4910
+ .cn-dashboard-page--board :deep(.cn-widget-wrapper__actions .button-vue) {
4911
+ min-height: 44px;
4912
+ }
4913
+
4914
+ .cn-dashboard-page--board .cn-dashboard-page__kpi-cell {
4915
+ min-height: 44px;
4916
+ }
4917
+ }
4882
4918
  </style>
4883
4919
 
4884
4920
  <!-- Unscoped, anchored on the stable `data-testid` NcActions renders on both
@@ -321,6 +321,7 @@
321
321
  :formatterOptions="col.formatterOptions || null"
322
322
  :widget="col.widget || null"
323
323
  :widgetProps="col.widgetProps || undefined"
324
+ :secondary="col.widget === 'avatar' ? secondaryValue(row, col) : ''"
324
325
  :format="columnFormat(col)"
325
326
  :row="row"
326
327
  :rowKey="rowKey" />
@@ -330,7 +331,7 @@
330
331
  of the row. Lets one column read "title" over "number ·
331
332
  requester" without a custom cell. -->
332
333
  <span
333
- v-if="secondaryValue(row, col)"
334
+ v-if="col.widget !== 'avatar' && secondaryValue(row, col)"
334
335
  class="cn-table-cell__secondary"
335
336
  data-testid="cn-cell-secondary">{{ secondaryValue(row, col) }}</span>
336
337
  <!-- A row that entered a file-content search through an attached