@conduction/nextcloud-vue 0.1.0-beta.6 → 0.1.0-beta.7

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 (82) hide show
  1. package/dist/nextcloud-vue.cjs.js +13606 -1918
  2. package/dist/nextcloud-vue.cjs.js.map +1 -1
  3. package/dist/nextcloud-vue.css +1238 -270
  4. package/dist/nextcloud-vue.esm.js +13548 -1880
  5. package/dist/nextcloud-vue.esm.js.map +1 -1
  6. package/package.json +9 -4
  7. package/src/components/CnActionsBar/CnActionsBar.vue +6 -1
  8. package/src/components/CnAdvancedFormDialog/CnAdvancedFormDialog.vue +1 -11
  9. package/src/components/CnAdvancedFormDialog/CnPropertiesTab.vue +5 -1
  10. package/src/components/CnAdvancedFormDialog/CnPropertyValueCell.vue +1 -1
  11. package/src/components/CnCard/CnCard.vue +415 -0
  12. package/src/components/CnCard/index.js +1 -0
  13. package/src/components/CnCardGrid/CnCardGrid.vue +20 -20
  14. package/src/components/CnChartWidget/CnChartWidget.vue +3 -1
  15. package/src/components/CnCopyDialog/CnCopyDialog.vue +7 -1
  16. package/src/components/CnDashboardGrid/CnDashboardGrid.vue +4 -0
  17. package/src/components/CnDashboardPage/CnDashboardPage.vue +2 -0
  18. package/src/components/CnDataTable/CnDataTable.vue +6 -2
  19. package/src/components/CnDeleteDialog/CnDeleteDialog.vue +7 -1
  20. package/src/components/CnDetailCard/CnDetailCard.vue +12 -1
  21. package/src/components/CnDetailGrid/CnDetailGrid.vue +254 -0
  22. package/src/components/CnDetailGrid/index.js +1 -0
  23. package/src/components/CnDetailPage/CnDetailPage.vue +157 -11
  24. package/src/components/CnFacetSidebar/CnFacetSidebar.vue +3 -1
  25. package/src/components/CnFormDialog/CnFormDialog.vue +934 -920
  26. package/src/components/CnIcon/CnIcon.vue +1 -1
  27. package/src/components/CnIndexPage/CnIndexPage.vue +51 -9
  28. package/src/components/CnIndexSidebar/CnIndexSidebar.vue +37 -9
  29. package/src/components/CnInfoWidget/CnInfoWidget.vue +219 -0
  30. package/src/components/CnInfoWidget/index.js +1 -0
  31. package/src/components/CnJsonViewer/CnJsonViewer.vue +283 -0
  32. package/src/components/CnJsonViewer/index.js +1 -0
  33. package/src/components/CnKpiGrid/CnKpiGrid.vue +5 -1
  34. package/src/components/CnMassCopyDialog/CnMassCopyDialog.vue +7 -1
  35. package/src/components/CnMassDeleteDialog/CnMassDeleteDialog.vue +7 -1
  36. package/src/components/CnMassExportDialog/CnMassExportDialog.vue +1 -1
  37. package/src/components/CnMassImportDialog/CnMassImportDialog.vue +1 -1
  38. package/src/components/CnObjectCard/CnObjectCard.vue +1 -1
  39. package/src/components/CnObjectSidebar/CnAuditTrailTab.vue +368 -0
  40. package/src/components/CnObjectSidebar/CnFilesTab.vue +286 -0
  41. package/src/components/CnObjectSidebar/CnNotesTab.vue +249 -0
  42. package/src/components/CnObjectSidebar/CnObjectSidebar.vue +45 -668
  43. package/src/components/CnObjectSidebar/CnTagsTab.vue +258 -0
  44. package/src/components/CnObjectSidebar/CnTasksTab.vue +482 -0
  45. package/src/components/CnObjectSidebar/index.js +5 -0
  46. package/src/components/CnProgressBar/CnProgressBar.vue +262 -0
  47. package/src/components/CnProgressBar/index.js +1 -0
  48. package/src/components/CnSchemaFormDialog/CnSchemaPropertiesTab.vue +1 -1
  49. package/src/components/CnStatsBlock/CnStatsBlock.vue +27 -11
  50. package/src/components/CnStatsPanel/CnStatsPanel.vue +320 -0
  51. package/src/components/CnStatsPanel/index.js +1 -0
  52. package/src/components/CnStatusBadge/CnStatusBadge.vue +15 -2
  53. package/src/components/CnTabbedFormDialog/CnTabbedFormDialog.vue +5 -1
  54. package/src/components/CnTableWidget/CnTableWidget.vue +332 -0
  55. package/src/components/CnTableWidget/index.js +1 -0
  56. package/src/components/CnWidgetWrapper/CnWidgetWrapper.vue +36 -1
  57. package/src/components/index.js +11 -0
  58. package/src/composables/useDashboardView.js +58 -12
  59. package/src/composables/useDetailView.js +3 -2
  60. package/src/composables/useListView.js +7 -6
  61. package/src/composables/useSubResource.js +3 -3
  62. package/src/css/badge.css +32 -0
  63. package/src/css/card.css +1 -0
  64. package/src/css/detail-page.css +74 -7
  65. package/src/index.js +16 -0
  66. package/src/mixins/gridLayout.js +118 -0
  67. package/src/store/createCrudStore.js +360 -0
  68. package/src/store/createSubResourcePlugin.js +5 -15
  69. package/src/store/index.js +1 -0
  70. package/src/store/plugins/auditTrails.js +346 -6
  71. package/src/store/plugins/lifecycle.js +4 -4
  72. package/src/store/plugins/registerMapping.js +18 -8
  73. package/src/store/plugins/relations.js +1 -1
  74. package/src/store/plugins/search.js +21 -8
  75. package/src/store/useObjectStore.js +30 -36
  76. package/src/utils/getTheme.js +9 -0
  77. package/src/utils/headers.js +13 -3
  78. package/src/utils/index.js +1 -0
  79. package/src/utils/schema.js +3 -3
  80. package/src/utils/widgetVisibility.js +162 -0
  81. package/src/components/CnObjectCard/eslint-setup.md +0 -235
  82. package/src/components/CnObjectCard/package.json-or.json +0 -132
