mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
fix(site): WCAG 2.1 AA accessibility remediation for core frontend flows (#22673)
This PR is several accessibility improvements researched by Mux.
Manually tested and these changes should be mostly harmless.
## Summary
Targeted WCAG 2.1 AA accessibility remediation across core frontend user
flows: login, dashboard navigation, audit interactions, settings, and
workspace parameter inputs.
### Changes
#### Navigation, keyboard & focus visibility (WCAG 2.4.1, 2.1.1, 2.4.7,
1.4.11)
- **DashboardLayout**: Added "Skip to main content" link (visually
hidden, visible on focus) with `#main-content` target on the main outlet
container.
- **AuditLogRow**: Expanded keyboard handler so both Enter and Space
toggle expandable audit details (with `preventDefault` on Space to
prevent scroll).
- **model-selector**: Removed `focus:ring-0 focus-visible:ring-0` from
`SelectTrigger` to restore the default visible focus indicator.
#### Forms & input assistance (WCAG 3.3.1, 3.3.2, 1.3.5)
- **PasswordSignInForm**: Wired `aria-invalid` and `aria-describedby` on
email/password inputs, pointing to stable-ID error elements
(`signin-email-error`, `signin-password-error`).
- **AccountForm**: Added `autoComplete="name"` to the Name field.
#### Name/role/value & status messages (WCAG 1.1.1, 4.1.2, 4.1.3, 1.3.1)
- **PortForwardButton**: Added `aria-label="Delete shared port"` to the
icon-only delete button.
- **DynamicParameter**: Replaced mouse-only peek-and-hold reveal with a
persistent keyboard-accessible toggle. Added dynamic `aria-label` ("Show
value"/"Hide value") and `aria-pressed`.
- **Tabs**: Removed incorrect `role="tablist"` (these are route
navigation links, not ARIA tabs). Added `aria-current="page"` on the
active `TabLink`.
- **Loader**: Wrapped spinner in `role="status" aria-live="polite"`
container with an `aria-label` for screen reader announcements.
- **Alert**: Changed `AlertTitle` from `<h1>` to `<h2>` to avoid
multiple page-level headings.
### Testing
- **9 Vitest test files** (22 tests) — all new or extended to cover the
a11y changes.
- **1 Jest test file** (38 tests) — DynamicParameter tests updated for
toggle semantics + keyboard activation.
- `pnpm lint:types` ✅
- `pnpm check` (Biome lint + format) ✅
### Files changed
| File | Change |
|------|--------|
| `site/src/modules/dashboard/DashboardLayout.tsx` | Skip link +
`#main-content` id |
| `site/src/modules/dashboard/DashboardLayout.test.tsx` | Skip link
assertions |
| `site/src/pages/AuditPage/AuditLogRow/AuditLogRow.tsx` | Space + Enter
keyboard handling |
| `site/src/pages/AuditPage/AuditPage.test.tsx` | Keyboard toggle tests
|
| `site/src/components/ai-elements/model-selector.tsx` | Remove focus
ring suppression |
| `site/src/components/ai-elements/model-selector.test.tsx` | **New** —
focus ring assertion |
| `site/src/pages/LoginPage/PasswordSignInForm.tsx` | aria-invalid +
aria-describedby |
| `site/src/pages/LoginPage/LoginPage.test.tsx` | Error association
tests |
| `site/src/pages/UserSettingsPage/AccountPage/AccountForm.tsx` |
autoComplete="name" |
| `site/src/pages/UserSettingsPage/AccountPage/AccountForm.test.tsx` |
Autocomplete assertion |
| `site/src/modules/resources/PortForwardButton.tsx` | aria-label on
delete button |
| `site/src/modules/resources/PortForwardButton.test.tsx` | **New** —
accessible name test |
| `site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx` |
Keyboard toggle + ARIA |
|
`site/src/modules/workspaces/DynamicParameter/DynamicParameter.jest.tsx`
| Toggle semantics tests |
| `site/src/components/Tabs/Tabs.tsx` | Remove tablist role, add
aria-current |
| `site/src/components/Tabs/Tabs.test.tsx` | **New** —
tablist/aria-current tests |
| `site/src/components/Loader/Loader.tsx` | role="status" + aria-live |
| `site/src/components/Loader/Loader.test.tsx` | **New** — status
semantics tests |
| `site/src/components/Alert/Alert.tsx` | h1 → h2 |
| `site/src/components/Alert/Alert.test.tsx` | **New** — heading level
test |
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "./Alert";
|
||||
|
||||
describe("AlertTitle", () => {
|
||||
it("renders as an h2 heading", () => {
|
||||
render(
|
||||
<Alert>
|
||||
<AlertTitle>Deployment warning</AlertTitle>
|
||||
<AlertDescription>Something needs your attention.</AlertDescription>
|
||||
</Alert>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 2, name: "Deployment warning" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("heading", { level: 1, name: "Deployment warning" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -135,9 +135,9 @@ export const AlertDescription: React.FC<React.PropsWithChildren> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const AlertTitle: React.FC<React.ComponentPropsWithRef<"h1">> = ({
|
||||
export const AlertTitle: React.FC<React.ComponentPropsWithRef<"h2">> = ({
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return <h1 className={cn("m-0 text-sm font-medium", className)} {...props} />;
|
||||
return <h2 className={cn("m-0 text-sm font-medium", className)} {...props} />;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { Loader } from "./Loader";
|
||||
|
||||
describe("Loader", () => {
|
||||
it("announces loading status politely", () => {
|
||||
render(<Loader />);
|
||||
|
||||
expect(screen.getByRole("status")).toHaveAttribute("aria-live", "polite");
|
||||
expect(screen.getByLabelText("Loading")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies custom spinner labels when provided", () => {
|
||||
render(<Loader label="Loading workspace resources" />);
|
||||
|
||||
expect(
|
||||
screen.getByLabelText("Loading workspace resources"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -14,21 +14,25 @@ interface LoaderProps extends HTMLAttributes<HTMLDivElement> {
|
||||
export const Loader: FC<LoaderProps> = ({
|
||||
fullscreen,
|
||||
size = "lg",
|
||||
label = "Loading...",
|
||||
label,
|
||||
className,
|
||||
...attrs
|
||||
}) => {
|
||||
const resolvedLabel = label ?? "Loading";
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="loader"
|
||||
{...attrs}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="loader"
|
||||
className={cn(
|
||||
"flex items-center justify-center",
|
||||
fullscreen ? "absolute inset-0 bg-surface-primary" : "w-full p-8",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Spinner aria-label={label} size={size} loading={true} />
|
||||
<Spinner aria-label={resolvedLabel} size={size} loading={true} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { TabLink, Tabs, TabsList } from "./Tabs";
|
||||
|
||||
const renderTabs = (active = "overview") => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Tabs active={active}>
|
||||
<TabsList>
|
||||
<TabLink to="/overview" value="overview">
|
||||
Overview
|
||||
</TabLink>
|
||||
<TabLink to="/settings" value="settings">
|
||||
Settings
|
||||
</TabLink>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
};
|
||||
|
||||
describe("Tabs", () => {
|
||||
it("does not expose tablist semantics for link navigation", () => {
|
||||
renderTabs();
|
||||
|
||||
expect(screen.queryByRole("tablist")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks only the active tab link as the current page", () => {
|
||||
renderTabs("overview");
|
||||
|
||||
expect(screen.getByRole("link", { name: "Overview" })).toHaveAttribute(
|
||||
"aria-current",
|
||||
"page",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Settings" })).not.toHaveAttribute(
|
||||
"aria-current",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -96,11 +96,7 @@ export const TabsList: FC<TabsListProps> = ({ className, ...props }) => {
|
||||
|
||||
return (
|
||||
<div ref={listRef} className="relative">
|
||||
<div
|
||||
role="tablist"
|
||||
className={cn("flex items-baseline gap-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
<div className={cn("flex items-baseline gap-6", className)} {...props} />
|
||||
<div
|
||||
ref={indicatorRef}
|
||||
className="absolute bottom-0 h-px bg-surface-invert-primary opacity-0 transition-all duration-300 ease-in-out"
|
||||
@@ -128,6 +124,7 @@ export const TabLink: FC<TabLinkProps> = ({
|
||||
return (
|
||||
<Link
|
||||
data-active={isActive}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
{...linkProps}
|
||||
className={cn(
|
||||
"text-sm text-content-secondary no-underline font-medium py-3 px-1 hover:text-content-primary rounded-md",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { ModelSelector, type ModelSelectorOption } from "./model-selector";
|
||||
|
||||
const mockModelOptions: readonly ModelSelectorOption[] = [
|
||||
{
|
||||
id: "gpt-4o-mini",
|
||||
provider: "openai",
|
||||
model: "gpt-4o-mini",
|
||||
displayName: "GPT-4o mini",
|
||||
},
|
||||
];
|
||||
|
||||
test("does not suppress focus ring styles on the model selector trigger", () => {
|
||||
render(
|
||||
<ModelSelector
|
||||
options={mockModelOptions}
|
||||
value="gpt-4o-mini"
|
||||
onValueChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = screen.getByRole("combobox");
|
||||
|
||||
expect(trigger.className).not.toContain("focus:ring-0");
|
||||
expect(trigger.className).not.toContain("focus-visible:ring-0");
|
||||
});
|
||||
@@ -93,7 +93,7 @@ export const ModelSelector: FC<ModelSelectorProps> = ({
|
||||
<Select value={value} onValueChange={onValueChange} disabled={isDisabled}>
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
"h-8 w-auto gap-1.5 border-none bg-transparent px-1 text-xs shadow-none transition-colors hover:bg-transparent hover:text-content-primary [&>svg]:transition-colors [&>svg]:hover:text-content-primary focus:ring-0 focus-visible:ring-0",
|
||||
"h-8 w-auto gap-1.5 border-none bg-transparent px-1 text-xs shadow-none transition-colors hover:bg-transparent hover:text-content-primary [&>svg]:transition-colors [&>svg]:hover:text-content-primary",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { renderWithAuth } from "testHelpers/renderHelpers";
|
||||
import {
|
||||
renderWithAuth,
|
||||
waitForLoaderToBeRemoved,
|
||||
} from "testHelpers/renderHelpers";
|
||||
import { server } from "testHelpers/server";
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { DashboardLayout } from "./DashboardLayout";
|
||||
|
||||
@@ -19,3 +23,40 @@ test("Show the new Coder version notification", async () => {
|
||||
});
|
||||
await screen.findByTestId("update-check-snackbar");
|
||||
});
|
||||
|
||||
test("renders a skip link before navigation content", async () => {
|
||||
renderWithAuth(<DashboardLayout />, {
|
||||
children: [{ element: <h1>Test page</h1> }],
|
||||
});
|
||||
await waitForLoaderToBeRemoved();
|
||||
|
||||
const skipToContentLink = screen.getByRole("link", {
|
||||
name: "Skip to main content",
|
||||
});
|
||||
const navigation = screen.getAllByRole("navigation")[0];
|
||||
const mainContent = document.getElementById("main-content");
|
||||
|
||||
expect(skipToContentLink).toHaveAttribute("href", "#main-content");
|
||||
expect(mainContent).toHaveAttribute("tabindex", "-1");
|
||||
expect(
|
||||
skipToContentLink.compareDocumentPosition(navigation) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test("moves focus to main content when skip link is clicked", async () => {
|
||||
renderWithAuth(<DashboardLayout />, {
|
||||
children: [{ element: <h1>Test page</h1> }],
|
||||
});
|
||||
await waitForLoaderToBeRemoved();
|
||||
|
||||
const user = userEvent.setup();
|
||||
const skipToContentLink = screen.getByRole("link", {
|
||||
name: "Skip to main content",
|
||||
});
|
||||
const mainContent = document.getElementById("main-content");
|
||||
|
||||
expect(mainContent).not.toBeNull();
|
||||
await user.click(skipToContentLink);
|
||||
expect(mainContent).toHaveFocus();
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AnnouncementBanners } from "modules/dashboard/AnnouncementBanners/Annou
|
||||
import { LicenseBanner } from "modules/dashboard/LicenseBanner/LicenseBanner";
|
||||
import { type FC, type HTMLAttributes, Suspense } from "react";
|
||||
import { Outlet } from "react-router";
|
||||
import { cn } from "utils/cn";
|
||||
import { docs } from "utils/docs";
|
||||
import { DeploymentBanner } from "./DeploymentBanner/DeploymentBanner";
|
||||
import { Navbar } from "./Navbar/Navbar";
|
||||
@@ -24,9 +25,28 @@ export const DashboardLayout: FC = () => {
|
||||
<AnnouncementBanners />
|
||||
|
||||
<div className="flex flex-col min-h-screen justify-between">
|
||||
{/* biome-ignore lint/a11y/useValidAnchor: Skip links use fragment anchors by design. */}
|
||||
<a
|
||||
href="#main-content"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
const main = document.getElementById("main-content");
|
||||
main?.focus();
|
||||
}}
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:p-4 focus:bg-surface-primary focus:text-content-primary"
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
<Navbar />
|
||||
|
||||
<div className="relative flex flex-col flex-1 min-h-0 overflow-y-auto">
|
||||
<div
|
||||
id="main-content"
|
||||
tabIndex={-1}
|
||||
className={cn(
|
||||
"relative flex flex-col flex-1 min-h-0 overflow-y-auto",
|
||||
"focus:outline-none",
|
||||
)}
|
||||
>
|
||||
<Suspense fallback={<Loader />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
MockListeningPortsResponse,
|
||||
MockSharedPortsResponse,
|
||||
MockTemplate,
|
||||
MockWorkspace,
|
||||
MockWorkspaceAgent,
|
||||
} from "testHelpers/entities";
|
||||
import { render } from "testHelpers/renderHelpers";
|
||||
import { screen } from "@testing-library/react";
|
||||
import { PortForwardPopoverView } from "./PortForwardButton";
|
||||
|
||||
describe("PortForwardPopoverView", () => {
|
||||
it("adds an accessible name to each shared-port delete button", () => {
|
||||
render(
|
||||
<PortForwardPopoverView
|
||||
host="coder.test"
|
||||
workspace={MockWorkspace}
|
||||
agent={MockWorkspaceAgent}
|
||||
template={MockTemplate}
|
||||
sharedPorts={MockSharedPortsResponse.shares}
|
||||
listeningPorts={MockListeningPortsResponse.ports}
|
||||
portSharingControlsEnabled
|
||||
refetchSharedPorts={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getAllByRole("button", { name: "Delete shared port" }),
|
||||
).toHaveLength(MockSharedPortsResponse.shares.length);
|
||||
});
|
||||
});
|
||||
@@ -527,6 +527,7 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
|
||||
<Button
|
||||
size="icon"
|
||||
variant="subtle"
|
||||
aria-label="Delete shared port"
|
||||
onClick={async () => {
|
||||
await deleteSharedPortMutation.mutateAsync({
|
||||
agent_name: agent.name,
|
||||
|
||||
@@ -835,7 +835,7 @@ describe("DynamicParameter", () => {
|
||||
},
|
||||
});
|
||||
|
||||
it("renders a password field by default and toggles visibility on mouse events", async () => {
|
||||
it("toggles visibility with persistent pressed-state semantics", async () => {
|
||||
render(
|
||||
<DynamicParameter
|
||||
parameter={mockMaskedInputParameter}
|
||||
@@ -847,12 +847,49 @@ describe("DynamicParameter", () => {
|
||||
const input = screen.getByLabelText("Masked Input Parameter");
|
||||
expect(input).toHaveAttribute("type", "password");
|
||||
|
||||
const toggleButton = screen.getByRole("button");
|
||||
fireEvent.mouseDown(toggleButton);
|
||||
const showButton = screen.getByRole("button", { name: "Show value" });
|
||||
expect(showButton).toHaveAttribute("aria-pressed", "false");
|
||||
|
||||
await userEvent.click(showButton);
|
||||
expect(input).toHaveAttribute("type", "text");
|
||||
|
||||
fireEvent.mouseUp(toggleButton);
|
||||
const hideButton = screen.getByRole("button", { name: "Hide value" });
|
||||
expect(hideButton).toHaveAttribute("aria-pressed", "true");
|
||||
|
||||
await userEvent.click(hideButton);
|
||||
expect(input).toHaveAttribute("type", "password");
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Show value" }),
|
||||
).toHaveAttribute("aria-pressed", "false");
|
||||
});
|
||||
|
||||
it("supports keyboard activation for toggling masked values", async () => {
|
||||
render(
|
||||
<DynamicParameter
|
||||
parameter={mockMaskedInputParameter}
|
||||
value="secret123"
|
||||
onChange={mockOnChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("Masked Input Parameter");
|
||||
const showButton = screen.getByRole("button", { name: "Show value" });
|
||||
showButton.focus();
|
||||
|
||||
await userEvent.keyboard("[Enter]");
|
||||
expect(input).toHaveAttribute("type", "text");
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Hide value" }),
|
||||
).toHaveAttribute("aria-pressed", "true");
|
||||
|
||||
const hideButton = screen.getByRole("button", { name: "Hide value" });
|
||||
hideButton.focus();
|
||||
await userEvent.keyboard("[Space]");
|
||||
|
||||
expect(input).toHaveAttribute("type", "password");
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Show value" }),
|
||||
).toHaveAttribute("aria-pressed", "false");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -540,9 +540,9 @@ const MaskableInput: FC<MaskableInputProps> = ({
|
||||
type="button"
|
||||
variant="subtle"
|
||||
size="icon"
|
||||
onMouseDown={() => setShowMaskedInput(true)}
|
||||
onMouseOut={() => setShowMaskedInput(false)}
|
||||
onMouseUp={() => setShowMaskedInput(false)}
|
||||
aria-label={showMaskedInput ? "Hide value" : "Show value"}
|
||||
aria-pressed={showMaskedInput}
|
||||
onClick={() => setShowMaskedInput((value) => !value)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{showMaskedInput ? (
|
||||
@@ -594,9 +594,9 @@ const MaskableTextArea: FC<MaskableInputProps> = ({
|
||||
type="button"
|
||||
variant="subtle"
|
||||
size="icon"
|
||||
onMouseDown={() => setShowMaskedInput(true)}
|
||||
onMouseOut={() => setShowMaskedInput(false)}
|
||||
onMouseUp={() => setShowMaskedInput(false)}
|
||||
aria-label={showMaskedInput ? "Hide value" : "Show value"}
|
||||
aria-pressed={showMaskedInput}
|
||||
onClick={() => setShowMaskedInput((value) => !value)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{showMaskedInput ? (
|
||||
|
||||
@@ -80,7 +80,8 @@ export const AuditLogRow: FC<AuditLogRowProps> = ({
|
||||
role="button"
|
||||
onClick={toggle}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -8,7 +8,13 @@ import {
|
||||
waitForLoaderToBeRemoved,
|
||||
} from "testHelpers/renderHelpers";
|
||||
import { server } from "testHelpers/server";
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import {
|
||||
createEvent,
|
||||
fireEvent,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { API } from "api/api";
|
||||
import type { AuditLogsRequest } from "api/typesGenerated";
|
||||
@@ -80,6 +86,58 @@ describe("AuditPage", () => {
|
||||
screen.getByTestId(`audit-log-row-${MockAuditLog2.id}`);
|
||||
});
|
||||
|
||||
it("toggles an expandable audit row with Enter", async () => {
|
||||
vi.spyOn(API, "getAuditLogs").mockResolvedValue({
|
||||
audit_logs: [MockAuditLog],
|
||||
count: 1,
|
||||
});
|
||||
|
||||
await renderPage();
|
||||
|
||||
const row = screen.getByTestId(`audit-log-row-${MockAuditLog.id}`);
|
||||
const expandableRowButton = within(row).getByRole("button");
|
||||
|
||||
expect(screen.queryByText(/ttl:/i)).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(expandableRowButton, { key: "Enter" });
|
||||
|
||||
expect(screen.getAllByText(/ttl:/i)).toHaveLength(2);
|
||||
|
||||
fireEvent.keyDown(expandableRowButton, { key: "Enter" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/ttl:/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("toggles an expandable audit row with Space and prevents default", async () => {
|
||||
vi.spyOn(API, "getAuditLogs").mockResolvedValue({
|
||||
audit_logs: [MockAuditLog],
|
||||
count: 1,
|
||||
});
|
||||
|
||||
await renderPage();
|
||||
|
||||
const row = screen.getByTestId(`audit-log-row-${MockAuditLog.id}`);
|
||||
const expandableRowButton = within(row).getByRole("button");
|
||||
const spaceEvent = createEvent.keyDown(expandableRowButton, {
|
||||
key: " ",
|
||||
code: "Space",
|
||||
});
|
||||
const preventDefaultSpy = vi.spyOn(spaceEvent, "preventDefault");
|
||||
|
||||
fireEvent(expandableRowButton, spaceEvent);
|
||||
|
||||
expect(preventDefaultSpy).toHaveBeenCalled();
|
||||
expect(screen.getAllByText(/ttl:/i)).toHaveLength(2);
|
||||
|
||||
fireEvent.keyDown(expandableRowButton, { key: " " });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/ttl:/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Filtering", () => {
|
||||
it("filters by URL", async () => {
|
||||
const getAuditLogsSpy = vi
|
||||
|
||||
@@ -48,6 +48,48 @@ describe("LoginPage", () => {
|
||||
expect(errorMessage).toBeDefined();
|
||||
});
|
||||
|
||||
it("associates email validation errors with the email input", async () => {
|
||||
// When
|
||||
render(<LoginPage />);
|
||||
await waitForLoaderToBeRemoved();
|
||||
|
||||
const emailInput = screen.getByLabelText(new RegExp(Language.emailLabel));
|
||||
const passwordInput = screen.getByLabelText(
|
||||
new RegExp(Language.passwordLabel),
|
||||
);
|
||||
expect(emailInput).not.toHaveAttribute("aria-invalid", "true");
|
||||
expect(emailInput).not.toHaveAttribute(
|
||||
"aria-describedby",
|
||||
"signin-email-error",
|
||||
);
|
||||
expect(passwordInput).not.toHaveAttribute("aria-invalid", "true");
|
||||
expect(passwordInput).not.toHaveAttribute(
|
||||
"aria-describedby",
|
||||
"signin-password-error",
|
||||
);
|
||||
|
||||
const signInButton = await screen.findByText(Language.passwordSignIn);
|
||||
fireEvent.click(signInButton);
|
||||
|
||||
// Then
|
||||
const emailError = await screen.findByText(Language.emailRequired);
|
||||
expect(emailInput).toHaveAttribute("aria-invalid", "true");
|
||||
expect(emailInput).toHaveAttribute(
|
||||
"aria-describedby",
|
||||
"signin-email-error",
|
||||
);
|
||||
|
||||
const emailErrorElement = document.getElementById("signin-email-error");
|
||||
expect(emailErrorElement).toBe(emailError);
|
||||
expect(emailErrorElement).toHaveTextContent(Language.emailRequired);
|
||||
|
||||
expect(passwordInput).not.toHaveAttribute("aria-invalid", "true");
|
||||
expect(passwordInput).not.toHaveAttribute(
|
||||
"aria-describedby",
|
||||
"signin-password-error",
|
||||
);
|
||||
expect(document.getElementById("signin-password-error")).toBeNull();
|
||||
});
|
||||
it("redirects to the setup page if there is no first user", async () => {
|
||||
// Given
|
||||
server.use(
|
||||
|
||||
@@ -41,6 +41,8 @@ export const PasswordSignInForm: FC<PasswordSignInFormProps> = ({
|
||||
const getFieldHelpers = getFormHelpers(form);
|
||||
const emailField = getFieldHelpers("email");
|
||||
const passwordField = getFieldHelpers("password");
|
||||
const emailErrorId = "signin-email-error";
|
||||
const passwordErrorId = "signin-password-error";
|
||||
|
||||
return (
|
||||
<form onSubmit={form.handleSubmit} className="flex flex-col gap-5">
|
||||
@@ -59,9 +61,13 @@ export const PasswordSignInForm: FC<PasswordSignInFormProps> = ({
|
||||
autoComplete="email"
|
||||
type="email"
|
||||
aria-invalid={Boolean(emailField.error)}
|
||||
aria-describedby={emailField.error ? emailErrorId : undefined}
|
||||
/>
|
||||
{emailField.error && (
|
||||
<span className="text-xs text-content-destructive text-left">
|
||||
<span
|
||||
id={emailErrorId}
|
||||
className="text-xs text-content-destructive text-left"
|
||||
>
|
||||
{emailField.helperText}
|
||||
</span>
|
||||
)}
|
||||
@@ -80,10 +86,14 @@ export const PasswordSignInForm: FC<PasswordSignInFormProps> = ({
|
||||
onBlur={passwordField.onBlur}
|
||||
autoComplete="current-password"
|
||||
type="password"
|
||||
aria-invalid={passwordField.error}
|
||||
aria-invalid={Boolean(passwordField.error)}
|
||||
aria-describedby={passwordField.error ? passwordErrorId : undefined}
|
||||
/>
|
||||
{passwordField.error && (
|
||||
<span className="text-xs text-content-destructive text-left">
|
||||
<span
|
||||
id={passwordErrorId}
|
||||
className="text-xs text-content-destructive text-left"
|
||||
>
|
||||
{passwordField.helperText}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -414,7 +414,7 @@ test("display pending badge and update it to running when status changes", async
|
||||
|
||||
renderEditorPage(createTestQueryClient());
|
||||
|
||||
const status = await screen.findByRole("status");
|
||||
const status = await screen.findByRole("status", { name: /pending/i });
|
||||
expect(status).toHaveTextContent("Pending");
|
||||
|
||||
// Manually update the endpoint, as to not rely on the editor page
|
||||
|
||||
@@ -23,9 +23,7 @@ describe("AccountForm", () => {
|
||||
email={MockUserMember.email}
|
||||
initialValues={mockInitialValues}
|
||||
isLoading={false}
|
||||
onSubmit={() => {
|
||||
return;
|
||||
}}
|
||||
onSubmit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -39,6 +37,28 @@ describe("AccountForm", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("sets name autocomplete to name", async () => {
|
||||
// Given
|
||||
const mockInitialValues: UpdateUserProfileRequest = {
|
||||
username: MockUserMember.username,
|
||||
name: MockUserMember.name ?? MockUserMember.username,
|
||||
};
|
||||
|
||||
// When
|
||||
render(
|
||||
<AccountForm
|
||||
editable
|
||||
email={MockUserMember.email}
|
||||
initialValues={mockInitialValues}
|
||||
isLoading={false}
|
||||
onSubmit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
const nameInput = await screen.findByLabelText("Name");
|
||||
expect(nameInput).toHaveAttribute("autocomplete", "name");
|
||||
});
|
||||
describe("when editable is set to false", () => {
|
||||
it("does not allow updating username", async () => {
|
||||
// Given
|
||||
@@ -54,9 +74,7 @@ describe("AccountForm", () => {
|
||||
email={MockUserMember.email}
|
||||
initialValues={mockInitialValues}
|
||||
isLoading={false}
|
||||
onSubmit={() => {
|
||||
return;
|
||||
}}
|
||||
onSubmit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ export const AccountForm: FC<AccountFormProps> = ({
|
||||
/>
|
||||
<TextField
|
||||
{...getFieldHelpers("name")}
|
||||
autoComplete="name"
|
||||
fullWidth
|
||||
onBlur={(e) => {
|
||||
e.target.value = e.target.value.trim();
|
||||
|
||||
Reference in New Issue
Block a user