Add fullscreen mode to Self-Serve Studio with improved canvas controls and responsive layout adjustments. Extend related E2E tests.
This commit is contained in:
+194
-24
@@ -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);
|
||||
|
||||
@@ -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?");
|
||||
|
||||
Reference in New Issue
Block a user