mirror of
https://github.com/coder/coder.git
synced 2026-10-31 19:36:41 +08:00
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.
247 lines
7.0 KiB
TypeScript
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…
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
className="text-content-destructive focus:text-content-destructive"
|
|
onClick={onBatchDeleteTransition}
|
|
>
|
|
<TrashIcon /> Delete…
|
|
</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>
|
|
);
|
|
};
|