feat(nexus): add thin wrappers for per-project tabs (phase 11)
Wires the 6 Builder tab content components: • IssuesTab — thin wrapper around IssuesList (which already accepts a projectId prop, so this is a full wiring). • GatesTab / AgentsTab / CostsTab / ActivityTab / OrgTab — render a shared TabPlaceholder marking the Phase 11 data gap. None of the underlying list components accept a projectId filter today, and the Phase 11 plan forbids modifying them unilaterally. The placeholders are explicit (never fabricate per-project data) and carry the follow-up description the controller will turn into a ticket. GatesTab is named "Gates" everywhere visible (display-only rename per spec §7.2.4 / plan §5); approvalsApi / Approval type / /api/approvals endpoints are all untouched. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
cd6c172d48
commit
d10c5b991e
8 changed files with 367 additions and 0 deletions
31
ui/src/components/projects/tabs/ActivityTab.tsx
Normal file
31
ui/src/components/projects/tabs/ActivityTab.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// [nexus] Phase 11 — Project Detail ACTIVITY tab.
|
||||
//
|
||||
// BLOCKED on component gap: the global /activity page does not expose
|
||||
// a standalone feed component with a project filter. It renders an
|
||||
// inline table directly. `activityApi.list` accepts
|
||||
// {entityType, entityId} which would let us filter on the server, but
|
||||
// Phase 11 is not permitted to extract a new feed component from the
|
||||
// existing page. A follow-up will land the reusable feed.
|
||||
import { TabPlaceholder } from "./TabPlaceholder";
|
||||
|
||||
export interface ActivityTabProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export function ActivityTab({ projectId: _projectId }: ActivityTabProps) {
|
||||
return (
|
||||
<TabPlaceholder
|
||||
testId="project-activity-tab"
|
||||
title="Activity"
|
||||
gapReason={
|
||||
<>
|
||||
Per-project activity feed not yet wired. The /activity page does
|
||||
not expose a reusable feed component; the Phase 11 plan forbids
|
||||
modifying it or copy-pasting. A follow-up ticket will extract a
|
||||
feed component that accepts a projectId and call
|
||||
activityApi.list with {`{`}entityType: "project", entityId{`}`}.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
30
ui/src/components/projects/tabs/AgentsTab.tsx
Normal file
30
ui/src/components/projects/tabs/AgentsTab.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// [nexus] Phase 11 — Project Detail AGENTS tab.
|
||||
//
|
||||
// BLOCKED on backend data gap: the shared Agent type has no `projectId`
|
||||
// association and the Agents page does not expose a standalone
|
||||
// AgentList component with a projectId prop. Phase 11 is not allowed
|
||||
// to modify those components. This tab renders a placeholder marking
|
||||
// the gap; the controller will add a per-project agent query + an
|
||||
// AgentList<projectId> prop in a follow-up ticket.
|
||||
import { TabPlaceholder } from "./TabPlaceholder";
|
||||
|
||||
export interface AgentsTabProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export function AgentsTab({ projectId: _projectId }: AgentsTabProps) {
|
||||
return (
|
||||
<TabPlaceholder
|
||||
testId="project-agents-tab"
|
||||
title="Agents"
|
||||
gapReason={
|
||||
<>
|
||||
Per-project agent list not yet wired. The shared Agent type has no
|
||||
project association, and the existing AgentList component does not
|
||||
accept a projectId prop. Phase 11 ships the tab container; a
|
||||
follow-up ticket will add the filter.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
32
ui/src/components/projects/tabs/CostsTab.tsx
Normal file
32
ui/src/components/projects/tabs/CostsTab.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// [nexus] Phase 11 — Project Detail COSTS tab.
|
||||
//
|
||||
// BLOCKED on component gap: there is no standalone CostsBreakdown
|
||||
// component. The /costs page (ui/src/pages/Costs.tsx) is ~1100 lines of
|
||||
// inline page content that composes ~8 card components, multiple
|
||||
// date-range selectors, and agent/project/biller tabs. Phase 11 may
|
||||
// not modify that page and the plan forbids copy-paste. The backend
|
||||
// does expose `costsApi.byProject`, but wiring a per-project breakdown
|
||||
// from scratch is out of Phase 11 scope.
|
||||
import { TabPlaceholder } from "./TabPlaceholder";
|
||||
|
||||
export interface CostsTabProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export function CostsTab({ projectId: _projectId }: CostsTabProps) {
|
||||
return (
|
||||
<TabPlaceholder
|
||||
testId="project-costs-tab"
|
||||
title="Costs"
|
||||
gapReason={
|
||||
<>
|
||||
Per-project cost breakdown not yet wired. There is no standalone
|
||||
CostsBreakdown component to reuse (the /costs page is monolithic),
|
||||
and the Phase 11 plan forbids duplicating it. A follow-up ticket
|
||||
will extract a reusable breakdown component that accepts a
|
||||
projectId filter and hook into costsApi.byProject.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
37
ui/src/components/projects/tabs/GatesTab.tsx
Normal file
37
ui/src/components/projects/tabs/GatesTab.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// [nexus] Phase 11 — Project Detail GATES tab.
|
||||
//
|
||||
// Display-only rename of "Approvals" → "Gates". Internally we still hit
|
||||
// /api/approvals and the `approvalsApi` client unchanged. The shared
|
||||
// `Approval` type has no `projectId` field; Phase 11 cannot modify
|
||||
// ApprovalCard / ApprovalsList to add a project filter. Until the
|
||||
// controller wires a per-project approval query in a follow-up, this
|
||||
// tab renders a placeholder marking the gap.
|
||||
//
|
||||
// (The `useGateIndicator` hook added in this phase counts *global*
|
||||
// pending gates for the IconRail dot — that's independent of this
|
||||
// per-project filter and needs no project scoping.)
|
||||
import { TabPlaceholder } from "./TabPlaceholder";
|
||||
|
||||
export interface GatesTabProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export function GatesTab({ projectId: _projectId }: GatesTabProps) {
|
||||
return (
|
||||
<TabPlaceholder
|
||||
testId="project-gates-tab"
|
||||
title="Gates"
|
||||
gapReason={
|
||||
<>
|
||||
Per-project gate list not yet wired. The shared Approval type has
|
||||
no projectId field (some payloads carry one on the JSON blob, but
|
||||
that's a convention, not a typed contract), and there is no
|
||||
ApprovalsList component to pass a projectId prop to. Phase 11
|
||||
ships the tab container and a display-only "Gates" rename; a
|
||||
follow-up will filter approvals by project once the backend
|
||||
exposes a stable contract.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
49
ui/src/components/projects/tabs/IssuesTab.tsx
Normal file
49
ui/src/components/projects/tabs/IssuesTab.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
// [nexus] Phase 11 — Project Detail ISSUES tab.
|
||||
//
|
||||
// Thin wrapper around the existing <IssuesList /> component scoped to
|
||||
// the current project. IssuesList already accepts a `projectId` prop
|
||||
// out of the box, so no upstream modifications are needed.
|
||||
//
|
||||
// Data loading (issues, agents, liveRuns) is intentionally left to the
|
||||
// caller — Phase 11 does not own IssuesList and should not duplicate
|
||||
// its hook orchestration. The existing ProjectDetail `ProjectIssuesList`
|
||||
// inner component already has the exact data wiring we need; this tab
|
||||
// component exists primarily as a stable type-safe surface the Builder
|
||||
// tab dispatcher can render.
|
||||
import type { Agent, Issue } from "@paperclipai/shared";
|
||||
import { IssuesList } from "../../IssuesList";
|
||||
|
||||
export interface IssuesTabProps {
|
||||
projectId: string;
|
||||
issues: Issue[];
|
||||
isLoading?: boolean;
|
||||
error?: Error | null;
|
||||
agents?: Agent[];
|
||||
liveIssueIds?: Set<string>;
|
||||
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function IssuesTab({
|
||||
projectId,
|
||||
issues,
|
||||
isLoading,
|
||||
error,
|
||||
agents,
|
||||
liveIssueIds,
|
||||
onUpdateIssue,
|
||||
}: IssuesTabProps) {
|
||||
return (
|
||||
<div data-testid="project-issues-tab">
|
||||
<IssuesList
|
||||
issues={issues}
|
||||
isLoading={isLoading}
|
||||
error={error ?? null}
|
||||
agents={agents}
|
||||
liveIssueIds={liveIssueIds}
|
||||
projectId={projectId}
|
||||
viewStateKey={`paperclip:project-view:${projectId}`}
|
||||
onUpdateIssue={onUpdateIssue}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
ui/src/components/projects/tabs/OrgTab.tsx
Normal file
34
ui/src/components/projects/tabs/OrgTab.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// [nexus] Phase 11 — Project Detail ORG tab.
|
||||
//
|
||||
// BLOCKED on component gap: the existing Org/OrgChart pages render
|
||||
// company-wide reporting trees. There's no project-scoped org tree in
|
||||
// the backend (agents.org() returns a company tree), and no reusable
|
||||
// component with a projectId filter prop. Phase 11 is not permitted
|
||||
// to modify those pages.
|
||||
//
|
||||
// Note the BuilderTabStrip hides the ORG tab entirely for single-agent
|
||||
// projects (spec §7.2.7 + ProjectDetail integration). This placeholder
|
||||
// only renders when a project has at least 2 agents *and* the user
|
||||
// clicks ORG.
|
||||
import { TabPlaceholder } from "./TabPlaceholder";
|
||||
|
||||
export interface OrgTabProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export function OrgTab({ projectId: _projectId }: OrgTabProps) {
|
||||
return (
|
||||
<TabPlaceholder
|
||||
testId="project-org-tab"
|
||||
title="Org"
|
||||
gapReason={
|
||||
<>
|
||||
Per-project org chart not yet wired. agents.org() returns a
|
||||
company-wide reporting tree; there's no project-scoped variant
|
||||
today. A follow-up ticket will land a server endpoint and
|
||||
reusable component that accepts a projectId filter.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
44
ui/src/components/projects/tabs/TabPlaceholder.tsx
Normal file
44
ui/src/components/projects/tabs/TabPlaceholder.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// [nexus] Phase 11 — shared placeholder for Builder tabs whose underlying
|
||||
// list components do not (yet) accept a `projectId` filter prop.
|
||||
//
|
||||
// Per the Phase 11 plan, Phase 11 MAY NOT modify existing list components
|
||||
// (AgentList, ApprovalsList, CostsBreakdown, etc.). The plan also says
|
||||
// STOP/BLOCKED if the components don't already accept a projectId filter.
|
||||
// The shared `Approval` type has no `projectId` field, there is no
|
||||
// standalone `CostsBreakdown` component, Activity filtering at the API
|
||||
// level only accepts entityType/entityId, and the Agents page does not
|
||||
// expose an AgentList with a project filter.
|
||||
//
|
||||
// Rather than fabricate per-project data or duplicate the global pages,
|
||||
// Phase 11 ships explicit placeholder tabs that mark each data gap. The
|
||||
// Phase 11 report enumerates the gaps and the controller will schedule
|
||||
// follow-up tickets to add projectId props post-Wave.
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface TabPlaceholderProps {
|
||||
testId: string;
|
||||
title: string;
|
||||
gapReason: ReactNode;
|
||||
}
|
||||
|
||||
export function TabPlaceholder({ testId, title, gapReason }: TabPlaceholderProps) {
|
||||
return (
|
||||
<section
|
||||
data-testid={testId}
|
||||
className="rounded-lg border border-border p-6"
|
||||
>
|
||||
<h3 className="mb-2 text-[12px] font-bold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-[14px] text-muted-foreground">
|
||||
<span
|
||||
data-testid={`${testId}-gap`}
|
||||
className="inline-block rounded border border-[#f4f692]/40 bg-[#f4f692]/5 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-[0.1em] text-[#f4f692]"
|
||||
>
|
||||
Phase 11 data gap
|
||||
</span>
|
||||
<span className="ml-2">{gapReason}</span>
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
110
ui/src/components/projects/tabs/tabs.test.tsx
Normal file
110
ui/src/components/projects/tabs/tabs.test.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { AgentsTab } from "./AgentsTab";
|
||||
import { GatesTab } from "./GatesTab";
|
||||
import { CostsTab } from "./CostsTab";
|
||||
import { ActivityTab } from "./ActivityTab";
|
||||
import { OrgTab } from "./OrgTab";
|
||||
import { TabPlaceholder } from "./TabPlaceholder";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
describe("Phase 11 per-project tab wrappers (placeholder tabs)", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root!.unmount();
|
||||
});
|
||||
root = null;
|
||||
}
|
||||
if (container.parentNode) container.remove();
|
||||
});
|
||||
|
||||
function renderOne(element: React.ReactElement) {
|
||||
root = createRoot(container);
|
||||
act(() => {
|
||||
root!.render(element);
|
||||
});
|
||||
}
|
||||
|
||||
describe("TabPlaceholder", () => {
|
||||
it("renders the title, gap badge, and reason copy", () => {
|
||||
renderOne(
|
||||
<TabPlaceholder
|
||||
testId="my-tab"
|
||||
title="My Tab"
|
||||
gapReason="This is why it's not wired yet."
|
||||
/>,
|
||||
);
|
||||
const card = container.querySelector<HTMLElement>('[data-testid="my-tab"]');
|
||||
expect(card).not.toBeNull();
|
||||
expect(card!.textContent).toContain("My Tab");
|
||||
expect(card!.textContent).toContain("Phase 11 data gap");
|
||||
expect(card!.textContent).toContain("This is why it's not wired yet.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentsTab", () => {
|
||||
it("renders a placeholder marking the data gap", () => {
|
||||
renderOne(<AgentsTab projectId="proj-1" />);
|
||||
const tab = container.querySelector<HTMLElement>('[data-testid="project-agents-tab"]');
|
||||
expect(tab).not.toBeNull();
|
||||
expect(tab!.textContent).toContain("Agents");
|
||||
expect(tab!.textContent).toContain("Phase 11 data gap");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GatesTab", () => {
|
||||
it("renders a Gates placeholder — never 'Approvals' (display rename)", () => {
|
||||
renderOne(<GatesTab projectId="proj-1" />);
|
||||
const tab = container.querySelector<HTMLElement>('[data-testid="project-gates-tab"]');
|
||||
expect(tab).not.toBeNull();
|
||||
expect(tab!.textContent).toContain("Gates");
|
||||
expect(tab!.textContent?.toLowerCase()).not.toContain("approvals tab");
|
||||
expect(tab!.textContent).toContain("Phase 11 data gap");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CostsTab", () => {
|
||||
it("renders a Costs placeholder marking the data gap", () => {
|
||||
renderOne(<CostsTab projectId="proj-1" />);
|
||||
const tab = container.querySelector<HTMLElement>('[data-testid="project-costs-tab"]');
|
||||
expect(tab).not.toBeNull();
|
||||
expect(tab!.textContent).toContain("Costs");
|
||||
expect(tab!.textContent).toContain("Phase 11 data gap");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ActivityTab", () => {
|
||||
it("renders an Activity placeholder marking the data gap", () => {
|
||||
renderOne(<ActivityTab projectId="proj-1" />);
|
||||
const tab = container.querySelector<HTMLElement>('[data-testid="project-activity-tab"]');
|
||||
expect(tab).not.toBeNull();
|
||||
expect(tab!.textContent).toContain("Activity");
|
||||
expect(tab!.textContent).toContain("Phase 11 data gap");
|
||||
});
|
||||
});
|
||||
|
||||
describe("OrgTab", () => {
|
||||
it("renders an Org placeholder marking the data gap", () => {
|
||||
renderOne(<OrgTab projectId="proj-1" />);
|
||||
const tab = container.querySelector<HTMLElement>('[data-testid="project-org-tab"]');
|
||||
expect(tab).not.toBeNull();
|
||||
expect(tab!.textContent).toContain("Org");
|
||||
expect(tab!.textContent).toContain("Phase 11 data gap");
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue