diff --git a/datagraph b/datagraph index 8ac4768..659ce64 100755 Binary files a/datagraph and b/datagraph differ diff --git a/node.go b/node.go index 159fcb6..143a03e 100644 --- a/node.go +++ b/node.go @@ -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 diff --git a/sql/0003.sql b/sql/0003.sql new file mode 100644 index 0000000..19809a9 --- /dev/null +++ b/sql/0003.sql @@ -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; diff --git a/sql/0004.sql b/sql/0004.sql new file mode 100644 index 0000000..ba107ab --- /dev/null +++ b/sql/0004.sql @@ -0,0 +1 @@ +ALTER TABLE public.node RENAME COLUMN parent_node_id TO parent_id; diff --git a/static/css/main.css b/static/css/main.css index 696e03a..9bf8c42 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; + 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; diff --git a/static/js/app.mjs b/static/js/app.mjs index 63d5dad..2fe5c01 100644 --- a/static/js/app.mjs +++ b/static/js/app.mjs @@ -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 {
- +
` + 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) } }// }}} diff --git a/static/less/main.less b/static/less/main.less index da42b00..f1c8f3c 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; + 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; } } diff --git a/views/pages/app.gotmpl b/views/pages/app.gotmpl index 43011c1..6542098 100644 --- a/views/pages/app.gotmpl +++ b/views/pages/app.gotmpl @@ -24,11 +24,12 @@
-
+