mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[e56e6543] Reorder sidebar, rename A2A, remove Notifications entry, 2-col objectives (#394)
* [11e82f2e] Frontend: sidebar reorder, A2A rename, Notifications removal, 2-col objectives (#393) * [946802b2] Objectives editor: 2-column desktop grid, 1-column mobile (#385) * [946802b2] feat(goals-tab): objectives editor 2-col grid on desktop, 1-col mobile * [946802b2] docs(goals-tab): document ObjectivesEditor responsive grid layout Added comprehensive JSDoc comment explaining the 2-column desktop / 1-column mobile responsive grid layout for objective cards. Documents the grid-cols-1 / md:grid-cols-2 classes, the gap spacing, and clarifies that the '+ Add objective' button sits as a full-width sibling below the grid rather than as a grid item. Includes a visual layout structure for future reference. --------- Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech> Co-authored-by: Frontend Documenter <fe-doc@roboco.tech> * [639c0d54] Sidebar: reorder + dividers + A2A rename + remove Notifications (#389) * [639c0d54] feat(panel): sidebar dividers, A2A rename, remove Notifications entry Group navItems into six sections rendered with a visible Separator between each group in SidebarNav (shared by desktop + mobile Sheet), rename the /a2a entry from "A2A Live" to "A2A", and drop the Notifications entry from the sidebar (/notifications stays reachable via the header's NotificationBell). Adds sidebar.test.tsx covering group dividers, the rename, the removed entry, item order, and the collapsed icon-only state. * [639c0d54] docs(sidebar): add navigation structure and grouping documentation Document the six-group sidebar organization with dividers, the A2A rename from "A2A Live", and the removal of Notifications from the sidebar. Covers visual behavior across desktop expanded/collapsed and mobile states, data structure rationale, and testing. Explains that Notifications remains accessible via the header NotificationBell. --------- Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech> Co-authored-by: Frontend Documenter <fe-doc@roboco.tech> --------- Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech> Co-authored-by: Frontend Documenter <fe-doc@roboco.tech> Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech> * [92a46054] Fix sidebar: exact flat order + move Business to footer (#415) * [11a612e2] Flatten sidebar nav order + move Business to footer (#413) * [11a612e2] fix(panel): flatten sidebar navItems + move Business to footer * [11a612e2] docs(sidebar): update navigation structure documentation for flat navItems + Business in footer --------- Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech> Co-authored-by: Frontend Documenter <fe-doc@roboco.tech> * [dc518639] revert(business): drop out-of-scope 2-col objectives grid from PR #415 (#418) Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech> --------- Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech> Co-authored-by: Frontend Documenter <fe-doc@roboco.tech> * [e2b50b06] Re-implement 2-column objectives grid in goals-tab.tsx (#435) Branch rebuilt from the root fork point so the assembled delta against master contains ONLY this task's work: the responsive objectives grid (grid-cols-1 md:grid-cols-2) and its test. The prior branch inherited the root's sidebar work into the against-master view, which the PR gate correctly flagged as an AC4 violation. Co-authored-by: Renn F <rennf93@users.noreply.github.com> --------- Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech> Co-authored-by: Frontend Documenter <fe-doc@roboco.tech> Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech> Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
co-authored by
Renn F
Frontend Developer 2
Frontend Documenter
Frontend Developer 1
parent
eefaca1d3b
commit
58354a364e
@@ -0,0 +1,119 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import type { CompanyGoals } from "@/lib/api/company-goals";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock @tanstack/react-query so we can control useQuery/useMutation return
|
||||
// values without a real QueryClientProvider.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const { mockUseQuery, mockUseMutation, mockMutate } = vi.hoisted(() => ({
|
||||
mockUseQuery: vi.fn(),
|
||||
mockUseMutation: vi.fn(),
|
||||
mockMutate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@tanstack/react-query")>();
|
||||
return {
|
||||
...actual,
|
||||
useQuery: mockUseQuery,
|
||||
useMutation: mockUseMutation,
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/api/company-goals", () => ({
|
||||
companyGoalsApi: {
|
||||
get: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import component AFTER mocks are set up
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { GoalsTab } from "../goals-tab";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildGoals(overrides: Partial<CompanyGoals> = {}): CompanyGoals {
|
||||
return {
|
||||
north_star: "Ship things",
|
||||
objectives: [
|
||||
{ metric: "Lead time", target: "< 24h", status: "Active" },
|
||||
{ metric: "Escaped defects", target: "0", status: "Active" },
|
||||
],
|
||||
constraints: [],
|
||||
operating_policy: {},
|
||||
brand_voice: "",
|
||||
updated_at: null,
|
||||
updated_by: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function setup(goals: CompanyGoals) {
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: goals,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
mockUseMutation.mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
isPending: false,
|
||||
});
|
||||
}
|
||||
|
||||
describe("GoalsTab — ObjectivesEditor grid", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders the objectives container as a 2-column grid that collapses to 1 column on mobile", () => {
|
||||
setup(buildGoals());
|
||||
|
||||
render(<GoalsTab />);
|
||||
|
||||
const objectiveOne = screen.getByText("Objective #1");
|
||||
// The grid wrapper is the parent of each objective card.
|
||||
const gridContainer = objectiveOne.closest("div.rounded-lg")
|
||||
?.parentElement as HTMLElement;
|
||||
|
||||
expect(gridContainer).toHaveClass("grid");
|
||||
expect(gridContainer).toHaveClass("grid-cols-1");
|
||||
expect(gridContainer).toHaveClass("md:grid-cols-2");
|
||||
});
|
||||
|
||||
it("keeps add/remove/edit objective behavior working", () => {
|
||||
setup(buildGoals());
|
||||
|
||||
render(<GoalsTab />);
|
||||
|
||||
// Two seeded objectives render as separate cards.
|
||||
expect(screen.getByText("Objective #1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Objective #2")).toBeInTheDocument();
|
||||
|
||||
// Edit a field on the first objective (both rows share the "metric" label,
|
||||
// so grab the first one — it belongs to Objective #1's input group).
|
||||
const metricInput = screen.getAllByLabelText(
|
||||
"metric",
|
||||
)[0] as HTMLInputElement;
|
||||
fireEvent.change(metricInput, { target: { value: "Updated metric" } });
|
||||
expect(metricInput.value).toBe("Updated metric");
|
||||
|
||||
// Add a new objective row.
|
||||
fireEvent.click(screen.getByText("+ Add objective"));
|
||||
expect(screen.getByText("Objective #3")).toBeInTheDocument();
|
||||
|
||||
// Remove the second objective row; remaining rows renumber to #1/#2.
|
||||
const removeButtons = screen.getAllByText("Remove");
|
||||
fireEvent.click(removeButtons[1]);
|
||||
expect(screen.getAllByText(/^Objective #/)).toHaveLength(2);
|
||||
expect(screen.queryByText("Objective #3")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -52,6 +52,19 @@ interface ObjectivesEditorProps {
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* ObjectivesEditor renders a responsive 2-column grid of objective cards.
|
||||
*
|
||||
* Layout:
|
||||
* - Desktop (md+): 2-column grid with 3px gap
|
||||
* - Mobile: 1-column stack (full width)
|
||||
*
|
||||
* Each card shows a set of editable fields derived from the first objective
|
||||
* (metric, target, status, etc.) with add/remove controls. The outer container
|
||||
* maintains space-y-3 gap to separate the grid from the "Add objective" button.
|
||||
*
|
||||
* Tests: `goals-tab.test.tsx` asserts the grid classes and add/remove/edit behavior.
|
||||
*/
|
||||
function ObjectivesEditor({
|
||||
items,
|
||||
onChange,
|
||||
@@ -80,42 +93,44 @@ function ObjectivesEditor({
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{items.map((item, rowIdx) => (
|
||||
<div key={rowIdx} className="rounded-lg border p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Objective #{rowIdx + 1}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
onClick={() => removeRow(rowIdx)}
|
||||
className="h-6 px-2 text-xs text-destructive hover:text-destructive"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
{keys.map((key) => (
|
||||
<div key={key} className="space-y-1">
|
||||
<Label
|
||||
htmlFor={`obj-${rowIdx}-${key}`}
|
||||
className="text-xs capitalize"
|
||||
>
|
||||
{key.replace(/_/g, " ")}
|
||||
</Label>
|
||||
<Input
|
||||
id={`obj-${rowIdx}-${key}`}
|
||||
value={String(item[key] ?? "")}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{items.map((item, rowIdx) => (
|
||||
<div key={rowIdx} className="rounded-lg border p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Objective #{rowIdx + 1}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
onChange={(e) => handleChange(rowIdx, key, e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
onClick={() => removeRow(rowIdx)}
|
||||
className="h-6 px-2 text-xs text-destructive hover:text-destructive"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{keys.map((key) => (
|
||||
<div key={key} className="space-y-1">
|
||||
<Label
|
||||
htmlFor={`obj-${rowIdx}-${key}`}
|
||||
className="text-xs capitalize"
|
||||
>
|
||||
{key.replace(/_/g, " ")}
|
||||
</Label>
|
||||
<Input
|
||||
id={`obj-${rowIdx}-${key}`}
|
||||
value={String(item[key] ?? "")}
|
||||
disabled={disabled}
|
||||
onChange={(e) => handleChange(rowIdx, key, e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { SidebarNav, SidebarFooter, navItems } from "../sidebar";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/overview",
|
||||
}));
|
||||
|
||||
const EXPECTED_ORDER = [
|
||||
"/overview",
|
||||
"/prompter",
|
||||
"/tasks",
|
||||
"/kanban",
|
||||
"/git",
|
||||
"/projects",
|
||||
"/products",
|
||||
"/social",
|
||||
"/knowledge-base",
|
||||
"/a2a",
|
||||
"/agents",
|
||||
"/journals",
|
||||
"/auditor",
|
||||
"/metrics",
|
||||
];
|
||||
|
||||
describe("navItems", () => {
|
||||
it("is a single flat array in the exact expected order", () => {
|
||||
expect(navItems.map((item) => item.href)).toEqual(EXPECTED_ORDER);
|
||||
});
|
||||
|
||||
it("does not include Business", () => {
|
||||
expect(navItems.some((item) => item.href === "/business")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SidebarNav", () => {
|
||||
it("renders no dividers — the nav list itself has no group separators", () => {
|
||||
const { container } = render(<SidebarNav />);
|
||||
expect(
|
||||
container.querySelectorAll('[data-slot="separator"]'),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("renders every nav item as a link, in order", () => {
|
||||
render(<SidebarNav />);
|
||||
const links = screen.getAllByRole("link");
|
||||
expect(links.map((link) => link.getAttribute("href"))).toEqual(
|
||||
EXPECTED_ORDER,
|
||||
);
|
||||
});
|
||||
|
||||
it("renders correctly when collapsed (icon-only, no layout break)", () => {
|
||||
render(<SidebarNav collapsed />);
|
||||
expect(screen.getAllByRole("link")).toHaveLength(navItems.length);
|
||||
expect(screen.queryByText("Overview")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SidebarFooter", () => {
|
||||
it("renders Business immediately before AI Providers, with Settings last", () => {
|
||||
render(<SidebarFooter />);
|
||||
const links = screen.getAllByRole("link");
|
||||
expect(links.map((link) => link.getAttribute("href"))).toEqual([
|
||||
"/business",
|
||||
"/settings/ai-providers",
|
||||
"/settings",
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders exactly one Separator between the nav list and the footer, expanded and collapsed", () => {
|
||||
const expanded = render(<SidebarFooter />);
|
||||
expect(
|
||||
expanded.container.querySelectorAll('[data-slot="separator"]'),
|
||||
).toHaveLength(1);
|
||||
expanded.unmount();
|
||||
|
||||
const collapsed = render(<SidebarFooter collapsed />);
|
||||
expect(
|
||||
collapsed.container.querySelectorAll('[data-slot="separator"]'),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("renders correctly when collapsed (icon-only)", () => {
|
||||
render(<SidebarFooter collapsed />);
|
||||
expect(screen.getAllByRole("link")).toHaveLength(3);
|
||||
expect(screen.queryByText("Business")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
LayoutDashboard,
|
||||
ListTodo,
|
||||
Kanban,
|
||||
Bell,
|
||||
Activity,
|
||||
ChevronLeft,
|
||||
Settings,
|
||||
@@ -27,39 +26,32 @@ import {
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useUIStore } from "@/store";
|
||||
|
||||
// Flat list, in the exact order product wants the sidebar to read top to
|
||||
// bottom. Notifications lives only in the header's NotificationBell now.
|
||||
export const navItems = [
|
||||
// Dashboard
|
||||
{ title: "Overview", href: "/overview", icon: LayoutDashboard },
|
||||
{ title: "Business", href: "/business", icon: Building2 },
|
||||
{ title: "Social", href: "/social", icon: Share2 },
|
||||
|
||||
// Work Management
|
||||
{ title: "Task Assistant", href: "/prompter", icon: Sparkles },
|
||||
{ title: "Tasks", href: "/tasks", icon: ListTodo },
|
||||
{ title: "Kanban", href: "/kanban", icon: Kanban },
|
||||
{ title: "Task Assistant", href: "/prompter", icon: Sparkles },
|
||||
|
||||
// Development
|
||||
{ title: "Git", href: "/git", icon: GitBranch },
|
||||
{ title: "Projects", href: "/projects", icon: FolderGit2 },
|
||||
{ title: "Products", href: "/products", icon: Boxes },
|
||||
{ title: "Git", href: "/git", icon: GitBranch },
|
||||
|
||||
// Team & Reference
|
||||
{ title: "Agents", href: "/agents", icon: Bot },
|
||||
{ title: "Social", href: "/social", icon: Share2 },
|
||||
{ title: "Knowledge Base", href: "/knowledge-base", icon: Database },
|
||||
{ title: "Auditor", href: "/auditor", icon: Shield },
|
||||
|
||||
// History
|
||||
{ title: "A2A Live", href: "/a2a", icon: Radio },
|
||||
{ title: "A2A", href: "/a2a", icon: Radio },
|
||||
{ title: "Agents", href: "/agents", icon: Bot },
|
||||
{ title: "Journals", href: "/journals", icon: BookOpen },
|
||||
|
||||
// System
|
||||
{ title: "Notifications", href: "/notifications", icon: Bell },
|
||||
{ title: "Auditor", href: "/auditor", icon: Shield },
|
||||
{ title: "Metrics", href: "/metrics", icon: Activity },
|
||||
];
|
||||
|
||||
// Business moved out of the main nav — it lives with the settings-adjacent
|
||||
// links, separated from navItems by a single Separator (see SidebarFooter).
|
||||
const footerItems = [
|
||||
{ title: "Business", href: "/business", icon: Building2 },
|
||||
{ title: "AI Providers", href: "/settings/ai-providers", icon: Cpu },
|
||||
{ title: "Settings", href: "/settings", icon: Settings },
|
||||
];
|
||||
@@ -114,23 +106,26 @@ export function SidebarFooter({
|
||||
onNavigate?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{footerItems.map((item) => (
|
||||
<Link
|
||||
prefetch={false}
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={onNavigate}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors",
|
||||
collapsed && "justify-center px-2",
|
||||
)}
|
||||
title={collapsed ? item.title : undefined}
|
||||
>
|
||||
<item.icon className="h-5 w-5" />
|
||||
{!collapsed && <span>{item.title}</span>}
|
||||
</Link>
|
||||
))}
|
||||
<div className="space-y-2">
|
||||
<Separator />
|
||||
<div className="space-y-1">
|
||||
{footerItems.map((item) => (
|
||||
<Link
|
||||
prefetch={false}
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={onNavigate}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors",
|
||||
collapsed && "justify-center px-2",
|
||||
)}
|
||||
title={collapsed ? item.title : undefined}
|
||||
>
|
||||
<item.icon className="h-5 w-5" />
|
||||
{!collapsed && <span>{item.title}</span>}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user