@structured-field/widget-editor 1.2.1 → 1.3.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 +1 -1
- package/dist/structured-widget-editor.css +1 -1
- package/dist/structured-widget-editor.esm.js +757 -235
- package/dist/structured-widget-editor.esm.js.map +1 -1
- package/dist/structured-widget-editor.iife.js +6 -6
- package/dist/structured-widget-editor.js +5 -5
- package/dist/structured-widget-editor.js.map +1 -1
- package/package.json +3 -1
- package/src/SchemaForm.vue +20 -13
- package/src/editors/BooleanEditor.vue +19 -9
- package/src/editors/DateEditor.vue +95 -0
- package/src/editors/JsonEditor.vue +173 -0
- package/src/editors/NullableEditor.vue +1 -1
- package/src/editors/NumberEditor.vue +23 -10
- package/src/editors/ObjectEditor.vue +14 -0
- package/src/editors/RelationEditor.vue +1 -1
- package/src/editors/SchemaEditor.vue +11 -1
- package/src/editors/SelectEditor.vue +46 -10
- package/src/editors/StringEditor.vue +29 -17
- package/src/editors/UnionEditor.vue +1 -1
- package/src/index.js +1 -0
- package/src/scss/components/editors.scss +114 -0
- package/src/utils.js +18 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@structured-field/widget-editor",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.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",
|
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
"scripts": {
|
|
28
28
|
"build": "rollup -c && node scripts/bundle-iife.mjs",
|
|
29
29
|
"dev": "rollup -c -w",
|
|
30
|
+
"test": "vitest run",
|
|
31
|
+
"test:watch": "vitest",
|
|
30
32
|
"test:serve": "vite --open"
|
|
31
33
|
},
|
|
32
34
|
"keywords": [
|
package/src/SchemaForm.vue
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
<script>
|
|
15
15
|
import SchemaEditor from './editors/SchemaEditor.vue';
|
|
16
|
-
import { deepClone } from './utils';
|
|
16
|
+
import { deepClone, isChoiceOneOf } from './utils';
|
|
17
17
|
import { applyConditionals, hasConditionals } from './conditionals';
|
|
18
18
|
|
|
19
19
|
export default {
|
|
@@ -89,29 +89,36 @@ export default {
|
|
|
89
89
|
const hasNull = schema.anyOf.some(s => s.type === 'null');
|
|
90
90
|
if (hasNull && nonNull.length === 1) {
|
|
91
91
|
const resolved = this.resolveSchema(nonNull[0]);
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
};
|
|
92
|
+
// Carry ALL sibling keys through the nullable collapse: pydantic
|
|
93
|
+
// emits json_schema_extra (placeholder, minLength/maxLength,
|
|
94
|
+
// minimum/maximum, format, choice oneOf, ...) as SIBLINGS of
|
|
95
|
+
// anyOf on Optional fields. Outer keys override the inner branch.
|
|
96
|
+
const { anyOf, oneOf, ...rest } = schema;
|
|
97
|
+
const out = { ...resolved, ...rest, _nullable: true };
|
|
98
|
+
if (!('default' in schema)) out.default = null;
|
|
99
|
+
if (isChoiceOneOf(oneOf)) out.oneOf = oneOf;
|
|
100
|
+
return out;
|
|
98
101
|
}
|
|
99
102
|
if (nonNull.length >= 1) return this.resolveSchema(nonNull[0]);
|
|
100
103
|
}
|
|
101
104
|
|
|
102
105
|
if (schema.oneOf && schema.discriminator) return schema;
|
|
103
106
|
|
|
107
|
+
// A oneOf of {const, title} value options is a choice list (select),
|
|
108
|
+
// not alternative sub-schemas: keep it intact for SelectEditor instead
|
|
109
|
+
// of collapsing to the first member (which routed to HiddenEditor and
|
|
110
|
+
// overwrote stored values on mount).
|
|
111
|
+
if (isChoiceOneOf(schema.oneOf)) return schema;
|
|
112
|
+
|
|
104
113
|
if (schema.oneOf) {
|
|
105
114
|
const nonNull = schema.oneOf.filter(s => s.type !== 'null');
|
|
106
115
|
const hasNull = schema.oneOf.some(s => s.type === 'null');
|
|
107
116
|
if (hasNull && nonNull.length === 1) {
|
|
108
117
|
const resolved = this.resolveSchema(nonNull[0]);
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
default: 'default' in schema ? schema.default : null,
|
|
114
|
-
};
|
|
118
|
+
const { anyOf, oneOf, ...rest } = schema;
|
|
119
|
+
const out = { ...resolved, ...rest, _nullable: true };
|
|
120
|
+
if (!('default' in schema)) out.default = null;
|
|
121
|
+
return out;
|
|
115
122
|
}
|
|
116
123
|
if (nonNull.length >= 1) return this.resolveSchema(nonNull[0]);
|
|
117
124
|
}
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div class="sf-field sf-field-boolean" :class="{ errors: fieldErrors.length }">
|
|
3
|
-
<
|
|
4
|
-
<
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
3
|
+
<div class="sf-boolean-row">
|
|
4
|
+
<label class="sf-checkbox-label">
|
|
5
|
+
<input
|
|
6
|
+
type="checkbox"
|
|
7
|
+
class="sf-checkbox"
|
|
8
|
+
:checked="!!modelValue"
|
|
9
|
+
@change="$emit('update:modelValue', $event.target.checked)"
|
|
10
|
+
/>
|
|
11
|
+
{{ title }}
|
|
12
|
+
<span v-if="isNullable && isNullValue" class="sf-null-badge">null</span>
|
|
13
|
+
</label>
|
|
14
|
+
<button v-if="isNullable && !isNullValue" type="button" class="sf-null-clear-btn" title="Set to null" @click="$emit('update:modelValue', null)">✕</button>
|
|
15
|
+
</div>
|
|
12
16
|
<ul v-if="fieldErrors.length" class="errorlist">
|
|
13
17
|
<li v-for="(err, i) in fieldErrors" :key="i">{{ err }}</li>
|
|
14
18
|
</ul>
|
|
@@ -29,6 +33,12 @@ export default {
|
|
|
29
33
|
title() {
|
|
30
34
|
return this.schema.title || this.humanize(this.path[this.path.length - 1]) || '';
|
|
31
35
|
},
|
|
36
|
+
isNullable() {
|
|
37
|
+
return !!this.schema._nullable;
|
|
38
|
+
},
|
|
39
|
+
isNullValue() {
|
|
40
|
+
return this.modelValue === null || this.modelValue === undefined;
|
|
41
|
+
},
|
|
32
42
|
fieldErrors() {
|
|
33
43
|
if (!this.form || !this.form.getErrorsForPath) return [];
|
|
34
44
|
return this.form.getErrorsForPath(this.path);
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="sf-field" :class="{ errors: fieldErrors.length }">
|
|
3
|
+
<span class="sf-label" :class="{ required: isRequired }">
|
|
4
|
+
{{ title }}
|
|
5
|
+
<span v-if="isNullable && isNullValue" class="sf-null-badge">null</span>
|
|
6
|
+
</span>
|
|
7
|
+
<div :class="isNullable ? 'sf-input-row' : null">
|
|
8
|
+
<input
|
|
9
|
+
:type="inputType"
|
|
10
|
+
class="sf-input"
|
|
11
|
+
:value="displayValue"
|
|
12
|
+
:placeholder="isNullValue ? 'null' : (schema.placeholder || '')"
|
|
13
|
+
:min="schema.minimum || schema.formatMinimum || null"
|
|
14
|
+
:max="schema.maximum || schema.formatMaximum || null"
|
|
15
|
+
@input="onInput"
|
|
16
|
+
/>
|
|
17
|
+
<button v-if="isNullable && !isNullValue" type="button" class="sf-null-clear-btn" title="Set to null" @click="$emit('update:modelValue', null)">✕</button>
|
|
18
|
+
</div>
|
|
19
|
+
<ul v-if="fieldErrors.length" class="errorlist">
|
|
20
|
+
<li v-for="(err, i) in fieldErrors" :key="i">{{ err }}</li>
|
|
21
|
+
</ul>
|
|
22
|
+
</div>
|
|
23
|
+
</template>
|
|
24
|
+
|
|
25
|
+
<script>
|
|
26
|
+
export default {
|
|
27
|
+
name: 'DateEditor',
|
|
28
|
+
props: {
|
|
29
|
+
schema: { type: Object, required: true },
|
|
30
|
+
modelValue: { default: '' },
|
|
31
|
+
path: { type: Array, default: () => [] },
|
|
32
|
+
form: { type: Object, default: null },
|
|
33
|
+
},
|
|
34
|
+
emits: ['update:modelValue'],
|
|
35
|
+
computed: {
|
|
36
|
+
isDateTime() {
|
|
37
|
+
return this.schema.format === 'date-time';
|
|
38
|
+
},
|
|
39
|
+
inputType() {
|
|
40
|
+
return this.isDateTime ? 'datetime-local' : 'date';
|
|
41
|
+
},
|
|
42
|
+
displayValue() {
|
|
43
|
+
if (this.isNullValue) return '';
|
|
44
|
+
const v = String(this.modelValue);
|
|
45
|
+
if (this.isDateTime) {
|
|
46
|
+
// Accept ISO 8601 strings like "2026-04-09T10:30:00[.sss][Z|+00:00]"
|
|
47
|
+
// datetime-local expects "YYYY-MM-DDTHH:mm" (or with seconds).
|
|
48
|
+
const match = v.match(/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2})(?::(\d{2}(?:\.\d+)?))?/);
|
|
49
|
+
if (match) {
|
|
50
|
+
return match[3] ? `${match[1]}T${match[2]}:${match[3]}` : `${match[1]}T${match[2]}`;
|
|
51
|
+
}
|
|
52
|
+
return v;
|
|
53
|
+
}
|
|
54
|
+
// date: expect "YYYY-MM-DD"
|
|
55
|
+
return v.slice(0, 10);
|
|
56
|
+
},
|
|
57
|
+
title() {
|
|
58
|
+
return this.schema.title || this.humanize(this.path[this.path.length - 1]) || '';
|
|
59
|
+
},
|
|
60
|
+
isRequired() {
|
|
61
|
+
if (this.path.length < 2 || !this.form) return false;
|
|
62
|
+
const parentPath = this.path.slice(0, -1);
|
|
63
|
+
const fieldName = this.path[this.path.length - 1];
|
|
64
|
+
const parentSchema = this.form.getSchemaAtPath(parentPath);
|
|
65
|
+
return parentSchema && Array.isArray(parentSchema.required) && parentSchema.required.includes(fieldName);
|
|
66
|
+
},
|
|
67
|
+
isNullable() {
|
|
68
|
+
return !!this.schema._nullable;
|
|
69
|
+
},
|
|
70
|
+
isNullValue() {
|
|
71
|
+
return this.modelValue === null || this.modelValue === undefined;
|
|
72
|
+
},
|
|
73
|
+
fieldErrors() {
|
|
74
|
+
if (!this.form || !this.form.getErrorsForPath) return [];
|
|
75
|
+
return this.form.getErrorsForPath(this.path);
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
methods: {
|
|
79
|
+
onInput(e) {
|
|
80
|
+
const val = e.target.value;
|
|
81
|
+
if (val === '') {
|
|
82
|
+
this.$emit('update:modelValue', this.isNullable ? null : '');
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
// Emit ISO-compatible string. Pydantic accepts both "YYYY-MM-DD"
|
|
86
|
+
// and "YYYY-MM-DDTHH:mm[:ss]" for date / datetime fields.
|
|
87
|
+
this.$emit('update:modelValue', val);
|
|
88
|
+
},
|
|
89
|
+
humanize(str) {
|
|
90
|
+
if (!str) return '';
|
|
91
|
+
return str.replace(/_/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, s => s.toUpperCase());
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
</script>
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="sf-field sf-field-json" :class="{ errors: fieldErrors.length }">
|
|
3
|
+
<span class="sf-label" :class="{ required: isRequired }">{{ title }}</span>
|
|
4
|
+
<div class="sf-json-editor" :class="{ 'sf-json-error': hasErrors }">
|
|
5
|
+
<div class="sf-json-toolbar">
|
|
6
|
+
<span v-if="loadError" class="sf-json-error-msg">{{ loadError }}</span>
|
|
7
|
+
<button v-if="ready" type="button" class="sf-btn sf-btn-sm sf-json-format-btn" @click="format">
|
|
8
|
+
Format
|
|
9
|
+
</button>
|
|
10
|
+
</div>
|
|
11
|
+
<div ref="aceContainer" class="sf-json-ace-container" />
|
|
12
|
+
<textarea
|
|
13
|
+
v-if="!ready"
|
|
14
|
+
class="sf-input sf-textarea sf-json-textarea-fallback"
|
|
15
|
+
:value="rawValue"
|
|
16
|
+
spellcheck="false"
|
|
17
|
+
@input="onFallbackInput"
|
|
18
|
+
/>
|
|
19
|
+
</div>
|
|
20
|
+
<ul v-if="fieldErrors.length" class="errorlist">
|
|
21
|
+
<li v-for="(err, i) in fieldErrors" :key="i">{{ err }}</li>
|
|
22
|
+
</ul>
|
|
23
|
+
</div>
|
|
24
|
+
</template>
|
|
25
|
+
|
|
26
|
+
<script>
|
|
27
|
+
const ACE_CDN = 'https://cdnjs.cloudflare.com/ajax/libs/ace/1.37.5/ace.min.js';
|
|
28
|
+
|
|
29
|
+
function loadAce() {
|
|
30
|
+
if (window.ace) return Promise.resolve(window.ace);
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
const existing = document.querySelector(`script[src="${ACE_CDN}"]`);
|
|
33
|
+
if (existing) {
|
|
34
|
+
existing.addEventListener('load', () => resolve(window.ace));
|
|
35
|
+
existing.addEventListener('error', reject);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const s = document.createElement('script');
|
|
39
|
+
s.src = ACE_CDN;
|
|
40
|
+
s.async = true;
|
|
41
|
+
s.onload = () => resolve(window.ace);
|
|
42
|
+
s.onerror = () => reject(new Error('Failed to load Ace Editor'));
|
|
43
|
+
document.head.appendChild(s);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export default {
|
|
48
|
+
name: 'JsonEditor',
|
|
49
|
+
props: {
|
|
50
|
+
schema: { type: Object, required: true },
|
|
51
|
+
modelValue: { default: null },
|
|
52
|
+
path: { type: Array, default: () => [] },
|
|
53
|
+
form: { type: Object, default: null },
|
|
54
|
+
},
|
|
55
|
+
emits: ['update:modelValue'],
|
|
56
|
+
data() {
|
|
57
|
+
return {
|
|
58
|
+
rawValue: this.modelValue != null ? JSON.stringify(this.modelValue, null, 2) : '{}',
|
|
59
|
+
ready: false,
|
|
60
|
+
hasErrors: false,
|
|
61
|
+
loadError: null,
|
|
62
|
+
_editor: null,
|
|
63
|
+
_silent: false,
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
computed: {
|
|
67
|
+
title() {
|
|
68
|
+
return this.schema.title || this.humanize(this.path[this.path.length - 1]) || '';
|
|
69
|
+
},
|
|
70
|
+
isRequired() {
|
|
71
|
+
if (this.path.length < 2 || !this.form) return false;
|
|
72
|
+
const parentPath = this.path.slice(0, -1);
|
|
73
|
+
const fieldName = this.path[this.path.length - 1];
|
|
74
|
+
const parentSchema = this.form.getSchemaAtPath(parentPath);
|
|
75
|
+
return parentSchema && Array.isArray(parentSchema.required) && parentSchema.required.includes(fieldName);
|
|
76
|
+
},
|
|
77
|
+
fieldErrors() {
|
|
78
|
+
if (!this.form || !this.form.getErrorsForPath) return [];
|
|
79
|
+
return this.form.getErrorsForPath(this.path);
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
watch: {
|
|
83
|
+
modelValue(val) {
|
|
84
|
+
if (this._silent) return;
|
|
85
|
+
const external = val != null ? JSON.stringify(val, null, 2) : '{}';
|
|
86
|
+
try {
|
|
87
|
+
if (JSON.stringify(JSON.parse(this.rawValue)) !== JSON.stringify(val)) {
|
|
88
|
+
this.rawValue = external;
|
|
89
|
+
if (this._editor) {
|
|
90
|
+
this._silent = true;
|
|
91
|
+
this._editor.setValue(external, -1);
|
|
92
|
+
this._silent = false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
} catch { /* rawValue is invalid, leave it */ }
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
async mounted() {
|
|
99
|
+
try {
|
|
100
|
+
const ace = await loadAce();
|
|
101
|
+
const container = this.$refs.aceContainer;
|
|
102
|
+
if (!container) return;
|
|
103
|
+
|
|
104
|
+
const editor = ace.edit(container);
|
|
105
|
+
editor.setTheme(this._isDark() ? 'ace/theme/one_dark' : 'ace/theme/chrome');
|
|
106
|
+
editor.session.setMode('ace/mode/json');
|
|
107
|
+
editor.session.setTabSize(2);
|
|
108
|
+
editor.session.setUseSoftTabs(true);
|
|
109
|
+
editor.setShowPrintMargin(false);
|
|
110
|
+
editor.setOption('minLines', 5);
|
|
111
|
+
editor.setOption('maxLines', 20);
|
|
112
|
+
editor.setValue(this.rawValue, -1);
|
|
113
|
+
editor.$blockScrolling = Infinity;
|
|
114
|
+
|
|
115
|
+
editor.session.on('change', () => {
|
|
116
|
+
if (this._silent) return;
|
|
117
|
+
const text = editor.getValue();
|
|
118
|
+
this.rawValue = text;
|
|
119
|
+
try {
|
|
120
|
+
const parsed = JSON.parse(text);
|
|
121
|
+
this.hasErrors = false;
|
|
122
|
+
this._silent = true;
|
|
123
|
+
this.$emit('update:modelValue', parsed);
|
|
124
|
+
this._silent = false;
|
|
125
|
+
} catch {
|
|
126
|
+
this.hasErrors = true;
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// Reflect annotation (lint) errors on hasErrors
|
|
131
|
+
editor.session.on('changeAnnotation', () => {
|
|
132
|
+
const annotations = editor.session.getAnnotations();
|
|
133
|
+
this.hasErrors = annotations.some(a => a.type === 'error');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
this._editor = editor;
|
|
137
|
+
this.ready = true;
|
|
138
|
+
} catch (err) {
|
|
139
|
+
this.loadError = `Editor unavailable: ${err.message}`;
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
beforeUnmount() {
|
|
143
|
+
if (this._editor) {
|
|
144
|
+
this._editor.destroy();
|
|
145
|
+
this._editor = null;
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
methods: {
|
|
149
|
+
humanize(str) {
|
|
150
|
+
if (!str) return '';
|
|
151
|
+
return str.replace(/_/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, s => s.toUpperCase());
|
|
152
|
+
},
|
|
153
|
+
_isDark() {
|
|
154
|
+
return document.documentElement.dataset.colorScheme === 'dark' ||
|
|
155
|
+
window.matchMedia?.('(prefers-color-scheme: dark)').matches;
|
|
156
|
+
},
|
|
157
|
+
format() {
|
|
158
|
+
if (!this._editor) return;
|
|
159
|
+
try {
|
|
160
|
+
const formatted = JSON.stringify(JSON.parse(this._editor.getValue()), null, 2);
|
|
161
|
+
this._silent = true;
|
|
162
|
+
this._editor.setValue(formatted, -1);
|
|
163
|
+
this._silent = false;
|
|
164
|
+
this.rawValue = formatted;
|
|
165
|
+
} catch { /* invalid JSON */ }
|
|
166
|
+
},
|
|
167
|
+
onFallbackInput(e) {
|
|
168
|
+
this.rawValue = e.target.value;
|
|
169
|
+
try { this.$emit('update:modelValue', JSON.parse(this.rawValue)); } catch { /* ignore */ }
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
</script>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div class="sf-nullable" :class="{ errors: fieldErrors.length }">
|
|
3
3
|
<div class="sf-nullable-header">
|
|
4
|
-
<
|
|
4
|
+
<span class="sf-label" :class="{ required: isRequired }">{{ title }}</span>
|
|
5
5
|
<button type="button" :class="toggleClass" @click="toggle">
|
|
6
6
|
<template v-if="isNull">
|
|
7
7
|
<SfIcon name="plus" /> Add
|
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div class="sf-field" :class="{ errors: fieldErrors.length }">
|
|
3
|
-
<
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
3
|
+
<span class="sf-label" :class="{ required: isRequired }">
|
|
4
|
+
{{ title }}
|
|
5
|
+
<span v-if="isNullable && isNullValue" class="sf-null-badge">null</span>
|
|
6
|
+
</span>
|
|
7
|
+
<div :class="isNullable ? 'sf-input-row' : null">
|
|
8
|
+
<input
|
|
9
|
+
type="number"
|
|
10
|
+
class="sf-input"
|
|
11
|
+
:step="schema.type === 'integer' ? '1' : 'any'"
|
|
12
|
+
:min="schema.minimum != null ? String(schema.minimum) : undefined"
|
|
13
|
+
:max="schema.maximum != null ? String(schema.maximum) : undefined"
|
|
14
|
+
:value="isNullValue ? '' : modelValue"
|
|
15
|
+
:placeholder="isNullValue ? 'null' : undefined"
|
|
16
|
+
@input="onInput"
|
|
17
|
+
/>
|
|
18
|
+
<button v-if="isNullable && !isNullValue" type="button" class="sf-null-clear-btn" title="Set to null" @click="$emit('update:modelValue', null)">✕</button>
|
|
19
|
+
</div>
|
|
13
20
|
<ul v-if="fieldErrors.length" class="errorlist">
|
|
14
21
|
<li v-for="(err, i) in fieldErrors" :key="i">{{ err }}</li>
|
|
15
22
|
</ul>
|
|
@@ -37,6 +44,12 @@ export default {
|
|
|
37
44
|
const parentSchema = this.form.getSchemaAtPath(parentPath);
|
|
38
45
|
return parentSchema && Array.isArray(parentSchema.required) && parentSchema.required.includes(fieldName);
|
|
39
46
|
},
|
|
47
|
+
isNullable() {
|
|
48
|
+
return !!this.schema._nullable;
|
|
49
|
+
},
|
|
50
|
+
isNullValue() {
|
|
51
|
+
return this.modelValue === null || this.modelValue === undefined;
|
|
52
|
+
},
|
|
40
53
|
fieldErrors() {
|
|
41
54
|
if (!this.form || !this.form.getErrorsForPath) return [];
|
|
42
55
|
return this.form.getErrorsForPath(this.path);
|
|
@@ -56,6 +56,9 @@ export default {
|
|
|
56
56
|
data() {
|
|
57
57
|
return {
|
|
58
58
|
collapsed: false,
|
|
59
|
+
// Values pruned when a conditional rule deactivated their field,
|
|
60
|
+
// kept so toggling the controller back restores what the user typed.
|
|
61
|
+
prunedStash: {},
|
|
59
62
|
};
|
|
60
63
|
},
|
|
61
64
|
computed: {
|
|
@@ -103,10 +106,21 @@ export default {
|
|
|
103
106
|
const allowed = new Set(Object.keys(effective.properties || {}));
|
|
104
107
|
let changed = false;
|
|
105
108
|
const out = {};
|
|
109
|
+
// restore stashed values for fields a rule just re-activated
|
|
110
|
+
for (const k of allowed) {
|
|
111
|
+
if (!(k in value) && k in this.prunedStash) {
|
|
112
|
+
out[k] = this.prunedStash[k];
|
|
113
|
+
delete this.prunedStash[k];
|
|
114
|
+
changed = true;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
106
117
|
for (const k of Object.keys(value)) {
|
|
107
118
|
if (allowed.has(k)) {
|
|
108
119
|
out[k] = value[k];
|
|
109
120
|
} else {
|
|
121
|
+
// pruned from the emitted value (documented behavior), but kept
|
|
122
|
+
// locally so a controller toggle round-trip is not destructive
|
|
123
|
+
this.prunedStash[k] = value[k];
|
|
110
124
|
changed = true;
|
|
111
125
|
}
|
|
112
126
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div class="sf-field sf-relation" :class="{ errors: fieldErrors.length }" ref="root">
|
|
3
|
-
<
|
|
3
|
+
<span class="sf-label" :class="{ required: isRequired }">{{ title }}</span>
|
|
4
4
|
<div class="sf-relation-wrapper">
|
|
5
5
|
<!-- Selected items -->
|
|
6
6
|
<div v-if="selected.length" class="sf-relation-selected">
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
import StringEditor from './StringEditor.vue';
|
|
26
26
|
import NumberEditor from './NumberEditor.vue';
|
|
27
27
|
import BooleanEditor from './BooleanEditor.vue';
|
|
28
|
+
import DateEditor from './DateEditor.vue';
|
|
28
29
|
import SelectEditor from './SelectEditor.vue';
|
|
29
30
|
import HiddenEditor from './HiddenEditor.vue';
|
|
30
31
|
import ObjectEditor from './ObjectEditor.vue';
|
|
@@ -32,7 +33,9 @@ import ArrayEditor from './ArrayEditor.vue';
|
|
|
32
33
|
import NullableEditor from './NullableEditor.vue';
|
|
33
34
|
import UnionEditor from './UnionEditor.vue';
|
|
34
35
|
import RelationEditor from './RelationEditor.vue';
|
|
36
|
+
import JsonEditor from './JsonEditor.vue';
|
|
35
37
|
import WebComponentWrapper from './WebComponentWrapper.vue';
|
|
38
|
+
import { isChoiceOneOf } from '../utils';
|
|
36
39
|
|
|
37
40
|
const MAX_DEPTH = 12;
|
|
38
41
|
|
|
@@ -42,6 +45,7 @@ export default {
|
|
|
42
45
|
StringEditor,
|
|
43
46
|
NumberEditor,
|
|
44
47
|
BooleanEditor,
|
|
48
|
+
DateEditor,
|
|
45
49
|
SelectEditor,
|
|
46
50
|
HiddenEditor,
|
|
47
51
|
ObjectEditor,
|
|
@@ -49,6 +53,7 @@ export default {
|
|
|
49
53
|
NullableEditor,
|
|
50
54
|
UnionEditor,
|
|
51
55
|
RelationEditor,
|
|
56
|
+
JsonEditor,
|
|
52
57
|
WebComponentWrapper,
|
|
53
58
|
},
|
|
54
59
|
inject: {
|
|
@@ -90,14 +95,19 @@ export default {
|
|
|
90
95
|
|
|
91
96
|
if (schema.type === 'relation') return 'RelationEditor';
|
|
92
97
|
if (schema.oneOf && schema.discriminator) return 'UnionEditor';
|
|
98
|
+
// Choice-list oneOf ({const, title} options) renders as a select —
|
|
99
|
+
// must be checked before the 'const' HiddenEditor routing.
|
|
100
|
+
if (isChoiceOneOf(schema.oneOf)) return 'SelectEditor';
|
|
93
101
|
if ('const' in schema) return 'HiddenEditor';
|
|
94
102
|
if (schema.enum && schema.enum.length === 1 && schema.type === 'string') return 'HiddenEditor';
|
|
95
|
-
if (schema._nullable) return 'NullableEditor';
|
|
103
|
+
if (schema._nullable && (schema.type === 'object' || schema.type === 'array')) return 'NullableEditor';
|
|
96
104
|
if (schema.type === 'object' && schema.properties) return 'ObjectEditor';
|
|
105
|
+
if (schema.type === 'object') return 'JsonEditor';
|
|
97
106
|
if (schema.type === 'array') return 'ArrayEditor';
|
|
98
107
|
if (schema.enum) return 'SelectEditor';
|
|
99
108
|
if (schema.type === 'boolean') return 'BooleanEditor';
|
|
100
109
|
if (schema.type === 'number' || schema.type === 'integer') return 'NumberEditor';
|
|
110
|
+
if (schema.type === 'string' && (schema.format === 'date' || schema.format === 'date-time')) return 'DateEditor';
|
|
101
111
|
|
|
102
112
|
return 'StringEditor';
|
|
103
113
|
},
|
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div class="sf-field" :class="{ errors: fieldErrors.length }">
|
|
3
|
-
<
|
|
4
|
-
|
|
5
|
-
class="sf-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
3
|
+
<span class="sf-label" :class="{ required: isRequired }">
|
|
4
|
+
{{ title }}
|
|
5
|
+
<span v-if="isNullable && isNullValue" class="sf-null-badge">null</span>
|
|
6
|
+
</span>
|
|
7
|
+
<div :class="isNullable ? 'sf-input-row' : null">
|
|
8
|
+
<select
|
|
9
|
+
class="sf-input sf-select"
|
|
10
|
+
:value="selectedIndex === -1 ? '' : String(selectedIndex)"
|
|
11
|
+
@change="onChange($event.target.value)"
|
|
12
|
+
>
|
|
13
|
+
<option v-if="selectedIndex === -1" value="" disabled selected>{{ isNullValue ? 'null' : '' }}</option>
|
|
14
|
+
<option v-for="(opt, i) in options" :key="i" :value="String(i)">
|
|
15
|
+
{{ opt.label }}
|
|
16
|
+
</option>
|
|
17
|
+
</select>
|
|
18
|
+
<button v-if="isNullable && !isNullValue" type="button" class="sf-null-clear-btn" title="Set to null" @click="$emit('update:modelValue', null)">✕</button>
|
|
19
|
+
</div>
|
|
13
20
|
<ul v-if="fieldErrors.length" class="errorlist">
|
|
14
21
|
<li v-for="(err, i) in fieldErrors" :key="i">{{ err }}</li>
|
|
15
22
|
</ul>
|
|
@@ -17,6 +24,8 @@
|
|
|
17
24
|
</template>
|
|
18
25
|
|
|
19
26
|
<script>
|
|
27
|
+
import { isChoiceOneOf } from '../utils';
|
|
28
|
+
|
|
20
29
|
export default {
|
|
21
30
|
name: 'SelectEditor',
|
|
22
31
|
props: {
|
|
@@ -27,6 +36,20 @@ export default {
|
|
|
27
36
|
},
|
|
28
37
|
emits: ['update:modelValue'],
|
|
29
38
|
computed: {
|
|
39
|
+
options() {
|
|
40
|
+
// Two source shapes: a choice-list oneOf ({const, title} pairs, e.g.
|
|
41
|
+
// metaobjects' select kind — labels preserved) or a plain enum.
|
|
42
|
+
if (isChoiceOneOf(this.schema.oneOf)) {
|
|
43
|
+
return this.schema.oneOf.map((o) => ({
|
|
44
|
+
value: o.const,
|
|
45
|
+
label: o.title != null ? o.title : String(o.const),
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
return (this.schema.enum || []).map((v) => ({ value: v, label: String(v) }));
|
|
49
|
+
},
|
|
50
|
+
selectedIndex() {
|
|
51
|
+
return this.options.findIndex((o) => o.value === this.modelValue);
|
|
52
|
+
},
|
|
30
53
|
title() {
|
|
31
54
|
return this.schema.title || this.humanize(this.path[this.path.length - 1]) || '';
|
|
32
55
|
},
|
|
@@ -37,12 +60,25 @@ export default {
|
|
|
37
60
|
const parentSchema = this.form.getSchemaAtPath(parentPath);
|
|
38
61
|
return parentSchema && Array.isArray(parentSchema.required) && parentSchema.required.includes(fieldName);
|
|
39
62
|
},
|
|
63
|
+
isNullable() {
|
|
64
|
+
return !!this.schema._nullable;
|
|
65
|
+
},
|
|
66
|
+
isNullValue() {
|
|
67
|
+
return this.modelValue === null || this.modelValue === undefined;
|
|
68
|
+
},
|
|
40
69
|
fieldErrors() {
|
|
41
70
|
if (!this.form || !this.form.getErrorsForPath) return [];
|
|
42
71
|
return this.form.getErrorsForPath(this.path);
|
|
43
72
|
},
|
|
44
73
|
},
|
|
45
74
|
methods: {
|
|
75
|
+
onChange(indexStr) {
|
|
76
|
+
const opt = this.options[Number(indexStr)];
|
|
77
|
+
// Emit the ORIGINAL option value (options are addressed by index in
|
|
78
|
+
// the DOM), so integer/boolean enums keep their native type instead
|
|
79
|
+
// of being stringified by the <select> element.
|
|
80
|
+
if (opt !== undefined) this.$emit('update:modelValue', opt.value);
|
|
81
|
+
},
|
|
46
82
|
humanize(str) {
|
|
47
83
|
if (!str) return '';
|
|
48
84
|
return str.replace(/_/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, s => s.toUpperCase());
|