@conduction/nextcloud-vue 2.98.0 → 2.100.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 (141) hide show
  1. package/dist/esm/components/CnActionsMenu/CnActionsMenu.vue2.js +12 -0
  2. package/dist/esm/components/CnActionsMenu/CnActionsMenu.vue2.js.map +1 -1
  3. package/dist/esm/components/CnActionsMenu/CnActionsMenu.vue3.js +11 -2
  4. package/dist/esm/components/CnActionsMenu/CnActionsMenu.vue3.js.map +1 -1
  5. package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue.js +1 -1
  6. package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue2.js +6 -1
  7. package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue2.js.map +1 -1
  8. package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue3.js +2 -1
  9. package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue3.js.map +1 -1
  10. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue.js +1 -1
  11. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue2.js.map +1 -1
  12. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue3.js +1 -1
  13. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue3.js.map +1 -1
  14. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +25 -8
  15. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  16. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +7 -4
  17. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  18. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue.js +1 -1
  19. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js +11 -8
  20. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js.map +1 -1
  21. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js +13 -4
  22. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js.map +1 -1
  23. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue.js +1 -1
  24. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js +54 -3
  25. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js.map +1 -1
  26. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js +16 -2
  27. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js.map +1 -1
  28. package/dist/esm/components/CnFlowsPage/CnFlowBoard.vue.js +1 -1
  29. package/dist/esm/components/CnFlowsPage/CnFlowBoard.vue2.js +42 -7
  30. package/dist/esm/components/CnFlowsPage/CnFlowBoard.vue2.js.map +1 -1
  31. package/dist/esm/components/CnFlowsPage/CnFlowBoard.vue3.js +121 -57
  32. package/dist/esm/components/CnFlowsPage/CnFlowBoard.vue3.js.map +1 -1
  33. package/dist/esm/components/CnFlowsPage/CnFlowEditorPage.vue2.js +10 -0
  34. package/dist/esm/components/CnFlowsPage/CnFlowEditorPage.vue2.js.map +1 -1
  35. package/dist/esm/components/CnFlowsPage/CnFlowEditorPage.vue3.js +2 -1
  36. package/dist/esm/components/CnFlowsPage/CnFlowEditorPage.vue3.js.map +1 -1
  37. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +136 -7
  38. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  39. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  40. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue.js +1 -1
  41. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue2.js +26 -7
  42. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue2.js.map +1 -1
  43. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue3.js +78 -17
  44. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue3.js.map +1 -1
  45. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue.js +1 -1
  46. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js +68 -7
  47. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js.map +1 -1
  48. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js +256 -240
  49. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js.map +1 -1
  50. package/dist/esm/components/CnSettingsField/CnSettingsField.vue.js +9 -0
  51. package/dist/esm/components/CnSettingsField/CnSettingsField.vue.js.map +1 -0
  52. package/dist/esm/components/CnSettingsField/CnSettingsField.vue2.js +265 -0
  53. package/dist/esm/components/CnSettingsField/CnSettingsField.vue2.js.map +1 -0
  54. package/dist/esm/components/CnSettingsField/CnSettingsField.vue3.js +191 -0
  55. package/dist/esm/components/CnSettingsField/CnSettingsField.vue3.js.map +1 -0
  56. package/dist/esm/components/CnSettingsFieldsSection/CnSettingsFieldsSection.vue.js +9 -0
  57. package/dist/esm/components/CnSettingsFieldsSection/CnSettingsFieldsSection.vue.js.map +1 -0
  58. package/dist/esm/components/CnSettingsFieldsSection/CnSettingsFieldsSection.vue2.js +537 -0
  59. package/dist/esm/components/CnSettingsFieldsSection/CnSettingsFieldsSection.vue2.js.map +1 -0
  60. package/dist/esm/components/CnSettingsFieldsSection/CnSettingsFieldsSection.vue3.js +99 -0
  61. package/dist/esm/components/CnSettingsFieldsSection/CnSettingsFieldsSection.vue3.js.map +1 -0
  62. package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue.js +1 -1
  63. package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue2.js +18 -70
  64. package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue2.js.map +1 -1
  65. package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue3.js +6 -44
  66. package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue3.js.map +1 -1
  67. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue.js +1 -1
  68. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js +16 -2
  69. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js.map +1 -1
  70. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue3.js +40 -25
  71. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue3.js.map +1 -1
  72. package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js +1 -0
  73. package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js.map +1 -1
  74. package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue.js +1 -1
  75. package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue2.js +7 -7
  76. package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue2.js.map +1 -1
  77. package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue3.js.map +1 -1
  78. package/dist/esm/components/CnWorkingCalendarWidget/CnWorkingCalendarWidget.vue.js +9 -0
  79. package/dist/esm/components/CnWorkingCalendarWidget/CnWorkingCalendarWidget.vue.js.map +1 -0
  80. package/dist/esm/components/CnWorkingCalendarWidget/CnWorkingCalendarWidget.vue2.js +472 -0
  81. package/dist/esm/components/CnWorkingCalendarWidget/CnWorkingCalendarWidget.vue2.js.map +1 -0
  82. package/dist/esm/components/CnWorkingCalendarWidget/CnWorkingCalendarWidget.vue3.js +231 -0
  83. package/dist/esm/components/CnWorkingCalendarWidget/CnWorkingCalendarWidget.vue3.js.map +1 -0
  84. package/dist/esm/components/CnWorkingCalendarWidget/index.js +27 -0
  85. package/dist/esm/components/CnWorkingCalendarWidget/index.js.map +1 -0
  86. package/dist/esm/composables/useFlowStore.js +2 -1
  87. package/dist/esm/composables/useFlowStore.js.map +1 -1
  88. package/dist/esm/index.js +3 -0
  89. package/dist/esm/index.js.map +1 -1
  90. package/dist/esm/l10n/en.json.js +101 -1
  91. package/dist/esm/l10n/en.json.js.map +1 -1
  92. package/dist/esm/l10n/nl.json.js +115 -1
  93. package/dist/esm/l10n/nl.json.js.map +1 -1
  94. package/dist/esm/library-style.js +1 -1
  95. package/dist/esm/nextcloud-vue.css +999 -233
  96. package/dist/esm/utils/libraryWidgetKeys.js +1 -0
  97. package/dist/esm/utils/libraryWidgetKeys.js.map +1 -1
  98. package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
  99. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  100. package/dist/esm/utils/workingCalendarMonth.js +85 -0
  101. package/dist/esm/utils/workingCalendarMonth.js.map +1 -0
  102. package/dist/nextcloud-vue.cjs.js +10314 -7778
  103. package/dist/nextcloud-vue.cjs.js.map +2 -2
  104. package/dist/nextcloud-vue.css +999 -233
  105. package/l10n/en.json +101 -1
  106. package/l10n/nl.json +115 -1
  107. package/package.json +1 -1
  108. package/src/components/CnActionsMenu/CnActionsMenu.vue +14 -1
  109. package/src/components/CnAdminSettingsShell/CnAdminSettingsShell.vue +7 -1
  110. package/src/components/CnBreadcrumbs/CnBreadcrumbs.vue +1 -0
  111. package/src/components/CnDetailPage/CnDetailPage.vue +29 -9
  112. package/src/components/CnDetailWidgetHost/CnDetailWidgetHost.vue +13 -4
  113. package/src/components/CnFactsWidget/CnFactsWidget.vue +65 -3
  114. package/src/components/CnFlowsPage/CnFlowBoard.vue +77 -2
  115. package/src/components/CnFlowsPage/CnFlowEditorPage.vue +11 -0
  116. package/src/components/CnIndexPage/CnIndexPage.vue +136 -7
  117. package/src/components/CnOrderedListField/CnOrderedListField.vue +81 -9
  118. package/src/components/CnRecordListWidget/CnRecordListWidget.vue +114 -7
  119. package/src/components/CnSettingsField/CnSettingsField.vue +449 -0
  120. package/src/components/CnSettingsField/index.js +4 -0
  121. package/src/components/CnSettingsFieldsSection/CnSettingsFieldsSection.vue +642 -0
  122. package/src/components/CnSettingsFieldsSection/index.js +4 -0
  123. package/src/components/CnSettingsPage/CnSettingsPage.vue +6 -86
  124. package/src/components/CnSetupWizard/CnSetupWizard.vue +37 -3
  125. package/src/components/CnWidgetGrid/registerDashboardWidgets.js +1 -0
  126. package/src/components/CnWizardDialog/CnWizardDialog.vue +7 -7
  127. package/src/components/CnWorkingCalendarWidget/CnWorkingCalendarWidget.vue +725 -0
  128. package/src/components/CnWorkingCalendarWidget/index.js +26 -0
  129. package/src/components/index.js +3 -0
  130. package/src/composables/useFlowStore.js +2 -1
  131. package/src/css/badge.css +32 -16
  132. package/src/css/detail-page.css +15 -0
  133. package/src/css/dialog.css +48 -0
  134. package/src/css/look-board-detail.css +80 -8
  135. package/src/css/look-board-index.css +52 -8
  136. package/src/css/look-board.css +301 -1
  137. package/src/index.js +3 -0
  138. package/src/schemas/app-manifest-v2.schema.json +25 -1
  139. package/src/utils/libraryWidgetKeys.js +1 -0
  140. package/src/utils/validateManifestV2.compiled.js +3 -3
  141. package/src/utils/workingCalendarMonth.js +82 -0
