mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user