This commit is contained in:
Magnus Åhall 2025-07-05 07:50:59 +02:00
parent a6bb845c9d
commit cfd5bfd719
9 changed files with 213 additions and 111 deletions

BIN
datagraph

Binary file not shown.

79
node.go
View file

@ -4,9 +4,11 @@ import (
// External
werr "git.gibonuddevalla.se/go/wrappederror"
"github.com/jmoiron/sqlx"
"github.com/lib/pq"
// Standard
"encoding/json"
"errors"
"time"
)
@ -30,10 +32,11 @@ type Node struct {
Children []*Node
}
func GetNode(nodeID int) (node Node, err error) {
func GetNode(nodeID int) (node Node, err error) { // {{{
row := db.QueryRowx(`
SELECT
n.id,
COALESCE(n.parent_id, -1) AS parent_id,
n.name,
n.updated,
n.data AS data_raw,
@ -66,9 +69,9 @@ func GetNode(nodeID int) (node Node, err error) {
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)
@ -96,47 +99,42 @@ 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
COALESCE(
(SELECT parent FROM connection WHERE child = $1),
0
) AS parent_id,
$1::int AS id,
0 AS depth
UNION
SELECT
c.parent,
c.child,
n.id,
ns.depth+1 AS depth
FROM connection c
INNER JOIN nodes ns ON ns.depth < $2 AND c.parent = ns.id
FROM node n
INNER JOIN nodes ns ON ns.depth < $2 AND n.parent_id = ns.id
)
SEARCH DEPTH FIRST BY id SET ordercol
SELECT
ns.parent_id,
COALESCE(n.parent_id, -1) AS parent_id,
n.id,
n.name,
CONCAT(REPEAT(' ', ns.depth), n.name) AS name,
n.type_id,
t.name AS type_name,
COALESCE(t.schema->>'icon', '') AS type_icon,
n.updated,
n.data AS data_raw,
COUNT(c.child) AS num_children
COUNT(node_children.id) AS num_children
FROM nodes ns
INNER JOIN public.node n ON ns.id = n.id
INNER JOIN public.type t ON n.type_id = t.id
LEFT JOIN public.connection c ON c.parent = n.id
LEFT JOIN node node_children ON node_children.parent_id = n.id
GROUP BY
ns.depth,
ns.parent_id,
n.parent_id,
n.id,
t.name,
t.schema,
@ -152,8 +150,8 @@ func GetNodeRows(startNodeID, maxDepth int) (rows *sqlx.Rows, err error) {// {{{
}
return
}// }}}
func ComposeTree(nodes map[int]*Node, node *Node) {// {{{
} // }}}
func ComposeTree(nodes map[int]*Node, node *Node) { // {{{
if node.Children == nil {
node.Children = []*Node{}
}
@ -168,18 +166,17 @@ 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) {// {{{
} // }}}
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) {// {{{
} // }}}
func CreateNode(parentNodeID, typeID int, name string) (nodeID int, err error) { // {{{
j, _ := json.Marshal(
struct {
New bool `json:"x-new"`
@ -188,22 +185,28 @@ func CreateNode(parentNodeID, typeID int, name string) (err error) {// {{{
})
row := db.QueryRow(`
INSERT INTO node(type_id, name, data)
VALUES($1, $2, $3::jsonb)
INSERT INTO node(parent_id, type_id, name, data)
VALUES($1, $2, $3, $4::jsonb)
RETURNING id
`,
typeID, name, j)
var id int
err = row.Scan(&id)
parentNodeID, typeID, name, j)
err = row.Scan(&nodeID)
if err != nil {
err = werr.Wrap(err)
return
}
_, err = db.Exec(`INSERT INTO connection("parent", "child") VALUES($1, $2)`, parentNodeID, id)
return
}// }}}
} // }}}
func DeleteNode(nodeID int) (err error) { // {{{
_, err = db.Exec(`DELETE FROM node WHERE id=$1`, nodeID)
if err != nil {
pqErr, ok := err.(*pq.Error)
if ok && pqErr.Code == "23503" {
err = errors.New("Can't delete a node with children.")
return
}
}
return
} // }}}
// vim: foldmethod=marker

6
sql/0003.sql Normal file
View file

@ -0,0 +1,6 @@
ALTER TABLE public.node ADD parent_node_id int4 DEFAULT 0 NULL;
UPDATE node
SET parent_node_id = (
SELECT parent FROM connection WHERE child = id
) WHERE id > 0;

1
sql/0004.sql Normal file
View file

@ -0,0 +1 @@
ALTER TABLE public.node RENAME COLUMN parent_node_id TO parent_id;

View file

@ -73,7 +73,7 @@ body {
}
#editor-node > div.ops {
display: grid;
grid-template-columns: min-content 1fr min-content;
grid-template-columns: min-content 1fr min-content min-content;
align-items: center;
grid-gap: 8px;
}
@ -115,15 +115,13 @@ body {
display: grid;
grid-template-columns: min-content min-content 100%;
white-space: nowrap;
user-select: none;
}
.node img {
height: 24px;
}
.node.selected > .name {
color: #a02c2c;
}
.node.selected > .type-icon {
filter: invert(0.7) sepia(0.5) hue-rotate(0deg) saturate(750%) brightness(0.85) !important;
font-weight: bold;
}
.node.expanded > .children {
display: block;

View file

@ -13,12 +13,14 @@ export class App {
this.tree = new Tree(document.getElementById('nodes'))
const events = [
'MENU_ITEM_SELECTED',
'NODE_SELECTED',
'EDITOR_NODE_SAVE',
'TYPES_LIST_FETCHED',
'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))
@ -45,6 +47,12 @@ export class App {
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
@ -65,7 +73,18 @@ export class App {
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:
@ -77,6 +96,10 @@ export class App {
let handled = true
switch (event.key.toUpperCase()) {
case 'D':
mbus.dispatch('NODE_DELETE')
break
case 'N':
if (!event.shiftKey || !event.altKey)
return
@ -145,30 +168,38 @@ export class App {
// 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'
})
}// }}}
nodeRename(nodeID, name) {// {{{
name = name.trim()
if (name.length === 0) {
alert('A name must be provided.')
return
}
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)
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)
@ -198,6 +229,17 @@ export class App {
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 {
@ -226,10 +268,11 @@ class NodeCreateDialog {
<div style="padding: 16px">
<select></select>
<input type="text" placeholder="Name">
<button onclick="this.commit()">Create</button>
<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 => {
@ -261,6 +304,13 @@ class NodeCreateDialog {
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))
@ -359,34 +409,41 @@ export class Tree {
})
}// }}}
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
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)
// 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())
// 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 => showError(err))
})
.catch(err => reject(err))
})
}// }}}
sortChildren(children) {// {{{
children.sort((a, b) => {
@ -407,6 +464,7 @@ export class TreeNode {
this.childrenFetched = false
this.element = null
this.children = null
this.expandEventListenerAdded = false
}// }}}
render() {// {{{
@ -423,16 +481,13 @@ export class TreeNode {
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.
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')
this.expandStatus = div.querySelector('.expand-status img')
this.updateExpandImages()
if (this.node.TypeIcon) {
const img = div.querySelector('.type-icon img')
@ -450,15 +505,32 @@ export class TreeNode {
hasChildren() {// {{{
return this.node.NumChildren > 0
}// }}}
toggleExpand(event) {// {{{
const node = event.target.closest('.node')
node?.classList.toggle('expanded')
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'
event.target.setAttribute('src', `/images/${_VERSION}/node_modules/@mdi/svg/svg/${img}.svg`)
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) {
console.log(`fetching for ${this.node.Name}`)
mbus.dispatch('NODE_EXPAND', this)
}
}// }}}

View file

@ -99,7 +99,7 @@ body {
& > div.ops {
display: grid;
grid-template-columns: min-content 1fr min-content;
grid-template-columns: min-content 1fr min-content min-content;
align-items: center;
grid-gap: 8px;
@ -153,6 +153,7 @@ body {
display: grid;
grid-template-columns: min-content min-content 100%;
white-space: nowrap;
user-select: none;
img {
height: 24px;
@ -160,11 +161,7 @@ body {
&.selected {
& > .name {
color: #a02c2c;
}
& > .type-icon {
filter: invert(.7) sepia(.5) hue-rotate(0deg) saturate(750%) brightness(0.85) !important;
font-weight: bold;
}
}

View file

@ -24,11 +24,12 @@
</div>
<div class="page section" id="nodes"></div>
<div class="page" id="editor-node">
<div class="page" id="editor-node" style="display: none">
<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" />
<img onclick="mbus.dispatch('NODE_DELETE')" src="/images/{{ .VERSION }}/node_modules/@mdi/svg/svg/trash-can.svg" style="display: block; height: 32px" />
</div>
<div class="section">

View file

@ -37,6 +37,7 @@ func initWebserver() (err error) {
http.HandleFunc("/nodes/{nodeID}", actionNode)
http.HandleFunc("/nodes/update/{nodeID}", actionNodeUpdate)
http.HandleFunc("/nodes/rename/{nodeID}", actionNodeRename)
http.HandleFunc("/nodes/delete/{nodeID}", actionNodeDelete)
http.HandleFunc("/nodes/create", actionNodeCreate)
http.HandleFunc("/types/{typeID}", actionType)
http.HandleFunc("/types/", actionTypesAll)
@ -163,7 +164,7 @@ func actionNodeRename(w http.ResponseWriter, r *http.Request) { // {{{
nodeID, _ = strconv.Atoi(nodeIDStr)
data, _ := io.ReadAll(r.Body)
var req struct { Name string }
var req struct{ Name string }
err := json.Unmarshal(data, &req)
err = RenameNode(nodeID, req.Name)
@ -195,7 +196,30 @@ func actionNodeCreate(w http.ResponseWriter, r *http.Request) { // {{{
return
}
err = CreateNode(req.ParentNodeID, req.TypeID, req.Name)
var nodeID int
nodeID, err = CreateNode(req.ParentNodeID, req.TypeID, req.Name)
if err != nil {
err = werr.Wrap(err)
httpError(w, err)
return
}
out := struct {
OK bool
NodeID int
}{
true,
nodeID,
}
j, _ := json.Marshal(out)
w.Write(j)
} // }}}
func actionNodeDelete(w http.ResponseWriter, r *http.Request) { // {{{
nodeID := 0
nodeIDStr := r.PathValue("nodeID")
nodeID, _ = strconv.Atoi(nodeIDStr)
err := DeleteNode(nodeID)
if err != nil {
err = werr.Wrap(err)
httpError(w, err)