Files
coder/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx
T
Tyler 376217423d fix(site/src/pages): use RotateCcwIcon for update actions (#28183)
Closes
[DES-22067](https://linear.app/codercom/issue/DES-22067/update-and-restart-action-uses-a-cloud-icon).

The "Update and restart" / "Update and start" actions used `CloudIcon`,
which reads as a cloud/network concept instead of an update-and-restart
build action.

Switch them to `RotateCcwIcon`, which the UI already uses for:

- the `Restart` button on the workspace page
- the `Update` action in `WorkspaceOutdatedTooltip`,
`AgentOutdatedTooltip`, and `SubAgentOutdatedTooltip`

Call sites changed:

- `WorkspacePage/WorkspaceActions/Buttons.tsx` - `UpdateButton`
- `WorkspacesPage/WorkspacesTable.tsx` - "Update and start workspace"
and "Update and restart workspace" primary actions
- `WorkspacesPage/WorkspacesPageView.tsx` - batch "Update" menu item

The `requireActiveVersion` variant still uses `PlayIcon`, since that
path starts the workspace on the active version rather than offering a
choice. Worth a separate design pass if that should change too.

<details>
<summary>Investigation notes</summary>

Verified against `origin/main` (`af90d8e2be`): no prior PR referenced
DES-22067 and all three `CloudIcon` usages were still present. `grep`
confirms no `CloudIcon` usages remain in `site/src` after this change.

Icon conventions found in the codebase:

- `RotateCcwIcon`: restart, update (outdated tooltips), retry
(RetryButton, TasksTable, TaskPage, HealthLayout)
- `RefreshCwIcon` / `RefreshCcwIcon`: softer "reload data" flows
(terminal, secrets, prebuilds invalidate, inbox, git panel)

`data-testid="bulk-action-update"` is preserved on the batch update
item, so e2e coverage is unaffected.

</details>

---

Generated by Coder Agents on behalf of @designertyler.
2026-08-20 10:17:03 -05:00

247 lines
7.0 KiB
TypeScript

import { PlayIcon, RotateCcwIcon, SquareIcon, TrashIcon } from "lucide-react";
import type { FC } from "react";
import type { UseQueryResult } from "react-query";
import { hasError, isApiValidationError } from "#/api/errors";
import type { Template, Workspace } from "#/api/typesGenerated";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { ChevronDownIcon } from "#/components/AnimatedIcons/ChevronDown";
import { Button } from "#/components/Button/Button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "#/components/DropdownMenu/DropdownMenu";
import { EmptyState } from "#/components/EmptyState/EmptyState";
import { Margins } from "#/components/Margins/Margins";
import {
PageHeader,
PageHeaderTitle,
} from "#/components/PageHeader/PageHeader";
import { PaginationAmount } from "#/components/PaginationWidget/PaginationAmount";
import { PaginationWidgetBase } from "#/components/PaginationWidget/PaginationWidgetBase";
import { Spinner } from "#/components/Spinner/Spinner";
import { TableToolbar } from "#/components/TableToolbar/TableToolbar";
import { WorkspacesTable } from "#/pages/WorkspacesPage/WorkspacesTable";
import { mustUpdateWorkspace } from "#/utils/workspace";
import {
type WorkspaceFilterState,
WorkspacesFilter,
} from "./filter/WorkspacesFilter";
import { WorkspaceHelpPopover } from "./WorkspaceHelpPopover";
import { WorkspacesButton } from "./WorkspacesButton";
type TemplateQuery = UseQueryResult<Template[]>;
interface WorkspacesPageViewProps {
error: unknown;
workspaces?: readonly Workspace[];
checkedWorkspaces: readonly Workspace[];
count?: number;
filterState: WorkspaceFilterState;
page: number;
limit: number;
onPageChange: (page: number) => void;
onCheckChange: (checkedWorkspaces: readonly Workspace[]) => void;
isRunningBatchAction: boolean;
onBatchDeleteTransition: () => void;
onBatchUpdateTransition: () => void;
onBatchStartTransition: () => void;
onBatchStopTransition: () => void;
templatesFetchStatus: TemplateQuery["status"];
templates: TemplateQuery["data"];
canCreateTemplate: boolean;
canCreateWorkspace: boolean;
canChangeVersions: boolean;
onActionSuccess: () => Promise<void>;
onActionError: (error: unknown) => void;
chatsByWorkspace?: Record<string, string>;
}
export const WorkspacesPageView: FC<WorkspacesPageViewProps> = ({
workspaces,
error,
limit,
count,
filterState,
onPageChange,
page,
checkedWorkspaces,
onCheckChange,
onBatchDeleteTransition,
onBatchUpdateTransition,
onBatchStopTransition,
onBatchStartTransition,
isRunningBatchAction,
templates,
templatesFetchStatus,
canCreateTemplate,
canCreateWorkspace,
canChangeVersions,
onActionSuccess,
onActionError,
chatsByWorkspace,
}) => {
// Let's say the user has 5 workspaces, but tried to hit page 100, which
// does not exist. In this case, the page is not valid and we want to show a
// better error message.
const pageNumberIsInvalid = page !== 1 && workspaces?.length === 0;
return (
<Margins className="pb-12">
<PageHeader
actions={
canCreateWorkspace && (
<WorkspacesButton
templates={templates}
templatesFetchStatus={templatesFetchStatus}
>
New workspace
</WorkspacesButton>
)
}
>
<PageHeaderTitle>
<div className="flex flex-row gap-2 items-center">
<span>Workspaces</span>
<WorkspaceHelpPopover />
</div>
</PageHeaderTitle>
</PageHeader>
<div className="flex flex-col gap-4">
{hasError(error) && !isApiValidationError(error) && (
<ErrorAlert error={error} />
)}
<WorkspacesFilter
filter={filterState.filter}
error={error}
statusMenu={filterState.menus.status}
templateMenu={filterState.menus.template}
userMenu={filterState.menus.user}
organizationsMenu={filterState.menus.organizations}
/>
</div>
<TableToolbar>
{checkedWorkspaces.length > 0 ? (
<>
<div>
Selected <strong>{checkedWorkspaces.length}</strong> of{" "}
<strong>{workspaces?.length}</strong>{" "}
{workspaces?.length === 1 ? "workspace" : "workspaces"}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
disabled={isRunningBatchAction}
variant="outline"
size="sm"
className="ml-auto"
>
Bulk actions
<Spinner loading={isRunningBatchAction}>
<ChevronDownIcon />
</Spinner>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
disabled={
!checkedWorkspaces?.some(
(w) =>
w.latest_build.status === "stopped" &&
!mustUpdateWorkspace(w, canChangeVersions),
)
}
onClick={onBatchStartTransition}
>
<PlayIcon /> Start
</DropdownMenuItem>
<DropdownMenuItem
disabled={
!checkedWorkspaces?.some(
(w) => w.latest_build.status === "running",
)
}
onClick={onBatchStopTransition}
>
<SquareIcon /> Stop
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onBatchUpdateTransition}>
<RotateCcwIcon
className="size-icon-sm"
data-testid="bulk-action-update"
/>{" "}
Update&hellip;
</DropdownMenuItem>
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onClick={onBatchDeleteTransition}
>
<TrashIcon /> Delete&hellip;
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
) : (
!pageNumberIsInvalid && (
<PaginationAmount
paginationUnitLabel="workspaces"
limit={limit}
totalRecords={count}
currentOffsetStart={(page - 1) * limit + 1}
/>
)
)}
</TableToolbar>
{pageNumberIsInvalid ? (
<EmptyState
className="border border-solid border-border rounded-lg"
message="Page not found"
description="The page you are trying to access does not exist."
cta={
<Button
onClick={() => {
onPageChange(1);
}}
>
Back to the first page
</Button>
}
/>
) : (
<WorkspacesTable
canCreateTemplate={canCreateTemplate}
canCreateWorkspace={canCreateWorkspace}
workspaces={workspaces}
isUsingFilter={filterState.filter.used}
checkedWorkspaces={checkedWorkspaces}
onCheckChange={onCheckChange}
templates={templates}
onActionSuccess={onActionSuccess}
onActionError={onActionError}
chatsByWorkspace={chatsByWorkspace}
/>
)}
{count !== undefined && (
// Temporary styling stopgap before component is migrated to using
// PaginationContainer (which renders PaginationWidgetBase using CSS
// flexbox gaps)
<div className="pt-4">
<PaginationWidgetBase
totalRecords={count}
pageSize={limit}
onPageChange={onPageChange}
currentPage={page}
/>
</div>
)}
</Margins>
);
};