Moveing of objects

This commit is contained in:
Magnus Åhall 2025-07-05 09:57:20 +02:00
parent cfd5bfd719
commit 25bbc0c748
7 changed files with 167 additions and 16 deletions

BIN
datagraph

Binary file not shown.

25
node.go
View file

@ -9,6 +9,9 @@ import (
// Standard
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
)
@ -120,7 +123,7 @@ func GetNodeRows(startNodeID, maxDepth int) (rows *sqlx.Rows, err error) { // {{
SELECT
COALESCE(n.parent_id, -1) AS parent_id,
n.id,
CONCAT(REPEAT(' ', ns.depth), n.name) AS name,
n.name,
n.type_id,
t.name AS type_name,
COALESCE(t.schema->>'icon', '') AS type_icon,
@ -208,5 +211,25 @@ func DeleteNode(nodeID int) (err error) { // {{{
}
return
} // }}}
func MoveNodes(newParentID int, nodeIDs []int) (err error) {
// TODO - implement a method to verify that a node isn't moved underneath itself.
// Preferably using a stored procedure?
var nodeIDStr []string
for _, n := range nodeIDs {
nodeIDStr = append(nodeIDStr, strconv.Itoa(n))
}
joinedIDs := strings.Join(nodeIDStr, ",")
sql := fmt.Sprintf(
`UPDATE node
SET parent_id=$1
WHERE id IN (%s)`,
joinedIDs,
)
_, err = db.Exec(sql, newParentID)
return
}
// vim: foldmethod=marker

View file

