Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions src/app/hypermedia-view/formly-extensions.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
28 changes: 26 additions & 2 deletions src/app/hypermedia-view/formly-extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 = [
Expand All @@ -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;
Expand Down
Loading