import { isObject, getObjectName, getType, getValuePreview, getPreview, cssClass, createElement } from './helpers'; import './style.less'; const DATE_STRING_REGEX = /(^\d{1,4}[\.|\\/|-]\d{1,2}[\.|\\/|-]\d{1,4})(\s*(?:0?[1-9]:[0-5]|1(?=[012])\d:[0-5])\d\s*[ap]m)?$/; const PARTIAL_DATE_REGEX = /\d{2}:\d{2}:\d{2} GMT-\d{4}/; const JSON_DATE_REGEX = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; // When toggleing, don't animated removal or addition of more than a few items const MAX_ANIMATED_TOGGLE_ITEMS = 10; const requestAnimationFrame = window.requestAnimationFrame || function(cb: ()=>void) { cb(); return 0; }; export interface JSONFormatterConfiguration { hoverPreviewEnabled?: boolean; hoverPreviewArrayCount?: number; hoverPreviewFieldCount?: number; animateOpen?: boolean; animateClose?: boolean; theme?: string; }; const _defaultConfig: JSONFormatterConfiguration = { hoverPreviewEnabled: false, hoverPreviewArrayCount: 100, hoverPreviewFieldCount: 5, animateOpen: true, animateClose: true, theme: null }; /** * @class JSONFormatter * * JSONFormatter allows you to render JSON objects in HTML with a * **collapsible** navigation. */ export default class JSONFormatter { // Hold the open state after the toggler is used private _isOpen : boolean = null; // A reference to the element that we render to private element: Element; /** * @param {object} json The JSON object you want to render. It has to be an * object or array. Do NOT pass raw JSON string. * * @param {number} [open=1] his number indicates up to how many levels the * rendered tree should expand. Set it to `0` to make the whole tree collapsed * or set it to `Infinity` to expand the tree deeply * * @param {object} [config=defaultConfig] - * defaultConfig = { * hoverPreviewEnabled: false, * hoverPreviewArrayCount: 100, * hoverPreviewFieldCount: 5 * } * * Available configurations: * #####Hover Preview * * `hoverPreviewEnabled`: enable preview on hover * * `hoverPreviewArrayCount`: number of array items to show in preview Any * array larger than this number will be shown as `Array[XXX]` where `XXX` * is length of the array. * * `hoverPreviewFieldCount`: number of object properties to show for object * preview. Any object with more properties that thin number will be * truncated. * * @param {string} [key=undefined] The key that this object in it's parent * context */ constructor(public json: any, private open = 1, private config: JSONFormatterConfiguration = _defaultConfig, private key?: string) { // Setting default values for config object if (this.config.hoverPreviewEnabled === undefined) { this.config.hoverPreviewEnabled = _defaultConfig.hoverPreviewEnabled; } if (this.config.hoverPreviewArrayCount === undefined) { this.config.hoverPreviewArrayCount = _defaultConfig.hoverPreviewArrayCount; } if (this.config.hoverPreviewFieldCount === undefined) { this.config.hoverPreviewFieldCount = _defaultConfig.hoverPreviewFieldCount; } } /* * is formatter open? */ private get isOpen(): boolean { if (this._isOpen !== null) { return this._isOpen } else { return this.open > 0; } } /* * set open state (from toggler) */ private set isOpen(value: boolean) { this._isOpen = value; } /* * is this a date string? */ private get isDate(): boolean { return (this.type === 'string') && (DATE_STRING_REGEX.test(this.json) || JSON_DATE_REGEX.test(this.json) || PARTIAL_DATE_REGEX.test(this.json)); } /* * is this a URL string? */ private get isUrl(): boolean { return this.type === 'string' && (this.json.indexOf('http') === 0); } /* * is this an array? */ private get isArray(): boolean { return Array.isArray(this.json); } /* * is this an object? * Note: In this context arrays are object as well */ private get isObject(): boolean { return isObject(this.json); } /* * is this an empty object with no properties? */ private get isEmptyObject(): boolean { return !this.keys.length && !this.isArray; } /* * is this an empty object or array? */ private get isEmpty(): boolean { return this.isEmptyObject || (this.keys && !this.keys.length && this.isArray); } /* * did we recieve a key argument? * This means that the formatter was called as a sub formatter of a parent formatter */ private get hasKey(): boolean { return typeof this.key !== 'undefined'; } /* * if this is an object, get constructor function name */ private get constructorName(): string { return getObjectName(this.json); } /* * get type of this value * Possible values: all JavaScript primitive types plus "array" and "null" */ private get type(): string { return getType(this.json); } /* * get object keys * If there is an empty key we pad it wit quotes to make it visible */ private get keys(): string[] { if (this.isObject) { return Object.keys(this.json).map((key)=> key ? key : '""'); } else { return []; } } /** * Toggles `isOpen` state * */ toggleOpen() { this.isOpen = !this.isOpen; if (this.element) { if (this.isOpen) { this.appendChildren(this.config.animateOpen); } else{ this.removeChildren(this.config.animateClose); } this.element.classList.toggle(cssClass('open')); } } /** * Open all children up to a certain depth. * Allows actions such as expand all/collapse all * */ openAtDepth(depth = 1) { if (depth < 0) { return; } this.open = depth; this.isOpen = (depth !== 0); if (this.element) { this.removeChildren(false); if (depth === 0) { this.element.classList.remove(cssClass('open')); } else { this.appendChildren(this.config.animateOpen); this.element.classList.add(cssClass('open')); } } } /** * Generates inline preview * * @returns {string} */ getInlinepreview() { if (this.isArray) { // if array length is greater then 100 it shows "Array[101]" if (this.json.length > this.config.hoverPreviewArrayCount) { return `Array[${this.json.length}]`; } else { return `[${this.json.map(getPreview).join(', ')}]`; } } else { const keys = this.keys; // the first five keys (like Chrome Developer Tool) const narrowKeys = keys.slice(0, this.config.hoverPreviewFieldCount); // json value schematic information const kvs = narrowKeys.map(key => `${key}:${getPreview(this.json[key])}`); // if keys count greater then 5 then show ellipsis const ellipsis = keys.length >= this.config.hoverPreviewFieldCount ? '…' : ''; return `{${kvs.join(', ')}${ellipsis}}`; } } /** * Renders an HTML element and installs event listeners * * @returns {HTMLDivElement} */ render(): HTMLDivElement { // construct the root element and assign it to this.element this.element = createElement('div', 'row'); // construct the toggler link const togglerLink = createElement('a', 'toggler-link'); // if this is an object we need a wrapper span (toggler) if (this.isObject) { togglerLink.appendChild(createElement('span', 'toggler')); } // if this is child of a parent formatter we need to append the key if (this.hasKey) { togglerLink.appendChild(createElement('span', 'key', `${this.key}:`)); } // Value for objects and arrays if (this.isObject) { // construct the value holder element const value = createElement('span', 'value'); // we need a wrapper span for objects const objectWrapperSpan = createElement('span'); // get constructor name and append it to wrapper span var constructorName = createElement('span', 'constructor-name', this.constructorName); objectWrapperSpan.appendChild(constructorName); // if it's an array append the array specific elements like brackets and length if (this.isArray) { const arrayWrapperSpan = createElement('span'); arrayWrapperSpan.appendChild(createElement('span', 'bracket', '[')); arrayWrapperSpan.appendChild(createElement('span', 'number', (this.json.length))); arrayWrapperSpan.appendChild(createElement('span', 'bracket', ']')); objectWrapperSpan.appendChild(arrayWrapperSpan); } // append object wrapper span to toggler link value.appendChild(objectWrapperSpan); togglerLink.appendChild(value); // Primitive values } else { // make a value holder element const value = this.isUrl ? createElement('a') : createElement('span'); // add type and other type related CSS classes value.classList.add(cssClass(this.type)); if (this.isDate) { value.classList.add(cssClass('date')); } if (this.isUrl) { value.classList.add(cssClass('url')); value.setAttribute('href', this.json); } // Append value content to value element const valuePreview = getValuePreview(this.json, this.json); value.appendChild(document.createTextNode(valuePreview)); // append the value element to toggler link togglerLink.appendChild(value); } // if hover preview is enabled, append the inline preview element if (this.isObject && this.config.hoverPreviewEnabled) { const preview = createElement('span', 'preview-text'); preview.appendChild(document.createTextNode(this.getInlinepreview())); togglerLink.appendChild(preview); } // construct a children element const children = createElement('div', 'children'); // set CSS classes for children if (this.isObject) { children.classList.add(cssClass('object')); } if (this.isArray) { children.classList.add(cssClass('array')); } if (this.isEmpty) { children.classList.add(cssClass('empty')); } // set CSS classes for root element if (this.config && this.config.theme) { this.element.classList.add(cssClass(this.config.theme)); } if (this.isOpen) { this.element.classList.add(cssClass('open')); } // append toggler and children elements to root element this.element.appendChild(togglerLink); this.element.appendChild(children); // if formatter is set to be open call appendChildren if (this.isObject && this.isOpen) { this.appendChildren(); } // add event listener for toggling if (this.isObject) { togglerLink.addEventListener('click', this.toggleOpen.bind(this)); } return this.element as HTMLDivElement; } /** * Appends all the children to children element * Animated option is used when user triggers this via a click */ appendChildren(animated: boolean = false) { const children = this.element.querySelector(`div.${cssClass('children')}`); if (!children || this.isEmpty) { return; } if (animated) { let index = 0; const addAChild = ()=> { const key = this.keys[index]; const formatter = new JSONFormatter(this.json[key], this.open - 1, this.config, key); children.appendChild(formatter.render()); index += 1; if (index < this.keys.length) { if (index > MAX_ANIMATED_TOGGLE_ITEMS) { addAChild(); } else { requestAnimationFrame(addAChild); } } }; requestAnimationFrame(addAChild); } else { this.keys.forEach(key => { const formatter = new JSONFormatter(this.json[key], this.open - 1, this.config, key); children.appendChild(formatter.render()); }); } } /** * Removes all the children from children element * Animated option is used when user triggers this via a click */ removeChildren(animated: boolean = false) { const childrenElement = this.element.querySelector(`div.${cssClass('children')}`) as HTMLDivElement; if (animated) { let childrenRemoved = 0; const removeAChild = ()=> { if (childrenElement && childrenElement.children.length) { childrenElement.removeChild(childrenElement.children[0]); childrenRemoved += 1; if (childrenRemoved > MAX_ANIMATED_TOGGLE_ITEMS) { removeAChild(); } else { requestAnimationFrame(removeAChild); } } }; requestAnimationFrame(removeAChild); } else { if (childrenElement) { childrenElement.innerHTML = ''; } } } }