Skip to content

Commit 47dbad7

Browse files
committed
fix(configurator): persist catalogue selection and filter role choices
1 parent 9f8436f commit 47dbad7

5 files changed

Lines changed: 250 additions & 18 deletions

File tree

‎landing-zone-configurator/app/apps/web/src/components/CloudCatalogues.tsx‎

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,39 @@ import {
66
useRef,
77
useState,
88
} from "react";
9+
import { workspaceKey } from "../workspace";
910
import type { Session } from "./Account";
1011
import { type CloudCatalogue, catalogueField } from "./cloud-catalogue-fields";
1112

13+
type CatalogueSelection = {
14+
profileId: string;
15+
projectId: string;
16+
region: string;
17+
};
18+
19+
function readSelection(key: string | null): CatalogueSelection | null {
20+
try {
21+
const raw = key && localStorage.getItem(`${key}:catalogue`);
22+
if (!raw || raw.length > 1024) return null;
23+
const value = JSON.parse(raw);
24+
if (
25+
typeof value.profileId !== "string" ||
26+
value.profileId.length > 36 ||
27+
typeof value.projectId !== "string" ||
28+
value.projectId.length > 64 ||
29+
!["eu01", "eu02"].includes(value.region)
30+
)
31+
return null;
32+
return {
33+
profileId: value.profileId,
34+
projectId: value.projectId,
35+
region: value.region,
36+
};
37+
} catch {
38+
return null;
39+
}
40+
}
41+
1242
type CatalogueState = {
1343
data: CloudCatalogue | null;
1444
setData: (data: CloudCatalogue | null) => void;
@@ -51,13 +81,30 @@ export function CloudCataloguePanel({ session }: { session: Session | null }) {
5181
const activeRequest = useRef<AbortController | null>(null);
5282
useEffect(() => () => activeRequest.current?.abort(), []);
5383
const [error, setError] = useState("");
84+
const [storageWarning, setStorageWarning] = useState("");
85+
function remember(selection: CatalogueSelection) {
86+
const key = workspaceKey(session);
87+
if (!key) return;
88+
try {
89+
localStorage.setItem(`${key}:catalogue`, JSON.stringify(selection));
90+
setStorageWarning("");
91+
} catch {
92+
setStorageWarning(
93+
"Die Katalogauswahl konnte in diesem Browser nicht gespeichert werden.",
94+
);
95+
}
96+
}
5497
useEffect(() => {
5598
activeRequest.current?.abort();
5699
setBusy(false);
57100
setData(null);
58101
setProfiles([]);
59102
setProfileId("");
60-
setProjectId("");
103+
const saved = readSelection(workspaceKey(session));
104+
setProjectId(saved?.projectId ?? "");
105+
setRegion(saved?.region ?? "eu01");
106+
setError("");
107+
setStorageWarning("");
61108
if (!session) return;
62109
const controller = new AbortController();
63110
void fetch("/api/v1/credentials", { signal: controller.signal })
@@ -66,8 +113,14 @@ export function CloudCataloguePanel({ session }: { session: Session | null }) {
66113
const body = (await response.json()) as {
67114
profiles: { id: string; name: string; state: string }[];
68115
};
69-
setProfiles(
70-
body.profiles.filter((profile) => profile.state === "stored"),
116+
const stored = body.profiles.filter(
117+
(profile) => profile.state === "stored",
118+
);
119+
setProfiles(stored);
120+
setProfileId(
121+
stored.some((profile) => profile.id === saved?.profileId)
122+
? (saved?.profileId ?? "")
123+
: "",
71124
);
72125
})
73126
.catch(() => {
@@ -131,6 +184,7 @@ export function CloudCataloguePanel({ session }: { session: Session | null }) {
131184
value={profileId}
132185
onChange={(event) => {
133186
setProfileId(event.target.value);
187+
remember({ profileId: event.target.value, projectId, region });
134188
setData(null);
135189
}}
136190
>
@@ -150,6 +204,7 @@ export function CloudCataloguePanel({ session }: { session: Session | null }) {
150204
placeholder="UUID eines vorhandenen STACKIT-Projekts"
151205
onChange={(event) => {
152206
setProjectId(event.target.value);
207+
remember({ profileId, projectId: event.target.value, region });
153208
setData(null);
154209
}}
155210
/>
@@ -162,6 +217,7 @@ export function CloudCataloguePanel({ session }: { session: Session | null }) {
162217
value={region}
163218
onChange={(event) => {
164219
setRegion(event.target.value);
220+
remember({ profileId, projectId, region: event.target.value });
165221
setData(null);
166222
}}
167223
>
@@ -185,6 +241,7 @@ export function CloudCataloguePanel({ session }: { session: Session | null }) {
185241
{busy ? "Wird geladen …" : "Produktoptionen aktualisieren"}
186242
</button>
187243
{error && <p role="alert">{error}</p>}
244+
{storageWarning && <p role="status">{storageWarning}</p>}
188245
{data && (
189246
<>
190247
<p role="status">

‎landing-zone-configurator/app/apps/web/src/components/TemplateParameters.tsx‎

Lines changed: 59 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,8 @@ export function TemplateParameters({
241241
}) => void;
242242
}) {
243243
const [inputs, setInputs] = useState<Record<string, JsonValue>>({});
244+
const [roleSearch, setRoleSearch] = useState("");
245+
const [selectedRolesOnly, setSelectedRolesOnly] = useState(false);
244246
const catalogue = useCatalogueOptions(
245247
"landing_zones[*].observability.plan_name",
246248
template.region,
@@ -305,7 +307,12 @@ export function TemplateParameters({
305307
...customRoles,
306308
...assigned,
307309
]),
308-
];
310+
].sort((leftRole, rightRole) => leftRole.localeCompare(rightRole));
311+
const visibleRoles = roleOptions.filter(
312+
(role) =>
313+
role.toLowerCase().includes(roleSearch.trim().toLowerCase()) &&
314+
(!selectedRolesOnly || assigned.includes(role)),
315+
);
309316
return (
310317
<details key={field.path} className="parameter-card">
311318
<summary>
@@ -355,27 +362,66 @@ export function TemplateParameters({
355362
</option>
356363
</select>
357364
</div>
358-
<fieldset>
365+
<fieldset className="role-options">
359366
<legend>
360367
Rollen für die projektverantwortliche Person
361368
</legend>
362-
{roleOptions.map((role) => (
363-
<label key={role} className="parameter-choice">
369+
<div className="role-filters">
370+
<div className="field">
371+
<label htmlFor={`${template.id}-role-search`}>
372+
Projektrollen durchsuchen
373+
</label>
374+
<input
375+
id={`${template.id}-role-search`}
376+
type="search"
377+
value={roleSearch}
378+
onChange={(event) =>
379+
setRoleSearch(event.target.value)
380+
}
381+
/>
382+
</div>
383+
<label className="parameter-choice">
364384
<input
365385
type="checkbox"
366-
checked={assigned.includes(role)}
386+
checked={selectedRolesOnly}
367387
onChange={(event) =>
368-
setPolicy(field.path, {
369-
...rule,
370-
roles: event.target.checked
371-
? [...assigned, role]
372-
: assigned.filter((value) => value !== role),
373-
})
388+
setSelectedRolesOnly(event.target.checked)
374389
}
375390
/>
376-
{role}
391+
Nur ausgewählte Rollen
377392
</label>
378-
))}
393+
</div>
394+
<p className="field-hint" role="status">
395+
{assigned.length} ausgewählt · {visibleRoles.length} von{" "}
396+
{roleOptions.length} Rollen
397+
</p>
398+
<div className="role-list">
399+
{visibleRoles.map((role) => (
400+
<label key={role} className="parameter-choice">
401+
<input
402+
type="checkbox"
403+
checked={assigned.includes(role)}
404+
disabled={
405+
!assigned.includes(role) && assigned.length >= 100
406+
}
407+
onChange={(event) =>
408+
setPolicy(field.path, {
409+
...rule,
410+
roles: event.target.checked
411+
? [...assigned, role]
412+
: assigned.filter((value) => value !== role),
413+
})
414+
}
415+
/>
416+
<span>{role}</span>
417+
</label>
418+
))}
419+
</div>
420+
{!visibleRoles.length && roleOptions.length > 0 && (
421+
<p className="field-hint">
422+
Keine Rollen für diesen Filter.
423+
</p>
424+
)}
379425
</fieldset>
380426
{!roleOptions.length && (
381427
<p className="field-hint">

‎landing-zone-configurator/app/apps/web/src/style.css‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1060,3 +1060,47 @@ pre {
10601060
margin: 4px 0 12px;
10611061
overflow-wrap: anywhere;
10621062
}
1063+
1064+
.role-options {
1065+
min-width: 0;
1066+
margin-block: 16px;
1067+
padding: 12px;
1068+
border: 1px solid var(--border);
1069+
border-radius: 4px;
1070+
}
1071+
.role-filters {
1072+
display: flex;
1073+
flex-wrap: wrap;
1074+
align-items: center;
1075+
gap: 12px 24px;
1076+
}
1077+
.role-filters .field {
1078+
flex: 1 1 240px;
1079+
min-width: 0;
1080+
}
1081+
.role-list {
1082+
display: grid;
1083+
grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr));
1084+
align-content: start;
1085+
gap: 8px 16px;
1086+
max-height: 320px;
1087+
overflow: auto;
1088+
scrollbar-gutter: stable;
1089+
}
1090+
.role-options .parameter-choice {
1091+
display: flex;
1092+
align-items: flex-start;
1093+
gap: 8px;
1094+
min-width: 0;
1095+
min-height: 36px;
1096+
padding: 6px;
1097+
font-weight: 400;
1098+
overflow-wrap: anywhere;
1099+
cursor: pointer;
1100+
}
1101+
.role-options .parameter-choice input {
1102+
flex: 0 0 16px;
1103+
width: 16px;
1104+
height: 16px;
1105+
margin: 2px 0 0;
1106+
}

‎landing-zone-configurator/app/e2e/cloud-catalogues.spec.ts‎

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test";
22

33
test("product choices preserve imported values and restore manual fields when catalogue access fails", async ({
44
page,
5-
}) => {
5+
}, testInfo) => {
66
const session = {
77
user: { id: "catalogue-user", login: "alice" },
88
tenant: { id: "personal-one" },
@@ -68,7 +68,13 @@ test("product choices preserve imported values and restore manual fields when ca
6868
},
6969
projectRoles: {
7070
status: "available",
71-
options: [{ value: "viewer", label: "viewer" }],
71+
options: [
72+
{ value: "viewer", label: "viewer" },
73+
...Array.from({ length: 150 }, (_, index) => ({
74+
value: `service-${index}.reader`,
75+
label: `service-${index}.reader`,
76+
})),
77+
],
7278
},
7379
projectPermissions: {
7480
status: "available",
@@ -315,11 +321,81 @@ test("product choices preserve imported values and restore manual fields when ca
315321
})
316322
.selectOption("context");
317323
await template.getByLabel("application-viewer", { exact: true }).check();
324+
const roleList = await template.locator(".role-list").evaluate((element) => ({
325+
height: element.clientHeight,
326+
scrollHeight: element.scrollHeight,
327+
width: element.clientWidth,
328+
scrollWidth: element.scrollWidth,
329+
}));
330+
expect(roleList.height).toBeLessThanOrEqual(320);
331+
expect(roleList.scrollHeight).toBeGreaterThan(roleList.height);
332+
expect(roleList.scrollWidth).toBeLessThanOrEqual(roleList.width);
333+
await template
334+
.locator(".role-options")
335+
.screenshot({ path: testInfo.outputPath("role-picker.png") });
336+
await template
337+
.getByLabel("Projektrollen durchsuchen", { exact: true })
338+
.fill("application");
339+
await expect(template.locator(".role-list input")).toHaveCount(1);
340+
await template
341+
.getByLabel("Projektrollen durchsuchen", { exact: true })
342+
.fill("");
343+
await template.getByLabel("Nur ausgewählte Rollen", { exact: true }).check();
344+
await expect(template.locator(".role-list input")).toHaveCount(1);
345+
await expect(
346+
template.getByLabel("application-viewer", { exact: true }),
347+
).toBeChecked();
318348
await template.getByText("Bestellung testen", { exact: true }).click();
319349
await expect(template.locator(".parameter-preview")).toContainText(
320350
"Projektverantwortliche Person wird bei der Instanziierung zugeordnet",
321351
);
322352
await expect(template.locator(".parameter-preview")).toContainText(
323353
"verifizierten STACKIT-Identität",
324354
);
355+
await page.getByLabel("Katalogregion", { exact: true }).selectOption("eu02");
356+
await page.reload();
357+
await expect(
358+
page.getByRole("button", { name: "Abmelden", exact: true }),
359+
).toBeVisible();
360+
await page
361+
.locator("summary")
362+
.filter({ hasText: "STACKIT-Produktoptionen laden" })
363+
.click();
364+
await expect(page.getByLabel("Katalogzugang", { exact: true })).toHaveValue(
365+
"11111111-1111-4111-8111-111111111111",
366+
);
367+
await expect(page.getByLabel("Referenzprojekt-ID")).toHaveValue(
368+
"22222222-2222-4222-8222-222222222222",
369+
);
370+
await expect(page.getByLabel("Katalogregion", { exact: true })).toHaveValue(
371+
"eu02",
372+
);
373+
const saved = await page.evaluate(() =>
374+
JSON.parse(
375+
localStorage.getItem(
376+
"lzc-workspace-v1:personal-one:catalogue-user:catalogue",
377+
) ?? "null",
378+
),
379+
);
380+
expect(Object.keys(saved).sort()).toEqual([
381+
"profileId",
382+
"projectId",
383+
"region",
384+
]);
385+
session.tenant.id = "personal-two";
386+
await page.reload();
387+
await expect(
388+
page.getByRole("button", { name: "Abmelden", exact: true }),
389+
).toBeVisible();
390+
await page
391+
.locator("summary")
392+
.filter({ hasText: "STACKIT-Produktoptionen laden" })
393+
.click();
394+
await expect(page.getByLabel("Katalogzugang", { exact: true })).toHaveValue(
395+
"",
396+
);
397+
await expect(page.getByLabel("Referenzprojekt-ID")).toHaveValue("");
398+
await expect(page.getByLabel("Katalogregion", { exact: true })).toHaveValue(
399+
"eu01",
400+
);
325401
});

‎landing-zone-configurator/docs/cloud-product-catalogues.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,15 @@ Alle Cloud-Ziele sind fest definiert; Redirects sind verboten, Antworten sind au
3838

3939
Der erste Abschnitt gilt im persönlichen Arbeitsbereich. Organisationstenants bleiben bis zur Einführung freigegebener Plattformzugänge im Entwurfsmodus; sie übernehmen keine Credentials aus persönlichen Arbeitsbereichen.
4040

41+
Katalogzugang, Referenzprojekt-ID und Region werden in diesem Browser pro Benutzer
42+
und Arbeitsbereich gespeichert und beim erneuten Öffnen wiederhergestellt.
43+
Gespeichert werden ausschließlich diese drei Auswahlwerte, keine Schlüssel,
44+
Tokens oder Katalogantworten. Das Profil wird erneut gegen die zugänglichen,
45+
gespeicherten Credential-Profile geprüft; ein gelöschtes Profil wird nicht mehr
46+
vorausgewählt. Kataloge werden weiterhin nur nach expliziter Aktualisierung
47+
abgerufen. Die Einstellung wird nicht zwischen Geräten synchronisiert; blockierter
48+
Browser-Speicher erzeugt einen sichtbaren Hinweis.
49+
4150
## Erweiterung: Observability und Diagnose-Bastion
4251

4352
- [x] `observability.plan_name` und die Observability-Blöcke von Plattform-Clustern, Landing Zones und Sandboxes verwenden `plans[].name` als Terraform-Eingabe, nicht die abweichende Plan-UUID.

0 commit comments

Comments
 (0)