refactor: update the workspace table design (#17404)

Related to https://github.com/coder/coder/issues/17309

**Before:**
<img width="1624" alt="Screenshot 2025-04-15 at 11 36 32"
src="https://github.com/user-attachments/assets/ecca4c22-8d9c-4ee9-8c1d-193f538a0515"
/>

**After:**
<img width="1624" alt="Screenshot 2025-04-15 at 11 36 22"
src="https://github.com/user-attachments/assets/dd95b5cb-12c0-4806-8253-9be97d5a3a8a"
/>
This commit is contained in:
Bruno Quaresma
2025-04-15 13:52:32 -03:00
committed by GitHub
parent 00b5f56734
commit b0fe626250
4 changed files with 207 additions and 253 deletions
+6 -16
View File
@@ -13,9 +13,9 @@
* It might not make sense to test this hook until the underlying design
* problems are fixed.
*/
import { type CSSObject, useTheme } from "@emotion/react";
import type { TableRowProps } from "@mui/material/TableRow";
import type { MouseEventHandler } from "react";
import { cn } from "utils/cn";
import {
type ClickableAriaRole,
type UseClickableResult,
@@ -26,7 +26,7 @@ type UseClickableTableRowResult<
TRole extends ClickableAriaRole = ClickableAriaRole,
> = UseClickableResult<HTMLTableRowElement, TRole> &
TableRowProps & {
css: CSSObject;
className: string;
hover: true;
onAuxClick: MouseEventHandler<HTMLTableRowElement>;
};
@@ -54,23 +54,13 @@ export const useClickableTableRow = <
onAuxClick: externalOnAuxClick,
}: UseClickableTableRowConfig<TRole>): UseClickableTableRowResult<TRole> => {
const clickableProps = useClickable(onClick, (role ?? "button") as TRole);
const theme = useTheme();
return {
...clickableProps,
css: {
cursor: "pointer",
"&:focus": {
outline: `1px solid ${theme.palette.primary.main}`,
outlineOffset: -1,
},
"&:last-of-type": {
borderBottomLeftRadius: 8,
borderBottomRightRadius: 8,
},
},
className: cn([
"cursor-pointer hover:outline focus:outline outline-1 -outline-offset-1 outline-border-hover",
"first:rounded-t-md last:rounded-b-md",
]),
hover: true,
onDoubleClick,
onAuxClick: (event) => {
@@ -102,7 +102,7 @@ const TemplateRow: FC<TemplateRowProps> = ({
);
const navigate = useNavigate();
const { css: clickableCss, ...clickableRow } = useClickableTableRow({
const clickableRow = useClickableTableRow({
onClick: () => navigate(templatePageLink),
});
@@ -111,7 +111,7 @@ const TemplateRow: FC<TemplateRowProps> = ({
key={template.id}
data-testid={`template-${template.id}`}
{...clickableRow}
css={[clickableCss, styles.tableRow]}
css={styles.tableRow}
>
<TableCell>
<AvatarData
+1 -4
View File
@@ -1,4 +1,3 @@
import { useTheme } from "@emotion/react";
import { Stack } from "components/Stack/Stack";
import { StatusIndicatorDot } from "components/StatusIndicator/StatusIndicator";
import dayjs from "dayjs";
@@ -12,8 +11,6 @@ interface LastUsedProps {
}
export const LastUsed: FC<LastUsedProps> = ({ lastUsedAt }) => {
const theme = useTheme();
const [circle, message] = useTime(() => {
const t = dayjs(lastUsedAt);
const now = dayjs();
@@ -40,7 +37,7 @@ export const LastUsed: FC<LastUsedProps> = ({ lastUsedAt }) => {
return (
<Stack
style={{ color: theme.palette.text.secondary }}
className="text-content-secondary"
direction="row"
spacing={1}
alignItems="center"
+198 -231
View File
@@ -1,15 +1,7 @@
import { useTheme } from "@emotion/react";
import KeyboardArrowRight from "@mui/icons-material/KeyboardArrowRight";
import Star from "@mui/icons-material/Star";
import Checkbox from "@mui/material/Checkbox";
import Skeleton from "@mui/material/Skeleton";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import { visuallyHidden } from "@mui/utils";
import type {
Template,
Workspace,
@@ -21,6 +13,14 @@ import { AvatarData } from "components/Avatar/AvatarData";
import { AvatarDataSkeleton } from "components/Avatar/AvatarDataSkeleton";
import { InfoTooltip } from "components/InfoTooltip/InfoTooltip";
import { Stack } from "components/Stack/Stack";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "components/Table/Table";
import {
TableLoaderSkeleton,
TableRowSkeleton,
@@ -34,6 +34,7 @@ import { WorkspaceStatusBadge } from "modules/workspaces/WorkspaceStatusBadge/Wo
import { LastUsed } from "pages/WorkspacesPage/LastUsed";
import { type FC, type ReactNode, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { cn } from "utils/cn";
import { getDisplayWorkspaceTemplateName } from "utils/workspace";
import { WorkspacesEmpty } from "./WorkspacesEmpty";
@@ -59,7 +60,6 @@ export const WorkspacesTable: FC<WorkspacesTableProps> = ({
templates,
canCreateTemplate,
}) => {
const theme = useTheme();
const dashboard = useDashboard();
const workspaceIDToAppByStatus = useMemo(() => {
return (
@@ -96,213 +96,189 @@ export const WorkspacesTable: FC<WorkspacesTableProps> = ({
);
return (
<TableContainer>
<Table>
<TableHead>
<Table>
<TableHeader>
<TableRow>
<TableHead className={hasAppStatus ? "w-1/6" : "w-2/6"}>
<div className="flex items-center gap-2">
{canCheckWorkspaces && (
<Checkbox
className="-my-[9px]"
disabled={!workspaces || workspaces.length === 0}
checked={checkedWorkspaces.length === workspaces?.length}
size="xsmall"
onChange={(_, checked) => {
if (!workspaces) {
return;
}
if (!checked) {
onCheckChange([]);
} else {
onCheckChange(workspaces);
}
}}
/>
)}
Name
</div>
</TableHead>
{hasAppStatus && <TableHead className="w-2/6">Activity</TableHead>}
<TableHead className="w-2/6">Template</TableHead>
<TableHead className="w-1/6">Last used</TableHead>
<TableHead className="w-1/6">Status</TableHead>
<TableHead className="w-0" />
</TableRow>
</TableHeader>
<TableBody className="[&_td]:h-[72px]">
{!workspaces && <TableLoader canCheckWorkspaces={canCheckWorkspaces} />}
{workspaces && workspaces.length === 0 && (
<TableRow>
<TableCell width={hasAppStatus ? "30%" : "40%"}>
<div css={{ display: "flex", alignItems: "center", gap: 8 }}>
{canCheckWorkspaces && (
<Checkbox
// Remove the extra padding added for the first cell in the
// table
css={{
marginLeft: "-20px",
// MUI by default adds 9px padding to enhance the
// clickable area. We aim to prevent this from impacting
// the layout of surrounding elements.
marginTop: -9,
marginBottom: -9,
}}
disabled={!workspaces || workspaces.length === 0}
checked={checkedWorkspaces.length === workspaces?.length}
size="xsmall"
onChange={(_, checked) => {
if (!workspaces) {
return;
}
if (!checked) {
onCheckChange([]);
} else {
onCheckChange(workspaces);
}
}}
/>
)}
Name
</div>
<TableCell colSpan={999}>
<WorkspacesEmpty
templates={templates}
isUsingFilter={isUsingFilter}
canCreateTemplate={canCreateTemplate}
/>
</TableCell>
{hasAppStatus && <TableCell width="30%">Activity</TableCell>}
<TableCell width="25%">Template</TableCell>
<TableCell width="20%">Last used</TableCell>
<TableCell width="15%">Status</TableCell>
<TableCell width="1%" />
</TableRow>
</TableHead>
<TableBody>
{!workspaces && (
<TableLoader canCheckWorkspaces={canCheckWorkspaces} />
)}
{workspaces && workspaces.length === 0 && (
<WorkspacesEmpty
templates={templates}
isUsingFilter={isUsingFilter}
canCreateTemplate={canCreateTemplate}
/>
)}
{workspaces?.map((workspace) => {
const checked = checkedWorkspaces.some(
(w) => w.id === workspace.id,
);
const activeOrg = dashboard.organizations.find(
(o) => o.id === workspace.organization_id,
);
)}
{workspaces?.map((workspace) => {
const checked = checkedWorkspaces.some((w) => w.id === workspace.id);
const activeOrg = dashboard.organizations.find(
(o) => o.id === workspace.organization_id,
);
return (
<WorkspacesRow
workspace={workspace}
key={workspace.id}
checked={checked}
>
return (
<WorkspacesRow
workspace={workspace}
key={workspace.id}
checked={checked}
>
<TableCell>
<div className="flex items-center gap-2">
{canCheckWorkspaces && (
<Checkbox
data-testid={`checkbox-${workspace.id}`}
size="xsmall"
disabled={cantBeChecked(workspace)}
checked={checked}
onClick={(e) => {
e.stopPropagation();
}}
onChange={(e) => {
if (e.currentTarget.checked) {
onCheckChange([...checkedWorkspaces, workspace]);
} else {
onCheckChange(
checkedWorkspaces.filter(
(w) => w.id !== workspace.id,
),
);
}
}}
/>
)}
<AvatarData
title={
<Stack direction="row" spacing={0.5} alignItems="center">
{workspace.name}
{workspace.favorite && <Star className="w-4 h-4" />}
{workspace.outdated && (
<WorkspaceOutdatedTooltip
organizationName={workspace.organization_name}
templateName={workspace.template_name}
latestVersionId={
workspace.template_active_version_id
}
onUpdateVersion={() => {
onUpdateWorkspace(workspace);
}}
/>
)}
</Stack>
}
subtitle={
<div>
<span className="sr-only">Owner: </span>
{workspace.owner_name}
</div>
}
avatar={
<Avatar
src={workspace.owner_avatar_url}
fallback={workspace.owner_name}
size="lg"
/>
}
/>
</div>
</TableCell>
{hasAppStatus && (
<TableCell>
<div css={{ display: "flex", alignItems: "center", gap: 8 }}>
{canCheckWorkspaces && (
<Checkbox
// Remove the extra padding added for the first cell in the
// table
css={{
marginLeft: "-20px",
}}
data-testid={`checkbox-${workspace.id}`}
size="xsmall"
disabled={cantBeChecked(workspace)}
checked={checked}
onClick={(e) => {
e.stopPropagation();
}}
onChange={(e) => {
if (e.currentTarget.checked) {
onCheckChange([...checkedWorkspaces, workspace]);
} else {
onCheckChange(
checkedWorkspaces.filter(
(w) => w.id !== workspace.id,
),
);
}
}}
<WorkspaceAppStatus
workspace={workspace}
agent={workspaceIDToAppByStatus[workspace.id]?.agent}
app={workspaceIDToAppByStatus[workspace.id]?.app}
status={workspace.latest_app_status}
/>
</TableCell>
)}
<TableCell>
<AvatarData
title={getDisplayWorkspaceTemplateName(workspace)}
subtitle={
dashboard.showOrganizations && (
<>
<span className="sr-only">Organization:</span>{" "}
{activeOrg?.display_name || workspace.organization_name}
</>
)
}
avatar={
<Avatar
variant="icon"
src={workspace.template_icon}
fallback={getDisplayWorkspaceTemplateName(workspace)}
size="lg"
/>
}
/>
</TableCell>
<TableCell>
<LastUsed lastUsedAt={workspace.last_used_at} />
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<WorkspaceStatusBadge workspace={workspace} />
{workspace.latest_build.status === "running" &&
!workspace.health.healthy && (
<InfoTooltip
type="warning"
title="Workspace is unhealthy"
message="Your workspace is running but some agents are unhealthy."
/>
)}
<AvatarData
title={
<Stack
direction="row"
spacing={0.5}
alignItems="center"
>
{workspace.name}
{workspace.favorite && (
<Star css={{ width: 16, height: 16 }} />
)}
{workspace.outdated && (
<WorkspaceOutdatedTooltip
organizationName={workspace.organization_name}
templateName={workspace.template_name}
latestVersionId={
workspace.template_active_version_id
}
onUpdateVersion={() => {
onUpdateWorkspace(workspace);
}}
/>
)}
</Stack>
}
subtitle={
<div>
<span css={{ ...visuallyHidden }}>Owner: </span>
{workspace.owner_name}
</div>
}
avatar={
<Avatar
variant="icon"
src={workspace.template_icon}
fallback={workspace.name}
size="lg"
/>
}
/>
</div>
</TableCell>
{hasAppStatus && (
<TableCell>
<WorkspaceAppStatus
workspace={workspace}
agent={workspaceIDToAppByStatus[workspace.id]?.agent}
app={workspaceIDToAppByStatus[workspace.id]?.app}
status={workspace.latest_app_status}
/>
</TableCell>
)}
<TableCell>
<div>{getDisplayWorkspaceTemplateName(workspace)}</div>
{dashboard.showOrganizations && (
<div
css={{
fontSize: 13,
color: theme.palette.text.secondary,
lineHeight: 1.5,
}}
>
<span css={{ ...visuallyHidden }}>Organization: </span>
{activeOrg?.display_name || workspace.organization_name}
</div>
{workspace.dormant_at && (
<WorkspaceDormantBadge workspace={workspace} />
)}
</TableCell>
</div>
</TableCell>
<TableCell>
<LastUsed lastUsedAt={workspace.last_used_at} />
</TableCell>
<TableCell>
<div css={{ display: "flex", alignItems: "center", gap: 8 }}>
<WorkspaceStatusBadge workspace={workspace} />
{workspace.latest_build.status === "running" &&
!workspace.health.healthy && (
<InfoTooltip
type="warning"
title="Workspace is unhealthy"
message="Your workspace is running but some agents are unhealthy."
/>
)}
{workspace.dormant_at && (
<WorkspaceDormantBadge workspace={workspace} />
)}
</div>
</TableCell>
<TableCell>
<div css={{ display: "flex", paddingLeft: 16 }}>
<KeyboardArrowRight
css={{
color: theme.palette.text.secondary,
width: 20,
height: 20,
}}
/>
</div>
</TableCell>
</WorkspacesRow>
);
})}
</TableBody>
</Table>
</TableContainer>
<TableCell>
<div className="flex pl-4">
<KeyboardArrowRight className="text-content-secondary w-5 h-5" />
</div>
</TableCell>
</WorkspacesRow>
);
})}
</TableBody>
</Table>
);
};
@@ -318,7 +294,6 @@ const WorkspacesRow: FC<WorkspacesRowProps> = ({
checked,
}) => {
const navigate = useNavigate();
const theme = useTheme();
const workspacePageLink = `/@${workspace.owner_name}/${workspace.name}`;
const openLinkInNewTab = () => window.open(workspacePageLink, "_blank");
@@ -339,20 +314,14 @@ const WorkspacesRow: FC<WorkspacesRowProps> = ({
},
});
const bgColor = checked ? theme.palette.action.hover : undefined;
return (
<TableRow
{...clickableProps}
data-testid={`workspace-${workspace.id}`}
css={{
...clickableProps.css,
backgroundColor: bgColor,
"&:hover": {
backgroundColor: `${bgColor} !important`,
},
}}
className={cn([
checked ? "bg-muted hover:bg-muted" : undefined,
clickableProps.className,
])}
>
{children}
</TableRow>
@@ -367,25 +336,23 @@ const TableLoader: FC<TableLoaderProps> = ({ canCheckWorkspaces }) => {
return (
<TableLoaderSkeleton>
<TableRowSkeleton>
<TableCell width="40%">
<div css={{ display: "flex", alignItems: "center", gap: 8 }}>
{canCheckWorkspaces && (
<Checkbox size="small" disabled css={{ marginLeft: "-20px" }} />
)}
<TableCell className="w-2/6">
<div className="flex items-center gap-2">
{canCheckWorkspaces && <Checkbox size="small" disabled />}
<AvatarDataSkeleton />
</div>
</TableCell>
<TableCell>
<Skeleton variant="text" width="25%" />
<TableCell className="w-2/6">
<AvatarDataSkeleton />
</TableCell>
<TableCell>
<Skeleton variant="text" width="25%" />
<TableCell className="w-1/6">
<Skeleton variant="text" width="75%" />
</TableCell>
<TableCell>
<Skeleton variant="text" width="25%" />
<TableCell className="w-1/6">
<Skeleton variant="text" width="75%" />
</TableCell>
<TableCell>
<Skeleton variant="text" width="25%" />
<TableCell className="w-0">
<Skeleton variant="text" width="75%" />
</TableCell>
</TableRowSkeleton>
</TableLoaderSkeleton>