diff --git a/datagraph b/datagraph index 659ce64..722460a 100755 Binary files a/datagraph and b/datagraph differ diff --git a/node.go b/node.go index 143a03e..199bb4d 100644 --- a/node.go +++ b/node.go @@ -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 diff --git a/static/css/main.css b/static/css/main.css index 9bf8c42..6127c2a 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -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; } diff --git a/static/js/app.mjs b/static/js/app.mjs index 2fe5c01..284d7ce 100644 --- a/static/js/app.mjs +++ b/static/js/app.mjs @@ -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') diff --git a/static/less/main.less b/static/less/main.less index f1c8f3c..c92a258 100644 --- a/static/less/main.less +++ b/static/less/main.less @@ -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 { diff --git a/views/pages/app.gotmpl b/views/pages/app.gotmpl index 6542098..1cf433b 100644 --- a/views/pages/app.gotmpl +++ b/views/pages/app.gotmpl @@ -30,6 +30,7 @@
+
diff --git a/webserver.go b/webserver.go index f6992bf..bdcce9f 100644 --- a/webserver.go +++ b/webserver.go @@ -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")