Skip to content

File tree

5 files changed

+339
-4
lines changed

5 files changed

+339
-4
lines changed
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { MockTasks, MockUserOwner, mockApiError } from "testHelpers/entities";
2+
import { withAuthProvider } from "testHelpers/storybook";
3+
import type { Meta, StoryObj } from "@storybook/react-vite";
4+
import { API } from "api/api";
5+
import { MockUsers } from "pages/UsersPage/storybookData/users";
6+
import { spyOn, userEvent, within } from "storybook/test";
7+
import { reactRouterParameters } from "storybook-addon-remix-react-router";
8+
import { TasksSidebar } from "./TasksSidebar";
9+
10+
const meta: Meta<typeof TasksSidebar> = {
11+
title: "modules/tasks/TasksSidebar",
12+
component: TasksSidebar,
13+
decorators: [withAuthProvider],
14+
parameters: {
15+
user: MockUserOwner,
16+
layout: "fullscreen",
17+
permissions: {
18+
viewAllUsers: true,
19+
},
20+
},
21+
beforeEach: () => {
22+
spyOn(API, "getUsers").mockResolvedValue({
23+
users: MockUsers,
24+
count: MockUsers.length,
25+
});
26+
},
27+
};
28+
29+
export default meta;
30+
type Story = StoryObj<typeof TasksSidebar>;
31+
32+
export const Loading: Story = {
33+
beforeEach: () => {
34+
spyOn(API.experimental, "getTasks").mockReturnValue(new Promise(() => {}));
35+
},
36+
};
37+
38+
export const Failed: Story = {
39+
beforeEach: () => {
40+
spyOn(API.experimental, "getTasks").mockRejectedValue(
41+
mockApiError({
42+
message: "Failed to fetch tasks",
43+
}),
44+
);
45+
},
46+
};
47+
48+
export const Loaded: Story = {
49+
beforeEach: () => {
50+
spyOn(API.experimental, "getTasks").mockResolvedValue(MockTasks);
51+
},
52+
parameters: {
53+
reactRouter: reactRouterParameters({
54+
location: {
55+
pathParams: {
56+
workspace: MockTasks[0].workspace.name,
57+
},
58+
},
59+
routing: { path: "/tasks/:workspace" },
60+
}),
61+
},
62+
};
63+
64+
export const Empty: Story = {
65+
beforeEach: () => {
66+
spyOn(API.experimental, "getTasks").mockResolvedValue([]);
67+
},
68+
};
69+
70+
export const Closed: Story = {
71+
beforeEach: () => {
72+
spyOn(API.experimental, "getTasks").mockResolvedValue(MockTasks);
73+
},
74+
parameters: {
75+
reactRouter: reactRouterParameters({
76+
location: {
77+
pathParams: {
78+
workspace: MockTasks[0].workspace.name,
79+
},
80+
},
81+
routing: { path: "/tasks/:workspace" },
82+
}),
83+
},
84+
play: async ({ canvasElement }) => {
85+
const canvas = within(canvasElement);
86+
const button = canvas.getByRole("button", { name: /close sidebar/i });
87+
await userEvent.click(button);
88+
},
89+
};
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
import { API } from "api/api";
2+
import { getErrorMessage } from "api/errors";
3+
import { cva } from "class-variance-authority";
4+
import { Button } from "components/Button/Button";
5+
import { CoderIcon } from "components/Icons/CoderIcon";
6+
import {
7+
Tooltip,
8+
TooltipContent,
9+
TooltipProvider,
10+
TooltipTrigger,
11+
} from "components/Tooltip/Tooltip";
12+
import { useAuthenticated } from "hooks";
13+
import { useSearchParamsKey } from "hooks/useSearchParamsKey";
14+
import { EditIcon, PanelLeftIcon } from "lucide-react";
15+
import type { Task } from "modules/tasks/tasks";
16+
import { type FC, useState } from "react";
17+
import { useQuery } from "react-query";
18+
import { Link as RouterLink, useParams } from "react-router";
19+
import { cn } from "utils/cn";
20+
import { UserCombobox } from "./UserCombobox";
21+
22+
export const TasksSidebar: FC = () => {
23+
const { user, permissions } = useAuthenticated();
24+
const usernameParam = useSearchParamsKey({
25+
key: "username",
26+
defaultValue: user.username,
27+
});
28+
29+
const [isCollapsed, setIsCollapsed] = useState(false);
30+
31+
return (
32+
<div
33+
className={cn(
34+
"h-full flex flex-col flex-1 min-h-0 gap-6 bg-surface-secondary max-w-80",
35+
"border-solid border-0 border-r transition-all p-3",
36+
{ "max-w-16 items-center": isCollapsed },
37+
)}
38+
>
39+
<div className="flex items-center place-content-between">
40+
{!isCollapsed && (
41+
<Button
42+
size="icon"
43+
variant="subtle"
44+
className={cn([
45+
"size-8 p-0 transition-[margin,opacity]",
46+
"group-data-[collapsible=icon]:-ml-10 group-data-[collapsible=icon]:opacity-0",
47+
])}
48+
>
49+
<CoderIcon className="fill-content-primary !size-6 !p-0" />
50+
</Button>
51+
)}
52+
53+
<TooltipProvider>
54+
<Tooltip>
55+
<TooltipTrigger asChild>
56+
<Button
57+
size="icon"
58+
variant="subtle"
59+
onClick={() => setIsCollapsed((v) => !v)}
60+
className="[&_svg]:p-0"
61+
>
62+
<PanelLeftIcon />
63+
<span className="sr-only">
64+
{isCollapsed ? "Open" : "Close"} Sidebar
65+
</span>
66+
</Button>
67+
</TooltipTrigger>
68+
<TooltipContent side="right" align="center">
69+
{isCollapsed ? "Open" : "Close"} Sidebar
70+
</TooltipContent>
71+
</Tooltip>
72+
</TooltipProvider>
73+
</div>
74+
75+
<TooltipProvider>
76+
<Tooltip>
77+
<TooltipTrigger asChild>
78+
<Button
79+
variant={isCollapsed ? "subtle" : "default"}
80+
size={isCollapsed ? "icon" : "sm"}
81+
asChild={true}
82+
className={cn({
83+
"[&_svg]:p-0": isCollapsed,
84+
})}
85+
>
86+
<RouterLink to="/tasks">
87+
<span className={isCollapsed ? "hidden" : ""}>New Task</span>{" "}
88+
<EditIcon />
89+
</RouterLink>
90+
</Button>
91+
</TooltipTrigger>
92+
<TooltipContent side="right" align="center">
93+
New task
94+
</TooltipContent>
95+
</Tooltip>
96+
</TooltipProvider>
97+
98+
{!isCollapsed && (
99+
<>
100+
{permissions.viewAllUsers && (
101+
<UserCombobox
102+
value={usernameParam.value}
103+
onValueChange={(username) => {
104+
if (username === usernameParam.value) {
105+
usernameParam.setValue("");
106+
return;
107+
}
108+
usernameParam.setValue(username);
109+
}}
110+
/>
111+
)}
112+
<TasksSidebarGroup username={usernameParam.value} />
113+
</>
114+
)}
115+
</div>
116+
);
117+
};
118+
119+
type TasksSidebarGroupProps = {
120+
username: string;
121+
};
122+
123+
const TasksSidebarGroup: FC<TasksSidebarGroupProps> = ({ username }) => {
124+
const filter = { username };
125+
const tasksQuery = useQuery({
126+
queryKey: ["tasks", filter],
127+
queryFn: () => API.experimental.getTasks(filter),
128+
refetchInterval: 10_000,
129+
});
130+
131+
return (
132+
<div className="flex flex-col flex-1 gap-2 min-h-0 transition-[opacity] group-data-[collapsible=icon]:opacity-0">
133+
<div className="text-content-secondary text-xs">Tasks</div>
134+
<div className="flex flex-col flex-1 gap-1 min-h-0 overflow-y-auto">
135+
{tasksQuery.data ? (
136+
tasksQuery.data.length > 0 ? (
137+
tasksQuery.data.map((t) => (
138+
<TaskSidebarMenuItem key={t.workspace.id} task={t} />
139+
))
140+
) : (
141+
<div className="text-content-secondary text-xs p-4 border-border border-solid rounded text-center">
142+
No tasks found
143+
</div>
144+
)
145+
) : tasksQuery.error ? (
146+
<div className="text-content-secondary text-xs p-4 border-border border-solid rounded text-center">
147+
{getErrorMessage(tasksQuery.error, "Failed to load tasks")}
148+
</div>
149+
) : (
150+
<div className="flex flex-col gap-1">
151+
{Array.from({ length: 5 }).map((_, index) => (
152+
<div
153+
key={index}
154+
aria-hidden={true}
155+
className="h-8 w-full rounded-lg bg-surface-tertiary animate-pulse"
156+
/>
157+
))}
158+
</div>
159+
)}
160+
</div>
161+
</div>
162+
);
163+
};
164+
165+
type TaskSidebarMenuItemProps = {
166+
task: Task;
167+
};
168+
169+
const TaskSidebarMenuItem: FC<TaskSidebarMenuItemProps> = ({ task }) => {
170+
const { workspace } = useParams<{ workspace: string }>();
171+
const isActive = task.workspace.name === workspace;
172+
173+
return (
174+
<Button
175+
size="sm"
176+
variant="subtle"
177+
className={cn(
178+
"w-full justify-start text-content-secondary hover:bg-surface-tertiary gap-2",
179+
{
180+
"text-content-primary bg-surface-quaternary pointer-events-none":
181+
isActive,
182+
},
183+
)}
184+
asChild
185+
>
186+
<RouterLink
187+
to={{
188+
pathname: `/tasks/${task.workspace.owner_name}/${task.workspace.name}`,
189+
search: window.location.search,
190+
}}
191+
>
192+
<TaskSidebarMenuItemStatus task={task} />
193+
{task.workspace.name}
194+
</RouterLink>
195+
</Button>
196+
);
197+
};
198+
199+
const taskStatusVariants = cva("block size-2 rounded-full shrink-0", {
200+
variants: {
201+
state: {
202+
default: "border border-content-secondary border-solid",
203+
complete: "bg-content-success",
204+
failure: "bg-content-destructive",
205+
idle: "bg-content-secondary",
206+
working: "bg-highlight-sky",
207+
},
208+
},
209+
defaultVariants: {
210+
state: "default",
211+
},
212+
});
213+
214+
const TaskSidebarMenuItemStatus: FC<{ task: Task }> = ({ task }) => {
215+
const statusText = task.workspace.latest_app_status
216+
? task.workspace.latest_app_status.state
217+
: "No activity yet";
218+
219+
return (
220+
<TooltipProvider>
221+
<Tooltip>
222+
<TooltipTrigger asChild>
223+
<div
224+
className={taskStatusVariants({
225+
state: task.workspace.latest_app_status?.state ?? "default",
226+
})}
227+
>
228+
<span className="sr-only">{statusText}</span>
229+
</div>
230+
</TooltipTrigger>
231+
<TooltipContent className="first-letter:capitalize">
232+
{statusText}
233+
</TooltipContent>
234+
</Tooltip>
235+
</TooltipProvider>
236+
);
237+
};

