@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.
- package/dist/nextcloud-vue.cjs.js +13606 -1918
- package/dist/nextcloud-vue.cjs.js.map +1 -1
- package/dist/nextcloud-vue.css +1238 -270
- package/dist/nextcloud-vue.esm.js +13548 -1880
- package/dist/nextcloud-vue.esm.js.map +1 -1
- package/package.json +9 -4
- package/src/components/CnActionsBar/CnActionsBar.vue +6 -1
- package/src/components/CnAdvancedFormDialog/CnAdvancedFormDialog.vue +1 -11
- package/src/components/CnAdvancedFormDialog/CnPropertiesTab.vue +5 -1
- package/src/components/CnAdvancedFormDialog/CnPropertyValueCell.vue +1 -1
- package/src/components/CnCard/CnCard.vue +415 -0
- package/src/components/CnCard/index.js +1 -0
- package/src/components/CnCardGrid/CnCardGrid.vue +20 -20
- package/src/components/CnChartWidget/CnChartWidget.vue +3 -1
- package/src/components/CnCopyDialog/CnCopyDialog.vue +7 -1
- package/src/components/CnDashboardGrid/CnDashboardGrid.vue +4 -0
- package/src/components/CnDashboardPage/CnDashboardPage.vue +2 -0
- package/src/components/CnDataTable/CnDataTable.vue +6 -2
- package/src/components/CnDeleteDialog/CnDeleteDialog.vue +7 -1
- package/src/components/CnDetailCard/CnDetailCard.vue +12 -1
- package/src/components/CnDetailGrid/CnDetailGrid.vue +254 -0
- package/src/components/CnDetailGrid/index.js +1 -0
- package/src/components/CnDetailPage/CnDetailPage.vue +157 -11
- package/src/components/CnFacetSidebar/CnFacetSidebar.vue +3 -1
- package/src/components/CnFormDialog/CnFormDialog.vue +934 -920
- package/src/components/CnIcon/CnIcon.vue +1 -1
- package/src/components/CnIndexPage/CnIndexPage.vue +51 -9
- package/src/components/CnIndexSidebar/CnIndexSidebar.vue +37 -9
- package/src/components/CnInfoWidget/CnInfoWidget.vue +219 -0
- package/src/components/CnInfoWidget/index.js +1 -0
- package/src/components/CnJsonViewer/CnJsonViewer.vue +283 -0
- package/src/components/CnJsonViewer/index.js +1 -0
- package/src/components/CnKpiGrid/CnKpiGrid.vue +5 -1
- package/src/components/CnMassCopyDialog/CnMassCopyDialog.vue +7 -1
- package/src/components/CnMassDeleteDialog/CnMassDeleteDialog.vue +7 -1
- package/src/components/CnMassExportDialog/CnMassExportDialog.vue +1 -1
- package/src/components/CnMassImportDialog/CnMassImportDialog.vue +1 -1
- package/src/components/CnObjectCard/CnObjectCard.vue +1 -1
- package/src/components/CnObjectSidebar/CnAuditTrailTab.vue +368 -0
- package/src/components/CnObjectSidebar/CnFilesTab.vue +286 -0
- package/src/components/CnObjectSidebar/CnNotesTab.vue +249 -0
- package/src/components/CnObjectSidebar/CnObjectSidebar.vue +45 -668
- package/src/components/CnObjectSidebar/CnTagsTab.vue +258 -0
- package/src/components/CnObjectSidebar/CnTasksTab.vue +482 -0
- package/src/components/CnObjectSidebar/index.js +5 -0
- package/src/components/CnProgressBar/CnProgressBar.vue +262 -0
- package/src/components/CnProgressBar/index.js +1 -0
- package/src/components/CnSchemaFormDialog/CnSchemaPropertiesTab.vue +1 -1
- package/src/components/CnStatsBlock/CnStatsBlock.vue +27 -11
- package/src/components/CnStatsPanel/CnStatsPanel.vue +320 -0
- package/src/components/CnStatsPanel/index.js +1 -0
- package/src/components/CnStatusBadge/CnStatusBadge.vue +15 -2
- package/src/components/CnTabbedFormDialog/CnTabbedFormDialog.vue +5 -1
- package/src/components/CnTableWidget/CnTableWidget.vue +332 -0
- package/src/components/CnTableWidget/index.js +1 -0
- package/src/components/CnWidgetWrapper/CnWidgetWrapper.vue +36 -1
- package/src/components/index.js +11 -0
- package/src/composables/useDashboardView.js +58 -12
- package/src/composables/useDetailView.js +3 -2
- package/src/composables/useListView.js +7 -6
- package/src/composables/useSubResource.js +3 -3
- package/src/css/badge.css +32 -0
- package/src/css/card.css +1 -0
- package/src/css/detail-page.css +74 -7
- package/src/index.js +16 -0
- package/src/mixins/gridLayout.js +118 -0
- package/src/store/createCrudStore.js +360 -0
- package/src/store/createSubResourcePlugin.js +5 -15
- package/src/store/index.js +1 -0
- package/src/store/plugins/auditTrails.js +346 -6
- package/src/store/plugins/lifecycle.js +4 -4
- package/src/store/plugins/registerMapping.js +18 -8
- package/src/store/plugins/relations.js +1 -1
- package/src/store/plugins/search.js +21 -8
- package/src/store/useObjectStore.js +30 -36
- package/src/utils/getTheme.js +9 -0
- package/src/utils/headers.js +13 -3
- package/src/utils/index.js +1 -0
- package/src/utils/schema.js +3 -3
- package/src/utils/widgetVisibility.js +162 -0
- package/src/components/CnObjectCard/eslint-setup.md +0 -235
- 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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
},
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
},
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
return
|
|
570
|
-
},
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
},
|
|
667
|
-
|
|
668
|
-
/**
|
|
669
|
-
* Get the effective
|
|
670
|
-
*
|
|
671
|
-
* @param {object} field The field definition
|
|
672
|
-
* @return {
|
|
673
|
-
*/
|
|
674
|
-
|
|
675
|
-
if (this.isAsyncEnum(field)) {
|
|
676
|
-
//
|
|
677
|
-
return this.
|
|
678
|
-
}
|
|
679
|
-
return this.
|
|
680
|
-
},
|
|
681
|
-
|
|
682
|
-
/**
|
|
683
|
-
*
|
|
684
|
-
*
|
|
685
|
-
* @param {object} field The field definition
|
|
686
|
-
* @
|
|
687
|
-
*/
|
|
688
|
-
|
|
689
|
-
if (this.isAsyncEnum(field)) {
|
|
690
|
-
//
|
|
691
|
-
this.
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
*
|
|
699
|
-
*
|
|
700
|
-
* @param {object}
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
},
|
|
710
|
-
|
|
711
|
-
/**
|
|
712
|
-
* Get effective
|
|
713
|
-
*
|
|
714
|
-
* @param {object} field The field definition
|
|
715
|
-
* @return {Array}
|
|
716
|
-
*/
|
|
717
|
-
|
|
718
|
-
if (this.isAsyncItemsEnum(field)) {
|
|
719
|
-
//
|
|
720
|
-
return this.
|
|
721
|
-
}
|
|
722
|
-
return this.
|
|
723
|
-
},
|
|
724
|
-
|
|
725
|
-
/**
|
|
726
|
-
*
|
|
727
|
-
*
|
|
728
|
-
* @param {object} field The field definition
|
|
729
|
-
* @
|
|
730
|
-
*/
|
|
731
|
-
|
|
732
|
-
if (this.isAsyncItemsEnum(field)) {
|
|
733
|
-
//
|
|
734
|
-
this.
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
*
|
|
742
|
-
*
|
|
743
|
-
* @param {
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
*
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
*
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
if (v.
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
*
|
|
852
|
-
*
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
.
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
.
|
|
873
|
-
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
font-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
}
|
|
920
|
-
|
|
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>
|