@conduction/nextcloud-vue 0.1.0-beta.14 → 0.1.0-beta.16

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 (46) hide show
  1. package/dist/nextcloud-vue.cjs.js +7282 -3443
  2. package/dist/nextcloud-vue.cjs.js.map +1 -1
  3. package/dist/nextcloud-vue.css +719 -100
  4. package/dist/nextcloud-vue.esm.js +7120 -3300
  5. package/dist/nextcloud-vue.esm.js.map +1 -1
  6. package/package.json +3 -2
  7. package/src/components/CnAdvancedFormDialog/CnAdvancedFormDialog.vue +36 -3
  8. package/src/components/CnAdvancedFormDialog/CnMetadataTab.vue +34 -19
  9. package/src/components/CnAdvancedFormDialog/CnPropertiesTab.vue +312 -36
  10. package/src/components/CnAdvancedFormDialog/CnPropertyValueCell.vue +983 -64
  11. package/src/components/CnAdvancedFormDialog/index.js +3 -0
  12. package/src/components/CnAppLoading/CnAppLoading.vue +93 -0
  13. package/src/components/CnAppLoading/index.js +3 -0
  14. package/src/components/CnAppNav/CnAppNav.vue +269 -0
  15. package/src/components/CnAppNav/index.js +3 -0
  16. package/src/components/CnAppRoot/CnAppRoot.vue +201 -0
  17. package/src/components/CnAppRoot/index.js +3 -0
  18. package/src/components/CnColorPicker/CnColorPicker.vue +251 -0
  19. package/src/components/CnColorPicker/index.js +1 -0
  20. package/src/components/CnContextMenu/CnContextMenu.vue +41 -4
  21. package/src/components/CnDashboardPage/CnDashboardPage.vue +8 -0
  22. package/src/components/CnDependencyMissing/CnDependencyMissing.vue +152 -0
  23. package/src/components/CnDependencyMissing/index.js +3 -0
  24. package/src/components/CnDetailPage/CnDetailPage.vue +27 -16
  25. package/src/components/CnIndexPage/CnIndexPage.vue +36 -6
  26. package/src/components/CnPageRenderer/CnPageRenderer.vue +278 -0
  27. package/src/components/CnPageRenderer/index.js +4 -0
  28. package/src/components/CnPageRenderer/pageTypes.js +37 -0
  29. package/src/components/CnRowActions/CnRowActions.vue +44 -3
  30. package/src/components/CnSchemaFormDialog/CnSchemaConfigurationTab.vue +4 -0
  31. package/src/components/CnSchemaFormDialog/CnSchemaFormDialog.vue +103 -74
  32. package/src/components/CnSchemaFormDialog/CnSchemaPropertiesTab.vue +30 -2
  33. package/src/components/CnSchemaFormDialog/CnSchemaSecurityTab.vue +16 -12
  34. package/src/components/CnTabbedFormDialog/CnTabbedFormDialog.vue +9 -4
  35. package/src/components/index.js +7 -1
  36. package/src/composables/index.js +2 -0
  37. package/src/composables/useAppManifest.js +115 -0
  38. package/src/composables/useAppStatus.js +107 -0
  39. package/src/css/CnSchemaFormDialog.css +22 -0
  40. package/src/index.js +24 -2
  41. package/src/schemas/app-manifest.schema.json +153 -0
  42. package/src/types/index.d.ts +9 -0
  43. package/src/types/manifest.d.ts +88 -0
  44. package/src/utils/index.js +1 -1
  45. package/src/utils/schema.js +157 -2
  46. package/src/utils/validateManifest.js +113 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conduction/nextcloud-vue",
3
- "version": "0.1.0-beta.14",
3
+ "version": "0.1.0-beta.16",
4
4
  "description": "Shared Vue component library for Conduction Nextcloud apps — complements @nextcloud/vue with higher-level components, OpenRegister integration, and NL Design System support",
5
5
  "license": "EUPL-1.2",
6
6
  "author": "Conduction B.V. <info@conduction.nl>",
@@ -47,7 +47,8 @@
47
47
  "@uiw/codemirror-theme-github": "^4.25.8",
48
48
  "gridstack": "^10.3.1",
49
49
  "vue-apexcharts": "^1.7.0",
50
- "vue-codemirror6": "^1.4.3"
50
+ "vue-codemirror6": "^1.4.3",
51
+ "vue-color": "^2.8.2"
51
52
  },
