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 = `
+
+ ${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