angular2-json-schema-form
Version:
Angular 2 JSON Schema Form builder
530 lines (461 loc) • 21 kB
text/typescript
import { Injectable } from '@angular/core';
import { AbstractControl, FormArray, FormGroup } from '@angular/forms';
import { Subject } from 'rxjs/Subject';
import * as Ajv from 'ajv';
import * as _ from 'lodash';
import {
buildFormGroup, buildFormGroupTemplate, buildLayout, buildSchemaFromData,
buildSchemaFromLayout, convertJsonSchema3to4, fixJsonFormOptions,
formatFormData, getControl, getSchemaReference, hasOwn, hasValue, isArray,
isDefined, isObject, isString, JsonPointer, parseText
} from './utilities/index';
export type CheckboxItem = { name: string, value: any, checked?: boolean };
export class JsonSchemaFormService {
public JsonFormCompatibility: boolean = false;
public ReactJsonSchemaFormCompatibility: boolean = false;
public AngularSchemaFormCompatibility: boolean = false;
public tpldata: any = {};
private ajv: any = new Ajv({ allErrors: true }); // AJV: Another JSON Schema Validator
private validateFormData: any = null; // Compiled AJV function to validate active form's schema
public initialValues: any = {}; // The initial data model (e.g. previously submitted data)
public schema: any = {}; // The internal JSON Schema
public layout: any[] = []; // The internal Form layout
public formGroupTemplate: any = {}; // The template used to create formGroup
public formGroup: any = null; // The Angular 2 formGroup, which powers the reactive form
public framework: any = null; // The active framework component
public data: any = {}; // Form data, formatted with correct data types
public validData: any = null; // Valid form data (or null)
public isValid: boolean = null; // Is current form data valid?
public validationErrors: any = null; // Any validation errors for current data
private formValueSubscription: any = null; // Subscription to formGroup.valueChanges observable (for un- and re-subscribing)
public dataChanges: Subject<any> = new Subject(); // Form data observable
public isValidChanges: Subject<any> = new Subject(); // isValid observable
public validationErrorChanges: Subject<any> = new Subject(); // validationErrors observable
public arrayMap: Map<string, number> = new Map<string, number>(); // Maps arrays in data object and number of tuple values
public dataMap: Map<string, any> = new Map<string, any>(); // Maps paths in data model to schema and formGroup paths
public dataRecursiveRefMap: Map<string, string> = new Map<string, string>(); // Maps recursive reference points in data model
public schemaRecursiveRefMap: Map<string, string> = new Map<string, string>(); // Maps recursive reference points in schema
public layoutRefLibrary: any = {}; // Library of layout nodes for adding to form
public schemaRefLibrary: any = {}; // Library of schemas for resolving schema $refs
public templateRefLibrary: any = {}; // Library of formGroup templates for adding to form
// Default global form options
public globalOptionDefaults: any = {
addSubmit: 'auto', // Add a submit button if layout does not have one?
// for addSubmit: true = always, false = never, 'auto' = only if layout is undefined
debug: false, // Show debugging output?
fieldsRequired: false, // Are there any required fields in the form?
framework: 'bootstrap-3', // The framework to load
widgets: {}, // Any custom widgets to load
loadExternalAssets: false, // Load external css and JavaScript for framework?
pristine: { errors: true, success: true },
supressPropertyTitles: false,
setSchemaDefaults: true,
validateOnRender: false,
formDefaults: { // Default options for form controls
addable: true, // Allow adding items to an array or $ref point?
orderable: true, // Allow reordering items within an array?
removable: true, // Allow removing items from an array or $ref point?
allowExponents: false, // Allow exponent entry in number fields?
enableErrorState: true, // Apply 'has-error' class when field fails validation?
// disableErrorState: false, // Don't apply 'has-error' class when field fails validation?
enableSuccessState: true, // Apply 'has-success' class when field validates?
// disableSuccessState: false, // Don't apply 'has-success' class when field validates?
feedback: false, // Show inline feedback icons?
notitle: false, // Hide title?
readonly: false, // Set control as read only?
},
};
public globalOptions: any;
constructor() {
this.globalOptions = _.cloneDeep(this.globalOptionDefaults);
}
public getData() { return this.data; }
public getSchema() { return this.schema; }
public getLayout() { return this.layout; }
public resetAllValues() {
this.JsonFormCompatibility = false;
this.ReactJsonSchemaFormCompatibility = false;
this.AngularSchemaFormCompatibility = false;
this.tpldata = {};
this.validateFormData = null;
this.initialValues = {};
this.schema = {};
this.layout = [];
this.formGroupTemplate = {};
this.formGroup = null;
this.framework = null;
this.data = {};
this.validData = null;
this.isValid = null;
this.validationErrors = null;
this.arrayMap = new Map<string, number>();
this.dataMap = new Map<string, any>();
this.dataRecursiveRefMap = new Map<string, string>();
this.schemaRecursiveRefMap = new Map<string, string>();
this.layoutRefLibrary = {};
this.schemaRefLibrary = {};
this.templateRefLibrary = {};
this.globalOptions = _.cloneDeep(this.globalOptionDefaults);
}
public convertJsonSchema3to4() {
this.schema = convertJsonSchema3to4(this.schema);
}
public fixJsonFormOptions(layout: any): any {
return fixJsonFormOptions(layout);
}
public buildFormGroupTemplate(setValues: boolean = true) {
this.formGroupTemplate =
buildFormGroupTemplate(this, this.initialValues, setValues);
}
private validateData(newValue: any, updateSubscriptions: boolean = true): void {
// Format raw form data to correct data types
this.data = formatFormData(
newValue, this.dataMap, this.dataRecursiveRefMap, this.arrayMap
);
this.isValid = this.validateFormData(this.data);
this.validData = this.isValid ? this.data : null;
this.validationErrors = this.validateFormData.errors;
if (updateSubscriptions) {
if (this.dataChanges.observers.length) {
this.dataChanges.next(this.data);
}
if (this.isValidChanges.observers.length) {
this.isValidChanges.next(this.isValid);
}
if (this.validationErrorChanges.observers.length) {
this.validationErrorChanges.next(this.validationErrors);
}
}
}
public buildFormGroup() {
this.formGroup = <FormGroup>buildFormGroup(this.formGroupTemplate);
if (this.formGroup) {
this.compileAjvSchema();
this.validateData(this.formGroup.value, false);
// Set up observables to emit data and validation info when form data changes
if (this.formValueSubscription) { this.formValueSubscription.unsubscribe(); }
this.formValueSubscription = this.formGroup.valueChanges.subscribe(
formValue => this.validateData(formValue)
);
}
}
public buildLayout(widgetLibrary: any) {
this.layout = buildLayout(this, widgetLibrary);
}
public setOptions(newOptions: any): void {
if (typeof newOptions === 'object') {
Object.assign(this.globalOptions, newOptions);
}
if (hasOwn(this.globalOptions.formDefaults, 'disableErrorState')) {
this.globalOptions.formDefaults.enableErrorState =
!this.globalOptions.formDefaults.disableErrorState;
delete this.globalOptions.formDefaults.disableErrorState;
}
if (hasOwn(this.globalOptions.formDefaults, 'disableSuccessState')) {
this.globalOptions.formDefaults.enableSuccessState =
!this.globalOptions.formDefaults.disableSuccessState;
delete this.globalOptions.formDefaults.disableSuccessState;
}
}
public compileAjvSchema() {
if (!this.validateFormData) {
this.validateFormData = this.ajv.compile(this.schema);
}
}
// Resolve all schema $ref links
public resolveSchemaRefLinks() {
// Search schema for $ref links
JsonPointer.forEachDeep(this.schema, (value, pointer) => {
if (hasOwn(value, '$ref') && isString(value['$ref'])) {
const newReference: string = JsonPointer.compile(value['$ref']);
const isRecursive: boolean = JsonPointer.isSubPointer(newReference, pointer);
// Save new target schemas in schemaRefLibrary
if (hasValue(newReference) && !hasOwn(this.schemaRefLibrary, newReference)) {
this.schemaRefLibrary[newReference] = getSchemaReference(
this.schema, newReference, this.schemaRefLibrary
);
}
// Save link in schemaRecursiveRefMap
if (!this.schemaRecursiveRefMap.has(pointer)) {
this.schemaRecursiveRefMap.set(pointer, newReference);
}
// If a $ref link is not recursive,
// remove link and replace with copy of target schema
if (!isRecursive) {
delete value['$ref'];
const targetSchema: any = Object.assign(
_.cloneDeep(this.schemaRefLibrary[newReference]), value
);
this.schema = JsonPointer.set(this.schema, pointer, targetSchema);
// Save partial link in schemaRecursiveRefMap,
// so it can be matched later if it is recursive
this.schemaRecursiveRefMap.set(newReference, pointer);
} else {
// If a matching partial link exists, complete it
const mappedReference: string = this.schemaRecursiveRefMap.get(newReference);
if (this.schemaRecursiveRefMap.has(newReference) &&
JsonPointer.isSubPointer(mappedReference, newReference)
) {
this.schemaRecursiveRefMap.set(newReference, mappedReference);
}
}
}
}, true);
// Add redirects for links to shared schemas (such as definitions)
let addRedirects: Map<string, string> = new Map<string, string>();
this.schemaRecursiveRefMap.forEach((toRef1, fromRef1) =>
this.schemaRecursiveRefMap.forEach((toRef2, fromRef2) => {
if (fromRef1 !== fromRef2 && fromRef1 !== toRef2 &&
JsonPointer.isSubPointer(toRef2, fromRef1)
) {
const newRef: string = fromRef2 + fromRef1.slice(toRef2.length);
if (!this.schemaRecursiveRefMap.has(newRef)) {
addRedirects.set(newRef, toRef1);
}
}
})
);
addRedirects.forEach((toRef, fromRef) => this.schemaRecursiveRefMap.set(fromRef, toRef));
// Fix recursive references pointing to shared schemas
this.schemaRecursiveRefMap.forEach((toRef1, fromRef1) =>
this.schemaRecursiveRefMap.forEach((toRef2, fromRef2) => {
if (fromRef1 !== fromRef2 && toRef1 === toRef2 &&
JsonPointer.isSubPointer(fromRef1, fromRef2)
) {
this.schemaRecursiveRefMap.set(fromRef2, fromRef1);
}
})
);
// Remove unmatched (non-recursive) partial links
this.schemaRecursiveRefMap.forEach((toRef, fromRef) => {
if (!JsonPointer.isSubPointer(toRef, fromRef) &&
!hasOwn(this.schemaRefLibrary, toRef)
) {
this.schemaRecursiveRefMap.delete(fromRef);
}
});
// // TODO: Create dataRecursiveRefMap from schemaRecursiveRefMap
// this.schemaRecursiveRefMap.forEach((toRef, fromRef) => {
// this.dataRecursiveRefMap.set(
// JsonPointer.toDataPointer(fromRef, this.schema),
// JsonPointer.toDataPointer(toRef, this.schema)
// );
// });
}
public buildSchemaFromData(data?: any, requireAllFields: boolean = false): any {
if (data) { return buildSchemaFromData(data, requireAllFields); }
this.schema = buildSchemaFromData(this.initialValues, requireAllFields);
}
public buildSchemaFromLayout(layout?: any): any {
if (layout) { return buildSchemaFromLayout(layout); }
this.schema = buildSchemaFromLayout(this.layout);
}
public setTpldata(newTpldata: any = {}): void {
this.tpldata = newTpldata;
}
public parseText(
text: string = '', value: any = {}, values: any = {}, key: number|string = null
): string {
return parseText(text, value, values, key, this.tpldata);
}
public setTitle(
parentCtx: any = {}, childNode: any = null, index: number = null
): string {
const parentNode: any = parentCtx.layoutNode;
let text: string;
let childValue: any;
let parentValues: any = this.getControlValue(parentCtx);
const isArrayItem: boolean =
parentNode.type.slice(-5) === 'array' && isArray(parentValues);
if (isArrayItem && childNode.type !== '$ref') {
text = JsonPointer.getFirst([
[childNode, '/options/legend'],
[childNode, '/options/title'],
[childNode, '/title'],
[parentNode, '/options/title'],
[parentNode, '/options/legend'],
[parentNode, '/title'],
]);
} else {
text = JsonPointer.getFirst([
[childNode, '/title'],
[childNode, '/options/title'],
[childNode, '/options/legend'],
[parentNode, '/title'],
[parentNode, '/options/title'],
[parentNode, '/options/legend']
]);
if (childNode.type === '$ref') { text = '+ ' + text; }
}
if (!text) { return text; }
childValue = isArrayItem ? parentValues[index] : parentValues;
return this.parseText(text, childValue, parentValues, index);
}
public initializeControl(ctx: any): boolean {
ctx.formControl = this.getControl(ctx);
ctx.boundControl = !!ctx.formControl;
if (ctx.boundControl) {
ctx.controlName = this.getControlName(ctx);
ctx.controlValue = ctx.formControl.value;
ctx.formControl.valueChanges.subscribe(v => ctx.controlValue = v);
ctx.controlDisabled = ctx.formControl.disabled;
// TODO: subscribe to status changes
// TODO: emit / display error messages
// ctx.formControl.statusChanges.subscribe(v => ...);
} else {
ctx.controlName = ctx.layoutNode.name;
ctx.controlValue = ctx.layoutNode.value;
const dataPointer = this.getDataPointer(ctx);
if (dataPointer) {
console.error('warning: control "' + dataPointer +
'" is not bound to the Angular 2 FormGroup.');
}
}
return ctx.boundControl;
}
public updateValue(ctx: any, value): void {
// Set value of current control
ctx.controlValue = value;
if (ctx.boundControl) {
ctx.formControl.setValue(value);
ctx.formControl.markAsDirty();
}
ctx.layoutNode.value = value;
// Set values of any related controls in copyValueTo array
if (isArray(ctx.options.copyValueTo)) {
for (let item of ctx.options.copyValueTo) {
let targetControl = getControl(this.formGroup, item);
if (isObject(targetControl) && typeof targetControl.setValue === 'function') {
targetControl.setValue(value);
targetControl.markAsDirty();
}
}
}
}
public updateArrayCheckboxList(ctx: any, checkboxList: CheckboxItem[]): void {
let formArray = <FormArray>this.getControl(ctx);
// Remove all existing items
while (formArray.value.length) { formArray.removeAt(0); }
// Re-add an item for each checked box
for (let checkboxItem of checkboxList) {
if (checkboxItem.checked) {
let newFormControl = buildFormGroup(JsonPointer.get(
this.templateRefLibrary, [ctx.layoutNode.dataPointer + '/-']
));
newFormControl.setValue(checkboxItem.value);
formArray.push(newFormControl);
}
}
formArray.markAsDirty();
}
public getControl(ctx: any): AbstractControl {
if (!ctx.layoutNode || !ctx.layoutNode.dataPointer ||
ctx.layoutNode.type === '$ref') { return null; }
return getControl(this.formGroup, this.getDataPointer(ctx));
}
public getControlValue(ctx: any): AbstractControl {
if (!ctx.layoutNode || !ctx.layoutNode.dataPointer ||
ctx.layoutNode.type === '$ref') { return null; }
const control = getControl(this.formGroup, this.getDataPointer(ctx));
return control ? control.value : null;
}
public getControlGroup(ctx: any): FormArray | FormGroup {
if (!ctx.layoutNode || !ctx.layoutNode.dataPointer) { return null; }
return getControl(this.formGroup, this.getDataPointer(ctx), true);
}
public getControlName(ctx: any): string {
if (!ctx.layoutNode || !ctx.layoutNode.dataPointer || !ctx.dataIndex) { return null; }
return JsonPointer.toKey(this.getDataPointer(ctx));
}
public getLayoutArray(ctx: any): any[] {
return JsonPointer.get(this.layout, this.getLayoutPointer(ctx), 0, -1);
}
public getParentNode(ctx: any): any[] {
return JsonPointer.get(this.layout, this.getLayoutPointer(ctx), 0, -2);
}
public getDataPointer(ctx: any): string {
if (!ctx.layoutNode || !ctx.layoutNode.dataPointer || !ctx.dataIndex) { return null; }
return JsonPointer.toIndexedPointer(ctx.layoutNode.dataPointer, ctx.dataIndex, this.arrayMap);
}
public getLayoutPointer(ctx: any): string {
if (!ctx.layoutNode || !ctx.layoutNode.layoutPointer || !ctx.layoutIndex) { return null; }
return JsonPointer.toIndexedPointer(ctx.layoutNode.layoutPointer, ctx.layoutIndex);
}
public isControlBound(ctx: any): boolean {
if (!ctx.layoutNode || !ctx.layoutNode.dataPointer || !ctx.dataIndex) { return false; }
const controlGroup = this.getControlGroup(ctx);
const name = this.getControlName(ctx);
return controlGroup ? controlGroup.controls.hasOwnProperty(name) : false;
}
public addItem(ctx: any): boolean {
if (!ctx.layoutNode || !ctx.layoutNode.$ref || !ctx.dataIndex ||
!ctx.layoutNode.layoutPointer || !ctx.layoutIndex) { return false; }
// Create a new Angular 2 form control from a template in templateRefLibrary
const newFormGroup = buildFormGroup(JsonPointer.get(
this.templateRefLibrary, [ctx.layoutNode.$ref]
));
// Add the new form control to the parent formArray or formGroup
if (ctx.layoutNode.arrayItem) { // Add new array item to formArray
(<FormArray>this.getControlGroup(ctx))
.push(newFormGroup);
} else { // Add new $ref item to formGroup
(<FormGroup>this.getControlGroup(ctx))
.addControl(this.getControlName(ctx), newFormGroup);
}
// Copy a new layoutNode from layoutRefLibrary
const newLayoutNode = _.cloneDeep(JsonPointer.get(
this.layoutRefLibrary, [ctx.layoutNode.$ref]
));
JsonPointer.forEachDeep(newLayoutNode, (value, pointer) => {
// Reset all _id's in newLayoutNode to unique values
if (hasOwn(value, '_id')) { value._id = _.uniqueId(); }
// If adding a recursive item, prefix current dataPointer
// and layoutPointer to all pointers in new layoutNode
if (!ctx.layoutNode.arrayItem || ctx.layoutNode.recursiveReference) {
if (hasOwn(value, 'dataPointer')) {
value.dataPointer = ctx.layoutNode.dataPointer + value.dataPointer;
}
if (hasOwn(value, 'layoutPointer')) {
value.layoutPointer =
ctx.layoutNode.layoutPointer.slice(0, -2) + value.layoutPointer;
}
}
});
// Add the new layoutNode to the layout
JsonPointer.insert(this.layout, this.getLayoutPointer(ctx), newLayoutNode);
return true;
}
public moveArrayItem(ctx: any, oldIndex: number, newIndex: number): boolean {
if (!ctx.layoutNode || !ctx.layoutNode.dataPointer || !ctx.dataIndex ||
!ctx.layoutNode.layoutPointer || !ctx.layoutIndex ||
!isDefined(oldIndex) || !isDefined(newIndex)) { return false; }
// Move item in the formArray
let formArray = <FormArray>this.getControlGroup(ctx);
formArray.controls.splice(newIndex, 0, // add to new index
formArray.controls.splice(oldIndex, 1)[0] // remove from old index
);
formArray.updateValueAndValidity();
(<any>formArray)._onCollectionChange();
// Move layout item
let layoutArray = this.getLayoutArray(ctx);
layoutArray.splice(newIndex, 0, layoutArray.splice(oldIndex, 1)[0]);
return true;
}
public removeItem(ctx: any): boolean {
if (!ctx.layoutNode || !ctx.layoutNode.dataPointer || !ctx.dataIndex ||
!ctx.layoutNode.layoutPointer || !ctx.layoutIndex) { return false; }
// Remove the Angular 2 form control from the parent formArray or formGroup
if (ctx.layoutNode.arrayItem) { // Remove array item from formArray
(<FormArray>this.getControlGroup(ctx))
.removeAt(ctx.dataIndex[ctx.dataIndex.length - 1]);
} else { // Remove $ref item from formGroup
(<FormGroup>this.getControlGroup(ctx))
.removeControl(this.getControlName(ctx));
}
// Remove layoutNode from layout
let layoutPointer = this.getLayoutPointer(ctx);
JsonPointer.remove(this.layout, layoutPointer);
return true;
}
}