@structured-field/widget-editor 0.1.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/LICENSE +21 -0
- package/README.md +228 -0
- package/dist/structured-widget-editor.css +1 -0
- package/dist/structured-widget-editor.esm.js +9957 -0
- package/dist/structured-widget-editor.esm.js.map +1 -0
- package/dist/structured-widget-editor.iife.js +31 -0
- package/dist/structured-widget-editor.js +29 -0
- package/dist/structured-widget-editor.js.map +1 -0
- package/package.json +105 -0
- package/src/SchemaForm.vue +142 -0
- package/src/editors/ArrayEditor.vue +108 -0
- package/src/editors/BooleanEditor.vue +44 -0
- package/src/editors/HiddenEditor.vue +33 -0
- package/src/editors/NullableEditor.vue +94 -0
- package/src/editors/NumberEditor.vue +60 -0
- package/src/editors/ObjectEditor.vue +63 -0
- package/src/editors/RelationEditor.vue +208 -0
- package/src/editors/SchemaEditor.vue +68 -0
- package/src/editors/SelectEditor.vue +52 -0
- package/src/editors/StringEditor.vue +62 -0
- package/src/editors/UnionEditor.vue +83 -0
- package/src/index.js +155 -0
- package/src/scss/components/editors.scss +162 -0
- package/src/scss/components/form.scss +137 -0
- package/src/scss/components/relation.scss +134 -0
- package/src/scss/main.scss +3 -0
- package/src/utils.js +38 -0
package/package.json
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@structured-field/widget-editor",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A lightweight JSON Schema form builder with support for relation fields (ForeignKey, QuerySet) and autocomplete search.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/structured-widget-editor.js",
|
|
7
|
+
"module": "dist/structured-widget-editor.esm.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/structured-widget-editor.esm.js",
|
|
11
|
+
"require": "./dist/structured-widget-editor.js"
|
|
12
|
+
},
|
|
13
|
+
"./iife": "./dist/structured-widget-editor.iife.js",
|
|
14
|
+
"./css": "./dist/structured-widget-editor.css",
|
|
15
|
+
"./scss": "./src/scss/main.scss"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"src",
|
|
20
|
+
"LICENSE",
|
|
21
|
+
"README.md"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "rollup -c && node scripts/bundle-iife.mjs",
|
|
25
|
+
"build:cdn": "rollup -c && node scripts/bundle-iife.mjs",
|
|
26
|
+
"dev": "rollup -c -w",
|
|
27
|
+
"test:serve": "vite --open"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"json-schema",
|
|
31
|
+
"form-builder",
|
|
32
|
+
"structured",
|
|
33
|
+
"widget",
|
|
34
|
+
"editor",
|
|
35
|
+
"autocomplete",
|
|
36
|
+
"relation"
|
|
37
|
+
],
|
|
38
|
+
"author": "Lotrek",
|
|
39
|
+
"license": "MIT",
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@rollup/plugin-commonjs": "^29.0.2",
|
|
45
|
+
"@rollup/plugin-node-resolve": "^16.0.3",
|
|
46
|
+
"@rollup/plugin-terser": "^0.4.4",
|
|
47
|
+
"@vitejs/plugin-vue": "^6.0.4",
|
|
48
|
+
"@vue/compiler-sfc": "^3.5.30",
|
|
49
|
+
"happy-dom": "^20.8.3",
|
|
50
|
+
"rollup": "^4.9.1",
|
|
51
|
+
"rollup-plugin-postcss": "^4.0.2",
|
|
52
|
+
"rollup-plugin-scss": "^4.0.0",
|
|
53
|
+
"rollup-plugin-vue": "^6.0.0",
|
|
54
|
+
"sass": "^1.69.7",
|
|
55
|
+
"vite": "^7.3.1",
|
|
56
|
+
"vitest": "^4.0.18"
|
|
57
|
+
},
|
|
58
|
+
"dependencies": {
|
|
59
|
+
"vue": "^3.5.30"
|
|
60
|
+
},
|
|
61
|
+
"release": {
|
|
62
|
+
"branches": [
|
|
63
|
+
"master"
|
|
64
|
+
],
|
|
65
|
+
"plugins": [
|
|
66
|
+
"@semantic-release/commit-analyzer",
|
|
67
|
+
"@semantic-release/release-notes-generator",
|
|
68
|
+
"@semantic-release/changelog",
|
|
69
|
+
"@semantic-release/npm",
|
|
70
|
+
[
|
|
71
|
+
"@semantic-release/git",
|
|
72
|
+
{
|
|
73
|
+
"assets": [
|
|
74
|
+
"package.json",
|
|
75
|
+
"CHANGELOG.md"
|
|
76
|
+
],
|
|
77
|
+
"message": "chore(release): ${nextRelease.version} [skip ci]"
|
|
78
|
+
}
|
|
79
|
+
],
|
|
80
|
+
[
|
|
81
|
+
"@semantic-release/github",
|
|
82
|
+
{
|
|
83
|
+
"assets": [
|
|
84
|
+
{
|
|
85
|
+
"path": "dist/structured-widget-editor.iife.js",
|
|
86
|
+
"label": "IIFE bundle with embedded CSS (${nextRelease.version})"
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
"path": "dist/structured-widget-editor.js",
|
|
90
|
+
"label": "IIFE bundle (${nextRelease.version})"
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"path": "dist/structured-widget-editor.css",
|
|
94
|
+
"label": "CSS stylesheet (${nextRelease.version})"
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
"path": "dist/structured-widget-editor.esm.js",
|
|
98
|
+
"label": "ESM module (${nextRelease.version})"
|
|
99
|
+
}
|
|
100
|
+
]
|
|
101
|
+
}
|
|
102
|
+
]
|
|
103
|
+
]
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="structured-field-editor">
|
|
3
|
+
<SchemaEditor
|
|
4
|
+
v-if="resolvedSchema"
|
|
5
|
+
:schema="resolvedSchema"
|
|
6
|
+
:model-value="currentValue"
|
|
7
|
+
:path="[]"
|
|
8
|
+
:form="formApi"
|
|
9
|
+
@update:model-value="onValueChange"
|
|
10
|
+
/>
|
|
11
|
+
</div>
|
|
12
|
+
</template>
|
|
13
|
+
|
|
14
|
+
<script>
|
|
15
|
+
import SchemaEditor from './editors/SchemaEditor.vue';
|
|
16
|
+
import { deepClone } from './utils';
|
|
17
|
+
|
|
18
|
+
export default {
|
|
19
|
+
name: 'SchemaForm',
|
|
20
|
+
components: { SchemaEditor },
|
|
21
|
+
props: {
|
|
22
|
+
schema: { type: [Object, String], default: () => ({}) },
|
|
23
|
+
initialData: { default: undefined },
|
|
24
|
+
errors: { type: Object, default: () => ({}) },
|
|
25
|
+
},
|
|
26
|
+
emits: ['change'],
|
|
27
|
+
data() {
|
|
28
|
+
const parsedSchema = typeof this.schema === 'string' ? JSON.parse(this.schema) : this.schema;
|
|
29
|
+
const defs = parsedSchema.$defs || parsedSchema.definitions || {};
|
|
30
|
+
return {
|
|
31
|
+
rootSchema: parsedSchema,
|
|
32
|
+
defs,
|
|
33
|
+
currentValue: this.initialData != null ? deepClone(this.initialData) : undefined,
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
computed: {
|
|
37
|
+
resolvedSchema() {
|
|
38
|
+
return this.resolveSchema(this.rootSchema);
|
|
39
|
+
},
|
|
40
|
+
formApi() {
|
|
41
|
+
return {
|
|
42
|
+
resolveSchema: (s) => this.resolveSchema(s),
|
|
43
|
+
getSchemaAtPath: (p) => this.getSchemaAtPath(p),
|
|
44
|
+
getErrorsForPath: (p) => this.getErrorsForPath(p),
|
|
45
|
+
};
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
watch: {
|
|
49
|
+
schema: {
|
|
50
|
+
handler(val) {
|
|
51
|
+
const parsed = typeof val === 'string' ? JSON.parse(val) : val;
|
|
52
|
+
this.rootSchema = parsed;
|
|
53
|
+
this.defs = parsed.$defs || parsed.definitions || {};
|
|
54
|
+
},
|
|
55
|
+
deep: true,
|
|
56
|
+
},
|
|
57
|
+
initialData: {
|
|
58
|
+
handler(val) {
|
|
59
|
+
this.currentValue = val != null ? deepClone(val) : undefined;
|
|
60
|
+
},
|
|
61
|
+
deep: true,
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
methods: {
|
|
65
|
+
resolveSchema(schema) {
|
|
66
|
+
if (!schema) return { type: 'string' };
|
|
67
|
+
|
|
68
|
+
if (schema.$ref) {
|
|
69
|
+
const refPath = schema.$ref.replace(/^#\/\$defs\//, '').replace(/^#\/definitions\//, '');
|
|
70
|
+
const resolved = this.defs[refPath];
|
|
71
|
+
if (!resolved) return { type: 'string', title: refPath };
|
|
72
|
+
const { $ref, ...rest } = schema;
|
|
73
|
+
return { ...this.resolveSchema(resolved), ...rest };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (schema.anyOf) {
|
|
77
|
+
const nonNull = schema.anyOf.filter(s => s.type !== 'null');
|
|
78
|
+
const hasNull = schema.anyOf.some(s => s.type === 'null');
|
|
79
|
+
if (hasNull && nonNull.length === 1) {
|
|
80
|
+
const resolved = this.resolveSchema(nonNull[0]);
|
|
81
|
+
return {
|
|
82
|
+
...resolved,
|
|
83
|
+
_nullable: true,
|
|
84
|
+
title: schema.title || resolved.title,
|
|
85
|
+
default: 'default' in schema ? schema.default : null,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
if (nonNull.length >= 1) return this.resolveSchema(nonNull[0]);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (schema.oneOf && schema.discriminator) return schema;
|
|
92
|
+
|
|
93
|
+
if (schema.oneOf) {
|
|
94
|
+
const nonNull = schema.oneOf.filter(s => s.type !== 'null');
|
|
95
|
+
const hasNull = schema.oneOf.some(s => s.type === 'null');
|
|
96
|
+
if (hasNull && nonNull.length === 1) {
|
|
97
|
+
const resolved = this.resolveSchema(nonNull[0]);
|
|
98
|
+
return {
|
|
99
|
+
...resolved,
|
|
100
|
+
_nullable: true,
|
|
101
|
+
title: schema.title || resolved.title,
|
|
102
|
+
default: 'default' in schema ? schema.default : null,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
if (nonNull.length >= 1) return this.resolveSchema(nonNull[0]);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return schema;
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
getSchemaAtPath(path) {
|
|
112
|
+
let schema = this.resolveSchema(this.rootSchema);
|
|
113
|
+
for (const segment of path) {
|
|
114
|
+
if (!schema) return null;
|
|
115
|
+
if (schema.properties && schema.properties[segment]) {
|
|
116
|
+
schema = this.resolveSchema(schema.properties[segment]);
|
|
117
|
+
} else if (schema.items) {
|
|
118
|
+
schema = this.resolveSchema(schema.items);
|
|
119
|
+
} else {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return schema;
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
onValueChange(val) {
|
|
127
|
+
this.currentValue = val;
|
|
128
|
+
this.$emit('change', val);
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
getErrorsForPath(path) {
|
|
132
|
+
if (!this.errors || typeof this.errors !== 'object') return [];
|
|
133
|
+
const key = path.join('.');
|
|
134
|
+
return this.errors[key] || [];
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
getValue() {
|
|
138
|
+
return this.currentValue;
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
</script>
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="sf-array" :class="{ errors: fieldErrors.length }">
|
|
3
|
+
<div class="sf-array-header">
|
|
4
|
+
<span class="sf-label">{{ title }}</span>
|
|
5
|
+
<span class="sf-array-count">{{ items.length }}</span>
|
|
6
|
+
<button type="button" class="sf-btn sf-btn-add" @click="addItem()">
|
|
7
|
+
<i class="fas fa-plus"></i> Add
|
|
8
|
+
</button>
|
|
9
|
+
</div>
|
|
10
|
+
<div class="sf-array-items">
|
|
11
|
+
<div v-for="(item, index) in items" :key="item._key" class="sf-array-item">
|
|
12
|
+
<div class="sf-array-item-header">
|
|
13
|
+
<span class="sf-array-item-index">#{{ index + 1 }}</span>
|
|
14
|
+
<div class="sf-array-item-actions">
|
|
15
|
+
<button v-if="index > 0" type="button" class="sf-btn sf-btn-sm" @click="moveItem(index, -1)">
|
|
16
|
+
<i class="fas fa-arrow-up"></i>
|
|
17
|
+
</button>
|
|
18
|
+
<button type="button" class="sf-btn sf-btn-sm" @click="moveItem(index, 1)">
|
|
19
|
+
<i class="fas fa-arrow-down"></i>
|
|
20
|
+
</button>
|
|
21
|
+
<button type="button" class="sf-btn sf-btn-sm sf-btn-danger" @click="removeItem(index)">
|
|
22
|
+
<i class="fas fa-times"></i>
|
|
23
|
+
</button>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
<div class="sf-array-item-body">
|
|
27
|
+
<SchemaEditor
|
|
28
|
+
:schema="itemSchema"
|
|
29
|
+
:model-value="item.value"
|
|
30
|
+
:path="[...path, String(index)]"
|
|
31
|
+
:form="form"
|
|
32
|
+
@update:model-value="onItemChange(index, $event)"
|
|
33
|
+
/>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
<ul v-if="fieldErrors.length" class="errorlist">
|
|
38
|
+
<li v-for="(err, i) in fieldErrors" :key="i">{{ err }}</li>
|
|
39
|
+
</ul>
|
|
40
|
+
</div>
|
|
41
|
+
</template>
|
|
42
|
+
|
|
43
|
+
<script>
|
|
44
|
+
import { defineAsyncComponent } from 'vue';
|
|
45
|
+
import { getDefaultForSchema } from '../utils';
|
|
46
|
+
|
|
47
|
+
let keyCounter = 0;
|
|
48
|
+
|
|
49
|
+
export default {
|
|
50
|
+
name: 'ArrayEditor',
|
|
51
|
+
components: { SchemaEditor: defineAsyncComponent(() => import('./SchemaEditor.vue')) },
|
|
52
|
+
props: {
|
|
53
|
+
schema: { type: Object, required: true },
|
|
54
|
+
modelValue: { default: () => [] },
|
|
55
|
+
path: { type: Array, default: () => [] },
|
|
56
|
+
form: { type: Object, required: true },
|
|
57
|
+
},
|
|
58
|
+
emits: ['update:modelValue'],
|
|
59
|
+
data() {
|
|
60
|
+
const arr = Array.isArray(this.modelValue) ? this.modelValue : [];
|
|
61
|
+
return {
|
|
62
|
+
items: arr.map(v => ({ _key: keyCounter++, value: v })),
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
computed: {
|
|
66
|
+
title() {
|
|
67
|
+
return this.schema.title || this.humanize(this.path[this.path.length - 1]) || '';
|
|
68
|
+
},
|
|
69
|
+
itemSchema() {
|
|
70
|
+
return this.form.resolveSchema(this.schema.items || {});
|
|
71
|
+
},
|
|
72
|
+
fieldErrors() {
|
|
73
|
+
if (!this.form || !this.form.getErrorsForPath) return [];
|
|
74
|
+
return this.form.getErrorsForPath(this.path);
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
methods: {
|
|
78
|
+
humanize(str) {
|
|
79
|
+
if (!str) return '';
|
|
80
|
+
return str.replace(/_/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, s => s.toUpperCase());
|
|
81
|
+
},
|
|
82
|
+
addItem() {
|
|
83
|
+
const value = getDefaultForSchema(this.itemSchema);
|
|
84
|
+
this.items.push({ _key: keyCounter++, value });
|
|
85
|
+
this.emitValue();
|
|
86
|
+
},
|
|
87
|
+
removeItem(index) {
|
|
88
|
+
this.items.splice(index, 1);
|
|
89
|
+
this.emitValue();
|
|
90
|
+
},
|
|
91
|
+
moveItem(index, direction) {
|
|
92
|
+
const newIndex = index + direction;
|
|
93
|
+
if (newIndex < 0 || newIndex >= this.items.length) return;
|
|
94
|
+
const temp = this.items[index];
|
|
95
|
+
this.items.splice(index, 1, this.items[newIndex]);
|
|
96
|
+
this.items.splice(newIndex, 1, temp);
|
|
97
|
+
this.emitValue();
|
|
98
|
+
},
|
|
99
|
+
onItemChange(index, value) {
|
|
100
|
+
this.items[index].value = value;
|
|
101
|
+
this.emitValue();
|
|
102
|
+
},
|
|
103
|
+
emitValue() {
|
|
104
|
+
this.$emit('update:modelValue', this.items.map(i => i.value));
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
</script>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="sf-field sf-field-boolean" :class="{ errors: fieldErrors.length }">
|
|
3
|
+
<label class="sf-checkbox-label">
|
|
4
|
+
<input
|
|
5
|
+
type="checkbox"
|
|
6
|
+
class="sf-checkbox"
|
|
7
|
+
:checked="!!modelValue"
|
|
8
|
+
@change="$emit('update:modelValue', $event.target.checked)"
|
|
9
|
+
/>
|
|
10
|
+
{{ title }}
|
|
11
|
+
</label>
|
|
12
|
+
<ul v-if="fieldErrors.length" class="errorlist">
|
|
13
|
+
<li v-for="(err, i) in fieldErrors" :key="i">{{ err }}</li>
|
|
14
|
+
</ul>
|
|
15
|
+
</div>
|
|
16
|
+
</template>
|
|
17
|
+
|
|
18
|
+
<script>
|
|
19
|
+
export default {
|
|
20
|
+
name: 'BooleanEditor',
|
|
21
|
+
props: {
|
|
22
|
+
schema: { type: Object, required: true },
|
|
23
|
+
modelValue: { default: false },
|
|
24
|
+
path: { type: Array, default: () => [] },
|
|
25
|
+
form: { type: Object, default: null },
|
|
26
|
+
},
|
|
27
|
+
emits: ['update:modelValue'],
|
|
28
|
+
computed: {
|
|
29
|
+
title() {
|
|
30
|
+
return this.schema.title || this.humanize(this.path[this.path.length - 1]) || '';
|
|
31
|
+
},
|
|
32
|
+
fieldErrors() {
|
|
33
|
+
if (!this.form || !this.form.getErrorsForPath) return [];
|
|
34
|
+
return this.form.getErrorsForPath(this.path);
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
methods: {
|
|
38
|
+
humanize(str) {
|
|
39
|
+
if (!str) return '';
|
|
40
|
+
return str.replace(/_/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, s => s.toUpperCase());
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
</script>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div style="display: none"></div>
|
|
3
|
+
</template>
|
|
4
|
+
|
|
5
|
+
<script>
|
|
6
|
+
export default {
|
|
7
|
+
name: 'HiddenEditor',
|
|
8
|
+
props: {
|
|
9
|
+
schema: { type: Object, required: true },
|
|
10
|
+
modelValue: { default: null },
|
|
11
|
+
path: { type: Array, default: () => [] },
|
|
12
|
+
form: { type: Object, default: null },
|
|
13
|
+
},
|
|
14
|
+
emits: ['update:modelValue'],
|
|
15
|
+
computed: {
|
|
16
|
+
resolvedValue() {
|
|
17
|
+
if ('const' in this.schema) return this.schema.const;
|
|
18
|
+
if (this.schema.enum && this.schema.enum.length === 1) return this.schema.enum[0];
|
|
19
|
+
return this.modelValue;
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
mounted() {
|
|
23
|
+
if (this.resolvedValue !== this.modelValue) {
|
|
24
|
+
this.$emit('update:modelValue', this.resolvedValue);
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
methods: {
|
|
28
|
+
getValue() {
|
|
29
|
+
return this.resolvedValue;
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
</script>
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="sf-nullable" :class="{ errors: fieldErrors.length }">
|
|
3
|
+
<div class="sf-nullable-header">
|
|
4
|
+
<label class="sf-label" :class="{ required: isRequired }">{{ title }}</label>
|
|
5
|
+
<button type="button" :class="toggleClass" @click="toggle">
|
|
6
|
+
<template v-if="isNull">
|
|
7
|
+
<i class="fas fa-plus"></i> Add
|
|
8
|
+
</template>
|
|
9
|
+
<template v-else>
|
|
10
|
+
<i class="fas fa-times"></i> Remove
|
|
11
|
+
</template>
|
|
12
|
+
</button>
|
|
13
|
+
</div>
|
|
14
|
+
<div class="sf-nullable-body">
|
|
15
|
+
<SchemaEditor
|
|
16
|
+
v-if="!isNull"
|
|
17
|
+
:schema="innerSchema"
|
|
18
|
+
:model-value="modelValue"
|
|
19
|
+
:path="path"
|
|
20
|
+
:form="form"
|
|
21
|
+
@update:model-value="$emit('update:modelValue', $event)"
|
|
22
|
+
/>
|
|
23
|
+
</div>
|
|
24
|
+
<ul v-if="fieldErrors.length" class="errorlist">
|
|
25
|
+
<li v-for="(err, i) in fieldErrors" :key="i">{{ err }}</li>
|
|
26
|
+
</ul>
|
|
27
|
+
</div>
|
|
28
|
+
</template>
|
|
29
|
+
|
|
30
|
+
<script>
|
|
31
|
+
import { defineAsyncComponent } from 'vue';
|
|
32
|
+
import { getDefaultForSchema } from '../utils';
|
|
33
|
+
|
|
34
|
+
export default {
|
|
35
|
+
name: 'NullableEditor',
|
|
36
|
+
components: { SchemaEditor: defineAsyncComponent(() => import('./SchemaEditor.vue')) },
|
|
37
|
+
props: {
|
|
38
|
+
schema: { type: Object, required: true },
|
|
39
|
+
modelValue: { default: null },
|
|
40
|
+
path: { type: Array, default: () => [] },
|
|
41
|
+
form: { type: Object, default: null },
|
|
42
|
+
},
|
|
43
|
+
emits: ['update:modelValue'],
|
|
44
|
+
data() {
|
|
45
|
+
return {
|
|
46
|
+
isNull: this.modelValue === null || this.modelValue === undefined,
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
computed: {
|
|
50
|
+
title() {
|
|
51
|
+
return this.schema.title || this.humanize(this.path[this.path.length - 1]) || '';
|
|
52
|
+
},
|
|
53
|
+
isRequired() {
|
|
54
|
+
if (this.path.length < 2 || !this.form) return false;
|
|
55
|
+
const parentPath = this.path.slice(0, -1);
|
|
56
|
+
const fieldName = this.path[this.path.length - 1];
|
|
57
|
+
const parentSchema = this.form.getSchemaAtPath(parentPath);
|
|
58
|
+
return parentSchema && Array.isArray(parentSchema.required) && parentSchema.required.includes(fieldName);
|
|
59
|
+
},
|
|
60
|
+
innerSchema() {
|
|
61
|
+
const s = { ...this.schema };
|
|
62
|
+
delete s._nullable;
|
|
63
|
+
return s;
|
|
64
|
+
},
|
|
65
|
+
toggleClass() {
|
|
66
|
+
return this.isNull ? 'sf-btn sf-btn-sm sf-btn-add' : 'sf-btn sf-btn-sm sf-btn-danger';
|
|
67
|
+
},
|
|
68
|
+
fieldErrors() {
|
|
69
|
+
if (!this.form || !this.form.getErrorsForPath) return [];
|
|
70
|
+
return this.form.getErrorsForPath(this.path);
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
watch: {
|
|
74
|
+
modelValue(val) {
|
|
75
|
+
this.isNull = val === null || val === undefined;
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
methods: {
|
|
79
|
+
humanize(str) {
|
|
80
|
+
if (!str) return '';
|
|
81
|
+
return str.replace(/_/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, s => s.toUpperCase());
|
|
82
|
+
},
|
|
83
|
+
toggle() {
|
|
84
|
+
if (this.isNull) {
|
|
85
|
+
this.isNull = false;
|
|
86
|
+
this.$emit('update:modelValue', getDefaultForSchema(this.innerSchema));
|
|
87
|
+
} else {
|
|
88
|
+
this.isNull = true;
|
|
89
|
+
this.$emit('update:modelValue', null);
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
</script>
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="sf-field" :class="{ errors: fieldErrors.length }">
|
|
3
|
+
<label class="sf-label" :class="{ required: isRequired }">{{ title }}</label>
|
|
4
|
+
<input
|
|
5
|
+
type="number"
|
|
6
|
+
class="sf-input"
|
|
7
|
+
:step="schema.type === 'integer' ? '1' : 'any'"
|
|
8
|
+
:min="schema.minimum != null ? String(schema.minimum) : undefined"
|
|
9
|
+
:max="schema.maximum != null ? String(schema.maximum) : undefined"
|
|
10
|
+
:value="modelValue != null ? modelValue : ''"
|
|
11
|
+
@input="onInput"
|
|
12
|
+
/>
|
|
13
|
+
<ul v-if="fieldErrors.length" class="errorlist">
|
|
14
|
+
<li v-for="(err, i) in fieldErrors" :key="i">{{ err }}</li>
|
|
15
|
+
</ul>
|
|
16
|
+
</div>
|
|
17
|
+
</template>
|
|
18
|
+
|
|
19
|
+
<script>
|
|
20
|
+
export default {
|
|
21
|
+
name: 'NumberEditor',
|
|
22
|
+
props: {
|
|
23
|
+
schema: { type: Object, required: true },
|
|
24
|
+
modelValue: { default: 0 },
|
|
25
|
+
path: { type: Array, default: () => [] },
|
|
26
|
+
form: { type: Object, default: null },
|
|
27
|
+
},
|
|
28
|
+
emits: ['update:modelValue'],
|
|
29
|
+
computed: {
|
|
30
|
+
title() {
|
|
31
|
+
return this.schema.title || this.humanize(this.path[this.path.length - 1]) || '';
|
|
32
|
+
},
|
|
33
|
+
isRequired() {
|
|
34
|
+
if (this.path.length < 2 || !this.form) return false;
|
|
35
|
+
const parentPath = this.path.slice(0, -1);
|
|
36
|
+
const fieldName = this.path[this.path.length - 1];
|
|
37
|
+
const parentSchema = this.form.getSchemaAtPath(parentPath);
|
|
38
|
+
return parentSchema && Array.isArray(parentSchema.required) && parentSchema.required.includes(fieldName);
|
|
39
|
+
},
|
|
40
|
+
fieldErrors() {
|
|
41
|
+
if (!this.form || !this.form.getErrorsForPath) return [];
|
|
42
|
+
return this.form.getErrorsForPath(this.path);
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
methods: {
|
|
46
|
+
humanize(str) {
|
|
47
|
+
if (!str) return '';
|
|
48
|
+
return str.replace(/_/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, s => s.toUpperCase());
|
|
49
|
+
},
|
|
50
|
+
onInput(e) {
|
|
51
|
+
const raw = e.target.value;
|
|
52
|
+
if (raw === '') {
|
|
53
|
+
this.$emit('update:modelValue', 0);
|
|
54
|
+
} else {
|
|
55
|
+
this.$emit('update:modelValue', this.schema.type === 'integer' ? parseInt(raw, 10) : parseFloat(raw));
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
</script>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div v-if="isRoot" class="sf-object sf-object-root">
|
|
3
|
+
<div class="sf-object-fields">
|
|
4
|
+
<SchemaEditor
|
|
5
|
+
v-for="(propSchema, key) in (schema.properties || {})"
|
|
6
|
+
:key="key"
|
|
7
|
+
:schema="form.resolveSchema(propSchema)"
|
|
8
|
+
:model-value="(modelValue || {})[key]"
|
|
9
|
+
:path="[...path, key]"
|
|
10
|
+
:form="form"
|
|
11
|
+
@update:model-value="onChildChange(key, $event)"
|
|
12
|
+
/>
|
|
13
|
+
</div>
|
|
14
|
+
</div>
|
|
15
|
+
<fieldset v-else class="sf-object">
|
|
16
|
+
<legend class="sf-object-title">{{ title }}</legend>
|
|
17
|
+
<div class="sf-object-fields">
|
|
18
|
+
<SchemaEditor
|
|
19
|
+
v-for="(propSchema, key) in (schema.properties || {})"
|
|
20
|
+
:key="key"
|
|
21
|
+
:schema="form.resolveSchema(propSchema)"
|
|
22
|
+
:model-value="(modelValue || {})[key]"
|
|
23
|
+
:path="[...path, key]"
|
|
24
|
+
:form="form"
|
|
25
|
+
@update:model-value="onChildChange(key, $event)"
|
|
26
|
+
/>
|
|
27
|
+
</div>
|
|
28
|
+
</fieldset>
|
|
29
|
+
</template>
|
|
30
|
+
|
|
31
|
+
<script>
|
|
32
|
+
import { defineAsyncComponent } from 'vue';
|
|
33
|
+
|
|
34
|
+
export default {
|
|
35
|
+
name: 'ObjectEditor',
|
|
36
|
+
components: { SchemaEditor: defineAsyncComponent(() => import('./SchemaEditor.vue')) },
|
|
37
|
+
props: {
|
|
38
|
+
schema: { type: Object, required: true },
|
|
39
|
+
modelValue: { default: () => ({}) },
|
|
40
|
+
path: { type: Array, default: () => [] },
|
|
41
|
+
form: { type: Object, required: true },
|
|
42
|
+
},
|
|
43
|
+
emits: ['update:modelValue'],
|
|
44
|
+
computed: {
|
|
45
|
+
isRoot() {
|
|
46
|
+
return this.path.length === 0;
|
|
47
|
+
},
|
|
48
|
+
title() {
|
|
49
|
+
return this.schema.title || this.humanize(this.path[this.path.length - 1]) || '';
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
methods: {
|
|
53
|
+
humanize(str) {
|
|
54
|
+
if (!str) return '';
|
|
55
|
+
return str.replace(/_/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, s => s.toUpperCase());
|
|
56
|
+
},
|
|
57
|
+
onChildChange(key, value) {
|
|
58
|
+
const newVal = { ...(this.modelValue || {}), [key]: value };
|
|
59
|
+
this.$emit('update:modelValue', newVal);
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
</script>
|