@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.
@@ -0,0 +1,208 @@
1
+ <template>
2
+ <div class="sf-field sf-relation" :class="{ errors: fieldErrors.length }" ref="root">
3
+ <label class="sf-label" :class="{ required: isRequired }">{{ title }}</label>
4
+ <div class="sf-relation-wrapper">
5
+ <!-- Selected items -->
6
+ <div v-if="selected.length" class="sf-relation-selected">
7
+ <div v-for="item in selected" :key="itemKey(item)" class="sf-relation-tag">
8
+ <span class="sf-relation-tag-text">{{ getDisplayName(item) }}</span>
9
+ <button v-if="isMultiple || allowClear" type="button" class="sf-relation-tag-remove"
10
+ @click.stop="removeItem(item)">
11
+ <i class="fas fa-times"></i>
12
+ </button>
13
+ </div>
14
+ </div>
15
+
16
+ <!-- Search box -->
17
+ <div v-show="showSearch" class="sf-relation-search">
18
+ <input ref="searchInput" type="text" class="sf-input sf-relation-input" :placeholder="placeholder"
19
+ autocomplete="off" v-model="searchQuery" @input="onSearchInput" @focus="openDropdown"
20
+ @keydown="handleKeyDown" />
21
+ <!-- Dropdown -->
22
+ <div v-show="dropdownVisible" class="sf-relation-dropdown">
23
+ <div v-if="filteredResults.length === 0 && !loading" class="sf-relation-dropdown-empty">
24
+ No results found
25
+ </div>
26
+ <div v-for="(item, i) in filteredResults" :key="itemKey(item)" class="sf-relation-dropdown-item"
27
+ :class="{ highlighted: i === highlightIndex }" @click="selectItem(item)">
28
+ {{ getDisplayName(item) }}
29
+ </div>
30
+ <div v-if="hasMore" class="sf-relation-dropdown-more" @click="fetchResults(searchQuery, currentPage + 1)">
31
+ Load more...
32
+ </div>
33
+ </div>
34
+ </div>
35
+ </div>
36
+ <ul v-if="fieldErrors.length" class="errorlist">
37
+ <li v-for="(err, i) in fieldErrors" :key="i">{{ err }}</li>
38
+ </ul>
39
+ </div>
40
+ </template>
41
+
42
+ <script>
43
+ import { debounce } from '../utils';
44
+
45
+ export default {
46
+ name: 'RelationEditor',
47
+ props: {
48
+ schema: { type: Object, required: true },
49
+ modelValue: { default: null },
50
+ path: { type: Array, default: () => [] },
51
+ form: { type: Object, default: null },
52
+ },
53
+ emits: ['update:modelValue'],
54
+ data() {
55
+ const isMultiple = !!this.schema.multiple;
56
+ const value = this.modelValue;
57
+ let selected;
58
+ if (isMultiple) {
59
+ selected = Array.isArray(value) ? [...value] : [];
60
+ } else {
61
+ selected = value ? [value] : [];
62
+ }
63
+ return {
64
+ isMultiple,
65
+ allowClear: this.schema.options?.select2?.allowClear ?? true,
66
+ placeholder: this.schema.options?.select2?.placeholder || 'Search...',
67
+ searchUrl: this.schema.options?.select2?.ajax?.url || '',
68
+ selected,
69
+ dropdownVisible: false,
70
+ searchResults: [],
71
+ currentPage: 1,
72
+ hasMore: false,
73
+ loading: false,
74
+ highlightIndex: -1,
75
+ searchQuery: '',
76
+ };
77
+ },
78
+ computed: {
79
+ title() {
80
+ return this.schema.title || this.humanize(this.path[this.path.length - 1]) || '';
81
+ },
82
+ isRequired() {
83
+ if (this.path.length < 2 || !this.form) return false;
84
+ const parentPath = this.path.slice(0, -1);
85
+ const fieldName = this.path[this.path.length - 1];
86
+ const parentSchema = this.form.getSchemaAtPath(parentPath);
87
+ return parentSchema && Array.isArray(parentSchema.required) && parentSchema.required.includes(fieldName);
88
+ },
89
+ showSearch() {
90
+ if (!this.isMultiple && this.selected.length > 0) return false;
91
+ return true;
92
+ },
93
+ fieldErrors() {
94
+ if (!this.form || !this.form.getErrorsForPath) return [];
95
+ return this.form.getErrorsForPath(this.path);
96
+ },
97
+ filteredResults() {
98
+ const selectedIds = new Set(this.selected.map(s => `${s.id}-${s.model || ''}`));
99
+ return this.searchResults.filter(item => !selectedIds.has(`${item.id}-${item.model || ''}`));
100
+ },
101
+ },
102
+ created() {
103
+ this._doSearch = debounce((query) => this.fetchResults(query, 1), 300);
104
+ this._onDocClick = (e) => {
105
+ if (this.$refs.root && !this.$refs.root.contains(e.target)) {
106
+ this.closeDropdown();
107
+ }
108
+ };
109
+ },
110
+ mounted() {
111
+ document.addEventListener('click', this._onDocClick);
112
+ },
113
+ beforeUnmount() {
114
+ document.removeEventListener('click', this._onDocClick);
115
+ },
116
+ methods: {
117
+ humanize(str) {
118
+ if (!str) return '';
119
+ return str.replace(/_/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, s => s.toUpperCase());
120
+ },
121
+ getDisplayName(item) {
122
+ if (!item) return '';
123
+ for (const key of ['__str__', 'name', 'title', 'label']) {
124
+ if (item[key]) return String(item[key]);
125
+ }
126
+ return `#${item.id || '?'}`;
127
+ },
128
+ itemKey(item) {
129
+ return `${item.id}-${item.model || ''}`;
130
+ },
131
+ onSearchInput() {
132
+ this._doSearch(this.searchQuery);
133
+ },
134
+ openDropdown() {
135
+ if (this.dropdownVisible) return;
136
+ this.dropdownVisible = true;
137
+ this.fetchResults(this.searchQuery, 1);
138
+ },
139
+ closeDropdown() {
140
+ this.dropdownVisible = false;
141
+ this.highlightIndex = -1;
142
+ },
143
+ async fetchResults(query, page) {
144
+ if (!this.searchUrl) return;
145
+ this.loading = true;
146
+ this.currentPage = page;
147
+
148
+ try {
149
+ const url = new URL(this.searchUrl, window.location.origin);
150
+ url.searchParams.set('_q', query || '');
151
+ url.searchParams.set('page', String(page));
152
+
153
+ const response = await fetch(url, { credentials: 'same-origin' });
154
+ if (!response.ok) return;
155
+ const data = await response.json();
156
+
157
+ if (page === 1) {
158
+ this.searchResults = data.items || [];
159
+ } else {
160
+ this.searchResults = this.searchResults.concat(data.items || []);
161
+ }
162
+ this.hasMore = data.more;
163
+ } finally {
164
+ this.loading = false;
165
+ }
166
+ },
167
+ handleKeyDown(e) {
168
+ if (!this.filteredResults.length) return;
169
+ if (e.key === 'ArrowDown') {
170
+ e.preventDefault();
171
+ this.highlightIndex = Math.min(this.highlightIndex + 1, this.filteredResults.length - 1);
172
+ } else if (e.key === 'ArrowUp') {
173
+ e.preventDefault();
174
+ this.highlightIndex = Math.max(this.highlightIndex - 1, 0);
175
+ } else if (e.key === 'Enter') {
176
+ e.preventDefault();
177
+ if (this.highlightIndex >= 0 && this.highlightIndex < this.filteredResults.length) {
178
+ this.selectItem(this.filteredResults[this.highlightIndex]);
179
+ }
180
+ } else if (e.key === 'Escape') {
181
+ this.closeDropdown();
182
+ }
183
+ },
184
+ selectItem(item) {
185
+ if (this.isMultiple) {
186
+ this.selected.push(item);
187
+ } else {
188
+ this.selected = [item];
189
+ }
190
+ this.searchQuery = '';
191
+ this.highlightIndex = -1;
192
+ this.closeDropdown();
193
+ this.emitValue();
194
+ },
195
+ removeItem(item) {
196
+ this.selected = this.selected.filter(s => !(s.id === item.id && (s.model || '') === (item.model || '')));
197
+ this.emitValue();
198
+ },
199
+ emitValue() {
200
+ if (this.isMultiple) {
201
+ this.$emit('update:modelValue', [...this.selected]);
202
+ } else {
203
+ this.$emit('update:modelValue', this.selected[0] || null);
204
+ }
205
+ },
206
+ },
207
+ };
208
+ </script>
@@ -0,0 +1,68 @@
1
+ <template>
2
+ <component
3
+ :is="editorComponent"
4
+ :schema="schema"
5
+ :model-value="modelValue"
6
+ :path="path"
7
+ :form="form"
8
+ @update:model-value="$emit('update:modelValue', $event)"
9
+ />
10
+ </template>
11
+
12
+ <script>
13
+ import StringEditor from './StringEditor.vue';
14
+ import NumberEditor from './NumberEditor.vue';
15
+ import BooleanEditor from './BooleanEditor.vue';
16
+ import SelectEditor from './SelectEditor.vue';
17
+ import HiddenEditor from './HiddenEditor.vue';
18
+ import ObjectEditor from './ObjectEditor.vue';
19
+ import ArrayEditor from './ArrayEditor.vue';
20
+ import NullableEditor from './NullableEditor.vue';
21
+ import UnionEditor from './UnionEditor.vue';
22
+ import RelationEditor from './RelationEditor.vue';
23
+
24
+ const MAX_DEPTH = 12;
25
+
26
+ export default {
27
+ name: 'SchemaEditor',
28
+ components: {
29
+ StringEditor,
30
+ NumberEditor,
31
+ BooleanEditor,
32
+ SelectEditor,
33
+ HiddenEditor,
34
+ ObjectEditor,
35
+ ArrayEditor,
36
+ NullableEditor,
37
+ UnionEditor,
38
+ RelationEditor,
39
+ },
40
+ props: {
41
+ schema: { type: Object, required: true },
42
+ modelValue: { default: undefined },
43
+ path: { type: Array, default: () => [] },
44
+ form: { type: Object, required: true },
45
+ },
46
+ emits: ['update:modelValue'],
47
+ computed: {
48
+ editorComponent() {
49
+ const schema = this.schema;
50
+
51
+ if (this.path.length > MAX_DEPTH) return 'StringEditor';
52
+
53
+ if (schema.type === 'relation') return 'RelationEditor';
54
+ if (schema.oneOf && schema.discriminator) return 'UnionEditor';
55
+ if ('const' in schema) return 'HiddenEditor';
56
+ if (schema.enum && schema.enum.length === 1 && schema.type === 'string') return 'HiddenEditor';
57
+ if (schema._nullable) return 'NullableEditor';
58
+ if (schema.type === 'object' && schema.properties) return 'ObjectEditor';
59
+ if (schema.type === 'array') return 'ArrayEditor';
60
+ if (schema.enum) return 'SelectEditor';
61
+ if (schema.type === 'boolean') return 'BooleanEditor';
62
+ if (schema.type === 'number' || schema.type === 'integer') return 'NumberEditor';
63
+
64
+ return 'StringEditor';
65
+ },
66
+ },
67
+ };
68
+ </script>
@@ -0,0 +1,52 @@
1
+ <template>
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>
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: 'SelectEditor',
22
+ props: {
23
+ schema: { type: Object, required: true },
24
+ modelValue: { default: '' },
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
+ },
51
+ };
52
+ </script>
@@ -0,0 +1,62 @@
1
+ <template>
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
+ />
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
+ export default {
28
+ name: 'StringEditor',
29
+ props: {
30
+ schema: { type: Object, required: true },
31
+ modelValue: { default: '' },
32
+ path: { type: Array, default: () => [] },
33
+ form: { type: Object, default: null },
34
+ },
35
+ emits: ['update:modelValue'],
36
+ computed: {
37
+ title() {
38
+ return this.schema.title || this.humanize(this.path[this.path.length - 1]) || '';
39
+ },
40
+ isRequired() {
41
+ if (this.path.length < 2 || !this.form) return false;
42
+ const parentPath = this.path.slice(0, -1);
43
+ const fieldName = this.path[this.path.length - 1];
44
+ const parentSchema = this.form.getSchemaAtPath(parentPath);
45
+ return parentSchema && Array.isArray(parentSchema.required) && parentSchema.required.includes(fieldName);
46
+ },
47
+ isLong() {
48
+ return this.schema.maxLength > 255 || this.schema.format === 'textarea';
49
+ },
50
+ fieldErrors() {
51
+ if (!this.form || !this.form.getErrorsForPath) return [];
52
+ return this.form.getErrorsForPath(this.path);
53
+ },
54
+ },
55
+ methods: {
56
+ humanize(str) {
57
+ if (!str) return '';
58
+ return str.replace(/_/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, s => s.toUpperCase());
59
+ },
60
+ },
61
+ };
62
+ </script>
@@ -0,0 +1,83 @@
1
+ <template>
2
+ <div class="sf-union">
3
+ <div class="sf-field">
4
+ <label class="sf-label">{{ title }}</label>
5
+ <select class="sf-input sf-select" :value="currentType" @change="onTypeChange">
6
+ <option v-for="key in typeKeys" :key="key" :value="key">{{ humanize(key) }}</option>
7
+ </select>
8
+ </div>
9
+ <div class="sf-union-body">
10
+ <SchemaEditor
11
+ :key="currentType"
12
+ :schema="currentSchema"
13
+ :model-value="innerValue"
14
+ :path="path"
15
+ :form="form"
16
+ @update:model-value="onInnerChange"
17
+ />
18
+ </div>
19
+ </div>
20
+ </template>
21
+
22
+ <script>
23
+ import { defineAsyncComponent } from 'vue';
24
+ import { getDefaultForSchema } from '../utils';
25
+
26
+ export default {
27
+ name: 'UnionEditor',
28
+ components: { SchemaEditor: defineAsyncComponent(() => import('./SchemaEditor.vue')) },
29
+ props: {
30
+ schema: { type: Object, required: true },
31
+ modelValue: { default: null },
32
+ path: { type: Array, default: () => [] },
33
+ form: { type: Object, required: true },
34
+ },
35
+ emits: ['update:modelValue'],
36
+ data() {
37
+ const discriminatorProp = this.schema.discriminator.propertyName;
38
+ const mapping = this.schema.discriminator.mapping;
39
+ const currentType = this.modelValue ? this.modelValue[discriminatorProp] : Object.keys(mapping)[0];
40
+ return {
41
+ discriminatorProp,
42
+ mapping,
43
+ currentType,
44
+ };
45
+ },
46
+ computed: {
47
+ title() {
48
+ return this.schema.title || this.humanize(this.path[this.path.length - 1]) || '';
49
+ },
50
+ typeKeys() {
51
+ return Object.keys(this.mapping);
52
+ },
53
+ currentSchema() {
54
+ const ref = this.mapping[this.currentType];
55
+ return this.form.resolveSchema({ $ref: ref });
56
+ },
57
+ innerValue() {
58
+ if (this.modelValue && this.modelValue[this.discriminatorProp] === this.currentType) {
59
+ return this.modelValue;
60
+ }
61
+ const def = getDefaultForSchema(this.currentSchema);
62
+ def[this.discriminatorProp] = this.currentType;
63
+ return def;
64
+ },
65
+ },
66
+ methods: {
67
+ humanize(str) {
68
+ if (!str) return '';
69
+ return str.replace(/_/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, s => s.toUpperCase());
70
+ },
71
+ onTypeChange(e) {
72
+ this.currentType = e.target.value;
73
+ const def = getDefaultForSchema(this.currentSchema);
74
+ def[this.discriminatorProp] = this.currentType;
75
+ this.$emit('update:modelValue', def);
76
+ },
77
+ onInnerChange(val) {
78
+ if (val) val[this.discriminatorProp] = this.currentType;
79
+ this.$emit('update:modelValue', val);
80
+ },
81
+ },
82
+ };
83
+ </script>
package/src/index.js ADDED
@@ -0,0 +1,155 @@
1
+ import './scss/main.scss';
2
+
3
+ export { default as SchemaForm } from './SchemaForm.vue';
4
+ export { default as SchemaEditor } from './editors/SchemaEditor.vue';
5
+ export { default as StringEditor } from './editors/StringEditor.vue';
6
+ export { default as NumberEditor } from './editors/NumberEditor.vue';
7
+ export { default as BooleanEditor } from './editors/BooleanEditor.vue';
8
+ export { default as SelectEditor } from './editors/SelectEditor.vue';
9
+ export { default as HiddenEditor } from './editors/HiddenEditor.vue';
10
+ export { default as ObjectEditor } from './editors/ObjectEditor.vue';
11
+ export { default as ArrayEditor } from './editors/ArrayEditor.vue';
12
+ export { default as NullableEditor } from './editors/NullableEditor.vue';
13
+ export { default as UnionEditor } from './editors/UnionEditor.vue';
14
+ export { default as RelationEditor } from './editors/RelationEditor.vue';
15
+
16
+ import { createApp, h, ref, reactive } from 'vue';
17
+ import SchemaFormComponent from './SchemaForm.vue';
18
+
19
+ export class SchemaFormElement extends HTMLElement {
20
+ constructor() {
21
+ super();
22
+ this._formRef = ref(null);
23
+ this._props = reactive({
24
+ schema: {},
25
+ initialData: undefined,
26
+ errors: {},
27
+ });
28
+ this._app = null;
29
+ this._mountPoint = null;
30
+ }
31
+
32
+ connectedCallback() {
33
+ this._mountPoint = document.createElement('div');
34
+ this.appendChild(this._mountPoint);
35
+
36
+ // Read from attributes if properties haven't been set programmatically
37
+ if (!this._propsSet) {
38
+ const schemaAttr = this.getAttribute('schema');
39
+ const dataAttr = this.getAttribute('initial-data');
40
+ if (schemaAttr) this._props.schema = JSON.parse(schemaAttr);
41
+ if (dataAttr) this._props.initialData = JSON.parse(dataAttr);
42
+ }
43
+
44
+ const formRef = this._formRef;
45
+ const props = this._props;
46
+
47
+ this._app = createApp({
48
+ render: () => h(SchemaFormComponent, {
49
+ ref: formRef,
50
+ schema: props.schema,
51
+ initialData: props.initialData,
52
+ errors: props.errors,
53
+ onChange: (val) => {
54
+ this.dispatchEvent(new CustomEvent('change', { detail: val, bubbles: true }));
55
+ },
56
+ }),
57
+ });
58
+
59
+ this._app.mount(this._mountPoint);
60
+ }
61
+
62
+ disconnectedCallback() {
63
+ if (this._app) {
64
+ this._app.unmount();
65
+ this._app = null;
66
+ }
67
+ this._mountPoint = null;
68
+ }
69
+
70
+ // --- Property API ---
71
+
72
+ get schema() {
73
+ return this._props.schema;
74
+ }
75
+
76
+ set schema(val) {
77
+ this._propsSet = true;
78
+ this._props.schema = typeof val === 'string' ? JSON.parse(val) : val;
79
+ this._rerender();
80
+ }
81
+
82
+ get initialData() {
83
+ return this._props.initialData;
84
+ }
85
+
86
+ set initialData(val) {
87
+ this._propsSet = true;
88
+ this._props.initialData = typeof val === 'string' ? JSON.parse(val) : val;
89
+ this._rerender();
90
+ }
91
+
92
+ get errors() {
93
+ return this._props.errors;
94
+ }
95
+
96
+ set errors(val) {
97
+ this._propsSet = true;
98
+ this._props.errors = typeof val === 'string' ? JSON.parse(val) : (val || {});
99
+ this._rerender();
100
+ }
101
+
102
+ // --- Attribute reflection ---
103
+
104
+ static get observedAttributes() {
105
+ return ['schema', 'initial-data', 'errors'];
106
+ }
107
+
108
+ attributeChangedCallback(name, oldVal, newVal) {
109
+ if (oldVal === newVal || this._propsSet) return;
110
+ if (name === 'schema') {
111
+ this._props.schema = newVal ? JSON.parse(newVal) : {};
112
+ } else if (name === 'initial-data') {
113
+ this._props.initialData = newVal ? JSON.parse(newVal) : undefined;
114
+ } else if (name === 'errors') {
115
+ this._props.errors = newVal ? JSON.parse(newVal) : {};
116
+ }
117
+ this._rerender();
118
+ }
119
+
120
+ // --- Public methods ---
121
+
122
+ getValue() {
123
+ return this._formRef.value?.getValue?.() ?? null;
124
+ }
125
+
126
+ // --- Internal ---
127
+
128
+ _rerender() {
129
+ if (!this._app) return;
130
+ this._app.unmount();
131
+ this._mountPoint.innerHTML = '';
132
+ const formRef = this._formRef;
133
+ const props = this._props;
134
+
135
+ this._app = createApp({
136
+ render: () => h(SchemaFormComponent, {
137
+ ref: formRef,
138
+ schema: props.schema,
139
+ initialData: props.initialData,
140
+ errors: props.errors,
141
+ onChange: (val) => {
142
+ this.dispatchEvent(new CustomEvent('change', { detail: val, bubbles: true }));
143
+ },
144
+ }),
145
+ });
146
+
147
+ this._app.mount(this._mountPoint);
148
+ }
149
+ }
150
+
151
+ export function registerCustomElement(tagName = 'schema-form') {
152
+ if (!customElements.get(tagName)) {
153
+ customElements.define(tagName, SchemaFormElement);
154
+ }
155
+ }