Connected nodes

This commit is contained in:
Magnus Åhall 2025-07-09 19:30:54 +02:00
parent 2b8472bcd1
commit ca0659a368
9 changed files with 581 additions and 89 deletions

213
node.go
View file

@ -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

2
sql/0006.sql Normal file
View file

@ -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;

1
sql/0007.sql Normal file
View file

@ -0,0 +1 @@
ALTER TABLE public."connection" ADD CONSTRAINT connection_unique UNIQUE (parent_node_id,child_node_id);

1
sql/0008.sql Normal file
View file

@ -0,0 +1 @@
ALTER TABLE public."connection" ADD "data" jsonb DEFAULT '{}' NOT NULL;

View file

@ -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;
}

View file

@ -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 {
<div class="type-icon"><img src="/images/${_VERSION}/node_modules/@mdi/svg/svg/${this.node.TypeIcon}.svg" /></div>
<div class="node-name">${this.node.Name}</div>
`
for (const el of tmpl.content.children) {
el.addEventListener('click', () => {
new ConnectionDataDialog(this.node, () => _app.edit(_app.currentNode.ID)).render()
})
}
return tmpl.content
}// }}}
}

256
static/js/select_node.mjs Normal file
View file

@ -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 = `
<div class="label">Search for node</div>
<input class="search-text" type="text" placeholder="Search" />
<select></select>
<div style="display: grid; grid-template-columns: min-content 1fr; align-items: center; grid-gap: 16px;">
<button>Search</button>
<div class="more-exist"></div>
</div>
<div class="search-results"></div>
`
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 = '&nbsp;&nbsp;&nbsp;&nbsp;' + (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 = `
<div class="label">${k}</div>
<div class="children"></div>
`
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 = `
<div>
<div style="float: left;" class="label">Connection data</div>
<div style="float: right;"><img src="/images/${_VERSION}/node_modules/@mdi/svg/svg/trash-can.svg" /></div>
</div>
<div style="clear: both;"><b>${this.node.Name}</b></div>
<div><textarea></textarea></div>
<div class="button"><button>Update</button></div>
`
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

View file

@ -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;
}
}

View file

@ -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