Files
coder/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx
T
Jake Howell a4c100e5fe fix(site): use singular pagination labels for one result (#30519)
> 🤖 This PR was written by Coder Agents on behalf of Jake Howell.

Fix pagination summaries such as “Showing 1 to 1 of 1 groups” and
“Showing 1 to 1 of 1 workspaces”. All nine callers now supply explicit
singular/plural labels, selected from the total result count rather than
the number of rows on the current page.

Preserves empty and filtered copy, loading states, capped counts, and
number formatting. Extends existing Storybook coverage for single
results and one-row final pages.

<details>
<summary>Implementation decisions and verification</summary>

- Follow the existing explicit-label and `=== 1` convention without
stripping suffixes or adding a dependency.
- Require both labels through the shared API; keep capped totals plural
because they are not exact counts.
- Passed 136 targeted Vitest tests, 109 Storybook tests, `pnpm format`,
`pnpm check`, full `pnpm lint`, and FE1–FE10 review.
- Browser-verified rendered wording. Screenshots were returned in chat;
direct image inspection was unavailable.

</details>
2026-10-14 14:26:12 +11:00

242 lines
7.0 KiB
TypeScript

import { PlayIcon, RotateCcwIcon, SquareIcon, TrashIcon } from "lucide-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 type { UseFilterResult } from "#/components/Filter/Filter";
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 { WorkspacesFilter } from "./filter/WorkspacesFilter";
import { WorkspaceHelpPopover } from "./WorkspaceHelpPopover";
import { WorkspacesButton } from "./WorkspacesButton";
type TemplateQuery = UseQueryResult<Template[]>;
type WorkspacesPageViewProps = {
error: unknown;
workspaces?: readonly Workspace[];
checkedWorkspaces: readonly Workspace[];
count?: number;
filter: UseFilterResult;
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: React.FC<WorkspacesPageViewProps> = ({
workspaces,
error,
limit,
count,
filter,
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="mb-4 flex flex-col gap-4">
{hasError(error) && !isApiValidationError(error) && (
<ErrorAlert error={error} />
)}
<WorkspacesFilter filter={filter} error={error} />
</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={{
singular: "workspace",
plural: "workspaces",
}}
limit={limit}
totalRecords={count}
currentOffsetStart={(page - 1) * limit + 1}
isFiltered={filter.query.trim() !== ""}
/>
)
)}
</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={filter.used}
onClearFilter={() => filter.update("")}
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>
);
};