Add new table and enhance studio layout logic
Introduce `department_selfserve_studio_layouts` table for department-specific layouts and implement advanced auto-layout functionality in the DepartmentSelfServeStudio module. Added custom node definitions, updated styling, and integrated new logics for sorting and visualizing nodes in the Vue Flow interface.
This commit is contained in:
+1
-1
@@ -5173,7 +5173,7 @@ paths:
|
||||
tags:
|
||||
- Self-Serve
|
||||
summary: Get all-in-one self-serve studio graph
|
||||
description: Returns the replacement studio workspace graph with nodes, edges, resolved lookup labels, validation, layout, versioning, simulator defaults, gateway workspace, and permissions.
|
||||
description: Returns the replacement studio workspace graph with nodes, edges, resolved lookup labels, validation, layout, versioning, simulator defaults, gateway workspace, and permissions. Vehicle type lookups and scope nodes are derived from selectable wash products.
|
||||
operationId: getSelfserveStudioGraph
|
||||
parameters:
|
||||
- name: department
|
||||
|
||||
+631
-46
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { VueFlow, useVueFlow } from "@vue-flow/core";
|
||||
import { Handle, Position, VueFlow, useVueFlow } from "@vue-flow/core";
|
||||
import "@vue-flow/core/dist/style.css";
|
||||
import "@vue-flow/core/dist/theme-default.css";
|
||||
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
||||
@@ -14,7 +14,7 @@ import { useAppToast } from "@/composables/useAppToast.js";
|
||||
|
||||
const currentRoute = useRoute();
|
||||
const toast = useAppToast();
|
||||
const { fitView, zoomIn, zoomOut } = useVueFlow({ id: "self-serve-studio-flow" });
|
||||
const { fitView, getViewport, zoomIn, zoomOut } = useVueFlow({ id: "self-serve-studio-flow" });
|
||||
|
||||
const parseIntOrZero = (value) => {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
@@ -64,6 +64,64 @@ const NODE_TEMPLATES = [
|
||||
{ kind: "task", label: "Task", icon: "fas fa-list-check" },
|
||||
];
|
||||
|
||||
const STUDIO_NODE_TYPES = {
|
||||
runtime_checkpoint: "studio-runtime",
|
||||
lane: "studio-scope",
|
||||
machine_type: "studio-scope",
|
||||
vehicle_type: "studio-scope",
|
||||
question: "studio-question",
|
||||
condition: "studio-condition",
|
||||
rule: "studio-rule",
|
||||
task: "studio-task",
|
||||
edge_gateway: "studio-gateway",
|
||||
relay_binding: "studio-relay-binding",
|
||||
relay: "studio-relay",
|
||||
};
|
||||
|
||||
const AUTO_LAYOUT_NODE_SIZES = {
|
||||
default: { width: 276, height: 164 },
|
||||
runtime_checkpoint: { width: 276, height: 126 },
|
||||
lane: { width: 276, height: 156 },
|
||||
machine_type: { width: 276, height: 156 },
|
||||
vehicle_type: { width: 276, height: 156 },
|
||||
condition: { width: 276, height: 178 },
|
||||
rule: { width: 276, height: 178 },
|
||||
question: { width: 276, height: 178 },
|
||||
task: { width: 276, height: 190 },
|
||||
edge_gateway: { width: 276, height: 188 },
|
||||
relay_binding: { width: 276, height: 162 },
|
||||
relay: { width: 276, height: 152 },
|
||||
};
|
||||
|
||||
const AUTO_LAYOUT_COLUMNS = [
|
||||
{ id: "scope", kinds: ["lane", "machine_type", "vehicle_type"], top: 230 },
|
||||
{ id: "conditions", kinds: ["condition"], top: 230 },
|
||||
{ id: "rules", kinds: ["rule"], top: 230 },
|
||||
{ id: "questions", kinds: ["question"], top: 230 },
|
||||
{ id: "tasks", kinds: ["task"], top: 230 },
|
||||
{ id: "hardware", kinds: ["edge_gateway", "relay_binding", "relay"], top: 230 },
|
||||
];
|
||||
|
||||
const AUTO_LAYOUT_KIND_ORDER = {
|
||||
runtime_checkpoint: 0,
|
||||
lane: 10,
|
||||
machine_type: 20,
|
||||
vehicle_type: 30,
|
||||
condition: 40,
|
||||
rule: 50,
|
||||
question: 60,
|
||||
task: 70,
|
||||
edge_gateway: 80,
|
||||
relay_binding: 90,
|
||||
relay: 100,
|
||||
};
|
||||
|
||||
const AUTO_LAYOUT_RUNTIME_STAGE_ORDER = {
|
||||
start: 0,
|
||||
eligible: 1,
|
||||
finish: 2,
|
||||
};
|
||||
|
||||
const flowNodes = ref([]);
|
||||
const flowEdges = ref([]);
|
||||
const graphPayload = ref(null);
|
||||
@@ -122,6 +180,145 @@ const labelFor = (type, id) => {
|
||||
|| `${type.replaceAll("_", " ")} ${normalizedId}`;
|
||||
};
|
||||
|
||||
const valueText = (value, fallback = "None") => {
|
||||
if (value === null || value === undefined || value === "" || value === 0 || value === "0") {
|
||||
return fallback;
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const rawFor = (data) => data?.raw || {};
|
||||
|
||||
const studioNodeType = (kind, fallback = "default") => STUDIO_NODE_TYPES[kind] || (["input", "output"].includes(fallback) ? "studio-runtime" : "studio-default");
|
||||
|
||||
const scopeParts = (data) => {
|
||||
const scope = data?.scope || {};
|
||||
if (Object.keys(scope).length > 0) {
|
||||
return [
|
||||
scope.lane,
|
||||
scope.product,
|
||||
scope.machine_type,
|
||||
].filter((entry) => entry && entry !== "All");
|
||||
}
|
||||
const raw = rawFor(data);
|
||||
return [
|
||||
raw.lane ? labelFor("lanes", raw.lane) : null,
|
||||
raw.product ? labelFor("products", raw.product) : null,
|
||||
raw.machine_type_id ? labelFor("machine_types", raw.machine_type_id) : null,
|
||||
].filter(Boolean);
|
||||
};
|
||||
|
||||
const nodeScopeLabel = (data) => scopeParts(data).join(" / ") || data?.subtitle || "Shared scope";
|
||||
|
||||
const ruleTargetLabel = (data) => {
|
||||
const raw = rawFor(data);
|
||||
const objectType = String(raw.object_type || "").toLowerCase();
|
||||
const objectId = raw.object_id;
|
||||
const lookupTypeByObject = {
|
||||
question: "questions",
|
||||
condition: "conditions",
|
||||
task: "tasks",
|
||||
};
|
||||
return objectType && objectId
|
||||
? `${objectType}: ${labelFor(lookupTypeByObject[objectType] || `${objectType}s`, objectId)}`
|
||||
: "Unbound object";
|
||||
};
|
||||
|
||||
const conditionGateLabel = (data) => {
|
||||
const raw = rawFor(data);
|
||||
return raw.condition_id ? labelFor("conditions", raw.condition_id) : "Always visible";
|
||||
};
|
||||
|
||||
const taskGateLabel = (data) => {
|
||||
const raw = rawFor(data);
|
||||
const gateType = String(raw.gate_type || "ALWAYS").toUpperCase();
|
||||
if (gateType === "QUESTION" && raw.gate_ref_id) {
|
||||
return `Question: ${labelFor("questions", raw.gate_ref_id)}`;
|
||||
}
|
||||
if (gateType === "CONDITION" && raw.gate_ref_id) {
|
||||
return `Condition: ${labelFor("conditions", raw.gate_ref_id)}`;
|
||||
}
|
||||
return "Always";
|
||||
};
|
||||
|
||||
const listCount = (value) => (Array.isArray(value) ? value.length : 0);
|
||||
|
||||
const nodeEdgeCount = (nodeId, direction, kinds = []) => flowEdges.value.filter((edge) => {
|
||||
const endpoint = direction === "incoming" ? edge.target : edge.source;
|
||||
return endpoint === nodeId && (kinds.length === 0 || kinds.includes(edge?.data?.kind));
|
||||
}).length;
|
||||
|
||||
const gatewayBindingCount = (data) => listCount(rawFor(data).bindings);
|
||||
|
||||
const statusTone = (status) => {
|
||||
const normalized = String(status || "").toUpperCase();
|
||||
if (["ONLINE", "CONNECTED", "READY", "OK", "HEALTHY"].includes(normalized)) {
|
||||
return "is-success";
|
||||
}
|
||||
if (["PENDING", "UPDATING", "INSTALLING", "DISCOVERING"].includes(normalized)) {
|
||||
return "is-warning";
|
||||
}
|
||||
if (["OFFLINE", "ERROR", "FAILED", "UNHEALTHY"].includes(normalized)) {
|
||||
return "is-danger";
|
||||
}
|
||||
return "is-neutral";
|
||||
};
|
||||
|
||||
const selectorSafeNodeId = (nodeId) => String(nodeId).replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
|
||||
|
||||
const currentFlowZoom = () => {
|
||||
try {
|
||||
const zoom = Number(getViewport?.().zoom);
|
||||
return Number.isFinite(zoom) && zoom > 0 ? zoom : 1;
|
||||
} catch {
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
|
||||
const nodeLayoutSize = (node) => {
|
||||
const kind = node?.data?.kind || "default";
|
||||
const fallback = AUTO_LAYOUT_NODE_SIZES[kind] || AUTO_LAYOUT_NODE_SIZES.default;
|
||||
const dimensions = node?.dimensions || {};
|
||||
const domRect = document
|
||||
.querySelector(`.vue-flow__node[data-id="${selectorSafeNodeId(node?.id)}"]`)
|
||||
?.getBoundingClientRect();
|
||||
const zoom = currentFlowZoom();
|
||||
|
||||
return {
|
||||
width: Math.ceil(Number(dimensions.width || node?.width || (domRect?.width ? domRect.width / zoom : 0) || fallback.width)),
|
||||
height: Math.ceil(Number(dimensions.height || node?.height || (domRect?.height ? domRect.height / zoom : 0) || fallback.height)),
|
||||
};
|
||||
};
|
||||
|
||||
const autoLayoutSortValue = (node) => {
|
||||
const raw = rawFor(node?.data);
|
||||
const kind = node?.data?.kind || "unknown";
|
||||
if (kind === "runtime_checkpoint") {
|
||||
return AUTO_LAYOUT_RUNTIME_STAGE_ORDER[node?.data?.stage] ?? 99;
|
||||
}
|
||||
if (kind === "task" || kind === "question") {
|
||||
return parseIntOrZero(raw.order_priority) || parseIntOrZero(raw.id) || parseIntOrZero(node?.data?.object_id);
|
||||
}
|
||||
if (kind === "rule") {
|
||||
return (parseIntOrZero(raw.condition_id) * 10000) + (parseIntOrZero(raw.id) || parseIntOrZero(node?.data?.object_id));
|
||||
}
|
||||
return parseIntOrZero(raw.id) || parseIntOrZero(node?.data?.object_id) || 0;
|
||||
};
|
||||
|
||||
const sortAutoLayoutNodes = (nodes) => [...nodes].sort((left, right) => {
|
||||
const leftKind = left?.data?.kind || "unknown";
|
||||
const rightKind = right?.data?.kind || "unknown";
|
||||
const kindDelta = (AUTO_LAYOUT_KIND_ORDER[leftKind] ?? 999) - (AUTO_LAYOUT_KIND_ORDER[rightKind] ?? 999);
|
||||
if (kindDelta !== 0) {
|
||||
return kindDelta;
|
||||
}
|
||||
const valueDelta = autoLayoutSortValue(left) - autoLayoutSortValue(right);
|
||||
if (valueDelta !== 0) {
|
||||
return valueDelta;
|
||||
}
|
||||
return String(left?.id || "").localeCompare(String(right?.id || ""));
|
||||
});
|
||||
|
||||
const canvasNodes = computed(() => flowNodes.value.map((node) => ({
|
||||
...node,
|
||||
class: [
|
||||
@@ -194,6 +391,7 @@ const selectedRaw = computed(() => selectedNode.value?.data?.raw || {});
|
||||
|
||||
const normalizeFlowNodes = (nodes) => (Array.isArray(nodes) ? nodes : []).map((node) => ({
|
||||
...node,
|
||||
type: studioNodeType(node.data?.kind, node.type),
|
||||
data: {
|
||||
...(node.data || {}),
|
||||
label: node.data?.label || node.label || node.id,
|
||||
@@ -525,32 +723,59 @@ const importLayout = async () => {
|
||||
};
|
||||
|
||||
const autoLayout = () => {
|
||||
const columns = {
|
||||
runtime_checkpoint: 0,
|
||||
lane: 0,
|
||||
machine_type: 0,
|
||||
vehicle_type: 0,
|
||||
condition: 360,
|
||||
rule: 600,
|
||||
question: 840,
|
||||
task: 1120,
|
||||
edge_gateway: 1420,
|
||||
relay_binding: 1660,
|
||||
relay: 1900,
|
||||
};
|
||||
const offsets = {};
|
||||
const nodeSizes = new Map(flowNodes.value.map((node) => [node.id, nodeLayoutSize(node)]));
|
||||
const positions = new Map();
|
||||
const columnGap = 116;
|
||||
const rowGap = 34;
|
||||
let cursorX = 0;
|
||||
|
||||
const runtimeNodes = sortAutoLayoutNodes(
|
||||
flowNodes.value.filter((node) => node.data?.kind === "runtime_checkpoint"),
|
||||
);
|
||||
let runtimeX = 0;
|
||||
for (const node of runtimeNodes) {
|
||||
const size = nodeSizes.get(node.id) || AUTO_LAYOUT_NODE_SIZES.runtime_checkpoint;
|
||||
positions.set(node.id, { x: runtimeX, y: 0 });
|
||||
runtimeX += size.width + columnGap;
|
||||
}
|
||||
|
||||
for (const column of AUTO_LAYOUT_COLUMNS) {
|
||||
const columnNodes = sortAutoLayoutNodes(
|
||||
flowNodes.value.filter((node) => column.kinds.includes(node.data?.kind)),
|
||||
);
|
||||
if (columnNodes.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const columnWidth = Math.max(
|
||||
...columnNodes.map((node) => nodeSizes.get(node.id)?.width || AUTO_LAYOUT_NODE_SIZES.default.width),
|
||||
);
|
||||
let cursorY = column.top;
|
||||
for (const node of columnNodes) {
|
||||
const size = nodeSizes.get(node.id) || AUTO_LAYOUT_NODE_SIZES.default;
|
||||
positions.set(node.id, {
|
||||
x: cursorX + Math.max(0, (columnWidth - size.width) / 2),
|
||||
y: cursorY,
|
||||
});
|
||||
cursorY += size.height + rowGap;
|
||||
}
|
||||
cursorX += columnWidth + columnGap;
|
||||
}
|
||||
|
||||
let fallbackY = 230;
|
||||
flowNodes.value = flowNodes.value.map((node) => {
|
||||
const kind = node.data?.kind || "unknown";
|
||||
offsets[kind] = offsets[kind] || 0;
|
||||
const position = {
|
||||
x: columns[kind] ?? 220,
|
||||
y: 120 + offsets[kind] * 118,
|
||||
};
|
||||
offsets[kind] += 1;
|
||||
return { ...node, position };
|
||||
if (!positions.has(node.id)) {
|
||||
const size = nodeSizes.get(node.id) || AUTO_LAYOUT_NODE_SIZES.default;
|
||||
positions.set(node.id, {
|
||||
x: cursorX,
|
||||
y: fallbackY,
|
||||
});
|
||||
fallbackY += size.height + rowGap;
|
||||
}
|
||||
return { ...node, position: positions.get(node.id) };
|
||||
});
|
||||
scheduleLayoutSave();
|
||||
nextTick(() => fitView({ padding: 0.14, duration: 250 }));
|
||||
nextTick(() => fitView({ padding: 0.18, duration: 250 }));
|
||||
};
|
||||
|
||||
const matchesNodeFilters = (node) => {
|
||||
@@ -746,7 +971,7 @@ onBeforeUnmount(() => {
|
||||
v-model:nodes="flowNodes"
|
||||
v-model:edges="flowEdges"
|
||||
:fit-view-on-init="true"
|
||||
:min-zoom="0.38"
|
||||
:min-zoom="0.18"
|
||||
:max-zoom="1.7"
|
||||
:nodes-draggable="true"
|
||||
:nodes-connectable="permissions.can_edit"
|
||||
@@ -755,31 +980,191 @@ onBeforeUnmount(() => {
|
||||
@node-click="handleNodeClick"
|
||||
@pane-click="selectedNodeId = null"
|
||||
>
|
||||
<template #node-default="{ data, selected }">
|
||||
<div class="studio-flow-node" :class="[nodeTone(data.kind), { 'is-selected': selected }]">
|
||||
<div class="studio-flow-node-header">
|
||||
<span class="icon"><i :class="nodeIcon(data.kind)"></i></span>
|
||||
<strong>{{ data.label }}</strong>
|
||||
<template #node-studio-runtime="{ id, data, selected, connectable }">
|
||||
<div class="studio-flow-node studio-custom-node studio-runtime-node" :class="{ 'is-selected': selected }" data-testid="studio-custom-node-runtime">
|
||||
<Handle v-if="data.stage !== 'start'" class="studio-handle is-target" type="target" :position="Position.Left" :connectable="connectable" />
|
||||
<div class="studio-node-topline">
|
||||
<span class="studio-node-icon"><i :class="nodeIcon(data.kind)"></i></span>
|
||||
<span class="studio-node-type">Runtime</span>
|
||||
<span class="studio-node-id">{{ data.stage || id }}</span>
|
||||
</div>
|
||||
<p v-if="data.subtitle" class="studio-flow-node-subtitle">{{ data.subtitle }}</p>
|
||||
<h4>{{ data.label }}</h4>
|
||||
<p>{{ data.subtitle }}</p>
|
||||
<div class="studio-node-chip-row">
|
||||
<span>{{ nodeEdgeCount(id, 'incoming') }} in</span>
|
||||
<span>{{ nodeEdgeCount(id, 'outgoing') }} out</span>
|
||||
</div>
|
||||
<Handle v-if="data.stage !== 'finish'" class="studio-handle is-source" type="source" :position="Position.Right" :connectable="connectable" />
|
||||
</div>
|
||||
</template>
|
||||
<template #node-input="{ data, selected }">
|
||||
<div class="studio-flow-node is-runtime" :class="{ 'is-selected': selected }">
|
||||
<div class="studio-flow-node-header">
|
||||
<span class="icon"><i :class="nodeIcon(data.kind)"></i></span>
|
||||
<strong>{{ data.label }}</strong>
|
||||
|
||||
<template #node-studio-scope="{ id, data, selected, connectable }">
|
||||
<div class="studio-flow-node studio-custom-node studio-scope-node" :class="{ 'is-selected': selected }" data-testid="studio-custom-node-scope">
|
||||
<Handle class="studio-handle is-target" type="target" :position="Position.Left" :connectable="connectable" />
|
||||
<div class="studio-node-topline">
|
||||
<span class="studio-node-icon"><i :class="nodeIcon(data.kind)"></i></span>
|
||||
<span class="studio-node-type">{{ String(data.kind || 'scope').replace('_', ' ') }}</span>
|
||||
<span class="studio-node-id">#{{ data.object_id }}</span>
|
||||
</div>
|
||||
<p class="studio-flow-node-subtitle">{{ data.subtitle }}</p>
|
||||
<h4>{{ data.label }}</h4>
|
||||
<dl class="studio-node-facts">
|
||||
<div><dt>Scope use</dt><dd>{{ nodeEdgeCount(id, 'outgoing', ['scope']) }} linked nodes</dd></div>
|
||||
<div><dt>Mode</dt><dd>{{ data.subtitle }}</dd></div>
|
||||
</dl>
|
||||
<Handle class="studio-handle is-source" type="source" :position="Position.Right" :connectable="connectable" />
|
||||
</div>
|
||||
</template>
|
||||
<template #node-output="{ data, selected }">
|
||||
<div class="studio-flow-node is-runtime" :class="{ 'is-selected': selected }">
|
||||
<div class="studio-flow-node-header">
|
||||
<span class="icon"><i :class="nodeIcon(data.kind)"></i></span>
|
||||
<strong>{{ data.label }}</strong>
|
||||
|
||||
<template #node-studio-question="{ id, data, selected, connectable }">
|
||||
<div class="studio-flow-node studio-custom-node studio-question-node" :class="{ 'is-selected': selected }" data-testid="studio-custom-node-question">
|
||||
<Handle class="studio-handle is-target" type="target" :position="Position.Left" :connectable="connectable" />
|
||||
<div class="studio-node-topline">
|
||||
<span class="studio-node-icon"><i :class="nodeIcon(data.kind)"></i></span>
|
||||
<span class="studio-node-type">Question</span>
|
||||
<span class="studio-node-id">#{{ data.object_id }}</span>
|
||||
</div>
|
||||
<p class="studio-flow-node-subtitle">{{ data.subtitle }}</p>
|
||||
<h4>{{ data.label }}</h4>
|
||||
<dl class="studio-node-facts">
|
||||
<div><dt>Scope</dt><dd>{{ nodeScopeLabel(data) }}</dd></div>
|
||||
<div><dt>Visibility</dt><dd>{{ conditionGateLabel(data) }}</dd></div>
|
||||
</dl>
|
||||
<div class="studio-node-chip-row">
|
||||
<span>Order {{ valueText(rawFor(data).order_priority, '-') }}</span>
|
||||
<span>{{ nodeEdgeCount(id, 'outgoing', ['task_gate']) }} task gates</span>
|
||||
</div>
|
||||
<Handle class="studio-handle is-source" type="source" :position="Position.Right" :connectable="connectable" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #node-studio-condition="{ id, data, selected, connectable }">
|
||||
<div class="studio-flow-node studio-custom-node studio-condition-node" :class="{ 'is-selected': selected }" data-testid="studio-custom-node-condition">
|
||||
<Handle class="studio-handle is-target" type="target" :position="Position.Left" :connectable="connectable" />
|
||||
<div class="studio-node-topline">
|
||||
<span class="studio-node-icon"><i :class="nodeIcon(data.kind)"></i></span>
|
||||
<span class="studio-node-type">Condition</span>
|
||||
<span class="studio-node-id">#{{ data.object_id }}</span>
|
||||
</div>
|
||||
<h4>{{ data.label }}</h4>
|
||||
<dl class="studio-node-facts">
|
||||
<div><dt>Scope</dt><dd>{{ nodeScopeLabel(data) }}</dd></div>
|
||||
<div><dt>Parent</dt><dd>{{ rawFor(data).condition_id ? labelFor('conditions', rawFor(data).condition_id) : 'Root condition' }}</dd></div>
|
||||
</dl>
|
||||
<div class="studio-node-chip-row">
|
||||
<span>{{ nodeEdgeCount(id, 'incoming', ['condition_rule']) }} rules</span>
|
||||
<span>{{ nodeEdgeCount(id, 'outgoing', ['visibility_gate', 'task_gate']) }} gates</span>
|
||||
</div>
|
||||
<Handle class="studio-handle is-source" type="source" :position="Position.Right" :connectable="connectable" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #node-studio-rule="{ id, data, selected, connectable }">
|
||||
<div class="studio-flow-node studio-custom-node studio-rule-node" :class="{ 'is-selected': selected }" data-testid="studio-custom-node-rule">
|
||||
<Handle class="studio-handle is-target" type="target" :position="Position.Left" :connectable="connectable" />
|
||||
<div class="studio-node-topline">
|
||||
<span class="studio-node-icon"><i :class="nodeIcon(data.kind)"></i></span>
|
||||
<span class="studio-node-type">Rule</span>
|
||||
<span class="studio-node-id">#{{ data.object_id }}</span>
|
||||
</div>
|
||||
<h4>{{ data.label }}</h4>
|
||||
<dl class="studio-node-facts">
|
||||
<div><dt>Condition</dt><dd>{{ labelFor('conditions', rawFor(data).condition_id) }}</dd></div>
|
||||
<div><dt>Input</dt><dd>{{ ruleTargetLabel(data) }}</dd></div>
|
||||
</dl>
|
||||
<div class="studio-node-chip-row">
|
||||
<span>{{ valueText(rawFor(data).type, 'RULE') }}</span>
|
||||
<span>{{ nodeEdgeCount(id, 'incoming', ['rule_input']) }} inputs</span>
|
||||
</div>
|
||||
<Handle class="studio-handle is-source" type="source" :position="Position.Right" :connectable="connectable" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #node-studio-task="{ id, data, selected, connectable }">
|
||||
<div class="studio-flow-node studio-custom-node studio-task-node" :class="{ 'is-selected': selected }" data-testid="studio-custom-node-task">
|
||||
<Handle class="studio-handle is-target" type="target" :position="Position.Left" :connectable="connectable" />
|
||||
<div class="studio-node-topline">
|
||||
<span class="studio-node-icon"><i :class="nodeIcon(data.kind)"></i></span>
|
||||
<span class="studio-node-type">Task</span>
|
||||
<span class="studio-node-id">#{{ data.object_id }}</span>
|
||||
</div>
|
||||
<h4>{{ data.label }}</h4>
|
||||
<dl class="studio-node-facts">
|
||||
<div><dt>Gate</dt><dd>{{ taskGateLabel(data) }}</dd></div>
|
||||
<div><dt>Scope</dt><dd>{{ nodeScopeLabel(data) }}</dd></div>
|
||||
</dl>
|
||||
<div class="studio-node-chip-row">
|
||||
<span>{{ listCount(rawFor(data).services) }} services</span>
|
||||
<span>{{ listCount(rawFor(data).buttons) }} buttons</span>
|
||||
<span>Order {{ valueText(rawFor(data).order_priority, '-') }}</span>
|
||||
</div>
|
||||
<Handle class="studio-handle is-source" type="source" :position="Position.Right" :connectable="connectable" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #node-studio-gateway="{ id, data, selected, connectable }">
|
||||
<div class="studio-flow-node studio-custom-node studio-gateway-node" :class="{ 'is-selected': selected }" data-testid="studio-custom-node-gateway">
|
||||
<Handle class="studio-handle is-target" type="target" :position="Position.Left" :connectable="connectable" />
|
||||
<div class="studio-node-topline">
|
||||
<span class="studio-node-icon"><i :class="nodeIcon(data.kind)"></i></span>
|
||||
<span class="studio-node-type">Edge gateway</span>
|
||||
<span class="studio-node-id">#{{ data.object_id }}</span>
|
||||
</div>
|
||||
<h4>{{ data.label }}</h4>
|
||||
<div class="studio-node-status" :class="statusTone(rawFor(data).status || data.subtitle)">
|
||||
{{ rawFor(data).status || data.subtitle || 'UNKNOWN' }}
|
||||
</div>
|
||||
<dl class="studio-node-facts">
|
||||
<div><dt>Bindings</dt><dd>{{ gatewayBindingCount(data) }}</dd></div>
|
||||
<div><dt>Address</dt><dd>{{ rawFor(data).host || rawFor(data).hostname || rawFor(data).ip || 'Managed gateway' }}</dd></div>
|
||||
</dl>
|
||||
<Handle class="studio-handle is-source" type="source" :position="Position.Right" :connectable="connectable" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #node-studio-relay-binding="{ id, data, selected, connectable }">
|
||||
<div class="studio-flow-node studio-custom-node studio-binding-node" :class="{ 'is-selected': selected }" data-testid="studio-custom-node-binding">
|
||||
<Handle class="studio-handle is-target" type="target" :position="Position.Left" :connectable="connectable" />
|
||||
<div class="studio-node-topline">
|
||||
<span class="studio-node-icon"><i :class="nodeIcon(data.kind)"></i></span>
|
||||
<span class="studio-node-type">Relay binding</span>
|
||||
<span class="studio-node-id">{{ rawFor(data).role || 'Relay' }}</span>
|
||||
</div>
|
||||
<h4>{{ data.label }}</h4>
|
||||
<dl class="studio-node-facts">
|
||||
<div><dt>Relay</dt><dd>{{ rawFor(data).relay_id || data.object_id }}</dd></div>
|
||||
<div><dt>Channel</dt><dd>{{ valueText(rawFor(data).channel || rawFor(data).output, 'Default') }}</dd></div>
|
||||
</dl>
|
||||
<Handle class="studio-handle is-source" type="source" :position="Position.Right" :connectable="connectable" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #node-studio-relay="{ id, data, selected, connectable }">
|
||||
<div class="studio-flow-node studio-custom-node studio-relay-node" :class="{ 'is-selected': selected }" data-testid="studio-custom-node-relay">
|
||||
<Handle class="studio-handle is-target" type="target" :position="Position.Left" :connectable="connectable" />
|
||||
<div class="studio-node-topline">
|
||||
<span class="studio-node-icon"><i :class="nodeIcon(data.kind)"></i></span>
|
||||
<span class="studio-node-type">Relay</span>
|
||||
<span class="studio-node-id">{{ data.object_id }}</span>
|
||||
</div>
|
||||
<h4>{{ data.label }}</h4>
|
||||
<dl class="studio-node-facts">
|
||||
<div><dt>Status</dt><dd>{{ rawFor(data).status || rawFor(data).online || 'Unknown' }}</dd></div>
|
||||
<div><dt>Lane links</dt><dd>{{ nodeEdgeCount(id, 'outgoing', ['lane_relay']) }}</dd></div>
|
||||
</dl>
|
||||
<Handle class="studio-handle is-source" type="source" :position="Position.Right" :connectable="connectable" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #node-studio-default="{ id, data, selected, connectable }">
|
||||
<div class="studio-flow-node studio-custom-node studio-default-node" :class="[nodeTone(data.kind), { 'is-selected': selected }]" data-testid="studio-custom-node-default">
|
||||
<Handle class="studio-handle is-target" type="target" :position="Position.Left" :connectable="connectable" />
|
||||
<div class="studio-node-topline">
|
||||
<span class="studio-node-icon"><i :class="nodeIcon(data.kind)"></i></span>
|
||||
<span class="studio-node-type">{{ data.kind || 'Node' }}</span>
|
||||
<span class="studio-node-id">{{ id }}</span>
|
||||
</div>
|
||||
<h4>{{ data.label }}</h4>
|
||||
<p v-if="data.subtitle">{{ data.subtitle }}</p>
|
||||
<Handle class="studio-handle is-source" type="source" :position="Position.Right" :connectable="connectable" />
|
||||
</div>
|
||||
</template>
|
||||
</VueFlow>
|
||||
@@ -1265,13 +1650,17 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.studio-flow-node {
|
||||
--node-accent: #64748b;
|
||||
--node-soft: #f8fafc;
|
||||
background: #ffffff;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
|
||||
color: #111827;
|
||||
min-width: 184px;
|
||||
padding: 10px 12px;
|
||||
min-width: 246px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
width: 276px;
|
||||
}
|
||||
|
||||
.studio-flow-node.is-selected {
|
||||
@@ -1279,6 +1668,202 @@ onBeforeUnmount(() => {
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.18);
|
||||
}
|
||||
|
||||
.studio-custom-node {
|
||||
border-left: 4px solid var(--node-accent);
|
||||
}
|
||||
|
||||
.studio-custom-node h4 {
|
||||
color: #111827;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
padding: 10px 12px 2px;
|
||||
}
|
||||
|
||||
.studio-custom-node p {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
padding: 0 12px 10px;
|
||||
}
|
||||
|
||||
.studio-node-topline {
|
||||
align-items: center;
|
||||
background: var(--node-soft);
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
grid-template-columns: 20px minmax(0, 1fr) auto;
|
||||
min-height: 34px;
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
.studio-node-icon {
|
||||
align-items: center;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
border-radius: 6px;
|
||||
color: var(--node-accent);
|
||||
display: inline-flex;
|
||||
height: 20px;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.studio-node-type,
|
||||
.studio-node-id {
|
||||
color: #475569;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.studio-node-id {
|
||||
color: #64748b;
|
||||
max-width: 92px;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.studio-node-facts {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
margin: 8px 10px;
|
||||
}
|
||||
|
||||
.studio-node-facts div {
|
||||
align-items: baseline;
|
||||
background: #f8fafc;
|
||||
border-radius: 5px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: 74px minmax(0, 1fr);
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.studio-node-facts dt {
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.studio-node-facts dd {
|
||||
color: #1f2937;
|
||||
font-size: 11px;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.studio-node-chip-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
padding: 0 10px 10px;
|
||||
}
|
||||
|
||||
.studio-node-chip-row span,
|
||||
.studio-node-status {
|
||||
background: #f1f5f9;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 999px;
|
||||
color: #475569;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
padding: 5px 7px;
|
||||
}
|
||||
|
||||
.studio-node-status {
|
||||
display: inline-flex;
|
||||
margin: 8px 10px 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.studio-node-status.is-success {
|
||||
background: #ecfdf5;
|
||||
border-color: #bbf7d0;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.studio-node-status.is-warning {
|
||||
background: #fffbeb;
|
||||
border-color: #fde68a;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.studio-node-status.is-danger {
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.studio-node-status.is-neutral {
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.studio-handle {
|
||||
background: #ffffff;
|
||||
border: 2px solid var(--node-accent);
|
||||
height: 11px;
|
||||
width: 11px;
|
||||
}
|
||||
|
||||
.studio-handle.is-target {
|
||||
left: -7px;
|
||||
}
|
||||
|
||||
.studio-handle.is-source {
|
||||
right: -7px;
|
||||
}
|
||||
|
||||
.studio-runtime-node {
|
||||
--node-accent: #0284c7;
|
||||
--node-soft: #f0f9ff;
|
||||
}
|
||||
|
||||
.studio-scope-node {
|
||||
--node-accent: #475569;
|
||||
--node-soft: #f8fafc;
|
||||
}
|
||||
|
||||
.studio-question-node {
|
||||
--node-accent: #2563eb;
|
||||
--node-soft: #eff6ff;
|
||||
}
|
||||
|
||||
.studio-condition-node {
|
||||
--node-accent: #0f766e;
|
||||
--node-soft: #f0fdfa;
|
||||
}
|
||||
|
||||
.studio-rule-node {
|
||||
--node-accent: #7e22ce;
|
||||
--node-soft: #faf5ff;
|
||||
}
|
||||
|
||||
.studio-task-node {
|
||||
--node-accent: #ea580c;
|
||||
--node-soft: #fff7ed;
|
||||
}
|
||||
|
||||
.studio-gateway-node,
|
||||
.studio-binding-node,
|
||||
.studio-relay-node {
|
||||
--node-accent: #4f46e5;
|
||||
--node-soft: #eef2ff;
|
||||
}
|
||||
|
||||
.studio-flow-node-header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
|
||||
@@ -11,10 +11,13 @@ function buildStudioGraph() {
|
||||
const lookups = {
|
||||
departments: [{ id: 6, label: "Roskilde" }],
|
||||
lanes: [{ id: 7, label: "Lane 7" }],
|
||||
products: [{ id: 2, label: "Truck wash" }],
|
||||
products: [{ id: 2, label: "Forvogn", is_wash: 1, subscription_allowed: 1 }],
|
||||
machine_types: [{ id: 1001, label: "Portal" }],
|
||||
vehicle_types: [{ id: 2, product: 2, label: "Truck" }],
|
||||
questions: [{ id: 11, label: "Are mirrors folded?" }],
|
||||
vehicle_types: [{ id: 2, product: 2, product_id: 2, label: "Forvogn", source: "products" }],
|
||||
questions: [
|
||||
{ id: 11, label: "Are mirrors folded?" },
|
||||
{ id: 12, label: "Is the lift lowered?" },
|
||||
],
|
||||
conditions: [{ id: 21, label: "Trailer present" }],
|
||||
rules: [{ id: 31, label: "Mirror answer" }],
|
||||
tasks: [{ id: 41, label: "Fold mirrors" }],
|
||||
@@ -24,10 +27,10 @@ function buildStudioGraph() {
|
||||
labels: {
|
||||
departments: { 6: "Roskilde" },
|
||||
lanes: { 7: "Lane 7" },
|
||||
products: { 2: "Truck wash" },
|
||||
products: { 2: "Forvogn" },
|
||||
machine_types: { 1001: "Portal" },
|
||||
vehicle_types: { 2: "Truck" },
|
||||
questions: { 11: "Are mirrors folded?" },
|
||||
vehicle_types: { 2: "Forvogn" },
|
||||
questions: { 11: "Are mirrors folded?", 12: "Is the lift lowered?" },
|
||||
conditions: { 21: "Trailer present" },
|
||||
rules: { 31: "Mirror answer" },
|
||||
tasks: { 41: "Fold mirrors" },
|
||||
@@ -38,6 +41,18 @@ function buildStudioGraph() {
|
||||
|
||||
return {
|
||||
nodes: [
|
||||
{
|
||||
id: "vehicle_type:2",
|
||||
type: "default",
|
||||
position: { x: 0, y: 140 },
|
||||
data: {
|
||||
kind: "vehicle_type",
|
||||
object_id: 2,
|
||||
label: "Forvogn",
|
||||
subtitle: "Vehicle scope",
|
||||
raw: { id: 2, product: 2, product_id: 2, name: "Forvogn", label: "Forvogn", source: "products" },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "condition:21",
|
||||
type: "default",
|
||||
@@ -46,7 +61,7 @@ function buildStudioGraph() {
|
||||
kind: "condition",
|
||||
object_id: 21,
|
||||
label: "Trailer present",
|
||||
subtitle: "Lane 7 / Truck wash",
|
||||
subtitle: "Lane 7 / Forvogn",
|
||||
raw: { id: 21, department: 6, lane: 7, product: 2, name: "Trailer present", description: "" },
|
||||
},
|
||||
},
|
||||
@@ -58,7 +73,7 @@ function buildStudioGraph() {
|
||||
kind: "question",
|
||||
object_id: 11,
|
||||
label: "Are mirrors folded?",
|
||||
subtitle: "Lane 7 / Truck wash",
|
||||
subtitle: "Lane 7 / Forvogn",
|
||||
raw: {
|
||||
id: 11,
|
||||
department: 6,
|
||||
@@ -71,6 +86,27 @@ function buildStudioGraph() {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "question:12",
|
||||
type: "default",
|
||||
position: { x: 642, y: 150 },
|
||||
data: {
|
||||
kind: "question",
|
||||
object_id: 12,
|
||||
label: "Is the lift lowered?",
|
||||
subtitle: "Lane 7 / Forvogn",
|
||||
raw: {
|
||||
id: 12,
|
||||
department: 6,
|
||||
lane: 7,
|
||||
product: 2,
|
||||
condition_id: null,
|
||||
question: "Is the lift lowered?",
|
||||
description: "",
|
||||
order_priority: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "task:41",
|
||||
type: "default",
|
||||
@@ -79,7 +115,7 @@ function buildStudioGraph() {
|
||||
kind: "task",
|
||||
object_id: 41,
|
||||
label: "Fold mirrors",
|
||||
subtitle: "Lane 7 / Truck wash",
|
||||
subtitle: "Lane 7 / Forvogn",
|
||||
raw: {
|
||||
id: 41,
|
||||
department: 6,
|
||||
@@ -95,6 +131,26 @@ function buildStudioGraph() {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "rule:31",
|
||||
type: "default",
|
||||
position: { x: 520, y: 320 },
|
||||
data: {
|
||||
kind: "rule",
|
||||
object_id: 31,
|
||||
label: "Mirror answer",
|
||||
subtitle: "IS_TRUE Are mirrors folded?",
|
||||
raw: {
|
||||
id: 31,
|
||||
condition_id: 21,
|
||||
type: "IS_TRUE",
|
||||
object_type: "question",
|
||||
object_id: 11,
|
||||
name: "Mirror answer",
|
||||
description: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "gateway:701",
|
||||
type: "default",
|
||||
@@ -109,6 +165,38 @@ function buildStudioGraph() {
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: "scope:vehicle_type:2:condition:21",
|
||||
source: "vehicle_type:2",
|
||||
target: "condition:21",
|
||||
type: "smoothstep",
|
||||
label: "scope",
|
||||
data: { kind: "scope" },
|
||||
},
|
||||
{
|
||||
id: "scope:vehicle_type:2:question:11",
|
||||
source: "vehicle_type:2",
|
||||
target: "question:11",
|
||||
type: "smoothstep",
|
||||
label: "scope",
|
||||
data: { kind: "scope" },
|
||||
},
|
||||
{
|
||||
id: "scope:vehicle_type:2:question:12",
|
||||
source: "vehicle_type:2",
|
||||
target: "question:12",
|
||||
type: "smoothstep",
|
||||
label: "scope",
|
||||
data: { kind: "scope" },
|
||||
},
|
||||
{
|
||||
id: "scope:vehicle_type:2:task:41",
|
||||
source: "vehicle_type:2",
|
||||
target: "task:41",
|
||||
type: "smoothstep",
|
||||
label: "scope",
|
||||
data: { kind: "scope" },
|
||||
},
|
||||
{
|
||||
id: "question-gate:21:11",
|
||||
source: "condition:21",
|
||||
@@ -117,6 +205,22 @@ function buildStudioGraph() {
|
||||
label: "show if",
|
||||
data: { kind: "visibility_gate" },
|
||||
},
|
||||
{
|
||||
id: "rule-input:question:11:31",
|
||||
source: "question:11",
|
||||
target: "rule:31",
|
||||
type: "smoothstep",
|
||||
label: "IS_TRUE",
|
||||
data: { kind: "rule_input" },
|
||||
},
|
||||
{
|
||||
id: "rule-owner:21:31",
|
||||
source: "rule:31",
|
||||
target: "condition:21",
|
||||
type: "smoothstep",
|
||||
label: "rule of",
|
||||
data: { kind: "condition_rule" },
|
||||
},
|
||||
{
|
||||
id: "task-gate:question:11:41",
|
||||
source: "question:11",
|
||||
@@ -184,7 +288,7 @@ async function installStudioRoutes(page, graph, captured) {
|
||||
kind: "question",
|
||||
object_id: 99,
|
||||
label: operation.data.question,
|
||||
subtitle: "Lane 7 / Truck wash",
|
||||
subtitle: "Lane 7 / Forvogn",
|
||||
raw: { id: 99, ...operation.data },
|
||||
},
|
||||
});
|
||||
@@ -252,6 +356,37 @@ test.describe("All-in-one self-serve studio", () => {
|
||||
await expect(page.getByTestId("studio-flow-actions")).toBeVisible();
|
||||
await expect(page.getByText("Are mirrors folded?").first()).toBeVisible();
|
||||
await expect(page.getByText("Roskilde Edge 01").first()).toBeVisible();
|
||||
await expect(page.getByTestId("studio-custom-node-question").first()).toContainText("Visibility");
|
||||
await expect(page.getByTestId("studio-custom-node-condition").first()).toContainText("Root condition");
|
||||
await expect(page.getByTestId("studio-custom-node-rule").first()).toContainText("Input");
|
||||
await expect(page.getByTestId("studio-custom-node-task").first()).toContainText("services");
|
||||
await expect(page.getByTestId("studio-custom-node-gateway").first()).toContainText("ONLINE");
|
||||
await expect(page.getByTestId("studio-custom-node-scope").first()).toContainText("Forvogn");
|
||||
|
||||
await page.getByTitle("Auto layout").click();
|
||||
await page.waitForTimeout(350);
|
||||
const customNodesDoNotOverlap = await page.evaluate(() => {
|
||||
const rects = [...document.querySelectorAll(".studio-custom-node")]
|
||||
.map((node) => node.getBoundingClientRect())
|
||||
.filter((rect) => rect.width > 0 && rect.height > 0);
|
||||
|
||||
for (let i = 0; i < rects.length; i += 1) {
|
||||
for (let j = i + 1; j < rects.length; j += 1) {
|
||||
const left = rects[i];
|
||||
const right = rects[j];
|
||||
const separated =
|
||||
left.right <= right.left + 1 ||
|
||||
right.right <= left.left + 1 ||
|
||||
left.bottom <= right.top + 1 ||
|
||||
right.bottom <= left.top + 1;
|
||||
if (!separated) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
expect(customNodesDoNotOverlap).toBe(true);
|
||||
|
||||
const controlsDoNotCoverDetails = await page.evaluate(() => {
|
||||
const controls = document.querySelector('[data-testid="studio-flow-actions"]')?.getBoundingClientRect();
|
||||
|
||||
Reference in New Issue
Block a user