feat: filter tasks by user (#18130)

Demo: 

https://github.com/user-attachments/assets/4f3a39c5-6b41-4493-a1c0-56eedadb7798
This commit is contained in:
Bruno Quaresma
2025-05-30 15:31:06 -03:00
committed by GitHub
parent 9fbccc032d
commit 19745a2dee
3 changed files with 246 additions and 31 deletions
+54 -16
View File
@@ -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: {
+59 -15
View File
@@ -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,
});
}),
+133
View File
@@ -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>
);
};