chore(site): add custom popover component (#10319)

This commit is contained in:
Bruno Quaresma
2023-10-19 09:13:21 -03:00
committed by GitHub
parent b8c7b56fda
commit f677c4470b
22 changed files with 793 additions and 1019 deletions
+4
View File
@@ -135,6 +135,10 @@ rules:
message:
"You should use the Alert component provided on
components/Alert/Alert"
- name: "@mui/material/Popover"
message:
"You should use the Popover component provided on
components/Popover/Popover"
no-unused-vars: "off"
"object-curly-spacing": "off"
react-hooks/exhaustive-deps: warn
@@ -1,30 +0,0 @@
import { css } from "@emotion/css";
import { useTheme } from "@emotion/react";
import Popover, { type PopoverProps } from "@mui/material/Popover";
import type { FC, PropsWithChildren } from "react";
type BorderedMenuVariant = "user-dropdown";
export type BorderedMenuProps = Omit<PopoverProps, "variant"> & {
variant?: BorderedMenuVariant;
};
export const BorderedMenu: FC<PropsWithChildren<BorderedMenuProps>> = ({
children,
variant,
...rest
}) => {
const theme = useTheme();
const paper = css`
width: 260px;
border-radius: ${theme.shape.borderRadius}px;
box-shadow: ${theme.shadows[6]};
`;
return (
<Popover classes={{ paper }} data-variant={variant} {...rest}>
{children}
</Popover>
);
};
@@ -1,4 +1,4 @@
import { MockUser } from "testHelpers/entities";
import { MockBuildInfo, MockUser } from "testHelpers/entities";
import { UserDropdown } from "./UserDropdown";
import type { Meta, StoryObj } from "@storybook/react";
@@ -7,6 +7,13 @@ const meta: Meta<typeof UserDropdown> = {
component: UserDropdown,
args: {
user: MockUser,
isDefaultOpen: true,
buildInfo: MockBuildInfo,
supportLinks: [
{ icon: "docs", name: "Documentation", target: "" },
{ icon: "bug", name: "Report a bug", target: "" },
{ icon: "chat", name: "Join the Coder Discord", target: "" },
],
},
};
@@ -1,21 +1,25 @@
import Badge from "@mui/material/Badge";
import MenuItem from "@mui/material/MenuItem";
import { useState, FC, PropsWithChildren, MouseEvent } from "react";
import { FC, PropsWithChildren } from "react";
import { colors } from "theme/colors";
import * as TypesGen from "api/typesGenerated";
import { navHeight } from "theme/constants";
import { BorderedMenu } from "./BorderedMenu";
import { DropdownArrow } from "components/DropdownArrow/DropdownArrow";
import { UserAvatar } from "components/UserAvatar/UserAvatar";
import { UserDropdownContent } from "./UserDropdownContent";
import { BUTTON_SM_HEIGHT } from "theme/theme";
import { css } from "@emotion/react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "components/Popover/Popover";
export interface UserDropdownProps {
user: TypesGen.User;
buildInfo?: TypesGen.BuildInfoResponse;
supportLinks?: TypesGen.LinkConfig[];
onSignOut: () => void;
isDefaultOpen?: boolean;
}
export const UserDropdown: FC<PropsWithChildren<UserDropdownProps>> = ({
@@ -23,76 +27,69 @@ export const UserDropdown: FC<PropsWithChildren<UserDropdownProps>> = ({
user,
supportLinks,
onSignOut,
isDefaultOpen,
}: UserDropdownProps) => {
const [anchorEl, setAnchorEl] = useState<HTMLElement | undefined>();
const handleDropdownClick = (ev: MouseEvent<HTMLLIElement>): void => {
setAnchorEl(ev.currentTarget);
};
const onPopoverClose = () => {
setAnchorEl(undefined);
};
return (
<>
<MenuItem
css={(theme) => css`
height: ${navHeight}px;
padding: ${theme.spacing(1.5, 0)};
<Popover isDefaultOpen={isDefaultOpen}>
{(popover) => (
<>
<PopoverTrigger>
<button
css={(theme) => css`
background: none;
border: 0;
cursor: pointer;
height: ${navHeight}px;
padding: ${theme.spacing(1.5, 0)};
&:hover {
background-color: transparent;
}
`}
onClick={handleDropdownClick}
data-testid="user-dropdown-trigger"
>
<div
css={{
display: "flex",
alignItems: "center",
minWidth: 0,
maxWidth: 300,
}}
>
<Badge overlap="circular">
<UserAvatar
sx={{
width: BUTTON_SM_HEIGHT,
height: BUTTON_SM_HEIGHT,
fontSize: 16,
}}
username={user.username}
avatarURL={user.avatar_url}
&:hover {
background-color: transparent;
}
`}
data-testid="user-dropdown-trigger"
>
<div
css={{
display: "flex",
alignItems: "center",
minWidth: 0,
maxWidth: 300,
}}
>
<Badge overlap="circular">
<UserAvatar
sx={{
width: BUTTON_SM_HEIGHT,
height: BUTTON_SM_HEIGHT,
fontSize: 16,
}}
username={user.username}
avatarURL={user.avatar_url}
/>
</Badge>
<DropdownArrow color={colors.gray[6]} close={popover.isOpen} />
</div>
</button>
</PopoverTrigger>
<PopoverContent
horizontal="right"
css={(theme) => ({
".MuiPaper-root": {
width: 260,
boxShadow: theme.shadows[6],
},
})}
>
<UserDropdownContent
user={user}
buildInfo={buildInfo}
supportLinks={supportLinks}
onSignOut={onSignOut}
/>
</Badge>
<DropdownArrow color={colors.gray[6]} close={Boolean(anchorEl)} />
</div>
</MenuItem>
<BorderedMenu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
anchorOrigin={{
vertical: "bottom",
horizontal: "right",
}}
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
marginThreshold={0}
variant="user-dropdown"
onClose={onPopoverClose}
>
<UserDropdownContent
user={user}
buildInfo={buildInfo}
supportLinks={supportLinks}
onPopoverClose={onPopoverClose}
onSignOut={onSignOut}
/>
</BorderedMenu>
</>
</PopoverContent>
</>
)}
</Popover>
);
};
@@ -1,42 +0,0 @@
import { MockUser } from "testHelpers/entities";
import { UserDropdownContent } from "./UserDropdownContent";
import type { Meta, StoryObj } from "@storybook/react";
const meta: Meta<typeof UserDropdownContent> = {
title: "components/UserDropdownContent",
component: UserDropdownContent,
};
export default meta;
type Story = StoryObj<typeof UserDropdownContent>;
export const ExampleNoRoles: Story = {
args: {
user: {
...MockUser,
roles: [],
},
},
};
export const ExampleOneRole: Story = {
args: {
user: {
...MockUser,
roles: [{ name: "member", display_name: "Member" }],
},
},
};
export const ExampleThreeRoles: Story = {
args: {
user: {
...MockUser,
roles: [
{ name: "admin", display_name: "Admin" },
{ name: "member", display_name: "Member" },
{ name: "auditor", display_name: "Auditor" },
],
},
},
};
@@ -2,15 +2,14 @@ import { screen } from "@testing-library/react";
import { MockUser } from "testHelpers/entities";
import { render, waitForLoaderToBeRemoved } from "testHelpers/renderHelpers";
import { Language, UserDropdownContent } from "./UserDropdownContent";
import { Popover } from "components/Popover/Popover";
describe("UserDropdownContent", () => {
it("has the correct link for the account item", async () => {
render(
<UserDropdownContent
user={MockUser}
onSignOut={jest.fn()}
onPopoverClose={jest.fn()}
/>,
<Popover>
<UserDropdownContent user={MockUser} onSignOut={jest.fn()} />
</Popover>,
);
await waitForLoaderToBeRemoved();
@@ -25,11 +24,9 @@ describe("UserDropdownContent", () => {
it("calls the onSignOut function", async () => {
const onSignOut = jest.fn();
render(
<UserDropdownContent
user={MockUser}
onSignOut={onSignOut}
onPopoverClose={jest.fn()}
/>,
<Popover>
<UserDropdownContent user={MockUser} onSignOut={onSignOut} />
</Popover>,
);
await waitForLoaderToBeRemoved();
screen.getByText(Language.signOutLabel).click();
@@ -16,6 +16,7 @@ import {
type Interpolation,
type Theme,
} from "@emotion/react";
import { usePopover } from "components/Popover/Popover";
export const Language = {
accountLabel: "Account",
@@ -82,7 +83,6 @@ export interface UserDropdownContentProps {
user: TypesGen.User;
buildInfo?: TypesGen.BuildInfoResponse;
supportLinks?: TypesGen.LinkConfig[];
onPopoverClose: () => void;
onSignOut: () => void;
}
@@ -90,9 +90,14 @@ export const UserDropdownContent: FC<UserDropdownContentProps> = ({
buildInfo,
user,
supportLinks,
onPopoverClose,
onSignOut,
}) => {
const popover = usePopover();
const onPopoverClose = () => {
popover.setIsOpen(false);
};
return (
<div>
<Stack css={styles.info} spacing={0}>
@@ -1,4 +1,6 @@
import Link from "@mui/material/Link";
// This is used as base for the main HelpTooltip component
// eslint-disable-next-line no-restricted-imports -- Read above
import Popover, { PopoverProps } from "@mui/material/Popover";
import HelpIcon from "@mui/icons-material/HelpOutline";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
+32 -34
View File
@@ -1,15 +1,19 @@
import Button from "@mui/material/Button";
import InputAdornment from "@mui/material/InputAdornment";
import Popover from "@mui/material/Popover";
import TextField, { TextFieldProps } from "@mui/material/TextField";
import { makeStyles } from "@mui/styles";
import Picker from "@emoji-mart/react";
import { useRef, FC, useState } from "react";
import { FC } from "react";
import { DropdownArrow } from "components/DropdownArrow/DropdownArrow";
import { Stack } from "components/Stack/Stack";
import { colors } from "theme/colors";
import data from "@emoji-mart/data/sets/14/twitter.json";
import icons from "theme/icons.json";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "components/Popover/Popover";
// See: https://github.com/missive/emoji-mart/issues/51#issuecomment-287353222
const urlFromUnifiedCode = (unified: string) =>
@@ -45,8 +49,6 @@ const IconField: FC<IconFieldProps> = ({ onPickEmoji, ...textFieldProps }) => {
}
const styles = useStyles();
const emojiButtonRef = useRef<HTMLButtonElement>(null);
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false);
const hasIcon = textFieldProps.value && textFieldProps.value !== "";
return (
@@ -71,36 +73,32 @@ const IconField: FC<IconFieldProps> = ({ onPickEmoji, ...textFieldProps }) => {
}}
/>
<Button
fullWidth
ref={emojiButtonRef}
endIcon={<DropdownArrow />}
onClick={() => {
setIsEmojiPickerOpen((v) => !v);
}}
>
Select emoji
</Button>
<Popover
id="emoji"
open={isEmojiPickerOpen}
anchorEl={emojiButtonRef.current}
onClose={() => {
setIsEmojiPickerOpen(false);
}}
>
<Picker
set="twitter"
theme="dark"
data={data}
custom={custom}
onEmojiSelect={(emoji) => {
const value = emoji.src ?? urlFromUnifiedCode(emoji.unified);
onPickEmoji(value);
setIsEmojiPickerOpen(false);
}}
/>
<Popover>
{(popover) => (
<>
<PopoverTrigger>
<Button fullWidth endIcon={<DropdownArrow />}>
Select emoji
</Button>
</PopoverTrigger>
<PopoverContent
id="emoji"
css={{ marginTop: 0, ".MuiPaper-root": { width: "auto" } }}
>
<Picker
set="twitter"
theme="dark"
data={data}
custom={custom}
onEmojiSelect={(emoji) => {
const value = emoji.src ?? urlFromUnifiedCode(emoji.unified);
onPickEmoji(value);
popover.setIsOpen(false);
}}
/>
</PopoverContent>
</>
)}
</Popover>
</Stack>
);
+178
View File
@@ -0,0 +1,178 @@
import {
ReactElement,
ReactNode,
cloneElement,
createContext,
useContext,
useEffect,
useRef,
useState,
} from "react";
// This is used as base for the main Popover component
// eslint-disable-next-line no-restricted-imports -- Read above
import MuiPopover, {
type PopoverProps as MuiPopoverProps,
} from "@mui/material/Popover";
type TriggerMode = "hover" | "click";
type TriggerRef = React.RefObject<HTMLElement>;
type TriggerElement = ReactElement<{
onClick?: () => void;
ref: TriggerRef;
}>;
type PopoverContextValue = {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
triggerRef: TriggerRef;
mode: TriggerMode;
};
const PopoverContext = createContext<PopoverContextValue | undefined>(
undefined,
);
export const Popover = (props: {
children: ReactNode | ((popover: PopoverContextValue) => ReactNode); // Allows inline usage
mode?: TriggerMode;
isDefaultOpen?: boolean;
}) => {
const [isOpen, setIsOpen] = useState(props.isDefaultOpen ?? false);
const triggerRef = useRef<HTMLElement>(null);
const value = { isOpen, setIsOpen, triggerRef, mode: props.mode ?? "click" };
return (
<PopoverContext.Provider value={value}>
{typeof props.children === "function"
? props.children(value)
: props.children}
</PopoverContext.Provider>
);
};
export const usePopover = () => {
const context = useContext(PopoverContext);
if (!context) {
throw new Error(
"Popover compound components cannot be rendered outside the Popover component",
);
}
return context;
};
export const PopoverTrigger = (props: {
children: TriggerElement;
hover?: boolean;
}) => {
const popover = usePopover();
const clickProps = {
onClick: () => {
popover.setIsOpen((isOpen) => !isOpen);
},
};
const hoverProps = {
onPointerEnter: () => {
popover.setIsOpen(true);
},
onPointerLeave: () => {
popover.setIsOpen(false);
},
};
return cloneElement(props.children, {
...(popover.mode === "click" ? clickProps : hoverProps),
ref: popover.triggerRef,
});
};
type Horizontal = "left" | "right";
export const PopoverContent = (
props: Omit<MuiPopoverProps, "open" | "onClose" | "anchorEl"> & {
horizontal?: Horizontal;
},
) => {
const popover = usePopover();
const [isReady, setIsReady] = useState(false);
const horizontal = props.horizontal ?? "left";
const hoverMode = popover.mode === "hover";
// This is a hack to make sure the popover is not rendered until the trigger
// is ready. This is a limitation on MUI that does not support defaultIsOpen
// on Popover but we need it to storybook the component.
useEffect(() => {
if (!isReady && popover.triggerRef.current !== null) {
setIsReady(true);
}
}, [isReady, popover.triggerRef]);
if (!popover.triggerRef.current) {
return null;
}
return (
<MuiPopover
disablePortal
css={(theme) => ({
// When it is on hover mode, and the moude is moving from the trigger to
// the popover, if there is any space, the popover will be closed. I
// found this is a limitation on how MUI structured the component. It is
// not a big issue for now but we can reavaluate it in the future.
marginTop: hoverMode ? undefined : theme.spacing(1),
pointerEvents: hoverMode ? "none" : undefined,
"& .MuiPaper-root": {
minWidth: theme.spacing(40),
fontSize: 14,
pointerEvents: hoverMode ? "auto" : undefined,
},
})}
{...horizontalProps(horizontal)}
{...modeProps(popover)}
{...props}
open={popover.isOpen}
onClose={() => popover.setIsOpen(false)}
anchorEl={popover.triggerRef.current}
/>
);
};
const modeProps = (popover: PopoverContextValue) => {
if (popover.mode === "hover") {
return {
onMouseEnter: () => {
popover.setIsOpen(true);
},
onMouseLeave: () => {
popover.setIsOpen(false);
},
};
}
return {};
};
const horizontalProps = (horizontal: Horizontal) => {
if (horizontal === "right") {
return {
anchorOrigin: {
vertical: "bottom",
horizontal: "right",
},
transformOrigin: {
vertical: "top",
horizontal: "right",
},
} as const;
}
return {
anchorOrigin: {
vertical: "bottom",
horizontal: "left",
},
} as const;
};
@@ -1,20 +0,0 @@
import { Meta, StoryObj } from "@storybook/react";
import { PopoverContainer } from "./PopoverContainer";
import Button from "@mui/material/Button";
const meta: Meta<typeof PopoverContainer> = {
title: "components/PopoverContainer",
component: PopoverContainer,
args: {
anchorButton: <Button>I have no hooks/refs</Button>,
children: <p>Hiya!</p>,
originY: "bottom",
},
};
export default meta;
type Story = StoryObj<typeof PopoverContainer>;
const Example: Story = {};
export { Example as PopoverContainer };
@@ -1,244 +0,0 @@
/**
* @file Abstracts over MUI's Popover component to simplify using it (and hide
* some of the wonkier parts of the API).
*
* Just place a button and some content in the component, and things just work.
* No setup needed with hooks or refs.
*/
import {
type KeyboardEvent,
type MouseEvent,
type PropsWithChildren,
type ReactElement,
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { type Theme, type SystemStyleObject, Box } from "@mui/system";
import Popover, { type PopoverOrigin } from "@mui/material/Popover";
import { useNavigate, type LinkProps } from "react-router-dom";
import { useTheme } from "@emotion/react";
function getButton(container: HTMLElement) {
return (
container.querySelector("button") ??
container.querySelector('[aria-role="button"]')
);
}
const ClosePopoverContext = createContext<(() => void) | null>(null);
type PopoverLinkProps = LinkProps & {
to: string;
sx?: SystemStyleObject<Theme>;
};
/**
* A custom version of a React Router Link that makes sure to close the popover
* before starting a navigation.
*
* This is necessary because React Router's navigation logic doesn't work well
* with modals (including MUI's base Popover component).
*
* ---
* If the page being navigated to has lazy loading and isn't available yet, the
* previous components are supposed to be hidden during the transition, but
* because most React modals use React.createPortal to put content outside of
* the main DOM tree, React Router has no way of knowing about them. So open
* modals have a high risk of not disappearing until the page transition
* finishes and the previous components fully unmount.
*/
export function PopoverLink({
children,
to,
sx,
...linkProps
}: PopoverLinkProps) {
const closePopover = useContext(ClosePopoverContext);
if (closePopover === null) {
throw new Error("PopoverLink is not located inside of a PopoverContainer");
}
// Luckily, useNavigate and Link are designed to be imperative/declarative
// mirrors of each other, so their inputs should never get out of sync
const navigate = useNavigate();
const theme = useTheme();
const onClick = (event: MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
event.stopPropagation();
closePopover();
// Hacky, but by using a promise to push the navigation to resolve via the
// micro-task queue, there's guaranteed to be a period for the popover to
// close. Tried React DOM's flushSync function, but it was unreliable.
void Promise.resolve().then(() => {
navigate(to, linkProps);
});
};
return (
<Box
component="a"
// Href still needed for accessibility reasons and semantic markup
href=""
onClick={onClick}
sx={{
outline: "none",
textDecoration: "none",
"&:focus": {
backgroundColor: theme.palette.action.focus,
},
"&:hover": {
textDecoration: "none",
backgroundColor: theme.palette.action.hover,
},
...sx,
}}
>
{children}
</Box>
);
}
type PopoverContainerProps = PropsWithChildren<{
/**
* Does not require any hooks or refs to work. Also does not override any refs
* or event handlers attached to the button.
*/
anchorButton: ReactElement;
width?: number;
originX?: PopoverOrigin["horizontal"];
originY?: PopoverOrigin["vertical"];
sx?: SystemStyleObject<Theme>;
}>;
export function PopoverContainer({
children,
anchorButton,
originX = 0,
originY = 0,
width = 320,
sx = {},
}: PopoverContainerProps) {
const parentClosePopover = useContext(ClosePopoverContext);
if (parentClosePopover !== null) {
throw new Error(
"Popover detected inside of Popover - this will always be a bad user experience",
);
}
const buttonContainerRef = useRef<HTMLDivElement>(null);
// Ref value is for effects and event listeners; state value is for React
// renders. Have to duplicate state because after the initial render, it's
// never safe to reference ref contents inside a render path, especially with
// React 18 concurrency. Duplication is a necessary evil because of MUI's
// weird, clunky APIs
const anchorButtonRef = useRef<HTMLButtonElement | null>(null);
const [loadedButton, setLoadedButton] = useState<HTMLButtonElement>();
// Makes container listen to changes in button. If this approach becomes
// untenable in the future, it can be replaced with React.cloneElement, but
// the trade-off there is that every single anchorButton will need to be
// wrapped inside React.forwardRef, making the abstraction leak a little more
useEffect(() => {
const buttonContainer = buttonContainerRef.current;
if (buttonContainer === null) {
throw new Error("Please attach container ref to button container");
}
const initialButton = getButton(buttonContainer);
if (initialButton === null) {
throw new Error("Initial ref query failed");
}
anchorButtonRef.current = initialButton;
const onContainerMutation: MutationCallback = () => {
const newButton = getButton(buttonContainer);
if (newButton === null) {
throw new Error("Semantic button removed after DOM update");
}
anchorButtonRef.current = newButton;
setLoadedButton((current) => {
return current === undefined ? undefined : newButton;
});
};
const observer = new MutationObserver(onContainerMutation);
observer.observe(buttonContainer, {
childList: true,
subtree: true,
});
return () => observer.disconnect();
}, []);
// Not using useInteractive because the container element is just meant to
// catch events from the inner button, not act as a button itself
const onInnerButtonInteraction = () => {
if (anchorButtonRef.current === null) {
throw new Error("Usable ref value is unavailable");
}
setLoadedButton(anchorButtonRef.current);
};
const onInnerButtonKeydown = (event: KeyboardEvent) => {
if (event.key === "Enter" || event.key === " ") {
onInnerButtonInteraction();
}
};
const closePopover = useCallback(() => {
setLoadedButton(undefined);
}, []);
return (
<>
{/* Cannot switch with Box component; breaks implementation */}
<div
// Disabling semantics for the container does not affect the button
// placed inside; the button should still be fully accessible
role="none"
tabIndex={-1}
ref={buttonContainerRef}
onClick={onInnerButtonInteraction}
onKeyDown={onInnerButtonKeydown}
// Only style that container should ever need
style={{ width: "fit-content" }}
>
{anchorButton}
</div>
<ClosePopoverContext.Provider value={closePopover}>
<Popover
open={loadedButton !== undefined}
anchorEl={loadedButton}
onClose={closePopover}
anchorOrigin={{ horizontal: originX, vertical: originY }}
sx={{
"& .MuiPaper-root": {
overflowY: "hidden",
width,
paddingY: 0,
...sx,
},
}}
transitionDuration={{
enter: 300,
exit: 0,
}}
>
{children}
</Popover>
</ClosePopoverContext.Provider>
</>
);
}
@@ -1,11 +1,9 @@
import Box from "@mui/material/Box";
import Link from "@mui/material/Link";
import Popover from "@mui/material/Popover";
import CircularProgress from "@mui/material/CircularProgress";
import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined";
import { css } from "@emotion/css";
import { useTheme } from "@emotion/react";
import { useRef, useState } from "react";
import { useQuery } from "react-query";
import { colors } from "theme/colors";
import {
@@ -22,6 +20,11 @@ import type {
WorkspaceAgentListeningPort,
} from "api/typesGenerated";
import { portForwardURL } from "utils/portForward";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "components/Popover/Popover";
export interface PortForwardButtonProps {
host: string;
@@ -32,9 +35,6 @@ export interface PortForwardButtonProps {
export const PortForwardButton: React.FC<PortForwardButtonProps> = (props) => {
const theme = useTheme();
const anchorRef = useRef<HTMLButtonElement>(null);
const [isOpen, setIsOpen] = useState(false);
const id = isOpen ? "schedule-popover" : undefined;
const portsQuery = useQuery({
queryKey: ["portForward", props.agent.id],
queryFn: () => getAgentListeningPorts(props.agent.id),
@@ -42,43 +42,36 @@ export const PortForwardButton: React.FC<PortForwardButtonProps> = (props) => {
refetchInterval: 5_000,
});
const onClose = () => {
setIsOpen(false);
};
return (
<>
<SecondaryAgentButton
disabled={!portsQuery.data}
ref={anchorRef}
onClick={() => {
setIsOpen(true);
}}
>
Ports
{portsQuery.data ? (
<Box
sx={{
fontSize: 12,
fontWeight: 500,
height: 20,
minWidth: 20,
padding: (theme) => theme.spacing(0, 0.5),
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: colors.gray[11],
ml: 1,
}}
>
{portsQuery.data.ports.length}
</Box>
) : (
<CircularProgress size={10} sx={{ ml: 1 }} />
)}
</SecondaryAgentButton>
<Popover
<Popover>
<PopoverTrigger>
<SecondaryAgentButton disabled={!portsQuery.data}>
Ports
{portsQuery.data ? (
<Box
sx={{
fontSize: 12,
fontWeight: 500,
height: 20,
minWidth: 20,
padding: (theme) => theme.spacing(0, 0.5),
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: colors.gray[11],
ml: 1,
}}
>
{portsQuery.data.ports.length}
</Box>
) : (
<CircularProgress size={10} sx={{ ml: 1 }} />
)}
</SecondaryAgentButton>
</PopoverTrigger>
<PopoverContent
horizontal="right"
classes={{
paper: css`
padding: 0;
@@ -87,22 +80,10 @@ export const PortForwardButton: React.FC<PortForwardButtonProps> = (props) => {
margin-top: ${theme.spacing(0.5)};
`,
}}
id={id}
open={isOpen}
anchorEl={anchorRef.current}
onClose={onClose}
anchorOrigin={{
vertical: "bottom",
horizontal: "right",
}}
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
>
<PortForwardPopoverView {...props} ports={portsQuery.data?.ports} />
</Popover>
</>
</PopoverContent>
</Popover>
);
};
@@ -22,7 +22,7 @@ export const Opened: Story = {
args: {
workspaceName: MockWorkspace.name,
agentName: MockWorkspaceAgent.name,
defaultIsOpen: true,
isDefaultOpen: true,
sshPrefix: "coder.",
},
};
@@ -1,7 +1,6 @@
import Popover from "@mui/material/Popover";
import { css } from "@emotion/css";
import { type Interpolation, type Theme, useTheme } from "@emotion/react";
import { type FC, type PropsWithChildren, useRef, useState } from "react";
import { type FC, type PropsWithChildren } from "react";
import {
HelpTooltipLink,
HelpTooltipLinksGroup,
@@ -11,41 +10,35 @@ import { docs } from "utils/docs";
import { CodeExample } from "../../CodeExample/CodeExample";
import { Stack } from "../../Stack/Stack";
import { SecondaryAgentButton } from "../AgentButton";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "components/Popover/Popover";
export interface SSHButtonProps {
workspaceName: string;
agentName: string;
defaultIsOpen?: boolean;
isDefaultOpen?: boolean;
sshPrefix?: string;
}
export const SSHButton: FC<PropsWithChildren<SSHButtonProps>> = ({
workspaceName,
agentName,
defaultIsOpen = false,
isDefaultOpen = false,
sshPrefix,
}) => {
const theme = useTheme();
const anchorRef = useRef<HTMLButtonElement>(null);
const [isOpen, setIsOpen] = useState(defaultIsOpen);
const id = isOpen ? "schedule-popover" : undefined;
const onClose = () => {
setIsOpen(false);
};
return (
<>
<SecondaryAgentButton
ref={anchorRef}
onClick={() => {
setIsOpen(true);
}}
>
SSH
</SecondaryAgentButton>
<Popover isDefaultOpen={isDefaultOpen}>
<PopoverTrigger>
<SecondaryAgentButton>SSH</SecondaryAgentButton>
</PopoverTrigger>
<Popover
<PopoverContent
horizontal="right"
classes={{
paper: css`
padding: ${theme.spacing(2, 3, 3)};
@@ -54,18 +47,6 @@ export const SSHButton: FC<PropsWithChildren<SSHButtonProps>> = ({
margin-top: ${theme.spacing(0.25)};
`,
}}
id={id}
open={isOpen}
anchorEl={anchorRef.current}
onClose={onClose}
anchorOrigin={{
vertical: "bottom",
horizontal: "left",
}}
transformOrigin={{
vertical: "top",
horizontal: "left",
}}
>
<HelpTooltipText>
Run the following commands to connect with SSH:
@@ -107,8 +88,8 @@ export const SSHButton: FC<PropsWithChildren<SSHButtonProps>> = ({
SSH configuration
</HelpTooltipLink>
</HelpTooltipLinksGroup>
</Popover>
</>
</PopoverContent>
</Popover>
);
};
@@ -5,7 +5,6 @@ import "react-date-range/dist/styles.css";
import "react-date-range/dist/theme/default.css";
import Button from "@mui/material/Button";
import ArrowRightAltOutlined from "@mui/icons-material/ArrowRightAltOutlined";
import Popover from "@mui/material/Popover";
import { DateRangePicker, createStaticRanges } from "react-date-range";
import {
addDays,
@@ -16,6 +15,11 @@ import {
startOfHour,
subDays,
} from "date-fns";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "components/Popover/Popover";
// The type definition from @types is wrong
declare module "react-date-range" {
@@ -41,8 +45,6 @@ export const DateRange = ({
onChange: (value: DateRangeValue) => void;
}) => {
const selectionStatusRef = useRef<"idle" | "selecting">("idle");
const anchorRef = useRef<HTMLButtonElement>(null);
const [isOpen, setIsOpen] = useState(false);
const [ranges, setRanges] = useState<RangesState>([
{
...value,
@@ -53,105 +55,91 @@ export const DateRange = ({
startDate: ranges[0].startDate as Date,
endDate: ranges[0].endDate as Date,
};
const handleClose = () => {
const now = new Date();
onChange({
startDate: startOfDay(currentRange.startDate),
endDate: isToday(currentRange.endDate)
? startOfHour(addHours(now, 1))
: startOfDay(addDays(currentRange.endDate, 1)),
});
setIsOpen(false);
};
return (
<>
<Button ref={anchorRef} onClick={() => setIsOpen(true)}>
<span>{format(currentRange.startDate, "MMM d, Y")}</span>
<ArrowRightAltOutlined sx={{ width: 16, height: 16, mx: 1 }} />
<span>{format(currentRange.endDate, "MMM d, Y")}</span>
</Button>
<Popover
anchorEl={anchorRef.current}
open={isOpen}
onClose={handleClose}
anchorOrigin={{
vertical: "bottom",
horizontal: "left",
}}
sx={{
"& .MuiPaper-root": {
marginTop: 1,
},
}}
>
<DateRangePickerWrapper
component={DateRangePicker}
onChange={(item) => {
const range = item.selection;
setRanges([range]);
<Popover>
{(popover) => (
<>
<PopoverTrigger>
<Button>
<span>{format(currentRange.startDate, "MMM d, Y")}</span>
<ArrowRightAltOutlined sx={{ width: 16, height: 16, mx: 1 }} />
<span>{format(currentRange.endDate, "MMM d, Y")}</span>
</Button>
</PopoverTrigger>
<PopoverContent>
<DateRangePickerWrapper
component={DateRangePicker}
onChange={(item) => {
const range = item.selection;
setRanges([range]);
// When it is the first selection, we don't want to close the popover
// We have to do that ourselves because the library doesn't provide a way to do it
if (selectionStatusRef.current === "idle") {
selectionStatusRef.current = "selecting";
return;
}
// When it is the first selection, we don't want to close the popover
// We have to do that ourselves because the library doesn't provide a way to do it
if (selectionStatusRef.current === "idle") {
selectionStatusRef.current = "selecting";
return;
}
selectionStatusRef.current = "idle";
const startDate = range.startDate as Date;
const endDate = range.endDate as Date;
onChange({
startDate,
endDate,
});
setIsOpen(false);
}}
moveRangeOnFirstSelection={false}
months={2}
ranges={ranges}
maxDate={new Date()}
direction="horizontal"
staticRanges={createStaticRanges([
{
label: "Today",
range: () => ({
startDate: new Date(),
endDate: new Date(),
}),
},
{
label: "Yesterday",
range: () => ({
startDate: subDays(new Date(), 1),
endDate: subDays(new Date(), 1),
}),
},
{
label: "Last 7 days",
range: () => ({
startDate: subDays(new Date(), 6),
endDate: new Date(),
}),
},
{
label: "Last 14 days",
range: () => ({
startDate: subDays(new Date(), 13),
endDate: new Date(),
}),
},
{
label: "Last 30 days",
range: () => ({
startDate: subDays(new Date(), 29),
endDate: new Date(),
}),
},
])}
/>
</Popover>
</>
selectionStatusRef.current = "idle";
const startDate = range.startDate as Date;
const endDate = range.endDate as Date;
const now = new Date();
onChange({
startDate: startOfDay(startDate),
endDate: isToday(endDate)
? startOfHour(addHours(now, 1))
: startOfDay(addDays(endDate, 1)),
});
popover.setIsOpen(false);
}}
moveRangeOnFirstSelection={false}
months={2}
ranges={ranges}
maxDate={new Date()}
direction="horizontal"
staticRanges={createStaticRanges([
{
label: "Today",
range: () => ({
startDate: new Date(),
endDate: new Date(),
}),
},
{
label: "Yesterday",
range: () => ({
startDate: subDays(new Date(), 1),
endDate: subDays(new Date(), 1),
}),
},
{
label: "Last 7 days",
range: () => ({
startDate: subDays(new Date(), 6),
endDate: new Date(),
}),
},
{
label: "Last 14 days",
range: () => ({
startDate: subDays(new Date(), 13),
endDate: new Date(),
}),
},
{
label: "Last 30 days",
range: () => ({
startDate: subDays(new Date(), 29),
endDate: new Date(),
}),
},
])}
/>
</PopoverContent>
</>
)}
</Popover>
);
};
+79 -79
View File
@@ -20,7 +20,6 @@ import Box from "@mui/material/Box";
import { useDashboard } from "components/Dashboard/DashboardProvider";
import { Region } from "api/typesGenerated";
import { getLatencyColor } from "utils/latency";
import Popover from "@mui/material/Popover";
import { ProxyStatusLatency } from "components/ProxyStatusLatency/ProxyStatusLatency";
import { portForwardURL } from "utils/portForward";
import {
@@ -31,6 +30,11 @@ import {
} from "./TerminalAlerts";
import { useQuery } from "react-query";
import { deploymentConfig } from "api/queries/deployment";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "components/Popover/Popover";
export const Language = {
workspaceErrorMessagePrefix: "Unable to fetch workspace: ",
@@ -332,13 +336,11 @@ const TerminalPage: FC = () => {
const BottomBar = ({ proxy, latency }: { proxy: Region; latency?: number }) => {
const theme = useTheme();
const color = getLatencyColor(theme, latency);
const anchorRef = useRef<HTMLButtonElement>(null);
const [isOpen, setIsOpen] = useState(false);
return (
<Box
sx={{
padding: (theme) => theme.spacing(1, 2),
padding: theme.spacing(0, 2),
background: (theme) => theme.palette.background.paper,
display: "flex",
alignItems: "center",
@@ -347,82 +349,80 @@ const BottomBar = ({ proxy, latency }: { proxy: Region; latency?: number }) => {
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
}}
>
<Box
ref={anchorRef}
component="button"
aria-label="Terminal latency"
aria-haspopup="true"
onMouseEnter={() => setIsOpen(true)}
onMouseLeave={() => setIsOpen(false)}
sx={{
background: "none",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: 1,
border: 0,
}}
>
<Box
sx={{
height: 6,
width: 6,
backgroundColor: color,
border: 0,
borderRadius: 9999,
}}
/>
<ProxyStatusLatency latency={latency} />
</Box>
<Popover
id="latency-popover"
disableRestoreFocus
anchorEl={anchorRef.current}
open={isOpen}
onClose={() => setIsOpen(false)}
sx={{
pointerEvents: "none",
"& .MuiPaper-root": {
padding: (theme) => theme.spacing(1, 2),
marginTop: -1,
},
}}
anchorOrigin={{
vertical: "top",
horizontal: "right",
}}
transformOrigin={{
vertical: "bottom",
horizontal: "right",
}}
>
<Box
sx={{
fontSize: 13,
color: (theme) => theme.palette.text.secondary,
fontWeight: 500,
}}
>
Selected proxy
</Box>
<Box
sx={{ fontSize: 14, display: "flex", gap: 3, alignItems: "center" }}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Box width={12} height={12} lineHeight={0}>
<Box
component="img"
src={proxy.icon_url}
alt=""
sx={{ objectFit: "contain" }}
width="100%"
height="100%"
/>
</Box>
{proxy.display_name}
<Popover mode="hover">
<PopoverTrigger>
<Box
component="button"
aria-label="Terminal latency"
aria-haspopup="true"
sx={{
background: "none",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: 1,
border: 0,
padding: theme.spacing(1),
}}
>
<Box
sx={{
height: 6,
width: 6,
backgroundColor: color,
border: 0,
borderRadius: 9999,
}}
/>
<ProxyStatusLatency latency={latency} />
</Box>
<ProxyStatusLatency latency={latency} />
</Box>
</PopoverTrigger>
<PopoverContent
id="latency-popover"
disableRestoreFocus
sx={{
pointerEvents: "none",
"& .MuiPaper-root": {
padding: (theme) => theme.spacing(1, 2),
},
}}
anchorOrigin={{
vertical: "top",
horizontal: "right",
}}
transformOrigin={{
vertical: "bottom",
horizontal: "right",
}}
>
<Box
sx={{
fontSize: 13,
color: (theme) => theme.palette.text.secondary,
fontWeight: 500,
}}
>
Selected proxy
</Box>
<Box
sx={{ fontSize: 14, display: "flex", gap: 3, alignItems: "center" }}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Box width={12} height={12} lineHeight={0}>
<Box
component="img"
src={proxy.icon_url}
alt=""
sx={{ objectFit: "contain" }}
width="100%"
height="100%"
/>
</Box>
{proxy.display_name}
</Box>
<ProxyStatusLatency latency={latency} />
</Box>
</PopoverContent>
</Popover>
</Box>
);
@@ -10,7 +10,7 @@ const meta: Meta<typeof EditRolesButton> = {
title: "pages/UsersPage/EditRolesButton",
component: EditRolesButton,
args: {
defaultIsOpen: true,
isDefaultOpen: true,
},
};
@@ -1,8 +1,7 @@
import IconButton from "@mui/material/IconButton";
import { EditSquare } from "components/Icons/EditSquare";
import { useRef, useState, FC } from "react";
import { FC } from "react";
import { makeStyles } from "@mui/styles";
import Popover from "@mui/material/Popover";
import { Stack } from "components/Stack/Stack";
import Checkbox from "@mui/material/Checkbox";
import UserIcon from "@mui/icons-material/PersonOutline";
@@ -12,6 +11,11 @@ import {
HelpTooltipText,
HelpTooltipTitle,
} from "components/HelpTooltip/HelpTooltip";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "components/Popover/Popover";
const roleDescriptions: Record<string, string> = {
owner:
@@ -59,7 +63,7 @@ export interface EditRolesButtonProps {
roles: Role[];
selectedRoles: Role[];
onChange: (roles: Role["name"][]) => void;
defaultIsOpen?: boolean;
isDefaultOpen?: boolean;
oidcRoleSync: boolean;
userLoginType: string;
}
@@ -69,14 +73,11 @@ export const EditRolesButton: FC<EditRolesButtonProps> = ({
selectedRoles,
onChange,
isLoading,
defaultIsOpen = false,
isDefaultOpen = false,
userLoginType,
oidcRoleSync,
}) => {
const styles = useStyles();
const anchorRef = useRef<HTMLButtonElement>(null);
const [isOpen, setIsOpen] = useState(defaultIsOpen);
const id = isOpen ? "edit-roles-popover" : undefined;
const selectedRoleNames = selectedRoles.map((role) => role.name);
const handleChange = (roleName: string) => {
@@ -91,42 +92,30 @@ export const EditRolesButton: FC<EditRolesButtonProps> = ({
const canSetRoles =
userLoginType !== "oidc" || (userLoginType === "oidc" && !oidcRoleSync);
if (!canSetRoles) {
return (
<HelpTooltip size="small">
<HelpTooltipTitle>Externally controlled</HelpTooltipTitle>
<HelpTooltipText>
Roles for this user are controlled by the OIDC identity provider.
</HelpTooltipText>
</HelpTooltip>
);
}
return (
<>
{canSetRoles ? (
<Popover isDefaultOpen={isDefaultOpen}>
<PopoverTrigger>
<IconButton
ref={anchorRef}
size="small"
className={styles.editButton}
title="Edit user roles"
onClick={() => setIsOpen(true)}
>
<EditSquare />
</IconButton>
) : (
<HelpTooltip size="small">
<HelpTooltipTitle>Externally controlled</HelpTooltipTitle>
<HelpTooltipText>
Roles for this user are controlled by the OIDC identity provider.
</HelpTooltipText>
</HelpTooltip>
)}
</PopoverTrigger>
<Popover
id={id}
open={isOpen}
anchorEl={anchorRef.current}
onClose={() => setIsOpen(false)}
anchorOrigin={{
vertical: "bottom",
horizontal: "left",
}}
transformOrigin={{
vertical: "top",
horizontal: "left",
}}
classes={{ paper: styles.popoverPaper }}
>
<PopoverContent classes={{ paper: styles.popoverPaper }}>
<fieldset
className={styles.fieldset}
disabled={isLoading}
@@ -156,8 +145,8 @@ export const EditRolesButton: FC<EditRolesButtonProps> = ({
</Stack>
</Stack>
</div>
</Popover>
</>
</PopoverContent>
</Popover>
);
};
@@ -1,7 +1,6 @@
import ExpandMoreOutlined from "@mui/icons-material/ExpandMoreOutlined";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Popover from "@mui/material/Popover";
import { useQuery } from "react-query";
import { getWorkspaceParameters } from "api/api";
import {
@@ -19,10 +18,15 @@ import {
HelpTooltipTitle,
} from "components/HelpTooltip/HelpTooltip";
import { useFormik } from "formik";
import { useRef, useState } from "react";
import { docs } from "utils/docs";
import { getFormHelpers } from "utils/formUtils";
import { getInitialRichParameterValues } from "utils/richParameters";
import {
Popover,
PopoverContent,
PopoverTrigger,
usePopover,
} from "components/Popover/Popover";
export const BuildParametersPopover = ({
workspace,
@@ -33,12 +37,43 @@ export const BuildParametersPopover = ({
disabled?: boolean;
onSubmit: (buildParameters: WorkspaceBuildParameter[]) => void;
}) => {
const anchorRef = useRef<HTMLButtonElement>(null);
const [isOpen, setIsOpen] = useState(false);
return (
<Popover>
<PopoverTrigger>
<Button
data-testid="build-parameters-button"
disabled={disabled}
color="neutral"
sx={{ px: 0 }}
>
<ExpandMoreOutlined sx={{ fontSize: 16 }} />
</Button>
</PopoverTrigger>
<PopoverContent
horizontal="right"
css={(theme) => ({ ".MuiPaper-root": { width: theme.spacing(38) } })}
>
<BuildParametersPopoverContent
workspace={workspace}
onSubmit={onSubmit}
/>
</PopoverContent>
</Popover>
);
};
const BuildParametersPopoverContent = ({
workspace,
onSubmit,
}: {
workspace: Workspace;
onSubmit: (buildParameters: WorkspaceBuildParameter[]) => void;
}) => {
const popover = usePopover();
const { data: parameters } = useQuery({
queryKey: ["workspace", workspace.id, "parameters"],
queryFn: () => getWorkspaceParameters(workspace),
enabled: isOpen,
enabled: popover.isOpen,
});
const ephemeralParameters = parameters
? parameters.templateVersionRichParameters.filter((p) => p.ephemeral)
@@ -46,93 +81,56 @@ export const BuildParametersPopover = ({
return (
<>
<Button
data-testid="build-parameters-button"
disabled={disabled}
color="neutral"
sx={{ px: 0 }}
ref={anchorRef}
onClick={() => {
setIsOpen(true);
}}
>
<ExpandMoreOutlined sx={{ fontSize: 16 }} />
</Button>
<Popover
open={isOpen}
anchorEl={anchorRef.current}
onClose={() => {
setIsOpen(false);
}}
anchorOrigin={{
vertical: "bottom",
horizontal: "right",
}}
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
sx={{
".MuiPaper-root": {
width: (theme) => theme.spacing(38),
marginTop: 1,
},
}}
>
<Box>
{parameters && parameters.buildParameters && ephemeralParameters ? (
ephemeralParameters.length > 0 ? (
<>
<Box
sx={{
color: (theme) => theme.palette.text.secondary,
p: 2.5,
borderBottom: (theme) =>
`1px solid ${theme.palette.divider}`,
}}
>
<HelpTooltipTitle>Build Options</HelpTooltipTitle>
<HelpTooltipText>
These parameters only apply for a single workspace start.
</HelpTooltipText>
</Box>
<Box sx={{ p: 2.5 }}>
<Form
onSubmit={(buildParameters) => {
onSubmit(buildParameters);
setIsOpen(false);
}}
ephemeralParameters={ephemeralParameters}
buildParameters={parameters.buildParameters}
/>
</Box>
</>
) : (
<Box
sx={{
color: (theme) => theme.palette.text.secondary,
p: 2.5,
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
{parameters && parameters.buildParameters && ephemeralParameters ? (
ephemeralParameters.length > 0 ? (
<>
<Box
sx={{
color: (theme) => theme.palette.text.secondary,
p: 2.5,
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
}}
>
<HelpTooltipTitle>Build Options</HelpTooltipTitle>
<HelpTooltipText>
These parameters only apply for a single workspace start.
</HelpTooltipText>
</Box>
<Box sx={{ p: 2.5 }}>
<Form
onSubmit={(buildParameters) => {
onSubmit(buildParameters);
popover.setIsOpen(false);
}}
ephemeralParameters={ephemeralParameters}
buildParameters={parameters.buildParameters}
/>
</Box>
</>
) : (
<Box
sx={{
color: (theme) => theme.palette.text.secondary,
p: 2.5,
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
}}
>
<HelpTooltipTitle>Build Options</HelpTooltipTitle>
<HelpTooltipText>
This template has no ephemeral build options.
</HelpTooltipText>
<HelpTooltipLinksGroup>
<HelpTooltipLink
href={docs("/templates/parameters#ephemeral-parameters")}
>
<HelpTooltipTitle>Build Options</HelpTooltipTitle>
<HelpTooltipText>
This template has no ephemeral build options.
</HelpTooltipText>
<HelpTooltipLinksGroup>
<HelpTooltipLink
href={docs("/templates/parameters#ephemeral-parameters")}
>
Read the docs
</HelpTooltipLink>
</HelpTooltipLinksGroup>
</Box>
)
) : (
<Loader />
)}
</Box>
</Popover>
Read the docs
</HelpTooltipLink>
</HelpTooltipLinksGroup>
</Box>
)
) : (
<Loader />
)}
</>
);
};
+141 -128
View File
@@ -1,6 +1,6 @@
import Link from "@mui/material/Link";
import { WorkspaceOutdatedTooltip } from "components/WorkspaceOutdatedTooltip/WorkspaceOutdatedTooltip";
import { FC, useRef, useState } from "react";
import { FC } from "react";
import { Link as RouterLink } from "react-router-dom";
import { createDayString } from "utils/createDayString";
import {
@@ -16,11 +16,16 @@ import IconButton from "@mui/material/IconButton";
import RemoveIcon from "@mui/icons-material/RemoveOutlined";
import { makeStyles } from "@mui/styles";
import AddIcon from "@mui/icons-material/AddOutlined";
import Popover from "@mui/material/Popover";
import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
import { WorkspaceStatusText } from "components/WorkspaceStatusBadge/WorkspaceStatusBadge";
import { DormantDeletionStat } from "components/WorkspaceDeletion";
import {
Popover,
PopoverContent,
PopoverTrigger,
usePopover,
} from "components/Popover/Popover";
const Language = {
workspaceDetails: "Workspace Details",
@@ -62,10 +67,6 @@ export const WorkspaceStats: FC<WorkspaceStatsProps> = ({
const styles = useStyles();
const deadlinePlusEnabled = maxDeadlineIncrease >= 1;
const deadlineMinusEnabled = maxDeadlineDecrease >= 1;
const addButtonRef = useRef<HTMLButtonElement>(null);
const subButtonRef = useRef<HTMLButtonElement>(null);
const [isAddingTime, setIsAddingTime] = useState(false);
const [isSubTime, setIsSubTime] = useState(false);
return (
<>
@@ -138,26 +139,50 @@ export const WorkspaceStats: FC<WorkspaceStatsProps> = ({
</Link>
{canUpdateWorkspace && canEditDeadline(workspace) && (
<span className={styles.scheduleControls}>
<IconButton
disabled={!deadlineMinusEnabled}
size="small"
title="Subtract hours from deadline"
className={styles.scheduleButton}
ref={subButtonRef}
onClick={() => setIsSubTime(true)}
>
<RemoveIcon />
</IconButton>
<IconButton
disabled={!deadlinePlusEnabled}
size="small"
title="Add hours to deadline"
className={styles.scheduleButton}
ref={addButtonRef}
onClick={() => setIsAddingTime(true)}
>
<AddIcon />
</IconButton>
<Popover>
<PopoverTrigger>
<IconButton
disabled={!deadlineMinusEnabled}
size="small"
title="Subtract hours from deadline"
className={styles.scheduleButton}
>
<RemoveIcon />
</IconButton>
</PopoverTrigger>
<PopoverContent
id="schedule-sub"
classes={{ paper: styles.timePopoverPaper }}
horizontal="right"
>
<DecreaseTimeContent
maxDeadlineDecrease={maxDeadlineDecrease}
onDeadlineMinus={onDeadlineMinus}
/>
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger>
<IconButton
disabled={!deadlinePlusEnabled}
size="small"
title="Add hours to deadline"
className={styles.scheduleButton}
>
<AddIcon />
</IconButton>
</PopoverTrigger>
<PopoverContent
id="schedule-add"
classes={{ paper: styles.timePopoverPaper }}
horizontal="right"
>
<AddTimeContent
maxDeadlineIncrease={maxDeadlineIncrease}
onDeadlinePlus={onDeadlinePlus}
/>
</PopoverContent>
</Popover>
</span>
)}
</span>
@@ -174,118 +199,106 @@ export const WorkspaceStats: FC<WorkspaceStatsProps> = ({
/>
)}
</Stats>
</>
);
};
<Popover
id="schedule-add"
classes={{ paper: styles.timePopoverPaper }}
open={isAddingTime}
anchorEl={addButtonRef.current}
onClose={() => setIsAddingTime(false)}
anchorOrigin={{
vertical: "bottom",
horizontal: "right",
}}
transformOrigin={{
vertical: "top",
horizontal: "right",
const AddTimeContent = (props: {
maxDeadlineIncrease: number;
onDeadlinePlus: (value: number) => void;
}) => {
const styles = useStyles();
const popover = usePopover();
return (
<>
<span className={styles.timePopoverTitle}>Add hours to deadline</span>
<span className={styles.timePopoverDescription}>
Delay the shutdown of this workspace for a few more hours. This is only
applied once.
</span>
<form
className={styles.timePopoverForm}
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const hours = Number(formData.get("hours"));
props.onDeadlinePlus(hours);
popover.setIsOpen(false);
}}
>
<span className={styles.timePopoverTitle}>Add hours to deadline</span>
<span className={styles.timePopoverDescription}>
Delay the shutdown of this workspace for a few more hours. This is
only applied once.
</span>
<form
className={styles.timePopoverForm}
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const hours = Number(formData.get("hours"));
onDeadlinePlus(hours);
setIsAddingTime(false);
<TextField
name="hours"
type="number"
size="small"
fullWidth
className={styles.timePopoverField}
InputProps={{
className: styles.timePopoverFieldInput,
}}
>
<TextField
name="hours"
type="number"
size="small"
fullWidth
className={styles.timePopoverField}
InputProps={{ className: styles.timePopoverFieldInput }}
inputProps={{
min: 0,
max: maxDeadlineIncrease,
step: 1,
defaultValue: 1,
}}
/>
inputProps={{
min: 0,
max: props.maxDeadlineIncrease,
step: 1,
defaultValue: 1,
}}
/>
<Button
size="small"
className={styles.timePopoverButton}
type="submit"
>
Apply
</Button>
</form>
</Popover>
<Button className={styles.timePopoverButton} type="submit">
Apply
</Button>
</form>
</>
);
};
<Popover
id="schedule-sub"
classes={{ paper: styles.timePopoverPaper }}
open={isSubTime}
anchorEl={subButtonRef.current}
onClose={() => setIsSubTime(false)}
anchorOrigin={{
vertical: "bottom",
horizontal: "right",
}}
transformOrigin={{
vertical: "top",
horizontal: "right",
export const DecreaseTimeContent = (props: {
onDeadlineMinus: (hours: number) => void;
maxDeadlineDecrease: number;
}) => {
const styles = useStyles();
const popover = usePopover();
return (
<>
<span className={styles.timePopoverTitle}>
Subtract hours to deadline
</span>
<span className={styles.timePopoverDescription}>
Anticipate the shutdown of this workspace for a few more hours. This is
only applied once.
</span>
<form
className={styles.timePopoverForm}
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const hours = Number(formData.get("hours"));
props.onDeadlineMinus(hours);
popover.setIsOpen(false);
}}
>
<span className={styles.timePopoverTitle}>
Subtract hours to deadline
</span>
<span className={styles.timePopoverDescription}>
Anticipate the shutdown of this workspace for a few more hours. This
is only applied once.
</span>
<form
className={styles.timePopoverForm}
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const hours = Number(formData.get("hours"));
onDeadlineMinus(hours);
setIsSubTime(false);
<TextField
name="hours"
type="number"
size="small"
fullWidth
className={styles.timePopoverField}
InputProps={{
className: styles.timePopoverFieldInput,
}}
>
<TextField
name="hours"
type="number"
size="small"
fullWidth
className={styles.timePopoverField}
InputProps={{ className: styles.timePopoverFieldInput }}
inputProps={{
min: 0,
max: maxDeadlineDecrease,
step: 1,
defaultValue: 1,
}}
/>
inputProps={{
min: 0,
max: props.maxDeadlineDecrease,
step: 1,
defaultValue: 1,
}}
/>
<Button
size="small"
className={styles.timePopoverButton}
type="submit"
>
Apply
</Button>
</form>
</Popover>
<Button className={styles.timePopoverButton} type="submit">
Apply
</Button>
</form>
</>
);
};
@@ -1,9 +1,4 @@
import {
type PropsWithChildren,
type ReactNode,
useState,
useRef,
} from "react";
import { type PropsWithChildren, type ReactNode, useState } from "react";
import { type Template } from "api/typesGenerated";
import { type UseQueryResult } from "react-query";
import {
@@ -20,7 +15,11 @@ import { OverflowY } from "components/OverflowY/OverflowY";
import { EmptyState } from "components/EmptyState/EmptyState";
import { Avatar } from "components/Avatar/Avatar";
import { SearchBox } from "./WorkspacesSearchBox";
import Popover from "@mui/material/Popover";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "components/Popover/Popover";
const ICON_SIZE = 18;
const COLUMN_GAP = 1.5;
@@ -42,9 +41,6 @@ export function WorkspacesButton({
const [searchTerm, setSearchTerm] = useState("");
const processed = sortTemplatesByUsersDesc(templates ?? [], searchTerm);
const anchorRef = useRef<HTMLButtonElement>(null);
const [isOpen, setIsOpen] = useState(false);
let emptyState: ReactNode = undefined;
if (templates?.length === 0) {
emptyState = (
@@ -62,37 +58,13 @@ export function WorkspacesButton({
}
return (
<>
<Button
startIcon={<AddIcon />}
variant="contained"
ref={anchorRef}
onClick={() => {
setIsOpen(true);
}}
>
{children}
</Button>
<Popover
disablePortal
open={isOpen}
onClose={() => setIsOpen(false)}
anchorEl={anchorRef.current}
anchorOrigin={{
vertical: "bottom",
horizontal: "right",
}}
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
css={(theme) => ({
marginTop: theme.spacing(1),
"& .MuiPaper-root": {
width: theme.spacing(40),
},
})}
>
<Popover>
<PopoverTrigger>
<Button startIcon={<AddIcon />} variant="contained">
{children}
</Button>
</PopoverTrigger>
<PopoverContent horizontal="right">
<SearchBox
value={searchTerm}
onValueChange={(newValue) => setSearchTerm(newValue)}
@@ -142,8 +114,8 @@ export function WorkspacesButton({
<span>See all templates</span>
</PopoverLink>
</Box>
</Popover>
</>
</PopoverContent>
</Popover>
);
}