@structured-field/widget-editor 1.1.0 → 1.2.0
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 +284 -0
- package/dist/structured-widget-editor.esm.js +621 -67
- package/dist/structured-widget-editor.esm.js.map +1 -1
- package/dist/structured-widget-editor.iife.js +4 -5
- package/dist/structured-widget-editor.js +4 -5
- package/dist/structured-widget-editor.js.map +1 -1
- package/package.json +1 -1
- package/src/BaseEditorElement.js +108 -0
- package/src/SchemaForm.vue +30 -0
- package/src/conditionals.js +248 -0
- package/src/editors/ArrayEditor.vue +1 -1
- package/src/editors/ObjectEditor.vue +24 -4
- package/src/editors/SchemaEditor.vue +25 -0
- package/src/editors/WebComponentWrapper.vue +55 -0
- package/src/index.js +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@structured-field/widget-editor",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "A lightweight JSON Schema form builder with support for relation fields (ForeignKey, QuerySet) and autocomplete search.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/structured-widget-editor.js",
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base class for creating web component custom editors.
|
|
3
|
+
*
|
|
4
|
+
* Handles the property contract with the structured-widget-editor wrapper:
|
|
5
|
+
* - Receives `schema`, `modelValue`, `path`, and `form` as JS properties.
|
|
6
|
+
* - Provides `emitChange(value)` to dispatch the value back to the form.
|
|
7
|
+
* - Provides `getErrors()` to retrieve validation errors for this field.
|
|
8
|
+
* - Calls `render()` once on `connectedCallback` and `update()` on property changes.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* import { BaseEditorElement } from '@structured-field/widget-editor';
|
|
12
|
+
*
|
|
13
|
+
* class MyColorPicker extends BaseEditorElement {
|
|
14
|
+
* render() {
|
|
15
|
+
* const input = document.createElement('input');
|
|
16
|
+
* input.type = 'color';
|
|
17
|
+
* input.value = this.modelValue || '#000000';
|
|
18
|
+
* input.addEventListener('input', () => this.emitChange(input.value));
|
|
19
|
+
* this._input = input;
|
|
20
|
+
* this.appendChild(input);
|
|
21
|
+
* }
|
|
22
|
+
*
|
|
23
|
+
* update() {
|
|
24
|
+
* if (this._input) this._input.value = this.modelValue || '#000000';
|
|
25
|
+
* }
|
|
26
|
+
* }
|
|
27
|
+
*
|
|
28
|
+
* customElements.define('my-color-picker', MyColorPicker);
|
|
29
|
+
*/
|
|
30
|
+
export class BaseEditorElement extends HTMLElement {
|
|
31
|
+
constructor() {
|
|
32
|
+
super();
|
|
33
|
+
this._schema = {};
|
|
34
|
+
this._modelValue = undefined;
|
|
35
|
+
this._path = [];
|
|
36
|
+
this._form = null;
|
|
37
|
+
this._connected = false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* ── Property contract ─────────────────────────────────────────────── */
|
|
41
|
+
|
|
42
|
+
get schema() { return this._schema; }
|
|
43
|
+
set schema(v) {
|
|
44
|
+
this._schema = v;
|
|
45
|
+
if (this._connected) this.update();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
get modelValue() { return this._modelValue; }
|
|
49
|
+
set modelValue(v) {
|
|
50
|
+
this._modelValue = v;
|
|
51
|
+
if (this._connected) this.update();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
get path() { return this._path; }
|
|
55
|
+
set path(v) {
|
|
56
|
+
this._path = v;
|
|
57
|
+
if (this._connected) this.update();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
get form() { return this._form; }
|
|
61
|
+
set form(v) {
|
|
62
|
+
this._form = v;
|
|
63
|
+
if (this._connected) this.update();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* ── Lifecycle ─────────────────────────────────────────────────────── */
|
|
67
|
+
|
|
68
|
+
connectedCallback() {
|
|
69
|
+
this._connected = true;
|
|
70
|
+
this.render();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
disconnectedCallback() {
|
|
74
|
+
this._connected = false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* ── Helpers ───────────────────────────────────────────────────────── */
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Dispatch the new value back to the form.
|
|
81
|
+
* @param {*} value - The new field value.
|
|
82
|
+
*/
|
|
83
|
+
emitChange(value) {
|
|
84
|
+
this.dispatchEvent(new CustomEvent('change', { detail: value }));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Returns the current validation errors for this field.
|
|
89
|
+
* @returns {string[]}
|
|
90
|
+
*/
|
|
91
|
+
getErrors() {
|
|
92
|
+
return this._form?.getErrorsForPath?.(this._path) ?? [];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* ── Override points ───────────────────────────────────────────────── */
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Called once when the element is connected to the DOM.
|
|
99
|
+
* Build the initial DOM structure here.
|
|
100
|
+
*/
|
|
101
|
+
render() {}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Called whenever a property (schema, modelValue, path, form) changes
|
|
105
|
+
* after the initial render. Update the DOM here.
|
|
106
|
+
*/
|
|
107
|
+
update() {}
|
|
108
|
+
}
|
package/src/SchemaForm.vue
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
<script>
|
|
15
15
|
import SchemaEditor from './editors/SchemaEditor.vue';
|
|
16
16
|
import { deepClone } from './utils';
|
|
17
|
+
import { applyConditionals, hasConditionals } from './conditionals';
|
|
17
18
|
|
|
18
19
|
export default {
|
|
19
20
|
name: 'SchemaForm',
|
|
@@ -22,9 +23,15 @@ export default {
|
|
|
22
23
|
schema: { type: [Object, String], default: () => ({}) },
|
|
23
24
|
initialData: { default: undefined },
|
|
24
25
|
errors: { type: Object, default: () => ({}) },
|
|
26
|
+
customEditors: { type: Array, default: () => [] },
|
|
25
27
|
},
|
|
26
28
|
emits: ['change'],
|
|
27
29
|
expose: ['getValue'],
|
|
30
|
+
provide() {
|
|
31
|
+
return {
|
|
32
|
+
customEditors: () => this.customEditors,
|
|
33
|
+
};
|
|
34
|
+
},
|
|
28
35
|
data() {
|
|
29
36
|
const parsedSchema = typeof this.schema === 'string' ? JSON.parse(this.schema) : this.schema;
|
|
30
37
|
const defs = parsedSchema.$defs || parsedSchema.definitions || {};
|
|
@@ -42,6 +49,7 @@ export default {
|
|
|
42
49
|
return {
|
|
43
50
|
resolveSchema: (s) => this.resolveSchema(s),
|
|
44
51
|
getSchemaAtPath: (p) => this.getSchemaAtPath(p),
|
|
52
|
+
getEffectiveSchemaAtPath: (p) => this.getEffectiveSchemaAtPath(p),
|
|
45
53
|
getErrorsForPath: (p) => this.getErrorsForPath(p),
|
|
46
54
|
};
|
|
47
55
|
},
|
|
@@ -124,6 +132,28 @@ export default {
|
|
|
124
132
|
return schema;
|
|
125
133
|
},
|
|
126
134
|
|
|
135
|
+
getEffectiveSchemaAtPath(path) {
|
|
136
|
+
let schema = this.resolveSchema(this.rootSchema);
|
|
137
|
+
let value = this.currentValue;
|
|
138
|
+
for (const segment of path) {
|
|
139
|
+
if (!schema) return null;
|
|
140
|
+
if (schema.properties || schema.if || schema.allOf || schema.dependentSchemas) {
|
|
141
|
+
if (hasConditionals(schema)) schema = applyConditionals(schema, value || {}, (s) => this.resolveSchema(s));
|
|
142
|
+
}
|
|
143
|
+
if (schema.properties && schema.properties[segment] !== undefined) {
|
|
144
|
+
schema = this.resolveSchema(schema.properties[segment]);
|
|
145
|
+
value = value != null ? value[segment] : undefined;
|
|
146
|
+
} else if (schema.items) {
|
|
147
|
+
schema = this.resolveSchema(schema.items);
|
|
148
|
+
value = Array.isArray(value) ? value[segment] : undefined;
|
|
149
|
+
} else {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (schema && hasConditionals(schema)) schema = applyConditionals(schema, value || {});
|
|
154
|
+
return schema;
|
|
155
|
+
},
|
|
156
|
+
|
|
127
157
|
onValueChange(val) {
|
|
128
158
|
this.currentValue = val;
|
|
129
159
|
this.$emit('change', val);
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// JSON Schema conditional evaluation for form rendering.
|
|
2
|
+
//
|
|
3
|
+
// Supports the standard keywords: `if`/`then`/`else`, `allOf` of those,
|
|
4
|
+
// `dependentSchemas`, and `dependentRequired`. The matcher implements the
|
|
5
|
+
// subset of JSON Schema validation that is meaningful for form-time
|
|
6
|
+
// conditionals on object properties:
|
|
7
|
+
//
|
|
8
|
+
// - `properties: { field: { const, enum, type, not } }`
|
|
9
|
+
// - `required: [...]` (treated as "key is present and not null/undefined")
|
|
10
|
+
// - `not`, `allOf`, `anyOf`, `oneOf` (recursive)
|
|
11
|
+
//
|
|
12
|
+
// The functions are pure: they take a schema + value and return an
|
|
13
|
+
// "effective schema" with `properties`/`required` merged from any matching
|
|
14
|
+
// branches. The renderer uses that effective schema instead of the raw one.
|
|
15
|
+
|
|
16
|
+
function isPresent(value, key) {
|
|
17
|
+
if (value == null || typeof value !== 'object') return false;
|
|
18
|
+
if (!(key in value)) return false;
|
|
19
|
+
const v = value[key];
|
|
20
|
+
return v !== undefined && v !== null && v !== '';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function matchesPropertyConstraint(value, constraint) {
|
|
24
|
+
if (!constraint || typeof constraint !== 'object') return true;
|
|
25
|
+
if ('const' in constraint) return value === constraint.const;
|
|
26
|
+
if (Array.isArray(constraint.enum)) return constraint.enum.includes(value);
|
|
27
|
+
if (constraint.type) {
|
|
28
|
+
const t = constraint.type;
|
|
29
|
+
if (t === 'string' && typeof value !== 'string') return false;
|
|
30
|
+
if (t === 'number' && typeof value !== 'number') return false;
|
|
31
|
+
if (t === 'integer' && (typeof value !== 'number' || !Number.isInteger(value))) return false;
|
|
32
|
+
if (t === 'boolean' && typeof value !== 'boolean') return false;
|
|
33
|
+
if (t === 'null' && value !== null) return false;
|
|
34
|
+
if (t === 'array' && !Array.isArray(value)) return false;
|
|
35
|
+
if (t === 'object' && (value == null || typeof value !== 'object' || Array.isArray(value))) return false;
|
|
36
|
+
}
|
|
37
|
+
// Numeric comparators
|
|
38
|
+
if (typeof value === 'number') {
|
|
39
|
+
if (typeof constraint.minimum === 'number' && value < constraint.minimum) return false;
|
|
40
|
+
if (typeof constraint.maximum === 'number' && value > constraint.maximum) return false;
|
|
41
|
+
if (typeof constraint.exclusiveMinimum === 'number' && value <= constraint.exclusiveMinimum) return false;
|
|
42
|
+
if (typeof constraint.exclusiveMaximum === 'number' && value >= constraint.exclusiveMaximum) return false;
|
|
43
|
+
if (typeof constraint.multipleOf === 'number' && constraint.multipleOf > 0) {
|
|
44
|
+
const q = value / constraint.multipleOf;
|
|
45
|
+
if (Math.abs(q - Math.round(q)) > 1e-9) return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// String comparators
|
|
49
|
+
if (typeof value === 'string') {
|
|
50
|
+
if (typeof constraint.minLength === 'number' && value.length < constraint.minLength) return false;
|
|
51
|
+
if (typeof constraint.maxLength === 'number' && value.length > constraint.maxLength) return false;
|
|
52
|
+
if (typeof constraint.pattern === 'string') {
|
|
53
|
+
try {
|
|
54
|
+
if (!new RegExp(constraint.pattern).test(value)) return false;
|
|
55
|
+
} catch (e) {
|
|
56
|
+
// Invalid pattern — treat as non-match rather than throwing in render path.
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (constraint.not) return !matchesSchema(value, constraint.not);
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Returns true if `value` (an object) satisfies the form-relevant subset of `schema`.
|
|
66
|
+
export function matchesSchema(value, schema) {
|
|
67
|
+
if (!schema || typeof schema !== 'object') return true;
|
|
68
|
+
|
|
69
|
+
if (Array.isArray(schema.required)) {
|
|
70
|
+
for (const k of schema.required) {
|
|
71
|
+
if (!isPresent(value, k)) return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (schema.properties && typeof schema.properties === 'object') {
|
|
76
|
+
for (const [k, constraint] of Object.entries(schema.properties)) {
|
|
77
|
+
// Standard JSON Schema: property constraints only apply if the key is present.
|
|
78
|
+
if (value == null || !(k in value)) continue;
|
|
79
|
+
if (!matchesPropertyConstraint(value[k], constraint)) return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (schema.not && matchesSchema(value, schema.not)) return false;
|
|
84
|
+
if (Array.isArray(schema.allOf) && !schema.allOf.every((s) => matchesSchema(value, s))) return false;
|
|
85
|
+
if (Array.isArray(schema.anyOf) && !schema.anyOf.some((s) => matchesSchema(value, s))) return false;
|
|
86
|
+
if (Array.isArray(schema.oneOf)) {
|
|
87
|
+
const matched = schema.oneOf.filter((s) => matchesSchema(value, s)).length;
|
|
88
|
+
if (matched !== 1) return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function insertAfter(properties, anchorKey, newEntries) {
|
|
95
|
+
// Rebuild the property map so newly-added keys appear immediately after
|
|
96
|
+
// their controlling field instead of being appended to the end.
|
|
97
|
+
const existingKeys = Object.keys(properties);
|
|
98
|
+
const anchorIdx = anchorKey ? existingKeys.indexOf(anchorKey) : -1;
|
|
99
|
+
if (anchorIdx === -1) {
|
|
100
|
+
const out = { ...properties };
|
|
101
|
+
for (const [k, v] of newEntries) out[k] = v;
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
const out = {};
|
|
105
|
+
for (let i = 0; i < existingKeys.length; i++) {
|
|
106
|
+
const key = existingKeys[i];
|
|
107
|
+
out[key] = properties[key];
|
|
108
|
+
if (i === anchorIdx) {
|
|
109
|
+
for (const [k, v] of newEntries) {
|
|
110
|
+
if (!(k in properties)) out[k] = v;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// Any new keys that already existed in properties have been kept in place;
|
|
115
|
+
// overwrite their values with the merged versions.
|
|
116
|
+
for (const [k, v] of newEntries) {
|
|
117
|
+
if (k in properties) out[k] = v;
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function mergeBranch(target, branch, anchorKey) {
|
|
123
|
+
if (!branch || typeof branch !== 'object') return target;
|
|
124
|
+
|
|
125
|
+
if (branch.properties) {
|
|
126
|
+
const merged = [];
|
|
127
|
+
for (const [k, v] of Object.entries(branch.properties)) {
|
|
128
|
+
const existing = target.properties && target.properties[k];
|
|
129
|
+
merged.push([k, existing ? { ...existing, ...v } : v]);
|
|
130
|
+
}
|
|
131
|
+
target.properties = insertAfter(target.properties || {}, anchorKey, merged);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (Array.isArray(branch.required)) {
|
|
135
|
+
const set = new Set(target.required || []);
|
|
136
|
+
for (const k of branch.required) set.add(k);
|
|
137
|
+
target.required = Array.from(set);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Conditionals can also nest more conditionals — flatten them.
|
|
141
|
+
if (branch.allOf) {
|
|
142
|
+
target.allOf = [...(target.allOf || []), ...branch.allOf];
|
|
143
|
+
}
|
|
144
|
+
if (branch.if) {
|
|
145
|
+
target.allOf = [...(target.allOf || []), { if: branch.if, then: branch.then, else: branch.else }];
|
|
146
|
+
}
|
|
147
|
+
if (branch.dependentSchemas) {
|
|
148
|
+
target.dependentSchemas = { ...(target.dependentSchemas || {}), ...branch.dependentSchemas };
|
|
149
|
+
}
|
|
150
|
+
if (branch.dependentRequired) {
|
|
151
|
+
target.dependentRequired = { ...(target.dependentRequired || {}), ...branch.dependentRequired };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return target;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Returns an effective schema for an object schema given the current value.
|
|
158
|
+
// Resolves `if/then/else`, `allOf` of those, `dependentSchemas`, and
|
|
159
|
+
// `dependentRequired`. Idempotent and safe to call on every render.
|
|
160
|
+
//
|
|
161
|
+
// `resolver` (optional) is called on each `then`/`else`/`dependentSchemas`
|
|
162
|
+
// branch before merging, so `$ref` inside conditional branches is followed.
|
|
163
|
+
// Pass `form.resolveSchema` from the renderer.
|
|
164
|
+
export function applyConditionals(schema, value, resolver) {
|
|
165
|
+
const resolve = typeof resolver === 'function' ? resolver : (s) => s;
|
|
166
|
+
if (!schema || typeof schema !== 'object') return schema;
|
|
167
|
+
if (schema.type !== 'object' && !schema.properties) return schema;
|
|
168
|
+
|
|
169
|
+
// Start with a shallow clone of the parts we may mutate.
|
|
170
|
+
let effective = {
|
|
171
|
+
...schema,
|
|
172
|
+
properties: { ...(schema.properties || {}) },
|
|
173
|
+
required: Array.isArray(schema.required) ? [...schema.required] : [],
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const safeValue = value && typeof value === 'object' ? value : {};
|
|
177
|
+
|
|
178
|
+
// Pick the controlling field of an `if` clause so newly-added properties
|
|
179
|
+
// can be inserted right after it in render order.
|
|
180
|
+
const anchorOf = (ifClause) => {
|
|
181
|
+
if (!ifClause || typeof ifClause !== 'object') return null;
|
|
182
|
+
const props = ifClause.properties && Object.keys(ifClause.properties);
|
|
183
|
+
if (props && props.length) return props[0];
|
|
184
|
+
if (Array.isArray(ifClause.required) && ifClause.required.length) return ifClause.required[0];
|
|
185
|
+
return null;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// Collect rules: top-level if/then/else + every entry in allOf that has one.
|
|
189
|
+
const rules = [];
|
|
190
|
+
if (effective.if) {
|
|
191
|
+
rules.push({ if: effective.if, then: effective.then, else: effective.else, anchor: anchorOf(effective.if) });
|
|
192
|
+
}
|
|
193
|
+
if (Array.isArray(effective.allOf)) {
|
|
194
|
+
for (const entry of effective.allOf) {
|
|
195
|
+
if (entry && typeof entry === 'object' && entry.if) {
|
|
196
|
+
rules.push({ if: entry.if, then: entry.then, else: entry.else, anchor: anchorOf(entry.if) });
|
|
197
|
+
} else if (entry && typeof entry === 'object' && (entry.properties || entry.required)) {
|
|
198
|
+
// Plain allOf branch (e.g. shared base) — always merge.
|
|
199
|
+
mergeBranch(effective, entry);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Iterate to a fixed point so newly-merged rules can themselves trigger further rules.
|
|
205
|
+
// Capped to avoid pathological loops.
|
|
206
|
+
for (let i = 0; i < 8; i++) {
|
|
207
|
+
let changed = false;
|
|
208
|
+
const before = JSON.stringify({ p: effective.properties, r: effective.required });
|
|
209
|
+
|
|
210
|
+
for (const rule of rules) {
|
|
211
|
+
const matched = matchesSchema(safeValue, rule.if);
|
|
212
|
+
const branch = matched ? rule.then : rule.else;
|
|
213
|
+
if (branch) mergeBranch(effective, resolve(branch), rule.anchor);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (effective.dependentSchemas) {
|
|
217
|
+
for (const [key, branch] of Object.entries(effective.dependentSchemas)) {
|
|
218
|
+
if (isPresent(safeValue, key)) mergeBranch(effective, resolve(branch), key);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (effective.dependentRequired) {
|
|
223
|
+
for (const [key, requiredKeys] of Object.entries(effective.dependentRequired)) {
|
|
224
|
+
if (isPresent(safeValue, key) && Array.isArray(requiredKeys)) {
|
|
225
|
+
const set = new Set(effective.required || []);
|
|
226
|
+
for (const k of requiredKeys) set.add(k);
|
|
227
|
+
effective.required = Array.from(set);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const after = JSON.stringify({ p: effective.properties, r: effective.required });
|
|
233
|
+
if (after !== before) changed = true;
|
|
234
|
+
if (!changed) break;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return effective;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Returns true if the schema declares any form-relevant conditional logic.
|
|
241
|
+
export function hasConditionals(schema) {
|
|
242
|
+
if (!schema || typeof schema !== 'object') return false;
|
|
243
|
+
if (schema.if || schema.dependentSchemas || schema.dependentRequired) return true;
|
|
244
|
+
if (Array.isArray(schema.allOf)) {
|
|
245
|
+
return schema.allOf.some((e) => e && typeof e === 'object' && (e.if || e.dependentSchemas));
|
|
246
|
+
}
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
<button v-if="index > 0" type="button" class="sf-btn sf-btn-sm" @click="moveItem(index, -1)">
|
|
33
33
|
<SfIcon name="arrow-up" />
|
|
34
34
|
</button>
|
|
35
|
-
<button type="button" class="sf-btn sf-btn-sm" @click="moveItem(index, 1)">
|
|
35
|
+
<button v-if="index < items.length - 1" type="button" class="sf-btn sf-btn-sm" @click="moveItem(index, 1)">
|
|
36
36
|
<SfIcon name="arrow-down" />
|
|
37
37
|
</button>
|
|
38
38
|
<button type="button" class="sf-btn sf-btn-sm sf-btn-danger" @click="removeItem(index)">
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
<div v-if="isRoot" class="sf-object sf-object-root">
|
|
3
3
|
<div class="sf-object-fields">
|
|
4
4
|
<SchemaEditor
|
|
5
|
-
v-for="(propSchema, key) in (
|
|
5
|
+
v-for="(propSchema, key) in (effectiveSchema.properties || {})"
|
|
6
6
|
:key="key"
|
|
7
7
|
:schema="form.resolveSchema(propSchema)"
|
|
8
8
|
:model-value="(modelValue || {})[key]"
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
</legend>
|
|
23
23
|
<div v-show="!collapsed" class="sf-object-fields">
|
|
24
24
|
<SchemaEditor
|
|
25
|
-
v-for="(propSchema, key) in (
|
|
25
|
+
v-for="(propSchema, key) in (effectiveSchema.properties || {})"
|
|
26
26
|
:key="key"
|
|
27
27
|
:schema="form.resolveSchema(propSchema)"
|
|
28
28
|
:model-value="(modelValue || {})[key]"
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
<script>
|
|
38
38
|
import SchemaEditor from './SchemaEditor.vue';
|
|
39
39
|
import SfIcon from './SfIcon.vue';
|
|
40
|
+
import { applyConditionals, hasConditionals } from '../conditionals';
|
|
40
41
|
|
|
41
42
|
export default {
|
|
42
43
|
name: 'ObjectEditor',
|
|
@@ -64,10 +65,14 @@ export default {
|
|
|
64
65
|
title() {
|
|
65
66
|
return this.schema.title || this.humanize(this.path[this.path.length - 1]) || '';
|
|
66
67
|
},
|
|
68
|
+
effectiveSchema() {
|
|
69
|
+
if (!hasConditionals(this.schema)) return this.schema;
|
|
70
|
+
return applyConditionals(this.schema, this.modelValue || {}, this.form?.resolveSchema);
|
|
71
|
+
},
|
|
67
72
|
summary() {
|
|
68
73
|
const val = this.modelValue || {};
|
|
69
74
|
const parts = [];
|
|
70
|
-
for (const key of Object.keys(this.
|
|
75
|
+
for (const key of Object.keys(this.effectiveSchema.properties || {})) {
|
|
71
76
|
if (parts.length >= 3) break;
|
|
72
77
|
const v = val[key];
|
|
73
78
|
if (v !== null && v !== undefined && v !== '' && typeof v !== 'object') {
|
|
@@ -90,7 +95,22 @@ export default {
|
|
|
90
95
|
},
|
|
91
96
|
onChildChange(key, value) {
|
|
92
97
|
const newVal = { ...(this.modelValue || {}), [key]: value };
|
|
93
|
-
this.$emit('update:modelValue', newVal);
|
|
98
|
+
this.$emit('update:modelValue', this.pruneInactive(newVal));
|
|
99
|
+
},
|
|
100
|
+
pruneInactive(value) {
|
|
101
|
+
if (!hasConditionals(this.schema)) return value;
|
|
102
|
+
const effective = applyConditionals(this.schema, value, this.form?.resolveSchema);
|
|
103
|
+
const allowed = new Set(Object.keys(effective.properties || {}));
|
|
104
|
+
let changed = false;
|
|
105
|
+
const out = {};
|
|
106
|
+
for (const k of Object.keys(value)) {
|
|
107
|
+
if (allowed.has(k)) {
|
|
108
|
+
out[k] = value[k];
|
|
109
|
+
} else {
|
|
110
|
+
changed = true;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return changed ? out : value;
|
|
94
114
|
},
|
|
95
115
|
},
|
|
96
116
|
};
|
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
<template>
|
|
2
|
+
<WebComponentWrapper
|
|
3
|
+
v-if="isWebComponent"
|
|
4
|
+
ref="editor"
|
|
5
|
+
:tag-name="editorComponent"
|
|
6
|
+
:schema="schema"
|
|
7
|
+
:model-value="modelValue"
|
|
8
|
+
:path="path"
|
|
9
|
+
:form="form"
|
|
10
|
+
@update:model-value="$emit('update:modelValue', $event)"
|
|
11
|
+
/>
|
|
2
12
|
<component
|
|
13
|
+
v-else
|
|
3
14
|
ref="editor"
|
|
4
15
|
:is="editorComponent"
|
|
5
16
|
:schema="schema"
|
|
@@ -21,6 +32,7 @@ import ArrayEditor from './ArrayEditor.vue';
|
|
|
21
32
|
import NullableEditor from './NullableEditor.vue';
|
|
22
33
|
import UnionEditor from './UnionEditor.vue';
|
|
23
34
|
import RelationEditor from './RelationEditor.vue';
|
|
35
|
+
import WebComponentWrapper from './WebComponentWrapper.vue';
|
|
24
36
|
|
|
25
37
|
const MAX_DEPTH = 12;
|
|
26
38
|
|
|
@@ -37,6 +49,10 @@ export default {
|
|
|
37
49
|
NullableEditor,
|
|
38
50
|
UnionEditor,
|
|
39
51
|
RelationEditor,
|
|
52
|
+
WebComponentWrapper,
|
|
53
|
+
},
|
|
54
|
+
inject: {
|
|
55
|
+
customEditors: { default: () => () => [] },
|
|
40
56
|
},
|
|
41
57
|
props: {
|
|
42
58
|
schema: { type: Object, required: true },
|
|
@@ -58,11 +74,20 @@ export default {
|
|
|
58
74
|
},
|
|
59
75
|
},
|
|
60
76
|
computed: {
|
|
77
|
+
isWebComponent() {
|
|
78
|
+
const c = this.editorComponent;
|
|
79
|
+
return typeof c === 'string' && c.includes('-');
|
|
80
|
+
},
|
|
61
81
|
editorComponent() {
|
|
62
82
|
const schema = this.schema;
|
|
63
83
|
|
|
64
84
|
if (this.path.length > MAX_DEPTH) return 'StringEditor';
|
|
65
85
|
|
|
86
|
+
const overrides = this.customEditors();
|
|
87
|
+
for (const override of overrides) {
|
|
88
|
+
if (override.match(schema, this.path)) return override.component;
|
|
89
|
+
}
|
|
90
|
+
|
|
66
91
|
if (schema.type === 'relation') return 'RelationEditor';
|
|
67
92
|
if (schema.oneOf && schema.discriminator) return 'UnionEditor';
|
|
68
93
|
if ('const' in schema) return 'HiddenEditor';
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import { h, ref, watch, onMounted, onBeforeUnmount } from 'vue';
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
name: 'WebComponentWrapper',
|
|
6
|
+
props: {
|
|
7
|
+
tagName: { type: String, required: true },
|
|
8
|
+
schema: { type: Object, required: true },
|
|
9
|
+
modelValue: { default: undefined },
|
|
10
|
+
path: { type: Array, default: () => [] },
|
|
11
|
+
form: { type: Object, required: true },
|
|
12
|
+
},
|
|
13
|
+
emits: ['update:modelValue'],
|
|
14
|
+
setup(props, { emit }) {
|
|
15
|
+
const elRef = ref(null);
|
|
16
|
+
|
|
17
|
+
function syncProps() {
|
|
18
|
+
const el = elRef.value;
|
|
19
|
+
if (!el) return;
|
|
20
|
+
el.schema = props.schema;
|
|
21
|
+
el.modelValue = props.modelValue;
|
|
22
|
+
el.path = props.path;
|
|
23
|
+
el.form = props.form;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function handleChange(e) {
|
|
27
|
+
const value = e.detail != null
|
|
28
|
+
? (Array.isArray(e.detail) ? e.detail[0] : e.detail)
|
|
29
|
+
: undefined;
|
|
30
|
+
emit('update:modelValue', value);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
onMounted(() => {
|
|
34
|
+
syncProps();
|
|
35
|
+
const el = elRef.value;
|
|
36
|
+
if (el) {
|
|
37
|
+
el.addEventListener('update:model-value', handleChange);
|
|
38
|
+
el.addEventListener('change', handleChange);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
onBeforeUnmount(() => {
|
|
43
|
+
const el = elRef.value;
|
|
44
|
+
if (el) {
|
|
45
|
+
el.removeEventListener('update:model-value', handleChange);
|
|
46
|
+
el.removeEventListener('change', handleChange);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
watch(() => [props.schema, props.modelValue, props.path, props.form], syncProps, { deep: true });
|
|
51
|
+
|
|
52
|
+
return () => h(props.tagName, { ref: elRef });
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
</script>
|
package/src/index.js
CHANGED
|
@@ -12,6 +12,9 @@ export { default as ArrayEditor } from './editors/ArrayEditor.vue';
|
|
|
12
12
|
export { default as NullableEditor } from './editors/NullableEditor.vue';
|
|
13
13
|
export { default as UnionEditor } from './editors/UnionEditor.vue';
|
|
14
14
|
export { default as RelationEditor } from './editors/RelationEditor.vue';
|
|
15
|
+
export { default as WebComponentWrapper } from './editors/WebComponentWrapper.vue';
|
|
16
|
+
export { BaseEditorElement } from './BaseEditorElement.js';
|
|
17
|
+
export { applyConditionals, matchesSchema, hasConditionals } from './conditionals.js';
|
|
15
18
|
|
|
16
19
|
import { defineCustomElement } from 'vue';
|
|
17
20
|
import SchemaFormComponent from './SchemaForm.vue';
|