datagraph/static/js/app.mjs
2025-07-07 20:29:15 +02:00

730 lines
18 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_MOVE',
'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')
if (event.detail !== null)
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 'NODE_MOVE':
const nodes = this.tree.markedNodes()
if (!confirm(`Are you sure you want to move ${nodes.length} nodes here?`))
return
this.nodesMove(nodes, 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(parseInt(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) {// {{{
if (!event.shiftKey || !event.altKey)
return
let handled = true
switch (event.key.toUpperCase()) {
case 'D':
mbus.dispatch('NODE_DELETE')
break
case 'M':
mbus.dispatch('NODE_MOVE')
break
case 'N':
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) {// {{{
if (nodeID === null) {
document.getElementById('editor-node').style.display = 'none'
this.currentNode = null
return
}
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) {// {{{
const node = this.tree.treeNodes.get(nodeID)
const parentID = node.node.ParentID
fetch(`/nodes/delete/${nodeID}`)
.then(data => data.json())
.then(json => {
if (!json.OK) {
showError(json.Error)
return
}
this.tree.updateNode(parseInt(parentID))
mbus.dispatch('NODE_SELECTED', null)
})
.catch(err => showError(err))
}// }}}
nodesMove(nodes, newParentID) {// {{{
const req = {
NewParentID: parseInt(newParentID),
NodeIDs: nodes.map(n => n.ID),
}
fetch(`/nodes/move`, {
method: 'POST',
body: JSON.stringify(req),
})
.then(data => data.json())
.then(async json => {
if (!json.OK) {
showError(json.Error)
return
}
const updateParents = new Map()
for (const n of nodes)
updateParents.set(n.ParentID, true)
updateParents.set(newParentID, true)
for (const nodeID of updateParents.keys())
this.tree.updateNode(nodeID)
})
.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: () => 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))
// click on the empty tree list to unmark all nodes.
const nodesEl = document.getElementById('nodes')
nodesEl.addEventListener('click', event => {
// To prevent accidentally removing all node marks,
// shift is required to be unpressed, since it is required to
// be pressed when marking nodes.
if (event.shiftKey)
return
const markedElements = document.querySelectorAll('#nodes .node.marked')
for (const e of markedElements)
e.classList.remove('marked')
})
// Fetch the top node to start
this.fetchNodes(0)
.then(node => {
const top = document.getElementById('nodes')
const topNode = this.treeNodes.get(0)
topNode.expanded = true
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
}
// Make sure treenodes are updated with the latest fetched data.
// The parent node is processed after the children, since the render function needs to have the children already available.
const nodes = []
nodes.push(...json.Nodes.Children)
nodes.push(json.Nodes)
for (const n of nodes) {
let treenode = this.treeNodes.get(n.ID)
if (treenode === undefined) {
treenode = new TreeNode(this, n)
treenode.render()
this.treeNodes.set(n.ID, treenode)
} else {
// Since the depth is set to 1, the childrens' children array will be empty.
// If children have been fetched, these should be kept.
if (n.NumChildren > 0 && n.Children.length == 0)
n.Children = treenode.node.Children
treenode.node = n
}
}
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, true)
.then(node => {
const thisTreeNode = this.treeNodes.get(nodeID)
thisTreeNode.render()
for (const n of thisTreeNode.node.Children)
this.treeNodes.get(n.ID)?.render()
resolve()
})
.catch(err => { showError(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
})
}// }}}
markedNodes() {// {{{
const markedElements = document.querySelectorAll('#nodes .node.marked')
const marked = []
for (const n of markedElements) {
const nodeID = n.getAttribute('data-node-id')
marked.push(this.treeNodes.get(parseInt(nodeID)).node)
}
return marked
}// }}}
}
export class TreeNode {
constructor(tree, data) {// {{{
this.tree = tree
this.node = data
this.childrenFetched = false
this.element = null
this.children = null
this.nameElement = null
this.expandEventListenerAdded = false
this.expanded = this.retrieveExpanded()
if (this.expanded)
this.tree.fetchNodes(this.node.ID)
.then(()=>this.render())
}// }}}
render() {// {{{
if (this.element === null) {
this.element = document.createElement('div')
this.element.classList.add('node')
this.element.setAttribute('data-node-id', this.node.ID)
const nodeHTML = `
<div class="expand-status"><img /></div>
<div class="type-icon"><img /></div>
<div class="name"></div>
<div class="children"></div>
`
this.element.innerHTML = nodeHTML
this.nameElement = this.element.querySelector('.name')
this.children = this.element.querySelector('.children')
this.expandImg = this.element.querySelector('.expand-status img')
this.expandStatus = this.element.querySelector('.expand-status img')
this.nameElement.addEventListener('click', event => this.clickNode(event))
}
// data.NumChildren is set regardless of having fetched the children or not.
this.updateExpandImages()
if (this.node.TypeIcon) {
const img = this.element.querySelector('.type-icon img')
img.setAttribute('src', `/images/${_VERSION}/node_modules/@mdi/svg/svg/${this.node.TypeIcon}.svg`)
}
this.nameElement.innerText = this.name()
this.tree.sortChildren(this.node.Children)
const children = []
for (const c of this.node.Children)
children.push(this.tree.treeNodes.get(c.ID).element)
this.children.replaceChildren(...children)
return this.element
}// }}}
name() {// {{{
if (this.node.TypeName === 'root_node')
return 'Start'
return this.node.Name
}// }}}
clickNode(event) {// {{{
if (!event.shiftKey)
mbus.dispatch('NODE_SELECTED', this.node.ID)
else
this.element.classList.toggle('marked')
event.stopPropagation()
}// }}}
hasChildren() {// {{{
return this.node.NumChildren > 0
}// }}}
updateExpandImages() {// {{{
if (this.hasChildren()) {
if (this.expanded)
this.element.classList.add('expanded')
else
this.element.classList.remove('expanded')
this.expandStatus.setAttribute('src', `/images/${_VERSION}/node_modules/@mdi/svg/svg/${this.expanded ? 'minus-box-outline.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) {// {{{
if (expanded === undefined)
this.expanded = !this.expanded
else
this.expanded = expanded
this.storeExpanded()
this.updateExpandImages()
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))
})
}// }}}
storeExpanded() {// {{{
sessionStorage.setItem(`tree_expand_${this.node.ID}`, this.expanded ? '1' : '0')
}// }}}
retrieveExpanded() {// {{{
return sessionStorage.getItem(`tree_expand_${this.node.ID}`) === '1'
}// }}}
}
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