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