mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add job status filter (#17202)
Closes https://github.com/coder/coder/issues/17155 **Demo:** https://github.com/user-attachments/assets/fc57e991-c2d5-4712-adac-e072ee7b318d
This commit is contained in:
+10
-1
@@ -400,6 +400,11 @@ export class MissingBuildParameters extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export type GetProvisionerJobsParams = {
|
||||
status?: TypesGen.ProvisionerJobStatus;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* This is the container for all API methods. It's split off to make it more
|
||||
* clear where API methods should go, but it is eventually merged into the Api
|
||||
@@ -2395,9 +2400,13 @@ class ApiMethods {
|
||||
return res.data;
|
||||
};
|
||||
|
||||
getProvisionerJobs = async (orgId: string) => {
|
||||
getProvisionerJobs = async (
|
||||
orgId: string,
|
||||
params: GetProvisionerJobsParams = {},
|
||||
) => {
|
||||
const res = await this.axios.get<TypesGen.ProvisionerJob[]>(
|
||||
`/api/v2/organizations/${orgId}/provisionerjobs`,
|
||||
{ params },
|
||||
);
|
||||
return res.data;
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { API } from "api/api";
|
||||
import { API, type GetProvisionerJobsParams } from "api/api";
|
||||
import type {
|
||||
CreateOrganizationRequest,
|
||||
GroupSyncSettings,
|
||||
PaginatedMembersRequest,
|
||||
PaginatedMembersResponse,
|
||||
ProvisionerJobStatus,
|
||||
RoleSyncSettings,
|
||||
UpdateOrganizationRequest,
|
||||
} from "api/typesGenerated";
|
||||
@@ -241,16 +242,18 @@ export const patchRoleSyncSettings = (
|
||||
};
|
||||
};
|
||||
|
||||
export const provisionerJobQueryKey = (orgId: string) => [
|
||||
"organization",
|
||||
orgId,
|
||||
"provisionerjobs",
|
||||
];
|
||||
export const provisionerJobsQueryKey = (
|
||||
orgId: string,
|
||||
params: GetProvisionerJobsParams = {},
|
||||
) => ["organization", orgId, "provisionerjobs", params];
|
||||
|
||||
export const provisionerJobs = (orgId: string) => {
|
||||
export const provisionerJobs = (
|
||||
orgId: string,
|
||||
params: GetProvisionerJobsParams = {},
|
||||
) => {
|
||||
return {
|
||||
queryKey: provisionerJobQueryKey(orgId),
|
||||
queryFn: () => API.getProvisionerJobs(orgId),
|
||||
queryKey: provisionerJobsQueryKey(orgId, params),
|
||||
queryFn: () => API.getProvisionerJobs(orgId, params),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import { API } from "api/api";
|
||||
import {
|
||||
getProvisionerDaemonsKey,
|
||||
provisionerJobQueryKey,
|
||||
provisionerJobsQueryKey,
|
||||
} from "api/queries/organizations";
|
||||
import type { ProvisionerJob } from "api/typesGenerated";
|
||||
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
|
||||
@@ -28,7 +28,7 @@ export const CancelJobConfirmationDialog: FC<
|
||||
mutationFn: cancelProvisionerJob,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(
|
||||
provisionerJobQueryKey(job.organization_id),
|
||||
provisionerJobsQueryKey(job.organization_id),
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
getProvisionerDaemonsKey(job.organization_id, job.tags),
|
||||
|
||||
+14
-10
@@ -52,16 +52,20 @@ export const JobRow: FC<JobRowProps> = ({ job }) => {
|
||||
<Badge size="sm">{job.type}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1 whitespace-nowrap">
|
||||
<Avatar
|
||||
variant="icon"
|
||||
src={metadata.template_icon}
|
||||
fallback={
|
||||
metadata.template_display_name || metadata.template_name
|
||||
}
|
||||
/>
|
||||
{metadata.template_display_name || metadata.template_name}
|
||||
</div>
|
||||
{job.metadata.template_name !== "" ? (
|
||||
<div className="flex items-center gap-1 whitespace-nowrap">
|
||||
<Avatar
|
||||
variant="icon"
|
||||
src={metadata.template_icon}
|
||||
fallback={
|
||||
metadata.template_display_name || metadata.template_name
|
||||
}
|
||||
/>
|
||||
{metadata.template_display_name || metadata.template_name}
|
||||
</div>
|
||||
) : (
|
||||
<span>-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TruncateTags tags={job.tags} />
|
||||
|
||||
+14
-1
@@ -1,26 +1,39 @@
|
||||
import type { GetProvisionerJobsParams } from "api/api";
|
||||
import { provisionerJobs } from "api/queries/organizations";
|
||||
import type { ProvisionerJobStatus } from "api/typesGenerated";
|
||||
import { useOrganizationSettings } from "modules/management/OrganizationSettingsLayout";
|
||||
import type { FC } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import OrganizationProvisionerJobsPageView from "./OrganizationProvisionerJobsPageView";
|
||||
|
||||
const OrganizationProvisionerJobsPage: FC = () => {
|
||||
const { organization } = useOrganizationSettings();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const filter = {
|
||||
status: searchParams.get("status") || "",
|
||||
};
|
||||
const queryParams = {
|
||||
...filter,
|
||||
limit: 100,
|
||||
} as GetProvisionerJobsParams;
|
||||
const {
|
||||
data: jobs,
|
||||
isLoadingError,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
...provisionerJobs(organization?.id || ""),
|
||||
...provisionerJobs(organization?.id || "", queryParams),
|
||||
enabled: organization !== undefined,
|
||||
});
|
||||
|
||||
return (
|
||||
<OrganizationProvisionerJobsPageView
|
||||
jobs={jobs}
|
||||
filter={filter}
|
||||
organization={organization}
|
||||
error={isLoadingError}
|
||||
onRetry={refetch}
|
||||
onFilterChange={setSearchParams}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+34
@@ -1,6 +1,7 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { expect, fn, userEvent, waitFor, within } from "@storybook/test";
|
||||
import type { ProvisionerJob } from "api/typesGenerated";
|
||||
import { useState } from "react";
|
||||
import { MockOrganization, MockProvisionerJob } from "testHelpers/entities";
|
||||
import { daysAgo } from "utils/time";
|
||||
import OrganizationProvisionerJobsPageView from "./OrganizationProvisionerJobsPageView";
|
||||
@@ -20,6 +21,7 @@ const meta: Meta<typeof OrganizationProvisionerJobsPageView> = {
|
||||
args: {
|
||||
organization: MockOrganization,
|
||||
jobs: MockProvisionerJobs,
|
||||
filter: { status: "" },
|
||||
onRetry: fn(),
|
||||
},
|
||||
};
|
||||
@@ -75,3 +77,35 @@ export const Empty: Story = {
|
||||
jobs: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const OnFilter: Story = {
|
||||
render: function FilterWithState({ ...args }) {
|
||||
const [jobs, setJobs] = useState<ProvisionerJob[]>([]);
|
||||
const [filter, setFilter] = useState({ status: "pending" });
|
||||
const handleFilterChange = (newFilter: { status: string }) => {
|
||||
setFilter(newFilter);
|
||||
const filteredJobs = MockProvisionerJobs.filter((job) =>
|
||||
newFilter.status ? job.status === newFilter.status : true,
|
||||
);
|
||||
setJobs(filteredJobs);
|
||||
};
|
||||
|
||||
return (
|
||||
<OrganizationProvisionerJobsPageView
|
||||
{...args}
|
||||
filter={filter}
|
||||
jobs={jobs}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const statusFilter = canvas.getByTestId("status-filter");
|
||||
await userEvent.click(statusFilter);
|
||||
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
const option = await body.findByRole("option", { name: "succeeded" });
|
||||
await userEvent.click(option);
|
||||
},
|
||||
};
|
||||
|
||||
+75
-2
@@ -1,8 +1,25 @@
|
||||
import type { Organization, ProvisionerJob } from "api/typesGenerated";
|
||||
import type {
|
||||
Organization,
|
||||
ProvisionerJob,
|
||||
ProvisionerJobStatus,
|
||||
} from "api/typesGenerated";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { EmptyState } from "components/EmptyState/EmptyState";
|
||||
import { Link } from "components/Link/Link";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "components/Select/Select";
|
||||
import {
|
||||
StatusIndicator,
|
||||
StatusIndicatorDot,
|
||||
type StatusIndicatorProps,
|
||||
} from "components/StatusIndicator/StatusIndicator";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -17,16 +34,45 @@ import { docs } from "utils/docs";
|
||||
import { pageTitle } from "utils/page";
|
||||
import { JobRow } from "./JobRow";
|
||||
|
||||
const variantByStatus: Record<
|
||||
ProvisionerJobStatus,
|
||||
StatusIndicatorProps["variant"]
|
||||
> = {
|
||||
succeeded: "success",
|
||||
failed: "failed",
|
||||
pending: "pending",
|
||||
running: "pending",
|
||||
canceling: "pending",
|
||||
canceled: "inactive",
|
||||
unknown: "inactive",
|
||||
};
|
||||
|
||||
const StatusFilters: ProvisionerJobStatus[] = [
|
||||
"succeeded",
|
||||
"pending",
|
||||
"running",
|
||||
"canceling",
|
||||
"canceled",
|
||||
"failed",
|
||||
"unknown",
|
||||
];
|
||||
|
||||
type JobProvisionersFilter = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
type OrganizationProvisionerJobsPageViewProps = {
|
||||
jobs: ProvisionerJob[] | undefined;
|
||||
organization: Organization | undefined;
|
||||
error: unknown;
|
||||
filter: JobProvisionersFilter;
|
||||
onRetry: () => void;
|
||||
onFilterChange: (filter: JobProvisionersFilter) => void;
|
||||
};
|
||||
|
||||
const OrganizationProvisionerJobsPageView: FC<
|
||||
OrganizationProvisionerJobsPageViewProps
|
||||
> = ({ jobs, organization, error, onRetry }) => {
|
||||
> = ({ jobs, organization, error, filter, onFilterChange, onRetry }) => {
|
||||
if (!organization) {
|
||||
return (
|
||||
<>
|
||||
@@ -61,6 +107,33 @@ const OrganizationProvisionerJobsPageView: FC<
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div>
|
||||
<Select
|
||||
value={filter.status}
|
||||
onValueChange={(status) => {
|
||||
onFilterChange({ status: status as ProvisionerJobStatus });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]" data-testid="status-filter">
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{StatusFilters.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
<StatusIndicator variant={variantByStatus[status]}>
|
||||
<StatusIndicatorDot />
|
||||
<span className="block first-letter:uppercase">
|
||||
{status}
|
||||
</span>
|
||||
</StatusIndicator>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
|
||||
Reference in New Issue
Block a user