refactor: replace other deprecated Popovers which open on hover (#19753)

closes #19397 
dependency: #19709 

#19635 covered a large chunk of deprecated `Popover`s which open on
hover, specifically the ones which instantiated `HelpTooltip`. This PR
replaces all of the non-`HelpTooltip` hover-triggered popovers, and
removes the deprecated popover component :)

---------

Co-authored-by: ケイラ <mckayla@hey.com>
This commit is contained in:
Andrew Aquino
2025-09-26 15:05:09 -07:00
committed by GitHub
co-authored by ケイラ
parent d29a52462b
commit 10d41b3a72
17 changed files with 432 additions and 744 deletions
@@ -1,57 +0,0 @@
import Button from "@mui/material/Button";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, screen, userEvent, waitFor, within } from "storybook/test";
import { Popover, PopoverContent, PopoverTrigger } from "./Popover";
const meta: Meta<typeof Popover> = {
title: "components/PopoverDeprecated",
component: Popover,
};
export default meta;
type Story = StoryObj<typeof Popover>;
const content = `
According to all known laws of aviation, there is no way a bee should be able to fly.
Its wings are too small to get its fat little body off the ground. The bee, of course,
flies anyway because bees don't care what humans think is impossible.
`;
export const Example: Story = {
args: {
children: (
<>
<PopoverTrigger>
<Button>Click here!</Button>
</PopoverTrigger>
<PopoverContent>{content}</PopoverContent>
</>
),
},
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
await step("click to open", async () => {
await userEvent.click(canvas.getByRole("button"));
await waitFor(() =>
expect(
screen.getByText(/according to all known laws/i),
).toBeInTheDocument(),
);
});
},
};
export const Horizontal: Story = {
args: {
children: (
<>
<PopoverTrigger>
<Button>Click here!</Button>
</PopoverTrigger>
<PopoverContent horizontal="right">{content}</PopoverContent>
</>
),
},
play: Example.play,
};
@@ -1,245 +0,0 @@
import MuiPopover, {
type PopoverProps as MuiPopoverProps,
// biome-ignore lint/style/noRestrictedImports: This is the base component that our custom popover is based on
} from "@mui/material/Popover";
import {
cloneElement,
createContext,
type FC,
type HTMLAttributes,
type PointerEvent,
type PointerEventHandler,
type ReactElement,
type ReactNode,
type RefObject,
useContext,
useEffect,
useId,
useRef,
useState,
} from "react";
type TriggerMode = "hover" | "click";
type TriggerRef = RefObject<HTMLElement | null>;
// Have to append ReactNode type to satisfy React's cloneElement function. It
// has absolutely no bearing on what happens at runtime
type TriggerElement = ReactNode &
ReactElement<{
ref: TriggerRef;
onClick?: () => void;
}>;
type PopoverContextValue = {
id: string;
open: boolean;
setOpen: (open: boolean) => void;
triggerRef: TriggerRef;
mode: TriggerMode;
};
const PopoverContext = createContext<PopoverContextValue | undefined>(
undefined,
);
type BasePopoverProps = {
children: ReactNode;
mode?: TriggerMode;
};
// By separating controlled and uncontrolled props, we achieve more accurate
// type inference.
type UncontrolledPopoverProps = BasePopoverProps & {
open?: undefined;
onOpenChange?: undefined;
};
type ControlledPopoverProps = BasePopoverProps & {
open: boolean;
onOpenChange: (open: boolean) => void;
};
type PopoverProps = UncontrolledPopoverProps | ControlledPopoverProps;
/** @deprecated prefer `components.Popover` */
export const Popover: FC<PopoverProps> = (props) => {
const hookId = useId();
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
const triggerRef: TriggerRef = useRef(null);
// Helps makes sure that popovers close properly when the user switches to
// a different tab. This won't help with controlled instances of the
// component, but this is basically the most we can do from here
useEffect(() => {
const closeOnTabSwitch = () => setUncontrolledOpen(false);
window.addEventListener("blur", closeOnTabSwitch);
return () => window.removeEventListener("blur", closeOnTabSwitch);
}, []);
const value: PopoverContextValue = {
triggerRef,
id: `${hookId}-popover`,
mode: props.mode ?? "click",
open: props.open ?? uncontrolledOpen,
setOpen: props.onOpenChange ?? setUncontrolledOpen,
};
return (
<PopoverContext.Provider value={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;
};
type PopoverTriggerRenderProps = Readonly<{
isOpen: boolean;
}>;
type PopoverTriggerProps = Readonly<
Omit<HTMLAttributes<HTMLElement>, "children"> & {
children:
| TriggerElement
| ((props: PopoverTriggerRenderProps) => TriggerElement);
}
>;
/** @deprecated prefer `components.Popover.PopoverTrigger` */
export const PopoverTrigger: FC<PopoverTriggerProps> = (props) => {
const popover = usePopover();
const { children, onClick, onPointerEnter, onPointerLeave, ...elementProps } =
props;
const clickProps = {
onClick: (event: PointerEvent<HTMLElement>) => {
popover.setOpen(true);
onClick?.(event);
},
};
const hoverProps = {
onPointerEnter: (event: PointerEvent<HTMLElement>) => {
popover.setOpen(true);
onPointerEnter?.(event);
},
onPointerLeave: (event: PointerEvent<HTMLElement>) => {
popover.setOpen(false);
onPointerLeave?.(event);
},
};
const evaluatedChildren =
typeof children === "function"
? children({ isOpen: popover.open })
: children;
return cloneElement(evaluatedChildren, {
...elementProps,
...(popover.mode === "click" ? clickProps : hoverProps),
// @ts-expect-error I would usually not hack around this, but this component
// is going to be deleted imminently.
"aria-haspopup": true,
"aria-owns": popover.id,
"aria-expanded": popover.open,
ref: popover.triggerRef,
});
};
type Horizontal = "left" | "right";
type PopoverContentProps = Omit<
MuiPopoverProps,
"open" | "onClose" | "anchorEl"
> & {
horizontal?: Horizontal;
};
/** @deprecated prefer `components.Popover.PopoverContent` */
export const PopoverContent: FC<PopoverContentProps> = ({
horizontal = "left",
onPointerEnter,
onPointerLeave,
...popoverProps
}) => {
const popover = usePopover();
const hoverMode = popover.mode === "hover";
return (
<MuiPopover
disablePortal
css={{
// When it is on hover mode, and the mode 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 re-evaluate it in the future.
marginTop: hoverMode ? undefined : 8,
pointerEvents: hoverMode ? "none" : undefined,
"& .MuiPaper-root": {
minWidth: 320,
fontSize: 14,
pointerEvents: hoverMode ? "auto" : undefined,
},
}}
{...horizontalProps(horizontal)}
{...modeProps(popover, onPointerEnter, onPointerLeave)}
{...popoverProps}
id={popover.id}
open={popover.open}
onClose={() => popover.setOpen(false)}
anchorEl={popover.triggerRef.current}
/>
);
};
const modeProps = (
popover: PopoverContextValue,
externalOnPointerEnter: PointerEventHandler<HTMLDivElement> | undefined,
externalOnPointerLeave: PointerEventHandler<HTMLDivElement> | undefined,
) => {
if (popover.mode === "hover") {
return {
onPointerEnter: (event: PointerEvent<HTMLDivElement>) => {
popover.setOpen(true);
externalOnPointerEnter?.(event);
},
onPointerLeave: (event: PointerEvent<HTMLDivElement>) => {
popover.setOpen(false);
externalOnPointerLeave?.(event);
},
};
}
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;
};
@@ -30,8 +30,8 @@ const Example: Story = {
await step("click to open", async () => {
await userEvent.click(canvas.getByRole("button"));
await waitFor(() =>
expect(screen.getByText(/v2\.\d+\.\d+/i)).toBeInTheDocument(),
await waitFor(async () =>
expect(await screen.findByText(/v2\.\d+\.\d+/i)).toBeInTheDocument(),
);
});
},
@@ -7,17 +7,18 @@ import {
PremiumBadge,
} from "components/Badges/Badges";
import { Button } from "components/Button/Button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "components/deprecated/Popover/Popover";
import { PopoverPaywall } from "components/Paywall/PopoverPaywall";
import {
SettingsHeader,
SettingsHeaderDescription,
SettingsHeaderTitle,
} from "components/SettingsHeader/SettingsHeader";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { useFormik } from "formik";
import type { FC } from "react";
import { getFormHelpers } from "utils/formUtils";
@@ -66,25 +67,31 @@ export const AppearanceSettingsPageView: FC<
</SettingsHeader>
<Badges>
<Popover mode="hover">
{isEntitled && !isPremium ? (
<EnterpriseBadge />
) : (
<PopoverTrigger>
<span>
<PremiumBadge />
</span>
</PopoverTrigger>
)}
<TooltipProvider>
<Tooltip delayDuration={0}>
{isEntitled && !isPremium ? (
<EnterpriseBadge />
) : (
<TooltipTrigger asChild>
<span>
<PremiumBadge />
</span>
</TooltipTrigger>
)}
<PopoverContent css={{ transform: "translateY(-28px)" }}>
<PopoverPaywall
message="Appearance"
description="With a Premium license, you can customize the appearance and branding of your deployment."
documentationLink="https://coder.com/docs/admin/appearance"
/>
</PopoverContent>
</Popover>
<TooltipContent
sideOffset={-28}
collisionPadding={16}
className="p-0"
>
<PopoverPaywall
message="Appearance"
description="With a Premium license, you can customize the appearance and branding of your deployment."
documentationLink="https://coder.com/docs/admin/appearance"
/>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Badges>
<Fieldset
@@ -1,6 +1,7 @@
import {
MockOrganization,
MockOrganization2,
MockOrganization3,
MockOrganizationSyncSettings,
MockOrganizationSyncSettings2,
MockOrganizationSyncSettingsEmpty,
@@ -15,7 +16,7 @@ const meta: Meta<typeof IdpOrgSyncPageView> = {
args: {
organizationSyncSettings: MockOrganizationSyncSettings2,
claimFieldValues: Object.keys(MockOrganizationSyncSettings2.mapping),
organizations: [MockOrganization, MockOrganization2],
organizations: [MockOrganization, MockOrganization2, MockOrganization3],
error: undefined,
},
};
@@ -1,10 +1,10 @@
import { useTheme } from "@emotion/react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "components/deprecated/Popover/Popover";
import { Pill } from "components/Pill/Pill";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import type { FC } from "react";
import { cn } from "utils/cn";
import { isUUID } from "utils/uuid";
@@ -25,10 +25,10 @@ export const OrganizationPills: FC<OrganizationPillsProps> = ({
<div className="flex flex-row gap-2">
{orgs.length > 0 ? (
<Pill
className={cn("border-none w-fit", {
"bg-surface-destructive": orgs[0].isUUID,
"bg-surface-secondary": !orgs[0].isUUID,
})}
className={cn(
"border-none w-fit",
orgs[0].isUUID ? "bg-surface-destructive" : "bg-surface-secondary",
)}
>
{orgs[0].name}
</Pill>
@@ -46,58 +46,37 @@ interface OverflowPillProps {
}
const OverflowPillList: FC<OverflowPillProps> = ({ organizations }) => {
const theme = useTheme();
return (
<Popover mode="hover">
<PopoverTrigger>
<Pill
className="min-h-4 min-w-6 bg-surface-secondary border-none px-3 py-1"
data-testid="overflow-pill"
>
+{organizations.length}
</Pill>
</PopoverTrigger>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Pill
className="min-h-4 min-w-6 bg-surface-secondary border-none px-3 py-1"
data-testid="overflow-pill"
>
+{organizations.length}
</Pill>
</TooltipTrigger>
<PopoverContent
disableRestoreFocus
disableScrollLock
css={{
".MuiPaper-root": {
display: "flex",
flexFlow: "column wrap",
columnGap: 8,
rowGap: 12,
padding: "12px 16px",
alignContent: "space-around",
minWidth: "auto",
backgroundColor: theme.palette.background.default,
},
}}
anchorOrigin={{
vertical: -4,
horizontal: "center",
}}
transformOrigin={{
vertical: "bottom",
horizontal: "center",
}}
>
<ul className="list-none my-0 pl-0">
{organizations.map((organization) => (
<li key={organization.name} className="mb-2 last:mb-0">
<Pill
className={cn("border-none w-fit", {
"bg-surface-destructive": organization.isUUID,
"bg-surface-secondary": !organization.isUUID,
})}
>
{organization.name}
</Pill>
</li>
))}
</ul>
</PopoverContent>
</Popover>
<TooltipContent className="px-4 py-3 border-surface-quaternary">
<ul className="flex flex-col gap-2 list-none my-0 pl-0">
{organizations.map((organization) => (
<li key={organization.name}>
<Pill
className={cn(
"border-none w-fit",
organization.isUUID
? "bg-surface-destructive"
: "bg-surface-secondary",
)}
>
{organization.name}
</Pill>
</li>
))}
</ul>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
@@ -52,3 +52,5 @@ export default meta;
type Story = StoryObj<typeof ObservabilitySettingsPageView>;
export const Page: Story = {};
export const Premium: Story = { args: { isPremium: true } };
@@ -4,11 +4,6 @@ import {
EnterpriseBadge,
PremiumBadge,
} from "components/Badges/Badges";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "components/deprecated/Popover/Popover";
import { PopoverPaywall } from "components/Paywall/PopoverPaywall";
import {
SettingsHeader,
@@ -17,6 +12,12 @@ import {
SettingsHeaderTitle,
} from "components/SettingsHeader/SettingsHeader";
import { Stack } from "components/Stack/Stack";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import type { FC } from "react";
import { deploymentGroupHasParent } from "utils/deployOptions";
import { docs } from "utils/docs";
@@ -52,25 +53,31 @@ export const ObservabilitySettingsPageView: FC<
</SettingsHeader>
<Badges>
<Popover mode="hover">
{featureAuditLogEnabled && !isPremium ? (
<EnterpriseBadge />
) : (
<PopoverTrigger>
<span>
<PremiumBadge />
</span>
</PopoverTrigger>
)}
<TooltipProvider>
<Tooltip delayDuration={0}>
{featureAuditLogEnabled && !isPremium ? (
<EnterpriseBadge />
) : (
<TooltipTrigger asChild>
<span>
<PremiumBadge />
</span>
</TooltipTrigger>
)}
<PopoverContent css={{ transform: "translateY(-28px)" }}>
<PopoverPaywall
message="Observability"
description="With a Premium license, you can monitor your application with logs and metrics."
documentationLink="https://coder.com/docs/admin/appearance"
/>
</PopoverContent>
</Popover>
<TooltipContent
sideOffset={-28}
collisionPadding={16}
className="p-0"
>
<PopoverPaywall
message="Observability"
description="With a Premium license, you can monitor your application with logs and metrics."
documentationLink="https://coder.com/docs/admin/appearance"
/>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Badges>
</div>
@@ -5,15 +5,16 @@ import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Badges, PremiumBadge } from "components/Badges/Badges";
import { Button } from "components/Button/Button";
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "components/deprecated/Popover/Popover";
import { IconField } from "components/IconField/IconField";
import { Paywall } from "components/Paywall/Paywall";
import { PopoverPaywall } from "components/Paywall/PopoverPaywall";
import { Spinner } from "components/Spinner/Spinner";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { useFormik } from "formik";
import { ArrowLeft } from "lucide-react";
import type { FC } from "react";
@@ -81,23 +82,29 @@ export const CreateOrganizationPageView: FC<
)}
<Badges>
<Popover mode="hover">
{isEntitled && (
<PopoverTrigger>
<span>
<PremiumBadge />
</span>
</PopoverTrigger>
)}
<TooltipProvider>
<Tooltip delayDuration={0}>
{isEntitled && (
<TooltipTrigger asChild>
<span>
<PremiumBadge />
</span>
</TooltipTrigger>
)}
<PopoverContent css={{ transform: "translateY(-28px)" }}>
<PopoverPaywall
message="Organizations"
description="Create multiple organizations within a single Coder deployment, allowing several platform teams to operate with isolated users, templates, and distinct underlying infrastructure."
documentationLink={docs("/admin/users/organizations")}
/>
</PopoverContent>
</Popover>
<TooltipContent
sideOffset={-28}
collisionPadding={16}
className="p-0"
>
<PopoverPaywall
message="Organizations"
description="Create multiple organizations within a single Coder deployment, allowing several platform teams to operate with isolated users, templates, and distinct underlying infrastructure."
documentationLink={docs("/admin/users/organizations")}
/>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Badges>
<header className="flex flex-col items-center">
@@ -1,12 +1,13 @@
import { type Interpolation, type Theme, useTheme } from "@emotion/react";
import Stack from "@mui/material/Stack";
import type { Permission } from "api/typesGenerated";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "components/deprecated/Popover/Popover";
import { Pill } from "components/Pill/Pill";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import type { FC } from "react";
function getUniqueResourceTypes(jsonObject: readonly Permission[]) {
@@ -76,52 +77,34 @@ const OverflowPermissionPill: FC<OverflowPermissionPillProps> = ({
const theme = useTheme();
return (
<Popover mode="hover">
<PopoverTrigger>
<Pill
css={{
backgroundColor: theme.palette.background.paper,
borderColor: theme.palette.divider,
}}
data-testid="overflow-permissions-pill"
>
+{resources.length} more
</Pill>
</PopoverTrigger>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Pill
css={{
backgroundColor: theme.palette.background.paper,
borderColor: theme.palette.divider,
}}
data-testid="overflow-permissions-pill"
>
+{resources.length} more
</Pill>
</TooltipTrigger>
<PopoverContent
disableRestoreFocus
disableScrollLock
css={{
".MuiPaper-root": {
display: "flex",
flexFlow: "column wrap",
columnGap: 8,
rowGap: 12,
padding: "12px 16px",
alignContent: "space-around",
minWidth: "auto",
backgroundColor: theme.palette.background.default,
},
}}
anchorOrigin={{
vertical: -4,
horizontal: "center",
}}
transformOrigin={{
vertical: "bottom",
horizontal: "center",
}}
>
{resources.map((resource) => (
<PermissionsPill
key={resource}
resource={resource}
permissions={permissions}
/>
))}
</PopoverContent>
</Popover>
<TooltipContent className="px-4 py-3 border-surface-quaternary">
<ul className="flex flex-col gap-2 list-none my-0 pl-0">
{resources.map((resource) => (
<li key={resource}>
<PermissionsPill
resource={resource}
permissions={permissions}
/>
</li>
))}
</ul>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
@@ -1,11 +1,12 @@
import { type Interpolation, type Theme, useTheme } from "@emotion/react";
import type { Interpolation, Theme } from "@emotion/react";
import Stack from "@mui/material/Stack";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "components/deprecated/Popover/Popover";
import { Pill } from "components/Pill/Pill";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import type { FC } from "react";
import { isUUID } from "utils/uuid";
@@ -34,53 +35,26 @@ interface OverflowPillProps {
}
const OverflowPill: FC<OverflowPillProps> = ({ roles }) => {
const theme = useTheme();
return (
<Popover mode="hover">
<PopoverTrigger>
<Pill
css={{
backgroundColor: theme.palette.background.paper,
borderColor: theme.palette.divider,
}}
data-testid="overflow-pill"
>
+{roles.length} more
</Pill>
</PopoverTrigger>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Pill data-testid="overflow-pill">+{roles.length} more</Pill>
</TooltipTrigger>
<PopoverContent
disableRestoreFocus
disableScrollLock
css={{
".MuiPaper-root": {
display: "flex",
flexFlow: "column wrap",
columnGap: 8,
rowGap: 12,
padding: "12px 16px",
alignContent: "space-around",
minWidth: "auto",
backgroundColor: theme.palette.background.default,
},
}}
anchorOrigin={{
vertical: -4,
horizontal: "center",
}}
transformOrigin={{
vertical: "bottom",
horizontal: "center",
}}
>
{roles.map((role) => (
<Pill key={role} css={isUUID(role) ? styles.errorPill : styles.pill}>
{role}
</Pill>
))}
</PopoverContent>
</Popover>
<TooltipContent className="px-4 py-3 border-surface-quaternary">
<ul className="flex flex-col gap-2 list-none my-0 pl-0">
{roles.map((role) => (
<li key={role}>
<Pill css={isUUID(role) ? styles.errorPill : styles.pill}>
{role}
</Pill>
</li>
))}
</ul>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
@@ -1,9 +1,11 @@
import {
MockGroup,
MockGroup2,
MockGroup3,
MockGroupSyncSettings,
MockGroupSyncSettings2,
MockLegacyMappingGroupSyncSettings,
MockMultipleOverflowGroupSyncSettings,
MockOrganization,
MockRoleSyncSettings,
} from "testHelpers/entities";
@@ -12,7 +14,7 @@ import { expect, userEvent } from "storybook/test";
import IdpSyncPageView from "./IdpSyncPageView";
const groupsMap = new Map<string, string>();
for (const group of [MockGroup, MockGroup2]) {
for (const group of [MockGroup, MockGroup2, MockGroup3]) {
groupsMap.set(group.id, group.display_name || group.name);
}
@@ -70,6 +72,12 @@ export const MissingGroups: Story = {
},
};
export const MultipleOverflowGroups: Story = {
args: {
groupSyncSettings: MockMultipleOverflowGroupSyncSettings,
},
};
export const WithLegacyMapping: Story = {
args: {
groupSyncSettings: MockLegacyMappingGroupSyncSettings,
@@ -14,15 +14,16 @@
* users like that, though, know that it will be painful
*/
import { type Interpolation, type Theme, useTheme } from "@emotion/react";
import Tooltip from "@mui/material/Tooltip";
import MUITooltip from "@mui/material/Tooltip";
import type { LoginType, SlimRole } from "api/typesGenerated";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "components/deprecated/Popover/Popover";
import { Pill } from "components/Pill/Pill";
import { TableCell } from "components/Table/Table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import type { FC } from "react";
import { EditRolesButton } from "./EditRolesButton";
@@ -87,9 +88,9 @@ export const UserRoleCell: FC<UserRoleCellProps> = ({
}
>
{mainDisplayRole.global ? (
<Tooltip title="This user has this role for all organizations.">
<MUITooltip title="This user has this role for all organizations.">
<span>{displayName}*</span>
</Tooltip>
</MUITooltip>
) : (
displayName
)}
@@ -109,51 +110,37 @@ const OverflowRolePill: FC<OverflowRolePillProps> = ({ roles }) => {
const theme = useTheme();
return (
<Popover mode="hover">
<PopoverTrigger>
<Pill
css={{
backgroundColor: theme.palette.background.paper,
borderColor: theme.palette.divider,
}}
>
+{roles.length} more
</Pill>
</PopoverTrigger>
<PopoverContent
disableRestoreFocus
disableScrollLock
css={{
".MuiPaper-root": {
display: "flex",
flexFlow: "row wrap",
columnGap: 8,
rowGap: 12,
padding: "12px 16px",
alignContent: "space-around",
minWidth: "auto",
},
}}
anchorOrigin={{ vertical: -4, horizontal: "center" }}
transformOrigin={{ vertical: "bottom", horizontal: "center" }}
>
{roles.map((role) => (
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Pill
key={role.name}
css={role.global ? styles.globalRoleBadge : styles.roleBadge}
css={{
backgroundColor: theme.palette.background.paper,
borderColor: theme.palette.divider,
}}
>
{role.global ? (
<Tooltip title="This user has this role for all organizations.">
<span>{role.display_name || role.name}*</span>
</Tooltip>
) : (
role.display_name || role.name
)}
+{roles.length} more
</Pill>
))}
</PopoverContent>
</Popover>
</TooltipTrigger>
<TooltipContent className="flex flex-row flex-wrap content-around gap-x-2 gap-y-3 px-4 py-3 border-surface-quaternary">
{roles.map((role) => (
<Pill
key={role.name}
css={role.global ? styles.globalRoleBadge : styles.roleBadge}
>
{role.global ? (
<MUITooltip title="This user has this role for all organizations.">
<span>{role.display_name || role.name}*</span>
</MUITooltip>
) : (
role.display_name || role.name
)}
</Pill>
))}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
@@ -3,13 +3,14 @@ import List from "@mui/material/List";
import ListItem from "@mui/material/ListItem";
import type { Group } from "api/typesGenerated";
import { Avatar } from "components/Avatar/Avatar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "components/deprecated/Popover/Popover";
import { OverflowY } from "components/OverflowY/OverflowY";
import { TableCell } from "components/Table/Table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { UsersIcon } from "lucide-react";
import type { FC } from "react";
import { cn } from "utils/cn";
@@ -29,99 +30,83 @@ export const UserGroupsCell: FC<GroupsCellProps> = ({ userGroups }) => {
// the table UI
<em css={{ fontStyle: "normal" }}>N/A</em>
) : (
<Popover mode="hover">
<PopoverTrigger>
<button
css={{
cursor: "pointer",
backgroundColor: "transparent",
border: "none",
padding: 0,
color: "inherit",
lineHeight: "1",
}}
type="button"
>
<div className="flex flex-row gap-2 items-center">
<UsersIcon
className={cn([
"size-4 opacity-50",
{
"opacity-80": userGroups.length > 0,
},
])}
/>
<span>
{userGroups.length} Group{userGroups.length !== 1 && "s"}
</span>
</div>
</button>
</PopoverTrigger>
<PopoverContent
disableScrollLock
disableRestoreFocus
css={{
".MuiPaper-root": {
minWidth: "auto",
},
}}
anchorOrigin={{
vertical: "top",
horizontal: "center",
}}
transformOrigin={{
vertical: "bottom",
horizontal: "center",
}}
>
<OverflowY maxHeight={400}>
<List
component="ul"
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<button
css={{
display: "flex",
flexFlow: "column nowrap",
fontSize: theme.typography.body2.fontSize,
padding: "4px 2px",
gap: 0,
cursor: "pointer",
backgroundColor: "transparent",
border: "none",
padding: 0,
color: "inherit",
lineHeight: "1",
}}
type="button"
>
{userGroups.map((group) => {
const groupName = group.display_name || group.name;
return (
<ListItem
key={group.id}
css={{
columnGap: 10,
alignItems: "center",
}}
>
<Avatar
size="sm"
variant="icon"
src={group.avatar_url}
fallback={groupName}
/>
<div className="flex flex-row gap-2 items-center">
<UsersIcon
className={cn([
"size-4 opacity-50",
userGroups.length > 0 && "opacity-80",
])}
/>
<span
<span>
{userGroups.length} Group{userGroups.length !== 1 && "s"}
</span>
</div>
</button>
</TooltipTrigger>
<TooltipContent className="p-0 bg-surface-secondary border-surface-quaternary text-white">
<OverflowY maxHeight={400}>
<List
component="ul"
css={{
display: "flex",
flexFlow: "column nowrap",
fontSize: theme.typography.body2.fontSize,
padding: "4px 2px",
gap: 0,
}}
>
{userGroups.map((group) => {
const groupName = group.display_name || group.name;
return (
<ListItem
key={group.id}
css={{
whiteSpace: "nowrap",
textOverflow: "ellipsis",
overflow: "hidden",
lineHeight: 1,
margin: 0,
columnGap: 10,
alignItems: "center",
}}
>
{groupName || <em>N/A</em>}
</span>
</ListItem>
);
})}
</List>
</OverflowY>
</PopoverContent>
</Popover>
<Avatar
size="sm"
variant="icon"
src={group.avatar_url}
fallback={groupName}
/>
<span
css={{
whiteSpace: "nowrap",
textOverflow: "ellipsis",
overflow: "hidden",
lineHeight: 1,
margin: 0,
}}
>
{groupName || <em>N/A</em>}
</span>
</ListItem>
);
})}
</List>
</OverflowY>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</TableCell>
);
@@ -1,14 +1,14 @@
import { type Interpolation, type Theme, useTheme } from "@emotion/react";
import type { AlertProps } from "components/Alert/Alert";
import { Button, type ButtonProps } from "components/Button/Button";
import {
Popover,
PopoverContent,
PopoverTrigger,
usePopover,
} from "components/deprecated/Popover/Popover";
import { Pill } from "components/Pill/Pill";
import type { FC, ReactNode } from "react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { type FC, type ReactNode, useState } from "react";
import type { ThemeRole } from "theme/roles";
export type NotificationItem = {
@@ -29,48 +29,58 @@ export const Notifications: FC<NotificationsProps> = ({
severity,
icon,
}) => {
const [isOpen, setIsOpen] = useState(false);
const theme = useTheme();
return (
<Popover mode="hover">
<PopoverTrigger>
<div
css={styles.pillContainer}
data-testid={`${severity}-notifications`}
>
<NotificationPill items={items} severity={severity} icon={icon} />
</div>
</PopoverTrigger>
<PopoverContent
horizontal="right"
css={{
"& .MuiPaper-root": {
<TooltipProvider>
<Tooltip open={isOpen} onOpenChange={setIsOpen} delayDuration={0}>
<TooltipTrigger asChild>
<div
css={styles.pillContainer}
data-testid={`${severity}-notifications`}
>
<NotificationPill
items={items}
severity={severity}
icon={icon}
isTooltipOpen={isOpen}
/>
</div>
</TooltipTrigger>
<TooltipContent
align="end"
collisionPadding={16}
className="max-w-[400px] p-0 bg-surface-secondary border-surface-quaternary text-sm text-white"
style={{
borderColor: theme.roles[severity].outline,
maxWidth: 400,
},
}}
>
{items.map((n) => (
<NotificationItem notification={n} key={n.title} />
))}
</PopoverContent>
</Popover>
}}
>
{items.map((n) => (
<NotificationItem notification={n} key={n.title} />
))}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
const NotificationPill: FC<NotificationsProps> = ({
type NotificationPillProps = NotificationsProps & {
isTooltipOpen: boolean;
};
const NotificationPill: FC<NotificationPillProps> = ({
items,
severity,
icon,
isTooltipOpen,
}) => {
const popover = usePopover();
return (
<Pill
icon={icon}
css={(theme) => ({
"& svg": { color: theme.roles[severity].outline },
borderColor: popover.open ? theme.roles[severity].outline : undefined,
borderColor: isTooltipOpen ? theme.roles[severity].outline : undefined,
})}
>
{items.length}
@@ -99,7 +109,7 @@ export const NotificationActionButton: FC<ButtonProps> = (props) => {
};
const styles = {
// Adds some spacing from the popover content
// Adds some spacing from the Tooltip content
pillContainer: {
padding: "8px 0",
},
@@ -9,7 +9,7 @@ import { withDashboardProvider } from "testHelpers/storybook";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { getWorkspaceResolveAutostartQueryKey } from "api/queries/workspaceQuota";
import type { WorkspacePermissions } from "modules/workspaces/permissions";
import { expect, userEvent, waitFor, within } from "storybook/test";
import { expect, screen, userEvent, waitFor } from "storybook/test";
import { WorkspaceNotifications } from "./WorkspaceNotifications";
export const defaultPermissions: WorkspacePermissions = {
@@ -51,15 +51,13 @@ export const Outdated: Story = {
workspace: MockOutdatedWorkspace,
},
play: async ({ canvasElement, step }) => {
const screen = within(canvasElement);
play: async ({ step }) => {
await step("activate hover trigger", async () => {
await userEvent.hover(screen.getByTestId("info-notifications"));
await waitFor(() =>
expect(
screen.getByText(MockTemplateVersion.message),
).toBeInTheDocument(),
expect(screen.getByRole("tooltip")).toHaveTextContent(
MockTemplateVersion.message,
),
);
});
},
@@ -71,13 +69,13 @@ export const OutdatedWithMarkdownMessage: Story = {
latestVersion: MockTemplateVersionWithMarkdownMessage,
},
play: async ({ canvasElement, step }) => {
const screen = within(canvasElement);
play: async ({ step }) => {
await step("activate hover trigger", async () => {
await userEvent.hover(screen.getByTestId("info-notifications"));
await waitFor(() =>
expect(screen.getByText(/an update is available/i)).toBeInTheDocument(),
expect(screen.getByRole("tooltip")).toHaveTextContent(
/an update is available/i,
),
);
});
},
@@ -102,15 +100,13 @@ export const RequiresManualUpdate: Story = {
],
},
play: async ({ canvasElement, step }) => {
const screen = within(canvasElement);
play: async ({ step }) => {
await step("activate hover trigger", async () => {
await userEvent.hover(screen.getByTestId("warning-notifications"));
await waitFor(() =>
expect(
screen.getByText(/unable to automatically update/i),
).toBeInTheDocument(),
expect(screen.getByRole("tooltip")).toHaveTextContent(
/unable to automatically update/i,
),
);
});
},
@@ -131,13 +127,13 @@ export const Unhealthy: Story = {
},
},
play: async ({ canvasElement, step }) => {
const screen = within(canvasElement);
play: async ({ step }) => {
await step("activate hover trigger", async () => {
await userEvent.hover(screen.getByTestId("warning-notifications"));
await waitFor(() =>
expect(screen.getByText(/workspace is unhealthy/i)).toBeInTheDocument(),
expect(screen.getByRole("tooltip")).toHaveTextContent(
/workspace is unhealthy/i,
),
);
});
},
@@ -165,13 +161,13 @@ export const Dormant: Story = {
workspace: DormantWorkspace,
},
play: async ({ canvasElement, step }) => {
const screen = within(canvasElement);
play: async ({ step }) => {
await step("activate hover trigger", async () => {
await userEvent.hover(screen.getByTestId("warning-notifications"));
await waitFor(() =>
expect(screen.getByText(/workspace is dormant/i)).toBeInTheDocument(),
expect(screen.getByRole("tooltip")).toHaveTextContent(
/workspace is dormant/i,
),
);
});
},
@@ -205,13 +201,13 @@ export const PendingInQueue: Story = {
},
},
play: async ({ canvasElement, step }) => {
const screen = within(canvasElement);
play: async ({ step }) => {
await step("activate hover trigger", async () => {
await userEvent.hover(await screen.findByTestId("info-notifications"));
await waitFor(() =>
expect(screen.getByText(/build is pending/i)).toBeInTheDocument(),
expect(screen.getByRole("tooltip")).toHaveTextContent(
/build is pending/i,
),
);
});
},
@@ -227,13 +223,13 @@ export const TemplateDeprecated: Story = {
},
},
play: async ({ canvasElement, step }) => {
const screen = within(canvasElement);
play: async ({ step }) => {
await step("activate hover trigger", async () => {
await userEvent.hover(screen.getByTestId("warning-notifications"));
await waitFor(() =>
expect(screen.getByText(/deprecated template/i)).toBeInTheDocument(),
expect(screen.getByRole("tooltip")).toHaveTextContent(
/deprecated template/i,
),
);
});
},
+45 -1
View File
@@ -39,6 +39,18 @@ export const MockOrganization2: TypesGen.Organization = {
is_default: false,
};
export const MockOrganization3: TypesGen.Organization = {
id: "my-organization-3-id",
name: "my-organization-3",
display_name: "My Organization 3",
description:
"Yet another organization that will show up in OrganizationPills.",
icon: "/emojis/1f957.png",
created_at: "",
updated_at: "",
is_default: false,
};
export const MockTemplateDAUResponse: TypesGen.DAUsResponse = {
tz_hour_offset: 0,
entries: [
@@ -2905,6 +2917,20 @@ export const MockGroupSyncSettings2: TypesGen.GroupSyncSettings = {
auto_create_missing_groups: false,
};
export const MockMultipleOverflowGroupSyncSettings: TypesGen.GroupSyncSettings =
{
field: "group-multiple-overflow-test",
mapping: {
"idp-group-1": [
"fbd2116a-8961-4954-87ae-e4575bd29ce0",
"13de3eb4-9b4f-49e7-b0f8-0c3728a0d2e2",
"d3562dc1-c120-43a9-ba02-88e43bbca192",
],
},
regex_filter: "@[a-zA-Z0-9_]+",
auto_create_missing_groups: false,
};
export const MockRoleSyncSettings: TypesGen.RoleSyncSettings = {
field: "role-test",
mapping: {
@@ -2929,7 +2955,11 @@ export const MockOrganizationSyncSettings2: TypesGen.OrganizationSyncSettings =
{
field: "organization-test",
mapping: {
"idp-org-1": ["my-organization-id", "my-organization-2-id"],
"idp-org-1": [
"my-organization-id",
"my-organization-2-id",
"my-organization-3-id",
],
"idp-org-2": ["my-organization-id"],
},
organization_assign_default: true,
@@ -2970,6 +3000,20 @@ export const MockGroup2: TypesGen.Group = {
total_member_count: 2,
};
export const MockGroup3: TypesGen.Group = {
id: "d3562dc1-c120-43a9-ba02-88e43bbca192",
name: "Back-End",
display_name: "",
avatar_url: "https://example.com",
organization_id: MockOrganization.id,
organization_name: MockOrganization.name,
organization_display_name: MockOrganization.display_name,
members: [MockUserOwner, MockUserMember],
quota_allowance: 5,
source: "user",
total_member_count: 2,
};
const MockEveryoneGroup: TypesGen.Group = {
// The "Everyone" group must have the same ID as a the organization it belongs
// to.