@conduction/nextcloud-vue 0.1.0-beta.1 → 0.1.0-beta.2
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/README.md +226 -0
- package/dist/nextcloud-vue.cjs.js +7039 -2409
- package/dist/nextcloud-vue.cjs.js.map +1 -1
- package/dist/nextcloud-vue.css +237 -52
- package/dist/nextcloud-vue.esm.js +7012 -2386
- package/dist/nextcloud-vue.esm.js.map +1 -1
- package/package.json +2 -4
- package/src/components/CnActionsBar/CnActionsBar.vue +225 -0
- package/src/components/CnActionsBar/index.js +1 -0
- package/src/components/CnCopyDialog/CnCopyDialog.vue +250 -0
- package/src/components/CnCopyDialog/index.js +1 -0
- package/src/components/CnDataTable/CnDataTable.vue +0 -5
- package/src/components/CnDeleteDialog/CnDeleteDialog.vue +170 -0
- package/src/components/CnDeleteDialog/index.js +1 -0
- package/src/components/CnFormDialog/CnFormDialog.vue +629 -0
- package/src/components/CnFormDialog/index.js +1 -0
- package/src/components/CnIcon/CnIcon.vue +89 -0
- package/src/components/CnIcon/index.js +1 -0
- package/src/components/CnIndexPage/CnIndexPage.vue +434 -300
- package/src/components/CnIndexSidebar/CnIndexSidebar.vue +484 -0
- package/src/components/CnIndexSidebar/index.js +1 -0
- package/src/components/CnPageHeader/CnPageHeader.vue +57 -0
- package/src/components/CnPageHeader/index.js +1 -0
- package/src/components/CnRegisterMapping/CnRegisterMapping.vue +792 -0
- package/src/components/CnRegisterMapping/index.js +1 -0
- package/src/components/index.js +8 -4
- package/src/constants/metadata.js +30 -0
- package/src/css/actions-bar.css +48 -0
- package/src/css/badge.css +4 -4
- package/src/css/card.css +23 -23
- package/src/css/detail.css +13 -13
- package/src/css/index-page.css +32 -0
- package/src/css/index-sidebar.css +187 -0
- package/src/css/index.css +4 -0
- package/src/css/layout.css +14 -14
- package/src/css/page-header.css +33 -0
- package/src/css/pagination.css +12 -12
- package/src/css/table.css +21 -22
- package/src/css/utilities.css +2 -2
- package/src/index.js +11 -8
- package/src/store/plugins/index.js +1 -0
- package/src/store/plugins/registerMapping.js +185 -0
- package/src/store/useObjectStore.js +122 -61
- package/src/utils/headers.js +7 -1
- package/src/utils/index.js +1 -1
- package/src/utils/schema.js +133 -1
- package/src/components/CnDetailViewLayout/CnDetailViewLayout.vue +0 -88
- package/src/components/CnDetailViewLayout/index.js +0 -1
- package/src/components/CnEmptyState/CnEmptyState.vue +0 -78
- package/src/components/CnEmptyState/index.js +0 -1
- package/src/components/CnListViewLayout/CnListViewLayout.vue +0 -80
- package/src/components/CnListViewLayout/index.js +0 -1
- package/src/components/CnViewModeToggle/CnViewModeToggle.vue +0 -77
- package/src/components/CnViewModeToggle/index.js +0 -1
|
@@ -0,0 +1,629 @@
|
|
|
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) -->
|
|
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="getEnumOptions(field)"
|
|
100
|
+
:value="getSelectedEnumOption(field)"
|
|
101
|
+
:clearable="!field.required"
|
|
102
|
+
:disabled="field.readOnly"
|
|
103
|
+
@input="onSelectChange(field.key, $event)" />
|
|
104
|
+
<span
|
|
105
|
+
v-if="errors[field.key] || field.description"
|
|
106
|
+
class="cn-form-dialog__helper"
|
|
107
|
+
:class="{ 'cn-form-dialog__helper--error': errors[field.key] }">
|
|
108
|
+
{{ errors[field.key] || field.description }}
|
|
109
|
+
</span>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<!-- Multiselect (array with enum items) -->
|
|
113
|
+
<div v-else-if="field.widget === 'multiselect'" class="cn-form-dialog__select-wrapper">
|
|
114
|
+
<label :for="'cn-form-' + field.key" class="cn-form-dialog__label">
|
|
115
|
+
{{ field.label }}{{ field.required ? ' *' : '' }}
|
|
116
|
+
</label>
|
|
117
|
+
<NcSelect
|
|
118
|
+
:input-id="'cn-form-' + field.key"
|
|
119
|
+
:options="getArrayEnumOptions(field)"
|
|
120
|
+
:value="getSelectedArrayOptions(field)"
|
|
121
|
+
:multiple="true"
|
|
122
|
+
:clearable="true"
|
|
123
|
+
:disabled="field.readOnly"
|
|
124
|
+
@input="onMultiSelectChange(field.key, $event)" />
|
|
125
|
+
<span
|
|
126
|
+
v-if="errors[field.key] || field.description"
|
|
127
|
+
class="cn-form-dialog__helper"
|
|
128
|
+
:class="{ 'cn-form-dialog__helper--error': errors[field.key] }">
|
|
129
|
+
{{ errors[field.key] || field.description }}
|
|
130
|
+
</span>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
<!-- Tags (array, freeform) -->
|
|
134
|
+
<div v-else-if="field.widget === 'tags'" class="cn-form-dialog__select-wrapper">
|
|
135
|
+
<label :for="'cn-form-' + field.key" class="cn-form-dialog__label">
|
|
136
|
+
{{ field.label }}{{ field.required ? ' *' : '' }}
|
|
137
|
+
</label>
|
|
138
|
+
<NcSelect
|
|
139
|
+
:input-id="'cn-form-' + field.key"
|
|
140
|
+
:value="formData[field.key] || []"
|
|
141
|
+
:multiple="true"
|
|
142
|
+
:taggable="true"
|
|
143
|
+
:clearable="true"
|
|
144
|
+
:disabled="field.readOnly"
|
|
145
|
+
@input="updateField(field.key, $event)" />
|
|
146
|
+
<span
|
|
147
|
+
v-if="errors[field.key] || field.description"
|
|
148
|
+
class="cn-form-dialog__helper"
|
|
149
|
+
:class="{ 'cn-form-dialog__helper--error': errors[field.key] }">
|
|
150
|
+
{{ errors[field.key] || field.description }}
|
|
151
|
+
</span>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
<!-- Checkbox / Switch (boolean) -->
|
|
155
|
+
<NcCheckboxRadioSwitch
|
|
156
|
+
v-else-if="field.widget === 'checkbox'"
|
|
157
|
+
:checked="!!formData[field.key]"
|
|
158
|
+
:disabled="field.readOnly"
|
|
159
|
+
type="switch"
|
|
160
|
+
@update:checked="value => updateField(field.key, value)">
|
|
161
|
+
{{ field.label }}{{ field.required ? ' *' : '' }}
|
|
162
|
+
</NcCheckboxRadioSwitch>
|
|
163
|
+
|
|
164
|
+
<!-- Date -->
|
|
165
|
+
<NcTextField
|
|
166
|
+
v-else-if="field.widget === 'date'"
|
|
167
|
+
:label="field.label + (field.required ? ' *' : '')"
|
|
168
|
+
:value="formData[field.key] || ''"
|
|
169
|
+
:helper-text="errors[field.key] || field.description"
|
|
170
|
+
:error="!!errors[field.key]"
|
|
171
|
+
type="date"
|
|
172
|
+
:disabled="field.readOnly"
|
|
173
|
+
@update:value="value => updateField(field.key, value)" />
|
|
174
|
+
|
|
175
|
+
<!-- Datetime -->
|
|
176
|
+
<NcTextField
|
|
177
|
+
v-else-if="field.widget === 'datetime'"
|
|
178
|
+
:label="field.label + (field.required ? ' *' : '')"
|
|
179
|
+
:value="formData[field.key] || ''"
|
|
180
|
+
:helper-text="errors[field.key] || field.description"
|
|
181
|
+
:error="!!errors[field.key]"
|
|
182
|
+
type="datetime-local"
|
|
183
|
+
:disabled="field.readOnly"
|
|
184
|
+
@update:value="value => updateField(field.key, value)" />
|
|
185
|
+
|
|
186
|
+
<!-- Fallback: text input -->
|
|
187
|
+
<NcTextField
|
|
188
|
+
v-else
|
|
189
|
+
:label="field.label + (field.required ? ' *' : '')"
|
|
190
|
+
:value="formData[field.key] != null ? String(formData[field.key]) : ''"
|
|
191
|
+
:helper-text="errors[field.key] || field.description"
|
|
192
|
+
:error="!!errors[field.key]"
|
|
193
|
+
:disabled="field.readOnly"
|
|
194
|
+
:placeholder="field.description"
|
|
195
|
+
@update:value="value => updateField(field.key, value)" />
|
|
196
|
+
</template>
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
<slot name="after-fields" />
|
|
200
|
+
</template>
|
|
201
|
+
</div>
|
|
202
|
+
|
|
203
|
+
<template #actions>
|
|
204
|
+
<NcButton @click="$emit('close')">
|
|
205
|
+
{{ result !== null ? closeLabel : cancelLabel }}
|
|
206
|
+
</NcButton>
|
|
207
|
+
<NcButton
|
|
208
|
+
v-if="result === null"
|
|
209
|
+
type="primary"
|
|
210
|
+
:disabled="loading"
|
|
211
|
+
@click="executeConfirm">
|
|
212
|
+
<template #icon>
|
|
213
|
+
<NcLoadingIcon v-if="loading" :size="20" />
|
|
214
|
+
<Plus v-else-if="isCreateMode" :size="20" />
|
|
215
|
+
<ContentSaveOutline v-else :size="20" />
|
|
216
|
+
</template>
|
|
217
|
+
{{ resolvedConfirmLabel }}
|
|
218
|
+
</NcButton>
|
|
219
|
+
</template>
|
|
220
|
+
</NcDialog>
|
|
221
|
+
</template>
|
|
222
|
+
|
|
223
|
+
<script>
|
|
224
|
+
import { NcDialog, NcButton, NcNoteCard, NcLoadingIcon, NcTextField, NcSelect, NcCheckboxRadioSwitch } from '@nextcloud/vue'
|
|
225
|
+
import Plus from 'vue-material-design-icons/Plus.vue'
|
|
226
|
+
import ContentSaveOutline from 'vue-material-design-icons/ContentSaveOutline.vue'
|
|
227
|
+
import { fieldsFromSchema } from '../../utils/schema.js'
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* CnFormDialog — Create/edit dialog with auto-generated form from schema.
|
|
231
|
+
*
|
|
232
|
+
* When `item` is null, operates in create mode. When `item` is provided,
|
|
233
|
+
* operates in edit mode. Auto-generates form fields from schema using
|
|
234
|
+
* `fieldsFromSchema()`, but supports slot overrides at three levels:
|
|
235
|
+
*
|
|
236
|
+
* - `#form` — Replace the entire form content
|
|
237
|
+
* - `#field-{key}` — Replace a single auto-generated field
|
|
238
|
+
* - `#before-fields` / `#after-fields` — Inject content around fields
|
|
239
|
+
*
|
|
240
|
+
* The dialog does NOT perform the save itself — it emits a `confirm` event
|
|
241
|
+
* with the form data. The parent performs the actual API call and calls
|
|
242
|
+
* `setResult()` via a ref.
|
|
243
|
+
*
|
|
244
|
+
* @event confirm Emitted when the user confirms the form. Payload: formData object (includes `id` in edit mode).
|
|
245
|
+
* @event close Emitted when the dialog should be closed (cancel, close button, or auto-close after success).
|
|
246
|
+
*
|
|
247
|
+
* @example
|
|
248
|
+
* <CnFormDialog
|
|
249
|
+
* v-if="showFormDialog"
|
|
250
|
+
* ref="formDialog"
|
|
251
|
+
* :schema="schema"
|
|
252
|
+
* :item="editItem"
|
|
253
|
+
* @confirm="onFormConfirm"
|
|
254
|
+
* @close="showFormDialog = false" />
|
|
255
|
+
*
|
|
256
|
+
* // In methods:
|
|
257
|
+
* async onFormConfirm(formData) {
|
|
258
|
+
* try {
|
|
259
|
+
* if (formData.id) {
|
|
260
|
+
* await store.updateItem(formData.id, formData)
|
|
261
|
+
* } else {
|
|
262
|
+
* await store.createItem(formData)
|
|
263
|
+
* }
|
|
264
|
+
* this.$refs.formDialog.setResult({ success: true })
|
|
265
|
+
* } catch (e) {
|
|
266
|
+
* this.$refs.formDialog.setResult({ error: e.message })
|
|
267
|
+
* }
|
|
268
|
+
* }
|
|
269
|
+
*/
|
|
270
|
+
export default {
|
|
271
|
+
name: 'CnFormDialog',
|
|
272
|
+
|
|
273
|
+
components: {
|
|
274
|
+
NcDialog,
|
|
275
|
+
NcButton,
|
|
276
|
+
NcNoteCard,
|
|
277
|
+
NcLoadingIcon,
|
|
278
|
+
NcTextField,
|
|
279
|
+
NcSelect,
|
|
280
|
+
NcCheckboxRadioSwitch,
|
|
281
|
+
Plus,
|
|
282
|
+
ContentSaveOutline,
|
|
283
|
+
},
|
|
284
|
+
|
|
285
|
+
props: {
|
|
286
|
+
/** Schema for auto-generating fields. Either schema or fields must be provided. */
|
|
287
|
+
schema: {
|
|
288
|
+
type: Object,
|
|
289
|
+
default: null,
|
|
290
|
+
},
|
|
291
|
+
/** Existing item for edit mode. Pass null for create mode. */
|
|
292
|
+
item: {
|
|
293
|
+
type: Object,
|
|
294
|
+
default: null,
|
|
295
|
+
},
|
|
296
|
+
/** Dialog title. Defaults to "Create {schema.title}" or "Edit {schema.title}". */
|
|
297
|
+
dialogTitle: {
|
|
298
|
+
type: String,
|
|
299
|
+
default: '',
|
|
300
|
+
},
|
|
301
|
+
/** Manual field definitions. Overrides schema-generated fields when provided. */
|
|
302
|
+
fields: {
|
|
303
|
+
type: Array,
|
|
304
|
+
default: null,
|
|
305
|
+
},
|
|
306
|
+
/** Field keys to exclude from auto-generated form */
|
|
307
|
+
excludeFields: {
|
|
308
|
+
type: Array,
|
|
309
|
+
default: () => [],
|
|
310
|
+
},
|
|
311
|
+
/** Field keys to include (whitelist mode) */
|
|
312
|
+
includeFields: {
|
|
313
|
+
type: Array,
|
|
314
|
+
default: null,
|
|
315
|
+
},
|
|
316
|
+
/** Per-field overrides passed to fieldsFromSchema */
|
|
317
|
+
fieldOverrides: {
|
|
318
|
+
type: Object,
|
|
319
|
+
default: () => ({}),
|
|
320
|
+
},
|
|
321
|
+
/** Which field is the "name" (used in result messages) */
|
|
322
|
+
nameField: {
|
|
323
|
+
type: String,
|
|
324
|
+
default: 'title',
|
|
325
|
+
},
|
|
326
|
+
/** NcDialog size */
|
|
327
|
+
size: {
|
|
328
|
+
type: String,
|
|
329
|
+
default: 'normal',
|
|
330
|
+
},
|
|
331
|
+
/** Success message. Defaults to "Item saved successfully." */
|
|
332
|
+
successText: {
|
|
333
|
+
type: String,
|
|
334
|
+
default: '',
|
|
335
|
+
},
|
|
336
|
+
cancelLabel: { type: String, default: 'Cancel' },
|
|
337
|
+
closeLabel: { type: String, default: 'Close' },
|
|
338
|
+
/** Confirm button label. Defaults to "Create" or "Save". */
|
|
339
|
+
confirmLabel: {
|
|
340
|
+
type: String,
|
|
341
|
+
default: '',
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
|
|
345
|
+
data() {
|
|
346
|
+
return {
|
|
347
|
+
formData: {},
|
|
348
|
+
errors: {},
|
|
349
|
+
loading: false,
|
|
350
|
+
result: null,
|
|
351
|
+
closeTimeout: null,
|
|
352
|
+
}
|
|
353
|
+
},
|
|
354
|
+
|
|
355
|
+
computed: {
|
|
356
|
+
isCreateMode() {
|
|
357
|
+
return !this.item
|
|
358
|
+
},
|
|
359
|
+
|
|
360
|
+
schemaTitle() {
|
|
361
|
+
return (this.schema && this.schema.title) || 'Item'
|
|
362
|
+
},
|
|
363
|
+
|
|
364
|
+
resolvedTitle() {
|
|
365
|
+
if (this.dialogTitle) return this.dialogTitle
|
|
366
|
+
return this.isCreateMode
|
|
367
|
+
? `Create ${this.schemaTitle}`
|
|
368
|
+
: `Edit ${this.schemaTitle}`
|
|
369
|
+
},
|
|
370
|
+
|
|
371
|
+
resolvedConfirmLabel() {
|
|
372
|
+
if (this.confirmLabel) return this.confirmLabel
|
|
373
|
+
return this.isCreateMode ? 'Create' : 'Save'
|
|
374
|
+
},
|
|
375
|
+
|
|
376
|
+
resolvedSuccessText() {
|
|
377
|
+
if (this.successText) return this.successText
|
|
378
|
+
return `${this.schemaTitle} saved successfully.`
|
|
379
|
+
},
|
|
380
|
+
|
|
381
|
+
resolvedFields() {
|
|
382
|
+
// Manual fields take priority
|
|
383
|
+
if (this.fields) return this.fields
|
|
384
|
+
|
|
385
|
+
// Auto-generate from schema
|
|
386
|
+
return fieldsFromSchema(this.schema, {
|
|
387
|
+
exclude: this.excludeFields,
|
|
388
|
+
include: this.includeFields,
|
|
389
|
+
overrides: this.fieldOverrides,
|
|
390
|
+
})
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
|
|
394
|
+
watch: {
|
|
395
|
+
item: {
|
|
396
|
+
immediate: true,
|
|
397
|
+
handler(newItem) {
|
|
398
|
+
this.initFormData(newItem)
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
},
|
|
402
|
+
|
|
403
|
+
methods: {
|
|
404
|
+
initFormData(item) {
|
|
405
|
+
if (item) {
|
|
406
|
+
// Edit mode: clone item data
|
|
407
|
+
this.formData = JSON.parse(JSON.stringify(item))
|
|
408
|
+
} else {
|
|
409
|
+
// Create mode: initialize with field defaults
|
|
410
|
+
const data = {}
|
|
411
|
+
for (const field of this.resolvedFields) {
|
|
412
|
+
if (field.default !== null && field.default !== undefined) {
|
|
413
|
+
data[field.key] = field.default
|
|
414
|
+
} else if (field.widget === 'checkbox') {
|
|
415
|
+
data[field.key] = false
|
|
416
|
+
} else if (field.widget === 'tags' || field.widget === 'multiselect') {
|
|
417
|
+
data[field.key] = []
|
|
418
|
+
} else {
|
|
419
|
+
data[field.key] = null
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
this.formData = data
|
|
423
|
+
}
|
|
424
|
+
this.errors = {}
|
|
425
|
+
},
|
|
426
|
+
|
|
427
|
+
updateField(key, value) {
|
|
428
|
+
this.$set(this.formData, key, value)
|
|
429
|
+
// Clear error when field is edited
|
|
430
|
+
if (this.errors[key]) {
|
|
431
|
+
this.$delete(this.errors, key)
|
|
432
|
+
}
|
|
433
|
+
},
|
|
434
|
+
|
|
435
|
+
getEnumOptions(field) {
|
|
436
|
+
if (!field.enum) return []
|
|
437
|
+
return field.enum.map((val) => ({
|
|
438
|
+
id: val,
|
|
439
|
+
label: String(val),
|
|
440
|
+
}))
|
|
441
|
+
},
|
|
442
|
+
|
|
443
|
+
getSelectedEnumOption(field) {
|
|
444
|
+
const val = this.formData[field.key]
|
|
445
|
+
if (val === null || val === undefined) return null
|
|
446
|
+
return { id: val, label: String(val) }
|
|
447
|
+
},
|
|
448
|
+
|
|
449
|
+
onSelectChange(key, option) {
|
|
450
|
+
this.updateField(key, option ? option.id : null)
|
|
451
|
+
},
|
|
452
|
+
|
|
453
|
+
getArrayEnumOptions(field) {
|
|
454
|
+
if (!field.items || !field.items.enum) return []
|
|
455
|
+
return field.items.enum.map((val) => ({
|
|
456
|
+
id: val,
|
|
457
|
+
label: String(val),
|
|
458
|
+
}))
|
|
459
|
+
},
|
|
460
|
+
|
|
461
|
+
getSelectedArrayOptions(field) {
|
|
462
|
+
const val = this.formData[field.key]
|
|
463
|
+
if (!Array.isArray(val)) return []
|
|
464
|
+
return val.map((v) => ({ id: v, label: String(v) }))
|
|
465
|
+
},
|
|
466
|
+
|
|
467
|
+
onMultiSelectChange(key, options) {
|
|
468
|
+
this.updateField(key, (options || []).map((o) => o.id))
|
|
469
|
+
},
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Run client-side validation on all form fields.
|
|
473
|
+
* Checks required, minLength, maxLength, pattern, minimum, maximum.
|
|
474
|
+
*
|
|
475
|
+
* @return {boolean} True if all fields pass validation
|
|
476
|
+
* @public
|
|
477
|
+
*/
|
|
478
|
+
validate() {
|
|
479
|
+
const newErrors = {}
|
|
480
|
+
for (const field of this.resolvedFields) {
|
|
481
|
+
const value = this.formData[field.key]
|
|
482
|
+
|
|
483
|
+
// Required check
|
|
484
|
+
if (field.required) {
|
|
485
|
+
if (value === null || value === undefined || value === '') {
|
|
486
|
+
newErrors[field.key] = `${field.label} is required.`
|
|
487
|
+
continue
|
|
488
|
+
}
|
|
489
|
+
if (Array.isArray(value) && value.length === 0) {
|
|
490
|
+
newErrors[field.key] = `${field.label} is required.`
|
|
491
|
+
continue
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Skip further validation if empty and not required
|
|
496
|
+
if (value === null || value === undefined || value === '') continue
|
|
497
|
+
|
|
498
|
+
const v = field.validation || {}
|
|
499
|
+
|
|
500
|
+
// String length checks
|
|
501
|
+
if (typeof value === 'string') {
|
|
502
|
+
if (v.minLength !== undefined && value.length < v.minLength) {
|
|
503
|
+
newErrors[field.key] = `Minimum ${v.minLength} characters.`
|
|
504
|
+
} else if (v.maxLength !== undefined && value.length > v.maxLength) {
|
|
505
|
+
newErrors[field.key] = `Maximum ${v.maxLength} characters.`
|
|
506
|
+
} else if (v.pattern !== undefined) {
|
|
507
|
+
try {
|
|
508
|
+
if (!new RegExp(v.pattern).test(value)) {
|
|
509
|
+
newErrors[field.key] = 'Invalid format.'
|
|
510
|
+
}
|
|
511
|
+
} catch {
|
|
512
|
+
// Ignore invalid regex patterns
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Number range checks
|
|
518
|
+
if (typeof value === 'number') {
|
|
519
|
+
if (v.minimum !== undefined && value < v.minimum) {
|
|
520
|
+
newErrors[field.key] = `Minimum value is ${v.minimum}.`
|
|
521
|
+
} else if (v.maximum !== undefined && value > v.maximum) {
|
|
522
|
+
newErrors[field.key] = `Maximum value is ${v.maximum}.`
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
this.errors = newErrors
|
|
528
|
+
return Object.keys(newErrors).length === 0
|
|
529
|
+
},
|
|
530
|
+
|
|
531
|
+
executeConfirm() {
|
|
532
|
+
if (!this.validate()) return
|
|
533
|
+
|
|
534
|
+
this.loading = true
|
|
535
|
+
/**
|
|
536
|
+
* @event confirm Emitted when the user confirms the form.
|
|
537
|
+
* Payload: form data object. Includes `id` when editing.
|
|
538
|
+
*/
|
|
539
|
+
this.$emit('confirm', { ...this.formData })
|
|
540
|
+
},
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Set the result of the save operation. Call this from the parent
|
|
544
|
+
* after the API call completes.
|
|
545
|
+
*
|
|
546
|
+
* @param {{ success?: boolean, error?: string }} resultData
|
|
547
|
+
* @public
|
|
548
|
+
*/
|
|
549
|
+
setResult(resultData) {
|
|
550
|
+
this.loading = false
|
|
551
|
+
this.result = resultData
|
|
552
|
+
if (resultData.success) {
|
|
553
|
+
this.closeTimeout = setTimeout(() => {
|
|
554
|
+
this.$emit('close')
|
|
555
|
+
}, 2000)
|
|
556
|
+
}
|
|
557
|
+
},
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Set per-field validation errors from the server. Call this from
|
|
561
|
+
* the parent when the API returns validation errors.
|
|
562
|
+
*
|
|
563
|
+
* @param {object} fieldErrors Object keyed by field key with error messages
|
|
564
|
+
* @public
|
|
565
|
+
*/
|
|
566
|
+
setValidationErrors(fieldErrors) {
|
|
567
|
+
this.loading = false
|
|
568
|
+
this.errors = { ...this.errors, ...fieldErrors }
|
|
569
|
+
},
|
|
570
|
+
},
|
|
571
|
+
}
|
|
572
|
+
</script>
|
|
573
|
+
|
|
574
|
+
<style scoped>
|
|
575
|
+
.cn-form-dialog__form {
|
|
576
|
+
display: flex;
|
|
577
|
+
flex-direction: column;
|
|
578
|
+
gap: 4px;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
.cn-form-dialog__field {
|
|
582
|
+
margin-bottom: 8px;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
.cn-form-dialog__textarea-wrapper,
|
|
586
|
+
.cn-form-dialog__select-wrapper {
|
|
587
|
+
display: flex;
|
|
588
|
+
flex-direction: column;
|
|
589
|
+
gap: 4px;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
.cn-form-dialog__label {
|
|
593
|
+
font-weight: 600;
|
|
594
|
+
font-size: 0.9em;
|
|
595
|
+
color: var(--color-main-text);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
.cn-form-dialog__textarea {
|
|
599
|
+
width: 100%;
|
|
600
|
+
min-height: 80px;
|
|
601
|
+
padding: 8px;
|
|
602
|
+
border: 2px solid var(--color-border-maxcontrast);
|
|
603
|
+
border-radius: var(--border-radius-large);
|
|
604
|
+
background-color: var(--color-main-background);
|
|
605
|
+
color: var(--color-main-text);
|
|
606
|
+
font-family: inherit;
|
|
607
|
+
font-size: inherit;
|
|
608
|
+
resize: vertical;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
.cn-form-dialog__textarea:focus {
|
|
612
|
+
border-color: var(--color-primary-element);
|
|
613
|
+
outline: none;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
.cn-form-dialog__textarea:disabled {
|
|
617
|
+
opacity: 0.5;
|
|
618
|
+
cursor: not-allowed;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
.cn-form-dialog__helper {
|
|
622
|
+
font-size: 0.85em;
|
|
623
|
+
color: var(--color-text-maxcontrast);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
.cn-form-dialog__helper--error {
|
|
627
|
+
color: var(--color-error);
|
|
628
|
+
}
|
|
629
|
+
</style>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as CnFormDialog } from './CnFormDialog.vue'
|