@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.
Files changed (208) hide show
  1. package/README.md +226 -0
  2. package/css/index.css +5 -0
  3. package/dist/nextcloud-vue.cjs.js +79416 -7715
  4. package/dist/nextcloud-vue.cjs.js.map +1 -1
  5. package/dist/nextcloud-vue.css +3583 -504
  6. package/dist/nextcloud-vue.esm.js +79343 -7692
  7. package/dist/nextcloud-vue.esm.js.map +1 -1
  8. package/l10n/en.json +164 -0
  9. package/l10n/nl.json +164 -0
  10. package/package.json +104 -63
  11. package/src/components/CnActionsBar/CnActionsBar.vue +254 -0
  12. package/src/components/CnActionsBar/index.js +1 -0
  13. package/src/components/CnAdvancedFormDialog/CnAdvancedFormDialog.vue +570 -0
  14. package/src/components/CnAdvancedFormDialog/CnDataTab.vue +217 -0
  15. package/src/components/CnAdvancedFormDialog/CnMetadataTab.vue +121 -0
  16. package/src/components/CnAdvancedFormDialog/CnPropertiesTab.vue +422 -0
  17. package/src/components/CnAdvancedFormDialog/CnPropertyValueCell.vue +247 -0
  18. package/src/components/CnAdvancedFormDialog/index.js +1 -0
  19. package/src/components/CnCard/CnCard.vue +415 -0
  20. package/src/components/CnCard/index.js +1 -0
  21. package/src/components/CnCardGrid/CnCardGrid.vue +156 -152
  22. package/src/components/CnCardGrid/index.js +1 -1
  23. package/src/components/CnCellRenderer/CnCellRenderer.vue +132 -132
  24. package/src/components/CnCellRenderer/index.js +1 -1
  25. package/src/components/CnChartWidget/CnChartWidget.vue +346 -0
  26. package/src/components/CnChartWidget/index.js +1 -0
  27. package/src/components/CnConfigurationCard/CnConfigurationCard.vue +77 -77
  28. package/src/components/CnConfigurationCard/index.js +1 -1
  29. package/src/components/CnContextMenu/CnContextMenu.vue +142 -0
  30. package/src/components/CnContextMenu/index.js +1 -0
  31. package/src/components/CnCopyDialog/CnCopyDialog.vue +266 -0
  32. package/src/components/CnCopyDialog/index.js +1 -0
  33. package/src/components/CnDashboardGrid/CnDashboardGrid.vue +229 -0
  34. package/src/components/CnDashboardGrid/index.js +1 -0
  35. package/src/components/CnDashboardPage/CnDashboardPage.vue +397 -0
  36. package/src/components/CnDashboardPage/index.js +1 -0
  37. package/src/components/CnDataTable/CnDataTable.vue +362 -354
  38. package/src/components/CnDataTable/index.js +1 -1
  39. package/src/components/CnDeleteDialog/CnDeleteDialog.vue +177 -0
  40. package/src/components/CnDeleteDialog/index.js +1 -0
  41. package/src/components/CnDetailCard/CnDetailCard.vue +225 -0
  42. package/src/components/CnDetailCard/index.js +1 -0
  43. package/src/components/CnDetailGrid/CnDetailGrid.vue +256 -0
  44. package/src/components/CnDetailGrid/index.js +1 -0
  45. package/src/components/CnDetailPage/CnDetailPage.vue +432 -0
  46. package/src/components/CnDetailPage/index.js +1 -0
  47. package/src/components/CnFacetSidebar/CnFacetSidebar.vue +234 -223
  48. package/src/components/CnFacetSidebar/index.js +1 -1
  49. package/src/components/CnFilterBar/CnFilterBar.vue +153 -152
  50. package/src/components/CnFilterBar/index.js +1 -1
  51. package/src/components/CnFormDialog/CnFormDialog.vue +1047 -0
  52. package/src/components/CnFormDialog/index.js +1 -0
  53. package/src/components/CnIcon/CnIcon.vue +89 -0
  54. package/src/components/CnIcon/index.js +1 -0
  55. package/src/components/CnIndexPage/CnIndexPage.vue +980 -682
  56. package/src/components/CnIndexPage/index.js +1 -1
  57. package/src/components/CnIndexSidebar/CnIndexSidebar.vue +536 -0
  58. package/src/components/CnIndexSidebar/index.js +1 -0
  59. package/src/components/CnInfoWidget/CnInfoWidget.vue +219 -0
  60. package/src/components/CnInfoWidget/index.js +1 -0
  61. package/src/components/CnItemCard/CnItemCard.vue +134 -0
  62. package/src/components/CnItemCard/index.js +1 -0
  63. package/src/components/CnJsonViewer/CnJsonViewer.vue +312 -0
  64. package/src/components/CnJsonViewer/index.js +1 -0
  65. package/src/components/CnKpiGrid/CnKpiGrid.vue +93 -89
  66. package/src/components/CnKpiGrid/index.js +1 -1
  67. package/src/components/CnMassActionBar/CnMassActionBar.vue +161 -160
  68. package/src/components/CnMassActionBar/index.js +1 -1
  69. package/src/components/CnMassCopyDialog/CnMassCopyDialog.vue +327 -320
  70. package/src/components/CnMassCopyDialog/index.js +1 -1
  71. package/src/components/CnMassDeleteDialog/CnMassDeleteDialog.vue +245 -238
  72. package/src/components/CnMassDeleteDialog/index.js +1 -1
  73. package/src/components/CnMassExportDialog/CnMassExportDialog.vue +191 -190
  74. package/src/components/CnMassExportDialog/index.js +1 -1
  75. package/src/components/CnMassImportDialog/CnMassImportDialog.vue +494 -491
  76. package/src/components/CnMassImportDialog/index.js +1 -1
  77. package/src/components/CnNoteCard/CnNoteCard.vue +149 -0
  78. package/src/components/CnNoteCard/index.js +1 -0
  79. package/src/components/CnNotesCard/CnNotesCard.vue +416 -0
  80. package/src/components/CnNotesCard/index.js +1 -0
  81. package/src/components/CnObjectCard/CnObjectCard.vue +294 -292
  82. package/src/components/CnObjectCard/index.js +1 -1
  83. package/src/components/CnObjectDataWidget/CnObjectDataWidget.vue +854 -0
  84. package/src/components/CnObjectDataWidget/index.js +1 -0
  85. package/src/components/CnObjectMetadataWidget/CnObjectMetadataWidget.vue +289 -0
  86. package/src/components/CnObjectMetadataWidget/index.js +1 -0
  87. package/src/components/CnObjectSidebar/CnAuditTrailTab.vue +369 -0
  88. package/src/components/CnObjectSidebar/CnFilesTab.vue +287 -0
  89. package/src/components/CnObjectSidebar/CnNotesTab.vue +250 -0
  90. package/src/components/CnObjectSidebar/CnObjectSidebar.vue +255 -0
  91. package/src/components/CnObjectSidebar/CnTagsTab.vue +259 -0
  92. package/src/components/CnObjectSidebar/CnTasksTab.vue +483 -0
  93. package/src/components/CnObjectSidebar/index.js +6 -0
  94. package/src/components/CnPageHeader/CnPageHeader.vue +61 -0
  95. package/src/components/CnPageHeader/index.js +1 -0
  96. package/src/components/CnPagination/CnPagination.vue +253 -252
  97. package/src/components/CnPagination/index.js +1 -1
  98. package/src/components/CnProgressBar/CnProgressBar.vue +262 -0
  99. package/src/components/CnProgressBar/index.js +1 -0
  100. package/src/components/CnRegisterMapping/CnRegisterMapping.vue +793 -0
  101. package/src/components/CnRegisterMapping/index.js +1 -0
  102. package/src/components/CnRowActions/CnRowActions.vue +95 -73
  103. package/src/components/CnRowActions/index.js +1 -1
  104. package/src/components/CnSchemaFormDialog/CnSchemaConfigurationTab.vue +226 -0
  105. package/src/components/CnSchemaFormDialog/CnSchemaFormDialog.vue +788 -0
  106. package/src/components/CnSchemaFormDialog/CnSchemaPropertiesTab.vue +305 -0
  107. package/src/components/CnSchemaFormDialog/CnSchemaPropertyActions.vue +1398 -0
  108. package/src/components/CnSchemaFormDialog/CnSchemaSecurityTab.vue +236 -0
  109. package/src/components/CnSchemaFormDialog/index.js +1 -0
  110. package/src/components/CnSettingsCard/CnSettingsCard.vue +92 -92
  111. package/src/components/CnSettingsCard/index.js +1 -1
  112. package/src/components/CnSettingsSection/CnSettingsSection.vue +267 -266
  113. package/src/components/CnSettingsSection/index.js +1 -1
  114. package/src/components/CnStatsBlock/CnStatsBlock.vue +437 -366
  115. package/src/components/CnStatsBlock/index.js +1 -1
  116. package/src/components/CnStatsPanel/CnStatsPanel.vue +321 -0
  117. package/src/components/CnStatsPanel/index.js +1 -0
  118. package/src/components/CnStatusBadge/CnStatusBadge.vue +90 -77
  119. package/src/components/CnStatusBadge/index.js +1 -1
  120. package/src/components/CnTabbedFormDialog/CnTabbedFormDialog.vue +545 -0
  121. package/src/components/CnTabbedFormDialog/index.js +1 -0
  122. package/src/components/CnTableWidget/CnTableWidget.vue +333 -0
  123. package/src/components/CnTableWidget/index.js +1 -0
  124. package/src/components/CnTasksCard/CnTasksCard.vue +374 -0
  125. package/src/components/CnTasksCard/index.js +1 -0
  126. package/src/components/CnTileWidget/CnTileWidget.vue +159 -0
  127. package/src/components/CnTileWidget/index.js +1 -0
  128. package/src/components/CnTimelineStages/CnTimelineStages.vue +294 -0
  129. package/src/components/CnTimelineStages/index.js +1 -0
  130. package/src/components/CnUserActionMenu/CnUserActionMenu.vue +436 -0
  131. package/src/components/CnUserActionMenu/index.js +1 -0
  132. package/src/components/CnVersionInfoCard/CnVersionInfoCard.vue +313 -312
  133. package/src/components/CnVersionInfoCard/index.js +1 -1
  134. package/src/components/CnWidgetRenderer/CnWidgetRenderer.vue +180 -0
  135. package/src/components/CnWidgetRenderer/index.js +1 -0
  136. package/src/components/CnWidgetWrapper/CnWidgetWrapper.vue +248 -0
  137. package/src/components/CnWidgetWrapper/index.js +1 -0
  138. package/src/components/index.js +57 -25
  139. package/src/composables/index.js +5 -3
  140. package/src/composables/useContextMenu.js +126 -0
  141. package/src/composables/useDashboardView.js +286 -0
  142. package/src/composables/useDetailView.js +290 -132
  143. package/src/composables/useListView.js +364 -153
  144. package/src/composables/useSubResource.js +142 -142
  145. package/src/constants/metadata.js +30 -0
  146. package/src/css/CnSchemaFormDialog.css +546 -0
  147. package/src/css/__sample_nextcloud_tokens.css +110 -0
  148. package/src/css/actions-bar.css +54 -0
  149. package/src/css/badge.css +83 -51
  150. package/src/css/card.css +129 -128
  151. package/src/css/context-menu.css +20 -0
  152. package/src/css/dashboard.css +70 -0
  153. package/src/css/detail-page.css +235 -0
  154. package/src/css/detail.css +68 -68
  155. package/src/css/index-page.css +44 -0
  156. package/src/css/index-sidebar.css +193 -0
  157. package/src/css/index.css +17 -8
  158. package/src/css/layout.css +90 -90
  159. package/src/css/page-header.css +35 -0
  160. package/src/css/pagination.css +72 -72
  161. package/src/css/table.css +142 -143
  162. package/src/css/timeline-stages.css +220 -0
  163. package/src/css/utilities.css +46 -46
  164. package/src/index.js +95 -50
  165. package/src/l10n/index.js +12 -0
  166. package/src/mixins/gridLayout.js +118 -0
  167. package/src/store/createCrudStore.d.ts +350 -0
  168. package/src/store/createCrudStore.js +413 -0
  169. package/src/store/createSubResourcePlugin.js +125 -135
  170. package/src/store/index.js +4 -3
  171. package/src/store/pluginMerge.js +55 -0
  172. package/src/store/plugins/auditTrails.js +357 -17
  173. package/src/store/plugins/files.js +250 -186
  174. package/src/store/plugins/index.js +8 -4
  175. package/src/store/plugins/lifecycle.js +180 -180
  176. package/src/store/plugins/logs.d.ts +22 -0
  177. package/src/store/plugins/logs.js +172 -0
  178. package/src/store/plugins/registerMapping.js +195 -0
  179. package/src/store/plugins/relations.js +68 -68
  180. package/src/store/plugins/search.js +385 -0
  181. package/src/store/plugins/selection.js +104 -0
  182. package/src/store/useObjectStore.js +793 -625
  183. package/src/types/auditTrail.d.ts +32 -32
  184. package/src/types/file.d.ts +23 -23
  185. package/src/types/index.d.ts +67 -35
  186. package/src/types/notification.d.ts +36 -36
  187. package/src/types/object.d.ts +40 -40
  188. package/src/types/organisation.d.ts +41 -41
  189. package/src/types/register.d.ts +25 -25
  190. package/src/types/schema.d.ts +39 -39
  191. package/src/types/shared.d.ts +79 -79
  192. package/src/types/source.d.ts +14 -14
  193. package/src/types/task.d.ts +31 -31
  194. package/src/utils/errors.js +96 -96
  195. package/src/utils/getTheme.js +9 -0
  196. package/src/utils/headers.js +80 -44
  197. package/src/utils/id.js +13 -0
  198. package/src/utils/index.js +4 -3
  199. package/src/utils/schema.js +423 -287
  200. package/src/utils/widgetVisibility.js +162 -0
  201. package/src/components/CnDetailViewLayout/CnDetailViewLayout.vue +0 -88
  202. package/src/components/CnDetailViewLayout/index.js +0 -1
  203. package/src/components/CnEmptyState/CnEmptyState.vue +0 -78
  204. package/src/components/CnEmptyState/index.js +0 -1
  205. package/src/components/CnListViewLayout/CnListViewLayout.vue +0 -80
  206. package/src/components/CnListViewLayout/index.js +0 -1
  207. package/src/components/CnViewModeToggle/CnViewModeToggle.vue +0 -77
  208. 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>