feat(site): refactor workspace header to be more slim (#11327)

This commit is contained in:
Bruno Quaresma 2024-01-02 12:42:51 -03:00 committed by GitHub
parent 608937c79c
commit cf17fabcc6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 662 additions and 482 deletions

View File

@ -21,8 +21,8 @@
"contravariance",
"cronstrue",
"databasefake",
"dbmem",
"dbgen",
"dbmem",
"dbtype",
"DERP",
"derphttp",
@ -118,13 +118,13 @@
"stretchr",
"STTY",
"stuntest",
"tanstack",
"tailbroker",
"tailcfg",
"tailexchange",
"tailnet",
"tailnettest",
"Tailscale",
"tanstack",
"tbody",
"TCGETS",
"tcpip",
@ -141,6 +141,7 @@
"tios",
"tmpdir",
"tokenconfig",
"Topbar",
"tparallel",
"trialer",
"trimprefix",

View File

@ -17,7 +17,6 @@ export const decorators = [
(Story, context) => {
const selectedTheme = DecoratorHelpers.pluckThemeFromContext(context);
const { themeOverride } = DecoratorHelpers.useThemeParameters();
const selected = themeOverride || selectedTheme || "dark";
return (
@ -39,23 +38,7 @@ export const decorators = [
</HelmetProvider>
);
},
(Story) => {
return (
<QueryClientProvider
client={
new QueryClient({
defaultOptions: {
queries: {
staleTime: Infinity,
},
},
})
}
>
<Story />
</QueryClientProvider>
);
},
withQuery,
];
export const parameters = {
@ -89,3 +72,25 @@ export const parameters = {
},
},
};
function withQuery(Story, { parameters }) {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: Infinity,
},
},
});
if (parameters.queries) {
parameters.queries.forEach((query) => {
queryClient.setQueryData(query.key, query.data);
});
}
return (
<QueryClientProvider client={queryClient}>
<Story />
</QueryClientProvider>
);
}

View File

