Skip to content

Commit 4dfa901

Browse files
refactor(site): hide select helper when only one proxy exists (coder#13496)
1 parent a8a81a6 commit 4dfa901

File tree

3 files changed

+337
-245
lines changed

3 files changed

+337
-245
lines changed

site/src/modules/dashboard/Navbar/NavbarView.tsx

Lines changed: 4 additions & 245 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,16 @@
11
import { css, type Interpolation, type Theme, useTheme } from "@emotion/react";
2-
import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined";
32
import MenuIcon from "@mui/icons-material/Menu";
4-
import Button from "@mui/material/Button";
5-
import Divider from "@mui/material/Divider";
63
import Drawer from "@mui/material/Drawer";
74
import IconButton from "@mui/material/IconButton";
8-
import Menu from "@mui/material/Menu";
9-
import MenuItem from "@mui/material/MenuItem";
10-
import Skeleton from "@mui/material/Skeleton";
11-
import { visuallyHidden } from "@mui/utils";
12-
import { type FC, useRef, useState } from "react";
13-
import { NavLink, useLocation, useNavigate } from "react-router-dom";
5+
import { type FC, useState } from "react";
6+
import { NavLink, useLocation } from "react-router-dom";
147
import type * as TypesGen from "api/typesGenerated";
15-
import { Abbr } from "components/Abbr/Abbr";
168
import { ExternalImage } from "components/ExternalImage/ExternalImage";
17-
import { displayError } from "components/GlobalSnackbar/utils";
189
import { CoderIcon } from "components/Icons/CoderIcon";
19-
import { Latency } from "components/Latency/Latency";
20-
import { useAuthenticated } from "contexts/auth/RequireAuth";
2110
import type { ProxyContextValue } from "contexts/ProxyContext";
22-
import { BUTTON_SM_HEIGHT, navHeight } from "theme/constants";
11+
import { navHeight } from "theme/constants";
2312
import { DeploymentDropdown } from "./DeploymentDropdown";
13+
import { ProxyMenu } from "./ProxyMenu";
2414
import { UserDropdown } from "./UserDropdown/UserDropdown";
2515

2616
export interface NavbarViewProps {
@@ -163,237 +153,6 @@ export const NavbarView: FC<NavbarViewProps> = ({
163153
);
164154
};
165155

166-
interface ProxyMenuProps {
167-
proxyContextValue: ProxyContextValue;
168-
}
169-
170-
const ProxyMenu: FC<ProxyMenuProps> = ({ proxyContextValue }) => {
171-
const theme = useTheme();
172-
const buttonRef = useRef<HTMLButtonElement>(null);
173-
const [isOpen, setIsOpen] = useState(false);
174-
const [refetchDate, setRefetchDate] = useState<Date>();
175-
const selectedProxy = proxyContextValue.proxy.proxy;
176-
const refreshLatencies = proxyContextValue.refetchProxyLatencies;
177-
const closeMenu = () => setIsOpen(false);
178-
const navigate = useNavigate();
179-
const latencies = proxyContextValue.proxyLatencies;
180-
const isLoadingLatencies = Object.keys(latencies).length === 0;
181-
const isLoading = proxyContextValue.isLoading || isLoadingLatencies;
182-
const { permissions } = useAuthenticated();
183-
184-
const proxyLatencyLoading = (proxy: TypesGen.Region): boolean => {
185-
if (!refetchDate) {
186-
// Only show loading if the user manually requested a refetch
187-
return false;
188-
}
189-
190-
// Only show a loading spinner if:
191-
// - A latency exists. This means the latency was fetched at some point, so
192-
// the loader *should* be resolved.
193-
// - The proxy is healthy. If it is not, the loader might never resolve.
194-
// - The latency reported is older than the refetch date. This means the
195-
// latency is stale and we should show a loading spinner until the new
196-
// latency is fetched.
197-
const latency = latencies[proxy.id];
198-
return proxy.healthy && latency !== undefined && latency.at < refetchDate;
199-
};
200-
201-
// This endpoint returns a 404 when not using enterprise.
202-
// If we don't return null, then it looks like this is
203-
// loading forever!
204-
if (proxyContextValue.error) {
205-
return null;
206-
}
207-
208-
if (isLoading) {
209-
return (
210-
<Skeleton
211-
width="110px"
212-
height={BUTTON_SM_HEIGHT}
213-
css={{ borderRadius: "9999px", transform: "none" }}
214-
/>
215-
);
216-
}
217-
218-
return (
219-
<>
220-
<Button
221-
ref={buttonRef}
222-
onClick={() => setIsOpen(true)}
223-
size="small"
224-
endIcon={<KeyboardArrowDownOutlined />}
225-
css={{
226-
"& .MuiSvgIcon-root": { fontSize: 14 },
227-
}}
228-
>
229-
<span css={{ ...visuallyHidden }}>
230-
Latency for {selectedProxy?.display_name ?? "your region"}
231-
</span>
232-
233-
{selectedProxy ? (
234-
<div css={{ display: "flex", gap: 8, alignItems: "center" }}>
235-
<div css={{ width: 16, height: 16, lineHeight: 0 }}>
236-
<img
237-
// Empty alt text used because we don't want to double up on
238-
// screen reader announcements from visually-hidden span
239-
alt=""
240-
src={selectedProxy.icon_url}
241-
css={{
242-
objectFit: "contain",
243-
width: "100%",
244-
height: "100%",
245-
}}
246-
/>
247-
</div>
248-
249-
<Latency
250-
latency={latencies?.[selectedProxy.id]?.latencyMS}
251-
isLoading={proxyLatencyLoading(selectedProxy)}
252-
/>
253-
</div>
254-
) : (
255-
"Select Proxy"
256-
)}
257-
</Button>
258-
259-
<Menu
260-
open={isOpen}
261-
anchorEl={buttonRef.current}
262-
onClick={closeMenu}
263-
onClose={closeMenu}
264-
css={{ "& .MuiMenu-paper": { paddingTop: 8, paddingBottom: 8 } }}
265-
// autoFocus here does not affect modal focus; it affects whether the
266-
// first item in the list will get auto-focus when the menu opens. Have
267-
// to turn this off because otherwise, screen readers will skip over all
268-
// the descriptive text and will only have access to the latency options
269-
autoFocus={false}
270-
>
271-
<div
272-
css={{
273-
width: "100%",
274-
maxWidth: "320px",
275-
fontSize: 14,
276-
padding: 16,
277-
lineHeight: "140%",
278-
}}
279-
>
280-
<h4
281-
autoFocus
282-
tabIndex={-1}
283-
css={{
284-
fontSize: "inherit",
285-
fontWeight: 600,
286-
lineHeight: "inherit",
287-
margin: 0,
288-
marginBottom: 4,
289-
}}
290-
>
291-
Select a region nearest to you
292-
</h4>
293-
294-
<p
295-
css={{
296-
fontSize: 13,
297-
color: theme.palette.text.secondary,
298-
lineHeight: "inherit",
299-
marginTop: 0.5,
300-
}}
301-
>
302-
Workspace proxies improve terminal and web app connections to
303-
workspaces. This does not apply to{" "}
304-
<Abbr title="Command-Line Interface" pronunciation="initialism">
305-
CLI
306-
</Abbr>{" "}
307-
connections. A region must be manually selected, otherwise the
308-
default primary region will be used.
309-
</p>
310-
</div>
311-
312-
<Divider css={{ borderColor: theme.palette.divider }} />
313-
314-
{proxyContextValue.proxies &&
315-
[...proxyContextValue.proxies]
316-
.sort((a, b) => {
317-
const latencyA = latencies?.[a.id]?.latencyMS ?? Infinity;
318-
const latencyB = latencies?.[b.id]?.latencyMS ?? Infinity;
319-
return latencyA - latencyB;
320-
})
321-
.map((proxy) => (
322-
<MenuItem
323-
key={proxy.id}
324-
selected={proxy.id === selectedProxy?.id}
325-
css={{ fontSize: 14 }}
326-
onClick={() => {
327-
if (!proxy.healthy) {
328-
displayError("Please select a healthy workspace proxy.");
329-
closeMenu();
330-
return;
331-
}
332-
333-
proxyContextValue.setProxy(proxy);
334-
closeMenu();
335-
}}
336-
>
337-
<div
338-
css={{
339-
display: "flex",
340-
gap: 24,
341-
alignItems: "center",
342-
width: "100%",
343-
}}
344-
>
345-
<div css={{ width: 14, height: 14, lineHeight: 0 }}>
346-
<img
347-
src={proxy.icon_url}
348-
alt=""
349-
css={{
350-
objectFit: "contain",
351-
width: "100%",
352-
height: "100%",
353-
}}
354-
/>
355-
</div>
356-
357-
{proxy.display_name}
358-
359-
<Latency
360-
latency={latencies?.[proxy.id]?.latencyMS}
361-
isLoading={proxyLatencyLoading(proxy)}
362-
/>
363-
</div>
364-
</MenuItem>
365-
))}
366-
367-
<Divider css={{ borderColor: theme.palette.divider }} />
368-
369-
{Boolean(permissions.editWorkspaceProxies) && (
370-
<MenuItem
371-
css={{ fontSize: 14 }}
372-
onClick={() => {
373-
navigate("/deployment/workspace-proxies");
374-
}}
375-
>
376-
Proxy settings
377-
</MenuItem>
378-
)}
379-
380-
<MenuItem
381-
css={{ fontSize: 14 }}
382-
onClick={(e) => {
383-
// Stop the menu from closing
384-
e.stopPropagation();
385-
// Refresh the latencies.
386-
const refetchDate = refreshLatencies();
387-
setRefetchDate(refetchDate);
388-
}}
389-
>
390-
Refresh Latencies
391-
</MenuItem>
392-
</Menu>
393-
</>
394-
);
395-
};
396-
397156
const styles = {
398157
desktopNavItems: (theme) => css`
399158
display: none;
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { fn, userEvent, within } from "@storybook/test";
3+
import { getAuthorizationKey } from "api/queries/authCheck";
4+
import { AuthProvider } from "contexts/auth/AuthProvider";
5+
import { permissionsToCheck } from "contexts/auth/permissions";
6+
import { getPreferredProxy } from "contexts/ProxyContext";
7+
import {
8+
MockAuthMethodsAll,
9+
MockPermissions,
10+
MockProxyLatencies,
11+
MockUser,
12+
MockWorkspaceProxies,
13+
} from "testHelpers/entities";
14+
import { ProxyMenu } from "./ProxyMenu";
15+
16+
const defaultProxyContextValue = {
17+
proxyLatencies: MockProxyLatencies,
18+
proxy: getPreferredProxy(MockWorkspaceProxies, undefined),
19+
proxies: MockWorkspaceProxies,
20+
isLoading: false,
21+
isFetched: true,
22+
setProxy: fn(),
23+
clearProxy: fn(),
24+
refetchProxyLatencies: () => new Date(),
25+
};
26+
27+
const meta: Meta<typeof ProxyMenu> = {
28+
title: "modules/dashboard/ProxyMenu",
29+
component: ProxyMenu,
30+
args: {
31+
proxyContextValue: defaultProxyContextValue,
32+
},
33+
decorators: [
34+
(Story) => (
35+
<AuthProvider>
36+
<Story />
37+
</AuthProvider>
38+
),
39+
(Story) => (
40+
<div css={{ width: 1200, height: 800 }}>
41+
<Story />
42+
</div>
43+
),
44+
],
45+
parameters: {
46+
queries: [
47+
{ key: ["me"], data: MockUser },
48+
{ key: ["authMethods"], data: MockAuthMethodsAll },
49+
{ key: ["hasFirstUser"], data: true },
50+
{
51+
key: getAuthorizationKey({ checks: permissionsToCheck }),
52+
data: MockPermissions,
53+
},
54+
],
55+
},
56+
};
57+
58+
export default meta;
59+
type Story = StoryObj<typeof ProxyMenu>;
60+
61+
export const Closed: Story = {};
62+
63+
export const Opened: Story = {
64+
play: async ({ canvasElement }) => {
65+
const canvas = within(canvasElement);
66+
await userEvent.click(canvas.getByRole("button"));
67+
},
68+
};
69+
70+
export const SingleProxy: Story = {
71+
args: {
72+
proxyContextValue: {
73+
...defaultProxyContextValue,
74+
proxies: [MockWorkspaceProxies[0]],
75+
},
76+
},
77+
play: async ({ canvasElement }) => {
78+
const canvas = within(canvasElement);
79+
await userEvent.click(canvas.getByRole("button"));
80+
},
81+
};

0 commit comments

Comments
 (0)