mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
chore: add tasks sidebar component (#19926)
This PR is part of a series of PRs aimed at completing the tasks sidebar. **Demo:** https://github.com/user-attachments/assets/0d830f22-dc94-45d7-833b-fe706226ad68 Reference: [https://github.com/coder/coder/issues/19573](https://github.com/coder/coder/issues/19573) Depends on: [https://github.com/coder/coder/pull/19920](https://github.com/coder/coder/pull/19920) Based on: [https://github.com/coder/coder/pull/19655](https://github.com/coder/coder/pull/19655)
This commit is contained in:
@@ -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 ? (
|
||||
|
||||
@@ -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!",
|
||||
|
||||
Reference in New Issue
Block a user