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 = `
-
- `
-
- 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
}