mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
PAP-10256 Team Catalog UI: INSTALLED group, out-of-date badge, Re-install CTA
Consume the installed-state endpoint to surface installed/out-of-date teams from the server signal (PAP-10238 §3.2 + §5): - list: installed teams collapse under `INSTALLED · N`; out-of-date rows show an amber chevron-up badge with a tooltip - detail: `Installed` badge when current, `Update available` chip when stale, and the primary CTA switches to `Re-install latest` - invalidate installed/agents queries after an install completes - design-guide + Storybook coverage via shared installed-state fixtures - tests: INSTALLED group, out-of-date vs current rendering Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
b2b3448d07
commit
4344ad6b1a
7 files changed
+216
-9
No files matched your search
@@ -6,6 +6,7 @@ import type {
|
||||
CatalogTeamInstallOptions,
|
||||
CatalogTeamInstallResult,
|
||||
CatalogTeamKind,
|
||||
InstalledCatalogTeam,
|
||||
} from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
@@ -35,6 +36,10 @@ export const teamCatalogApi = {
|
||||
},
|
||||
catalogDetail: (catalogRef: string) =>
|
||||
api.get<CatalogTeam>(`/teams/catalog/${encodeURIComponent(catalogRef)}`),
|
||||
installed: (companyId: string) =>
|
||||
api.get<InstalledCatalogTeam[]>(
|
||||
`/companies/${encodeURIComponent(companyId)}/teams/catalog/installed`,
|
||||
),
|
||||
catalogFile: (catalogRef: string, relativePath = "TEAM.md") =>
|
||||
api.get<CatalogTeamFileDetail>(
|
||||
`/teams/catalog/${encodeURIComponent(catalogRef)}/files?path=${encodeURIComponent(relativePath)}`,
|
||||
|
||||
@@ -23,6 +23,7 @@ export const queryKeys = {
|
||||
catalogDetail: (catalogRef: string) => ["team-catalog", "catalog", "detail", catalogRef] as const,
|
||||
catalogFile: (catalogRef: string, relativePath: string) =>
|
||||
["team-catalog", "catalog", "file", catalogRef, relativePath] as const,
|
||||
installed: (companyId: string) => ["team-catalog", "installed", companyId] as const,
|
||||
},
|
||||
agents: {
|
||||
list: (companyId: string) => ["agents", companyId] as const,
|
||||
|
||||
@@ -138,8 +138,10 @@ import {
|
||||
TeamRow,
|
||||
} from "@/pages/TeamCatalog";
|
||||
import {
|
||||
currentInstalledState,
|
||||
onboardingTeams,
|
||||
optionalTeam,
|
||||
outOfDateInstalledState,
|
||||
sampleSkillPreparations,
|
||||
sampleTeam,
|
||||
warnTeam,
|
||||
@@ -1474,8 +1476,17 @@ export function DesignGuide() {
|
||||
Optional · 2
|
||||
</div>
|
||||
<TeamRow team={optionalTeam} selected={false} onSelect={() => {}} />
|
||||
<TeamRow team={warnTeam} selected={false} onSelect={() => {}} />
|
||||
<div className="px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Installed · 2
|
||||
</div>
|
||||
<TeamRow team={sampleTeam} selected={false} onSelect={() => {}} installed={outOfDateInstalledState} />
|
||||
<TeamRow team={warnTeam} selected={false} onSelect={() => {}} installed={currentInstalledState} />
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Installed teams collapse under <code className="font-mono">INSTALLED · N</code>; an out-of-date
|
||||
install (server <code className="font-mono">originHash</code> ≠ catalog <code className="font-mono">contentHash</code>)
|
||||
shows the amber <code className="font-mono">↑</code> badge (PAP-10256).
|
||||
</p>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="TeamCard (onboarding grid)">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
CatalogTeam,
|
||||
CatalogTeamSkillPreparation,
|
||||
InstalledCatalogTeam,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -95,6 +96,25 @@ export const warnTeam: CatalogTeam = {
|
||||
],
|
||||
};
|
||||
|
||||
// Server-computed installed-team state (PAP-10256). Drives the `INSTALLED · N`
|
||||
// group, the per-row out-of-date badge, and the detail header chip. Shared by
|
||||
// the Storybook stories and /design-guide showcase so they stay in sync.
|
||||
export const outOfDateInstalledState: InstalledCatalogTeam = {
|
||||
catalogId: sampleTeam.id,
|
||||
catalogKey: sampleTeam.key,
|
||||
present: true,
|
||||
currentContentHash: sampleTeam.contentHash,
|
||||
installedOriginHashes: ["sha256:0000older0000older0000older"],
|
||||
agentCount: 3,
|
||||
outOfDate: true,
|
||||
};
|
||||
|
||||
export const currentInstalledState: InstalledCatalogTeam = {
|
||||
...outOfDateInstalledState,
|
||||
installedOriginHashes: [sampleTeam.contentHash],
|
||||
outOfDate: false,
|
||||
};
|
||||
|
||||
export const sampleSkillPreparations: CatalogTeamSkillPreparation[] = [
|
||||
{ type: "catalog", ref: "engineering/code-review", agentSlugs: ["cto"], action: "already_in_package", catalogSkillId: "skill-1", catalogSkillKey: "engineering/code-review", sourceLocator: null, sourceRef: null, reason: null },
|
||||
{ type: "github", ref: "acme/growth-playbook@v1.2.0", agentSlugs: ["cmo"], action: "external_import_required", catalogSkillId: null, catalogSkillKey: null, sourceLocator: "github.com/acme/growth-playbook", sourceRef: "v1.2.0", reason: "Resolved from GitHub at install time" },
|
||||
|
||||
@@ -17,6 +17,7 @@ const mockTeamCatalogApi = vi.hoisted(() => ({
|
||||
catalogFile: vi.fn(),
|
||||
preview: vi.fn(),
|
||||
install: vi.fn(),
|
||||
installed: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAgentsApi = vi.hoisted(() => ({
|
||||
@@ -201,6 +202,7 @@ describe("TeamCatalog install preview path", () => {
|
||||
document.body.appendChild(container);
|
||||
currentRoute = "team-no-deps";
|
||||
mockAgentsApi.list.mockResolvedValue([]);
|
||||
mockTeamCatalogApi.installed.mockResolvedValue([]);
|
||||
mockTeamCatalogApi.catalogList.mockResolvedValue([makeTeam()]);
|
||||
mockTeamCatalogApi.preview.mockResolvedValue(makePreview());
|
||||
mockTeamCatalogApi.install.mockResolvedValue({
|
||||
@@ -387,4 +389,53 @@ describe("TeamCatalog install preview path", () => {
|
||||
// Preview has not been requested yet (still on step 1).
|
||||
expect(mockTeamCatalogApi.preview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces the INSTALLED group, out-of-date badge, and Re-install CTA from the server signal", async () => {
|
||||
mockTeamCatalogApi.installed.mockResolvedValue([
|
||||
{
|
||||
catalogId: "team-no-deps",
|
||||
catalogKey: "paperclipai/bundled/company-defaults/team-no-deps",
|
||||
present: true,
|
||||
currentContentHash: "sha256:deadbeefdeadbeefdeadbeef",
|
||||
installedOriginHashes: ["sha256:stale"],
|
||||
agentCount: 2,
|
||||
outOfDate: true,
|
||||
},
|
||||
]);
|
||||
|
||||
await renderPage();
|
||||
|
||||
// List group header reflects the installed team, not Bundled.
|
||||
expect(document.body.textContent).toContain("Installed · 1");
|
||||
expect(document.body.textContent).not.toContain("Bundled · 1");
|
||||
|
||||
// Detail header chip + Re-install CTA replace the plain Install button.
|
||||
expect(document.body.textContent).toContain("Update available");
|
||||
expect(findButton("Re-install latest")).toBeTruthy();
|
||||
expect(findButton("Install team")).toBeFalsy();
|
||||
|
||||
// The out-of-date badge in the list row exposes an accessible label.
|
||||
expect(document.querySelector('[aria-label="Update available"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders an Installed badge (no update chip) when the installed team is current", async () => {
|
||||
mockTeamCatalogApi.installed.mockResolvedValue([
|
||||
{
|
||||
catalogId: "team-no-deps",
|
||||
catalogKey: "paperclipai/bundled/company-defaults/team-no-deps",
|
||||
present: true,
|
||||
currentContentHash: "sha256:deadbeefdeadbeefdeadbeef",
|
||||
installedOriginHashes: ["sha256:deadbeefdeadbeefdeadbeef"],
|
||||
agentCount: 2,
|
||||
outOfDate: false,
|
||||
},
|
||||
]);
|
||||
|
||||
await renderPage();
|
||||
|
||||
expect(document.body.textContent).toContain("Installed · 1");
|
||||
expect(document.body.textContent).toContain("Installed");
|
||||
expect(document.body.textContent).not.toContain("Update available");
|
||||
expect(findButton("Re-install latest")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate, useParams, useSearchParams } from "@/lib/router";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type {
|
||||
Agent,
|
||||
CatalogTeam,
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
CatalogTeamImportOptions,
|
||||
CatalogTeamInstallOptions,
|
||||
CatalogTeamInstallResult,
|
||||
InstalledCatalogTeam,
|
||||
CompanyPortabilityAdapterOverride,
|
||||
CompanyPortabilityCollisionStrategy,
|
||||
} from "@paperclipai/shared";
|
||||
@@ -82,6 +83,7 @@ import {
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
Cpu,
|
||||
Crown,
|
||||
Download,
|
||||
@@ -641,6 +643,7 @@ export function TeamDetailPane({
|
||||
onInstall,
|
||||
canInstall,
|
||||
fileContent,
|
||||
installed,
|
||||
}: {
|
||||
team: CatalogTeam;
|
||||
selectedPath: string | null;
|
||||
@@ -648,11 +651,14 @@ export function TeamDetailPane({
|
||||
onInstall: () => void;
|
||||
canInstall: boolean;
|
||||
fileContent: string | null;
|
||||
installed?: InstalledCatalogTeam | null;
|
||||
}) {
|
||||
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
|
||||
const tree = useMemo(() => buildTree(team.files), [team.files]);
|
||||
const invalid = team.compatibility === "invalid";
|
||||
const unsafe = team.trustLevel === "scripts_executables";
|
||||
const isInstalled = Boolean(installed);
|
||||
const outOfDate = Boolean(installed?.outOfDate);
|
||||
|
||||
const toggleDir = (name: string) =>
|
||||
setExpandedDirs((current) => {
|
||||
@@ -662,10 +668,22 @@ export function TeamDetailPane({
|
||||
return next;
|
||||
});
|
||||
|
||||
// Installed teams default to update/re-install semantics; out-of-date teams
|
||||
// get the primary amber affordance (design §5 / PAP-10256).
|
||||
const installButton = (
|
||||
<Button onClick={onInstall} disabled={invalid || !canInstall}>
|
||||
{unsafe ? <AlertTriangle className="h-4 w-4" /> : <Download className="h-4 w-4" />}
|
||||
Install team
|
||||
<Button
|
||||
onClick={onInstall}
|
||||
disabled={invalid || !canInstall}
|
||||
variant={outOfDate ? "default" : isInstalled ? "outline" : "default"}
|
||||
>
|
||||
{unsafe ? (
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
) : isInstalled ? (
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
{isInstalled ? "Re-install latest" : "Install team"}
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -684,6 +702,19 @@ export function TeamDetailPane({
|
||||
<TrustChip level={team.trustLevel} />
|
||||
<CompatChip compatibility={team.compatibility} />
|
||||
<ProvenanceBadge team={team} />
|
||||
{isInstalled && !outOfDate && (
|
||||
<Badge variant="secondary" className="gap-1 text-[10px]">
|
||||
<CheckCircle2 className="h-3 w-3" /> Installed
|
||||
</Badge>
|
||||
)}
|
||||
{outOfDate && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="gap-1 border-amber-500/40 bg-amber-500/10 text-[10px] text-amber-600 dark:text-amber-300"
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" /> Update available
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{invalid ? (
|
||||
@@ -2001,12 +2032,15 @@ export function TeamRow({
|
||||
team,
|
||||
selected,
|
||||
onSelect,
|
||||
installed,
|
||||
}: {
|
||||
team: CatalogTeam;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
installed?: InstalledCatalogTeam | null;
|
||||
}) {
|
||||
const risk = teamRisk(team);
|
||||
const outOfDate = Boolean(installed?.outOfDate);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -2021,6 +2055,19 @@ export function TeamRow({
|
||||
<span className={cn("line-clamp-2 text-[13px] font-medium", selected && "text-foreground")}>
|
||||
{team.name}
|
||||
</span>
|
||||
{outOfDate && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
aria-label="Update available"
|
||||
className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-amber-500/40 bg-amber-500/10 text-amber-600 dark:text-amber-300"
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Update available — installed team is out of date</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{risk !== "safe" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -2124,6 +2171,7 @@ export function TeamCatalog() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const { pushToast } = useToastActions();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const parsedRoute = useMemo(() => parseTeamRoute(routePath), [routePath]);
|
||||
const selectedRef = parsedRoute.catalogRef;
|
||||
@@ -2193,6 +2241,23 @@ export function TeamCatalog() {
|
||||
enabled: Boolean(selectedCompanyId),
|
||||
});
|
||||
|
||||
// Server-computed installed/out-of-date state. Drives the `INSTALLED · N`
|
||||
// group, the per-row out-of-date badge, and the detail header chip from a
|
||||
// real server signal (design §3.2 + §5 / PAP-10256).
|
||||
const installedQuery = useQuery({
|
||||
queryKey: queryKeys.teamCatalog.installed(selectedCompanyId ?? ""),
|
||||
queryFn: () => teamCatalogApi.installed(selectedCompanyId!),
|
||||
enabled: Boolean(selectedCompanyId),
|
||||
});
|
||||
|
||||
const installedById = useMemo(() => {
|
||||
const map = new Map<string, InstalledCatalogTeam>();
|
||||
for (const entry of installedQuery.data ?? []) {
|
||||
if (entry.present) map.set(entry.catalogId, entry);
|
||||
}
|
||||
return map;
|
||||
}, [installedQuery.data]);
|
||||
|
||||
function setFilterParam(key: string, value: string | null) {
|
||||
setSearchParams((current) => {
|
||||
const next = new URLSearchParams(current);
|
||||
@@ -2204,11 +2269,15 @@ export function TeamCatalog() {
|
||||
|
||||
const anyFilterActive = q !== "" || kindFilter !== "all" || categoryFilter !== "" || riskFilter !== "any";
|
||||
|
||||
// Installed teams collapse under a single `INSTALLED · N` group and drop out
|
||||
// of their BUNDLED/OPTIONAL home (design §5 "Already installed").
|
||||
const grouped = useMemo(() => {
|
||||
const bundled = filtered.filter((t) => t.kind === "bundled");
|
||||
const optional = filtered.filter((t) => t.kind === "optional");
|
||||
return { bundled, optional };
|
||||
}, [filtered]);
|
||||
const installed = filtered.filter((t) => installedById.has(t.id));
|
||||
const remaining = filtered.filter((t) => !installedById.has(t.id));
|
||||
const bundled = remaining.filter((t) => t.kind === "bundled");
|
||||
const optional = remaining.filter((t) => t.kind === "optional");
|
||||
return { bundled, optional, installed };
|
||||
}, [filtered, installedById]);
|
||||
|
||||
const canInstall = true; // server enforces; UI shows the affordance to operators
|
||||
|
||||
@@ -2374,6 +2443,22 @@ export function TeamCatalog() {
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{grouped.installed.length > 0 && (
|
||||
<>
|
||||
<div className="px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Installed · {grouped.installed.length}
|
||||
</div>
|
||||
{grouped.installed.map((team) => (
|
||||
<TeamRow
|
||||
key={team.id}
|
||||
team={team}
|
||||
selected={team.id === selectedTeam?.id}
|
||||
onSelect={() => navigate(teamRoute(team.id))}
|
||||
installed={installedById.get(team.id) ?? null}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -2405,6 +2490,7 @@ export function TeamCatalog() {
|
||||
onInstall={() => setInstallOpen(true)}
|
||||
canInstall={canInstall}
|
||||
fileContent={fileQuery.data?.content ?? null}
|
||||
installed={installedById.get(selectedTeam.id) ?? null}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
@@ -2424,6 +2510,11 @@ export function TeamCatalog() {
|
||||
onClose={() => setInstallOpen(false)}
|
||||
onInstalled={() => {
|
||||
pushToast({ tone: "success", title: "Team installed", body: `${selectedTeam.name} was imported.` });
|
||||
// Provenance now lives on the new agents — refresh installed/out-of-date state.
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.teamCatalog.installed(selectedCompanyId),
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId) });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -17,8 +17,10 @@ import {
|
||||
TeamRow,
|
||||
} from "@/pages/TeamCatalog";
|
||||
import {
|
||||
currentInstalledState,
|
||||
onboardingTeams,
|
||||
optionalTeam,
|
||||
outOfDateInstalledState,
|
||||
sampleTeam as baseTeam,
|
||||
warnTeam,
|
||||
} from "@/pages/TeamCatalog.fixtures";
|
||||
@@ -169,6 +171,32 @@ export const DetailPane: Story = {
|
||||
),
|
||||
};
|
||||
|
||||
// PAP-10256: installed/out-of-date surface driven by the server signal.
|
||||
export const InstalledStates: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="w-[28rem] border border-border">
|
||||
<div className="px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Bundled · 1</div>
|
||||
<TeamRow team={optionalTeam} selected={false} onSelect={noop} />
|
||||
<div className="px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Installed · 2</div>
|
||||
<TeamRow team={baseTeam} selected onSelect={noop} installed={outOfDateInstalledState} />
|
||||
<TeamRow team={warnTeam} selected={false} onSelect={noop} installed={currentInstalledState} />
|
||||
</div>
|
||||
<div className="h-[760px] overflow-hidden border border-border">
|
||||
<TeamDetailPane
|
||||
team={baseTeam}
|
||||
selectedPath={null}
|
||||
onSelectFile={noop}
|
||||
onInstall={noop}
|
||||
canInstall
|
||||
fileContent={null}
|
||||
installed={outOfDateInstalledState}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const InstallTargetManager: Story = {
|
||||
render: function Render() {
|
||||
const [managerId, setManagerId] = useState<string | null>(null);
|
||||
|
||||
Reference in new issue
Block a user