mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
feat(site): add deployment menu to navbar (#13401)
This commit is contained in:
@@ -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<DeploymentDropdownProps> = ({
|
||||
canViewAuditLog,
|
||||
canViewDeployment,
|
||||
canViewAllUsers,
|
||||
canViewHealth,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
if (
|
||||
!canViewAuditLog &&
|
||||
!canViewDeployment &&
|
||||
!canViewAllUsers &&
|
||||
!canViewHealth
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<Button
|
||||
size="small"
|
||||
endIcon={
|
||||
<DropdownArrow
|
||||
color={theme.experimental.l2.fill.solid}
|
||||
close={false}
|
||||
margin={false}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Deployment
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
horizontal="right"
|
||||
css={{
|
||||
".MuiPaper-root": {
|
||||
minWidth: "auto",
|
||||
width: 180,
|
||||
boxShadow: theme.shadows[6],
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DeploymentDropdownContent
|
||||
canViewAuditLog={canViewAuditLog}
|
||||
canViewDeployment={canViewDeployment}
|
||||
canViewAllUsers={canViewAllUsers}
|
||||
canViewHealth={canViewHealth}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const DeploymentDropdownContent: FC<DeploymentDropdownProps> = ({
|
||||
canViewAuditLog,
|
||||
canViewDeployment,
|
||||
canViewAllUsers,
|
||||
canViewHealth,
|
||||
}) => {
|
||||
const popover = usePopover();
|
||||
|
||||
const onPopoverClose = () => popover.setIsOpen(false);
|
||||
|
||||
return (
|
||||
<nav>
|
||||
{canViewDeployment && (
|
||||
<MenuItem
|
||||
component={NavLink}
|
||||
to="/deployment/general"
|
||||
css={styles.menuItem}
|
||||
onClick={onPopoverClose}
|
||||
>
|
||||
Settings
|
||||
</MenuItem>
|
||||
)}
|
||||
{canViewAllUsers && (
|
||||
<MenuItem
|
||||
component={NavLink}
|
||||
to={USERS_LINK}
|
||||
css={styles.menuItem}
|
||||
onClick={onPopoverClose}
|
||||
>
|
||||
Users
|
||||
</MenuItem>
|
||||
)}
|
||||
{canViewAuditLog && (
|
||||
<MenuItem
|
||||
component={NavLink}
|
||||
to="/audit"
|
||||
css={styles.menuItem}
|
||||
onClick={onPopoverClose}
|
||||
>
|
||||
Auditing
|
||||
</MenuItem>
|
||||
)}
|
||||
{canViewHealth && (
|
||||
<MenuItem
|
||||
component={NavLink}
|
||||
to="/health"
|
||||
css={styles.menuItem}
|
||||
onClick={onPopoverClose}
|
||||
>
|
||||
Healthcheck
|
||||
</MenuItem>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
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<string, Interpolation<Theme>>;
|
||||
@@ -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(<App />);
|
||||
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(<App />);
|
||||
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(<App />);
|
||||
await waitFor(
|
||||
() => {
|
||||
const link = screen.queryByText(Language.audit);
|
||||
const link = screen.queryByText("Deployment");
|
||||
expect(link).toBe(null);
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
|
||||
@@ -10,6 +10,10 @@ const meta: Meta<typeof NavbarView> = {
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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<NavItemsProps> = ({
|
||||
className,
|
||||
canViewAuditLog,
|
||||
canViewDeployment,
|
||||
canViewAllUsers,
|
||||
canViewHealth,
|
||||
}) => {
|
||||
const NavItems: FC<NavItemsProps> = ({ className }) => {
|
||||
const location = useLocation();
|
||||
const theme = useTheme();
|
||||
|
||||
@@ -83,26 +69,6 @@ const NavItems: FC<NavItemsProps> = ({
|
||||
<NavLink css={styles.link} to="/templates">
|
||||
{Language.templates}
|
||||
</NavLink>
|
||||
{canViewAllUsers && (
|
||||
<NavLink css={styles.link} to={USERS_LINK}>
|
||||
{Language.users}
|
||||
</NavLink>
|
||||
)}
|
||||
{canViewAuditLog && (
|
||||
<NavLink css={styles.link} to="/audit">
|
||||
{Language.audit}
|
||||
</NavLink>
|
||||
)}
|
||||
{canViewDeployment && (
|
||||
<NavLink css={styles.link} to="/deployment/general">
|
||||
{Language.deployment}
|
||||
</NavLink>
|
||||
)}
|
||||
{canViewHealth && (
|
||||
<NavLink css={styles.link} to="/health">
|
||||
Health
|
||||
</NavLink>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
@@ -157,12 +123,7 @@ export const NavbarView: FC<NavbarViewProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<NavItems
|
||||
canViewAuditLog={canViewAuditLog}
|
||||
canViewDeployment={canViewDeployment}
|
||||
canViewAllUsers={canViewAllUsers}
|
||||
canViewHealth={canViewHealth}
|
||||
/>
|
||||
<NavItems />
|
||||
</div>
|
||||
</Drawer>
|
||||
|
||||
@@ -174,18 +135,20 @@ export const NavbarView: FC<NavbarViewProps> = ({
|
||||
)}
|
||||
</NavLink>
|
||||
|
||||
<NavItems
|
||||
css={styles.desktopNavItems}
|
||||
canViewAuditLog={canViewAuditLog}
|
||||
canViewDeployment={canViewDeployment}
|
||||
canViewAllUsers={canViewAllUsers}
|
||||
canViewHealth={canViewHealth}
|
||||
/>
|
||||
<NavItems css={styles.desktopNavItems} />
|
||||
|
||||
<div css={styles.navMenus}>
|
||||
{proxyContextValue && (
|
||||
<ProxyMenu proxyContextValue={proxyContextValue} />
|
||||
)}
|
||||
|
||||
<DeploymentDropdown
|
||||
canViewAuditLog={canViewAuditLog}
|
||||
canViewDeployment={canViewDeployment}
|
||||
canViewAllUsers={canViewAllUsers}
|
||||
canViewHealth={canViewHealth}
|
||||
/>
|
||||
|
||||
{user && (
|
||||
<UserDropdown
|
||||
user={user}
|
||||
@@ -260,7 +223,6 @@ const ProxyMenu: FC<ProxyMenuProps> = ({ proxyContextValue }) => {
|
||||
size="small"
|
||||
endIcon={<KeyboardArrowDownOutlined />}
|
||||
css={{
|
||||
borderRadius: "999px",
|
||||
"& .MuiSvgIcon-root": { fontSize: 14 },
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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<string, Interpolation<Theme>>;
|
||||
|
||||
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<string, Interpolation<Theme>>;
|
||||
|
||||
@@ -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",
|
||||
)}`;
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user