@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,555 @@
1
+ <!--
2
+ - SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ - SPDX-License-Identifier: EUPL-1.2
4
+ -->
5
+
6
+ <template>
7
+ <form class="cn-settings-form"
8
+ data-testid="cn-settings-form"
9
+ novalidate
10
+ @submit.prevent="save">
11
+ <div class="cn-settings-form__grid" :class="`cn-settings-form__grid--${columns}`">
12
+ <div
13
+ v-for="field in fields"
14
+ :key="field.key"
15
+ class="cn-settings-form__field"
16
+ :class="{ 'cn-settings-form__field--wide': field.wide }"
17
+ :data-testid="`cn-settings-form-field-${field.key}`">
18
+ <NcCheckboxRadioSwitch
19
+ v-if="field.type === 'switch'"
20
+ v-model="form[field.key]"
21
+ type="switch"
22
+ :disabled="field.readOnly">
23
+ {{ field.label }}
24
+ </NcCheckboxRadioSwitch>
25
+ <template v-else>
26
+ <label class="cn-settings-form__label" :for="inputId(field)">{{ field.label }}</label>
27
+ <div v-if="field.type === 'file'" class="cn-settings-form__file" :data-testid="`cn-settings-form-file-${field.key}`">
28
+ <span
29
+ :id="inputId(field)"
30
+ class="cn-settings-form__control cn-settings-form__control--file"
31
+ :class="{ 'cn-settings-form__control--placeholder': !form[field.key] }">
32
+ {{ form[field.key] ? fileName(form[field.key]) : field.placeholder }}
33
+ </span>
34
+ <NcButton
35
+ v-if="!field.readOnly"
36
+ variant="secondary"
37
+ :aria-describedby="inputId(field)"
38
+ :data-testid="`cn-settings-form-choose-${field.key}`"
39
+ @click="chooseFile(field)">
40
+ {{ field.chooseLabel }}
41
+ </NcButton>
42
+ <NcButton
43
+ v-if="!field.readOnly && form[field.key]"
44
+ variant="tertiary"
45
+ :data-testid="`cn-settings-form-clear-${field.key}`"
46
+ @click="form[field.key] = ''">
47
+ {{ field.clearLabel }}
48
+ </NcButton>
49
+ </div>
50
+ <select
51
+ v-else-if="field.type === 'select'"
52
+ :id="inputId(field)"
53
+ v-model="form[field.key]"
54
+ class="cn-settings-form__control"
55
+ :disabled="field.readOnly">
56
+ <option v-for="option in field.options" :key="String(option.value)" :value="option.value">
57
+ {{ option.label }}
58
+ </option>
59
+ </select>
60
+ <textarea
61
+ v-else-if="field.type === 'textarea'"
62
+ :id="inputId(field)"
63
+ v-model="form[field.key]"
64
+ class="cn-settings-form__control cn-settings-form__control--area"
65
+ :rows="field.rows"
66
+ :placeholder="field.placeholder"
67
+ :readonly="field.readOnly" />
68
+ <input
69
+ v-else
70
+ :id="inputId(field)"
71
+ v-model="form[field.key]"
72
+ class="cn-settings-form__control"
73
+ :type="field.type === 'number' ? 'number' : 'text'"
74
+ :min="field.min"
75
+ :max="field.max"
76
+ :placeholder="field.placeholder"
77
+ :readonly="field.readOnly"
78
+ :aria-invalid="errors[field.key] ? 'true' : null">
79
+ </template>
80
+ <p v-if="errors[field.key]" class="cn-settings-form__error" role="alert">
81
+ {{ errors[field.key] }}
82
+ </p>
83
+ <p v-else-if="field.hint" class="cn-settings-form__hint">
84
+ {{ field.hint }}
85
+ </p>
86
+ </div>
87
+ </div>
88
+
89
+ <NcNoteCard v-if="notice" :type="noticeType" data-testid="cn-settings-form-notice">
90
+ {{ notice }}
91
+ </NcNoteCard>
92
+
93
+ <div v-if="!readOnly" class="cn-settings-form__actions">
94
+ <NcButton
95
+ variant="primary"
96
+ type="submit"
97
+ :disabled="saving"
98
+ data-testid="cn-settings-form-save">
99
+ {{ saveLabel }}
100
+ </NcButton>
101
+ </div>
102
+ </form>
103
+ </template>
104
+
105
+ <script>
106
+ import { FilePickerClosed, getFilePickerBuilder } from '@nextcloud/dialogs'
107
+ import { translate as t } from '@nextcloud/l10n'
108
+ import { NcButton, NcCheckboxRadioSwitch, NcNoteCard } from '@nextcloud/vue'
109
+ import { changedFields, objectUrlOf, patchObject } from '../../utils/objectWrite.js'
110
+ import { readPath } from '../../utils/readPath.js'
111
+ import { serverErrorMessage } from '../../utils/serverErrorMessage.js'
112
+
113
+ // The native file-picker modal ships its chrome styles here; without this the
114
+ // spawned picker renders unstyled. Rules are scoped to the picker/dialog classes.
115
+ import '@nextcloud/dialogs/style.css'
116
+
117
+ const TYPES = ['text', 'textarea', 'select', 'switch', 'number', 'file']
118
+
119
+ let uid = 0
120
+
121
+ /**
122
+ * CnSettingsFormWidget — the `settings-form` widget: fields of the CURRENT
123
+ * object as a one- or two-column grid of framed inputs with light labels and
124
+ * hints, saved by ONE PATCH of the fields that changed since the last save.
125
+ *
126
+ * "Last save" is the baseline this widget keeps: a save moves it forward, so
127
+ * editing a field back to the value the page loaded with, after a save, still
128
+ * counts as a change (comparing against the loaded object skipped that PATCH).
129
+ *
130
+ * @spec openspec/changes/abstract-settings-form/specs/settings-form/spec.md
131
+ */
132
+ export default {
133
+ name: 'CnSettingsFormWidget',
134
+
135
+ components: { NcButton, NcCheckboxRadioSwitch, NcNoteCard },
136
+
137
+ inject: {
138
+ cnTranslate: { default: () => (key) => key },
139
+ },
140
+
141
+ props: {
142
+ /**
143
+ * The widget configuration.
144
+ *
145
+ * - `fields` (required) — `[{ key, label, type?: 'text'|'textarea'|'select'|'switch'|'number'|'file', picker?: { title?, mimeTypes?, startAt?, valueTemplate? }, chooseLabel?, clearLabel?, hint?, placeholder?, options?: [{ value, label }], required?, pattern?, patternMessage?, min?, max?, rows?, wide?, readOnly? }]`; `key` is a top-level field of the object.
146
+ * - `columns` — 1 or 2 (default 2).
147
+ * - `saveLabel`, `savedText`, `readOnly`.
148
+ *
149
+ * @type {object}
150
+ */
151
+ content: {
152
+ type: Object,
153
+ default: () => ({}),
154
+ },
155
+
156
+ /** The current detail-page object. */
157
+ objectData: {
158
+ type: Object,
159
+ default: null,
160
+ },
161
+
162
+ /** The current object id. */
163
+ objectId: {
164
+ type: [String, Number],
165
+ default: '',
166
+ },
167
+
168
+ /** OpenRegister register slug of the surface. */
169
+ register: {
170
+ type: [String, Object],
171
+ default: '',
172
+ },
173
+
174
+ /** OpenRegister schema slug of the surface. */
175
+ schema: {
176
+ type: [String, Object],
177
+ default: '',
178
+ },
179
+ },
180
+
181
+ emits: ['saved'],
182
+
183
+ data() {
184
+ uid += 1
185
+ return {
186
+ uid,
187
+ form: {},
188
+ baseline: {},
189
+ errors: {},
190
+ saving: false,
191
+ notice: '',
192
+ noticeType: 'success',
193
+ }
194
+ },
195
+
196
+ computed: {
197
+ /**
198
+ * The configured fields, normalised and translated.
199
+ *
200
+ * @return {Array<object>}
201
+ */
202
+ fields() {
203
+ const list = Array.isArray(this.content.fields) ? this.content.fields : []
204
+ return list
205
+ .filter((field) => field && field.key)
206
+ .map((field) => ({
207
+ ...field,
208
+ type: TYPES.includes(field.type) ? field.type : 'text',
209
+ label: this.tr(field.label || field.key),
210
+ hint: this.tr(field.hint || ''),
211
+ placeholder: this.tr(field.placeholder || ''),
212
+ chooseLabel: this.tr(field.chooseLabel || 'Choose a file'),
213
+ clearLabel: this.tr(field.clearLabel || 'Remove'),
214
+ rows: field.rows || 3,
215
+ wide: field.wide === true || field.type === 'textarea',
216
+ readOnly: this.readOnly || field.readOnly === true,
217
+ options: (Array.isArray(field.options) ? field.options : []).map((option) => (
218
+ typeof option === 'object' && option !== null
219
+ ? { value: option.value, label: this.tr(String(option.label ?? option.value)) }
220
+ : { value: option, label: this.tr(String(option)) }
221
+ )),
222
+ }))
223
+ },
224
+
225
+ columns() {
226
+ return this.content.columns === 1 ? 1 : 2
227
+ },
228
+
229
+ readOnly() {
230
+ return this.content.readOnly === true
231
+ },
232
+
233
+ saveLabel() {
234
+ return this.tr(this.content.saveLabel || 'Save')
235
+ },
236
+
237
+ /** The object URL the form is saved to, or '' when it is not known. */
238
+ objectUrl() {
239
+ return objectUrlOf(this.register, this.schema, this.objectId || (this.objectData && this.objectData.id))
240
+ },
241
+ },
242
+
243
+ watch: {
244
+ objectData() {
245
+ this.adopt()
246
+ },
247
+ },
248
+
249
+ created() {
250
+ this.adopt(true)
251
+ },
252
+
253
+ methods: {
254
+ tr(text) {
255
+ if (typeof text !== 'string' || text === '') {
256
+ return ''
257
+ }
258
+ return typeof this.cnTranslate === 'function' ? this.cnTranslate(text) : text
259
+ },
260
+
261
+ inputId(field) {
262
+ return `cn-settings-form-${this.uid}-${field.key}`
263
+ },
264
+
265
+ /**
266
+ * The name shown for a stored file value: its last path segment.
267
+ *
268
+ * @param {string} value The stored value.
269
+ * @return {string}
270
+ */
271
+ fileName(value) {
272
+ const text = String(value)
273
+ return text.split('/').filter(Boolean).pop() || text
274
+ },
275
+
276
+ /**
277
+ * Open the Nextcloud file picker and take the chosen file as the value.
278
+ * `field.picker` = `{ title?, mimeTypes?, startAt?, valueTemplate? }`; the
279
+ * template's `{path}`, `{fileid}` and `{name}` are filled from the chosen
280
+ * node (default `{path}`).
281
+ *
282
+ * @param {object} field The field.
283
+ * @return {Promise<void>}
284
+ * @spec openspec/changes/abstract-settings-form-file/specs/settings-form/spec.md#requirement-a-file-field-takes-a-file-from-the-nextcloud-picker
285
+ */
286
+ async chooseFile(field) {
287
+ const picker = field.picker || {}
288
+ let builder = getFilePickerBuilder(this.tr(picker.title || field.label))
289
+ .setMultiSelect(false)
290
+ .allowDirectories(false)
291
+ .startAt(picker.startAt || '/')
292
+ .addButton({ label: t('nextcloud-vue', 'Choose'), type: 'primary', callback: () => {} })
293
+ if (Array.isArray(picker.mimeTypes) && picker.mimeTypes.length > 0) {
294
+ builder = builder.setMimeTypeFilter(picker.mimeTypes)
295
+ }
296
+ try {
297
+ const nodes = await builder.build().pickNodes()
298
+ const node = Array.isArray(nodes) ? nodes[0] : nodes
299
+ if (!node) {
300
+ return
301
+ }
302
+ this.form[field.key] = String(picker.valueTemplate || '{path}').replace(/\{(path|fileid|name)\}/g, (match, part) => {
303
+ const value = part === 'name' ? (node.basename || this.fileName(node.path)) : node[part]
304
+ return value === undefined || value === null ? '' : String(value)
305
+ })
306
+ } catch (e) {
307
+ if (e instanceof FilePickerClosed) {
308
+ return
309
+ }
310
+ this.noticeType = 'error'
311
+ this.notice = this.tr('The file picker could not be opened.')
312
+ }
313
+ },
314
+
315
+ /**
316
+ * The stored value of a field as the input holds it.
317
+ *
318
+ * @param {object} field The field.
319
+ * @return {unknown}
320
+ */
321
+ stored(field) {
322
+ const value = readPath(this.objectData || {}, field.key)
323
+ if (field.type === 'switch') {
324
+ return value === true
325
+ }
326
+ return value === undefined || value === null ? '' : value
327
+ },
328
+
329
+ /**
330
+ * Take the object's values as the new baseline; a field the person has
331
+ * not touched follows it, a touched one keeps the typed value.
332
+ *
333
+ * @param {boolean} [first] Whether this is the first read.
334
+ */
335
+ adopt(first = false) {
336
+ const baseline = {}
337
+ const form = {}
338
+ for (const field of this.fields) {
339
+ baseline[field.key] = this.stored(field)
340
+ const untouched = first || JSON.stringify(this.form[field.key]) === JSON.stringify(this.baseline[field.key])
341
+ form[field.key] = untouched ? baseline[field.key] : this.form[field.key]
342
+ }
343
+ this.baseline = baseline
344
+ this.form = form
345
+ },
346
+
347
+ /**
348
+ * The value to send for a field: numbers as numbers, an empty value as null.
349
+ *
350
+ * @param {object} field The field.
351
+ * @return {unknown}
352
+ */
353
+ outgoing(field) {
354
+ const value = this.form[field.key]
355
+ if (field.type === 'number') {
356
+ return value === '' || value === null || value === undefined ? null : Number(value)
357
+ }
358
+ if (field.type === 'switch') {
359
+ return value === true
360
+ }
361
+ if (typeof value === 'string') {
362
+ return value.trim() === '' ? null : value.trim()
363
+ }
364
+ return value
365
+ },
366
+
367
+ /**
368
+ * Check required, number range and pattern before any write.
369
+ *
370
+ * @return {object} Error text by field key.
371
+ */
372
+ validate() {
373
+ const errors = {}
374
+ for (const field of this.fields) {
375
+ if (field.type === 'switch' || field.readOnly) {
376
+ continue
377
+ }
378
+ const value = this.outgoing(field)
379
+ const empty = value === null || value === undefined || value === ''
380
+ if (empty) {
381
+ if (field.required) {
382
+ errors[field.key] = this.tr('Fill this in.')
383
+ }
384
+ continue
385
+ }
386
+ if (field.type === 'number') {
387
+ if (Number.isNaN(value) || (field.min !== undefined && value < field.min) || (field.max !== undefined && value > field.max)) {
388
+ errors[field.key] = this.tr('This number is not valid.')
389
+ }
390
+ } else if (field.pattern && !new RegExp(field.pattern).test(String(value))) {
391
+ errors[field.key] = this.tr(field.patternMessage || 'This value is not valid.')
392
+ }
393
+ }
394
+ return errors
395
+ },
396
+
397
+ /**
398
+ * Save: PATCH the fields that differ from the last saved values.
399
+ *
400
+ * @return {Promise<void>}
401
+ * @spec openspec/changes/abstract-settings-form/specs/settings-form/spec.md#requirement-a-settings-form-saves-only-what-changed
402
+ */
403
+ async save() {
404
+ this.errors = this.validate()
405
+ this.notice = ''
406
+ if (Object.keys(this.errors).length > 0) {
407
+ return
408
+ }
409
+ if (!this.objectUrl) {
410
+ this.noticeType = 'error'
411
+ this.notice = this.tr('This form cannot be saved here.')
412
+ return
413
+ }
414
+ const next = {}
415
+ const compare = {}
416
+ for (const field of this.fields) {
417
+ if (!field.readOnly) {
418
+ next[field.key] = this.outgoing(field)
419
+ compare[field.key] = this.baseline[field.key] === '' ? null : this.baseline[field.key]
420
+ }
421
+ }
422
+ const patch = changedFields(compare, next)
423
+ this.saving = true
424
+ try {
425
+ if (Object.keys(patch).length > 0) {
426
+ const data = await patchObject(this.objectUrl, patch)
427
+ const baseline = { ...this.baseline }
428
+ const form = { ...this.form }
429
+ for (const key of Object.keys(patch)) {
430
+ const field = this.fields.find((candidate) => candidate.key === key)
431
+ const written = data[key] !== undefined ? data[key] : patch[key]
432
+ baseline[key] = field.type === 'switch' ? written === true : (written === null ? '' : written)
433
+ form[key] = baseline[key]
434
+ }
435
+ this.baseline = baseline
436
+ this.form = form
437
+ }
438
+ this.noticeType = 'success'
439
+ this.notice = this.tr(this.content.savedText || 'Saved.')
440
+ /**
441
+ * @event saved The changed fields were written.
442
+ * @type {{patch: object}}
443
+ */
444
+ this.$emit('saved', { patch })
445
+ } catch (e) {
446
+ this.noticeType = 'error'
447
+ this.notice = serverErrorMessage(e)
448
+ } finally {
449
+ this.saving = false
450
+ }
451
+ },
452
+ },
453
+ }
454
+ </script>
455
+
456
+ <style scoped>
457
+ .cn-settings-form {
458
+ display: flex;
459
+ flex-direction: column;
460
+ gap: 14px;
461
+ min-width: 0;
462
+ }
463
+
464
+ .cn-settings-form__grid {
465
+ display: grid;
466
+ gap: 14px 16px;
467
+ grid-template-columns: minmax(0, 1fr);
468
+ }
469
+
470
+ .cn-settings-form__grid--2 {
471
+ grid-template-columns: repeat(2, minmax(0, 1fr));
472
+ }
473
+
474
+ .cn-settings-form__field {
475
+ display: flex;
476
+ flex-direction: column;
477
+ gap: 4px;
478
+ min-width: 0;
479
+ }
480
+
481
+ .cn-settings-form__field--wide {
482
+ grid-column: 1 / -1;
483
+ }
484
+
485
+ .cn-settings-form__label {
486
+ font-size: 13px;
487
+ font-weight: 600;
488
+ color: var(--color-main-text);
489
+ }
490
+
491
+ .cn-settings-form__control {
492
+ width: 100%;
493
+ box-sizing: border-box;
494
+ height: var(--cn-board-control-height, 40px);
495
+ padding: 0 12px;
496
+ border: 1px solid var(--color-border-maxcontrast);
497
+ border-radius: var(--cn-board-control-radius, 8px);
498
+ background: var(--color-main-background);
499
+ color: var(--color-main-text);
500
+ font: inherit;
501
+ }
502
+
503
+ .cn-settings-form__file {
504
+ display: flex;
505
+ flex-wrap: wrap;
506
+ align-items: center;
507
+ gap: 8px;
508
+ }
509
+
510
+ .cn-settings-form__control--file {
511
+ display: inline-flex;
512
+ align-items: center;
513
+ width: auto;
514
+ min-width: 120px;
515
+ flex: 0 1 auto;
516
+ overflow-wrap: anywhere;
517
+ }
518
+
519
+ .cn-settings-form__control--placeholder {
520
+ color: var(--color-text-maxcontrast);
521
+ }
522
+
523
+ .cn-settings-form__control--area {
524
+ height: auto;
525
+ padding: 10px 12px;
526
+ resize: vertical;
527
+ }
528
+
529
+ .cn-settings-form__control:focus-visible {
530
+ outline: 2px solid var(--color-primary-element);
531
+ outline-offset: 1px;
532
+ }
533
+
534
+ .cn-settings-form__hint,
535
+ .cn-settings-form__error {
536
+ margin: 0;
537
+ font-size: 12px;
538
+ color: var(--color-text-maxcontrast);
539
+ }
540
+
541
+ .cn-settings-form__error {
542
+ color: var(--color-error-text);
543
+ }
544
+
545
+ .cn-settings-form__actions {
546
+ display: flex;
547
+ justify-content: flex-start;
548
+ }
549
+
550
+ @media (max-width: 640px) {
551
+ .cn-settings-form__grid--2 {
552
+ grid-template-columns: minmax(0, 1fr);
553
+ }
554
+ }
555
+ </style>
@@ -0,0 +1,26 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ * SPDX-License-Identifier: EUPL-1.2
4
+ *
5
+ * CnSettingsFormWidget — self-registers the `settings-form` widget type (framed
6
+ * inputs for fields of the current object, saved by PATCH of the changed
7
+ * fields) into the shared dashboardWidgetRegistry at module load.
8
+ */
9
+
10
+ import CnSettingsFormWidget from './CnSettingsFormWidget.vue'
11
+ import { registerDashboardWidget } from '../CnWidgetGrid/dashboardWidgetRegistry.js'
12
+
13
+ registerDashboardWidget('settings-form', {
14
+ renderer: CnSettingsFormWidget,
15
+ form: null,
16
+ defaultContent: {
17
+ fields: [],
18
+ columns: 2,
19
+ },
20
+ displayName: 'Settings form',
21
+ icon: 'FormTextbox',
22
+ surfaces: ['detail-page'],
23
+ })
24
+
25
+ export { CnSettingsFormWidget }
26
+ export default CnSettingsFormWidget