feat: cleanup <Tabs /> component (#23839)

This refactors `<Tabs />` into two clearer patterns: link tabs for route
navigation and Radix tabs for stateful tab panels. That gives us proper
accessibility semantics where we need them without overloading simple
navigation tabs.

As part of that split, this updates several consumers, adds coverage for
both variants, and cleans up some nearby styling.

- introduce Radix-backed tabs primitives for tabbed content
- move router-based tabs to `LinkTabs`
- update notifications, IdP sync, and workspace build pages to use
semantic tabs
- preserve route navigation tabs for groups and templates
- add stories/tests for both tab implementations
- simplify related layout and styling in touched components
This commit is contained in:
Jake Howell
2026-04-02 03:45:20 +11:00
committed by GitHub
parent 4a363b0d85
commit e81275a91c
15 changed files with 476 additions and 262 deletions
+1
View File
@@ -77,6 +77,7 @@
"@radix-ui/react-slider": "1.3.6",
"@radix-ui/react-slot": "1.2.4",
"@radix-ui/react-switch": "1.2.6",
"@radix-ui/react-tabs": "1.1.13",
"@radix-ui/react-tooltip": "1.2.8",
"@tanstack/react-query-devtools": "5.77.0",
"@xterm/addon-canvas": "0.7.0",
+32
View File
@@ -130,6 +130,9 @@ importers:
'@radix-ui/react-switch':
specifier: 1.2.6
version: 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.2(react@19.2.2))(react@19.2.2)
'@radix-ui/react-tabs':
specifier: 1.1.13
version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.2(react@19.2.2))(react@19.2.2)
'@radix-ui/react-tooltip':
specifier: 1.2.8
version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.2(react@19.2.2))(react@19.2.2)
@@ -2134,6 +2137,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-tabs@1.1.13':
resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==, tarball: https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-tooltip@1.2.8':
resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==, tarball: https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz}
peerDependencies:
@@ -9097,6 +9113,22 @@ snapshots:
'@types/react': 19.2.7
'@types/react-dom': 19.2.3(@types/react@19.2.7)
'@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.2(react@19.2.2))(react@19.2.2)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.2)
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.2.2)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.2.2)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.2(react@19.2.2))(react@19.2.2)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.2(react@19.2.2))(react@19.2.2)
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.2(react@19.2.2))(react@19.2.2)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.2)
react: 19.2.2
react-dom: 19.2.2(react@19.2.2)
optionalDependencies:
'@types/react': 19.2.7
'@types/react-dom': 19.2.3(@types/react@19.2.7)
'@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.2(react@19.2.2))(react@19.2.2)':
dependencies:
'@radix-ui/primitive': 1.1.3
@@ -1,5 +1,5 @@
import { type CSSObject, useTheme } from "@emotion/react";
import type { FC, PropsWithChildren, ReactNode } from "react";
import { cn } from "#/utils/cn";
interface FullWidthPageHeaderProps {
children?: ReactNode;
@@ -10,36 +10,15 @@ export const FullWidthPageHeader: FC<FullWidthPageHeaderProps> = ({
children,
sticky = true,
}) => {
const theme = useTheme();
return (
<header
data-testid="header"
css={[
{
...(theme.typography.body2 as CSSObject),
padding: 24,
background: theme.palette.background.default,
borderBottom: `1px solid ${theme.palette.divider}`,
display: "flex",
alignItems: "center",
gap: 48,
zIndex: 10,
flexWrap: "wrap",
[theme.breakpoints.down("lg")]: {
position: "unset",
alignItems: "flex-start",
},
[theme.breakpoints.down("md")]: {
flexDirection: "column",
},
},
sticky && {
position: "sticky",
top: 0,
},
]}
className={cn(
"bg-surface-primary border-0 border-b border-solid border-border",
"text-sm p-6 flex items-center gap-12 flex-wrap z-10",
"lg:items-center flex-col lg:flex-row",
sticky && "sticky top-0",
)}
>
{children}
</header>
@@ -47,19 +26,7 @@ export const FullWidthPageHeader: FC<FullWidthPageHeaderProps> = ({
};
const _PageHeaderActions: FC<PropsWithChildren> = ({ children }) => {
const theme = useTheme();
return (
<div
css={{
marginLeft: "auto",
[theme.breakpoints.down("md")]: {
marginLeft: "unset",
},
}}
>
{children}
</div>
);
return <div className="ml-auto md:ml-0">{children}</div>;
};
export const PageHeaderTitle: FC<PropsWithChildren> = ({ children }) => {
@@ -67,16 +34,7 @@ export const PageHeaderTitle: FC<PropsWithChildren> = ({ children }) => {
};
export const PageHeaderSubtitle: FC<PropsWithChildren> = ({ children }) => {
const theme = useTheme();
return (
<span
css={{
fontSize: 14,
color: theme.palette.text.secondary,
display: "block",
}}
>
{children}
</span>
<span className="text-sm text-content-secondary block">{children}</span>
);
};
+50 -7
View File
@@ -1,19 +1,27 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { TabLink, Tabs, TabsList } from "./Tabs";
import {
LinkTabs,
LinkTabsList,
TabLink,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "./Tabs";
const meta: Meta<typeof Tabs> = {
const meta: Meta<typeof LinkTabs> = {
title: "components/Tabs",
component: Tabs,
component: LinkTabs,
};
export default meta;
type Story = StoryObj<typeof Tabs>;
type Story = StoryObj<typeof LinkTabs>;
export const Default: Story = {
export const LinkNavigation: Story = {
args: {
active: "tab-1",
children: (
<TabsList>
<LinkTabsList>
<TabLink value="tab-1" to="">
Tab 1
</TabLink>
@@ -23,7 +31,42 @@ export const Default: Story = {
<TabLink value="tab-3" to="tab-4">
Tab 3
</TabLink>
</TabsList>
</LinkTabsList>
),
},
render: (args) => <LinkTabs {...args} />,
};
export const RadixInsideBox: StoryObj = {
render: () => (
<Tabs defaultValue="a">
<TabsList variant="insideBox">
<TabsTrigger value="a">Alpha</TabsTrigger>
<TabsTrigger value="b">Beta</TabsTrigger>
</TabsList>
<TabsContent value="a" className="p-4">
Panel A
</TabsContent>
<TabsContent value="b" className="p-4">
Panel B
</TabsContent>
</Tabs>
),
};
export const RadixOutsideBox: StoryObj = {
render: () => (
<Tabs defaultValue="a">
<TabsList variant="outsideBox">
<TabsTrigger value="a">Alpha</TabsTrigger>
<TabsTrigger value="b">Beta</TabsTrigger>
</TabsList>
<TabsContent value="a" className="p-4">
Panel A
</TabsContent>
<TabsContent value="b" className="p-4">
Panel B
</TabsContent>
</Tabs>
),
};
+40 -9
View File
@@ -1,33 +1,41 @@
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { TabLink, Tabs, TabsList } from "./Tabs";
import {
LinkTabs,
LinkTabsList,
TabLink,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "./Tabs";
const renderTabs = (active = "overview") => {
const renderLinkTabs = (active = "overview") => {
render(
<MemoryRouter>
<Tabs active={active}>
<TabsList>
<LinkTabs active={active}>
<LinkTabsList>
<TabLink to="/overview" value="overview">
Overview
</TabLink>
<TabLink to="/settings" value="settings">
Settings
</TabLink>
</TabsList>
</Tabs>
</LinkTabsList>
</LinkTabs>
</MemoryRouter>,
);
};
describe("Tabs", () => {
describe("LinkTabs", () => {
it("does not expose tablist semantics for link navigation", () => {
renderTabs();
renderLinkTabs();
expect(screen.queryByRole("tablist")).not.toBeInTheDocument();
});
it("marks only the active tab link as the current page", () => {
renderTabs("overview");
renderLinkTabs("overview");
expect(screen.getByRole("link", { name: "Overview" })).toHaveAttribute(
"aria-current",
@@ -38,3 +46,26 @@ describe("Tabs", () => {
);
});
});
describe("Tabs (Radix)", () => {
it("exposes tablist semantics for keyboard navigation", () => {
render(
<Tabs defaultValue="a">
<TabsList variant="insideBox" aria-label="Example">
<TabsTrigger value="a">Alpha</TabsTrigger>
<TabsTrigger value="b">Beta</TabsTrigger>
</TabsList>
<TabsContent value="a">A</TabsContent>
<TabsContent value="b">B</TabsContent>
</Tabs>,
);
expect(
screen.getByRole("tablist", { name: "Example" }),
).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Alpha" })).toHaveAttribute(
"data-state",
"active",
);
});
});
+99 -12
View File
@@ -1,4 +1,7 @@
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cva, type VariantProps } from "class-variance-authority";
import {
type ComponentProps,
createContext,
type FC,
type HTMLAttributes,
@@ -11,20 +14,101 @@ import {
import { Link, type LinkProps } from "react-router";
import { cn } from "#/utils/cn";
// Keeping this for now because of a workaround in WorkspaceBUildPageView
// --- Radix tabs (stateful panels) ---
type TabsProps = ComponentProps<typeof TabsPrimitive.Root>;
export const Tabs: FC<TabsProps> = ({ ...props }) => {
return <TabsPrimitive.Root {...props} />;
};
const tabsListVariants = cva("flex flex-wrap items-center", {
variants: {
variant: {
insideBox: cn(
"border-solid border-x-0 border-y",
"[&_button[data-state=active]]:bg-surface-secondary",
"[&_button]:border-x [&_button]:border-y-0 [&_button]:border-solid",
"[&_button]:border-x-transparent [&_button[data-state=active]]:border-x-border",
"[&_button]:px-4",
"[&_button]:text-content-secondary",
"[&_button[data-state=active]]:text-content-primary",
),
outsideBox: cn(
"border-solid border-0 border-b gap-6",
"[&_button]:text-content-secondary [&_button[data-state=active]]:text-content-primary",
"[&_button]:border-0 [&_button]:border-y [&_button]:border-solid",
"[&_button]:border-transparent [&_button[data-state=active]]:border-b-white",
"[&_button]:hover:text-content-primary",
"[&_button]:px-1",
),
},
},
defaultVariants: {
variant: "outsideBox",
},
});
type TabsListProps = ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>;
export const TabsList: FC<TabsListProps> = ({
className,
variant,
...props
}) => {
return (
<TabsPrimitive.List
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
);
};
type TabsTriggerProps = ComponentProps<typeof TabsPrimitive.Trigger>;
export const TabsTrigger: FC<TabsTriggerProps> = ({ ...props }) => {
return (
<TabsPrimitive.Trigger
className={cn(
"border-none py-3 bg-transparent",
"text-inherit font-normal text-sm",
"inline-flex gap-2 items-center",
"cursor-pointer",
"transition-colors duration-150 ease-linear",
)}
{...props}
/>
);
};
type TabsContentProps = ComponentProps<typeof TabsPrimitive.Content>;
export const TabsContent: FC<TabsContentProps> = ({ ...props }) => {
return <TabsPrimitive.Content {...props} />;
};
// --- Router link tabs (URL-driven navigation) ---
// Keeping this for now because of a workaround in WorkspaceBuildPageView.
export const TAB_PADDING_X = 16;
type TabsContextValue = {
type LinkTabsContextValue = {
active: string;
};
const TabsContext = createContext<TabsContextValue | undefined>(undefined);
const LinkTabsContext = createContext<LinkTabsContextValue | undefined>(
undefined,
);
type TabsProps = HTMLAttributes<HTMLDivElement> & TabsContextValue;
type LinkTabsProps = HTMLAttributes<HTMLDivElement> & LinkTabsContextValue;
export const Tabs: FC<TabsProps> = ({ className, active, ...htmlProps }) => {
export const LinkTabs: FC<LinkTabsProps> = ({
className,
active,
...htmlProps
}) => {
return (
<TabsContext.Provider value={{ active }}>
<LinkTabsContext.Provider value={{ active }}>
<div
// Because the Tailwind preflight is not used, its necessary to set border style to solid and
// reset all border widths to 0 https://tailwindcss.com/docs/border-width#using-without-preflight
@@ -34,14 +118,17 @@ export const Tabs: FC<TabsProps> = ({ className, active, ...htmlProps }) => {
)}
{...htmlProps}
/>
</TabsContext.Provider>
</LinkTabsContext.Provider>
);
};
type TabsListProps = HTMLAttributes<HTMLDivElement>;
type LinkTabsListProps = HTMLAttributes<HTMLDivElement>;
export const TabsList: FC<TabsListProps> = ({ className, ...props }) => {
const tabsContext = useContext(TabsContext);
export const LinkTabsList: FC<LinkTabsListProps> = ({
className,
...props
}) => {
const tabsContext = useContext(LinkTabsContext);
const listRef = useRef<HTMLDivElement>(null);
const indicatorRef = useRef<HTMLDivElement>(null);
const hasInitialized = useRef(false);
@@ -114,9 +201,9 @@ export const TabLink: FC<TabLinkProps> = ({
className,
...linkProps
}) => {
const tabsContext = useContext(TabsContext);
const tabsContext = useContext(LinkTabsContext);
if (!tabsContext) {
throw new Error("Tab only can be used inside of Tabs");
throw new Error("TabLink must be used inside LinkTabs");
}
const isActive = tabsContext.active === value;
@@ -88,12 +88,10 @@ export const WorkspaceBuildLogs: FC<WorkspaceBuildLogsProps> = ({
<Fragment key={stage}>
<div
className={cn(
"logs-header text-sm font-semibold font-sans leading-none",
"border-solid border-0 border-b bg-surface-primary",
"flex items-center",
"[&:last-child]:border-b-0 [&:last-child]:rounded-b-lg",
"[&:first-of-type]:rounded-t-lg",
sticky && "sticky top-0",
"logs-header",
"flex items-center border-solid border-0 border-b border-border font-sans",
"bg-surface-primary text-xs font-semibold leading-none",
"first-of-type:pt-4",
)}
style={{
padding: `12px var(--log-line-side-padding, ${DEFAULT_LOG_LINE_SIDE_PADDING}px)`,
@@ -101,12 +99,18 @@ export const WorkspaceBuildLogs: FC<WorkspaceBuildLogsProps> = ({
>
<div>{stage}</div>
{shouldDisplayDuration && (
<div className="ml-auto text-content-secondary text-xs">
<div className="ml-auto text-xs text-content-secondary">
{duration} seconds
</div>
)}
</div>
{!isEmpty && <Logs hideTimestamps={hideTimestamps} lines={lines} />}
{!isEmpty && (
<Logs
className="border-b-border"
hideTimestamps={hideTimestamps}
lines={lines}
/>
)}
</Fragment>
);
})}
@@ -1,4 +1,3 @@
import type { Interpolation, Theme } from "@emotion/react";
import type { FC } from "react";
import { useQueries } from "react-query";
import {
@@ -14,7 +13,12 @@ import {
SettingsHeaderDocsLink,
SettingsHeaderTitle,
} from "#/components/SettingsHeader/SettingsHeader";
import { TabLink, Tabs, TabsList } from "#/components/Tabs/Tabs";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "#/components/Tabs/Tabs";
import { useSearchParamsKey } from "#/hooks/useSearchParamsKey";
import { useDeploymentConfig } from "#/modules/management/DeploymentConfigProvider";
import { castNotificationMethod } from "#/modules/notifications/utils";
@@ -25,6 +29,14 @@ import OptionsTable from "../OptionsTable";
import { NotificationEvents } from "./NotificationEvents";
import { Troubleshooting } from "./Troubleshooting";
const NOTIFICATION_TABS = ["events", "settings", "troubleshooting"] as const;
function isNotificationTab(
value: string,
): value is (typeof NOTIFICATION_TABS)[number] {
return (NOTIFICATION_TABS as readonly string[]).includes(value);
}
const NotificationsPage: FC = () => {
const { deploymentConfig } = useDeploymentConfig();
const [systemTemplatesByGroup, customTemplatesByGroup, dispatchMethods] =
@@ -46,6 +58,10 @@ const NotificationsPage: FC = () => {
defaultValue: "events",
});
const activeTab = isNotificationTab(tabState.value)
? tabState.value
: NOTIFICATION_TABS[0];
const ready = !!(
systemTemplatesByGroup.data &&
customTemplatesByGroup.data &&
@@ -73,23 +89,16 @@ const NotificationsPage: FC = () => {
</SettingsHeaderDescription>
</SettingsHeader>
<Tabs active={tabState.value}>
<TabsList>
<TabLink to="?tab=events" value="events">
Events
</TabLink>
<TabLink to="?tab=settings" value="settings">
Settings
</TabLink>
<TabLink to="?tab=troubleshooting" value="troubleshooting">
Troubleshooting
</TabLink>
</TabsList>
</Tabs>
<div css={styles.content}>
{ready ? (
tabState.value === "events" ? (
{!ready ? (
<Loader />
) : (
<Tabs value={activeTab} onValueChange={tabState.setValue}>
<TabsList>
<TabsTrigger value="events">Events</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
<TabsTrigger value="troubleshooting">Troubleshooting</TabsTrigger>
</TabsList>
<TabsContent value="events" className="py-6">
<NotificationEvents
templatesByGroup={allTemplatesByGroup}
deploymentConfig={deploymentConfig.config}
@@ -100,25 +109,21 @@ const NotificationsPage: FC = () => {
castNotificationMethod,
)}
/>
) : tabState.value === "troubleshooting" ? (
<Troubleshooting />
) : (
</TabsContent>
<TabsContent value="settings" className="py-6">
<OptionsTable
options={deploymentConfig.options.filter((o) =>
deploymentGroupHasParent(o.group, "Notifications"),
)}
/>
)
) : (
<Loader />
)}
</div>
</TabsContent>
<TabsContent value="troubleshooting" className="py-6">
<Troubleshooting />
</TabsContent>
</Tabs>
)}
</>
);
};
export default NotificationsPage;
const styles = {
content: { paddingTop: 24 },
} as Record<string, Interpolation<Theme>>;
+5 -5
View File
@@ -29,7 +29,7 @@ import {
SettingsHeaderDescription,
SettingsHeaderTitle,
} from "#/components/SettingsHeader/SettingsHeader";
import { TabLink, Tabs, TabsList } from "#/components/Tabs/Tabs";
import { LinkTabs, LinkTabsList, TabLink } from "#/components/Tabs/Tabs";
import { usePaginatedQuery } from "#/hooks/usePaginatedQuery";
import { pageTitle } from "#/utils/page";
@@ -135,16 +135,16 @@ const GroupPage: FC = () => {
</div>
<div className="flex flex-col gap-10 w-full">
{canUpdateGroup && (
<Tabs active={activeTab}>
<TabsList className="w-full justify-start">
<LinkTabs active={activeTab}>
<LinkTabsList className="w-full justify-start">
<TabLink to="." value="members">
Group members
</TabLink>
<TabLink to="settings" value="settings">
Group settings
</TabLink>
</TabsList>
</Tabs>
</LinkTabsList>
</LinkTabs>
)}
<Outlet
@@ -1,4 +1,5 @@
import type { FC } from "react";
import { useSearchParams } from "react-router";
import type {
Group,
GroupSyncSettings,
@@ -8,7 +9,12 @@ import type {
} from "#/api/typesGenerated";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { Loader } from "#/components/Loader/Loader";
import { TabLink, Tabs, TabsList } from "#/components/Tabs/Tabs";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "#/components/Tabs/Tabs";
import { IdpGroupSyncForm } from "./IdpGroupSyncForm";
import { IdpRoleSyncForm } from "./IdpRoleSyncForm";
@@ -43,6 +49,7 @@ const IdpSyncPageView: FC<IdpSyncPageViewProps> = ({
onSubmitGroupSyncSettings,
onSubmitRoleSyncSettings,
}) => {
const [_, setSearchParams] = useSearchParams();
const groupMappingCount = groupSyncSettings?.mapping
? Object.entries(groupSyncSettings.mapping).length
: 0;
@@ -60,39 +67,41 @@ const IdpSyncPageView: FC<IdpSyncPageViewProps> = ({
return (
<div className="flex flex-col gap-4">
{Boolean(error) && <ErrorAlert error={error} />}
<Tabs active={tab}>
<Tabs
value={tab}
onValueChange={(value: string) => {
setSearchParams({ tab: value });
}}
>
<TabsList>
<TabLink to="?tab=groups" value="groups">
Group sync settings
</TabLink>
<TabLink to="?tab=roles" value="roles">
Role sync settings
</TabLink>
<TabsTrigger value="groups">Group sync settings</TabsTrigger>
<TabsTrigger value="roles">Role sync settings</TabsTrigger>
</TabsList>
<TabsContent value="groups">
<IdpGroupSyncForm
groupSyncSettings={groupSyncSettings}
claimFieldValues={claimFieldValues}
groupMappingCount={groupMappingCount}
legacyGroupMappingCount={legacyGroupMappingCount}
groups={groups}
groupsMap={groupsMap}
organization={organization}
onSubmit={onSubmitGroupSyncSettings}
onSyncFieldChange={onGroupSyncFieldChange}
/>
</TabsContent>
<TabsContent value="roles">
<IdpRoleSyncForm
roleSyncSettings={roleSyncSettings}
claimFieldValues={claimFieldValues}
roleMappingCount={roleMappingCount}
roles={roles || []}
organization={organization}
onSubmit={onSubmitRoleSyncSettings}
onSyncFieldChange={onRoleSyncFieldChange}
/>
</TabsContent>
</Tabs>
{tab === "groups" ? (
<IdpGroupSyncForm
groupSyncSettings={groupSyncSettings}
claimFieldValues={claimFieldValues}
groupMappingCount={groupMappingCount}
legacyGroupMappingCount={legacyGroupMappingCount}
groups={groups}
groupsMap={groupsMap}
organization={organization}
onSubmit={onSubmitGroupSyncSettings}
onSyncFieldChange={onGroupSyncFieldChange}
/>
) : (
<IdpRoleSyncForm
roleSyncSettings={roleSyncSettings}
claimFieldValues={claimFieldValues}
roleMappingCount={roleMappingCount}
roles={roles || []}
organization={organization}
onSubmit={onSubmitRoleSyncSettings}
onSyncFieldChange={onRoleSyncFieldChange}
/>
)}
</div>
);
};
@@ -13,7 +13,7 @@ import type { AuthorizationRequest } from "#/api/typesGenerated";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { Loader } from "#/components/Loader/Loader";
import { Margins } from "#/components/Margins/Margins";
import { TabLink, Tabs, TabsList } from "#/components/Tabs/Tabs";
import { LinkTabs, LinkTabsList, TabLink } from "#/components/Tabs/Tabs";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility";
import {
@@ -135,9 +135,9 @@ export const TemplateLayout: FC<PropsWithChildren> = ({
}}
/>
<Tabs active={activeTab} className="mb-10 -mt-3">
<LinkTabs active={activeTab} className="mb-10 -mt-3">
<Margins>
<TabsList>
<LinkTabsList>
<TabLink to="docs" value="docs">
Docs
</TabLink>
@@ -166,9 +166,9 @@ export const TemplateLayout: FC<PropsWithChildren> = ({
Prebuilds
</TabLink>
)}
</TabsList>
</LinkTabsList>
</Margins>
</Tabs>
</LinkTabs>
<Margins>
<TemplateLayoutContext.Provider value={data}>
@@ -1,17 +1,16 @@
import type { FC, HTMLAttributes } from "react";
import { cn } from "#/utils/cn";
export const Sidebar: FC<HTMLAttributes<HTMLElement>> = ({
children,
...attrs
}) => {
return (
<nav
css={(theme) => ({
width: 256,
flexShrink: 0,
borderRight: `1px solid ${theme.palette.divider}`,
height: "100%",
overflowY: "auto",
})}
className={cn(
"w-64 flex-shrink-0 border-solid border-0 border-r",
"h-full py-2 overflow-y-auto",
)}
{...attrs}
>
{children}
@@ -1,5 +1,7 @@
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import WS from "jest-websocket-mock";
import * as apiModule from "#/api/api";
import { API } from "#/api/api";
import {
MockWorkspace,
@@ -8,6 +10,11 @@ import {
MockWorkspaceBuild,
} from "#/testHelpers/entities";
import { renderWithAuth } from "#/testHelpers/renderHelpers";
import {
createMockWebSocket,
type MockWebSocketServer,
} from "#/testHelpers/websockets";
import { OneWayWebSocket } from "#/utils/OneWayWebSocket";
import WorkspaceBuildPage from "./WorkspaceBuildPage";
import { LOGS_TAB_KEY } from "./WorkspaceBuildPageView";
@@ -64,18 +71,46 @@ describe("WorkspaceBuildPage", () => {
});
test("shows selected agent logs", async () => {
const server = new WS(
`ws://localhost/api/v2/workspaceagents/${
MockWorkspaceAgent.id
}/logs?follow&after=0`,
);
let mockServer: MockWebSocketServer | undefined;
jest
.spyOn(apiModule, "watchWorkspaceAgentLogs")
.mockImplementation((agentId, params) => {
return new OneWayWebSocket({
apiRoute: `/api/v2/workspaceagents/${agentId}/logs`,
searchParams: new URLSearchParams({
follow: "true",
after: params?.after?.toString() || "0",
}),
websocketInit: (url, protocol) => {
const [socket, server] = createMockWebSocket(url, protocol);
mockServer = server;
return socket;
},
});
});
const user = userEvent.setup();
renderWithAuth(<WorkspaceBuildPage />, {
route: `/@${MockWorkspace.owner_name}/${MockWorkspace.name}/builds/${MockWorkspace.latest_build.build_number}?${LOGS_TAB_KEY}=${MockWorkspaceAgent.id}`,
path: "/:username/:workspace/builds/:buildNumber",
});
await screen.findByText(`Build #${MockWorkspaceBuild.build_number}`);
await server.connected;
server.send(JSON.stringify(MockWorkspaceAgentLogs));
await user.click(
screen.getByRole("tab", {
name: `coder_agent.${MockWorkspaceAgent.name}`,
}),
);
expect(mockServer).toBeDefined();
mockServer?.publishMessage(
new MessageEvent("message", {
data: JSON.stringify(MockWorkspaceAgentLogs),
}),
);
await screen.findByText(MockWorkspaceAgentLogs[0].output);
});
});
@@ -6,7 +6,7 @@ import {
useLayoutEffect,
useRef,
} from "react";
import { Link } from "react-router";
import { Link, useSearchParams } from "react-router";
import type {
ProvisionerJobLog,
WorkspaceAgent,
@@ -14,6 +14,7 @@ import type {
} from "#/api/typesGenerated";
import { Alert } from "#/components/Alert/Alert";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { Button } from "#/components/Button/Button";
import { Loader } from "#/components/Loader/Loader";
import { Margins } from "#/components/Margins/Margins";
import {
@@ -23,8 +24,13 @@ import {
} from "#/components/PageHeader/FullWidthPageHeader";
import { Stack } from "#/components/Stack/Stack";
import { Stats, StatsItem } from "#/components/Stats/Stats";
import { TAB_PADDING_X, TabLink, Tabs, TabsList } from "#/components/Tabs/Tabs";
import { useSearchParamsKey } from "#/hooks/useSearchParamsKey";
import {
TAB_PADDING_X,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "#/components/Tabs/Tabs";
import { BuildAvatar } from "#/modules/builds/BuildAvatar/BuildAvatar";
import { DashboardFullPage } from "#/modules/dashboard/DashboardLayout";
import { AgentLogs } from "#/modules/resources/AgentLogs/AgentLogs";
@@ -71,10 +77,7 @@ export const WorkspaceBuildPageView: FC<WorkspaceBuildPageViewProps> = ({
builds,
activeBuildNumber,
}) => {
const tabState = useSearchParamsKey({
key: LOGS_TAB_KEY,
defaultValue: "build",
});
const [searchParams, setSearchParams] = useSearchParams();
if (buildError) {
return (
@@ -88,8 +91,12 @@ export const WorkspaceBuildPageView: FC<WorkspaceBuildPageViewProps> = ({
return <Loader />;
}
const agents = build.resources.flatMap((r) => r.agents ?? []);
const selectedAgent = agents.find((a) => a.id === tabState.value);
const agents = build.resources.flatMap((resource) => resource.agents ?? []);
const logsParam = searchParams.get(LOGS_TAB_KEY);
const selectedTab =
logsParam && agents.some((agent) => agent.id === logsParam)
? logsParam
: "build";
return (
<DashboardFullPage>
@@ -151,86 +158,91 @@ export const WorkspaceBuildPageView: FC<WorkspaceBuildPageViewProps> = ({
</Sidebar>
<ScrollArea>
<div className="flex items-center justify-between border-0 border-b border-solid border-border">
<Tabs active={tabState.value}>
<TabsList className="gap-0">
<TabLink
to={`?${LOGS_TAB_KEY}=build`}
value="build"
className="px-6 pb-2"
>
Build
</TabLink>
{agents.map((a) => (
<TabLink
className="px-6 pb-2"
to={`?${LOGS_TAB_KEY}=${a.id}`}
value={a.id}
key={a.id}
>
coder_agent.{a.name}
</TabLink>
<div className="flex items-center justify-between border-0 border-b border-solid border-border relative">
<Tabs
value={selectedTab}
onValueChange={(value: string) => {
setSearchParams((previous) => {
const next = new URLSearchParams(previous);
if (value === "build") {
next.delete(LOGS_TAB_KEY);
} else {
next.set(LOGS_TAB_KEY, value);
}
return next;
});
}}
className="w-full -m-px"
>
<TabsList variant="insideBox">
<TabsTrigger value="build">Build</TabsTrigger>
{agents.map((agent) => (
<TabsTrigger value={agent.id} key={agent.id}>
coder_agent.{agent.name}
</TabsTrigger>
))}
</TabsList>
<TabsContent value="build">
<div className="p-2 flex justify-end absolute right-0 top-0">
<Button asChild size="sm" variant="outline">
<a
href={`/api/v2/workspacebuilds/${build.id}/logs?format=text`}
target="_blank"
rel="noopener noreferrer"
>
View raw logs
<ExternalLinkIcon className="size-3" />
</a>
</Button>
</div>
{build.transition === "delete" &&
build.job.status === "failed" && (
<Alert
severity="error"
prominent
className="rounded-none border-0 border-b border-solid border-border"
>
<div>
The workspace may have failed to delete due to a
Terraform state mismatch. A template admin may run{" "}
<code className="font-semibold w-fit inline-block">
{`coder rm ${`${build.workspace_owner_name}/${build.workspace_name}`} --orphan`}
</code>{" "}
to delete the workspace skipping resource destruction.
</div>
</Alert>
)}
{build?.job?.logs_overflowed && (
<Alert
severity="warning"
prominent
className="rounded-none border-0 border-b border-solid border-border"
>
Provisioner logs exceeded the max size of 1MB. Will not
continue to write provisioner logs for workspace build.
</Alert>
)}
<BuildLogsContent logs={logs} build={build} />
</TabsContent>
{agents.map((agent) => (
<TabsContent value={agent.id} key={agent.id}>
<div className="p-2 flex justify-end absolute right-0 top-0">
<Button asChild size="sm" variant="outline">
<a
href={`/api/v2/workspaceagents/${agent.id}/logs?format=text`}
target="_blank"
rel="noopener noreferrer"
>
View raw logs
<ExternalLinkIcon className="size-3" />
</a>
</Button>
</div>
<AgentLogsContent agent={agent} />
</TabsContent>
))}
</Tabs>
{tabState.value === "build" && (
<a
href={`/api/v2/workspacebuilds/${build.id}/logs?format=text`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 px-4 text-xs text-content-secondary hover:text-content-primary"
>
View raw logs
<ExternalLinkIcon className="size-3" />
</a>
)}
{tabState.value !== "build" && selectedAgent && (
<a
href={`/api/v2/workspaceagents/${selectedAgent.id}/logs?format=text`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 px-4 text-xs text-content-secondary hover:text-content-primary"
>
View raw logs
<ExternalLinkIcon className="size-3" />
</a>
)}
</div>
{build.transition === "delete" && build.job.status === "failed" && (
<Alert
severity="error"
prominent
className="rounded-none border-0 border-b border-solid border-border"
>
<div>
The workspace may have failed to delete due to a Terraform state
mismatch. A template admin may run{" "}
<code className="font-semibold w-fit inline-block">
{`coder rm ${`${build.workspace_owner_name}/${build.workspace_name}`} --orphan`}
</code>{" "}
to delete the workspace skipping resource destruction.
</div>
</Alert>
)}
{build?.job?.logs_overflowed && (
<Alert
severity="warning"
prominent
className="rounded-none border-0 border-b border-solid border-border"
>
Provisioner logs exceeded the max size of 1MB. Will not continue
to write provisioner logs for workspace build.
</Alert>
)}
{tabState.value === "build" && (
<BuildLogsContent logs={logs} build={build} />
)}
{tabState.value !== "build" && selectedAgent && (
<AgentLogsContent agent={selectedAgent} />
)}
</ScrollArea>
</div>
</DashboardFullPage>
@@ -304,9 +316,7 @@ const BuildLogsContent: FC<{
return (
<WorkspaceBuildLogs
// logs header class adds extra spacing to the first log header to
// prevent it from being too close to the tabs
className="border-none [&_.logs-header:first-of-type]:pt-4"
className="border-none"
style={{ "--log-line-side-padding": `${TAB_PADDING_X}px` }}
build={build}
logs={sortLogsByCreatedAt(logs)}
@@ -27,7 +27,7 @@ export const WorkspaceBuildLogsSection: FC<WorkspaceBuildLogsSectionProps> = ({
<WorkspaceBuildLogs
sticky
logs={logs}
css={{ border: 0, borderRadius: 0 }}
className="rounded-none border-none"
/>
) : (
<div className="flex items-center justify-center w-full h-full">