@@ -1,920 +1,934 @@
1
- <template>
2
- <NcDialog
3
- :name="resolvedTitle"
4
- :size="size"
5
- :can-close="!loading"
6
- @closing="$emit('close')">
7
- <!-- Result phase -->
8
- <div v-if="result !== null" class="cn-form-dialog__result">
9
- <NcNoteCard v-if="result.success" type="success">
10
- {{ resolvedSuccessText }}
11
- </NcNoteCard>
12
- <NcNoteCard v-if="result.error" type="error">
13
- {{ result.error }}
14
- </NcNoteCard>
15
- </div>
16
-
17
- <!-- Form phase -->
18
- <div v-else class="cn-form-dialog__form">
19
- <!-- Full form override slot -->
20
- <slot
21
- v-if="$scopedSlots.form"
22
- name="form"
23
- :fields="resolvedFields"
24
- :form-data="formData"
25
- :errors="errors"
26
- :update-field="updateField" />
27
-
28
- <!-- Auto-generated form -->
29
- <template v-else>
30
- <slot name="before-fields" />
31
-
32
- <div
33
- v-for="field in resolvedFields"
34
- :key="field.key"
35
- class="cn-form-dialog__field">
36
- <!-- Per-field override slot -->
37
- <slot
38
- v-if="$scopedSlots['field-' + field.key]"
39
- :name="'field-' + field.key"
40
- :field="field"
41
- :value="formData[field.key]"
42
- :error="errors[field.key]"
43
- :update-field="updateField" />
44
-
45
- <!-- Auto-generated field -->
46
- <template v-else>
47
- <!-- Text / Email / URL -->
48
- <NcTextField
49
- v-if="field.widget === 'text' || field.widget === 'email' || field.widget === 'url'"
50
- :label="field.label + (field.required ? ' *' : '')"
51
- :value="formData[field.key] != null ? String(formData[field.key]) : ''"
52
- :helper-text="errors[field.key] || field.description"
53
- :error="!!errors[field.key]"
54
- :type="field.widget === 'email' ? 'email' : field.widget === 'url' ? 'url' : 'text'"
55
- :disabled="field.readOnly"
56
- :placeholder="field.description"
57
- @update:value="value => updateField(field.key, value)" />
58
-
59
- <!-- Number -->
60
- <NcTextField
61
- v-else-if="field.widget === 'number'"
62
- :label="field.label + (field.required ? ' *' : '')"
63
- :value="formData[field.key] != null ? String(formData[field.key]) : ''"
64
- :helper-text="errors[field.key] || field.description"
65
- :error="!!errors[field.key]"
66
- type="number"
67
- :disabled="field.readOnly"
68
- :placeholder="field.description"
69
- @update:value="value => updateField(field.key, value !== '' ? Number(value) : null)" />
70
-
71
- <!-- Textarea -->
72
- <div v-else-if="field.widget === 'textarea'" class="cn-form-dialog__textarea-wrapper">
73
- <label :for="'cn-form-' + field.key" class="cn-form-dialog__label">
74
- {{ field.label }}{{ field.required ? ' *' : '' }}
75
- </label>
76
- <textarea
77
- :id="'cn-form-' + field.key"
78
- class="cn-form-dialog__textarea"
79
- :value="formData[field.key] || ''"
80
- :disabled="field.readOnly"
81
- :placeholder="field.description"
82
- rows="4"
83
- @input="updateField(field.key, $event.target.value)" />
84
- <span
85
- v-if="errors[field.key] || field.description"
86
- class="cn-form-dialog__helper"
87
- :class="{ 'cn-form-dialog__helper--error': errors[field.key] }">
88
- {{ errors[field.key] || field.description }}
89
- </span>
90
- </div>
91
-
92
- <!-- Select (enum, supports async function) -->
93
- <div v-else-if="field.widget === 'select'" class="cn-form-dialog__select-wrapper">
94
- <label :for="'cn-form-' + field.key" class="cn-form-dialog__label">
95
- {{ field.label }}{{ field.required ? ' *' : '' }}
96
- </label>
97
- <NcSelect
98
- :input-id="'cn-form-' + field.key"
99
- :options="getEffectiveOptions(field)"
100
- :value="getEffectiveSelectedOption(field)"
101
- :clearable="!field.required"
102
- :disabled="field.readOnly"
103
- :loading="isFieldLoading(field)"
104
- :filterable="!isAsyncEnum(field)"
105
- @input="onEffectiveSelectChange(field, $event)"
106
- @search="isAsyncEnum(field) ? onAsyncSearch(field, $event) : undefined">
107
- <template
108
- v-if="$scopedSlots['field-' + field.key + '-option']"
109
- #option="optionProps">
110
- <slot :name="'field-' + field.key + '-option'" v-bind="optionProps" />
111
- </template>
112
- <template
113
- v-if="$scopedSlots['field-' + field.key + '-selected-option']"
114
- #selected-option="optionProps">
115
- <slot :name="'field-' + field.key + '-selected-option'" v-bind="optionProps" />
116
- </template>
117
- </NcSelect>
118
- <span
119
- v-if="errors[field.key] || field.description"
120
- class="cn-form-dialog__helper"
121
- :class="{ 'cn-form-dialog__helper--error': errors[field.key] }">
122
- {{ errors[field.key] || field.description }}
123
- </span>
124
- </div>
125
-
126
- <!-- Multiselect (array with enum items, supports async function) -->
127
- <div v-else-if="field.widget === 'multiselect'" class="cn-form-dialog__select-wrapper">
128
- <label :for="'cn-form-' + field.key" class="cn-form-dialog__label">
129
- {{ field.label }}{{ field.required ? ' *' : '' }}
130
- </label>
131
- <NcSelect
132
- :input-id="'cn-form-' + field.key"
133
- :options="getEffectiveArrayOptions(field)"
134
- :value="getEffectiveSelectedArrayOptions(field)"
135
- :multiple="true"
136
- :clearable="true"
137
- :disabled="field.readOnly"
138
- :loading="isFieldLoading(field)"
139
- :filterable="!isAsyncItemsEnum(field)"
140
- @input="onEffectiveMultiSelectChange(field, $event)"
141
- @search="isAsyncItemsEnum(field) ? onAsyncSearch(field, $event) : undefined">
142
- <template
143
- v-if="$scopedSlots['field-' + field.key + '-option']"
144
- #option="optionProps">
145
- <slot :name="'field-' + field.key + '-option'" v-bind="optionProps" />
146
- </template>
147
- <template
148
- v-if="$scopedSlots['field-' + field.key + '-selected-option']"
149
- #selected-option="optionProps">
150
- <slot :name="'field-' + field.key + '-selected-option'" v-bind="optionProps" />
151
- </template>
152
- </NcSelect>
153
- <span
154
- v-if="errors[field.key] || field.description"
155
- class="cn-form-dialog__helper"
156
- :class="{ 'cn-form-dialog__helper--error': errors[field.key] }">
157
- {{ errors[field.key] || field.description }}
158
- </span>
159
- </div>
160
-
161
- <!-- Tags (array, freeform, supports async suggestions) -->
162
- <div v-else-if="field.widget === 'tags'" class="cn-form-dialog__select-wrapper">
163
- <label :for="'cn-form-' + field.key" class="cn-form-dialog__label">
164
- {{ field.label }}{{ field.required ? ' *' : '' }}
165
- </label>
166
- <!-- TODO: restore `:options` to `asyncState[field.key]?.options` once on Vue 3 (buble doesn't support optional chaining) -->
167
- <NcSelect
168
- :input-id="'cn-form-' + field.key"
169
- :value="formData[field.key] || []"
170
- :options="isFieldAsync(field) ? ((asyncState[field.key] && asyncState[field.key].options) || []) : []"
171
- :multiple="true"
172
- :taggable="true"
173
- :clearable="true"
174
- :disabled="field.readOnly"
175
- :loading="isFieldLoading(field)"
176
- :filterable="!isFieldAsync(field)"
177
- @input="updateField(field.key, $event)"
178
- @search="isFieldAsync(field) ? onAsyncSearch(field, $event) : undefined">
179
- <template
180
- v-if="$scopedSlots['field-' + field.key + '-option']"
181
- #option="optionProps">
182
- <slot :name="'field-' + field.key + '-option'" v-bind="optionProps" />
183
- </template>
184
- <template
185
- v-if="$scopedSlots['field-' + field.key + '-selected-option']"
186
- #selected-option="optionProps">
187
- <slot :name="'field-' + field.key + '-selected-option'" v-bind="optionProps" />
188
- </template>
189
- </NcSelect>
190
- <span
191
- v-if="errors[field.key] || field.description"
192
- class="cn-form-dialog__helper"
193
- :class="{ 'cn-form-dialog__helper--error': errors[field.key] }">
194
- {{ errors[field.key] || field.description }}
195
- </span>
196
- </div>
197
-
198
- <!-- Checkbox / Switch (boolean) -->
199
- <NcCheckboxRadioSwitch
200
- v-else-if="field.widget === 'checkbox'"
201
- :checked="!!formData[field.key]"
202
- :disabled="field.readOnly"
203
- type="switch"
204
- @update:checked="value => updateField(field.key, value)">
205
- {{ field.label }}{{ field.required ? ' *' : '' }}
206
- </NcCheckboxRadioSwitch>
207
-
208
- <!-- Date -->
209
- <NcTextField
210
- v-else-if="field.widget === 'date'"
211
- :label="field.label + (field.required ? ' *' : '')"
212
- :value="formData[field.key] || ''"
213
- :helper-text="errors[field.key] || field.description"
214
- :error="!!errors[field.key]"
215
- type="date"
216
- :disabled="field.readOnly"
217
- @update:value="value => updateField(field.key, value)" />
218
-
219
- <!-- Datetime -->
220
- <NcTextField
221
- v-else-if="field.widget === 'datetime'"
222
- :label="field.label + (field.required ? ' *' : '')"
223
- :value="formData[field.key] || ''"
224
- :helper-text="errors[field.key] || field.description"
225
- :error="!!errors[field.key]"
226
- type="datetime-local"
227
- :disabled="field.readOnly"
228
- @update:value="value => updateField(field.key, value)" />
229
-
230
- <!-- Fallback: text input -->
231
- <NcTextField
232
- v-else
233
- :label="field.label + (field.required ? ' *' : '')"
234
- :value="formData[field.key] != null ? String(formData[field.key]) : ''"
235
- :helper-text="errors[field.key] || field.description"
236
- :error="!!errors[field.key]"
237
- :disabled="field.readOnly"
238
- :placeholder="field.description"
239
- @update:value="value => updateField(field.key, value)" />
240
- </template>
241
- </div>
242
-
243
- <slot name="after-fields" />
244
- </template>
245
- </div>
246
-
247
- <template #actions>
248
- <NcButton @click="$emit('close')">
249
- {{ result !== null ? closeLabel : cancelLabel }}
250
- </NcButton>
251
- <NcButton
252
- v-if="result === null"
253
- type="primary"
254
- :disabled="loading"
255
- @click="executeConfirm">
256
- <template #icon>
257
- <NcLoadingIcon v-if="loading" :size="20" />
258
- <Plus v-else-if="isCreateMode" :size="20" />
259
- <ContentSaveOutline v-else :size="20" />
260
- </template>
261
- {{ resolvedConfirmLabel }}
262
- </NcButton>
263
- </template>
264
- </NcDialog>
265
- </template>
266
-
267
- <script>
268
- import { NcDialog, NcButton, NcNoteCard, NcLoadingIcon, NcTextField, NcSelect, NcCheckboxRadioSwitch } from '@nextcloud/vue'
269
- import Plus from 'vue-material-design-icons/Plus.vue'
270
- import ContentSaveOutline from 'vue-material-design-icons/ContentSaveOutline.vue'
271
- import { fieldsFromSchema } from '../../utils/schema.js'
272
-
273
- /**
274
- * CnFormDialog — Create/edit dialog with auto-generated form from schema.
275
- *
276
- * When `item` is null, operates in create mode. When `item` is provided,
277
- * operates in edit mode. Auto-generates form fields from schema using
278
- * `fieldsFromSchema()`, but supports slot overrides at three levels:
279
- *
280
- * - `#form` — Replace the entire form content
281
- * - `#field-{key}` — Replace a single auto-generated field
282
- * - `#field-{key}-option` — Customize dropdown option rendering for a select/multiselect/tags field
283
- * - `#field-{key}-selected-option` — Customize selected option display for a select/multiselect/tags field
284
- * - `#before-fields` / `#after-fields` — Inject content around fields
285
- *
286
- * ## Async select support
287
- *
288
- * Select, multiselect, and tags fields support async options by setting `field.enum`
289
- * (or `field.items.enum` for multiselect) to an async function instead of a static array:
290
- *
291
- * ```js
292
- * { key: 'org', widget: 'select', enum: async (query) => fetchOrgs(query) }
293
- * ```
294
- *
295
- * The function receives the search query and must return an array of option objects
296
- * (each must have a `label` property for default display). Options are loaded on mount
297
- * (with empty query) and on each search input (debounced, default 300ms, configurable
298
- * via `field.debounce`). Async selects store the full option object in formData.
299
- *
300
- * The dialog does NOT perform the save itself — it emits a `confirm` event
301
- * with the form data. The parent performs the actual API call and calls
302
- * `setResult()` via a ref.
303
- *
304
- * @event confirm Emitted when the user confirms the form. Payload: formData object (includes `id` in edit mode).
305
- * @event close Emitted when the dialog should be closed (cancel, close button, or auto-close after success).
306
- *
307
- * @example
308
- * <CnFormDialog
309
- * v-if="showFormDialog"
310
- * ref="formDialog"
311
- * :schema="schema"
312
- * :item="editItem"
313
- * @confirm="onFormConfirm"
314
- * @close="showFormDialog = false" />
315
- *
316
- * // In methods:
317
- * async onFormConfirm(formData) {
318
- * try {
319
- * if (formData.id) {
320
- * await store.updateItem(formData.id, formData)
321
- * } else {
322
- * await store.createItem(formData)
323
- * }
324
- * this.$refs.formDialog.setResult({ success: true })
325
- * } catch (e) {
326
- * this.$refs.formDialog.setResult({ error: e.message })
327
- * }
328
- * }
329
- *
330
- * @example <caption>Async select with custom option rendering</caption>
331
- * <CnFormDialog :fields="[{
332
- * key: 'organisation',
333
- * widget: 'select',
334
- * label: 'Organisation',
335
- * required: true,
336
- * enum: async (query) => {
337
- * const results = await store.search(query)
338
- * return results.map(o => ({ label: o.name, id: o.uuid, ...o }))
339
- * },
340
- * debounce: 500,
341
- * }]" @confirm="onConfirm">
342
- * <template #field-organisation-option="{ name, description }">
343
- * <strong>{{ name }}</strong>
344
- * <p>{{ description }}</p>
345
- * </template>
346
- * <template #field-organisation-selected-option="{ name }">
347
- * {{ name }}
348
- * </template>
349
- * </CnFormDialog>
350
- */
351
- export default {
352
- name: 'CnFormDialog',
353
-
354
- components: {
355
- NcDialog,
356
- NcButton,
357
- NcNoteCard,
358
- NcLoadingIcon,
359
- NcTextField,
360
- NcSelect,
361
- NcCheckboxRadioSwitch,
362
- Plus,
363
- ContentSaveOutline,
364
- },
365
-
366
- props: {
367
- /** Schema for auto-generating fields. Either schema or fields must be provided. */
368
- schema: {
369
- type: Object,
370
- default: null,
371
- },
372
- /** Existing item for edit mode. Pass null for create mode. */
373
- item: {
374
- type: Object,
375
- default: null,
376
- },
377
- /** Dialog title. Defaults to "Create {schema.title}" or "Edit {schema.title}". */
378
- dialogTitle: {
379
- type: String,
380
- default: '',
381
- },
382
- /** Manual field definitions. Overrides schema-generated fields when provided. */
383
- fields: {
384
- type: Array,
385
- default: null,
386
- },
387
- /** Field keys to exclude from auto-generated form */
388
- excludeFields: {
389
- type: Array,
390
- default: () => [],
391
- },
392
- /** Field keys to include (whitelist mode) */
393
- includeFields: {
394
- type: Array,
395
- default: null,
396
- },
397
- /** Per-field overrides passed to fieldsFromSchema */
398
- fieldOverrides: {
399
- type: Object,
400
- default: () => ({}),
401
- },
402
- /** Which field is the "name" (used in result messages) */
403
- nameField: {
404
- type: String,
405
- default: 'title',
406
- },
407
- /** NcDialog size */
408
- size: {
409
- type: String,
410
- default: 'normal',
411
- },
412
- /** Success message. Defaults to "Item saved successfully." */
413
- successText: {
414
- type: String,
415
- default: '',
416
- },
417
- cancelLabel: { type: String, default: 'Cancel' },
418
- closeLabel: { type: String, default: 'Close' },
419
- /** Confirm button label. Defaults to "Create" or "Save". */
420
- confirmLabel: {
421
- type: String,
422
- default: '',
423
- },
424
- },
425
-
426
- data() {
427
- return {
428
- formData: {},
429
- errors: {},
430
- loading: false,
431
- result: null,
432
- closeTimeout: null,
433
- /** Per-field async state: { [fieldKey]: { options: [], loading: false, searchTimeout: null } } */
434
- asyncState: {},
435
- }
436
- },
437
-
438
- computed: {
439
- isCreateMode() {
440
- return !this.item
441
- },
442
-
443
- schemaTitle() {
444
- return (this.schema && this.schema.title) || 'Item'
445
- },
446
-
447
- resolvedTitle() {
448
- if (this.dialogTitle) return this.dialogTitle
449
- return this.isCreateMode
450
- ? `Create ${this.schemaTitle}`
451
- : `Edit ${this.schemaTitle}`
452
- },
453
-
454
- resolvedConfirmLabel() {
455
- if (this.confirmLabel) return this.confirmLabel
456
- return this.isCreateMode ? 'Create' : 'Save'
457
- },
458
-
459
- resolvedSuccessText() {
460
- if (this.successText) return this.successText
461
- return `${this.schemaTitle} saved successfully.`
462
- },
463
-
464
- resolvedFields() {
465
- // Manual fields take priority
466
- if (this.fields) return this.fields
467
-
468
- // Auto-generate from schema
469
- return fieldsFromSchema(this.schema, {
470
- exclude: this.excludeFields,
471
- include: this.includeFields,
472
- overrides: this.fieldOverrides,
473
- })
474
- },
475
- },
476
-
477
- watch: {
478
- item: {
479
- immediate: true,
480
- handler(newItem) {
481
- this.initFormData(newItem)
482
- },
483
- },
484
- },
485
-
486
- beforeDestroy() {
487
- for (const state of Object.values(this.asyncState)) {
488
- if (state.searchTimeout) clearTimeout(state.searchTimeout)
489
- }
490
- if (this.closeTimeout) clearTimeout(this.closeTimeout)
491
- },
492
-
493
- methods: {
494
- initFormData(item) {
495
- if (item) {
496
- // Edit mode: clone item data
497
- this.formData = JSON.parse(JSON.stringify(item))
498
- } else {
499
- // Create mode: initialize with field defaults
500
- const data = {}
501
- for (const field of this.resolvedFields) {
502
- if (field.default !== null && field.default !== undefined) {
503
- data[field.key] = field.default
504
- } else if (field.widget === 'checkbox') {
505
- data[field.key] = false
506
- } else if (field.widget === 'tags' || field.widget === 'multiselect') {
507
- data[field.key] = []
508
- } else {
509
- data[field.key] = null
510
- }
511
- }
512
- this.formData = data
513
- }
514
- this.errors = {}
515
- this.initAsyncFields()
516
- },
517
-
518
- updateField(key, value) {
519
- this.$set(this.formData, key, value)
520
- // Clear error when field is edited
521
- if (this.errors[key]) {
522
- this.$delete(this.errors, key)
523
- }
524
- },
525
-
526
- getEnumOptions(field) {
527
- if (!field.enum) return []
528
- return field.enum.map((val) => ({
529
- id: val,
530
- label: String(val),
531
- }))
532
- },
533
-
534
- getSelectedEnumOption(field) {
535
- const val = this.formData[field.key]
536
- if (val === null || val === undefined) return null
537
- return { id: val, label: String(val) }
538
- },
539
-
540
- onSelectChange(key, option) {
541
- this.updateField(key, option ? option.id : null)
542
- },
543
-
544
- getArrayEnumOptions(field) {
545
- if (!field.items || !field.items.enum) return []
546
- return field.items.enum.map((val) => ({
547
- id: val,
548
- label: String(val),
549
- }))
550
- },
551
-
552
- getSelectedArrayOptions(field) {
553
- const val = this.formData[field.key]
554
- if (!Array.isArray(val)) return []
555
- return val.map((v) => ({ id: v, label: String(v) }))
556
- },
557
-
558
- onMultiSelectChange(key, options) {
559
- this.updateField(key, (options || []).map((o) => o.id))
560
- },
561
-
562
- /**
563
- * Check if a field has an async enum (function instead of static array).
564
- *
565
- * @param {object} field The field definition
566
- * @return {boolean}
567
- */
568
- isAsyncEnum(field) {
569
- return typeof field.enum === 'function'
570
- },
571
-
572
- /**
573
- * Check if an array field has an async items enum.
574
- *
575
- * @param {object} field The field definition
576
- * @return {boolean}
577
- */
578
- isAsyncItemsEnum(field) {
579
- return !!(field.items && typeof field.items.enum === 'function')
580
- },
581
-
582
- /**
583
- * Initialize async state for all async fields and trigger initial load.
584
- */
585
- initAsyncFields() {
586
- // Clean up existing timeouts
587
- for (const state of Object.values(this.asyncState)) {
588
- if (state.searchTimeout) clearTimeout(state.searchTimeout)
589
- }
590
-
591
- const newState = {}
592
- for (const field of this.resolvedFields) {
593
- if (this.isAsyncEnum(field) || this.isAsyncItemsEnum(field)) {
594
- newState[field.key] = { options: [], loading: false, searchTimeout: null }
595
- }
596
- }
597
- this.asyncState = newState
598
-
599
- // Trigger initial load for each async field
600
- this.$nextTick(() => {
601
- for (const field of this.resolvedFields) {
602
- if (this.isAsyncEnum(field) || this.isAsyncItemsEnum(field)) {
603
- this.loadAsyncOptions(field, '')
604
- }
605
- }
606
- })
607
- },
608
-
609
- /**
610
- * Load async options for a field by calling its enum function.
611
- *
612
- * @param {object} field The field definition
613
- * @param {string} query Search query
614
- */
615
- async loadAsyncOptions(field, query) {
616
- const state = this.asyncState[field.key]
617
- if (!state) return
618
-
619
- this.$set(state, 'loading', true)
620
-
621
- try {
622
- const enumFn = this.isAsyncEnum(field) ? field.enum : field.items.enum
623
- const results = await enumFn(query)
624
- this.$set(state, 'options', Array.isArray(results) ? results : [])
625
- } catch (err) {
626
- console.error(`CnFormDialog: async enum error for field "${field.key}":`, err)
627
- this.$set(state, 'options', [])
628
- } finally {
629
- this.$set(state, 'loading', false)
630
- }
631
- },
632
-
633
- /**
634
- * Handle search input on an async select with debounce.
635
- *
636
- * @param {object} field The field definition
637
- * @param {string} query Search query
638
- */
639
- onAsyncSearch(field, query) {
640
- const state = this.asyncState[field.key]
641
- if (!state) return
642
-
643
- if (state.searchTimeout) {
644
- clearTimeout(state.searchTimeout)
645
- }
646
-
647
- const debounceMs = field.debounce || 300
648
-
649
- state.searchTimeout = setTimeout(() => {
650
- this.loadAsyncOptions(field, query || '')
651
- }, debounceMs)
652
- },
653
-
654
- /**
655
- * Get the effective options for a select field (async or static).
656
- *
657
- * @param {object} field The field definition
658
- * @return {Array}
659
- */
660
- getEffectiveOptions(field) {
661
- if (this.isAsyncEnum(field)) {
662
- // TODO: restore to `this.asyncState[field.key]?.options || []` once on Vue 3 (buble doesn't support optional chaining)
663
- return (this.asyncState[field.key] && this.asyncState[field.key].options) || []
664
- }
665
- return this.getEnumOptions(field)
666
- },
667
-
668
- /**
669
- * Get the effective selected value for a select field (async or static).
670
- *
671
- * @param {object} field The field definition
672
- * @return {object|null}
673
- */
674
- getEffectiveSelectedOption(field) {
675
- if (this.isAsyncEnum(field)) {
676
- // For async fields, formData stores the full option object
677
- return this.formData[field.key] || null
678
- }
679
- return this.getSelectedEnumOption(field)
680
- },
681
-
682
- /**
683
- * Handle select change for both async and static fields.
684
- *
685
- * @param {object} field The field definition
686
- * @param {object|null} option The selected option
687
- */
688
- onEffectiveSelectChange(field, option) {
689
- if (this.isAsyncEnum(field)) {
690
- // Store full option object for async selects
691
- this.updateField(field.key, option || null)
692
- } else {
693
- this.onSelectChange(field.key, option)
694
- }
695
- },
696
-
697
- /**
698
- * Get effective options for a multiselect field (async or static).
699
- *
700
- * @param {object} field The field definition
701
- * @return {Array}
702
- */
703
- getEffectiveArrayOptions(field) {
704
- if (this.isAsyncItemsEnum(field)) {
705
- // TODO: restore to `this.asyncState[field.key]?.options || []` once on Vue 3 (buble doesn't support optional chaining)
706
- return (this.asyncState[field.key] && this.asyncState[field.key].options) || []
707
- }
708
- return this.getArrayEnumOptions(field)
709
- },
710
-
711
- /**
712
- * Get effective selected values for a multiselect field (async or static).
713
- *
714
- * @param {object} field The field definition
715
- * @return {Array}
716
- */
717
- getEffectiveSelectedArrayOptions(field) {
718
- if (this.isAsyncItemsEnum(field)) {
719
- // For async fields, formData stores array of full option objects
720
- return this.formData[field.key] || []
721
- }
722
- return this.getSelectedArrayOptions(field)
723
- },
724
-
725
- /**
726
- * Handle multiselect change for both async and static fields.
727
- *
728
- * @param {object} field The field definition
729
- * @param {Array} options The selected options
730
- */
731
- onEffectiveMultiSelectChange(field, options) {
732
- if (this.isAsyncItemsEnum(field)) {
733
- // Store full option objects for async multiselect
734
- this.updateField(field.key, options || [])
735
- } else {
736
- this.onMultiSelectChange(field.key, options)
737
- }
738
- },
739
-
740
- /**
741
- * Whether a field's async options are currently loading.
742
- *
743
- * @param {object} field The field definition
744
- * @return {boolean}
745
- */
746
- isFieldLoading(field) {
747
- // TODO: restore to `this.asyncState[field.key]?.loading || false` once on Vue 3 (buble doesn't support optional chaining)
748
- return (this.asyncState[field.key] && this.asyncState[field.key].loading) || false
749
- },
750
-
751
- /**
752
- * Whether a field has any async behavior (enum or items.enum is a function).
753
- *
754
- * @param {object} field The field definition
755
- * @return {boolean}
756
- */
757
- isFieldAsync(field) {
758
- return this.isAsyncEnum(field) || this.isAsyncItemsEnum(field)
759
- },
760
-
761
- /**
762
- * Run client-side validation on all form fields.
763
- * Checks required, minLength, maxLength, pattern, minimum, maximum.
764
- *
765
- * @return {boolean} True if all fields pass validation
766
- * @public
767
- */
768
- validate() {
769
- const newErrors = {}
770
- for (const field of this.resolvedFields) {
771
- const value = this.formData[field.key]
772
-
773
- // Required check
774
- if (field.required) {
775
- if (value === null || value === undefined || value === '') {
776
- newErrors[field.key] = `${field.label} is required.`
777
- continue
778
- }
779
- if (Array.isArray(value) && value.length === 0) {
780
- newErrors[field.key] = `${field.label} is required.`
781
- continue
782
- }
783
- }
784
-
785
- // Skip further validation if empty and not required
786
- if (value === null || value === undefined || value === '') continue
787
-
788
- const v = field.validation || {}
789
-
790
- // String length checks
791
- if (typeof value === 'string') {
792
- if (v.minLength !== undefined && value.length < v.minLength) {
793
- newErrors[field.key] = `Minimum ${v.minLength} characters.`
794
- } else if (v.maxLength !== undefined && value.length > v.maxLength) {
795
- newErrors[field.key] = `Maximum ${v.maxLength} characters.`
796
- } else if (v.pattern !== undefined) {
797
- try {
798
- if (!new RegExp(v.pattern).test(value)) {
799
- newErrors[field.key] = 'Invalid format.'
800
- }
801
- // TODO: restore to `catch {` (optional catch binding) once on Vue 3 (buble doesn't support it)
802
- } catch (e) {
803
- // Ignore invalid regex patterns
804
- }
805
- }
806
- }
807
-
808
- // Number range checks
809
- if (typeof value === 'number') {
810
- if (v.minimum !== undefined && value < v.minimum) {
811
- newErrors[field.key] = `Minimum value is ${v.minimum}.`
812
- } else if (v.maximum !== undefined && value > v.maximum) {
813
- newErrors[field.key] = `Maximum value is ${v.maximum}.`
814
- }
815
- }
816
- }
817
-
818
- this.errors = newErrors
819
- return Object.keys(newErrors).length === 0
820
- },
821
-
822
- executeConfirm() {
823
- if (!this.validate()) return
824
-
825
- this.loading = true
826
- /**
827
- * @event confirm Emitted when the user confirms the form.
828
- * Payload: form data object. Includes `id` when editing.
829
- */
830
- this.$emit('confirm', { ...this.formData })
831
- },
832
-
833
- /**
834
- * Set the result of the save operation. Call this from the parent
835
- * after the API call completes.
836
- *
837
- * @param {{ success?: boolean, error?: string }} resultData
838
- * @public
839
- */
840
- setResult(resultData) {
841
- this.loading = false
842
- this.result = resultData
843
- if (resultData.success) {
844
- this.closeTimeout = setTimeout(() => {
845
- this.$emit('close')
846
- }, 2000)
847
- }
848
- },
849
-
850
- /**
851
- * Set per-field validation errors from the server. Call this from
852
- * the parent when the API returns validation errors.
853
- *
854
- * @param {object} fieldErrors Object keyed by field key with error messages
855
- * @public
856
- */
857
- setValidationErrors(fieldErrors) {
858
- this.loading = false
859
- this.errors = { ...this.errors, ...fieldErrors }
860
- },
861
- },
862
- }
863
- </script>
864
-
865
- <style scoped>
866
- .cn-form-dialog__form {
867
- display: flex;
868
- flex-direction: column;
869
- gap: 4px;
870
- }
871
-
872
- .cn-form-dialog__field {
873
- margin-bottom: 8px;
874
- }
875
-
876
- .cn-form-dialog__textarea-wrapper,
877
- .cn-form-dialog__select-wrapper {
878
- display: flex;
879
- flex-direction: column;
880
- gap: 4px;
881
- }
882
-
883
- .cn-form-dialog__label {
884
- font-weight: 600;
885
- font-size: 0.9em;
886
- color: var(--color-main-text);
887
- }
888
-
889
- .cn-form-dialog__textarea {
890
- width: 100%;
891
- min-height: 80px;
892
- padding: 8px;
893
- border: 2px solid var(--color-border-maxcontrast);
894
- border-radius: var(--border-radius-large);
895
- background-color: var(--color-main-background);
896
- color: var(--color-main-text);
897
- font-family: inherit;
898
- font-size: inherit;
899
- resize: vertical;
900
- }
901
-
902
- .cn-form-dialog__textarea:focus {
903
- border-color: var(--color-primary-element);
904
- outline: none;
905
- }
906
-
907
- .cn-form-dialog__textarea:disabled {
908
- opacity: 0.5;
909
- cursor: not-allowed;
910
- }
911
-
912
- .cn-form-dialog__helper {
913
- font-size: 0.85em;
914
- color: var(--color-text-maxcontrast);
915
- }
916
-
917
- .cn-form-dialog__helper--error {
918
- color: var(--color-error);
919
- }
920
- </style>
1
+ <template>
2
+ <NcDialog
3
+ :name="resolvedTitle"
4
+ :size="size"
5
+ :can-close="!loading"
6
+ @closing="$emit('close')">
7
+ <!-- Result phase -->
8
+ <div v-if="result !== null" class="cn-form-dialog__result">
9
+ <NcNoteCard v-if="result.success" type="success">
10
+ {{ resolvedSuccessText }}
11
+ </NcNoteCard>
12
+ <NcNoteCard v-if="result.error" type="error">
13
+ {{ result.error }}
14
+ </NcNoteCard>
15
+ </div>
16
+
17
+ <!-- Form phase -->
18
+ <div v-else class="cn-form-dialog__form">
19
+ <!-- Full form override slot -->
20
+ <slot
21
+ v-if="$scopedSlots.form"
22
+ name="form"
23
+ :fields="resolvedFields"
24
+ :form-data="formData"
25
+ :errors="errors"
26
+ :update-field="updateField" />
27
+
28
+ <!-- Auto-generated form -->
29
+ <template v-else>
30
+ <slot name="before-fields" />
31
+
32
+ <div
33
+ v-for="field in resolvedFields"
34
+ :key="field.key"
35
+ class="cn-form-dialog__field">
36
+ <!-- Per-field override slot -->
37
+ <slot
38
+ v-if="$scopedSlots['field-' + field.key]"
39
+ :name="'field-' + field.key"
40
+ :field="field"
41
+ :value="formData[field.key]"
42
+ :error="errors[field.key]"
43
+ :update-field="updateField" />
44
+
45
+ <!-- Auto-generated field -->
46
+ <template v-else>
47
+ <!-- Text / Email / URL -->
48
+ <NcTextField
49
+ v-if="field.widget === 'text' || field.widget === 'email' || field.widget === 'url'"
50
+ :label="field.label + (field.required ? ' *' : '')"
51
+ :value="formData[field.key] != null ? String(formData[field.key]) : ''"
52
+ :helper-text="errors[field.key] || field.description"
53
+ :error="!!errors[field.key]"
54
+ :type="field.widget === 'email' ? 'email' : field.widget === 'url' ? 'url' : 'text'"
55
+ :disabled="field.readOnly"
56
+ :placeholder="field.description"
57
+ @update:value="value => updateField(field.key, value)" />
58
+
59
+ <!-- Number -->
60
+ <NcTextField
61
+ v-else-if="field.widget === 'number'"
62
+ :label="field.label + (field.required ? ' *' : '')"
63
+ :value="formData[field.key] != null ? String(formData[field.key]) : ''"
64
+ :helper-text="errors[field.key] || field.description"
65
+ :error="!!errors[field.key]"
66
+ type="number"
67
+ :disabled="field.readOnly"
68
+ :placeholder="field.description"
69
+ @update:value="value => updateField(field.key, value !== '' ? Number(value) : null)" />
70
+
71
+ <!-- Textarea -->
72
+ <div v-else-if="field.widget === 'textarea'" class="cn-form-dialog__textarea-wrapper">
73
+ <label :for="'cn-form-' + field.key" class="cn-form-dialog__label">
74
+ {{ field.label }}{{ field.required ? ' *' : '' }}
75
+ </label>
76
+ <textarea
77
+ :id="'cn-form-' + field.key"
78
+ class="cn-form-dialog__textarea"
79
+ :value="formData[field.key] || ''"
80
+ :disabled="field.readOnly"
81
+ :placeholder="field.description"
82
+ rows="4"
83
+ @input="updateField(field.key, $event.target.value)" />
84
+ <span
85
+ v-if="errors[field.key] || field.description"
86
+ class="cn-form-dialog__helper"
87
+ :class="{ 'cn-form-dialog__helper--error': errors[field.key] }">
88
+ {{ errors[field.key] || field.description }}
89
+ </span>
90
+ </div>
91
+
92
+ <!-- Select (enum, supports async function) -->
93
+ <div v-else-if="field.widget === 'select'" class="cn-form-dialog__select-wrapper">
94
+ <label :for="'cn-form-' + field.key" class="cn-form-dialog__label">
95
+ {{ field.label }}{{ field.required ? ' *' : '' }}
96
+ </label>
97
+ <NcSelect
98
+ :input-id="'cn-form-' + field.key"
99
+ :options="getEffectiveOptions(field)"
100
+ :value="getEffectiveSelectedOption(field)"
101
+ :clearable="!field.required"
102
+ :disabled="field.readOnly"
103
+ :loading="isFieldLoading(field)"
104
+ :filterable="!isAsyncEnum(field)"
105
+ @input="onEffectiveSelectChange(field, $event)"
106
+ @search="isAsyncEnum(field) ? onAsyncSearch(field, $event) : undefined">
107
+ <template
108
+ v-if="$scopedSlots['field-' + field.key + '-option']"
109
+ #option="optionProps">
110
+ <slot :name="'field-' + field.key + '-option'" v-bind="optionProps" />
111
+ </template>
112
+ <template
113
+ v-if="$scopedSlots['field-' + field.key + '-selected-option']"
114
+ #selected-option="optionProps">
115
+ <slot :name="'field-' + field.key + '-selected-option'" v-bind="optionProps" />
116
+ </template>
117
+ </NcSelect>
118
+ <span
119
+ v-if="errors[field.key] || field.description"
120
+ class="cn-form-dialog__helper"
121
+ :class="{ 'cn-form-dialog__helper--error': errors[field.key] }">
122
+ {{ errors[field.key] || field.description }}
123
+ </span>
124
+ </div>
125
+
126
+ <!-- Multiselect (array with enum items, supports async function) -->
127
+ <div v-else-if="field.widget === 'multiselect'" class="cn-form-dialog__select-wrapper">
128
+ <label :for="'cn-form-' + field.key" class="cn-form-dialog__label">
129
+ {{ field.label }}{{ field.required ? ' *' : '' }}
130
+ </label>
131
+ <NcSelect
132
+ :input-id="'cn-form-' + field.key"
133
+ :options="getEffectiveArrayOptions(field)"
134
+ :value="getEffectiveSelectedArrayOptions(field)"
135
+ :multiple="true"
136
+ :clearable="true"
137
+ :disabled="field.readOnly"
138
+ :loading="isFieldLoading(field)"
139
+ :filterable="!isAsyncItemsEnum(field)"
140
+ @input="onEffectiveMultiSelectChange(field, $event)"
141
+ @search="isAsyncItemsEnum(field) ? onAsyncSearch(field, $event) : undefined">
142
+ <template
143
+ v-if="$scopedSlots['field-' + field.key + '-option']"
144
+ #option="optionProps">
145
+ <slot :name="'field-' + field.key + '-option'" v-bind="optionProps" />
146
+ </template>
147
+ <template
148
+ v-if="$scopedSlots['field-' + field.key + '-selected-option']"
149
+ #selected-option="optionProps">
150
+ <slot :name="'field-' + field.key + '-selected-option'" v-bind="optionProps" />
151
+ </template>
152
+ </NcSelect>
153
+ <span
154
+ v-if="errors[field.key] || field.description"
155
+ class="cn-form-dialog__helper"
156
+ :class="{ 'cn-form-dialog__helper--error': errors[field.key] }">
157
+ {{ errors[field.key] || field.description }}
158
+ </span>
159
+ </div>
160
+
161
+ <!-- Tags (array, freeform, supports async suggestions) -->
162
+ <div v-else-if="field.widget === 'tags'" class="cn-form-dialog__select-wrapper">
163
+ <label :for="'cn-form-' + field.key" class="cn-form-dialog__label">
164
+ {{ field.label }}{{ field.required ? ' *' : '' }}
165
+ </label>
166
+ <!-- TODO: restore `:options` to `asyncState[field.key]?.options` once on Vue 3 (buble doesn't support optional chaining) -->
167
+ <NcSelect
168
+ :input-id="'cn-form-' + field.key"
169
+ :value="formData[field.key] || []"
170
+ :options="isFieldAsync(field) ? ((asyncState[field.key] && asyncState[field.key].options) || []) : []"
171
+ :multiple="true"
172
+ :taggable="true"
173
+ :clearable="true"
174
+ :disabled="field.readOnly"
175
+ :loading="isFieldLoading(field)"
176
+ :filterable="!isFieldAsync(field)"
177
+ @input="updateField(field.key, $event)"
178
+ @search="isFieldAsync(field) ? onAsyncSearch(field, $event) : undefined">
179
+ <template
180
+ v-if="$scopedSlots['field-' + field.key + '-option']"
181
+ #option="optionProps">
182
+ <slot :name="'field-' + field.key + '-option'" v-bind="optionProps" />
183
+ </template>
184
+ <template
185
+ v-if="$scopedSlots['field-' + field.key + '-selected-option']"
186
+ #selected-option="optionProps">
187
+ <slot :name="'field-' + field.key + '-selected-option'" v-bind="optionProps" />
188
+ </template>
189
+ </NcSelect>
190
+ <span
191
+ v-if="errors[field.key] || field.description"
192
+ class="cn-form-dialog__helper"
193
+ :class="{ 'cn-form-dialog__helper--error': errors[field.key] }">
194
+ {{ errors[field.key] || field.description }}
195
+ </span>
196
+ </div>
197
+
198
+ <!-- Checkbox / Switch (boolean) -->
199
+ <NcCheckboxRadioSwitch
200
+ v-else-if="field.widget === 'checkbox'"
201
+ :checked="!!formData[field.key]"
202
+ :disabled="field.readOnly"
203
+ type="switch"
204
+ @update:checked="value => updateField(field.key, value)">
205
+ {{ field.label }}{{ field.required ? ' *' : '' }}
206
+ </NcCheckboxRadioSwitch>
207
+
208
+ <!-- Date -->
209
+ <NcTextField
210
+ v-else-if="field.widget === 'date'"
211
+ :label="field.label + (field.required ? ' *' : '')"
212
+ :value="formData[field.key] || ''"
213
+ :helper-text="errors[field.key] || field.description"
214
+ :error="!!errors[field.key]"
215
+ type="date"
216
+ :disabled="field.readOnly"
217
+ @update:value="value => updateField(field.key, value)" />
218
+
219
+ <!-- Datetime -->
220
+ <NcTextField
221
+ v-else-if="field.widget === 'datetime'"
222
+ :label="field.label + (field.required ? ' *' : '')"
223
+ :value="formData[field.key] || ''"
224
+ :helper-text="errors[field.key] || field.description"
225
+ :error="!!errors[field.key]"
226
+ type="datetime-local"
227
+ :disabled="field.readOnly"
228
+ @update:value="value => updateField(field.key, value)" />
229
+
230
+ <!-- Fallback: text input -->
231
+ <NcTextField
232
+ v-else
233
+ :label="field.label + (field.required ? ' *' : '')"
234
+ :value="formData[field.key] != null ? String(formData[field.key]) : ''"
235
+ :helper-text="errors[field.key] || field.description"
236
+ :error="!!errors[field.key]"
237
+ :disabled="field.readOnly"
238
+ :placeholder="field.description"
239
+ @update:value="value => updateField(field.key, value)" />
240
+ </template>
241
+ </div>
242
+
243
+ <slot name="after-fields" />
244
+ </template>
245
+ </div>
246
+
247
+ <template #actions>
248
+ <NcButton @click="$emit('close')">
249
+ {{ result !== null ? closeLabel : cancelLabel }}
250
+ </NcButton>
251
+ <NcButton
252
+ v-if="result === null"
253
+ type="primary"
254
+ :disabled="loading || !requiredFieldsFilled"
255
+ @click="executeConfirm">
256
+ <template #icon>
257
+ <NcLoadingIcon v-if="loading" :size="20" />
258
+ <Plus v-else-if="isCreateMode" :size="20" />
259
+ <ContentSaveOutline v-else :size="20" />
260
+ </template>
261
+ {{ resolvedConfirmLabel }}
262
+ </NcButton>
263
+ </template>
264
+ </NcDialog>
265
+ </template>
266
+
267
+ <script>
268
+ import { NcDialog, NcButton, NcNoteCard, NcLoadingIcon, NcTextField, NcSelect, NcCheckboxRadioSwitch } from '@nextcloud/vue'
269
+ import Plus from 'vue-material-design-icons/Plus.vue'
270
+ import ContentSaveOutline from 'vue-material-design-icons/ContentSaveOutline.vue'
271
+ import { fieldsFromSchema } from '../../utils/schema.js'
272
+
273
+ /**
274
+ * CnFormDialog — Create/edit dialog with auto-generated form from schema.
275
+ *
276
+ * When `item` is null, operates in create mode. When `item` is provided,
277
+ * operates in edit mode. Auto-generates form fields from schema using
278
+ * `fieldsFromSchema()`, but supports slot overrides at three levels:
279
+ *
280
+ * - `#form` — Replace the entire form content
281
+ * - `#field-{key}` — Replace a single auto-generated field
282
+ * - `#field-{key}-option` — Customize dropdown option rendering for a select/multiselect/tags field
283
+ * - `#field-{key}-selected-option` — Customize selected option display for a select/multiselect/tags field
284
+ * - `#before-fields` / `#after-fields` — Inject content around fields
285
+ *
286
+ * ## Async select support
287
+ *
288
+ * Select, multiselect, and tags fields support async options by setting `field.enum`
289
+ * (or `field.items.enum` for multiselect) to an async function instead of a static array:
290
+ *
291
+ * ```js
292
+ * { key: 'org', widget: 'select', enum: async (query) => fetchOrgs(query) }
293
+ * ```
294
+ *
295
+ * The function receives the search query and must return an array of option objects
296
+ * (each must have a `label` property for default display). Options are loaded on mount
297
+ * (with empty query) and on each search input (debounced, default 300ms, configurable
298
+ * via `field.debounce`). Async selects store the full option object in formData.
299
+ *
300
+ * The dialog does NOT perform the save itself — it emits a `confirm` event
301
+ * with the form data. The parent performs the actual API call and calls
302
+ * `setResult()` via a ref.
303
+ *
304
+ * @event confirm Emitted when the user confirms the form. Payload: formData object (includes `id` in edit mode).
305
+ * @event close Emitted when the dialog should be closed (cancel, close button, or auto-close after success).
306
+ *
307
+ * @example
308
+ * <CnFormDialog
309
+ * v-if="showFormDialog"
310
+ * ref="formDialog"
311
+ * :schema="schema"
312
+ * :item="editItem"
313
+ * @confirm="onFormConfirm"
314
+ * @close="showFormDialog = false" />
315
+ *
316
+ * // In methods:
317
+ * async onFormConfirm(formData) {
318
+ * try {
319
+ * if (formData.id) {
320
+ * await store.updateItem(formData.id, formData)
321
+ * } else {
322
+ * await store.createItem(formData)
323
+ * }
324
+ * this.$refs.formDialog.setResult({ success: true })
325
+ * } catch (e) {
326
+ * this.$refs.formDialog.setResult({ error: e.message })
327
+ * }
328
+ * }
329
+ *
330
+ * @example <caption>Async select with custom option rendering</caption>
331
+ * <CnFormDialog :fields="[{
332
+ * key: 'organisation',
333
+ * widget: 'select',
334
+ * label: 'Organisation',
335
+ * required: true,
336
+ * enum: async (query) => {
337
+ * const results = await store.search(query)
338
+ * return results.map(o => ({ label: o.name, id: o.uuid, ...o }))
339
+ * },
340
+ * debounce: 500,
341
+ * }]" @confirm="onConfirm">
342
+ * <template #field-organisation-option="{ name, description }">
343
+ * <strong>{{ name }}</strong>
344
+ * <p>{{ description }}</p>
345
+ * </template>
346
+ * <template #field-organisation-selected-option="{ name }">
347
+ * {{ name }}
348
+ * </template>
349
+ * </CnFormDialog>
350
+ */
351
+ export default {
352
+ name: 'CnFormDialog',
353
+
354
+ components: {
355
+ NcDialog,
356
+ NcButton,
357
+ NcNoteCard,
358
+ NcLoadingIcon,
359
+ NcTextField,
360
+ NcSelect,
361
+ NcCheckboxRadioSwitch,
362
+ Plus,
363
+ ContentSaveOutline,
364
+ },
365
+
366
+ props: {
367
+ /** Schema for auto-generating fields. Either schema or fields must be provided. */
368
+ schema: {
369
+ type: Object,
370
+ default: null,
371
+ },
372
+ /** Existing item for edit mode. Pass null for create mode. */
373
+ item: {
374
+ type: Object,
375
+ default: null,
376
+ },
377
+ /** Dialog title. Defaults to "Create {schema.title}" or "Edit {schema.title}". */
378
+ dialogTitle: {
379
+ type: String,
380
+ default: '',
381
+ },
382
+ /** Manual field definitions. Overrides schema-generated fields when provided. */
383
+ fields: {
384
+ type: Array,
385
+ default: null,
386
+ },
387
+ /** Field keys to exclude from auto-generated form */
388
+ excludeFields: {
389
+ type: Array,
390
+ default: () => [],
391
+ },
392
+ /** Field keys to include (whitelist mode) */
393
+ includeFields: {
394
+ type: Array,
395
+ default: null,
396
+ },
397
+ /** Per-field overrides passed to fieldsFromSchema */
398
+ fieldOverrides: {
399
+ type: Object,
400
+ default: () => ({}),
401
+ },
402
+ /** Which field is the "name" (used in result messages) */
403
+ nameField: {
404
+ type: String,
405
+ default: 'title',
406
+ },
407
+ /** NcDialog size */
408
+ size: {
409
+ type: String,
410
+ default: 'normal',
411
+ },
412
+ /** Success message. Defaults to "Item saved successfully." */
413
+ successText: {
414
+ type: String,
415
+ default: '',
416
+ },
417
+ cancelLabel: { type: String, default: 'Cancel' },
418
+ closeLabel: { type: String, default: 'Close' },
419
+ /** Confirm button label. Defaults to "Create" or "Save". */
420
+ confirmLabel: {
421
+ type: String,
422
+ default: '',
423
+ },
424
+ },
425
+
426
+ data() {
427
+ return {
428
+ formData: {},
429
+ errors: {},
430
+ loading: false,
431
+ result: null,
432
+ closeTimeout: null,
433
+ /** Per-field async state: { [fieldKey]: { options: [], loading: false, searchTimeout: null } } */
434
+ asyncState: {},
435
+ }
436
+ },
437
+
438
+ computed: {
439
+ isCreateMode() {
440
+ return !this.item
441
+ },
442
+
443
+ schemaTitle() {
444
+ return (this.schema && this.schema.title) || 'Item'
445
+ },
446
+
447
+ resolvedTitle() {
448
+ if (this.dialogTitle) return this.dialogTitle
449
+ return this.isCreateMode
450
+ ? `Create ${this.schemaTitle}`
451
+ : `Edit ${this.schemaTitle}`
452
+ },
453
+
454
+ resolvedConfirmLabel() {
455
+ if (this.confirmLabel) return this.confirmLabel
456
+ return this.isCreateMode ? 'Create' : 'Save'
457
+ },
458
+
459
+ resolvedSuccessText() {
460
+ if (this.successText) return this.successText
461
+ return `${this.schemaTitle} saved successfully.`
462
+ },
463
+
464
+ /** Whether all required fields have a non-empty value */
465
+ requiredFieldsFilled() {
466
+ return this.resolvedFields
467
+ .filter((f) => f.required)
468
+ .every((f) => {
469
+ const val = this.formData[f.key]
470
+ if (val === null || val === undefined || val === '') return false
471
+ if (Array.isArray(val) && val.length === 0) return false
472
+ return true
473
+ })
474
+ },
475
+
476
+ resolvedFields() {
477
+ // Manual fields take priority
478
+ if (this.fields) return this.fields
479
+
480
+ // Auto-generate from schema
481
+ return fieldsFromSchema(this.schema, {
482
+ exclude: this.excludeFields,
483
+ include: this.includeFields,
484
+ overrides: this.fieldOverrides,
485
+ })
486
+ },
487
+ },
488
+
489
+ watch: {
490
+ item: {
491
+ immediate: true,
492
+ handler(newItem) {
493
+ this.initFormData(newItem)
494
+ },
495
+ },
496
+ },
497
+
498
+ beforeDestroy() {
499
+ for (const state of Object.values(this.asyncState)) {
500
+ if (state.searchTimeout) clearTimeout(state.searchTimeout)
501
+ }
502
+ if (this.closeTimeout) clearTimeout(this.closeTimeout)
503
+ },
504
+
505
+ methods: {
506
+ initFormData(item) {
507
+ if (item) {
508
+ // Edit mode: clone item data
509
+ this.formData = JSON.parse(JSON.stringify(item))
510
+ } else {
511
+ // Create mode: initialize with field defaults
512
+ const data = {}
513
+ for (const field of this.resolvedFields) {
514
+ if (field.default !== null && field.default !== undefined) {
515
+ data[field.key] = field.default
516
+ } else if (field.widget === 'checkbox') {
517
+ data[field.key] = false
518
+ } else if (field.widget === 'tags' || field.widget === 'multiselect') {
519
+ data[field.key] = []
520
+ } else {
521
+ data[field.key] = null
522
+ }
523
+ }
524
+ this.formData = data
525
+ }
526
+ this.errors = {}
527
+ this.initAsyncFields()
528
+ },
529
+
530
+ updateField(key, value) {
531
+ this.$set(this.formData, key, value)
532
+ // Clear error when field is edited
533
+ if (this.errors[key]) {
534
+ this.$delete(this.errors, key)
535
+ }
536
+ },
537
+
538
+ getEnumOptions(field) {
539
+ if (!field.enum) return []
540
+ const labels = field.enumLabels || {}
541
+ return field.enum.map((val) => ({
542
+ id: val,
543
+ label: labels[val] || String(val),
544
+ }))
545
+ },
546
+
547
+ getSelectedEnumOption(field) {
548
+ const val = this.formData[field.key]
549
+ if (val === null || val === undefined) return null
550
+ const labels = field.enumLabels || {}
551
+ return { id: val, label: labels[val] || String(val) }
552
+ },
553
+
554
+ onSelectChange(key, option) {
555
+ this.updateField(key, option ? option.id : null)
556
+ },
557
+
558
+ getArrayEnumOptions(field) {
559
+ if (!field.items || !field.items.enum) return []
560
+ return field.items.enum.map((val) => ({
561
+ id: val,
562
+ label: String(val),
563
+ }))
564
+ },
565
+
566
+ getSelectedArrayOptions(field) {
567
+ const val = this.formData[field.key]
568
+ if (!Array.isArray(val)) return []
569
+ return val.map((v) => ({ id: v, label: String(v) }))
570
+ },
571
+
572
+ onMultiSelectChange(key, options) {
573
+ this.updateField(key, (options || []).map((o) => o.id))
574
+ },
575
+
576
+ /**
577
+ * Check if a field has an async enum (function instead of static array).
578
+ *
579
+ * @param {object} field The field definition
580
+ * @return {boolean}
581
+ */
582
+ isAsyncEnum(field) {
583
+ return typeof field.enum === 'function'
584
+ },
585
+
586
+ /**
587
+ * Check if an array field has an async items enum.
588
+ *
589
+ * @param {object} field The field definition
590
+ * @return {boolean}
591
+ */
592
+ isAsyncItemsEnum(field) {
593
+ return !!(field.items && typeof field.items.enum === 'function')
594
+ },
595
+
596
+ /**
597
+ * Initialize async state for all async fields and trigger initial load.
598
+ */
599
+ initAsyncFields() {
600
+ // Clean up existing timeouts
601
+ for (const state of Object.values(this.asyncState)) {
602
+ if (state.searchTimeout) clearTimeout(state.searchTimeout)
603
+ }
604
+
605
+ const newState = {}
606
+ for (const field of this.resolvedFields) {
607
+ if (this.isAsyncEnum(field) || this.isAsyncItemsEnum(field)) {
608
+ newState[field.key] = { options: [], loading: false, searchTimeout: null }
609
+ }
610
+ }
611
+ this.asyncState = newState
612
+
613
+ // Trigger initial load for each async field
614
+ this.$nextTick(() => {
615
+ for (const field of this.resolvedFields) {
616
+ if (this.isAsyncEnum(field) || this.isAsyncItemsEnum(field)) {
617
+ this.loadAsyncOptions(field, '')
618
+ }
619
+ }
620
+ })
621
+ },
622
+
623
+ /**
624
+ * Load async options for a field by calling its enum function.
625
+ *
626
+ * @param {object} field The field definition
627
+ * @param {string} query Search query
628
+ */
629
+ async loadAsyncOptions(field, query) {
630
+ const state = this.asyncState[field.key]
631
+ if (!state) return
632
+
633
+ this.$set(state, 'loading', true)
634
+
635
+ try {
636
+ const enumFn = this.isAsyncEnum(field) ? field.enum : field.items.enum
637
+ const results = await enumFn(query)
638
+ this.$set(state, 'options', Array.isArray(results) ? results : [])
639
+ } catch (err) {
640
+ console.error(`CnFormDialog: async enum error for field "${field.key}":`, err)
641
+ this.$set(state, 'options', [])
642
+ } finally {
643
+ this.$set(state, 'loading', false)
644
+ }
645
+ },
646
+
647
+ /**
648
+ * Handle search input on an async select with debounce.
649
+ *
650
+ * @param {object} field The field definition
651
+ * @param {string} query Search query
652
+ */
653
+ onAsyncSearch(field, query) {
654
+ const state = this.asyncState[field.key]
655
+ if (!state) return
656
+
657
+ if (state.searchTimeout) {
658
+ clearTimeout(state.searchTimeout)
659
+ }
660
+
661
+ const debounceMs = field.debounce || 300
662
+
663
+ state.searchTimeout = setTimeout(() => {
664
+ this.loadAsyncOptions(field, query || '')
665
+ }, debounceMs)
666
+ },
667
+
668
+ /**
669
+ * Get the effective options for a select field (async or static).
670
+ *
671
+ * @param {object} field The field definition
672
+ * @return {Array}
673
+ */
674
+ getEffectiveOptions(field) {
675
+ if (this.isAsyncEnum(field)) {
676
+ // TODO: restore to `this.asyncState[field.key]?.options || []` once on Vue 3 (buble doesn't support optional chaining)
677
+ return (this.asyncState[field.key] && this.asyncState[field.key].options) || []
678
+ }
679
+ return this.getEnumOptions(field)
680
+ },
681
+
682
+ /**
683
+ * Get the effective selected value for a select field (async or static).
684
+ *
685
+ * @param {object} field The field definition
686
+ * @return {object|null}
687
+ */
688
+ getEffectiveSelectedOption(field) {
689
+ if (this.isAsyncEnum(field)) {
690
+ // For async fields, formData stores the full option object
691
+ return this.formData[field.key] || null
692
+ }
693
+ return this.getSelectedEnumOption(field)
694
+ },
695
+
696
+ /**
697
+ * Handle select change for both async and static fields.
698
+ *
699
+ * @param {object} field The field definition
700
+ * @param {object|null} option The selected option
701
+ */
702
+ onEffectiveSelectChange(field, option) {
703
+ if (this.isAsyncEnum(field)) {
704
+ // Store full option object for async selects
705
+ this.updateField(field.key, option || null)
706
+ } else {
707
+ this.onSelectChange(field.key, option)
708
+ }
709
+ },
710
+
711
+ /**
712
+ * Get effective options for a multiselect field (async or static).
713
+ *
714
+ * @param {object} field The field definition
715
+ * @return {Array}
716
+ */
717
+ getEffectiveArrayOptions(field) {
718
+ if (this.isAsyncItemsEnum(field)) {
719
+ // TODO: restore to `this.asyncState[field.key]?.options || []` once on Vue 3 (buble doesn't support optional chaining)
720
+ return (this.asyncState[field.key] && this.asyncState[field.key].options) || []
721
+ }
722
+ return this.getArrayEnumOptions(field)
723
+ },
724
+
725
+ /**
726
+ * Get effective selected values for a multiselect field (async or static).
727
+ *
728
+ * @param {object} field The field definition
729
+ * @return {Array}
730
+ */
731
+ getEffectiveSelectedArrayOptions(field) {
732
+ if (this.isAsyncItemsEnum(field)) {
733
+ // For async fields, formData stores array of full option objects
734
+ return this.formData[field.key] || []
735
+ }
736
+ return this.getSelectedArrayOptions(field)
737
+ },
738
+
739
+ /**
740
+ * Handle multiselect change for both async and static fields.
741
+ *
742
+ * @param {object} field The field definition
743
+ * @param {Array} options The selected options
744
+ */
745
+ onEffectiveMultiSelectChange(field, options) {
746
+ if (this.isAsyncItemsEnum(field)) {
747
+ // Store full option objects for async multiselect
748
+ this.updateField(field.key, options || [])
749
+ } else {
750
+ this.onMultiSelectChange(field.key, options)
751
+ }
752
+ },
753
+
754
+ /**
755
+ * Whether a field's async options are currently loading.
756
+ *
757
+ * @param {object} field The field definition
758
+ * @return {boolean}
759
+ */
760
+ isFieldLoading(field) {
761
+ // TODO: restore to `this.asyncState[field.key]?.loading || false` once on Vue 3 (buble doesn't support optional chaining)
762
+ return (this.asyncState[field.key] && this.asyncState[field.key].loading) || false
763
+ },
764
+
765
+ /**
766
+ * Whether a field has any async behavior (enum or items.enum is a function).
767
+ *
768
+ * @param {object} field The field definition
769
+ * @return {boolean}
770
+ */
771
+ isFieldAsync(field) {
772
+ return this.isAsyncEnum(field) || this.isAsyncItemsEnum(field)
773
+ },
774
+
775
+ /**
776
+ * Run client-side validation on all form fields.
777
+ * Checks required, minLength, maxLength, pattern, minimum, maximum.
778
+ *
779
+ * @return {boolean} True if all fields pass validation
780
+ * @public
781
+ */
782
+ validate() {
783
+ const newErrors = {}
784
+ for (const field of this.resolvedFields) {
785
+ const value = this.formData[field.key]
786
+
787
+ // Required check
788
+ if (field.required) {
789
+ if (value === null || value === undefined || value === '') {
790
+ newErrors[field.key] = `${field.label} is required.`
791
+ continue
792
+ }
793
+ if (Array.isArray(value) && value.length === 0) {
794
+ newErrors[field.key] = `${field.label} is required.`
795
+ continue
796
+ }
797
+ }
798
+
799
+ // Skip further validation if empty and not required
800
+ if (value === null || value === undefined || value === '') continue
801
+
802
+ const v = field.validation || {}
803
+
804
+ // String length checks
805
+ if (typeof value === 'string') {
806
+ if (v.minLength !== undefined && value.length < v.minLength) {
807
+ newErrors[field.key] = `Minimum ${v.minLength} characters.`
808
+ } else if (v.maxLength !== undefined && value.length > v.maxLength) {
809
+ newErrors[field.key] = `Maximum ${v.maxLength} characters.`
810
+ } else if (v.pattern !== undefined) {
811
+ try {
812
+ if (!new RegExp(v.pattern).test(value)) {
813
+ newErrors[field.key] = 'Invalid format.'
814
+ }
815
+ // TODO: restore to `catch {` (optional catch binding) once on Vue 3 (buble doesn't support it)
816
+ } catch (e) {
817
+ // Ignore invalid regex patterns
818
+ }
819
+ }
820
+ }
821
+
822
+ // Number range checks
823
+ if (typeof value === 'number') {
824
+ if (v.minimum !== undefined && value < v.minimum) {
825
+ newErrors[field.key] = `Minimum value is ${v.minimum}.`
826
+ } else if (v.maximum !== undefined && value > v.maximum) {
827
+ newErrors[field.key] = `Maximum value is ${v.maximum}.`
828
+ }
829
+ }
830
+ }
831
+
832
+ this.errors = newErrors
833
+ return Object.keys(newErrors).length === 0
834
+ },
835
+
836
+ executeConfirm() {
837
+ if (!this.validate()) return
838
+
839
+ this.loading = true
840
+ /**
841
+ * @event confirm Emitted when the user confirms the form.
842
+ * Payload: form data object. Includes `id` when editing.
843
+ */
844
+ this.$emit('confirm', { ...this.formData })
845
+ },
846
+
847
+ /**
848
+ * Set the result of the save operation. Call this from the parent
849
+ * after the API call completes.
850
+ *
851
+ * @param {{ success?: boolean, error?: string }} resultData - Result data to pass to the dialog
852
+ * @public
853
+ */
854
+ setResult(resultData) {
855
+ this.loading = false
856
+ this.result = resultData
857
+ if (resultData.success) {
858
+ this.closeTimeout = setTimeout(() => {
859
+ this.$emit('close')
860
+ }, 2000)
861
+ }
862
+ },
863
+
864
+ /**
865
+ * Set per-field validation errors from the server. Call this from
866
+ * the parent when the API returns validation errors.
867
+ *
868
+ * @param {object} fieldErrors Object keyed by field key with error messages
869
+ * @public
870
+ */
871
+ setValidationErrors(fieldErrors) {
872
+ this.loading = false
873
+ this.errors = { ...this.errors, ...fieldErrors }
874
+ },
875
+ },
876
+ }
877
+ </script>
878
+
879
+ <style scoped>
880
+ .cn-form-dialog__form {
881
+ display: flex;
882
+ flex-direction: column;
883
+ gap: 4px;
884
+ }
885
+
886
+ .cn-form-dialog__field {
887
+ margin-bottom: 8px;
888
+ }
889
+
890
+ .cn-form-dialog__textarea-wrapper,
891
+ .cn-form-dialog__select-wrapper {
892
+ display: flex;
893
+ flex-direction: column;
894
+ gap: 4px;
895
+ }
896
+
897
+ .cn-form-dialog__label {
898
+ font-weight: 600;
899
+ font-size: 0.9em;
900
+ color: var(--color-main-text);
901
+ }
902
+
903
+ .cn-form-dialog__textarea {
904
+ width: 100%;
905
+ min-height: 80px;
906
+ padding: 8px;
907
+ border: 2px solid var(--color-border-maxcontrast);
908
+ border-radius: var(--border-radius-large);
909
+ background-color: var(--color-main-background);
910
+ color: var(--color-main-text);
911
+ font-family: inherit;
912
+ font-size: inherit;
913
+ resize: vertical;
914
+ }
915
+
916
+ .cn-form-dialog__textarea:focus {
917
+ border-color: var(--color-primary-element);
918
+ outline: none;
919
+ }
920
+
921
+ .cn-form-dialog__textarea:disabled {
922
+ opacity: 0.5;
923
+ cursor: not-allowed;
924
+ }
925
+
926
+ .cn-form-dialog__helper {
927
+ font-size: 0.85em;
928
+ color: var(--color-text-maxcontrast);
929
+ }
930
+
931
+ .cn-form-dialog__helper--error {
932
+ color: var(--color-error);
933
+ }
934
+ </style>