From 2b8472bcd1460c270fabb0ffc327db366d926cc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Tue, 8 Jul 2025 20:14:28 +0200 Subject: [PATCH] Connected nodes --- node.go | 61 +++++++++++++++++++ static/css/main.css | 54 ++++++++++++++++- static/js/app.mjs | 134 +++++++++++++++++------------------------ static/less/main.less | 68 ++++++++++++++++++++- views/pages/app.gotmpl | 5 +- webserver.go | 42 +++++++++++++ 6 files changed, 280 insertions(+), 84 deletions(-) diff --git a/node.go b/node.go index d44b82b..e121410 100644 --- a/node.go +++ b/node.go @@ -275,5 +275,66 @@ func GetNodeConnections(nodeID int) (connections []Node, err error) { // {{{ return } // }}} +func SearchNodes(typeID int, search string, maxResults int) (nodes []Node, err error) { // {{{ + nodes = []Node{} + + var rows *sqlx.Rows + rows, err = db.Queryx(` + SELECT + n.id, + n.name, + n.updated, + n.data, + COALESCE(n.parent_id, 0) AS parent_id, + + 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) + + ORDER BY + t.schema->>'title' ASC, + UPPER(n.name) ASC + LIMIT $3 + `, + typeID, + search, + maxResults+1, + ) + 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) + } + + return +} // }}} // vim: foldmethod=marker diff --git a/static/css/main.css b/static/css/main.css index b531d85..978bd2d 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -30,6 +30,9 @@ body { [onClick] { cursor: pointer; } +button { + padding: 4px 8px; +} .page { display: none; } @@ -199,10 +202,16 @@ select:focus { outline-offset: -2px; } #connected-nodes > .label { - margin-bottom: 16px; color: var(--section-color); font-weight: bold; font-size: 1.25em; + margin-bottom: 8px; +} +#connected-nodes > .add { + margin-bottom: 8px; +} +#connected-nodes > .add img { + height: 24px; } #connected-nodes .connected-nodes { display: flex; @@ -224,3 +233,46 @@ select:focus { display: block; height: 24px; } +#select-node { + padding: 32px; + display: grid; + grid-template-columns: min-content; + grid-gap: 8px; + justify-items: start; +} +#select-node > * { + min-width: 300px; + width: 100%; +} +#select-node button { + width: 100px !important; +} +#select-node .more-exist { + color: #a44; +} +#select-node .search-results .node-table { + display: flex; + align-items: start; + gap: 32px; +} +#select-node .search-results .node-table .group { + margin-top: 16px; +} +#select-node .search-results .node-table .group .label { + font-weight: bold; + color: var(--section-color); + margin-bottom: 8px; + white-space: nowrap; +} +#select-node .search-results .node-table .group .children { + display: grid; + grid-template-columns: min-content 1fr; + grid-gap: 8px; +} +#select-node .search-results .node-table .group .children .node { + cursor: pointer; +} +#select-node .search-results .node-table .group .children img { + cursor: pointer; + height: 24px; +} diff --git a/static/js/app.mjs b/static/js/app.mjs index 3657f7a..44da61f 100644 --- a/static/js/app.mjs +++ b/static/js/app.mjs @@ -1,5 +1,6 @@ import { Editor } from '@editor' import { MessageBus } from '@mbus' +import { SelectType, SelectNode } from '@select_node' export class App { constructor() {// {{{ @@ -15,6 +16,7 @@ export class App { const events = [ 'EDITOR_NODE_SAVE', 'MENU_ITEM_SELECTED', + 'NODE_CONNECT', 'NODE_CREATE_DIALOG', 'NODE_DELETE', 'NODE_EDIT_NAME', @@ -38,6 +40,14 @@ export class App { this.page(item, event.detail) break + case 'NODE_CONNECT': + const selectnode = new SelectNode(selectedNode => { + this.nodeConnect(this.currentNode, selectedNode) + .then(() => this.edit(this.currentNode)) + }) + selectnode.render() + break + case 'NODE_SELECTED': for (const n of document.querySelectorAll('#nodes .node.selected')) n.classList.remove('selected') @@ -147,6 +157,7 @@ export class App { case 'type': document.getElementById('types').classList.add('show') document.getElementById('editor-type-schema').classList.add('show') + document.getElementById('editor-node').style.display = 'none' if (this.typesList === null) this.typesList = new TypesList() @@ -201,7 +212,7 @@ export class App { showError(err) return } - + const connectedNodes = new ConnectedNodes(json.Nodes) document.getElementById('connected-nodes').replaceChildren(connectedNodes.render()) @@ -307,6 +318,27 @@ export class App { }) .catch(err => showError(err)) }// }}} + async nodeConnect(parentNode, nodeToConnect) { + return new Promise((resolve, reject)=>{ + // XXX - here + //fetch('/nodes/) + }) + } + typeSort(a, b) {// {{{ + if (a.Schema['x-group'] === undefined) + a.Schema['x-group'] = 'No group' + + if (b.Schema['x-group'] === undefined) + b.Schema['x-group'] = 'No group' + + if (a.Schema['x-group'] < b.Schema['x-group']) return -1 + if (a.Schema['x-group'] > b.Schema['x-group']) return 1 + + if ((a.Schema.title || a.Name) < (b.Schema.title || b.Name)) return -1 + if ((a.Schema.title || a.Name) > (b.Schema.title || b.Name)) return 1 + + return 0 + }// }}} } class NodeCreateDialog { @@ -319,18 +351,13 @@ class NodeCreateDialog { this.createElements() - this.fetchTypes() - .then(() => { - const st = new SelectType(this.types) - this.select.replaceChildren(st.render()) - }) - this.dialog.showModal() - this.select.focus() + //this.select.focus() }// }}} createElements() {// {{{ this.dialog = document.createElement('dialog') this.dialog.id = 'create-type' + this.dialog.addEventListener('close', () => this.dialog.remove()) this.dialog.innerHTML = `
@@ -339,6 +366,13 @@ class NodeCreateDialog {
` + new SelectType().render() + .then(select => { + this.select = select + this.dialog.querySelector('select').replaceWith(this.select) + this.select.focus() + }) + this.dialog.querySelector('button').addEventListener('click', () => this.commit()) this.select = this.dialog.querySelector('select') this.input = this.dialog.querySelector('input') @@ -379,51 +413,6 @@ class NodeCreateDialog { }) .catch(err => showError(err)) }// }}} - async fetchTypes() {// {{{ - return new Promise((resolve, reject) => { - fetch('/types/') - .then(data => data.json()) - .then(json => { - if (!json.OK) { - showError(json.Error) - return - } - this.types = json.Types - resolve() - }) - .catch(err => reject(err)) - }) - }// }}} -} - -class SelectType { - constructor(types) {// {{{ - this.types = types - }// }}} - render() {// {{{ - const tmpl = document.createElement('template') - - this.types.sort(typeSort) - let prevGroup = null - for (const t of this.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']) - tmpl.content.appendChild(group) - } - - const opt = document.createElement('option') - opt.setAttribute('value', t.ID) - opt.innerText = t.Schema.title || t.Name - tmpl.content.appendChild(opt) - } - - return tmpl.content - }// }}} } export class Tree { @@ -696,7 +685,7 @@ export class TypesList { render() {// {{{ const div = document.createElement('div') - this.types.sort(typeSort) + this.types.sort(_app.typeSort) let prevGroup = null @@ -726,33 +715,20 @@ export class TypesList { }// }}} } -function typeSort(a, b) {// {{{ - if (a.Schema['x-group'] === undefined) - a.Schema['x-group'] = 'No group' - - if (b.Schema['x-group'] === undefined) - b.Schema['x-group'] = 'No group' - - if (a.Schema['x-group'] < b.Schema['x-group']) return -1 - if (a.Schema['x-group'] > b.Schema['x-group']) return 1 - - if ((a.Schema.title || a.Name) < (b.Schema.title || b.Name)) return -1 - if ((a.Schema.title || a.Name) > (b.Schema.title || b.Name)) return 1 - - return 0 -}// }}} - class ConnectedNodes { - constructor(nodes) { + constructor(nodes) {// {{{ this.nodes = nodes - } - render() { + }// }}} + render() {// {{{ const div = document.createElement('template') div.innerHTML = `
Connected nodes
+
` + div.content.querySelector('.add').addEventListener('click', () => mbus.dispatch('NODE_CONNECT')) + const types = new Map() for (const n of this.nodes) { let typeGroup = types.get(n.TypeSchema.title) @@ -774,23 +750,21 @@ class ConnectedNodes { } return div.content - } + }// }}} } - - class ConnectedNode { - constructor(node) { + constructor(node) {// {{{ this.node = node - } - render() { + }// }}} + render() {// {{{ const tmpl = document.createElement('template') tmpl.innerHTML = `
${this.node.Name}
` return tmpl.content - } + }// }}} } // vim: foldmethod=marker diff --git a/static/less/main.less b/static/less/main.less index c594876..3cac2e8 100644 --- a/static/less/main.less +++ b/static/less/main.less @@ -38,6 +38,10 @@ body { cursor: pointer; } +button { + padding: 4px 8px; +} + .page { display: none; @@ -270,10 +274,18 @@ select:focus { #connected-nodes { & > .label { - margin-bottom: 16px; color: var(--section-color); font-weight: bold; font-size: 1.25em; + margin-bottom: 8px; + } + + & > .add { + margin-bottom: 8px; + + img { + height: 24px; + } } .connected-nodes { @@ -302,3 +314,57 @@ select:focus { } } } + +#select-node { + padding: 32px; + display: grid; + grid-template-columns: min-content; + grid-gap: 8px; + justify-items: start; + + & > * { + min-width: 300px; + width: 100%; + } + + button { + width: 100px !important; + } + + .more-exist { + color: #a44; + } + + .search-results { + .node-table { + display: flex; + align-items: start; + gap: 32px; + + .group { + margin-top: 16px; + .label { + font-weight: bold; + color: var(--section-color); + margin-bottom: 8px; + white-space: nowrap; + } + + .children { + display: grid; + grid-template-columns: min-content 1fr; + grid-gap: 8px; + + .node { + cursor: pointer; + } + + img { + cursor: pointer; + height: 24px; + } + } + } + } + } +} diff --git a/views/pages/app.gotmpl b/views/pages/app.gotmpl index 34b2514..fbaf4b9 100644 --- a/views/pages/app.gotmpl +++ b/views/pages/app.gotmpl @@ -4,8 +4,9 @@ diff --git a/webserver.go b/webserver.go index 59af967..5e283f7 100644 --- a/webserver.go +++ b/webserver.go @@ -41,6 +41,7 @@ func initWebserver() (err error) { http.HandleFunc("/nodes/connections/{nodeID}", actionNodeConnections) http.HandleFunc("/nodes/create", actionNodeCreate) http.HandleFunc("/nodes/move", actionNodeMove) + http.HandleFunc("/nodes/search", actionNodeSearch) http.HandleFunc("/types/{typeID}", actionType) http.HandleFunc("/types/", actionTypesAll) @@ -286,6 +287,47 @@ func actionNodeMove(w http.ResponseWriter, r *http.Request) { // {{{ j, _ := json.Marshal(out) w.Write(j) } // }}} +func actionNodeSearch(w http.ResponseWriter, r *http.Request) { // {{{ + maxResults := 25 + + typeIDStr := r.URL.Query().Get("type_id") + typeID, err := strconv.Atoi(typeIDStr) + if err != nil { + typeID = -1 + } + + searchText := r.URL.Query().Get("search") + + var nodes []Node + nodes, err = SearchNodes(typeID, searchText, maxResults) + if err != nil { + err = werr.Wrap(err) + httpError(w, err) + return + } + + moreExist := len(nodes) > maxResults + if moreExist { + nodes = nodes[:maxResults] + } + + out := struct { + OK bool + Nodes []Node + MoreExistThan int + }{ + true, + nodes, + 0, + } + + if moreExist { + out.MoreExistThan = maxResults + } + + j, _ := json.Marshal(out) + w.Write(j) +} // }}} func actionType(w http.ResponseWriter, r *http.Request) { // {{{ typeID := 0 typeIDStr := r.PathValue("typeID")