refactor: update the navbar to match the new designs (#15964)

Update the navbar to match the designs in [this Figma
file](https://www.figma.com/design/WfqIgsTFXN2BscBSSyXWF8/Coder-kit?node-id=656-2354&t=4a6pX5tQU5Ti2Oyi-0).
Related to https://github.com/coder/coder/issues/15617.

**Desktop preview:**


https://github.com/user-attachments/assets/01ce7cd2-baaa-49c4-9e9a-bf6e675151da

**Mobile preview:**


https://github.com/user-attachments/assets/155e2521-7293-4368-a5f5-425179d76326

For a closer look, you can check Chromatic snapshots or test the changes
locally.

**A few considerations:**
- I made some adjustments to improve the design, such as removing the
chevron from the profile menu and reducing the size of the chevrons in
the dropdowns. I’ve documented these changes in the [Figma
file](https://www.figma.com/design/WfqIgsTFXN2BscBSSyXWF8/Coder-kit?node-id=656-2354&t=4a6pX5tQU5Ti2Oyi-0)
so @chrifro can review them after returning from vacation.
- Some of the design questions involve how the proxy and account
dropdown menus should look on desktop and mobile. For desktop, I decided
to retain the current styles, and for mobile, I tried to infer how they
should look based on the existing design.
- There is some duplicated logic between the regular/desktop navbar
menus and the mobile menus, which could lead to inconsistencies and make
maintenance harder in the future. I plan to address this in a follow-up
PR to keep this review manageable.
- I’ve added tests to minimize inconsistencies and potential bugs while
working on this refactor.
This commit is contained in:
Bruno Quaresma
2024-12-30 14:25:00 -03:00
committed by GitHub
parent 459003fdb1
commit 49fadb801d
20 changed files with 692 additions and 332 deletions
+8
View File
@@ -64,6 +64,14 @@ export const parameters = {
},
type: "tablet",
},
iphone12: {
name: "iPhone 12",
styles: {
height: "844px",
width: "390px",
},
type: "mobile",
},
terminal: {
name: "Terminal",
styles: {
@@ -28,7 +28,7 @@ export const BreadcrumbList = forwardRef<
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center text-sm pl-12 my-4 gap-1.5 break-words font-medium list-none sm:gap-2.5",
"flex flex-wrap items-center text-sm pl-6 my-4 gap-1.5 break-words font-medium list-none sm:gap-2.5",
className,
)}
{...props}
+22 -17
View File
@@ -4,7 +4,7 @@
*/
import { Slot } from "@radix-ui/react-slot";
import { type VariantProps, cva } from "class-variance-authority";
import { type FC, forwardRef } from "react";
import { forwardRef } from "react";
import { cn } from "utils/cn";
export const buttonVariants = cva(
@@ -13,7 +13,8 @@ export const buttonVariants = cva(
text-sm font-semibold font-medium cursor-pointer
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link
disabled:pointer-events-none disabled:text-content-disabled
[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0`,
[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0
px-3 py-2`,
{
variants: {
variant: {
@@ -25,10 +26,15 @@ export const buttonVariants = cva(
"border-none bg-transparent text-content-secondary hover:text-content-primary",
warning:
"border border-border-error text-content-primary bg-surface-error hover:bg-transparent",
ghost:
"text-content-primary bg-transparent border-0 hover:bg-surface-secondary",
},
size: {
default: "h-9 px-3 py-2",
sm: "h-8 px-2 text-xs",
lg: "h-10",
default: "h-9",
sm: "h-8 px-2 py-1.5 text-xs",
icon: "h-10 w-10",
},
},
defaultVariants: {
@@ -44,16 +50,15 @@ export interface ButtonProps
asChild?: boolean;
}
export const Button: FC<ButtonProps> = forwardRef<
HTMLButtonElement,
ButtonProps
>(({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
});
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
@@ -7,10 +7,12 @@
*/
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { Button } from "components/Button/Button";
import { Check, ChevronDownIcon, ChevronRight, Circle } from "lucide-react";
import {
type ComponentPropsWithoutRef,
type ElementRef,
type FC,
type HTMLAttributes,
forwardRef,
} from "react";
@@ -196,7 +198,7 @@ export const DropdownMenuSeparator = forwardRef<
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn(["-mx-1 my-1 h-px bg-border"], className)}
className={cn(["-mx-1 my-3 h-px bg-border"], className)}
{...props}
/>
));
+4 -2
View File
@@ -15,6 +15,8 @@ import {
import { useQuery } from "react-query";
import { type ProxyLatencyReport, useProxyLatency } from "./useProxyLatency";
export type Proxies = readonly Region[] | readonly WorkspaceProxy[];
export type ProxyLatencies = Record<string, ProxyLatencyReport>;
export interface ProxyContextValue {
// proxy is **always** the workspace proxy that should be used.
// The 'proxy.selectedProxy' field is the proxy being used and comes from either:
@@ -43,7 +45,7 @@ export interface ProxyContextValue {
// WorkspaceProxy[] is returned if the user is an admin. WorkspaceProxy extends Region with
// more information about the proxy and the status. More information includes the error message if
// the proxy is unhealthy.
proxies?: readonly Region[] | readonly WorkspaceProxy[];
proxies?: Proxies;
// isFetched is true when the 'proxies' api call is complete.
isFetched: boolean;
isLoading: boolean;
@@ -51,7 +53,7 @@ export interface ProxyContextValue {
// proxyLatencies is a map of proxy id to latency report. If the proxyLatencies[proxy.id] is undefined
// then the latency has not been fetched yet. Calculations happen async for each proxy in the list.
// Refer to the returned report for a given proxy for more information.
proxyLatencies: Record<string, ProxyLatencyReport>;
proxyLatencies: ProxyLatencies;
// refetchProxyLatencies will trigger refreshing of the proxy latencies. By default the latencies
// are loaded once.
refetchProxyLatencies: () => Date;
+13
View File
@@ -70,4 +70,17 @@
* {
@apply border-border;
}
/*
By default, Radix adds a margin to the `body` element when a dropdown is displayed,
causing some shifting when the dropdown has a full-width size, as is the case with the mobile menu.
To prevent this, we need to apply the styles below.
There’s a related issue on GitHub: Radix UI Primitives Issue #3251
https://github.com/radix-ui/primitives/issues/3251
*/
html body[data-scroll-locked] {
--removed-body-scroll-bar-size: 0 !important;
margin-right: 0 !important;
}
}
@@ -1,7 +1,6 @@
import { type Interpolation, type Theme, css, useTheme } from "@emotion/react";
import Button from "@mui/material/Button";
import MenuItem from "@mui/material/MenuItem";
import { DropdownArrow } from "components/DropdownArrow/DropdownArrow";
import { Button } from "components/Button/Button";
import { FeatureStageBadge } from "components/FeatureStageBadge/FeatureStageBadge";
import {
Popover,
@@ -9,6 +8,7 @@ import {
PopoverTrigger,
usePopover,
} from "components/deprecated/Popover/Popover";
import { ChevronDownIcon } from "lucide-react";
import { linkToAuditing } from "modules/navigation";
import type { FC } from "react";
import { NavLink } from "react-router-dom";
@@ -16,7 +16,6 @@ import { NavLink } from "react-router-dom";
interface DeploymentDropdownProps {
canViewDeployment: boolean;
canViewOrganizations: boolean;
canViewAllUsers: boolean;
canViewAuditLog: boolean;
canViewHealth: boolean;
}
@@ -24,7 +23,6 @@ interface DeploymentDropdownProps {
export const DeploymentDropdown: FC<DeploymentDropdownProps> = ({
canViewDeployment,
canViewOrganizations,
canViewAllUsers,
canViewAuditLog,
canViewHealth,
}) => {
@@ -34,7 +32,6 @@ export const DeploymentDropdown: FC<DeploymentDropdownProps> = ({
!canViewAuditLog &&
!canViewOrganizations &&
!canViewDeployment &&
!canViewAllUsers &&
!canViewHealth
) {
return null;
@@ -43,17 +40,9 @@ export const DeploymentDropdown: FC<DeploymentDropdownProps> = ({
return (
<Popover>
<PopoverTrigger>
<Button
size="small"
endIcon={
<DropdownArrow
color={theme.experimental.l2.fill.solid}
close={false}
margin={false}
/>
}
>
<Button variant="outline" size="lg">
Admin settings
<ChevronDownIcon className="text-content-primary !size-icon-xs" />
</Button>
</PopoverTrigger>
@@ -70,7 +59,6 @@ export const DeploymentDropdown: FC<DeploymentDropdownProps> = ({
<DeploymentDropdownContent
canViewDeployment={canViewDeployment}
canViewOrganizations={canViewOrganizations}
canViewAllUsers={canViewAllUsers}
canViewAuditLog={canViewAuditLog}
canViewHealth={canViewHealth}
/>
@@ -0,0 +1,146 @@
import type { Meta, StoryObj } from "@storybook/react";
import { fn, userEvent, within } from "@storybook/test";
import { PointerEventsCheckLevel } from "@testing-library/user-event";
import type { FC } from "react";
import { chromaticWithTablet } from "testHelpers/chromatic";
import {
MockPrimaryWorkspaceProxy,
MockProxyLatencies,
MockSupportLinks,
MockUser,
MockUser2,
MockWorkspaceProxies,
} from "testHelpers/entities";
import { MobileMenu } from "./MobileMenu";
const meta: Meta<typeof MobileMenu> = {
title: "modules/dashboard/MobileMenu",
parameters: {
layout: "fullscreen",
viewport: {
defaultViewport: "iphone12",
},
},
component: MobileMenu,
args: {
proxyContextValue: {
proxy: {
preferredPathAppURL: "",
preferredWildcardHostname: "",
proxy: MockPrimaryWorkspaceProxy,
},
isLoading: false,
isFetched: true,
setProxy: fn(),
clearProxy: fn(),
refetchProxyLatencies: fn(),
proxyLatencies: MockProxyLatencies,
proxies: MockWorkspaceProxies,
},
user: MockUser,
supportLinks: MockSupportLinks,
docsHref: "https://coder.com/docs",
onSignOut: fn(),
isDefaultOpen: true,
canViewAuditLog: true,
canViewDeployment: true,
canViewHealth: true,
canViewOrganizations: true,
},
decorators: [withNavbarMock],
};
export default meta;
type Story = StoryObj<typeof MobileMenu>;
export const Closed: Story = {
args: {
isDefaultOpen: false,
},
};
export const Admin: Story = {
play: openAdminSettings,
};
export const Auditor: Story = {
args: {
user: MockUser2,
canViewAuditLog: true,
canViewDeployment: false,
canViewHealth: false,
canViewOrganizations: false,
},
play: openAdminSettings,
};
export const OrgAdmin: Story = {
args: {
user: MockUser2,
canViewAuditLog: true,
canViewDeployment: false,
canViewHealth: false,
canViewOrganizations: true,
},
play: openAdminSettings,
};
export const Member: Story = {
args: {
user: MockUser2,
canViewAuditLog: false,
canViewDeployment: false,
canViewHealth: false,
canViewOrganizations: false,
},
};
export const ProxySettings: Story = {
play: async ({ canvasElement }) => {
const user = setupUser();
const body = within(canvasElement.ownerDocument.body);
const menuItem = await body.findByRole("menuitem", {
name: /workspace proxy settings/i,
});
await user.click(menuItem);
},
};
export const UserSettings: Story = {
play: async ({ canvasElement }) => {
const user = setupUser();
const body = within(canvasElement.ownerDocument.body);
const menuItem = await body.findByRole("menuitem", {
name: /user settings/i,
});
await user.click(menuItem);
},
};
function withNavbarMock(Story: FC) {
return (
<div className="h-[72px] border-0 border-b border-solid px-6 flex items-center justify-end">
<Story />
</div>
);
}
function setupUser() {
// It seems the dropdown component is disabling pointer events, which is
// causing Testing Library to throw an error. As a workaround, we can
// disable the pointer events check.
return userEvent.setup({
pointerEventsCheck: PointerEventsCheckLevel.Never,
});
}
async function openAdminSettings({
canvasElement,
}: { canvasElement: HTMLElement }) {
const user = setupUser();
const body = within(canvasElement.ownerDocument.body);
const menuItem = await body.findByRole("menuitem", {
name: /admin settings/i,
});
await user.click(menuItem);
}
@@ -0,0 +1,339 @@
import type * as TypesGen from "api/typesGenerated";
import { Avatar } from "components/Avatar/Avatar";
import { Button } from "components/Button/Button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "components/Collapsible/Collapsible";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "components/DropdownMenu/DropdownMenu";
import { FeatureStageBadge } from "components/FeatureStageBadge/FeatureStageBadge";
import { displayError } from "components/GlobalSnackbar/utils";
import { Latency } from "components/Latency/Latency";
import type { ProxyContextValue } from "contexts/ProxyContext";
import {
ChevronRightIcon,
CircleHelpIcon,
MenuIcon,
XIcon,
} from "lucide-react";
import { type FC, useState } from "react";
import { Link } from "react-router-dom";
import { cn } from "utils/cn";
import { sortProxiesByLatency } from "./proxyUtils";
const itemStyles = {
default: "px-9 h-10 no-underline",
sub: "pl-12",
open: "text-content-primary",
};
type MobileMenuPermissions = {
canViewDeployment: boolean;
canViewOrganizations: boolean;
canViewAuditLog: boolean;
canViewHealth: boolean;
};
type MobileMenuProps = MobileMenuPermissions & {
proxyContextValue?: ProxyContextValue;
user?: TypesGen.User;
supportLinks?: readonly TypesGen.LinkConfig[];
docsHref: string;
onSignOut: () => void;
isDefaultOpen?: boolean; // Useful for storybook
};
export const MobileMenu: FC<MobileMenuProps> = ({
isDefaultOpen,
proxyContextValue,
user,
supportLinks,
docsHref,
onSignOut,
...permissions
}) => {
const [open, setOpen] = useState(isDefaultOpen);
const hasSomePermission = Object.values(permissions).some((p) => p);
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
{open && (
<div className="fixed inset-0 top-[72px] backdrop-blur-sm z-10 bg-content-primary/50" />
)}
<DropdownMenuTrigger asChild>
<Button
aria-label={open ? "Close menu" : "Open menu"}
size="icon"
variant="ghost"
className="ml-auto md:hidden [&_svg]:size-6"
>
{open ? (
<XIcon className="size-icon-lg" />
) : (
<MenuIcon className="size-icon-lg" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-screen border-0 border-b border-solid p-0 py-2"
sideOffset={17}
>
<ProxySettingsSub proxyContextValue={proxyContextValue} />
{hasSomePermission && (
<>
<DropdownMenuSeparator />
<AdminSettingsSub {...permissions} />
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem asChild className={itemStyles.default}>
<a href={docsHref} target="_blank" rel="noreferrer norefereer">
Docs
</a>
</DropdownMenuItem>
<DropdownMenuSeparator />
<UserSettingsSub
user={user}
supportLinks={supportLinks}
onSignOut={onSignOut}
/>
</DropdownMenuContent>
</DropdownMenu>
);
};
type ProxySettingsSubProps = {
proxyContextValue?: ProxyContextValue;
};
const ProxySettingsSub: FC<ProxySettingsSubProps> = ({ proxyContextValue }) => {
const selectedProxy = proxyContextValue?.proxy.proxy;
const latency = selectedProxy
? proxyContextValue?.proxyLatencies[selectedProxy?.id]
: undefined;
const [open, setOpen] = useState(false);
if (!selectedProxy) {
return null;
}
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger asChild>
<DropdownMenuItem
className={cn(itemStyles.default, open ? itemStyles.open : "")}
onClick={(e) => {
e.preventDefault();
setOpen((prev) => !prev);
}}
>
Workspace proxy settings:
<span className="leading-none flex items-center gap-1">
<img
className="w-4 h-4"
src={selectedProxy.icon_url}
alt={selectedProxy.name}
/>
{latency && <Latency latency={latency.latencyMS} />}
</span>
<ChevronRightIcon
className={cn("ml-auto", open ? "rotate-90" : "")}
/>
</DropdownMenuItem>
</CollapsibleTrigger>
<CollapsibleContent>
{proxyContextValue.proxies &&
sortProxiesByLatency(
proxyContextValue.proxies,
proxyContextValue.proxyLatencies,
).map((p) => {
const latency = proxyContextValue.proxyLatencies[p.id];
return (
<DropdownMenuItem
className={cn(itemStyles.default, itemStyles.sub)}
key={p.id}
onClick={(e) => {
e.preventDefault();
if (!p.healthy) {
displayError("Please select a healthy workspace proxy.");
return;
}
proxyContextValue.setProxy(p);
setOpen(false);
}}
>
<img className="w-4 h-4" src={p.icon_url} alt={p.name} />
{p.display_name || p.name}
{latency ? (
<Latency latency={latency.latencyMS} />
) : (
<CircleHelpIcon className="ml-auto" />
)}
</DropdownMenuItem>
);
})}
<DropdownMenuSeparator />
<DropdownMenuItem
asChild
className={cn(itemStyles.default, itemStyles.sub)}
>
<Link to="/deployment/workspace-proxies">Proxy settings</Link>
</DropdownMenuItem>
<DropdownMenuItem
className={cn(itemStyles.default, itemStyles.sub)}
onClick={() => {
proxyContextValue.refetchProxyLatencies();
}}
>
Refresh latencies
</DropdownMenuItem>
</CollapsibleContent>
</Collapsible>
);
};
const AdminSettingsSub: FC<MobileMenuPermissions> = ({
canViewDeployment,
canViewOrganizations,
canViewAuditLog,
canViewHealth,
}) => {
const [open, setOpen] = useState(false);
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger asChild>
<DropdownMenuItem
className={cn(itemStyles.default, open ? itemStyles.open : "")}
onClick={(e) => {
e.preventDefault();
setOpen((prev) => !prev);
}}
>
Admin settings
<ChevronRightIcon
className={cn("ml-auto", open ? "rotate-90" : "")}
/>
</DropdownMenuItem>
</CollapsibleTrigger>
<CollapsibleContent>
{canViewDeployment && (
<DropdownMenuItem
asChild
className={cn(itemStyles.default, itemStyles.sub)}
>
<Link to="/deployment/general">Deployment</Link>
</DropdownMenuItem>
)}
{canViewOrganizations && (
<DropdownMenuItem
asChild
className={cn(itemStyles.default, itemStyles.sub)}
>
<Link to="/organizations">
Organizations
<FeatureStageBadge
contentType="beta"
size="sm"
showTooltip={false}
/>
</Link>
</DropdownMenuItem>
)}
{canViewAuditLog && (
<DropdownMenuItem
asChild
className={cn(itemStyles.default, itemStyles.sub)}
>
<Link to="/audit">Audit logs</Link>
</DropdownMenuItem>
)}
{canViewHealth && (
<DropdownMenuItem
asChild
className={cn(itemStyles.default, itemStyles.sub)}
>
<Link to="/health">Healthcheck</Link>
</DropdownMenuItem>
)}
</CollapsibleContent>
</Collapsible>
);
};
type UserSettingsSubProps = {
user?: TypesGen.User;
supportLinks?: readonly TypesGen.LinkConfig[];
onSignOut: () => void;
};
const UserSettingsSub: FC<UserSettingsSubProps> = ({
user,
supportLinks,
onSignOut,
}) => {
const [open, setOpen] = useState(false);
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger asChild>
<DropdownMenuItem
className={cn(itemStyles.default, open ? itemStyles.open : "")}
onClick={(e) => {
e.preventDefault();
setOpen((prev) => !prev);
}}
>
<Avatar
src={user?.avatar_url}
fallback={user?.name || user?.username}
/>
User settings
<ChevronRightIcon
className={cn("ml-auto", open ? "rotate-90" : "")}
/>
</DropdownMenuItem>
</CollapsibleTrigger>
<CollapsibleContent>
<DropdownMenuItem
asChild
className={cn(itemStyles.default, itemStyles.sub)}
>
<Link to="/settings/account">Account</Link>
</DropdownMenuItem>
<DropdownMenuItem
className={cn(itemStyles.default, itemStyles.sub)}
onClick={onSignOut}
>
Sign out
</DropdownMenuItem>
{supportLinks && (
<>
<DropdownMenuSeparator />
{supportLinks?.map((l) => (
<DropdownMenuItem
key={l.name}
asChild
className={cn(itemStyles.default, itemStyles.sub)}
>
<a href={l.target} target="_blank" rel="noreferrer">
{l.name}
</a>
</DropdownMenuItem>
))}
</>
)}
</CollapsibleContent>
</Collapsible>
);
};
@@ -7,7 +7,6 @@ import {
MockMemberPermissions,
} from "testHelpers/entities";
import { server } from "testHelpers/server";
import { Language } from "./NavbarView";
/**
* The LicenseBanner, mounted above the AppRouter, fetches entitlements. Thus, to test their
@@ -24,13 +23,7 @@ describe("Navbar", () => {
render(<App />);
const deploymentMenu = await screen.findByText("Admin settings");
await userEvent.click(deploymentMenu);
await waitFor(
() => {
const link = screen.getByText(Language.audit);
expect(link).toBeDefined();
},
{ timeout: 2000 },
);
await screen.findByText("Audit Logs");
});
it("does not show Audit Log link when not entitled", async () => {
@@ -41,8 +34,7 @@ describe("Navbar", () => {
await userEvent.click(deploymentMenu);
await waitFor(
() => {
const link = screen.queryByText(Language.audit);
expect(link).toBe(null);
expect(screen.queryByText("Audit Logs")).not.toBeInTheDocument();
},
{ timeout: 2000 },
);
@@ -64,8 +56,7 @@ describe("Navbar", () => {
render(<App />);
await waitFor(
() => {
const link = screen.queryByText("Deployment");
expect(link).toBe(null);
expect(screen.queryByText("Deployment")).not.toBeInTheDocument();
},
{ timeout: 2000 },
);
+1 -2
View File
@@ -20,7 +20,6 @@ export const Navbar: FC = () => {
const canViewDeployment = Boolean(permissions.viewDeploymentValues);
const canViewOrganizations =
Boolean(permissions.editAnyOrganization) && showOrganizations;
const canViewAllUsers = Boolean(permissions.viewAllUsers);
const proxyContextValue = useProxy();
const canViewHealth = canViewDeployment;
@@ -33,10 +32,10 @@ export const Navbar: FC = () => {
onSignOut={signOut}
canViewDeployment={canViewDeployment}
canViewOrganizations={canViewOrganizations}
canViewAllUsers={canViewAllUsers}
canViewHealth={canViewHealth}
canViewAuditLog={canViewAuditLog}
proxyContextValue={proxyContextValue}
docsHref={appearance.docs_url}
/>
);
};
@@ -11,7 +11,6 @@ const meta: Meta<typeof NavbarView> = {
component: NavbarView,
args: {
user: MockUser,
canViewAllUsers: true,
canViewAuditLog: true,
canViewDeployment: true,
canViewHealth: true,
@@ -35,7 +34,6 @@ export const ForAdmin: Story = {
export const ForAuditor: Story = {
args: {
user: MockUser2,
canViewAllUsers: false,
canViewAuditLog: true,
canViewDeployment: false,
canViewHealth: false,
@@ -52,7 +50,6 @@ export const ForAuditor: Story = {
export const ForOrgAdmin: Story = {
args: {
user: MockUser2,
canViewAllUsers: false,
canViewAuditLog: true,
canViewDeployment: false,
canViewHealth: false,
@@ -69,7 +66,6 @@ export const ForOrgAdmin: Story = {
export const ForMember: Story = {
args: {
user: MockUser2,
canViewAllUsers: false,
canViewAuditLog: false,
canViewDeployment: false,
canViewHealth: false,
@@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event";
import type { ProxyContextValue } from "contexts/ProxyContext";
import { MockPrimaryWorkspaceProxy, MockUser } from "testHelpers/entities";
import { renderWithAuth } from "testHelpers/renderHelpers";
import { NavbarView, Language as navLanguage } from "./NavbarView";
import { NavbarView } from "./NavbarView";
const proxyContextValue: ProxyContextValue = {
proxy: {
@@ -25,76 +25,75 @@ describe("NavbarView", () => {
it("workspaces nav link has the correct href", async () => {
renderWithAuth(
<NavbarView
docsHref="https://docs.coder.com"
proxyContextValue={proxyContextValue}
user={MockUser}
onSignOut={noop}
canViewDeployment
canViewOrganizations
canViewAllUsers
canViewHealth
canViewAuditLog
/>,
);
const workspacesLink = await screen.findByText(navLanguage.workspaces);
expect((workspacesLink as HTMLAnchorElement).href).toContain("/workspaces");
const workspacesLink =
await screen.findByText<HTMLAnchorElement>(/workspaces/i);
expect(workspacesLink.href).toContain("/workspaces");
});
it("templates nav link has the correct href", async () => {
renderWithAuth(
<NavbarView
docsHref="https://docs.coder.com"
proxyContextValue={proxyContextValue}
user={MockUser}
onSignOut={noop}
canViewDeployment
canViewOrganizations
canViewAllUsers
canViewHealth
canViewAuditLog
/>,
);
const templatesLink = await screen.findByText(navLanguage.templates);
expect((templatesLink as HTMLAnchorElement).href).toContain("/templates");
const templatesLink =
await screen.findByText<HTMLAnchorElement>(/templates/i);
expect(templatesLink.href).toContain("/templates");
});
it("audit nav link has the correct href", async () => {
renderWithAuth(
<NavbarView
docsHref="https://docs.coder.com"
proxyContextValue={proxyContextValue}
user={MockUser}
onSignOut={noop}
canViewDeployment
canViewOrganizations
canViewAllUsers
canViewHealth
canViewAuditLog
/>,
);
const deploymentMenu = await screen.findByText("Admin settings");
await userEvent.click(deploymentMenu);
const auditLink = await screen.findByText(navLanguage.audit);
expect((auditLink as HTMLAnchorElement).href).toContain("/audit");
const auditLink = await screen.findByText<HTMLAnchorElement>(/audit logs/i);
expect(auditLink.href).toContain("/audit");
});
it("deployment nav link has the correct href", async () => {
renderWithAuth(
<NavbarView
docsHref="https://docs.coder.com"
proxyContextValue={proxyContextValue}
user={MockUser}
onSignOut={noop}
canViewDeployment
canViewOrganizations
canViewAllUsers
canViewHealth
canViewAuditLog
/>,
);
const deploymentMenu = await screen.findByText("Admin settings");
await userEvent.click(deploymentMenu);
const deploymentSettingsLink = await screen.findByText(
navLanguage.deployment,
);
expect((deploymentSettingsLink as HTMLAnchorElement).href).toContain(
"/deployment/general",
);
const deploymentSettingsLink =
await screen.findByText<HTMLAnchorElement>(/deployment/i);
expect(deploymentSettingsLink.href).toContain("/deployment/general");
});
});
+94 -199
View File
@@ -1,38 +1,104 @@
import { type Interpolation, type Theme, css, useTheme } from "@emotion/react";
import MenuIcon from "@mui/icons-material/Menu";
import Drawer from "@mui/material/Drawer";
import IconButton from "@mui/material/IconButton";
import type * as TypesGen from "api/typesGenerated";
import { ExternalImage } from "components/ExternalImage/ExternalImage";
import { CoderIcon } from "components/Icons/CoderIcon";
import type { ProxyContextValue } from "contexts/ProxyContext";
import { type FC, useState } from "react";
import type { FC } from "react";
import { NavLink, useLocation } from "react-router-dom";
import { navHeight } from "theme/constants";
import { cn } from "utils/cn";
import { DeploymentDropdown } from "./DeploymentDropdown";
import { MobileMenu } from "./MobileMenu";
import { ProxyMenu } from "./ProxyMenu";
import { UserDropdown } from "./UserDropdown/UserDropdown";
export interface NavbarViewProps {
logo_url?: string;
user?: TypesGen.User;
docsHref: string;
buildInfo?: TypesGen.BuildInfoResponse;
supportLinks?: readonly TypesGen.LinkConfig[];
onSignOut: () => void;
canViewDeployment: boolean;
canViewOrganizations: boolean;
canViewAllUsers: boolean;
canViewAuditLog: boolean;
canViewHealth: boolean;
proxyContextValue?: ProxyContextValue;
}
export const Language = {
workspaces: "Workspaces",
templates: "Templates",
users: "Users",
audit: "Audit Logs",
deployment: "Deployment",
const linkStyles = {
default:
"text-sm font-medium text-content-secondary no-underline block h-full px-2 flex items-center hover:text-content-primary transition-colors",
active: "text-content-primary",
};
export const NavbarView: FC<NavbarViewProps> = ({
user,
logo_url,
docsHref,
buildInfo,
supportLinks,
onSignOut,
canViewDeployment,
canViewOrganizations,
canViewHealth,
canViewAuditLog,
proxyContextValue,
}) => {
return (
<div className="border-0 border-b border-solid h-[72px] flex items-center leading-none px-6">
<NavLink to="/workspaces">
{logo_url ? (
<ExternalImage className="h-7" src={logo_url} alt="Custom Logo" />
) : (
<CoderIcon className="h-7 w-7 fill-content-primary" />
)}
</NavLink>
<NavItems className="ml-4" />
<div className=" hidden md:flex items-center gap-3 ml-auto">
{proxyContextValue && (
<ProxyMenu proxyContextValue={proxyContextValue} />
)}
<DeploymentDropdown
canViewAuditLog={canViewAuditLog}
canViewOrganizations={canViewOrganizations}
canViewDeployment={canViewDeployment}
canViewHealth={canViewHealth}
/>
<a
className={linkStyles.default}
href={docsHref}
target="_blank"
rel="noreferrer"
>
Docs
</a>
{user && (
<UserDropdown
user={user}
buildInfo={buildInfo}
supportLinks={supportLinks}
onSignOut={onSignOut}
/>
)}
</div>
<MobileMenu
proxyContextValue={proxyContextValue}
user={user}
supportLinks={supportLinks}
docsHref={docsHref}
onSignOut={onSignOut}
canViewAuditLog={canViewAuditLog}
canViewOrganizations={canViewOrganizations}
canViewDeployment={canViewDeployment}
canViewHealth={canViewHealth}
/>
</div>
);
};
interface NavItemsProps {
@@ -41,199 +107,28 @@ interface NavItemsProps {
const NavItems: FC<NavItemsProps> = ({ className }) => {
const location = useLocation();
const theme = useTheme();
return (
<nav className={className}>
<nav className={cn("flex items-center gap-4 h-full", className)}>
<NavLink
css={[
styles.link,
location.pathname.startsWith("/@") && {
color: theme.palette.text.primary,
fontWeight: 500,
},
]}
className={({ isActive }) => {
if (location.pathname.startsWith("/@")) {
isActive = true;
}
return cn(linkStyles.default, isActive ? linkStyles.active : "");
}}
to="/workspaces"
>
{Language.workspaces}
Workspaces
</NavLink>
<NavLink css={styles.link} to="/templates">
{Language.templates}
<NavLink
className={({ isActive }) => {
return cn(linkStyles.default, isActive ? linkStyles.active : "");
}}
to="/templates"
>
Templates
</NavLink>
</nav>
);
};
export const NavbarView: FC<NavbarViewProps> = ({
user,
logo_url,
buildInfo,
supportLinks,
onSignOut,
canViewDeployment,
canViewOrganizations,
canViewAllUsers,
canViewHealth,
canViewAuditLog,
proxyContextValue,
}) => {
const theme = useTheme();
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
return (
<nav
css={{
height: navHeight,
backgroundColor: theme.palette.background.paper,
borderBottom: `1px solid ${theme.palette.divider}`,
}}
>
<div css={styles.wrapper}>
<IconButton
aria-label="Open menu"
css={styles.mobileMenuButton}
onClick={() => {
setIsDrawerOpen(true);
}}
size="large"
>
<MenuIcon />
</IconButton>
<Drawer
anchor="left"
open={isDrawerOpen}
onClose={() => setIsDrawerOpen(false)}
>
<div css={{ width: 250 }}>
<div css={styles.drawerHeader}>
<div css={[styles.logo, styles.drawerLogo]}>
{logo_url ? (
<ExternalImage src={logo_url} alt="Custom Logo" />
) : (
<CoderIcon />
)}
</div>
</div>
<NavItems />
</div>
</Drawer>
<NavLink css={styles.logo} to="/workspaces">
{logo_url ? (
<ExternalImage src={logo_url} alt="Custom Logo" />
) : (
<CoderIcon fill="white" opacity={1} width={125} />
)}
</NavLink>
<NavItems css={styles.desktopNavItems} />
<div css={styles.navMenus}>
{proxyContextValue && (
<ProxyMenu proxyContextValue={proxyContextValue} />
)}
<DeploymentDropdown
canViewAuditLog={canViewAuditLog}
canViewOrganizations={canViewOrganizations}
canViewDeployment={canViewDeployment}
canViewAllUsers={canViewAllUsers}
canViewHealth={canViewHealth}
/>
{user && (
<UserDropdown
user={user}
buildInfo={buildInfo}
supportLinks={supportLinks}
onSignOut={onSignOut}
/>
)}
</div>
</div>
</nav>
);
};
const styles = {
desktopNavItems: (theme) => css`
display: none;
${theme.breakpoints.up("md")} {
display: flex;
}
`,
mobileMenuButton: (theme) => css`
${theme.breakpoints.up("md")} {
display: none;
}
`,
navMenus: (theme) => ({
display: "flex",
gap: 16,
alignItems: "center",
paddingRight: 16,
[theme.breakpoints.up("md")]: {
marginLeft: "auto",
},
}),
wrapper: (theme) => css`
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
${theme.breakpoints.up("md")} {
justify-content: flex-start;
}
`,
drawerHeader: {
padding: 16,
paddingTop: 32,
paddingBottom: 32,
},
logo: (theme) => css`
align-items: center;
display: flex;
height: ${navHeight}px;
color: ${theme.palette.text.primary};
padding: 16px;
// svg is for the Coder logo, img is for custom images
& svg,
& img {
height: 100%;
object-fit: contain;
}
`,
drawerLogo: {
padding: 0,
maxHeight: 40,
},
link: (theme) => css`
align-items: center;
color: ${theme.palette.text.secondary};
display: flex;
flex: 1;
font-size: 16px;
padding: 12px 16px;
text-decoration: none;
transition: background-color 0.15s ease-in-out;
&.active {
color: ${theme.palette.text.primary};
font-weight: 500;
}
&:hover {
background-color: ${theme.experimental.l2.hover.background};
}
${theme.breakpoints.up("md")} {
height: ${navHeight}px;
padding: 0 24px;
}
`,
} satisfies Record<string, Interpolation<Theme>>;
+12 -19
View File
@@ -1,6 +1,4 @@
import { useTheme } from "@emotion/react";
import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined";
import Button from "@mui/material/Button";
import Divider from "@mui/material/Divider";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
@@ -8,13 +6,15 @@ import Skeleton from "@mui/material/Skeleton";
import { visuallyHidden } from "@mui/utils";
import type * as TypesGen from "api/typesGenerated";
import { Abbr } from "components/Abbr/Abbr";
import { Button } from "components/Button/Button";
import { displayError } from "components/GlobalSnackbar/utils";
import { Latency } from "components/Latency/Latency";
import type { ProxyContextValue } from "contexts/ProxyContext";
import { useAuthenticated } from "contexts/auth/RequireAuth";
import { ChevronDownIcon } from "lucide-react";
import { type FC, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { BUTTON_SM_HEIGHT } from "theme/constants";
import { sortProxiesByLatency } from "./proxyUtils";
interface ProxyMenuProps {
proxyContextValue: ProxyContextValue;
@@ -62,7 +62,7 @@ export const ProxyMenu: FC<ProxyMenuProps> = ({ proxyContextValue }) => {
return (
<Skeleton
width="110px"
height={BUTTON_SM_HEIGHT}
height={40}
css={{ borderRadius: 6, transform: "none" }}
/>
);
@@ -71,13 +71,10 @@ export const ProxyMenu: FC<ProxyMenuProps> = ({ proxyContextValue }) => {
return (
<>
<Button
variant="outline"
ref={buttonRef}
onClick={() => setIsOpen(true)}
size="small"
endIcon={<KeyboardArrowDownOutlined />}
css={{
"& .MuiSvgIcon-root": { fontSize: 14 },
}}
size="lg"
>
<span css={{ ...visuallyHidden }}>
Latency for {selectedProxy?.display_name ?? "your region"}
@@ -107,6 +104,8 @@ export const ProxyMenu: FC<ProxyMenuProps> = ({ proxyContextValue }) => {
) : (
"Select Proxy"
)}
<ChevronDownIcon className="text-content-primary !size-icon-xs" />
</Button>
<Menu
@@ -169,15 +168,8 @@ export const ProxyMenu: FC<ProxyMenuProps> = ({ proxyContextValue }) => {
]}
{proxyContextValue.proxies &&
[...proxyContextValue.proxies]
.sort((a, b) => {
const latencyA =
latencies?.[a.id]?.latencyMS ?? Number.POSITIVE_INFINITY;
const latencyB =
latencies?.[b.id]?.latencyMS ?? Number.POSITIVE_INFINITY;
return latencyA - latencyB;
})
.map((proxy) => (
sortProxiesByLatency(proxyContextValue.proxies, latencies).map(
(proxy) => (
<MenuItem
key={proxy.id}
selected={proxy.id === selectedProxy?.id}
@@ -221,7 +213,8 @@ export const ProxyMenu: FC<ProxyMenuProps> = ({ proxyContextValue }) => {
/>
</div>
</MenuItem>
))}
),
)}
<Divider />
@@ -1,15 +1,12 @@
import { type Interpolation, type Theme, css, useTheme } from "@emotion/react";
import Badge from "@mui/material/Badge";
import { useTheme } from "@emotion/react";
import type * as TypesGen from "api/typesGenerated";
import { Avatar } from "components/Avatar/Avatar";
import { DropdownArrow } from "components/DropdownArrow/DropdownArrow";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "components/deprecated/Popover/Popover";
import { type FC, useState } from "react";
import { navHeight } from "theme/constants";
import { UserDropdownContent } from "./UserDropdownContent";
export interface UserDropdownProps {
@@ -31,20 +28,11 @@ export const UserDropdown: FC<UserDropdownProps> = ({
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger>
<button css={styles.button} data-testid="user-dropdown-trigger">
<div css={styles.badgeContainer}>
<Badge overlap="circular">
<Avatar
fallback={user.username}
src={user.avatar_url}
size="lg"
/>
</Badge>
<DropdownArrow
color={theme.experimental.l2.fill.solid}
close={open}
/>
</div>
<button
type="button"
className="bg-transparent border-0 cursor-pointer p-0"
>
<Avatar fallback={user.username} src={user.avatar_url} size="lg" />
</button>
</PopoverTrigger>
@@ -68,24 +56,3 @@ export const UserDropdown: FC<UserDropdownProps> = ({
</Popover>
);
};
const styles = {
button: css`
background: none;
border: 0;
cursor: pointer;
height: ${navHeight}px;
padding: 12px 0;
&:hover {
background-color: transparent;
}
`,
badgeContainer: {
display: "flex",
alignItems: "center",
minWidth: 0,
maxWidth: 300,
},
} satisfies Record<string, Interpolation<Theme>>;
@@ -0,0 +1,12 @@
import type { Proxies, ProxyLatencies } from "contexts/ProxyContext";
export function sortProxiesByLatency(
proxies: Proxies,
latencies: ProxyLatencies,
) {
return proxies.toSorted((a, b) => {
const latencyA = latencies?.[a.id]?.latencyMS ?? Number.POSITIVE_INFINITY;
const latencyB = latencies?.[b.id]?.latencyMS ?? Number.POSITIVE_INFINITY;
return latencyA - latencyB;
});
}
@@ -39,7 +39,7 @@ const DeploymentSettingsLayout: FC = () => {
</BreadcrumbList>
</Breadcrumb>
<hr className="h-px border-none bg-border" />
<div className="px-10 max-w-screen-2xl">
<div className="px-6 max-w-screen-2xl">
<div className="flex flex-row gap-12 py-10">
<DeploymentSidebar />
<main css={{ flexGrow: 1 }}>
@@ -109,7 +109,7 @@ const OrganizationSettingsLayout: FC = () => {
</BreadcrumbList>
</Breadcrumb>
<hr className="h-px border-none bg-border" />
<div className="px-10 max-w-screen-2xl">
<div className="px-6 max-w-screen-2xl">
<div className="flex flex-row gap-12 py-10">
<OrganizationSidebar />
<main css={{ flexGrow: 1 }}>
+5
View File
@@ -8,6 +8,11 @@ module.exports = {
important: ["#root", "#storybook-root"],
theme: {
extend: {
size: {
"icon-lg": "1.5rem",
"icon-sm": "1.125rem",
"icon-xs": "0.875rem",
},
fontSize: {
"2xs": ["0.625rem", "0.875rem"],
sm: ["0.875rem", "1.5rem"],