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.
457 lines
16 KiB
JavaScript
457 lines
16 KiB
JavaScript
import { expect, test } from "@playwright/test";
|
|
import { mockApi, primeMockSession } from "./support/network.js";
|
|
|
|
const json = (body, status = 200) => ({
|
|
status,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
function buildStudioGraph() {
|
|
const lookups = {
|
|
departments: [{ id: 6, label: "Roskilde" }],
|
|
lanes: [{ id: 7, label: "Lane 7" }],
|
|
products: [{ id: 2, label: "Forvogn", is_wash: 1, subscription_allowed: 1 }],
|
|
machine_types: [{ id: 1001, label: "Portal" }],
|
|
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" }],
|
|
gateways: [{ id: 701, label: "Roskilde Edge 01", status: "ONLINE" }],
|
|
relays: [{ id: "M-7", label: "Machine relay" }],
|
|
bindings: [{ id: "701:M-7:0", label: "Machine relay" }],
|
|
labels: {
|
|
departments: { 6: "Roskilde" },
|
|
lanes: { 7: "Lane 7" },
|
|
products: { 2: "Forvogn" },
|
|
machine_types: { 1001: "Portal" },
|
|
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" },
|
|
gateways: { 701: "Roskilde Edge 01" },
|
|
relays: { "M-7": "Machine relay" },
|
|
},
|
|
};
|
|
|
|
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",
|
|
position: { x: 320, y: 140 },
|
|
data: {
|
|
kind: "condition",
|
|
object_id: 21,
|
|
label: "Trailer present",
|
|
subtitle: "Lane 7 / Forvogn",
|
|
raw: { id: 21, department: 6, lane: 7, product: 2, name: "Trailer present", description: "" },
|
|
},
|
|
},
|
|
{
|
|
id: "question:11",
|
|
type: "default",
|
|
position: { x: 640, y: 140 },
|
|
data: {
|
|
kind: "question",
|
|
object_id: 11,
|
|
label: "Are mirrors folded?",
|
|
subtitle: "Lane 7 / Forvogn",
|
|
raw: {
|
|
id: 11,
|
|
department: 6,
|
|
lane: 7,
|
|
product: 2,
|
|
condition_id: 21,
|
|
question: "Are mirrors folded?",
|
|
description: "",
|
|
order_priority: 1,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
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",
|
|
position: { x: 960, y: 140 },
|
|
data: {
|
|
kind: "task",
|
|
object_id: 41,
|
|
label: "Fold mirrors",
|
|
subtitle: "Lane 7 / Forvogn",
|
|
raw: {
|
|
id: 41,
|
|
department: 6,
|
|
lane: 7,
|
|
product: 2,
|
|
task: "Fold mirrors",
|
|
description: "",
|
|
gate_type: "QUESTION",
|
|
gate_ref_id: 11,
|
|
order_priority: 1,
|
|
services: ["MACHINE"],
|
|
buttons: [1],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
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",
|
|
position: { x: 1280, y: 140 },
|
|
data: {
|
|
kind: "edge_gateway",
|
|
object_id: 701,
|
|
label: "Roskilde Edge 01",
|
|
subtitle: "ONLINE",
|
|
raw: { id: 701, label: "Roskilde Edge 01", status: "ONLINE" },
|
|
},
|
|
},
|
|
],
|
|
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",
|
|
target: "question:11",
|
|
type: "smoothstep",
|
|
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",
|
|
target: "task:41",
|
|
type: "smoothstep",
|
|
label: "unlocks",
|
|
data: { kind: "task_gate" },
|
|
},
|
|
],
|
|
lookups,
|
|
validation: { valid: true, errors: [], warnings: [], items: [] },
|
|
layout: { nodes: [], viewport: [], runtime_affecting: false },
|
|
versions: [
|
|
{ id: 90, version_number: 3, status: "DRAFT", created_at: "2026-04-28 09:00:00" },
|
|
{ id: 80, version_number: 2, status: "PUBLISHED", published_at: "2026-04-27 09:00:00" },
|
|
],
|
|
active_config: null,
|
|
draft: { id: 90, status: "DRAFT", version_number: 3 },
|
|
simulator_defaults: { department: 6, lane_id: 7, vehicle_type_id: 2, reg: "TEST123", customer_number: null },
|
|
gateway_workspace: {
|
|
gateways: [
|
|
{
|
|
id: 701,
|
|
label: "Roskilde Edge 01",
|
|
status: "ONLINE",
|
|
bindings: [{ relay_id: "M-7", label: "Machine relay", role: "MACHINE" }],
|
|
},
|
|
],
|
|
relays: [{ relay_id: "M-7", name: "Machine relay" }],
|
|
lanes: [],
|
|
issues: [],
|
|
actions: [],
|
|
},
|
|
permissions: {
|
|
can_view: true,
|
|
can_edit: true,
|
|
can_publish: true,
|
|
can_rollback: true,
|
|
can_simulate: true,
|
|
modules_shelly_config: true,
|
|
can_manage_gateways: true,
|
|
can_run_gateway_destructive_actions: true,
|
|
can_run_live_lane_actions: true,
|
|
},
|
|
meta: { department_id: 6, layout_affects_runtime: false, generated_at: "2026-04-28T09:00:00+02:00" },
|
|
};
|
|
}
|
|
|
|
async function installStudioRoutes(page, graph, captured) {
|
|
await page.route(/\/department\/selfserve\/studio\/graph(?:\?.*)?$/i, async (route, request) => {
|
|
if (request.method() === "GET") {
|
|
await route.fulfill(json({ data: graph }));
|
|
return;
|
|
}
|
|
|
|
const body = request.postDataJSON?.() || {};
|
|
captured.graphSaves.push(body);
|
|
for (const operation of body.operations || []) {
|
|
if (operation.action === "create" && operation.entity === "question") {
|
|
graph.nodes.push({
|
|
id: "question:99",
|
|
type: "default",
|
|
position: { x: 700, y: 320 },
|
|
data: {
|
|
kind: "question",
|
|
object_id: 99,
|
|
label: operation.data.question,
|
|
subtitle: "Lane 7 / Forvogn",
|
|
raw: { id: 99, ...operation.data },
|
|
},
|
|
});
|
|
}
|
|
if (operation.action === "update" && operation.entity === "question" && Number(operation.id) === 11) {
|
|
const node = graph.nodes.find((entry) => entry.id === "question:11");
|
|
node.data.raw = { ...node.data.raw, ...operation.data };
|
|
node.data.label = operation.data.question;
|
|
}
|
|
}
|
|
await route.fulfill(json({ data: graph }));
|
|
});
|
|
|
|
await page.route(/\/department\/selfserve\/studio\/layout(?:\?.*)?$/i, async (route, request) => {
|
|
captured.layouts.push(request.postDataJSON?.() || {});
|
|
await route.fulfill(json({ data: { nodes: [], viewport: [], runtime_affecting: false } }));
|
|
});
|
|
|
|
await page.route(/\/department\/selfserve\/studio\/validate(?:\?.*)?$/i, async (route) => {
|
|
await route.fulfill(json({ data: graph.validation }));
|
|
});
|
|
|
|
await page.route(/\/department\/selfserve\/studio\/simulate(?:\?.*)?$/i, async (route, request) => {
|
|
captured.simulations.push(request.postDataJSON?.() || {});
|
|
await route.fulfill(json({ data: { allowed: true, questions: [], tasks: [{ task: "Fold mirrors" }] } }));
|
|
});
|
|
|
|
await page.route(/\/department\/selfserve\/studio\/publish(?:\?.*)?$/i, async (route) => {
|
|
captured.published += 1;
|
|
await route.fulfill(json({ data: { id: 91, status: "PUBLISHED", version_number: 4 } }));
|
|
});
|
|
|
|
await page.route(/\/department\/selfserve\/studio\/rollback(?:\?.*)?$/i, async (route, request) => {
|
|
captured.rollbacks.push(request.postDataJSON?.() || {});
|
|
await route.fulfill(json({ data: { id: 92, status: "PUBLISHED", version_number: 5 } }));
|
|
});
|
|
|
|
await page.route(/\/department\/selfserve\/studio\/gateway-action(?:\?.*)?$/i, async (route, request) => {
|
|
captured.gatewayActions.push(request.postDataJSON?.() || {});
|
|
await route.fulfill(json({ data: { action: "discovery", operation: { id: 1, status: "PENDING" } } }));
|
|
});
|
|
}
|
|
|
|
test.describe("All-in-one self-serve studio", () => {
|
|
test.describe.configure({ mode: "serial", timeout: 120_000 });
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
await mockApi(page, {
|
|
authenticated: true,
|
|
permissions: ["superuser", "user"],
|
|
selfServe: true,
|
|
});
|
|
await primeMockSession(page, { token: "self-serve-studio-token" });
|
|
});
|
|
|
|
test("loads the replacement graph workspace and edits a question from the inspector", async ({ page }) => {
|
|
const graph = buildStudioGraph();
|
|
const captured = { graphSaves: [], layouts: [], simulations: [], gatewayActions: [], rollbacks: [], published: 0 };
|
|
await installStudioRoutes(page, graph, captured);
|
|
|
|
await page.goto("/admin/6/modules/self-serve/studio", { waitUntil: "domcontentloaded", timeout: 90_000 });
|
|
|
|
await expect(page.getByTestId("self-serve-studio")).toBeVisible();
|
|
await expect(page.getByTestId("self-serve-studio-flow")).toBeVisible();
|
|
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();
|
|
const details = document.querySelector(".studio-details")?.getBoundingClientRect();
|
|
if (!controls || !details) {
|
|
return false;
|
|
}
|
|
return (
|
|
controls.right <= details.left ||
|
|
controls.bottom <= details.top ||
|
|
controls.left >= details.right ||
|
|
controls.top >= details.bottom
|
|
);
|
|
});
|
|
expect(controlsDoNotCoverDetails).toBe(true);
|
|
|
|
await page.getByTestId("studio-fullscreen-toggle").click();
|
|
await expect(page.getByTestId("self-serve-studio")).toHaveClass(/is-studio-fullscreen/);
|
|
await expect(page.getByTestId("studio-fullscreen-toggle")).toContainText("Exit fullscreen");
|
|
await page.getByTestId("studio-fullscreen-toggle").click();
|
|
await expect(page.getByTestId("self-serve-studio")).not.toHaveClass(/is-studio-fullscreen/);
|
|
|
|
await page.getByText("Are mirrors folded?").first().click();
|
|
await expect(page.getByRole("heading", { name: "Inspector" })).toBeVisible();
|
|
await page.getByRole("textbox", { name: "Question", exact: true }).fill("Are loose straps secured?");
|
|
await page.getByRole("button", { name: "Save" }).click();
|
|
|
|
await expect.poll(() => captured.graphSaves.some((save) => save.operations?.[0]?.action === "update")).toBe(true);
|
|
await expect(page.getByText("Are loose straps secured?")).toBeVisible();
|
|
});
|
|
|
|
test("supports drag-drop quick add, gateway action, simulator, publish, and rollback panels", async ({ page }) => {
|
|
const graph = buildStudioGraph();
|
|
const captured = { graphSaves: [], layouts: [], simulations: [], gatewayActions: [], rollbacks: [], published: 0 };
|
|
await installStudioRoutes(page, graph, captured);
|
|
|
|
await page.goto("/admin/6/modules/self-serve/studio", { waitUntil: "domcontentloaded", timeout: 90_000 });
|
|
await expect(page.getByTestId("self-serve-studio-flow")).toBeVisible();
|
|
|
|
await page.evaluate(() => {
|
|
const source = document.querySelector('[data-testid="studio-palette-question"]');
|
|
const target = document.querySelector('[data-testid="self-serve-studio-flow"]');
|
|
const dataTransfer = new DataTransfer();
|
|
source.dispatchEvent(new DragEvent("dragstart", { bubbles: true, cancelable: true, dataTransfer }));
|
|
target.dispatchEvent(new DragEvent("dragover", { bubbles: true, cancelable: true, dataTransfer }));
|
|
target.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer }));
|
|
});
|
|
await expect.poll(() => captured.graphSaves.some((save) => save.operations?.[0]?.action === "create")).toBe(true);
|
|
await expect(page.getByText("New question").first()).toBeVisible();
|
|
|
|
await page.getByTestId("studio-panel-hardware").click();
|
|
await page.getByTitle("Discover gateway").click();
|
|
await expect.poll(() => captured.gatewayActions[0]?.action).toBe("discovery");
|
|
|
|
await page.getByTestId("studio-panel-simulator").click();
|
|
await page.getByLabel("Registration").fill("AB12345");
|
|
await page.getByRole("button", { name: "Run" }).click();
|
|
await expect.poll(() => captured.simulations[0]?.reg).toBe("AB12345");
|
|
await expect(page.locator(".studio-json")).toContainText("Fold mirrors");
|
|
|
|
await page.getByTestId("studio-panel-versions").click();
|
|
await page.getByRole("button", { name: "Publish" }).first().click();
|
|
await expect.poll(() => captured.published).toBe(1);
|
|
await page.getByRole("button", { name: "Rollback" }).last().click();
|
|
await expect.poll(() => captured.rollbacks[0]?.target_version_id).toBe(80);
|
|
});
|
|
});
|