@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/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>