diff --git a/site/src/modules/tasks/TasksSidebar/TasksSidebar.stories.tsx b/site/src/modules/tasks/TasksSidebar/TasksSidebar.stories.tsx new file mode 100644 index 0000000000..ce49156530 --- /dev/null +++ b/site/src/modules/tasks/TasksSidebar/TasksSidebar.stories.tsx @@ -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 = { + 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; + +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); + }, +}; diff --git a/site/src/modules/tasks/TasksSidebar/TasksSidebar.tsx b/site/src/modules/tasks/TasksSidebar/TasksSidebar.tsx new file mode 100644 index 0000000000..cbf76efcd0 --- /dev/null +++ b/site/src/modules/tasks/TasksSidebar/TasksSidebar.tsx @@ -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 ( +
+
+ {!isCollapsed && ( + + )} + + + + + + + + {isCollapsed ? "Open" : "Close"} Sidebar + + + +
+ + + + + + + + New task + + + + + {!isCollapsed && ( + <> + {permissions.viewAllUsers && ( + { + if (username === usernameParam.value) { + usernameParam.setValue(""); + return; + } + usernameParam.setValue(username); + }} + /> + )} + + + )} +
+ ); +}; + +type TasksSidebarGroupProps = { + username: string; +}; + +const TasksSidebarGroup: FC = ({ username }) => { + const filter = { username }; + const tasksQuery = useQuery({ + queryKey: ["tasks", filter], + queryFn: () => API.experimental.getTasks(filter), + refetchInterval: 10_000, + }); + + return ( +
+
Tasks
+
+ {tasksQuery.data ? ( + tasksQuery.data.length > 0 ? ( + tasksQuery.data.map((t) => ( + + )) + ) : ( +
+ No tasks found +
+ ) + ) : tasksQuery.error ? ( +
+ {getErrorMessage(tasksQuery.error, "Failed to load tasks")} +
+ ) : ( +
+ {Array.from({ length: 5 }).map((_, index) => ( +
+ ))} +
+ )} +
+
+ ); +}; + +type TaskSidebarMenuItemProps = { + task: Task; +}; + +const TaskSidebarMenuItem: FC = ({ task }) => { + const { workspace } = useParams<{ workspace: string }>(); + const isActive = task.workspace.name === workspace; + + return ( + + ); +}; + +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 ( + + + +
+ {statusText} +
+
+ + {statusText} + +
+
+ ); +}; diff --git a/site/src/modules/tasks/TasksSidebar/UserCombobox.stories.tsx b/site/src/modules/tasks/TasksSidebar/UserCombobox.stories.tsx index 3f4900000a..00acf7ee9d 100644 --- a/site/src/modules/tasks/TasksSidebar/UserCombobox.stories.tsx +++ b/site/src/modules/tasks/TasksSidebar/UserCombobox.stories.tsx @@ -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, + }); }, }; diff --git a/site/src/modules/tasks/TasksSidebar/UserCombobox.tsx b/site/src/modules/tasks/TasksSidebar/UserCombobox.tsx index af5c199c99..761329175b 100644 --- a/site/src/modules/tasks/TasksSidebar/UserCombobox.tsx +++ b/site/src/modules/tasks/TasksSidebar/UserCombobox.tsx @@ -43,6 +43,9 @@ export const UserCombobox: FC = ({ 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 = ({ 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 ? ( diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 0530c04ad3..79084d50ad 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -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!",