Skip to content

feat: filter tasks by user #18130

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
May 30, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 54 additions & 16 deletions site/src/pages/TasksPage/TasksPage.stories.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
});
},
};

Expand Down Expand Up @@ -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);
});
},
Expand Down Expand Up @@ -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"),
});
});
},
Expand Down Expand Up @@ -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: {
Expand Down
70 changes: 57 additions & 13 deletions site/src/pages/TasksPage/TasksPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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"],
});
},
});
Expand Down Expand Up @@ -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">
Expand Down Expand Up @@ -229,18 +243,43 @@ const TaskForm: FC<TaskFormProps> = ({ templates }) => {
);
};

type TasksFilterProps = {
filter: TasksFilter;
onFilterChange: (filter: TasksFilter) => void;
};

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 }) => {
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,
});

Expand Down Expand Up @@ -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,
});
}),
Expand Down
133 changes: 133 additions & 0 deletions site/src/pages/TasksPage/UsersCombobox.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
};
Loading