diff --git a/datagraph b/datagraph index 30cb21a..8ac4768 100755 Binary files a/datagraph and b/datagraph differ diff --git a/main.go b/main.go index 0f95837..f374023 100644 --- a/main.go +++ b/main.go @@ -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() diff --git a/node.go b/node.go index 2e064e5..159fcb6 100644 --- a/node.go +++ b/node.go @@ -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 diff --git a/static/css/main.css b/static/css/main.css index f00c2fc..696e03a 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -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; diff --git a/static/js/app.mjs b/static/js/app.mjs index 930ad14..63d5dad 100644 --- a/static/js/app.mjs +++ b/static/js/app.mjs @@ -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 {
- +
` @@ -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 = ` -
-
-
-
${this.name()}
-
-
- ` - - 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 = ` +
+
+
${this.name()}
+
+ ` + + 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) diff --git a/static/js/editor.mjs b/static/js/editor.mjs index 067ccb0..8bdf396 100644 --- a/static/js/editor.mjs +++ b/static/js/editor.mjs @@ -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() } diff --git a/static/less/main.less b/static/less/main.less index c3e195d..da42b00 100644 --- a/static/less/main.less +++ b/static/less/main.less @@ -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 { diff --git a/views/pages/app.gotmpl b/views/pages/app.gotmpl index 78a40e4..43011c1 100644 --- a/views/pages/app.gotmpl +++ b/views/pages/app.gotmpl @@ -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() - }) -
@@ -39,7 +25,9 @@
-
+
+ +
diff --git a/webserver.go b/webserver.go index 7ffd87b..61b284a 100644 --- a/webserver.go +++ b/webserver.go @@ -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 }