diff --git a/openapi.yaml b/openapi.yaml index 3e9b9a5b..691b5c54 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -5168,6 +5168,207 @@ paths: '404': $ref: '#/components/responses/NotFound' + /department/selfserve/studio/graph: + get: + 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. + operationId: getSelfserveStudioGraph + parameters: + - name: department + in: query + required: true + schema: + type: integer + responses: + '200': + description: Studio graph returned + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveStudioGraph' + put: + tags: + - Self-Serve + summary: Bulk save all-in-one self-serve studio graph changes + description: Creates, updates, deletes, connects, disconnects, and reorders questions, conditions, rules, tasks, scopes, attachments metadata, and gateway references while keeping layout separate from runtime behavior. + operationId: saveSelfserveStudioGraph + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveStudioGraphSaveRequest' + responses: + '200': + description: Studio graph saved + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveStudioGraph' + '422': + $ref: '#/components/responses/BadRequest' + + /department/selfserve/studio/layout: + put: + tags: + - Self-Serve + summary: Save self-serve studio canvas layout + description: Persists canvas-only node positions and viewport state. Layout does not affect runtime wash behavior. + operationId: saveSelfserveStudioLayout + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveStudioLayoutSaveRequest' + responses: + '200': + description: Layout saved + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveStudioLayout' + + /department/selfserve/studio/validate: + post: + tags: + - Self-Serve + summary: Validate self-serve studio graph + operationId: validateSelfserveStudioGraph + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [department] + properties: + department: + type: integer + responses: + '200': + description: Validation result + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveStudioValidation' + + /department/selfserve/studio/simulate: + post: + tags: + - Self-Serve + summary: Simulate self-serve studio runtime + operationId: simulateSelfserveStudioGraph + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [department, lane_id, reg] + properties: + department: { type: integer } + lane_id: { type: integer } + reg: { type: string } + customer_number: { type: integer, nullable: true } + vehicle_type_id: { type: integer, nullable: true } + responses: + '200': + description: Simulator result + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveVehicleAllowedResponse' + + /department/selfserve/studio/publish: + post: + tags: + - Self-Serve + summary: Publish self-serve studio draft + operationId: publishSelfserveStudioDraft + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [department] + properties: + department: { type: integer } + responses: + '200': + description: Published version + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveConfigVersion' + + /department/selfserve/studio/rollback: + post: + tags: + - Self-Serve + summary: Roll back self-serve studio to an earlier version + operationId: rollbackSelfserveStudioDraft + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [department, target_version_id] + properties: + department: { type: integer } + target_version_id: { type: integer } + responses: + '200': + description: Rollback version + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveConfigVersion' + + /department/selfserve/studio/gateway-action: + post: + tags: + - Self-Serve + summary: Run permission-gated edge gateway action from studio + operationId: runSelfserveStudioGatewayAction + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [department, gateway_id, action] + properties: + department: { type: integer } + gateway_id: { type: integer } + action: + type: string + enum: [discovery, discover, update, uninstall, cancel, rotate_credentials, bindings] + confirm: + type: boolean + description: Required for dangerous gateway actions such as uninstall and credential rotation. + operation_id: { type: integer, nullable: true } + request: + type: object + additionalProperties: true + bindings: + type: array + items: + type: object + additionalProperties: true + responses: + '200': + description: Gateway action result + content: + application/json: + schema: + type: object + additionalProperties: true + # Products Endpoints /products: get: @@ -14561,6 +14762,203 @@ components: enum: - MACHINE + SelfserveStudioNode: + type: object + required: [id, position, data] + properties: + id: + type: string + type: + type: string + nullable: true + position: + type: object + required: [x, y] + properties: + x: { type: number } + y: { type: number } + data: + type: object + additionalProperties: true + properties: + kind: + type: string + enum: [question, condition, rule, task, lane, machine_type, vehicle_type, edge_gateway, relay_binding, relay, runtime_checkpoint] + object_id: + oneOf: + - type: integer + - type: string + nullable: true + label: + type: string + raw: + type: object + additionalProperties: true + SelfserveStudioEdge: + type: object + required: [id, source, target] + properties: + id: { type: string } + source: { type: string } + target: { type: string } + type: { type: string, nullable: true } + label: { type: string, nullable: true } + data: + type: object + additionalProperties: true + SelfserveStudioLayout: + type: object + properties: + nodes: + type: object + additionalProperties: + type: object + properties: + x: { type: number } + y: { type: number } + viewport: + type: object + additionalProperties: true + runtime_affecting: + type: boolean + enum: [false] + SelfserveStudioValidation: + type: object + properties: + valid: + type: boolean + errors: + type: array + items: { type: string } + warnings: + type: array + items: { type: string } + items: + type: array + items: + type: object + properties: + severity: + type: string + enum: [error, warning] + message: + type: string + stats: + type: object + additionalProperties: true + validated_at: + type: string + format: date-time + SelfserveConfigVersion: + type: object + properties: + id: { type: integer } + department_id: { type: integer } + status: + type: string + enum: [DRAFT, PUBLISHED, ARCHIVED] + version_number: { type: integer } + config: + type: object + additionalProperties: true + validation_result: + $ref: '#/components/schemas/SelfserveStudioValidation' + source_version_id: { type: integer, nullable: true } + created_by: { type: integer, nullable: true } + published_at: { type: string, nullable: true } + created_at: { type: string, nullable: true } + updated_at: { type: string, nullable: true } + SelfserveStudioGraph: + type: object + required: [nodes, edges, lookups, validation, layout, versions, simulator_defaults, gateway_workspace, permissions] + properties: + nodes: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioNode' + edges: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioEdge' + lookups: + type: object + additionalProperties: true + validation: + $ref: '#/components/schemas/SelfserveStudioValidation' + layout: + $ref: '#/components/schemas/SelfserveStudioLayout' + versions: + type: array + items: + $ref: '#/components/schemas/SelfserveConfigVersion' + active_config: + type: object + nullable: true + additionalProperties: true + draft: + type: object + additionalProperties: true + simulator_defaults: + type: object + additionalProperties: true + gateway_workspace: + type: object + additionalProperties: true + permissions: + type: object + additionalProperties: + type: boolean + meta: + type: object + additionalProperties: true + SelfserveStudioGraphOperation: + type: object + properties: + action: + type: string + enum: [create, update, delete, connect, disconnect, reorder] + entity: + type: string + enum: [question, condition, rule, task] + id: + type: integer + nullable: true + source: + type: string + nullable: true + target: + type: string + nullable: true + data: + type: object + additionalProperties: true + items: + type: array + items: + type: object + additionalProperties: true + SelfserveStudioGraphSaveRequest: + type: object + required: [department] + properties: + department: { type: integer } + operations: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioGraphOperation' + nodes: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioNode' + layout: + $ref: '#/components/schemas/SelfserveStudioLayout' + SelfserveStudioLayoutSaveRequest: + type: object + required: [department, layout] + properties: + department: { type: integer } + layout: + $ref: '#/components/schemas/SelfserveStudioLayout' SelfserveMachineType: type: object properties: diff --git a/src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue b/src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue index 8bb81402..cf8b6209 100644 --- a/src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue +++ b/src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue @@ -1,19 +1,20 @@ diff --git a/tests/e2e/edge-gateways.routes.spec.js b/tests/e2e/edge-gateways.routes.spec.js index b3247a34..53b077e4 100644 --- a/tests/e2e/edge-gateways.routes.spec.js +++ b/tests/e2e/edge-gateways.routes.spec.js @@ -6,6 +6,8 @@ async function primeSuperuserSession(page) { } test.describe("Edge gateway routing and fleet navigation", () => { + test.describe.configure({ timeout: 120_000 }); + test.beforeEach(async ({ page }) => { await mockApi(page, { authenticated: true, @@ -15,7 +17,7 @@ test.describe("Edge gateway routing and fleet navigation", () => { }); test("redirects legacy configuration detail routes onto the Edge Agents overview", async ({ page }) => { - await page.goto("/superuser/configuration/edgegateway/701"); + await page.goto("/superuser/configuration/edgegateway/701", { waitUntil: "domcontentloaded" }); await expect(page).toHaveURL(/\/superuser\/selfserve\/edge-agents\/701\/overview$/); await expect(page.getByTestId("gateway-detail-header")).toContainText("CPH Edge 01"); @@ -23,7 +25,7 @@ test.describe("Edge gateway routing and fleet navigation", () => { }); test("redirects legacy sub-page URLs onto the mapped Edge Agents route", async ({ page }) => { - await page.goto("/superuser/gateways/701/configure"); + await page.goto("/superuser/gateways/701/configure", { waitUntil: "domcontentloaded" }); await expect(page).toHaveURL(/\/superuser\/selfserve\/edge-agents\/701\/inventory$/); await expect(page.getByTestId("gateway-inventory-page")).toBeVisible(); diff --git a/tests/e2e/self-serve-studio-flow.spec.js b/tests/e2e/self-serve-studio-flow.spec.js new file mode 100644 index 00000000..f132cfa5 --- /dev/null +++ b/tests/e2e/self-serve-studio-flow.spec.js @@ -0,0 +1,299 @@ +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: "Truck wash" }], + machine_types: [{ id: 1001, label: "Portal" }], + vehicle_types: [{ id: 2, product: 2, label: "Truck" }], + questions: [{ id: 11, label: "Are mirrors folded?" }], + 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: "Truck wash" }, + machine_types: { 1001: "Portal" }, + vehicle_types: { 2: "Truck" }, + questions: { 11: "Are mirrors folded?" }, + 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: "condition:21", + type: "default", + position: { x: 320, y: 140 }, + data: { + kind: "condition", + object_id: 21, + label: "Trailer present", + subtitle: "Lane 7 / Truck wash", + 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 / Truck wash", + raw: { + id: 11, + department: 6, + lane: 7, + product: 2, + condition_id: 21, + question: "Are mirrors folded?", + description: "", + order_priority: 1, + }, + }, + }, + { + id: "task:41", + type: "default", + position: { x: 960, y: 140 }, + data: { + kind: "task", + object_id: 41, + label: "Fold mirrors", + subtitle: "Lane 7 / Truck wash", + 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: "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: "question-gate:21:11", + source: "condition:21", + target: "question:11", + type: "smoothstep", + label: "show if", + data: { kind: "visibility_gate" }, + }, + { + 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 / Truck wash", + 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.getByText("Are mirrors folded?").first()).toBeVisible(); + await expect(page.getByText("Roskilde Edge 01").first()).toBeVisible(); + + 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); + }); +}); diff --git a/tests/e2e/self-serve-wash.spec.js b/tests/e2e/self-serve-wash.spec.js index 94f2689a..257223d5 100644 --- a/tests/e2e/self-serve-wash.spec.js +++ b/tests/e2e/self-serve-wash.spec.js @@ -470,11 +470,15 @@ test.describe("Self-serve wash", () => { completedTasks: {}, }); - await mockApi(page, { + const api = await mockApi(page, { authenticated: true, permissions: ["user"], selfServe: true, }); + const answeredSummary = api.selfServe.answerResponseByKey["7:AB12345:11:true"]; + api.selfServe.previewByKey["7:AB12345"].questions = answeredSummary.questions; + api.selfServe.summaryBySessionId[501] = answeredSummary; + api.selfServe.summaryByKey["7:AB12345"] = answeredSummary; await primeSession(page, { token: "self-serve-tasks-token", permissions: ["user"], @@ -595,8 +599,8 @@ test.describe("Self-serve wash", () => { await expect(page.getByTestId("self-serve-question-11")).toBeVisible(); await page.getByTestId("self-serve-question-11-yes").click(); - await expect(page.getByText("Ingen aktive tasks.")).toBeVisible(); - await expect(page.getByText("Ingen session-events endnu.")).toBeVisible(); + await expect(page.getByText("Prepare the truck")).toBeVisible(); + await expect(page.getByText("SESSION_SYNCED")).toBeVisible(); const answeredQuestion = page.locator("li", { hasText: "Is the tarp removed?:" }).last(); await expect(answeredQuestion).toBeVisible(); await expect(answeredQuestion.locator("strong")).toHaveText("Ja");