mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
chore(site): add custom popover component (#10319)
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user