mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
chore(site): convert more components from Emotion to TailwindCSS (#19719)
## Changes made - Patched React `CSSProperties` type to add support for custom CSS properties - Updated several of the components in the `components` directory to Tailwind - Updated most of the `WorkspacePageBuildView` component to Tailwind to account for CSS specificity changes - Updated `Search` to address accessibility violation and removed all MUI logic - Updated `Search` stories (added new story, decoupled all stories from single decorator) - Updated `autoFocus` behavior in `SearchField` - Updated the styling for `WorkspacePageBuildView` to make sure the tabs had enough padding - Fixed layout effect in `WorkspacePageBuildView` to fire correctly
This commit is contained in:
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
declare module "react" {
|
||||
interface CSSProperties {
|
||||
[key: `--${string}`]: string | number | undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -52,8 +52,8 @@ export const SelectFilter: FC<SelectFilterProps> = ({
|
||||
<SelectMenuTrigger>
|
||||
<SelectMenuButton
|
||||
startIcon={selectedOption?.startIcon}
|
||||
css={{ flexBasis: width, flexGrow: 1 }}
|
||||
className="shrink-0"
|
||||
className="shrink-0 grow"
|
||||
style={{ flexBasis: width }}
|
||||
aria-label={label}
|
||||
>
|
||||
{selectedOption?.label ?? placeholder}
|
||||
|
||||
@@ -506,7 +506,7 @@ export const MultiSelectCombobox = forwardRef<
|
||||
<Badge
|
||||
key={option.value}
|
||||
className={cn(
|
||||
"data-[disabled]:bg-content-disabled data-[disabled]:text-surface-tertiarydata-[disabled]:hover:bg-content-disabled",
|
||||
"data-[disabled]:bg-content-disabled data-[disabled]:text-surface-tertiary data-[disabled]:hover:bg-content-disabled",
|
||||
"data-[fixed]:bg-content-disabled data-[fixed]:text-surface-tertiary data-[fixed]:hover:bg-surface-secondary",
|
||||
badgeClassName,
|
||||
)}
|
||||
|
||||
@@ -1,26 +1,46 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Search, SearchInput } from "./Search";
|
||||
import { Search, SearchEmpty, SearchInput } from "./Search";
|
||||
|
||||
const meta: Meta<typeof SearchInput> = {
|
||||
title: "components/Search",
|
||||
component: SearchInput,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<Search>
|
||||
<Story />
|
||||
</Search>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SearchInput>;
|
||||
|
||||
export const Example: Story = {};
|
||||
export const Example: Story = {
|
||||
render: (props) => (
|
||||
<Search>
|
||||
<SearchInput {...props} />
|
||||
</Search>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithPlaceholder: Story = {
|
||||
export const WithCustomPlaceholder: Story = {
|
||||
args: {
|
||||
label: "uwu",
|
||||
placeholder: "uwu",
|
||||
},
|
||||
render: (props) => (
|
||||
<Search>
|
||||
<SearchInput {...props} />
|
||||
</Search>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithSearchEmpty: Story = {
|
||||
args: {
|
||||
label: "I crave the certainty of steel",
|
||||
placeholder: "Alas, I am empty",
|
||||
},
|
||||
render: (props) => (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Search>
|
||||
<SearchInput {...props} />
|
||||
</Search>
|
||||
|
||||
<SearchEmpty />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import type { Interpolation, Theme } from "@emotion/react";
|
||||
// biome-ignore lint/style/noRestrictedImports: use it to have the component prop
|
||||
import Box, { type BoxProps } from "@mui/material/Box";
|
||||
import visuallyHidden from "@mui/utils/visuallyHidden";
|
||||
import { SearchIcon } from "lucide-react";
|
||||
import type { FC, HTMLAttributes, InputHTMLAttributes, Ref } from "react";
|
||||
import { cn } from "utils/cn";
|
||||
|
||||
interface SearchProps extends Omit<BoxProps, "ref"> {
|
||||
$$ref?: Ref<unknown>;
|
||||
interface SearchProps extends HTMLAttributes<HTMLDivElement> {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -18,100 +15,63 @@ interface SearchProps extends Omit<BoxProps, "ref"> {
|
||||
* </Search>
|
||||
* ```
|
||||
*/
|
||||
export const Search: FC<SearchProps> = ({ children, $$ref, ...boxProps }) => {
|
||||
export const Search: FC<SearchProps> = ({
|
||||
children,
|
||||
ref,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<Box ref={$$ref} {...boxProps} css={SearchStyles.container}>
|
||||
<SearchIcon className="size-icon-xs" css={SearchStyles.icon} />
|
||||
<div
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={cn(
|
||||
"flex items-center h-10 pl-4 border-0 border-b border-solid border-border",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<SearchIcon className="size-icon-xs text-sm text-content-secondary" />
|
||||
{children}
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SearchStyles = {
|
||||
container: (theme) => ({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
paddingLeft: 16,
|
||||
height: 40,
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
}),
|
||||
|
||||
icon: (theme) => ({
|
||||
fontSize: 14,
|
||||
color: theme.palette.text.secondary,
|
||||
}),
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
||||
|
||||
type SearchInputProps = InputHTMLAttributes<HTMLInputElement> & {
|
||||
label?: string;
|
||||
$$ref?: Ref<HTMLInputElement>;
|
||||
ref?: Ref<HTMLInputElement>;
|
||||
};
|
||||
|
||||
export const SearchInput: FC<SearchInputProps> = ({
|
||||
label,
|
||||
$$ref,
|
||||
ref,
|
||||
id,
|
||||
...inputProps
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<label css={{ ...visuallyHidden }} htmlFor={inputProps.id}>
|
||||
<label className="sr-only" htmlFor={id}>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
ref={$$ref}
|
||||
tabIndex={-1}
|
||||
ref={ref}
|
||||
id={id}
|
||||
tabIndex={0}
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
css={SearchInputStyles.input}
|
||||
className="text-inherit h-full border-0 bg-transparent grow basis-0 outline-none pl-4 placeholder:text-content-secondary"
|
||||
{...inputProps}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const SearchInputStyles = {
|
||||
input: (theme) => ({
|
||||
color: "inherit",
|
||||
height: "100%",
|
||||
border: 0,
|
||||
background: "none",
|
||||
flex: 1,
|
||||
marginLeft: 16,
|
||||
outline: 0,
|
||||
"&::placeholder": {
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
}),
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
||||
|
||||
export const SearchEmpty: FC<HTMLAttributes<HTMLDivElement>> = ({
|
||||
children = "Not found",
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<div css={SearchEmptyStyles.empty} {...props}>
|
||||
<div className="text-sm text-content-secondary text-center py-2" {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SearchEmptyStyles = {
|
||||
empty: (theme) => ({
|
||||
fontSize: 13,
|
||||
color: theme.palette.text.secondary,
|
||||
textAlign: "center",
|
||||
paddingTop: 8,
|
||||
paddingBottom: 8,
|
||||
}),
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
||||
|
||||
/**
|
||||
* Reusable styles for consumers of the base components
|
||||
*/
|
||||
export const searchStyles = {
|
||||
content: {
|
||||
width: 320,
|
||||
padding: 0,
|
||||
borderRadius: 4,
|
||||
},
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useTheme } from "@emotion/react";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import InputAdornment from "@mui/material/InputAdornment";
|
||||
import TextField, { type TextFieldProps } from "@mui/material/TextField";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import visuallyHidden from "@mui/utils/visuallyHidden";
|
||||
import { useEffectEvent } from "hooks/hookPolyfills";
|
||||
import { SearchIcon, XIcon } from "lucide-react";
|
||||
import { type FC, useEffect, useRef } from "react";
|
||||
import { type FC, useLayoutEffect, useRef } from "react";
|
||||
|
||||
export type SearchFieldProps = Omit<TextFieldProps, "onChange"> & {
|
||||
onChange: (query: string) => void;
|
||||
@@ -13,39 +12,38 @@ export type SearchFieldProps = Omit<TextFieldProps, "onChange"> & {
|
||||
};
|
||||
|
||||
export const SearchField: FC<SearchFieldProps> = ({
|
||||
value = "",
|
||||
onChange,
|
||||
autoFocus = false,
|
||||
InputProps,
|
||||
onChange,
|
||||
value = "",
|
||||
autoFocus = false,
|
||||
...textFieldProps
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// MUI's autoFocus behavior is wonky. If you set autoFocus=true, the
|
||||
// component will keep getting focus on every single render, even if there
|
||||
// are other input elements on screen. We want this to be one-time logic
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const focusOnMount = useEffectEvent((): void => {
|
||||
if (autoFocus) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
});
|
||||
useLayoutEffect(() => {
|
||||
focusOnMount();
|
||||
}, [focusOnMount]);
|
||||
|
||||
return (
|
||||
<TextField
|
||||
// Specifying `minWidth` so that the text box can't shrink so much
|
||||
inputRef={inputRef}
|
||||
// Specifying min width so that the text box can't shrink so much
|
||||
// that it becomes un-clickable as we add more filter controls
|
||||
css={{ minWidth: "280px" }}
|
||||
className="min-w-[280px]"
|
||||
size="small"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
inputRef={inputRef}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon
|
||||
className="size-icon-xs"
|
||||
css={{
|
||||
color: theme.palette.text.secondary,
|
||||
}}
|
||||
/>
|
||||
<SearchIcon className="size-icon-xs text-content-secondary" />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: value !== "" && (
|
||||
@@ -58,7 +56,7 @@ export const SearchField: FC<SearchFieldProps> = ({
|
||||
}}
|
||||
>
|
||||
<XIcon className="size-icon-xs" />
|
||||
<span css={{ ...visuallyHidden }}>Clear search</span>
|
||||
<span className="sr-only">Clear search</span>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</InputAdornment>
|
||||
|
||||
@@ -22,8 +22,6 @@ import {
|
||||
} from "react";
|
||||
import { cn } from "utils/cn";
|
||||
|
||||
const SIDE_PADDING = 16;
|
||||
|
||||
export const SelectMenu = Popover;
|
||||
|
||||
export const SelectMenuTrigger = PopoverTrigger;
|
||||
@@ -37,13 +35,20 @@ type SelectMenuButtonProps = ButtonProps & {
|
||||
export const SelectMenuButton = forwardRef<
|
||||
HTMLButtonElement,
|
||||
SelectMenuButtonProps
|
||||
>((props, ref) => {
|
||||
const { startIcon, ...restProps } = props;
|
||||
>(({ className, startIcon, children, ...props }, ref) => {
|
||||
return (
|
||||
<Button variant="outline" size="lg" ref={ref} {...restProps}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
ref={ref}
|
||||
// Shrink padding right slightly to account for visual weight of
|
||||
// the chevron
|
||||
className={cn("flex flex-row gap-2 pr-1.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{startIcon}
|
||||
<span className="text-left block overflow-hidden text-ellipsis flex-grow">
|
||||
{props.children}
|
||||
{children}
|
||||
</span>
|
||||
<ChevronDownIcon />
|
||||
</Button>
|
||||
@@ -55,22 +60,7 @@ export const SelectMenuSearch: FC<SearchFieldProps> = (props) => {
|
||||
<SearchField
|
||||
fullWidth
|
||||
size="medium"
|
||||
css={(theme) => ({
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
"& input": {
|
||||
fontSize: 14,
|
||||
},
|
||||
"& fieldset": {
|
||||
border: 0,
|
||||
borderRadius: 0,
|
||||
},
|
||||
"& .MuiInputBase-root": {
|
||||
padding: `12px ${SIDE_PADDING}px`,
|
||||
},
|
||||
"& .MuiInputAdornment-positionStart": {
|
||||
marginRight: SIDE_PADDING,
|
||||
},
|
||||
})}
|
||||
className="border border-solid border-border [&_input]:text-sm [&_fieldset]:border-0 [&_fieldset]:rounded-none [&_.MuiInputBase-root]:px-4 [&_.MuiInputBase-root]:py-3"
|
||||
{...props}
|
||||
inputProps={{ autoFocus: true, ...props.inputProps }}
|
||||
/>
|
||||
|
||||
@@ -1,45 +1,16 @@
|
||||
import type { Interpolation, Theme } from "@emotion/react";
|
||||
import type { FC, PropsWithChildren } from "react";
|
||||
|
||||
export const SignInLayout: FC<PropsWithChildren> = ({ children }) => {
|
||||
return (
|
||||
<div css={styles.container}>
|
||||
<div css={styles.content}>
|
||||
<div css={styles.signIn}>{children}</div>
|
||||
<div css={styles.copyright}>
|
||||
<div className="grow basis-0 h-screen flex justify-center items-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="max-w-[385px] flex flex-col items-center">
|
||||
{children}
|
||||
</div>
|
||||
<div className="text-xs text-content-secondary pt-6">
|
||||
{"\u00a9"} {new Date().getFullYear()} Coder Technologies, Inc.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
container: {
|
||||
flex: 1,
|
||||
// Fallback to 100vh
|
||||
height: ["100vh", "-webkit-fill-available"],
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
|
||||
content: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
},
|
||||
|
||||
signIn: {
|
||||
maxWidth: 385,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
},
|
||||
|
||||
copyright: (theme) => ({
|
||||
fontSize: 12,
|
||||
color: theme.palette.text.secondary,
|
||||
marginTop: 24,
|
||||
}),
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
||||
|
||||
@@ -9,7 +9,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "utils/cn";
|
||||
|
||||
const leaves = 8;
|
||||
const leaves = Array.from({ length: 8 }).map((_, i) => i);
|
||||
|
||||
const spinnerVariants = cva("", {
|
||||
variants: {
|
||||
@@ -49,29 +49,25 @@ export function Spinner({
|
||||
{...props}
|
||||
>
|
||||
<title>Loading spinner</title>
|
||||
{[...Array(leaves)].map((_, i) => {
|
||||
const rotation = i * (360 / leaves);
|
||||
|
||||
return (
|
||||
<rect
|
||||
key={i}
|
||||
x="10.9"
|
||||
y="2"
|
||||
width="2"
|
||||
height="5.5"
|
||||
rx="1"
|
||||
// 0.8 = leaves * 0.1
|
||||
className={
|
||||
isChromatic() ? "" : "animate-[loading_0.8s_ease-in-out_infinite]"
|
||||
}
|
||||
style={{
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
transformOrigin: "center",
|
||||
animationDelay: `${-i * 0.1}s`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{leaves.map((leaf) => (
|
||||
<rect
|
||||
key={leaf}
|
||||
x="10.9"
|
||||
y="2"
|
||||
width="2"
|
||||
height="5.5"
|
||||
rx="1"
|
||||
// 0.8 = leaves * 0.1
|
||||
className={
|
||||
isChromatic() ? "" : "animate-[loading_0.8s_ease-in-out_infinite]"
|
||||
}
|
||||
style={{
|
||||
transform: `rotate(${leaf * (360 / leaves.length)}deg)`,
|
||||
transformOrigin: "center",
|
||||
animationDelay: `${-leaf * 0.1}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,32 +3,33 @@ import FormHelperText, {
|
||||
} from "@mui/material/FormHelperText";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import type { ComponentProps, FC } from "react";
|
||||
import { cn } from "utils/cn";
|
||||
|
||||
/**
|
||||
* Use these components as the label in FormControlLabel when implementing radio
|
||||
* buttons, checkboxes, or switches to ensure proper styling.
|
||||
*/
|
||||
|
||||
export const StackLabel: FC<ComponentProps<typeof Stack>> = (props) => {
|
||||
export const StackLabel: FC<ComponentProps<typeof Stack>> = ({
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<Stack
|
||||
spacing={0.5}
|
||||
css={{ paddingLeft: 12, fontWeight: 500 }}
|
||||
className={cn("pl-3 font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const StackLabelHelperText: FC<FormHelperTextProps> = (props) => {
|
||||
export const StackLabelHelperText: FC<FormHelperTextProps> = ({
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<FormHelperText
|
||||
css={(theme) => ({
|
||||
marginTop: 0,
|
||||
|
||||
"& strong": {
|
||||
color: theme.palette.text.primary,
|
||||
},
|
||||
})}
|
||||
className={cn("mt-0 [&_strong]:text-content-primary", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import type { CSSObject, Interpolation, Theme } from "@emotion/react";
|
||||
import type { FC, HTMLAttributes, ReactNode } from "react";
|
||||
import { cn } from "utils/cn";
|
||||
|
||||
export const Stats: FC<HTMLAttributes<HTMLDivElement>> = ({
|
||||
children,
|
||||
className,
|
||||
...attrs
|
||||
}) => {
|
||||
return (
|
||||
<div css={styles.stats} {...attrs}>
|
||||
<div
|
||||
className={cn(
|
||||
"p-4 rounded-[8px] block flex-wrap items-center m-0 text-content-secondary border border-solid border-border text-sm leading-relaxed font-normal md:py-0 md:flex",
|
||||
className,
|
||||
)}
|
||||
{...attrs}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -17,67 +24,24 @@ interface StatsItemProps extends HTMLAttributes<HTMLDivElement> {
|
||||
value: ReactNode;
|
||||
}
|
||||
|
||||
export const StatsItem: FC<StatsItemProps> = ({ label, value, ...attrs }) => {
|
||||
export const StatsItem: FC<StatsItemProps> = ({
|
||||
label,
|
||||
value,
|
||||
className,
|
||||
...attrs
|
||||
}) => {
|
||||
return (
|
||||
<div css={styles.statItem} {...attrs}>
|
||||
<span css={styles.statsLabel}>{label}:</span>
|
||||
<span css={styles.statsValue}>{value}</span>
|
||||
<div
|
||||
className={cn(
|
||||
"text-sm p-2 flex items-baseline gap-2 md:py-3.5 md:px-4",
|
||||
className,
|
||||
)}
|
||||
{...attrs}
|
||||
>
|
||||
<span className="block break-words">{label}:</span>
|
||||
<span className="flex items-center break-words text-content-primary [&_a]:text-content-primary [&_a]:no-underline [&_a]:font-semibold [&_a:hover]:no-underline">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
stats: (theme) => ({
|
||||
...(theme.typography.body2 as CSSObject),
|
||||
paddingLeft: 16,
|
||||
paddingRight: 16,
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
color: theme.palette.text.secondary,
|
||||
margin: "0px",
|
||||
flexWrap: "wrap",
|
||||
|
||||
[theme.breakpoints.down("md")]: {
|
||||
display: "block",
|
||||
padding: 16,
|
||||
},
|
||||
}),
|
||||
|
||||
statItem: (theme) => ({
|
||||
padding: 14,
|
||||
paddingLeft: 16,
|
||||
paddingRight: 16,
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
gap: 8,
|
||||
|
||||
[theme.breakpoints.down("md")]: {
|
||||
padding: 8,
|
||||
},
|
||||
}),
|
||||
|
||||
statsLabel: {
|
||||
display: "block",
|
||||
wordWrap: "break-word",
|
||||
},
|
||||
|
||||
statsValue: (theme) => ({
|
||||
marginTop: 2,
|
||||
display: "flex",
|
||||
wordWrap: "break-word",
|
||||
color: theme.palette.text.primary,
|
||||
alignItems: "center",
|
||||
|
||||
"& a": {
|
||||
color: theme.palette.text.primary,
|
||||
textDecoration: "none",
|
||||
fontWeight: 600,
|
||||
|
||||
"&:hover": {
|
||||
textDecoration: "underline",
|
||||
},
|
||||
},
|
||||
}),
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Loader } from "../Loader/Loader";
|
||||
export const TableLoader: FC = () => {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={999} css={{ textAlign: "center", height: 160 }}>
|
||||
<TableCell colSpan={999} className="text-center h-40">
|
||||
<Loader />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -31,17 +31,26 @@ export const Tabs: FC<TabsProps> = ({ className, active, ...htmlProps }) => {
|
||||
|
||||
type TabsListProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const TabsList: FC<TabsListProps> = (props) => {
|
||||
return <div role="tablist" className="flex items-baseline" {...props} />;
|
||||
export const TabsList: FC<TabsListProps> = ({ className, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
role="tablist"
|
||||
className={cn("flex items-baseline gap-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
type TabLinkProps = LinkProps & {
|
||||
value: string;
|
||||
};
|
||||
|
||||
export const TabLink: FC<TabLinkProps> = ({ value, ...linkProps }) => {
|
||||
export const TabLink: FC<TabLinkProps> = ({
|
||||
value,
|
||||
className,
|
||||
...linkProps
|
||||
}) => {
|
||||
const tabsContext = useContext(TabsContext);
|
||||
|
||||
if (!tabsContext) {
|
||||
throw new Error("Tab only can be used inside of Tabs");
|
||||
}
|
||||
@@ -52,13 +61,14 @@ export const TabLink: FC<TabLinkProps> = ({ value, ...linkProps }) => {
|
||||
<Link
|
||||
{...linkProps}
|
||||
className={cn(
|
||||
`text-sm text-content-secondary no-underline font-medium py-3 px-1 mr-6 hover:text-content-primary rounded-md
|
||||
`text-sm text-content-secondary no-underline font-medium py-3 px-1 hover:text-content-primary rounded-md
|
||||
focus-visible:ring-offset-1 focus-visible:ring-offset-surface-primary
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link focus-visible:rounded-sm`,
|
||||
{
|
||||
"text-content-primary relative before:absolute before:bg-surface-invert-primary before:left-0 before:w-full before:h-px before:-bottom-px before:content-['']":
|
||||
isActive,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Interpolation } from "@emotion/react";
|
||||
import TableRow, { type TableRowProps } from "@mui/material/TableRow";
|
||||
import { forwardRef } from "react";
|
||||
import type { Theme } from "theme";
|
||||
import { cn } from "utils/cn";
|
||||
|
||||
interface TimelineEntryProps extends TableRowProps {
|
||||
clickable?: boolean;
|
||||
@@ -10,48 +9,20 @@ interface TimelineEntryProps extends TableRowProps {
|
||||
export const TimelineEntry = forwardRef<
|
||||
HTMLTableRowElement,
|
||||
TimelineEntryProps
|
||||
>(function TimelineEntry({ children, clickable = true, ...props }, ref) {
|
||||
>(({ children, clickable = true, className, ...props }, ref) => {
|
||||
return (
|
||||
<TableRow
|
||||
ref={ref}
|
||||
css={[styles.row, clickable ? styles.clickable : null]}
|
||||
className={cn(
|
||||
"focus:outline focus:-outline-offset-1 focus:outline-2 focus:outline-content-primary ",
|
||||
"[&_td]:relative [&_td]:overflow-hidden",
|
||||
"[&_td:before]:absolute [&_td:before]:block [&_td:before]:h-full [&_td:before]:content-[''] [&_td:before]:bg-border [&_td:before]:w-0.5 [&_td:before]:left-[calc((32px+(var(--avatar-default)/2))-1px)]",
|
||||
clickable && "cursor-pointer hover:bg-surface-secondary",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TableRow>
|
||||
);
|
||||
});
|
||||
|
||||
const styles = {
|
||||
row: (theme) => ({
|
||||
"--side-padding": "32px",
|
||||
"&:focus": {
|
||||
outlineStyle: "solid",
|
||||
outlineOffset: -1,
|
||||
outlineWidth: 2,
|
||||
outlineColor: theme.palette.primary.main,
|
||||
},
|
||||
"& td": {
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
},
|
||||
"& td:before": {
|
||||
"--line-width": "2px",
|
||||
position: "absolute",
|
||||
left: "calc((var(--side-padding) + var(--avatar-default)/2) - var(--line-width) / 2)",
|
||||
display: "block",
|
||||
content: "''",
|
||||
height: "100%",
|
||||
width: "var(--line-width)",
|
||||
background: theme.palette.divider,
|
||||
},
|
||||
}),
|
||||
|
||||
clickable: (theme) => ({
|
||||
cursor: "pointer",
|
||||
|
||||
"&:hover": {
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
},
|
||||
}),
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { type Interpolation, type Theme, useTheme } from "@emotion/react";
|
||||
import type {
|
||||
ProvisionerJobLog,
|
||||
WorkspaceAgent,
|
||||
@@ -28,23 +27,31 @@ import {
|
||||
} from "modules/workspaces/WorkspaceBuildData/WorkspaceBuildData";
|
||||
import { WorkspaceBuildLogs } from "modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs";
|
||||
import {
|
||||
type CSSProperties,
|
||||
type FC,
|
||||
type HTMLProps,
|
||||
type ReactNode,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Link } from "react-router";
|
||||
import { cn } from "utils/cn";
|
||||
import { displayWorkspaceBuildDuration } from "utils/workspace";
|
||||
import { Sidebar, SidebarCaption, SidebarItem } from "./Sidebar";
|
||||
|
||||
export const LOGS_TAB_KEY = "logs";
|
||||
|
||||
const sortLogsByCreatedAt = (logs: ProvisionerJobLog[]) => {
|
||||
return [...logs].sort(
|
||||
(a, b) =>
|
||||
new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
|
||||
type BuildStatsItemProps = Readonly<{
|
||||
children?: ReactNode;
|
||||
label: string;
|
||||
}>;
|
||||
|
||||
const BuildStatsItem: FC<BuildStatsItemProps> = ({ children, label }) => {
|
||||
return (
|
||||
<StatsItem
|
||||
className="flex-col gap-0 p-0 [&>span:first-of-type]:text-xs [&>span:first-of-type]:font-medium md:p-0"
|
||||
label={label}
|
||||
value={children}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -63,7 +70,6 @@ export const WorkspaceBuildPageView: FC<WorkspaceBuildPageViewProps> = ({
|
||||
builds,
|
||||
activeBuildNumber,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const tabState = useSearchParamsKey({
|
||||
key: LOGS_TAB_KEY,
|
||||
defaultValue: "build",
|
||||
@@ -72,10 +78,7 @@ export const WorkspaceBuildPageView: FC<WorkspaceBuildPageViewProps> = ({
|
||||
if (buildError) {
|
||||
return (
|
||||
<Margins>
|
||||
<ErrorAlert
|
||||
error={buildError}
|
||||
css={{ marginTop: 16, marginBottom: 16 }}
|
||||
/>
|
||||
<ErrorAlert error={buildError} className="my-4" />
|
||||
</Margins>
|
||||
);
|
||||
}
|
||||
@@ -98,54 +101,33 @@ export const WorkspaceBuildPageView: FC<WorkspaceBuildPageViewProps> = ({
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
<Stats aria-label="Build details" css={styles.stats}>
|
||||
<StatsItem
|
||||
css={styles.statsItem}
|
||||
label="Workspace"
|
||||
value={
|
||||
<Link
|
||||
to={`/@${build.workspace_owner_name}/${build.workspace_name}`}
|
||||
>
|
||||
{build.workspace_name}
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<StatsItem
|
||||
css={styles.statsItem}
|
||||
label="Template version"
|
||||
value={build.template_version_name}
|
||||
/>
|
||||
<StatsItem
|
||||
css={styles.statsItem}
|
||||
label="Duration"
|
||||
value={displayWorkspaceBuildDuration(build)}
|
||||
/>
|
||||
<StatsItem
|
||||
css={styles.statsItem}
|
||||
label="Started at"
|
||||
value={new Date(build.created_at).toLocaleString()}
|
||||
/>
|
||||
<StatsItem
|
||||
css={styles.statsItem}
|
||||
label="Action"
|
||||
value={
|
||||
<span css={{ textTransform: "capitalize" }}>
|
||||
{build.transition}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<Stats
|
||||
aria-label="Build details"
|
||||
className="flex flex-col items-start gap-2 px-0 border-none grow basis-0 md:flex-row md:gap-x-12 md:gap-y-6"
|
||||
>
|
||||
<BuildStatsItem label="Workspace">
|
||||
<Link
|
||||
to={`/@${build.workspace_owner_name}/${build.workspace_name}`}
|
||||
>
|
||||
{build.workspace_name}
|
||||
</Link>
|
||||
</BuildStatsItem>
|
||||
<BuildStatsItem label="Template version">
|
||||
{build.template_version_name}
|
||||
</BuildStatsItem>
|
||||
<BuildStatsItem label="Duration">
|
||||
{displayWorkspaceBuildDuration(build)}
|
||||
</BuildStatsItem>
|
||||
<BuildStatsItem label="Started at">
|
||||
{new Date(build.created_at).toLocaleString()}
|
||||
</BuildStatsItem>
|
||||
<BuildStatsItem label="Action">
|
||||
<span className="capitalize">{build.transition}</span>
|
||||
</BuildStatsItem>
|
||||
</Stats>
|
||||
</FullWidthPageHeader>
|
||||
|
||||
<div
|
||||
css={{
|
||||
display: "flex",
|
||||
alignItems: "start",
|
||||
overflow: "hidden",
|
||||
flex: 1,
|
||||
flexBasis: 0,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start overflow-hidden grow basis-0">
|
||||
<Sidebar>
|
||||
<SidebarCaption>Builds</SidebarCaption>
|
||||
{!builds &&
|
||||
@@ -169,13 +151,18 @@ export const WorkspaceBuildPageView: FC<WorkspaceBuildPageViewProps> = ({
|
||||
|
||||
<ScrollArea>
|
||||
<Tabs active={tabState.value}>
|
||||
<TabsList>
|
||||
<TabLink to={`?${LOGS_TAB_KEY}=build`} value="build">
|
||||
<TabsList className="gap-0">
|
||||
<TabLink
|
||||
to={`?${LOGS_TAB_KEY}=build`}
|
||||
value="build"
|
||||
className="px-6 pb-2"
|
||||
>
|
||||
Build
|
||||
</TabLink>
|
||||
|
||||
{agents.map((a) => (
|
||||
<TabLink
|
||||
className="px-6 pb-2"
|
||||
to={`?${LOGS_TAB_KEY}=${a.id}`}
|
||||
value={a.id}
|
||||
key={a.id}
|
||||
@@ -188,23 +175,12 @@ export const WorkspaceBuildPageView: FC<WorkspaceBuildPageViewProps> = ({
|
||||
{build.transition === "delete" && build.job.status === "failed" && (
|
||||
<Alert
|
||||
severity="error"
|
||||
css={{
|
||||
borderRadius: 0,
|
||||
border: 0,
|
||||
background: theme.roles.error.background,
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
}}
|
||||
className="rounded-none border-0 border-b border-solid border-border"
|
||||
>
|
||||
<div>
|
||||
The workspace may have failed to delete due to a Terraform state
|
||||
mismatch. A template admin may run{" "}
|
||||
<code
|
||||
css={{
|
||||
display: "inline-block",
|
||||
width: "fit-content",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<code className="font-semibold w-fit inline-block">
|
||||
{`coder rm ${`${build.workspace_owner_name}/${build.workspace_name}`} --orphan`}
|
||||
</code>{" "}
|
||||
to delete the workspace skipping resource destruction.
|
||||
@@ -215,12 +191,7 @@ export const WorkspaceBuildPageView: FC<WorkspaceBuildPageViewProps> = ({
|
||||
{build?.job?.logs_overflowed && (
|
||||
<Alert
|
||||
severity="warning"
|
||||
css={{
|
||||
borderRadius: 0,
|
||||
border: 0,
|
||||
background: theme.roles.warning.background,
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
}}
|
||||
className="rounded-none border-0 border-b border-solid border-border"
|
||||
>
|
||||
Provisioner logs exceeded the max size of 1MB. Will not continue
|
||||
to write provisioner logs for workspace build.
|
||||
@@ -239,31 +210,41 @@ export const WorkspaceBuildPageView: FC<WorkspaceBuildPageViewProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const ScrollArea: FC<HTMLProps<HTMLDivElement>> = (props) => {
|
||||
// TODO: Use only CSS to set the height of the content.
|
||||
// Note: On Safari, when content is rendered inside a flex container and needs
|
||||
// to scroll, the parent container must have a height set. Achieving this may
|
||||
// require significant refactoring of the layout components where we currently
|
||||
// use height and min-height set to 100%.
|
||||
// Issue: https://github.com/coder/coder/issues/9687
|
||||
// Reference: https://stackoverflow.com/questions/43381836/height100-works-in-chrome-but-not-in-safari
|
||||
const ScrollArea: FC<HTMLProps<HTMLDivElement>> = ({ className, ...props }) => {
|
||||
/**
|
||||
* @todo 2024-10-03 - Use only CSS to set the height of the content.
|
||||
*
|
||||
* On Safari, when content is rendered inside a flex container and needs to
|
||||
* scroll, the parent container must have a height set. Achieving this may
|
||||
* require significant refactoring of the layout components where we
|
||||
* currently use height and min-height set to 100%.
|
||||
*
|
||||
* @see {@link https://github.com/coder/coder/issues/9687}
|
||||
* @see {@link https://stackoverflow.com/questions/43381836/height100-works-in-chrome-but-not-in-safari}
|
||||
*/
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [height, setHeight] = useState<CSSProperties["height"]>("100%");
|
||||
useLayoutEffect(() => {
|
||||
const contentEl = contentRef.current;
|
||||
if (!contentEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
/**
|
||||
* 2025-09-17 - We're updating the height directly to minimize the
|
||||
* overhead in React itself. There is a risk down the line that the
|
||||
* height value will be wiped on re-renders, but that seemed like a
|
||||
* small enough risk that it wasn't worth accounting for just yet
|
||||
*/
|
||||
const syncParentSize = () => {
|
||||
const parentEl = contentEl.parentElement;
|
||||
if (!parentEl) {
|
||||
return;
|
||||
if (parentEl && contentEl) {
|
||||
contentEl.style.height = `${contentEl.parentElement.clientHeight}px`;
|
||||
}
|
||||
setHeight(parentEl.clientHeight);
|
||||
});
|
||||
resizeObserver.observe(document.body);
|
||||
};
|
||||
|
||||
syncParentSize();
|
||||
const resizeObserver = new ResizeObserver(syncParentSize);
|
||||
resizeObserver.observe(document.body);
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
@@ -272,33 +253,36 @@ const ScrollArea: FC<HTMLProps<HTMLDivElement>> = (props) => {
|
||||
return (
|
||||
<div
|
||||
ref={contentRef}
|
||||
css={{ height, overflowY: "auto", width: "100%" }}
|
||||
className={cn("overflow-y-auto w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
function sortLogsByCreatedAt(
|
||||
logs: readonly ProvisionerJobLog[],
|
||||
): ProvisionerJobLog[] {
|
||||
return [...logs].sort((a, b) => {
|
||||
return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
|
||||
});
|
||||
}
|
||||
|
||||
const BuildLogsContent: FC<{
|
||||
logs?: ProvisionerJobLog[];
|
||||
build?: WorkspaceBuild;
|
||||
}> = ({ logs, build }) => {
|
||||
}> = ({ logs = [], build }) => {
|
||||
if (!logs) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspaceBuildLogs
|
||||
css={{
|
||||
border: 0,
|
||||
"--log-line-side-padding": `${TAB_PADDING_X}px`,
|
||||
// Add extra spacing to the first log header to prevent it from being
|
||||
// too close to the tabs
|
||||
"& .logs-header:first-of-type": {
|
||||
paddingTop: 16,
|
||||
},
|
||||
}}
|
||||
logs={sortLogsByCreatedAt(logs)}
|
||||
// logs header class adds extra spacing to the first log header to
|
||||
// prevent it from being too close to the tabs
|
||||
className="border-none [&_.logs-header:first-of-type]:pt-4"
|
||||
style={{ "--log-line-side-padding": `${TAB_PADDING_X}px` }}
|
||||
build={build}
|
||||
logs={sortLogsByCreatedAt(logs)}
|
||||
disableAutoscroll
|
||||
/>
|
||||
);
|
||||
@@ -330,31 +314,3 @@ const AgentLogsContent: FC<AgentLogsContentProps> = ({ agent }) => {
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
stats: (theme) => ({
|
||||
padding: 0,
|
||||
border: 0,
|
||||
gap: 48,
|
||||
rowGap: 24,
|
||||
flex: 1,
|
||||
|
||||
[theme.breakpoints.down("md")]: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
gap: 8,
|
||||
},
|
||||
}),
|
||||
|
||||
statsItem: {
|
||||
flexDirection: "column",
|
||||
gap: 0,
|
||||
padding: 0,
|
||||
|
||||
"& > span:first-of-type": {
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
},
|
||||
},
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { MenuSearch } from "components/Menu/MenuSearch";
|
||||
import { OverflowY } from "components/OverflowY/OverflowY";
|
||||
import { SearchEmpty, searchStyles } from "components/Search/Search";
|
||||
import { SearchEmpty } from "components/Search/Search";
|
||||
import { ChevronDownIcon, ExternalLinkIcon } from "lucide-react";
|
||||
import { linkToTemplate, useLinks } from "modules/navigation";
|
||||
import { type FC, type ReactNode, useState } from "react";
|
||||
@@ -63,7 +63,11 @@ export const WorkspacesButton: FC<WorkspacesButtonProps> = ({
|
||||
<PopoverContent
|
||||
horizontal="right"
|
||||
css={{
|
||||
".MuiPaper-root": searchStyles.content,
|
||||
".MuiPaper-root": {
|
||||
width: 320,
|
||||
padding: 0,
|
||||
borderRadius: 4,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MenuSearch
|
||||
|
||||
Reference in New Issue
Block a user