mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
refactor(site): hide select helper when only one proxy exists (#13496)
This commit is contained in:
@@ -1,26 +1,16 @@
|
||||
import { css, type Interpolation, type Theme, useTheme } from "@emotion/react";
|
||||
import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined";
|
||||
import MenuIcon from "@mui/icons-material/Menu";
|
||||
import Button from "@mui/material/Button";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import Drawer from "@mui/material/Drawer";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
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, useRef, useState } from "react";
|
||||
import { NavLink, useLocation, useNavigate } from "react-router-dom";
|
||||
import { type FC, useState } from "react";
|
||||
import { NavLink, useLocation } from "react-router-dom";
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import { Abbr } from "components/Abbr/Abbr";
|
||||
import { ExternalImage } from "components/ExternalImage/ExternalImage";
|
||||
import { displayError } from "components/GlobalSnackbar/utils";
|
||||
import { CoderIcon } from "components/Icons/CoderIcon";
|
||||
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 { navHeight } from "theme/constants";
|
||||
import { DeploymentDropdown } from "./DeploymentDropdown";
|
||||
import { ProxyMenu } from "./ProxyMenu";
|
||||
import { UserDropdown } from "./UserDropdown/UserDropdown";
|
||||
|
||||
export interface NavbarViewProps {
|
||||
@@ -163,237 +153,6 @@ export const NavbarView: FC<NavbarViewProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
interface ProxyMenuProps {
|
||||
proxyContextValue: ProxyContextValue;
|
||||
}
|
||||
|
||||
const ProxyMenu: FC<ProxyMenuProps> = ({ proxyContextValue }) => {
|
||||
const theme = useTheme();
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [refetchDate, setRefetchDate] = useState<Date>();
|
||||
const selectedProxy = proxyContextValue.proxy.proxy;
|
||||
const refreshLatencies = proxyContextValue.refetchProxyLatencies;
|
||||
const closeMenu = () => setIsOpen(false);
|
||||
const navigate = useNavigate();
|
||||
const latencies = proxyContextValue.proxyLatencies;
|
||||
const isLoadingLatencies = Object.keys(latencies).length === 0;
|
||||
const isLoading = proxyContextValue.isLoading || isLoadingLatencies;
|
||||
const { permissions } = useAuthenticated();
|
||||
|
||||
const proxyLatencyLoading = (proxy: TypesGen.Region): boolean => {
|
||||
if (!refetchDate) {
|
||||
// Only show loading if the user manually requested a refetch
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only show a loading spinner if:
|
||||
// - A latency exists. This means the latency was fetched at some point, so
|
||||
// the loader *should* be resolved.
|
||||
// - The proxy is healthy. If it is not, the loader might never resolve.
|
||||
// - The latency reported is older than the refetch date. This means the
|
||||
// latency is stale and we should show a loading spinner until the new
|
||||
// latency is fetched.
|
||||
const latency = latencies[proxy.id];
|
||||
return proxy.healthy && latency !== undefined && latency.at < refetchDate;
|
||||
};
|
||||
|
||||
// This endpoint returns a 404 when not using enterprise.
|
||||
// If we don't return null, then it looks like this is
|
||||
// loading forever!
|
||||
if (proxyContextValue.error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Skeleton
|
||||
width="110px"
|
||||
height={BUTTON_SM_HEIGHT}
|
||||
css={{ borderRadius: "9999px", transform: "none" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
ref={buttonRef}
|
||||
onClick={() => setIsOpen(true)}
|
||||
size="small"
|
||||
endIcon={<KeyboardArrowDownOutlined />}
|
||||
css={{
|
||||
"& .MuiSvgIcon-root": { fontSize: 14 },
|
||||
}}
|
||||
>
|
||||
<span css={{ ...visuallyHidden }}>
|
||||
Latency for {selectedProxy?.display_name ?? "your region"}
|
||||
</span>
|
||||
|
||||
{selectedProxy ? (
|
||||
<div css={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
<div css={{ width: 16, height: 16, lineHeight: 0 }}>
|
||||
<img
|
||||
// Empty alt text used because we don't want to double up on
|
||||
// screen reader announcements from visually-hidden span
|
||||
alt=""
|
||||
src={selectedProxy.icon_url}
|
||||
css={{
|
||||
objectFit: "contain",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Latency
|
||||
latency={latencies?.[selectedProxy.id]?.latencyMS}
|
||||
isLoading={proxyLatencyLoading(selectedProxy)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
"Select Proxy"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Menu
|
||||
open={isOpen}
|
||||
anchorEl={buttonRef.current}
|
||||
onClick={closeMenu}
|
||||
onClose={closeMenu}
|
||||
css={{ "& .MuiMenu-paper": { paddingTop: 8, paddingBottom: 8 } }}
|
||||
// autoFocus here does not affect modal focus; it affects whether the
|
||||
// first item in the list will get auto-focus when the menu opens. Have
|
||||
// to turn this off because otherwise, screen readers will skip over all
|
||||
// the descriptive text and will only have access to the latency options
|
||||
autoFocus={false}
|
||||
>
|
||||
<div
|
||||
css={{
|
||||
width: "100%",
|
||||
maxWidth: "320px",
|
||||
fontSize: 14,
|
||||
padding: 16,
|
||||
lineHeight: "140%",
|
||||
}}
|
||||
>
|
||||
<h4
|
||||
autoFocus
|
||||
tabIndex={-1}
|
||||
css={{
|
||||
fontSize: "inherit",
|
||||
fontWeight: 600,
|
||||
lineHeight: "inherit",
|
||||
margin: 0,
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
Select a region nearest to you
|
||||
</h4>
|
||||
|
||||
<p
|
||||
css={{
|
||||
fontSize: 13,
|
||||
color: theme.palette.text.secondary,
|
||||
lineHeight: "inherit",
|
||||
marginTop: 0.5,
|
||||
}}
|
||||
>
|
||||
Workspace proxies improve terminal and web app connections to
|
||||
workspaces. This does not apply to{" "}
|
||||
<Abbr title="Command-Line Interface" pronunciation="initialism">
|
||||
CLI
|
||||
</Abbr>{" "}
|
||||
connections. A region must be manually selected, otherwise the
|
||||
default primary region will be used.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Divider css={{ borderColor: theme.palette.divider }} />
|
||||
|
||||
{proxyContextValue.proxies &&
|
||||
[...proxyContextValue.proxies]
|
||||
.sort((a, b) => {
|
||||
const latencyA = latencies?.[a.id]?.latencyMS ?? Infinity;
|
||||
const latencyB = latencies?.[b.id]?.latencyMS ?? Infinity;
|
||||
return latencyA - latencyB;
|
||||
})
|
||||
.map((proxy) => (
|
||||
<MenuItem
|
||||
key={proxy.id}
|
||||
selected={proxy.id === selectedProxy?.id}
|
||||
css={{ fontSize: 14 }}
|
||||
onClick={() => {
|
||||
if (!proxy.healthy) {
|
||||
displayError("Please select a healthy workspace proxy.");
|
||||
closeMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
proxyContextValue.setProxy(proxy);
|
||||
closeMenu();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
css={{
|
||||
display: "flex",
|
||||
gap: 24,
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<div css={{ width: 14, height: 14, lineHeight: 0 }}>
|
||||
<img
|
||||
src={proxy.icon_url}
|
||||
alt=""
|
||||
css={{
|
||||
objectFit: "contain",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{proxy.display_name}
|
||||
|
||||
<Latency
|
||||
latency={latencies?.[proxy.id]?.latencyMS}
|
||||
isLoading={proxyLatencyLoading(proxy)}
|
||||
/>
|
||||
</div>
|
||||
</MenuItem>
|
||||
))}
|
||||
|
||||
<Divider css={{ borderColor: theme.palette.divider }} />
|
||||
|
||||
{Boolean(permissions.editWorkspaceProxies) && (
|
||||
<MenuItem
|
||||
css={{ fontSize: 14 }}
|
||||
onClick={() => {
|
||||
navigate("/deployment/workspace-proxies");
|
||||
}}
|
||||
>
|
||||
Proxy settings
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
<MenuItem
|
||||
css={{ fontSize: 14 }}
|
||||
onClick={(e) => {
|
||||
// Stop the menu from closing
|
||||
e.stopPropagation();
|
||||
// Refresh the latencies.
|
||||
const refetchDate = refreshLatencies();
|
||||
setRefetchDate(refetchDate);
|
||||
}}
|
||||
>
|
||||
Refresh Latencies
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
desktopNavItems: (theme) => css`
|
||||
display: none;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { fn, userEvent, within } from "@storybook/test";
|
||||
import { getAuthorizationKey } from "api/queries/authCheck";
|
||||
import { AuthProvider } from "contexts/auth/AuthProvider";
|
||||
import { permissionsToCheck } from "contexts/auth/permissions";
|
||||
import { getPreferredProxy } from "contexts/ProxyContext";
|
||||
import {
|
||||
MockAuthMethodsAll,
|
||||
MockPermissions,
|
||||
MockProxyLatencies,
|
||||
MockUser,
|
||||
MockWorkspaceProxies,
|
||||
} from "testHelpers/entities";
|
||||
import { ProxyMenu } from "./ProxyMenu";
|
||||
|
||||
const defaultProxyContextValue = {
|
||||
proxyLatencies: MockProxyLatencies,
|
||||
proxy: getPreferredProxy(MockWorkspaceProxies, undefined),
|
||||
proxies: MockWorkspaceProxies,
|
||||
isLoading: false,
|
||||
isFetched: true,
|
||||
setProxy: fn(),
|
||||
clearProxy: fn(),
|
||||
refetchProxyLatencies: () => new Date(),
|
||||
};
|
||||
|
||||
const meta: Meta<typeof ProxyMenu> = {
|
||||
title: "modules/dashboard/ProxyMenu",
|
||||
component: ProxyMenu,
|
||||
args: {
|
||||
proxyContextValue: defaultProxyContextValue,
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<AuthProvider>
|
||||
<Story />
|
||||
</AuthProvider>
|
||||
),
|
||||
(Story) => (
|
||||
<div css={{ width: 1200, height: 800 }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
parameters: {
|
||||
queries: [
|
||||
{ key: ["me"], data: MockUser },
|
||||
{ key: ["authMethods"], data: MockAuthMethodsAll },
|
||||
{ key: ["hasFirstUser"], data: true },
|
||||
{
|
||||
key: getAuthorizationKey({ checks: permissionsToCheck }),
|
||||
data: MockPermissions,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ProxyMenu>;
|
||||
|
||||
export const Closed: Story = {};
|
||||
|
||||
export const Opened: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button"));
|
||||
},
|
||||
};
|
||||
|
||||
export const SingleProxy: Story = {
|
||||
args: {
|
||||
proxyContextValue: {
|
||||
...defaultProxyContextValue,
|
||||
proxies: [MockWorkspaceProxies[0]],
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button"));
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,252 @@
|
||||
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";
|
||||
import Skeleton from "@mui/material/Skeleton";
|
||||
import { visuallyHidden } from "@mui/utils";
|
||||
import { type FC, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import { Abbr } from "components/Abbr/Abbr";
|
||||
import { displayError } from "components/GlobalSnackbar/utils";
|
||||
import { Latency } from "components/Latency/Latency";
|
||||
import { useAuthenticated } from "contexts/auth/RequireAuth";
|
||||
import type { ProxyContextValue } from "contexts/ProxyContext";
|
||||
import { BUTTON_SM_HEIGHT } from "theme/constants";
|
||||
|
||||
interface ProxyMenuProps {
|
||||
proxyContextValue: ProxyContextValue;
|
||||
}
|
||||
|
||||
export const ProxyMenu: FC<ProxyMenuProps> = ({ proxyContextValue }) => {
|
||||
const theme = useTheme();
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [refetchDate, setRefetchDate] = useState<Date>();
|
||||
const selectedProxy = proxyContextValue.proxy.proxy;
|
||||
const refreshLatencies = proxyContextValue.refetchProxyLatencies;
|
||||
const closeMenu = () => setIsOpen(false);
|
||||
const navigate = useNavigate();
|
||||
const latencies = proxyContextValue.proxyLatencies;
|
||||
const isLoadingLatencies = Object.keys(latencies).length === 0;
|
||||
const isLoading = proxyContextValue.isLoading || isLoadingLatencies;
|
||||
const { permissions } = useAuthenticated();
|
||||
|
||||
const proxyLatencyLoading = (proxy: TypesGen.Region): boolean => {
|
||||
if (!refetchDate) {
|
||||
// Only show loading if the user manually requested a refetch
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only show a loading spinner if:
|
||||
// - A latency exists. This means the latency was fetched at some point, so
|
||||
// the loader *should* be resolved.
|
||||
// - The proxy is healthy. If it is not, the loader might never resolve.
|
||||
// - The latency reported is older than the refetch date. This means the
|
||||
// latency is stale and we should show a loading spinner until the new
|
||||
// latency is fetched.
|
||||
const latency = latencies[proxy.id];
|
||||
return proxy.healthy && latency !== undefined && latency.at < refetchDate;
|
||||
};
|
||||
|
||||
// This endpoint returns a 404 when not using enterprise.
|
||||
// If we don't return null, then it looks like this is
|
||||
// loading forever!
|
||||
if (proxyContextValue.error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Skeleton
|
||||
width="110px"
|
||||
height={BUTTON_SM_HEIGHT}
|
||||
css={{ borderRadius: 6, transform: "none" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
ref={buttonRef}
|
||||
onClick={() => setIsOpen(true)}
|
||||
size="small"
|
||||
endIcon={<KeyboardArrowDownOutlined />}
|
||||
css={{
|
||||
"& .MuiSvgIcon-root": { fontSize: 14 },
|
||||
}}
|
||||
>
|
||||
<span css={{ ...visuallyHidden }}>
|
||||
Latency for {selectedProxy?.display_name ?? "your region"}
|
||||
</span>
|
||||
|
||||
{selectedProxy ? (
|
||||
<div css={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
<div css={{ width: 16, height: 16, lineHeight: 0 }}>
|
||||
<img
|
||||
// Empty alt text used because we don't want to double up on
|
||||
// screen reader announcements from visually-hidden span
|
||||
alt=""
|
||||
src={selectedProxy.icon_url}
|
||||
css={{
|
||||
objectFit: "contain",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Latency
|
||||
latency={latencies?.[selectedProxy.id]?.latencyMS}
|
||||
isLoading={proxyLatencyLoading(selectedProxy)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
"Select Proxy"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Menu
|
||||
open={isOpen}
|
||||
anchorEl={buttonRef.current}
|
||||
onClick={closeMenu}
|
||||
onClose={closeMenu}
|
||||
css={{ "& .MuiMenu-paper": { paddingTop: 8, paddingBottom: 8 } }}
|
||||
// autoFocus here does not affect modal focus; it affects whether the
|
||||
// first item in the list will get auto-focus when the menu opens. Have
|
||||
// to turn this off because otherwise, screen readers will skip over all
|
||||
// the descriptive text and will only have access to the latency options
|
||||
autoFocus={false}
|
||||
>
|
||||
{proxyContextValue.proxies && proxyContextValue.proxies.length > 1 && (
|
||||
<>
|
||||
<div
|
||||
css={{
|
||||
width: "100%",
|
||||
maxWidth: "320px",
|
||||
fontSize: 14,
|
||||
padding: 16,
|
||||
lineHeight: "140%",
|
||||
}}
|
||||
>
|
||||
<h4
|
||||
autoFocus
|
||||
tabIndex={-1}
|
||||
css={{
|
||||
fontSize: "inherit",
|
||||
fontWeight: 600,
|
||||
lineHeight: "inherit",
|
||||
margin: 0,
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
Select a region nearest to you
|
||||
</h4>
|
||||
|
||||
<p
|
||||
css={{
|
||||
fontSize: 13,
|
||||
color: theme.palette.text.secondary,
|
||||
lineHeight: "inherit",
|
||||
marginTop: 0.5,
|
||||
}}
|
||||
>
|
||||
Workspace proxies improve terminal and web app connections to
|
||||
workspaces. This does not apply to{" "}
|
||||
<Abbr title="Command-Line Interface" pronunciation="initialism">
|
||||
CLI
|
||||
</Abbr>{" "}
|
||||
connections. A region must be manually selected, otherwise the
|
||||
default primary region will be used.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
{proxyContextValue.proxies &&
|
||||
[...proxyContextValue.proxies]
|
||||
.sort((a, b) => {
|
||||
const latencyA = latencies?.[a.id]?.latencyMS ?? Infinity;
|
||||
const latencyB = latencies?.[b.id]?.latencyMS ?? Infinity;
|
||||
return latencyA - latencyB;
|
||||
})
|
||||
.map((proxy) => (
|
||||
<MenuItem
|
||||
key={proxy.id}
|
||||
selected={proxy.id === selectedProxy?.id}
|
||||
css={{ fontSize: 14 }}
|
||||
onClick={() => {
|
||||
if (!proxy.healthy) {
|
||||
displayError("Please select a healthy workspace proxy.");
|
||||
closeMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
proxyContextValue.setProxy(proxy);
|
||||
closeMenu();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
css={{
|
||||
display: "flex",
|
||||
gap: 24,
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<div css={{ width: 14, height: 14, lineHeight: 0 }}>
|
||||
<img
|
||||
src={proxy.icon_url}
|
||||
alt=""
|
||||
css={{
|
||||
objectFit: "contain",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{proxy.display_name}
|
||||
|
||||
<Latency
|
||||
latency={latencies?.[proxy.id]?.latencyMS}
|
||||
isLoading={proxyLatencyLoading(proxy)}
|
||||
/>
|
||||
</div>
|
||||
</MenuItem>
|
||||
))}
|
||||
|
||||
<Divider />
|
||||
|
||||
{Boolean(permissions.editWorkspaceProxies) && (
|
||||
<MenuItem
|
||||
css={{ fontSize: 14 }}
|
||||
onClick={() => {
|
||||
navigate("/deployment/workspace-proxies");
|
||||
}}
|
||||
>
|
||||
Proxy settings
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
<MenuItem
|
||||
css={{ fontSize: 14 }}
|
||||
onClick={(e) => {
|
||||
// Stop the menu from closing
|
||||
e.stopPropagation();
|
||||
// Refresh the latencies.
|
||||
const refetchDate = refreshLatencies();
|
||||
setRefetchDate(refetchDate);
|
||||
}}
|
||||
>
|
||||
Refresh Latencies
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user