@@ -0,0 +1,642 @@
1
+ <!--
2
+ - SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ - SPDX-License-Identifier: EUPL-1.2
4
+ -->
5
+
6
+ <template>
7
+ <CnSettingsSection
8
+ :name="name"
9
+ :description="description"
10
+ :docUrl="docUrl"
11
+ :wide="wide"
12
+ :loading="loading"
13
+ :error="loadError !== ''"
14
+ :errorMessage="loadError"
15
+ :onRetry="load"
16
+ data-testid="cn-settings-fields-section">
17
+ <form v-if="!loading && loadError === ''"
18
+ class="cn-settings-fields"
19
+ novalidate
20
+ @submit.prevent="save">
21
+ <div class="cn-settings-fields__grid" :class="`cn-settings-fields__grid--${columnCount}`">
22
+ <CnSettingsField
23
+ v-for="field in resolvedFields"
24
+ :key="field.key"
25
+ class="cn-settings-fields__field"
26
+ :class="{ 'cn-settings-fields__field--wide': field.wide }"
27
+ :field="field"
28
+ :modelValue="form[field.key]"
29
+ :error="errors[field.key] || unreadable[field.key] || ''"
30
+ :hint="field.hint"
31
+ @update:modelValue="setValue(field, $event)"
32
+ @uploaded="onUploaded(field, $event)" />
33
+ </div>
34
+
35
+ <NcNoteCard v-if="notice"
36
+ :type="notice.type"
37
+ :role="notice.type === 'error' ? 'alert' : 'status'"
38
+ data-testid="cn-settings-fields-notice">
39
+ {{ notice.text }}
40
+ </NcNoteCard>
41
+
42
+ <div class="cn-settings-section__save cn-settings-fields__save">
43
+ <span v-if="dirty" class="cn-settings-fields__dirty" data-testid="cn-settings-fields-dirty">
44
+ {{ t('nextcloud-vue', 'Unsaved changes') }}
45
+ </span>
46
+ <NcButton
47
+ variant="primary"
48
+ type="submit"
49
+ :disabled="saving || !dirty"
50
+ data-testid="cn-settings-fields-save">
51
+ <template v-if="saving" #icon>
52
+ <NcLoadingIcon :size="20" />
53
+ </template>
54
+ {{ resolvedSaveLabel }}
55
+ </NcButton>
56
+ </div>
57
+ </form>
58
+ </CnSettingsSection>
59
+ </template>
60
+
61
+ <script>
62
+ import axios from '@nextcloud/axios'
63
+ import { translate as t } from '@nextcloud/l10n'
64
+ import { NcButton, NcLoadingIcon, NcNoteCard } from '@nextcloud/vue'
65
+ import { prefixUrl } from '../../utils/headers.js'
66
+ import { readPath } from '../../utils/readPath.js'
67
+ import { serverErrorMessage } from '../../utils/serverErrorMessage.js'
68
+ import { CnSettingsField } from '../CnSettingsField/index.js'
69
+ import { CnSettingsSection } from '../CnSettingsSection/index.js'
70
+
71
+ /**
72
+ * A value by key: the key as written when the object holds it (a flat map
73
+ * with dotted keys such as `integration.brk.mode`), else as a dotted path.
74
+ *
75
+ * @param {object} data The object.
76
+ * @param {string} key The key or dotted path.
77
+ * @return {unknown}
78
+ */
79
+ function literalFirst(data, key) {
80
+ if (data && typeof data === 'object' && Object.hasOwn(data, key)) {
81
+ return data[key]
82
+ }
83
+ return readPath(data, key)
84
+ }
85
+
86
+ /**
87
+ * The request options for a target: an OCS endpoint (`/ocs/...`) needs the
88
+ * `OCS-APIRequest` header; `prefixUrl` already serves it outside index.php.
89
+ *
90
+ * @param {string} url The target.
91
+ * @return {object}
92
+ */
93
+ function ocsConfig(url) {
94
+ return /^\/ocs\//.test(String(url)) ? { headers: { 'OCS-APIRequest': 'true' } } : {}
95
+ }
96
+
97
+ /**
98
+ * Up to two initials of a name, for an image field without an image.
99
+ *
100
+ * @param {unknown} name The name.
101
+ * @return {string}
102
+ */
103
+ function initialsOf(name) {
104
+ return String(name || '').trim().split(/\s+/).filter(Boolean).slice(0, 2).map((word) => word[0].toUpperCase()).join('')
105
+ }
106
+
107
+ /**
108
+ * CnSettingsFieldsSection - a settings section declared as fields: it reads
109
+ * the app's settings, draws the fields in one to three columns, and saves
110
+ * them with ONE Save button for the section.
111
+ *
112
+ * It reads `GET url` (default `/apps/<appId>/api/settings`) and writes the
113
+ * section's fields with `PUT saveUrl` (default the same url). The Save button
114
+ * is on while something changed ("Unsaved changes"); a save checks the
115
+ * fields first (required, pattern, min, max) and marks each failing field,
116
+ * then says it saved or why it did not. A `secret` field never shows the
117
+ * stored value: it says a value is stored, and is sent only when something
118
+ * was typed.
119
+ *
120
+ * It renders inside CnAdminSettingsShell and CnSettingsPage, in the default
121
+ * and the board look.
122
+ *
123
+ * ```vue
124
+ * <CnSettingsFieldsSection
125
+ * app-id="myapp"
126
+ * name="Connection"
127
+ * :columns="2"
128
+ * :fields="[
129
+ * { key: 'baseUrl', label: 'Server address', required: true, pattern: '^https://', patternMessage: 'Starts with https://' },
130
+ * { key: 'apiKey', label: 'API key', type: 'secret' },
131
+ * { key: 'mode', label: 'Mode', type: 'select', options: [{ value: 'live', label: 'Live' }, { value: 'test', label: 'Test' }] },
132
+ * { key: 'enabled', label: 'Enabled', type: 'switch', hint: 'Off stops every call.' },
133
+ * ]" />
134
+ * ```
135
+ */
136
+ export default {
137
+ name: 'CnSettingsFieldsSection',
138
+
139
+ components: { CnSettingsField, CnSettingsSection, NcButton, NcLoadingIcon, NcNoteCard },
140
+
141
+ inject: {
142
+ /** The app's translate function; identity outside CnAppRoot. */
143
+ cnTranslate: { default: () => (key) => key },
144
+ },
145
+
146
+ props: {
147
+ /** The section title. */
148
+ name: {
149
+ type: String,
150
+ required: true,
151
+ },
152
+
153
+ /** The line under the title. */
154
+ description: {
155
+ type: String,
156
+ default: '',
157
+ },
158
+
159
+ /** A documentation link beside the title. */
160
+ docUrl: {
161
+ type: String,
162
+ default: '',
163
+ },
164
+
165
+ /** Span the whole settings grid in the board look. */
166
+ wide: {
167
+ type: Boolean,
168
+ default: false,
169
+ },
170
+
171
+ /**
172
+ * The fields: `[{ key, label, type?, hint?, placeholder?, required?,
173
+ * pattern?, patternMessage?, min?, max?, rows?, options?, optionsUrl?,
174
+ * optionsPath?, optionLabel?, optionValue?, default?, readOnly?, wide?,
175
+ * url?, valuePath?, saveUrl?, saveMethod?, saveKey?, saveParams?, uploadUrl?,
176
+ * uploadField?, uploadParams?, uploadValuePath?, initials?, initialsFrom? }]`.
177
+ * `type` is `text` (default), `textarea`, `number`, `switch`, `select`,
178
+ * `secret` or `image`. `key` names the setting; a dotted key reads a
179
+ * nested value and is written as written. A field with its own `url`
180
+ * is read there (at `valuePath`, default its key) and saved to its
181
+ * `saveUrl` (default that `url`) with `saveMethod` (default POST) under
182
+ * `saveKey` (default its key), with `saveParams` merged into the body,
183
+ * so one section can hold the settings of several apps. A target under
184
+ * `/ocs/` is sent the `OCS-APIRequest` header. An `image` uploads at once to `uploadUrl`. Labels,
185
+ * hints, placeholders and messages go through the app's translate
186
+ * function.
187
+ *
188
+ * @type {Array<object>}
189
+ * @spec openspec/changes/settings-fields-section/specs/settings-fields-section/spec.md#requirement-a-section-declared-as-fields-reads-and-saves-the-apps-settings
190
+ */
191
+ fields: {
192
+ type: Array,
193
+ required: true,
194
+ },
195
+
196
+ /** Columns of the field grid: 1, 2 or 3. A narrow card stacks them. */
197
+ columns: {
198
+ type: Number,
199
+ default: 1,
200
+ validator: (value) => [1, 2, 3].includes(value),
201
+ },
202
+
203
+ /** The app id, for the default url `/apps/<appId>/api/settings`. */
204
+ appId: {
205
+ type: String,
206
+ default: '',
207
+ },
208
+
209
+ /** Where the settings are read (`GET`); app-absolute or a full URL. */
210
+ url: {
211
+ type: String,
212
+ default: '',
213
+ },
214
+
215
+ /** Where the section saves; defaults to `url`. */
216
+ saveUrl: {
217
+ type: String,
218
+ default: '',
219
+ },
220
+
221
+ /** The method of the save request. */
222
+ saveMethod: {
223
+ type: String,
224
+ default: 'PUT',
225
+ validator: (value) => ['PUT', 'POST', 'PATCH'].includes(String(value).toUpperCase()),
226
+ },
227
+
228
+ /** A dotted path to the values in the GET answer; empty reads the answer itself. */
229
+ valuesPath: {
230
+ type: String,
231
+ default: '',
232
+ },
233
+
234
+ /** The Save button's label. Defaults to "Save". */
235
+ saveLabel: {
236
+ type: String,
237
+ default: '',
238
+ },
239
+ },
240
+
241
+ emits: ['saved', 'save-error'],
242
+
243
+ data() {
244
+ return {
245
+ form: {},
246
+ baseline: {},
247
+ storedSecrets: {},
248
+ // Field key to the message of a read target that failed.
249
+ unreadable: {},
250
+ errors: {},
251
+ loading: true,
252
+ loadError: '',
253
+ saving: false,
254
+ notice: null,
255
+ }
256
+ },
257
+
258
+ computed: {
259
+ /** @return {number} The column count, 1 to 3. */
260
+ columnCount() {
261
+ return [1, 2, 3].includes(this.columns) ? this.columns : 1
262
+ },
263
+
264
+ /** @return {string} Where the settings are read: `url`, else the app's settings endpoint. */
265
+ readTarget() {
266
+ return this.url || (this.appId ? `/apps/${this.appId}/api/settings` : '')
267
+ },
268
+
269
+ /** @return {string} Where the section saves: `saveUrl`, else the read target. */
270
+ writeTarget() {
271
+ return this.saveUrl || this.readTarget
272
+ },
273
+
274
+ /**
275
+ * The declared fields with their text translated and a secret's stored state.
276
+ *
277
+ * @return {Array<object>}
278
+ */
279
+ resolvedFields() {
280
+ return (Array.isArray(this.fields) ? this.fields : [])
281
+ .filter((field) => field && typeof field.key === 'string' && field.key !== '')
282
+ .map((field) => ({
283
+ ...field,
284
+ label: this.tr(field.label || field.key),
285
+ hint: this.tr(field.hint || ''),
286
+ placeholder: this.tr(field.placeholder || ''),
287
+ secretSet: field.type === 'secret' ? !!this.storedSecrets[field.key] : undefined,
288
+ initials: field.type === 'image' ? initialsOf(field.initialsFrom ? this.form[field.initialsFrom] : field.initials) : undefined,
289
+ readOnly: field.readOnly || !!this.unreadable[field.key],
290
+ }))
291
+ },
292
+
293
+ /**
294
+ * Whether something changed since the last load or save.
295
+ *
296
+ * @return {boolean}
297
+ * @spec openspec/changes/settings-fields-section/specs/settings-fields-section/spec.md#requirement-one-save-per-section-with-a-dirty-state
298
+ */
299
+ dirty() {
300
+ return this.resolvedFields.some((field) => field.type !== 'image' && !this.same(this.form[field.key], this.baseline[field.key]))
301
+ },
302
+
303
+ /** @return {string} The Save button's label. */
304
+ resolvedSaveLabel() {
305
+ return this.saveLabel ? this.tr(this.saveLabel) : t('nextcloud-vue', 'Save')
306
+ },
307
+ },
308
+
309
+ mounted() {
310
+ this.load()
311
+ },
312
+
313
+ methods: {
314
+ t,
315
+
316
+ /**
317
+ * @param {string} value A declared string.
318
+ * @return {string} It through the app's translate function.
319
+ */
320
+ tr(value) {
321
+ return typeof value === 'string' && value !== '' ? this.cnTranslate(value) : (value || '')
322
+ },
323
+
324
+ /**
325
+ * @param {unknown} a A value.
326
+ * @param {unknown} b Another.
327
+ * @return {boolean} Whether they count as the same setting.
328
+ */
329
+ same(a, b) {
330
+ const empty = (v) => v === null || v === undefined || v === ''
331
+ if (empty(a) && empty(b)) {
332
+ return true
333
+ }
334
+ return JSON.stringify(a) === JSON.stringify(b)
335
+ },
336
+
337
+ /**
338
+ * Change a field's value; its error goes until the next save.
339
+ *
340
+ * @param {object} field The field.
341
+ * @param {unknown} value The value.
342
+ * @return {void}
343
+ */
344
+ setValue(field, value) {
345
+ this.form = { ...this.form, [field.key]: value }
346
+ if (this.errors[field.key]) {
347
+ const { [field.key]: _gone, ...rest } = this.errors
348
+ this.errors = rest
349
+ }
350
+ this.notice = null
351
+ },
352
+
353
+ /**
354
+ * Read the settings and fill the fields; a secret's value is never put
355
+ * in the form, only whether one is stored.
356
+ *
357
+ * @return {Promise<void>}
358
+ */
359
+ async load() {
360
+ this.loading = true
361
+ this.loadError = ''
362
+ try {
363
+ // One GET per distinct read target; the section's own target must
364
+ // answer, a field's own target may fail on its own.
365
+ const targets = [...new Set(this.fields
366
+ .filter((field) => field && field.key)
367
+ .map((field) => this.readUrlOf(field))
368
+ .filter(Boolean))]
369
+ const answers = {}
370
+ const failures = {}
371
+ await Promise.all(targets.map(async (target) => {
372
+ try {
373
+ const response = await axios.get(prefixUrl(target), ocsConfig(target))
374
+ answers[target] = response?.data ?? {}
375
+ } catch (error) {
376
+ failures[target] = error
377
+ }
378
+ }))
379
+ if (this.readTarget && failures[this.readTarget]) {
380
+ throw failures[this.readTarget]
381
+ }
382
+ const form = {}
383
+ const stored = {}
384
+ const unreadable = {}
385
+ for (const field of this.fields || []) {
386
+ if (!field || !field.key) {
387
+ continue
388
+ }
389
+ const target = this.readUrlOf(field)
390
+ if (target && failures[target]) {
391
+ unreadable[field.key] = t('nextcloud-vue', 'This value could not be loaded: {reason}', { reason: serverErrorMessage(failures[target]) })
392
+ }
393
+ const raw = this.readValue(field, target ? answers[target] : null)
394
+ if (field.type === 'secret') {
395
+ stored[field.key] = raw !== null && raw !== undefined && raw !== '' && raw !== false
396
+ form[field.key] = ''
397
+ continue
398
+ }
399
+ form[field.key] = raw === undefined ? (field.default ?? (field.type === 'switch' ? false : '')) : raw
400
+ }
401
+ this.form = form
402
+ this.baseline = { ...form }
403
+ this.storedSecrets = stored
404
+ this.unreadable = unreadable
405
+ this.errors = {}
406
+ } catch (error) {
407
+ this.loadError = t('nextcloud-vue', 'The settings could not be loaded: {reason}', { reason: serverErrorMessage(error) })
408
+ } finally {
409
+ this.loading = false
410
+ }
411
+ },
412
+
413
+ /**
414
+ * Where a field is read: its own `url`, else the section's.
415
+ *
416
+ * @param {object} field The field.
417
+ * @return {string}
418
+ * @spec openspec/changes/settings-fields-section/specs/settings-fields-section/spec.md#requirement-a-field-can-read-and-save-elsewhere
419
+ */
420
+ readUrlOf(field) {
421
+ return field.url || this.readTarget
422
+ },
423
+
424
+ /**
425
+ * A field's value in its read answer: at its `valuePath` (a field with its
426
+ * own `url`), else at the section's `valuesPath`, then its `key`. A key
427
+ * the answer holds as written wins over reading it as a dotted path.
428
+ *
429
+ * @param {object} field The field.
430
+ * @param {object|null} data The answer of the field's read target.
431
+ * @return {unknown}
432
+ */
433
+ readValue(field, data) {
434
+ if (!data || typeof data !== 'object') {
435
+ return undefined
436
+ }
437
+ if (field.url) {
438
+ return literalFirst(data, field.valuePath || field.key)
439
+ }
440
+ const values = (this.valuesPath ? literalFirst(data, this.valuesPath) : data) || {}
441
+ return literalFirst(values, field.key)
442
+ },
443
+
444
+ /**
445
+ * Check the fields: required, pattern, and a number's min and max.
446
+ *
447
+ * @return {object} Field key to message; empty when all pass.
448
+ * @spec openspec/changes/settings-fields-section/specs/settings-fields-section/spec.md#requirement-a-failing-field-says-why
449
+ */
450
+ validate() {
451
+ const errors = {}
452
+ for (const field of this.resolvedFields) {
453
+ const value = this.form[field.key]
454
+ const empty = value === null || value === undefined || value === ''
455
+ if (field.type === 'switch' || field.type === 'image' || this.unreadable[field.key]) {
456
+ continue
457
+ }
458
+ if (field.required && empty && !(field.type === 'secret' && field.secretSet)) {
459
+ errors[field.key] = t('nextcloud-vue', '{field} is required.', { field: field.label })
460
+ continue
461
+ }
462
+ if (empty) {
463
+ continue
464
+ }
465
+ if (field.pattern && !new RegExp(field.pattern).test(String(value))) {
466
+ errors[field.key] = field.patternMessage ? this.tr(field.patternMessage) : t('nextcloud-vue', '{field} is not in the right form.', { field: field.label })
467
+ continue
468
+ }
469
+ if (field.type === 'number') {
470
+ const number = Number(value)
471
+ if (Number.isNaN(number)) {
472
+ errors[field.key] = t('nextcloud-vue', '{field} must be a number.', { field: field.label })
473
+ } else if (field.min !== undefined && number < field.min) {
474
+ errors[field.key] = t('nextcloud-vue', '{field} must be at least {min}.', { field: field.label, min: field.min })
475
+ } else if (field.max !== undefined && number > field.max) {
476
+ errors[field.key] = t('nextcloud-vue', '{field} must be at most {max}.', { field: field.label, max: field.max })
477
+ }
478
+ }
479
+ }
480
+ return errors
481
+ },
482
+
483
+ /**
484
+ * Save the section's fields in one request, after checking them. A
485
+ * secret is sent only when something was typed.
486
+ *
487
+ * @return {Promise<void>}
488
+ * @spec openspec/changes/settings-fields-section/specs/settings-fields-section/spec.md#requirement-one-save-per-section-with-a-dirty-state
489
+ */
490
+ async save() {
491
+ if (this.saving) {
492
+ return
493
+ }
494
+ this.errors = this.validate()
495
+ const failing = Object.keys(this.errors)
496
+ if (failing.length > 0) {
497
+ this.notice = { type: 'error', text: t('nextcloud-vue', 'Check the marked fields.') }
498
+ this.$nextTick(() => {
499
+ const el = this.$el?.querySelector?.('[aria-invalid="true"], .cn-settings-field__error')
500
+ const control = el?.closest?.('.cn-settings-field')?.querySelector?.('input, textarea, [role="combobox"]')
501
+ control?.focus?.()
502
+ })
503
+ return
504
+ }
505
+ // The changed fields, one request per save target, each carrying its
506
+ // own fields under their `saveKey`. A secret goes only when typed; an
507
+ // image is uploaded on its own; a field that could not be read is left out.
508
+ const groups = new Map()
509
+ for (const field of this.resolvedFields) {
510
+ const value = this.form[field.key]
511
+ if (field.type === 'image' || this.unreadable[field.key] || this.same(value, this.baseline[field.key])) {
512
+ continue
513
+ }
514
+ if (field.type === 'secret' && (value === '' || value === null || value === undefined)) {
515
+ continue
516
+ }
517
+ const url = field.saveUrl || (field.url ? field.url : this.writeTarget)
518
+ const method = String(field.saveMethod || (field.saveUrl || field.url ? 'POST' : this.saveMethod)).toLowerCase()
519
+ const id = `${method} ${url}`
520
+ if (!groups.has(id)) {
521
+ groups.set(id, { url, method, fields: [], data: {} })
522
+ }
523
+ const group = groups.get(id)
524
+ group.fields.push(field)
525
+ Object.assign(group.data, field.saveParams || {})
526
+ group.data[field.saveKey || field.key] = value
527
+ }
528
+ this.saving = true
529
+ this.notice = null
530
+ const failed = []
531
+ const sent = {}
532
+ for (const group of groups.values()) {
533
+ try {
534
+ await axios.request({ url: prefixUrl(group.url), method: group.method, data: group.data, ...ocsConfig(group.url) })
535
+ Object.assign(sent, group.data)
536
+ this.acceptSaved(group.fields)
537
+ } catch (error) {
538
+ failed.push({ group, error })
539
+ }
540
+ }
541
+ this.saving = false
542
+ if (failed.length === 0) {
543
+ this.notice = { type: 'success', text: t('nextcloud-vue', 'Saved.') }
544
+ /**
545
+ * @event saved The section saved. Payload: what was sent, by save key.
546
+ */
547
+ this.$emit('saved', sent)
548
+ return
549
+ }
550
+ const reasons = failed.map(({ group, error }) => (groups.size > 1
551
+ ? t('nextcloud-vue', '{fields}: {reason}', { fields: group.fields.map((field) => field.label).join(', '), reason: serverErrorMessage(error) })
552
+ : serverErrorMessage(error)))
553
+ this.notice = { type: 'error', text: t('nextcloud-vue', 'The settings could not be saved: {reason}', { reason: reasons.join('; ') }) }
554
+ /**
555
+ * @event save-error A save request failed. Payload: the first error.
556
+ */
557
+ this.$emit('save-error', failed[0].error)
558
+ },
559
+
560
+ /**
561
+ * Take saved fields as the new baseline; a saved secret reads as stored.
562
+ *
563
+ * @param {Array<object>} fields The fields that saved.
564
+ * @return {void}
565
+ */
566
+ acceptSaved(fields) {
567
+ const form = { ...this.form }
568
+ const baseline = { ...this.baseline }
569
+ const stored = { ...this.storedSecrets }
570
+ for (const field of fields) {
571
+ if (field.type === 'secret') {
572
+ stored[field.key] = true
573
+ form[field.key] = ''
574
+ }
575
+ baseline[field.key] = form[field.key]
576
+ }
577
+ this.form = form
578
+ this.baseline = baseline
579
+ this.storedSecrets = stored
580
+ },
581
+
582
+ /**
583
+ * An image field uploaded a file: its new value is the baseline too.
584
+ *
585
+ * @param {object} field The field.
586
+ * @param {string} value The new image url.
587
+ * @return {void}
588
+ */
589
+ onUploaded(field, value) {
590
+ this.form = { ...this.form, [field.key]: value }
591
+ this.baseline = { ...this.baseline, [field.key]: value }
592
+ this.notice = { type: 'success', text: t('nextcloud-vue', 'Saved.') }
593
+ },
594
+ },
595
+ }
596
+ </script>
597
+
598
+ <style scoped>
599
+ .cn-settings-fields {
600
+ display: flex;
601
+ flex-direction: column;
602
+ gap: 16px;
603
+ container-type: inline-size;
604
+ }
605
+
606
+ .cn-settings-fields__grid {
607
+ display: grid;
608
+ grid-template-columns: minmax(0, 1fr);
609
+ gap: 16px 20px;
610
+ }
611
+
612
+ /* Two or three columns where the card is wide enough; stacked otherwise. */
613
+ @container (min-width: 520px) {
614
+ .cn-settings-fields__grid--2,
615
+ .cn-settings-fields__grid--3 {
616
+ grid-template-columns: repeat(2, minmax(0, 1fr));
617
+ }
618
+ }
619
+
620
+ @container (min-width: 780px) {
621
+ .cn-settings-fields__grid--3 {
622
+ grid-template-columns: repeat(3, minmax(0, 1fr));
623
+ }
624
+ }
625
+
626
+ .cn-settings-fields__field--wide {
627
+ grid-column: 1 / -1;
628
+ }
629
+
630
+ .cn-settings-fields__save {
631
+ display: flex;
632
+ flex-wrap: wrap;
633
+ align-items: center;
634
+ justify-content: flex-end;
635
+ gap: 12px;
636
+ }
637
+
638
+ .cn-settings-fields__dirty {
639
+ font-size: 14px;
640
+ color: var(--color-text-maxcontrast);
641
+ }
642
+ </style>
@@ -0,0 +1,4 @@
1
+ import CnSettingsFieldsSection from './CnSettingsFieldsSection.vue'
2
+
3
+ export default CnSettingsFieldsSection
4
+ export { CnSettingsFieldsSection }