Refactor node management to automatically position basic and admin at each edge

This commit is contained in:
Andrew Kingston 2024-10-01 15:27:33 +01:00
parent 354b5041c7
commit d2b59fedeb
No known key found for this signature in database
5 changed files with 192 additions and 176 deletions

View File

@ -2,7 +2,7 @@
import { Button, ActionButton } from "@budibase/bbui"
import { useSvelteFlow } from "@xyflow/svelte"
import { getContext } from "svelte"
import { autoLayout } from "./layout"
import { autoLayout } from "./utils"
import { MaxAutoZoom, ZoomDuration } from "./constants"
const { nodes, edges, createRole } = getContext("flow")

View File

@ -5,109 +5,51 @@
SvelteFlow,
Background,
BackgroundVariant,
Position,
useSvelteFlow,
getNodesBounds,
} from "@xyflow/svelte"
import "@xyflow/svelte/dist/style.css"
import RoleNode from "./RoleNode.svelte"
import RoleEdge from "./RoleEdge.svelte"
import { autoLayout } from "./layout"
import {
autoLayout,
getAdminPosition,
getBasicPosition,
rolesToLayout,
} from "./utils"
import { setContext, tick } from "svelte"
import Controls from "./Controls.svelte"
import { GridResolution, MaxAutoZoom } from "./constants"
import { roles } from "stores/builder"
import { Roles } from "constants/backend"
import { getSequentialName } from "helpers/duplicate"
import { derivedMemo } from "@budibase/frontend-core"
const flow = useSvelteFlow()
const nodes = writable([])
const edges = writable([])
const nodes = writable([])
const dragging = writable(false)
const selectedNodes = derived(nodes, $nodes =>
$nodes.filter(x => x.selected).map(x => x.id)
)
// Ensure role changes are synced with nodes and edges
// Derive the list of selected nodes
const selectedNodes = derived(nodes, $nodes => {
return $nodes.filter(node => node.selected).map(node => node.id)
})
// Derive the bounds of all custom role nodes
const bounds = derivedMemo(nodes, $nodes => {
return getNodesBounds($nodes.filter(node => node.data.custom))
})
$: handleExternalRoleChanges($roles)
// Converts a role doc into a node structure
const roleToNode = role => {
const custom = !role._id.match(/[A-Z]+/)
return {
id: role._id,
sourcePosition: Position.Right,
targetPosition: Position.Left,
type: "role",
position: { x: 0, y: 0 },
data: {
...role.uiMetadata,
custom,
},
deletable: custom,
draggable: custom,
connectable: custom,
}
}
// Converts a node structure back into a role doc
const nodeToRole = node => {
const role = $roles.find(x => x._id === node.id)
const inherits = $edges.filter(x => x.target === node.id).map(x => x.source)
// TODO save inherits array
return {
...role,
inherits,
uiMetadata: {
displayName: node.data.displayName,
color: node.data.color,
description: node.data.description,
},
}
}
// Builds a layout from an array of roles
const rolesToLayout = roles => {
let nodes = []
let edges = []
// Remove some builtins
const ignoredRoles = [Roles.PUBLIC, Roles.POWER]
roles = roles.filter(role => !ignoredRoles.includes(role._id))
for (let role of roles) {
// Add node for this role
nodes.push(roleToNode(role))
// Add edges for this role
let inherits = []
if (role.inherits) {
inherits = Array.isArray(role.inherits)
? role.inherits
: [role.inherits]
}
for (let sourceRole of inherits) {
if (!roles.some(x => x._id === sourceRole)) {
continue
}
edges.push({
id: `${sourceRole}-${role._id}`,
source: sourceRole,
target: role._id,
})
}
}
return {
nodes,
edges,
}
}
$: updateBuiltins($bounds)
// Updates nodes and edges based on external changes to roles
const handleExternalRoleChanges = roles => {
const currentNodes = $nodes
const newLayout = autoLayout(rolesToLayout(roles))
edges.set(newLayout.edges)
// For nodes we want to persist some metadata
// For nodes we want to persist some metadata if possible
nodes.set(
newLayout.nodes.map(node => {
const currentNode = currentNodes.find(x => x.id === node.id)
@ -121,22 +63,18 @@
}
})
)
// Edges can always be updated
edges.set(newLayout.edges)
}
// Manually selects a node
const selectNode = roleId => {
nodes.update($nodes => {
return $nodes.map(node => ({
...node,
selected: node.id === roleId,
}))
// Positions the basic and admin role at either edge of the flow
const updateBuiltins = bounds => {
flow.updateNode(Roles.BASIC, {
position: getBasicPosition(bounds),
})
flow.updateNode(Roles.ADMIN, {
position: getAdminPosition(bounds),
})
}
// Creates a new role
const createRole = async () => {
const roleId = Helpers.uuid()
await roles.save({
@ -151,30 +89,30 @@
permissionId: "write",
})
await tick()
selectNode(roleId)
// Select the new node
nodes.update($nodes => {
return $nodes.map(node => ({
...node,
selected: node.id === roleId,
}))
})
}
// Updates a role with new metadata
const updateRole = async (roleId, metadata) => {
// Don't update builtins
const node = $nodes.find(x => x.id === roleId)
const node = $nodes.find(node => node.id === roleId)
if (!node) {
return
}
// Update metadata
// Update metadata immediately, before saving
if (metadata) {
flow.updateNodeData(roleId, metadata)
}
// Actually save changes
const role = nodeToRole(node)
await roles.save(role)
await roles.save(nodeToRole(node))
}
// Deletes a role
const deleteRole = async roleId => {
const role = $roles.find(x => x._id === roleId)
const role = $roles.find(role => role._id === roleId)
if (role) {
roles.delete(role)
}
@ -189,7 +127,7 @@
await updateRole(edge.target)
}
// Saves a new connection
// Updates roles which have had a new connection
const onConnect = async connection => {
await updateRole(connection.target)
}

View File

@ -3,3 +3,5 @@ export const MaxAutoZoom = 1.2
export const GridResolution = 20
export const NodeHeight = GridResolution * 3
export const NodeWidth = GridResolution * 12
export const NodeHSpacing = GridResolution * 4
export const NodeVSpacing = GridResolution * 2

View File

@ -1,71 +0,0 @@
import dagre from "@dagrejs/dagre"
import { NodeWidth, NodeHeight, GridResolution } from "./constants"
import { Position } from "@xyflow/svelte"
import { Roles } from "constants/backend"
import { Helpers } from "@budibase/bbui"
// Updates positions of nodes and edges into a nice graph structure
export const dagreLayout = ({ nodes, edges }) => {
const dagreGraph = new dagre.graphlib.Graph()
dagreGraph.setDefaultEdgeLabel(() => ({}))
dagreGraph.setGraph({
rankdir: "LR",
ranksep: GridResolution * 4,
nodesep: GridResolution * 2,
})
nodes.forEach(node => {
dagreGraph.setNode(node.id, { width: NodeWidth, height: NodeHeight })
})
edges.forEach(edge => {
dagreGraph.setEdge(edge.source, edge.target)
})
// Add ephemeral edges for basic and admin so that we can position them properly
for (let node of nodes) {
if (
!edges.some(x => x.target === node.id) &&
node.id !== Roles.BASIC &&
node.id !== Roles.ADMIN
) {
dagreGraph.setEdge(Roles.BASIC, node.id)
}
if (
!edges.some(x => x.source === node.id) &&
node.id !== Roles.BASIC &&
node.id !== Roles.ADMIN
) {
dagreGraph.setEdge(node.id, Roles.ADMIN)
}
}
dagre.layout(dagreGraph)
nodes.forEach(node => {
const pos = dagreGraph.node(node.id)
node.targetPosition = Position.Left
node.sourcePosition = Position.Right
node.position = {
x: Math.round((pos.x - NodeWidth / 2) / GridResolution) * GridResolution,
y: Math.round((pos.y - NodeHeight / 2) / GridResolution) * GridResolution,
}
})
return { nodes, edges }
}
// Adds additional edges as needed to the flow structure to ensure compatibility with BB role logic
const sanitiseLayout = ({ nodes, edges }) => {
// Remove any inheritance of basic and admin since this is implied
edges = edges.filter(
edge => edge.source !== Roles.BASIC && edge.target !== Roles.ADMIN
)
return {
nodes,
edges,
}
}
// Automatically lays out the graph, sanitising and enriching the structure
export const autoLayout = ({ nodes, edges }) => {
return dagreLayout(sanitiseLayout({ nodes, edges }))
}

View File

@ -0,0 +1,147 @@
import dagre from "@dagrejs/dagre"
import {
NodeWidth,
NodeHeight,
GridResolution,
NodeHSpacing,
NodeVSpacing,
} from "./constants"
import { getNodesBounds, Position } from "@xyflow/svelte"
import { Roles } from "constants/backend"
// Gets the position of the basic role
export const getBasicPosition = bounds => ({
x: bounds.x - NodeHSpacing - NodeWidth,
y: bounds.y + bounds.height / 2 - NodeHeight / 2,
})
// Gets the position of the admin role
export const getAdminPosition = bounds => ({
x: bounds.x + bounds.width + NodeHSpacing,
y: bounds.y + bounds.height / 2 - NodeHeight / 2,
})
// Updates positions of nodes and edges into a nice graph structure
export const dagreLayout = ({ nodes, edges }) => {
const dagreGraph = new dagre.graphlib.Graph()
dagreGraph.setDefaultEdgeLabel(() => ({}))
dagreGraph.setGraph({
rankdir: "LR",
ranksep: NodeHSpacing,
nodesep: NodeVSpacing,
})
nodes.forEach(node => {
dagreGraph.setNode(node.id, { width: NodeWidth, height: NodeHeight })
})
edges.forEach(edge => {
dagreGraph.setEdge(edge.source, edge.target)
})
dagre.layout(dagreGraph)
nodes.forEach(node => {
const pos = dagreGraph.node(node.id)
node.targetPosition = Position.Left
node.sourcePosition = Position.Right
node.position = {
x: Math.round((pos.x - NodeWidth / 2) / GridResolution) * GridResolution,
y: Math.round((pos.y - NodeHeight / 2) / GridResolution) * GridResolution,
}
})
// Reposition basic and admin to bound the custom nodes
const bounds = getNodesBounds(nodes.filter(node => node.data.custom))
nodes.find(x => x.id === Roles.BASIC).position = getBasicPosition(bounds)
nodes.find(x => x.id === Roles.ADMIN).position = getAdminPosition(bounds)
return { nodes, edges }
}
// Adds additional edges as needed to the flow structure to ensure compatibility with BB role logic
const sanitiseLayout = ({ nodes, edges }) => {
const ignoredRoles = [Roles.PUBLIC, Roles.POWER]
const edglessRoles = [...ignoredRoles, Roles.BASIC, Roles.ADMIN]
return {
nodes: nodes.filter(node => !ignoredRoles.includes(node.id)),
edges: edges.filter(edge => {
return (
!edglessRoles.includes(edge.source) &&
!edglessRoles.includes(edge.target)
)
}),
}
}
// Automatically lays out the graph, sanitising and enriching the structure
export const autoLayout = ({ nodes, edges }) => {
return dagreLayout(sanitiseLayout({ nodes, edges }))
}
// Converts a role doc into a node structure
export const roleToNode = role => {
const custom = !role._id.match(/[A-Z]+/)
return {
id: role._id,
sourcePosition: Position.Right,
targetPosition: Position.Left,
type: "role",
position: { x: 0, y: 0 },
data: {
...role.uiMetadata,
custom,
},
measured: {
width: NodeWidth,
height: NodeHeight,
},
deletable: custom,
draggable: custom,
connectable: custom,
}
}
// Converts a node structure back into a role doc
export const nodeToRole = node => {
const role = $roles.find(x => x._id === node.id)
const inherits = $edges.filter(x => x.target === node.id).map(x => x.source)
// TODO save inherits array
return {
...role,
inherits,
uiMetadata: {
displayName: node.data.displayName,
color: node.data.color,
description: node.data.description,
},
}
}
// Builds a layout from an array of roles
export const rolesToLayout = roles => {
let nodes = []
let edges = []
// Remove some builtins
for (let role of roles) {
// Add node for this role
nodes.push(roleToNode(role))
// Add edges for this role
let inherits = []
if (role.inherits) {
inherits = Array.isArray(role.inherits) ? role.inherits : [role.inherits]
}
for (let sourceRole of inherits) {
if (!roles.some(x => x._id === sourceRole)) {
continue
}
edges.push({
id: `${sourceRole}-${role._id}`,
source: sourceRole,
target: role._id,
})
}
}
return {
nodes,
edges,
}
}