620 lines
15 KiB
JavaScript
620 lines
15 KiB
JavaScript
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 = `
|
|
<div style="padding: 16px">
|
|
<select></select>
|
|
<input type="text" placeholder="Name">
|
|
<button>Create</button>
|
|
</div>
|
|
`
|
|
|
|
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 = `
|
|
<div class="expand-status"><img /></div>
|
|
<div class="type-icon"><img /></div>
|
|
<div class="name">${this.name()}</div>
|
|
<div class="children"></div>
|
|
`
|
|
|
|
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 = `
|
|
<div class="img"><img src="/images/${_VERSION}/node_modules/@mdi/svg/svg/file-document-check-outline.svg" /></div>
|
|
<div class="title">${t.Schema.title || t.Name}</div>
|
|
`
|
|
|
|
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
|