angular-data-mapper 1.0.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/.claude/settings.local.json +10 -0
- package/LICENSE +190 -0
- package/PUBLISHING.md +75 -0
- package/README.md +214 -0
- package/angular.json +121 -0
- package/package.json +67 -0
- package/projects/demo-app/public/favicon.ico +0 -0
- package/projects/demo-app/src/app/app.config.ts +12 -0
- package/projects/demo-app/src/app/app.html +36 -0
- package/projects/demo-app/src/app/app.routes.ts +62 -0
- package/projects/demo-app/src/app/app.scss +65 -0
- package/projects/demo-app/src/app/app.ts +11 -0
- package/projects/demo-app/src/app/layout/app-layout.component.ts +294 -0
- package/projects/demo-app/src/app/pages/mapper-page/mapper-page.component.html +87 -0
- package/projects/demo-app/src/app/pages/mapper-page/mapper-page.component.scss +202 -0
- package/projects/demo-app/src/app/pages/mapper-page/mapper-page.component.ts +192 -0
- package/projects/demo-app/src/app/pages/mappings-page/add-mapping-dialog.component.ts +163 -0
- package/projects/demo-app/src/app/pages/mappings-page/mappings-page.component.ts +306 -0
- package/projects/demo-app/src/app/pages/schema-creator-page/schema-creator-page.component.ts +88 -0
- package/projects/demo-app/src/app/pages/schema-editor-page/schema-editor-page.component.html +108 -0
- package/projects/demo-app/src/app/pages/schema-editor-page/schema-editor-page.component.scss +317 -0
- package/projects/demo-app/src/app/pages/schema-editor-page/schema-editor-page.component.ts +129 -0
- package/projects/demo-app/src/app/services/app-state.service.ts +233 -0
- package/projects/demo-app/src/app/services/sample-data.service.ts +228 -0
- package/projects/demo-app/src/index.html +15 -0
- package/projects/demo-app/src/main.ts +6 -0
- package/projects/demo-app/src/styles.scss +54 -0
- package/projects/demo-app/tsconfig.app.json +13 -0
- package/projects/ngx-data-mapper/ng-package.json +7 -0
- package/projects/ngx-data-mapper/package.json +40 -0
- package/projects/ngx-data-mapper/src/lib/components/array-filter-modal/array-filter-modal.component.html +183 -0
- package/projects/ngx-data-mapper/src/lib/components/array-filter-modal/array-filter-modal.component.scss +352 -0
- package/projects/ngx-data-mapper/src/lib/components/array-filter-modal/array-filter-modal.component.ts +277 -0
- package/projects/ngx-data-mapper/src/lib/components/array-selector-modal/array-selector-modal.component.html +174 -0
- package/projects/ngx-data-mapper/src/lib/components/array-selector-modal/array-selector-modal.component.scss +357 -0
- package/projects/ngx-data-mapper/src/lib/components/array-selector-modal/array-selector-modal.component.ts +258 -0
- package/projects/ngx-data-mapper/src/lib/components/condition-builder/condition-builder.component.html +139 -0
- package/projects/ngx-data-mapper/src/lib/components/condition-builder/condition-builder.component.scss +213 -0
- package/projects/ngx-data-mapper/src/lib/components/condition-builder/condition-builder.component.ts +261 -0
- package/projects/ngx-data-mapper/src/lib/components/data-mapper/data-mapper.component.html +199 -0
- package/projects/ngx-data-mapper/src/lib/components/data-mapper/data-mapper.component.scss +321 -0
- package/projects/ngx-data-mapper/src/lib/components/data-mapper/data-mapper.component.ts +618 -0
- package/projects/ngx-data-mapper/src/lib/components/default-value-popover/default-value-popover.component.html +67 -0
- package/projects/ngx-data-mapper/src/lib/components/default-value-popover/default-value-popover.component.scss +97 -0
- package/projects/ngx-data-mapper/src/lib/components/default-value-popover/default-value-popover.component.ts +105 -0
- package/projects/ngx-data-mapper/src/lib/components/schema-editor/schema-editor.component.html +552 -0
- package/projects/ngx-data-mapper/src/lib/components/schema-editor/schema-editor.component.scss +824 -0
- package/projects/ngx-data-mapper/src/lib/components/schema-editor/schema-editor.component.ts +730 -0
- package/projects/ngx-data-mapper/src/lib/components/schema-tree/schema-tree.component.html +82 -0
- package/projects/ngx-data-mapper/src/lib/components/schema-tree/schema-tree.component.scss +352 -0
- package/projects/ngx-data-mapper/src/lib/components/schema-tree/schema-tree.component.ts +225 -0
- package/projects/ngx-data-mapper/src/lib/components/transformation-popover/transformation-popover.component.html +346 -0
- package/projects/ngx-data-mapper/src/lib/components/transformation-popover/transformation-popover.component.scss +511 -0
- package/projects/ngx-data-mapper/src/lib/components/transformation-popover/transformation-popover.component.ts +368 -0
- package/projects/ngx-data-mapper/src/lib/models/json-schema.model.ts +164 -0
- package/projects/ngx-data-mapper/src/lib/models/schema.model.ts +173 -0
- package/projects/ngx-data-mapper/src/lib/services/mapping.service.ts +615 -0
- package/projects/ngx-data-mapper/src/lib/services/schema-parser.service.ts +270 -0
- package/projects/ngx-data-mapper/src/lib/services/svg-connector.service.ts +135 -0
- package/projects/ngx-data-mapper/src/lib/services/transformation.service.ts +453 -0
- package/projects/ngx-data-mapper/src/public-api.ts +22 -0
- package/projects/ngx-data-mapper/tsconfig.lib.json +13 -0
- package/projects/ngx-data-mapper/tsconfig.lib.prod.json +9 -0
- package/tsconfig.json +28 -0
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Component,
|
|
3
|
+
Input,
|
|
4
|
+
Output,
|
|
5
|
+
EventEmitter,
|
|
6
|
+
signal,
|
|
7
|
+
} from '@angular/core';
|
|
8
|
+
import { CommonModule } from '@angular/common';
|
|
9
|
+
import { FormsModule } from '@angular/forms';
|
|
10
|
+
import { MatButtonModule } from '@angular/material/button';
|
|
11
|
+
import { MatIconModule } from '@angular/material/icon';
|
|
12
|
+
import { MatInputModule } from '@angular/material/input';
|
|
13
|
+
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
14
|
+
import { MatSelectModule } from '@angular/material/select';
|
|
15
|
+
import { MatTooltipModule } from '@angular/material/tooltip';
|
|
16
|
+
import { MatMenuModule } from '@angular/material/menu';
|
|
17
|
+
import { MatButtonToggleModule } from '@angular/material/button-toggle';
|
|
18
|
+
import { DragDropModule, CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
|
|
19
|
+
import { JsonSchema } from '../../models/json-schema.model';
|
|
20
|
+
|
|
21
|
+
// Internal representation for UI editing
|
|
22
|
+
interface EditorField {
|
|
23
|
+
id: string;
|
|
24
|
+
name: string;
|
|
25
|
+
type: 'string' | 'number' | 'boolean' | 'date' | 'object' | 'array';
|
|
26
|
+
description?: string;
|
|
27
|
+
required?: boolean;
|
|
28
|
+
defaultValue?: string | number | boolean;
|
|
29
|
+
allowedValues?: string[];
|
|
30
|
+
children?: EditorField[];
|
|
31
|
+
expanded?: boolean;
|
|
32
|
+
isEditing?: boolean;
|
|
33
|
+
isEditingValues?: boolean;
|
|
34
|
+
isEditingDefault?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
@Component({
|
|
38
|
+
selector: 'schema-editor',
|
|
39
|
+
standalone: true,
|
|
40
|
+
imports: [
|
|
41
|
+
CommonModule,
|
|
42
|
+
FormsModule,
|
|
43
|
+
MatButtonModule,
|
|
44
|
+
MatIconModule,
|
|
45
|
+
MatInputModule,
|
|
46
|
+
MatFormFieldModule,
|
|
47
|
+
MatSelectModule,
|
|
48
|
+
MatTooltipModule,
|
|
49
|
+
MatMenuModule,
|
|
50
|
+
MatButtonToggleModule,
|
|
51
|
+
DragDropModule,
|
|
52
|
+
],
|
|
53
|
+
templateUrl: './schema-editor.component.html',
|
|
54
|
+
styleUrl: './schema-editor.component.scss',
|
|
55
|
+
})
|
|
56
|
+
export class SchemaEditorComponent {
|
|
57
|
+
@Input() set schema(value: JsonSchema | null) {
|
|
58
|
+
if (value) {
|
|
59
|
+
// Don't overwrite fields if we have uncommitted changes (fields being edited or with empty names)
|
|
60
|
+
const hasUncommittedChanges = this.fields().some(f =>
|
|
61
|
+
f.isEditing || f.isEditingDefault || f.isEditingValues || !f.name
|
|
62
|
+
) || this.hasUncommittedChildFields(this.fields());
|
|
63
|
+
|
|
64
|
+
this.schemaName.set(value.title || 'New Schema');
|
|
65
|
+
|
|
66
|
+
if (!hasUncommittedChanges) {
|
|
67
|
+
this.fields.set(this.jsonSchemaToEditorFields(value));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private hasUncommittedChildFields(fields: EditorField[]): boolean {
|
|
73
|
+
for (const field of fields) {
|
|
74
|
+
if (field.children) {
|
|
75
|
+
if (field.children.some(c => c.isEditing || c.isEditingDefault || c.isEditingValues || !c.name)) {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
if (this.hasUncommittedChildFields(field.children)) {
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
@Input() showJsonToggle = true;
|
|
87
|
+
@Input() showSchemaName = true;
|
|
88
|
+
|
|
89
|
+
@Output() schemaChange = new EventEmitter<JsonSchema>();
|
|
90
|
+
@Output() save = new EventEmitter<JsonSchema>();
|
|
91
|
+
|
|
92
|
+
schemaName = signal('New Schema');
|
|
93
|
+
fields = signal<EditorField[]>([]);
|
|
94
|
+
|
|
95
|
+
// View mode: 'visual' or 'json'
|
|
96
|
+
viewMode = signal<'visual' | 'json'>('visual');
|
|
97
|
+
jsonText = signal<string>('');
|
|
98
|
+
jsonError = signal<string | null>(null);
|
|
99
|
+
|
|
100
|
+
fieldTypes: Array<{ value: string; label: string; icon: string }> = [
|
|
101
|
+
{ value: 'string', label: 'String', icon: 'text_fields' },
|
|
102
|
+
{ value: 'number', label: 'Number', icon: 'pin' },
|
|
103
|
+
{ value: 'boolean', label: 'Boolean', icon: 'toggle_on' },
|
|
104
|
+
{ value: 'date', label: 'Date', icon: 'calendar_today' },
|
|
105
|
+
{ value: 'object', label: 'Object', icon: 'data_object' },
|
|
106
|
+
{ value: 'array', label: 'Array', icon: 'data_array' },
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
private generateId(): string {
|
|
110
|
+
return `field-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private cloneFields(fields: EditorField[]): EditorField[] {
|
|
114
|
+
return fields.map(f => ({
|
|
115
|
+
...f,
|
|
116
|
+
children: f.children ? this.cloneFields(f.children) : undefined,
|
|
117
|
+
}));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
getTypeIcon(type: string): string {
|
|
121
|
+
return this.fieldTypes.find(t => t.value === type)?.icon || 'help_outline';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Add a new field at the root level
|
|
125
|
+
addField(): void {
|
|
126
|
+
const newField: EditorField = {
|
|
127
|
+
id: this.generateId(),
|
|
128
|
+
name: '',
|
|
129
|
+
type: 'string',
|
|
130
|
+
isEditing: true,
|
|
131
|
+
expanded: false,
|
|
132
|
+
};
|
|
133
|
+
this.fields.update(fields => [...fields, newField]);
|
|
134
|
+
// Don't emit change here - field has empty name and would be filtered out
|
|
135
|
+
// Change will be emitted in stopEdit() when user provides a name
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Add a child field to an object or array
|
|
139
|
+
addChildField(parent: EditorField): void {
|
|
140
|
+
if (!parent.children) {
|
|
141
|
+
parent.children = [];
|
|
142
|
+
}
|
|
143
|
+
const newField: EditorField = {
|
|
144
|
+
id: this.generateId(),
|
|
145
|
+
name: '',
|
|
146
|
+
type: 'string',
|
|
147
|
+
isEditing: true,
|
|
148
|
+
};
|
|
149
|
+
parent.children.push(newField);
|
|
150
|
+
parent.expanded = true;
|
|
151
|
+
this.fields.update(fields => [...fields]);
|
|
152
|
+
// Don't emit change here - field has empty name and would be filtered out
|
|
153
|
+
// Change will be emitted in stopEdit() when user provides a name
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Delete a field
|
|
157
|
+
deleteField(field: EditorField, parentList: EditorField[]): void {
|
|
158
|
+
const index = parentList.indexOf(field);
|
|
159
|
+
if (index > -1) {
|
|
160
|
+
parentList.splice(index, 1);
|
|
161
|
+
this.fields.update(fields => [...fields]);
|
|
162
|
+
this.emitChange();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Duplicate a field
|
|
167
|
+
duplicateField(field: EditorField, parentList: EditorField[]): void {
|
|
168
|
+
const index = parentList.indexOf(field);
|
|
169
|
+
if (index > -1) {
|
|
170
|
+
const clone: EditorField = {
|
|
171
|
+
...field,
|
|
172
|
+
id: this.generateId(),
|
|
173
|
+
name: field.name + '_copy',
|
|
174
|
+
children: field.children ? this.cloneFields(field.children) : undefined,
|
|
175
|
+
isEditing: false,
|
|
176
|
+
};
|
|
177
|
+
parentList.splice(index + 1, 0, clone);
|
|
178
|
+
this.fields.update(fields => [...fields]);
|
|
179
|
+
this.emitChange();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Toggle field expansion
|
|
184
|
+
toggleExpand(field: EditorField): void {
|
|
185
|
+
field.expanded = !field.expanded;
|
|
186
|
+
this.fields.update(fields => [...fields]);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Start editing a field
|
|
190
|
+
startEdit(field: EditorField): void {
|
|
191
|
+
field.isEditing = true;
|
|
192
|
+
this.fields.update(fields => [...fields]);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Stop editing a field
|
|
196
|
+
stopEdit(field: EditorField): void {
|
|
197
|
+
field.isEditing = false;
|
|
198
|
+
if (!field.name.trim()) {
|
|
199
|
+
field.name = 'unnamed';
|
|
200
|
+
}
|
|
201
|
+
this.fields.update(fields => [...fields]);
|
|
202
|
+
this.emitChange();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Handle field name input - only allow valid property name characters
|
|
206
|
+
onFieldNameChange(field: EditorField, event: Event): void {
|
|
207
|
+
const input = event.target as HTMLInputElement;
|
|
208
|
+
// Remove invalid characters: only allow letters, numbers, underscores, and dollar signs
|
|
209
|
+
// Property names should start with letter, underscore, or dollar sign
|
|
210
|
+
const sanitized = input.value.replace(/[^a-zA-Z0-9_$]/g, '');
|
|
211
|
+
field.name = sanitized;
|
|
212
|
+
// Update input value if it was sanitized
|
|
213
|
+
if (input.value !== sanitized) {
|
|
214
|
+
input.value = sanitized;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Handle field type change
|
|
219
|
+
onFieldTypeChange(field: EditorField, type: string): void {
|
|
220
|
+
field.type = type as EditorField['type'];
|
|
221
|
+
// Initialize children array for object/array types
|
|
222
|
+
if ((type === 'object' || type === 'array') && !field.children) {
|
|
223
|
+
field.children = [];
|
|
224
|
+
}
|
|
225
|
+
this.fields.update(fields => [...fields]);
|
|
226
|
+
this.emitChange();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Toggle required status
|
|
230
|
+
toggleRequired(field: EditorField): void {
|
|
231
|
+
field.required = !field.required;
|
|
232
|
+
this.fields.update(fields => [...fields]);
|
|
233
|
+
this.emitChange();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Update field description
|
|
237
|
+
onDescriptionChange(field: EditorField, description: string): void {
|
|
238
|
+
field.description = description;
|
|
239
|
+
this.fields.update(fields => [...fields]);
|
|
240
|
+
this.emitChange();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Toggle allowed values editor
|
|
244
|
+
toggleValuesEditor(field: EditorField): void {
|
|
245
|
+
const wasEditingDefault = field.isEditingDefault;
|
|
246
|
+
field.isEditingValues = !field.isEditingValues;
|
|
247
|
+
if (field.isEditingValues) {
|
|
248
|
+
// Close default editor when opening values editor
|
|
249
|
+
field.isEditingDefault = false;
|
|
250
|
+
if (!field.allowedValues) {
|
|
251
|
+
field.allowedValues = [];
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
this.fields.update(fields => [...fields]);
|
|
255
|
+
// Emit change if we closed the default editor (to save any default value)
|
|
256
|
+
if (wasEditingDefault) {
|
|
257
|
+
this.emitChange();
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Add allowed value
|
|
262
|
+
addAllowedValue(field: EditorField, input: HTMLInputElement | Event): void {
|
|
263
|
+
// Handle both direct input reference and event from button click
|
|
264
|
+
let inputEl: HTMLInputElement | null = null;
|
|
265
|
+
|
|
266
|
+
if (input instanceof HTMLInputElement) {
|
|
267
|
+
inputEl = input;
|
|
268
|
+
} else {
|
|
269
|
+
// Find the input by traversing up to .values-header and then querying for .value-input
|
|
270
|
+
const target = input.target as HTMLElement;
|
|
271
|
+
const header = target.closest('.values-header');
|
|
272
|
+
inputEl = header?.querySelector('.value-input') as HTMLInputElement;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (!inputEl) return;
|
|
276
|
+
|
|
277
|
+
const value = inputEl.value.trim();
|
|
278
|
+
if (value && !field.allowedValues?.includes(value)) {
|
|
279
|
+
if (!field.allowedValues) {
|
|
280
|
+
field.allowedValues = [];
|
|
281
|
+
}
|
|
282
|
+
field.allowedValues.push(value);
|
|
283
|
+
inputEl.value = '';
|
|
284
|
+
this.fields.update(fields => [...fields]);
|
|
285
|
+
this.emitChange();
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Remove allowed value
|
|
290
|
+
removeAllowedValue(field: EditorField, index: number): void {
|
|
291
|
+
if (field.allowedValues) {
|
|
292
|
+
field.allowedValues.splice(index, 1);
|
|
293
|
+
if (field.allowedValues.length === 0) {
|
|
294
|
+
field.allowedValues = undefined;
|
|
295
|
+
}
|
|
296
|
+
this.fields.update(fields => [...fields]);
|
|
297
|
+
this.emitChange();
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Handle Enter key in allowed value input
|
|
302
|
+
onAllowedValueKeydown(event: KeyboardEvent, field: EditorField, input: HTMLInputElement): void {
|
|
303
|
+
if (event.key === 'Enter') {
|
|
304
|
+
event.preventDefault();
|
|
305
|
+
this.addAllowedValue(field, input);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Toggle default value editor
|
|
310
|
+
toggleDefaultEditor(field: EditorField): void {
|
|
311
|
+
const wasEditingValues = field.isEditingValues;
|
|
312
|
+
field.isEditingDefault = !field.isEditingDefault;
|
|
313
|
+
if (field.isEditingDefault) {
|
|
314
|
+
// Close values editor when opening default editor
|
|
315
|
+
field.isEditingValues = false;
|
|
316
|
+
}
|
|
317
|
+
this.fields.update(fields => [...fields]);
|
|
318
|
+
// Emit change if we closed the values editor (to save any allowed values)
|
|
319
|
+
if (wasEditingValues) {
|
|
320
|
+
this.emitChange();
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Update default value
|
|
325
|
+
onDefaultValueChange(field: EditorField, value: string): void {
|
|
326
|
+
if (value === '') {
|
|
327
|
+
field.defaultValue = undefined;
|
|
328
|
+
} else if (field.type === 'number') {
|
|
329
|
+
const num = parseFloat(value);
|
|
330
|
+
field.defaultValue = isNaN(num) ? undefined : num;
|
|
331
|
+
} else if (field.type === 'boolean') {
|
|
332
|
+
field.defaultValue = value === 'true';
|
|
333
|
+
} else {
|
|
334
|
+
field.defaultValue = value;
|
|
335
|
+
}
|
|
336
|
+
this.fields.update(fields => [...fields]);
|
|
337
|
+
this.emitChange();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Clear default value
|
|
341
|
+
clearDefaultValue(field: EditorField): void {
|
|
342
|
+
field.defaultValue = undefined;
|
|
343
|
+
field.isEditingDefault = false;
|
|
344
|
+
this.fields.update(fields => [...fields]);
|
|
345
|
+
this.emitChange();
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Handle Enter key in default value input
|
|
349
|
+
onDefaultValueKeydown(event: KeyboardEvent, field: EditorField): void {
|
|
350
|
+
if (event.key === 'Enter' || event.key === 'Escape') {
|
|
351
|
+
field.isEditingDefault = false;
|
|
352
|
+
this.fields.update(fields => [...fields]);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Handle keyboard events in field name input
|
|
357
|
+
onFieldNameKeydown(event: KeyboardEvent, field: EditorField): void {
|
|
358
|
+
if (event.key === 'Enter') {
|
|
359
|
+
this.stopEdit(field);
|
|
360
|
+
} else if (event.key === 'Escape') {
|
|
361
|
+
field.isEditing = false;
|
|
362
|
+
this.fields.update(fields => [...fields]);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Move field up in list
|
|
367
|
+
moveFieldUp(field: EditorField, parentList: EditorField[]): void {
|
|
368
|
+
const index = parentList.indexOf(field);
|
|
369
|
+
if (index > 0) {
|
|
370
|
+
[parentList[index - 1], parentList[index]] = [parentList[index], parentList[index - 1]];
|
|
371
|
+
this.fields.update(fields => [...fields]);
|
|
372
|
+
this.emitChange();
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Move field down in list
|
|
377
|
+
moveFieldDown(field: EditorField, parentList: EditorField[]): void {
|
|
378
|
+
const index = parentList.indexOf(field);
|
|
379
|
+
if (index < parentList.length - 1) {
|
|
380
|
+
[parentList[index], parentList[index + 1]] = [parentList[index + 1], parentList[index]];
|
|
381
|
+
this.fields.update(fields => [...fields]);
|
|
382
|
+
this.emitChange();
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Handle drag and drop reorder
|
|
387
|
+
onFieldDrop(event: CdkDragDrop<EditorField[]>): void {
|
|
388
|
+
if (event.previousIndex !== event.currentIndex) {
|
|
389
|
+
moveItemInArray(event.container.data, event.previousIndex, event.currentIndex);
|
|
390
|
+
this.fields.update(fields => [...fields]);
|
|
391
|
+
// Don't emit change here - it causes parent to reset expanded state
|
|
392
|
+
// Order change will be captured on next edit or save
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Check if field can be indented (previous sibling must be object/array)
|
|
397
|
+
canIndent(field: EditorField, parentList: EditorField[]): boolean {
|
|
398
|
+
const index = parentList.indexOf(field);
|
|
399
|
+
if (index <= 0) return false;
|
|
400
|
+
const prevSibling = parentList[index - 1];
|
|
401
|
+
return prevSibling.type === 'object' || prevSibling.type === 'array';
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Indent field - move into previous sibling's children
|
|
405
|
+
indentField(field: EditorField, parentList: EditorField[]): void {
|
|
406
|
+
const index = parentList.indexOf(field);
|
|
407
|
+
if (index <= 0) return;
|
|
408
|
+
|
|
409
|
+
const prevSibling = parentList[index - 1];
|
|
410
|
+
if (prevSibling.type !== 'object' && prevSibling.type !== 'array') return;
|
|
411
|
+
|
|
412
|
+
// Remove from current list
|
|
413
|
+
parentList.splice(index, 1);
|
|
414
|
+
|
|
415
|
+
// Add to previous sibling's children
|
|
416
|
+
if (!prevSibling.children) {
|
|
417
|
+
prevSibling.children = [];
|
|
418
|
+
}
|
|
419
|
+
prevSibling.children.push(field);
|
|
420
|
+
|
|
421
|
+
// Always expand the target (keep open if already open, open if closed)
|
|
422
|
+
prevSibling.expanded = true;
|
|
423
|
+
|
|
424
|
+
this.fields.update(fields => [...fields]);
|
|
425
|
+
// Don't emit change here - it causes parent to reset expanded state
|
|
426
|
+
// Structure change will be captured on next edit or save
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Outdent field - move out of parent to grandparent level
|
|
430
|
+
outdentField(field: EditorField, parentList: EditorField[], level: number): void {
|
|
431
|
+
if (level === 0) return;
|
|
432
|
+
|
|
433
|
+
// Find the parent object/array that contains this list
|
|
434
|
+
const parent = this.findParentOfList(this.fields(), parentList);
|
|
435
|
+
if (!parent) return;
|
|
436
|
+
|
|
437
|
+
// Find the grandparent list
|
|
438
|
+
const grandparentList = this.findParentList(this.fields(), parent);
|
|
439
|
+
if (!grandparentList) return;
|
|
440
|
+
|
|
441
|
+
// Remove from current list
|
|
442
|
+
const index = parentList.indexOf(field);
|
|
443
|
+
parentList.splice(index, 1);
|
|
444
|
+
|
|
445
|
+
// Add to grandparent list after the parent
|
|
446
|
+
const parentIndex = grandparentList.indexOf(parent);
|
|
447
|
+
grandparentList.splice(parentIndex + 1, 0, field);
|
|
448
|
+
|
|
449
|
+
this.fields.update(fields => [...fields]);
|
|
450
|
+
// Don't emit change here - it causes parent to reset expanded state
|
|
451
|
+
// Structure change will be captured on next edit or save
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Find the parent field that contains a given list
|
|
455
|
+
private findParentOfList(searchIn: EditorField[], targetList: EditorField[]): EditorField | null {
|
|
456
|
+
for (const field of searchIn) {
|
|
457
|
+
if (field.children === targetList) {
|
|
458
|
+
return field;
|
|
459
|
+
}
|
|
460
|
+
if (field.children) {
|
|
461
|
+
const found = this.findParentOfList(field.children, targetList);
|
|
462
|
+
if (found) return found;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Find the list that contains a given field
|
|
469
|
+
private findParentList(searchIn: EditorField[], targetField: EditorField): EditorField[] | null {
|
|
470
|
+
if (searchIn.includes(targetField)) {
|
|
471
|
+
return searchIn;
|
|
472
|
+
}
|
|
473
|
+
for (const field of searchIn) {
|
|
474
|
+
if (field.children) {
|
|
475
|
+
const found = this.findParentList(field.children, targetField);
|
|
476
|
+
if (found) return found;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Update schema name - only allow valid characters
|
|
483
|
+
onSchemaNameChange(name: string, input?: HTMLInputElement): void {
|
|
484
|
+
const sanitized = name.replace(/[^a-zA-Z0-9_$]/g, '');
|
|
485
|
+
this.schemaName.set(sanitized);
|
|
486
|
+
if (input && input.value !== sanitized) {
|
|
487
|
+
input.value = sanitized;
|
|
488
|
+
}
|
|
489
|
+
this.emitChange();
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Emit change event
|
|
493
|
+
private emitChange(): void {
|
|
494
|
+
this.schemaChange.emit(this.toJsonSchema() as JsonSchema);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Save the schema
|
|
498
|
+
onSave(): void {
|
|
499
|
+
this.save.emit(this.toJsonSchema() as JsonSchema);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Convert JSON Schema to internal EditorField format
|
|
503
|
+
private jsonSchemaToEditorFields(schema: JsonSchema, requiredFields: string[] = []): EditorField[] {
|
|
504
|
+
const fields: EditorField[] = [];
|
|
505
|
+
|
|
506
|
+
if (schema.type === 'object' && schema.properties) {
|
|
507
|
+
const required = schema.required || requiredFields;
|
|
508
|
+
for (const [name, propSchema] of Object.entries(schema.properties)) {
|
|
509
|
+
fields.push(this.jsonSchemaPropertyToEditorField(name, propSchema, required.includes(name)));
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
return fields;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
private jsonSchemaPropertyToEditorField(name: string, schema: JsonSchema, isRequired: boolean): EditorField {
|
|
517
|
+
const field: EditorField = {
|
|
518
|
+
id: this.generateId(),
|
|
519
|
+
name,
|
|
520
|
+
type: this.jsonSchemaTypeToEditorType(schema),
|
|
521
|
+
description: schema.description,
|
|
522
|
+
required: isRequired,
|
|
523
|
+
allowedValues: schema.enum as string[] | undefined,
|
|
524
|
+
defaultValue: schema.default as string | number | boolean | undefined,
|
|
525
|
+
expanded: false,
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
if (schema.type === 'object' && schema.properties) {
|
|
529
|
+
field.children = this.jsonSchemaToEditorFields(schema, schema.required);
|
|
530
|
+
} else if (schema.type === 'array' && schema.items) {
|
|
531
|
+
if (schema.items.type === 'object' && schema.items.properties) {
|
|
532
|
+
field.children = this.jsonSchemaToEditorFields(schema.items, schema.items.required);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return field;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
private jsonSchemaTypeToEditorType(schema: JsonSchema): EditorField['type'] {
|
|
540
|
+
if (schema.format === 'date' || schema.format === 'date-time') {
|
|
541
|
+
return 'date';
|
|
542
|
+
}
|
|
543
|
+
const type = Array.isArray(schema.type) ? schema.type[0] : schema.type;
|
|
544
|
+
switch (type) {
|
|
545
|
+
case 'string': return 'string';
|
|
546
|
+
case 'number':
|
|
547
|
+
case 'integer': return 'number';
|
|
548
|
+
case 'boolean': return 'boolean';
|
|
549
|
+
case 'object': return 'object';
|
|
550
|
+
case 'array': return 'array';
|
|
551
|
+
default: return 'string';
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Convert to internal JSON format
|
|
556
|
+
toJson(): string {
|
|
557
|
+
return JSON.stringify(
|
|
558
|
+
{
|
|
559
|
+
name: this.schemaName(),
|
|
560
|
+
fields: this.stripEditingState(this.fields()),
|
|
561
|
+
},
|
|
562
|
+
null,
|
|
563
|
+
2
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Convert to valid JSON Schema format
|
|
568
|
+
toJsonSchema(): object {
|
|
569
|
+
const required: string[] = [];
|
|
570
|
+
const properties: Record<string, object> = {};
|
|
571
|
+
|
|
572
|
+
for (const field of this.fields()) {
|
|
573
|
+
if (field.required && field.name) {
|
|
574
|
+
required.push(field.name);
|
|
575
|
+
}
|
|
576
|
+
if (field.name) {
|
|
577
|
+
properties[field.name] = this.fieldToJsonSchema(field);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const schema: Record<string, unknown> = {
|
|
582
|
+
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
583
|
+
type: 'object',
|
|
584
|
+
title: this.schemaName(),
|
|
585
|
+
properties,
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
if (required.length > 0) {
|
|
589
|
+
schema['required'] = required;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
return schema;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
private fieldToJsonSchema(field: EditorField): object {
|
|
596
|
+
const schema: Record<string, unknown> = {};
|
|
597
|
+
|
|
598
|
+
// Map type
|
|
599
|
+
if (field.type === 'date') {
|
|
600
|
+
schema['type'] = 'string';
|
|
601
|
+
schema['format'] = 'date-time';
|
|
602
|
+
} else if (field.type === 'array') {
|
|
603
|
+
schema['type'] = 'array';
|
|
604
|
+
if (field.children && field.children.length > 0) {
|
|
605
|
+
// If array has children, treat first child as item schema
|
|
606
|
+
const itemProperties: Record<string, object> = {};
|
|
607
|
+
const itemRequired: string[] = [];
|
|
608
|
+
for (const child of field.children) {
|
|
609
|
+
if (child.name) {
|
|
610
|
+
itemProperties[child.name] = this.fieldToJsonSchema(child);
|
|
611
|
+
if (child.required) {
|
|
612
|
+
itemRequired.push(child.name);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
const items: Record<string, unknown> = {
|
|
617
|
+
type: 'object',
|
|
618
|
+
properties: itemProperties,
|
|
619
|
+
};
|
|
620
|
+
if (itemRequired.length > 0) {
|
|
621
|
+
items['required'] = itemRequired;
|
|
622
|
+
}
|
|
623
|
+
schema['items'] = items;
|
|
624
|
+
}
|
|
625
|
+
} else if (field.type === 'object') {
|
|
626
|
+
schema['type'] = 'object';
|
|
627
|
+
if (field.children && field.children.length > 0) {
|
|
628
|
+
const childProperties: Record<string, object> = {};
|
|
629
|
+
const childRequired: string[] = [];
|
|
630
|
+
for (const child of field.children) {
|
|
631
|
+
if (child.name) {
|
|
632
|
+
childProperties[child.name] = this.fieldToJsonSchema(child);
|
|
633
|
+
if (child.required) {
|
|
634
|
+
childRequired.push(child.name);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
schema['properties'] = childProperties;
|
|
639
|
+
if (childRequired.length > 0) {
|
|
640
|
+
schema['required'] = childRequired;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
} else {
|
|
644
|
+
schema['type'] = field.type;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Add description
|
|
648
|
+
if (field.description) {
|
|
649
|
+
schema['description'] = field.description;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Add enum for allowed values
|
|
653
|
+
if (field.allowedValues && field.allowedValues.length > 0) {
|
|
654
|
+
schema['enum'] = field.allowedValues;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Add default value
|
|
658
|
+
if (field.defaultValue !== undefined) {
|
|
659
|
+
schema['default'] = field.defaultValue;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
return schema;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
private stripEditingState(fields: EditorField[]): EditorField[] {
|
|
666
|
+
return fields.map(f => {
|
|
667
|
+
const { isEditing, isEditingValues, isEditingDefault, ...rest } = f;
|
|
668
|
+
return {
|
|
669
|
+
...rest,
|
|
670
|
+
children: f.children ? this.stripEditingState(f.children) : undefined,
|
|
671
|
+
};
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Track by function for ngFor
|
|
676
|
+
trackByFieldId(index: number, field: EditorField): string {
|
|
677
|
+
return field.id;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// --- JSON View Methods ---
|
|
681
|
+
|
|
682
|
+
setViewMode(mode: 'visual' | 'json'): void {
|
|
683
|
+
if (mode === 'json') {
|
|
684
|
+
// Sync JSON text from current schema state
|
|
685
|
+
this.jsonText.set(JSON.stringify(this.toJsonSchema(), null, 2));
|
|
686
|
+
this.jsonError.set(null);
|
|
687
|
+
}
|
|
688
|
+
this.viewMode.set(mode);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
onJsonTextChange(text: string): void {
|
|
692
|
+
this.jsonText.set(text);
|
|
693
|
+
try {
|
|
694
|
+
JSON.parse(text);
|
|
695
|
+
this.jsonError.set(null);
|
|
696
|
+
} catch (e) {
|
|
697
|
+
this.jsonError.set((e as Error).message);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
applyJsonChanges(): void {
|
|
702
|
+
try {
|
|
703
|
+
const parsed = JSON.parse(this.jsonText()) as JsonSchema;
|
|
704
|
+
// Update internal state from JSON
|
|
705
|
+
this.schemaName.set(parsed.title || 'New Schema');
|
|
706
|
+
this.fields.set(this.jsonSchemaToEditorFields(parsed));
|
|
707
|
+
this.jsonError.set(null);
|
|
708
|
+
this.emitChange();
|
|
709
|
+
} catch (e) {
|
|
710
|
+
this.jsonError.set((e as Error).message);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
formatJson(): void {
|
|
715
|
+
try {
|
|
716
|
+
const parsed = JSON.parse(this.jsonText());
|
|
717
|
+
this.jsonText.set(JSON.stringify(parsed, null, 2));
|
|
718
|
+
this.jsonError.set(null);
|
|
719
|
+
} catch (e) {
|
|
720
|
+
this.jsonError.set((e as Error).message);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
copyJson(): void {
|
|
725
|
+
const text = this.jsonText();
|
|
726
|
+
navigator.clipboard.writeText(text).catch(err => {
|
|
727
|
+
console.error('Failed to copy JSON to clipboard:', err);
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
}
|