@structured-field/widget-editor 1.2.0 → 1.2.2
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 +521 -184
- 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 +1 -1
- package/src/SchemaForm.vue +2 -0
- package/src/editors/BooleanEditor.vue +19 -9
- package/src/editors/JsonEditor.vue +173 -0
- package/src/editors/NullableEditor.vue +1 -1
- package/src/editors/NumberEditor.vue +23 -10
- package/src/editors/RelationEditor.vue +6 -1
- package/src/editors/SchemaEditor.vue +4 -1
- package/src/editors/SelectEditor.vue +23 -10
- package/src/editors/StringEditor.vue +29 -17
- package/src/editors/UnionEditor.vue +1 -1
- package/src/scss/components/editors.scss +117 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@structured-field/widget-editor",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.2",
|
|
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",
|
package/src/SchemaForm.vue
CHANGED
|
@@ -24,12 +24,14 @@ export default {
|
|
|
24
24
|
initialData: { default: undefined },
|
|
25
25
|
errors: { type: Object, default: () => ({}) },
|
|
26
26
|
customEditors: { type: Array, default: () => [] },
|
|
27
|
+
language: { type: String, default: '' },
|
|
27
28
|
},
|
|
28
29
|
emits: ['change'],
|
|
29
30
|
expose: ['getValue'],
|
|
30
31
|
provide() {
|
|
31
32
|
return {
|
|
32
33
|
customEditors: () => this.customEditors,
|
|
34
|
+
language: () => this.language,
|
|
33
35
|
};
|
|
34
36
|
},
|
|
35
37
|
data() {
|
|
@@ -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,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);
|
|
@@ -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">
|
|
@@ -46,6 +46,9 @@ import SfIcon from './SfIcon.vue';
|
|
|
46
46
|
export default {
|
|
47
47
|
name: 'RelationEditor',
|
|
48
48
|
components: { SfIcon },
|
|
49
|
+
inject: {
|
|
50
|
+
language: { from: 'language', default: () => () => '' },
|
|
51
|
+
},
|
|
49
52
|
props: {
|
|
50
53
|
schema: { type: Object, required: true },
|
|
51
54
|
modelValue: { default: null },
|
|
@@ -151,6 +154,8 @@ export default {
|
|
|
151
154
|
const url = new URL(this.searchUrl, window.location.origin);
|
|
152
155
|
url.searchParams.set('_q', query || '');
|
|
153
156
|
url.searchParams.set('page', String(page));
|
|
157
|
+
const lang = typeof this.language === 'function' ? this.language() : this.language;
|
|
158
|
+
if (lang) url.searchParams.set('_lang', lang);
|
|
154
159
|
|
|
155
160
|
const response = await fetch(url, { credentials: 'same-origin' });
|
|
156
161
|
if (!response.ok) return;
|
|
@@ -32,6 +32,7 @@ import ArrayEditor from './ArrayEditor.vue';
|
|
|
32
32
|
import NullableEditor from './NullableEditor.vue';
|
|
33
33
|
import UnionEditor from './UnionEditor.vue';
|
|
34
34
|
import RelationEditor from './RelationEditor.vue';
|
|
35
|
+
import JsonEditor from './JsonEditor.vue';
|
|
35
36
|
import WebComponentWrapper from './WebComponentWrapper.vue';
|
|
36
37
|
|
|
37
38
|
const MAX_DEPTH = 12;
|
|
@@ -49,6 +50,7 @@ export default {
|
|
|
49
50
|
NullableEditor,
|
|
50
51
|
UnionEditor,
|
|
51
52
|
RelationEditor,
|
|
53
|
+
JsonEditor,
|
|
52
54
|
WebComponentWrapper,
|
|
53
55
|
},
|
|
54
56
|
inject: {
|
|
@@ -92,8 +94,9 @@ export default {
|
|
|
92
94
|
if (schema.oneOf && schema.discriminator) return 'UnionEditor';
|
|
93
95
|
if ('const' in schema) return 'HiddenEditor';
|
|
94
96
|
if (schema.enum && schema.enum.length === 1 && schema.type === 'string') return 'HiddenEditor';
|
|
95
|
-
if (schema._nullable) return 'NullableEditor';
|
|
97
|
+
if (schema._nullable && (schema.type === 'object' || schema.type === 'array')) return 'NullableEditor';
|
|
96
98
|
if (schema.type === 'object' && schema.properties) return 'ObjectEditor';
|
|
99
|
+
if (schema.type === 'object') return 'JsonEditor';
|
|
97
100
|
if (schema.type === 'array') return 'ArrayEditor';
|
|
98
101
|
if (schema.enum) return 'SelectEditor';
|
|
99
102
|
if (schema.type === 'boolean') return 'BooleanEditor';
|
|
@@ -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="isNullValue ? '' : (modelValue != null ? String(modelValue) : '')"
|
|
11
|
+
@change="$emit('update:modelValue', $event.target.value)"
|
|
12
|
+
>
|
|
13
|
+
<option v-if="isNullable && isNullValue" value="" disabled selected>null</option>
|
|
14
|
+
<option v-for="opt in (schema.enum || [])" :key="opt" :value="String(opt)">
|
|
15
|
+
{{ opt }}
|
|
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>
|
|
@@ -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);
|
|
@@ -1,22 +1,28 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div class="sf-field" :class="{ errors: fieldErrors.length }">
|
|
3
|
-
<
|
|
4
|
-
|
|
5
|
-
v-if="
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
+
<textarea
|
|
9
|
+
v-if="isLong"
|
|
10
|
+
class="sf-input sf-textarea"
|
|
11
|
+
rows="3"
|
|
12
|
+
:value="isNullValue ? '' : modelValue"
|
|
13
|
+
:placeholder="isNullValue ? 'null' : (schema.placeholder || '')"
|
|
14
|
+
@input="$emit('update:modelValue', $event.target.value)"
|
|
15
|
+
/>
|
|
16
|
+
<input
|
|
17
|
+
v-else
|
|
18
|
+
type="text"
|
|
19
|
+
class="sf-input"
|
|
20
|
+
:value="isNullValue ? '' : (modelValue != null ? String(modelValue) : '')"
|
|
21
|
+
:placeholder="isNullValue ? 'null' : (schema.placeholder || '')"
|
|
22
|
+
@input="$emit('update:modelValue', $event.target.value)"
|
|
23
|
+
/>
|
|
24
|
+
<button v-if="isNullable && !isNullValue" type="button" class="sf-null-clear-btn" title="Set to null" @click="$emit('update:modelValue', null)">✕</button>
|
|
25
|
+
</div>
|
|
20
26
|
<ul v-if="fieldErrors.length" class="errorlist">
|
|
21
27
|
<li v-for="(err, i) in fieldErrors" :key="i">{{ err }}</li>
|
|
22
28
|
</ul>
|
|
@@ -47,6 +53,12 @@ export default {
|
|
|
47
53
|
isLong() {
|
|
48
54
|
return this.schema.maxLength > 255 || this.schema.format === 'textarea';
|
|
49
55
|
},
|
|
56
|
+
isNullable() {
|
|
57
|
+
return !!this.schema._nullable;
|
|
58
|
+
},
|
|
59
|
+
isNullValue() {
|
|
60
|
+
return this.modelValue === null || this.modelValue === undefined;
|
|
61
|
+
},
|
|
50
62
|
fieldErrors() {
|
|
51
63
|
if (!this.form || !this.form.getErrorsForPath) return [];
|
|
52
64
|
return this.form.getErrorsForPath(this.path);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div class="sf-union">
|
|
3
3
|
<div class="sf-field">
|
|
4
|
-
<
|
|
4
|
+
<span class="sf-label">{{ title }}</span>
|
|
5
5
|
<select class="sf-input sf-select" :value="currentType" @change="onTypeChange">
|
|
6
6
|
<option v-for="key in typeKeys" :key="key" :value="key">{{ humanize(key) }}</option>
|
|
7
7
|
</select>
|
|
@@ -131,7 +131,8 @@
|
|
|
131
131
|
border: 1px solid var(--border-color, #ccc);
|
|
132
132
|
border-radius: 4px;
|
|
133
133
|
background: var(--darkened-bg, #f8f8f8);
|
|
134
|
-
overflow:
|
|
134
|
+
overflow: visible;
|
|
135
|
+
position: relative;
|
|
135
136
|
}
|
|
136
137
|
|
|
137
138
|
.sf-array-item {
|
|
@@ -156,6 +157,7 @@
|
|
|
156
157
|
padding: 4px 8px;
|
|
157
158
|
background: var(--darkened-bg, #f0f0f0);
|
|
158
159
|
border-bottom: 1px solid var(--border-color, #ccc);
|
|
160
|
+
border-radius: 4px 4px 0 0;
|
|
159
161
|
}
|
|
160
162
|
|
|
161
163
|
.sf-array-item-left {
|
|
@@ -204,6 +206,63 @@
|
|
|
204
206
|
}
|
|
205
207
|
}
|
|
206
208
|
|
|
209
|
+
// --- Inline nullable controls ---
|
|
210
|
+
.sf-null-badge {
|
|
211
|
+
display: inline-block;
|
|
212
|
+
font-size: 0.65rem;
|
|
213
|
+
font-weight: 600;
|
|
214
|
+
text-transform: uppercase;
|
|
215
|
+
letter-spacing: 0.04em;
|
|
216
|
+
color: var(--body-quiet-color, #888);
|
|
217
|
+
background: var(--darkened-bg, #f0f0f0);
|
|
218
|
+
border: 1px solid var(--border-color, #ccc);
|
|
219
|
+
border-radius: 3px;
|
|
220
|
+
padding: 1px 5px;
|
|
221
|
+
vertical-align: middle;
|
|
222
|
+
margin-left: 4px;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
.sf-input-row {
|
|
226
|
+
display: flex;
|
|
227
|
+
align-items: stretch;
|
|
228
|
+
gap: 4px;
|
|
229
|
+
|
|
230
|
+
.sf-input {
|
|
231
|
+
flex: 1;
|
|
232
|
+
min-width: 0;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
.sf-boolean-row {
|
|
237
|
+
display: flex;
|
|
238
|
+
align-items: center;
|
|
239
|
+
gap: 6px;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
.sf-null-clear-btn {
|
|
243
|
+
flex-shrink: 0;
|
|
244
|
+
display: inline-flex;
|
|
245
|
+
align-items: center;
|
|
246
|
+
justify-content: center;
|
|
247
|
+
width: 26px;
|
|
248
|
+
height: 26px;
|
|
249
|
+
padding: 0;
|
|
250
|
+
border: 1px solid var(--border-color, #ccc);
|
|
251
|
+
border-radius: 4px;
|
|
252
|
+
background: transparent;
|
|
253
|
+
cursor: pointer;
|
|
254
|
+
color: var(--body-quiet-color, #888);
|
|
255
|
+
font-size: 0.75rem;
|
|
256
|
+
line-height: 1;
|
|
257
|
+
align-self: center;
|
|
258
|
+
|
|
259
|
+
&:hover {
|
|
260
|
+
background: var(--error-bg, #fff2f2);
|
|
261
|
+
border-color: var(--error-fg, #ba2121);
|
|
262
|
+
color: var(--error-fg, #ba2121);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
207
266
|
// --- Nullable editor ---
|
|
208
267
|
.sf-nullable {
|
|
209
268
|
margin-bottom: 12px;
|
|
@@ -232,6 +291,63 @@
|
|
|
232
291
|
}
|
|
233
292
|
}
|
|
234
293
|
|
|
294
|
+
// --- JSON editor ---
|
|
295
|
+
.sf-field-json {
|
|
296
|
+
.sf-json-editor {
|
|
297
|
+
position: relative;
|
|
298
|
+
border: 1px solid var(--border-color, #ccc);
|
|
299
|
+
border-radius: 4px;
|
|
300
|
+
overflow: hidden;
|
|
301
|
+
|
|
302
|
+
&.sf-json-error {
|
|
303
|
+
border-color: var(--error-fg, #ba2121);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
.sf-json-toolbar {
|
|
308
|
+
display: flex;
|
|
309
|
+
align-items: center;
|
|
310
|
+
justify-content: flex-end;
|
|
311
|
+
gap: 8px;
|
|
312
|
+
padding: 4px 6px;
|
|
313
|
+
background: var(--darkened-bg, #f8f8f8);
|
|
314
|
+
border-bottom: 1px solid var(--border-color, #ccc);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
.sf-json-error-msg {
|
|
318
|
+
font-size: 0.72rem;
|
|
319
|
+
color: var(--error-fg, #ba2121);
|
|
320
|
+
flex: 1;
|
|
321
|
+
white-space: nowrap;
|
|
322
|
+
overflow: hidden;
|
|
323
|
+
text-overflow: ellipsis;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
.sf-json-format-btn {
|
|
327
|
+
flex-shrink: 0;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
.sf-json-ace-container {
|
|
331
|
+
width: 100%;
|
|
332
|
+
font-size: 0.8rem;
|
|
333
|
+
// Ace sets its own height via maxLines/minLines
|
|
334
|
+
.ace_editor {
|
|
335
|
+
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace !important;
|
|
336
|
+
font-size: 0.8rem !important;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
.sf-json-textarea-fallback {
|
|
341
|
+
width: 100%;
|
|
342
|
+
min-height: 120px;
|
|
343
|
+
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
|
344
|
+
font-size: 0.8rem;
|
|
345
|
+
border: none;
|
|
346
|
+
border-radius: 0;
|
|
347
|
+
resize: vertical;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
235
351
|
// --- Union editor ---
|
|
236
352
|
.sf-union {
|
|
237
353
|
margin-bottom: 12px;
|