diff --git a/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx b/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx new file mode 100644 index 0000000000000..23f0355ad3e9a --- /dev/null +++ b/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx @@ -0,0 +1,151 @@ +import { css, type Interpolation, type Theme, useTheme } from "@emotion/react"; +import Button from "@mui/material/Button"; +import MenuItem from "@mui/material/MenuItem"; +import type { FC } from "react"; +import { NavLink } from "react-router-dom"; +import { DropdownArrow } from "components/DropdownArrow/DropdownArrow"; +import { + Popover, + PopoverContent, + PopoverTrigger, + usePopover, +} from "components/Popover/Popover"; +import { USERS_LINK } from "modules/navigation"; + +interface DeploymentDropdownProps { + canViewAuditLog: boolean; + canViewDeployment: boolean; + canViewAllUsers: boolean; + canViewHealth: boolean; +} + +export const DeploymentDropdown: FC = ({ + canViewAuditLog, + canViewDeployment, + canViewAllUsers, + canViewHealth, +}) => { + const theme = useTheme(); + + if ( + !canViewAuditLog && + !canViewDeployment && + !canViewAllUsers && + !canViewHealth + ) { + return null; + } + + return ( + + + + + + + + + + ); +}; + +const DeploymentDropdownContent: FC = ({ + canViewAuditLog, + canViewDeployment, + canViewAllUsers, + canViewHealth, +}) => { + const popover = usePopover(); + + const onPopoverClose = () => popover.setIsOpen(false); + + return ( + + ); +}; + +const styles = { + menuItem: (theme) => css` + text-decoration: none; + color: inherit; + gap: 20px; + padding: 8px 20px; + font-size: 14px; + + &:hover { + background-color: ${theme.palette.action.hover}; + transition: background-color 0.3s ease; + } + `, + menuItemIcon: (theme) => ({ + color: theme.palette.text.secondary, + width: 20, + height: 20, + }), +} satisfies Record>; diff --git a/site/src/modules/dashboard/Navbar/Navbar.test.tsx b/site/src/modules/dashboard/Navbar/Navbar.test.tsx index 681af84728851..93953530c2357 100644 --- a/site/src/modules/dashboard/Navbar/Navbar.test.tsx +++ b/site/src/modules/dashboard/Navbar/Navbar.test.tsx @@ -1,4 +1,5 @@ import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { HttpResponse, http } from "msw"; import { App } from "App"; import { @@ -21,6 +22,8 @@ describe("Navbar", () => { }), ); render(); + const deploymentMenu = await screen.findByText("Deployment"); + await userEvent.click(deploymentMenu); await waitFor( () => { const link = screen.getByText(Language.audit); @@ -34,6 +37,8 @@ describe("Navbar", () => { // by default, user is an Admin with permission to see the audit log, // but is unlicensed so not entitled to see the audit log render(); + const deploymentMenu = await screen.findByText("Deployment"); + await userEvent.click(deploymentMenu); await waitFor( () => { const link = screen.queryByText(Language.audit); @@ -59,7 +64,7 @@ describe("Navbar", () => { render(); await waitFor( () => { - const link = screen.queryByText(Language.audit); + const link = screen.queryByText("Deployment"); expect(link).toBe(null); }, { timeout: 2000 }, diff --git a/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx b/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx index 2490234bd36e1..146a66b9372e3 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx @@ -10,6 +10,10 @@ const meta: Meta = { component: NavbarView, args: { user: MockUser, + canViewAuditLog: true, + canViewDeployment: true, + canViewAllUsers: true, + canViewHealth: true, }, decorators: [withDashboardProvider], }; @@ -25,6 +29,7 @@ export const ForMember: Story = { canViewAuditLog: false, canViewDeployment: false, canViewAllUsers: false, + canViewHealth: false, }, }; diff --git a/site/src/modules/dashboard/Navbar/NavbarView.test.tsx b/site/src/modules/dashboard/Navbar/NavbarView.test.tsx index c881fa300000d..a6541ea688486 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.test.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.test.tsx @@ -1,4 +1,5 @@ import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import type { ProxyContextValue } from "contexts/ProxyContext"; import { MockPrimaryWorkspaceProxy, MockUser } from "testHelpers/entities"; import { renderWithAuth } from "testHelpers/renderHelpers"; @@ -65,6 +66,8 @@ describe("NavbarView", () => { canViewHealth />, ); + const deploymentMenu = await screen.findByText("Deployment"); + await userEvent.click(deploymentMenu); const userLink = await screen.findByText(navLanguage.users); expect((userLink as HTMLAnchorElement).href).toContain("/users"); }); @@ -81,6 +84,8 @@ describe("NavbarView", () => { canViewHealth />, ); + const deploymentMenu = await screen.findByText("Deployment"); + await userEvent.click(deploymentMenu); const auditLink = await screen.findByText(navLanguage.audit); expect((auditLink as HTMLAnchorElement).href).toContain("/audit"); }); @@ -97,8 +102,12 @@ describe("NavbarView", () => { canViewHealth />, ); - const auditLink = await screen.findByText(navLanguage.deployment); - expect((auditLink as HTMLAnchorElement).href).toContain( + const deploymentMenu = await screen.findByText("Deployment"); + await userEvent.click(deploymentMenu); + const deploymentSettingsLink = await screen.findByText( + navLanguage.deployment, + ); + expect((deploymentSettingsLink as HTMLAnchorElement).href).toContain( "/deployment/general", ); }); diff --git a/site/src/modules/dashboard/Navbar/NavbarView.tsx b/site/src/modules/dashboard/Navbar/NavbarView.tsx index 440bdd44f249d..376273c8d75ee 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.tsx @@ -9,7 +9,7 @@ import Menu from "@mui/material/Menu"; import MenuItem from "@mui/material/MenuItem"; import Skeleton from "@mui/material/Skeleton"; import { visuallyHidden } from "@mui/utils"; -import { type FC, type ReactNode, useRef, useState } from "react"; +import { type FC, useRef, useState } from "react"; import { NavLink, useLocation, useNavigate } from "react-router-dom"; import type * as TypesGen from "api/typesGenerated"; import { Abbr } from "components/Abbr/Abbr"; @@ -20,12 +20,9 @@ import { Latency } from "components/Latency/Latency"; import { useAuthenticated } from "contexts/auth/RequireAuth"; import type { ProxyContextValue } from "contexts/ProxyContext"; import { BUTTON_SM_HEIGHT, navHeight } from "theme/constants"; +import { DeploymentDropdown } from "./DeploymentDropdown"; import { UserDropdown } from "./UserDropdown/UserDropdown"; -export const USERS_LINK = `/users?filter=${encodeURIComponent( - "status:active", -)}`; - export interface NavbarViewProps { logo_url?: string; user?: TypesGen.User; @@ -43,26 +40,15 @@ export const Language = { workspaces: "Workspaces", templates: "Templates", users: "Users", - audit: "Audit", - deployment: "Deployment", + audit: "Auditing", + deployment: "Settings", }; interface NavItemsProps { - children?: ReactNode; className?: string; - canViewAuditLog: boolean; - canViewDeployment: boolean; - canViewAllUsers: boolean; - canViewHealth: boolean; } -const NavItems: FC = ({ - className, - canViewAuditLog, - canViewDeployment, - canViewAllUsers, - canViewHealth, -}) => { +const NavItems: FC = ({ className }) => { const location = useLocation(); const theme = useTheme(); @@ -83,26 +69,6 @@ const NavItems: FC = ({ {Language.templates} - {canViewAllUsers && ( - - {Language.users} - - )} - {canViewAuditLog && ( - - {Language.audit} - - )} - {canViewDeployment && ( - - {Language.deployment} - - )} - {canViewHealth && ( - - Health - - )} ); }; @@ -157,12 +123,7 @@ export const NavbarView: FC = ({ )} - + @@ -174,18 +135,20 @@ export const NavbarView: FC = ({ )} - +
{proxyContextValue && ( )} + + + {user && ( = ({ proxyContextValue }) => { size="small" endIcon={} css={{ - borderRadius: "999px", "& .MuiSvgIcon-root": { fontSize: 14 }, }} > diff --git a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx index 631b673b15c0e..c0ad5111ea9ae 100644 --- a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx +++ b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx @@ -27,61 +27,6 @@ export const Language = { copyrightText: `\u00a9 ${new Date().getFullYear()} Coder Technologies, Inc.`, }; -const styles = { - info: (theme) => [ - theme.typography.body2 as CSSObject, - { - padding: 20, - }, - ], - userName: { - fontWeight: 600, - }, - userEmail: (theme) => ({ - color: theme.palette.text.secondary, - width: "100%", - textOverflow: "ellipsis", - overflow: "hidden", - }), - link: { - textDecoration: "none", - color: "inherit", - }, - menuItem: (theme) => css` - gap: 20px; - padding: 8px 20px; - - &:hover { - background-color: ${theme.palette.action.hover}; - transition: background-color 0.3s ease; - } - `, - menuItemIcon: (theme) => ({ - color: theme.palette.text.secondary, - width: 20, - height: 20, - }), - menuItemText: { - fontSize: 14, - }, - footerText: (theme) => css` - font-size: 12px; - text-decoration: none; - color: ${theme.palette.text.secondary}; - display: flex; - align-items: center; - gap: 4px; - - & svg { - width: 12px; - height: 12px; - } - `, - buildInfo: (theme) => ({ - color: theme.palette.text.primary, - }), -} satisfies Record>; - export interface UserDropdownContentProps { user: TypesGen.User; organizations?: TypesGen.Organization[]; @@ -268,3 +213,58 @@ const includeBuildInfo = ( )}`, ); }; + +const styles = { + info: (theme) => [ + theme.typography.body2 as CSSObject, + { + padding: 20, + }, + ], + userName: { + fontWeight: 600, + }, + userEmail: (theme) => ({ + color: theme.palette.text.secondary, + width: "100%", + textOverflow: "ellipsis", + overflow: "hidden", + }), + link: { + textDecoration: "none", + color: "inherit", + }, + menuItem: (theme) => css` + gap: 20px; + padding: 8px 20px; + + &:hover { + background-color: ${theme.palette.action.hover}; + transition: background-color 0.3s ease; + } + `, + menuItemIcon: (theme) => ({ + color: theme.palette.text.secondary, + width: 20, + height: 20, + }), + menuItemText: { + fontSize: 14, + }, + footerText: (theme) => css` + font-size: 12px; + text-decoration: none; + color: ${theme.palette.text.secondary}; + display: flex; + align-items: center; + gap: 4px; + + & svg { + width: 12px; + height: 12px; + } + `, + buildInfo: (theme) => ({ + color: theme.palette.text.primary, + }), +} satisfies Record>; diff --git a/site/src/modules/navigation.ts b/site/src/modules/navigation.ts new file mode 100644 index 0000000000000..74217a4ceaaac --- /dev/null +++ b/site/src/modules/navigation.ts @@ -0,0 +1,7 @@ +/** + * @fileoverview TODO: centralize navigation code here! URL constants, URL formatting, all of it + */ + +export const USERS_LINK = `/users?filter=${encodeURIComponent( + "status:active", +)}`; diff --git a/site/src/pages/UsersPage/UsersLayout.tsx b/site/src/pages/UsersPage/UsersLayout.tsx index bb85cae1b03b8..8b3dc7858c41e 100644 --- a/site/src/pages/UsersPage/UsersLayout.tsx +++ b/site/src/pages/UsersPage/UsersLayout.tsx @@ -13,8 +13,8 @@ import { Margins } from "components/Margins/Margins"; import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"; import { TAB_PADDING_Y, TabLink, Tabs, TabsList } from "components/Tabs/Tabs"; import { useAuthenticated } from "contexts/auth/RequireAuth"; -import { USERS_LINK } from "modules/dashboard/Navbar/NavbarView"; import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility"; +import { USERS_LINK } from "modules/navigation"; export const UsersLayout: FC = () => { const { permissions } = useAuthenticated();