import { Editor } from '@editor' import { MessageBus } from '@mbus' export class App { constructor() {// {{{ window.mbus = new MessageBus() this.editor = null this.typesList = null this.currentNode = null this.currentNodeID = null this.types = [] this.currentPage = null this.tree = new Tree(document.getElementById('nodes')) const events = [ 'EDITOR_NODE_SAVE', 'MENU_ITEM_SELECTED', 'NODE_CREATE_DIALOG', 'NODE_DELETE', 'NODE_EDIT_NAME', 'NODE_SELECTED', 'TREE_RELOAD_NODE', 'TYPES_LIST_FETCHED', ] for (const eventName of events) mbus.subscribe(eventName, event => this.eventHandler(event)) document.addEventListener('keydown', event => this.keyHandler(event)) mbus.dispatch('MENU_ITEM_SELECTED', 'node') }// }}} eventHandler(event) {// {{{ switch (event.type) { case 'MENU_ITEM_SELECTED': const item = document.querySelector(`#menu [data-section="${event.detail}"]`) this.page(item, event.detail) break case 'NODE_SELECTED': for (const n of document.querySelectorAll('#nodes .node.selected')) n.classList.remove('selected') for (const n of document.querySelectorAll(`#nodes .node[data-node-id="${event.detail}"]`)) n.classList?.add('selected') this.edit(event.detail) break case 'NODE_DELETE': if (!confirm('Are you sure you want to delete this node?')) return this.nodeDelete(this.currentNode.ID) break case 'EDITOR_NODE_SAVE': this.nodeUpdate() break case 'TYPES_LIST_FETCHED': const types = document.getElementById('types') types.replaceChildren(this.typesList.render()) break case 'NODE_CREATE_DIALOG': if (this.currentPage !== 'node' || this.currentNode === null) return new NodeCreateDialog(this.currentNode.ID) break case 'NODE_EDIT_NAME': const newName = prompt('Rename node', this.currentNode.Name) if (newName === null) return this.nodeRename(this.currentNode.ID, newName) .then(() => mbus.dispatch('TREE_RELOAD_NODE', { parentNodeID: this.currentNode.ParentID })) break case 'TREE_RELOAD_NODE': this.tree.updateNode(event.detail.parentNodeID) .then(() => { if (event.detail.callback) event.detail.callback() .catch(err => showError(err)) }) break default: alert(`Unhandled event: ${event.type}`) console.log(event) } }// }}} keyHandler(event) {// {{{ let handled = true switch (event.key.toUpperCase()) { case 'D': mbus.dispatch('NODE_DELETE') break case 'N': if (!event.shiftKey || !event.altKey) return mbus.dispatch('NODE_CREATE_DIALOG') break default: handled = false } if (handled) { event.stopPropagation() event.preventDefault() } }// }}} page(item, name) {// {{{ for (const el of document.querySelectorAll('#menu .item')) el.classList.remove('selected') item.classList.add('selected') for (const el of document.querySelectorAll('.page.show')) el.classList.remove('show') this.currentPage = name switch (name) { case 'node': document.getElementById('nodes').classList.add('show') document.getElementById('editor-node').classList.add('show') break case 'type': document.getElementById('types').classList.add('show') document.getElementById('editor-type-schema').classList.add('show') if (this.typesList === null) this.typesList = new TypesList() this.typesList.fetchTypes().then(() => { mbus.dispatch('TYPES_LIST_FETCHED') }) break } }// }}} edit(nodeID) {// {{{ fetch(`/nodes/${nodeID}`) .then(data => data.json()) .then(json => { if (!json.OK) { showError(json.Error) return } this.currentNode = json.Node // The JSON editor is created each time. Could probably be reused. const editorEl = document.querySelector('#editor-node .editor') this.editor = new Editor(json.Node.TypeSchema) if (json.Node.Data['x-new']) editorEl.replaceChildren(this.editor.render(null)) else editorEl.replaceChildren(this.editor.render(json.Node.Data)) // Name is separate from the JSON node. const name = document.getElementById('editor-node-name') name.innerText = json.Node.Name // The editor-node div is hidden from the start as a lot of the elements // doesn't make any sense before a node is selected. document.getElementById('editor-node').style.display = 'grid' }) }// }}} async nodeRename(nodeID, name) {// {{{ return new Promise((resolve, reject) => { name = name.trim() if (name.length === 0) { alert('A name must be provided.') return } fetch(`/nodes/rename/${nodeID}`, { method: 'POST', body: JSON.stringify({ Name: name, }), }) .then(data => data.json()) .then(json => { if (!json.OK) { showError(json.Error) return } this.edit(nodeID) resolve() }) .catch(err => reject(err)) }) }// }}} nodeUpdate() {// {{{ if (this.editor === null) return const btn = document.querySelector('#editor-node .controls button') btn.disabled = true const buttonPressed = Date.now() const nodeData = this.editor.data() fetch(`/nodes/update/${this.currentNode.ID}`, { method: 'POST', body: JSON.stringify(nodeData), }) .then(data => data.json()) .then(json => { if (!json.OK) { showError(json.Error) return } const timePassed = Date.now() - buttonPressed if (timePassed < 250) setTimeout(() => btn.disabled = false, 250 - timePassed) else btn.disabled = false }) }// }}} nodeDelete(nodeID) {// {{{ fetch(`/nodes/delete/${nodeID}`) .then(data => data.json()) .then(json => { if (!json.OK) { showError(json.Error) return } }) .catch(err => showError(err)) }// }}} } class NodeCreateDialog { constructor(parentNodeID) {// {{{ this.parentNodeID = parentNodeID this.dialog = null this.types = null this.select = null this.input = null this.createElements() this.fetchTypes() .then(() => { const st = new SelectType(this.types) this.select.replaceChildren(st.render()) }) this.dialog.showModal() this.select.focus() }// }}} createElements() {// {{{ this.dialog = document.createElement('dialog') this.dialog.id = 'create-type' this.dialog.innerHTML = `
` this.dialog.querySelector('button').addEventListener('click', () => this.commit()) this.select = this.dialog.querySelector('select') this.input = this.dialog.querySelector('input') this.input.addEventListener('keydown', event => { if (event.key === 'Enter') this.commit() }) document.body.appendChild(this.dialog) }// }}} commit() {// {{{ if (this.input.value.trim().length === 0) { alert('Give a name.') return } const req = { ParentNodeID: this.parentNodeID, TypeID: parseInt(this.select.value), Name: this.input.value.trim(), } fetch('/nodes/create', { method: 'POST', body: JSON.stringify(req), }) .then(data => data.json()) .then(json => { if (!json.OK) { showError(json.Error) return } mbus.dispatch('TREE_RELOAD_NODE', { parentNodeID: this.parentNodeID, callback: () => { console.log('hum foo') mbus.dispatch('NODE_SELECTED', json.NodeID) }, }) this.dialog.close() }) .catch(err => showError(err)) }// }}} async fetchTypes() {// {{{ return new Promise((resolve, reject) => { fetch('/types/') .then(data => data.json()) .then(json => { if (!json.OK) { showError(json.Error) return } this.types = json.Types resolve() }) .catch(err => reject(err)) }) }// }}} } class SelectType { constructor(types) {// {{{ this.types = types }// }}} render() {// {{{ const tmpl = document.createElement('template') this.types.sort(typeSort) let prevGroup = null for (const t of this.types) { if (t.Name == 'root_node') continue if (t.Schema['x-group'] != prevGroup) { prevGroup = t.Schema['x-group'] const group = document.createElement('optgroup') group.setAttribute('label', t.Schema['x-group']) tmpl.content.appendChild(group) } const opt = document.createElement('option') opt.setAttribute('value', t.ID) opt.innerText = t.Schema.title || t.Name tmpl.content.appendChild(opt) } return tmpl.content }// }}} } export class Tree { constructor() {// {{{ this.treeNodes = new Map() const events = [ 'NODE_EXPAND', ] for (const e of events) mbus.subscribe(e, event => this.eventHandler(event)) this.fetchNodes(0) .then(node => { const top = document.getElementById('nodes') const topNode = new TreeNode(node) this.treeNodes.set(node.ID, topNode) top.appendChild(topNode.render()) this.updateNode(0) }) .catch(err => showError(err)) }// }}} eventHandler(event) {// {{{ switch (event.type) { case 'NODE_EXPAND': this.updateNode(event.detail.node.ID) break default: alert(`Unhandled event: ${event.type}`) console.log(event) } }// }}} async fetchNodes(topNode) {// {{{ return new Promise((resolve, reject) => { fetch(`/nodes/tree/${topNode}?depth=1`) .then(data => data.json()) .then(json => { if (!json.OK) { reject(json.Error) return } resolve(json.Nodes) }) }) }// }}} updateNode(nodeID) {// {{{ return new Promise((resolve, reject) => { // updateNode retrieves a node and its' immediate children. // Node and each child is found in the treeNodes map and the names are updated. // If not found, created and added. // // Newly created nodes are found and added, existing but renamed nodes are modified, and unchanged are left as is. this.fetchNodes(nodeID) .then(node => { const thisTreeNode = this.treeNodes.get(nodeID) thisTreeNode.childrenFetched = true thisTreeNode.node = node thisTreeNode.updateExpandImages() thisTreeNode.toggleExpand(true) // Children are sorted according to type and name. this.sortChildren(node.Children) // Update or add children for (const n of node.Children) { if (this.treeNodes.has(n.ID)) { const treenode = this.treeNodes.get(n.ID) treenode.node = n treenode.element.querySelector('.name').innerText = n.Name treenode.updateExpandImages() } else { const treenode = new TreeNode(n) this.treeNodes.set(n.ID, treenode) thisTreeNode.children.appendChild(treenode.render()) } } resolve() }) .catch(err => reject(err)) }) }// }}} sortChildren(children) {// {{{ children.sort((a, b) => { if (a.TypeName < b.TypeName) return -1 if (a.TypeName > b.TypeName) return 1 if (a.Name < b.Name) return -1 if (a.Name > b.Name) return 1 return 0 }) }// }}} } export class TreeNode { constructor(data) {// {{{ this.node = data this.childrenFetched = false this.element = null this.children = null this.expandEventListenerAdded = false }// }}} render() {// {{{ const nodeHTML = `
${this.name()}
` const div = document.createElement('div') div.classList.add('node') div.setAttribute('data-node-id', this.node.ID) div.innerHTML = nodeHTML this.children = div.querySelector('.children') this.expandImg = div.querySelector('.expand-status img') div.querySelector('.name').addEventListener('click', () => mbus.dispatch('NODE_SELECTED', this.node.ID)) // data.NumChildren is set regardless of having fetched the children or not. this.expandStatus = div.querySelector('.expand-status img') this.updateExpandImages() if (this.node.TypeIcon) { const img = div.querySelector('.type-icon img') img.setAttribute('src', `/images/${_VERSION}/node_modules/@mdi/svg/svg/${this.node.TypeIcon}.svg`) } this.element = div return div }// }}} name() {// {{{ if (this.node.TypeName === 'root_node') return 'Start' return this.node.Name }// }}} hasChildren() {// {{{ return this.node.NumChildren > 0 }// }}} updateExpandImages() {// {{{ if (this.hasChildren()) { this.expandStatus.setAttribute('src', `/images/${_VERSION}/node_modules/@mdi/svg/svg/plus-box-outline.svg`) if (!this.expandEventListenerAdded) { this.expandStatus.addEventListener('click', () => this.toggleExpand()) this.expandEventListenerAdded = true } } else { this.expandStatus.setAttribute('src', `/images/${_VERSION}/node_modules/@mdi/svg/svg/circle-medium.svg`) } }// }}} toggleExpand(expanded) {// {{{ const node = this.element if (expanded === undefined) node?.classList.toggle('expanded') else if (expanded === true) node?.classList.add('expanded') else node?.classList.remove('expanded') const img = node?.classList.contains('expanded') ? 'minus-box-outline' : 'plus-box-outline' this.expandStatus.setAttribute('src', `/images/${_VERSION}/node_modules/@mdi/svg/svg/${img}.svg`) if (!this.childrenFetched && this.node.NumChildren > 0 && this.node.Children.length == 0) { mbus.dispatch('NODE_EXPAND', this) } }// }}} async fetchChildren() {// {{{ return new Promise((resolve, reject) => { fetch(`/nodes/tree/${this.node.ID}?depth=1`) .then(data => data.json()) .then(json => { if (json.OK) resolve(json.Nodes) else reject(json.Error) }) .catch(err => reject(err)) }) }// }}} } export class TypesList { constructur() {// {{{ this.types = [] }// }}} async fetchTypes() {// {{{ return new Promise((resolve, reject) => { fetch('/types/') .then(data => data.json()) .then(json => { if (!json.OK) { showError(json.Error) return } this.types = json.Types resolve() }) .catch(err => reject(err)) }) }// }}} render() {// {{{ const div = document.createElement('div') this.types.sort(typeSort) let prevGroup = null for (const t of this.types) { if (t.Name == 'root_node') continue if (t.Schema['x-group'] != prevGroup) { prevGroup = t.Schema['x-group'] const group = document.createElement('div') group.classList.add('group') group.innerText = t.Schema['x-group'] div.appendChild(group) } const tDiv = document.createElement('div') tDiv.classList.add('type') tDiv.innerHTML = `
${t.Schema.title || t.Name}
` div.appendChild(tDiv) } return div }// }}} } function typeSort(a, b) {// {{{ if (a.Schema['x-group'] === undefined) a.Schema['x-group'] = 'No group' if (b.Schema['x-group'] === undefined) b.Schema['x-group'] = 'No group' if (a.Schema['x-group'] < b.Schema['x-group']) return -1 if (a.Schema['x-group'] > b.Schema['x-group']) return 1 if ((a.Schema.title || a.Name) < (b.Schema.title || b.Name)) return -1 if ((a.Schema.title || a.Name) > (b.Schema.title || b.Name)) return 1 return 0 }// }}} // vim: foldmethod=marker