@conduction/nextcloud-vue 0.1.0-beta.1 → 0.1.0-beta.11
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/css/index.css +5 -0
- package/dist/nextcloud-vue.cjs.js +79416 -7715
- package/dist/nextcloud-vue.cjs.js.map +1 -1
- package/dist/nextcloud-vue.css +3583 -504
- package/dist/nextcloud-vue.esm.js +79343 -7692
- package/dist/nextcloud-vue.esm.js.map +1 -1
- package/l10n/en.json +164 -0
- package/l10n/nl.json +164 -0
- package/package.json +104 -63
- package/src/components/CnActionsBar/CnActionsBar.vue +254 -0
- package/src/components/CnActionsBar/index.js +1 -0
- package/src/components/CnAdvancedFormDialog/CnAdvancedFormDialog.vue +570 -0
- package/src/components/CnAdvancedFormDialog/CnDataTab.vue +217 -0
- package/src/components/CnAdvancedFormDialog/CnMetadataTab.vue +121 -0
- package/src/components/CnAdvancedFormDialog/CnPropertiesTab.vue +422 -0
- package/src/components/CnAdvancedFormDialog/CnPropertyValueCell.vue +247 -0
- package/src/components/CnAdvancedFormDialog/index.js +1 -0
- package/src/components/CnCard/CnCard.vue +415 -0
- package/src/components/CnCard/index.js +1 -0
- package/src/components/CnCardGrid/CnCardGrid.vue +156 -152
- package/src/components/CnCardGrid/index.js +1 -1
- package/src/components/CnCellRenderer/CnCellRenderer.vue +132 -132
- package/src/components/CnCellRenderer/index.js +1 -1
- package/src/components/CnChartWidget/CnChartWidget.vue +346 -0
- package/src/components/CnChartWidget/index.js +1 -0
- package/src/components/CnConfigurationCard/CnConfigurationCard.vue +77 -77
- package/src/components/CnConfigurationCard/index.js +1 -1
- package/src/components/CnContextMenu/CnContextMenu.vue +142 -0
- package/src/components/CnContextMenu/index.js +1 -0
- package/src/components/CnCopyDialog/CnCopyDialog.vue +266 -0
- package/src/components/CnCopyDialog/index.js +1 -0
- package/src/components/CnDashboardGrid/CnDashboardGrid.vue +229 -0
- package/src/components/CnDashboardGrid/index.js +1 -0
- package/src/components/CnDashboardPage/CnDashboardPage.vue +397 -0
- package/src/components/CnDashboardPage/index.js +1 -0
- package/src/components/CnDataTable/CnDataTable.vue +362 -354
- package/src/components/CnDataTable/index.js +1 -1
- package/src/components/CnDeleteDialog/CnDeleteDialog.vue +177 -0
- package/src/components/CnDeleteDialog/index.js +1 -0
- package/src/components/CnDetailCard/CnDetailCard.vue +225 -0
- package/src/components/CnDetailCard/index.js +1 -0
- package/src/components/CnDetailGrid/CnDetailGrid.vue +256 -0
- package/src/components/CnDetailGrid/index.js +1 -0
- package/src/components/CnDetailPage/CnDetailPage.vue +432 -0
- package/src/components/CnDetailPage/index.js +1 -0
- package/src/components/CnFacetSidebar/CnFacetSidebar.vue +234 -223
- package/src/components/CnFacetSidebar/index.js +1 -1
- package/src/components/CnFilterBar/CnFilterBar.vue +153 -152
- package/src/components/CnFilterBar/index.js +1 -1
- package/src/components/CnFormDialog/CnFormDialog.vue +1047 -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 +980 -682
- package/src/components/CnIndexPage/index.js +1 -1
- package/src/components/CnIndexSidebar/CnIndexSidebar.vue +536 -0
- package/src/components/CnIndexSidebar/index.js +1 -0
- package/src/components/CnInfoWidget/CnInfoWidget.vue +219 -0
- package/src/components/CnInfoWidget/index.js +1 -0
- package/src/components/CnItemCard/CnItemCard.vue +134 -0
- package/src/components/CnItemCard/index.js +1 -0
- package/src/components/CnJsonViewer/CnJsonViewer.vue +312 -0
- package/src/components/CnJsonViewer/index.js +1 -0
- package/src/components/CnKpiGrid/CnKpiGrid.vue +93 -89
- package/src/components/CnKpiGrid/index.js +1 -1
- package/src/components/CnMassActionBar/CnMassActionBar.vue +161 -160
- package/src/components/CnMassActionBar/index.js +1 -1
- package/src/components/CnMassCopyDialog/CnMassCopyDialog.vue +327 -320
- package/src/components/CnMassCopyDialog/index.js +1 -1
- package/src/components/CnMassDeleteDialog/CnMassDeleteDialog.vue +245 -238
- package/src/components/CnMassDeleteDialog/index.js +1 -1
- package/src/components/CnMassExportDialog/CnMassExportDialog.vue +191 -190
- package/src/components/CnMassExportDialog/index.js +1 -1
- package/src/components/CnMassImportDialog/CnMassImportDialog.vue +494 -491
- package/src/components/CnMassImportDialog/index.js +1 -1
- package/src/components/CnNoteCard/CnNoteCard.vue +149 -0
- package/src/components/CnNoteCard/index.js +1 -0
- package/src/components/CnNotesCard/CnNotesCard.vue +416 -0
- package/src/components/CnNotesCard/index.js +1 -0
- package/src/components/CnObjectCard/CnObjectCard.vue +294 -292
- package/src/components/CnObjectCard/index.js +1 -1
- package/src/components/CnObjectDataWidget/CnObjectDataWidget.vue +854 -0
- package/src/components/CnObjectDataWidget/index.js +1 -0
- package/src/components/CnObjectMetadataWidget/CnObjectMetadataWidget.vue +289 -0
- package/src/components/CnObjectMetadataWidget/index.js +1 -0
- package/src/components/CnObjectSidebar/CnAuditTrailTab.vue +369 -0
- package/src/components/CnObjectSidebar/CnFilesTab.vue +287 -0
- package/src/components/CnObjectSidebar/CnNotesTab.vue +250 -0
- package/src/components/CnObjectSidebar/CnObjectSidebar.vue +255 -0
- package/src/components/CnObjectSidebar/CnTagsTab.vue +259 -0
- package/src/components/CnObjectSidebar/CnTasksTab.vue +483 -0
- package/src/components/CnObjectSidebar/index.js +6 -0
- package/src/components/CnPageHeader/CnPageHeader.vue +61 -0
- package/src/components/CnPageHeader/index.js +1 -0
- package/src/components/CnPagination/CnPagination.vue +253 -252
- package/src/components/CnPagination/index.js +1 -1
- package/src/components/CnProgressBar/CnProgressBar.vue +262 -0
- package/src/components/CnProgressBar/index.js +1 -0
- package/src/components/CnRegisterMapping/CnRegisterMapping.vue +793 -0
- package/src/components/CnRegisterMapping/index.js +1 -0
- package/src/components/CnRowActions/CnRowActions.vue +95 -73
- package/src/components/CnRowActions/index.js +1 -1
- package/src/components/CnSchemaFormDialog/CnSchemaConfigurationTab.vue +226 -0
- package/src/components/CnSchemaFormDialog/CnSchemaFormDialog.vue +788 -0
- package/src/components/CnSchemaFormDialog/CnSchemaPropertiesTab.vue +305 -0
- package/src/components/CnSchemaFormDialog/CnSchemaPropertyActions.vue +1398 -0
- package/src/components/CnSchemaFormDialog/CnSchemaSecurityTab.vue +236 -0
- package/src/components/CnSchemaFormDialog/index.js +1 -0
- package/src/components/CnSettingsCard/CnSettingsCard.vue +92 -92
- package/src/components/CnSettingsCard/index.js +1 -1
- package/src/components/CnSettingsSection/CnSettingsSection.vue +267 -266
- package/src/components/CnSettingsSection/index.js +1 -1
- package/src/components/CnStatsBlock/CnStatsBlock.vue +437 -366
- package/src/components/CnStatsBlock/index.js +1 -1
- package/src/components/CnStatsPanel/CnStatsPanel.vue +321 -0
- package/src/components/CnStatsPanel/index.js +1 -0
- package/src/components/CnStatusBadge/CnStatusBadge.vue +90 -77
- package/src/components/CnStatusBadge/index.js +1 -1
- package/src/components/CnTabbedFormDialog/CnTabbedFormDialog.vue +545 -0
- package/src/components/CnTabbedFormDialog/index.js +1 -0
- package/src/components/CnTableWidget/CnTableWidget.vue +333 -0
- package/src/components/CnTableWidget/index.js +1 -0
- package/src/components/CnTasksCard/CnTasksCard.vue +374 -0
- package/src/components/CnTasksCard/index.js +1 -0
- package/src/components/CnTileWidget/CnTileWidget.vue +159 -0
- package/src/components/CnTileWidget/index.js +1 -0
- package/src/components/CnTimelineStages/CnTimelineStages.vue +294 -0
- package/src/components/CnTimelineStages/index.js +1 -0
- package/src/components/CnUserActionMenu/CnUserActionMenu.vue +436 -0
- package/src/components/CnUserActionMenu/index.js +1 -0
- package/src/components/CnVersionInfoCard/CnVersionInfoCard.vue +313 -312
- package/src/components/CnVersionInfoCard/index.js +1 -1
- package/src/components/CnWidgetRenderer/CnWidgetRenderer.vue +180 -0
- package/src/components/CnWidgetRenderer/index.js +1 -0
- package/src/components/CnWidgetWrapper/CnWidgetWrapper.vue +248 -0
- package/src/components/CnWidgetWrapper/index.js +1 -0
- package/src/components/index.js +57 -25
- package/src/composables/index.js +5 -3
- package/src/composables/useContextMenu.js +126 -0
- package/src/composables/useDashboardView.js +286 -0
- package/src/composables/useDetailView.js +290 -132
- package/src/composables/useListView.js +364 -153
- package/src/composables/useSubResource.js +142 -142
- package/src/constants/metadata.js +30 -0
- package/src/css/CnSchemaFormDialog.css +546 -0
- package/src/css/__sample_nextcloud_tokens.css +110 -0
- package/src/css/actions-bar.css +54 -0
- package/src/css/badge.css +83 -51
- package/src/css/card.css +129 -128
- package/src/css/context-menu.css +20 -0
- package/src/css/dashboard.css +70 -0
- package/src/css/detail-page.css +235 -0
- package/src/css/detail.css +68 -68
- package/src/css/index-page.css +44 -0
- package/src/css/index-sidebar.css +193 -0
- package/src/css/index.css +17 -8
- package/src/css/layout.css +90 -90
- package/src/css/page-header.css +35 -0
- package/src/css/pagination.css +72 -72
- package/src/css/table.css +142 -143
- package/src/css/timeline-stages.css +220 -0
- package/src/css/utilities.css +46 -46
- package/src/index.js +95 -50
- package/src/l10n/index.js +12 -0
- package/src/mixins/gridLayout.js +118 -0
- package/src/store/createCrudStore.d.ts +350 -0
- package/src/store/createCrudStore.js +413 -0
- package/src/store/createSubResourcePlugin.js +125 -135
- package/src/store/index.js +4 -3
- package/src/store/pluginMerge.js +55 -0
- package/src/store/plugins/auditTrails.js +357 -17
- package/src/store/plugins/files.js +250 -186
- package/src/store/plugins/index.js +8 -4
- package/src/store/plugins/lifecycle.js +180 -180
- package/src/store/plugins/logs.d.ts +22 -0
- package/src/store/plugins/logs.js +172 -0
- package/src/store/plugins/registerMapping.js +195 -0
- package/src/store/plugins/relations.js +68 -68
- package/src/store/plugins/search.js +385 -0
- package/src/store/plugins/selection.js +104 -0
- package/src/store/useObjectStore.js +793 -625
- package/src/types/auditTrail.d.ts +32 -32
- package/src/types/file.d.ts +23 -23
- package/src/types/index.d.ts +67 -35
- package/src/types/notification.d.ts +36 -36
- package/src/types/object.d.ts +40 -40
- package/src/types/organisation.d.ts +41 -41
- package/src/types/register.d.ts +25 -25
- package/src/types/schema.d.ts +39 -39
- package/src/types/shared.d.ts +79 -79
- package/src/types/source.d.ts +14 -14
- package/src/types/task.d.ts +31 -31
- package/src/utils/errors.js +96 -96
- package/src/utils/getTheme.js +9 -0
- package/src/utils/headers.js +80 -44
- package/src/utils/id.js +13 -0
- package/src/utils/index.js +4 -3
- package/src/utils/schema.js +423 -287
- package/src/utils/widgetVisibility.js +162 -0
- 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,1047 @@
|
|
|
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
|
+
<!-- JSON (type: 'object'|'array'|... with widget: 'json'): parses on input, stores parsed value in formData -->
|
|
231
|
+
<div v-else-if="field.widget === 'json'" class="cn-form-dialog__json-wrapper">
|
|
232
|
+
<label :for="'cn-form-' + field.key" class="cn-form-dialog__label">
|
|
233
|
+
{{ field.label }}{{ field.required ? ' *' : '' }}
|
|
234
|
+
</label>
|
|
235
|
+
<CnJsonViewer
|
|
236
|
+
:value="jsonStringFor(field)"
|
|
237
|
+
language="json"
|
|
238
|
+
:read-only="field.readOnly"
|
|
239
|
+
:error-text="jsonErrors[field.key] || ''"
|
|
240
|
+
@update:value="value => onJsonFieldInput(field, value)" />
|
|
241
|
+
<span
|
|
242
|
+
v-if="errors[field.key] || field.description"
|
|
243
|
+
class="cn-form-dialog__helper"
|
|
244
|
+
:class="{ 'cn-form-dialog__helper--error': errors[field.key] }">
|
|
245
|
+
{{ errors[field.key] || field.description }}
|
|
246
|
+
</span>
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
<!-- Code (freeform editor, stored as raw string; optional `field.language` chooses highlighting) -->
|
|
250
|
+
<div v-else-if="field.widget === 'code'" class="cn-form-dialog__json-wrapper">
|
|
251
|
+
<label :for="'cn-form-' + field.key" class="cn-form-dialog__label">
|
|
252
|
+
{{ field.label }}{{ field.required ? ' *' : '' }}
|
|
253
|
+
</label>
|
|
254
|
+
<CnJsonViewer
|
|
255
|
+
:value="formData[field.key] != null ? String(formData[field.key]) : ''"
|
|
256
|
+
:language="field.language || 'auto'"
|
|
257
|
+
:read-only="field.readOnly"
|
|
258
|
+
@update:value="value => updateField(field.key, value)" />
|
|
259
|
+
<span
|
|
260
|
+
v-if="errors[field.key] || field.description"
|
|
261
|
+
class="cn-form-dialog__helper"
|
|
262
|
+
:class="{ 'cn-form-dialog__helper--error': errors[field.key] }">
|
|
263
|
+
{{ errors[field.key] || field.description }}
|
|
264
|
+
</span>
|
|
265
|
+
</div>
|
|
266
|
+
|
|
267
|
+
<!-- Fallback: text input -->
|
|
268
|
+
<NcTextField
|
|
269
|
+
v-else
|
|
270
|
+
:label="field.label + (field.required ? ' *' : '')"
|
|
271
|
+
:value="formData[field.key] != null ? String(formData[field.key]) : ''"
|
|
272
|
+
:helper-text="errors[field.key] || field.description"
|
|
273
|
+
:error="!!errors[field.key]"
|
|
274
|
+
:disabled="field.readOnly"
|
|
275
|
+
:placeholder="field.description"
|
|
276
|
+
@update:value="value => updateField(field.key, value)" />
|
|
277
|
+
</template>
|
|
278
|
+
</div>
|
|
279
|
+
|
|
280
|
+
<slot name="after-fields" />
|
|
281
|
+
</template>
|
|
282
|
+
</div>
|
|
283
|
+
|
|
284
|
+
<template #actions>
|
|
285
|
+
<NcButton @click="$emit('close')">
|
|
286
|
+
{{ result !== null ? closeLabel : cancelLabel }}
|
|
287
|
+
</NcButton>
|
|
288
|
+
<NcButton
|
|
289
|
+
v-if="result === null"
|
|
290
|
+
type="primary"
|
|
291
|
+
:disabled="loading || !requiredFieldsFilled || !jsonFieldsValid"
|
|
292
|
+
@click="executeConfirm">
|
|
293
|
+
<template #icon>
|
|
294
|
+
<NcLoadingIcon v-if="loading" :size="20" />
|
|
295
|
+
<Plus v-else-if="isCreateMode" :size="20" />
|
|
296
|
+
<ContentSaveOutline v-else :size="20" />
|
|
297
|
+
</template>
|
|
298
|
+
{{ resolvedConfirmLabel }}
|
|
299
|
+
</NcButton>
|
|
300
|
+
</template>
|
|
301
|
+
</NcDialog>
|
|
302
|
+
</template>
|
|
303
|
+
|
|
304
|
+
<script>
|
|
305
|
+
import { translate as t } from '@nextcloud/l10n'
|
|
306
|
+
import { NcDialog, NcButton, NcNoteCard, NcLoadingIcon, NcTextField, NcSelect, NcCheckboxRadioSwitch } from '@nextcloud/vue'
|
|
307
|
+
import CnJsonViewer from '../CnJsonViewer/CnJsonViewer.vue'
|
|
308
|
+
import Plus from 'vue-material-design-icons/Plus.vue'
|
|
309
|
+
import ContentSaveOutline from 'vue-material-design-icons/ContentSaveOutline.vue'
|
|
310
|
+
import { fieldsFromSchema } from '../../utils/schema.js'
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* CnFormDialog — Create/edit dialog with auto-generated form from schema.
|
|
314
|
+
*
|
|
315
|
+
* When `item` is null, operates in create mode. When `item` is provided,
|
|
316
|
+
* operates in edit mode. Auto-generates form fields from schema using
|
|
317
|
+
* `fieldsFromSchema()`, but supports slot overrides at three levels:
|
|
318
|
+
*
|
|
319
|
+
* - `#form` — Replace the entire form content
|
|
320
|
+
* - `#field-{key}` — Replace a single auto-generated field
|
|
321
|
+
* - `#field-{key}-option` — Customize dropdown option rendering for a select/multiselect/tags field
|
|
322
|
+
* - `#field-{key}-selected-option` — Customize selected option display for a select/multiselect/tags field
|
|
323
|
+
* - `#before-fields` / `#after-fields` — Inject content around fields
|
|
324
|
+
*
|
|
325
|
+
* ## Async select support
|
|
326
|
+
*
|
|
327
|
+
* Select, multiselect, and tags fields support async options by setting `field.enum`
|
|
328
|
+
* (or `field.items.enum` for multiselect) to an async function instead of a static array:
|
|
329
|
+
*
|
|
330
|
+
* ```js
|
|
331
|
+
* { key: 'org', widget: 'select', enum: async (query) => fetchOrgs(query) }
|
|
332
|
+
* ```
|
|
333
|
+
*
|
|
334
|
+
* The function receives the search query and must return an array of option objects
|
|
335
|
+
* (each must have a `label` property for default display). Options are loaded on mount
|
|
336
|
+
* (with empty query) and on each search input (debounced, default 300ms, configurable
|
|
337
|
+
* via `field.debounce`). Async selects store the full option object in formData.
|
|
338
|
+
*
|
|
339
|
+
* ## JSON / code fields
|
|
340
|
+
*
|
|
341
|
+
* Two widgets render a CnJsonViewer-powered editor:
|
|
342
|
+
*
|
|
343
|
+
* - `widget: 'json'` — Parses on input. formData holds the parsed value (object,
|
|
344
|
+
* array, number, string, boolean, or `null` for empty). Invalid JSON displays an
|
|
345
|
+
* inline error and blocks the confirm button until fixed. Pair with `type: 'object'`
|
|
346
|
+
* (or any type) to opt a property out of the default object-filter in `fieldsFromSchema`.
|
|
347
|
+
* - `widget: 'code'` — Stores the raw string. Optional `field.language` chooses
|
|
348
|
+
* syntax highlighting (`'json'|'xml'|'html'|'text'|'auto'`, default `'auto'`).
|
|
349
|
+
*
|
|
350
|
+
* The dialog does NOT perform the save itself — it emits a `confirm` event
|
|
351
|
+
* with the form data. The parent performs the actual API call and calls
|
|
352
|
+
* `setResult()` via a ref.
|
|
353
|
+
*
|
|
354
|
+
* @event confirm Emitted when the user confirms the form. Payload: formData object (includes `id` in edit mode).
|
|
355
|
+
* @event close Emitted when the dialog should be closed (cancel, close button, or auto-close after success).
|
|
356
|
+
*
|
|
357
|
+
* @example
|
|
358
|
+
* <CnFormDialog
|
|
359
|
+
* v-if="showFormDialog"
|
|
360
|
+
* ref="formDialog"
|
|
361
|
+
* :schema="schema"
|
|
362
|
+
* :item="editItem"
|
|
363
|
+
* @confirm="onFormConfirm"
|
|
364
|
+
* @close="showFormDialog = false" />
|
|
365
|
+
*
|
|
366
|
+
* // In methods:
|
|
367
|
+
* async onFormConfirm(formData) {
|
|
368
|
+
* try {
|
|
369
|
+
* if (formData.id) {
|
|
370
|
+
* await store.updateItem(formData.id, formData)
|
|
371
|
+
* } else {
|
|
372
|
+
* await store.createItem(formData)
|
|
373
|
+
* }
|
|
374
|
+
* this.$refs.formDialog.setResult({ success: true })
|
|
375
|
+
* } catch (e) {
|
|
376
|
+
* this.$refs.formDialog.setResult({ error: e.message })
|
|
377
|
+
* }
|
|
378
|
+
* }
|
|
379
|
+
*
|
|
380
|
+
* @example <caption>Async select with custom option rendering</caption>
|
|
381
|
+
* <CnFormDialog :fields="[{
|
|
382
|
+
* key: 'organisation',
|
|
383
|
+
* widget: 'select',
|
|
384
|
+
* label: 'Organisation',
|
|
385
|
+
* required: true,
|
|
386
|
+
* enum: async (query) => {
|
|
387
|
+
* const results = await store.search(query)
|
|
388
|
+
* return results.map(o => ({ label: o.name, id: o.uuid, ...o }))
|
|
389
|
+
* },
|
|
390
|
+
* debounce: 500,
|
|
391
|
+
* }]" @confirm="onConfirm">
|
|
392
|
+
* <template #field-organisation-option="{ name, description }">
|
|
393
|
+
* <strong>{{ name }}</strong>
|
|
394
|
+
* <p>{{ description }}</p>
|
|
395
|
+
* </template>
|
|
396
|
+
* <template #field-organisation-selected-option="{ name }">
|
|
397
|
+
* {{ name }}
|
|
398
|
+
* </template>
|
|
399
|
+
* </CnFormDialog>
|
|
400
|
+
*/
|
|
401
|
+
export default {
|
|
402
|
+
name: 'CnFormDialog',
|
|
403
|
+
|
|
404
|
+
components: {
|
|
405
|
+
NcDialog,
|
|
406
|
+
NcButton,
|
|
407
|
+
NcNoteCard,
|
|
408
|
+
NcLoadingIcon,
|
|
409
|
+
NcTextField,
|
|
410
|
+
NcSelect,
|
|
411
|
+
NcCheckboxRadioSwitch,
|
|
412
|
+
CnJsonViewer,
|
|
413
|
+
Plus,
|
|
414
|
+
ContentSaveOutline,
|
|
415
|
+
},
|
|
416
|
+
|
|
417
|
+
props: {
|
|
418
|
+
/** Schema for auto-generating fields. Either schema or fields must be provided. */
|
|
419
|
+
schema: {
|
|
420
|
+
type: Object,
|
|
421
|
+
default: null,
|
|
422
|
+
},
|
|
423
|
+
/** Existing item for edit mode. Pass null for create mode. */
|
|
424
|
+
item: {
|
|
425
|
+
type: Object,
|
|
426
|
+
default: null,
|
|
427
|
+
},
|
|
428
|
+
/** Dialog title. Defaults to "Create {schema.title}" or "Edit {schema.title}". */
|
|
429
|
+
dialogTitle: {
|
|
430
|
+
type: String,
|
|
431
|
+
default: '',
|
|
432
|
+
},
|
|
433
|
+
/** Manual field definitions. Overrides schema-generated fields when provided. */
|
|
434
|
+
fields: {
|
|
435
|
+
type: Array,
|
|
436
|
+
default: null,
|
|
437
|
+
},
|
|
438
|
+
/** Field keys to exclude from auto-generated form */
|
|
439
|
+
excludeFields: {
|
|
440
|
+
type: Array,
|
|
441
|
+
default: () => [],
|
|
442
|
+
},
|
|
443
|
+
/** Field keys to include (whitelist mode) */
|
|
444
|
+
includeFields: {
|
|
445
|
+
type: Array,
|
|
446
|
+
default: null,
|
|
447
|
+
},
|
|
448
|
+
/** Per-field overrides passed to fieldsFromSchema */
|
|
449
|
+
fieldOverrides: {
|
|
450
|
+
type: Object,
|
|
451
|
+
default: () => ({}),
|
|
452
|
+
},
|
|
453
|
+
/** Which field is the "name" (used in result messages) */
|
|
454
|
+
nameField: {
|
|
455
|
+
type: String,
|
|
456
|
+
default: 'title',
|
|
457
|
+
},
|
|
458
|
+
/** NcDialog size */
|
|
459
|
+
size: {
|
|
460
|
+
type: String,
|
|
461
|
+
default: 'normal',
|
|
462
|
+
},
|
|
463
|
+
/** Success message. Defaults to "Item saved successfully." */
|
|
464
|
+
successText: {
|
|
465
|
+
type: String,
|
|
466
|
+
default: '',
|
|
467
|
+
},
|
|
468
|
+
cancelLabel: { type: String, default: () => t('nextcloud-vue', 'Cancel') },
|
|
469
|
+
closeLabel: { type: String, default: () => t('nextcloud-vue', 'Close') },
|
|
470
|
+
/** Confirm button label. Defaults to "Create" or "Save". */
|
|
471
|
+
confirmLabel: {
|
|
472
|
+
type: String,
|
|
473
|
+
default: '',
|
|
474
|
+
},
|
|
475
|
+
},
|
|
476
|
+
|
|
477
|
+
data() {
|
|
478
|
+
return {
|
|
479
|
+
formData: {},
|
|
480
|
+
errors: {},
|
|
481
|
+
loading: false,
|
|
482
|
+
result: null,
|
|
483
|
+
closeTimeout: null,
|
|
484
|
+
/** Per-field async state: { [fieldKey]: { options: [], loading: false, searchTimeout: null } } */
|
|
485
|
+
asyncState: {},
|
|
486
|
+
/** Per-field editor string for `json` widgets (preserves input between keystrokes even while invalid) */
|
|
487
|
+
jsonDrafts: {},
|
|
488
|
+
/** Per-field parse-error messages for `json` widgets (blocks confirm) */
|
|
489
|
+
jsonErrors: {},
|
|
490
|
+
}
|
|
491
|
+
},
|
|
492
|
+
|
|
493
|
+
computed: {
|
|
494
|
+
isCreateMode() {
|
|
495
|
+
return !this.item
|
|
496
|
+
},
|
|
497
|
+
|
|
498
|
+
schemaTitle() {
|
|
499
|
+
return (this.schema && this.schema.title) || t('nextcloud-vue', 'Item')
|
|
500
|
+
},
|
|
501
|
+
|
|
502
|
+
resolvedTitle() {
|
|
503
|
+
if (this.dialogTitle) return this.dialogTitle
|
|
504
|
+
return this.isCreateMode
|
|
505
|
+
? t('nextcloud-vue', 'Create {title}', { title: this.schemaTitle })
|
|
506
|
+
: t('nextcloud-vue', 'Edit {title}', { title: this.schemaTitle })
|
|
507
|
+
},
|
|
508
|
+
|
|
509
|
+
resolvedConfirmLabel() {
|
|
510
|
+
if (this.confirmLabel) return this.confirmLabel
|
|
511
|
+
return this.isCreateMode ? t('nextcloud-vue', 'Create') : t('nextcloud-vue', 'Save')
|
|
512
|
+
},
|
|
513
|
+
|
|
514
|
+
resolvedSuccessText() {
|
|
515
|
+
if (this.successText) return this.successText
|
|
516
|
+
return t('nextcloud-vue', '{title} saved successfully.', { title: this.schemaTitle })
|
|
517
|
+
},
|
|
518
|
+
|
|
519
|
+
/** Whether all required fields have a non-empty value */
|
|
520
|
+
requiredFieldsFilled() {
|
|
521
|
+
return this.resolvedFields
|
|
522
|
+
.filter((f) => f.required)
|
|
523
|
+
.every((f) => {
|
|
524
|
+
const val = this.formData[f.key]
|
|
525
|
+
if (val === null || val === undefined || val === '') return false
|
|
526
|
+
if (Array.isArray(val) && val.length === 0) return false
|
|
527
|
+
return true
|
|
528
|
+
})
|
|
529
|
+
},
|
|
530
|
+
|
|
531
|
+
/** Whether every `json` widget currently parses successfully */
|
|
532
|
+
jsonFieldsValid() {
|
|
533
|
+
return Object.keys(this.jsonErrors).every((k) => !this.jsonErrors[k])
|
|
534
|
+
},
|
|
535
|
+
|
|
536
|
+
resolvedFields() {
|
|
537
|
+
// Manual fields take priority
|
|
538
|
+
if (this.fields) return this.fields
|
|
539
|
+
|
|
540
|
+
// Auto-generate from schema
|
|
541
|
+
return fieldsFromSchema(this.schema, {
|
|
542
|
+
exclude: this.excludeFields,
|
|
543
|
+
include: this.includeFields,
|
|
544
|
+
overrides: this.fieldOverrides,
|
|
545
|
+
})
|
|
546
|
+
},
|
|
547
|
+
},
|
|
548
|
+
|
|
549
|
+
watch: {
|
|
550
|
+
item: {
|
|
551
|
+
immediate: true,
|
|
552
|
+
handler(newItem) {
|
|
553
|
+
this.initFormData(newItem)
|
|
554
|
+
},
|
|
555
|
+
},
|
|
556
|
+
},
|
|
557
|
+
|
|
558
|
+
beforeDestroy() {
|
|
559
|
+
for (const state of Object.values(this.asyncState)) {
|
|
560
|
+
if (state.searchTimeout) clearTimeout(state.searchTimeout)
|
|
561
|
+
}
|
|
562
|
+
if (this.closeTimeout) clearTimeout(this.closeTimeout)
|
|
563
|
+
},
|
|
564
|
+
|
|
565
|
+
methods: {
|
|
566
|
+
initFormData(item) {
|
|
567
|
+
if (item) {
|
|
568
|
+
// Edit mode: clone item data
|
|
569
|
+
this.formData = JSON.parse(JSON.stringify(item))
|
|
570
|
+
} else {
|
|
571
|
+
// Create mode: initialize with field defaults
|
|
572
|
+
const data = {}
|
|
573
|
+
for (const field of this.resolvedFields) {
|
|
574
|
+
if (field.default !== null && field.default !== undefined) {
|
|
575
|
+
data[field.key] = field.default
|
|
576
|
+
} else if (field.widget === 'checkbox') {
|
|
577
|
+
data[field.key] = false
|
|
578
|
+
} else if (field.widget === 'tags' || field.widget === 'multiselect') {
|
|
579
|
+
data[field.key] = []
|
|
580
|
+
} else if (field.widget === 'code') {
|
|
581
|
+
data[field.key] = ''
|
|
582
|
+
} else {
|
|
583
|
+
data[field.key] = null
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
this.formData = data
|
|
587
|
+
}
|
|
588
|
+
this.errors = {}
|
|
589
|
+
this.jsonDrafts = {}
|
|
590
|
+
this.jsonErrors = {}
|
|
591
|
+
this.initAsyncFields()
|
|
592
|
+
},
|
|
593
|
+
|
|
594
|
+
updateField(key, value) {
|
|
595
|
+
this.$set(this.formData, key, value)
|
|
596
|
+
// Clear error when field is edited
|
|
597
|
+
if (this.errors[key]) {
|
|
598
|
+
this.$delete(this.errors, key)
|
|
599
|
+
}
|
|
600
|
+
},
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Resolve the string shown in the CnJsonViewer for a `json`-widget field.
|
|
604
|
+
* Prefers the unparsed draft (so invalid typing isn't clobbered), falling
|
|
605
|
+
* back to a pretty-printed stringification of the parsed value in formData.
|
|
606
|
+
*
|
|
607
|
+
* @param {object} field Field definition.
|
|
608
|
+
* @return {string} JSON string for the editor.
|
|
609
|
+
*/
|
|
610
|
+
jsonStringFor(field) {
|
|
611
|
+
if (Object.prototype.hasOwnProperty.call(this.jsonDrafts, field.key)) {
|
|
612
|
+
return this.jsonDrafts[field.key]
|
|
613
|
+
}
|
|
614
|
+
const value = this.formData[field.key]
|
|
615
|
+
if (value === null || value === undefined) return ''
|
|
616
|
+
try {
|
|
617
|
+
return JSON.stringify(value, null, 2)
|
|
618
|
+
} catch (e) {
|
|
619
|
+
return String(value)
|
|
620
|
+
}
|
|
621
|
+
},
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Handle input in a `json`-widget CnJsonViewer. Parses on the fly:
|
|
625
|
+
* on success, the parsed value lands in formData and any previous error
|
|
626
|
+
* is cleared; on failure, formData keeps its last-known-good value and
|
|
627
|
+
* `jsonErrors[key]` is set, which surfaces inline and disables confirm.
|
|
628
|
+
*
|
|
629
|
+
* @param {object} field Field definition.
|
|
630
|
+
* @param {string} newString Current editor content.
|
|
631
|
+
*/
|
|
632
|
+
onJsonFieldInput(field, newString) {
|
|
633
|
+
this.$set(this.jsonDrafts, field.key, newString)
|
|
634
|
+
const trimmed = (newString || '').trim()
|
|
635
|
+
if (!trimmed) {
|
|
636
|
+
this.updateField(field.key, null)
|
|
637
|
+
this.$delete(this.jsonErrors, field.key)
|
|
638
|
+
return
|
|
639
|
+
}
|
|
640
|
+
try {
|
|
641
|
+
const parsed = JSON.parse(trimmed)
|
|
642
|
+
this.updateField(field.key, parsed)
|
|
643
|
+
this.$delete(this.jsonErrors, field.key)
|
|
644
|
+
} catch (e) {
|
|
645
|
+
this.$set(this.jsonErrors, field.key, t('nextcloud-vue', 'Invalid JSON: {msg}', { msg: e.message }))
|
|
646
|
+
}
|
|
647
|
+
},
|
|
648
|
+
|
|
649
|
+
getEnumOptions(field) {
|
|
650
|
+
if (!field.enum) return []
|
|
651
|
+
const labels = field.enumLabels || {}
|
|
652
|
+
return field.enum.map((val) => ({
|
|
653
|
+
id: val,
|
|
654
|
+
label: labels[val] || String(val),
|
|
655
|
+
}))
|
|
656
|
+
},
|
|
657
|
+
|
|
658
|
+
getSelectedEnumOption(field) {
|
|
659
|
+
const val = this.formData[field.key]
|
|
660
|
+
if (val === null || val === undefined) return null
|
|
661
|
+
const labels = field.enumLabels || {}
|
|
662
|
+
return { id: val, label: labels[val] || String(val) }
|
|
663
|
+
},
|
|
664
|
+
|
|
665
|
+
onSelectChange(key, option) {
|
|
666
|
+
this.updateField(key, option ? option.id : null)
|
|
667
|
+
},
|
|
668
|
+
|
|
669
|
+
getArrayEnumOptions(field) {
|
|
670
|
+
if (!field.items || !field.items.enum) return []
|
|
671
|
+
return field.items.enum.map((val) => ({
|
|
672
|
+
id: val,
|
|
673
|
+
label: String(val),
|
|
674
|
+
}))
|
|
675
|
+
},
|
|
676
|
+
|
|
677
|
+
getSelectedArrayOptions(field) {
|
|
678
|
+
const val = this.formData[field.key]
|
|
679
|
+
if (!Array.isArray(val)) return []
|
|
680
|
+
return val.map((v) => ({ id: v, label: String(v) }))
|
|
681
|
+
},
|
|
682
|
+
|
|
683
|
+
onMultiSelectChange(key, options) {
|
|
684
|
+
this.updateField(key, (options || []).map((o) => o.id))
|
|
685
|
+
},
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Check if a field has an async enum (function instead of static array).
|
|
689
|
+
*
|
|
690
|
+
* @param {object} field The field definition
|
|
691
|
+
* @return {boolean}
|
|
692
|
+
*/
|
|
693
|
+
isAsyncEnum(field) {
|
|
694
|
+
return typeof field.enum === 'function'
|
|
695
|
+
},
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Check if an array field has an async items enum.
|
|
699
|
+
*
|
|
700
|
+
* @param {object} field The field definition
|
|
701
|
+
* @return {boolean}
|
|
702
|
+
*/
|
|
703
|
+
isAsyncItemsEnum(field) {
|
|
704
|
+
return !!(field.items && typeof field.items.enum === 'function')
|
|
705
|
+
},
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Initialize async state for all async fields and trigger initial load.
|
|
709
|
+
*/
|
|
710
|
+
initAsyncFields() {
|
|
711
|
+
// Clean up existing timeouts
|
|
712
|
+
for (const state of Object.values(this.asyncState)) {
|
|
713
|
+
if (state.searchTimeout) clearTimeout(state.searchTimeout)
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const newState = {}
|
|
717
|
+
for (const field of this.resolvedFields) {
|
|
718
|
+
if (this.isAsyncEnum(field) || this.isAsyncItemsEnum(field)) {
|
|
719
|
+
newState[field.key] = { options: [], loading: false, searchTimeout: null }
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
this.asyncState = newState
|
|
723
|
+
|
|
724
|
+
// Trigger initial load for each async field
|
|
725
|
+
this.$nextTick(() => {
|
|
726
|
+
for (const field of this.resolvedFields) {
|
|
727
|
+
if (this.isAsyncEnum(field) || this.isAsyncItemsEnum(field)) {
|
|
728
|
+
this.loadAsyncOptions(field, '')
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
})
|
|
732
|
+
},
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Load async options for a field by calling its enum function.
|
|
736
|
+
*
|
|
737
|
+
* @param {object} field The field definition
|
|
738
|
+
* @param {string} query Search query
|
|
739
|
+
*/
|
|
740
|
+
async loadAsyncOptions(field, query) {
|
|
741
|
+
const state = this.asyncState[field.key]
|
|
742
|
+
if (!state) return
|
|
743
|
+
|
|
744
|
+
this.$set(state, 'loading', true)
|
|
745
|
+
|
|
746
|
+
try {
|
|
747
|
+
const enumFn = this.isAsyncEnum(field) ? field.enum : field.items.enum
|
|
748
|
+
const results = await enumFn(query)
|
|
749
|
+
this.$set(state, 'options', Array.isArray(results) ? results : [])
|
|
750
|
+
} catch (err) {
|
|
751
|
+
console.error(`CnFormDialog: async enum error for field "${field.key}":`, err)
|
|
752
|
+
this.$set(state, 'options', [])
|
|
753
|
+
} finally {
|
|
754
|
+
this.$set(state, 'loading', false)
|
|
755
|
+
}
|
|
756
|
+
},
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Handle search input on an async select with debounce.
|
|
760
|
+
*
|
|
761
|
+
* @param {object} field The field definition
|
|
762
|
+
* @param {string} query Search query
|
|
763
|
+
*/
|
|
764
|
+
onAsyncSearch(field, query) {
|
|
765
|
+
const state = this.asyncState[field.key]
|
|
766
|
+
if (!state) return
|
|
767
|
+
|
|
768
|
+
if (state.searchTimeout) {
|
|
769
|
+
clearTimeout(state.searchTimeout)
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const debounceMs = field.debounce || 300
|
|
773
|
+
|
|
774
|
+
state.searchTimeout = setTimeout(() => {
|
|
775
|
+
this.loadAsyncOptions(field, query || '')
|
|
776
|
+
}, debounceMs)
|
|
777
|
+
},
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* Get the effective options for a select field (async or static).
|
|
781
|
+
*
|
|
782
|
+
* @param {object} field The field definition
|
|
783
|
+
* @return {Array}
|
|
784
|
+
*/
|
|
785
|
+
getEffectiveOptions(field) {
|
|
786
|
+
if (this.isAsyncEnum(field)) {
|
|
787
|
+
// TODO: restore to `this.asyncState[field.key]?.options || []` once on Vue 3 (buble doesn't support optional chaining)
|
|
788
|
+
return (this.asyncState[field.key] && this.asyncState[field.key].options) || []
|
|
789
|
+
}
|
|
790
|
+
return this.getEnumOptions(field)
|
|
791
|
+
},
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Get the effective selected value for a select field (async or static).
|
|
795
|
+
*
|
|
796
|
+
* @param {object} field The field definition
|
|
797
|
+
* @return {object|null}
|
|
798
|
+
*/
|
|
799
|
+
getEffectiveSelectedOption(field) {
|
|
800
|
+
if (this.isAsyncEnum(field)) {
|
|
801
|
+
// For async fields, formData stores the full option object
|
|
802
|
+
return this.formData[field.key] || null
|
|
803
|
+
}
|
|
804
|
+
return this.getSelectedEnumOption(field)
|
|
805
|
+
},
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Handle select change for both async and static fields.
|
|
809
|
+
*
|
|
810
|
+
* @param {object} field The field definition
|
|
811
|
+
* @param {object|null} option The selected option
|
|
812
|
+
*/
|
|
813
|
+
onEffectiveSelectChange(field, option) {
|
|
814
|
+
if (this.isAsyncEnum(field)) {
|
|
815
|
+
// Store full option object for async selects
|
|
816
|
+
this.updateField(field.key, option || null)
|
|
817
|
+
} else {
|
|
818
|
+
this.onSelectChange(field.key, option)
|
|
819
|
+
}
|
|
820
|
+
},
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Get effective options for a multiselect field (async or static).
|
|
824
|
+
*
|
|
825
|
+
* @param {object} field The field definition
|
|
826
|
+
* @return {Array}
|
|
827
|
+
*/
|
|
828
|
+
getEffectiveArrayOptions(field) {
|
|
829
|
+
if (this.isAsyncItemsEnum(field)) {
|
|
830
|
+
// TODO: restore to `this.asyncState[field.key]?.options || []` once on Vue 3 (buble doesn't support optional chaining)
|
|
831
|
+
return (this.asyncState[field.key] && this.asyncState[field.key].options) || []
|
|
832
|
+
}
|
|
833
|
+
return this.getArrayEnumOptions(field)
|
|
834
|
+
},
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Get effective selected values for a multiselect field (async or static).
|
|
838
|
+
*
|
|
839
|
+
* @param {object} field The field definition
|
|
840
|
+
* @return {Array}
|
|
841
|
+
*/
|
|
842
|
+
getEffectiveSelectedArrayOptions(field) {
|
|
843
|
+
if (this.isAsyncItemsEnum(field)) {
|
|
844
|
+
// For async fields, formData stores array of full option objects
|
|
845
|
+
return this.formData[field.key] || []
|
|
846
|
+
}
|
|
847
|
+
return this.getSelectedArrayOptions(field)
|
|
848
|
+
},
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Handle multiselect change for both async and static fields.
|
|
852
|
+
*
|
|
853
|
+
* @param {object} field The field definition
|
|
854
|
+
* @param {Array} options The selected options
|
|
855
|
+
*/
|
|
856
|
+
onEffectiveMultiSelectChange(field, options) {
|
|
857
|
+
if (this.isAsyncItemsEnum(field)) {
|
|
858
|
+
// Store full option objects for async multiselect
|
|
859
|
+
this.updateField(field.key, options || [])
|
|
860
|
+
} else {
|
|
861
|
+
this.onMultiSelectChange(field.key, options)
|
|
862
|
+
}
|
|
863
|
+
},
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* Whether a field's async options are currently loading.
|
|
867
|
+
*
|
|
868
|
+
* @param {object} field The field definition
|
|
869
|
+
* @return {boolean}
|
|
870
|
+
*/
|
|
871
|
+
isFieldLoading(field) {
|
|
872
|
+
// TODO: restore to `this.asyncState[field.key]?.loading || false` once on Vue 3 (buble doesn't support optional chaining)
|
|
873
|
+
return (this.asyncState[field.key] && this.asyncState[field.key].loading) || false
|
|
874
|
+
},
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* Whether a field has any async behavior (enum or items.enum is a function).
|
|
878
|
+
*
|
|
879
|
+
* @param {object} field The field definition
|
|
880
|
+
* @return {boolean}
|
|
881
|
+
*/
|
|
882
|
+
isFieldAsync(field) {
|
|
883
|
+
return this.isAsyncEnum(field) || this.isAsyncItemsEnum(field)
|
|
884
|
+
},
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* Run client-side validation on all form fields.
|
|
888
|
+
* Checks required, minLength, maxLength, pattern, minimum, maximum.
|
|
889
|
+
*
|
|
890
|
+
* @return {boolean} True if all fields pass validation
|
|
891
|
+
* @public
|
|
892
|
+
*/
|
|
893
|
+
validate() {
|
|
894
|
+
const newErrors = {}
|
|
895
|
+
for (const field of this.resolvedFields) {
|
|
896
|
+
const value = this.formData[field.key]
|
|
897
|
+
|
|
898
|
+
// Required check
|
|
899
|
+
if (field.required) {
|
|
900
|
+
if (value === null || value === undefined || value === '') {
|
|
901
|
+
newErrors[field.key] = `${field.label} is required.`
|
|
902
|
+
continue
|
|
903
|
+
}
|
|
904
|
+
if (Array.isArray(value) && value.length === 0) {
|
|
905
|
+
newErrors[field.key] = `${field.label} is required.`
|
|
906
|
+
continue
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// Skip further validation if empty and not required
|
|
911
|
+
if (value === null || value === undefined || value === '') continue
|
|
912
|
+
|
|
913
|
+
const v = field.validation || {}
|
|
914
|
+
|
|
915
|
+
// String length checks
|
|
916
|
+
if (typeof value === 'string') {
|
|
917
|
+
if (v.minLength !== undefined && value.length < v.minLength) {
|
|
918
|
+
newErrors[field.key] = `Minimum ${v.minLength} characters.`
|
|
919
|
+
} else if (v.maxLength !== undefined && value.length > v.maxLength) {
|
|
920
|
+
newErrors[field.key] = `Maximum ${v.maxLength} characters.`
|
|
921
|
+
} else if (v.pattern !== undefined) {
|
|
922
|
+
try {
|
|
923
|
+
if (!new RegExp(v.pattern).test(value)) {
|
|
924
|
+
newErrors[field.key] = 'Invalid format.'
|
|
925
|
+
}
|
|
926
|
+
// TODO: restore to `catch {` (optional catch binding) once on Vue 3 (buble doesn't support it)
|
|
927
|
+
} catch (e) {
|
|
928
|
+
// Ignore invalid regex patterns
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// Number range checks
|
|
934
|
+
if (typeof value === 'number') {
|
|
935
|
+
if (v.minimum !== undefined && value < v.minimum) {
|
|
936
|
+
newErrors[field.key] = `Minimum value is ${v.minimum}.`
|
|
937
|
+
} else if (v.maximum !== undefined && value > v.maximum) {
|
|
938
|
+
newErrors[field.key] = `Maximum value is ${v.maximum}.`
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
this.errors = newErrors
|
|
944
|
+
return Object.keys(newErrors).length === 0
|
|
945
|
+
},
|
|
946
|
+
|
|
947
|
+
executeConfirm() {
|
|
948
|
+
if (!this.validate()) return
|
|
949
|
+
if (!this.jsonFieldsValid) return
|
|
950
|
+
|
|
951
|
+
this.loading = true
|
|
952
|
+
/**
|
|
953
|
+
* @event confirm Emitted when the user confirms the form.
|
|
954
|
+
* Payload: form data object. Includes `id` when editing.
|
|
955
|
+
*/
|
|
956
|
+
this.$emit('confirm', { ...this.formData })
|
|
957
|
+
},
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Set the result of the save operation. Call this from the parent
|
|
961
|
+
* after the API call completes.
|
|
962
|
+
*
|
|
963
|
+
* @param {{ success?: boolean, error?: string }} resultData - Result data to pass to the dialog
|
|
964
|
+
* @public
|
|
965
|
+
*/
|
|
966
|
+
setResult(resultData) {
|
|
967
|
+
this.loading = false
|
|
968
|
+
this.result = resultData
|
|
969
|
+
if (resultData.success) {
|
|
970
|
+
this.closeTimeout = setTimeout(() => {
|
|
971
|
+
this.$emit('close')
|
|
972
|
+
}, 2000)
|
|
973
|
+
}
|
|
974
|
+
},
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* Set per-field validation errors from the server. Call this from
|
|
978
|
+
* the parent when the API returns validation errors.
|
|
979
|
+
*
|
|
980
|
+
* @param {object} fieldErrors Object keyed by field key with error messages
|
|
981
|
+
* @public
|
|
982
|
+
*/
|
|
983
|
+
setValidationErrors(fieldErrors) {
|
|
984
|
+
this.loading = false
|
|
985
|
+
this.errors = { ...this.errors, ...fieldErrors }
|
|
986
|
+
},
|
|
987
|
+
},
|
|
988
|
+
}
|
|
989
|
+
</script>
|
|
990
|
+
|
|
991
|
+
<style scoped>
|
|
992
|
+
.cn-form-dialog__form {
|
|
993
|
+
display: flex;
|
|
994
|
+
flex-direction: column;
|
|
995
|
+
gap: 4px;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
.cn-form-dialog__field {
|
|
999
|
+
margin-bottom: 8px;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
.cn-form-dialog__textarea-wrapper,
|
|
1003
|
+
.cn-form-dialog__select-wrapper,
|
|
1004
|
+
.cn-form-dialog__json-wrapper {
|
|
1005
|
+
display: flex;
|
|
1006
|
+
flex-direction: column;
|
|
1007
|
+
gap: 4px;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
.cn-form-dialog__label {
|
|
1011
|
+
font-weight: 600;
|
|
1012
|
+
font-size: 0.9em;
|
|
1013
|
+
color: var(--color-main-text);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
.cn-form-dialog__textarea {
|
|
1017
|
+
width: 100%;
|
|
1018
|
+
min-height: 80px;
|
|
1019
|
+
padding: 8px;
|
|
1020
|
+
border: 2px solid var(--color-border-maxcontrast);
|
|
1021
|
+
border-radius: var(--border-radius-large);
|
|
1022
|
+
background-color: var(--color-main-background);
|
|
1023
|
+
color: var(--color-main-text);
|
|
1024
|
+
font-family: inherit;
|
|
1025
|
+
font-size: inherit;
|
|
1026
|
+
resize: vertical;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
.cn-form-dialog__textarea:focus {
|
|
1030
|
+
border-color: var(--color-primary-element);
|
|
1031
|
+
outline: none;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
.cn-form-dialog__textarea:disabled {
|
|
1035
|
+
opacity: 0.5;
|
|
1036
|
+
cursor: not-allowed;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
.cn-form-dialog__helper {
|
|
1040
|
+
font-size: 0.85em;
|
|
1041
|
+
color: var(--color-text-maxcontrast);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
.cn-form-dialog__helper--error {
|
|
1045
|
+
color: var(--color-error);
|
|
1046
|
+
}
|
|
1047
|
+
</style>
|