Skip to content

feat(site): add WorkspacesButton component #10011

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 26 commits into from
Oct 5, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
f0a2aae
chore: Add OverflowY component
Parkreiner Oct 3, 2023
2420167
chore: Add PopoverContainer component
Parkreiner Oct 3, 2023
88d73af
chore: Add SearchBox
Parkreiner Oct 3, 2023
209eed4
feat: add WorkspacesButton
Parkreiner Oct 3, 2023
9300b11
chore: Install MUI utils package
Parkreiner Oct 3, 2023
2b13f1e
chore: integrate WorkspacesButton
Parkreiner Oct 3, 2023
e86207e
chore: reorganize files
Parkreiner Oct 3, 2023
5b249d5
fix: resolve hover state visual glitch
Parkreiner Oct 3, 2023
b1e1271
chore: Add story for OverflowY
Parkreiner Oct 3, 2023
b367495
fix: remove dynamic name from OverflowY story
Parkreiner Oct 3, 2023
5a34769
chore: update stories again
Parkreiner Oct 3, 2023
b9f6cb8
fix: remove all references to icons (for now)
Parkreiner Oct 3, 2023
1996e2b
refactor: move flex shrink to be OverflowY concern
Parkreiner Oct 4, 2023
5ecbbe6
fix: remove needless render key
Parkreiner Oct 4, 2023
55ab10a
fix: make sure popover closes before navigation
Parkreiner Oct 5, 2023
1a01127
refactor: clean up WorkspacesButton to use more native MUI
Parkreiner Oct 5, 2023
80928c6
Merge branch 'main' into mes/workspace-button-2
Parkreiner Oct 5, 2023
55c6061
fix: update integration into rest of view
Parkreiner Oct 5, 2023
5aea06f
fix: remove JS security concern
Parkreiner Oct 5, 2023
2aee4fe
refactor: parameterize button language
Parkreiner Oct 5, 2023
12ac56f
revert: undo sql/go file change
Parkreiner Oct 5, 2023
99f2656
fix: remove permissions dependency
Parkreiner Oct 5, 2023
430e30c
fix: simplify button prop types
Parkreiner Oct 5, 2023
ff37ab5
fix: lift data dependencies to page component
Parkreiner Oct 5, 2023
1b934ab
refactor: clean up props
Parkreiner Oct 5, 2023
84c6642
fix: update dependencies again for Storybook
Parkreiner Oct 5, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
fix: make sure popover closes before navigation
  • Loading branch information
Parkreiner committed Oct 5, 2023
commit 55ab10a8ff7266033636f928e61094e9cf012dea
161 changes: 128 additions & 33 deletions site/src/components/PopoverContainer/PopoverContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,49 +1,138 @@
/**
* @file Abstracts over MUI's Popover component to simplify using it (and hide)
* some of the wonkier parts of the API.
* @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,
PropsWithChildren,
} from "react";

import { type Theme, type SystemStyleObject } from "@mui/system";
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";

type Props = PropsWithChildren<{
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);
});
};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


return (
<Box
component="a"
// Href still needed for accessibility reasons and semantic markup
href="javascript:void(0)"
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>;
}>;

function getButton(container: HTMLElement) {
return (
container.querySelector("button") ??
container.querySelector('[aria-role="button"]')
);
}

export function PopoverContainer({
children,
anchorButton,
originX = 0,
originY = 0,
width = 320,
sx = {},
}: Props) {
}: 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
Expand Down Expand Up @@ -107,6 +196,10 @@ export function PopoverContainer({
}
};

const closePopover = useCallback(() => {
setLoadedButton(undefined);
}, []);

return (
<>
{/* Cannot switch with Box component; breaks implementation */}
Expand All @@ -124,26 +217,28 @@ export function PopoverContainer({
{anchorButton}
</div>

<Popover
open={loadedButton !== undefined}
anchorEl={loadedButton}
onClose={() => setLoadedButton(undefined)}
anchorOrigin={{ horizontal: originX, vertical: originY }}
sx={{
"& .MuiPaper-root": {
overflowY: "hidden",
width,
paddingY: 0,
...sx,
},
}}
transitionDuration={{
enter: 300,
exit: 0,
}}
>
{children}
</Popover>
<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>
</>
);
}
26 changes: 7 additions & 19 deletions site/src/pages/WorkspacesPage/WorkspacesButton.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ReactNode, useState } from "react";
import { type ReactNode, useState } from "react";
import { useOrganizationId, usePermissions } from "hooks";

import { useQuery } from "@tanstack/react-query";
Expand All @@ -13,11 +13,14 @@ import AddIcon from "@mui/icons-material/AddOutlined";
import OpenIcon from "@mui/icons-material/OpenInNewOutlined";

import { Loader } from "components/Loader/Loader";
import { PopoverContainer } from "components/PopoverContainer/PopoverContainer";
import { OverflowY } from "components/OverflowY/OverflowY";
import { EmptyState } from "components/EmptyState/EmptyState";
import { Avatar } from "components/Avatar/Avatar";
import { SearchBox } from "./WorkspacesSearchBox";
import {
PopoverContainer,
PopoverLink,
} from "components/PopoverContainer/PopoverContainer";

const ICON_SIZE = 18;
const COLUMN_GAP = 1.5;
Expand All @@ -40,22 +43,7 @@ function sortTemplatesByUsersDesc(

function WorkspaceResultsRow({ template }: { template: Template }) {
return (
<Link
component={RouterLink}
// Sending user directly to workspace creation page for UX
// reasons; avoids extra clicks on the user's part
to={`/templates/${template.name}/workspace`}
sx={{
outline: "none",
"&:focus": {
backgroundColor: (theme) => theme.palette.action.focus,
},
"&:hover": {
textDecoration: "none",
backgroundColor: (theme) => theme.palette.action.hover,
},
}}
>
<PopoverLink to={`/templates/${template.name}/workspace`}>
<Box
sx={{
display: "flex",
Expand Down Expand Up @@ -122,7 +110,7 @@ function WorkspaceResultsRow({ template }: { template: Template }) {
</Box>
</Box>
</Box>
</Link>
</PopoverLink>
);
}

Expand Down