angular2-json-schema-form
Version:
Angular 2 JSON Schema Form builder
467 lines (454 loc) • 17 kB
text/typescript
import {
AbstractControl, FormArray, FormControl, FormGroup, ValidatorFn
} from '@angular/forms';
import * as _ from 'lodash';
import {
forEach, getControlValidators, hasOwn, hasValue, inArray, isArray, isEmpty,
isObject, isDefined, isPrimitive, JsonPointer, JsonValidators, Pointer,
toJavaScriptType, toSchemaType, resolveRecursiveReferences, SchemaPrimitiveType
} from './index';
/**
* FormGroup function library:
*
* buildFormGroupTemplate: Builds a FormGroupTemplate from schema
*
* buildFormGroup: Builds an Angular 2 FormGroup from a FormGroupTemplate
*
* setRequiredFields:
*
* formatFormData:
*
* getControl:
*
* fixJsonFormOptions:
*
* ---- Under construction: ----
* buildFormGroupTemplateFromLayout: Builds a FormGroupTemplate from a form layout
*/
/**
* 'buildFormGroupTemplate' function
*
* Builds a template for an Angular 2 FormGroup from a JSON Schema.
*
* TODO: add support for pattern properties
* https://spacetelescope.github.io/understanding-json-schema/reference/object.html
*
* @param {any} jsf -
* @param {any = null} setValues -
* @param {boolean = true} mapArrays -
* @param {string = ''} schemaPointer -
* @param {string = ''} dataPointer -
* @param {any = ''} templatePointer -
* @return {any} -
*/
export function buildFormGroupTemplate(
jsf: any, setValues: any = null, mapArrays: boolean = true,
schemaPointer: string = '', dataPointer: string = '', templatePointer: any = ''
): any {
const schema: any = JsonPointer.get(jsf.schema, schemaPointer);
let useValues: any = jsf.globalOptions.setSchemaDefaults ?
mergeValues(JsonPointer.get(schema, '/default'), setValues) : setValues;
const schemaType: string | string[] = JsonPointer.get(schema, '/type');
let controlType: 'FormGroup' | 'FormArray' | 'FormControl' | '$ref';
if (schemaType === 'object' && hasOwn(schema, 'properties')) {
controlType = 'FormGroup';
} else if (schemaType === 'array' && hasOwn(schema, 'items')) {
controlType = 'FormArray';
} else if (!schemaType && hasOwn(schema, '$ref')) {
controlType = '$ref';
} else {
controlType = 'FormControl';
}
if (dataPointer !== '' && !jsf.dataMap.has(dataPointer)) {
jsf.dataMap.set(dataPointer, new Map);
jsf.dataMap.get(dataPointer).set('schemaPointer', schemaPointer);
jsf.dataMap.get(dataPointer).set('schemaType', schema.type);
if (controlType) {
jsf.dataMap.get(dataPointer).set('templatePointer', templatePointer);
jsf.dataMap.get(dataPointer).set('templateType', controlType);
}
const genericDataPointer =
JsonPointer.toGenericPointer(dataPointer, jsf.arrayMap);
if (!jsf.dataMap.has(genericDataPointer)) {
jsf.dataMap.set(genericDataPointer, new Map);
jsf.dataMap.get(genericDataPointer).set('schemaPointer', schemaPointer);
jsf.dataMap.get(genericDataPointer).set('schemaType', schema.type);
}
}
let controls: any;
let validators: any = getControlValidators(schema);
switch (controlType) {
case 'FormGroup':
controls = {};
if (jsf.globalOptions.setSchemaDefaults) {
useValues = mergeValues(
JsonPointer.get(schema, '/properties/default'), useValues);
}
forEach(schema.properties, (item, key) => {
if (key !== 'ui:order') {
controls[key] = buildFormGroupTemplate(
jsf, JsonPointer.get(useValues, [<string>key]), mapArrays,
schemaPointer + '/properties/' + key,
dataPointer + '/' + key,
templatePointer + '/controls/' + key
);
}
});
jsf.globalOptions.fieldsRequired =
setRequiredFields(schema, controls);
return { controlType, controls, validators };
case 'FormArray':
const minItems = schema.minItems || 0;
const maxItems = schema.maxItems || 1000000;
if (isArray(schema.items)) { // 'items' is an array = tuple items
if (mapArrays && !jsf.arrayMap.get(dataPointer)) {
jsf.arrayMap.set(dataPointer, schema.items.length);
}
controls = [];
for (let i = 0, l = schema.items.length; i < l; i++) {
if (i >= minItems &&
!JsonPointer.has(jsf.templateRefLibrary, [dataPointer + '/' + i])
) {
jsf.templateRefLibrary[dataPointer + '/' + i] =
buildFormGroupTemplate(
jsf, null, mapArrays,
schemaPointer + '/items/' + i,
dataPointer + '/' + i,
templatePointer + '/controls/' + i
);
}
if (i < maxItems) {
const useValue = isArray(useValues) ? useValues[i] : useValues;
controls.push(buildFormGroupTemplate(
jsf, useValue, false,
schemaPointer + '/items/' + i,
dataPointer + '/' + i,
templatePointer + '/controls/' + i
));
}
}
if (schema.items.length < maxItems &&
hasOwn(schema, 'additionalItems') && isObject(schema.additionalItems)
) { // 'additionalItems' is an object = additional list items
const l = Math.max(
schema.items.length + 1,
isArray(useValues) ? useValues.length : 0
);
for (let i = schema.items.length; i < l; i++) {
const useValue = isArray(useValues) ? useValues[i] : useValues;
controls.push(buildFormGroupTemplate(
jsf, useValue, false,
schemaPointer + '/additionalItems',
dataPointer + '/' + i,
templatePointer + '/controls/' + i
));
if (isArray(useValues)) { useValues = null; }
}
if (
!JsonPointer.has(jsf, ['templateRefLibrary', dataPointer + '/-'])
) {
jsf.templateRefLibrary[dataPointer + '/-'] =
buildFormGroupTemplate(
jsf, null, mapArrays,
schemaPointer + '/additionalItems',
dataPointer + '/-',
templatePointer + '/controls/-'
);
}
}
} else { // 'items' is an object = list items only (no tuple items)
if (mapArrays && !jsf.arrayMap.get(dataPointer)) {
jsf.arrayMap.set(dataPointer, 0);
}
if (
!JsonPointer.has(jsf.templateRefLibrary, [dataPointer + '/-'])
) {
jsf.templateRefLibrary[dataPointer + '/-'] =
buildFormGroupTemplate(
jsf, null, mapArrays,
schemaPointer + '/items',
dataPointer + '/-',
templatePointer + '/controls/-'
);
}
controls = [];
if (jsf.globalOptions.setSchemaDefaults) {
useValues = mergeValues(
JsonPointer.get(schema, '/items/default'), useValues);
}
if (isArray(useValues) && useValues.length) {
for (let i of Object.keys(useValues)) {
controls.push(buildFormGroupTemplate(
jsf, useValues[i], false,
schemaPointer + '/items',
dataPointer + '/' + i,
templatePointer + '/controls/' + i
));
}
useValues = null;
}
}
let initialItemCount =
Math.max(minItems, JsonPointer.has(schema, '/items/$ref') ? 0 : 1);
if (controls.length < initialItemCount) {
for (let i = controls.length, l = initialItemCount; i < l; i++) {
controls.push(buildFormGroupTemplate(
jsf, useValues, false,
schemaPointer + '/items',
dataPointer + '/' + i,
templatePointer + '/controls/' + i
));
}
}
return { controlType, controls, validators };
case 'FormControl':
let value: { value: any, disabled: boolean } = {
value: isPrimitive(useValues) ? useValues : null,
disabled: schema['disabled'] ||
JsonPointer.get(schema, '/x-schema-form/disabled') || false
};
return { controlType, value, validators };
case '$ref':
const schemaRef: string = JsonPointer.compile(schema.$ref);
if (!hasOwn(jsf.templateRefLibrary, schemaRef)) {
// Set to null first to prevent recursive reference from causing endless loop
jsf.templateRefLibrary[schemaRef] = null;
const newTemplate: any = buildFormGroupTemplate(jsf, null, false, schemaRef);
if (newTemplate) {
jsf.templateRefLibrary[schemaRef] = newTemplate;
} else {
delete jsf.templateRefLibrary[schemaRef];
}
}
return null;
default:
return null;
}
}
/**
* 'buildFormGroup' function
*
* @param {any} template -
* @return {AbstractControl}
*/
export function buildFormGroup(template: any): AbstractControl {
let validatorFns: ValidatorFn[] = [];
let validatorFn: ValidatorFn = null;
if (hasOwn(template, 'validators')) {
forEach(template.validators, (parameters, validator) => {
if (typeof JsonValidators[validator] === 'function') {
validatorFns.push(JsonValidators[validator].apply(null, parameters));
}
});
if (validatorFns.length &&
inArray(['FormGroup', 'FormArray'], template.controlType)
) {
validatorFn = validatorFns.length > 1 ?
JsonValidators.compose(validatorFns) : validatorFns[0];
}
}
if (hasOwn(template, 'controlType')) {
switch (template.controlType) {
case 'FormGroup':
let groupControls: { [key: string]: AbstractControl } = {};
forEach(template.controls, (controls, key) => {
let newControl: AbstractControl = buildFormGroup(controls);
if (newControl) { groupControls[key] = newControl; }
});
return new FormGroup(groupControls, validatorFn);
case 'FormArray':
return new FormArray(_.filter(_.map(template.controls,
controls => buildFormGroup(controls)
)), validatorFn);
case 'FormControl':
return new FormControl(template.value, validatorFns);
}
}
return null;
}
/**
* 'mergeValues' function
*
* @param {any[]} ...valuesToMerge - Multiple values to merge
* @return {any} - Merged values
*/
export function mergeValues(...valuesToMerge) {
let mergedValues: any = null;
for (let index = 0, length = arguments.length; index < length; index++) {
const currentValue = arguments[index];
if (!isEmpty(currentValue)) {
if (typeof currentValue === 'object' &&
(isEmpty(mergedValues) || typeof mergedValues !== 'object')
) {
if (isArray(currentValue)) {
mergedValues = [].concat(currentValue);
} else if (isObject(currentValue)) {
mergedValues = Object.assign({}, currentValue);
}
} else if (typeof currentValue !== 'object') {
mergedValues = currentValue;
} else if (isObject(mergedValues) && isObject(currentValue)) {
Object.assign(mergedValues, currentValue);
} else if (isObject(mergedValues) && isArray(currentValue)) {
let newValues = [];
for (let value of currentValue) {
newValues.push(mergeValues(mergedValues, value));
}
mergedValues = newValues;
} else if (isArray(mergedValues) && isObject(currentValue)) {
let newValues = [];
for (let value of mergedValues) {
newValues.push(mergeValues(value, currentValue));
}
mergedValues = newValues;
} else if (isArray(mergedValues) && isArray(currentValue)) {
let newValues = [];
const l = Math.max(mergedValues.length, currentValue.length);
for (let i = 0; i < l; i++) {
if (i < mergedValues.length && i < currentValue.length) {
newValues.push(mergeValues(mergedValues[i], currentValue[i]));
} else if (i < mergedValues.length) {
newValues.push(mergedValues[i]);
} else if (i < currentValue.length) {
newValues.push(currentValue[i]);
}
}
mergedValues = newValues;
}
}
}
return mergedValues;
}
/**
* 'setRequiredFields' function
*
* @param {schema} schema - JSON Schema
* @param {object} formControlTemplate - Form Control Template object
* @return {boolean} - true if any fields have been set to required, false if not
*/
export function setRequiredFields(schema: any, formControlTemplate: any): boolean {
let fieldsRequired = false;
if (hasOwn(schema, 'required') && !isEmpty(schema.required)) {
fieldsRequired = true;
let requiredArray = isArray(schema.required) ? schema.required : [schema.required];
requiredArray = forEach(requiredArray,
key => JsonPointer.set(formControlTemplate, '/' + key + '/validators/required', [])
);
}
return fieldsRequired;
// TODO: Add support for patternProperties
// https://spacetelescope.github.io/understanding-json-schema/reference/object.html
// #pattern-properties
}
/**
* 'formatFormData' function
*
* @param {any} formData - Angular 2 FormGroup data object
* @param {Map<string, any>} dataMap -
* @param {Map<string, string>} recursiveRefMap -
* @param {Map<string, number>} arrayMap -
* @param {boolean = false} fixErrors - if TRUE, tries to fix data
* @return {any} - formatted data object
*/
export function formatFormData(
formData: any, dataMap: Map<string, any>, recursiveRefMap: Map<string, string>,
arrayMap: Map<string, number>, fixErrors: boolean = false
): any {
// return formData;
let formattedData = {};
JsonPointer.forEachDeep(formData, (value, dataPointer) => {
if (typeof value !== 'object') {
let genericPointer: string =
JsonPointer.has(dataMap, [dataPointer, 'schemaType']) ?
dataPointer :
resolveRecursiveReferences(dataPointer, recursiveRefMap, arrayMap);
if (JsonPointer.has(dataMap, [genericPointer, 'schemaType'])) {
const schemaType: SchemaPrimitiveType | SchemaPrimitiveType[] =
dataMap.get(genericPointer).get('schemaType');
if (schemaType === 'null') {
JsonPointer.set(formattedData, dataPointer, null);
} else if ( hasValue(value) &&
inArray(schemaType, ['string', 'integer', 'number', 'boolean'])
) {
const newValue = fixErrors ?
toSchemaType(value, schemaType) :
toJavaScriptType(value, schemaType);
if (isDefined(newValue)) {
JsonPointer.set(formattedData, dataPointer, newValue);
}
}
} else {
console.error('formatFormData error: Schema type not found ' +
'for form value at "' + genericPointer + '".');
console.error(formData);
console.error(dataMap);
console.error(recursiveRefMap);
console.error(arrayMap);
}
}
});
return formattedData;
}
/**
* 'getControl' function
*
* Uses a JSON Pointer for a data object to retrieve a control from
* an Angular 2 formGroup or formGroup template. (Note: though a formGroup
* template is much simpler, its basic structure is idential to a formGroup).
*
* If the optional third parameter 'returnGroup' is set to TRUE, the group
* containing the control is returned, rather than the control itself.
*
* @param {FormGroup} formGroup - Angular 2 FormGroup to get value from
* @param {Pointer} dataPointer - JSON Pointer (string or array)
* @param {boolean = false} returnGroup - If true, return group containing control
* @return {group} - Located value (or true or false, if returnError = true)
*/
export function getControl(
formGroup: any, dataPointer: Pointer, returnGroup: boolean = false
): any {
const dataPointerArray: string[] = JsonPointer.parse(dataPointer);
let subGroup = formGroup;
if (dataPointerArray !== null) {
let l = dataPointerArray.length - (returnGroup ? 1 : 0);
for (let i = 0; i < l; ++i) {
let key = dataPointerArray[i];
if (subGroup.hasOwnProperty('controls')) {
subGroup = subGroup.controls;
}
if (isArray(subGroup) && (key === '-')) {
subGroup = subGroup[subGroup.length - 1];
} else if (subGroup.hasOwnProperty(key)) {
subGroup = subGroup[key];
} else {
console.error('getControl error: Unable to find "' + key +
'" item in FormGroup.');
console.error(dataPointer);
console.error(formGroup);
return;
}
}
return subGroup;
}
console.error('getControl error: Invalid JSON Pointer: ' + dataPointer);
}
/**
* 'fixJsonFormOptions' function
*
* Rename JSON Form-style 'options' lists to
* Angular Schema Form-style 'titleMap' lists.
*
* @param {any} formObject
* @return {any}
*/
export function fixJsonFormOptions(layout: any): any {
if (isObject(layout) || isArray(layout)) {
forEach(layout, (value, key) => {
if (isObject(value) && hasOwn(value, 'options') && isObject(value.options)) {
value.titleMap = value.options;
delete value.options;
}
}, 'top-down');
}
return layout;
}