@structured-field/widget-editor 1.2.2 → 1.3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@structured-field/widget-editor",
3
- "version": "1.2.2",
3
+ "version": "1.3.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
6
  "main": "dist/structured-widget-editor.js",
@@ -27,6 +27,8 @@
27
27
  "scripts": {
28
28
  "build": "rollup -c && node scripts/bundle-iife.mjs",
29
29
  "dev": "rollup -c -w",
30
+ "test": "vitest run",
31
+ "test:watch": "vitest",
30
32
  "test:serve": "vite --open"
31
33
  },
32
34
  "keywords": [
@@ -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
- return {
93
- ...resolved,
94
- _nullable: true,
95
- title: schema.title || resolved.title,
96
- default: 'default' in schema ? schema.default : null,
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
- return {
110
- ...resolved,
111
- _nullable: true,
112
- title: schema.title || resolved.title,
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)">&#x2715;</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>
@@ -56,6 +56,9 @@ export default {
56
56
  data() {
57
57
  return {
58
58
  collapsed: false,
59
+ // Values pruned when a conditional rule deactivated their field,
60
+ // kept so toggling the controller back restores what the user typed.
61
+ prunedStash: {},
59
62
  };
60
63
  },
61
64
  computed: {
@@ -103,10 +106,21 @@ export default {
103
106
  const allowed = new Set(Object.keys(effective.properties || {}));
104
107
  let changed = false;
105
108
  const out = {};
109
+ // restore stashed values for fields a rule just re-activated
110
+ for (const k of allowed) {
111
+ if (!(k in value) && k in this.prunedStash) {
112
+ out[k] = this.prunedStash[k];
113
+ delete this.prunedStash[k];
114
+ changed = true;
115
+ }
116
+ }
106
117
  for (const k of Object.keys(value)) {
107
118
  if (allowed.has(k)) {
108
119
  out[k] = value[k];
109
120
  } else {
121
+ // pruned from the emitted value (documented behavior), but kept
122
+ // locally so a controller toggle round-trip is not destructive
123
+ this.prunedStash[k] = value[k];
110
124
  changed = true;
111
125
  }
112
126
  }
@@ -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,6 +35,7 @@ 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';
38
+ import { isChoiceOneOf } from '../utils';
37
39
 
38
40
  const MAX_DEPTH = 12;
39
41
 
@@ -43,6 +45,7 @@ export default {
43
45
  StringEditor,
44
46
  NumberEditor,
45
47
  BooleanEditor,
48
+ DateEditor,
46
49
  SelectEditor,
47
50
  HiddenEditor,
48
51
  ObjectEditor,
@@ -92,6 +95,9 @@ export default {
92
95
 
93
96
  if (schema.type === 'relation') return 'RelationEditor';
94
97
  if (schema.oneOf && schema.discriminator) return 'UnionEditor';
98
+ // Choice-list oneOf ({const, title} options) renders as a select —
99
+ // must be checked before the 'const' HiddenEditor routing.
100
+ if (isChoiceOneOf(schema.oneOf)) return 'SelectEditor';
95
101
  if ('const' in schema) return 'HiddenEditor';
96
102
  if (schema.enum && schema.enum.length === 1 && schema.type === 'string') return 'HiddenEditor';
97
103
  if (schema._nullable && (schema.type === 'object' || schema.type === 'array')) return 'NullableEditor';
@@ -101,6 +107,7 @@ export default {
101
107
  if (schema.enum) return 'SelectEditor';
102
108
  if (schema.type === 'boolean') return 'BooleanEditor';
103
109
  if (schema.type === 'number' || schema.type === 'integer') return 'NumberEditor';
110
+ if (schema.type === 'string' && (schema.format === 'date' || schema.format === 'date-time')) return 'DateEditor';
104
111
 
105
112
  return 'StringEditor';
106
113
  },
@@ -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="isNullValue ? '' : (modelValue != null ? String(modelValue) : '')"
11
- @change="$emit('update:modelValue', $event.target.value)"
10
+ :value="selectedIndex === -1 ? '' : String(selectedIndex)"
11
+ @change="onChange($event.target.value)"
12
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 }}
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)">&#x2715;</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';
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') {