From 414df408f2089a256cada435e6b39dfbda6c93d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Mon, 26 Oct 2026 15:10:52 +0000 Subject: [PATCH] fix(configurator): open configuration editing directly from navigation --- .../web/src/components/Configurations.tsx | 36 ++-- .../app/apps/web/src/main.tsx | 4 +- .../app/e2e/cloud-catalogues.spec.ts | 2 +- .../app/e2e/common-editor.spec.ts | 3 +- .../app/e2e/configurator.spec.ts | 59 +++--- .../app/e2e/organisation.spec.ts | 2 +- .../app/e2e/platform-owner-flow.spec.ts | 169 ++++++++++++++++++ .../docs/development.de.md | 7 + landing-zone-configurator/docs/development.md | 7 +- 9 files changed, 235 insertions(+), 54 deletions(-) diff --git a/landing-zone-configurator/app/apps/web/src/components/Configurations.tsx b/landing-zone-configurator/app/apps/web/src/components/Configurations.tsx index eaa45ab..826033c 100644 --- a/landing-zone-configurator/app/apps/web/src/components/Configurations.tsx +++ b/landing-zone-configurator/app/apps/web/src/components/Configurations.tsx @@ -1,3 +1,4 @@ +import { assistantValuesEqual } from "@lzc/contracts"; import { type EditorDraft, editorIssues, @@ -81,6 +82,7 @@ export function Configurations({ onLoad, onPrepare, compact = false, + dirty = true, }: { session: Session | null; draft: EditorDraft | null; @@ -89,6 +91,7 @@ export function Configurations({ onLoad: (draft: EditorDraft, binding: ConfigurationBinding) => void; onPrepare: (selection: DeploymentSelection) => void; compact?: boolean; + dirty?: boolean; }) { const [configurations, setConfigurations] = useState([]); const [busy, setBusy] = useState(false); @@ -103,6 +106,8 @@ export function Configurations({ string, string > | null>(null); + const currentDraft = useRef({ draft, dirty }); + currentDraft.current = { draft, dirty }; const active = useRef(false); const controller = useRef(null); const listRequest = useRef(0); @@ -380,15 +385,6 @@ export function Configurations({ value0: item.name, })} onClick={() => { - if ( - draft && - !window.confirm( - t( - "Aktuellen Entwurf durch die gespeicherte Konfiguration ersetzen?", - ), - ) - ) - return; void perform(async () => { const response = await checked( await fetch(`/api/v1/configurations/${item.id}`, { @@ -399,11 +395,23 @@ export function Configurations({ configuration: StoredConfiguration; }; const loaded = readEditorDraft(configuration.draft); - if (!controller.current?.signal.aborted) - onLoad(loaded, { - id: configuration.id, - revision: configuration.revision, - }); + if (controller.current?.signal.aborted) return; + const current = currentDraft.current; + if ( + current.draft && + current.dirty && + !assistantValuesEqual(current.draft, loaded) && + !window.confirm( + t( + "Aktuellen Entwurf durch die gespeicherte Konfiguration ersetzen?", + ), + ) + ) + return; + onLoad(loaded, { + id: configuration.id, + revision: configuration.revision, + }); }); }} > diff --git a/landing-zone-configurator/app/apps/web/src/main.tsx b/landing-zone-configurator/app/apps/web/src/main.tsx index 89c0c86..d78e73c 100644 --- a/landing-zone-configurator/app/apps/web/src/main.tsx +++ b/landing-zone-configurator/app/apps/web/src/main.tsx @@ -456,7 +456,7 @@ function App() { type="button" className={configurationView ? "nav-item active" : "nav-item"} aria-current={configurationView ? "page" : undefined} - onClick={() => setView("repositories")} + onClick={() => setView(draft ? "editor" : "repositories")} > {t("Konfigurationen")} @@ -772,6 +772,7 @@ function App() { session={session} draft={draft} binding={configurationBinding} + dirty={draftDirty} compact onSaved={saveConfigurationBinding} onLoad={loadConfiguration} @@ -990,6 +991,7 @@ function App() { session={session} draft={draft} binding={configurationBinding} + dirty={draftDirty} compact={view === "editor"} onSaved={saveConfigurationBinding} onPrepare={openDeployment} diff --git a/landing-zone-configurator/app/e2e/cloud-catalogues.spec.ts b/landing-zone-configurator/app/e2e/cloud-catalogues.spec.ts index 0f09572..78b472d 100644 --- a/landing-zone-configurator/app/e2e/cloud-catalogues.spec.ts +++ b/landing-zone-configurator/app/e2e/cloud-catalogues.spec.ts @@ -271,7 +271,7 @@ for (const kind of ["personal", "organisation"]) { await page .getByRole("button", { name: "Konfigurationen", exact: true }) .click(); - await page.getByRole("button", { name: /weiterbearbeiten$/ }).click(); + await page .getByRole("button", { name: "4 Plattform", exact: true }) .click(); diff --git a/landing-zone-configurator/app/e2e/common-editor.spec.ts b/landing-zone-configurator/app/e2e/common-editor.spec.ts index 5a02eaa..a56c2d8 100644 --- a/landing-zone-configurator/app/e2e/common-editor.spec.ts +++ b/landing-zone-configurator/app/e2e/common-editor.spec.ts @@ -465,7 +465,8 @@ test("shared configuration saves, reopens and restores in the account workspace" "Gemeinsamer Fork-Entwurf", ); await page - .getByRole("button", { name: "Konfigurationen", exact: true }) + .getByRole("navigation", { name: "Brotkrumennavigation", exact: true }) + .getByRole("link", { name: "Konfigurationen", exact: true }) .click(); await page.getByRole("button", { name: /Deployment vorbereiten/ }).click(); await expect(page.getByRole("alert")).toContainText( diff --git a/landing-zone-configurator/app/e2e/configurator.spec.ts b/landing-zone-configurator/app/e2e/configurator.spec.ts index f9119b7..1192d91 100644 --- a/landing-zone-configurator/app/e2e/configurator.spec.ts +++ b/landing-zone-configurator/app/e2e/configurator.spec.ts @@ -33,6 +33,20 @@ import { } from "../scripts/assistant-scenarios.js"; import { expect, type Page, test } from "./fixtures"; +// Storage workflows use the editor breadcrumb; the primary navigation now resumes editing. +async function openConfigurationStorage(page: Page) { + const listLink = page + .getByRole("navigation", { name: "Brotkrumennavigation", exact: true }) + .getByRole("link", { name: "Konfigurationen", exact: true }); + if (!(await listLink.isVisible())) { + await page + .getByRole("navigation", { name: "Hauptnavigation", exact: true }) + .getByRole("button", { name: "Konfigurationen", exact: true }) + .click(); + } + if (await listLink.isVisible()) await listLink.click(); +} + async function holdAssistantCompletion(page: Page, path: string) { await page.addInitScript((endpoint) => { const original = window.fetch.bind(window); @@ -2173,9 +2187,7 @@ test("database configurations save, reopen, protect revisions and delete without .getByRole("textbox", { name: "Name der Konfiguration", exact: true }) .fill("Ohne GitHub gespeichert"); await expect(name).toHaveValue("Ohne GitHub gespeichert"); - await page - .getByRole("button", { name: "Konfigurationen", exact: true }) - .click(); + await openConfigurationStorage(page); const storage = page.getByRole("region", { name: "Gespeicherte Konfigurationen", }); @@ -2234,9 +2246,7 @@ test("database configurations save, reopen, protect revisions and delete without await page .getByRole("textbox", { name: "Name der Konfiguration", exact: true }) .fill("Aktualisiert ohne GitHub"); - await page - .getByRole("button", { name: "Konfigurationen", exact: true }) - .click(); + await openConfigurationStorage(page); await storage .getByRole("button", { name: "Konfiguration speichern", exact: true }) .click(); @@ -3034,9 +3044,7 @@ test("edit, validate and download an isolated standalone copy", async ({ () => document.documentElement.scrollWidth <= window.innerWidth, ), ).toBe(true); - await page - .getByRole("button", { name: "Konfigurationen", exact: true }) - .click(); + await openConfigurationStorage(page); await page .getByRole("button", { name: "Neue Konfiguration", exact: true }) .click(); @@ -3046,9 +3054,7 @@ test("edit, validate and download an isolated standalone copy", async ({ await expect(page.locator(".topology")).toContainText( "Public · public-exmpl", ); - await page - .getByRole("button", { name: "Konfigurationen", exact: true }) - .click(); + await openConfigurationStorage(page); await page .getByRole("button", { name: "Meine Testumgebung weiterbearbeiten", @@ -3205,9 +3211,7 @@ test("URLs and browser back/forward preserve an in-progress draft", async ({ exact: true, }), ).toBeVisible(); - await page - .getByRole("button", { name: "Konfigurationen", exact: true }) - .click(); + await openConfigurationStorage(page); await expect(page).toHaveURL(/\/configurations$/); await page.goBack(); await expect(page).toHaveURL(/\/configurations\/edit\/projects$/); @@ -3346,9 +3350,7 @@ test("select a fork, reopen a config and resolve a save conflict as a new copy", await expect(page.getByLabel("Name der Konfiguration")).toHaveValue( "Meine neue Kopie", ); - await page - .getByRole("button", { name: "Konfigurationen", exact: true }) - .click(); + await openConfigurationStorage(page); await page .getByRole("button", { name: "Im Fork speichern", exact: true }) .click(); @@ -3357,9 +3359,7 @@ test("select a fork, reopen a config and resolve a save conflict as a new copy", await expect(page.getByLabel("Name der Konfiguration")).toHaveValue( "Meine neue Kopie", ); - await page - .getByRole("button", { name: "Konfigurationen", exact: true }) - .click(); + await openConfigurationStorage(page); await page.getByRole("button", { name: "Neue Kopie vorbereiten" }).click(); await page .getByRole("button", { name: "Im Fork speichern", exact: true }) @@ -3388,9 +3388,7 @@ test("select a fork, reopen a config and resolve a save conflict as a new copy", await page.goto("/templates"); await expect(page.getByText("@alice", { exact: true })).toBeVisible(); await expect(page).toHaveURL(/\/templates$/); - await page - .getByRole("button", { name: "Konfigurationen", exact: true }) - .click(); + await openConfigurationStorage(page); await expect( page.getByRole("button", { name: "Meine neue Kopie weiterbearbeiten", @@ -4366,10 +4364,7 @@ test.describe("explicit saved-plan approval", () => { ).toHaveAttribute("aria-selected", "true"); expect(saves).toBe(0); expect(cloudMutations).toEqual([]); - await page - .getByRole("navigation", { name: "Hauptnavigation", exact: true }) - .getByRole("button", { name: "Konfigurationen", exact: true }) - .click(); + await openConfigurationStorage(page); await page .getByRole("button", { name: "Meine Plattform weiterbearbeiten", @@ -4402,9 +4397,7 @@ test.describe("explicit saved-plan approval", () => { .getByRole("button", { name: "Arbeitsbereich öffnen: Persönlich" }) .click(); await expect(page).toHaveURL(/\/workspaces$/); - await page - .getByRole("button", { name: "Konfigurationen", exact: true }) - .click(); + await openConfigurationStorage(page); await page .getByRole("button", { name: "Meine Plattform geändert weiterbearbeiten", @@ -4431,9 +4424,7 @@ test.describe("explicit saved-plan approval", () => { path: testInfo.outputPath("configuration-flow.png"), fullPage: true, }); - await page - .getByRole("button", { name: "Konfigurationen", exact: true }) - .click(); + await openConfigurationStorage(page); await page .getByRole("button", { name: "Neue Konfiguration", exact: true }) .click(); diff --git a/landing-zone-configurator/app/e2e/organisation.spec.ts b/landing-zone-configurator/app/e2e/organisation.spec.ts index ba1ff76..c447346 100644 --- a/landing-zone-configurator/app/e2e/organisation.spec.ts +++ b/landing-zone-configurator/app/e2e/organisation.spec.ts @@ -3294,7 +3294,7 @@ for (const { publishing, applied, renewProof, executing } of [ await page .getByRole("button", { name: "Konfigurationen", exact: true }) .click(); - await page.getByRole("button", { name: /weiterbearbeiten$/ }).click(); + await page .getByRole("button", { name: "5 Template-Entwürfe", diff --git a/landing-zone-configurator/app/e2e/platform-owner-flow.spec.ts b/landing-zone-configurator/app/e2e/platform-owner-flow.spec.ts index 833d774..3f1b58d 100644 --- a/landing-zone-configurator/app/e2e/platform-owner-flow.spec.ts +++ b/landing-zone-configurator/app/e2e/platform-owner-flow.spec.ts @@ -303,3 +303,172 @@ test("Application Owner receives the catalogue without platform editor, binding expect(state.writes).toEqual([]); expect(state.executionWrites).toEqual([]); }); + +test("Configurations resumes editing and only confirms losing a changed draft", async ({ + page, +}) => { + const state = await mockWorkspace(page); + const dialogs: string[] = []; + let discard = false; + page.on("dialog", async (dialog) => { + dialogs.push(dialog.message()); + if (discard) await dialog.accept(); + else await dialog.dismiss(); + }); + await page.goto("/configurations"); + const open = () => + page + .getByRole("button", { + name: "Konfiguration öffnen: Existing platform workflow", + exact: true, + }) + .click(); + await open(); + const name = page.getByRole("textbox", { + name: "Name der Konfiguration", + exact: true, + }); + await expect(name).toHaveValue("Existing platform workflow"); + const navigation = page.getByRole("navigation", { + name: "Hauptnavigation", + exact: true, + }); + await navigation + .getByRole("button", { name: "Application Landing Zones", exact: true }) + .click(); + await navigation + .getByRole("button", { name: "Konfigurationen", exact: true }) + .click(); + await expect(page).toHaveURL(/\/configurations\/edit\/basics$/); + await expect(name).toHaveValue("Existing platform workflow"); + const showList = () => + page + .getByRole("navigation", { name: "Brotkrumennavigation", exact: true }) + .getByRole("link", { name: "Konfigurationen", exact: true }) + .click(); + await showList(); + await open(); + await expect(name).toHaveValue("Existing platform workflow"); + expect(dialogs).toEqual([]); + await name.fill("Unsaved platform draft"); + await showList(); + await open(); + await expect.poll(() => dialogs.length).toBe(1); + await expect(page).toHaveURL(/\/configurations$/); + await page + .getByRole("button", { + name: "Unsaved platform draft weiterbearbeiten", + exact: true, + }) + .click(); + await expect(name).toHaveValue("Unsaved platform draft"); + await expect( + page.getByRole("button", { name: "Deployment vorbereiten", exact: true }), + ).toBeDisabled(); + await showList(); + discard = true; + await open(); + await expect(name).toHaveValue("Existing platform workflow"); + await expect( + page.getByRole("button", { name: "Deployment vorbereiten", exact: true }), + ).toBeEnabled(); + expect(dialogs).toHaveLength(2); + expect(state.writes).toEqual([]); + expect(state.executionWrites).toEqual([]); + expect(state.revision()).toBe(1); +}); + +test("configuration reopen protects edits made during its read and skips confirmation for equal saved content", async ({ + page, +}) => { + const state = await mockWorkspace(page); + const dialogs: string[] = []; + page.on("dialog", async (dialog) => { + dialogs.push(dialog.message()); + await dialog.dismiss(); + }); + await page.goto("/configurations"); + const open = () => + page + .getByRole("button", { + name: "Konfiguration öffnen: Existing platform workflow", + exact: true, + }) + .click(); + const showList = () => + page + .getByRole("navigation", { name: "Brotkrumennavigation", exact: true }) + .getByRole("link", { name: "Konfigurationen", exact: true }) + .click(); + await open(); + const name = page.getByRole("textbox", { + name: "Name der Konfiguration", + exact: true, + }); + await expect(name).toHaveValue("Existing platform workflow"); + await showList(); + let release = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + await page.route( + `**/api/v1/configurations/${configurationId}`, + async (route) => { + expect(route.request().method()).toBe("GET"); + await gate; + return route.fulfill({ + json: { + configuration: { + id: configurationId, + name: state.stored().name, + revision: 1, + draft: state.stored(), + }, + }, + }); + }, + ); + const pendingRead = page.waitForRequest( + `**/api/v1/configurations/${configurationId}`, + ); + await open(); + await pendingRead; + await page + .getByRole("button", { + name: "Existing platform workflow weiterbearbeiten", + exact: true, + }) + .click(); + await name.fill("Changed while loading"); + release(); + await expect.poll(() => dialogs.length).toBe(1); + await expect(name).toHaveValue("Changed while loading"); + await expect( + page.getByRole("button", { name: "Deployment vorbereiten", exact: true }), + ).toBeDisabled(); + // Another browser saved exactly the current content. It is dirty relative to + // this tab's old snapshot, but reopening would discard no actual changes. + const equivalent = { ...state.stored(), name: "Changed while loading" }; + await page.route(`**/api/v1/configurations/${configurationId}`, (route) => { + expect(route.request().method()).toBe("GET"); + return route.fulfill({ + json: { + configuration: { + id: configurationId, + name: equivalent.name, + revision: 2, + draft: equivalent, + }, + }, + }); + }); + await showList(); + await open(); + await expect(name).toHaveValue("Changed while loading"); + await expect( + page.getByRole("button", { name: "Deployment vorbereiten", exact: true }), + ).toBeEnabled(); + expect(dialogs).toHaveLength(1); + expect(state.writes).toEqual([]); + expect(state.executionWrites).toEqual([]); +}); diff --git a/landing-zone-configurator/docs/development.de.md b/landing-zone-configurator/docs/development.de.md index 7ecac24..c34c542 100644 --- a/landing-zone-configurator/docs/development.de.md +++ b/landing-zone-configurator/docs/development.de.md @@ -41,6 +41,13 @@ Identitäts-/Arbeitsumgebungswechsel setzen weiterhin den Entwurf und den bereichsgebundenen Katalogprovider zurück; private Kataloge dürfen nie über diese Grenzen hinweg erhalten bleiben. +Die Navigation „Konfigurationen“ setzt den aktuellen Entwurf direkt im Editor +fort; der Brotkrumen-Link im Editor öffnet die gespeicherte Liste. Listenaktionen +öffnen direkt den Editor und fragen nur nach Ersetzen, wenn ein abweichender +ungespeicherter Entwurf verloren ginge. Unveränderte Entwürfe öffnen ohne +Bestätigung; Abbrechen erhält Entwurf und Speicherbindung. Die +Deployment-Phasen bleiben davon getrennte, ausdrückliche Aktionen. + Halte Frontend-Navigation und die explizite SPA-Routen-Allowlist der API synchron, einschließlich Arbeitsumgebungs- und Deployment-Phasen-URLs. Direkte Aufrufe und Neuladen müssen die kompilierte UI über die API ausliefern, nicht nur über den diff --git a/landing-zone-configurator/docs/development.md b/landing-zone-configurator/docs/development.md index c2ae4e7..b3d3f87 100644 --- a/landing-zone-configurator/docs/development.md +++ b/landing-zone-configurator/docs/development.md @@ -44,8 +44,11 @@ between these views. Preparation provenance must use the persisted configuration binding ID, never the document-local `draft.id`: saves and copies assign a separate database ID without rewriting the document. Pass that binding through both editor paths and keep distinct IDs in fixtures. Platform binding contains execution/backend setup before -publication. Configuration editing belongs to Configurations; deployment phases -remain separate explicit actions. Keep the catalogue provider scoped to workspace +publication. Configuration editing belongs to Configurations: its navigation entry resumes the +current draft in the editor; the editor breadcrumb opens the saved list. List +actions open the editor directly and ask for replacement only when a different +unsaved draft would be lost. Clean drafts reopen without confirmation; cancel +retains the draft and binding. Deployment phases remain separate explicit actions. Keep the catalogue provider scoped to workspace and platform capability so losing the platform role clears private catalogue data even though the Application view remains accessible. The Platform Owner workflow browser regression covers save, reload and progression; its Application Owner