Connected nodes

This commit is contained in:
Magnus Åhall 2025-07-08 20:14:28 +02:00
parent dff17cad5b
commit 2b8472bcd1
6 changed files with 280 additions and 84 deletions

61
node.go
View file

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

View file

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

View file

@ -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 = `
<div style="padding: 16px">
<select></select>
@ -339,6 +366,13 @@ class NodeCreateDialog {
</div>
`
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 = `
<div class="label">Connected nodes</div>
<div class="add"><img src="/images/${_VERSION}/node_modules/@mdi/svg/svg/plus-box.svg" /></div>
<div class="connected-nodes"></div>
`
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 = `
<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>
`
return tmpl.content
}
}// }}}
}
// vim: foldmethod=marker

View file

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

View file

@ -4,8 +4,9 @@
<script type="importmap">
{
"imports": {
"@editor": "/js/{{ .VERSION }}/editor.mjs",
"@mbus": "/js/{{ .VERSION }}/mbus.mjs"
"@editor": "/js/{{ .VERSION }}/editor.mjs",
"@mbus": "/js/{{ .VERSION }}/mbus.mjs",
"@select_node": "/js/{{ .VERSION }}/select_node.mjs"
}
}
</script>

View file

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