@ -73,7 +73,7 @@ body {
}
#editor-node > div.ops {
display: grid;
grid-template-columns: min-content 1fr min-content min-content;
grid-template-columns: min-content 1fr repeat(3, min-content);
align-items: center;
grid-gap: 8px;
}
@ -123,6 +123,9 @@ body {
.node.selected > .name {
font-weight: bold;
}
.node.marked > .name {
color: #a00;
}
.node.expanded > .children {
display: block;
}

View file

@ -18,6 +18,8 @@ export class App {
'NODE_CREATE_DIALOG',
'NODE_DELETE',
'NODE_EDIT_NAME',
'NODE_MOVE',
'NODE_REMOVED',
'NODE_SELECTED',
'TREE_RELOAD_NODE',
'TYPES_LIST_FETCHED',
@ -41,8 +43,9 @@ export class App {
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')
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
@ -53,6 +56,20 @@ export class App {
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 'NODE_REMOVED':
// Event dispatched when a tree node is removed after an update.
if (this.currentNode.ID !== event.detail)
return
mbus.dispatch('NODE_SELECTED', null)
break
case 'EDITOR_NODE_SAVE':
this.nodeUpdate()
break
@ -79,12 +96,12 @@ export class App {
break
case 'TREE_RELOAD_NODE':
this.tree.updateNode(event.detail.parentNodeID)
this.tree.updateNode(parseInt(event.detail.parentNodeID))
.then(() => {
if (event.detail.callback)
event.detail.callback()
.catch(err => showError(err))
})
.catch(err => showError(err))
break
default:
@ -93,16 +110,20 @@ export class App {
}
}// }}}
keyHandler(event) {// {{{
let handled = true
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':
if (!event.shiftKey || !event.altKey)
return
mbus.dispatch('NODE_CREATE_DIALOG')
break
@ -146,6 +167,12 @@ export class App {
}// }}}
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 => {
@ -240,6 +267,29 @@ export class App {
})
.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(json => {
if (!json.OK) {
showError(json.Error)
return
}
const newParentElement = this.tree.treeNodes.get(newParentID).children
for (const n of nodes)
newParentElement.append(this.tree.treeNodes.get(n.ID).element)
})
.catch(err => showError(err))
}// }}}
}
class NodeCreateDialog {
@ -306,10 +356,7 @@ class NodeCreateDialog {
}
mbus.dispatch('TREE_RELOAD_NODE', {
parentNodeID: this.parentNodeID,
callback: () => {
console.log('hum foo')
mbus.dispatch('NODE_SELECTED', json.NodeID)
},
callback: () => mbus.dispatch('NODE_SELECTED', json.NodeID)
})
this.dialog.close()
})
@ -374,6 +421,21 @@ export class Tree {
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')
@ -426,6 +488,17 @@ export class Tree {
// Children are sorted according to type and name.
this.sortChildren(node.Children)
// Deleted or moved children
for (const c of thisTreeNode.children.children) {
const nodeID = parseInt(c.dataset.nodeId)
const nodeStillExist = node.Children.some(n => n.ID === nodeID)
if (!nodeStillExist) {
c.remove()
mbus.dispatch('NODE_REMOVED', nodeID)
}
}
// Update or add children
for (const n of node.Children) {
if (this.treeNodes.has(n.ID)) {
@ -439,10 +512,11 @@ export class Tree {
thisTreeNode.children.appendChild(treenode.render())
}
}
resolve()
})
.catch(err => reject(err))
.catch(err => { showError(err); reject(err) })
})
}// }}}
sortChildren(children) {// {{{
@ -456,6 +530,15 @@ export class Tree {
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 {
@ -483,7 +566,13 @@ export class TreeNode {
this.children = div.querySelector('.children')
this.expandImg = div.querySelector('.expand-status img')
div.querySelector('.name').addEventListener('click', () => mbus.dispatch('NODE_SELECTED', this.node.ID))
div.querySelector('.name').addEventListener('click', event => {
if (!event.shiftKey)
mbus.dispatch('NODE_SELECTED', this.node.ID)
else
this.element.classList.toggle('marked')
event.stopPropagation()
})
// data.NumChildren is set regardless of having fetched the children or not.
this.expandStatus = div.querySelector('.expand-status img')

View file

@ -99,7 +99,7 @@ body {
& > div.ops {
display: grid;
grid-template-columns: min-content 1fr min-content min-content;
grid-template-columns: min-content 1fr repeat(3, min-content);
align-items: center;
grid-gap: 8px;
@ -164,6 +164,12 @@ body {
font-weight: bold;
}
}
&.marked {
& > .name {
color: #a00;
}
}
&.expanded {
&>.children {

View file

@ -30,6 +30,7 @@
<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" />
<img onclick="mbus.dispatch('NODE_MOVE')" src="/images/{{ .VERSION }}/node_modules/@mdi/svg/svg/file-move-outline.svg" style="display: block; height: 32px" />
</div>
<div class="section">

View file

@ -39,6 +39,7 @@ func initWebserver() (err error) {
http.HandleFunc("/nodes/rename/{nodeID}", actionNodeRename)
http.HandleFunc("/nodes/delete/{nodeID}", actionNodeDelete)
http.HandleFunc("/nodes/create", actionNodeCreate)
http.HandleFunc("/nodes/move", actionNodeMove)
http.HandleFunc("/types/{typeID}", actionType)
http.HandleFunc("/types/", actionTypesAll)
@ -234,6 +235,34 @@ func actionNodeDelete(w http.ResponseWriter, r *http.Request) { // {{{
j, _ := json.Marshal(out)
w.Write(j)
} // }}}
func actionNodeMove(w http.ResponseWriter, r *http.Request) { // {{{
var req struct {
NewParentID int
NodeIDs []int
}
data, _ := io.ReadAll(r.Body)
err := json.Unmarshal(data, &req)
if err != nil {
err = werr.Wrap(err)
httpError(w, err)
return
}
err = MoveNodes(req.NewParentID, req.NodeIDs)
if err != nil {
err = werr.Wrap(err)
httpError(w, err)
return
}
out := struct {
OK bool
}{
true,
}
j, _ := json.Marshal(out)
w.Write(j)
} // }}}
func actionType(w http.ResponseWriter, r *http.Request) { // {{{
typeID := 0
typeIDStr := r.PathValue("typeID")