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 +

pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy