[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:
Renzo F
2026-07-11 07:42:37 +02:00
committed by GitHub
co-authored by Renn F Frontend Developer 2 Frontend Documenter Frontend Developer 1
parent eefaca1d3b
commit 58354a364e
5 changed files with 405 additions and 71 deletions
@@ -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();
});
});
+49 -34
View File
@@ -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();
});
});
+32 -37
View File
@@ -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>
);
}