Skip to content

Commit 19745a2

Browse files
1 parent 9fbccc0 commit 19745a2

File tree

3 files changed

+244
-29
lines changed

3 files changed

+244
-29
lines changed

site/src/pages/TasksPage/TasksPage.stories.tsx

Lines changed: 54 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { Meta, StoryObj } from "@storybook/react";
22
import { expect, spyOn, userEvent, within } from "@storybook/test";
3+
import { API } from "api/api";
4+
import { MockUsers } from "pages/UsersPage/storybookData/users";
35
import {
46
MockTemplate,
57
MockUserOwner,
@@ -20,6 +22,15 @@ const meta: Meta<typeof TasksPage> = {
2022
decorators: [withAuthProvider],
2123
parameters: {
2224
user: MockUserOwner,
25+
permissions: {
26+
viewDeploymentConfig: true,
27+
},
28+
},
29+
beforeEach: () => {
30+
spyOn(API, "getUsers").mockResolvedValue({
31+
users: MockUsers,
32+
count: MockUsers.length,
33+
});
2334
},
2435
};
2536

@@ -62,7 +73,8 @@ export const LoadingTasks: Story = {
6273
const canvas = within(canvasElement);
6374

6475
await step("Select the first AI template", async () => {
65-
const combobox = await canvas.findByRole("combobox");
76+
const form = await canvas.findByRole("form");
77+
const combobox = await within(form).findByRole("combobox");
6678
expect(combobox).toHaveTextContent(MockTemplate.display_name);
6779
});
6880
},
@@ -94,37 +106,40 @@ export const LoadedTasks: Story = {
94106
},
95107
};
96108

109+
const newTaskData = {
110+
prompt: "Create a new task",
111+
workspace: {
112+
...MockWorkspace,
113+
id: "workspace-4",
114+
latest_app_status: {
115+
...MockWorkspaceAppStatus,
116+
message: "Task created successfully!",
117+
},
118+
},
119+
};
120+
97121
export const CreateTaskSuccessfully: Story = {
98122
decorators: [withProxyProvider()],
99123
beforeEach: () => {
100124
spyOn(data, "fetchAITemplates").mockResolvedValue([MockTemplate]);
101-
spyOn(data, "fetchTasks").mockResolvedValue(MockTasks);
102-
spyOn(data, "createTask").mockImplementation((prompt: string) => {
103-
return Promise.resolve({
104-
prompt,
105-
workspace: {
106-
...MockWorkspace,
107-
latest_app_status: {
108-
...MockWorkspaceAppStatus,
109-
message: "Task created successfully!",
110-
},
111-
},
112-
});
113-
});
125+
spyOn(data, "fetchTasks")
126+
.mockResolvedValueOnce(MockTasks)
127+
.mockResolvedValue([newTaskData, ...MockTasks]);
128+
spyOn(data, "createTask").mockResolvedValue(newTaskData);
114129
},
115130
play: async ({ canvasElement, step }) => {
116131
const canvas = within(canvasElement);
117132

118133
await step("Run task", async () => {
119134
const prompt = await canvas.findByLabelText(/prompt/i);
120-
await userEvent.type(prompt, "Create a new task");
135+
await userEvent.type(prompt, newTaskData.prompt);
121136
const submitButton = canvas.getByRole("button", { name: /run task/i });
122137
await userEvent.click(submitButton);
123138
});
124139

125140
await step("Verify task in the table", async () => {
126141
await canvas.findByRole("row", {
127-
name: /create a new task/i,
142+
name: new RegExp(newTaskData.prompt, "i"),
128143
});
129144
});
130145
},
@@ -158,6 +173,29 @@ export const CreateTaskError: Story = {
158173
},
159174
};
160175

