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:
Michael Smith
2025-09-17 18:55:25 -04:00
committed by GitHub
parent 679179f404
commit d3bf5065a0
16 changed files with 281 additions and 433 deletions
+7
View File
@@ -0,0 +1,7 @@
declare module "react" {
interface CSSProperties {
[key: `--${string}`]: string | number | undefined;
}
}
export {};
+2 -2
View File
@@ -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,
)}
+30 -10
View File
@@ -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>
),
};
+28 -68
View File
@@ -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>>;
+18 -20
View File
@@ -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>
+12 -22
View File
@@ -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>>;
+20 -24
View File
@@ -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>
);
}
+11 -10
View File
@@ -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}
/>
);
+26 -62
View File
@@ -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>
+15 -5
View File
@@ -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,
)}
/>
);
+9 -38
View File
@@ -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