From 49fadb801d9a768ade6a6292e9c2db535590b32a Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Mon, 30 Dec 2024 14:25:00 -0300 Subject: [PATCH] refactor: update the navbar to match the new designs (#15964) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- site/.storybook/preview.jsx | 8 + site/src/components/Breadcrumb/Breadcrumb.tsx | 2 +- site/src/components/Button/Button.tsx | 39 +- .../components/DropdownMenu/DropdownMenu.tsx | 6 +- site/src/contexts/ProxyContext.tsx | 6 +- site/src/index.css | 13 + .../dashboard/Navbar/DeploymentDropdown.tsx | 20 +- .../dashboard/Navbar/MobileMenu.stories.tsx | 146 ++++++++ .../modules/dashboard/Navbar/MobileMenu.tsx | 339 ++++++++++++++++++ .../modules/dashboard/Navbar/Navbar.test.tsx | 15 +- site/src/modules/dashboard/Navbar/Navbar.tsx | 3 +- .../dashboard/Navbar/NavbarView.stories.tsx | 4 - .../dashboard/Navbar/NavbarView.test.tsx | 33 +- .../modules/dashboard/Navbar/NavbarView.tsx | 293 +++++---------- .../modules/dashboard/Navbar/ProxyMenu.tsx | 31 +- .../Navbar/UserDropdown/UserDropdown.tsx | 45 +-- .../modules/dashboard/Navbar/proxyUtils.tsx | 12 + .../management/DeploymentSettingsLayout.tsx | 2 +- .../management/OrganizationSettingsLayout.tsx | 2 +- site/tailwind.config.js | 5 + 20 files changed, 692 insertions(+), 332 deletions(-) create mode 100644 site/src/modules/dashboard/Navbar/MobileMenu.stories.tsx create mode 100644 site/src/modules/dashboard/Navbar/MobileMenu.tsx create mode 100644 site/src/modules/dashboard/Navbar/proxyUtils.tsx diff --git a/site/.storybook/preview.jsx b/site/.storybook/preview.jsx index 9953c0533e..17e6113508 100644 --- a/site/.storybook/preview.jsx +++ b/site/.storybook/preview.jsx @@ -64,6 +64,14 @@ export const parameters = { }, type: "tablet", }, + iphone12: { + name: "iPhone 12", + styles: { + height: "844px", + width: "390px", + }, + type: "mobile", + }, terminal: { name: "Terminal", styles: { diff --git a/site/src/components/Breadcrumb/Breadcrumb.tsx b/site/src/components/Breadcrumb/Breadcrumb.tsx index cd6625a42c..35f90d30a5 100644 --- a/site/src/components/Breadcrumb/Breadcrumb.tsx +++ b/site/src/components/Breadcrumb/Breadcrumb.tsx @@ -28,7 +28,7 @@ export const BreadcrumbList = forwardRef<
    = forwardRef< - HTMLButtonElement, - ButtonProps ->(({ className, variant, size, asChild = false, ...props }, ref) => { - const Comp = asChild ? Slot : "button"; - return ( - - ); -}); +export const Button = forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + }, +); diff --git a/site/src/components/DropdownMenu/DropdownMenu.tsx b/site/src/components/DropdownMenu/DropdownMenu.tsx index 8008ea9d6c..c924317b20 100644 --- a/site/src/components/DropdownMenu/DropdownMenu.tsx +++ b/site/src/components/DropdownMenu/DropdownMenu.tsx @@ -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) => ( )); diff --git a/site/src/contexts/ProxyContext.tsx b/site/src/contexts/ProxyContext.tsx index fb43291dd4..1aa749e83e 100644 --- a/site/src/contexts/ProxyContext.tsx +++ b/site/src/contexts/ProxyContext.tsx @@ -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; 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; + proxyLatencies: ProxyLatencies; // refetchProxyLatencies will trigger refreshing of the proxy latencies. By default the latencies // are loaded once. refetchProxyLatencies: () => Date; diff --git a/site/src/index.css b/site/src/index.css index c97e827b98..5f690b5616 100644 --- a/site/src/index.css +++ b/site/src/index.css @@ -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; + } } diff --git a/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx b/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx index dc4b1b4d92..d1a75c02cd 100644 --- a/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx +++ b/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx @@ -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 = ({ canViewDeployment, canViewOrganizations, - canViewAllUsers, canViewAuditLog, canViewHealth, }) => { @@ -34,7 +32,6 @@ export const DeploymentDropdown: FC = ({ !canViewAuditLog && !canViewOrganizations && !canViewDeployment && - !canViewAllUsers && !canViewHealth ) { return null; @@ -43,17 +40,9 @@ export const DeploymentDropdown: FC = ({ return ( - @@ -70,7 +59,6 @@ export const DeploymentDropdown: FC = ({ diff --git a/site/src/modules/dashboard/Navbar/MobileMenu.stories.tsx b/site/src/modules/dashboard/Navbar/MobileMenu.stories.tsx new file mode 100644 index 0000000000..19c66c14b3 --- /dev/null +++ b/site/src/modules/dashboard/Navbar/MobileMenu.stories.tsx @@ -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 = { + 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; + +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 ( +
    + +
    + ); +} + +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); +} diff --git a/site/src/modules/dashboard/Navbar/MobileMenu.tsx b/site/src/modules/dashboard/Navbar/MobileMenu.tsx new file mode 100644 index 0000000000..e04bb7328d --- /dev/null +++ b/site/src/modules/dashboard/Navbar/MobileMenu.tsx @@ -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 = ({ + isDefaultOpen, + proxyContextValue, + user, + supportLinks, + docsHref, + onSignOut, + ...permissions +}) => { + const [open, setOpen] = useState(isDefaultOpen); + const hasSomePermission = Object.values(permissions).some((p) => p); + + return ( + + {open && ( +
    + )} + + + + + + + {hasSomePermission && ( + <> + + + + )} + + + + Docs + + + + + + + ); +}; + +type ProxySettingsSubProps = { + proxyContextValue?: ProxyContextValue; +}; + +const ProxySettingsSub: FC = ({ proxyContextValue }) => { + const selectedProxy = proxyContextValue?.proxy.proxy; + const latency = selectedProxy + ? proxyContextValue?.proxyLatencies[selectedProxy?.id] + : undefined; + const [open, setOpen] = useState(false); + + if (!selectedProxy) { + return null; + } + + return ( + + + { + e.preventDefault(); + setOpen((prev) => !prev); + }} + > + Workspace proxy settings: + + {selectedProxy.name} + {latency && } + + + + + + {proxyContextValue.proxies && + sortProxiesByLatency( + proxyContextValue.proxies, + proxyContextValue.proxyLatencies, + ).map((p) => { + const latency = proxyContextValue.proxyLatencies[p.id]; + return ( + { + e.preventDefault(); + + if (!p.healthy) { + displayError("Please select a healthy workspace proxy."); + return; + } + + proxyContextValue.setProxy(p); + setOpen(false); + }} + > + {p.name} + {p.display_name || p.name} + {latency ? ( + + ) : ( + + )} + + ); + })} + + + Proxy settings + + { + proxyContextValue.refetchProxyLatencies(); + }} + > + Refresh latencies + + + + ); +}; + +const AdminSettingsSub: FC = ({ + canViewDeployment, + canViewOrganizations, + canViewAuditLog, + canViewHealth, +}) => { + const [open, setOpen] = useState(false); + + return ( + + + { + e.preventDefault(); + setOpen((prev) => !prev); + }} + > + Admin settings + + + + + {canViewDeployment && ( + + Deployment + + )} + {canViewOrganizations && ( + + + Organizations + + + + )} + {canViewAuditLog && ( + + Audit logs + + )} + {canViewHealth && ( + + Healthcheck + + )} + + + ); +}; + +type UserSettingsSubProps = { + user?: TypesGen.User; + supportLinks?: readonly TypesGen.LinkConfig[]; + onSignOut: () => void; +}; + +const UserSettingsSub: FC = ({ + user, + supportLinks, + onSignOut, +}) => { + const [open, setOpen] = useState(false); + + return ( + + + { + e.preventDefault(); + setOpen((prev) => !prev); + }} + > + + User settings + + + + + + Account + + + Sign out + + {supportLinks && ( + <> + + {supportLinks?.map((l) => ( + + + {l.name} + + + ))} + + )} + + + ); +}; diff --git a/site/src/modules/dashboard/Navbar/Navbar.test.tsx b/site/src/modules/dashboard/Navbar/Navbar.test.tsx index e01a1506f4..aa9a2c0400 100644 --- a/site/src/modules/dashboard/Navbar/Navbar.test.tsx +++ b/site/src/modules/dashboard/Navbar/Navbar.test.tsx @@ -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(); 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(); await waitFor( () => { - const link = screen.queryByText("Deployment"); - expect(link).toBe(null); + expect(screen.queryByText("Deployment")).not.toBeInTheDocument(); }, { timeout: 2000 }, ); diff --git a/site/src/modules/dashboard/Navbar/Navbar.tsx b/site/src/modules/dashboard/Navbar/Navbar.tsx index 5a3e86832e..5c3ccb72ff 100644 --- a/site/src/modules/dashboard/Navbar/Navbar.tsx +++ b/site/src/modules/dashboard/Navbar/Navbar.tsx @@ -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} /> ); }; diff --git a/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx b/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx index 6ac0e51087..ae13c7fcc9 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx @@ -11,7 +11,6 @@ const meta: Meta = { 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, diff --git a/site/src/modules/dashboard/Navbar/NavbarView.test.tsx b/site/src/modules/dashboard/Navbar/NavbarView.test.tsx index 3dd4251385..7b51561dde 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.test.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.test.tsx @@ -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( , ); - const workspacesLink = await screen.findByText(navLanguage.workspaces); - expect((workspacesLink as HTMLAnchorElement).href).toContain("/workspaces"); + const workspacesLink = + await screen.findByText(/workspaces/i); + expect(workspacesLink.href).toContain("/workspaces"); }); it("templates nav link has the correct href", async () => { renderWithAuth( , ); - const templatesLink = await screen.findByText(navLanguage.templates); - expect((templatesLink as HTMLAnchorElement).href).toContain("/templates"); + const templatesLink = + await screen.findByText(/templates/i); + expect(templatesLink.href).toContain("/templates"); }); it("audit nav link has the correct href", async () => { renderWithAuth( , ); 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(/audit logs/i); + expect(auditLink.href).toContain("/audit"); }); it("deployment nav link has the correct href", async () => { renderWithAuth( , ); 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(/deployment/i); + expect(deploymentSettingsLink.href).toContain("/deployment/general"); }); }); diff --git a/site/src/modules/dashboard/Navbar/NavbarView.tsx b/site/src/modules/dashboard/Navbar/NavbarView.tsx index 662e39ca9d..ec3a1c690b 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.tsx @@ -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 = ({ + user, + logo_url, + docsHref, + buildInfo, + supportLinks, + onSignOut, + canViewDeployment, + canViewOrganizations, + canViewHealth, + canViewAuditLog, + proxyContextValue, +}) => { + return ( +
    + + {logo_url ? ( + + ) : ( + + )} + + + + +
    + {proxyContextValue && ( + + )} + + + + + Docs + + + {user && ( + + )} +
    + + +
    + ); }; interface NavItemsProps { @@ -41,199 +107,28 @@ interface NavItemsProps { const NavItems: FC = ({ className }) => { const location = useLocation(); - const theme = useTheme(); return ( -
    - ))} + ), + )} diff --git a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.tsx b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.tsx index 6fb7428bb0..6fc41fe723 100644 --- a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.tsx +++ b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.tsx @@ -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 = ({ return ( - @@ -68,24 +56,3 @@ export const UserDropdown: FC = ({ ); }; - -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>; diff --git a/site/src/modules/dashboard/Navbar/proxyUtils.tsx b/site/src/modules/dashboard/Navbar/proxyUtils.tsx new file mode 100644 index 0000000000..57afadb7fb --- /dev/null +++ b/site/src/modules/dashboard/Navbar/proxyUtils.tsx @@ -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; + }); +} diff --git a/site/src/modules/management/DeploymentSettingsLayout.tsx b/site/src/modules/management/DeploymentSettingsLayout.tsx index 2a0a999ac9..65c2e70ea3 100644 --- a/site/src/modules/management/DeploymentSettingsLayout.tsx +++ b/site/src/modules/management/DeploymentSettingsLayout.tsx @@ -39,7 +39,7 @@ const DeploymentSettingsLayout: FC = () => {
    -
    +
    diff --git a/site/src/modules/management/OrganizationSettingsLayout.tsx b/site/src/modules/management/OrganizationSettingsLayout.tsx index 484a6dd8a6..aa586e877d 100644 --- a/site/src/modules/management/OrganizationSettingsLayout.tsx +++ b/site/src/modules/management/OrganizationSettingsLayout.tsx @@ -109,7 +109,7 @@ const OrganizationSettingsLayout: FC = () => {
    -
    +
    diff --git a/site/tailwind.config.js b/site/tailwind.config.js index 61d35ee033..389ffb22fe 100644 --- a/site/tailwind.config.js +++ b/site/tailwind.config.js @@ -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"],