176+
export const NonAdmin: Story = {
177+
decorators: [withProxyProvider()],
178+
parameters: {
179+
permissions: {
180+
viewDeploymentConfig: false,
181+
},
182+
},
183+
beforeEach: () => {
184+
spyOn(data, "fetchAITemplates").mockResolvedValue([MockTemplate]);
185+
spyOn(data, "fetchTasks").mockResolvedValue(MockTasks);
186+
},
187+
play: async ({ canvasElement, step }) => {
188+
const canvas = within(canvasElement);
189+
190+
await step("Can't see filters", async () => {
191+
await canvas.findByRole("table");
192+
expect(
193+
canvas.queryByRole("region", { name: /filters/i }),
194+
).not.toBeInTheDocument();
195+
});
196+
},
197+
};
198+
161199
const MockTasks = [
162200
{
163201
workspace: {

site/src/pages/TasksPage/TasksPage.tsx

Lines changed: 57 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,18 @@ import { useAuthenticated } from "hooks";
3232
import { ExternalLinkIcon, RotateCcwIcon, SendIcon } from "lucide-react";
3333
import { AI_PROMPT_PARAMETER_NAME, type Task } from "modules/tasks/tasks";
3434
import { WorkspaceAppStatus } from "modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus";
35-
import type { FC, ReactNode } from "react";
35+
import { type FC, type ReactNode, useState } from "react";
3636
import { Helmet } from "react-helmet-async";
3737
import { useMutation, useQuery, useQueryClient } from "react-query";
3838
import { Link as RouterLink } from "react-router-dom";
3939
import TextareaAutosize from "react-textarea-autosize";
4040
import { pageTitle } from "utils/page";
4141
import { relativeTime } from "utils/time";
42+
import { type UserOption, UsersCombobox } from "./UsersCombobox";
43+
44+
type TasksFilter = {
45+
user: UserOption | undefined;
46+
};
4247

4348
const TasksPage: FC = () => {
4449
const {
@@ -50,6 +55,14 @@ const TasksPage: FC = () => {
5055
queryFn: data.fetchAITemplates,
5156
...disabledRefetchOptions,
5257
});
58+
const { user, permissions } = useAuthenticated();
59+
const [filter, setFilter] = useState<TasksFilter>({
60+
user: {
61+
value: user.username,
62+
label: user.name || user.username,
63+
avatarUrl: user.avatar_url,
64+
},
65+
});
5366

5467
let content: ReactNode = null;
5568

@@ -91,7 +104,10 @@ const TasksPage: FC = () => {
91104
) : (
92105
<>
93106
<TaskForm templates={templates} />
94-
<TasksTable templates={templates} />
107+
{permissions.viewDeploymentConfig && (
108+
<TasksFilter filter={filter} onFilterChange={setFilter} />
109+
)}
110+
<TasksTable templates={templates} filter={filter} />
95111
</>
96112
);
97113
} else {
@@ -147,12 +163,9 @@ const TaskForm: FC<TaskFormProps> = ({ templates }) => {
147163
const createTaskMutation = useMutation({
148164
mutationFn: async ({ prompt, templateId }: CreateTaskMutationFnProps) =>
149165
data.createTask(prompt, user.id, templateId),
150-
onSuccess: (newTask) => {
151-
// The current data loading is heavy, so we manually update the cache to
152-
// avoid re-fetching. Once we improve data loading, we can replace the
153-
// manual update with queryClient.invalidateQueries.
154-
queryClient.setQueryData<Task[]>(["tasks"], (oldTasks = []) => {
155-
return [newTask, ...oldTasks];
166+
onSuccess: async () => {
167+
await queryClient.invalidateQueries({
168+
queryKey: ["tasks"],
156169
});
157170
},
158171
});
@@ -186,6 +199,7 @@ const TaskForm: FC<TaskFormProps> = ({ templates }) => {
186199
<form
187200
className="border border-border border-solid rounded-lg p-4"
188201
onSubmit={onSubmit}
202+
aria-label="Create AI task"
189203
>
190204
<fieldset disabled={createTaskMutation.isPending}>
191205
<label htmlFor="prompt" className="sr-only">
@@ -229,18 +243,43 @@ const TaskForm: FC<TaskFormProps> = ({ templates }) => {
229243
);
230244
};
231245

246+
type TasksFilterProps = {
247+
filter: TasksFilter;
248+
onFilterChange: (filter: TasksFilter) => void;
249+
};
250+
251+
const TasksFilter: FC<TasksFilterProps> = ({ filter, onFilterChange }) => {
252+
return (
253+
<section className="mt-6" aria-labelledby="filters-title">
254+
<h3 id="filters-title" className="sr-only">
255+
Filters
256+
</h3>
257+
<UsersCombobox
258+
selectedOption={filter.user}
259+
onSelect={(userOption) =>
260+
onFilterChange({
261+
...filter,
262+
user: userOption,
263+
})
264+
}
265+
/>
266+
</section>
267+
);
268+
};
269+
232270
type TasksTableProps = {
233271
templates: Template[];
272+
filter: TasksFilter;
234273
};
235274

236-
const TasksTable: FC<TasksTableProps> = ({ templates }) => {
275+
const TasksTable: FC<TasksTableProps> = ({ templates, filter }) => {
237276
const {
238277
data: tasks,
239278
error,
240279
refetch,
241280
} = useQuery({
242-
queryKey: ["tasks"],
243-
queryFn: () => data.fetchTasks(templates),
281+
queryKey: ["tasks", filter],
282+
queryFn: () => data.fetchTasks(templates, filter),
244283
refetchInterval: 10_000,
245284
});
246285

@@ -397,11 +436,16 @@ export const data = {
397436
// template individually and its build parameters resulting in excessive API
398437
// calls and slow performance. Consider implementing a backend endpoint that
399438
// returns all AI-related workspaces in a single request to improve efficiency.
400-
async fetchTasks(aiTemplates: Template[]) {
439+
async fetchTasks(aiTemplates: Template[], filter: TasksFilter) {
401440
const workspaces = await Promise.all(
402441
aiTemplates.map((template) => {
442+
const queryParts = [`template:${template.name}`];
443+
if (filter.user) {
444+
queryParts.push(`owner:${filter.user.value}`);
445+
}
446+
403447
return API.getWorkspaces({
404-
q: `template:${template.name}`,
448+
q: queryParts.join(" "),
405449
limit: 100,
406450
});
407451
}),
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import Skeleton from "@mui/material/Skeleton";
2+
import { users } from "api/queries/users";
3+
import { Avatar } from "components/Avatar/Avatar";
4+
import { Button } from "components/Button/Button";
5+
import {
6+
Command,
7+
CommandEmpty,
8+
CommandGroup,
9+
CommandInput,
10+
CommandItem,
11+
CommandList,
12+
} from "components/Command/Command";
13+
import {
14+
Popover,
15+
PopoverContent,
16+
PopoverTrigger,
17+
} from "components/Popover/Popover";
18+
import { useDebouncedValue } from "hooks/debounce";
19+
import { CheckIcon, ChevronsUpDownIcon } from "lucide-react";
20+
import { type FC, useState } from "react";
21+
import { keepPreviousData, useQuery } from "react-query";
22+
import { cn } from "utils/cn";
23+
24+
export type UserOption = {
25+
label: string;
26+
value: string; // Username
27+
avatarUrl?: string;
28+
};
29+
30+
type UsersComboboxProps = {
31+
selectedOption: UserOption | undefined;
32+
onSelect: (option: UserOption | undefined) => void;
33+
};
34+
35+
export const UsersCombobox: FC<UsersComboboxProps> = ({
36+
selectedOption,
37+
onSelect,
38+
}) => {
39+
const [open, setOpen] = useState(false);
40+
const [search, setSearch] = useState("");
41+
const debouncedSearch = useDebouncedValue(search, 250);
42+
const usersQuery = useQuery({
43+
...users({ q: debouncedSearch }),
44+
select: (data) =>
45+
data.users.toSorted((a, b) => {
46+
return selectedOption && a.username === selectedOption.value ? -1 : 0;
47+
}),
48+
placeholderData: keepPreviousData,
49+
});
50+
51+
const options = usersQuery.data?.map((user) => ({
52+
label: user.name || user.username,
53+
value: user.username,
54+
avatarUrl: user.avatar_url,
55+
}));
56+
57+
return (
58+
<Popover open={open} onOpenChange={setOpen}>
59+
<PopoverTrigger asChild>
60+
<Button
61+
disabled={!options}
62+
variant="outline"
63+
role="combobox"
64+
aria-expanded={open}
65+
className="w-[280px] justify-between"
66+
>
67+
{options ? (
68+
selectedOption ? (
69+
<UserItem option={selectedOption} className="-ml-1" />
70+
) : (
71+
"Select user..."
72+
)
73+
) : (
74+
<Skeleton variant="text" className="w-[120px] h-3" />
75+
)}
76+
<ChevronsUpDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
77+
</Button>
78+
</PopoverTrigger>
79+
<PopoverContent className="w-[280px] p-0">
80+
<Command>
81+
<CommandInput
82+
placeholder="Search user..."
83+
value={search}
84+
onValueChange={setSearch}
85+
/>
86+
<CommandList>
87+
<CommandEmpty>No users found.</CommandEmpty>
88+
<CommandGroup>
89+
{options?.map((option) => (
90+
<CommandItem
91+
key={option.value}
92+
value={option.value}
93+
onSelect={() => {
94+
onSelect(
95+
option.value === selectedOption?.value
96+
? undefined
97+
: option,
98+
);
99+
setOpen(false);
100+
}}
101+
>
102+
<UserItem option={option} />
103+
<CheckIcon
104+
className={cn(
105+
"ml-2 h-4 w-4",
106+
option.value === selectedOption?.value
107+
? "opacity-100"
108+
: "opacity-0",
109+
)}
110+
/>
111+
</CommandItem>
112+
))}
113+
</CommandGroup>
114+
</CommandList>
115+
</Command>
116+
</PopoverContent>
117+
</Popover>
118+
);
119+
};
120+
121+
type UserItemProps = {
122+
option: UserOption;
123+
className?: string;
124+
};
125+
126+
const UserItem: FC<UserItemProps> = ({ option, className }) => {
127+
return (
128+
<div className={cn("flex flex-1 items-center gap-2", className)}>
129+
<Avatar src={option.avatarUrl} fallback={option.label} />
130+
{option.label}
131+
</div>
132+
);
133+
};

0 commit comments

Comments
 (0)
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