mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
feat: filter tasks by user (#18130)
Demo: https://github.com/user-attachments/assets/4f3a39c5-6b41-4493-a1c0-56eedadb7798
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { expect, spyOn, userEvent, within } from "@storybook/test";
|
||||
import { API } from "api/api";
|
||||
import { MockUsers } from "pages/UsersPage/storybookData/users";
|
||||
import {
|
||||
MockTemplate,
|
||||
MockUserOwner,
|
||||
@@ -20,6 +22,15 @@ const meta: Meta<typeof TasksPage> = {
|
||||
decorators: [withAuthProvider],
|
||||
parameters: {
|
||||
user: MockUserOwner,
|
||||
permissions: {
|
||||
viewDeploymentConfig: true,
|
||||
},
|
||||
},
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getUsers").mockResolvedValue({
|
||||
users: MockUsers,
|
||||
count: MockUsers.length,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -62,7 +73,8 @@ export const LoadingTasks: Story = {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await step("Select the first AI template", async () => {
|
||||
const combobox = await canvas.findByRole("combobox");
|
||||
const form = await canvas.findByRole("form");
|
||||
const combobox = await within(form).findByRole("combobox");
|
||||
expect(combobox).toHaveTextContent(MockTemplate.display_name);
|
||||
});
|
||||
},
|
||||
@@ -94,37 +106,40 @@ export const LoadedTasks: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
const newTaskData = {
|
||||
prompt: "Create a new task",
|
||||
workspace: {
|
||||
...MockWorkspace,
|
||||
id: "workspace-4",
|
||||
latest_app_status: {
|
||||
...MockWorkspaceAppStatus,
|
||||
message: "Task created successfully!",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const CreateTaskSuccessfully: Story = {
|
||||
decorators: [withProxyProvider()],
|
||||
beforeEach: () => {
|
||||
spyOn(data, "fetchAITemplates").mockResolvedValue([MockTemplate]);
|
||||
spyOn(data, "fetchTasks").mockResolvedValue(MockTasks);
|
||||
spyOn(data, "createTask").mockImplementation((prompt: string) => {
|
||||
return Promise.resolve({
|
||||
prompt,
|
||||
workspace: {
|
||||
...MockWorkspace,
|
||||
latest_app_status: {
|
||||
...MockWorkspaceAppStatus,
|
||||
message: "Task created successfully!",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
spyOn(data, "fetchTasks")
|
||||
.mockResolvedValueOnce(MockTasks)
|
||||
.mockResolvedValue([newTaskData, ...MockTasks]);
|
||||
spyOn(data, "createTask").mockResolvedValue(newTaskData);
|
||||
},
|
||||
play: async ({ canvasElement, step }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await step("Run task", async () => {
|
||||
const prompt = await canvas.findByLabelText(/prompt/i);
|
||||
await userEvent.type(prompt, "Create a new task");
|
||||
await userEvent.type(prompt, newTaskData.prompt);
|
||||
const submitButton = canvas.getByRole("button", { name: /run task/i });
|
||||
await userEvent.click(submitButton);
|
||||
});
|
||||
|
||||
await step("Verify task in the table", async () => {
|
||||
await canvas.findByRole("row", {
|
||||
name: /create a new task/i,
|
||||
name: new RegExp(newTaskData.prompt, "i"),
|
||||
});
|
||||
});
|
||||
},
|
||||
@@ -158,6 +173,29 @@ export const CreateTaskError: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const NonAdmin: Story = {
|
||||
decorators: [withProxyProvider()],
|
||||
parameters: {
|
||||
permissions: {
|
||||
viewDeploymentConfig: false,
|
||||
},
|
||||
},
|
||||
beforeEach: () => {
|
||||
spyOn(data, "fetchAITemplates").mockResolvedValue([MockTemplate]);
|
||||
spyOn(data, "fetchTasks").mockResolvedValue(MockTasks);
|
||||
},
|
||||
play: async ({ canvasElement, step }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await step("Can't see filters", async () => {
|
||||
await canvas.findByRole("table");
|
||||
expect(
|
||||
canvas.queryByRole("region", { name: /filters/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const MockTasks = [
|
||||
{
|
||||
workspace: {
|
||||
|
||||
@@ -32,13 +32,18 @@ import { useAuthenticated } from "hooks";
|
||||
import { ExternalLinkIcon, RotateCcwIcon, SendIcon } from "lucide-react";
|
||||
import { AI_PROMPT_PARAMETER_NAME, type Task } from "modules/tasks/tasks";
|
||||
import { WorkspaceAppStatus } from "modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus";
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { type FC, type ReactNode, useState } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import TextareaAutosize from "react-textarea-autosize";
|
||||
import { pageTitle } from "utils/page";
|
||||
import { relativeTime } from "utils/time";
|
||||
import { type UserOption, UsersCombobox } from "./UsersCombobox";
|
||||
|
||||
type TasksFilter = {
|
||||
user: UserOption | undefined;
|
||||
};
|
||||
|
||||
const TasksPage: FC = () => {
|
||||
const {
|
||||
@@ -50,6 +55,14 @@ const TasksPage: FC = () => {
|
||||
queryFn: data.fetchAITemplates,
|
||||
...disabledRefetchOptions,
|
||||
});
|
||||
const { user, permissions } = useAuthenticated();
|
||||
const [filter, setFilter] = useState<TasksFilter>({
|
||||
user: {
|
||||
value: user.username,
|
||||
label: user.name || user.username,
|
||||
avatarUrl: user.avatar_url,
|
||||
},
|
||||
});
|
||||
|
||||
let content: ReactNode = null;
|
||||
|
||||
@@ -91,7 +104,10 @@ const TasksPage: FC = () => {
|
||||
) : (
|
||||
<>
|
||||
<TaskForm templates={templates} />
|
||||
<TasksTable templates={templates} />
|
||||
{permissions.viewDeploymentConfig && (
|
||||
<TasksFilter filter={filter} onFilterChange={setFilter} />
|
||||
)}
|
||||
<TasksTable templates={templates} filter={filter} />
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
@@ -147,12 +163,9 @@ const TaskForm: FC<TaskFormProps> = ({ templates }) => {
|
||||
const createTaskMutation = useMutation({
|
||||
mutationFn: async ({ prompt, templateId }: CreateTaskMutationFnProps) =>
|
||||
data.createTask(prompt, user.id, templateId),
|
||||
onSuccess: (newTask) => {
|
||||
// The current data loading is heavy, so we manually update the cache to
|
||||
// avoid re-fetching. Once we improve data loading, we can replace the
|
||||
// manual update with queryClient.invalidateQueries.
|
||||
queryClient.setQueryData<Task[]>(["tasks"], (oldTasks = []) => {
|
||||
return [newTask, ...oldTasks];
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["tasks"],
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -186,6 +199,7 @@ const TaskForm: FC<TaskFormProps> = ({ templates }) => {
|
||||
<form
|
||||
className="border border-border border-solid rounded-lg p-4"
|
||||
onSubmit={onSubmit}
|
||||
aria-label="Create AI task"
|
||||
>
|
||||
<fieldset disabled={createTaskMutation.isPending}>
|
||||
<label htmlFor="prompt" className="sr-only">
|
||||
@@ -229,18 +243,43 @@ const TaskForm: FC<TaskFormProps> = ({ templates }) => {
|
||||
);
|
||||
};
|
||||
|
||||
type TasksTableProps = {
|
||||
templates: Template[];
|
||||
type TasksFilterProps = {
|
||||
filter: TasksFilter;
|
||||
onFilterChange: (filter: TasksFilter) => void;
|
||||
};
|
||||
|
||||
const TasksTable: FC<TasksTableProps> = ({ templates }) => {
|
||||
const TasksFilter: FC<TasksFilterProps> = ({ filter, onFilterChange }) => {
|
||||
return (
|
||||
<section className="mt-6" aria-labelledby="filters-title">
|
||||
<h3 id="filters-title" className="sr-only">
|
||||
Filters
|
||||
</h3>
|
||||
<UsersCombobox
|
||||
selectedOption={filter.user}
|
||||
onSelect={(userOption) =>
|
||||
onFilterChange({
|
||||
...filter,
|
||||
user: userOption,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
type TasksTableProps = {
|
||||
templates: Template[];
|
||||
filter: TasksFilter;
|
||||
};
|
||||
|
||||
const TasksTable: FC<TasksTableProps> = ({ templates, filter }) => {
|
||||
const {
|
||||
data: tasks,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["tasks"],
|
||||
queryFn: () => data.fetchTasks(templates),
|
||||
queryKey: ["tasks", filter],
|
||||
queryFn: () => data.fetchTasks(templates, filter),
|
||||
refetchInterval: 10_000,
|
||||
});
|
||||
|
||||
@@ -397,11 +436,16 @@ export const data = {
|
||||
// template individually and its build parameters resulting in excessive API
|
||||
// calls and slow performance. Consider implementing a backend endpoint that
|
||||
// returns all AI-related workspaces in a single request to improve efficiency.
|
||||
async fetchTasks(aiTemplates: Template[]) {
|
||||
async fetchTasks(aiTemplates: Template[], filter: TasksFilter) {
|
||||
const workspaces = await Promise.all(
|
||||
aiTemplates.map((template) => {
|
||||
const queryParts = [`template:${template.name}`];
|
||||
if (filter.user) {
|
||||
queryParts.push(`owner:${filter.user.value}`);
|
||||
}
|
||||
|
||||
return API.getWorkspaces({
|
||||
q: `template:${template.name}`,
|
||||
q: queryParts.join(" "),
|
||||
limit: 100,
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import Skeleton from "@mui/material/Skeleton";
|
||||
import { users } from "api/queries/users";
|
||||
import { Avatar } from "components/Avatar/Avatar";
|
||||
import { Button } from "components/Button/Button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "components/Command/Command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "components/Popover/Popover";
|
||||
import { useDebouncedValue } from "hooks/debounce";
|
||||
import { CheckIcon, ChevronsUpDownIcon } from "lucide-react";
|
||||
import { type FC, useState } from "react";
|
||||
import { keepPreviousData, useQuery } from "react-query";
|
||||
import { cn } from "utils/cn";
|
||||
|
||||
export type UserOption = {
|
||||
label: string;
|
||||
value: string; // Username
|
||||
avatarUrl?: string;
|
||||
};
|
||||
|
||||
type UsersComboboxProps = {
|
||||
selectedOption: UserOption | undefined;
|
||||
onSelect: (option: UserOption | undefined) => void;
|
||||
};
|
||||
|
||||
export const UsersCombobox: FC<UsersComboboxProps> = ({
|
||||
selectedOption,
|
||||
onSelect,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSearch = useDebouncedValue(search, 250);
|
||||
const usersQuery = useQuery({
|
||||
...users({ q: debouncedSearch }),
|
||||
select: (data) =>
|
||||
data.users.toSorted((a, b) => {
|
||||
return selectedOption && a.username === selectedOption.value ? -1 : 0;
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const options = usersQuery.data?.map((user) => ({
|
||||
label: user.name || user.username,
|
||||
value: user.username,
|
||||
avatarUrl: user.avatar_url,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
disabled={!options}
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-[280px] justify-between"
|
||||
>
|
||||
{options ? (
|
||||
selectedOption ? (
|
||||
<UserItem option={selectedOption} className="-ml-1" />
|
||||
) : (
|
||||
"Select user..."
|
||||
)
|
||||
) : (
|
||||
<Skeleton variant="text" className="w-[120px] h-3" />
|
||||
)}
|
||||
<ChevronsUpDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[280px] p-0">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search user..."
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>No users found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options?.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
onSelect={() => {
|
||||
onSelect(
|
||||
option.value === selectedOption?.value
|
||||
? undefined
|
||||
: option,
|
||||
);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<UserItem option={option} />
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-2 h-4 w-4",
|
||||
option.value === selectedOption?.value
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
type UserItemProps = {
|
||||
option: UserOption;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const UserItem: FC<UserItemProps> = ({ option, className }) => {
|
||||
return (
|
||||
<div className={cn("flex flex-1 items-center gap-2", className)}>
|
||||
<Avatar src={option.avatarUrl} fallback={option.label} />
|
||||
{option.label}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user