fix(frontend): isolate administration rail from portal CSS
This commit is contained in:
@@ -0,0 +1,107 @@
|
|||||||
|
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||||
|
import { createAuthenticationStack } from "./fixtures/auth-stack.mjs";
|
||||||
|
|
||||||
|
test.describe.configure({ mode: "serial" });
|
||||||
|
test.use({ viewport: { width: 1660, height: 822 } });
|
||||||
|
|
||||||
|
let stack: Awaited<ReturnType<typeof createAuthenticationStack>>;
|
||||||
|
|
||||||
|
function json(route: Route, body: unknown) {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signInAsAdmin(page: Page) {
|
||||||
|
const account = stack.localAccount("admin");
|
||||||
|
await page.getByLabel("Username").fill(account.username);
|
||||||
|
await page.locator('input[name="password"]').fill(account.password);
|
||||||
|
await page.getByRole("button", { name: "Sign in", exact: true }).click();
|
||||||
|
await expect(page.getByTestId("app-shell")).toBeVisible({ timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
stack = await createAuthenticationStack({ withF1Workspace: true });
|
||||||
|
await stack.useLocalMode();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await stack?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Administration stays within its rail when hosted by the Omics portal", async ({ page }) => {
|
||||||
|
await page.route("**/api/settings", (route) => json(route, {
|
||||||
|
workspace: "fixture-workspace",
|
||||||
|
provider: "zai",
|
||||||
|
model: "glm-5.2",
|
||||||
|
thinking: "medium",
|
||||||
|
}));
|
||||||
|
await page.route("**/api/workspaces", (route) => json(route, [{
|
||||||
|
id: "fixture-workspace",
|
||||||
|
name: "fixture-workspace",
|
||||||
|
file: "fixture-workspace/workspace.yaml",
|
||||||
|
displayName: "Fixture workspace",
|
||||||
|
configurationState: "ready",
|
||||||
|
revision: {
|
||||||
|
id: "fixture-workspace",
|
||||||
|
commit: "a".repeat(40),
|
||||||
|
blob: "b".repeat(40),
|
||||||
|
snapshotPath: `/snapshots/${"a".repeat(40)}/fixture-workspace.yaml`,
|
||||||
|
},
|
||||||
|
}]));
|
||||||
|
await page.route("**/api/workspaces/fixture-workspace/preprocessing", (route) => json(route, {
|
||||||
|
schemaVersion: 1,
|
||||||
|
workspaceId: "fixture-workspace",
|
||||||
|
state: "running",
|
||||||
|
actionable: false,
|
||||||
|
clearable: false,
|
||||||
|
detail: "Building schema and LSH.",
|
||||||
|
progress: {
|
||||||
|
stage: "schema_index",
|
||||||
|
step: 2,
|
||||||
|
totalSteps: 4,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
await page.goto(stack.publicUrl);
|
||||||
|
await signInAsAdmin(page);
|
||||||
|
await expect(page.getByRole("combobox", { name: "Workspace" })).toHaveValue("fixture-workspace");
|
||||||
|
|
||||||
|
// Hyper/Bootstrap and Thoth currently share the portal document. These
|
||||||
|
// utility names are therefore resolved with the portal's !important values.
|
||||||
|
await page.addStyleTag({ content: `
|
||||||
|
.px-2 { padding-right: 0.75rem !important; padding-left: 0.75rem !important; }
|
||||||
|
.px-3 { padding-right: 1.5rem !important; padding-left: 1.5rem !important; }
|
||||||
|
.px-4 { padding-right: 2.25rem !important; padding-left: 2.25rem !important; }
|
||||||
|
` });
|
||||||
|
|
||||||
|
const rail = page.getByRole("complementary", { name: "Session navigation" });
|
||||||
|
await rail.getByRole("button", { name: "Administration", exact: true }).click();
|
||||||
|
const administration = rail.getByRole("region", { name: "Administration" });
|
||||||
|
const boundary = rail.getByTestId("administration-boundary");
|
||||||
|
await expect(administration).toBeVisible();
|
||||||
|
await expect(rail.getByText("2 / 4", { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
const containment = await administration.evaluate((panel) => {
|
||||||
|
const elements = Array.from(panel.querySelectorAll<HTMLElement>('button, [role="progressbar"], span'))
|
||||||
|
.filter((element) => element.matches('button, [role="progressbar"]') || element.textContent?.trim() === "2 / 4");
|
||||||
|
return elements.map((element) => {
|
||||||
|
const box = element.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
label: element.textContent?.trim() || element.getAttribute("aria-label") || element.tagName,
|
||||||
|
left: box.left,
|
||||||
|
right: box.right,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const boundaryBox = await boundary.boundingBox();
|
||||||
|
expect(boundaryBox).not.toBeNull();
|
||||||
|
if (!boundaryBox) return;
|
||||||
|
|
||||||
|
for (const item of containment) {
|
||||||
|
expect.soft(item.left, `${item.label} crosses the Administration left edge`).toBeGreaterThanOrEqual(boundaryBox.x - 1);
|
||||||
|
expect.soft(item.right, `${item.label} crosses the Administration right edge`).toBeLessThanOrEqual(boundaryBox.x + boundaryBox.width + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -426,12 +426,15 @@ test("context panels stay inside the manager and the Tables grid sits in a sideb
|
|||||||
const administrationPanel = adminNavigationRail.getByRole("region", { name: "Administration" });
|
const administrationPanel = adminNavigationRail.getByRole("region", { name: "Administration" });
|
||||||
await expect(administrationPanel).toBeVisible();
|
await expect(administrationPanel).toBeVisible();
|
||||||
expect(await administrationPanel.locator(":scope > *").evaluateAll((elements) => elements.map((element) => (
|
expect(await administrationPanel.locator(":scope > *").evaluateAll((elements) => elements.map((element) => (
|
||||||
element.getAttribute("role") === "separator" ? "separator" : element.textContent?.trim()
|
element.getAttribute("role") === "separator"
|
||||||
|
? "separator"
|
||||||
|
: element.getAttribute("aria-label") ?? element.textContent?.trim()
|
||||||
)))).toEqual([
|
)))).toEqual([
|
||||||
"Database management",
|
"Database management",
|
||||||
"separator",
|
"separator",
|
||||||
"Workspace management",
|
"Workspace management",
|
||||||
"Pi management",
|
"Pi management",
|
||||||
|
"Workspace preprocessing",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await administration.click();
|
await administration.click();
|
||||||
|
|||||||
@@ -181,6 +181,21 @@
|
|||||||
* Scoped so it never leaks into widgets.
|
* Scoped so it never leaks into widgets.
|
||||||
*/
|
*/
|
||||||
@layer components {
|
@layer components {
|
||||||
|
/* Omics Portal and Thoth share one document in the embedded deployment.
|
||||||
|
Keep rail geometry on product-specific class names so Hyper/Bootstrap's
|
||||||
|
global spacing utilities (for example .px-4 !important) cannot resize it. */
|
||||||
|
.thot-session-navigation__header {
|
||||||
|
padding: 1.25rem 1rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thot-session-navigation__primary-controls {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0 1rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Standard micro-label: panel headers, nav sections, meta rows. Set in the
|
/* Standard micro-label: panel headers, nav sections, meta rows. Set in the
|
||||||
mono register so labels/meta read as a distinct typographic layer from the
|
mono register so labels/meta read as a distinct typographic layer from the
|
||||||
sans body prose (mono needs less tracking than the sans did). */
|
sans body prose (mono needs less tracking than the sans did). */
|
||||||
|
|||||||
@@ -855,7 +855,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
|||||||
{/* Right session rail */}
|
{/* Right session rail */}
|
||||||
{(activeSurface === "database-management" || !showActivity) && (
|
{(activeSurface === "database-management" || !showActivity) && (
|
||||||
<aside aria-label="Session navigation" className="flex w-64 shrink-0 flex-col bg-sidebar">
|
<aside aria-label="Session navigation" className="flex w-64 shrink-0 flex-col bg-sidebar">
|
||||||
<div className="relative px-4 pb-3 pt-5 text-center">
|
<div className="thot-session-navigation__header relative text-center">
|
||||||
<h1 className="font-heading text-xl font-semibold leading-none tracking-tight text-foreground">
|
<h1 className="font-heading text-xl font-semibold leading-none tracking-tight text-foreground">
|
||||||
Thoth<span className="text-primary">II</span>
|
Thoth<span className="text-primary">II</span>
|
||||||
</h1>
|
</h1>
|
||||||
@@ -878,11 +878,11 @@ export function AppShell({ canLogout }: AppShellProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 px-4 pb-3">
|
<div className="thot-session-navigation__primary-controls">
|
||||||
<Button
|
<Button
|
||||||
variant={currentNavigation === "core" ? "navigationActive" : "outline"}
|
variant={currentNavigation === "core" ? "navigationActive" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full"
|
className="w-full min-w-0 max-w-full gap-[0.25rem] px-[0.75rem]"
|
||||||
aria-current={currentNavigation === "core" ? "page" : undefined}
|
aria-current={currentNavigation === "core" ? "page" : undefined}
|
||||||
data-navigation-state={preprocessingBlocksNewSession
|
data-navigation-state={preprocessingBlocksNewSession
|
||||||
? "unavailable"
|
? "unavailable"
|
||||||
@@ -897,15 +897,16 @@ export function AppShell({ canLogout }: AppShellProps) {
|
|||||||
<Accordion.Root
|
<Accordion.Root
|
||||||
value={adminNavigationValue}
|
value={adminNavigationValue}
|
||||||
onValueChange={setAdminNavigationValue}
|
onValueChange={setAdminNavigationValue}
|
||||||
className="rounded-lg border border-border/80 bg-card/45 p-1.5"
|
data-testid="administration-boundary"
|
||||||
|
className="min-w-0 max-w-full rounded-lg border border-border/80 bg-card/45 p-1.5"
|
||||||
>
|
>
|
||||||
<Accordion.Item value="administration">
|
<Accordion.Item value="administration" className="min-w-0 max-w-full">
|
||||||
<Accordion.Header className="m-0">
|
<Accordion.Header className="m-0 min-w-0 max-w-full">
|
||||||
<Accordion.Trigger
|
<Accordion.Trigger
|
||||||
className={buttonVariants({
|
className={buttonVariants({
|
||||||
variant: !adminNavigationOpen && managementNavigationCurrent ? "navigationActive" : "outline",
|
variant: !adminNavigationOpen && managementNavigationCurrent ? "navigationActive" : "outline",
|
||||||
size: "sm",
|
size: "sm",
|
||||||
className: "w-full justify-between px-2.5",
|
className: "w-full min-w-0 max-w-full justify-between px-2.5",
|
||||||
})}
|
})}
|
||||||
data-navigation-state={!adminNavigationOpen && managementNavigationCurrent ? "current" : "available"}
|
data-navigation-state={!adminNavigationOpen && managementNavigationCurrent ? "current" : "available"}
|
||||||
>
|
>
|
||||||
@@ -917,11 +918,11 @@ export function AppShell({ canLogout }: AppShellProps) {
|
|||||||
/>
|
/>
|
||||||
</Accordion.Trigger>
|
</Accordion.Trigger>
|
||||||
</Accordion.Header>
|
</Accordion.Header>
|
||||||
<Accordion.Panel className="grid gap-1.5 px-0.5 pt-1.5">
|
<Accordion.Panel className="grid min-w-0 max-w-full gap-1.5 px-0.5 pt-1.5">
|
||||||
<Button
|
<Button
|
||||||
variant={currentNavigation === "database" ? "navigationActive" : "outline"}
|
variant={currentNavigation === "database" ? "navigationActive" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full"
|
className="w-full min-w-0 max-w-full gap-[0.25rem] px-[0.75rem]"
|
||||||
aria-current={currentNavigation === "database" ? "page" : undefined}
|
aria-current={currentNavigation === "database" ? "page" : undefined}
|
||||||
data-navigation-state={currentNavigation === "database" ? "current" : canManageDatabase ? "available" : "unavailable"}
|
data-navigation-state={currentNavigation === "database" ? "current" : canManageDatabase ? "available" : "unavailable"}
|
||||||
disabled={!canManageDatabase}
|
disabled={!canManageDatabase}
|
||||||
@@ -937,7 +938,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
|||||||
<Button
|
<Button
|
||||||
variant={currentNavigation === "workspace" ? "navigationActive" : "outline"}
|
variant={currentNavigation === "workspace" ? "navigationActive" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full"
|
className="w-full min-w-0 max-w-full gap-[0.25rem] px-[0.75rem]"
|
||||||
aria-current={currentNavigation === "workspace" ? "page" : undefined}
|
aria-current={currentNavigation === "workspace" ? "page" : undefined}
|
||||||
aria-expanded={activeManagementPanel === "workspace"}
|
aria-expanded={activeManagementPanel === "workspace"}
|
||||||
data-navigation-state={currentNavigation === "workspace" ? "current" : "available"}
|
data-navigation-state={currentNavigation === "workspace" ? "current" : "available"}
|
||||||
@@ -952,7 +953,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
|||||||
<Button
|
<Button
|
||||||
variant={currentNavigation === "pi" ? "navigationActive" : "outline"}
|
variant={currentNavigation === "pi" ? "navigationActive" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full"
|
className="w-full min-w-0 max-w-full gap-[0.25rem] px-[0.75rem]"
|
||||||
aria-current={currentNavigation === "pi" ? "page" : undefined}
|
aria-current={currentNavigation === "pi" ? "page" : undefined}
|
||||||
aria-expanded={activeManagementPanel === "pi"}
|
aria-expanded={activeManagementPanel === "pi"}
|
||||||
data-navigation-state={currentNavigation === "pi" ? "current" : canManagePi ? "available" : "unavailable"}
|
data-navigation-state={currentNavigation === "pi" ? "current" : canManagePi ? "available" : "unavailable"}
|
||||||
|
|||||||
@@ -57,8 +57,8 @@ function StatusIcon({ state }: { state?: WorkspacePreprocessingState }) {
|
|||||||
function PhaseProgress({ progress }: { progress: PreprocessingProgress }) {
|
function PhaseProgress({ progress }: { progress: PreprocessingProgress }) {
|
||||||
const step = Math.max(1, Math.min(progress.step, progress.totalSteps));
|
const step = Math.max(1, Math.min(progress.step, progress.totalSteps));
|
||||||
return (
|
return (
|
||||||
<div className="col-span-2 mt-1 grid gap-1">
|
<div className="col-span-2 mt-1 grid min-w-0 max-w-full gap-1">
|
||||||
<div className="flex items-center justify-between gap-2 text-[9px] leading-none">
|
<div className="flex min-w-0 max-w-full items-center justify-between gap-[0.5rem] text-[9px] leading-none">
|
||||||
<span className="min-w-0 truncate font-medium text-foreground">
|
<span className="min-w-0 truncate font-medium text-foreground">
|
||||||
{PROGRESS_LABELS[progress.stage]}
|
{PROGRESS_LABELS[progress.stage]}
|
||||||
</span>
|
</span>
|
||||||
@@ -240,7 +240,7 @@ export function WorkspacePreprocessingControl({
|
|||||||
<section
|
<section
|
||||||
aria-label="Workspace preprocessing"
|
aria-label="Workspace preprocessing"
|
||||||
aria-busy={state === "running" || loading || clearMutation.isPending}
|
aria-busy={state === "running" || loading || clearMutation.isPending}
|
||||||
className="mt-0.5 border-t border-border/90 px-0.5 pt-2"
|
className="mt-0.5 min-w-0 max-w-full border-t border-border/90 px-0.5 pt-2"
|
||||||
>
|
>
|
||||||
<p className="mb-2 px-0.5 font-mono text-[9px] font-semibold uppercase tracking-[0.08em] text-primary">
|
<p className="mb-2 px-0.5 font-mono text-[9px] font-semibold uppercase tracking-[0.08em] text-primary">
|
||||||
Preprocessing
|
Preprocessing
|
||||||
@@ -272,11 +272,11 @@ export function WorkspacePreprocessingControl({
|
|||||||
<span><strong className="block text-foreground">Keep</strong><small className="text-muted-foreground">Memory and solved questions</small></span>
|
<span><strong className="block text-foreground">Keep</strong><small className="text-muted-foreground">Memory and solved questions</small></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-[0.78fr_1.22fr] gap-1.5">
|
<div className="grid min-w-0 max-w-full grid-cols-[minmax(0,0.78fr)_minmax(0,1.22fr)] gap-1.5">
|
||||||
<Button type="button" variant="outline" size="xs" className="h-7 text-[9px]" disabled={clearMutation.isPending} onClick={() => setConfirmingClear(false)}>
|
<Button type="button" variant="outline" size="xs" className="h-7 min-w-0 max-w-full px-[0.625rem] text-[9px]" disabled={clearMutation.isPending} onClick={() => setConfirmingClear(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" variant="destructive" size="xs" className="h-7 text-[9px]" disabled={clearMutation.isPending} onClick={() => clearMutation.mutate()}>
|
<Button type="button" variant="destructive" size="xs" className="h-7 min-w-0 max-w-full px-[0.625rem] text-[9px]" disabled={clearMutation.isPending} onClick={() => clearMutation.mutate()}>
|
||||||
{clearMutation.isPending ? <LoaderCircle aria-hidden="true" className="animate-spin motion-reduce:animate-none" /> : <Trash2 aria-hidden="true" />}
|
{clearMutation.isPending ? <LoaderCircle aria-hidden="true" className="animate-spin motion-reduce:animate-none" /> : <Trash2 aria-hidden="true" />}
|
||||||
{clearMutation.isPending ? "Clearing" : "Clear derived data"}
|
{clearMutation.isPending ? "Clearing" : "Clear derived data"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -284,7 +284,7 @@ export function WorkspacePreprocessingControl({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-[1.25rem_minmax(0,1fr)] items-center gap-1.5">
|
<div className="grid min-w-0 max-w-full grid-cols-[1.25rem_minmax(0,1fr)] items-center gap-1.5">
|
||||||
<span className={`grid size-5 place-items-center rounded-full bg-muted ${stateClasses(state)}`}>
|
<span className={`grid size-5 place-items-center rounded-full bg-muted ${stateClasses(state)}`}>
|
||||||
<StatusIcon state={state} />
|
<StatusIcon state={state} />
|
||||||
</span>
|
</span>
|
||||||
@@ -293,12 +293,12 @@ export function WorkspacePreprocessingControl({
|
|||||||
{state !== "running" && <span className="text-[9px] leading-tight text-muted-foreground" title={detail}>{detail}</span>}
|
{state !== "running" && <span className="text-[9px] leading-tight text-muted-foreground" title={detail}>{detail}</span>}
|
||||||
</div>
|
</div>
|
||||||
{progress && <PhaseProgress progress={progress} />}
|
{progress && <PhaseProgress progress={progress} />}
|
||||||
<div className="col-span-2 mt-0.5 grid grid-cols-[0.78fr_1.22fr] gap-1.5">
|
<div className="col-span-2 mt-0.5 grid min-w-0 max-w-full grid-cols-[minmax(0,0.78fr)_minmax(0,1.22fr)] gap-1.5">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="xs"
|
size="xs"
|
||||||
className="h-7 px-2 text-[10px] hover:border-destructive/35 hover:bg-destructive/5 hover:text-destructive"
|
className="h-7 min-w-0 max-w-full px-[0.5rem] text-[10px] hover:border-destructive/35 hover:bg-destructive/5 hover:text-destructive"
|
||||||
disabled={!clearable}
|
disabled={!clearable}
|
||||||
title={!canManage ? "Database management permission is required" : undefined}
|
title={!canManage ? "Database management permission is required" : undefined}
|
||||||
onClick={() => setConfirmingClear(true)}
|
onClick={() => setConfirmingClear(true)}
|
||||||
@@ -309,7 +309,7 @@ export function WorkspacePreprocessingControl({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="xs"
|
size="xs"
|
||||||
className="h-7 px-2 text-[10px]"
|
className="h-7 min-w-0 max-w-full px-[0.5rem] text-[10px]"
|
||||||
disabled={!actionable}
|
disabled={!actionable}
|
||||||
title={!canManage ? "Database management permission is required" : undefined}
|
title={!canManage ? "Database management permission is required" : undefined}
|
||||||
onClick={() => mutation.mutate()}
|
onClick={() => mutation.mutate()}
|
||||||
|
|||||||
Reference in New Issue
Block a user