Skip to content

Commit

Permalink
feat(json-schema): partial support of anyOf/oneOf keyword (#1811)
Browse files Browse the repository at this point in the history
  • Loading branch information
aitboudad committed Sep 30, 2019
1 parent 371723e commit d60e0f5
Show file tree
Hide file tree
Showing 9 changed files with 249 additions and 6 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ See the [issues labeled enhancement](https://github.com/ngx-formly/ngx-formly/la

## Contributors

Thanks goes to these wonderful people ([emoji key](https://github.com/kentcdodds/all-contributors#emoji-key)):
Thanks goes to these wonderful people:

<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
[<img alt="aitboudad" src="https://avatars2.githubusercontent.com/u/1753742?v=4&s=117" width="117">](https://github.com/aitboudad)[<img alt="mohammedzamakhan" src="https://avatars3.githubusercontent.com/u/2327532?v=4&s=117" width="117">](https://github.com/mohammedzamakhan)[<img alt="divyakumarjain" src="https://avatars2.githubusercontent.com/u/2039134?v=4&s=117" width="117">](https://github.com/divyakumarjain)[<img alt="couzic" src="https://avatars2.githubusercontent.com/u/1380322?v=4&s=117" width="117">](https://github.com/couzic)[<img alt="juristr" src="https://avatars3.githubusercontent.com/u/542458?v=4&s=117" width="117">](https://github.com/juristr)[<img alt="franzeal" src="https://avatars3.githubusercontent.com/u/7455769?v=4&s=117" width="117">](https://github.com/franzeal)
Expand Down
4 changes: 3 additions & 1 deletion demo/src/app/examples/advanced/json-schema/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@ export class AppComponent {
'numbers',
'references',
'schema_dependencies',
'allOf',
'null_field',
'nullable',
'allOf',
'anyOf',
'oneOf',
'select_alternatives',
];

Expand Down
3 changes: 3 additions & 0 deletions demo/src/app/examples/advanced/json-schema/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { MatMenuModule } from '@angular/material/menu';
import { AppComponent } from './app.component';
import { ArrayTypeComponent } from './array.type';
import { ObjectTypeComponent } from './object.type';
import { MultiSchemaTypeComponent } from './multischema.type';
import { NullTypeComponent } from './null.type';

export function minItemsValidationMessage(err, field: FormlyFieldConfig) {
Expand Down Expand Up @@ -93,13 +94,15 @@ export function exclusiveMaximumValidationMessage(err, field: FormlyFieldConfig)
{ name: 'null', component: NullTypeComponent, wrappers: ['form-field'] },
{ name: 'array', component: ArrayTypeComponent },
{ name: 'object', component: ObjectTypeComponent },
{ name: 'multischema', component: MultiSchemaTypeComponent },
],
}),
],
declarations: [
AppComponent,
ArrayTypeComponent,
ObjectTypeComponent,
MultiSchemaTypeComponent,
NullTypeComponent,
],
})
Expand Down
5 changes: 4 additions & 1 deletion demo/src/app/examples/advanced/json-schema/config.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,19 @@ import { AppComponent } from './app.component';
{ file: 'app.module.ts', content: require('!!highlight-loader?raw=true&lang=typescript!./app.module.ts'), filecontent: require('!!raw-loader!./app.module.ts') },
{ file: 'array.type.ts', content: require('!!highlight-loader?raw=true&lang=typescript!./array.type.ts'), filecontent: require('!!raw-loader!./array.type.ts') },
{ file: 'object.type.ts', content: require('!!highlight-loader?raw=true&lang=typescript!./object.type.ts'), filecontent: require('!!raw-loader!./object.type.ts') },
{ file: 'multischema.type.ts', content: require('!!highlight-loader?raw=true&lang=typescript!./multischema.type.ts'), filecontent: require('!!raw-loader!./multischema.type.ts') },
{ file: 'null.type.ts', content: require('!!highlight-loader?raw=true&lang=typescript!./null.type.ts'), filecontent: require('!!raw-loader!./null.type.ts') },
{ file: 'assets/json-schema/simple.json', content: require('!!highlight-loader?raw=true&lang=typescript!@assets/json-schema/simple_json'), filecontent: require('!!raw-loader!@assets/json-schema/simple_json') },
{ file: 'assets/json-schema/nested.json', content: require('!!highlight-loader?raw=true&lang=typescript!@assets/json-schema/nested_json'), filecontent: require('!!raw-loader!@assets/json-schema/nested_json') },
{ file: 'assets/json-schema/arrays.json', content: require('!!highlight-loader?raw=true&lang=typescript!@assets/json-schema/arrays_json'), filecontent: require('!!raw-loader!@assets/json-schema/arrays_json') },
{ file: 'assets/json-schema/numbers.json', content: require('!!highlight-loader?raw=true&lang=typescript!@assets/json-schema/numbers_json'), filecontent: require('!!raw-loader!@assets/json-schema/numbers_json') },
{ file: 'assets/json-schema/references.json', content: require('!!highlight-loader?raw=true&lang=typescript!@assets/json-schema/references_json'), filecontent: require('!!raw-loader!@assets/json-schema/references_json') },
{ file: 'assets/json-schema/schema_dependencies.json', content: require('!!highlight-loader?raw=true&lang=typescript!@assets/json-schema/schema_dependencies_json'), filecontent: require('!!raw-loader!@assets/json-schema/schema_dependencies_json') },
{ file: 'assets/json-schema/allOf.json', content: require('!!highlight-loader?raw=true&lang=typescript!@assets/json-schema/allOf_json'), filecontent: require('!!raw-loader!@assets/json-schema/allOf_json') },
{ file: 'assets/json-schema/null_field.json', content: require('!!highlight-loader?raw=true&lang=typescript!@assets/json-schema/null_field_json'), filecontent: require('!!raw-loader!@assets/json-schema/null_field_json') },
{ file: 'assets/json-schema/nullable.json', content: require('!!highlight-loader?raw=true&lang=typescript!@assets/json-schema/nullable_json'), filecontent: require('!!raw-loader!@assets/json-schema/nullable_json') },
{ file: 'assets/json-schema/allOf.json', content: require('!!highlight-loader?raw=true&lang=typescript!@assets/json-schema/allOf_json'), filecontent: require('!!raw-loader!@assets/json-schema/allOf_json') },
{ file: 'assets/json-schema/anyOf.json', content: require('!!highlight-loader?raw=true&lang=typescript!@assets/json-schema/anyOf_json'), filecontent: require('!!raw-loader!@assets/json-schema/anyOf_json') },
{ file: 'assets/json-schema/oneOf.json', content: require('!!highlight-loader?raw=true&lang=typescript!@assets/json-schema/oneOf_json'), filecontent: require('!!raw-loader!@assets/json-schema/oneOf_json') },
{ file: 'assets/json-schema/select_alternatives.json', content: require('!!highlight-loader?raw=true&lang=typescript!@assets/json-schema/select_alternatives_json'), filecontent: require('!!raw-loader!@assets/json-schema/select_alternatives_json') },
],
}],
Expand Down
19 changes: 19 additions & 0 deletions demo/src/app/examples/advanced/json-schema/multischema.type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Component } from '@angular/core';
import { FieldType } from '@ngx-formly/core';

