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.
Files changed (64) hide show
  1. package/.claude/settings.local.json +10 -0
  2. package/LICENSE +190 -0
  3. package/PUBLISHING.md +75 -0
  4. package/README.md +214 -0
  5. package/angular.json +121 -0
  6. package/package.json +67 -0
  7. package/projects/demo-app/public/favicon.ico +0 -0
  8. package/projects/demo-app/src/app/app.config.ts +12 -0
  9. package/projects/demo-app/src/app/app.html +36 -0
  10. package/projects/demo-app/src/app/app.routes.ts +62 -0
  11. package/projects/demo-app/src/app/app.scss +65 -0
  12. package/projects/demo-app/src/app/app.ts +11 -0
  13. package/projects/demo-app/src/app/layout/app-layout.component.ts +294 -0
  14. package/projects/demo-app/src/app/pages/mapper-page/mapper-page.component.html +87 -0
  15. package/projects/demo-app/src/app/pages/mapper-page/mapper-page.component.scss +202 -0
  16. package/projects/demo-app/src/app/pages/mapper-page/mapper-page.component.ts +192 -0
  17. package/projects/demo-app/src/app/pages/mappings-page/add-mapping-dialog.component.ts +163 -0
  18. package/projects/demo-app/src/app/pages/mappings-page/mappings-page.component.ts +306 -0
  19. package/projects/demo-app/src/app/pages/schema-creator-page/schema-creator-page.component.ts +88 -0
  20. package/projects/demo-app/src/app/pages/schema-editor-page/schema-editor-page.component.html +108 -0
  21. package/projects/demo-app/src/app/pages/schema-editor-page/schema-editor-page.component.scss +317 -0
  22. package/projects/demo-app/src/app/pages/schema-editor-page/schema-editor-page.component.ts +129 -0
  23. package/projects/demo-app/src/app/services/app-state.service.ts +233 -0
  24. package/projects/demo-app/src/app/services/sample-data.service.ts +228 -0
  25. package/projects/demo-app/src/index.html +15 -0
  26. package/projects/demo-app/src/main.ts +6 -0
  27. package/projects/demo-app/src/styles.scss +54 -0
  28. package/projects/demo-app/tsconfig.app.json +13 -0
  29. package/projects/ngx-data-mapper/ng-package.json +7 -0
  30. package/projects/ngx-data-mapper/package.json +40 -0
  31. package/projects/ngx-data-mapper/src/lib/components/array-filter-modal/array-filter-modal.component.html +183 -0
  32. package/projects/ngx-data-mapper/src/lib/components/array-filter-modal/array-filter-modal.component.scss +352 -0
  33. package/projects/ngx-data-mapper/src/lib/components/array-filter-modal/array-filter-modal.component.ts +277 -0
  34. package/projects/ngx-data-mapper/src/lib/components/array-selector-modal/array-selector-modal.component.html +174 -0
  35. package/projects/ngx-data-mapper/src/lib/components/array-selector-modal/array-selector-modal.component.scss +357 -0
  36. package/projects/ngx-data-mapper/src/lib/components/array-selector-modal/array-selector-modal.component.ts +258 -0
  37. package/projects/ngx-data-mapper/src/lib/components/condition-builder/condition-builder.component.html +139 -0
  38. package/projects/ngx-data-mapper/src/lib/components/condition-builder/condition-builder.component.scss +213 -0
  39. package/projects/ngx-data-mapper/src/lib/components/condition-builder/condition-builder.component.ts +261 -0
  40. package/projects/ngx-data-mapper/src/lib/components/data-mapper/data-mapper.component.html +199 -0
  41. package/projects/ngx-data-mapper/src/lib/components/data-mapper/data-mapper.component.scss +321 -0
  42. package/projects/ngx-data-mapper/src/lib/components/data-mapper/data-mapper.component.ts +618 -0
  43. package/projects/ngx-data-mapper/src/lib/components/default-value-popover/default-value-popover.component.html +67 -0
  44. package/projects/ngx-data-mapper/src/lib/components/default-value-popover/default-value-popover.component.scss +97 -0
  45. package/projects/ngx-data-mapper/src/lib/components/default-value-popover/default-value-popover.component.ts +105 -0
  46. package/projects/ngx-data-mapper/src/lib/components/schema-editor/schema-editor.component.html +552 -0
  47. package/projects/ngx-data-mapper/src/lib/components/schema-editor/schema-editor.component.scss +824 -0
  48. package/projects/ngx-data-mapper/src/lib/components/schema-editor/schema-editor.component.ts +730 -0
  49. package/projects/ngx-data-mapper/src/lib/components/schema-tree/schema-tree.component.html +82 -0
  50. package/projects/ngx-data-mapper/src/lib/components/schema-tree/schema-tree.component.scss +352 -0
  51. package/projects/ngx-data-mapper/src/lib/components/schema-tree/schema-tree.component.ts +225 -0
  52. package/projects/ngx-data-mapper/src/lib/components/transformation-popover/transformation-popover.component.html +346 -0
  53. package/projects/ngx-data-mapper/src/lib/components/transformation-popover/transformation-popover.component.scss +511 -0
  54. package/projects/ngx-data-mapper/src/lib/components/transformation-popover/transformation-popover.component.ts +368 -0
  55. package/projects/ngx-data-mapper/src/lib/models/json-schema.model.ts +164 -0
  56. package/projects/ngx-data-mapper/src/lib/models/schema.model.ts +173 -0
  57. package/projects/ngx-data-mapper/src/lib/services/mapping.service.ts +615 -0
  58. package/projects/ngx-data-mapper/src/lib/services/schema-parser.service.ts +270 -0
  59. package/projects/ngx-data-mapper/src/lib/services/svg-connector.service.ts +135 -0
  60. package/projects/ngx-data-mapper/src/lib/services/transformation.service.ts +453 -0
  61. package/projects/ngx-data-mapper/src/public-api.ts +22 -0
  62. package/projects/ngx-data-mapper/tsconfig.lib.json +13 -0
  63. package/projects/ngx-data-mapper/tsconfig.lib.prod.json +9 -0
  64. 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
+ }