@ -47,12 +47,9 @@ export const createWorkspace = async (
await expect(page).toHaveURL("/@admin/" + name);
await page.waitForSelector(
"span[data-testid='build-status'] >> text=Running",
{
state: "visible",
},
);
await page.waitForSelector("*[data-testid='build-status'] >> text=Running", {
state: "visible",
});
return name;
};
@ -197,12 +194,9 @@ export const stopWorkspace = async (page: Page, workspaceName: string) => {
await page.getByTestId("workspace-stop-button").click();
await page.waitForSelector(
"span[data-testid='build-status'] >> text=Stopped",
{
state: "visible",
},
);
await page.waitForSelector("*[data-testid='build-status'] >> text=Stopped", {
state: "visible",
});
};
export const buildWorkspaceWithParameters = async (
@ -225,12 +219,9 @@ export const buildWorkspaceWithParameters = async (
await page.getByTestId("confirm-button").click();
}
await page.waitForSelector(
"span[data-testid='build-status'] >> text=Running",
{
state: "visible",
},
);
await page.waitForSelector("*[data-testid='build-status'] >> text=Running", {
state: "visible",
});
};
// startAgent runs the coder agent with the provided token.
@ -772,12 +763,9 @@ export const updateWorkspace = async (
await fillParameters(page, richParameters, buildParameters);
await page.getByTestId("form-submit").click();
await page.waitForSelector(
"span[data-testid='build-status'] >> text=Running",
{
state: "visible",
},
);
await page.waitForSelector("*[data-testid='build-status'] >> text=Running", {
state: "visible",
});
};
export const updateWorkspaceParameters = async (
@ -796,10 +784,7 @@ export const updateWorkspaceParameters = async (
await fillParameters(page, richParameters, buildParameters);
await page.getByTestId("form-submit").click();
await page.waitForSelector(
"span[data-testid='build-status'] >> text=Running",
{
state: "visible",
},
);
await page.waitForSelector("*[data-testid='build-status'] >> text=Running", {
state: "visible",
});
};

11
site/src/@types/storybook.d.ts vendored Normal file
View File

@ -0,0 +1,11 @@
import * as _storybook_types from "@storybook/react";
import { Experiments, FeatureName } from "api/typesGenerated";
import { QueryKey } from "react-query";
declare module "@storybook/react" {
interface Parameters {
features?: FeatureName[];
experiments?: Experiments;
queries?: { key: QueryKey; data: unknown }[];
}
}

View File

@ -1,6 +1,6 @@
import * as API from "api/api";
const getWorkspaceQuotaQueryKey = (username: string) => [
export const getWorkspaceQuotaQueryKey = (username: string) => [
username,
"workspaceQuota",
];

View File

@ -0,0 +1,127 @@
import { css } from "@emotion/css";
import Button, { ButtonProps } from "@mui/material/Button";
import IconButton, { IconButtonProps } from "@mui/material/IconButton";
import { useTheme } from "@mui/material/styles";
import { Avatar, AvatarProps } from "components/Avatar/Avatar";
import {
ForwardedRef,
HTMLAttributes,
PropsWithChildren,
ReactElement,
cloneElement,
forwardRef,
} from "react";
export const Topbar = (props: HTMLAttributes<HTMLDivElement>) => {
const theme = useTheme();
return (
<header
{...props}
css={{
minHeight: 48,
borderBottom: `1px solid ${theme.palette.divider}`,
display: "flex",
alignItems: "center",
fontSize: 13,
lineHeight: "1.2",
}}
/>
);
};
export const TopbarIconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
(props, ref) => {
return (
<IconButton
ref={ref}
{...props}
size="small"
css={{
padding: 0,
borderRadius: 0,
height: 48,
width: 48,
"& svg": {
fontSize: 20,
},
}}
/>
);
},
) as typeof IconButton;
export const TopbarButton = forwardRef<HTMLButtonElement, ButtonProps>(
(props: ButtonProps, ref) => {
return (
<Button
ref={ref}
color="neutral"
css={{
height: 28,
fontSize: 13,
borderRadius: 4,
padding: "0 12px",
}}
{...props}
/>
);
},
);
export const TopbarData = (props: HTMLAttributes<HTMLDivElement>) => {
return (
<div
{...props}
css={{
display: "flex",
gap: 8,
alignItems: "center",
justifyContent: "center",
}}
/>
);
};
export const TopbarDivider = (props: HTMLAttributes<HTMLSpanElement>) => {
const theme = useTheme();
return (
<span {...props} css={{ color: theme.palette.divider }}>
/
</span>
);
};
export const TopbarAvatar = (props: AvatarProps) => {
return (
<Avatar
{...props}
variant="square"
fitImage
css={{ width: 16, height: 16 }}
/>
);
};
type TopbarIconProps = PropsWithChildren<HTMLAttributes<HTMLOrSVGElement>>;
export const TopbarIcon = forwardRef<HTMLOrSVGElement, TopbarIconProps>(
(props: TopbarIconProps, ref) => {
const { children, ...restProps } = props;
const theme = useTheme();
return cloneElement(
children as ReactElement<
HTMLAttributes<HTMLOrSVGElement> & {
ref: ForwardedRef<HTMLOrSVGElement>;
}
>,
{
...restProps,
ref,
className: css({ fontSize: 16, color: theme.palette.text.disabled }),
},
);
},
);

View File

@ -1,61 +0,0 @@
import { StatsItem } from "components/Stats/Stats";
import Link from "@mui/material/Link";
import { type FC } from "react";
import { Link as RouterLink } from "react-router-dom";
import type { Workspace } from "api/typesGenerated";
import { useDashboard } from "components/Dashboard/DashboardProvider";
import { displayDormantDeletion } from "./utils";
interface DormantDeletionStatProps {
workspace: Workspace;
}
export const DormantDeletionStat: FC<DormantDeletionStatProps> = ({
workspace,
}) => {
const { entitlements, experiments } = useDashboard();
const allowAdvancedScheduling =
entitlements.features["advanced_template_scheduling"].enabled;
// This check can be removed when https://github.com/coder/coder/milestone/19
// is merged up
const allowWorkspaceActions = experiments.includes("workspace_actions");
if (
!displayDormantDeletion(
workspace,
allowAdvancedScheduling,
allowWorkspaceActions,
)
) {
return null;
}
return (
<StatsItem
label="Deletion on"
className="containerClass"
value={
<Link
component={RouterLink}
to={`/templates/${workspace.template_name}/settings/schedule`}
title="Schedule settings"
>
{/* We check for string existence in the conditional */}
{new Date(workspace.deleting_at!).toLocaleString()}
</Link>
}
css={{
"&.containerClass": {
flexDirection: "column",
gap: 0,
padding: 0,
"& > span:first-of-type": {
fontSize: 12,
fontWeight: 500,
},
},
}}
/>
);
};

View File

@ -1,6 +1,6 @@
import { type FC } from "react";
import type { Workspace } from "api/typesGenerated";
import { displayDormantDeletion } from "./utils";
import { displayDormantDeletion } from "utils/dormant";
import { useDashboard } from "components/Dashboard/DashboardProvider";
interface DormantDeletionTextProps {

View File

@ -1,3 +1,2 @@
export * from "./DormantDeletionStat";
export * from "./DormantDeletionText";
export * from "./DormantWorkspaceBanner";

View File

@ -42,12 +42,12 @@ export const WorkspaceOutdatedTooltip: FC<TooltipProps> = (props) => {
<InfoIcon css={styles.icon} />
</HelpTooltipTrigger>
<OutdatedTooltipContent {...props} />
<WorkspaceOutdatedTooltipContent {...props} />
</HelpTooltip>
);
};
const OutdatedTooltipContent = (props: TooltipProps) => {
export const WorkspaceOutdatedTooltipContent = (props: TooltipProps) => {
const popover = usePopover();
const { onUpdateVersion, ariaLabel, latestVersionId, templateName } = props;
const { data: activeVersion } = useQuery({
@ -62,7 +62,7 @@ const OutdatedTooltipContent = (props: TooltipProps) => {
<HelpTooltipText>{Language.versionTooltipText}</HelpTooltipText>
<div css={styles.container}>
<div>
<div css={{ lineHeight: "1.6" }}>
<div css={styles.bold}>New version</div>
<div>
{activeVersion ? (
@ -79,7 +79,7 @@ const OutdatedTooltipContent = (props: TooltipProps) => {
</div>
</div>
<div>
<div css={{ lineHeight: "1.6" }}>
<div css={styles.bold}>Message</div>
<div>
{activeVersion ? (

View File

@ -47,13 +47,25 @@ export const WorkspaceStatusBadge: FC<WorkspaceStatusBadgeProps> = ({
}
placement="top"
>
<Pill role="status" className={className} icon={icon} type={type}>
<Pill
role="status"
data-testid="build-status"
className={className}
icon={icon}
type={type}
>
{text}
</Pill>
</FailureTooltip>
</Cond>
<Cond>
<Pill role="status" className={className} icon={icon} type={type}>
<Pill
role="status"
data-testid="build-status"
className={className}
icon={icon}
type={type}
>
{text}
</Pill>
</Cond>

View File

@ -1,4 +1,4 @@
import Button, { type ButtonProps } from "@mui/material/Button";
import Button from "@mui/material/Button";
import IconButton from "@mui/material/IconButton";
import Tooltip from "@mui/material/Tooltip";
import CreateIcon from "@mui/icons-material/AddOutlined";
@ -12,7 +12,6 @@ import type {
} from "api/typesGenerated";
import { Link as RouterLink } from "react-router-dom";
import { Alert, AlertDetail } from "components/Alert/Alert";
import { Avatar } from "components/Avatar/Avatar";
import { TemplateResourcesTable } from "components/TemplateResourcesTable/TemplateResourcesTable";
import { WorkspaceBuildLogs } from "components/WorkspaceBuildLogs/WorkspaceBuildLogs";
import { PublishVersionData } from "pages/TemplateVersionEditorPage/types";
@ -45,6 +44,14 @@ import ArrowBackOutlined from "@mui/icons-material/ArrowBackOutlined";
import CloseOutlined from "@mui/icons-material/CloseOutlined";
import { MONOSPACE_FONT_FAMILY } from "theme/constants";
import { Loader } from "components/Loader/Loader";
import {
Topbar,
TopbarAvatar,
TopbarButton,
TopbarData,
TopbarDivider,
TopbarIconButton,
} from "components/FullPageLayout/Topbar";
type Tab = "logs" | "resources" | undefined; // Undefined is to hide the tab
@ -176,48 +183,26 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
return (
<>
<div css={{ height: "100%", display: "flex", flexDirection: "column" }}>
<div
<Topbar
css={{
height: 48,
borderBottom: `1px solid ${theme.palette.divider}`,
display: "grid",
gridTemplateColumns: "1fr 2fr 1fr",
alignItems: "center",
}}
data-testid="topbar"
>
<div>
<Tooltip title="Back to the template">
<IconButton
<TopbarIconButton
component={RouterLink}
to={`/templates/${template.name}`}
size="small"
css={{
padding: "0 16px",
borderRadius: 0,
height: 48,
}}
>
<ArrowBackOutlined css={{ width: 20, height: 20 }} />
</IconButton>
<ArrowBackOutlined />
</TopbarIconButton>
</Tooltip>
</div>
<div
css={{
fontSize: 13,
display: "flex",
gap: 8,
alignItems: "center",
justifyContent: "center",
}}
>
<Avatar
src={template.icon}
variant="square"
fitImage
css={{ width: 16, height: 16 }}
/>
<TopbarData>
<TopbarAvatar src={template.icon} />
<RouterLink
to={`/templates/${template.name}`}
css={{
@ -231,11 +216,11 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
>
{template.display_name || template.name}
</RouterLink>
<span css={{ color: theme.palette.divider }}>/</span>
<TopbarDivider />
<span css={{ color: theme.palette.text.secondary }}>
{templateVersion.name}
</span>
</div>
</TopbarData>
<div
css={{
@ -273,7 +258,7 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
Publish
</TopbarButton>
</div>
</div>
</Topbar>
<div
css={{
@ -458,7 +443,6 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
<div
css={{
borderTop: `1px solid ${theme.palette.divider}`,
overflow: "hidden",
display: "flex",
flexDirection: "column",
@ -644,22 +628,6 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
);
};
const TopbarButton: FC<ButtonProps> = ({ children, ...buttonProps }) => {
return (
<Button
{...buttonProps}
css={{
height: 28,
fontSize: 13,
borderRadius: 4,
padding: "0 12px",
}}
>
{children}
</Button>
);
};
const styles = {
tab: (theme) => ({
"&:not(:disabled)": {

View File

@ -9,25 +9,17 @@ import { Alert, AlertDetail } from "components/Alert/Alert";
import { Margins } from "components/Margins/Margins";
import { Resources } from "components/Resources/Resources";
import { Stack } from "components/Stack/Stack";
import {
FullWidthPageHeader,
PageHeaderActions,
PageHeaderTitle,
PageHeaderSubtitle,
} from "components/PageHeader/FullWidthPageHeader";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { DormantWorkspaceBanner } from "components/WorkspaceDeletion";
import { Avatar } from "components/Avatar/Avatar";
import { AgentRow } from "components/Resources/AgentRow";
import { useLocalStorage } from "hooks";
import { WorkspaceActions } from "pages/WorkspacePage/WorkspaceActions/WorkspaceActions";
import {
ActiveTransition,
WorkspaceBuildProgress,
} from "./WorkspaceBuildProgress";
import { BuildsTable } from "./BuildsTable";
import { WorkspaceDeletedBanner } from "./WorkspaceDeletedBanner";
import { WorkspaceStats } from "./WorkspaceStats";
import { WorkspaceTopbar } from "./WorkspaceTopbar/WorkspaceTopbar";
export type WorkspaceError =
| "getBuildsError"
@ -59,7 +51,6 @@ export interface WorkspaceProps {
buildInfo?: TypesGen.BuildInfoResponse;
sshPrefix?: string;
template?: TypesGen.Template;
quotaBudget?: number;
canRetryDebugMode: boolean;
handleBuildRetry: () => void;
handleBuildRetryDebug: () => void;
@ -159,51 +150,25 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
return (
<>
<FullWidthPageHeader>
<Stack direction="row" spacing={3} alignItems="center">
<Avatar
size="md"
src={workspace.template_icon}
variant={workspace.template_icon ? "square" : undefined}
fitImage={Boolean(workspace.template_icon)}
>
{workspace.name}
</Avatar>
<div>
<PageHeaderTitle>{workspace.name}</PageHeaderTitle>
<PageHeaderSubtitle>{workspace.owner_name}</PageHeaderSubtitle>
</div>
</Stack>
<WorkspaceStats
workspace={workspace}
handleUpdate={handleUpdate}
canUpdateWorkspace={canUpdateWorkspace}
/>
{canUpdateWorkspace && (
<PageHeaderActions>
<WorkspaceActions
workspace={workspace}
handleStart={handleStart}
handleStop={handleStop}
handleRestart={handleRestart}
handleDelete={handleDelete}
handleUpdate={handleUpdate}
handleCancel={handleCancel}
handleSettings={handleSettings}
handleRetry={handleBuildRetry}
handleRetryDebug={handleBuildRetryDebug}
handleChangeVersion={handleChangeVersion}
handleDormantActivate={handleDormantActivate}
canRetryDebug={canRetryDebugMode}
canChangeVersions={canChangeVersions}
isUpdating={isUpdating}
isRestarting={isRestarting}
/>
</PageHeaderActions>
)}
</FullWidthPageHeader>
<WorkspaceTopbar
workspace={workspace}
handleStart={handleStart}
handleStop={handleStop}
handleRestart={handleRestart}
handleDelete={handleDelete}
handleUpdate={handleUpdate}
handleCancel={handleCancel}
handleSettings={handleSettings}
handleBuildRetry={handleBuildRetry}
handleBuildRetryDebug={handleBuildRetryDebug}
handleChangeVersion={handleChangeVersion}
handleDormantActivate={handleDormantActivate}
canRetryDebugMode={canRetryDebugMode}
canChangeVersions={canChangeVersions}
isUpdating={isUpdating}
isRestarting={isRestarting}
canUpdateWorkspace={canUpdateWorkspace}
/>
<Margins css={styles.content}>
<Stack direction="column" css={styles.firstColumnSpacer} spacing={4}>

View File

@ -28,6 +28,7 @@ import {
PopoverTrigger,
usePopover,
} from "components/Popover/Popover";
import { TopbarButton } from "components/FullPageLayout/Topbar";
interface BuildParametersPopoverProps {
workspace: Workspace;
@ -51,14 +52,14 @@ export const BuildParametersPopover: FC<BuildParametersPopoverProps> = ({
return (
<Popover>
<PopoverTrigger>
<Button
<TopbarButton
data-testid="build-parameters-button"
disabled={disabled}
color="neutral"
css={{ paddingLeft: 0, paddingRight: 0 }}
css={{ paddingLeft: 0, paddingRight: 0, minWidth: "28px !important" }}
>
<ExpandMoreOutlined css={{ fontSize: 16 }} />
</Button>
<ExpandMoreOutlined css={{ fontSize: 14 }} />
</TopbarButton>
</PopoverTrigger>
<PopoverContent
horizontal="right"

View File

@ -1,6 +1,4 @@
import Tooltip from "@mui/material/Tooltip";
import Button from "@mui/material/Button";
import LoadingButton from "@mui/lab/LoadingButton";
import ButtonGroup from "@mui/material/ButtonGroup";
import CloudQueueIcon from "@mui/icons-material/CloudQueue";
import CropSquareIcon from "@mui/icons-material/CropSquare";
@ -14,6 +12,7 @@ import RetryDebugIcon from "@mui/icons-material/BugReportOutlined";
import { type FC } from "react";
import type { Workspace, WorkspaceBuildParameter } from "api/typesGenerated";
import { BuildParametersPopover } from "./BuildParametersPopover";
import { TopbarButton } from "components/FullPageLayout/Topbar";
interface ActionButtonProps {
loading?: boolean;
@ -27,15 +26,14 @@ export const UpdateButton: FC<ActionButtonProps> = ({
loading,
}) => {
return (
<LoadingButton
loading={loading}
loadingPosition="start"
<TopbarButton
disabled={loading}
data-testid="workspace-update-button"
startIcon={<CloudQueueIcon />}
onClick={() => handleAction()}
>
{loading ? <>Updating&hellip;</> : <>Update&hellip;</>}
</LoadingButton>
</TopbarButton>
);
};
@ -44,14 +42,13 @@ export const ActivateButton: FC<ActionButtonProps> = ({
loading,
}) => {
return (
<LoadingButton
loading={loading}
loadingPosition="start"
<TopbarButton
disabled={loading}
startIcon={<PowerSettingsNewIcon />}
onClick={() => handleAction()}
>
{loading ? <>Activating&hellip;</> : "Activate"}
</LoadingButton>
</TopbarButton>
);
};
@ -77,15 +74,13 @@ export const StartButton: FC<ActionButtonPropsWithWorkspace> = ({
}}
disabled={disabled}
>
<LoadingButton
loading={loading}
loadingPosition="start"
<TopbarButton
startIcon={<PlayCircleOutlineIcon />}
onClick={() => handleAction()}
disabled={disabled}
disabled={disabled || loading}
>
{loading ? <>Starting&hellip;</> : "Start"}
</LoadingButton>
</TopbarButton>
<BuildParametersPopover
workspace={workspace}
disabled={loading}
@ -106,15 +101,14 @@ export const StopButton: FC<ActionButtonProps> = ({
loading,
}) => {
return (
<LoadingButton
loading={loading}
loadingPosition="start"
<TopbarButton
disabled={loading}
startIcon={<CropSquareIcon />}
onClick={() => handleAction()}
data-testid="workspace-stop-button"
>
{loading ? <>Stopping&hellip;</> : "Stop"}
</LoadingButton>
</TopbarButton>
);
};
@ -136,16 +130,14 @@ export const RestartButton: FC<ActionButtonPropsWithWorkspace> = ({
}}
disabled={disabled}
>
<LoadingButton
loading={loading}
loadingPosition="start"
<TopbarButton
startIcon={<ReplayIcon />}
onClick={() => handleAction()}
data-testid="workspace-restart-button"
disabled={disabled}
disabled={disabled || loading}
>
{loading ? <>Restarting&hellip;</> : <>Restart&hellip;</>}
</LoadingButton>
</TopbarButton>
<BuildParametersPopover
workspace={workspace}
disabled={loading}
@ -163,9 +155,9 @@ export const RestartButton: FC<ActionButtonPropsWithWorkspace> = ({
export const CancelButton: FC<ActionButtonProps> = ({ handleAction }) => {
return (
<Button startIcon={<BlockIcon />} onClick={() => handleAction()}>
<TopbarButton startIcon={<BlockIcon />} onClick={() => handleAction()}>
Cancel
</Button>
</TopbarButton>
);
};
@ -175,21 +167,9 @@ interface DisabledButtonProps {
export const DisabledButton: FC<DisabledButtonProps> = ({ label }) => {
return (
<Button startIcon={<OutlinedBlockIcon />} disabled>
<TopbarButton startIcon={<OutlinedBlockIcon />} disabled>
{label}
</Button>
);
};
interface LoadingProps {
label: string;
}
export const ActionLoadingButton: FC<LoadingProps> = ({ label }) => {
return (
<LoadingButton loading loadingPosition="start" startIcon={<ReplayIcon />}>
{label}
</LoadingButton>
</TopbarButton>
);
};
@ -202,11 +182,11 @@ export const RetryButton: FC<RetryButtonProps> = ({
debug = false,
}) => {
return (
<Button
<TopbarButton
startIcon={debug ? <RetryDebugIcon /> : <RetryIcon />}
onClick={() => handleAction()}
>
Retry{debug && " (Debug)"}
</Button>
</TopbarButton>
);
};

View File

@ -1,12 +1,9 @@
import { type FC, type ReactNode, Fragment } from "react";
import { Workspace, WorkspaceBuildParameter } from "api/typesGenerated";
import { useWorkspaceDuplication } from "pages/CreateWorkspacePage/useWorkspaceDuplication";
import { workspaceUpdatePolicy } from "utils/workspace";
import { type ActionType, abilitiesByWorkspaceStatus } from "./constants";
import {
ActionLoadingButton,
CancelButton,
DisabledButton,
StartButton,
@ -22,14 +19,14 @@ import DuplicateIcon from "@mui/icons-material/FileCopyOutlined";
import SettingsIcon from "@mui/icons-material/SettingsOutlined";
import HistoryIcon from "@mui/icons-material/HistoryOutlined";
import DeleteIcon from "@mui/icons-material/DeleteOutlined";
import {
MoreMenu,
MoreMenuContent,
MoreMenuItem,
MoreMenuTrigger,
ThreeDotsButton,
} from "components/MoreMenu/MoreMenu";
import { TopbarIconButton } from "components/FullPageLayout/Topbar";
import MoreVertOutlined from "@mui/icons-material/MoreVertOutlined";
export interface WorkspaceActionsProps {
workspace: Workspace;
@ -124,10 +121,10 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
tooltipText={tooltipText}
/>
),
deleting: <ActionLoadingButton label="Deleting" />,
deleting: <DisabledButton label="Deleting" />,
canceling: <DisabledButton label="Canceling..." />,
deleted: <DisabledButton label="Deleted" />,
pending: <ActionLoadingButton label="Pending..." />,
pending: <DisabledButton label="Pending..." />,
activate: <ActivateButton handleAction={handleDormantActivate} />,
activating: <ActivateButton loading handleAction={handleDormantActivate} />,
retry: <RetryButton handleAction={handleRetry} />,
@ -136,7 +133,7 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
return (
<div
css={{ display: "flex", alignItems: "center", gap: 12 }}
css={{ display: "flex", alignItems: "center", gap: 8 }}
data-testid="workspace-actions"
>
{canBeUpdated && (
@ -153,13 +150,14 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
<MoreMenu>
<MoreMenuTrigger>
<ThreeDotsButton
<TopbarIconButton
title="More options"
size="small"
data-testid="workspace-options-button"
aria-controls="workspace-options"
disabled={!canAcceptJobs}
/>
>
<MoreVertOutlined />
</TopbarIconButton>
</MoreMenuTrigger>
<MoreMenuContent id="workspace-options">

View File

@ -74,7 +74,7 @@ export const WorkspaceScheduleControls: FC<WorkspaceScheduleControlsProps> = ({
<AutoStopDisplay workspace={workspace} />
) : (
<ScheduleSettingsLink>
{autostartDisplay(workspace.autostart_schedule)}
Starts at {autostartDisplay(workspace.autostart_schedule)}
</ScheduleSettingsLink>
)}
@ -251,7 +251,7 @@ const AutoStopDisplay: FC<AutoStopDisplayProps> = ({ workspace }) => {
: undefined,
})}
>
{display.message}
Stop {display.message}
</ScheduleSettingsLink>
</Tooltip>
);
@ -268,6 +268,7 @@ const ScheduleSettingsLink = forwardRef<HTMLAnchorElement, LinkProps>(
component={RouterLink}
to="settings/schedule"
css={{
color: "inherit",
"&:first-letter": {
textTransform: "uppercase",
},
@ -310,10 +311,6 @@ const isShutdownSoon = (workspace: Workspace): boolean => {
return diff < oneHour;
};
export const scheduleLabel = (workspace: Workspace) => {
return isWorkspaceOn(workspace) ? "Stops" : "Starts at";
};
const classNames = {
paper: css`
padding: 24px;

View File

@ -1,53 +0,0 @@
import { Meta, StoryObj } from "@storybook/react";
import {
MockWorkspace,
MockAppearanceConfig,
MockBuildInfo,
MockEntitlementsWithScheduling,
MockExperiments,
} from "testHelpers/entities";
import { WorkspaceStats } from "./WorkspaceStats";
import { DashboardProviderContext } from "components/Dashboard/DashboardProvider";
const MockedAppearance = {
config: MockAppearanceConfig,
isPreview: false,
setPreview: () => {},
};
const meta: Meta<typeof WorkspaceStats> = {
title: "pages/WorkspacePage/WorkspaceStats",
component: WorkspaceStats,
decorators: [
(Story) => (
<DashboardProviderContext.Provider
value={{
buildInfo: MockBuildInfo,
entitlements: MockEntitlementsWithScheduling,
experiments: MockExperiments,
appearance: MockedAppearance,
}}
>
<Story />
</DashboardProviderContext.Provider>
),
],
};
export default meta;
type Story = StoryObj<typeof WorkspaceStats>;
export const Example: Story = {
args: {
workspace: MockWorkspace,
},
};
export const Outdated: Story = {
args: {
workspace: {
...MockWorkspace,
outdated: true,
},
},
};

View File

@ -1,140 +0,0 @@
import { type Interpolation, type Theme } from "@emotion/react";
import Link from "@mui/material/Link";
import { WorkspaceOutdatedTooltip } from "components/WorkspaceOutdatedTooltip/WorkspaceOutdatedTooltip";
import { type FC } from "react";
import { Link as RouterLink } from "react-router-dom";
import { getDisplayWorkspaceTemplateName } from "utils/workspace";
import type { Workspace } from "api/typesGenerated";
import { Stats, StatsItem } from "components/Stats/Stats";
import { WorkspaceStatusText } from "components/WorkspaceStatusBadge/WorkspaceStatusBadge";
import { DormantDeletionStat } from "components/WorkspaceDeletion";
import { workspaceQuota } from "api/queries/workspaceQuota";
import { useQuery } from "react-query";
import _ from "lodash";
import {
WorkspaceScheduleControls,
scheduleLabel,
shouldDisplayScheduleControls,
} from "./WorkspaceScheduleControls";
const Language = {
workspaceDetails: "Workspace Details",
templateLabel: "Template",
costLabel: "Daily cost",
updatePolicy: "Update policy",
};
export interface WorkspaceStatsProps {
workspace: Workspace;
canUpdateWorkspace: boolean;
handleUpdate: () => void;
}
export const WorkspaceStats: FC<WorkspaceStatsProps> = ({
workspace,
canUpdateWorkspace,
handleUpdate,
}) => {
const displayTemplateName = getDisplayWorkspaceTemplateName(workspace);
const quotaQuery = useQuery(workspaceQuota(workspace.owner_name));
const quotaBudget = quotaQuery.data?.budget;
return (
<>
<Stats aria-label={Language.workspaceDetails} css={styles.stats}>
<StatsItem
css={styles.statsItem}
label="Status"
value={<WorkspaceStatusText workspace={workspace} />}
/>
<DormantDeletionStat workspace={workspace} />
<StatsItem
css={styles.statsItem}
label={Language.templateLabel}
value={
<Link
component={RouterLink}
to={`/templates/${workspace.template_name}`}
>
{displayTemplateName}
</Link>
}
/>
<StatsItem
css={styles.statsItem}
label="Version"
value={
<span css={{ display: "flex", alignItems: "center", gap: 4 }}>
<Link
component={RouterLink}
to={`/templates/${workspace.template_name}/versions/${workspace.latest_build.template_version_name}`}
>
{workspace.latest_build.template_version_name}
</Link>
{workspace.outdated && (
<WorkspaceOutdatedTooltip
templateName={workspace.template_name}
latestVersionId={workspace.template_active_version_id}
onUpdateVersion={handleUpdate}
ariaLabel="update version"
/>
)}
</span>
}
/>
{shouldDisplayScheduleControls(workspace) && (
<StatsItem
css={styles.statsItem}
label={scheduleLabel(workspace)}
value={
<WorkspaceScheduleControls
workspace={workspace}
canUpdateSchedule={canUpdateWorkspace}
/>
}
/>
)}
{workspace.latest_build.daily_cost > 0 && (
<StatsItem
css={styles.statsItem}
label={Language.costLabel}
value={`${workspace.latest_build.daily_cost} ${
quotaBudget ? `/ ${quotaBudget}` : ""
}`}
/>
)}
</Stats>
</>
);
};
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>>;

View File

@ -0,0 +1,82 @@
import { Meta, StoryObj } from "@storybook/react";
import { MockUser, MockWorkspace } from "testHelpers/entities";
import { WorkspaceTopbar } from "./WorkspaceTopbar";
import { withDashboardProvider } from "testHelpers/storybook";
import { addDays } from "date-fns";
import { getWorkspaceQuotaQueryKey } from "api/queries/workspaceQuota";
// We want a workspace without a deadline to not pollute the screenshot
const baseWorkspace = {
...MockWorkspace,
latest_build: {
...MockWorkspace.latest_build,
deadline: undefined,
},
};
const meta: Meta<typeof WorkspaceTopbar> = {
title: "pages/WorkspacePage/WorkspaceTopbar",
component: WorkspaceTopbar,
decorators: [withDashboardProvider],
args: {
workspace: baseWorkspace,
},
parameters: {
layout: "fullscreen",
features: ["advanced_template_scheduling"],
experiments: ["workspace_actions"],
},
};
export default meta;
type Story = StoryObj<typeof WorkspaceTopbar>;
export const Example: Story = {};
export const Outdated: Story = {
args: {
workspace: {
...MockWorkspace,
outdated: true,
},
},
};
export const Dormant: Story = {
args: {
workspace: {
...baseWorkspace,
deleting_at: addDays(new Date(), 7).toISOString(),
latest_build: {
...baseWorkspace.latest_build,
status: "failed",
},
},
},
};
export const WithDeadline: Story = {
args: {
workspace: {
...MockWorkspace,
latest_build: {
...MockWorkspace.latest_build,
deadline: MockWorkspace.latest_build.deadline,
},
},
},
};
export const WithQuota: Story = {
parameters: {
queries: [
{
key: getWorkspaceQuotaQueryKey(MockUser.username),
data: {
credits_consumed: 2,
budget: 40,
},
},
],
},
};

View File

@ -0,0 +1,255 @@
import { Link as RouterLink } from "react-router-dom";
import type * as TypesGen from "api/typesGenerated";
import { WorkspaceActions } from "pages/WorkspacePage/WorkspaceActions/WorkspaceActions";
import {
Topbar,
TopbarAvatar,
TopbarData,
TopbarDivider,
TopbarIcon,
TopbarIconButton,
} from "components/FullPageLayout/Topbar";
import Tooltip from "@mui/material/Tooltip";
import ArrowBackOutlined from "@mui/icons-material/ArrowBackOutlined";
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
import { WorkspaceOutdatedTooltipContent } from "components/WorkspaceOutdatedTooltip/WorkspaceOutdatedTooltip";
import { Popover, PopoverTrigger } from "components/Popover/Popover";
import ScheduleOutlined from "@mui/icons-material/ScheduleOutlined";
import { WorkspaceStatusBadge } from "components/WorkspaceStatusBadge/WorkspaceStatusBadge";
import { Pill } from "components/Pill/Pill";
import {
WorkspaceScheduleControls,
shouldDisplayScheduleControls,
} from "../WorkspaceScheduleControls";
import { workspaceQuota } from "api/queries/workspaceQuota";
import { useQuery } from "react-query";
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
import { useTheme } from "@mui/material/styles";
import InfoOutlined from "@mui/icons-material/InfoOutlined";
import Link from "@mui/material/Link";
import { useDashboard } from "components/Dashboard/DashboardProvider";
import { displayDormantDeletion } from "utils/dormant";
import DeleteOutline from "@mui/icons-material/DeleteOutline";
export type WorkspaceError =
| "getBuildsError"
| "buildError"
| "cancellationError";
export type WorkspaceErrors = Partial<Record<WorkspaceError, unknown>>;
export interface WorkspaceProps {
handleStart: (buildParameters?: TypesGen.WorkspaceBuildParameter[]) => void;
handleStop: () => void;
handleRestart: (buildParameters?: TypesGen.WorkspaceBuildParameter[]) => void;
handleDelete: () => void;
handleUpdate: () => void;
handleCancel: () => void;
handleSettings: () => void;
handleChangeVersion: () => void;
handleDormantActivate: () => void;
isUpdating: boolean;
isRestarting: boolean;
workspace: TypesGen.Workspace;
canUpdateWorkspace: boolean;
canChangeVersions: boolean;
canRetryDebugMode: boolean;
handleBuildRetry: () => void;
handleBuildRetryDebug: () => void;
}
export const WorkspaceTopbar = (props: WorkspaceProps) => {
const {
handleStart,
handleStop,
handleRestart,
handleDelete,
handleUpdate,
handleCancel,
handleSettings,
handleChangeVersion,
handleDormantActivate,
workspace,
isUpdating,
isRestarting,
canUpdateWorkspace,
canChangeVersions,
canRetryDebugMode,
handleBuildRetry,
handleBuildRetryDebug,
} = props;
const theme = useTheme();
// Quota
const hasDailyCost = workspace.latest_build.daily_cost > 0;
const { data: quota } = useQuery({
...workspaceQuota(workspace.owner_name),
enabled: hasDailyCost,
});
// Dormant
const { entitlements, experiments } = useDashboard();
const allowAdvancedScheduling =
entitlements.features["advanced_template_scheduling"].enabled;
// This check can be removed when https://github.com/coder/coder/milestone/19
// is merged up
const allowWorkspaceActions = experiments.includes("workspace_actions");
const shouldDisplayDormantData = displayDormantDeletion(
workspace,
allowAdvancedScheduling,
allowWorkspaceActions,
);
return (
<Topbar>
<Tooltip title="Back to workspaces">
<TopbarIconButton component={RouterLink} to="workspaces">
<ArrowBackOutlined />
</TopbarIconButton>
</Tooltip>
<div
css={{
display: "flex",
alignItems: "center",
columnGap: 24,
rowGap: 8,
flexWrap: "wrap",
// 12px - It is needed to keep vertical spacing when the content is wrapped
padding: "12px 0 12px 16px",
}}
>
<TopbarData>
<TopbarAvatar src={workspace.template_icon} />
<span css={{ fontWeight: 500 }}>{workspace.name}</span>
<TopbarDivider />
<Link
component={RouterLink}
to={`/templates/${workspace.template_name}`}
css={{ color: "inherit" }}
>
{workspace.template_display_name ?? workspace.template_name}
</Link>
{workspace.outdated ? (
<Popover mode="hover">
<PopoverTrigger>
{/* Added to give some bottom space from the popover content */}
<div css={{ padding: "4px 0", margin: "-4px 0" }}>
<Pill
icon={
<InfoOutlined
css={{
width: "12px !important",
height: "12px !important",
color: theme.palette.warning.light,
}}
/>
}
>
<span css={{ color: theme.palette.warning.light }}>
{workspace.latest_build.template_version_name}
</span>
</Pill>
</div>
</PopoverTrigger>
<WorkspaceOutdatedTooltipContent
templateName={workspace.template_name}
latestVersionId={workspace.template_active_version_id}
onUpdateVersion={handleUpdate}
ariaLabel="update version"
/>
</Popover>
) : (
<Pill>{workspace.latest_build.template_version_name}</Pill>
)}
</TopbarData>
<TopbarData>
<Tooltip title="Owner">
<TopbarIcon>
<PersonOutlineOutlined aria-label="Owner" />
</TopbarIcon>
</Tooltip>
<span>{workspace.owner_name}</span>
</TopbarData>
{shouldDisplayDormantData && (
<TopbarData>
<TopbarIcon>
<DeleteOutline />
</TopbarIcon>
<Link
component={RouterLink}
to={`/templates/${workspace.template_name}/settings/schedule`}
title="Schedule settings"
css={{ color: "inherit" }}
>
Deletion on {new Date(workspace.deleting_at!).toLocaleString()}
</Link>
</TopbarData>
)}
{shouldDisplayScheduleControls(workspace) && (
<TopbarData>
<TopbarIcon>
<Tooltip title="Schedule">
<ScheduleOutlined aria-label="Schedule" />
</Tooltip>
</TopbarIcon>
<WorkspaceScheduleControls
workspace={workspace}
canUpdateSchedule={canUpdateWorkspace}
/>
</TopbarData>
)}
{quota && (
<TopbarData>
<TopbarIcon>
<Tooltip title="Daily usage">
<MonetizationOnOutlined aria-label="Daily usage" />
</Tooltip>
</TopbarIcon>
<span>
{workspace.latest_build.daily_cost}{" "}
<span css={{ color: theme.palette.text.secondary }}>
credits of
</span>{" "}
{quota.budget}
</span>
</TopbarData>
)}
</div>
<div
css={{
marginLeft: "auto",
display: "flex",
alignItems: "center",
gap: 12,
}}
>
<WorkspaceStatusBadge workspace={workspace} />
<WorkspaceActions
workspace={workspace}
handleStart={handleStart}
handleStop={handleStop}
handleRestart={handleRestart}
handleDelete={handleDelete}
handleUpdate={handleUpdate}
handleCancel={handleCancel}
handleSettings={handleSettings}
handleRetry={handleBuildRetry}
handleRetryDebug={handleBuildRetryDebug}
handleChangeVersion={handleChangeVersion}
handleDormantActivate={handleDormantActivate}
canRetryDebug={canRetryDebugMode}
canChangeVersions={canChangeVersions}
isUpdating={isUpdating}
isRestarting={isRestarting}
/>
</div>
</Topbar>
);
};

View File

@ -0,0 +1,48 @@
import { DashboardProviderContext } from "components/Dashboard/DashboardProvider";
import {
MockAppearanceConfig,
MockBuildInfo,
MockEntitlements,
} from "./entities";
import { FC } from "react";
import { StoryContext } from "@storybook/react";
import * as _storybook_types from "@storybook/react";
import { Entitlements } from "api/typesGenerated";
import { withDefaultFeatures } from "api/api";
export const withDashboardProvider = (
Story: FC,
{ parameters }: StoryContext,
) => {
const { features = [], experiments = [] } = parameters;
const entitlements: Entitlements = {
...MockEntitlements,
features: withDefaultFeatures(
features.reduce(
(acc, feature) => {
acc[feature] = { enabled: true, entitlement: "entitled" };
return acc;
},
{} as Entitlements["features"],
),
),
};
return (
<DashboardProviderContext.Provider
value={{
buildInfo: MockBuildInfo,
entitlements,
experiments,
appearance: {
config: MockAppearanceConfig,
isPreview: false,
setPreview: () => {},
},
}}
>
<Story />
</DashboardProviderContext.Provider>
);
};

View File

@ -1,6 +1,6 @@
import * as TypesGen from "api/typesGenerated";
import * as Mocks from "testHelpers/entities";
import { displayDormantDeletion } from "./utils";
import { displayDormantDeletion } from "./dormant";
describe("displayDormantDeletion", () => {
const today = new Date();