@Component({
selector: 'formly-multi-schema-type',
template: `
<div class="card mb-3">
<div class="card-body">
<legend *ngIf="to.label">{{ to.label }}</legend>
<p *ngIf="to.description">{{ to.description }}</p>
<div class="alert alert-danger" role="alert" *ngIf="showError && formControl.errors">
<formly-validation-message [field]="field"></formly-validation-message>
</div>
<formly-field *ngFor="let f of field.fieldGroup" [field]="f"></formly-field>
</div>
</div>
`,
})
export class MultiSchemaTypeComponent extends FieldType {}
39 changes: 39 additions & 0 deletions demo/src/assets/json-schema/anyOf_json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"schema": {
"type": "object",
"properties": {
"age": {
"type": "integer",
"title": "Age"
}
},
"anyOf": [
{
"title": "First method of identification",
"properties": {
"firstName": {
"type": "string",
"title": "First name",
"default": "Chuck"
},
"lastName": {
"type": "string",
"title": "Last name"
}
}
},
{
"title": "Second method of identification",
"properties": {
"idCode": {
"type": "string",
"title": "ID code"
}
}
}
]
},
"model": {
"firstName": "Chuck"
}
}
32 changes: 32 additions & 0 deletions demo/src/assets/json-schema/oneOf_json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"schema": {
"type": "object",
"oneOf": [
{
"title": "Option 1",
"properties": {
"lorem": {
"title": "lorem",
"type": "string"
}
},
"required": [
"lorem"
]
},
{
"title": "Option 2",
"properties": {
"ipsum": {
"title": "ipsum",
"type": "string"
}
},
"required": [
"ipsum"
]
}
]
},
"model": {}
}
70 changes: 68 additions & 2 deletions src/core/json-schema/src/formly-json-schema.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@

import { FormlyJsonschema } from './formly-json-schema.service';
import { JSONSchema7 } from 'json-schema';
import { FormlyFieldConfig, FormlyTemplateOptions } from '@ngx-formly/core';
import { FormControl } from '@angular/forms';
import { FormlyFieldConfig, FormlyTemplateOptions, FormlyFormBuilder, FormlyModule } from '@ngx-formly/core';
import { FormControl, FormGroup } from '@angular/forms';
import { inject, TestBed, tick, fakeAsync } from '@angular/core/testing';
import { MockComponent } from 'src/core/src/lib/test-utils';

describe('Service: FormlyJsonschema', () => {
let formlyJsonschema: FormlyJsonschema;
Expand Down Expand Up @@ -797,6 +799,70 @@ describe('Service: FormlyJsonschema', () => {
});
});
});

