Refactor node management to automatically position basic and admin at each edge
This commit is contained in:
parent
354b5041c7
commit
d2b59fedeb
|
@ -2,7 +2,7 @@
|
||||||
import { Button, ActionButton } from "@budibase/bbui"
|
import { Button, ActionButton } from "@budibase/bbui"
|
||||||
import { useSvelteFlow } from "@xyflow/svelte"
|
import { useSvelteFlow } from "@xyflow/svelte"
|
||||||
import { getContext } from "svelte"
|
import { getContext } from "svelte"
|
||||||
import { autoLayout } from "./layout"
|
import { autoLayout } from "./utils"
|
||||||
import { MaxAutoZoom, ZoomDuration } from "./constants"
|
import { MaxAutoZoom, ZoomDuration } from "./constants"
|
||||||
|
|
||||||
const { nodes, edges, createRole } = getContext("flow")
|
const { nodes, edges, createRole } = getContext("flow")
|
||||||
|
|
|
@ -5,109 +5,51 @@
|
||||||
SvelteFlow,
|
SvelteFlow,
|
||||||
Background,
|
Background,
|
||||||
BackgroundVariant,
|
BackgroundVariant,
|
||||||
Position,
|
|
||||||
useSvelteFlow,
|
useSvelteFlow,
|
||||||
|
getNodesBounds,
|
||||||
} from "@xyflow/svelte"
|
} from "@xyflow/svelte"
|
||||||
import "@xyflow/svelte/dist/style.css"
|
import "@xyflow/svelte/dist/style.css"
|
||||||
import RoleNode from "./RoleNode.svelte"
|
import RoleNode from "./RoleNode.svelte"
|
||||||
import RoleEdge from "./RoleEdge.svelte"
|
import RoleEdge from "./RoleEdge.svelte"
|
||||||
import { autoLayout } from "./layout"
|
import {
|
||||||
|
autoLayout,
|
||||||
|
getAdminPosition,
|
||||||
|
getBasicPosition,
|
||||||
|
rolesToLayout,
|
||||||
|
} from "./utils"
|
||||||
import { setContext, tick } from "svelte"
|
import { setContext, tick } from "svelte"
|
||||||
import Controls from "./Controls.svelte"
|
import Controls from "./Controls.svelte"
|
||||||
import { GridResolution, MaxAutoZoom } from "./constants"
|
import { GridResolution, MaxAutoZoom } from "./constants"
|
||||||
import { roles } from "stores/builder"
|
import { roles } from "stores/builder"
|
||||||
import { Roles } from "constants/backend"
|
import { Roles } from "constants/backend"
|
||||||
import { getSequentialName } from "helpers/duplicate"
|
import { getSequentialName } from "helpers/duplicate"
|
||||||
|
import { derivedMemo } from "@budibase/frontend-core"
|
||||||
|
|
||||||
const flow = useSvelteFlow()
|
const flow = useSvelteFlow()
|
||||||
const nodes = writable([])
|
|
||||||
const edges = writable([])
|
const edges = writable([])
|
||||||
|
const nodes = writable([])
|
||||||
const dragging = writable(false)
|
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)
|
$: handleExternalRoleChanges($roles)
|
||||||
|
$: updateBuiltins($bounds)
|
||||||
// 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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Updates nodes and edges based on external changes to roles
|
// Updates nodes and edges based on external changes to roles
|
||||||
const handleExternalRoleChanges = roles => {
|
const handleExternalRoleChanges = roles => {
|
||||||
const currentNodes = $nodes
|
const currentNodes = $nodes
|
||||||
const newLayout = autoLayout(rolesToLayout(roles))
|
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(
|
nodes.set(
|
||||||
newLayout.nodes.map(node => {
|
newLayout.nodes.map(node => {
|
||||||
const currentNode = currentNodes.find(x => x.id === node.id)
|
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
|
// Positions the basic and admin role at either edge of the flow
|
||||||
const selectNode = roleId => {
|
const updateBuiltins = bounds => {
|
||||||
nodes.update($nodes => {
|
flow.updateNode(Roles.BASIC, {
|
||||||
return $nodes.map(node => ({
|
position: getBasicPosition(bounds),
|
||||||
...node,
|
})
|
||||||
selected: node.id === roleId,
|
flow.updateNode(Roles.ADMIN, {
|
||||||
}))
|
position: getAdminPosition(bounds),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Creates a new role
|
|
||||||
const createRole = async () => {
|
const createRole = async () => {
|
||||||
const roleId = Helpers.uuid()
|
const roleId = Helpers.uuid()
|
||||||
await roles.save({
|
await roles.save({
|
||||||
|
@ -151,30 +89,30 @@
|
||||||
permissionId: "write",
|
permissionId: "write",
|
||||||
})
|
})
|
||||||
await tick()
|
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) => {
|
const updateRole = async (roleId, metadata) => {
|
||||||
// Don't update builtins
|
const node = $nodes.find(node => node.id === roleId)
|
||||||
const node = $nodes.find(x => x.id === roleId)
|
|
||||||
if (!node) {
|
if (!node) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Update metadata immediately, before saving
|
||||||
// Update metadata
|
|
||||||
if (metadata) {
|
if (metadata) {
|
||||||
flow.updateNodeData(roleId, metadata)
|
flow.updateNodeData(roleId, metadata)
|
||||||
}
|
}
|
||||||
|
await roles.save(nodeToRole(node))
|
||||||
// Actually save changes
|
|
||||||
const role = nodeToRole(node)
|
|
||||||
await roles.save(role)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deletes a role
|
|
||||||
const deleteRole = async roleId => {
|
const deleteRole = async roleId => {
|
||||||
const role = $roles.find(x => x._id === roleId)
|
const role = $roles.find(role => role._id === roleId)
|
||||||
if (role) {
|
if (role) {
|
||||||
roles.delete(role)
|
roles.delete(role)
|
||||||
}
|
}
|
||||||
|
@ -189,7 +127,7 @@
|
||||||
await updateRole(edge.target)
|
await updateRole(edge.target)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Saves a new connection
|
// Updates roles which have had a new connection
|
||||||
const onConnect = async connection => {
|
const onConnect = async connection => {
|
||||||
await updateRole(connection.target)
|
await updateRole(connection.target)
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,3 +3,5 @@ export const MaxAutoZoom = 1.2
|
||||||
export const GridResolution = 20
|
export const GridResolution = 20
|
||||||
export const NodeHeight = GridResolution * 3
|
export const NodeHeight = GridResolution * 3
|
||||||
export const NodeWidth = GridResolution * 12
|
export const NodeWidth = GridResolution * 12
|
||||||
|
export const NodeHSpacing = GridResolution * 4
|
||||||
|
export const NodeVSpacing = GridResolution * 2
|
||||||
|
|
|
@ -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 }))
|
|
||||||
}
|
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue