From ca0659a3684ad0928e47763df64a545736bcd14b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Wed, 9 Jul 2025 19:30:54 +0200 Subject: [PATCH] Connected nodes --- node.go | 213 +++++++++++++++++++++---------- sql/0006.sql | 2 + sql/0007.sql | 1 + sql/0008.sql | 1 + static/css/main.css | 24 ++++ static/js/app.mjs | 52 ++++---- static/js/select_node.mjs | 256 ++++++++++++++++++++++++++++++++++++++ static/less/main.less | 29 +++++ webserver.go | 92 +++++++++++++- 9 files changed, 581 insertions(+), 89 deletions(-) create mode 100644 sql/0006.sql create mode 100644 sql/0007.sql create mode 100644 sql/0008.sql create mode 100644 static/js/select_node.mjs diff --git a/node.go b/node.go index e121410..46e2073 100644 --- a/node.go +++ b/node.go @@ -27,50 +27,86 @@ type Node struct { TypeSchemaRaw []byte `db:"type_schema_raw" json:"-"` TypeIcon string `db:"type_icon"` + ConnectionID int + ConnectionData any + Updated time.Time Data any DataRaw []byte `db:"data_raw" json:"-"` NumChildren int `db:"num_children"` Children []*Node + + ConnectedNodes []Node } 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, + to_json(res) AS node + FROM ( + SELECT + n.id, + COALESCE(n.parent_id, -1) AS ParentID, + n.name, + n.updated, + n.data AS data, - t.id AS type_id, - t.name AS type_name, - t.schema AS type_schema_raw, - t.schema->>'icon' AS type_icon - FROM public.node n - INNER JOIN public.type t ON n.type_id = t.id - WHERE - n.id = $1 - `, nodeID) + t.id AS TypeID, + t.name AS TypeName, + t.schema AS TypeSchema, + t.schema->>'icon' AS TypeIcon, - err = row.StructScan(&node) + COALESCE( + ( + SELECT jsonb_agg(res) + FROM ( + SELECT + nn.id, + COALESCE(nn.parent_id, -1) AS ParentID, + nn.name, + nn.updated, + nn.data AS data, + + tt.id AS TypeID, + tt.name AS TypeName, + tt.schema AS TypeSchema, + tt.schema->>'icon' AS TypeIcon, + + c.id AS ConnectionID, + c.data AS ConnectionData + FROM connection c + INNER JOIN public.node nn ON c.child_node_id = nn.id + INNER JOIN public.type tt ON nn.type_id = tt.id + WHERE + c.parent_node_id = n.id + ) AS res + ) + , '[]'::jsonb + ) AS ConnectedNodes + + FROM public.node n + INNER JOIN public.type t ON n.type_id = t.id + WHERE + n.id = $1 + ) res + `, + nodeID, + ) + + var body []byte + err = row.Scan(&body) if err != nil { err = werr.Wrap(err) return } - err = json.Unmarshal(node.TypeSchemaRaw, &node.TypeSchema) + err = json.Unmarshal(body, &node) if err != nil { err = werr.Wrap(err) return } - err = json.Unmarshal(node.DataRaw, &node.Data) - if err != nil { - err = werr.Wrap(err) - return - } return } // }}} @@ -243,10 +279,10 @@ func GetNodeConnections(nodeID int) (connections []Node, err error) { // {{{ t.schema AS type_schema_raw, t.schema->>'icon' AS type_icon FROM public.connection c - INNER JOIN public.node n ON c.to = n.id + INNER JOIN public.node n ON c.child_node_id = n.id INNER JOIN public.type t ON n.type_id = t.id WHERE - c.from = $1 + c.parent_node_id = $1 `, nodeID, ) @@ -275,66 +311,115 @@ func GetNodeConnections(nodeID int) (connections []Node, err error) { // {{{ return } // }}} +func ConnectNode(parentID, childID int) (err error) { // {{{ + _, err = db.Exec(`INSERT INTO public.connection(parent_node_id, child_node_id) VALUES($1, $2)`, parentID, childID) + return +} // }}} func SearchNodes(typeID int, search string, maxResults int) (nodes []Node, err error) { // {{{ nodes = []Node{} - var rows *sqlx.Rows - rows, err = db.Queryx(` + row := db.QueryRowx(` SELECT - n.id, - n.name, - n.updated, - n.data, - COALESCE(n.parent_id, 0) AS parent_id, + json_agg(res) AS node + FROM ( + SELECT + n.id, + n.name, + n.updated, + n.data, + COALESCE(n.parent_id, 0) AS ParentID, - t.id AS type_id, - t.name AS type_name, - t.schema AS type_schema_raw, - t.schema->>'icon' AS type_icon - FROM public.node n - INNER JOIN public.type t ON n.type_id = t.id - WHERE - n.id > 0 AND - n.name ILIKE $2 AND - (CASE - WHEN $1 = -1 THEN true - ELSE - type_id = $1 - END) + t.id AS TypeID, + t.name AS TypeName, + t.schema AS TypeSchema, + t.schema->>'icon' AS TypeIcon, - ORDER BY - t.schema->>'title' ASC, - UPPER(n.name) ASC - LIMIT $3 + COALESCE( + ( + SELECT jsonb_agg(res) + FROM ( + SELECT + nn.id, + COALESCE(nn.parent_id, -1) AS ParentID, + nn.name, + nn.updated, + nn.data AS data, + + tt.id AS TypeID, + tt.name AS TypeName, + tt.schema AS TypeSchema, + tt.schema->>'icon' AS TypeIcon, + + c.id AS ConnectionID, + c.data AS ConnectionData + FROM connection c + INNER JOIN public.node nn ON c.child_node_id = nn.id + INNER JOIN public.type tt ON nn.type_id = tt.id + WHERE + c.parent_node_id = n.id + ) AS res + ) + , '[]'::jsonb + ) AS ConnectedNodes + + FROM public.node n + INNER JOIN public.type t ON n.type_id = t.id + WHERE + n.id > 0 AND + n.name ILIKE $2 AND + (CASE + WHEN $1 = -1 THEN true + ELSE + type_id = $1 + END) + + ORDER BY + t.schema->>'title' ASC, + UPPER(n.name) ASC + LIMIT $3 + ) AS res `, typeID, search, maxResults+1, ) + + var body []byte + err = row.Scan(&body) if err != nil { err = werr.Wrap(err) return } - defer rows.Close() - for rows.Next() { - var node Node - err = rows.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 - } - - nodes = append(nodes, node) + err = json.Unmarshal(body, &nodes) + if err != nil { + err = werr.Wrap(err) + return } return } // }}} +func UpdateConnection(connID int, data []byte) (err error) { // {{{ + _, err = db.Exec(`UPDATE public.connection SET data=$2 WHERE id=$1`, connID, data) + if err != nil { + pqErr, ok := err.(*pq.Error) + if ok && pqErr.Code == "22P02" { + err = errors.New("Invalid JSON") + } else { + err = werr.Wrap(err) + } + return + } + return +} // }}} +func DeleteConnection(connID int) (err error) {// {{{ + _, err = db.Exec(`DELETE FROM public.connection WHERE id=$1`, connID) + if err != nil { + err = werr.Wrap(err) + return + } + return +}// }}} + // vim: foldmethod=marker diff --git a/sql/0006.sql b/sql/0006.sql new file mode 100644 index 0000000..d4d976b --- /dev/null +++ b/sql/0006.sql @@ -0,0 +1,2 @@ +ALTER TABLE public."connection" RENAME COLUMN "from" TO parent_node_id; +ALTER TABLE public."connection" RENAME COLUMN "to" TO child_node_id; diff --git a/sql/0007.sql b/sql/0007.sql new file mode 100644 index 0000000..4d67d61 --- /dev/null +++ b/sql/0007.sql @@ -0,0 +1 @@ +ALTER TABLE public."connection" ADD CONSTRAINT connection_unique UNIQUE (parent_node_id,child_node_id); diff --git a/sql/0008.sql b/sql/0008.sql new file mode 100644 index 0000000..91c3ce8 --- /dev/null +++ b/sql/0008.sql @@ -0,0 +1 @@ +ALTER TABLE public."connection" ADD "data" jsonb DEFAULT '{}' NOT NULL; diff --git a/static/css/main.css b/static/css/main.css index 978bd2d..8a1078e 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -229,6 +229,10 @@ select:focus { font-weight: bold; grid-column: 1 / -1; } +#connected-nodes .connected-nodes .type-group .type-icon, +#connected-nodes .connected-nodes .type-group .node-name { + cursor: pointer; +} #connected-nodes .type-icon img { display: block; height: 24px; @@ -276,3 +280,23 @@ select:focus { cursor: pointer; height: 24px; } +dialog#connection-data { + padding: 24px; +} +dialog#connection-data .label { + font-size: 1.25em; + font-weight: bold; + color: var(--section-color); +} +dialog#connection-data img { + height: 32px; +} +dialog#connection-data textarea { + margin-top: 16px; + width: 300px; + height: 200px; +} +dialog#connection-data div.button { + text-align: center; + margin-top: 8px; +} diff --git a/static/js/app.mjs b/static/js/app.mjs index 44da61f..4c1b7a6 100644 --- a/static/js/app.mjs +++ b/static/js/app.mjs @@ -1,6 +1,6 @@ import { Editor } from '@editor' import { MessageBus } from '@mbus' -import { SelectType, SelectNode } from '@select_node' +import { SelectType, SelectNodeDialog, ConnectionDataDialog } from '@select_node' export class App { constructor() {// {{{ @@ -41,9 +41,9 @@ export class App { break case 'NODE_CONNECT': - const selectnode = new SelectNode(selectedNode => { + const selectnode = new SelectNodeDialog(selectedNode => { this.nodeConnect(this.currentNode, selectedNode) - .then(() => this.edit(this.currentNode)) + .then(() => this.edit(this.currentNode.ID)) }) selectnode.render() break @@ -202,23 +202,11 @@ export class App { // 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' - }) - .catch(err => showError(err)) - fetch(`/nodes/connections/${nodeID}`) - .then(data => data.json()) - .then(json => { - if (!json.OK) { - showError(err) - return - } - - const connectedNodes = new ConnectedNodes(json.Nodes) + const connectedNodes = new ConnectedNodes(json.Node.ConnectedNodes) document.getElementById('connected-nodes').replaceChildren(connectedNodes.render()) - }) .catch(err => showError(err)) - }// }}} async nodeRename(nodeID, name) {// {{{ return new Promise((resolve, reject) => { @@ -318,12 +306,28 @@ export class App { }) .catch(err => showError(err)) }// }}} - async nodeConnect(parentNode, nodeToConnect) { - return new Promise((resolve, reject)=>{ - // XXX - here - //fetch('/nodes/) + async nodeConnect(parentNode, nodeToConnect) {// {{{ + return new Promise((resolve, reject) => { + const req = { + ParentNodeID: parentNode.ID, + ChildNodeID: nodeToConnect.ID, + } + + fetch(`/nodes/connect`, { + method: 'POST', + body: JSON.stringify(req), + }) + .then(data => data.json()) + .then(json => { + if (!json.OK) { + showError(json.Error) + return + } + resolve() + }) + .catch(err => reject(err)) }) - } + }// }}} typeSort(a, b) {// {{{ if (a.Schema['x-group'] === undefined) a.Schema['x-group'] = 'No group' @@ -763,6 +767,12 @@ class ConnectedNode {
${this.node.Name}
` + + for (const el of tmpl.content.children) { + el.addEventListener('click', () => { + new ConnectionDataDialog(this.node, () => _app.edit(_app.currentNode.ID)).render() + }) + } return tmpl.content }// }}} } diff --git a/static/js/select_node.mjs b/static/js/select_node.mjs new file mode 100644 index 0000000..b4f0947 --- /dev/null +++ b/static/js/select_node.mjs @@ -0,0 +1,256 @@ +export class SelectNodeDialog { + constructor(callback) {// {{{ + this.selectType = new SelectType + this.searchResults = null + this.searchText = null + this.selectType = null + this.nodeTable = null + this.moreExist = null + + if (callback !== undefined) + this.callback = callback + else + this.callback = () => { } + }// }}} + async render() {// {{{ + const dlg = document.createElement('dialog') + dlg.id = 'select-node' + dlg.addEventListener('close', () => dlg.remove()) + + dlg.innerHTML = ` +
Search for node
+ + +
+ +
+
+
+ ` + + this.nodeTable = new NodeTable((_node, node) => { + this.callback(node) + dlg.close() + }) + + this.searchText = dlg.querySelector('.search-text') + this.searchResults = dlg.querySelector('.search-results') + this.moreExist = dlg.querySelector('.more-exist') + const button = dlg.querySelector('button') + button.addEventListener('click', () => this.search()) + + this.searchText.addEventListener('keydown', event => { + if (event.key === 'Enter') + this.search() + }) + this.searchText.focus() + this.searchText.value = '%' + + new SelectType(true).render() + .then(select => { + this.selectType = select + dlg.querySelector('select').replaceWith(this.selectType) + }) + + document.body.appendChild(dlg) + dlg.showModal() + }// }}} + search() {// {{{ + const type_id = this.selectType.value + const search = this.searchText.value + this.moreExist.innerText = '' + + fetch(`/nodes/search?` + new URLSearchParams({ type_id, search })) + .then(data => data.json()) + .then(json => { + if (!json.OK) { + showError(json.Error) + return + } + + this.nodeTable.clearNodes() + this.nodeTable.addNodes(json.Nodes) + this.searchResults.replaceChildren(this.nodeTable.render()) + + if (json.MoreExistThan > 0) + this.moreExist.innerText = `Only displaying ${json.MoreExistThan} nodes. There are more matching the given criteria.` + }) + .catch(err => showError(err)) + }// }}} +} + +export class SelectType { + constructor(allowNoType) {// {{{ + this.allowNoType = allowNoType + }// }}} + async render() {// {{{ + return new Promise((resolve, reject) => { + this.fetchTypes() + .then(types => { + const select = document.createElement('select') + + if (this.allowNoType) { + const option = document.createElement('option') + option.setAttribute('value', -1) + option.innerText = '[ No specific type ]' + select.appendChild(option) + } + + types.sort(_app.typeSort) + let prevGroup = null + for (const t of types) { + if (t.Name == 'root_node') + continue + + if (t.Schema['x-group'] != prevGroup) { + prevGroup = t.Schema['x-group'] + const group = document.createElement('optgroup') + group.setAttribute('label', t.Schema['x-group']) + select.appendChild(group) + } + + const opt = document.createElement('option') + opt.setAttribute('value', t.ID) + opt.innerHTML = '    ' + (t.Schema.title || t.Name) + select.appendChild(opt) + } + + resolve(select) + }) + .catch(err => reject(err)) + }) + }// }}} + async fetchTypes() {// {{{ + return new Promise((resolve, reject) => { + fetch('/types/') + .then(data => data.json()) + .then(json => { + if (!json.OK) { + showError(json.Error) + return + } + resolve(json.Types) + }) + .catch(err => reject(err)) + }) + }// }}} +} + +class NodeTable { + constructor(callback) {// {{{ + this.nodes = new Map() + + if (callback !== undefined) + this.callback = callback + else + this.callback = () => { } + }// }}} + render() {// {{{ + const div = document.createElement('div') + div.classList.add('node-table') + + for (const k of Array.from(this.nodes.keys())) { + const group = document.createElement('div') + group.classList.add('group') + group.innerHTML = ` +
${k}
+
+ ` + + const groupChildren = group.querySelector('.children') + for (const n of this.nodes.get(k)) { + const icon = document.createElement('img') + icon.setAttribute('src', `/images/${_VERSION}/node_modules/@mdi/svg/svg/${n.TypeIcon}.svg`) + icon.addEventListener('click', event => this.callback(event, n)) + + const node = document.createElement('div') + node.classList.add('node') + node.innerText = n.Name + node.addEventListener('click', event => this.callback(event, n)) + + groupChildren.appendChild(icon) + groupChildren.appendChild(node) + } + + div.appendChild(group) + } + + return div + }// }}} + clearNodes() {// {{{ + this.nodes = new Map() + }// }}} + addNodes(nodes) {// {{{ + for (const n of nodes) { + let tableNodes = this.nodes.get(n.TypeSchema.title) + if (tableNodes === undefined) { + tableNodes = [] + this.nodes.set(n.TypeSchema.title, tableNodes) + } + tableNodes.push(n) + } + }// }}} +} + +export class ConnectionDataDialog { + constructor(node, callback) {// {{{ + this.node = node + this.callback = callback + }// }}} + render() {// {{{ + const dlg = document.createElement('dialog') + dlg.id = 'connection-data' + dlg.addEventListener('close', () => dlg.remove()) + + dlg.innerHTML = ` +
+
Connection data
+
+
+
${this.node.Name}
+
+
+ ` + dlg.querySelector('textarea').value = JSON.stringify(this.node.ConnectionData, null, 4) + + dlg.querySelector('img').addEventListener('click', ()=>{ + if(!confirm('Do you want to delete the connection?')) + return + + fetch(`/connection/delete/${this.node.ConnectionID}`) + .then(data => data.json()) + .then(json => { + if (!json.OK) { + showError(json.Error) + return + } + dlg.close() + this.callback() + }) + .catch(err => showError(err)) + }) + + dlg.querySelector('button').addEventListener('click', () => { + // Connection data is updated. + fetch(`/connection/update/${this.node.ConnectionID}`, { + method: 'POST', + body: dlg.querySelector('textarea').value, + }) + .then(data => data.json()) + .then(json => { + if (!json.OK) { + showError(json.Error) + return + } + dlg.close() + this.callback() + }) + .catch(err => showError(err)) + }) + + document.body.appendChild(dlg) + dlg.showModal() + }// }}} +} + +// vim: foldmethod=marker diff --git a/static/less/main.less b/static/less/main.less index 3cac2e8..7295aab 100644 --- a/static/less/main.less +++ b/static/less/main.less @@ -304,6 +304,10 @@ select:focus { font-weight: bold; grid-column: 1 / -1; } + + .type-icon, .node-name { + cursor: pointer; + } } } @@ -368,3 +372,28 @@ select:focus { } } } + +dialog#connection-data { + padding: 24px; + + .label { + font-size: 1.25em; + font-weight: bold; + color: var(--section-color); + } + + img { + height: 32px; + } + + textarea { + margin-top: 16px; + width: 300px; + height: 200px; + } + + div.button { + text-align: center; + margin-top: 8px; + } +} diff --git a/webserver.go b/webserver.go index 5e283f7..5cb2d85 100644 --- a/webserver.go +++ b/webserver.go @@ -4,9 +4,11 @@ import ( // External "git.ahall.se/go/html_template" werr "git.gibonuddevalla.se/go/wrappederror" + "github.com/lib/pq" // Standard "encoding/json" + "errors" "fmt" "io" "io/fs" @@ -42,8 +44,11 @@ func initWebserver() (err error) { http.HandleFunc("/nodes/create", actionNodeCreate) http.HandleFunc("/nodes/move", actionNodeMove) http.HandleFunc("/nodes/search", actionNodeSearch) + http.HandleFunc("/nodes/connect", actionNodeConnect) http.HandleFunc("/types/{typeID}", actionType) http.HandleFunc("/types/", actionTypesAll) + http.HandleFunc("/connection/update/{connID}", actionConnectionUpdate) + http.HandleFunc("/connection/delete/{connID}", actionConnectionDelete) err = http.ListenAndServe(address, nil) return @@ -250,7 +255,7 @@ func actionNodeConnections(w http.ResponseWriter, r *http.Request) { // {{{ } out := struct { - OK bool + OK bool Nodes []Node }{ true, @@ -280,7 +285,7 @@ func actionNodeMove(w http.ResponseWriter, r *http.Request) { // {{{ } out := struct { - OK bool + OK bool }{ true, } @@ -312,8 +317,8 @@ func actionNodeSearch(w http.ResponseWriter, r *http.Request) { // {{{ } out := struct { - OK bool - Nodes []Node + OK bool + Nodes []Node MoreExistThan int }{ true, @@ -328,6 +333,37 @@ func actionNodeSearch(w http.ResponseWriter, r *http.Request) { // {{{ j, _ := json.Marshal(out) w.Write(j) } // }}} +func actionNodeConnect(w http.ResponseWriter, r *http.Request) { // {{{ + var req struct { + ParentNodeID int + ChildNodeID int + } + + body, _ := io.ReadAll(r.Body) + err := json.Unmarshal(body, &req) + if err != nil { + err = werr.Wrap(err) + httpError(w, err) + return + } + + err = ConnectNode(req.ParentNodeID, req.ChildNodeID) + if err != nil { + pqErr, ok := err.(*pq.Error) + if ok && pqErr.Code == "23505" { + err = errors.New("This node is already connected.") + } else { + err = werr.Wrap(err) + } + httpError(w, err) + return + } + + res := struct{ OK bool }{true} + j, _ := json.Marshal(res) + w.Write(j) + +} // }}} func actionType(w http.ResponseWriter, r *http.Request) { // {{{ typeID := 0 typeIDStr := r.PathValue("typeID") @@ -362,5 +398,53 @@ func actionTypesAll(w http.ResponseWriter, r *http.Request) { // {{{ j, _ := json.Marshal(out) w.Write(j) } // }}} +func actionConnectionUpdate(w http.ResponseWriter, r *http.Request) { // {{{ + connIDStr := r.PathValue("connID") + connID, err := strconv.Atoi(connIDStr) + if err != nil { + err = werr.Wrap(err) + httpError(w, err) + return + } + + data, _ := io.ReadAll(r.Body) + + err = UpdateConnection(connID, data) + if err != nil { + httpError(w, err) + return + } + + out := struct { + OK bool + }{ + true, + } + j, _ := json.Marshal(out) + w.Write(j) +} // }}} +func actionConnectionDelete(w http.ResponseWriter, r *http.Request) { // {{{ + connIDStr := r.PathValue("connID") + connID, err := strconv.Atoi(connIDStr) + if err != nil { + err = werr.Wrap(err) + httpError(w, err) + return + } + + err = DeleteConnection(connID) + if err != nil { + httpError(w, err) + return + } + + out := struct { + OK bool + }{ + true, + } + j, _ := json.Marshal(out) + w.Write(j) +} // }}} // vim: foldmethod=marker