52
53
  "peerDependencies": {
53
54
  "@nextcloud/axios": "^2.0.0",
@@ -37,8 +37,13 @@
37
37
  <!-- Main tabs -->
38
38
  <div v-else class="cn-advanced-form-dialog__tabs tabContainer">
39
39
  <BTabs v-model="activeTab" content-class="mt-3" justified>
40
- <!-- Properties tab -->
41
- <BTab v-if="showPropertiesTable" title="Properties">
40
+ <!-- Properties tab — disabled when the active schema has no
41
+ properties to render (a bare JSON blob is still
42
+ editable via the Data tab). -->
43
+ <BTab
44
+ v-if="showPropertiesTable"
45
+ title="Properties"
46
+ :disabled="!hasSchemaProperties">
42
47
  <slot
43
48
  name="tab-properties"
44
49
  :form-data="formData"
@@ -130,7 +135,7 @@ import CnMetadataTab from './CnMetadataTab.vue'
130
135
  import CnDataTab from './CnDataTab.vue'
131
136
 
132
137
  /** Schema types for which we have built-in inline editing support in the properties table. */
133
- const EDITABLE_SUPPORTED_TYPES = ['string', 'number', 'integer', 'boolean']
138
+ const EDITABLE_SUPPORTED_TYPES = ['string', 'number', 'integer', 'boolean', 'array', 'object']
134
139
 
135
140
  /**
136
141
  * CnAdvancedFormDialog — Create/edit dialog with properties table (click-to-edit), JSON tab, and optional store integration.
@@ -229,6 +234,24 @@ export default {
229
234
  return !!this.item
230
235
  },
231
236
 
237
+ /**
238
+ * True when the active schema declares at least one (non-metadata)
239
+ * property the Properties tab can render. Used to disable the tab
240
+ * when there's nothing for it to show — the Data tab still works.
241
+ */
242
+ hasSchemaProperties() {
243
+ const props = this.schema?.properties || {}
244
+ const exclude = this.excludeFields || []
245
+ const include = this.includeFields
246
+ for (const key of Object.keys(props)) {
247
+ if (key === '@self' || key === 'id') continue
248
+ if (exclude.includes(key)) continue
249
+ if (include && !include.includes(key)) continue
250
+ return true
251
+ }
252
+ return false
253
+ },
254
+
232
255
  resolvedFields() {
233
256
  return fieldsFromSchema(this.schema, {
234
257
  exclude: this.excludeFields,
@@ -297,6 +320,16 @@ export default {
297
320
  this.initFormData(newItem)
298
321
  },
299
322
  },
323
+ hasSchemaProperties: {
324
+ immediate: true,
325
+ handler(hasProps) {
326
+ // When the Properties tab is disabled, skip past it so we
327
+ // don't land on a non-interactive tab on first render.
328
+ if (!hasProps && this.activeTab === 0 && this.showPropertiesTable) {
329
+ this.activeTab = this.resolvedShowMetadataTab ? 1 : (this.showJsonTab ? 1 : 0)
330
+ }
331
+ },
332
+ },
300
333
  jsonData(newVal) {
301
334
  if (!this.isInternalUpdate && this.isValidJson(newVal)) {
302
335
  this.updateFormFromJson()
@@ -12,28 +12,15 @@
12
12
  </tr>
13
13
  </thead>
14
14
  <tbody>
15
- <tr class="cn-advanced-form-dialog__table-row">
16
- <td class="cn-advanced-form-dialog__table-col-constrained">
17
- {{ t('nextcloud-vue', 'ID') }}
18
- </td>
19
- <td class="cn-advanced-form-dialog__table-col-expanded">
20
- {{ metadataId }}
21
- </td>
22
- </tr>
23
- <tr class="cn-advanced-form-dialog__table-row">
15
+ <tr
16
+ v-for="[label, value] in resolvedRows"
17
+ :key="label"
18
+ class="cn-advanced-form-dialog__table-row">
24
19
  <td class="cn-advanced-form-dialog__table-col-constrained">
25
- {{ t('nextcloud-vue', 'Created') }}
20
+ {{ label }}
26
21
  </td>
27
22
  <td class="cn-advanced-form-dialog__table-col-expanded">
28
- {{ metadataCreated }}
29
- </td>
30
- </tr>
31
- <tr class="cn-advanced-form-dialog__table-row">
32
- <td class="cn-advanced-form-dialog__table-col-constrained">
33
- {{ t('nextcloud-vue', 'Updated') }}
34
- </td>
35
- <td class="cn-advanced-form-dialog__table-col-expanded">
36
- {{ metadataUpdated }}
23
+ {{ value }}
37
24
  </td>
38
25
  </tr>
39
26
  </tbody>
@@ -42,12 +29,24 @@
42
29
  </template>
43
30
 
44
31
  <script>
32
+ import { translate as t } from '@nextcloud/l10n'
33
+
45
34
  export default {
46
35
  name: 'CnMetadataTab',
47
36
 
48
37
  props: {
49
38
  item: { type: Object, default: null },
50
39
  formData: { type: Object, default: () => ({}) },
40
+ /**
41
+ * Additional `[label, value]` rows appended to (or, when `replaceRows` is true,
42
+ * replacing) the default ID/Created/Updated rows. Use this to surface
43
+ * domain-specific metadata (version, owner, custom timestamps, etc.).
44
+ */
45
+ extraRows: { type: Array, default: () => [] },
46
+ /**
47
+ * When true, render only `extraRows` and skip the default ID/Created/Updated rows.
48
+ */
49
+ replaceRows: { type: Boolean, default: false },
51
50
  },
52
51
 
53
52
  computed: {
@@ -67,7 +66,23 @@ export default {
67
66
  const v = o?.updated
68
67
  return v ? new Date(v).toLocaleString() : '—'
69
68
  },
69
+
70
+ defaultRows() {
71
+ return [
72
+ [this.t('nextcloud-vue', 'ID'), this.metadataId],
73
+ [this.t('nextcloud-vue', 'Created'), this.metadataCreated],
74
+ [this.t('nextcloud-vue', 'Updated'), this.metadataUpdated],
75
+ ]
76
+ },
77
+
78
+ resolvedRows() {
79
+ const extra = Array.isArray(this.extraRows) ? this.extraRows : []
80
+ if (this.replaceRows) return extra
81
+ return [...this.defaultRows, ...extra]
82
+ },
70
83
  },
84
+
85
+ methods: { t },
71
86
  }
72
87
  </script>
73
88
 
@@ -9,6 +9,11 @@
9
9
  <th class="cn-advanced-form-dialog__table-col-expanded">
10
10
  {{ t('nextcloud-vue', 'Value') }}
11
11
  </th>
12
+ <th
13
+ v-if="hasRowActionsSlot"
14
+ class="cn-advanced-form-dialog__table-col-actions">
15
+ <slot name="row-actions-header" />
16
+ </th>
12
17
  </tr>
13
18
  </thead>
14
19
  <tbody>
@@ -18,18 +23,24 @@
18
23
  class="cn-advanced-form-dialog__table-row"
19
24
  :class="{
20
25
  'cn-advanced-form-dialog__table-row--selected': selectedProperty === key,
21
- 'cn-advanced-form-dialog__table-row--edited': formData[key] !== undefined,
26
+ 'cn-advanced-form-dialog__table-row--edited': isValueChanged(key),
22
27
  'cn-advanced-form-dialog__table-row--non-editable': !isPropertyEditable(key, resolvedValue(key, value)),
23
28
  [getPropertyValidationClass(key, value)]: validationDisplay === 'indicator',
24
29
  }"
25
30
  @click="handleRowClick(key, $event)">
26
- <td class="cn-advanced-form-dialog__table-col-constrained cn-advanced-form-dialog__prop-cell">
31
+ <td class="cn-advanced-form-dialog__table-col-constrained cn-advanced-form-dialog__prop-cell"
32
+ :style="getPropCellStyle(key, value)">
27
33
  <div class="cn-advanced-form-dialog__prop-cell-content">
28
34
  <AlertCircle
29
35
  v-if="validationDisplay === 'indicator' && getPropertyValidationState(key, resolvedValue(key, value)) === 'invalid'"
30
36
  class="cn-advanced-form-dialog__validation-icon cn-advanced-form-dialog__validation-icon--error"
31
37
  :size="16"
32
38
  :title="getPropertyErrorMessage(key, resolvedValue(key, value))" />
39
+ <Alert
40
+ v-else-if="validationDisplay === 'indicator' && isValueChanged(key) && getPropertyValidationState(key, resolvedValue(key, value)) !== 'invalid'"
41
+ class="cn-advanced-form-dialog__validation-icon cn-advanced-form-dialog__validation-icon--edited"
42
+ :size="16"
43
+ :title="t('nextcloud-vue', 'This field has been changed')" />
33
44
  <Alert
34
45
  v-else-if="validationDisplay === 'indicator' && getPropertyValidationState(key, resolvedValue(key, value)) === 'warning'"
35
46
  class="cn-advanced-form-dialog__validation-icon cn-advanced-form-dialog__validation-icon--warning"
@@ -46,19 +57,58 @@
46
57
  :size="16"
47
58
  :title="getEditabilityWarning(key, resolvedValue(key, value)) || ''" />
48
59
  <span :title="getPropertyTooltip(key)">{{ getPropertyDisplayName(key) }}</span>
60
+ <span
61
+ v-if="isRequired(key)"
62
+ class="cn-advanced-form-dialog__required-indicator"
63
+ :title="t('nextcloud-vue', 'Required')"
64
+ aria-label="required">*</span>
65
+ <span
66
+ v-if="isImmutableHint(key)"
67
+ class="cn-advanced-form-dialog__immutable-badge"
68
+ :title="t('nextcloud-vue', 'This value can be set on creation but cannot be changed afterwards.')">
69
+ {{ t('nextcloud-vue', 'Set once') }}
70
+ </span>
49
71
  </div>
50
72
  </td>
51
73
  <td class="cn-advanced-form-dialog__table-col-expanded cn-advanced-form-dialog__value-cell">
52
- <CnPropertyValueCell
53
- :ref="'cell-' + key"
74
+ <slot
75
+ name="value-cell"
54
76
  :property-key="key"
55
- :schema="schema"
56
- :value="resolvedValue(key, value)"
57
- :is-editable="isPropertyEditable(key, resolvedValue(key, value))"
77
+ :value="value"
78
+ :resolved-value="resolvedValue(key, value)"
58
79
  :is-editing="selectedProperty === key"
80
+ :is-editable="isPropertyEditable(key, resolvedValue(key, value))"
59
81
  :display-name="getPropertyDisplayName(key)"
82
+ :schema-prop="schema && schema.properties && schema.properties[key]"
60
83
  :editability-warning="getPropertyEditabilityWarning(key, resolvedValue(key, value))"
61
- @update:value="onPropertyValueUpdate(key, $event)" />
84
+ :on-update="(v) => onPropertyValueUpdate(key, v)">
85
+ <CnPropertyValueCell
86
+ :ref="'cell-' + key"
87
+ :property-key="key"
88
+ :schema="schema"
89
+ :value="resolvedValue(key, value)"
90
+ :is-editable="isPropertyEditable(key, resolvedValue(key, value))"
91
+ :is-editing="selectedProperty === key"
92
+ :display-name="getPropertyDisplayName(key)"
93
+ :editability-warning="getPropertyEditabilityWarning(key, resolvedValue(key, value))"
94
+ :widget="(propertyOverrides[key] && propertyOverrides[key].widget) || null"
95
+ :select-options="(propertyOverrides[key] && propertyOverrides[key].selectOptions) || null"
96
+ :select-multiple="propertyOverrides[key] ? propertyOverrides[key].selectMultiple !== false : true"
97
+ :textarea-rows="(propertyOverrides[key] && propertyOverrides[key].textareaRows) || 4"
98
+ @update:value="onPropertyValueUpdate(key, $event)" />
99
+ </slot>
100
+ </td>
101
+ <td
102
+ v-if="hasRowActionsSlot"
103
+ class="cn-advanced-form-dialog__table-col-actions"
104
+ @click.stop>
105
+ <slot
106
+ name="row-actions"
107
+ :property-key="key"
108
+ :value="value"
109
+ :resolved-value="resolvedValue(key, value)"
110
+ :is-editable="isPropertyEditable(key, resolvedValue(key, value))"
111
+ :is-schema-property="!!(schema && schema.properties && Object.prototype.hasOwnProperty.call(schema.properties, key))" />
62
112
  </td>
63
113
  </tr>
64
114
  </tbody>
@@ -67,6 +117,7 @@
67
117
  </template>
68
118
 
69
119
  <script>
120
+ import { translate as t } from '@nextcloud/l10n'
70
121
  import AlertCircle from 'vue-material-design-icons/AlertCircle.vue'
71
122
  import Alert from 'vue-material-design-icons/Alert.vue'
72
123
  import LockOutline from 'vue-material-design-icons/LockOutline.vue'
@@ -89,13 +140,40 @@ export default {
89
140
  item: { type: Object, default: null },
90
141
  formData: { type: Object, default: () => ({}) },
91
142
  selectedProperty: { type: String, default: null },
92
- editableTypes: { type: Array, default: () => ['string', 'number', 'integer', 'boolean'] },
143
+ editableTypes: { type: Array, default: () => ['string', 'number', 'integer', 'boolean', 'array', 'object'] },
93
144
  validationDisplay: { type: String, default: 'indicator' },
94
145
  excludeFields: { type: Array, default: () => [] },
95
146
  includeFields: { type: Array, default: null },
147
+ /**
148
+ * When false (default), properties whose schema entry has `const`
149
+ * set are filtered out of the list — the user can't change them so
150
+ * they only add noise. Set to `true` to render them anyway (e.g. for
151
+ * debugging or admin views). `immutable` / `readOnly` properties are
152
+ * always rendered regardless of this flag; they're just non-editable
153
+ * once they have a value.
154
+ */
155
+ showConstantProperties: { type: Boolean, default: false },
156
+ /**
157
+ * Per-property overrides forwarded to CnPropertyValueCell. Keyed by property key.
158
+ * Each entry may contain: `{ widget, selectOptions, selectMultiple, textareaRows }`.
159
+ */
160
+ propertyOverrides: { type: Object, default: () => ({}) },
161
+ /**
162
+ * Override the left-edge indicator color on the property-name cell.
163
+ * When null (default) the CSS default is used (var(--color-primary)).
164
+ * Pass "none" to remove the indicator and let the per-row validation
165
+ * colors (green/yellow/red) on the <tr> show through instead.
166
+ * Any other CSS color string is applied directly.
167
+ */
168
+ propCellColor: { type: String, default: null },
96
169
  },
97
170
 
98
171
  computed: {
172
+ propCellStyle() {
173
+ if (this.propCellColor === null) return undefined
174
+ if (this.propCellColor === 'none') return { boxShadow: 'none' }
175
+ return { boxShadow: `inset 3px 0 0 0 ${this.propCellColor}` }
176
+ },
99
177
  objectProperties() {
100
178
  const schemaProps = this.schema?.properties || {}
101
179
  const obj = this.item || {}
@@ -105,6 +183,7 @@ export default {
105
183
  if (k === '@self' || k === 'id') return false
106
184
  if (exclude.includes(k)) return false
107
185
  if (include && !include.includes(k)) return false
186
+ if (schemaProps[k]?.hideOnForm === true) return false
108
187
  return true
109
188
  }
110
189
  const existing = Object.entries(obj).filter(([k]) => filterKey(k))
@@ -112,24 +191,94 @@ export default {
112
191
  for (const [key, prop] of Object.entries(schemaProps)) {
113
192
  if (!filterKey(key)) continue
114
193
  if (!Object.prototype.hasOwnProperty.call(obj, key)) {
115
- let def
116
- switch (prop.type) {
117
- case 'string': def = prop.const ?? ''; break
118
- case 'number':
119
- case 'integer': def = 0; break
120
- case 'boolean': def = false; break
121
- case 'array': def = []; break
122
- case 'object': def = {}; break
123
- default: def = ''
124
- }
125
- missing.push([key, def])
194
+ missing.push([key, this.defaultForProperty(prop)])
126
195
  }
127
196
  }
128
- return [...existing, ...missing]
197
+ const all = [...existing, ...missing]
198
+ const filtered = this.showConstantProperties
199
+ ? all
200
+ : all.filter(([key]) => !this.isConstantOrImmutableKey(key))
201
+ // Sort: schema `order` ascending (0 first), unspecified last; preserve
202
+ // schema/property declaration order as a stable tiebreaker.
203
+ const indexFor = (key) => {
204
+ const i = Object.keys(schemaProps).indexOf(key)
205
+ return i === -1 ? Number.MAX_SAFE_INTEGER : i
206
+ }
207
+ const orderFor = (key) => {
208
+ const o = schemaProps[key]?.order
209
+ return typeof o === 'number' ? o : Number.MAX_SAFE_INTEGER
210
+ }
211
+ return filtered
212
+ .map(([k, v], i) => ({ k, v, order: orderFor(k), idx: indexFor(k), insertion: i }))
213
+ .sort((a, b) => (a.order - b.order) || (a.idx - b.idx) || (a.insertion - b.insertion))
214
+ .map(({ k, v }) => [k, v])
215
+ },
216
+
217
+ /**
218
+ * True when at least one property in the (unfiltered) list is constant or immutable.
219
+ * Useful for parents that want to render a show/hide-toggle button only when relevant.
220
+ */
221
+ hasConstantOrImmutableProperties() {
222
+ const schemaProps = this.schema?.properties || {}
223
+ const obj = this.item || {}
224
+ const exclude = this.excludeFields || []
225
+ const include = this.includeFields
226
+ const keys = new Set([
227
+ ...Object.keys(obj),
228
+ ...Object.keys(schemaProps),
229
+ ])
230
+ for (const k of keys) {
231
+ if (k === '@self' || k === 'id') continue
232
+ if (exclude.includes(k)) continue
233
+ if (include && !include.includes(k)) continue
234
+ if (schemaProps[k]?.hideOnForm === true) continue
235
+ if (this.isConstantOrImmutableKey(k)) return true
236
+ }
237
+ return false
238
+ },
239
+
240
+ hasRowActionsSlot() {
241
+ return !!this.$scopedSlots['row-actions']
129
242
  },
130
243
  },
131
244
 
132
245
  methods: {
246
+ t,
247
+
248
+ getPropCellStyle(key, value) {
249
+ if (this.validationDisplay !== 'indicator') {
250
+ return this.propCellStyle
251
+ }
252
+ const resolvedVal = this.resolvedValue(key, value)
253
+ const state = this.getPropertyValidationState(key, resolvedVal)
254
+ if (state === 'invalid') {
255
+ return { boxShadow: 'inset 3px 0 0 0 var(--color-error)' }
256
+ }
257
+ if (this.isValueChanged(key)) {
258
+ return { boxShadow: 'inset 3px 0 0 0 var(--color-warning)' }
259
+ }
260
+ if (state === 'valid') {
261
+ return { boxShadow: 'inset 3px 0 0 0 var(--color-success)' }
262
+ }
263
+ if (state === 'warning') {
264
+ return { boxShadow: 'inset 3px 0 0 0 var(--color-warning)' }
265
+ }
266
+ if (state === 'new') {
267
+ return { boxShadow: 'inset 3px 0 0 0 var(--color-primary-element)' }
268
+ }
269
+ return this.propCellStyle
270
+ },
271
+
272
+ isValueChanged(key) {
273
+ if (this.formData[key] === undefined) return false
274
+ const original = this.item ? this.item[key] : undefined
275
+ const current = this.formData[key]
276
+ if (typeof current === 'object' || typeof original === 'object') {
277
+ return JSON.stringify(current) !== JSON.stringify(original)
278
+ }
279
+ return current !== original
280
+ },
281
+
133
282
  /**
134
283
  * The effective value for a key: formData override or the object's own value
135
284
  * @param {string} key - The property key to look up
@@ -139,15 +288,92 @@ export default {
139
288
  return this.formData[key] !== undefined ? this.formData[key] : objectValue
140
289
  },
141
290
 
291
+ /**
292
+ * Initial display value for a schema property that doesn't yet exist on the
293
+ * object. Honors `default` and `const` first, then falls back to the
294
+ * type-appropriate empty value.
295
+ * @param {object} prop - The schema property entry.
296
+ */
297
+ defaultForProperty(prop) {
298
+ if (!prop) return ''
299
+ if (prop.default !== undefined) return prop.default
300
+ if (prop.const !== undefined) return prop.const
301
+ switch (prop.type) {
302
+ case 'string': return ''
303
+ case 'number':
304
+ case 'integer': return 0
305
+ case 'boolean': return false
306
+ case 'array': return []
307
+ case 'object': return {}
308
+ default: return ''
309
+ }
310
+ },
311
+
142
312
  onPropertyValueUpdate(key, value) {
143
313
  this.$emit('update:property-value', { key, value })
144
314
  },
145
315
 
316
+ /**
317
+ * Whether a property is marked required either via `schema.required: [...]`
318
+ * (the JSON-Schema-canonical place) or via `prop.required: true` on the
319
+ * property entry itself (a non-standard but commonly seen variant).
320
+ * @param {string} key - Property key.
321
+ * @return {boolean}
322
+ */
323
+ isRequired(key) {
324
+ if ((this.schema?.required || []).includes(key)) return true
325
+ const prop = this.schema?.properties?.[key]
326
+ return !!(prop && prop.required === true)
327
+ },
328
+
329
+ /**
330
+ * Whether the property's value is fixed by the schema (`const`) and
331
+ * should be hide-able via the show/hide toggle. Note: `immutable` /
332
+ * `readOnly` are NOT considered constant — they're set on creation
333
+ * and locked afterward, but should remain visible in the form.
334
+ * @param {string} key - Property key.
335
+ * @return {boolean}
336
+ */
337
+ /**
338
+ * True when an "immutable" / "readOnly" hint badge should be shown
339
+ * for this property — i.e. the prop is settable on creation but
340
+ * locks once persisted, AND it isn't already locked. Once locked the
341
+ * lock icon takes over and the badge would be redundant.
342
+ * @param {string} key - Property key.
343
+ * @return {boolean}
344
+ */
345
+ isImmutableHint(key) {
346
+ const prop = this.schema?.properties?.[key]
347
+ if (!prop) return false
348
+ if (prop.const !== undefined) return false
349
+ const lockOnce = prop.immutable === true || prop.readOnly === true
350
+ if (!lockOnce) return false
351
+ return this.isPropertyEditable(key, null)
352
+ },
353
+
354
+ isConstantOrImmutableKey(key) {
355
+ const prop = this.schema?.properties?.[key]
356
+ if (!prop) return false
357
+ return prop.const !== undefined
358
+ },
359
+
360
+ // `value` is intentionally unused — kept in the signature for callers
361
+ // that already pass it (slot consumers, the cell, the row click
362
+ // handler). Editability is now driven by the persisted `item`.
363
+ // eslint-disable-next-line no-unused-vars
146
364
  isPropertyEditable(key, value) {
147
365
  const prop = this.schema?.properties?.[key]
148
366
  if (!prop) return true
149
367
  if (prop.const !== undefined) return false
150
- if (prop.immutable && value != null && value !== '') return false
368
+ // `immutable` / `readOnly` mean "settable on creation, locked
369
+ // once persisted". Use the persisted `item` (not the live value
370
+ // the user is typing) as the source of truth — otherwise the
371
+ // field would lock the moment the first character is entered.
372
+ const lockOnce = prop.immutable === true || prop.readOnly === true
373
+ if (lockOnce) {
374
+ const persisted = this.item && this.item[key]
375
+ if (persisted != null && persisted !== '') return false
376
+ }
151
377
  const type = prop.type || 'string'
152
378
  return this.editableTypes.includes(type)
153
379
  },
@@ -197,6 +423,8 @@ export default {
197
423
  case 'string':
198
424
  if (typeof value !== 'string') return false
199
425
  if (schemaProperty?.format === 'date-time' && !this.isValidDate(value)) return false
426
+ if (schemaProperty?.format === 'email' && !this.isValidEmail(value)) return false
427
+ if (schemaProperty?.format === 'uri' && !this.isValidUri(value)) return false
200
428
  if (schemaProperty?.const && value !== schemaProperty.const) return false
201
429
  return true
202
430
  case 'number':
@@ -229,6 +457,12 @@ export default {
229
457
  if (prop.format === 'date-time' && !this.isValidDate(value)) {
230
458
  return `Property '${key}' should be a valid date-time value.`
231
459
  }
460
+ if (prop.format === 'email' && !this.isValidEmail(value)) {
461
+ return `Property '${key}' should be a valid email address.`
462
+ }
463
+ if (prop.format === 'uri' && !this.isValidUri(value)) {
464
+ return `Property '${key}' should be a valid URI.`
465
+ }
232
466
  if (prop.const && value !== prop.const) {
233
467
  return `Property '${key}' should be '${prop.const}' but is '${value}'.`
234
468
  }
@@ -285,6 +519,20 @@ export default {
285
519
  const d = new Date(v)
286
520
  return d instanceof Date && !Number.isNaN(d.getTime())
287
521
  },
522
+
523
+ isValidEmail(v) {
524
+ if (!v) return false
525
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)
526
+ },
527
+
528
+ isValidUri(v) {
529
+ if (!v) return false
530
+ try {
531
+ return !!new URL(v)
532
+ } catch {
533
+ return false
534
+ }
535
+ },
288
536
  },
289
537
  }
290
538
  </script>
@@ -293,7 +541,6 @@ export default {
293
541
  .cn-advanced-form-dialog__table-container {
294
542
  background: var(--color-main-background);
295
543
  border-radius: var(--border-radius);
296
- overflow: hidden;
297
544
  box-shadow: 0 2px 4px var(--color-box-shadow);
298
545
  border: 1px solid var(--color-border);
299
546
  margin-bottom: calc(5 * var(--default-grid-baseline));
@@ -313,6 +560,13 @@ export default {
313
560
  vertical-align: middle;
314
561
  }
315
562
 
563
+ /* Selected (editing) row: align the constrained label cell to the top of the
564
+ value cell so tall inputs (textarea, JSON editor) do not visually overflow
565
+ into the next row. */
566
+ .cn-advanced-form-dialog__table-row--selected td {
567
+ vertical-align: top;
568
+ }
569
+
316
570
  .cn-advanced-form-dialog__table th {
317
571
  background: var(--color-background-dark);
318
572
  font-weight: 500;
@@ -337,8 +591,7 @@ export default {
337
591
  }
338
592
 
339
593
  .cn-advanced-form-dialog__table-row--edited {
340
- background-color: var(--color-success-light);
341
- box-shadow: inset 3px 0 0 0 var(--color-success);
594
+ background-color: var(--color-warning-light);
342
595
  }
343
596
 
344
597
  .cn-advanced-form-dialog__table-row--non-editable {
@@ -351,22 +604,12 @@ export default {
351
604
  cursor: not-allowed !important;
352
605
  }
353
606
 
354
- .cn-advanced-form-dialog__table-row--valid {
355
- box-shadow: inset 3px 0 0 0 var(--color-success);
356
- }
357
-
358
607
  .cn-advanced-form-dialog__table-row--invalid {
359
608
  background-color: var(--color-error-light);
360
- box-shadow: inset 3px 0 0 0 var(--color-error);
361
609
  }
362
610
 
363
611
  .cn-advanced-form-dialog__table-row--warning {
364
612
  background-color: var(--color-warning-light);
365
- box-shadow: inset 3px 0 0 0 var(--color-warning);
366
- }
367
-
368
- .cn-advanced-form-dialog__table-row--new {
369
- box-shadow: inset 3px 0 0 0 var(--color-primary-element);
370
613
  }
371
614
 
372
615
  .cn-advanced-form-dialog__table-col-constrained {
@@ -382,10 +625,15 @@ export default {
382
625
  min-width: 200px;
383
626
  }
384
627
 
628
+ .cn-advanced-form-dialog__table-col-actions {
629
+ width: 56px;
630
+ text-align: right;
631
+ white-space: nowrap;
632
+ }
633
+
385
634
  .cn-advanced-form-dialog__prop-cell {
386
635
  width: 30%;
387
636
  font-weight: 600;
388
- box-shadow: inset 3px 0 0 0 var(--color-primary);
389
637
  }
390
638
 
391
639
  .cn-advanced-form-dialog__value-cell {
@@ -394,6 +642,10 @@ export default {
394
642
  border-radius: 4px;
395
643
  }
396
644
 
645
+ .cn-advanced-form-dialog__value-cell > * {
646
+ max-width: 100%;
647
+ }
648
+
397
649
  .cn-advanced-form-dialog__prop-cell-content {
398
650
  display: flex;
399
651
  align-items: center;
@@ -408,6 +660,10 @@ export default {
408
660
  color: var(--color-error);
409
661
  }
410
662
 
663
+ .cn-advanced-form-dialog__validation-icon--edited {
664
+ color: var(--color-warning);
665
+ }
666
+
411
667
  .cn-advanced-form-dialog__validation-icon--warning {
412
668
  color: var(--color-warning);
413
669
  }
@@ -419,4 +675,24 @@ export default {
419
675
  .cn-advanced-form-dialog__validation-icon--new {
420
676
  color: var(--color-primary-element);
421
677
  }
678
+
679
+ .cn-advanced-form-dialog__required-indicator {
680
+ color: var(--color-error-text);
681
+ font-weight: bold;
682
+ cursor: help;
683
+ }
684
+
685
+ .cn-advanced-form-dialog__immutable-badge {
686
+ display: inline-block;
687
+ padding: 1px 6px;
688
+ border-radius: 10px;
689
+ background: var(--color-background-dark);
690
+ color: var(--color-text-maxcontrast);
691
+ font-size: 0.75em;
692
+ font-weight: 500;
693
+ text-transform: uppercase;
694
+ letter-spacing: 0.02em;
695
+ cursor: help;
696
+ white-space: nowrap;
697
+ }
422
698
  </style>