Reworked tree

This commit is contained in:
Magnus Åhall 2025-07-04 16:40:13 +02:00
parent c0255fadb8
commit a6bb845c9d
9 changed files with 314 additions and 117 deletions

BIN
datagraph

Binary file not shown.

View file

@ -1,6 +1,9 @@
package main
import (
// External
werr "git.gibonuddevalla.se/go/wrappederror"
// Standard
"embed"
"flag"
@ -41,6 +44,8 @@ func initCmdline() {
flag.Parse()
}
func main() {
werr.Init()
initLog()
initCmdline()

47
node.go
View file

@ -2,6 +2,7 @@ package main
import (
// External
werr "git.gibonuddevalla.se/go/wrappederror"
"github.com/jmoiron/sqlx"
// Standard
@ -49,26 +50,30 @@ func GetNode(nodeID int) (node Node, err error) {
err = row.StructScan(&node)
if err != nil {
err = werr.Wrap(err)
return
}
err = json.Unmarshal(node.TypeSchemaRaw, &node.TypeSchema)
if err != nil {
err = werr.Wrap(err)
return
}
err = json.Unmarshal(node.DataRaw, &node.Data)
if err != nil {
err = werr.Wrap(err)
return
}
return
}
func GetNodeTree(startNodeID, maxDepth int) (topNode *Node, err error) {
func GetNodeTree(startNodeID, maxDepth int) (topNode *Node, err error) {// {{{
nodes := make(map[int]*Node)
var rows *sqlx.Rows
rows, err = GetNodeRows(startNodeID, maxDepth)
if err != nil {
err = werr.Wrap(err)
return
}
defer rows.Close()
@ -78,6 +83,7 @@ func GetNodeTree(startNodeID, maxDepth int) (topNode *Node, err error) {
var node Node
err = rows.StructScan(&node)
if err != nil {
err = werr.Wrap(err)
return
}
@ -90,9 +96,8 @@ func GetNodeTree(startNodeID, maxDepth int) (topNode *Node, err error) {
}
return
}
func GetNodeRows(startNodeID, maxDepth int) (rows *sqlx.Rows, err error) {
}// }}}
func GetNodeRows(startNodeID, maxDepth int) (rows *sqlx.Rows, err error) {// {{{
rows, err = db.Queryx(`
WITH RECURSIVE nodes AS (
SELECT
@ -141,10 +146,14 @@ func GetNodeRows(startNodeID, maxDepth int) (rows *sqlx.Rows, err error) {
startNodeID,
maxDepth,
)
return
}
func ComposeTree(nodes map[int]*Node, node *Node) {
if err != nil {
err = werr.Wrap(err)
}
return
}// }}}
func ComposeTree(nodes map[int]*Node, node *Node) {// {{{
if node.Children == nil {
node.Children = []*Node{}
}
@ -159,15 +168,24 @@ func ComposeTree(nodes map[int]*Node, node *Node) {
}
nodes[node.ID] = node
}
}// }}}
func UpdateNode(nodeID int, data []byte) (err error) {
func UpdateNode(nodeID int, data []byte) (err error) {// {{{
_, err = db.Exec(`UPDATE public.node SET data=$2 WHERE id=$1`, nodeID, data)
return
}
}// }}}
func RenameNode(nodeID int, name string) (err error) {// {{{
_, err = db.Exec(`UPDATE node SET name=$2 WHERE id=$1`, nodeID, name)
return
}// }}}
func CreateNode(parentNodeID, typeID int, name string) (err error) {
j, _ := json.Marshal(struct { Name string }{name})
func CreateNode(parentNodeID, typeID int, name string) (err error) {// {{{
j, _ := json.Marshal(
struct {
New bool `json:"x-new"`
}{
true,
})
row := db.QueryRow(`
INSERT INTO node(type_id, name, data)
@ -179,10 +197,13 @@ func CreateNode(parentNodeID, typeID int, name string) (err error) {
var id int
err = row.Scan(&id)
if err != nil {
err = werr.Wrap(err)
return
}
_, err = db.Exec(`INSERT INTO connection("parent", "child") VALUES($1, $2)`, parentNodeID, id)
return
}
}// }}}
// vim: foldmethod=marker

View file

@ -65,8 +65,20 @@ body {
}
#editor-node {
grid-area: details;
display: grid;
grid-gap: 16px;
grid-template-rows: min-content 1fr min-content;
}
#editor-node.show {
display: grid;
}
#editor-node > div.ops {
display: grid;
grid-template-columns: min-content 1fr min-content;
align-items: center;
grid-gap: 8px;
}
#editor-node > div.ops #editor-node-name {
font-weight: bold;
}
#types {
grid-area: navigation;

View file

@ -6,9 +6,11 @@ export class App {
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 = [
'MENU_ITEM_SELECTED',
@ -16,7 +18,7 @@ export class App {
'EDITOR_NODE_SAVE',
'TYPES_LIST_FETCHED',
'NODE_CREATE_DIALOG',
'NODE_CREATE',
'NODE_EDIT_NAME',
]
for (const eventName of events)
mbus.subscribe(eventName, event => this.eventHandler(event))
@ -26,7 +28,7 @@ export class App {
mbus.dispatch('MENU_ITEM_SELECTED', 'node')
}// }}}
async eventHandler(event) {// {{{
eventHandler(event) {// {{{
switch (event.type) {
case 'MENU_ITEM_SELECTED':
const item = document.querySelector(`#menu [data-section="${event.detail}"]`)
@ -40,8 +42,7 @@ export class App {
for (const n of document.querySelectorAll(`#nodes .node[data-node-id="${event.detail}"]`))
n.classList?.add('selected')
this.currentNodeID = event.detail
this.edit(this.currentNodeID)
this.edit(event.detail)
break
case 'EDITOR_NODE_SAVE':
@ -51,18 +52,24 @@ export class App {
case 'TYPES_LIST_FETCHED':
const types = document.getElementById('types')
types.replaceChildren(this.typesList.render())
case 'NODE_CREATE_DIALOG':
if (this.currentPage !== 'node' || this.currentNodeID === null)
return
new NodeCreateDialog(this.currentNodeID)
break
case 'NODE_CREATE':
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)
break
default:
alert(`Unhandled event: ${event.type}`)
console.log(event)
}
}// }}}
@ -72,7 +79,7 @@ export class App {
switch (event.key.toUpperCase()) {
case 'N':
if (!event.shiftKey || !event.altKey)
break
return
mbus.dispatch('NODE_CREATE_DIALOG')
break
@ -114,8 +121,8 @@ export class App {
break
}
}// }}}
edit(nodeID) {// {{{
console.log(nodeID)
fetch(`/nodes/${nodeID}`)
.then(data => data.json())
.then(json => {
@ -124,10 +131,43 @@ export class App {
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)
editorEl.replaceChildren(this.editor.render(json.Node.Data))
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
})
}// }}}
nodeRename(nodeID, name) {// {{{
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)
})
}// }}}
nodeUpdate() {// {{{
@ -140,7 +180,7 @@ export class App {
const nodeData = this.editor.data()
fetch(`/nodes/update/${this.currentNodeID}`, {
fetch(`/nodes/update/${this.currentNode.ID}`, {
method: 'POST',
body: JSON.stringify(nodeData),
})
@ -186,7 +226,7 @@ class NodeCreateDialog {
<div style="padding: 16px">
<select></select>
<input type="text" placeholder="Name">
<button onclick="mbus.dispatch('NODE_CREATE', ()=>this.commit())">Create</button>
<button onclick="this.commit()">Create</button>
</div>
`
@ -273,61 +313,83 @@ class SelectType {
}// }}}
}
export class TreeNode {
constructor(parent, data) {// {{{
this.data = data
this.parent = parent
this.childrenFetched = false
this.children = null
export class Tree {
constructor() {// {{{
this.treeNodes = new Map()
this.sortChildren()
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
render() {// {{{
const nodeHTML = `
<div class="node" data-node-id="${this.data.ID}">
<div class="expand-status"><img /></div>
<div class="type-icon"><img /></div>
<div class="name">${this.name()}</div>
<div class="children"></div>
</div>
`
const tmpl = document.createElement('template')
tmpl.innerHTML = nodeHTML
this.children = tmpl.content.querySelector('.children')
tmpl.content.querySelector('.name').addEventListener('click', () => mbus.dispatch('NODE_SELECTED', this.data.ID))
// data.NumChildren is set regardless of having fetched the children or not.
if (this.hasChildren()) {
const img = tmpl.content.querySelector('.expand-status img')
img.setAttribute('src', `/images/${_VERSION}/node_modules/@mdi/svg/svg/plus-box-outline.svg`)
img.addEventListener('click', event => this.toggleExpand(event))
} else
tmpl.content.querySelector('.expand-status').classList.add('leaf')
if (this.data.TypeIcon) {
const img = tmpl.content.querySelector('.type-icon img')
img.setAttribute('src', `/images/${_VERSION}/node_modules/@mdi/svg/svg/${this.data.TypeIcon}.svg`)
}
this.parent.appendChild(tmpl.content)
for (const c of this.data.Children || []) {
(new TreeNode(this.children, c)).render()
default:
alert(`Unhandled event: ${event.type}`)
console.log(event)
}
}// }}}
name() {// {{{
if (this.data.TypeName === 'root_node')
return 'Start'
return this.data.Name
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)
})
})
}// }}}
hasChildren() {// {{{
return this.data.NumChildren > 0
updateNode(nodeID) {// {{{
// 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
// 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
} else {
const treenode = new TreeNode(n)
this.treeNodes.set(n.ID, treenode)
thisTreeNode.children.appendChild(treenode.render())
}
}
})
.catch(err => showError(err))
}// }}}
sortChildren() {// {{{
this.data.Children.sort((a, b) => {
sortChildren(children) {// {{{
children.sort((a, b) => {
if (a.TypeName < b.TypeName) return -1
if (a.TypeName > b.TypeName) return 1
@ -337,7 +399,57 @@ export class TreeNode {
return 0
})
}// }}}
}
export class TreeNode {
constructor(data) {// {{{
this.node = data
this.childrenFetched = false
this.element = null
this.children = null
}// }}}
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')
div.querySelector('.name').addEventListener('click', () => mbus.dispatch('NODE_SELECTED', this.node.ID))
// data.NumChildren is set regardless of having fetched the children or not.
if (this.hasChildren()) {
const img = div.querySelector('.expand-status img')
img.setAttribute('src', `/images/${_VERSION}/node_modules/@mdi/svg/svg/plus-box-outline.svg`)
img.addEventListener('click', event => this.toggleExpand(event))
} else
div.querySelector('.expand-status').classList.add('leaf')
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
}// }}}
toggleExpand(event) {// {{{
const node = event.target.closest('.node')
node?.classList.toggle('expanded')
@ -345,27 +457,14 @@ export class TreeNode {
const img = node?.classList.contains('expanded') ? 'minus-box-outline' : 'plus-box-outline'
event.target.setAttribute('src', `/images/${_VERSION}/node_modules/@mdi/svg/svg/${img}.svg`)
if (!this.childrenFetched && this.data.NumChildren > 0 && this.data.Children.length == 0) {
this.fetchChildren()
.then(data => {
this.childrenFetched = true
this.data.Children = data.Children
this.sortChildren()
for (const nodeData of this.data.Children) {
const node = new TreeNode(this.children, nodeData)
node.render()
}
})
.catch(err => {
alert(err)
console.error(err)
})
if (!this.childrenFetched && this.node.NumChildren > 0 && this.node.Children.length == 0) {
console.log(`fetching for ${this.node.Name}`)
mbus.dispatch('NODE_EXPAND', this)
}
}// }}}
async fetchChildren() {// {{{
return new Promise((resolve, reject) => {
fetch(`/nodes/tree/${this.data.ID}?depth=2`)
fetch(`/nodes/tree/${this.node.ID}?depth=1`)
.then(data => data.json())
.then(json => {
if (json.OK)

View file

@ -5,22 +5,39 @@ export class Editor {
}
render(data) {
const div = document.createElement('div')
this.editor = new JSONEditor(div, {
const options = {
theme: 'spectre',
iconlib: 'spectre',
disable_collapse: true,
disable_properties: true,
schema: this.schema,
});
}
this.editor.on('ready', () => {
this.editor.setValue(data)
})
// startval isn't set if this is a newly created node.
// When setValue is called (or startval set), all widgets/fields are hidden if not defined in the JSON data.
// When startval isn't set, the schema properties are displayed instead.
if (data !== undefined && data !== null)
options.startval = data
const div = document.createElement('div')
this.editor = new JSONEditor(div, options);
// this.editor.on('ready', ()=>{
// })
div.addEventListener('keydown', event=>this.keyHandler(event))
return div
}
keyHandler(event) {
if (!event.ctrlKey || event.key != 's')
return
mbus.dispatch('EDITOR_NODE_SAVE')
event.stopPropagation()
event.preventDefault()
}
data() {
return this.editor.getValue()
}

View file

@ -86,8 +86,27 @@ body {
#editor-node {
grid-area: details;
display: grid;
grid-gap: 16px;
grid-template-rows:
min-content
1fr
min-content
;
&.show {
display: grid;
}
& > div.ops {
display: grid;
grid-template-columns: min-content 1fr min-content;
align-items: center;
grid-gap: 8px;
#editor-node-name {
font-weight: bold;
}
}
}
#types {

View file

@ -14,20 +14,6 @@
import {App, TreeNode} from '/js/{{ .VERSION }}/app.mjs'
window._VERSION = '{{ .VERSION }}'
window._app = new App()
fetch('/nodes/tree/0?depth=2')
.then(data => data.json())
.then(json => {
if (!json.OK) {
showError(json.Error)
return
}
const top = document.getElementById('nodes')
const topNode = new TreeNode(top, json.Nodes)
topNode.render()
})
</script>
<div id="layout">
@ -39,7 +25,9 @@
<div class="page section" id="nodes"></div>
<div class="page" id="editor-node">
<div class="section">
<div class="section ops">
<img onclick="mbus.dispatch('NODE_EDIT_NAME')" src="/images/{{ .VERSION }}/node_modules/@mdi/svg/svg/tag-text-outline.svg" style="display: block; height: 32px" />
<div onclick="mbus.dispatch('NODE_EDIT_NAME')" id="editor-node-name"></div>
<img onclick="mbus.dispatch('NODE_CREATE_DIALOG')" src="/images/{{ .VERSION }}/node_modules/@mdi/svg/svg/plus-box.svg" style="display: block; height: 32px" />
</div>

View file

@ -3,6 +3,7 @@ package main
import (
// External
"git.ahall.se/go/html_template"
werr "git.gibonuddevalla.se/go/wrappederror"
// Standard
"encoding/json"
@ -26,6 +27,7 @@ func initWebserver() (err error) {
engine, err = HTMLTemplate.NewEngine(subViewFS, subStaticFS, flagDev)
if err != nil {
err = werr.Wrap(err)
return
}
@ -34,6 +36,7 @@ func initWebserver() (err error) {
http.HandleFunc("/nodes/tree/{startNode}", actionNodesTree)
http.HandleFunc("/nodes/{nodeID}", actionNode)
http.HandleFunc("/nodes/update/{nodeID}", actionNodeUpdate)
http.HandleFunc("/nodes/rename/{nodeID}", actionNodeRename)
http.HandleFunc("/nodes/create", actionNodeCreate)
http.HandleFunc("/types/{typeID}", actionType)
http.HandleFunc("/types/", actionTypesAll)
@ -66,6 +69,7 @@ func pageApp(w http.ResponseWriter, r *http.Request) { // {{{
ts, err := GetTypes()
if err != nil {
err = werr.Wrap(err)
httpError(w, err)
return
}
@ -74,6 +78,7 @@ func pageApp(w http.ResponseWriter, r *http.Request) { // {{{
err = engine.Render(page, w, r)
if err != nil {
err = werr.Wrap(err)
w.Write([]byte(err.Error()))
}
} // }}}
@ -92,6 +97,7 @@ func actionNodesTree(w http.ResponseWriter, r *http.Request) { // {{{
topNode, err := GetNodeTree(startNode, maxDepth)
if err != nil {
err = werr.Wrap(err)
httpError(w, err)
return
}
@ -114,6 +120,7 @@ func actionNode(w http.ResponseWriter, r *http.Request) { // {{{
node, err := GetNode(nodeID)
if err != nil {
err = werr.Wrap(err)
httpError(w, err)
return
}
@ -137,6 +144,31 @@ func actionNodeUpdate(w http.ResponseWriter, r *http.Request) { // {{{
err := UpdateNode(nodeID, data)
if err != nil {
err = werr.Wrap(err)
httpError(w, err)
return
}
out := struct {
OK bool
}{
true,
}
j, _ := json.Marshal(out)
w.Write(j)
} // }}}
func actionNodeRename(w http.ResponseWriter, r *http.Request) { // {{{
nodeID := 0
nodeIDStr := r.PathValue("nodeID")
nodeID, _ = strconv.Atoi(nodeIDStr)
data, _ := io.ReadAll(r.Body)
var req struct { Name string }
err := json.Unmarshal(data, &req)
err = RenameNode(nodeID, req.Name)
if err != nil {
err = werr.Wrap(err)
httpError(w, err)
return
}
@ -158,12 +190,14 @@ func actionNodeCreate(w http.ResponseWriter, r *http.Request) { // {{{
data, _ := io.ReadAll(r.Body)
err := json.Unmarshal(data, &req)
if err != nil {
err = werr.Wrap(err)
httpError(w, err)
return
}
err = CreateNode(req.ParentNodeID, req.TypeID, req.Name)
if err != nil {
err = werr.Wrap(err)
httpError(w, err)
return
}
@ -183,6 +217,7 @@ func actionType(w http.ResponseWriter, r *http.Request) { // {{{
typ, err := GetType(typeID)
if err != nil {
err = werr.Wrap(err)
httpError(w, err)
return
}
@ -193,6 +228,7 @@ func actionType(w http.ResponseWriter, r *http.Request) { // {{{
func actionTypesAll(w http.ResponseWriter, r *http.Request) { // {{{
types, err := GetTypes()
if err != nil {
err = werr.Wrap(err)
httpError(w, err)
return
}