mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: display the number of idle tasks in the navbar (#19471)
Depends on: https://github.com/coder/coder/pull/19377 Closes https://github.com/coder/coder/issues/19323 **Screenshot:** <img width="1511" height="777" alt="Screenshot 2025-08-21 at 11 52 21" src="https://github.com/user-attachments/assets/be04e507-bf04-47d0-8748-2f71b93b5685" /> **Screen recording:** https://github.com/user-attachments/assets/f70b34fe-952b-427b-9bc9-71961ca23201 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - New Features - Added a Tasks navigation item showing a badge with the number of idle tasks and a tooltip: “You have X tasks waiting for input.” - Improvements - Fetches per-user tasks with periodic refresh for up-to-date counts. - Updated active styling for the Tasks link for clearer navigation state. - User menu now always appears on medium+ screens. - Tests - Expanded Storybook with preloaded, user-filtered task scenarios to showcase idle/task states. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -1,13 +1,31 @@
|
||||
import { chromaticWithTablet } from "testHelpers/chromatic";
|
||||
import { MockUserMember, MockUserOwner } from "testHelpers/entities";
|
||||
import {
|
||||
MockUserMember,
|
||||
MockUserOwner,
|
||||
MockWorkspace,
|
||||
MockWorkspaceAppStatus,
|
||||
} from "testHelpers/entities";
|
||||
import { withDashboardProvider } from "testHelpers/storybook";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { userEvent, within } from "storybook/test";
|
||||
import { NavbarView } from "./NavbarView";
|
||||
|
||||
const tasksFilter = {
|
||||
username: MockUserOwner.username,
|
||||
};
|
||||
|
||||
const meta: Meta<typeof NavbarView> = {
|
||||
title: "modules/dashboard/NavbarView",
|
||||
parameters: { chromatic: chromaticWithTablet, layout: "fullscreen" },
|
||||
parameters: {
|
||||
chromatic: chromaticWithTablet,
|
||||
layout: "fullscreen",
|
||||
queries: [
|
||||
{
|
||||
key: ["tasks", tasksFilter],
|
||||
data: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
component: NavbarView,
|
||||
args: {
|
||||
user: MockUserOwner,
|
||||
@@ -78,3 +96,36 @@ export const CustomLogo: Story = {
|
||||
logo_url: "/icon/github.svg",
|
||||
},
|
||||
};
|
||||
|
||||
export const IdleTasks: Story = {
|
||||
parameters: {
|
||||
queries: [
|
||||
{
|
||||
key: ["tasks", tasksFilter],
|
||||
data: [
|
||||
{
|
||||
prompt: "Task 1",
|
||||
workspace: {
|
||||
...MockWorkspace,
|
||||
latest_app_status: {
|
||||
...MockWorkspaceAppStatus,
|
||||
state: "idle",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
prompt: "Task 2",
|
||||
workspace: MockWorkspace,
|
||||
},
|
||||
{
|
||||
prompt: "Task 3",
|
||||
workspace: {
|
||||
...MockWorkspace,
|
||||
latest_app_status: MockWorkspaceAppStatus,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { API } from "api/api";
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import { Badge } from "components/Badge/Badge";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { ExternalImage } from "components/ExternalImage/ExternalImage";
|
||||
import { CoderIcon } from "components/Icons/CoderIcon";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "components/Tooltip/Tooltip";
|
||||
import type { ProxyContextValue } from "contexts/ProxyContext";
|
||||
import { useWebpushNotifications } from "contexts/useWebpushNotifications";
|
||||
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
|
||||
import { NotificationsInbox } from "modules/notifications/NotificationsInbox/NotificationsInbox";
|
||||
import type { FC } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { NavLink, useLocation } from "react-router";
|
||||
import { cn } from "utils/cn";
|
||||
import { DeploymentDropdown } from "./DeploymentDropdown";
|
||||
@@ -17,7 +25,7 @@ import { UserDropdown } from "./UserDropdown/UserDropdown";
|
||||
|
||||
interface NavbarViewProps {
|
||||
logo_url?: string;
|
||||
user?: TypesGen.User;
|
||||
user: TypesGen.User;
|
||||
buildInfo?: TypesGen.BuildInfoResponse;
|
||||
supportLinks?: readonly TypesGen.LinkConfig[];
|
||||
onSignOut: () => void;
|
||||
@@ -60,7 +68,7 @@ export const NavbarView: FC<NavbarViewProps> = ({
|
||||
)}
|
||||
</NavLink>
|
||||
|
||||
<NavItems className="ml-4" />
|
||||
<NavItems className="ml-4" user={user} />
|
||||
|
||||
<div className="flex items-center gap-3 ml-auto">
|
||||
{proxyContextValue && (
|
||||
@@ -109,16 +117,14 @@ export const NavbarView: FC<NavbarViewProps> = ({
|
||||
}
|
||||
/>
|
||||
|
||||
{user && (
|
||||
<div className="hidden md:block">
|
||||
<UserDropdown
|
||||
user={user}
|
||||
buildInfo={buildInfo}
|
||||
supportLinks={supportLinks}
|
||||
onSignOut={onSignOut}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="hidden md:block">
|
||||
<UserDropdown
|
||||
user={user}
|
||||
buildInfo={buildInfo}
|
||||
supportLinks={supportLinks}
|
||||
onSignOut={onSignOut}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:hidden">
|
||||
<MobileMenu
|
||||
@@ -140,11 +146,11 @@ export const NavbarView: FC<NavbarViewProps> = ({
|
||||
|
||||
interface NavItemsProps {
|
||||
className?: string;
|
||||
user: TypesGen.User;
|
||||
}
|
||||
|
||||
const NavItems: FC<NavItemsProps> = ({ className }) => {
|
||||
const NavItems: FC<NavItemsProps> = ({ className, user }) => {
|
||||
const location = useLocation();
|
||||
const { metadata } = useEmbeddedMetadata();
|
||||
|
||||
return (
|
||||
<nav className={cn("flex items-center gap-4 h-full", className)}>
|
||||
@@ -153,7 +159,7 @@ const NavItems: FC<NavItemsProps> = ({ className }) => {
|
||||
if (location.pathname.startsWith("/@")) {
|
||||
isActive = true;
|
||||
}
|
||||
return cn(linkStyles.default, isActive ? linkStyles.active : "");
|
||||
return cn(linkStyles.default, { [linkStyles.active]: isActive });
|
||||
}}
|
||||
to="/workspaces"
|
||||
>
|
||||
@@ -161,22 +167,76 @@ const NavItems: FC<NavItemsProps> = ({ className }) => {
|
||||
</NavLink>
|
||||
<NavLink
|
||||
className={({ isActive }) => {
|
||||
return cn(linkStyles.default, isActive ? linkStyles.active : "");
|
||||
return cn(linkStyles.default, { [linkStyles.active]: isActive });
|
||||
}}
|
||||
to="/templates"
|
||||
>
|
||||
Templates
|
||||
</NavLink>
|
||||
{metadata["tasks-tab-visible"].value && (
|
||||
<NavLink
|
||||
className={({ isActive }) => {
|
||||
return cn(linkStyles.default, isActive ? linkStyles.active : "");
|
||||
}}
|
||||
to="/tasks"
|
||||
>
|
||||
Tasks
|
||||
</NavLink>
|
||||
)}
|
||||
<TasksNavItem user={user} />
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
type TasksNavItemProps = {
|
||||
user: TypesGen.User;
|
||||
};
|
||||
|
||||
const TasksNavItem: FC<TasksNavItemProps> = ({ user }) => {
|
||||
const { metadata } = useEmbeddedMetadata();
|
||||
const canSeeTasks = Boolean(
|
||||
metadata["tasks-tab-visible"].value ||
|
||||
process.env.NODE_ENV === "development" ||
|
||||
process.env.STORYBOOK,
|
||||
);
|
||||
const filter = {
|
||||
username: user.username,
|
||||
};
|
||||
const { data: idleCount } = useQuery({
|
||||
queryKey: ["tasks", filter],
|
||||
queryFn: () => API.experimental.getTasks(filter),
|
||||
refetchInterval: 1_000 * 60,
|
||||
enabled: canSeeTasks,
|
||||
refetchOnWindowFocus: true,
|
||||
initialData: [],
|
||||
select: (data) =>
|
||||
data.filter((task) => task.workspace.latest_app_status?.state === "idle")
|
||||
.length,
|
||||
});
|
||||
|
||||
if (!canSeeTasks) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
to="/tasks"
|
||||
className={({ isActive }) => {
|
||||
return cn(linkStyles.default, { [linkStyles.active]: isActive });
|
||||
}}
|
||||
>
|
||||
Tasks
|
||||
{idleCount > 0 && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="info"
|
||||
size="xs"
|
||||
className="ml-2"
|
||||
aria-label={idleTasksLabel(idleCount)}
|
||||
>
|
||||
{idleCount}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{idleTasksLabel(idleCount)}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
};
|
||||
|
||||
function idleTasksLabel(count: number) {
|
||||
return `You have ${count} ${count === 1 ? "task" : "tasks"} waiting for input`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user