Bruno Quaresma
2025-09-24 10:40:01 -03:00
committed by GitHub
parent 50a92147e3
commit 7baaa16ac1
5 changed files with 339 additions and 4 deletions
@@ -0,0 +1,89 @@
import { MockTasks, MockUserOwner, mockApiError } from "testHelpers/entities";
import { withAuthProvider } from "testHelpers/storybook";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { API } from "api/api";
import { MockUsers } from "pages/UsersPage/storybookData/users";
import { spyOn, userEvent, within } from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
import { TasksSidebar } from "./TasksSidebar";
const meta: Meta<typeof TasksSidebar> = {
title: "modules/tasks/TasksSidebar",
component: TasksSidebar,
decorators: [withAuthProvider],
parameters: {
user: MockUserOwner,
layout: "fullscreen",
permissions: {
viewAllUsers: true,
},
},
beforeEach: () => {
spyOn(API, "getUsers").mockResolvedValue({
users: MockUsers,
count: MockUsers.length,
});
},
};
export default meta;
type Story = StoryObj<typeof TasksSidebar>;
export const Loading: Story = {
beforeEach: () => {
spyOn(API.experimental, "getTasks").mockReturnValue(new Promise(() => {}));
},
};
export const Failed: Story = {
beforeEach: () => {
spyOn(API.experimental, "getTasks").mockRejectedValue(
mockApiError({
message: "Failed to fetch tasks",
}),
);
},
};
export const Loaded: Story = {
beforeEach: () => {
spyOn(API.experimental, "getTasks").mockResolvedValue(MockTasks);
},
parameters: {
reactRouter: reactRouterParameters({
location: {
pathParams: {
workspace: MockTasks[0].workspace.name,
},
},
routing: { path: "/tasks/:workspace" },
}),
},
};
export const Empty: Story = {
beforeEach: () => {
spyOn(API.experimental, "getTasks").mockResolvedValue([]);
},
};
export const Closed: Story = {
beforeEach: () => {
spyOn(API.experimental, "getTasks").mockResolvedValue(MockTasks);
},
parameters: {
reactRouter: reactRouterParameters({
location: {
pathParams: {
workspace: MockTasks[0].workspace.name,
},
},
routing: { path: "/tasks/:workspace" },
}),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole("button", { name: /close sidebar/i });
await userEvent.click(button);
},
};
@@ -0,0 +1,237 @@
import { API } from "api/api";
import { getErrorMessage } from "api/errors";
import { cva } from "class-variance-authority";
import { Button } from "components/Button/Button";
import { CoderIcon } from "components/Icons/CoderIcon";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { useAuthenticated } from "hooks";
import { useSearchParamsKey } from "hooks/useSearchParamsKey";
import { EditIcon, PanelLeftIcon } from "lucide-react";
import type { Task } from "modules/tasks/tasks";
import { type FC, useState } from "react";
import { useQuery } from "react-query";
import { Link as RouterLink, useParams } from "react-router";
import { cn } from "utils/cn";
import { UserCombobox } from "./UserCombobox";
export const TasksSidebar: FC = () => {
const { user, permissions } = useAuthenticated();
const usernameParam = useSearchParamsKey({
key: "username",
defaultValue: user.username,
});
const [isCollapsed, setIsCollapsed] = useState(false);
return (
<div
className={cn(
"h-full flex flex-col flex-1 min-h-0 gap-6 bg-surface-secondary max-w-80",
"border-solid border-0 border-r transition-all p-3",
{ "max-w-16 items-center": isCollapsed },
)}
>
<div className="flex items-center place-content-between">
{!isCollapsed && (
<Button
size="icon"
variant="subtle"
className={cn([
"size-8 p-0 transition-[margin,opacity]",
"group-data-[collapsible=icon]:-ml-10 group-data-[collapsible=icon]:opacity-0",
])}
>
<CoderIcon className="fill-content-primary !size-6 !p-0" />
</Button>
)}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="subtle"
onClick={() => setIsCollapsed((v) => !v)}
className="[&_svg]:p-0"
>
<PanelLeftIcon />
<span className="sr-only">
{isCollapsed ? "Open" : "Close"} Sidebar
</span>
</Button>
</TooltipTrigger>
<TooltipContent side="right" align="center">
{isCollapsed ? "Open" : "Close"} Sidebar
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={isCollapsed ? "subtle" : "default"}
size={isCollapsed ? "icon" : "sm"}
asChild={true}
className={cn({
"[&_svg]:p-0": isCollapsed,
})}
>
<RouterLink to="/tasks">
<span className={isCollapsed ? "hidden" : ""}>New Task</span>{" "}
<EditIcon />
</RouterLink>
</Button>
</TooltipTrigger>
<TooltipContent side="right" align="center">
New task
</TooltipContent>
</Tooltip>
</TooltipProvider>
{!isCollapsed && (
<>
{permissions.viewAllUsers && (
<UserCombobox
value={usernameParam.value}
onValueChange={(username) => {
if (username === usernameParam.value) {
usernameParam.setValue("");
return;
}
usernameParam.setValue(username);
}}
/>
)}
<TasksSidebarGroup username={usernameParam.value} />
</>
)}
</div>
);
};
type TasksSidebarGroupProps = {
username: string;
};
const TasksSidebarGroup: FC<TasksSidebarGroupProps> = ({ username }) => {
const filter = { username };
const tasksQuery = useQuery({
queryKey: ["tasks", filter],
queryFn: () => API.experimental.getTasks(filter),
refetchInterval: 10_000,
});
return (
<div className="flex flex-col flex-1 gap-2 min-h-0 transition-[opacity] group-data-[collapsible=icon]:opacity-0">
<div className="text-content-secondary text-xs">Tasks</div>
<div className="flex flex-col flex-1 gap-1 min-h-0 overflow-y-auto">
{tasksQuery.data ? (
tasksQuery.data.length > 0 ? (
tasksQuery.data.map((t) => (
<TaskSidebarMenuItem key={t.workspace.id} task={t} />
))
) : (
<div className="text-content-secondary text-xs p-4 border-border border-solid rounded text-center">
No tasks found
</div>
)
) : tasksQuery.error ? (
<div className="text-content-secondary text-xs p-4 border-border border-solid rounded text-center">
{getErrorMessage(tasksQuery.error, "Failed to load tasks")}
</div>
) : (
<div className="flex flex-col gap-1">
{Array.from({ length: 5 }).map((_, index) => (
<div
key={index}
aria-hidden={true}
className="h-8 w-full rounded-lg bg-surface-tertiary animate-pulse"
/>
))}
</div>
)}
</div>
</div>
);
};
type TaskSidebarMenuItemProps = {
task: Task;
};
const TaskSidebarMenuItem: FC<TaskSidebarMenuItemProps> = ({ task }) => {
const { workspace } = useParams<{ workspace: string }>();
const isActive = task.workspace.name === workspace;
return (
<Button
size="sm"
variant="subtle"
className={cn(
"w-full justify-start text-content-secondary hover:bg-surface-tertiary gap-2",
{
"text-content-primary bg-surface-quaternary pointer-events-none":
isActive,
},
)}
asChild
>
<RouterLink
to={{
pathname: `/tasks/${task.workspace.owner_name}/${task.workspace.name}`,
search: window.location.search,
}}
>
<TaskSidebarMenuItemStatus task={task} />
{task.workspace.name}
</RouterLink>
</Button>
);
};
const taskStatusVariants = cva("block size-2 rounded-full shrink-0", {
variants: {
state: {
default: "border border-content-secondary border-solid",
complete: "bg-content-success",
failure: "bg-content-destructive",
idle: "bg-content-secondary",
working: "bg-highlight-sky",
},
},
defaultVariants: {
state: "default",
},
});
const TaskSidebarMenuItemStatus: FC<{ task: Task }> = ({ task }) => {
const statusText = task.workspace.latest_app_status
? task.workspace.latest_app_status.state
: "No activity yet";
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div
className={taskStatusVariants({
state: task.workspace.latest_app_status?.state ?? "default",
})}
>
<span className="sr-only">{statusText}</span>
</div>
</TooltipTrigger>
<TooltipContent className="first-letter:capitalize">
{statusText}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
@@ -34,9 +34,12 @@ export const Loading: Story = {
},
};
export const AllUsers: Story = {
parameters: {
queries: [{ key: ["users"], data: MockUsers }],
export const Loaded: Story = {
beforeEach: () => {
spyOn(API, "getUsers").mockResolvedValue({
count: MockUsers.length,
users: MockUsers,
});
},
};
@@ -43,6 +43,9 @@ export const UserCombobox: FC<UserComboboxProps> = ({
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const debouncedSearch = useDebouncedValue(search, 250);
// By default, this combobox filters by the authenticated user.
// To ensure consistent behavior, we must always include the
// authenticated user in the list of options.
const { user } = useAuthenticated();
const { data: options, isFetched } = useQuery({
...users({ q: debouncedSearch }),
@@ -58,7 +61,7 @@ export const UserCombobox: FC<UserComboboxProps> = ({
disabled={!isFetched}
role="combobox"
aria-expanded={open}
className="justify-between rounded-full bg-surface-tertiary border border-border hover:bg-surface-quaternary text-content-primary pl-3 w-full"
className="justify-between rounded-full bg-surface-tertiary border border-border hover:bg-surface-quaternary text-content-primary pl-3 w-fit"
size="sm"
>
{isFetched ? (
+3
View File
@@ -4877,6 +4877,7 @@ export const MockTasks = [
{
workspace: {
...MockWorkspace,
name: "create-competitors-page",
latest_app_status: MockWorkspaceAppStatus,
},
prompt: "Create competitors page",
@@ -4885,6 +4886,7 @@ export const MockTasks = [
workspace: {
...MockWorkspace,
id: "workspace-2",
name: "fix-avatar-size",
latest_app_status: {
...MockWorkspaceAppStatus,
message: "Avatar size fixed!",
@@ -4896,6 +4898,7 @@ export const MockTasks = [
workspace: {
...MockWorkspace,
id: "workspace-3",
name: "fix-accessibility-issues",
latest_app_status: {
...MockWorkspaceAppStatus,
message: "Accessibility issues fixed!",