diff --git a/src/app/components/array-form/array-form.component.ts b/src/app/components/array-form/array-form.component.ts index 973515f..ac5a1f6 100644 --- a/src/app/components/array-form/array-form.component.ts +++ b/src/app/components/array-form/array-form.component.ts @@ -5,6 +5,7 @@ import { MatIconModule } from '@angular/material/icon'; import { ParamInfo } from '../../models/form.model'; import { ListsService } from '../../services/lists.service'; import { SchemaService } from '../../services/schema.service'; +import { parseStringToArray } from '../../utils/params.utils'; @Component({ selector: 'app-array-form', @@ -30,7 +31,7 @@ export class ArrayFormComponent implements OnInit { ngOnInit(): void { if (this.paramFormControl && this.paramInfo?.schema) { if (!Array.isArray(this.paramFormControl.value)) { - this.arrayValue = this.schemaService.parseStringToArray( + this.arrayValue = parseStringToArray( this.paramFormControl.value, this.paramInfo.schema, ); diff --git a/src/app/components/params-form/params-form.component.html b/src/app/components/params-form/params-form.component.html index 65fe90e..02ef436 100644 --- a/src/app/components/params-form/params-form.component.html +++ b/src/app/components/params-form/params-form.component.html @@ -1,15 +1,4 @@ @if (paramsSchema && paramsFormInfo) { - @if (paramsOptions.length > 1) { - - @for (paramsOption of paramsOptions; track paramsOption) { - - } - - }
@for (key of paramKeys(); track key) { diff --git a/src/app/components/params-form/params-form.component.spec.ts b/src/app/components/params-form/params-form.component.spec.ts new file mode 100644 index 0000000..ec1ac7c --- /dev/null +++ b/src/app/components/params-form/params-form.component.spec.ts @@ -0,0 +1,95 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing' +import { ParamsFormComponent } from './params-form.component'; +import { SchemaService } from '../../services/schema.service'; +import { SomeJSONSchema } from 'ajv/dist/types/json-schema'; + +describe('ParamsForm', ()=>{ + let component: ParamsFormComponent; + let fixture: ComponentFixture; + + beforeEach(async ()=>{ + fixture = TestBed.createComponent(ParamsFormComponent); + component = fixture.componentInstance; + await fixture.whenStable(); + }) + + it('should create', ()=>{ + expect(component).toBeTruthy(); + }) + + it('should create form for schema', async ()=>{ + const paramsSchema: SomeJSONSchema = { + type: "object", + properties: {}, + required: [] + } + fixture.componentRef.setInput('paramsSchema', paramsSchema) + await fixture.whenStable() + expect(fixture.nativeElement.querySelector('form')).toBeTruthy() + }) + + it('form should have input for string property', async ()=>{ + const paramsSchema: SomeJSONSchema = { + type: "object", + properties: { + "test": { + type: "string", + } + }, + required: [] + } + fixture.componentRef.setInput('paramsSchema', paramsSchema) + await fixture.whenStable() + const formEl = fixture.nativeElement.querySelector('form') + expect(formEl).toBeDefined() + const inputEl = formEl.querySelector('input') + expect(inputEl).toBeDefined() + }) + + it('form should have select for string enum property', async ()=>{ + const paramsSchema: SomeJSONSchema = { + type: "object", + properties: { + "test": { + type: "string", + enum: ['one','two','three'] + } + }, + required: [] + } + fixture.componentRef.setInput('paramsSchema', paramsSchema) + await fixture.whenStable() + const formEl = fixture.nativeElement.querySelector('form') + expect(formEl).toBeDefined() + const selectEl = formEl.querySelector('select') + expect(selectEl).toBeDefined() + }) + + it('form input should reflect data', async ()=>{ + const paramsSchema: SomeJSONSchema = { + type: "object", + properties: { + "test": { + type: "string", + } + }, + required: [] + } + + fixture.componentRef.setInput('paramsSchema', paramsSchema) + await fixture.whenStable() + fixture.componentRef.setInput('data', { + test: 'hello' + }) + await fixture.whenStable() + const inputEl = fixture.nativeElement.querySelector('input') + expect(inputEl).toBeDefined() + expect(inputEl.value).toBe('hello') + + fixture.componentRef.setInput('data', { + test: 'changed' + }) + await fixture.whenStable() + expect(inputEl.value).toBe('changed') + }) +}) \ No newline at end of file diff --git a/src/app/components/params-form/params-form.component.ts b/src/app/components/params-form/params-form.component.ts index efc47b5..bbbe452 100644 --- a/src/app/components/params-form/params-form.component.ts +++ b/src/app/components/params-form/params-form.component.ts @@ -1,18 +1,18 @@ import { JsonPipe } from '@angular/common'; -import { Component, inject, Input, OnInit, output } from '@angular/core'; +import { Component, inject, Input, OnChanges, OnDestroy, OnInit, output, SimpleChange, SimpleChanges } from '@angular/core'; import { ReactiveFormsModule } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; -import { MatTabChangeEvent, MatTabsModule } from '@angular/material/tabs'; +import { MatTabsModule } from '@angular/material/tabs'; import { MatTooltipModule } from '@angular/material/tooltip'; import { SomeJSONSchema } from 'ajv/dist/types/json-schema'; -import { cloneDeep, has } from 'lodash-es'; +import { cloneDeep, has, isEqual } from 'lodash-es'; import { Subscription } from 'rxjs'; import { ParamInfo, ParamsFormInfo } from '../../models/form.model'; import { SchemaService } from '../../services/schema.service'; +import { cleanParams, schemaToParamsFormInfo } from '../../utils/params.utils'; import { ArrayFormComponent } from '../array-form/array-form.component'; - @Component({ selector: 'app-params-form', templateUrl: './params-form.component.html', @@ -29,7 +29,7 @@ import { ArrayFormComponent } from '../array-form/array-form.component'; ], standalone: true, }) -export class ParamsFormComponent implements OnInit { +export class ParamsFormComponent implements OnChanges, OnDestroy { @Input() paramsSchema?: SomeJSONSchema; @Input() data?: any; updated = output(); @@ -37,136 +37,75 @@ export class ParamsFormComponent implements OnInit { paramsFormInfo?: ParamsFormInfo; formGroupSubscription?: Subscription; - paramsOptions: { - display: string; - paramsFormInfo: ParamsFormInfo; - keys: string[]; - schema: SomeJSONSchema; - }[] = []; - paramsOptionsSelectedIndex: number = 0; - private schemaService = inject(SchemaService); - - constructor() {} - - ngOnInit(): void { - if (this.paramsSchema) { - if (this.paramsSchema.properties) { - this.paramsFormInfo = this.schemaService.getFormInfoFromParamsSchema(this.paramsSchema); - } else if (this.paramsSchema.oneOf) { - this.paramsOptions = this.paramsSchema.oneOf.map((oneOf: any, index: number) => { - const paramsOption = { - display: oneOf.title ? oneOf.title : `Option ${index + 1}`, - schema: oneOf, - paramsFormInfo: this.schemaService.getFormInfoFromParamsSchema(oneOf), - }; - return { - ...paramsOption, - keys: Object.keys(paramsOption.paramsFormInfo.formGroup.controls), - }; - }); - const matchingSchemaIndex = this.schemaService.matchParamsDataToSchema( - this.data, - this.paramsOptions.map((paramsOption) => paramsOption.schema), - ); - - this.paramsOptionsSelectedIndex = matchingSchemaIndex; - this.paramsFormInfo = this.paramsOptions[matchingSchemaIndex].paramsFormInfo; - this.paramsSchema = this.paramsOptions[matchingSchemaIndex].schema; - } else { - console.error('params is not a singular or oneOf'); - console.error(this.paramsSchema); - } + ngOnDestroy(): void { + console.log('params-form destroyed') + if (this.formGroupSubscription) { + this.formGroupSubscription.unsubscribe() } - - if (this.data && this.paramsFormInfo?.formGroup) { - // NOTE(jwetzell): prepare data for form patching - const dataToPatch = cloneDeep(this.data); - Object.entries(this.paramsFormInfo.paramsInfo).forEach(([paramKey, paramInfo]) => { - if (has(dataToPatch, paramKey)) { - switch (paramInfo.type) { - case 'object': - dataToPatch[paramKey] = JSON.stringify(dataToPatch[paramKey]); - break; - case 'array': - dataToPatch[paramKey] = dataToPatch[paramKey] - .map((item: any) => { - switch (typeof item) { - case 'object': - return JSON.stringify(item); - - default: - return item; - } - }) - .join(','); - break; - default: - break; - } - } - }); - - this.paramsFormInfo.formGroup.patchValue(dataToPatch); - } - - this.formGroupSubscription = this.paramsFormInfo?.formGroup.valueChanges.subscribe((value) => { - this.formUpdated(); - }); } - paramsOptionsTabSelected(event: MatTabChangeEvent) { - // NOTE(jwetzell): no longer interested in the old formGroup valueChanges - if (this.formGroupSubscription) { - this.formGroupSubscription.unsubscribe(); - } - - const paramsOption = this.paramsOptions[event.index]; - - this.paramsSchema = paramsOption.schema; - this.paramsFormInfo = paramsOption.paramsFormInfo; - - // NOTE(jwetzell): modify params that should change from the data when switch paramOptions - Object.entries(this.paramsFormInfo.paramsInfo).forEach(([paramKey, paramInfo]) => { - if (this.data !== undefined) { - if (paramInfo.isConst) { - if (this.data[paramKey]) { - delete this.data[paramKey]; - } - } - - if (paramInfo.default) { - if (this.data[paramKey] !== undefined && this.data[paramKey] !== paramInfo.default) { - this.data[paramKey] = paramInfo.default; + ngOnChanges(changes: SimpleChanges<{ + paramsSchema: SomeJSONSchema + data: any + }>): void { + console.log(changes) + if (changes.paramsSchema) { + if (changes.paramsSchema.previousValue === undefined && changes.paramsSchema.currentValue !== undefined) { + if (this.paramsSchema) { + if (this.paramsSchema.properties) { + this.paramsFormInfo = schemaToParamsFormInfo(this.paramsSchema); + if (this.formGroupSubscription === undefined) { + this.formGroupSubscription = this.paramsFormInfo?.formGroup.valueChanges.subscribe((value) => { + this.formUpdated(); + }); + } + } else { + console.error('params is not a singular object'); + console.error(this.paramsSchema); } } } - }); + } - const allowedParamKeys = Object.keys(this.paramsSchema?.properties); - if (this.data) { - // NOTE(jwetzell): remove keys that aren't allowed in the new params variation - Object.keys(this.data).forEach((paramKey) => { - if (allowedParamKeys && !allowedParamKeys.includes(paramKey)) { - delete this.data[paramKey]; + if (changes.data) { + if (!isEqual(changes.data.currentValue, changes.data.previousValue)) { + if (this.paramsFormInfo?.formGroup !== undefined) { + // NOTE(jwetzell): prepare data for form patching + const dataToPatch = cloneDeep(this.data); + Object.entries(this.paramsFormInfo.paramsInfo).forEach(([paramKey, paramInfo]) => { + if (has(dataToPatch, paramKey)) { + switch (paramInfo.type) { + case 'object': + dataToPatch[paramKey] = JSON.stringify(dataToPatch[paramKey]); + break; + case 'array': + dataToPatch[paramKey] = dataToPatch[paramKey] + .map((item: any) => { + switch (typeof item) { + case 'object': + return JSON.stringify(item); + default: + return item; + } + }) + .join(','); + break; + default: + break; + } + } + }); + + this.paramsFormInfo.formGroup.patchValue(dataToPatch); } - }); - } - - if (this.paramsFormInfo.formGroup) { - this.formGroupSubscription = this.paramsFormInfo.formGroup.valueChanges.subscribe((value) => { - this.formUpdated(); - }); - } - - if (this.data && this.paramsFormInfo.formGroup) { - this.paramsFormInfo.formGroup.patchValue(this.data); + } } } formUpdated() { if (this.paramsSchema) { - const params = this.schemaService.cleanParams( + const params = cleanParams( this.paramsSchema, this.paramsFormInfo?.formGroup.value, ); @@ -186,14 +125,16 @@ export class ParamsFormComponent implements OnInit { getParamInfo(key: string): ParamInfo | undefined { return this.paramsFormInfo?.paramsInfo[key]; } - + getParamValue(key: string) { if (this.paramsSchema) { - const params = this.schemaService.cleanParams( + const params = cleanParams( this.paramsSchema, this.paramsFormInfo?.formGroup.value, ); return params[key]; } } + + } diff --git a/src/app/services/schema.service.ts b/src/app/services/schema.service.ts index 1c5ecfb..f24e4b5 100644 --- a/src/app/services/schema.service.ts +++ b/src/app/services/schema.service.ts @@ -286,97 +286,7 @@ export class SchemaService { return 0; } - getFormInfoFromParamsSchema(schema: SomeJSONSchema): ParamsFormInfo { - const paramsFormInfo: ParamsFormInfo = { - formGroup: new FormGroup({}), - paramsInfo: {}, - }; - if (schema?.properties) { - const paramKeys = Object.keys(schema.properties); - Object.entries(schema.properties).forEach(([paramKey, paramSchema]: [string, any]) => { - if (paramSchema.type) { - switch (paramSchema.type) { - case 'string': - case 'number': - case 'integer': - case 'boolean': - case 'array': // TODO(jwetzell): actually handle arrays - case 'object': // TODO(jwetzell): actually handle objects - let formDefault = ''; - const validators: ValidatorFn[] = []; - - // NOTE(jwetzell): check for a default value to set - if (paramSchema.const) { - formDefault = paramSchema.const; - } else if (paramSchema.default) { - formDefault = paramSchema.default; - } - - // NOTE(jwetzell): add as many validators as we can - if (paramSchema.minimum) { - validators.push(Validators.min(paramSchema.minimum)); - } - - if (paramSchema.maximum) { - validators.push(Validators.max(paramSchema.maximum)); - } - - if (paramSchema.pattern) { - validators.push(Validators.pattern(new RegExp(paramSchema.pattern))); - } - - if (schema.required) { - if (schema.required.includes(paramKey)) { - validators.push(Validators.required); - } - } - - paramsFormInfo.paramsInfo[paramKey] = { - key: paramKey, - display: paramSchema.title ? paramSchema.title : paramKey, - type: paramSchema.type, - hint: paramSchema.description, - isConst: !!paramSchema.const, - schema: paramSchema, - placeholder: '', - default: paramSchema.default ? paramSchema.default : undefined, - }; - - if (paramSchema.examples && paramSchema.examples.length > 0) { - paramsFormInfo.paramsInfo[paramKey].placeholder = paramSchema.examples[0]; - } - - if (paramSchema.type === 'object') { - validators.push(this.objectValidator); - } - - //TODO(jwetzell): figure out how to disable a control but not have to deal with undefined values on disabled controls - paramsFormInfo.formGroup.addControl( - paramKey, - new FormControl(formDefault, validators), - ); - - if (paramSchema.enum) { - paramsFormInfo.paramsInfo[paramKey].options = paramSchema.enum; - } - - break; - default: - console.error( - `schema-service: unhandled param schema type for form group = ${paramSchema.type}`, - ); - break; - } - } else { - console.error('schema-service: param property without type'); - } - }); - } else { - console.error('trigger-form: params schema without properties'); - console.error(schema); - } - return paramsFormInfo; - } + cleanArray(values: any[], itemSchema: SomeJSONSchema) { if (Array.isArray(values)) { @@ -397,117 +307,6 @@ export class SchemaService { return []; } - cleanParams(paramsSchema: SomeJSONSchema, params: any): any { - Object.keys(params).forEach((paramKey) => { - if (paramsSchema.properties[paramKey]) { - const paramSchema = paramsSchema.properties[paramKey]; - - // delete null/undefined/empty params that aren't required - if ( - params[paramKey] === undefined || - params[paramKey] === null || - params[paramKey] === '' - ) { - if (paramSchema.required) { - if (!paramSchema.includes(paramKey)) { - delete params[paramKey]; - return; - } - } else { - delete params[paramKey]; - return; - } - } - - if (paramSchema.type) { - switch (paramSchema.type) { - case 'integer': - var paramValue = parseInt(params[paramKey]); - // NOTE(jwetzell): delete non-numbers - if (Number.isNaN(paramValue)) { - delete params[paramKey]; - } else { - params[paramKey] = paramValue; - } - break; - case 'number': - var paramValue = parseFloat(params[paramKey]); - // NOTE(jwetzell): delete non-numbers - if (Number.isNaN(paramValue)) { - delete params[paramKey]; - } else { - params[paramKey] = paramValue; - } - break; - case 'array': - if (!Array.isArray(params[paramKey])) { - const paramValue = params[paramKey]; - params[paramKey] = this.parseStringToArray(paramValue, paramSchema); - } - break; - case 'object': - try { - params[paramKey] = JSON.parse(params[paramKey]); - } catch (error) { - console.error('object param is not JSON'); - } - break; - case 'string': - // string is default so nothing needs to happen to clean it's value - break; - default: - console.log(`schema-service: unhandled param schema type: ${paramSchema.type}`); - break; - } - } - } - }); - return params; - } - - parseStringToArray(value: any, schema: SomeJSONSchema): any[] | undefined { - if (!Array.isArray(value)) { - const paramValue = value; - if (paramValue === undefined || paramValue.trim().length === 0) { - value = []; - } else { - if (schema.items?.type) { - if (schema.items?.type === 'integer') { - return paramValue - .split(',') - .map((part: string) => part.trim()) - .map((item: any) => parseInt(item)); - } else if (schema.items?.type === 'number') { - return paramValue - .split(',') - .map((part: string) => part.trim()) - .map((item: any) => parseFloat(item)); - } else if (schema.items?.type === 'string') { - return paramValue.split(',').map((part: string) => part.trim()); - } else if (schema.items?.type === 'object') { - // TODO(jwetzell): this seems gross, not sure if this covers everything - return JSON.parse(`[${paramValue}]`); - } else { - console.error(`schema-service: unhandled array schema type: ${schema.items?.type}`); - } - } else { - // NOTE(jwetzell): default to comma-separated strings - return paramValue.split(',').map((part: string) => part.trim()); - } - } - } - return undefined; - } - - objectValidator(control: AbstractControl): ValidationErrors | null { - try { - JSON.parse(control.value); - return null; - } catch (error) { - return { json: true }; - } - } - jsonValidator(validateSchema: SomeJSONSchema) { return (control: AbstractControl): ValidationErrors | null => { try { diff --git a/src/app/utils/params.utils.ts b/src/app/utils/params.utils.ts new file mode 100644 index 0000000..2a6c7db --- /dev/null +++ b/src/app/utils/params.utils.ts @@ -0,0 +1,206 @@ +import { FormGroup, ValidatorFn, Validators, FormControl, AbstractControl, ValidationErrors } from "@angular/forms"; +import { SomeJSONSchema } from "ajv/dist/types/json-schema"; +import { ParamsFormInfo } from "../models/form.model"; + +export function schemaToParamsFormInfo(schema: SomeJSONSchema): ParamsFormInfo { + const paramsFormInfo: ParamsFormInfo = { + formGroup: new FormGroup({}), + paramsInfo: {}, + }; + if (schema?.properties) { + const paramKeys = Object.keys(schema.properties); + Object.entries(schema.properties).forEach(([paramKey, paramSchema]: [string, any]) => { + if (paramSchema.type) { + switch (paramSchema.type) { + case 'string': + case 'number': + case 'integer': + case 'boolean': + case 'array': // TODO(jwetzell): actually handle arrays + case 'object': // TODO(jwetzell): actually handle objects + let formDefault = ''; + const validators: ValidatorFn[] = []; + + // NOTE(jwetzell): check for a default value to set + if (paramSchema.const) { + formDefault = paramSchema.const; + } else if (paramSchema.default) { + formDefault = paramSchema.default; + } + + // NOTE(jwetzell): add as many validators as we can + if (paramSchema.minimum) { + validators.push(Validators.min(paramSchema.minimum)); + } + + if (paramSchema.maximum) { + validators.push(Validators.max(paramSchema.maximum)); + } + + if (paramSchema.pattern) { + validators.push(Validators.pattern(new RegExp(paramSchema.pattern))); + } + + if (schema.required) { + if (schema.required.includes(paramKey)) { + validators.push(Validators.required); + } + } + + paramsFormInfo.paramsInfo[paramKey] = { + key: paramKey, + display: paramSchema.title ? paramSchema.title : paramKey, + type: paramSchema.type, + hint: paramSchema.description, + isConst: !!paramSchema.const, + schema: paramSchema, + placeholder: '', + default: paramSchema.default ? paramSchema.default : undefined, + }; + + if (paramSchema.examples && paramSchema.examples.length > 0) { + paramsFormInfo.paramsInfo[paramKey].placeholder = paramSchema.examples[0]; + } + + if (paramSchema.type === 'object') { + validators.push(objectValidator); + } + + //TODO(jwetzell): figure out how to disable a control but not have to deal with undefined values on disabled controls + paramsFormInfo.formGroup.addControl( + paramKey, + new FormControl(formDefault, validators), + ); + + if (paramSchema.enum) { + paramsFormInfo.paramsInfo[paramKey].options = paramSchema.enum; + } + + break; + default: + console.error( + `schema-service: unhandled param schema type for form group = ${paramSchema.type}`, + ); + break; + } + } else { + console.error('schema-service: param property without type'); + } + }); + } else { + console.error('trigger-form: params schema without properties'); + console.error(schema); + } + return paramsFormInfo; +} + +export function cleanParams(paramsSchema: SomeJSONSchema, params: any): any { + Object.keys(params).forEach((paramKey) => { + if (paramsSchema.properties[paramKey]) { + const paramSchema = paramsSchema.properties[paramKey]; + + // delete null/undefined/empty params that aren't required + if ( + params[paramKey] === undefined || + params[paramKey] === null || + params[paramKey] === '' + ) { + if (paramSchema.required) { + if (!paramSchema.includes(paramKey)) { + delete params[paramKey]; + return; + } + } else { + delete params[paramKey]; + return; + } + } + + if (paramSchema.type) { + switch (paramSchema.type) { + case 'integer': + var paramValue = parseInt(params[paramKey]); + // NOTE(jwetzell): delete non-numbers + if (Number.isNaN(paramValue)) { + delete params[paramKey]; + } else { + params[paramKey] = paramValue; + } + break; + case 'number': + var paramValue = parseFloat(params[paramKey]); + // NOTE(jwetzell): delete non-numbers + if (Number.isNaN(paramValue)) { + delete params[paramKey]; + } else { + params[paramKey] = paramValue; + } + break; + case 'array': + if (!Array.isArray(params[paramKey])) { + const paramValue = params[paramKey]; + params[paramKey] = parseStringToArray(paramValue, paramSchema); + } + break; + case 'object': + try { + params[paramKey] = JSON.parse(params[paramKey]); + } catch (error) { + console.error('object param is not JSON'); + } + break; + case 'string': + // string is default so nothing needs to happen to clean it's value + break; + default: + console.log(`schema-service: unhandled param schema type: ${paramSchema.type}`); + break; + } + } + } + }); + return params; +} + +function objectValidator(control: AbstractControl): ValidationErrors | null { + try { + JSON.parse(control.value); + return null; + } catch (error) { + return { json: true }; + } +} + +export function parseStringToArray(value: any, schema: SomeJSONSchema): any[] | undefined { + if (!Array.isArray(value)) { + const paramValue = value; + if (paramValue === undefined || paramValue.trim().length === 0) { + value = []; + } else { + if (schema.items?.type) { + if (schema.items?.type === 'integer') { + return paramValue + .split(',') + .map((part: string) => part.trim()) + .map((item: any) => parseInt(item)); + } else if (schema.items?.type === 'number') { + return paramValue + .split(',') + .map((part: string) => part.trim()) + .map((item: any) => parseFloat(item)); + } else if (schema.items?.type === 'string') { + return paramValue.split(',').map((part: string) => part.trim()); + } else if (schema.items?.type === 'object') { + // TODO(jwetzell): this seems gross, not sure if this covers everything + return JSON.parse(`[${paramValue}]`); + } else { + console.error(`schema-service: unhandled array schema type: ${schema.items?.type}`); + } + } else { + // NOTE(jwetzell): default to comma-separated strings + return paramValue.split(',').map((part: string) => part.trim()); + } + } + } + return undefined; +} \ No newline at end of file