@structured-field/widget-editor 1.2.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@structured-field/widget-editor",
3
- "version": "1.2.1",
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",
@@ -1,14 +1,18 @@
1
1
  <template>
2
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>
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)">&#x2715;</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
- <label class="sf-label" :class="{ required: isRequired }">{{ title }}</label>
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
- <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
- />
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)">&#x2715;</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
- <label class="sf-label" :class="{ required: isRequired }">{{ title }}</label>
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">
@@ -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
- <label class="sf-label" :class="{ required: isRequired }">{{ title }}</label>
4
- <select
5
- class="sf-input sf-select"
6
- :value="modelValue != null ? String(modelValue) : ''"
7
- @change="$emit('update:modelValue', $event.target.value)"
8
- >
9
- <option v-for="opt in (schema.enum || [])" :key="opt" :value="String(opt)">
10
- {{ opt }}
11
- </option>
12
- </select>
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)">&#x2715;</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
- <label class="sf-label" :class="{ required: isRequired }">{{ title }}</label>
4
- <textarea
5
- v-if="isLong"
6
- class="sf-input sf-textarea"
7
- rows="3"
8
- :value="modelValue"
9
- :placeholder="schema.placeholder || ''"
10
- @input="$emit('update:modelValue', $event.target.value)"
11
- />
12
- <input
13
- v-else
14
- type="text"
15
- class="sf-input"
16
- :value="modelValue != null ? String(modelValue) : ''"
17
- :placeholder="schema.placeholder || ''"
18
- @input="$emit('update:modelValue', $event.target.value)"
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)">&#x2715;</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
- <label class="sf-label">{{ title }}</label>
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>
@@ -206,6 +206,63 @@
206
206
  }
207
207
  }
208
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
+
209
266
  // --- Nullable editor ---
210
267
  .sf-nullable {
211
268
  margin-bottom: 12px;
@@ -234,6 +291,63 @@
234
291
  }
235
292
  }
236
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
+
237
351
  // --- Union editor ---
238
352
  .sf-union {
239
353
  margin-bottom: 12px;