@structured-field/widget-editor 1.2.2 → 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/dist/structured-widget-editor.esm.js +286 -94
- package/dist/structured-widget-editor.esm.js.map +1 -1
- package/dist/structured-widget-editor.iife.js +4 -4
- package/dist/structured-widget-editor.js +4 -4
- package/dist/structured-widget-editor.js.map +1 -1
- package/package.json +3 -1
- package/src/SchemaForm.vue +20 -13
- package/src/editors/DateEditor.vue +95 -0
- package/src/editors/ObjectEditor.vue +14 -0
- package/src/editors/SchemaEditor.vue +7 -0
- package/src/editors/SelectEditor.vue +28 -5
- package/src/index.js +1 -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
|
}
|
|
@@ -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>
|
|
@@ -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
|
}
|
|
@@ -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';
|
|
@@ -34,6 +35,7 @@ import UnionEditor from './UnionEditor.vue';
|
|
|
34
35
|
import RelationEditor from './RelationEditor.vue';
|
|
35
36
|
import JsonEditor from './JsonEditor.vue';
|
|
36
37
|
import WebComponentWrapper from './WebComponentWrapper.vue';
|
|
38
|
+
import { isChoiceOneOf } from '../utils';
|
|
37
39
|
|
|
38
40
|
const MAX_DEPTH = 12;
|
|
39
41
|
|
|
@@ -43,6 +45,7 @@ export default {
|
|
|
43
45
|
StringEditor,
|
|
44
46
|
NumberEditor,
|
|
45
47
|
BooleanEditor,
|
|
48
|
+
DateEditor,
|
|
46
49
|
SelectEditor,
|
|
47
50
|
HiddenEditor,
|
|
48
51
|
ObjectEditor,
|
|
@@ -92,6 +95,9 @@ export default {
|
|
|
92
95
|
|
|
93
96
|
if (schema.type === 'relation') return 'RelationEditor';
|
|
94
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';
|
|
95
101
|
if ('const' in schema) return 'HiddenEditor';
|
|
96
102
|
if (schema.enum && schema.enum.length === 1 && schema.type === 'string') return 'HiddenEditor';
|
|
97
103
|
if (schema._nullable && (schema.type === 'object' || schema.type === 'array')) return 'NullableEditor';
|
|
@@ -101,6 +107,7 @@ export default {
|
|
|
101
107
|
if (schema.enum) return 'SelectEditor';
|
|
102
108
|
if (schema.type === 'boolean') return 'BooleanEditor';
|
|
103
109
|
if (schema.type === 'number' || schema.type === 'integer') return 'NumberEditor';
|
|
110
|
+
if (schema.type === 'string' && (schema.format === 'date' || schema.format === 'date-time')) return 'DateEditor';
|
|
104
111
|
|
|
105
112
|
return 'StringEditor';
|
|
106
113
|
},
|
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
<div :class="isNullable ? 'sf-input-row' : null">
|
|
8
8
|
<select
|
|
9
9
|
class="sf-input sf-select"
|
|
10
|
-
:value="
|
|
11
|
-
@change="
|
|
10
|
+
:value="selectedIndex === -1 ? '' : String(selectedIndex)"
|
|
11
|
+
@change="onChange($event.target.value)"
|
|
12
12
|
>
|
|
13
|
-
<option v-if="
|
|
14
|
-
<option v-for="opt in
|
|
15
|
-
{{ opt }}
|
|
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
16
|
</option>
|
|
17
17
|
</select>
|
|
18
18
|
<button v-if="isNullable && !isNullValue" type="button" class="sf-null-clear-btn" title="Set to null" @click="$emit('update:modelValue', null)">✕</button>
|
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
</template>
|
|
25
25
|
|
|
26
26
|
<script>
|
|
27
|
+
import { isChoiceOneOf } from '../utils';
|
|
28
|
+
|
|
27
29
|
export default {
|
|
28
30
|
name: 'SelectEditor',
|
|
29
31
|
props: {
|
|
@@ -34,6 +36,20 @@ export default {
|
|
|
34
36
|
},
|
|
35
37
|
emits: ['update:modelValue'],
|
|
36
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
|
+
},
|
|
37
53
|
title() {
|
|
38
54
|
return this.schema.title || this.humanize(this.path[this.path.length - 1]) || '';
|
|
39
55
|
},
|
|
@@ -56,6 +72,13 @@ export default {
|
|
|
56
72
|
},
|
|
57
73
|
},
|
|
58
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
|
+
},
|
|
59
82
|
humanize(str) {
|
|
60
83
|
if (!str) return '';
|
|
61
84
|
return str.replace(/_/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, s => s.toUpperCase());
|
package/src/index.js
CHANGED
|
@@ -5,6 +5,7 @@ export { default as SchemaEditor } from './editors/SchemaEditor.vue';
|
|
|
5
5
|
export { default as StringEditor } from './editors/StringEditor.vue';
|
|
6
6
|
export { default as NumberEditor } from './editors/NumberEditor.vue';
|
|
7
7
|
export { default as BooleanEditor } from './editors/BooleanEditor.vue';
|
|
8
|
+
export { default as DateEditor } from './editors/DateEditor.vue';
|
|
8
9
|
export { default as SelectEditor } from './editors/SelectEditor.vue';
|
|
9
10
|
export { default as HiddenEditor } from './editors/HiddenEditor.vue';
|
|
10
11
|
export { default as ObjectEditor } from './editors/ObjectEditor.vue';
|
package/src/utils.js
CHANGED
|
@@ -16,6 +16,24 @@ export function deepClone(obj) {
|
|
|
16
16
|
return clone;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
export function isChoiceOneOf(list) {
|
|
20
|
+
// A "choice list" oneOf: every member is a {const, title?} value option
|
|
21
|
+
// (the shape emitted for enumerated choices, e.g. metaobjects' select
|
|
22
|
+
// kind), as opposed to a oneOf of alternative sub-schemas.
|
|
23
|
+
return (
|
|
24
|
+
Array.isArray(list) &&
|
|
25
|
+
list.length > 0 &&
|
|
26
|
+
list.every(
|
|
27
|
+
(m) =>
|
|
28
|
+
m &&
|
|
29
|
+
typeof m === 'object' &&
|
|
30
|
+
'const' in m &&
|
|
31
|
+
!('properties' in m) &&
|
|
32
|
+
m.type !== 'null'
|
|
33
|
+
)
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
19
37
|
export function getDefaultForSchema(schema) {
|
|
20
38
|
if ('default' in schema) return deepClone(schema.default);
|
|
21
39
|
if (schema.type === 'object') {
|