diff --git a/src/app/hypermedia-view/formly-extensions.spec.ts b/src/app/hypermedia-view/formly-extensions.spec.ts new file mode 100644 index 0000000..609aa7e --- /dev/null +++ b/src/app/hypermedia-view/formly-extensions.spec.ts @@ -0,0 +1,85 @@ +import {TestBed} from '@angular/core/testing'; +import {FormGroup} from '@angular/forms'; +import {FormlyFieldConfig, FormlyFormBuilder, FormlyModule} from '@ngx-formly/core'; +import {FormlyJsonschema} from '@ngx-formly/core/json-schema'; +import {FormlyMaterialModule} from '@ngx-formly/material'; +import {JSONSchema7} from 'json-schema'; +import {createActionFormlyFields} from './formly-extensions'; +import {SchemaSimplifier} from './siren-parser/schema-simplifier'; + +describe('createActionFormlyFields', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + FormlyModule.forRoot({ + // the app's custom components are not needed to build the form controls + types: [ + {name: 'null', extends: 'input'}, + {name: 'object', extends: 'formly-group'}, + {name: 'multischema', extends: 'formly-group'}, + ], + }), + FormlyMaterialModule, + ], + }); + }); + + const enumValues = ['Running', 'Stopped']; + const definitions: JSONSchema7['definitions'] = { + State: {enum: enumValues}, + TypedState: {type: 'string', enum: enumValues}, + }; + + // runs the schema through the same simplification as schemas fetched from the server + function buildForm(property: JSONSchema7, model: object = {}): {form: FormGroup, field: FormlyFieldConfig} { + const schema: JSONSchema7 = {type: 'object', properties: {value: property}, definitions: structuredClone(definitions)}; + new SchemaSimplifier().simplifySchema(schema); + const fields = createActionFormlyFields(TestBed.inject(FormlyJsonschema), schema); + const form = new FormGroup({}); + TestBed.inject(FormlyFormBuilder).build({form, fieldGroup: fields, model, options: {}}); + return {form, field: fields[0].fieldGroup![0]}; + } + + const optionalCases: [string, JSONSchema7][] = [ + ['nullable type', {type: ['string', 'null']}], + ['nullable integer', {type: ['integer', 'null']}], + ['nullable boolean', {type: ['boolean', 'null']}], + ['nullable date', {type: ['string', 'null'], format: 'date'}], + ['nullable integer as oneOf', {oneOf: [{type: 'integer', format: 'int32'}, {type: 'null'}]}], + ['enum containing null without type', {enum: [...enumValues, null]}], + ['nullable inline enum as oneOf', {oneOf: [{type: 'null'}, {enum: enumValues}]}], + ['nullable enum reference as oneOf', {oneOf: [{$ref: '#/definitions/State'}, {type: 'null'}]}], + ['nullable typed enum reference as oneOf', {oneOf: [{$ref: '#/definitions/TypedState'}, {type: 'null'}]}], + ['nullable type as anyOf', {anyOf: [{type: 'string'}, {type: 'null'}]}], + ['schema without type', {}], + ]; + + optionalCases.forEach(([name, property]) => { + it(`accepts an empty value for a ${name}`, () => { + const {form, field} = buildForm(property); + expect(form.valid).toBeTrue(); + expect(field.props?.required).toBeFalsy(); + }); + }); + + const mandatoryCases: [string, JSONSchema7, unknown][] = [ + ['non nullable type', {type: 'string'}, 'Running'], + ['integer', {type: 'integer', format: 'int32'}, 0], + ['boolean', {type: 'boolean'}, false], + ['enum without null', {enum: enumValues}, 'Running'], + ['enum reference', {$ref: '#/definitions/State'}, 'Running'], + ['number or integer as anyOf', {anyOf: [{type: 'number'}, {type: 'integer'}]}, 1], + ]; + + mandatoryCases.forEach(([name, property, value]) => { + it(`rejects an empty value for a ${name}`, () => { + const {form, field} = buildForm(property); + expect(form.valid).toBeFalse(); + expect(field.props?.required).toBeTrue(); + }); + + it(`accepts a value for a ${name}`, () => { + expect(buildForm(property, {value}).form.valid).toBeTrue(); + }); + }); +}); diff --git a/src/app/hypermedia-view/formly-extensions.ts b/src/app/hypermedia-view/formly-extensions.ts index 9f1fbc6..3a260d2 100644 --- a/src/app/hypermedia-view/formly-extensions.ts +++ b/src/app/hypermedia-view/formly-extensions.ts @@ -2,6 +2,7 @@ import {AbstractControl} from '@angular/forms'; import {FormlyExtension, FormlyFieldConfig} from '@ngx-formly/core'; import {FormlyJsonschema} from '@ngx-formly/core/json-schema'; +import {JSONSchema7} from 'json-schema'; // fix forms with arrays do not allow empty arrays to be submitted (which is not null) export const allowEmptyArrayExtension: FormlyExtension = { @@ -36,9 +37,14 @@ export function createActionFormlyFields(formlyJsonschema: FormlyJsonschema, jso if (mappedField.key && mappedField.props) { mappedField.props.label = mappedField.key + ''; } + const types = schemaTypes(mapSource); + const allowsNull = isUntyped(mapSource) || isNullable(mapSource) || hasNullableBranch(mapSource); mappedField.validators ??= {}; - mappedField.validators['required'] = (control: AbstractControl) => (types.includes('null') || (control.value !== null && control.value !== undefined)); - const types = mapSource.type === undefined ? [] : mapSource.type instanceof Array ? mapSource.type : [mapSource.type]; + mappedField.validators['required'] = (control: AbstractControl) => (allowsNull || (control.value !== null && control.value !== undefined)); + // shows the asterisk, formly only sets it for properties in the schema's required list + if (!allowsNull && mappedField.key !== undefined && mappedField.props) { + mappedField.props.required = true; + } if (types.includes('string') && mapSource.format === 'date') { mappedField.type = 'date'; mappedField.parsers = [ @@ -51,6 +57,24 @@ export function createActionFormlyFields(formlyJsonschema: FormlyJsonschema, jso ]; } +function schemaTypes(schema: JSONSchema7): string[] { + return schema.type === undefined ? [] : schema.type instanceof Array ? schema.type : [schema.type]; +} + +function isNullable(schema: JSONSchema7): boolean { + return schemaTypes(schema).includes('null') || !!schema.enum?.includes(null); +} + +// the simplifier types anyOf wrappers as object, so their own type does not tell +function hasNullableBranch(schema: JSONSchema7): boolean { + return [...(schema.oneOf ?? []), ...(schema.anyOf ?? [])].some(branch => typeof branch === 'object' && isNullable(branch)); +} + +// no type info, e.g. {} +function isUntyped(schema: JSONSchema7): boolean { + return schema.type === undefined && schema.enum === undefined && schema.const === undefined; +} + function formatDate(date: Date) { const year = date.getFullYear(); const month = date.getMonth() + 1;