angular2-json-schema-form
Version:
Angular 2 JSON Schema Form builder
690 lines (664 loc) • 26 kB
text/typescript
import { Injectable } from '@angular/core';
import { isDefined, isEmpty, isObject, isArray, isMap } from './validator.functions';
import { hasOwn, copy } from './utility.functions';
/**
* 'JsonPointer' class
*
* Some utilities for using JSON Pointers with JSON objects
* https://tools.ietf.org/html/rfc6901
*
* get, getFirst, set, setCopy, insert, insertCopy, remove, has, dict,
* forEachDeep, forEachDeepCopy, escape, unescape, parse, compile, toKey,
* isJsonPointer, isSubPointer, toIndexedPointer, toGenericPointer,
* toControlPointer, parseObjectPath
*
* Partly based on manuelstofer's json-pointer utilities
* https://github.com/manuelstofer/json-pointer
*/
export type Pointer = string | string[];
export class JsonPointer {
/**
* 'get' function
*
* Uses a JSON Pointer to retrieve a value from an object
*
* @param {object} object - Object to get value from
* @param {Pointer} pointer - JSON Pointer (string or array)
* @param {number = 0} startSlice - Zero-based index of first Pointer key to use
* @param {number} endSlice - Zero-based index of last Pointer key to use
* @param {boolean = false} getBoolean - Return only true or false?
* @param {boolean = true} errors - Show error if not found?
* @return {object} - Located value (or true or false if getBoolean = true)
*/
static get(
object: any, pointer: Pointer, startSlice: number = 0,
endSlice: number = null, getBoolean: boolean = false, errors: boolean = false
): any {
if (object === null) { return getBoolean ? false : undefined; }
let keyArray: any[] = this.parse(pointer);
if (typeof object === 'object' && keyArray !== null) {
let subObject = object;
if (startSlice >= keyArray.length || endSlice <= -keyArray.length) { return object; }
if (startSlice <= -keyArray.length) { startSlice = 0; }
if (!isDefined(endSlice) || endSlice >= keyArray.length) { endSlice = keyArray.length; }
keyArray = keyArray.slice(startSlice, endSlice);
for (let key of keyArray) {
if (key === '-' && isArray(subObject) && subObject.length) {
key = subObject.length - 1;
}
if (typeof subObject === 'object' && subObject !== null &&
hasOwn(subObject, key)
) {
subObject = subObject[key];
} else if (isMap(subObject) && subObject.has(key)) {
subObject = subObject.get(key);
} else {
if (errors) {
console.error('get error: "' + key + '" key not found in object.');
console.error(pointer);
console.error(object);
}
return getBoolean ? false : undefined;
}
}
return getBoolean ? true : subObject;
}
if (errors && keyArray === null) {
console.error('get error: Invalid JSON Pointer: ' + pointer);
}
if (errors && typeof object !== 'object') {
console.error('get error: Invalid object:=');
console.error(object);
}
return getBoolean ? false : undefined;
}
/**
* 'getFirst' function
*
* Takes an array of JSON Pointers and objects, and returns the value
* from the first pointer to find a value in its object.
*
* @param {[object, pointer][]} items - array of objects and pointers to check
* @param {any} defaultValue - Optional value to return if nothing found
* @return {any} - first set value
*/
static getFirst(items: any, defaultValue: any = null): any {
if (isEmpty(items)) { return; }
if (isArray(items)) {
for (let item of items) {
if (isEmpty(item)) { continue; }
if (isArray(item) && item.length >= 2) {
if (isEmpty(item[0]) || isEmpty(item[1])) { continue; }
const value: any = this.get(item[0], item[1]);
if (value) { return value; }
continue;
}
console.error('getFirst error: Input not in correct format.\n' +
'Should be: [ [ object1, pointer1 ], [ object 2, pointer2 ], etc... ]');
return;
}
return defaultValue;
}
if (isMap(items)) {
for (let [object, pointer] of items) {
if (object === null || !this.isJsonPointer(pointer)) { continue; }
const value: any = this.get(object, pointer);
if (value) { return value; }
}
return defaultValue;
}
console.error('getFirst error: Input not in correct format.\n' +
'Should be: [ [ object1, pointer1 ], [ object 2, pointer2 ], etc... ]');
}
/**
* 'set' function
*
* Uses a JSON Pointer to set a value on an object
*
* If the optional fourth parameter is TRUE and the inner-most container
* is an array, the function will insert the value as a new item at the
* specified location in the array, rather than overwriting the existing value
*
* @param {object} object - The object to set value in
* @param {Pointer} pointer - The JSON Pointer (string or array)
* @param {any} value - The value to set
* @return {object} - The original object, modified with the set value
*/
static set(
object: any, pointer: Pointer, value: any, insert: boolean = false
): any {
const keyArray: string[] = this.parse(pointer);
if (keyArray !== null) {
let subObject: any = object;
for (let i = 0, l = keyArray.length - 1; i < l; ++i) {
let key: string = keyArray[i];
if (key === '-' && isArray(subObject)) {
key = subObject.length;
}
if (isMap(subObject) && subObject.has(key)) {
subObject = subObject.get(key);
} else {
if (!hasOwn(subObject, key)) {
subObject[key] = (keyArray[i + 1].match(/^(\d+|-)$/)) ? [] : {};
}
subObject = subObject[key];
}
}
let lastKey: string = keyArray[keyArray.length - 1];
if (isArray(subObject) && lastKey === '-') {
subObject.push(value);
} else if (insert && isArray(subObject) && !isNaN(+lastKey)) {
subObject.splice(lastKey, 0, value);
} else if (isMap(subObject)) {
subObject.set(lastKey, value);
} else {
subObject[lastKey] = value;
}
return object;
}
console.error('set error: Invalid JSON Pointer: ' + pointer);
}
/**
* 'setCopy' function
*
* Copies an object and uses a JSON Pointer to set a value on the copy.
*
* If the optional fourth parameter is TRUE and the inner-most container
* is an array, the function will insert the value as a new item at the
* specified location in the array, rather than overwriting the existing value.
*
* @param {object} object - The object to copy and set value in
* @param {Pointer} pointer - The JSON Pointer (string or array)
* @param {any} value - The value to set
* @return {object} - The new object with the set value
*/
static setCopy(
object: any, pointer: Pointer, value: any, insert: boolean = false
): any {
const keyArray: string[] = this.parse(pointer);
if (keyArray !== null) {
let newObject: any = copy(object);
let subObject: any = newObject;
for (let i = 0, l = keyArray.length - 1; i < l; ++i) {
let key: string = keyArray[i];
if (key === '-' && isArray(subObject)) {
key = subObject.length;
}
if (isMap(subObject) && subObject.has(key)) {
subObject.set(key, copy(subObject.get(key)));
subObject = subObject.get(key);
} else {
if (!hasOwn(subObject, key)) {
subObject[key] = (keyArray[i + 1].match(/^(\d+|-)$/)) ? [] : {};
}
subObject[key] = copy(subObject[key]);
subObject = subObject[key];
}
}
let lastKey: string = keyArray[keyArray.length - 1];
if (isArray(subObject) && lastKey === '-') {
subObject.push(value);
} else if (insert && isArray(subObject) && !isNaN(+lastKey)) {
subObject.splice(lastKey, 0, value);
} else if (isMap(subObject)) {
subObject.set(lastKey, value);
} else {
subObject[lastKey] = value;
}
return newObject;
}
console.error('setCopy error: Invalid JSON Pointer: ' + pointer);
}
/**
* 'insert' function
*
* Calls 'set' with insert = TRUE
*
* @param {object} object - object to insert value in
* @param {Pointer} pointer - JSON Pointer (string or array)
* @param {any} value - value to insert
* @return {object}
*/
static insert(object: any, pointer: Pointer, value: any): any {
this.set(object, pointer, value, true);
}
/**
* 'insertCopy' function
*
* Calls 'setCopy' with insert = TRUE
*
* @param {object} object - object to insert value in
* @param {Pointer} pointer - JSON Pointer (string or array)
* @param {any} value - value to insert
* @return {object}
*/
static insertCopy(object: any, pointer: Pointer, value: any): any {
this.setCopy(object, pointer, value, true);
}
/**
* 'remove' function
*
* Uses a JSON Pointer to remove a key and its attribute from an object
*
* @param {object} object - object to delete attribute from
* @param {Pointer} pointer - JSON Pointer (string or array)
* @return {object}
*/
static remove(object: any, pointer: Pointer): any {
let keyArray: any[] = this.parse(pointer);
if (keyArray !== null && keyArray.length) {
let lastKey = keyArray.pop();
let parentObject = this.get(object, keyArray);
if (isArray(parentObject)) {
if (lastKey === '-') { lastKey = parentObject.length - 1; }
parentObject.splice(lastKey, 1);
} else if (isObject(parentObject)) {
delete parentObject[lastKey];
}
return object;
}
console.error('remove error: Invalid JSON Pointer: ' + pointer);
}
/**
* 'has' function
*
* Tests if an object has a value at the location specified by a JSON Pointer
*
* @param {object} object - object to chek for value
* @param {Pointer} pointer - JSON Pointer (string or array)
* @return {boolean}
*/
static has(object: any, pointer: Pointer): boolean {
return this.get(object, pointer, 0, null, true);
}
/**
* 'dict' function
*
* Returns a (pointer -> value) dictionary for an object
*
* @param {Object} object - The object to create a dictionary from
* @return {Object} - The resulting dictionary object
*/
static dict(object: any): any {
let results: any = {};
this.forEachDeep(object, (value, pointer) => {
if (typeof value !== 'object') { results[pointer] = value; }
});
return results;
}
/**
* 'forEachDeep' function
*
* Iterates over own enumerable properties of an object or items in an array
* and invokes an iteratee function for each key/value or index/value pair.
* By default, iterates over items within objects and arrays after calling
* the iteratee function on the containing object or array itself.
*
* The iteratee is invoked with three arguments: (value, pointer, rootObject),
* where pointer is a JSON pointer indicating the location of the current
* value within the root object, and rootObject is the root object initially
* submitted to th function.
*
* If a third optional parameter 'bottomUp' is set to TRUE, the iterator
* function will be called on sub-objects and arrays after being
* called on their contents, rather than before, which is the default.
*
* This function can also optionally be called directly on a sub-object by
* including optional 4th and 5th parameterss to specify the initial
* root object and pointer.
*
* @param {object} object - the initial object or array
* @param {(v: any, k?: string, o?: any, p?: any) => any} function - iteratee function
* @param {boolean = false} bottomUp - optional, set to TRUE to reverse direction
* @param {object = object} rootObject - optional, root object or array
* @param {string = ''} pointer - optional, JSON Pointer to object within rootObject
*/
static forEachDeep(
object: any, fn: (v: any, p?: string, o?: any) => any,
bottomUp: boolean = false, pointer: string = '', rootObject: any = object
): void {
if (typeof fn === 'function') {
if (!bottomUp) { fn(object, pointer, rootObject); }
if (isObject(object) || isArray(object)) {
for (let key of Object.keys(object)) {
const newPointer: string = pointer + '/' + this.escape(key);
this.forEachDeep(object[key], fn, bottomUp, newPointer, rootObject);
}
}
if (bottomUp) { fn(object, pointer, rootObject); }
} else {
console.error('forEachDeep error: Iterator must be a function.');
}
}
/**
* 'forEachDeepCopy' function
*
* Similar to forEachDeep, but returns a copy of the original object, with
* the same keys and indexes, but with values replaced with the result of
* the iteratee function.
*
* @param {object} object - the initial object or array
* @param {(v: any, k?: string, o?: any, p?: any) => any} function - iteratee function
* @param {boolean = false} bottomUp - optional, set to TRUE to reverse direction
* @param {object = object} rootObject - optional, root object or array
* @param {string = ''} pointer - optional, JSON Pointer to object within rootObject
*/
static forEachDeepCopy(
object: any, fn: (v: any, p?: string, o?: any) => any,
bottomUp: boolean = false, pointer: string = '', rootObject: any = object
): void {
if (typeof fn === 'function') {
if (isObject(object) || isArray(object)) {
let newObject = Object.assign(isArray(object) ? [] : {}, object);
if (!bottomUp) { fn(newObject, pointer, rootObject); }
for (let key of Object.keys(newObject)) {
const newPointer: string = pointer + '/' + this.escape(key);
newObject[key] = this.forEachDeepCopy(object[key], fn, bottomUp, newPointer, rootObject);
}
if (bottomUp) { fn(newObject, pointer, rootObject); }
} else {
return fn(object, pointer, rootObject);
}
}
console.error('forEachDeep error: Iterator must be a function.');
}
/**
* 'escape' function
*
* Escapes a string reference key
*
* @param {string} key - string key to escape
* @return {string} - escaped key
*/
static escape(key: string): string {
return key.toString().replace(/~/g, '~0').replace(/\//g, '~1');
}
/**
* 'unescape' function
* Unescapes a string reference key
*
* @param {string} key - string key to unescape
* @return {string} - unescaped key
*/
static unescape(key: string): string {
return key.toString().replace(/~1/g, '/').replace(/~0/g, '~');
}
/**
* 'parse' function
*
* Converts a string JSON Pointer into a array of keys
* (if input is already an an array of keys, it is returned unchanged)
*
* @param {Pointer} pointer - JSON Pointer (string or array)
* @return {string[]} - JSON Pointer array of keys
*/
static parse(pointer: Pointer): string[] {
if (isArray(pointer)) { return <string[]>pointer; }
if (typeof pointer === 'string') {
if ((<string>pointer)[0] === '#') { pointer = pointer.slice(1); }
if (<string>pointer === '') { return []; }
if ((<string>pointer)[0] !== '/') {
console.error('parse error: Invalid JSON Pointer, does not start with "/": ' +
pointer);
return;
}
return (<string>pointer).slice(1).split('/').map(this.unescape);
}
console.error('parse error: Invalid JSON Pointer, not a string or array:');
console.error(pointer);
}
/**
* 'compile' function
*
* Converts an array of keys into a JSON Pointer string
* (if input is already a string, it is normalized and returned)
*
* The optional second parameter is a default which will replace any empty keys.
*
* @param {Pointer} keyArray - JSON Pointer (string or array)
* @returns {string} - JSON Pointer string
*/
static compile(keyArray: Pointer, defaultValue: string | number = ''): string {
if (isArray(keyArray)) {
if ((<string[]>keyArray).length === 0) { return ''; }
return '/' + (<string[]>keyArray).map(
key => key === '' ? defaultValue : this.escape(key)
).join('/');
}
if (typeof keyArray === 'string') {
if (keyArray[0] === '#') { keyArray = keyArray.slice(1); }
if (keyArray.length && keyArray[0] !== '/') {
console.error('compile error: Invalid JSON Pointer, does not start with "/": ' +
keyArray);
return;
}
return keyArray;
}
console.error('compile error: Invalid JSON Pointer, not a string or array:');
console.error(keyArray);
}
/**
* 'toKey' function
*
* Extracts name of the final key from a JSON Pointer.
*
* @param {Pointer} pointer - JSON Pointer (string or array)
* @returns {string} - the extracted key
*/
static toKey(pointer: Pointer): string {
let keyArray = this.parse(pointer);
if (keyArray === null) { return null; }
if (!keyArray.length) { return ''; }
return keyArray[keyArray.length - 1];
}
/**
* 'isJsonPointer' function
*
* Checks a string value to determine if it is a valid JSON Pointer.
* This function only checks for valid JSON Pointer strings, not arrays.
* (Any array of string values is assumed to be a potentially valid JSON Pointer.)
*
* @param {any} value - value to check
* @returns {boolean} - true if value is a valid JSON Pointer, otherwise false
*/
static isJsonPointer(value: any): boolean {
if (typeof value === 'string') {
if (value === '') { return true; }
if (value[0] === '#') { value = value.slice(1); }
if (value[0] === '/') { return true; }
}
return false;
}
/**
* 'isSubPointer' function
*
* Checks whether one JSON Pointer is a subset of another.
*
* @param {Pointer} shortPointer - potential subset JSON Pointer
* @param {Pointer} longPointer - potential superset JSON Pointer
* @return {boolean} - true if shortPointer is a subset of longPointer, false if not
*/
static isSubPointer(shortPointer: Pointer, longPointer: Pointer): boolean {
if (isArray(shortPointer)) { shortPointer = this.compile(shortPointer); }
if (isArray(longPointer)) { longPointer = this.compile(longPointer); }
if (typeof shortPointer !== 'string' || typeof longPointer !== 'string') {
console.error('isSubPointer error: Invalid JSON Pointer, not a string or array:');
if (typeof shortPointer !== 'string') { console.error(shortPointer); }
if (typeof longPointer !== 'string') { console.error(longPointer); }
return;
}
return shortPointer === longPointer.slice(0, shortPointer.length);
}
/**
* 'toIndexedPointer' function
*
* Merges an array of numeric indexes and a generic pointer to create an
* indexed pointer for a specific item.
*
* For example, merging the generic pointer '/foo/-/bar/-/baz' and
* the array [4, 2] would result in the indexed pointer '/foo/4/bar/2/baz'
*
* @function
* @param {string | string[]} genericPointer - The generic pointer
* @param {number[]} indexArray - The array of numeric indexes
* @param {Map<string, number>} arrayMap - An optional array map
* @return {string} - The merged pointer with indexes
*/
static toIndexedPointer(
genericPointer: string, indexArray: number[], arrayMap: Map<string, number> = null
) {
if (genericPointer[0] === '#') {
genericPointer = genericPointer.slice(1);
}
if (this.isJsonPointer(genericPointer) && isArray(indexArray)) {
if (isMap(arrayMap)) {
let arrayIndex: number = 0;
return genericPointer.replace(/\/\-(?=\/|$)/g, (key, stringIndex) => {
const subPointer = genericPointer.slice(0, stringIndex);
if (arrayMap.has(subPointer)) { return '/' + indexArray[arrayIndex++]; }
});
} else {
let indexedPointer = genericPointer;
for (let pointerIndex of indexArray) {
indexedPointer = indexedPointer.replace('/-', '/' + pointerIndex);
}
return indexedPointer;
}
}
console.error('toIndexedPointer error: genericPointer must be ' +
'a JSON Pointer and indexArray must be an array.');
console.error(genericPointer);
console.error(indexArray);
};
/**
* 'toGenericPointer' function
*
* Compares an indexed pointer to an array map and removes list array
* indexes (but leaves tuple arrray indexes and all object keys, including
* numeric keys) to create a generic pointer.
*
* For example, using the indexed pointer '/foo/1/bar/2/baz/3' and
* the arrayMap [['/foo', 0], ['/foo/-/bar', 3], ['/foo/-/bar/2/baz', 0]]
* would result in the generic pointer '/foo/-/bar/2/baz/-'
* Using the indexed pointer '/foo/1/bar/4/baz/3' and the same arrayMap
* would result in the generic pointer '/foo/-/bar/-/baz/-'
*
* The structure of the arrayMap is: [['path to array', number of tuple items]...]
*
* @function
* @param {Pointer} indexedPointer - The indexed pointer (array or string)
* @param {Map<string, number>} arrayMap - The optional array map (for preserving tuple indexes)
* @return {string} - The generic pointer with indexes removed
*/
static toGenericPointer(
indexedPointer: Pointer, arrayMap: Map<string, number> = new Map<string, number>()
) {
if (this.isJsonPointer(indexedPointer) && isMap(arrayMap)) {
let pointerArray = this.parse(indexedPointer);
for (let i = 1, l = pointerArray.length; i < l; i++) {
const subPointer = this.compile(pointerArray.slice(0, i));
if (arrayMap.has(subPointer) && arrayMap.get(subPointer) <= +pointerArray[i]) {
pointerArray[i] = '-';
}
}
return this.compile(pointerArray);
}
console.error('toGenericPointer error: ' +
'indexedPointer must be a JSON Pointer and arrayMap must be a Map.');
console.error(indexedPointer);
console.error(arrayMap);
};
/**
* 'toControlPointer' function
*
* Accepts a JSON Pointer for a data object and returns a JSON Pointer for the
* matching control in an Angular 2 FormGroup.
*
* @param {FormGroup} formGroup - Angular 2 FormGroup to get value from
* @param {Pointer} dataPointer - JSON Pointer (string or array) to a data object
* @return {Pointer} - JSON Pointer (string) to the formGroup object
*/
static toControlPointer(formGroup: any, dataPointer: Pointer): string {
const dataPointerArray: string[] = this.parse(dataPointer);
let controlPointerArray: string[] = [];
let subGroup = formGroup;
if (dataPointerArray !== null) {
for (let key of dataPointerArray) {
if (subGroup.hasOwnProperty('controls')) {
controlPointerArray.push('controls');
subGroup = subGroup.controls;
}
if (isArray(subGroup) && (key === '-')) {
controlPointerArray.push((subGroup.length - 1).toString());
subGroup = subGroup[subGroup.length - 1];
} else if (subGroup.hasOwnProperty(key)) {
controlPointerArray.push(key);
subGroup = subGroup[key];
} else {
console.error('toControlPointer error: Unable to find "' + key +
'" item in FormGroup.');
console.error(dataPointer);
console.error(formGroup);
return;
}
}
return this.compile(controlPointerArray);
}
console.error('getControl error: Invalid JSON Pointer: ' + dataPointer);
}
/**
* 'parseObjectPath' function
*
* Parses a JavaScript object path into an array of keys, which
* can then be passed to compile() to convert into a string JSON Pointer.
*
* Based on mike-marcacci's objectpath parse function:
* https://github.com/mike-marcacci/objectpath
*
* @param {string} path - The object path to parse
* @return {string[]} - The resulting array of keys
*/
static parseObjectPath(path: string | string[]): string[] {
if (isArray(path)) { return <string[]>path; }
if (typeof path === 'string') {
let index: number = 0;
let parts: string[] = [];
while (index < path.length) {
const nextDot: number = path.indexOf('.', index);
const nextOB: number = path.indexOf('[', index); // next open bracket
if (nextDot === -1 && nextOB === -1) { // last item
parts.push(path.slice(index));
index = path.length;
} else if (nextDot !== -1 && (nextDot < nextOB || nextOB === -1)) { // dot notation
parts.push(path.slice(index, nextDot));
index = nextDot + 1;
} else { // bracket notation
if (nextOB > index) {
parts.push(path.slice(index, nextOB));
index = nextOB;
}
const quote: string = path.charAt(nextOB + 1);
if (quote === '"' || quote === "'") { // enclosing quotes
let nextCB: number = path.indexOf(quote + ']', nextOB); // next close bracket
while (nextCB !== -1 && path.charAt(nextCB - 1) === '\\') {
nextCB = path.indexOf(quote + ']', nextCB + 2);
}
if (nextCB === -1) { nextCB = path.length; }
parts.push(path.slice(index + 2, nextCB)
.replace(new RegExp('\\' + quote, 'g'), quote));
index = nextCB + 2;
} else { // no enclosing quotes
let nextCB: number = path.indexOf(']', nextOB); // next close bracket
if (nextCB === -1) { nextCB = path.length; }
parts.push(path.slice(index + 1, nextCB));
index = nextCB + 1;
}
if (path.charAt(index) === '.') { index++; }
}
}
return parts;
}
console.error('parseObjectPath error: Input object path must be a string.');
}
}