UNPKG

angular2-json-schema-form

Version:
569 lines (547 loc) 20.4 kB
import * as _ from 'lodash'; import { getType, hasValue, inArray, isString, isFunction, isObject, isArray } from './validator.functions'; import { forEach, hasOwn, mergeFilteredObject } from './utility.functions'; import { JsonPointer, Pointer } from './jsonpointer.functions'; import { JsonValidators } from './json.validators'; /** * JSON Schema function library: * * buildSchemaFromLayout: TODO: Write this function * * buildSchemaFromData: * * getFromSchema: * * getSchemaReference: * * getInputType: * * isInputRequired: * * updateInputOptions: * * getControlValidators: */ /** * 'buildSchemaFromLayout' function * * Build a JSON Schema from a JSON Form layout * * @param {any[]} layout - The JSON Form layout * @return {JSON Schema} - The new JSON Schema */ export function buildSchemaFromLayout(layout: any[]): any { return; // let newSchema: any = { }; // const walkLayout = (layoutItems: any[], callback: Function): any[] => { // let returnArray: any[] = []; // for (let layoutItem of layoutItems) { // const returnItem: any = callback(layoutItem); // if (returnItem) { returnArray = returnArray.concat(callback(layoutItem)); } // if (layoutItem.items) { // returnArray = returnArray.concat(walkLayout(layoutItem.items, callback)); // } // } // return returnArray; // }; // walkLayout(layout, layoutItem => { // let itemKey: string; // if (typeof layoutItem === 'string') { // itemKey = layoutItem; // } else if (layoutItem.key) { // itemKey = layoutItem.key; // } // if (!itemKey) { return; } // // // }); } /** * 'buildSchemaFromData' function * * Build a JSON Schema from a data object * * @param {any} data - The data object * @return {JSON Schema} - The new JSON Schema */ export function buildSchemaFromData( data: any, requireAllFields: boolean = false, isRoot: boolean = true ): any { let newSchema: any = { }; if (isRoot) { newSchema.$schema = 'http://json-schema.org/draft-04/schema#'; } const getFieldType = (value: any): string => { let fieldType = getType(value, 'strict'); if (fieldType === 'integer') { return 'number'; } if (fieldType === 'null') { return 'string'; } return fieldType; }; newSchema.type = getFieldType(data); if (newSchema.type === 'object') { newSchema.properties = {}; if (requireAllFields) { newSchema.required = []; } for (let key of Object.keys(data)) { newSchema.properties[key] = buildSchemaFromData(data[key], requireAllFields, false); if (requireAllFields) { newSchema.required.push(key); } } } else if (newSchema.type === 'array') { let itemTypes: string[] = data.map(getFieldType).reduce( (types, type) => types.concat(types.indexOf(type) === -1 ? type : []) , []); const buildSubSchemaFromData = (value) => buildSchemaFromData(value, requireAllFields, false); if (itemTypes.length === 1) { newSchema.items = data.map(buildSubSchemaFromData).reduce( (combined, item) => Object.assign(combined, item) , { }); } else { newSchema.items = data.map(buildSubSchemaFromData); } if (requireAllFields) { newSchema.minItems = 1; } } return newSchema; } /** * 'getFromSchema' function * * Uses a JSON Pointer for a data object to retrieve a sub-schema from * a JSON Schema which describes that data object * * @param {JSON Schema} schema - The schema to get the sub-schema from * @param {Pointer} dataPointer - JSON Pointer (string or array) * @param {boolean = false} returnContainer - Return containing object instead? * @return {schema} - The located sub-schema */ export function getFromSchema( schema: any, dataPointer: Pointer, returnContainer: boolean = false ): any { const dataPointerArray: any[] = JsonPointer.parse(dataPointer); let subSchema = schema; if (dataPointerArray === null) { console.error('getFromSchema error: Invalid JSON Pointer: ' + dataPointer); return null; } const l = returnContainer ? dataPointerArray.length - 1 : dataPointerArray.length; for (let i = 0; i < l; ++i) { const parentSchema = subSchema; const key = dataPointerArray[i]; let subSchemaArray = false; let subSchemaObject = false; if (typeof subSchema !== 'object') { console.error('getFromSchema error: Unable to find "' + key + '" key in schema.'); console.error(schema); console.error(dataPointer); return null; } if (subSchema['type'] === 'array' && subSchema.hasOwnProperty('items') && (!isNaN(key) || key === '-') ) { subSchema = subSchema['items']; subSchemaArray = true; } if (subSchema['type'] === 'object' && subSchema.hasOwnProperty('properties')) { subSchema = subSchema['properties']; subSchemaObject = true; } if (!subSchemaArray || !subSchemaObject) { if (subSchemaArray && key === '-') { subSchema = (parentSchema.hasOwnProperty('additionalItems')) ? parentSchema.additionalItems : { }; } else if (typeof subSchema === 'object' && subSchema.hasOwnProperty(key)) { subSchema = subSchema[key]; } else { console.error('getFromSchema error: Unable to find "' + key + '" item in schema.'); console.error(schema); console.error(dataPointer); return; } } } return subSchema; } /** * 'getSchemaReference' function * * Return the sub-section of a schema referred to * by a JSON Pointer or '$ref' object. * * @param {object} schema - The schema to return a sub-section from * @param {string|object} reference - JSON Pointer or '$ref' object * @param {object} schemaRefLibrary - Optional library of resolved refernces * @param {object} recursiveRefMap - Optional map of recursive links * @return {object} - The refernced schema sub-section */ export function getSchemaReference( schema: any, reference: any, schemaRefLibrary: any = null, recursiveRefMap: Map<string, string> = null ): any { let schemaPointer: string; let newSchema: any; if (isArray(reference) || typeof reference === 'string') { schemaPointer = JsonPointer.compile(reference); } else if (isObject(reference) && Object.keys(reference).length === 1 && reference.hasOwnProperty('$ref') && typeof reference.$ref === 'string' ) { schemaPointer = JsonPointer.compile(reference.$ref); } else { console.error('getSchemaReference error: ' + 'reference must be a JSON Pointer or $ref link'); console.error(reference); return reference; } if (recursiveRefMap) { schemaPointer = resolveRecursiveReferences(schemaPointer, recursiveRefMap); } if (schemaPointer === '') { return _.cloneDeep(schema); } else if (schemaRefLibrary && schemaRefLibrary.hasOwnProperty(schemaPointer)) { return _.cloneDeep(schemaRefLibrary[schemaPointer]); // TODO: Add ability to download remote schema, if necessary // } else if (schemaPointer.slice(0, 4) === 'http') { // http.get(schemaPointer).subscribe(response => { // // TODO: check for recursive references // // TODO: test and adjust to allow for for async response // if (schemaRefLibrary) schemaRefLibrary[schemaPointer] = response.json(); // return response.json(); // }); } else { newSchema = _.cloneDeep(JsonPointer.get(schema, schemaPointer)); // If newSchema is an allOf array, combine array elements // TODO: Check and fix duplicate elements with different values if (isObject(newSchema) && Object.keys(newSchema).length === 1 && hasOwn(newSchema, 'allOf') && isArray(newSchema.allOf) ) { newSchema = newSchema.allOf .map(object => getSchemaReference(schema, object, schemaRefLibrary, recursiveRefMap)) .reduce((schema1, schema2) => Object.assign(schema1, schema2), { }); } if (schemaRefLibrary) { schemaRefLibrary[schemaPointer] = _.cloneDeep(newSchema); } return newSchema; } } /** * 'resolveRecursiveReferences' function * * Checks a JSON Pointer against a map of recursive references and returns * a JSON Pointer to the shallowest equivalent location in the same object. * * Using this functions enables an object to be constructed with unlimited * recursion, while maintaing a fixed set of metadata, such as field data types. * The object can grow as large as it wants, and deeply recursed nodes can * just refer to the metadata for their shallow equivalents, instead of having * to add additional redundant metadata for each recursively added node. * * Example: * * pointer: '/stuff/and/more/and/more/and/more/and/more/stuff' * recursiveRefMap: [['/stuff/and/more/and/more', '/stuff/and/more/']] * returned: '/stuff/and/more/stuff' * * @param {Pointer} pointer - * @param {Map<string, string>} recursiveRefMap - * @param {Map<string, number>} arrayMap - optional * @return {string} - */ export function resolveRecursiveReferences( pointer: Pointer, recursiveRefMap: Map<string, string>, arrayMap: Map<string, number> = new Map<string, number>() ): string { let genericPointer = JsonPointer.toGenericPointer(JsonPointer.compile(pointer), arrayMap); let possibleReferences = true; let previousPointerValues: Pointer[] = []; const catchCircularLinks = (newPointer) => { if (previousPointerValues.indexOf(newPointer) !== -1) { console.error('resolveRecursiveReferences error: ' + 'recursive reference map contains circular links'); console.error(recursiveRefMap); return; } previousPointerValues.push(genericPointer); return newPointer; }; while (possibleReferences) { possibleReferences = false; recursiveRefMap.forEach((toPointer, fromPointer) => { if (JsonPointer.isSubPointer(toPointer, fromPointer)) { while (JsonPointer.isSubPointer(fromPointer, genericPointer)) { genericPointer = catchCircularLinks(JsonPointer.toGenericPointer( toPointer + genericPointer.slice(fromPointer.length), arrayMap )); possibleReferences = true; } } }); } return genericPointer; } /** * 'getInputType' function * * @param {any} schema * @return {string} */ export function getInputType(schema: any, layoutNode: any = null): string { // x-schema-form = Angular Schema Form compatibility // widget & component = React Jsonschema Form compatibility let controlType = JsonPointer.getFirst([ [schema, '/x-schema-form/type'], [schema, '/x-schema-form/widget/component'], [schema, '/x-schema-form/widget'], [schema, '/widget/component'], [schema, '/widget'] ]); if (isString(controlType)) { return checkInlineType(controlType, schema, layoutNode); } let schemaType = schema.type; if (schemaType) { if (isArray(schemaType)) { // If multiple types listed, use most inclusive type if (inArray('object', schemaType) && hasOwn(schema, 'properties')) { schemaType = 'object'; } else if (inArray('array', schemaType) && hasOwn(schema, 'items')) { schemaType = 'array'; } else if (inArray('string', schemaType)) { schemaType = 'string'; } else if (inArray('number', schemaType)) { schemaType = 'number'; } else if (inArray('integer', schemaType)) { schemaType = 'integer'; } else if (inArray('boolean', schemaType)) { schemaType = 'boolean'; } else { schemaType = 'null'; } } if (schemaType === 'boolean') { return 'checkbox'; } if (schemaType === 'object') { if (hasOwn(schema, 'properties')) { return 'fieldset'; } if (hasOwn(schema, '$ref') || JsonPointer.has(schema, '/additionalProperties/$ref')) { return '$ref'; } return null; // return 'textarea'; (?) } if (schemaType === 'array') { let itemsObject = JsonPointer.getFirst([ [schema, '/items'], [schema, '/additionalItems'] ]); if (!itemsObject) { return null; } if (hasOwn(itemsObject, 'enum')) { return checkInlineType('checkboxes', schema, layoutNode); } else { return 'array'; } } if (schemaType === 'null') { return 'hidden'; } if (hasOwn(schema, 'enum')) { return 'select'; } if (schemaType === 'number' || schemaType === 'integer') { if (hasOwn(schema, 'maximum') && hasOwn(schema, 'minimum') && (schemaType === 'integer' || hasOwn(schema, 'multipleOf'))) { return 'range'; } return schemaType; } if (schemaType === 'string') { if (hasOwn(schema, 'format')) { if (schema.format === 'color') { return 'color'; } if (schema.format === 'date') { return 'date'; } if (schema.format === 'date-time') { return 'datetime-local'; } if (schema.format === 'email') { return 'email'; } if (schema.format === 'uri') { return 'url'; } } return 'text'; } } if (hasOwn(schema, '$ref')) { return '$ref'; } return 'text'; } /** * 'checkInlineType' function * * @param {string} controlType - * @param {JSON Schema} schema - * @return {string} */ export function checkInlineType( controlType: string, schema: any, layoutNode: any = null ): string { if (!isString(controlType) || ( controlType.slice(0, 8) !== 'checkbox' && controlType.slice(0, 5) !== 'radio' )) { return controlType; } if ( JsonPointer.getFirst([ [layoutNode, '/inline'], [layoutNode, '/options/inline'], [schema, '/inline'], [schema, '/x-schema-form/inline'], [schema, '/x-schema-form/options/inline'], [schema, '/x-schema-form/widget/inline'], [schema, '/x-schema-form/widget/component/inline'], [schema, '/x-schema-form/widget/component/options/inline'], [schema, '/widget/inline'], [schema, '/widget/component/inline'], [schema, '/widget/component/options/inline'], ]) === true ) { return controlType.slice(0, 5) === 'radio' ? 'radios-inline' : 'checkboxes-inline'; } else { return controlType; } } /** * 'isInputRequired' function * * Checks a JSON Schema to see if an item is required * * @param {schema} schema - the schema to check * @param {string} key - the key of the item to check * @return {boolean} - true if the item is required, false if not */ export function isInputRequired(schema: any, pointer: string): boolean { if (!isObject(schema)) { console.error('isInputRequired error: Input schema must be an object.'); return false; } let listPointerArray: string[] = JsonPointer.parse(pointer); if (isArray(listPointerArray) && listPointerArray.length) { let keyName: string = listPointerArray.pop(); let requiredList: string[]; if (listPointerArray.length) { if (listPointerArray[listPointerArray.length - 1] === '-') { requiredList = JsonPointer.get(schema, listPointerArray.slice(-1).concat(['items', 'required'])); } else { requiredList = JsonPointer.get(schema, listPointerArray.concat('required')); } } else { requiredList = schema['required']; } if (isArray(requiredList)) { return requiredList.indexOf(keyName) !== -1; } } return false; }; /** * 'updateInputOptions' function * * @param {any} layoutNode * @param {any} schema * @return {void} */ export function updateInputOptions(layoutNode: any, schema: any, jsf: any) { if (!isObject(layoutNode)) { return; } const templatePointer = JsonPointer.get(jsf, ['dataMap', layoutNode.dataPointer, 'templatePointer']); // If a validator is available for a layout option, // and not already set in the formGroup template, set it Object.keys(layoutNode).forEach(option => { if (option !== 'type' && isFunction(JsonValidators[option]) && ( !hasOwn(schema, option) || ( schema[option] !== layoutNode[option] && !(option.slice(0, 3) === 'min' && schema[option] < layoutNode[option]) && !(option.slice(0, 3) === 'max' && schema[option] > layoutNode[option]) ) )) { const validatorPointer = templatePointer + '/validators/' + option; jsf.formGroupTemplate = JsonPointer.set( jsf.formGroupTemplate, validatorPointer, [layoutNode[option]] ); } }); // Set all option values in layoutNode.options let newOptions: any = { }; const fixUiKeys = (key) => key.slice(0, 3) === 'ui:' ? key.slice(3) : key; mergeFilteredObject(newOptions, jsf.globalOptions.formDefaults, [], fixUiKeys); if (JsonPointer.has(schema, '/items/enum')) { newOptions.enum = schema.items.enum; } if (JsonPointer.has(schema, '/items/titleMap')) { newOptions.enum = schema.items.titleMap; } mergeFilteredObject(newOptions, JsonPointer.get(schema, '/ui:widget/options'), [], fixUiKeys); mergeFilteredObject(newOptions, JsonPointer.get(schema, '/ui:widget'), [], fixUiKeys); mergeFilteredObject(newOptions, schema, ['properties', 'items', 'required', 'type', 'x-schema-form', '$ref'], fixUiKeys); mergeFilteredObject(newOptions, JsonPointer.get(schema, '/x-schema-form/options'), [], fixUiKeys); mergeFilteredObject(newOptions, JsonPointer.get(schema, '/x-schema-form'), ['items', 'options'], fixUiKeys); mergeFilteredObject(newOptions, layoutNode, ['arrayItem', 'dataPointer', 'dataType', 'items', 'layoutPointer', 'listItems', 'name', 'options', 'tupleItems', 'type', 'widget', '_id', '$ref'], fixUiKeys); mergeFilteredObject(newOptions, layoutNode.options, [], fixUiKeys); layoutNode.options = newOptions; // If schema type is integer, enforce by setting multipleOf = 1 if (schema.type === 'integer' && !hasValue(layoutNode.options.multipleOf)) { layoutNode.options.multipleOf = 1; } // Copy any typeahead word lists to options.typeahead.source if (JsonPointer.has(newOptions, '/autocomplete/source')) { newOptions.typeahead = newOptions.autocomplete; } else if (JsonPointer.has(newOptions, '/tagsinput/source')) { newOptions.typeahead = newOptions.tagsinput; } else if (JsonPointer.has(newOptions, '/tagsinput/typeahead/source')) { newOptions.typeahead = newOptions.tagsinput.typeahead; } // If field value is set in layoutNode, and no input data, update template value if (templatePointer && schema.type !== 'array' && schema.type !== 'object') { let layoutNodeValue: any = JsonPointer.getFirst([ [ jsf.defaultValues, layoutNode.dataPointer ], [ layoutNode, '/value' ], [ layoutNode, '/default' ] ]); let templateValue: any = JsonPointer.get( jsf.formGroupTemplate, templatePointer + '/value/value' ); if (hasValue(layoutNodeValue) && layoutNodeValue !== templateValue) { jsf.formGroupTemplate = JsonPointer.set( jsf.formGroupTemplate, templatePointer + '/value/value', layoutNodeValue ); } delete layoutNode.value; delete layoutNode.default; } } /** * 'getControlValidators' function * * @param {schema} schema * @return {validators} */ export function getControlValidators(schema: any) { if (!isObject(schema)) { return null; } let validators: any = { }; if (hasOwn(schema, 'type')) { switch (schema.type) { case 'string': forEach(['pattern', 'format', 'minLength', 'maxLength'], (prop) => { if (hasOwn(schema, prop)) { validators[prop] = [schema[prop]]; } }); break; case 'number': case 'integer': forEach(['Minimum', 'Maximum'], (Limit) => { let eLimit = 'exclusive' + Limit; let limit = Limit.toLowerCase(); if (hasOwn(schema, limit)) { let exclusive = hasOwn(schema, eLimit) && schema[eLimit] === true; validators[limit] = [schema[limit], exclusive]; } }); forEach(['multipleOf', 'type'], (prop) => { if (hasOwn(schema, prop)) { validators[prop] = [schema[prop]]; } }); break; case 'object': forEach(['minProperties', 'maxProperties', 'dependencies'], (prop) => { if (hasOwn(schema, prop)) { validators[prop] = [schema[prop]]; } }); break; case 'array': forEach(['minItems', 'maxItems', 'uniqueItems'], (prop) => { if (hasOwn(schema, prop)) { validators[prop] = [schema[prop]]; } }); break; } } if (hasOwn(schema, 'enum')) { validators['enum'] = [schema['enum']]; } return validators; }