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)