site/src/modules/tasks/TasksSidebar/UserCombobox.stories.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,12 @@ export const Loading: Story = {
3434
},
3535
};
3636

37-
export const AllUsers: Story = {
38-
parameters: {
39-
queries: [{ key: ["users"], data: MockUsers }],
37+
export const Loaded: Story = {
38+
beforeEach: () => {
39+
spyOn(API, "getUsers").mockResolvedValue({
40+
count: MockUsers.length,
41+
users: MockUsers,
42+
});
4043
},
4144
};
4245

site/src/modules/tasks/TasksSidebar/UserCombobox.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ export const UserCombobox: FC<UserComboboxProps> = ({
4343
const [open, setOpen] = useState(false);
4444
const [search, setSearch] = useState("");
4545
const debouncedSearch = useDebouncedValue(search, 250);
46+
// By default, this combobox filters by the authenticated user.
47+
// To ensure consistent behavior, we must always include the
48+
// authenticated user in the list of options.
4649
const { user } = useAuthenticated();
4750
const { data: options, isFetched } = useQuery({
4851
...users({ q: debouncedSearch }),
@@ -58,7 +61,7 @@ export const UserCombobox: FC<UserComboboxProps> = ({
5861
disabled={!isFetched}
5962
role="combobox"
6063
aria-expanded={open}
61-
className="justify-between rounded-full bg-surface-tertiary border border-border hover:bg-surface-quaternary text-content-primary pl-3 w-full"
64+
className="justify-between rounded-full bg-surface-tertiary border border-border hover:bg-surface-quaternary text-content-primary pl-3 w-fit"
6265
size="sm"
6366
>
6467
{isFetched ? (

site/src/testHelpers/entities.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4877,6 +4877,7 @@ export const MockTasks = [
48774877
{
48784878
workspace: {
48794879
...MockWorkspace,
4880+
name: "create-competitors-page",
48804881
latest_app_status: MockWorkspaceAppStatus,
48814882
},
48824883
prompt: "Create competitors page",
@@ -4885,6 +4886,7 @@ export const MockTasks = [
48854886
workspace: {
48864887
...MockWorkspace,
48874888
id: "workspace-2",
4889+
name: "fix-avatar-size",
48884890
latest_app_status: {
48894891
...MockWorkspaceAppStatus,
48904892
message: "Avatar size fixed!",
@@ -4896,6 +4898,7 @@ export const MockTasks = [
48964898
workspace: {
48974899
...MockWorkspace,
48984900
id: "workspace-3",
4901+
name: "fix-accessibility-issues",
48994902
latest_app_status: {
49004903
...MockWorkspaceAppStatus,
49014904
message: "Accessibility issues fixed!",

0 commit comments

Comments
 (0)