From ae552da96de1fa6a272ffcfd07a06db94083cd67 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Fri, 30 May 2025 13:56:26 +0000 Subject: [PATCH 1/4] feat: filter tasks by user --- site/src/pages/TasksPage/TasksPage.tsx | 62 ++++++++-- site/src/pages/TasksPage/UsersCombobox.tsx | 133 +++++++++++++++++++++ 2 files changed, 187 insertions(+), 8 deletions(-) create mode 100644 site/src/pages/TasksPage/UsersCombobox.tsx diff --git a/site/src/pages/TasksPage/TasksPage.tsx b/site/src/pages/TasksPage/TasksPage.tsx index c4a0ae897bd37..47d60c94590db 100644 --- a/site/src/pages/TasksPage/TasksPage.tsx +++ b/site/src/pages/TasksPage/TasksPage.tsx @@ -45,13 +45,23 @@ import { ExternalLinkIcon, RotateCcwIcon, SendIcon } from "lucide-react"; import { useAppLink } from "modules/apps/useAppLink"; import { AI_PROMPT_PARAMETER_NAME, type Task } from "modules/tasks/tasks"; import { WorkspaceAppStatus } from "modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus"; -import type { FC, PropsWithChildren, ReactNode } from "react"; +import { + type FC, + type PropsWithChildren, + 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 { cn } from "utils/cn"; 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 { @@ -63,6 +73,14 @@ const TasksPage: FC = () => { queryFn: data.fetchAITemplates, ...disabledRefetchOptions, }); + const { user } = useAuthenticated(); + const [filter, setFilter] = useState({ + user: { + value: user.username, + label: user.name ?? user.username, + avatarUrl: user.avatar_url, + }, + }); let content: ReactNode = null; @@ -104,7 +122,8 @@ const TasksPage: FC = () => { ) : ( <> - + + ); } else { @@ -242,18 +261,40 @@ const TaskForm: FC = ({ templates }) => { ); }; +type TasksFilterProps = { + filter: TasksFilter; + onFilterChange: (filter: TasksFilter) => void; +}; + +const TasksFilter: FC = ({ filter, onFilterChange }) => { + return ( +
+ + onFilterChange({ + ...filter, + user: userOption, + }) + } + /> +
+ ); +}; + type TasksTableProps = { templates: Template[]; + filter: TasksFilter; }; -const TasksTable: FC = ({ templates }) => { +const TasksTable: FC = ({ 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, }); @@ -380,7 +421,7 @@ const TasksTable: FC = ({ templates }) => { } return ( - +
Task @@ -484,11 +525,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, }); }), diff --git a/site/src/pages/TasksPage/UsersCombobox.tsx b/site/src/pages/TasksPage/UsersCombobox.tsx new file mode 100644 index 0000000000000..ecd1f9bda4270 --- /dev/null +++ b/site/src/pages/TasksPage/UsersCombobox.tsx @@ -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 = ({ + 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 ( + + + + + + + + + No users found. + + {options?.map((option) => ( + { + onSelect( + option.value === selectedOption?.value + ? undefined + : option, + ); + setOpen(false); + }} + > + + + + ))} + + + + + + ); +}; + +type UserItemProps = { + option: UserOption; + className?: string; +}; + +const UserItem: FC = ({ option, className }) => { + return ( +
+ + {option.label} +
+ ); +}; From 953f3db79debfc4bb2e647fa5718a44b23889de0 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Fri, 30 May 2025 14:47:48 +0000 Subject: [PATCH 2/4] Fix tests and logic --- .../src/pages/TasksPage/TasksPage.stories.tsx | 44 ++++++++++++------- site/src/pages/TasksPage/TasksPage.tsx | 12 +++-- site/src/pages/TasksPage/UsersCombobox.tsx | 2 +- 3 files changed, 34 insertions(+), 24 deletions(-) diff --git a/site/src/pages/TasksPage/TasksPage.stories.tsx b/site/src/pages/TasksPage/TasksPage.stories.tsx index 4dee0e7fe32a9..d3aa29cd970c4 100644 --- a/site/src/pages/TasksPage/TasksPage.stories.tsx +++ b/site/src/pages/TasksPage/TasksPage.stories.tsx @@ -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, @@ -21,6 +23,12 @@ const meta: Meta = { parameters: { user: MockUserOwner, }, + beforeEach: () => { + spyOn(API, "getUsers").mockResolvedValue({ + users: MockUsers, + count: MockUsers.length, + }); + }, }; export default meta; @@ -62,7 +70,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 +103,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"), }); }); }, diff --git a/site/src/pages/TasksPage/TasksPage.tsx b/site/src/pages/TasksPage/TasksPage.tsx index 47d60c94590db..221c981493d79 100644 --- a/site/src/pages/TasksPage/TasksPage.tsx +++ b/site/src/pages/TasksPage/TasksPage.tsx @@ -77,7 +77,7 @@ const TasksPage: FC = () => { const [filter, setFilter] = useState({ user: { value: user.username, - label: user.name ?? user.username, + label: user.name || user.username, avatarUrl: user.avatar_url, }, }); @@ -179,12 +179,9 @@ const TaskForm: FC = ({ 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(["tasks"], (oldTasks = []) => { - return [newTask, ...oldTasks]; + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: ["tasks"], }); }, }); @@ -218,6 +215,7 @@ const TaskForm: FC = ({ templates }) => {
+
Task From e0083331c34e1d6025df861bbd52b643e34ee8f5 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Fri, 30 May 2025 18:16:57 +0000 Subject: [PATCH 4/4] FMT --- site/src/pages/TasksPage/TasksPage.stories.tsx | 14 ++++++++------ site/src/pages/TasksPage/TasksPage.tsx | 14 +++++++------- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/site/src/pages/TasksPage/TasksPage.stories.tsx b/site/src/pages/TasksPage/TasksPage.stories.tsx index 4045a8e8ab891..9b6179ab9bae2 100644 --- a/site/src/pages/TasksPage/TasksPage.stories.tsx +++ b/site/src/pages/TasksPage/TasksPage.stories.tsx @@ -23,8 +23,8 @@ const meta: Meta = { parameters: { user: MockUserOwner, permissions: { - viewDeploymentConfig: true - } + viewDeploymentConfig: true, + }, }, beforeEach: () => { spyOn(API, "getUsers").mockResolvedValue({ @@ -177,8 +177,8 @@ export const NonAdmin: Story = { decorators: [withProxyProvider()], parameters: { permissions: { - viewDeploymentConfig: false - } + viewDeploymentConfig: false, + }, }, beforeEach: () => { spyOn(data, "fetchAITemplates").mockResolvedValue([MockTemplate]); @@ -188,8 +188,10 @@ export const NonAdmin: Story = { const canvas = within(canvasElement); await step("Can't see filters", async () => { - await canvas.findByRole("table") - expect(canvas.queryByRole("region", { name: /filters/i})).not.toBeInTheDocument(); + await canvas.findByRole("table"); + expect( + canvas.queryByRole("region", { name: /filters/i }), + ).not.toBeInTheDocument(); }); }, }; diff --git a/site/src/pages/TasksPage/TasksPage.tsx b/site/src/pages/TasksPage/TasksPage.tsx index 80f4b732ee8c4..31d5e284b22a6 100644 --- a/site/src/pages/TasksPage/TasksPage.tsx +++ b/site/src/pages/TasksPage/TasksPage.tsx @@ -32,11 +32,7 @@ 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, - type ReactNode, - useState, -} 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"; @@ -108,7 +104,9 @@ const TasksPage: FC = () => { ) : ( <> - {permissions.viewDeploymentConfig && } + {permissions.viewDeploymentConfig && ( + + )} ); @@ -253,7 +251,9 @@ type TasksFilterProps = { const TasksFilter: FC = ({ filter, onFilterChange }) => { return (
-

Filters

+

+ Filters +