@structured-field/widget-editor 1.2.2 → 1.4.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 +51 -0
- package/dist/structured-widget-editor.css +1 -1
- package/dist/structured-widget-editor.esm.js +470 -125
- package/dist/structured-widget-editor.esm.js.map +1 -1
- package/dist/structured-widget-editor.iife.js +5 -5
- package/dist/structured-widget-editor.js +4 -4
- package/dist/structured-widget-editor.js.map +1 -1
- package/package.json +8 -2
- package/src/SchemaForm.vue +20 -13
- package/src/editors/DateEditor.vue +95 -0
- package/src/editors/ObjectEditor.vue +49 -18
- package/src/editors/RelationEditor.vue +1 -1
- package/src/editors/SchemaEditor.vue +8 -2
- package/src/editors/SelectEditor.vue +28 -5
- package/src/index.js +2 -0
- package/src/layout.js +116 -0
- package/src/scss/components/layout.scss +76 -0
- package/src/scss/main.scss +1 -0
- package/src/utils.js +18 -0
package/package.json
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@structured-field/widget-editor",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.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
|
+
"packageManager": "pnpm@10.11.1",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=20"
|
|
9
|
+
},
|
|
6
10
|
"main": "dist/structured-widget-editor.js",
|
|
7
11
|
"module": "dist/structured-widget-editor.esm.js",
|
|
8
12
|
"repository": {
|
|
@@ -27,7 +31,9 @@
|
|
|
27
31
|
"scripts": {
|
|
28
32
|
"build": "rollup -c && node scripts/bundle-iife.mjs",
|
|
29
33
|
"dev": "rollup -c -w",
|
|
30
|
-
"test
|
|
34
|
+
"test": "vitest run",
|
|
35
|
+
"test:watch": "vitest",
|
|
36
|
+
"playground": "vite --open"
|
|
31
37
|
},
|
|
32
38
|
"keywords": [
|
|
33
39
|
"json-schema",
|
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>
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div v-if="isRoot" class="sf-object sf-object-root">
|
|
3
3
|
<div class="sf-object-fields">
|
|
4
|
-
<
|
|
5
|
-
v-
|
|
6
|
-
:
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
4
|
+
<template v-for="cell in cells" :key="cell.key">
|
|
5
|
+
<div v-if="cell.breakBefore" class="sf-flow-break" aria-hidden="true"></div>
|
|
6
|
+
<div :class="cell.classes">
|
|
7
|
+
<SchemaEditor
|
|
8
|
+
:schema="cell.schema"
|
|
9
|
+
:model-value="(modelValue || {})[cell.key]"
|
|
10
|
+
:path="[...path, cell.key]"
|
|
11
|
+
:form="form"
|
|
12
|
+
@update:model-value="onChildChange(cell.key, $event)"
|
|
13
|
+
/>
|
|
14
|
+
</div>
|
|
15
|
+
</template>
|
|
13
16
|
</div>
|
|
14
17
|
</div>
|
|
15
18
|
<fieldset v-else class="sf-object" :class="{ 'sf-object-collapsed': collapsed }">
|
|
@@ -21,15 +24,18 @@
|
|
|
21
24
|
<span v-if="collapsed && summary" class="sf-object-summary">{{ summary }}</span>
|
|
22
25
|
</legend>
|
|
23
26
|
<div v-show="!collapsed" class="sf-object-fields">
|
|
24
|
-
<
|
|
25
|
-
v-
|
|
26
|
-
:
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
27
|
+
<template v-for="cell in cells" :key="cell.key">
|
|
28
|
+
<div v-if="cell.breakBefore" class="sf-flow-break" aria-hidden="true"></div>
|
|
29
|
+
<div :class="cell.classes">
|
|
30
|
+
<SchemaEditor
|
|
31
|
+
:schema="cell.schema"
|
|
32
|
+
:model-value="(modelValue || {})[cell.key]"
|
|
33
|
+
:path="[...path, cell.key]"
|
|
34
|
+
:form="form"
|
|
35
|
+
@update:model-value="onChildChange(cell.key, $event)"
|
|
36
|
+
/>
|
|
37
|
+
</div>
|
|
38
|
+
</template>
|
|
33
39
|
</div>
|
|
34
40
|
</fieldset>
|
|
35
41
|
</template>
|
|
@@ -38,6 +44,7 @@
|
|
|
38
44
|
import SchemaEditor from './SchemaEditor.vue';
|
|
39
45
|
import SfIcon from './SfIcon.vue';
|
|
40
46
|
import { applyConditionals, hasConditionals } from '../conditionals';
|
|
47
|
+
import { layoutCells } from '../layout';
|
|
41
48
|
|
|
42
49
|
export default {
|
|
43
50
|
name: 'ObjectEditor',
|
|
@@ -46,6 +53,9 @@ export default {
|
|
|
46
53
|
this.$options.components.SchemaEditor = SchemaEditor;
|
|
47
54
|
this.$options.components.SfIcon = SfIcon;
|
|
48
55
|
},
|
|
56
|
+
inject: {
|
|
57
|
+
customEditors: { default: () => () => [] },
|
|
58
|
+
},
|
|
49
59
|
props: {
|
|
50
60
|
schema: { type: Object, required: true },
|
|
51
61
|
modelValue: { default: () => ({}) },
|
|
@@ -56,6 +66,9 @@ export default {
|
|
|
56
66
|
data() {
|
|
57
67
|
return {
|
|
58
68
|
collapsed: false,
|
|
69
|
+
// Values pruned when a conditional rule deactivated their field,
|
|
70
|
+
// kept so toggling the controller back restores what the user typed.
|
|
71
|
+
prunedStash: {},
|
|
59
72
|
};
|
|
60
73
|
},
|
|
61
74
|
computed: {
|
|
@@ -69,6 +82,13 @@ export default {
|
|
|
69
82
|
if (!hasConditionals(this.schema)) return this.schema;
|
|
70
83
|
return applyConditionals(this.schema, this.modelValue || {}, this.form?.resolveSchema);
|
|
71
84
|
},
|
|
85
|
+
cells() {
|
|
86
|
+
return layoutCells(this.effectiveSchema.properties, {
|
|
87
|
+
resolveSchema: this.form?.resolveSchema,
|
|
88
|
+
customEditors: this.customEditors(),
|
|
89
|
+
basePath: this.path,
|
|
90
|
+
});
|
|
91
|
+
},
|
|
72
92
|
summary() {
|
|
73
93
|
const val = this.modelValue || {};
|
|
74
94
|
const parts = [];
|
|
@@ -103,10 +123,21 @@ export default {
|
|
|
103
123
|
const allowed = new Set(Object.keys(effective.properties || {}));
|
|
104
124
|
let changed = false;
|
|
105
125
|
const out = {};
|
|
126
|
+
// restore stashed values for fields a rule just re-activated
|
|
127
|
+
for (const k of allowed) {
|
|
128
|
+
if (!(k in value) && k in this.prunedStash) {
|
|
129
|
+
out[k] = this.prunedStash[k];
|
|
130
|
+
delete this.prunedStash[k];
|
|
131
|
+
changed = true;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
106
134
|
for (const k of Object.keys(value)) {
|
|
107
135
|
if (allowed.has(k)) {
|
|
108
136
|
out[k] = value[k];
|
|
109
137
|
} else {
|
|
138
|
+
// pruned from the emitted value (documented behavior), but kept
|
|
139
|
+
// locally so a controller toggle round-trip is not destructive
|
|
140
|
+
this.prunedStash[k] = value[k];
|
|
110
141
|
changed = true;
|
|
111
142
|
}
|
|
112
143
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div class="sf-field sf-relation" :class="{ errors: fieldErrors.length }" ref="root">
|
|
2
|
+
<div class="sf-field sf-relation" :class="{ errors: fieldErrors.length, 'sf-relation-multiple': isMultiple, 'sf-relation-open': dropdownVisible }" ref="root">
|
|
3
3
|
<span class="sf-label" :class="{ required: isRequired }">{{ title }}</span>
|
|
4
4
|
<div class="sf-relation-wrapper">
|
|
5
5
|
<!-- Selected items -->
|
|
@@ -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,8 +35,8 @@ 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';
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
import { isChoiceOneOf } from '../utils';
|
|
39
|
+
import { MAX_DEPTH } from '../layout';
|
|
39
40
|
|
|
40
41
|
export default {
|
|
41
42
|
name: 'SchemaEditor',
|
|
@@ -43,6 +44,7 @@ export default {
|
|
|
43
44
|
StringEditor,
|
|
44
45
|
NumberEditor,
|
|
45
46
|
BooleanEditor,
|
|
47
|
+
DateEditor,
|
|
46
48
|
SelectEditor,
|
|
47
49
|
HiddenEditor,
|
|
48
50
|
ObjectEditor,
|
|
@@ -92,6 +94,9 @@ export default {
|
|
|
92
94
|
|
|
93
95
|
if (schema.type === 'relation') return 'RelationEditor';
|
|
94
96
|
if (schema.oneOf && schema.discriminator) return 'UnionEditor';
|
|
97
|
+
// Choice-list oneOf ({const, title} options) renders as a select —
|
|
98
|
+
// must be checked before the 'const' HiddenEditor routing.
|
|
99
|
+
if (isChoiceOneOf(schema.oneOf)) return 'SelectEditor';
|
|
95
100
|
if ('const' in schema) return 'HiddenEditor';
|
|
96
101
|
if (schema.enum && schema.enum.length === 1 && schema.type === 'string') return 'HiddenEditor';
|
|
97
102
|
if (schema._nullable && (schema.type === 'object' || schema.type === 'array')) return 'NullableEditor';
|
|
@@ -101,6 +106,7 @@ export default {
|
|
|
101
106
|
if (schema.enum) return 'SelectEditor';
|
|
102
107
|
if (schema.type === 'boolean') return 'BooleanEditor';
|
|
103
108
|
if (schema.type === 'number' || schema.type === 'integer') return 'NumberEditor';
|
|
109
|
+
if (schema.type === 'string' && (schema.format === 'date' || schema.format === 'date-time')) return 'DateEditor';
|
|
104
110
|
|
|
105
111
|
return 'StringEditor';
|
|
106
112
|
},
|
|
@@ -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';
|
|
@@ -15,6 +16,7 @@ export { default as RelationEditor } from './editors/RelationEditor.vue';
|
|
|
15
16
|
export { default as WebComponentWrapper } from './editors/WebComponentWrapper.vue';
|
|
16
17
|
export { BaseEditorElement } from './BaseEditorElement.js';
|
|
17
18
|
export { applyConditionals, matchesSchema, hasConditionals } from './conditionals.js';
|
|
19
|
+
export { fieldSize, layoutCells, normalizeLayoutHint } from './layout.js';
|
|
18
20
|
|
|
19
21
|
import { defineCustomElement } from 'vue';
|
|
20
22
|
import SchemaFormComponent from './SchemaForm.vue';
|
package/src/layout.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { isChoiceOneOf } from './utils';
|
|
2
|
+
|
|
3
|
+
// Field-size classification for the multicolumn flow layout.
|
|
4
|
+
// Sizes are flex-basis tokens (see scss/components/layout.scss), not column
|
|
5
|
+
// counts: columns emerge from how many cells fit the container's width.
|
|
6
|
+
// Invariant: visual order === DOM order === tab order. Never reorder cells.
|
|
7
|
+
|
|
8
|
+
const SIZES = ['xs', 'sm', 'md', 'lg', 'full'];
|
|
9
|
+
const BREAKS = ['before', 'after', 'both'];
|
|
10
|
+
|
|
11
|
+
// Schema-author hint: `layout: 'sm'` or `layout: { size: 'sm', break: 'before' }`.
|
|
12
|
+
// Invalid hints are silently ignored so a typo degrades to the heuristic.
|
|
13
|
+
export function normalizeLayoutHint(layout) {
|
|
14
|
+
if (typeof layout === 'string') {
|
|
15
|
+
return { size: SIZES.includes(layout) ? layout : null, break: null };
|
|
16
|
+
}
|
|
17
|
+
if (layout && typeof layout === 'object' && !Array.isArray(layout)) {
|
|
18
|
+
return {
|
|
19
|
+
size: SIZES.includes(layout.size) ? layout.size : null,
|
|
20
|
+
break: BREAKS.includes(layout.break) ? layout.break : null,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
return { size: null, break: null };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Nullable scalars carry the inline null-clear button (~30px of chrome), and
|
|
27
|
+
// datetime-local needs its full width — one tier of slack keeps them usable.
|
|
28
|
+
const NULLABLE_BUMP = { xs: 'sm', sm: 'md' };
|
|
29
|
+
|
|
30
|
+
function bumpNullable(size, schema) {
|
|
31
|
+
return schema._nullable ? (NULLABLE_BUMP[size] || size) : size;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function choiceSize(labels, schema) {
|
|
35
|
+
const compact = labels.length <= 8 && labels.every((l) => String(l ?? '').length <= 12);
|
|
36
|
+
return bumpNullable(compact ? 'sm' : 'md', schema);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Shared with SchemaEditor: past this depth everything renders StringEditor.
|
|
40
|
+
export const MAX_DEPTH = 12;
|
|
41
|
+
|
|
42
|
+
// Intrinsic size of a RESOLVED schema node.
|
|
43
|
+
// Returns 'xs' | 'sm' | 'md' | 'lg' | 'full' | 'hidden'.
|
|
44
|
+
// Hidden routing (const / single-string-enum) is authoritative: a size hint
|
|
45
|
+
// on a field that renders HiddenEditor would only produce an empty cell.
|
|
46
|
+
export function fieldSize(schema) {
|
|
47
|
+
if (!schema || typeof schema !== 'object') return 'full';
|
|
48
|
+
const intrinsic = intrinsicSize(schema);
|
|
49
|
+
if (intrinsic === 'hidden') return 'hidden';
|
|
50
|
+
return normalizeLayoutHint(schema.layout).size || intrinsic;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The branch order mirrors SchemaEditor.editorComponent — keep them in sync.
|
|
54
|
+
function intrinsicSize(schema) {
|
|
55
|
+
if (schema.type === 'relation') return schema.multiple ? 'lg' : 'md';
|
|
56
|
+
if (schema.oneOf && schema.discriminator) return 'full';
|
|
57
|
+
if (isChoiceOneOf(schema.oneOf)) {
|
|
58
|
+
return choiceSize(schema.oneOf.map((o) => o.title ?? o.const), schema);
|
|
59
|
+
}
|
|
60
|
+
if ('const' in schema) return 'hidden';
|
|
61
|
+
if (schema.enum && schema.enum.length === 1 && schema.type === 'string') return 'hidden';
|
|
62
|
+
// Covers ObjectEditor, JsonEditor, ArrayEditor and NullableEditor containers.
|
|
63
|
+
if (schema.type === 'object' || schema.type === 'array') return 'full';
|
|
64
|
+
if (schema.enum) return choiceSize(schema.enum, schema);
|
|
65
|
+
if (schema.type === 'boolean') return bumpNullable('xs', schema);
|
|
66
|
+
if (schema.type === 'number' || schema.type === 'integer') return bumpNullable('xs', schema);
|
|
67
|
+
if (schema.type === 'string') {
|
|
68
|
+
if (schema.format === 'date') return bumpNullable('sm', schema);
|
|
69
|
+
if (schema.format === 'date-time') return bumpNullable('md', schema);
|
|
70
|
+
// Mirrors StringEditor.isLong (textarea rendering).
|
|
71
|
+
if (schema.format === 'textarea' || schema.maxLength > 255) return 'full';
|
|
72
|
+
if (schema.maxLength > 0 && schema.maxLength <= 40) return bumpNullable('sm', schema);
|
|
73
|
+
return 'md';
|
|
74
|
+
}
|
|
75
|
+
return 'full';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Builds the cell list for an object's properties: resolved schema, wrapper
|
|
79
|
+
// classes and row-break flags. `break: 'after'` marks the NEXT visible field;
|
|
80
|
+
// hidden fields neither consume nor emit a pending break.
|
|
81
|
+
// Custom-editor matches default to 'full' (we can't predict their rendering)
|
|
82
|
+
// unless the schema hint or the override's own `layout` says otherwise.
|
|
83
|
+
export function layoutCells(properties, { resolveSchema, customEditors = [], basePath = [] } = {}) {
|
|
84
|
+
const cells = [];
|
|
85
|
+
let pendingBreak = false;
|
|
86
|
+
// Mirrors SchemaEditor: the depth guard runs before custom-editor overrides,
|
|
87
|
+
// and past it every field (const included) renders a visible StringEditor.
|
|
88
|
+
const pastMaxDepth = basePath.length + 1 > MAX_DEPTH;
|
|
89
|
+
for (const [key, raw] of Object.entries(properties || {})) {
|
|
90
|
+
const schema = resolveSchema ? resolveSchema(raw) : raw;
|
|
91
|
+
const hint = normalizeLayoutHint(schema.layout);
|
|
92
|
+
const override = !pastMaxDepth
|
|
93
|
+
&& customEditors.find((o) => o.match && o.match(schema, [...basePath, key]));
|
|
94
|
+
let size;
|
|
95
|
+
if (pastMaxDepth) {
|
|
96
|
+
size = 'md';
|
|
97
|
+
} else if (override) {
|
|
98
|
+
size = hint.size || normalizeLayoutHint(override.layout).size || 'full';
|
|
99
|
+
} else {
|
|
100
|
+
size = fieldSize(schema);
|
|
101
|
+
}
|
|
102
|
+
const classes = ['sf-cell', `sf-cell-${size}`];
|
|
103
|
+
// Only the plain-boolean shape reaches the label-less BooleanEditor;
|
|
104
|
+
// boolean enums / choice oneOfs render SelectEditor (which has a label).
|
|
105
|
+
const isCheckbox = schema.type === 'boolean' && !override && !pastMaxDepth
|
|
106
|
+
&& !schema.enum && !schema.oneOf && !('const' in schema);
|
|
107
|
+
let breakBefore = false;
|
|
108
|
+
if (size !== 'hidden') {
|
|
109
|
+
if (isCheckbox) classes.push('sf-cell-bool');
|
|
110
|
+
breakBefore = pendingBreak || hint.break === 'before' || hint.break === 'both';
|
|
111
|
+
pendingBreak = hint.break === 'after' || hint.break === 'both';
|
|
112
|
+
}
|
|
113
|
+
cells.push({ key, schema, classes: classes.join(' '), breakBefore });
|
|
114
|
+
}
|
|
115
|
+
return cells;
|
|
116
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Multicolumn flow layout for object fields.
|
|
2
|
+
//
|
|
3
|
+
// Model: each .sf-object-fields is a wrapping flex row; cells carry a
|
|
4
|
+
// flex-basis token per field kind (set in src/layout.js) and grow to share
|
|
5
|
+
// the line. Columns emerge from the available width at every nesting level —
|
|
6
|
+
// no media/container queries, no containment (container-type would create
|
|
7
|
+
// stacking contexts under the relation dropdown and collapse the widget's
|
|
8
|
+
// intrinsic width inside Django tabular-inline table cells).
|
|
9
|
+
//
|
|
10
|
+
// Invariant: visual order === DOM order === tab order. No `order`, ever.
|
|
11
|
+
//
|
|
12
|
+
// Gate: @supports (inset: 0) matches the flex-`gap` era (Chrome 87+,
|
|
13
|
+
// FF 66+, Safari 14.5+). Do not test `gap` itself — it false-positives on
|
|
14
|
+
// browsers that only support grid gap. Ungated browsers keep the original
|
|
15
|
+
// single-column flex layout from editors.scss.
|
|
16
|
+
.structured-field-editor {
|
|
17
|
+
|
|
18
|
+
.sf-cell {
|
|
19
|
+
// Required guard: without it a long relation tag or wide intrinsic
|
|
20
|
+
// content makes the flex item refuse to shrink and blows up the row.
|
|
21
|
+
min-width: 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.sf-cell-hidden {
|
|
25
|
+
display: none;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@supports (inset: 0) {
|
|
29
|
+
.sf-object-fields {
|
|
30
|
+
flex-flow: row wrap;
|
|
31
|
+
column-gap: 16px;
|
|
32
|
+
align-items: flex-start;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Vertical rhythm stays on the editors' own margin-bottom (as in the
|
|
36
|
+
// single-column layout), so the zero-height row break adds no space.
|
|
37
|
+
.sf-flow-break {
|
|
38
|
+
flex-basis: 100%;
|
|
39
|
+
height: 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.sf-cell-xs { flex: 1 1 var(--sf-basis-xs, 8rem); }
|
|
43
|
+
.sf-cell-sm { flex: 1 1 var(--sf-basis-sm, 12rem); }
|
|
44
|
+
.sf-cell-md { flex: 1 1 var(--sf-basis-md, 18rem); }
|
|
45
|
+
.sf-cell-lg { flex: 1 1 var(--sf-basis-lg, 26rem); }
|
|
46
|
+
.sf-cell-full { flex: 1 1 100%; }
|
|
47
|
+
|
|
48
|
+
// Checkbox fields have no label above: equalize the bottom margin and
|
|
49
|
+
// anchor them to the line's bottom so the checkbox row sits on the same
|
|
50
|
+
// visual line as its neighbors' inputs.
|
|
51
|
+
.sf-cell > .sf-field-boolean {
|
|
52
|
+
margin-bottom: 12px;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.sf-cell-bool {
|
|
56
|
+
align-self: flex-end;
|
|
57
|
+
|
|
58
|
+
.sf-boolean-row {
|
|
59
|
+
min-height: var(--sf-control-height, 30px);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// While validation errors are shown, flex-end would sink checkboxes to
|
|
64
|
+
// the bottom of the grown line; anchor them to the top instead with a
|
|
65
|
+
// synthesized label row (one .sf-label line + its 4px margin).
|
|
66
|
+
@supports selector(:has(*)) {
|
|
67
|
+
.sf-object-fields:has(.errorlist) > .sf-cell-bool {
|
|
68
|
+
align-self: flex-start;
|
|
69
|
+
|
|
70
|
+
> .sf-field-boolean {
|
|
71
|
+
padding-top: calc(0.8125rem * 1.4 + 4px);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
package/src/scss/main.scss
CHANGED
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') {
|