mirror of https://github.com/coder/coder.git
refactor: move all code to hypothetical useClassNames (just to see if it's possible - maybe this should all be deleted)
This commit is contained in:
parent
154380b111
commit
d650cfae22
|
@ -151,9 +151,7 @@ rules:
|
|||
message: "Import from lodash/<name> instead."
|
||||
no-unused-vars: "off"
|
||||
"object-curly-spacing": "off"
|
||||
react-hooks/exhaustive-deps:
|
||||
- warn
|
||||
- additionalHooks: "(useClassName)"
|
||||
react-hooks/exhaustive-deps: warn
|
||||
react-hooks/rules-of-hooks: error
|
||||
react/display-name: "off"
|
||||
react/jsx-no-script-url:
|
||||
|
|
|
@ -4,7 +4,7 @@ import { useTheme } from "@emotion/react";
|
|||
import { type FC } from "react";
|
||||
import type { WorkspaceBuild } from "api/typesGenerated";
|
||||
import { getDisplayWorkspaceBuildStatus } from "utils/workspace";
|
||||
import { useClassName } from "hooks/useClassName";
|
||||
import { makeClassNames } from "hooks/useClassNames";
|
||||
import { Avatar, AvatarProps } from "components/Avatar/Avatar";
|
||||
import { BuildIcon } from "components/BuildIcon/BuildIcon";
|
||||
|
||||
|
@ -13,13 +13,20 @@ export interface BuildAvatarProps {
|
|||
size?: AvatarProps["size"];
|
||||
}
|
||||
|
||||
type WorkspaceType = ReturnType<typeof getDisplayWorkspaceBuildStatus>["type"];
|
||||
|
||||
const useClassNames = makeClassNames<"badgeType", { type: WorkspaceType }>(
|
||||
(css, theme) => ({
|
||||
badgeType: ({ type }) => {
|
||||
return css({ backgroundColor: theme.palette[type].light });
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export const BuildAvatar: FC<BuildAvatarProps> = ({ build, size }) => {
|
||||
const theme = useTheme();
|
||||
const { status, type } = getDisplayWorkspaceBuildStatus(theme, build);
|
||||
const badgeType = useClassName(
|
||||
(css, theme) => css({ backgroundColor: theme.palette[type].light }),
|
||||
[type],
|
||||
);
|
||||
const { badgeType } = useClassNames({ type });
|
||||
|
||||
return (
|
||||
<Badge
|
||||
|
|
|
@ -5,7 +5,7 @@ import Snackbar, {
|
|||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import { type FC } from "react";
|
||||
import { type Interpolation, type Theme } from "@emotion/react";
|
||||
import { useClassName } from "hooks/useClassName";
|
||||
import { makeClassNames } from "hooks/useClassNames";
|
||||
|
||||
type EnterpriseSnackbarVariant = "error" | "info" | "success";
|
||||
|
||||
|
@ -16,6 +16,21 @@ export interface EnterpriseSnackbarProps extends MuiSnackbarProps {
|
|||
variant?: EnterpriseSnackbarVariant;
|
||||
}
|
||||
|
||||
type HookInput = Readonly<{ variant: EnterpriseSnackbarVariant }>;
|
||||
|
||||
const useClassNames = makeClassNames<"content", HookInput>((css, theme) => ({
|
||||
content: ({ variant }) => css`
|
||||
border: 1px solid ${theme.palette.divider};
|
||||
border-left: 4px solid ${variantColor(variant, theme)};
|
||||
border-radius: 8px;
|
||||
padding: 8px 24px 8px 16px;
|
||||
box-shadow: ${theme.shadows[6]};
|
||||
align-items: inherit;
|
||||
background-color: ${theme.palette.background.paper};
|
||||
color: ${theme.palette.text.secondary};
|
||||
`,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Wrapper around Material UI's Snackbar component, provides pre-configured
|
||||
* themes and convenience props. Coder UI's Snackbars require a close handler,
|
||||
|
@ -35,19 +50,7 @@ export const EnterpriseSnackbar: FC<EnterpriseSnackbarProps> = ({
|
|||
action,
|
||||
...snackbarProps
|
||||
}) => {
|
||||
const content = useClassName(
|
||||
(css, theme) => css`
|
||||
border: 1px solid ${theme.palette.divider};
|
||||
border-left: 4px solid ${variantColor(variant, theme)};
|
||||
border-radius: 8px;
|
||||
padding: 8px 24px 8px 16px;
|
||||
box-shadow: ${theme.shadows[6]};
|
||||
align-items: inherit;
|
||||
background-color: ${theme.palette.background.paper};
|
||||
color: ${theme.palette.text.secondary};
|
||||
`,
|
||||
[variant],
|
||||
);
|
||||
const classNames = useClassNames({ variant });
|
||||
|
||||
return (
|
||||
<Snackbar
|
||||
|
@ -65,7 +68,7 @@ export const EnterpriseSnackbar: FC<EnterpriseSnackbarProps> = ({
|
|||
}
|
||||
ContentProps={{
|
||||
...ContentProps,
|
||||
className: content,
|
||||
className: classNames.content,
|
||||
}}
|
||||
onClose={onClose}
|
||||
{...snackbarProps}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import Badge from "@mui/material/Badge";
|
||||
import Group from "@mui/icons-material/Group";
|
||||
import { type FC } from "react";
|
||||
import { type ClassName, useClassName } from "hooks/useClassName";
|
||||
import { Avatar } from "components/Avatar/Avatar";
|
||||
import { makeClassNames } from "hooks/useClassNames";
|
||||
|
||||
export interface GroupAvatarProps {
|
||||
name: string;
|
||||
|
@ -10,14 +10,14 @@ export interface GroupAvatarProps {
|
|||
}
|
||||
|
||||
export const GroupAvatar: FC<GroupAvatarProps> = ({ name, avatarURL }) => {
|
||||
const badge = useClassName((css, theme) => classNames.badge(css, theme), []);
|
||||
const classNames = useClassNames(null);
|
||||
|
||||
return (
|
||||
<Badge
|
||||
overlap="circular"
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
|
||||
badgeContent={<Group />}
|
||||
classes={{ badge }}
|
||||
classes={{ badge: classNames.badge }}
|
||||
>
|
||||
<Avatar background src={avatarURL}>
|
||||
{name}
|
||||
|
@ -26,21 +26,20 @@ export const GroupAvatar: FC<GroupAvatarProps> = ({ name, avatarURL }) => {
|
|||
);
|
||||
};
|
||||
|
||||
const classNames = {
|
||||
badge: (css, theme) =>
|
||||
css({
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: "100%",
|
||||
width: 24,
|
||||
height: 24,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
const useClassNames = makeClassNames((css, theme) => ({
|
||||
badge: css({
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: "100%",
|
||||
width: 24,
|
||||
height: 24,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
|
||||
"& svg": {
|
||||
width: 14,
|
||||
height: 14,
|
||||
},
|
||||
}),
|
||||
} satisfies Record<string, ClassName>;
|
||||
"& svg": {
|
||||
width: 14,
|
||||
height: 14,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
|
|
@ -3,7 +3,7 @@ import { type CSSObject, type Interpolation, type Theme } from "@emotion/react";
|
|||
import { type ElementType, type FC, type ReactNode } from "react";
|
||||
import { Link, NavLink } from "react-router-dom";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { type ClassName, useClassName } from "hooks/useClassName";
|
||||
import { makeClassNames } from "hooks/useClassNames";
|
||||
|
||||
interface SidebarProps {
|
||||
children?: ReactNode;
|
||||
|
@ -60,17 +60,15 @@ export const SidebarNavItem: FC<SidebarNavItemProps> = ({
|
|||
href,
|
||||
icon: Icon,
|
||||
}) => {
|
||||
const link = useClassName((css, theme) => classNames.link(css, theme), []);
|
||||
const activeLink = useClassName(
|
||||
(css, theme) => classNames.activeLink(css, theme),
|
||||
[],
|
||||
);
|
||||
const classNames = useClassNames(null);
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
end
|
||||
to={href}
|
||||
className={({ isActive }) => cx([link, isActive && activeLink])}
|
||||
className={({ isActive }) =>
|
||||
cx([classNames.link, isActive && classNames.activeLink])
|
||||
}
|
||||
>
|
||||
<Stack alignItems="center" spacing={1.5} direction="row">
|
||||
<Icon css={{ width: 16, height: 16 }} />
|
||||
|
@ -106,8 +104,8 @@ const styles = {
|
|||
}),
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
||||
|
||||
const classNames = {
|
||||
link: (css, theme) => css`
|
||||
const useClassNames = makeClassNames((css, theme) => ({
|
||||
link: css`
|
||||
color: inherit;
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
|
@ -123,7 +121,7 @@ const classNames = {
|
|||
}
|
||||
`,
|
||||
|
||||
activeLink: (css, theme) => css`
|
||||
activeLink: css`
|
||||
background-color: ${theme.palette.action.hover};
|
||||
|
||||
&:before {
|
||||
|
@ -139,4 +137,4 @@ const classNames = {
|
|||
border-bottom-left-radius: 8px;
|
||||
}
|
||||
`,
|
||||
} satisfies Record<string, ClassName>;
|
||||
}));
|
||||
|
|
|
@ -2,7 +2,7 @@ import { cx } from "@emotion/css";
|
|||
import { type FC, type PropsWithChildren } from "react";
|
||||
import { NavLink, NavLinkProps } from "react-router-dom";
|
||||
import { Margins } from "components/Margins/Margins";
|
||||
import { type ClassName, useClassName } from "hooks/useClassName";
|
||||
import { makeClassNames } from "hooks/useClassNames";
|
||||
|
||||
export const Tabs: FC<PropsWithChildren> = ({ children }) => {
|
||||
return (
|
||||
|
@ -34,20 +34,16 @@ export const TabLink: FC<TabLinkProps> = ({
|
|||
children,
|
||||
...linkProps
|
||||
}) => {
|
||||
const tabLink = useClassName(
|
||||
(css, theme) => classNames.tabLink(css, theme),
|
||||
[],
|
||||
);
|
||||
|
||||
const activeTabLink = useClassName(
|
||||
(css, theme) => classNames.activeTabLink(css, theme),
|
||||
[],
|
||||
);
|
||||
const classNames = useClassNames(null);
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
className={({ isActive }) =>
|
||||
cx([tabLink, isActive && activeTabLink, className])
|
||||
cx([
|
||||
classNames.tabLink,
|
||||
isActive && classNames.activeTabLink,
|
||||
className,
|
||||
])
|
||||
}
|
||||
{...linkProps}
|
||||
>
|
||||
|
@ -56,8 +52,8 @@ export const TabLink: FC<TabLinkProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
const classNames = {
|
||||
tabLink: (css, theme) => css`
|
||||
const useClassNames = makeClassNames((css, theme) => ({
|
||||
tabLink: css`
|
||||
text-decoration: none;
|
||||
color: ${theme.palette.text.secondary};
|
||||
font-size: 14px;
|
||||
|
@ -68,7 +64,7 @@ const classNames = {
|
|||
color: ${theme.palette.text.primary};
|
||||
}
|
||||
`,
|
||||
activeTabLink: (css, theme) => css`
|
||||
activeTabLink: css`
|
||||
color: ${theme.palette.text.primary};
|
||||
position: relative;
|
||||
|
||||
|
@ -82,4 +78,4 @@ const classNames = {
|
|||
position: absolute;
|
||||
}
|
||||
`,
|
||||
} satisfies Record<string, ClassName>;
|
||||
}));
|
||||
|
|
|
@ -1,216 +0,0 @@
|
|||
import { type Theme, useTheme } from "@emotion/react";
|
||||
import { css } from "@emotion/css";
|
||||
import { useState } from "react";
|
||||
|
||||
type Primitive = string | number | boolean | null | undefined | symbol | bigint;
|
||||
type EmptyObject = Record<string, never>;
|
||||
|
||||
type CSSInput = Readonly<{
|
||||
css: typeof css;
|
||||
theme: Theme;
|
||||
}>;
|
||||
|
||||
type ClassNameFunction<TInput extends NonNullable<unknown>> = (
|
||||
args: CSSInput & TInput,
|
||||
) => string;
|
||||
|
||||
type MakeClassNamesResult<
|
||||
THookInput extends Record<string, Primitive>,
|
||||
TConfig extends Record<string, ClassNameFunction<THookInput>>,
|
||||
> = (hookInput: THookInput) => Readonly<Record<keyof TConfig, string>>;
|
||||
|
||||
/**
|
||||
* Hook factory for giving you an escape hatch for making Emotion styles. This
|
||||
* should be used as a last resort; use the React CSS prop whenever possible.
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* Sometimes you need to combine/collate styles using the className prop in a
|
||||
* component, but Emotion does not give you an easy way to define a className
|
||||
* and use it from within the same component.
|
||||
*
|
||||
* Other times, you need to use inputs that will change on each render to make
|
||||
* your styles, but you only want the styles relying on those inputs to be
|
||||
* re-computed when the input actually changes. Otherwise, CSS will keep
|
||||
* thrashing the <style> tag with diffs each and every render.
|
||||
*
|
||||
* Also, sometimes just you don't want to think about dependency arrays and
|
||||
* stale closure issues.
|
||||
*
|
||||
* This function solves all three problems. The custom hook it returns will give
|
||||
* you a type-safe collection of className values, and auto-memoize all inputs,
|
||||
* only re-computing new CSS styles when one of the inputs changes by reference.
|
||||
*
|
||||
* Making that memoization possible comes with two caveats:
|
||||
* 1. All inputs fed into the hook must be primitives. No nested objects or
|
||||
* functions or arrays.
|
||||
* 2. All styles defined via the hook are tied to the same memoization cache. If
|
||||
* one of the inputs changes, all classnames for the hook will be
|
||||
* re-computed, even if none of the classnames actually use the input that
|
||||
* changed.
|
||||
*
|
||||
* If (2) is a performance problem, you can define separate hooks by calling
|
||||
* makeClassNames multiple times for each hook you need.
|
||||
*/
|
||||
export function makeClassNames<
|
||||
THookInput extends Record<string, Primitive> = EmptyObject,
|
||||
TConfig extends Record<string, ClassNameFunction<THookInput>> = Record<
|
||||
string,
|
||||
ClassNameFunction<THookInput>
|
||||
>,
|
||||
>(styleConfig: TConfig): MakeClassNamesResult<THookInput, TConfig> {
|
||||
type StyleRecord = Record<keyof TConfig, string>;
|
||||
|
||||
const computeNewStyles = (theme: Theme, hookInput: THookInput) => {
|
||||
const result: Partial<StyleRecord> = {};
|
||||
for (const key in styleConfig) {
|
||||
const configFunc = styleConfig[key];
|
||||
result[key] = configFunc({ css, theme, ...hookInput });
|
||||
}
|
||||
|
||||
return result as Readonly<StyleRecord>;
|
||||
};
|
||||
|
||||
const didInputsChangeByValue = (
|
||||
inputs1: THookInput,
|
||||
inputs2: THookInput,
|
||||
): boolean => {
|
||||
for (const key in inputs1) {
|
||||
const value1 = inputs1[key];
|
||||
const value2 = inputs2[key];
|
||||
|
||||
if (Number.isNaN(value1) && Number.isNaN(value2)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value1 !== value2) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
return function useClassNames(hookInputs) {
|
||||
const activeTheme = useTheme();
|
||||
const computeNewCacheValue = () => ({
|
||||
theme: activeTheme,
|
||||
inputs: hookInputs,
|
||||
styles: computeNewStyles(activeTheme, hookInputs),
|
||||
});
|
||||
|
||||
const [cache, setCache] = useState(computeNewCacheValue);
|
||||
const needNewStyles =
|
||||
cache.theme !== activeTheme ||
|
||||
didInputsChangeByValue(cache.inputs, hookInputs);
|
||||
|
||||
if (needNewStyles) {
|
||||
setCache(computeNewCacheValue());
|
||||
}
|
||||
|
||||
return cache.styles;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Issues left to figure out:
|
||||
* 1. Bare minimum, you'll almost always want to pass in one type parameter for
|
||||
* the additional inputs that you're accessing. Having one explicit type
|
||||
* parameter should not break type inference for the other type parameter,
|
||||
* and destroy auto-complete for classNames's properties
|
||||
* 2. If the hook is being called with no additional inputs, it'd be nice if you
|
||||
* could just call the hook with no arguments whatsoever
|
||||
*/
|
||||
type HookInput = Readonly<{
|
||||
paddingTop: number;
|
||||
variant: "contained" | "stroked";
|
||||
}>;
|
||||
|
||||
const useClassNames = makeClassNames<HookInput>({
|
||||
class1: ({ css, theme, paddingTop }) => css`
|
||||
background-color: red;
|
||||
padding: ${theme.spacing(2)};
|
||||
padding-top: ${paddingTop}px;
|
||||
`,
|
||||
|
||||
class2: ({ css, variant }) => css`
|
||||
color: ${variant === "contained" ? "red" : "blue"};
|
||||
`,
|
||||
});
|
||||
|
||||
/**
|
||||
* Idea I have - there are two main benefits to having the main function
|
||||
* argument be defined like this:
|
||||
* 1. Gives you another function boundary, so TConfig should hopefully be
|
||||
* bindable to it. That lets you split up the type parameters and gives you
|
||||
* more options for restoring type inference/auto-complete
|
||||
* 2. The logic centralizes the css and theme arguments, which reduces keyboard
|
||||
* typing
|
||||
*/
|
||||
// const useRevampedClassNames = makeClassNames<HookInput>((css, theme) => ({
|
||||
// class1: ({ paddingTop }) => css`
|
||||
// background-color: red;
|
||||
// padding: ${theme.spacing(2)};
|
||||
// padding-top: ${paddingTop}px;
|
||||
// `,
|
||||
|
||||
// class2: ({ variant }) => css`
|
||||
// color: ${variant === "contained" ? "red" : "blue"};
|
||||
// `,
|
||||
// }));
|
||||
|
||||
// function makeClassNames2(styleFunction) {
|
||||
// const computeNewStyles = (theme, hookInput) => {
|
||||
// const stylesObject = styleFunction(css, theme);
|
||||
// const result = {};
|
||||
|
||||
// for (const key in stylesObject) {
|
||||
// const configFunc = stylesObject[key];
|
||||
// result[key] = configFunc(hookInput);
|
||||
// }
|
||||
|
||||
// return result;
|
||||
// };
|
||||
|
||||
// const didInputsChangeByValue = (inputs1, inputs2) => {
|
||||
// for (const key in inputs1) {
|
||||
// const value1 = inputs1[key];
|
||||
// const value2 = inputs2[key];
|
||||
|
||||
// if (Number.isNaN(value1) && Number.isNaN(value2)) {
|
||||
// continue;
|
||||
// }
|
||||
|
||||
// if (value1 !== value2) {
|
||||
// return true;
|
||||
// }
|
||||
// }
|
||||
|
||||
// return false;
|
||||
// };
|
||||
|
||||
// return function useClassNames(hookInput) {
|
||||
// const activeTheme = useTheme();
|
||||
// const computeNewCacheValue = () => ({
|
||||
// prevTheme: activeTheme,
|
||||
// prevInput: hookInput,
|
||||
// styles: computeNewStyles(activeTheme, hookInput),
|
||||
// });
|
||||
|
||||
// const [cache, setCache] = useState(computeNewCacheValue);
|
||||
// const needNewStyles =
|
||||
// cache.prevTheme !== activeTheme ||
|
||||
// didInputsChangeByValue(cache.prevInput, hookInput);
|
||||
|
||||
// if (needNewStyles) {
|
||||
// setCache(computeNewCacheValue());
|
||||
// }
|
||||
|
||||
// return cache.styles;
|
||||
// };
|
||||
// }
|
||||
|
||||
export function useTempBlah() {
|
||||
const classNames = useClassNames({ variant: "contained", paddingTop: 12 });
|
||||
return classNames;
|
||||
}
|
|
@ -1,99 +0,0 @@
|
|||
import { type DependencyList } from "react";
|
||||
import { type ClassName, useClassName } from "./useClassName";
|
||||
import { type RenderHookOptions, renderHook } from "@testing-library/react";
|
||||
import { ThemeProvider } from "@emotion/react";
|
||||
import { ThemeOverride } from "contexts/ThemeProvider";
|
||||
import themes from "theme";
|
||||
|
||||
/**
|
||||
* Treating the string that Emotion generates for the hook as an opaque value.
|
||||
* Trying to make assertions on the format could lead to flakier tests.
|
||||
*/
|
||||
describe(useClassName.name, () => {
|
||||
type Props = Readonly<{ styles: ClassName; deps: DependencyList }>;
|
||||
|
||||
function renderUseClassNames(
|
||||
styles: ClassName,
|
||||
deps: DependencyList,
|
||||
options: Omit<RenderHookOptions<Props>, "initialProps"> = {},
|
||||
) {
|
||||
return renderHook<string, Props>(
|
||||
/* eslint-disable-next-line react-hooks/exhaustive-deps --
|
||||
Disabling to make test setup easier; disabling this rule should still
|
||||
be treated as a bad idea in general */
|
||||
({ styles, deps }) => useClassName(styles, deps),
|
||||
{ initialProps: { styles, deps }, ...options },
|
||||
);
|
||||
}
|
||||
|
||||
test("Two separate hook calls with the same styles should produce the same string (regardless of function references)", () => {
|
||||
const func1: ClassName = (css) => css`
|
||||
background-color: chartreuse;
|
||||
`;
|
||||
const func2: ClassName = (css) => css`
|
||||
background-color: chartreuse;
|
||||
`;
|
||||
|
||||
const { result: result1 } = renderUseClassNames(func1, []);
|
||||
const { result: result2 } = renderUseClassNames(func2, []);
|
||||
expect(result1.current).toBe(result2.current);
|
||||
});
|
||||
|
||||
it("Only re-evaluates the styles callback if the dependencies change during re-renders", () => {
|
||||
let color: "red" | "blue" = "red";
|
||||
const className: ClassName = (css) => css`
|
||||
color: ${color};
|
||||
`;
|
||||
|
||||
const mockCallback = jest.fn(className);
|
||||
const { result, rerender } = renderUseClassNames(mockCallback, [color]);
|
||||
expect(mockCallback).toBeCalledTimes(1);
|
||||
|
||||
const initialResult = result.current;
|
||||
rerender({ styles: mockCallback, deps: [color] });
|
||||
expect(result.current).toEqual(initialResult);
|
||||
expect(mockCallback).toBeCalledTimes(1);
|
||||
|
||||
color = "blue";
|
||||
rerender({ styles: mockCallback, deps: [color] });
|
||||
expect(result.current).not.toEqual(initialResult);
|
||||
expect(mockCallback).toBeCalledTimes(2);
|
||||
});
|
||||
|
||||
it("Should use the closest theme provider available in the React tree", () => {
|
||||
const className: ClassName = (css, theme) => css`
|
||||
background-color: ${theme.roles.active.background};
|
||||
`;
|
||||
|
||||
const { result: result1 } = renderUseClassNames(className, [], {
|
||||
wrapper: ({ children }) => (
|
||||
<ThemeProvider theme={themes.dark}>{children}</ThemeProvider>
|
||||
),
|
||||
});
|
||||
|
||||
const { result: result2 } = renderUseClassNames(className, [], {
|
||||
wrapper: ({ children }) => (
|
||||
<ThemeProvider theme={themes.dark}>
|
||||
<ThemeOverride theme={themes.light}>{children}</ThemeOverride>
|
||||
</ThemeProvider>
|
||||
),
|
||||
});
|
||||
|
||||
const { result: result3 } = renderUseClassNames(className, [], {
|
||||
wrapper: ({ children }) => (
|
||||
<ThemeProvider theme={themes.dark}>
|
||||
<ThemeOverride theme={themes.light}>
|
||||
<ThemeOverride theme={themes.darkBlue}>
|
||||
<ThemeOverride theme={themes.light}>
|
||||
<ThemeOverride theme={themes.dark}>{children}</ThemeOverride>
|
||||
</ThemeOverride>
|
||||
</ThemeOverride>
|
||||
</ThemeOverride>
|
||||
</ThemeProvider>
|
||||
),
|
||||
});
|
||||
|
||||
expect(result1.current).not.toEqual(result2.current);
|
||||
expect(result1.current).toEqual(result3.current);
|
||||
});
|
||||
});
|
|
@ -1,28 +0,0 @@
|
|||
/**
|
||||
* @file This hook has had the ESLint exhaustive-deps rule added to it.
|
||||
*/
|
||||
import { type DependencyList, useMemo } from "react";
|
||||
import { useEffectEvent } from "./hookPolyfills";
|
||||
import { type Theme, useTheme } from "@emotion/react";
|
||||
import { css } from "@emotion/css";
|
||||
|
||||
export type ClassName = (cssFn: typeof css, theme: Theme) => string;
|
||||
|
||||
/**
|
||||
* An escape hatch for when you really need to manually pass around a
|
||||
* `className`. Prefer using the `css` prop whenever possible. If you
|
||||
* can't use that, then this might be helpful for you.
|
||||
*/
|
||||
export function useClassName(styles: ClassName, deps: DependencyList): string {
|
||||
const theme = useTheme();
|
||||
const stableStylesCallback = useEffectEvent(styles);
|
||||
|
||||
return useMemo(
|
||||
() => stableStylesCallback(css, theme),
|
||||
/* eslint-disable-next-line react-hooks/exhaustive-deps --
|
||||
Hook needs to be able to handle variadic number of dependencies at the
|
||||
API level. There should be a custom ESLint rule set to ensure that the
|
||||
number of dependencies doesn't change across renders. */
|
||||
[stableStylesCallback, theme, ...deps],
|
||||
);
|
||||
}
|
|
@ -0,0 +1,181 @@
|
|||
import { type StylesFunction, makeClassNames } from "./useClassNames";
|
||||
import { RenderHookOptions, renderHook } from "@testing-library/react";
|
||||
|
||||
import { css as emotionCss } from "@emotion/css";
|
||||
import { type Theme, ThemeProvider } from "@emotion/react";
|
||||
import { ThemeOverride } from "contexts/ThemeProvider";
|
||||
import themes from "theme";
|
||||
|
||||
type HookProps = Readonly<{
|
||||
color: "red" | "blue";
|
||||
}>;
|
||||
|
||||
function setupUseClassNames<TKey extends string = string>(
|
||||
implementation: StylesFunction<TKey, HookProps>,
|
||||
options?: Omit<RenderHookOptions<HookProps>, "initialProps">,
|
||||
) {
|
||||
const mockCallback = jest.fn(implementation);
|
||||
const useClassNames = makeClassNames(mockCallback);
|
||||
const { wrapper, ...delegatedOptions } = options ?? {};
|
||||
type Result = ReturnType<typeof useClassNames>;
|
||||
|
||||
const { result, rerender, unmount } = renderHook<Result, HookProps>(
|
||||
({ color }) => useClassNames({ color }),
|
||||
{
|
||||
...delegatedOptions,
|
||||
initialProps: { color: "red" },
|
||||
wrapper:
|
||||
wrapper ??
|
||||
(({ children }) => (
|
||||
<ThemeProvider theme={themes.dark}>{children}</ThemeProvider>
|
||||
)),
|
||||
},
|
||||
);
|
||||
|
||||
return { result, rerender, unmount, mockCallback };
|
||||
}
|
||||
|
||||
/**
|
||||
* Treating the string that Emotion generates for the hook as an opaque value.
|
||||
* Trying to make assertions on the format could lead to flakier tests.
|
||||
*/
|
||||
describe(makeClassNames.name, () => {
|
||||
test("Hook output should be fully deterministic based on theme and inputs", () => {
|
||||
const { result } = setupUseClassNames((css, theme) => ({
|
||||
class1: css`
|
||||
background-color: chartreuse;
|
||||
`,
|
||||
|
||||
class2: css`
|
||||
background-color: chartreuse;
|
||||
`,
|
||||
|
||||
class3: css`
|
||||
background-color: ${theme.roles.active.background};
|
||||
`,
|
||||
|
||||
class4: css`
|
||||
background-color: ${theme.roles.active.background};
|
||||
`,
|
||||
}));
|
||||
|
||||
const { current } = result;
|
||||
expect(current.class1).toEqual(current.class2);
|
||||
expect(current.class3).toEqual(current.class4);
|
||||
});
|
||||
|
||||
it("Only re-evaluates the styles callback if the dependencies change during re-renders", () => {
|
||||
const { result, rerender, unmount, mockCallback } = setupUseClassNames(
|
||||
(css) => ({
|
||||
testClass: ({ color }) => css`
|
||||
color: ${color};
|
||||
`,
|
||||
}),
|
||||
);
|
||||
|
||||
const initialResult = result.current.testClass;
|
||||
expect(mockCallback).toBeCalledTimes(1);
|
||||
|
||||
rerender({ color: "red" });
|
||||
expect(mockCallback).toBeCalledTimes(1);
|
||||
expect(result.current.testClass).toEqual(initialResult);
|
||||
|
||||
rerender({ color: "blue" });
|
||||
expect(mockCallback).toBeCalledTimes(2);
|
||||
expect(result.current).not.toEqual(initialResult);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("Should use the closest theme provider available in the React tree", () => {
|
||||
const useClassNames = makeClassNames((css, theme) => ({
|
||||
testClass: css`
|
||||
background-color: ${theme.roles.active.background};
|
||||
`,
|
||||
}));
|
||||
|
||||
const { result: result1 } = renderHook(useClassNames, {
|
||||
wrapper: ({ children }) => (
|
||||
<ThemeProvider theme={themes.dark}>{children}</ThemeProvider>
|
||||
),
|
||||
});
|
||||
|
||||
const { result: result2 } = renderHook(useClassNames, {
|
||||
wrapper: ({ children }) => (
|
||||
<ThemeProvider theme={themes.dark}>
|
||||
<ThemeOverride theme={themes.light}>{children}</ThemeOverride>
|
||||
</ThemeProvider>
|
||||
),
|
||||
});
|
||||
|
||||
const { result: result3 } = renderHook(useClassNames, {
|
||||
wrapper: ({ children }) => (
|
||||
<ThemeProvider theme={themes.dark}>
|
||||
<ThemeOverride theme={themes.light}>
|
||||
<ThemeOverride theme={themes.darkBlue}>
|
||||
<ThemeOverride theme={themes.light}>
|
||||
<ThemeOverride theme={themes.dark}>{children}</ThemeOverride>
|
||||
</ThemeOverride>
|
||||
</ThemeOverride>
|
||||
</ThemeOverride>
|
||||
</ThemeProvider>
|
||||
),
|
||||
});
|
||||
|
||||
expect(result1.current.testClass).not.toEqual(result2.current.testClass);
|
||||
expect(result1.current.testClass).toEqual(result3.current.testClass);
|
||||
});
|
||||
|
||||
it("Should recalculate all defined styles when any of the dependencies change (even if a style doesn't use them)", () => {
|
||||
const noInputsCallback = jest.fn(
|
||||
(css: typeof emotionCss) => css`
|
||||
color: yellow;
|
||||
`,
|
||||
);
|
||||
|
||||
const { rerender } = setupUseClassNames((css) => ({
|
||||
withInputs: ({ color }) => css`
|
||||
background-color: ${color};
|
||||
`,
|
||||
|
||||
noInputs: () => noInputsCallback(css),
|
||||
}));
|
||||
|
||||
expect(noInputsCallback).toBeCalledTimes(1);
|
||||
rerender({ color: "blue" });
|
||||
expect(noInputsCallback).toBeCalledTimes(2);
|
||||
});
|
||||
|
||||
it("Should calculate new styles immediately when dependencies change (no stale closure issues or delays via async tasks)", () => {
|
||||
let activeTheme: Theme = themes.dark;
|
||||
const { result, rerender, mockCallback } = setupUseClassNames(
|
||||
(css, theme) => ({
|
||||
testClass: ({ color }) => css`
|
||||
color: ${color};
|
||||
background-color: ${theme.roles.active.background};
|
||||
`,
|
||||
}),
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<ThemeProvider theme={activeTheme}>{children}</ThemeProvider>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const initialResult = result.current.testClass;
|
||||
expect(mockCallback).toBeCalledTimes(1);
|
||||
|
||||
activeTheme = themes.light;
|
||||
rerender({ color: "red" });
|
||||
expect(mockCallback).toBeCalledTimes(2);
|
||||
|
||||
const themeChangeResult = result.current.testClass;
|
||||
expect(themeChangeResult).not.toEqual(initialResult);
|
||||
|
||||
rerender({ color: "blue" });
|
||||
expect(mockCallback).toBeCalledTimes(3);
|
||||
|
||||
const colorChangeResult = result.current.testClass;
|
||||
expect(colorChangeResult).not.toEqual(initialResult);
|
||||
expect(colorChangeResult).not.toEqual(themeChangeResult);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,171 @@
|
|||
import { useReducer } from "react";
|
||||
import { type Theme, useTheme } from "@emotion/react";
|
||||
import { css as emotionCss } from "@emotion/css";
|
||||
|
||||
type Primitive = string | number | boolean | null | undefined | symbol | bigint;
|
||||
type PrimitiveRecord = Record<string, Primitive>;
|
||||
type EmptyRecord = Record<string, never>;
|
||||
|
||||
type MapRawInputToHookInput<TRawInput extends PrimitiveRecord> = [
|
||||
TRawInput,
|
||||
] extends [never]
|
||||
? EmptyRecord | null
|
||||
: TRawInput;
|
||||
|
||||
type ClassNameFunction<T extends PrimitiveRecord> = (
|
||||
input: MapRawInputToHookInput<T>,
|
||||
) => string;
|
||||
|
||||
export type StylesFunction<
|
||||
TKey extends string,
|
||||
TRawHookInput extends PrimitiveRecord,
|
||||
> = (
|
||||
css: typeof emotionCss,
|
||||
theme: Theme,
|
||||
) => Record<TKey, string | ClassNameFunction<TRawHookInput>>;
|
||||
|
||||
type UseClassNamesCustomHookResult<TKey extends string> = Record<TKey, string>;
|
||||
|
||||
type UseClassNamesCustomHook<
|
||||
TKey extends string,
|
||||
TRawHookInput extends PrimitiveRecord,
|
||||
> = (
|
||||
input: MapRawInputToHookInput<TRawHookInput>,
|
||||
) => Readonly<UseClassNamesCustomHookResult<TKey>>;
|
||||
|
||||
/**
|
||||
* Hook factory for giving you an escape hatch for making Emotion styles. This
|
||||
* should be used as a last resort; use the React CSS prop whenever possible.
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* Sometimes you need to combine/collate styles using the `className` prop in a
|
||||
* component, but Emotion does not give you an easy way to define a className
|
||||
* and use it from within the same component.
|
||||
*
|
||||
* Other times, you need to use inputs that will change on each render to make
|
||||
* your styles, but you only want the styles relying on those inputs to be
|
||||
* re-computed when the input actually changes. Otherwise, Emotion's CSS
|
||||
* function might keep thrashing the <style> tag with diffs each and every
|
||||
* render.
|
||||
*
|
||||
* And sometimes just you don't want to think about dependency arrays and
|
||||
* stale closure issues.
|
||||
*
|
||||
* This function solves all three problems. The custom hook it returns will give
|
||||
* you a type-safe collection of className values, and auto-memoize all inputs,
|
||||
* only re-computing new CSS styles when one of the inputs changes by reference.
|
||||
*
|
||||
* Making that memoization possible comes with two caveats:
|
||||
* 1. All inputs fed into the hook must be primitives. No nested objects or
|
||||
* functions or arrays.
|
||||
* 2. All styles defined via the hook are tied to the same memoization cache. If
|
||||
* one of the inputs changes, all classnames for the hook will be
|
||||
* re-computed, even if none of the classnames actually use the input that
|
||||
* changed.
|
||||
*
|
||||
* If (2) is a performance problem, you can define separate hooks by calling
|
||||
* makeClassNames multiple times for each hook you need.
|
||||
*/
|
||||
export function makeClassNames<
|
||||
/*
|
||||
Making the user specify the keys first is a heavy-handed way to circumvent
|
||||
TypeScript language limitations and make sure type safety doesn't degrade.
|
||||
|
||||
TS type inference is all or nothing; if you specify one type parameter,
|
||||
type inference stops and default values kick in for all other parameters
|
||||
|
||||
If the input were specified first, a dev could accidentally specify only
|
||||
the input. That would cause the key to get inferred as type string, and
|
||||
they would lose auto-complete and protection against accidental typos.
|
||||
Ergonomics are slightly worse this way, but that should be fine for what'
|
||||
a last-resort escape hatch anyway
|
||||
|
||||
Could maybe get cleaned up if private type parameters ever become a thing
|
||||
*/
|
||||
TKey extends string = string,
|
||||
TRawHookInput extends PrimitiveRecord = never,
|
||||
>(
|
||||
styleFunction: StylesFunction<TKey, TRawHookInput>,
|
||||
): UseClassNamesCustomHook<TKey, TRawHookInput> {
|
||||
type Result = UseClassNamesCustomHookResult<TKey>;
|
||||
type HookInput = MapRawInputToHookInput<TRawHookInput>;
|
||||
|
||||
const computeNewStyles = (
|
||||
theme: Theme,
|
||||
hookInput: HookInput,
|
||||
): Readonly<Result> => {
|
||||
const stylesObject = styleFunction(emotionCss, theme);
|
||||
const result: Partial<Result> = {};
|
||||
|
||||
for (const key in stylesObject) {
|
||||
const styleValue = stylesObject[key];
|
||||
const resultValue =
|
||||
typeof styleValue === "string" ? styleValue : styleValue(hookInput);
|
||||
|
||||
result[key] = resultValue;
|
||||
}
|
||||
|
||||
return result as Result;
|
||||
};
|
||||
|
||||
// This function could technically be defined outside, since it doesn't rely
|
||||
// on closure, but feeding in the right type info got too awkward
|
||||
const didInputsChangeByValue = (
|
||||
hookInput1: HookInput,
|
||||
hookInput2: HookInput,
|
||||
): boolean => {
|
||||
// Slightly clunky syntax, but have to do it this way to make the compiler
|
||||
// happy and not make it worry about null values in the loop
|
||||
if (hookInput1 === null) {
|
||||
if (hookInput2 === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} else if (hookInput2 === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const key in hookInput1) {
|
||||
const value1 = hookInput1[key];
|
||||
const value2 = hookInput2[key];
|
||||
|
||||
if (Number.isNaN(value1) && Number.isNaN(value2)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value1 !== value2) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
return function useClassNames(hookInput) {
|
||||
const activeTheme = useTheme();
|
||||
const getNewCacheValue = () => ({
|
||||
prevTheme: activeTheme,
|
||||
prevInput: hookInput,
|
||||
styles: computeNewStyles(activeTheme, hookInput),
|
||||
});
|
||||
|
||||
const [cache, updateCacheAndRedoRender] = useReducer(
|
||||
getNewCacheValue,
|
||||
null,
|
||||
getNewCacheValue,
|
||||
);
|
||||
|
||||
const needNewStyles =
|
||||
hookInput !== null &&
|
||||
(cache.prevTheme !== activeTheme ||
|
||||
didInputsChangeByValue(cache.prevInput, hookInput));
|
||||
|
||||
if (needNewStyles) {
|
||||
updateCacheAndRedoRender();
|
||||
}
|
||||
|
||||
return cache.styles;
|
||||
};
|
||||
}
|
|
@ -40,7 +40,7 @@ import colors from "theme/tailwindColors";
|
|||
import { getDisplayWorkspaceStatus } from "utils/workspace";
|
||||
import { HelpTooltipTitle } from "components/HelpTooltip/HelpTooltip";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { type ClassName, useClassName } from "hooks/useClassName";
|
||||
import { makeClassNames } from "hooks/useClassNames";
|
||||
|
||||
export const bannerHeight = 36;
|
||||
|
||||
|
@ -50,16 +50,27 @@ export interface DeploymentBannerViewProps {
|
|||
fetchStats?: () => void;
|
||||
}
|
||||
|
||||
const useDeploymentBannerClassNames = makeClassNames((css, theme) => ({
|
||||
summaryTooltip: css`
|
||||
${theme.typography.body2 as CSSObject}
|
||||
|
||||
margin: 0 0 4px 12px;
|
||||
width: 400px;
|
||||
padding: 16px;
|
||||
color: ${theme.palette.text.primary};
|
||||
background-color: ${theme.palette.background.paper};
|
||||
border: 1px solid ${theme.palette.divider};
|
||||
pointer-events: none;
|
||||
`,
|
||||
}));
|
||||
|
||||
export const DeploymentBannerView: FC<DeploymentBannerViewProps> = ({
|
||||
health,
|
||||
stats,
|
||||
fetchStats,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const summaryTooltip = useClassName(
|
||||
(css, theme) => classNames.summaryTooltip(css, theme),
|
||||
[],
|
||||
);
|
||||
const classNames = useDeploymentBannerClassNames(null);
|
||||
|
||||
const aggregatedMinutes = useMemo(() => {
|
||||
if (!stats) {
|
||||
|
@ -133,7 +144,7 @@ export const DeploymentBannerView: FC<DeploymentBannerViewProps> = ({
|
|||
}}
|
||||
>
|
||||
<Tooltip
|
||||
classes={{ tooltip: summaryTooltip }}
|
||||
classes={{ tooltip: classNames.summaryTooltip }}
|
||||
title={
|
||||
healthErrors.length > 0 ? (
|
||||
<>
|
||||
|
@ -415,20 +426,6 @@ const getHealthErrors = (health: HealthcheckReport) => {
|
|||
return warnings;
|
||||
};
|
||||
|
||||
const classNames = {
|
||||
summaryTooltip: (css, theme) => css`
|
||||
${theme.typography.body2 as CSSObject}
|
||||
|
||||
margin: 0 0 4px 12px;
|
||||
width: 400px;
|
||||
padding: 16px;
|
||||
color: ${theme.palette.text.primary};
|
||||
background-color: ${theme.palette.background.paper};
|
||||
border: 1px solid ${theme.palette.divider};
|
||||
pointer-events: none;
|
||||
`,
|
||||
} satisfies Record<string, ClassName>;
|
||||
|
||||
const styles = {
|
||||
statusBadge: (theme) => css`
|
||||
display: flex;
|
||||
|
|
|
@ -13,7 +13,6 @@ import type {
|
|||
WorkspaceAgentListeningPortsResponse,
|
||||
} from "api/typesGenerated";
|
||||
import { portForwardURL } from "utils/portForward";
|
||||
import { type ClassName, useClassName } from "hooks/useClassName";
|
||||
import {
|
||||
HelpTooltipLink,
|
||||
HelpTooltipLinksGroup,
|
||||
|
@ -26,6 +25,7 @@ import {
|
|||
PopoverTrigger,
|
||||
} from "components/Popover/Popover";
|
||||
import KeyboardArrowDown from "@mui/icons-material/KeyboardArrowDown";
|
||||
import { makeClassNames } from "hooks/useClassNames";
|
||||
|
||||
export interface PortForwardButtonProps {
|
||||
host: string;
|
||||
|
@ -41,10 +41,18 @@ export interface PortForwardButtonProps {
|
|||
};
|
||||
}
|
||||
|
||||
const useClassNames = makeClassNames((css, theme) => ({
|
||||
paper: css`
|
||||
padding: 0;
|
||||
width: 304px;
|
||||
color: ${theme.palette.text.secondary};
|
||||
margin-top: 4px;
|
||||
`,
|
||||
}));
|
||||
|
||||
export const PortForwardButton: FC<PortForwardButtonProps> = (props) => {
|
||||
const { agent, storybook } = props;
|
||||
|
||||
const paper = useClassName((css, theme) => classNames.paper(css, theme), []);
|
||||
const classNames = useClassNames(null);
|
||||
|
||||
const portsQuery = useQuery({
|
||||
queryKey: ["portForward", agent.id],
|
||||
|
@ -77,7 +85,7 @@ export const PortForwardButton: FC<PortForwardButtonProps> = (props) => {
|
|||
Open ports
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent horizontal="right" classes={{ paper }}>
|
||||
<PopoverContent horizontal="right" classes={{ paper: classNames.paper }}>
|
||||
<PortForwardPopoverView {...props} ports={data?.ports} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
@ -203,15 +211,6 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
const classNames = {
|
||||
paper: (css, theme) => css`
|
||||
padding: 0;
|
||||
width: 304px;
|
||||
color: ${theme.palette.text.secondary};
|
||||
margin-top: 4px;
|
||||
`,
|
||||
} satisfies Record<string, ClassName>;
|
||||
|
||||
const styles = {
|
||||
portCount: (theme) => ({
|
||||
fontSize: 12,
|
||||
|
|
|
@ -6,7 +6,6 @@ import {
|
|||
HelpTooltipText,
|
||||
} from "components/HelpTooltip/HelpTooltip";
|
||||
import { docs } from "utils/docs";
|
||||
import { type ClassName, useClassName } from "hooks/useClassName";
|
||||
import { CodeExample } from "components/CodeExample/CodeExample";
|
||||
import {
|
||||
Popover,
|
||||
|
@ -16,6 +15,7 @@ import {
|
|||
import { Stack } from "components/Stack/Stack";
|
||||
import Button from "@mui/material/Button";
|
||||
import KeyboardArrowDown from "@mui/icons-material/KeyboardArrowDown";
|
||||
import { makeClassNames } from "hooks/useClassNames";
|
||||
|
||||
export interface SSHButtonProps {
|
||||
workspaceName: string;
|
||||
|
@ -24,13 +24,22 @@ export interface SSHButtonProps {
|
|||
sshPrefix?: string;
|
||||
}
|
||||
|
||||
const useClassNames = makeClassNames((css, theme) => ({
|
||||
paper: css`
|
||||
padding: 16px 24px 24px;
|
||||
width: 304px;
|
||||
color: ${theme.palette.text.secondary};
|
||||
margin-top: 2px;
|
||||
`,
|
||||
}));
|
||||
|
||||
export const SSHButton: FC<SSHButtonProps> = ({
|
||||
workspaceName,
|
||||
agentName,
|
||||
isDefaultOpen = false,
|
||||
sshPrefix,
|
||||
}) => {
|
||||
const paper = useClassName((css, theme) => classNames.paper(css, theme), []);
|
||||
const classNames = useClassNames(null);
|
||||
|
||||
return (
|
||||
<Popover isDefaultOpen={isDefaultOpen}>
|
||||
|
@ -45,7 +54,7 @@ export const SSHButton: FC<SSHButtonProps> = ({
|
|||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent horizontal="right" classes={{ paper }}>
|
||||
<PopoverContent horizontal="right" classes={{ paper: classNames.paper }}>
|
||||
<HelpTooltipText>
|
||||
Run the following commands to connect with SSH:
|
||||
</HelpTooltipText>
|
||||
|
@ -92,15 +101,6 @@ export const SSHButton: FC<SSHButtonProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
const classNames = {
|
||||
paper: (css, theme) => css`
|
||||
padding: 16px 24px 24px;
|
||||
width: 304px;
|
||||
color: ${theme.palette.text.secondary};
|
||||
margin-top: 2px;
|
||||
`,
|
||||
} satisfies Record<string, ClassName>;
|
||||
|
||||
const styles = {
|
||||
codeExamples: {
|
||||
marginTop: 12,
|
||||
|
|
|
@ -11,8 +11,8 @@ import { Pill } from "components/Pill/Pill";
|
|||
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne";
|
||||
import { DormantDeletionText } from "./DormantDeletionText";
|
||||
import { getDisplayWorkspaceStatus } from "utils/workspace";
|
||||
import { useClassName } from "hooks/useClassName";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { makeClassNames } from "hooks/useClassNames";
|
||||
|
||||
export type WorkspaceStatusBadgeProps = {
|
||||
workspace: Workspace;
|
||||
|
@ -172,21 +172,22 @@ export const WorkspaceStatusText: FC<WorkspaceStatusBadgeProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
const useClassNames = makeClassNames((css, theme) => ({
|
||||
popper: css`
|
||||
& .${tooltipClasses.tooltip} {
|
||||
background-color: ${theme.palette.background.paper};
|
||||
border: 1px solid ${theme.palette.divider};
|
||||
font-size: 12px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
`,
|
||||
}));
|
||||
|
||||
const FailureTooltip: FC<TooltipProps> = ({ children, ...tooltipProps }) => {
|
||||
const popper = useClassName(
|
||||
(css, theme) => css`
|
||||
& .${tooltipClasses.tooltip} {
|
||||
background-color: ${theme.palette.background.paper};
|
||||
border: 1px solid ${theme.palette.divider};
|
||||
font-size: 12px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
`,
|
||||
[],
|
||||
);
|
||||
const classNames = useClassNames(null);
|
||||
|
||||
return (
|
||||
<Tooltip {...tooltipProps} classes={{ popper }}>
|
||||
<Tooltip {...tooltipProps} classes={{ popper: classNames.popper }}>
|
||||
{children}
|
||||
</Tooltip>
|
||||
);
|
||||
|
|
|
@ -12,16 +12,17 @@ import { NavLink, Outlet } from "react-router-dom";
|
|||
import kebabCase from "lodash/fp/kebabCase";
|
||||
import { health, refreshHealth } from "api/queries/debug";
|
||||
import type { HealthSeverity } from "api/typesGenerated";
|
||||
import { type ClassName, useClassName } from "hooks/useClassName";
|
||||
import { pageTitle } from "utils/page";
|
||||
import { createDayString } from "utils/createDayString";
|
||||
import { DashboardFullPage } from "modules/dashboard/DashboardLayout";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { HealthIcon } from "./Content";
|
||||
import { makeClassNames } from "hooks/useClassNames";
|
||||
|
||||
export const HealthLayout: FC = () => {
|
||||
const theme = useTheme();
|
||||
const queryClient = useQueryClient();
|
||||
const classNames = useClassNames(null);
|
||||
const { data: healthStatus } = useQuery({
|
||||
...health(),
|
||||
refetchInterval: 30_000,
|
||||
|
@ -39,12 +40,6 @@ export const HealthLayout: FC = () => {
|
|||
} as const;
|
||||
const visibleSections = filterVisibleSections(sections);
|
||||
|
||||
const link = useClassName((css, theme) => classNames.link(css, theme), []);
|
||||
const activeLink = useClassName(
|
||||
(css, theme) => classNames.activeLink(css, theme),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
|
@ -169,7 +164,10 @@ export const HealthLayout: FC = () => {
|
|||
key={key}
|
||||
to={`/health/${kebabCase(key)}`}
|
||||
className={({ isActive }) =>
|
||||
cx([link, isActive && activeLink])
|
||||
cx([
|
||||
classNames.link,
|
||||
isActive && classNames.activeLink,
|
||||
])
|
||||
}
|
||||
>
|
||||
<HealthIcon
|
||||
|
@ -224,34 +222,32 @@ const filterVisibleSections = <T extends object>(sections: T) => {
|
|||
);
|
||||
};
|
||||
|
||||
const classNames = {
|
||||
link: (css, theme) =>
|
||||
css({
|
||||
background: "none",
|
||||
pointerEvents: "auto",
|
||||
color: theme.palette.text.secondary,
|
||||
border: "none",
|
||||
fontSize: 14,
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
textAlign: "left",
|
||||
height: 36,
|
||||
padding: "0 24px",
|
||||
cursor: "pointer",
|
||||
textDecoration: "none",
|
||||
const useClassNames = makeClassNames((css, theme) => ({
|
||||
link: css({
|
||||
background: "none",
|
||||
pointerEvents: "auto",
|
||||
color: theme.palette.text.secondary,
|
||||
border: "none",
|
||||
fontSize: 14,
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
textAlign: "left",
|
||||
height: 36,
|
||||
padding: "0 24px",
|
||||
cursor: "pointer",
|
||||
textDecoration: "none",
|
||||
|
||||
"&:hover": {
|
||||
background: theme.palette.action.hover,
|
||||
color: theme.palette.text.primary,
|
||||
},
|
||||
}),
|
||||
|
||||
activeLink: (css, theme) =>
|
||||
css({
|
||||
"&:hover": {
|
||||
background: theme.palette.action.hover,
|
||||
pointerEvents: "none",
|
||||
color: theme.palette.text.primary,
|
||||
}),
|
||||
} satisfies Record<string, ClassName>;
|
||||
},
|
||||
}),
|
||||
|
||||
activeLink: css({
|
||||
background: theme.palette.action.hover,
|
||||
pointerEvents: "none",
|
||||
color: theme.palette.text.primary,
|
||||
}),
|
||||
}));
|
||||
|
|
|
@ -18,7 +18,7 @@ import {
|
|||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "components/Popover/Popover";
|
||||
import { type ClassName, useClassName } from "hooks/useClassName";
|
||||
import { makeClassNames } from "hooks/useClassNames";
|
||||
|
||||
const roleDescriptions: Record<string, string> = {
|
||||
owner:
|
||||
|
@ -86,7 +86,7 @@ export const EditRolesButton: FC<EditRolesButtonProps> = ({
|
|||
userLoginType,
|
||||
oidcRoleSync,
|
||||
}) => {
|
||||
const paper = useClassName((css, theme) => classNames.paper(css, theme), []);
|
||||
const classNames = useClassNames(null);
|
||||
|
||||
const handleChange = (roleName: string) => {
|
||||
if (selectedRoleNames.has(roleName)) {
|
||||
|
@ -127,7 +127,7 @@ export const EditRolesButton: FC<EditRolesButtonProps> = ({
|
|||
</IconButton>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent classes={{ paper }}>
|
||||
<PopoverContent classes={{ paper: classNames.paper }}>
|
||||
<fieldset
|
||||
css={styles.fieldset}
|
||||
disabled={isLoading}
|
||||
|
@ -162,13 +162,13 @@ export const EditRolesButton: FC<EditRolesButtonProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
const classNames = {
|
||||
paper: (css, theme) => css`
|
||||
const useClassNames = makeClassNames((css, theme) => ({
|
||||
paper: css`
|
||||
width: 360px;
|
||||
margin-top: 8px;
|
||||
background: ${theme.palette.background.paper};
|
||||
`,
|
||||
} satisfies Record<string, ClassName>;
|
||||
}));
|
||||
|
||||
const styles = {
|
||||
editButton: (theme) => ({
|
||||
|
|
Loading…
Reference in New Issue