Refactor RBAC flow and use selected states more

This commit is contained in:
Andrew Kingston 2024-09-16 13:31:36 +01:00
parent d23d4156c3
commit d61594d74e
No known key found for this signature in database
6 changed files with 321 additions and 293 deletions

View File

@ -8,15 +8,15 @@
const { nodes, edges, createRole } = getContext("flow")
const flow = useSvelteFlow()
const doAutoLayout = async () => {
const addRole = async () => {
await createRole()
doAutoLayout()
}
const doAutoLayout = () => {
const layout = autoLayout({ nodes: $nodes, edges: $edges })
nodes.set(layout.nodes)
edges.set(layout.edges)
await tick()
flow.fitView({
maxZoom: MaxAutoZoom,
duration: ZoomDuration,
})
}
</script>
@ -43,7 +43,7 @@
<Button secondary on:click={doAutoLayout}>Auto layout</Button>
</div>
<div class="control bottom-right">
<Button icon="Add" cta on:click={createRole}>Add role</Button>
<Button icon="Add" cta on:click={addRole}>Add role</Button>
</div>
<style>

View File

@ -20,13 +20,12 @@
export let target
const flow = useSvelteFlow()
const { updateRole } = getContext("flow")
const { updateRole, selectedNode } = getContext("flow")
let edgeHovered = false
let labelHovered = false
let iconHovered = false
$: hovered = edgeHovered || labelHovered
$: edgeClasses = getEdgeClasses(hovered, labelHovered)
$: active = source === $selectedNode || target === $selectedNode
$: edgeClasses = getEdgeClasses(active, iconHovered)
$: [edgePath, labelX, labelY] = getBezierPath({
sourceX,
sourceY,
@ -42,41 +41,19 @@
? `Stop ${targetRole.uiMetadata.displayName} from inheriting ${sourceRole.uiMetadata.displayName}`
: null
const getEdgeClasses = (hovered, labelHovered) => {
const getEdgeClasses = (active, iconHovered) => {
let classes = ""
if (hovered) classes += `hovered `
if (labelHovered) classes += `delete `
if (active) classes += `active `
if (iconHovered) classes += `delete `
return classes
}
const onEdgeMouseOver = () => {
edgeHovered = true
}
const onEdgeMouseOut = () => {
edgeHovered = false
}
const deleteEdge = async () => {
flow.deleteElements({
edges: [{ id }],
})
await updateRole(target)
}
onMount(() => {
const edge = document.querySelector(`.svelte-flow__edge[data-id="${id}"]`)
if (edge) {
edge.addEventListener("mouseover", onEdgeMouseOver)
edge.addEventListener("mouseout", onEdgeMouseOut)
}
return () => {
if (edge) {
edge.removeEventListener("mouseover", onEdgeMouseOver)
edge.removeEventListener("mouseout", onEdgeMouseOut)
}
}
})
</script>
<BaseEdge path={edgePath} class={edgeClasses} />
@ -87,10 +64,10 @@
<div
style:transform="translate(-50%, -50%) translate({labelX}px,{labelY}px)"
class="edge-label nodrag nopan"
class:hovered
class:active
on:click={deleteEdge}
on:mouseover={() => (labelHovered = true)}
on:mouseout={() => (labelHovered = false)}
on:mouseover={() => (iconHovered = true)}
on:mouseout={() => (iconHovered = false)}
>
<Icon name="Delete" {tooltip} tooltipPosition={TooltipPosition.Top} />
</div>
@ -101,11 +78,11 @@
position: absolute;
padding: 8px;
opacity: 0;
pointer-events: all;
pointer-events: none;
}
.edge-label:hover,
.edge-label.hovered {
.edge-label.active {
opacity: 1;
pointer-events: all;
cursor: pointer;
}
.edge-label:hover :global(.spectrum-Icon) {
@ -116,13 +93,12 @@
color: var(--spectrum-global-color-gray-600);
}
.edge-label :global(svg) {
padding: 8px;
padding: 4px;
}
:global(.svelte-flow__edge:hover .svelte-flow__edge-path),
:global(.svelte-flow__edge-path.hovered) {
:global(.svelte-flow__edge-path.active) {
stroke: var(--spectrum-global-color-blue-400);
}
:global(.svelte-flow__edge-path.hovered.delete) {
:global(.svelte-flow__edge-path.active.delete) {
stroke: var(--spectrum-global-color-red-400);
}
</style>

View File

@ -1,227 +1,8 @@
<script>
import { Heading, Helpers } from "@budibase/bbui"
import { writable } from "svelte/store"
import {
SvelteFlow,
Background,
BackgroundVariant,
Position,
} from "@xyflow/svelte"
import "@xyflow/svelte/dist/style.css"
import RoleNode from "./RoleNode.svelte"
import RoleEdge from "./RoleEdge.svelte"
import { autoLayout } from "./layout"
import { setContext } 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"
const nodes = writable([])
const edges = writable([])
const dragging = writable(false)
// Ensure role changes are synced with nodes and edges
$: handleExternalRoleChanges($roles)
// Converts a role doc into a node structure
const roleToNode = role => ({
id: role._id,
sourcePosition: Position.Right,
targetPosition: Position.Left,
type: "role",
position: { x: 0, y: 0 },
data: {
...role.uiMetadata,
custom: !role._id.match(/[A-Z]+/),
},
})
// 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)
console.log(inherits)
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 = []
for (let role of roles.filter(role => role._id !== Roles.PUBLIC)) {
// 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
const handleExternalRoleChanges = roles => {
const currentNodes = $nodes
const newLayout = autoLayout(rolesToLayout(roles))
// For roles we want to persist their current positions
nodes.set(
newLayout.nodes.map(node => {
const position = currentNodes.find(x => x.id === node.id)?.position
if (!position) {
return node
}
return { ...node, position }
})
)
// Edges can always be updated
edges.set(newLayout.edges)
}
// Creates a new role
const createRole = async () => {
const role = {
name: Helpers.uuid(),
uiMetadata: {
displayName: getSequentialName($roles, "New role ", {
getName: x => x.uiMetadata.displayName,
}),
color: "var(--spectrum-global-color-gray-700)",
description: "Custom role",
},
permissionId: "write",
inherits: Roles.BASIC,
}
await roles.save(role)
}
// Updates a role based on the latest flow data
const updateRole = async roleId => {
const node = $nodes.find(x => x.id === roleId)
if (node) {
const role = nodeToRole(node)
await roles.save(role)
}
}
// Deletes a role
const deleteRole = async roleId => {
const role = $roles.find(x => x._id === roleId)
if (role) {
roles.delete(role)
}
}
// Saves a new connection
const onConnect = async connection => {
await saveRole(connection.target)
}
setContext("flow", {
nodes,
edges,
dragging,
createRole,
updateRole,
deleteRole,
})
import { SvelteFlowProvider } from "@xyflow/svelte"
import RoleFlow from "./RoleFlow.svelte"
</script>
<div class="title">
<div class="heading">
<Heading size="S">Manage roles</Heading>
</div>
<div class="description">Roles inherit permissions from each other.</div>
</div>
<div class="flow">
<SvelteFlow
fitView
{nodes}
{edges}
snapGrid={[GridResolution, GridResolution]}
nodeTypes={{ role: RoleNode }}
edgeTypes={{ role: RoleEdge }}
proOptions={{ hideAttribution: true }}
fitViewOptions={{ maxZoom: MaxAutoZoom }}
defaultEdgeOptions={{ type: "role", animated: true, selectable: false }}
onconnectstart={() => dragging.set(true)}
onconnectend={() => dragging.set(false)}
onconnect={onConnect}
>
<Background variant={BackgroundVariant.Dots} />
<Controls />
</SvelteFlow>
</div>
<style>
.heading {
border-bottom: 1px solid var(--spectrum-global-color-gray-300);
margin-bottom: 20px;
padding-bottom: 12px;
}
.description {
color: var(--spectrum-global-color-gray-600);
margin-bottom: calc(20px - var(--spacing-l));
}
.flow {
flex: 1 1 auto;
border-radius: 8px;
overflow: hidden;
position: relative;
--background-color: var(--spectrum-global-color-gray-50);
--node-background: var(--spectrum-global-color-gray-100);
--node-background-hover: var(--spectrum-global-color-gray-300);
--border-color: var(--spectrum-global-color-gray-300);
--edge-color: var(--spectrum-global-color-gray-500);
--handle-color: var(--spectrum-global-color-gray-600);
--selected-color: var(--spectrum-global-color-blue-400);
}
/* Customise svelte-flow theme */
.flow :global(.svelte-flow) {
/* Panel */
--xy-background-color: var(--background-color);
/* Controls */
--xy-controls-button-background-color: var(--node-background);
--xy-controls-button-background-color-hover: var(--node-background-hover);
--xy-controls-button-border-color: var(--border-color);
/* Handles */
--xy-handle-background-color: var(--handle-color);
--xy-handle-border-color: var(--handle-color);
/* Edges */
--xy-edge-stroke: var(--edge-color);
--xy-edge-stroke-selected: var(--edge-color);
--xy-edge-stroke-width: 2px;
}
</style>
<SvelteFlowProvider>
<RoleFlow />
</SvelteFlowProvider>

View File

@ -0,0 +1,271 @@
<script>
import { Heading, Helpers } from "@budibase/bbui"
import { writable, derived } from "svelte/store"
import {
SvelteFlow,
Background,
BackgroundVariant,
Position,
useSvelteFlow,
} from "@xyflow/svelte"
import "@xyflow/svelte/dist/style.css"
import RoleNode from "./RoleNode.svelte"
import RoleEdge from "./RoleEdge.svelte"
import { autoLayout } from "./layout"
import { setContext } 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 { meta } from "@roxi/routify"
const flow = useSvelteFlow()
const nodes = writable([])
const edges = writable([])
const dragging = writable(false)
const selectedNode = derived(
nodes,
$nodes => $nodes.find(x => x.selected)?.id
)
// Ensure role changes are synced with nodes and edges
$: handleExternalRoleChanges($roles)
// Converts a role doc into a node structure
const roleToNode = role => ({
id: role._id,
sourcePosition: Position.Right,
targetPosition: Position.Left,
type: "role",
position: { x: 0, y: 0 },
data: {
...role.uiMetadata,
custom: !role._id.match(/[A-Z]+/),
},
})
// 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)
console.log(inherits)
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 = []
for (let role of roles.filter(role => role._id !== Roles.PUBLIC)) {
// 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
const handleExternalRoleChanges = roles => {
const currentNodes = $nodes
const newLayout = autoLayout(rolesToLayout(roles))
// For nodes we want to persist some metadata
nodes.set(
newLayout.nodes.map(node => {
const currentNode = currentNodes.find(x => x.id === node.id)
if (!currentNode) {
return node
}
return {
...node,
position: currentNode.position || node.position,
selected: currentNode.selected || node.selected,
}
})
)
// Edges can always be updated
edges.set(newLayout.edges)
}
// Creates a new role
const createRole = async () => {
const newRole = {
name: Helpers.uuid(),
uiMetadata: {
displayName: getSequentialName($roles, "New role ", {
getName: x => x.uiMetadata.displayName,
}),
color: "var(--spectrum-global-color-gray-700)",
description: "Custom role",
},
permissionId: "write",
inherits: Roles.BASIC,
}
// Immediate state update
const newNode = {
...roleToNode({ ...newRole, _id: newRole.name }),
selected: true,
}
const layout = autoLayout({
nodes: [...$nodes.map(node => ({ ...node, selected: false })), newNode],
edges: $edges,
})
nodes.set(layout.nodes)
edges.set(layout.edges)
// Actually create role
await roles.save(newRole)
}
// Updates a role with new metadata
const updateRole = async (roleId, metadata) => {
// Don't update builtins
const node = $nodes.find(x => x.id === roleId)
if (!node || !node.data.custom) {
return
}
// Immediate state update
if (metadata) {
flow.updateNodeData(roleId, metadata)
}
// Actually save changes
const role = nodeToRole(node)
await roles.save(role)
}
// Deletes a role
const deleteRole = async roleId => {
// Immediate state update
const layout = autoLayout({
nodes: $nodes.filter(x => x.id !== roleId),
edges: $edges.filter(x => x.source !== roleId && x.target !== roleId),
})
nodes.set(layout.nodes)
edges.set(layout.edges)
// Actually delete role
const role = $roles.find(x => x._id === roleId)
if (role) {
roles.delete(role)
}
}
// Saves a new connection
const onConnect = async connection => {
await updateRole(connection.target)
}
setContext("flow", {
nodes,
edges,
dragging,
selectedNode,
createRole,
updateRole,
deleteRole,
})
</script>
<div class="title">
<div class="heading">
<Heading size="S">Manage roles</Heading>
</div>
<div class="description">Roles inherit permissions from each other.</div>
</div>
<div class="flow">
<SvelteFlow
fitView
{nodes}
{edges}
snapGrid={[GridResolution, GridResolution]}
nodeTypes={{ role: RoleNode }}
edgeTypes={{ role: RoleEdge }}
proOptions={{ hideAttribution: true }}
fitViewOptions={{ maxZoom: MaxAutoZoom }}
defaultEdgeOptions={{ type: "role", animated: true, selectable: false }}
onconnectstart={() => dragging.set(true)}
onconnectend={() => dragging.set(false)}
onconnect={onConnect}
>
<Background variant={BackgroundVariant.Dots} />
<Controls />
</SvelteFlow>
</div>
<style>
.heading {
border-bottom: 1px solid var(--spectrum-global-color-gray-300);
margin-bottom: 20px;
padding-bottom: 12px;
}
.description {
color: var(--spectrum-global-color-gray-600);
margin-bottom: calc(20px - var(--spacing-l));
}
.flow {
flex: 1 1 auto;
border-radius: 8px;
overflow: hidden;
position: relative;
--background-color: var(--spectrum-global-color-gray-50);
--node-background: var(--spectrum-global-color-gray-100);
--node-background-hover: var(--spectrum-global-color-gray-300);
--border-color: var(--spectrum-global-color-gray-300);
--edge-color: var(--spectrum-global-color-gray-500);
--handle-color: var(--spectrum-global-color-gray-600);
--selected-color: var(--spectrum-global-color-blue-400);
}
/* Customise svelte-flow theme */
.flow :global(.svelte-flow) {
/* Panel */
--xy-background-color: var(--background-color);
/* Controls */
--xy-controls-button-background-color: var(--node-background);
--xy-controls-button-background-color-hover: var(--node-background-hover);
--xy-controls-button-border-color: var(--border-color);
/* Handles */
--xy-handle-background-color: var(--handle-color);
--xy-handle-border-color: var(--handle-color);
/* Edges */
--xy-edge-stroke: var(--edge-color);
--xy-edge-stroke-selected: var(--edge-color);
--xy-edge-stroke-width: 2px;
}
</style>

View File

@ -1,5 +1,5 @@
<script>
import { Handle, Position, useSvelteFlow } from "@xyflow/svelte"
import { Handle, Position, useSvelteFlow, NodeToolbar } from "@xyflow/svelte"
import {
Icon,
Input,
@ -16,8 +16,9 @@
export let data
export let id
export let selected
const { nodes, edges, dragging, updateRole, deleteRole } = getContext("flow")
const { dragging, updateRole, deleteRole } = getContext("flow")
const flow = useSvelteFlow()
let anchor
@ -48,34 +49,31 @@
return null
}
const openPopover = () => {
const openPopover = e => {
e.stopPropagation()
tempDisplayName = data.displayName
tempDescription = data.description
tempColor = data.color
modal.show()
}
const saveChanges = async () => {
const newData = {
const saveChanges = () => {
updateRole(id, {
displayName: tempDisplayName,
description: tempDescription,
color: tempColor,
}
flow.updateNodeData(id, newData)
await updateRole(id)
})
}
const doAutoLayout = () => {
const layout = autoLayout({ nodes: $nodes, edges: $edges })
nodes.set(layout.nodes)
edges.set(layout.edges)
flow.fitView({ maxZoom: MaxAutoZoom, duration: ZoomDuration })
const handleDelete = async e => {
e.stopPropagation()
await deleteRole(id)
}
</script>
<div
class="node"
class:selected={false}
class:selected
style={`--color:${data.color}; --width:${NodeWidth}px; --height:${NodeHeight}px;`}
bind:this={anchor}
>
@ -88,12 +86,7 @@
{#if data.custom}
<div class="buttons">
<Icon size="S" name="Edit" hoverable on:click={openPopover} />
<Icon
size="S"
name="Delete"
hoverable
on:click={() => deleteRole(id)}
/>
<Icon size="S" name="Delete" hoverable on:click={handleDelete} />
</div>
{/if}
</div>
@ -153,9 +146,11 @@
display: flex;
flex-direction: row;
box-sizing: border-box;
cursor: pointer;
}
.node.selected {
background: var(--spectrum-global-color-blue-100);
cursor: grab;
}
.color {
border-top-left-radius: 4px;
@ -207,7 +202,7 @@
color: var(--spectrum-global-color-gray-600);
font-size: 12px;
}
.node:hover .buttons {
.node.selected .buttons {
display: flex;
}
.node :global(.svelte-flow__handle) {

View File

@ -23,7 +23,12 @@ export function createRolesStore() {
roles.sort((a, b) => {
const priorityA = RoleUtils.getRolePriority(a._id)
const priorityB = RoleUtils.getRolePriority(b._id)
return priorityA > priorityB ? -1 : 1
if (priorityA !== priorityB) {
return priorityA > priorityB ? -1 : 1
}
const nameA = a.uiMetadata?.displayName || a.name
const nameB = b.uiMetadata?.displayName || b.name
return nameA < nameB ? -1 : 1
})
)
}