admin-client/src/lib/jsonTreeView.js
/**
* https://gist.github.com/mudge/5830382#gistcomment-2691957
*/
class EventEmitter {
constructor() {
this.events = {};
}
_getEventListByName(eventName) {
if (typeof this.events[eventName] === 'undefined') {
this.events[eventName] = new Set();
}
return this.events[eventName];
}
on(eventName, fn) {
this._getEventListByName(eventName).add(fn);
}
once(eventName, fn) {
const self = this;
const onceFn = function (...args) {
self.removeListener(eventName, onceFn);
fn.apply(self, args);
};
this.on(eventName, onceFn);
}
emit(eventName, ...args) {
this._getEventListByName(eventName).forEach((fn) => {
fn.apply(this, args);
});
}
removeListener(eventName, fn) {
this._getEventListByName(eventName).delete(fn);
}
removeAllListeners() {
this.events = {};
}
}
/**
* Adapted from https://github.com/luyuan/json-tree-view
*/
function JSONTreeView(name_, value_, parent_, isRoot_) {
const self = this;
this.ee = new EventEmitter();
this.on = (...args) => this.ee.on(...args);
this.once = (...args) => this.ee.once(...args);
this.emit = (...args) => this.ee.emit(...args);
this.removeAllListeners = (...args) => this.ee.removeAllListeners(...args);
if (typeof isRoot_ === 'undefined' && arguments.length < 4) {
isRoot_ = true;
}
if (arguments.length < 2) {
value_ = name_;
name_ = undefined;
}
let name; let value; let type; let oldType = null; let filterText = ''; let hidden = false;
let readonly = parent_ ? parent_.readonly : true;
let readonlyWhenFiltering = parent_ ? parent_.readonlyWhenFiltering : true;
let alwaysShowRoot = false;
let showCount = parent_ ? parent_.showCountOfObjectOrArray : true;
let includingRootName = true;
const domEventListeners = []; const children = []; let expanded = false;
let edittingName = false; let edittingValue = false;
let nameEditable = true; let
valueEditable = true;
const dom = {
container: document.createElement('div'),
collapseExpand: document.createElement('div'),
name: document.createElement('div'),
separator: document.createElement('div'),
value: document.createElement('div'),
spacing: document.createElement('div'),
// delete : document.createElement('div'),
children: document.createElement('div'),
// insert : document.createElement('div')
};
Object.defineProperties(self, {
dom: {
value: dom.container,
enumerable: true,
},
isRoot: {
get() {
return isRoot_;
},
},
parent: {
get() {
return parent_;
},
},
children: {
get() {
let result = null;
if (type === 'array') {
result = children;
} else if (type === 'object') {
result = {};
children.forEach((e) => {
result[e.name] = e;
});
}
return result;
},
},
readonly: {
get() {
return !!(readonly & 1);
},
set(ro) {
readonly = setBit(readonly, 0, +ro);
readonly & 1 ? dom.container.classList.add('readonly')
: dom.container.classList.remove('readonly');
for (const i in children) {
if (typeof children[i] === 'object') {
children[i].readonly = setBit(readonly, 0, +ro);
}
}
},
},
readonlyWhenFiltering: {
get() {
return readonlyWhenFiltering;
},
set(rowf) {
readonly = setBit(readonly, 1, +rowf);
readonlyWhenFiltering = rowf;
(readonly && this.filterText) || !!(readonly & 1)
? dom.container.classList.add('readonly')
: dom.container.classList.remove('readonly');
for (const i in children) {
if (typeof children[i] === 'object') {
children[i].readonly = setBit(readonly, 1, +rowf);
children[i].readonlyWhenFiltering = rowf;
}
}
},
},
hidden: {
get() {
return hidden;
},
set(h) {
hidden = h;
h ? dom.container.classList.add('hidden')
: dom.container.classList.remove('hidden');
if (!h) {
parent_ && (parent_.hidden = h);
}
},
},
showCountOfObjectOrArray: {
get() {
return showCount;
},
set(show) {
showCount = show;
for (const i in children) {
if (typeof children[i] === 'object') {
children[i].showCountOfObjectOrArray = show;
}
}
(this.type === 'object' || this.type === 'array') && this.updateCount();
},
},
filterText: {
get() {
return filterText;
},
set(text) {
filterText = text;
if (text) {
if (readonly > 0) {
dom.container.classList.add('readonly');
}
const key = `${this.name}`;
let value = `${this.value}`;
if (this.type === 'object' || this.type === 'array') {
value = '';
}
if (key.indexOf(text) > -1 || value.indexOf(text) > -1) {
this.hidden = false;
} else if (!this.alwaysShowRoot || !isRoot_) {
this.hidden = true;
}
} else {
!this.readonly && dom.container.classList.remove('readonly');
this.hidden = false;
}
for (const i in children) {
if (typeof children[i] === 'object') {
children[i].filterText = text;
}
}
},
},
alwaysShowRoot: {
get() {
return alwaysShowRoot;
},
set(value) {
if (isRoot_ && this.filterText) {
this.hidden = !value;
}
alwaysShowRoot = value;
for (const i in children) {
if (typeof children[i] === 'object') {
children[i].alwaysShowRoot = value;
}
}
},
},
withRootName: {
get() {
return includingRootName;
},
set(value) {
includingRootName = value;
},
},
name: {
get() {
return name;
},
set: setName,
enumerable: true,
},
value: {
get() {
return value;
},
set: setValue,
enumerable: true,
},
type: {
get() {
return type;
},
enumerable: true,
},
oldType: {
get() {
return oldType;
},
enumerable: true,
},
nameEditable: {
get() {
return nameEditable;
},
set(value) {
nameEditable = !!value;
},
enumerable: true,
},
valueEditable: {
get() {
return valueEditable;
},
set(value) {
valueEditable = !!value;
},
enumerable: true,
},
refresh: {
value: refresh,
enumerable: true,
},
updateCount: {
value: updateObjectChildCount,
enumerable: true,
},
collapse: {
value: collapse,
enumerable: true,
},
expand: {
value: expand,
enumerable: true,
},
destroy: {
value: destroy,
enumerable: true,
},
editName: {
value: editField.bind(null, 'name'),
enumerable: true,
},
editValue: {
value: editField.bind(null, 'value'),
enumerable: true,
},
});
Object.keys(dom).forEach((k) => {
if (k === 'delete' && self.isRoot) {
return;
}
const element = dom[k];
if (k == 'container') {
return;
}
element.className = k;
if (['name', 'separator', 'value', 'spacing'].indexOf(k) > -1) {
element.className += ' item';
}
dom.container.appendChild(element);
});
dom.container.className = 'jsonView';
addDomEventListener(dom.collapseExpand, 'click', onCollapseExpandClick);
addDomEventListener(dom.value, 'click', expand.bind(null, false));
addDomEventListener(dom.name, 'click', expand.bind(null, false));
addDomEventListener(dom.name, 'dblclick', editField.bind(null, 'name'));
addDomEventListener(dom.name, 'click', itemClicked.bind(null, 'name'));
addDomEventListener(dom.name, 'blur', editFieldStop.bind(null, 'name'));
addDomEventListener(dom.name, 'keypress',
editFieldKeyPressed.bind(null, 'name'));
addDomEventListener(dom.name, 'keydown',
editFieldTabPressed.bind(null, 'name'));
addDomEventListener(dom.value, 'dblclick', editField.bind(null, 'value'));
addDomEventListener(dom.value, 'click', itemClicked.bind(null, 'value'));
addDomEventListener(dom.value, 'blur', editFieldStop.bind(null, 'value'));
addDomEventListener(dom.value, 'keypress',
editFieldKeyPressed.bind(null, 'value'));
addDomEventListener(dom.value, 'keydown',
editFieldTabPressed.bind(null, 'value'));
addDomEventListener(dom.value, 'keydown', numericValueKeyDown);
// addDomEventListener(dom.insert, 'click', onInsertClick);
// addDomEventListener(dom.delete, 'click', onDeleteClick);
setName(name_);
setValue(value_);
function setBit(n, i, b) {
let j = 0;
while ((n >> j << j)) {
j++;
}
return i >= j
? (n | +b << i)
: (n >> (i + 1) << (i + 1)) | (n % (n >> i << i)) | (+b << i);
}
function squarebracketify(exp) {
return typeof exp === 'string'
? exp.replace(/\.([0-9]+)/g, '[$1]') : `${exp}`;
}
function refresh(silent) {
const expandable = type == 'object' || type == 'array';
children.forEach((child) => {
child.refresh(true);
});
dom.collapseExpand.style.display = expandable ? '' : 'none';
if (expanded && expandable) {
expand(false, silent);
} else {
collapse(false, silent);
}
if (!silent) {
self.emit('refresh', self, [self.name], self.value);
}
}
function collapse(recursive, silent) {
if (recursive) {
children.forEach((child) => {
child.collapse(true, true);
});
}
expanded = false;
dom.children.style.display = 'none';
dom.collapseExpand.className = 'expand';
dom.container.classList.add('collapsed');
dom.container.classList.remove('expanded');
if (!silent && (type == 'object' || type == 'array')) {
self.emit('collapse', self, [self.name], self.value);
}
}
function expand(recursive, silent) {
let keys;
if (type == 'object') {
keys = Object.keys(value);
} else if (type == 'array') {
keys = value.map((v, k) => k);
} else {
keys = [];
}
// Remove children that no longer exist
for (let i = children.length - 1; i >= 0; i--) {
const child = children[i];
if (!child) {
break;
}
if (keys.indexOf(child.name) == -1) {
children.splice(i, 1);
removeChild(child);
}
}
if (type != 'object' && type != 'array') {
return collapse();
}
keys.forEach((key) => {
addChild(key, value[key]);
});
if (recursive) {
children.forEach((child) => {
child.expand(true, true);
});
}
expanded = true;
dom.children.style.display = '';
dom.collapseExpand.className = 'collapse';
dom.container.classList.add('expanded');
dom.container.classList.remove('collapsed');
if (!silent && (type == 'object' || type == 'array')) {
self.emit('expand', self, [self.name], self.value);
}
}
function destroy() {
let child; let
event;
while (event = domEventListeners.pop()) {
event.element.removeEventListener(event.name, event.fn);
}
while (child = children.pop()) {
removeChild(child);
}
}
function setName(newName) {
const nameType = typeof newName;
const oldName = name;
if (newName === name) {
return;
}
if (nameType != 'string' && nameType != 'number') {
throw new Error(`Name must be either string or number, ${newName}`);
}
dom.name.innerText = newName;
name = newName;
self.emit('rename', self, [name], oldName, newName, true);
}
function setValue(newValue) {
let oldValue = value;
let str; let
len;
if (isRoot_ && !oldValue) {
oldValue = newValue;
}
type = getType(newValue);
oldType = oldValue ? getType(oldValue) : type;
switch (type) {
case 'null':
str = 'null';
break;
case 'undefined':
str = 'undefined';
break;
case 'object':
len = Object.keys(newValue).length;
str = showCount ? `Object[${len}]` : (len < 1 ? '{}' : '');
break;
case 'array':
len = newValue.length;
str = showCount ? `Array[${len}]` : (len < 1 ? '[]' : '');
break;
default:
str = newValue;
break;
}
dom.value.innerText = str;
dom.value.className = `value item ${type}`;
if (newValue === value) {
return;
}
value = newValue;
if (type == 'array' || type == 'object') {
// Cannot edit objects as string because the formatting is too messy
// Would have to either pass as JSON and force user to wrap properties in quotes
// Or first JSON stringify the input before passing, this could allow users to reference globals
// Instead the user can modify individual properties, or just delete the object and start again
valueEditable = false;
if (type == 'array') {
// Obviously cannot modify array keys
nameEditable = false;
}
}
self.emit('change', self, [name], oldValue, newValue);
refresh();
}
function updateObjectChildCount() {
let str = ''; let
len;
if (type === 'object') {
len = Object.keys(value).length;
str = showCount ? `Object[${len}]` : (len < 1 ? '{}' : '');
}
if (type === 'array') {
len = value.length;
str = showCount ? `Array[${len}]` : (len < 1 ? '[]' : '');
}
dom.value.innerText = str;
}
function addChild(key, val) {
let child;
for (let i = 0, len = children.length; i < len; i++) {
if (children[i].name == key) {
child = children[i];
break;
}
}
if (child) {
child.value = val;
} else {
child = new JSONTreeView(key, val, self, false);
child.on('rename', onChildRename);
child.on('delete', onChildDelete);
child.on('change', onChildChange);
child.on('append', onChildAppend);
child.on('click', onChildClick);
child.on('expand', onChildExpand);
child.on('collapse', onChildCollapse);
child.on('refresh', onChildRefresh);
children.push(child);
child.emit('append', child, [key], 'value', val, true);
}
dom.children.appendChild(child.dom);
return child;
}
function removeChild(child) {
if (child.dom.parentNode) {
dom.children.removeChild(child.dom);
}
child.destroy();
child.emit('delete', child, [child.name], child.value,
child.parent.isRoot ? child.parent.oldType : child.parent.type, true);
child.removeAllListeners();
}
function editField(field) {
if ((readonly > 0 && filterText) || !!(readonly & 1)) {
return;
}
if (field === 'value' && (type === 'object' || type === 'array')) {
return;
}
if (parent_ && parent_.type == 'array') {
// Obviously cannot modify array keys
nameEditable = false;
}
const editable = field == 'name' ? nameEditable : valueEditable;
const element = dom[field];
if (!editable && (parent_ && parent_.type === 'array')) {
if (!parent_.inserting) {
// throw new Error('Cannot edit an array index.');
return;
}
}
if (field == 'value' && type == 'string') {
element.innerText = `"${value}"`;
}
if (field == 'name') {
edittingName = true;
}
if (field == 'value') {
edittingValue = true;
}
element.classList.add('edit');
element.setAttribute('contenteditable', true);
element.focus();
document.execCommand('selectAll', false, null);
}
function itemClicked(field) {
self.emit('click', self,
!self.withRootName && self.isRoot ? [''] : [self.name], self.value);
}
function editFieldStop(field) {
const element = dom[field];
if (field == 'name') {
if (!edittingName) {
return;
}
edittingName = false;
}
if (field == 'value') {
if (!edittingValue) {
return;
}
edittingValue = false;
}
if (field == 'name') {
const p = self.parent;
const edittingNameText = element.innerText;
if (p && p.type === 'object' && edittingNameText in p.value) {
element.innerText = name;
element.classList.remove('edit');
element.removeAttribute('contenteditable');
// throw new Error('Name exist, ' + edittingNameText);
} else {
setName.call(self, edittingNameText);
}
} else {
const text = element.innerText;
try {
setValue(text === 'undefined' ? undefined : JSON.parse(text));
} catch (err) {
setValue(text);
}
}
element.classList.remove('edit');
element.removeAttribute('contenteditable');
}
function editFieldKeyPressed(field, e) {
switch (e.key) {
case 'Escape':
case 'Enter':
editFieldStop(field);
break;
}
}
function editFieldTabPressed(field, e) {
if (e.key == 'Tab') {
editFieldStop(field);
if (field == 'name') {
e.preventDefault();
editField('value');
} else {
editFieldStop(field);
}
}
}
function numericValueKeyDown(e) {
let increment = 0; let
currentValue;
if (type != 'number') {
return;
}
switch (e.key) {
case 'ArrowDown':
case 'Down':
increment = -1;
break;
case 'ArrowUp':
case 'Up':
increment = 1;
break;
}
if (e.shiftKey) {
increment *= 10;
}
if (e.ctrlKey || e.metaKey) {
increment /= 10;
}
if (increment) {
currentValue = parseFloat(dom.value.innerText);
if (!isNaN(currentValue)) {
setValue(Number((currentValue + increment).toFixed(10)));
}
}
}
function getType(value) {
const type = typeof value;
if (type == 'object') {
if (value === null) {
return 'null';
}
if (Array.isArray(value)) {
return 'array';
}
}
if (type === 'undefined') {
return 'undefined';
}
return type;
}
function onCollapseExpandClick() {
if (expanded) {
collapse();
} else {
expand();
}
}
function onInsertClick() {
const newName = type == 'array' ? value.length : undefined;
const child = addChild(newName, null);
if (child.parent) {
child.parent.inserting = true;
}
if (type == 'array') {
value.push(null);
child.editValue();
child.emit('append', self, [value.length - 1], 'value', null, true);
if (child.parent) {
child.parent.inserting = false;
}
} else {
child.editName();
}
}
function onDeleteClick() {
self.emit('delete', self, [self.name], self.value,
self.parent.isRoot ? self.parent.oldType : self.parent.type, false);
}
function onChildRename(child, keyPath, oldName, newName, original) {
const allow = newName && type != 'array' && !(newName in value) && original;
if (allow) {
value[newName] = child.value;
delete value[oldName];
if (self.inserting) {
child.emit('append', child, [newName], 'name', newName, true);
self.inserting = false;
return;
}
} else if (oldName === undefined) {
// A new node inserted via the UI
original && removeChild(child);
} else if (original) {
// Cannot rename array keys, or duplicate object key names
child.name = oldName;
return;
}
// value[keyPath] = newName;
// child.once('rename', onChildRename);
if (self.withRootName || !self.isRoot) {
keyPath.unshift(name);
} else if (self.withRootName && self.isRoot) {
keyPath.unshift(name);
}
if (oldName !== undefined) {
self.emit('rename', child, keyPath, oldName, newName, false);
}
}
function onChildAppend(child, keyPath, nameOrValue, newValue, sender) {
if (self.withRootName || !self.isRoot) {
keyPath.unshift(name);
}
self.emit('append', child, keyPath, nameOrValue, newValue, false);
sender && updateObjectChildCount();
}
function onChildChange(child, keyPath, oldValue, newValue, recursed) {
if (!recursed) {
value[keyPath] = newValue;
}
if (self.withRootName || !self.isRoot) {
keyPath.unshift(name);
}
self.emit('change', child, keyPath, oldValue, newValue, true);
}
function onChildDelete(child, keyPath, deletedValue, parentType, passive) {
const key = child.name;
if (passive) {
if (self.withRootName/* || !self.isRoot */) {
keyPath.unshift(name);
}
self.emit('delete', child, keyPath, deletedValue, parentType, passive);
updateObjectChildCount();
} else {
if (type == 'array') {
value.splice(key, 1);
} else {
delete value[key];
}
refresh(true);
}
}
function onChildClick(child, keyPath, value) {
if (self.withRootName || !self.isRoot) {
keyPath.unshift(name);
}
self.emit('click', child, keyPath, value);
}
function onChildExpand(child, keyPath, value) {
if (self.withRootName || !self.isRoot) {
keyPath.unshift(name);
}
self.emit('expand', child, keyPath, value);
}
function onChildCollapse(child, keyPath, value) {
if (self.withRootName || !self.isRoot) {
keyPath.unshift(name);
}
self.emit('collapse', child, keyPath, value);
}
function onChildRefresh(child, keyPath, value) {
if (self.withRootName || !self.isRoot) {
keyPath.unshift(name);
}
self.emit('refresh', child, keyPath, value);
}
function addDomEventListener(element, name, fn) {
element.addEventListener(name, fn);
domEventListeners.push({ element, name, fn });
}
}
export default JSONTreeView;