import { Component, OnInit, Input, Output, EventEmitter, SimpleChanges, OnChanges, ComponentFactoryResolver, ViewChild, ViewChildren, QueryList, Inject } from '@angular/core'; import { MatSelectChange } from '@angular/material/select'; import { MatDatepickerInputEvent } from '@angular/material/datepicker'; import { MatCheckboxChange } from '@angular/material/checkbox'; import { HttpClient } from '@angular/common/http'; import { forkJoin, Observable, ReplaySubject } from 'rxjs'; import { KeyValue } from '@angular/common'; import { Schema } from './schema'; import { WidgetComponent } from './widget.component'; import { WidgetDirective } from './widget.directive'; import { JsonSchemaFormService } from './json-schema-form.service'; import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; import { JsonPointer } from './json-pointer'; import { Choice, ChoiceHandler, DefaultChoiceHandler } from './choice'; import { FormControl } from '@angular/forms'; import { debounceTime, startWith, switchMap } from 'rxjs/operators'; import { MatDialog } from '@angular/material/dialog'; import { Edit } from './edit'; import { MatChipInputEvent } from '@angular/material/chips'; import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'; import { COMMA, ENTER, TAB } from '@angular/cdk/keycodes'; import jsonata from 'jsonata'; import { EDIT_DIALOG_TOKEN } from './edit-dialog-token'; import { ComponentType } from '@angular/cdk/portal'; /** * generates an input form base on JSON schema and JSON object. * The component is used recursively. */ @Component({ selector: 'lib-json-schema-form', templateUrl: './json-schema-form.component.html', styleUrls: ['./json-schema-form.component.css'] }) export class JsonSchemaFormComponent implements OnInit, OnChanges { /** * component constructor * @param http http client * @param componentFactoryResolver allows dynamic components * @param service application service for registering components etc. * @param dialog dialog service */ constructor( private http: HttpClient, private componentFactoryResolver: ComponentFactoryResolver, public service: JsonSchemaFormService, private dialog: MatDialog, @Inject(EDIT_DIALOG_TOKEN) private component: ComponentType<any>) { } /** * container children for event propagation */ @ViewChildren('children') children: QueryList<JsonSchemaFormComponent>; /** * container children for event propagation */ @ViewChild('child') child: JsonSchemaFormComponent; /** * if an array is displayed, indicates which array index is being hovered over in order to * display the "-" remove button */ hover: number; /** * the name of the input field (only set if inside type: object) */ @Input() name: string; /** * the label of the input field */ @Input() label: string; /** * the input value */ @Input() value: any; /** * root form value (can be used in custom components) */ @Input() rootValue: any; /** * emit changes done by the user in the component */ @Output() valueChange: EventEmitter<any> = new EventEmitter(); /** * emit whether this part of the form is valid */ @Output() errorChange: EventEmitter<string> = new EventEmitter(); /** * indicate schema changes done via the layout editor */ @Output() schemaChange: EventEmitter<void> = new EventEmitter(); /** * JSON schema to use */ @Input() schema: Schema; /** * parent schema to edit required */ @Input() parentSchema: Schema; /** * form editor */ edit: Edit; /** * root JSON schema to use when looking up $ref (simply passed along the tree) */ @Input() rootSchema: Schema; /** * base URL for resolving $ref */ @Input() base: string; /** * indicates whether this is the root of the component tree */ isRoot = false; /** * if present: value of the switch property that determines whether this component renders itself * (schema.case not present or schema.case === switch) */ @Input() switch: string; /** * indicates to child components whether the parent object has hideUndefined set (i.e. do not render yourself * if your value is undefined) */ @Input() hideUndefined: boolean; /** * are we already in the expansion panel? */ @Input() inExpansion: boolean; /** * are we already in an array? */ @Input() inArray: boolean; /** * required imported from parent */ @Input() required: boolean; /** * hook for custom widgets */ @ViewChild(WidgetDirective, { static: true }) widgetHost: WidgetDirective; /** * order field transforms properties into this structure. * allows omission, ordering and hierarchy */ orderedProperties: { [key: string]: Schema }[]; /** * avoids change detection issues for arrays */ arrayIndices: number[] = []; /** * avoids change detection issues for arrays */ additionalPropNames: string[] = []; /** * make sure to return the same date object instance (cannot delete date #83) */ date: Date; /** * choices that might be loaded async, initialized with current value and its potentially delayed toString value */ choices: ReplaySubject<Choice[]>; /** * autocomplete filtered choices */ filteredOptions: Observable<Choice[]>; /** * autocomplete form control for simpler change detection */ control: FormControl; /** * implementation specified in displayWith */ ch: ChoiceHandler; /** * complete chip entry with enter or comma */ readonly separatorKeysCodes: number[] = [ENTER, COMMA, TAB]; /** * readOnly if schema.readOnly or schema.createOnly and value set */ readOnly: boolean; /** * error from a custom component */ customError: string; /** * apply order, called anytime properties are set */ setOrderedProperties() { if (this.schema.order) { this.orderedProperties = []; for (const p of this.schema.order) { const arr = Array.isArray(p) ? p : [p]; const o = {}; for (const q of arr) { o[q] = this.schema.properties[q]; } this.orderedProperties.push(o); } } else if (this.schema.properties) { this.orderedProperties = []; for (const [key, value] of Object.entries(this.schema.properties)) { const o = {}; o[key] = value; this.orderedProperties.push(o); } } } /** * initialize the comonent. * replace undefined with null and init autocomplete choices */ ngOnInit(): void { if (!this.schema) { this.schema = { type: 'string' }; } this.readOnly = this.schema.readOnly || (this.schema.createOnly && this.value); if (!this.rootSchema) { this.rootSchema = this.schema; this.rootValue = this.value; this.isRoot = true; } if (!this.schema.type) { const p = this.schema.$ref; const parts = p.split('#'); if (parts.length === 1) { // URL only this.url(parts[0], null); } else { if (parts[0]) { // URL + anchor this.url(parts[0], parts[1]); } else { // local ref this.schema = JsonPointer.jsonPointer(this.rootSchema, parts[1]); this.setOrderedProperties(); } } } else { this.setOrderedProperties(); } if (typeof this.value === 'undefined') { if (this.schema.default) { this.value = this.schema.default; setTimeout(() => this.emit(this.value), 500); } else { if (!this.hideUndefined) { this.value = null; } } } if (this.getLayout() === 'custom') { this.loadComponent(); } if (this.isRoot) { setTimeout(() => { this.errorChange.emit(this.recursiveError()); }, 10); } this.ch = this.service.displayWithRegistry[this.schema.displayWith]; if (!this.ch) { this.ch = new DefaultChoiceHandler(this.http); } this.control = new FormControl(this.value); this.choices = new ReplaySubject(); if (Array.isArray(this.value)) { const arr = []; for (const i of this.value) { arr.push({ name: i, value: i }); } this.choices.next(arr); } else { this.choices.next([{ name: this.value, value: this.value }]); } if (this.value || this.value === 0) { if (Array.isArray(this.value)) { this.arrayIndices = Array.from(Array(this.value.length).keys()); const arr: Observable<Choice>[] = []; for (const i of this.value) { arr.push(this.ch.choice(i, this.schema)); } forkJoin(arr).subscribe(res => this.choices.next(res)); } else { this.ch.choice(this.value, this.schema).subscribe(res => this.choices.next([res])); } } this.filteredOptions = this.control.valueChanges .pipe( startWith(this.value), debounceTime(this.ch.debounceTime()), switchMap(x => { this.change({ target: { value: x } }); return this.ch.filter(this.value, this.schema, x, this.choices); }) ); if (this.schema.choicesLoad === 'onLoad') { this.focus(); } if (this.schema.additionalProperties && this.value) { this.additionalPropNames = Object.keys(this.value); this.arrayIndices = Array.from(Array(this.additionalPropNames.length).keys()); } this.edit = new Edit(this.schemaChange, this.name, this.schema, this.parentSchema, this.dialog, this.component); } /** * choice element activated - load values */ focus() { const o = this.ch.load(this.value, this.schema); if (o) { o.subscribe(res => { this.choices.next(res); }); } } /** * load schema from ref, apply pointer if needed */ url(ref: string, pointer: string) { // URL + anchor this.base = this.base ? new URL(ref, this.base).href : ref; // check root schema referenced map if (this.rootSchema.referenced && this.rootSchema.referenced[this.base]) { const res = this.rootSchema.referenced[this.base]; this.schema = pointer ? JsonPointer.jsonPointer(res, pointer) : res; this.setOrderedProperties(); return; } this.http.get(this.base).subscribe(res => { this.schema = pointer ? JsonPointer.jsonPointer(res, pointer) : res; this.setOrderedProperties(); }, error => console.log(error)); // set temporary pseudo schema this.schema = { type: 'string' }; this.setOrderedProperties(); } /** * emit valueChange event and also any validation error */ emit(event: any) { this.valueChange.emit(event); if (this.isRoot) { setTimeout(() => { this.errorChange.emit(this.recursiveError()); }, 10); } } /** * if the schema changes from the outside, * reset the component state wrt. errors and the choices cache */ ngOnChanges(changes: SimpleChanges): void { if (changes.value && !changes.value.isFirstChange() && this.getLayout() === 'autocomplete') { // make sure autocomplete form control picks up external changes this.control.setValue(changes.value.currentValue); } if (changes.schema) { if (changes.schema.previousValue) { this.rootSchema = null; if (this.widgetHost.viewContainerRef) { this.widgetHost.viewContainerRef.clear(); } this.ngOnInit(); } } if (changes.switch && !changes.switch.isFirstChange()) { if (this.getLayout() === 'custom') { this.loadComponent(); } else { if (this.widgetHost.viewContainerRef) { this.widgetHost.viewContainerRef.clear(); } } } } /** * angular pipe sorting function for keyValue - keep the JSON order and do not * order alphabetically */ originalOrder = (a: KeyValue<string, Schema>, b: KeyValue<string, Schema>): number => { return 0; } /** * key method to instruct the display which HTML block to activate. */ getLayout(): string { if (this.schema.case && this.schema.case.indexOf(this.switch) < 0) { return 'none'; } if (this.schema.widget === 'custom') { return 'custom'; } if (this.hideUndefined && this.value === undefined) { return 'none'; } if (this.schema.widget === 'upload') { return 'upload'; } if (this.schema.widget === 'upload64') { return 'upload64'; } if (this.schema.type === 'object') { if (this.schema.additionalProperties) { if (this.schema.layout === 'tab') { return 'additionalPropertiesTab'; } return 'additionalProperties'; } return 'object'; } if (this.schema.type === 'array') { if (this.schema.layout === 'tab') { return 'tab'; } if (this.schema.layout === 'table') { return 'table'; } if (this.schema.layout === 'select') { return 'array-select'; } if (this.schema.layout === 'chips') { return 'chips'; } return 'array'; } if (this.schema.enum) { return 'enum'; } if (this.schema.widget === 'date') { return 'date'; } if (this.schema.widget === 'textarea') { return 'textarea'; } if (this.schema.type === 'boolean') { return 'checkbox'; } if (this.schema.choicesUrl) { return 'autocomplete'; } if (this.schema.choices) { return 'autocomplete'; } if (this.schema.displayWith) { return 'autocomplete'; } return 'single'; } /** * called from template in the "simple" type. If "type" is "number" or "integer", * the HTML input type is "number" which avoids normal string input */ getInputType(schema: Schema): string { if (schema.type === 'number') { return 'number'; } if (schema.type === 'integer') { return 'number'; } return schema.widget; } /** * event handler for object display. Catches the child component event and * handle it by setting the value[key]. * Also init null objects with {} */ onValueChange(key: string, value: any) { if (!this.value || typeof (this.value) !== 'object') { this.value = {}; } this.value[key] = value; if (this.schema.computed) { for (const field of Object.keys(this.schema.computed)) { const expression = this.schema.computed[field]; const expr = jsonata(expression); expr.registerFunction('context', (k) => this.service.context[k], '<s:j>'); this.value[field] = expr.evaluate(this.value); } } this.emit(this.value); } /** * add an element to an array. * null arrays are initialized with [] */ add() { if (!(this.value instanceof Array)) { this.value = []; } if (this.schema.items?.type === 'array') { this.value.push([]); } else if (this.schema.items?.type === 'object') { this.value.push({}); } else { this.value.push(null); } this.arrayIndices.push(this.value.length - 1); this.emit(this.value); } /** * event handler for adding a field */ addField() { if (!this.value) { this.value = {}; } if (Object.keys(this.value).includes('')) { return; } this.value[''] = null; this.additionalPropNames.push(''); this.arrayIndices.push(this.additionalPropNames.length - 1); this.emit(this.value); } /** * remove an element from an array */ remove(i: number) { this.value.splice(i, 1); this.arrayIndices.pop(); this.emit(this.value); } /** * remove a field */ removeField(key: string) { delete this.value[key]; this.additionalPropNames.splice(this.additionalPropNames.indexOf(key), 1); this.arrayIndices.pop(); this.emit(this.value); } /** * event handler for changed field names with "additionalProperties" */ fieldNameChange(key: string, newvalue: any) { if (this.additionalPropNames.includes(newvalue)) { const tmp = this.additionalPropNames; this.additionalPropNames = []; setTimeout(() => { this.additionalPropNames = tmp; }, 1); return; } this.value[newvalue] = this.value[key]; delete this.value[key]; this.additionalPropNames[this.additionalPropNames.indexOf(key)] = newvalue; this.emit(this.value); } /** * returns the validation error on this level and call recursively for all children. * returns null if the form contents is valid */ recursiveError(): string { const e = this.error(); if (e) { return e; } if (this.child) { return this.child.recursiveError(); } if (this.children) { for (const c of this.children) { const r = c.recursiveError(); if (r) { return r; } } } return null; } /** * return the error message provided in the schema or the generic error message * returned from the validation code */ e(error: string): string { if (this.schema.errorMessage) { return this.schema.errorMessage; } return error; } /** * return error string */ error(): string { if (this.schema.widget === 'custom') { return this.customError; } if (this.schema.case && this.schema.case.indexOf(this.switch) < 0) { return null; } if (this.value) { if (this.schema.maxItems) { if (!(this.value.length <= this.schema.maxItems)) { return this.e('Only ' + this.schema.maxItems + ' array entries allowed'); } } if (this.schema.uniqueItems) { if (!(new Set(this.value).size === this.value.length)) { return this.e('Array entries must be unique'); } } if (this.schema.minItems) { if (!(this.value.length >= this.schema.minItems)) { return this.e('At least ' + this.schema.minItems + ' array entries required'); } } if (this.schema.maxProperties) { if (!(Object.keys(this.value).length <= this.schema.maxProperties)) { return this.e('Only ' + this.schema.maxProperties + ' fields allowed'); } } if (this.schema.propertyNames) { for (const key of Object.keys(this.value)) { const re = new RegExp(this.schema.propertyNames); if (!re.test(key)) { return this.e('illegal field name: ' + key); } } } if (this.schema.dependencies) { for (const dep of Object.keys(this.schema.dependencies)) { if (this.value[dep]) { for (const l of this.schema.dependencies[dep]) { if (!this.value[l]) { return this.e(dep + ' depends on ' + l); } } } } } if (this.schema.minProperties) { if (!(Object.keys(this.value).length >= this.schema.minProperties)) { return this.e('At least ' + this.schema.minProperties + ' fields required'); } } if (this.schema.maxLength) { if (!(('' + this.value).length <= this.schema.maxLength)) { return this.e('Input is longer than ' + this.schema.maxLength); } } if (this.schema.minLength) { if (!(('' + this.value).length >= this.schema.minLength)) { return this.e('Input is shorter than ' + this.schema.minLength); } } if (this.schema.multipleOf) { if (!Number.isInteger(Number(this.value) / this.schema.multipleOf)) { return this.e('Must be multiple of ' + this.schema.multipleOf); } } if (this.schema.exclusiveMaximum) { if (!(Number(this.value) < this.schema.exclusiveMaximum)) { return this.e('Must be less than ' + this.schema.exclusiveMaximum); } } if (this.schema.maximum) { if (!(Number(this.value) <= this.schema.maximum)) { return this.e('Must be less than or equal ' + this.schema.maximum); } } if (this.schema.exclusiveMinimum) { if (!(Number(this.value) > this.schema.exclusiveMinimum)) { return this.e('Must greater than ' + this.schema.exclusiveMinimum); } } if (this.schema.minimum) { if (!(Number(this.value) >= this.schema.minimum)) { return this.e('Must greater than or equal ' + this.schema.minimum); } } } if (this.required) { if (this.value == null || Object.is(this.value, NaN)) { return this.e('required'); } } if (this.schema.required) { for (const dep of this.schema.required) { if (!this.value[dep] && this.value[dep] !== false && this.value[dep] !== 0) { // ignore 'required' if dep is inactive due to switch / case let inactive = false; if (this.schema.switch) { const switc = this.value[this.schema.switch]; if (switc && this.schema.properties[dep].case?.indexOf(switc) < 0) { inactive = true; } } if (!inactive) { return this.e(dep + ' is required'); } } } } if (this.schema.pattern) { const re = new RegExp(this.schema.pattern); if (this.value && !re.test(this.value)) { return this.e('illegal string'); } } if (this.schema.format && this.service.formats[this.schema.format]) { const re = new RegExp(this.service.formats[this.schema.format]); if (this.value && !re.test(this.value)) { return this.e('illegal string'); } } return null; } /** * use the element title if present, defaults to the label input or "" is both are null */ getLabel(): string { if (this.schema.title) { return this.schema.title; } if (this.label) { return this.label; } return ''; } /** * input element change handler. * normalize the different kind of events, handle the datatypes, set the value and emit the change */ change(event: any) { let eventTarget: any; if (event instanceof MatSelectChange) { event = event.value; } else if (event instanceof MatDatepickerInputEvent) { event = this.serializeDate(event.value, this.schema.dateFormat, this.schema.type); } else if (event instanceof MatAutocompleteSelectedEvent) { event = event.option.value; } else if (event instanceof MatCheckboxChange) { event = event.checked; } else { // save the event target in case the parsing changes the value // (e.g. integer input 5.3 becomes 5, this is reflected on the UI via this handle) eventTarget = event.target; event = event.target.value; } if (event === '') { event = null; } if (event == null) { this.value = null; } if (this.schema.type === 'number') { this.value = parseFloat(event); } else if (this.schema.type === 'integer') { this.value = parseInt(event, 10); } else if (this.schema.type === 'boolean') { if (typeof event === 'string') { if (event === 'true') { this.value = true; } else if (event === 'false') { this.value = false; } else { this.value = null; } } else { this.value = event; } } else if (this.schema.type === 'string') { this.value = event; } else if (this.schema.type === 'array') { this.value = event; } else { throw new Error('unknown type: ' + this.schema.type); } this.emit(this.value); } /** * allows for the result of a file upload to be written into a text form element */ handleFileInput(base64: boolean, event: any) { if (10 * 1024 * 1024 <= event.target.files.item(0).size) { console.log('The file size is limited to 10MB'); return; } const reader = new FileReader(); reader.onload = () => { this.value = reader.result; if (this.schema.type === 'object' || this.schema.type === 'array') { this.value = JSON.parse(this.value); } this.emit(this.value); }; if (base64) { reader.readAsDataURL(event.target.files.item(0)); } else { reader.readAsText(event.target.files.item(0)); } } /** * get example values from example array and default */ example(): string { if (this.schema.examples && this.schema.examples[0]) { return this.schema.examples[0]; } if (this.schema.default) { return this.schema.default; } return null; } /** * load the dynamic custom widget */ loadComponent() { const componentFactory = this.componentFactoryResolver.resolveComponentFactory(this.service.registry[this.schema.widgetType]); const viewContainerRef = this.widgetHost.viewContainerRef; viewContainerRef.clear(); const componentRef = viewContainerRef.createComponent(componentFactory); // input values (componentRef.instance as WidgetComponent).label = this.label; (componentRef.instance as WidgetComponent).value = this.value; (componentRef.instance as WidgetComponent).schema = this.schema; (componentRef.instance as WidgetComponent).rootSchema = this.rootSchema; (componentRef.instance as WidgetComponent).rootValue = this.rootValue; // subscribe to value changes and forward them (componentRef.instance as WidgetComponent).valueChange.subscribe(data => { this.value = data; this.emit(this.value); }); // subscribe to error changes and forward them (componentRef.instance as WidgetComponent).errorChange.subscribe(error => { this.customError = error; this.errorChange.emit(error); }); } /** * used for expansion panels - set value and forward event */ setAndEmit(event: any) { this.value = event; this.emit(this.value); } /** * set an array element and emit value change event */ setIndexAndEmit(i: number, event: any) { this.value[i] = event; this.emit(this.value); } /** * set an array element's field and emit value change event (applies to table layout) */ setIndexAndEmitTable(i: number, field: string, event: any) { this.value[i][field] = event; this.emit(this.value); } /** * used when hideUndefined is active. Called from the UI to * show a property with undefined value (in order to be able to set if in the form) */ showProperty(prop: string) { if (!this.value) { this.value = {}; } if (this.value[prop] === undefined) { this.value[prop] = null; } else if (this.value[prop] === null) { this.value[prop] = undefined; } } /** * used when hideUndefined is active. Called from the UI * to determine which properties are included in the "to add" list */ showPropertyList(): string[] { if (this.schema.switch && this.value) { const sw = this.value[this.schema.switch]; const props = []; for (const [k, v] of Object.entries(this.schema.properties)) { if (v.case) { if (v.case.includes(sw)) { props.push(k); } } else { props.push(k); } } return props.sort(); } else { return Object.keys(this.schema.properties).sort(); } } /** * string to date * @param date date string / number (millisecs since 1970) * @param format date format */ parseDate(date: any, format: string): Date { if (!date && date !== 0) { return date; } if (typeof date === 'number') { return this.sameDate(new Date(date)); } if (!format) { return date; } const pdate = date.split(this.getDelimiter(format)); const pformat = format.split(this.getDelimiter(format)); return this.sameDate(new Date(pdate[pformat.indexOf('yyyy')], pdate[pformat.indexOf('MM')] - 1, pdate[pformat.indexOf('dd')])); } /** * make sure to return the same date object instance (cannot delete date #83) */ sameDate(nd: Date): Date { if (!this.date) { this.date = nd; } if (this.date.getTime() !== nd.getTime()) { this.date = nd; } return this.date; } /** * date to string * @param date the date to serialize * @param format the date format (e.g. dd-MM-yyyy) * @param type target datatype (allows serializing to millisecs since 1970) */ serializeDate(date: Date, format: string, type: string): string { if (date == null) { return ''; } if (type === 'integer' || type === 'number') { return '' + date.valueOf(); } if (!format) { return date.toISOString(); } const pformat = format.split(this.getDelimiter(format)); const pdate = [null, null, null]; pdate[pformat.indexOf('yyyy')] = date.getFullYear(); pdate[pformat.indexOf('MM')] = date.getMonth() + 1; pdate[pformat.indexOf('dd')] = date.getDate(); return pdate[0] + this.getDelimiter(format) + pdate[1] + this.getDelimiter(format) + pdate[2]; } /** * find the first non letter character in a date format such as dd/MM/yyyy (returns /) */ getDelimiter(format: string): string { const delim = format.match(/\W/g); if (!delim[0]) { throw new Error('No delimiter found in date format: ' + format); } return delim[0]; } /** * new chip entered */ addChip(event: MatChipInputEvent): void { const input = event.input; const value = event.value; // Add our fruit if ((value || '').trim()) { if (!this.value) { this.value = []; } this.value.push(value.trim()); this.emit(this.value); } // Reset the input value if (input) { input.value = ''; } } /** * remove a chip */ removeChip(v: string): void { const index = this.value.indexOf(v); if (index >= 0) { this.value.splice(index, 1); if (this.value.length === 0) { this.value = null; } this.emit(this.value); } } /** * chips d&d handler */ dropChip(event: CdkDragDrop<string[]>) { moveItemInArray(this.value, event.previousIndex, event.currentIndex); this.emit(this.value); } }