describe('Multi-Schema (oneOf, anyOf) support', () => {
let schema: JSONSchema7;
let builder: FormlyFormBuilder;

beforeEach(() => {
const TestComponent = MockComponent({ selector: 'formly-test-cmp' });
TestBed.configureTestingModule({
declarations: [TestComponent],
imports: [
FormlyModule.forRoot({
types: [
{ name: 'object', component: TestComponent },
{ name: 'multischema', component: TestComponent },
{ name: 'enum', component: TestComponent },
{ name: 'string', component: TestComponent },
],
}),
],
});
});

beforeEach(inject([FormlyFormBuilder], (formlyBuilder: FormlyFormBuilder) => {
builder = formlyBuilder;
schema = {
type: 'object',
oneOf: [
{
properties: { foo: { type: 'string' } },
required: ['foo'],
},
{ properties: { bar: { type: 'string' } } },
],
anyOf: [
{ properties: { foo: { type: 'string' } } },
{ properties: { bar: { type: 'string' } } },
],
};
}));

it('should render multischema type when oneOf/anyOf is present', () => {
const { fieldGroup: [{ type: oneOfType }, { type: anyOfType }] } = formlyJsonschema.toFieldConfig(schema);
expect(oneOfType).toEqual('multischema');
expect(anyOfType).toEqual('multischema');
});

it('should render the valid oneOf field on first render', fakeAsync(() => {
const { fieldGroup: [f] } = formlyJsonschema.toFieldConfig(schema);

builder.buildForm(new FormGroup({}), [f], {}, {});
const [enumField, { fieldGroup: [fooField, barField] }] = f.fieldGroup;
enumField.hooks.onInit(enumField);
tick();

expect(fooField.hide).toBeTruthy();
expect(barField.hide).toBeFalsy();

enumField.formControl.setValue(0);

expect(fooField.hide).toBeFalsy();
expect(barField.hide).toBeTruthy();
}));
});

// TODO: discuss support of writeOnly. Note: this may not be needed.
// TODO: discuss support of examples. By spec, default can be used in its place.
// https://json-schema.org/latest/json-schema-validation.html#rfc.section.10
Expand Down
81 changes: 80 additions & 1 deletion src/core/json-schema/src/formly-json-schema.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { Injectable } from '@angular/core';
import { FormlyFieldConfig } from '@ngx-formly/core';
import { JSONSchema7, JSONSchema7TypeName } from 'json-schema';
import { ɵreverseDeepMerge as reverseDeepMerge } from '@ngx-formly/core';
import { AbstractControl } from '@angular/forms';
import { AbstractControl, FormControl } from '@angular/forms';
import { Subscription } from 'rxjs';

export interface FormlyJsonschemaOptions {
/**
Expand All @@ -17,6 +18,27 @@ function isEmpty(v: any) {
return v === '' || v === undefined || v === null;
}

function clearFieldModel(field: FormlyFieldConfig) {
if (field.key) {
field.formControl.patchValue(undefined);
delete field.model[field.key];
} else if (field.fieldGroup) {
field.fieldGroup.forEach(f => clearFieldModel(f));
}
}

function checkField(field: FormlyFieldConfig) {
(field.options as any)._checkField(field);
}

function isFieldValid(field: FormlyFieldConfig): boolean {
if (field.key) {
return field.formControl.valid;
}

return field.fieldGroup.every(f => isFieldValid(f));
}

interface IOptions extends FormlyJsonschemaOptions {
schema: JSONSchema7;
}
Expand Down Expand Up @@ -109,6 +131,20 @@ export class FormlyJsonschema {
});
}
});

if (schema.oneOf) {
field.fieldGroup.push(this.resolveMultiSchema(
<JSONSchema7[]> schema.oneOf,
options,
));
}

if (schema.anyOf) {
field.fieldGroup.push(this.resolveMultiSchema(
<JSONSchema7[]> schema.anyOf,
options,
));
}
break;
}
case 'array': {
Expand Down Expand Up @@ -230,6 +266,49 @@ export class FormlyJsonschema {
}, baseSchema);
}

private resolveMultiSchema(schemas: JSONSchema7[], options: IOptions): FormlyFieldConfig {
let subscription: Subscription = null;

return {
type: 'multischema',
fieldGroup: [
{
type: 'enum',
templateOptions: {
options: schemas
.map((s, i) => ({ label: s.title, value: i })),
},
hooks: {
onInit(f) {
const anyOfField = f.parent.fieldGroup[1];
const value = anyOfField.fieldGroup.findIndex(isFieldValid);
f.formControl = new FormControl(value !== -1 ? value : 0);
setTimeout(() => checkField(anyOfField));

subscription = f.formControl.valueChanges.subscribe(v => {
clearFieldModel(anyOfField);
checkField(anyOfField);
});
},
onDestroy() {
subscription && subscription.unsubscribe();
},
},
},
{
fieldGroup: schemas.map((s, i) => ({
...this._toFieldConfig(s, options),
hideExpression: (m, fs, f) => {
const control = f.parent.parent.fieldGroup[0].formControl;

return !control || control.value !== i;
},
})),
},
],
};
}

private resolveDefinition(schema: JSONSchema7, options: IOptions): JSONSchema7 {
const [uri, pointer] = schema.$ref.split('#/');
if (uri) {
Expand Down

0 comments on commit d60e0f5

Please sign in to comment.