Add fullscreen mode to Self-Serve Studio with improved canvas controls and responsive layout adjustments. Extend related E2E tests.

This commit is contained in:
Jeppe Bundgaard
2026-04-28 13:46:47 +02:00
parent e9cbb40559
commit 586a0cc86d
2 changed files with 216 additions and 24 deletions
@@ -1,5 +1,5 @@
<script setup>
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { VueFlow, useVueFlow } from "@vue-flow/core";
import "@vue-flow/core/dist/style.css";
@@ -89,11 +89,13 @@ const filters = ref({
});
const isLoading = ref(false);
const isSaving = ref(false);
const isStudioFullscreen = ref(false);
const layoutSaveTimer = ref(null);
const suppressLayoutSave = ref(false);
const exportText = ref("");
const importText = ref("");
const draggedTemplateKind = ref(null);
const studioRoot = ref(null);
const graphStats = computed(() => {
const counts = {};
@@ -129,6 +131,48 @@ const canvasNodes = computed(() => flowNodes.value.map((node) => ({
].filter(Boolean).join(" "),
})));
const refitStudioCanvas = () => {
window.setTimeout(() => {
try {
fitView({ padding: 0.18, duration: 250 });
} catch {
// Vue Flow can be unavailable during route teardown.
}
}, 80);
};
const syncStudioFullscreenState = () => {
if (document.fullscreenElement !== studioRoot.value) {
isStudioFullscreen.value = false;
}
refitStudioCanvas();
};
const handleStudioFullscreenKeydown = (event) => {
if (event.key === "Escape" && isStudioFullscreen.value && !document.fullscreenElement) {
isStudioFullscreen.value = false;
refitStudioCanvas();
}
};
const toggleStudioFullscreen = async () => {
if (isStudioFullscreen.value) {
if (document.fullscreenElement === studioRoot.value && document.exitFullscreen) {
await document.exitFullscreen().catch(() => {});
}
isStudioFullscreen.value = false;
refitStudioCanvas();
return;
}
isStudioFullscreen.value = true;
await nextTick();
if (studioRoot.value?.requestFullscreen && !document.fullscreenElement) {
await studioRoot.value.requestFullscreen().catch(() => {});
}
refitStudioCanvas();
};
const filteredListNodes = computed(() => flowNodes.value.filter(matchesNodeFilters));
const conditionNodes = computed(() => flowNodes.value.filter((node) => node.data?.kind === "condition"));
const ruleNodes = computed(() => flowNodes.value.filter((node) => node.data?.kind === "rule"));
@@ -561,8 +605,21 @@ const formatJson = (value) => JSON.stringify(value || {}, null, 2);
watch(selectedNode, refreshInspectorForm);
watch(flowNodes, scheduleLayoutSave, { deep: true });
watch(departmentId, loadStudioGraph);
watch(isStudioFullscreen, refitStudioCanvas);
onMounted(loadStudioGraph);
onMounted(() => {
loadStudioGraph();
document.addEventListener("fullscreenchange", syncStudioFullscreenState);
document.addEventListener("keydown", handleStudioFullscreenKeydown);
});
onBeforeUnmount(() => {
document.removeEventListener("fullscreenchange", syncStudioFullscreenState);
document.removeEventListener("keydown", handleStudioFullscreenKeydown);
if (layoutSaveTimer.value) {
window.clearTimeout(layoutSaveTimer.value);
}
});
</script>
<template>
@@ -572,10 +629,27 @@ onMounted(loadStudioGraph);
:exists="SessionUser.functions.getDepartmentIdFromUrl() && SessionUser.canAccessDepartment(SessionUser.functions.getDepartmentIdFromUrl())"
:error="$t('admin.errors.select_department')"
>
<section class="self-serve-studio" data-testid="self-serve-studio">
<section
ref="studioRoot"
class="self-serve-studio"
:class="{ 'is-studio-fullscreen': isStudioFullscreen }"
data-testid="self-serve-studio"
>
<PageTitle title="Self-Serve Studio" subtitle="All-in-one flow, gateway, simulation, and publish workspace">
<template #buttons>
<div class="buttons studio-page-actions">
<button
class="button is-light"
type="button"
:title="isStudioFullscreen ? 'Exit fullscreen' : 'Fullscreen'"
data-testid="studio-fullscreen-toggle"
@click="toggleStudioFullscreen"
>
<span class="icon">
<i :class="isStudioFullscreen ? 'fas fa-compress' : 'fas fa-expand'"></i>
</span>
<span>{{ isStudioFullscreen ? 'Exit fullscreen' : 'Fullscreen' }}</span>
</button>
<button class="button is-light" :class="{ 'is-loading': isLoading }" @click="loadStudioGraph">
<span class="icon"><i class="fas fa-rotate"></i></span>
<span>Reload</span>
@@ -632,20 +706,6 @@ onMounted(loadStudioGraph);
<option v-for="machineType in lookupRows('machine_types')" :key="machineType.id" :value="machineType.id">{{ machineType.label }}</option>
</select>
</div>
<div class="buttons studio-canvas-actions">
<button class="button is-small is-light" title="Zoom in" @click="zoomIn()">
<span class="icon"><i class="fas fa-plus"></i></span>
</button>
<button class="button is-small is-light" title="Zoom out" @click="zoomOut()">
<span class="icon"><i class="fas fa-minus"></i></span>
</button>
<button class="button is-small is-light" title="Fit view" @click="fitView({ padding: 0.16, duration: 250 })">
<span class="icon"><i class="fas fa-expand"></i></span>
</button>
<button class="button is-small is-light" title="Auto layout" @click="autoLayout">
<span class="icon"><i class="fas fa-wand-magic-sparkles"></i></span>
</button>
</div>
</div>
<div class="studio-workspace">
@@ -667,11 +727,27 @@ onMounted(loadStudioGraph);
</div>
<div class="studio-flow-surface" data-testid="self-serve-studio-flow" @dragover.prevent @drop.prevent="handleCanvasDrop">
<div class="buttons studio-flow-actions" data-testid="studio-flow-actions">
<button class="button is-small is-light" title="Zoom in" type="button" @click="zoomIn()">
<span class="icon"><i class="fas fa-plus"></i></span>
</button>
<button class="button is-small is-light" title="Zoom out" type="button" @click="zoomOut()">
<span class="icon"><i class="fas fa-minus"></i></span>
</button>
<button class="button is-small is-light" title="Fit view" type="button" @click="fitView({ padding: 0.16, duration: 250 })">
<span class="icon"><i class="fas fa-expand"></i></span>
</button>
<button class="button is-small is-light" title="Auto layout" type="button" @click="autoLayout">
<span class="icon"><i class="fas fa-wand-magic-sparkles"></i></span>
</button>
</div>
<VueFlow
id="self-serve-studio-flow"
v-model:nodes="flowNodes"
v-model:edges="flowEdges"
:fit-view-on-init="true"
:min-zoom="0.38"
:max-zoom="1.7"
:nodes-draggable="true"
:nodes-connectable="permissions.can_edit"
:elements-selectable="true"
@@ -989,18 +1065,42 @@ onMounted(loadStudioGraph);
<style scoped>
.self-serve-studio {
container-type: inline-size;
min-height: calc(100vh - 160px);
}
.self-serve-studio.is-studio-fullscreen,
.self-serve-studio:fullscreen {
background: #ffffff;
inset: 0;
min-height: 100vh;
overflow: auto;
padding: 24px;
position: fixed;
z-index: 10000;
}
.studio-page-actions {
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
}
.studio-page-actions.buttons .button {
margin-bottom: 0.5rem;
}
.studio-shell {
display: grid;
grid-template-columns: 168px minmax(0, 1fr) 360px;
grid-template-columns: 168px minmax(620px, 1fr) minmax(300px, 340px);
gap: 14px;
min-height: 760px;
min-height: min(760px, calc(100vh - 230px));
}
.self-serve-studio.is-studio-fullscreen .studio-shell,
.self-serve-studio:fullscreen .studio-shell {
grid-template-columns: 190px minmax(760px, 1fr) minmax(340px, 420px);
min-height: calc(100vh - 132px);
}
.studio-sidebar,
@@ -1054,7 +1154,7 @@ onMounted(loadStudioGraph);
.studio-toolbar {
align-items: center;
display: grid;
grid-template-columns: minmax(180px, 1fr) repeat(3, minmax(132px, 170px)) auto;
grid-template-columns: minmax(220px, 1fr) repeat(3, minmax(142px, 184px));
gap: 8px;
}
@@ -1074,11 +1174,21 @@ onMounted(loadStudioGraph);
padding-left: 36px;
}
.studio-toolbar .select,
.studio-toolbar .select select {
width: 100%;
}
.studio-workspace {
display: grid;
grid-template-columns: 132px minmax(0, 1fr);
grid-template-columns: 132px minmax(460px, 1fr);
gap: 10px;
min-height: 700px;
min-height: min(700px, calc(100vh - 290px));
}
.self-serve-studio.is-studio-fullscreen .studio-workspace,
.self-serve-studio:fullscreen .studio-workspace {
min-height: calc(100vh - 196px);
}
.studio-palette {
@@ -1113,15 +1223,47 @@ onMounted(loadStudioGraph);
linear-gradient(135deg, #dbeafe, #ddd6fe) border-box;
border: 1px solid transparent;
border-radius: 8px;
min-height: 700px;
min-height: min(700px, calc(100vh - 290px));
overflow: hidden;
position: relative;
}
.self-serve-studio.is-studio-fullscreen .studio-flow-surface,
.self-serve-studio:fullscreen .studio-flow-surface {
min-height: calc(100vh - 196px);
}
.studio-flow-surface :deep(.vue-flow) {
background-color: #f8fafc;
}
.studio-flow-surface :deep(.vue-flow__controls) {
display: none;
}
.studio-flow-actions {
align-items: center;
background: rgba(255, 255, 255, 0.9);
border: 1px solid #e2e8f0;
border-radius: 7px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
display: flex;
flex-wrap: nowrap;
gap: 4px;
margin: 0;
padding: 4px;
position: absolute;
right: 12px;
top: 12px;
z-index: 4;
}
.studio-flow-actions.buttons .button {
height: 30px;
margin: 0;
min-width: 30px;
}
.studio-flow-node {
background: #ffffff;
border: 1px solid #cbd5e1;
@@ -1187,11 +1329,16 @@ onMounted(loadStudioGraph);
display: flex;
flex-direction: column;
gap: 14px;
max-height: 760px;
max-height: min(760px, calc(100vh - 230px));
overflow: auto;
padding-left: 14px;
}
.self-serve-studio.is-studio-fullscreen .studio-details,
.self-serve-studio:fullscreen .studio-details {
max-height: calc(100vh - 132px);
}
.studio-details-header {
align-items: center;
display: flex;
@@ -1377,6 +1524,29 @@ onMounted(loadStudioGraph);
min-height: 160px;
}
@container (max-width: 1120px) {
.studio-shell {
grid-template-columns: 64px minmax(0, 1fr);
}
.studio-sidebar .studio-panel-button span:last-child {
display: none;
}
.studio-workspace {
grid-template-columns: 128px minmax(0, 1fr);
}
.studio-details {
border-left: 0;
border-top: 1px solid #e2e8f0;
grid-column: 1 / -1;
max-height: none;
padding-left: 0;
padding-top: 12px;
}
}
@media (max-width: 1280px) {
.studio-shell {
grid-template-columns: 64px minmax(0, 1fr);
+22
View File
@@ -249,9 +249,31 @@ test.describe("All-in-one self-serve studio", () => {
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();
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?");