fix: restore office sidebar navigation (#1396)
This commit is contained in:
@@ -14,13 +14,13 @@ const PAGE_TITLES: Record<string, string> = {
|
||||
"/office/workspace/costs": "Costs",
|
||||
"/office/workspace/activity": "Activity",
|
||||
"/office/workspace/routing": "Provider Routing",
|
||||
"/office/workspace/settings": "Settings",
|
||||
"/office/workspace/settings": "Preferences",
|
||||
};
|
||||
|
||||
function resolveTitle(pathname: string): string | null {
|
||||
const exact = PAGE_TITLES[pathname];
|
||||
if (exact) return exact;
|
||||
if (pathname.startsWith("/office/workspace/settings")) return "Settings";
|
||||
if (pathname.startsWith("/office/workspace/settings")) return "Preferences";
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -51,11 +51,7 @@ export function OfficeTopbar() {
|
||||
{detail ? (
|
||||
<div id="office-topbar-slot" className="flex items-center gap-2 flex-1 min-w-0" />
|
||||
) : (
|
||||
title && (
|
||||
<h1 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{title}
|
||||
</h1>
|
||||
)
|
||||
title && <h1 className="truncate text-sm font-medium text-foreground">{title}</h1>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -89,7 +89,7 @@ export function OrgChartCanvas({ agents }: OrgChartCanvasProps) {
|
||||
if (agents.length === 0) return <EmptyOrgState />;
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 min-h-0 overflow-auto">
|
||||
<div className="relative flex-1 min-h-0 overflow-auto p-4 md:p-6">
|
||||
<OrgZoomControls
|
||||
onZoomIn={handleZoomIn}
|
||||
onZoomOut={handleZoomOut}
|
||||
@@ -98,6 +98,7 @@ export function OrgChartCanvas({ agents }: OrgChartCanvasProps) {
|
||||
/>
|
||||
|
||||
<div
|
||||
className="mx-auto"
|
||||
style={{
|
||||
width: canvasW * zoom,
|
||||
height: canvasH * zoom,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/** Section IDs used for both display and persistence keys in the AppSidebar. */
|
||||
export const APP_SIDEBAR_SECTION_IDS = {
|
||||
tasks: "tasks",
|
||||
officeWork: "office-work",
|
||||
officeWorkspace: "office-workspace",
|
||||
projects: "projects",
|
||||
agents: "agents",
|
||||
integrations: "integrations",
|
||||
|
||||
@@ -8,7 +8,13 @@ const mocks = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
const state = {
|
||||
workspaces: { activeId: "ws-1" as string | null },
|
||||
workspaces: {
|
||||
activeId: "kanban-1" as string | null,
|
||||
items: [
|
||||
{ id: "kanban-1", name: "Kanban", office_workflow_id: "" },
|
||||
{ id: "office-1", name: "Office", office_workflow_id: "wf-office" },
|
||||
],
|
||||
},
|
||||
appSidebar: { settingsMode: false },
|
||||
toggleAppSidebarSettingsMode: mocks.toggleSettingsMode,
|
||||
};
|
||||
@@ -65,7 +71,9 @@ function renderFooter() {
|
||||
describe("AppSidebarFooter", () => {
|
||||
beforeEach(() => {
|
||||
officeEnabled = false;
|
||||
state.workspaces.activeId = "kanban-1";
|
||||
state.appSidebar.settingsMode = false;
|
||||
window.localStorage.clear();
|
||||
mocks.routerPush.mockClear();
|
||||
mocks.toggleSettingsMode.mockClear();
|
||||
});
|
||||
@@ -88,7 +96,7 @@ describe("AppSidebarFooter", () => {
|
||||
expect(screen.queryByRole("link", { name: "Office" })).toBeNull();
|
||||
});
|
||||
|
||||
it("navigates from the Stats and Office footer buttons", () => {
|
||||
it("navigates from the Stats and Office footer buttons when kanban is active", () => {
|
||||
officeEnabled = true;
|
||||
|
||||
renderFooter();
|
||||
@@ -97,6 +105,20 @@ describe("AppSidebarFooter", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Office" }));
|
||||
|
||||
expect(mocks.routerPush).toHaveBeenNthCalledWith(1, "/stats");
|
||||
expect(mocks.routerPush).toHaveBeenNthCalledWith(2, "/office");
|
||||
expect(mocks.routerPush).toHaveBeenNthCalledWith(2, "/office?workspaceId=office-1");
|
||||
expect(window.localStorage.getItem("kandev.lastKanbanWorkspaceId")).toBe("kanban-1");
|
||||
});
|
||||
|
||||
it("shows a Kanban button when an office workspace is active", () => {
|
||||
officeEnabled = true;
|
||||
state.workspaces.activeId = "office-1";
|
||||
window.localStorage.setItem("kandev.lastKanbanWorkspaceId", "kanban-1");
|
||||
|
||||
renderFooter();
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Office" })).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Kanban" }));
|
||||
|
||||
expect(mocks.routerPush).toHaveBeenCalledWith("/?workspaceId=kanban-1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
import {
|
||||
IconBuildings,
|
||||
IconChartBar,
|
||||
IconLayoutKanban,
|
||||
IconSettings,
|
||||
IconSparkles,
|
||||
IconStethoscope,
|
||||
@@ -20,6 +21,13 @@ import { useReleaseNotes } from "@/hooks/use-release-notes";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { linkToTask } from "@/lib/links";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
isOfficeWorkspace,
|
||||
rememberLastKanbanWorkspace,
|
||||
resolveFirstOfficeWorkspace,
|
||||
resolveLastKanbanWorkspace,
|
||||
workspaceHomeHref,
|
||||
} from "./app-sidebar-workspace-navigation";
|
||||
|
||||
type AppSidebarFooterProps = {
|
||||
collapsed: boolean;
|
||||
@@ -88,7 +96,13 @@ function FooterIconButton({
|
||||
|
||||
export function AppSidebarFooter({ collapsed }: AppSidebarFooterProps) {
|
||||
const router = useRouter();
|
||||
const workspaceId = useAppStore((s) => s.workspaces.activeId);
|
||||
const workspaces = useAppStore((s) => s.workspaces);
|
||||
const workspaceId = workspaces.activeId;
|
||||
const activeWorkspace = workspaces.items.find((workspace) => workspace.id === workspaceId);
|
||||
const activeIsOffice = isOfficeWorkspace(activeWorkspace);
|
||||
const targetWorkspace = activeIsOffice
|
||||
? resolveLastKanbanWorkspace(workspaces.items)
|
||||
: resolveFirstOfficeWorkspace(workspaces.items);
|
||||
const settingsMode = useAppStore((s) => s.appSidebar.settingsMode);
|
||||
const toggleSettingsMode = useAppStore((s) => s.toggleAppSidebarSettingsMode);
|
||||
const officeEnabled = useFeature("office");
|
||||
@@ -136,11 +150,14 @@ export function AppSidebarFooter({ collapsed }: AppSidebarFooterProps) {
|
||||
)}
|
||||
{officeEnabled && (
|
||||
<FooterIconButton
|
||||
icon={IconBuildings}
|
||||
label="Office"
|
||||
icon={activeIsOffice ? IconLayoutKanban : IconBuildings}
|
||||
label={activeIsOffice ? "Kanban" : "Office"}
|
||||
collapsed={collapsed}
|
||||
onClick={() => router.push("/office")}
|
||||
testId="sidebar-office-button"
|
||||
onClick={() => {
|
||||
if (!activeIsOffice) rememberLastKanbanWorkspace(activeWorkspace);
|
||||
router.push(workspaceHomeHref(targetWorkspace ?? undefined));
|
||||
}}
|
||||
testId={activeIsOffice ? "sidebar-kanban-button" : "sidebar-office-button"}
|
||||
/>
|
||||
)}
|
||||
<ThemeToggle />
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { TooltipProvider } from "@kandev/ui/tooltip";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = {
|
||||
workspaces: {
|
||||
activeId: "kanban-1" as string | null,
|
||||
items: [
|
||||
{ id: "kanban-1", name: "Kanban", office_workflow_id: "" },
|
||||
{ id: "office-1", name: "Office", office_workflow_id: "wf-office" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("@/components/state-provider", () => ({
|
||||
useAppStore: (selector: (s: typeof state) => unknown) => selector(state),
|
||||
}));
|
||||
|
||||
vi.mock("./app-sidebar-workspace-picker", () => ({
|
||||
AppSidebarWorkspacePicker: () => <div data-testid="workspace-picker" />,
|
||||
}));
|
||||
|
||||
import { AppSidebarHeader } from "./app-sidebar-header";
|
||||
|
||||
function renderHeader(collapsed = false) {
|
||||
return render(
|
||||
<TooltipProvider>
|
||||
<AppSidebarHeader collapsed={collapsed} onToggleCollapse={vi.fn()} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("AppSidebarHeader", () => {
|
||||
beforeEach(() => {
|
||||
state.workspaces.activeId = "kanban-1";
|
||||
});
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it("routes the Kandev brand to the active kanban workspace home", () => {
|
||||
renderHeader();
|
||||
|
||||
expect(screen.getByRole("link", { name: "Kandev home" }).getAttribute("href")).toBe(
|
||||
"/?workspaceId=kanban-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("routes the Kandev brand to the active office workspace home", () => {
|
||||
state.workspaces.activeId = "office-1";
|
||||
|
||||
renderHeader();
|
||||
|
||||
expect(screen.getByRole("link", { name: "Kandev home" }).getAttribute("href")).toBe(
|
||||
"/office?workspaceId=office-1",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,9 @@ import Link from "next/link";
|
||||
import { IconLayoutSidebarLeftCollapse, IconLayoutSidebarLeftExpand } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { workspaceHomeHref } from "./app-sidebar-workspace-navigation";
|
||||
import { AppSidebarWorkspacePicker } from "./app-sidebar-workspace-picker";
|
||||
|
||||
type AppSidebarHeaderProps = {
|
||||
@@ -15,6 +17,12 @@ type AppSidebarHeaderProps = {
|
||||
const COLLAPSE_BUTTON_CLASS = "h-7 w-7 shrink-0 cursor-pointer";
|
||||
|
||||
export function AppSidebarHeader({ collapsed, onToggleCollapse }: AppSidebarHeaderProps) {
|
||||
const workspaces = useAppStore((s) => s.workspaces);
|
||||
const activeWorkspace = workspaces.items.find(
|
||||
(workspace) => workspace.id === workspaces.activeId,
|
||||
);
|
||||
const homeHref = workspaceHomeHref(activeWorkspace);
|
||||
|
||||
if (collapsed) {
|
||||
// Minimal rail: brand home + expand. The workspace switcher lives only in
|
||||
// the expanded header — a lone workspace glyph here read as noise.
|
||||
@@ -23,7 +31,7 @@ export function AppSidebarHeader({ collapsed, onToggleCollapse }: AppSidebarHead
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href="/"
|
||||
href={homeHref}
|
||||
aria-label="Kandev home"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-foreground/80 hover:bg-muted/60 cursor-pointer"
|
||||
>
|
||||
@@ -60,7 +68,7 @@ export function AppSidebarHeader({ collapsed, onToggleCollapse }: AppSidebarHead
|
||||
className="flex items-center gap-1.5 h-10 px-3 shrink-0 border-b border-border"
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
href={homeHref}
|
||||
aria-label="Kandev home"
|
||||
className={cn(
|
||||
"shrink-0 cursor-pointer text-sm font-semibold tracking-tight",
|
||||
|
||||
@@ -103,6 +103,28 @@ describe("AppSidebarSection", () => {
|
||||
renderSection({ collapsed: true, grow: true });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Tasks" }));
|
||||
expect(storeState.setAppSidebarCollapsed).toHaveBeenCalledWith(false);
|
||||
expect(storeState.toggleAppSidebarSection).toHaveBeenCalledWith("tasks");
|
||||
expect(storeState.toggleAppSidebarSection).toHaveBeenCalledWith("tasks", false);
|
||||
});
|
||||
|
||||
it("collapses a default-expanded section on the first click when state is missing", () => {
|
||||
storeState.appSidebar.sectionExpanded = {};
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<AppSidebarSection
|
||||
id="office-work"
|
||||
label="Work"
|
||||
icon={IconCircleDot}
|
||||
collapsed={false}
|
||||
grow
|
||||
defaultExpanded
|
||||
>
|
||||
<div data-testid={CHILDREN_TESTID}>children</div>
|
||||
</AppSidebarSection>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Work" }));
|
||||
|
||||
expect(storeState.toggleAppSidebarSection).toHaveBeenCalledWith("office-work", true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,8 @@ type AppSidebarSectionProps = {
|
||||
headerActionVisibility?: "expanded" | "always";
|
||||
/** Fills remaining sidebar height when expanded. Parent must be a flex column. */
|
||||
grow?: boolean;
|
||||
/** Initial expansion when the persisted section map does not yet contain this id. */
|
||||
defaultExpanded?: boolean;
|
||||
};
|
||||
|
||||
type SectionHeaderProps = {
|
||||
@@ -75,8 +77,9 @@ export function AppSidebarSection({
|
||||
headerAction,
|
||||
headerActionVisibility = "expanded",
|
||||
grow,
|
||||
defaultExpanded = false,
|
||||
}: AppSidebarSectionProps) {
|
||||
const expanded = useAppStore((s) => s.appSidebar.sectionExpanded[id] ?? false);
|
||||
const expanded = useAppStore((s) => s.appSidebar.sectionExpanded[id] ?? defaultExpanded);
|
||||
const toggleSection = useAppStore((s) => s.toggleAppSidebarSection);
|
||||
const setCollapsed = useAppStore((s) => s.setAppSidebarCollapsed);
|
||||
|
||||
@@ -88,7 +91,7 @@ export function AppSidebarSection({
|
||||
className="flex h-9 w-9 mx-auto items-center justify-center rounded-md text-foreground/70 hover:bg-muted/60 cursor-pointer"
|
||||
onClick={() => {
|
||||
setCollapsed(false);
|
||||
if (!expanded) toggleSection(id);
|
||||
if (!expanded) toggleSection(id, defaultExpanded);
|
||||
}}
|
||||
aria-label={label}
|
||||
>
|
||||
@@ -101,7 +104,7 @@ export function AppSidebarSection({
|
||||
|
||||
if (collapsed && !grow) return railButton;
|
||||
|
||||
const handleToggle = () => toggleSection(id);
|
||||
const handleToggle = () => toggleSection(id, defaultExpanded);
|
||||
|
||||
// The grow section (Tasks) absorbs remaining vertical space and scrolls
|
||||
// internally, so it stays flex-driven rather than animating to content
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
LAST_KANBAN_WORKSPACE_KEY,
|
||||
rememberLastKanbanWorkspace,
|
||||
resolveFirstOfficeWorkspace,
|
||||
resolveLastKanbanWorkspace,
|
||||
workspaceHomeHref,
|
||||
} from "./app-sidebar-workspace-navigation";
|
||||
|
||||
const kanban = { id: "kanban-1", office_workflow_id: "" };
|
||||
const kanbanTwo = { id: "kanban-2", office_workflow_id: null };
|
||||
const office = { id: "office-1", office_workflow_id: "wf-office" };
|
||||
|
||||
describe("app sidebar workspace navigation", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it("routes workspace home by active workspace type", () => {
|
||||
expect(workspaceHomeHref(kanban)).toBe("/?workspaceId=kanban-1");
|
||||
expect(workspaceHomeHref(office)).toBe("/office?workspaceId=office-1");
|
||||
expect(workspaceHomeHref(undefined)).toBe("/");
|
||||
});
|
||||
|
||||
it("remembers and resolves the last kanban workspace", () => {
|
||||
rememberLastKanbanWorkspace(kanbanTwo);
|
||||
|
||||
expect(window.localStorage.getItem(LAST_KANBAN_WORKSPACE_KEY)).toBe("kanban-2");
|
||||
expect(resolveLastKanbanWorkspace([kanban, office, kanbanTwo])).toBe(kanbanTwo);
|
||||
});
|
||||
|
||||
it("does not overwrite the last kanban workspace with office workspaces", () => {
|
||||
rememberLastKanbanWorkspace(kanban);
|
||||
rememberLastKanbanWorkspace(office);
|
||||
|
||||
expect(resolveLastKanbanWorkspace([kanban, office])).toBe(kanban);
|
||||
});
|
||||
|
||||
it("falls back to the first kanban workspace and first office workspace", () => {
|
||||
expect(resolveLastKanbanWorkspace([office, kanban, kanbanTwo])).toBe(kanban);
|
||||
expect(resolveFirstOfficeWorkspace([kanban, office, kanbanTwo])).toBe(office);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
export type SidebarWorkspace = {
|
||||
id: string;
|
||||
office_workflow_id?: string | null;
|
||||
};
|
||||
|
||||
export const LAST_KANBAN_WORKSPACE_KEY = "kandev.lastKanbanWorkspaceId";
|
||||
|
||||
export function isOfficeWorkspace(workspace: SidebarWorkspace | undefined): boolean {
|
||||
return Boolean(workspace?.office_workflow_id);
|
||||
}
|
||||
|
||||
export function workspaceHomeHref(workspace: SidebarWorkspace | undefined): string {
|
||||
if (!workspace) return "/";
|
||||
const path = isOfficeWorkspace(workspace) ? "/office" : "/";
|
||||
return `${path}?workspaceId=${workspace.id}`;
|
||||
}
|
||||
|
||||
export function rememberLastKanbanWorkspace(workspace: SidebarWorkspace | undefined): void {
|
||||
if (!workspace || isOfficeWorkspace(workspace) || typeof window === "undefined") return;
|
||||
window.localStorage.setItem(LAST_KANBAN_WORKSPACE_KEY, workspace.id);
|
||||
}
|
||||
|
||||
export function resolveLastKanbanWorkspace(
|
||||
workspaces: SidebarWorkspace[],
|
||||
): SidebarWorkspace | null {
|
||||
const kanbanWorkspaces = workspaces.filter((workspace) => !isOfficeWorkspace(workspace));
|
||||
if (kanbanWorkspaces.length === 0) return null;
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
const storedId = window.localStorage.getItem(LAST_KANBAN_WORKSPACE_KEY);
|
||||
const stored = kanbanWorkspaces.find((workspace) => workspace.id === storedId);
|
||||
if (stored) return stored;
|
||||
}
|
||||
|
||||
return kanbanWorkspaces[0] ?? null;
|
||||
}
|
||||
|
||||
export function resolveFirstOfficeWorkspace(
|
||||
workspaces: SidebarWorkspace[],
|
||||
): SidebarWorkspace | null {
|
||||
return workspaces.find(isOfficeWorkspace) ?? null;
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useFeature } from "@/hooks/domains/features/use-feature";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { rememberLastKanbanWorkspace } from "./app-sidebar-workspace-navigation";
|
||||
|
||||
/**
|
||||
* Compact, secondary workspace switcher inlined after the Kandev brand in the
|
||||
@@ -93,6 +94,7 @@ export function AppSidebarWorkspacePicker() {
|
||||
return;
|
||||
}
|
||||
document.cookie = `office-active-workspace=${id}; path=/; max-age=86400; samesite=strict; secure`;
|
||||
rememberLastKanbanWorkspace(workspace);
|
||||
setActiveWorkspace(id);
|
||||
if (officeEnabled) {
|
||||
const target = workspaceType(workspace) === "office" ? "/office" : "/";
|
||||
|
||||
@@ -5,6 +5,9 @@ import { APP_SIDEBAR_EXPANDED_WIDTH } from "./app-sidebar-constants";
|
||||
const navigationMock = vi.hoisted(() => ({
|
||||
pathname: "/",
|
||||
}));
|
||||
const officeRouteMock = vi.hoisted(() => ({
|
||||
inOffice: false,
|
||||
}));
|
||||
|
||||
// The AppSidebar pulls in a lot of children that touch the dockview / kanban
|
||||
// data layer. For unit testing the collapse + section toggle behaviour we stub
|
||||
@@ -48,6 +51,11 @@ vi.mock("./sections/agents-section", () => ({
|
||||
vi.mock("./sections/integrations-section", () => ({
|
||||
IntegrationsSection: () => <div data-testid="integrations-section" />,
|
||||
}));
|
||||
vi.mock("./sections/office-navigation-section", () => ({
|
||||
OfficeNavigationSection: ({ section }: { section?: "all" | "work" | "office" }) => (
|
||||
<div data-testid={`office-navigation-section-${section ?? "all"}`} />
|
||||
),
|
||||
}));
|
||||
vi.mock("./app-sidebar-footer", () => ({
|
||||
AppSidebarFooter: () => <div data-testid="footer" />,
|
||||
}));
|
||||
@@ -59,11 +67,17 @@ vi.mock("next/navigation", () => ({
|
||||
usePathname: () => navigationMock.pathname,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-in-office", () => ({
|
||||
useInOffice: () => officeRouteMock.inOffice,
|
||||
}));
|
||||
|
||||
const storeState = {
|
||||
appSidebar: {
|
||||
collapsed: false,
|
||||
sectionExpanded: {
|
||||
tasks: true,
|
||||
"office-work": true,
|
||||
"office-workspace": true,
|
||||
projects: false,
|
||||
agents: false,
|
||||
integrations: false,
|
||||
@@ -88,6 +102,7 @@ import { AppSidebar } from "./app-sidebar";
|
||||
describe("AppSidebar", () => {
|
||||
beforeEach(() => {
|
||||
navigationMock.pathname = "/";
|
||||
officeRouteMock.inOffice = false;
|
||||
storeState.appSidebar.collapsed = false;
|
||||
storeState.appSidebar.settingsMode = false;
|
||||
storeState.toggleAppSidebar = vi.fn();
|
||||
@@ -108,6 +123,38 @@ describe("AppSidebar", () => {
|
||||
expect(screen.queryByTestId("settings-section")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders office navigation without kanban-only sections in office mode", () => {
|
||||
officeRouteMock.inOffice = true;
|
||||
navigationMock.pathname = "/office";
|
||||
|
||||
render(<AppSidebar />);
|
||||
|
||||
expect(screen.getByTestId("office-navigation-section-work")).toBeTruthy();
|
||||
expect(screen.getByTestId("office-navigation-section-office")).toBeTruthy();
|
||||
expect(screen.queryByTestId("tasks-section")).toBeNull();
|
||||
expect(screen.queryByTestId("integrations-section")).toBeNull();
|
||||
});
|
||||
|
||||
it("orders office navigation sections around entity groups", () => {
|
||||
officeRouteMock.inOffice = true;
|
||||
navigationMock.pathname = "/office";
|
||||
|
||||
render(<AppSidebar />);
|
||||
|
||||
const nav = screen.getByRole("navigation");
|
||||
expect(
|
||||
Array.from(nav.querySelectorAll("[data-testid]")).map((node) =>
|
||||
node.getAttribute("data-testid"),
|
||||
),
|
||||
).toEqual([
|
||||
"primary-nav",
|
||||
"office-navigation-section-work",
|
||||
"projects-section",
|
||||
"agents-section",
|
||||
"office-navigation-section-office",
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders collapsed when store reports collapsed=true", () => {
|
||||
storeState.appSidebar.collapsed = true;
|
||||
render(<AppSidebar />);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useInOffice } from "@/hooks/use-in-office";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
APP_SIDEBAR_COLLAPSED_WIDTH,
|
||||
@@ -16,13 +17,22 @@ import { AppSidebarResizeHandle } from "./app-sidebar-resize-handle";
|
||||
import { AppSidebarSettingsMode } from "./app-sidebar-settings-mode";
|
||||
import { AgentsSection } from "./sections/agents-section";
|
||||
import { IntegrationsSection } from "./sections/integrations-section";
|
||||
import { OfficeNavigationSection } from "./sections/office-navigation-section";
|
||||
import { ProjectsSection } from "./sections/projects-section";
|
||||
import { TasksSection } from "./sections/tasks-section";
|
||||
|
||||
const SECTION_ROUTE_MAP: Array<{ id: string; matches: (path: string) => boolean }> = [
|
||||
{
|
||||
id: APP_SIDEBAR_SECTION_IDS.tasks,
|
||||
matches: (p) => p.startsWith("/office/tasks") || p.startsWith("/t/"),
|
||||
matches: (p) => p.startsWith("/t/"),
|
||||
},
|
||||
{
|
||||
id: APP_SIDEBAR_SECTION_IDS.officeWork,
|
||||
matches: (p) => p.startsWith("/office/tasks") || p.startsWith("/office/routines"),
|
||||
},
|
||||
{
|
||||
id: APP_SIDEBAR_SECTION_IDS.officeWorkspace,
|
||||
matches: (p) => p.startsWith("/office/workspace"),
|
||||
},
|
||||
{ id: APP_SIDEBAR_SECTION_IDS.projects, matches: (p) => p.startsWith("/office/projects") },
|
||||
{ id: APP_SIDEBAR_SECTION_IDS.agents, matches: (p) => p.startsWith("/office/agents") },
|
||||
@@ -50,6 +60,7 @@ export function AppSidebar() {
|
||||
const toggleSettingsMode = useAppStore((s) => s.toggleAppSidebarSettingsMode);
|
||||
const setWidth = useAppStore((s) => s.setAppSidebarWidth);
|
||||
const pathname = usePathname();
|
||||
const inOffice = useInOffice();
|
||||
|
||||
const handleResize = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
@@ -134,15 +145,17 @@ export function AppSidebar() {
|
||||
<>
|
||||
<div className="shrink-0 flex flex-col gap-2 overflow-y-auto">
|
||||
<AppSidebarPrimaryNav collapsed={collapsed} />
|
||||
{inOffice && <OfficeNavigationSection collapsed={collapsed} section="work" />}
|
||||
<ProjectsSection collapsed={collapsed} />
|
||||
<AgentsSection collapsed={collapsed} />
|
||||
<IntegrationsSection collapsed={collapsed} />
|
||||
{inOffice && <OfficeNavigationSection collapsed={collapsed} section="office" />}
|
||||
{!inOffice && <IntegrationsSection collapsed={collapsed} />}
|
||||
</div>
|
||||
{/* Tasks is the flex-grow middle section so it absorbs remaining
|
||||
vertical space and scrolls internally. Settings is intentionally
|
||||
not a nav section — it's reached only via the footer gear, which
|
||||
takes the whole sidebar over with the settings tree. */}
|
||||
<TasksSection collapsed={collapsed} />
|
||||
{/* In regular kanban mode, Tasks is the flex-grow middle section so
|
||||
it absorbs remaining vertical space and scrolls internally.
|
||||
Office has a dedicated /office/tasks page, so the sidebar only
|
||||
renders a lightweight Tasks nav row above. */}
|
||||
{!inOffice && <TasksSection collapsed={collapsed} />}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { cleanup, render, screen, within } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const routerMock = vi.hoisted(() => ({
|
||||
push: vi.fn(),
|
||||
}));
|
||||
|
||||
const state = {
|
||||
appSidebar: {
|
||||
sectionExpanded: {
|
||||
agents: true,
|
||||
} as Record<string, boolean>,
|
||||
},
|
||||
office: {
|
||||
agentProfiles: [],
|
||||
inboxItems: [],
|
||||
},
|
||||
workspaces: {
|
||||
activeId: "workspace-1",
|
||||
},
|
||||
setOfficeAgentProfiles: vi.fn(),
|
||||
toggleAppSidebarSection: vi.fn(),
|
||||
setAppSidebarCollapsed: vi.fn(),
|
||||
sessions: {
|
||||
byId: {},
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/office",
|
||||
useRouter: () => routerMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-in-office", () => ({
|
||||
useInOffice: () => true,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-office-refetch", () => ({
|
||||
useOfficeRefetch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/domains/office-api", () => ({
|
||||
listAgentProfiles: vi.fn(() => Promise.resolve({ agents: [] })),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/state-provider", () => ({
|
||||
useAppStore: (selector: (s: typeof state) => unknown) => selector(state),
|
||||
}));
|
||||
|
||||
vi.mock("@kandev/ui/collapsible", () => ({
|
||||
Collapsible: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
CollapsibleContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@kandev/ui/tooltip", () => ({
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
TooltipContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
import { AgentsSection } from "./agents-section";
|
||||
|
||||
describe("AgentsSection", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders Agent Topology as the header action before Add agent", () => {
|
||||
render(<AgentsSection collapsed={false} />);
|
||||
|
||||
const agentsHeader = screen.getByRole("button", { name: "Agents" }).closest(".group\\/section");
|
||||
expect(agentsHeader).toBeTruthy();
|
||||
|
||||
const topology = within(agentsHeader as HTMLElement).getByRole("link", {
|
||||
name: "Agent topology",
|
||||
});
|
||||
const addAgent = within(agentsHeader as HTMLElement).getByRole("button", {
|
||||
name: "Add agent",
|
||||
});
|
||||
|
||||
expect(topology.getAttribute("href")).toBe("/office/workspace/org");
|
||||
expect(topology.compareDocumentPosition(addAgent) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { IconPlus, IconRobot } from "@tabler/icons-react";
|
||||
import { IconPlus, IconRobot, IconSitemap } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
@@ -49,20 +49,38 @@ export function AgentsSection({ collapsed }: AgentsSectionProps) {
|
||||
if (!inOffice) return null;
|
||||
|
||||
const headerAction = (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 cursor-pointer"
|
||||
aria-label="Add agent"
|
||||
onClick={() => router.push("/office/agents")}
|
||||
>
|
||||
<IconPlus className="h-3 w-3 text-muted-foreground/60" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Add agent</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 cursor-pointer"
|
||||
aria-label="Agent topology"
|
||||
>
|
||||
<Link href="/office/workspace/org">
|
||||
<IconSitemap className="h-3 w-3 text-muted-foreground/60" />
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Agent topology</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 cursor-pointer"
|
||||
aria-label="Add agent"
|
||||
onClick={() => router.push("/office/agents")}
|
||||
>
|
||||
<IconPlus className="h-3 w-3 text-muted-foreground/60" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Add agent</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -72,6 +90,8 @@ export function AgentsSection({ collapsed }: AgentsSectionProps) {
|
||||
collapsed={collapsed}
|
||||
icon={IconRobot}
|
||||
headerAction={headerAction}
|
||||
headerActionVisibility="always"
|
||||
defaultExpanded
|
||||
>
|
||||
{agents.length === 0 ? (
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground">No agents yet</p>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let pathname = "/office";
|
||||
|
||||
const state = {
|
||||
appSidebar: {
|
||||
sectionExpanded: {
|
||||
"office-work": true,
|
||||
"office-workspace": true,
|
||||
} as Record<string, boolean>,
|
||||
},
|
||||
office: {
|
||||
dashboard: {
|
||||
task_count: 7,
|
||||
routine_count: 2,
|
||||
skill_count: 3,
|
||||
},
|
||||
},
|
||||
toggleAppSidebarSection: vi.fn(),
|
||||
setAppSidebarCollapsed: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("@/components/state-provider", () => ({
|
||||
useAppStore: (selector: (s: typeof state) => unknown) => selector(state),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => pathname,
|
||||
}));
|
||||
|
||||
vi.mock("@kandev/ui/collapsible", () => ({
|
||||
Collapsible: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
CollapsibleContent: ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => <div className={className}>{children}</div>,
|
||||
}));
|
||||
|
||||
import { OfficeNavigationSection } from "./office-navigation-section";
|
||||
|
||||
function hrefFor(label: string) {
|
||||
return screen.getByRole("link", { name: new RegExp(label, "i") }).getAttribute("href");
|
||||
}
|
||||
|
||||
describe("OfficeNavigationSection", () => {
|
||||
beforeEach(() => {
|
||||
pathname = "/office";
|
||||
state.appSidebar.sectionExpanded = {
|
||||
"office-work": true,
|
||||
"office-workspace": true,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("restores links for the dedicated office pages", () => {
|
||||
render(<OfficeNavigationSection collapsed={false} />);
|
||||
|
||||
expect(hrefFor("Tasks")).toBe("/office/tasks");
|
||||
expect(hrefFor("Routines")).toBe("/office/routines");
|
||||
expect(hrefFor("Skills")).toBe("/office/workspace/skills");
|
||||
expect(hrefFor("Costs")).toBe("/office/workspace/costs");
|
||||
expect(hrefFor("Activity")).toBe("/office/workspace/activity");
|
||||
expect(hrefFor("Routing")).toBe("/office/workspace/routing");
|
||||
expect(hrefFor("Preferences")).toBe("/office/workspace/settings");
|
||||
expect(screen.queryByRole("link", { name: /Agent Topology/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("uses expanded defaults when old persisted sidebar state lacks new office keys", () => {
|
||||
state.appSidebar.sectionExpanded = {};
|
||||
|
||||
render(<OfficeNavigationSection collapsed={false} />);
|
||||
|
||||
expect(screen.getByRole("link", { name: /Tasks/i })).toBeTruthy();
|
||||
expect(screen.getByRole("link", { name: /Preferences/i })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
IconBoxMultiple,
|
||||
IconCircleDot,
|
||||
IconCurrencyDollar,
|
||||
IconHistory,
|
||||
IconRepeat,
|
||||
IconRoute,
|
||||
IconSettings,
|
||||
} from "@tabler/icons-react";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { APP_SIDEBAR_SECTION_IDS } from "../app-sidebar-constants";
|
||||
import { AppSidebarNavItem } from "../app-sidebar-nav-item";
|
||||
import { AppSidebarSection } from "../app-sidebar-section";
|
||||
|
||||
type OfficeNavigationSectionProps = {
|
||||
collapsed: boolean;
|
||||
section?: "all" | "work" | "office";
|
||||
};
|
||||
|
||||
const workItems = [
|
||||
{ icon: IconCircleDot, label: "Tasks", href: "/office/tasks" },
|
||||
{ icon: IconRepeat, label: "Routines", href: "/office/routines" },
|
||||
] as const;
|
||||
|
||||
const workspaceItems = [
|
||||
{ icon: IconBoxMultiple, label: "Skills", href: "/office/workspace/skills" },
|
||||
{ icon: IconCurrencyDollar, label: "Costs", href: "/office/workspace/costs" },
|
||||
{ icon: IconHistory, label: "Activity", href: "/office/workspace/activity" },
|
||||
{ icon: IconRoute, label: "Routing", href: "/office/workspace/routing" },
|
||||
{ icon: IconSettings, label: "Preferences", href: "/office/workspace/settings" },
|
||||
] as const;
|
||||
|
||||
export function OfficeNavigationSection({
|
||||
collapsed,
|
||||
section = "all",
|
||||
}: OfficeNavigationSectionProps) {
|
||||
const dashboard = useAppStore((s) => s.office.dashboard);
|
||||
const taskCount = dashboard?.task_count ?? 0;
|
||||
const routineCount = dashboard?.routine_count ?? 0;
|
||||
const skillCount = dashboard?.skill_count ?? 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{(section === "all" || section === "work") && (
|
||||
<AppSidebarSection
|
||||
id={APP_SIDEBAR_SECTION_IDS.officeWork}
|
||||
label="Work"
|
||||
collapsed={collapsed}
|
||||
icon={IconCircleDot}
|
||||
defaultExpanded
|
||||
>
|
||||
{workItems.map((item) => (
|
||||
<AppSidebarNavItem
|
||||
key={item.href}
|
||||
icon={item.icon}
|
||||
label={item.label}
|
||||
href={item.href}
|
||||
badge={getWorkBadge(item.href, taskCount, routineCount)}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
))}
|
||||
</AppSidebarSection>
|
||||
)}
|
||||
{(section === "all" || section === "office") && (
|
||||
<AppSidebarSection
|
||||
id={APP_SIDEBAR_SECTION_IDS.officeWorkspace}
|
||||
label="Office"
|
||||
collapsed={collapsed}
|
||||
icon={IconSettings}
|
||||
defaultExpanded
|
||||
>
|
||||
{workspaceItems.map((item) => (
|
||||
<AppSidebarNavItem
|
||||
key={item.href}
|
||||
icon={item.icon}
|
||||
label={item.label}
|
||||
href={item.href}
|
||||
badge={getWorkspaceBadge(item.href, skillCount)}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
))}
|
||||
</AppSidebarSection>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getWorkBadge(
|
||||
href: (typeof workItems)[number]["href"],
|
||||
taskCount: number,
|
||||
routineCount: number,
|
||||
): number | undefined {
|
||||
if (href === "/office/tasks" && taskCount > 0) return taskCount;
|
||||
if (href === "/office/routines" && routineCount > 0) return routineCount;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getWorkspaceBadge(
|
||||
href: (typeof workspaceItems)[number]["href"],
|
||||
skillCount: number,
|
||||
): number | undefined {
|
||||
if (href === "/office/workspace/skills" && skillCount > 0) return skillCount;
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { cleanup, render, screen, within } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const routerMock = vi.hoisted(() => ({
|
||||
push: vi.fn(),
|
||||
}));
|
||||
|
||||
const state = {
|
||||
appSidebar: {
|
||||
sectionExpanded: {
|
||||
projects: false,
|
||||
} as Record<string, boolean>,
|
||||
},
|
||||
office: {
|
||||
projects: [],
|
||||
},
|
||||
toggleAppSidebarSection: vi.fn(),
|
||||
setAppSidebarCollapsed: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => routerMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-in-office", () => ({
|
||||
useInOffice: () => true,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/state-provider", () => ({
|
||||
useAppStore: (selector: (s: typeof state) => unknown) => selector(state),
|
||||
}));
|
||||
|
||||
vi.mock("@kandev/ui/collapsible", () => ({
|
||||
Collapsible: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
CollapsibleContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@kandev/ui/tooltip", () => ({
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
TooltipContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
import { ProjectsSection } from "./projects-section";
|
||||
|
||||
describe("ProjectsSection", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("keeps the add-project action visible when the section is collapsed", () => {
|
||||
render(<ProjectsSection collapsed={false} />);
|
||||
|
||||
const projectsHeader = screen
|
||||
.getByRole("button", { name: "Projects" })
|
||||
.closest(".group\\/section");
|
||||
expect(projectsHeader).toBeTruthy();
|
||||
|
||||
within(projectsHeader as HTMLElement).getByRole("button", { name: "Add project" });
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,7 @@ export function ProjectsSection({ collapsed }: ProjectsSectionProps) {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 cursor-pointer"
|
||||
aria-label="Add project"
|
||||
onClick={() => router.push("/office/projects")}
|
||||
>
|
||||
<IconPlus className="h-3 w-3 text-muted-foreground/60" />
|
||||
@@ -46,6 +47,8 @@ export function ProjectsSection({ collapsed }: ProjectsSectionProps) {
|
||||
collapsed={collapsed}
|
||||
icon={IconBoxMultiple}
|
||||
headerAction={headerAction}
|
||||
headerActionVisibility="always"
|
||||
defaultExpanded
|
||||
>
|
||||
{activeProjects.length === 0 ? (
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground">No projects yet</p>
|
||||
|
||||
@@ -31,9 +31,9 @@ test.describe("Config Sync", () => {
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
test("settings page renders", async ({ testPage, officeSeed: _ }) => {
|
||||
test("preferences page renders", async ({ testPage, officeSeed: _ }) => {
|
||||
await testPage.goto("/office/workspace/settings");
|
||||
await expect(testPage.getByRole("heading", { name: /settings/i })).toBeVisible({
|
||||
await expect(testPage.getByRole("heading", { name: /preferences/i })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,9 +56,9 @@ test.describe("Dashboard", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("settings page renders", async ({ testPage, officeSeed: _ }) => {
|
||||
test("preferences page renders", async ({ testPage, officeSeed: _ }) => {
|
||||
await testPage.goto("/office/workspace/settings");
|
||||
await expect(testPage.getByRole("heading", { name: /settings/i })).toBeVisible({
|
||||
await expect(testPage.getByRole("heading", { name: /preferences/i })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,17 +5,10 @@ test.describe("Sidebar navigation", () => {
|
||||
test("sidebar shows CEO agent link", async ({ testPage, officeSeed: _ }) => {
|
||||
await testPage.goto("/office");
|
||||
await expect(testPage.getByText("Agents Enabled")).toBeVisible({ timeout: 10_000 });
|
||||
// Post-overhaul: the office Agents section lives in the unified AppSidebar
|
||||
// (`<aside data-testid="app-sidebar">`) inside a COLLAPSIBLE section that
|
||||
// defaults to collapsed on the `/office` dashboard (SECTION_ROUTE_MAP only
|
||||
// auto-expands it on `/office/agents`). Expand it first, then assert the
|
||||
// row. Each agent row is a single `<Link href="/office/agents/<id>">` whose
|
||||
// Each agent row is a single `<Link href="/office/agents/<id>">` whose
|
||||
// accessible name is the agent name (the avatar is aria-hidden). The
|
||||
// sidebar agent list hydrates from a client-side fetch after first paint —
|
||||
// 10s gives that hydration headroom on a heavily-loaded run without
|
||||
// affecting the happy path (<1s in isolation).
|
||||
// sidebar agent list hydrates from a client-side fetch after first paint.
|
||||
const sidebar = new AppSidebarPage(testPage);
|
||||
await sidebar.expandSection("Agents");
|
||||
await expect(sidebar.root.getByRole("link", { name: /CEO/i }).first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
@@ -24,7 +17,33 @@ test.describe("Sidebar navigation", () => {
|
||||
test("sidebar shows tasks link", async ({ testPage, officeSeed: _ }) => {
|
||||
await testPage.goto("/office");
|
||||
await expect(testPage.getByText("Agents Enabled")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(testPage.getByRole("link", { name: /Tasks/i }).first()).toBeVisible();
|
||||
const sidebar = new AppSidebarPage(testPage);
|
||||
await expect(sidebar.root.getByRole("link", { name: /Tasks/i })).toBeVisible();
|
||||
await expect(sidebar.root.getByText("No tasks yet.")).toHaveCount(0);
|
||||
await expect(sidebar.root.getByRole("button", { name: "Integrations" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("sidebar shows office workspace pages", async ({ testPage, officeSeed: _ }) => {
|
||||
await testPage.goto("/office");
|
||||
await expect(testPage.getByText("Agents Enabled")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const sidebar = new AppSidebarPage(testPage);
|
||||
await expect(sidebar.root.getByRole("link", { name: "Preferences" })).toHaveAttribute(
|
||||
"href",
|
||||
"/office/workspace/settings",
|
||||
);
|
||||
await expect(sidebar.root.getByRole("link", { name: "Skills" })).toHaveAttribute(
|
||||
"href",
|
||||
"/office/workspace/skills",
|
||||
);
|
||||
await expect(sidebar.root.getByRole("link", { name: "Agent topology" })).toHaveAttribute(
|
||||
"href",
|
||||
"/office/workspace/org",
|
||||
);
|
||||
await expect(sidebar.root.getByRole("link", { name: "Costs" })).toHaveAttribute(
|
||||
"href",
|
||||
"/office/workspace/costs",
|
||||
);
|
||||
});
|
||||
|
||||
test("navigate to agents page via sidebar", async ({ testPage, officeSeed: _ }) => {
|
||||
@@ -40,11 +59,8 @@ test.describe("Sidebar navigation", () => {
|
||||
test("navigate to tasks page via dashboard card", async ({ testPage, officeSeed: _ }) => {
|
||||
await testPage.goto("/office");
|
||||
await expect(testPage.getByText("Agents Enabled")).toBeVisible({ timeout: 10_000 });
|
||||
// Post-overhaul: the sidebar "Tasks" entry is a collapsible section header
|
||||
// (a toggle button), not a navigation link — there is no longer an
|
||||
// in-sidebar link to the /office/tasks page. Navigate via the dashboard
|
||||
// "Tasks In Progress" metric card link instead (mirrors the sibling
|
||||
// "navigate to agents page via sidebar" test, which uses "Agents Enabled").
|
||||
// Keep dashboard-card navigation covered separately from the sidebar Tasks
|
||||
// link asserted above; both are intentional entry points to /office/tasks.
|
||||
await testPage.getByRole("link", { name: /Tasks In Progress/i }).click();
|
||||
// Scope the heading assertion to the page content (`<main>` in the office
|
||||
// layout). The unified AppSidebar's collapsible "Tasks" section header also
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { test, expect } from "../../fixtures/office-fixture";
|
||||
import { AppSidebarPage } from "../../pages/app-sidebar-page";
|
||||
|
||||
async function sectionTop(sidebar: AppSidebarPage, label: string): Promise<number> {
|
||||
const box = await sidebar.root.getByRole("button", { name: label, exact: true }).boundingBox();
|
||||
if (!box) throw new Error(`Missing sidebar section header: ${label}`);
|
||||
return box.y;
|
||||
}
|
||||
|
||||
test.describe("Sidebar workspace mode navigation", () => {
|
||||
test("routes brand and footer toggle by active workspace type", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
officeSeed,
|
||||
}) => {
|
||||
const kanbanWorkspace = await apiClient.createWorkspace("Sidebar Mode Kanban Workspace");
|
||||
const sidebar = new AppSidebarPage(testPage);
|
||||
|
||||
await testPage.goto("/");
|
||||
await testPage.getByTestId("sidebar-workspace-trigger").click();
|
||||
await testPage.getByTestId(`sidebar-workspace-item-${kanbanWorkspace.id}`).click();
|
||||
await expect(testPage).toHaveURL(
|
||||
(url) => url.pathname === "/" && url.searchParams.get("workspaceId") === kanbanWorkspace.id,
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
|
||||
await expect(sidebar.root.getByRole("link", { name: "Kandev home" })).toHaveAttribute(
|
||||
"href",
|
||||
`/?workspaceId=${kanbanWorkspace.id}`,
|
||||
);
|
||||
await expect(sidebar.root.getByRole("button", { name: "Office" })).toBeVisible();
|
||||
await sidebar.root.getByRole("button", { name: "Office" }).click();
|
||||
|
||||
await expect(testPage).toHaveURL(
|
||||
(url) =>
|
||||
url.pathname === "/office" &&
|
||||
url.searchParams.get("workspaceId") === officeSeed.workspaceId,
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
await expect(testPage.getByText("Agents Enabled")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(sidebar.root.getByRole("link", { name: "Kandev home" })).toHaveAttribute(
|
||||
"href",
|
||||
`/office?workspaceId=${officeSeed.workspaceId}`,
|
||||
);
|
||||
await expect(sidebar.root.getByRole("button", { name: "Kanban" })).toBeVisible();
|
||||
await sidebar.root.getByRole("button", { name: "Kanban" }).click();
|
||||
|
||||
await expect(testPage).toHaveURL(
|
||||
(url) => url.pathname === "/" && url.searchParams.get("workspaceId") === kanbanWorkspace.id,
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
await expect(sidebar.root.getByRole("link", { name: "Kandev home" })).toHaveAttribute(
|
||||
"href",
|
||||
`/?workspaceId=${kanbanWorkspace.id}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("orders office groups and keeps collapsed group actions visible", async ({
|
||||
testPage,
|
||||
officeSeed: _,
|
||||
}) => {
|
||||
await testPage.goto("/office");
|
||||
await expect(testPage.getByText("Agents Enabled")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const sidebar = new AppSidebarPage(testPage);
|
||||
const orderedTops = await Promise.all(
|
||||
["Work", "Projects", "Agents", "Office"].map((label) => sectionTop(sidebar, label)),
|
||||
);
|
||||
expect(orderedTops).toEqual([...orderedTops].sort((a, b) => a - b));
|
||||
|
||||
const projectsHeader = sidebar.root.getByRole("button", { name: "Projects", exact: true });
|
||||
await expect(projectsHeader).toHaveAttribute("aria-expanded", "true");
|
||||
await projectsHeader.click();
|
||||
await expect(projectsHeader).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(sidebar.root.getByRole("button", { name: "Add project" })).toBeVisible();
|
||||
|
||||
const agentsHeader = sidebar.root.getByRole("button", { name: "Agents", exact: true });
|
||||
await expect(agentsHeader).toHaveAttribute("aria-expanded", "true");
|
||||
await agentsHeader.click();
|
||||
await expect(agentsHeader).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(sidebar.root.getByRole("link", { name: "Agent topology" })).toBeVisible();
|
||||
await expect(sidebar.root.getByRole("button", { name: "Add agent" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -10,13 +10,14 @@ import {
|
||||
import { APP_SIDEBAR_EXPANDED_WIDTH } from "@/components/app-sidebar/app-sidebar-constants";
|
||||
import type { AppSidebarState, UISlice } from "./types";
|
||||
|
||||
/** Tasks expanded by default; other sections collapsed. Mirrors the
|
||||
* "open question / risks" note in the spec: keep the unified sidebar from
|
||||
* defaulting too tall on first open. */
|
||||
/** Keep primary navigation and entity groups open by default so first-time
|
||||
* Office users can see projects, agents, and workspace tools immediately. */
|
||||
export const DEFAULT_SECTION_EXPANDED: Record<string, boolean> = {
|
||||
tasks: true,
|
||||
projects: false,
|
||||
agents: false,
|
||||
"office-work": true,
|
||||
"office-workspace": true,
|
||||
projects: true,
|
||||
agents: true,
|
||||
integrations: false,
|
||||
settings: false,
|
||||
};
|
||||
@@ -47,9 +48,9 @@ export function buildAppSidebarActions(set: ImmerSet) {
|
||||
draft.appSidebar.collapsed = collapsed;
|
||||
setStoredAppSidebarCollapsed(collapsed);
|
||||
}),
|
||||
toggleAppSidebarSection: (sectionId: string) =>
|
||||
toggleAppSidebarSection: (sectionId: string, defaultExpanded = false) =>
|
||||
set((draft) => {
|
||||
const current = draft.appSidebar.sectionExpanded[sectionId] ?? false;
|
||||
const current = draft.appSidebar.sectionExpanded[sectionId] ?? defaultExpanded;
|
||||
draft.appSidebar.sectionExpanded[sectionId] = !current;
|
||||
setStoredAppSidebarSectionExpanded({ ...draft.appSidebar.sectionExpanded });
|
||||
}),
|
||||
|
||||
@@ -220,7 +220,7 @@ export type UISliceActions = {
|
||||
removeTaskFromSidebarPrefs: (taskId: string) => void;
|
||||
toggleAppSidebar: () => void;
|
||||
setAppSidebarCollapsed: (collapsed: boolean) => void;
|
||||
toggleAppSidebarSection: (sectionId: string) => void;
|
||||
toggleAppSidebarSection: (sectionId: string, defaultExpanded?: boolean) => void;
|
||||
setAppSidebarWidth: (width: number) => void;
|
||||
toggleAppSidebarSettingsMode: () => void;
|
||||
};
|
||||
|
||||
@@ -227,7 +227,10 @@ describe("appSidebar actions", () => {
|
||||
expect(store.getState().appSidebar.collapsed).toBe(false);
|
||||
expect(store.getState().appSidebar.width).toBe(APP_SIDEBAR_EXPANDED_WIDTH);
|
||||
expect(store.getState().appSidebar.sectionExpanded.tasks).toBe(true);
|
||||
expect(store.getState().appSidebar.sectionExpanded.projects).toBe(false);
|
||||
expect(store.getState().appSidebar.sectionExpanded["office-work"]).toBe(true);
|
||||
expect(store.getState().appSidebar.sectionExpanded["office-workspace"]).toBe(true);
|
||||
expect(store.getState().appSidebar.sectionExpanded.projects).toBe(true);
|
||||
expect(store.getState().appSidebar.sectionExpanded.agents).toBe(true);
|
||||
});
|
||||
|
||||
it("hydrates collapsed flag from localStorage", () => {
|
||||
@@ -257,12 +260,25 @@ describe("appSidebar actions", () => {
|
||||
it("toggleAppSidebarSection flips per-section state and persists the map", () => {
|
||||
const store = makeStore();
|
||||
store.getState().toggleAppSidebarSection("projects");
|
||||
expect(store.getState().appSidebar.sectionExpanded.projects).toBe(true);
|
||||
expect(store.getState().appSidebar.sectionExpanded.projects).toBe(false);
|
||||
const persisted = JSON.parse(window.localStorage.getItem(SECTION_KEY) ?? "{}");
|
||||
expect(persisted.projects).toBe(true);
|
||||
expect(persisted.projects).toBe(false);
|
||||
|
||||
store.getState().toggleAppSidebarSection("projects");
|
||||
expect(store.getState().appSidebar.sectionExpanded.projects).toBe(false);
|
||||
expect(store.getState().appSidebar.sectionExpanded.projects).toBe(true);
|
||||
});
|
||||
|
||||
it("toggleAppSidebarSection honors the caller default for missing section keys", () => {
|
||||
const store = makeStore();
|
||||
store.setState((draft) => {
|
||||
delete draft.appSidebar.sectionExpanded["future-section"];
|
||||
});
|
||||
|
||||
store.getState().toggleAppSidebarSection("future-section", true);
|
||||
|
||||
expect(store.getState().appSidebar.sectionExpanded["future-section"]).toBe(false);
|
||||
const persisted = JSON.parse(window.localStorage.getItem(SECTION_KEY) ?? "{}");
|
||||
expect(persisted["future-section"]).toBe(false);
|
||||
});
|
||||
|
||||
it("settingsMode defaults off and is never read from storage", () => {
|
||||
|
||||
Reference in New Issue
Block a user