mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
Add company artifacts gallery
This commit is contained in:
1 parent
96b98c58e8
commit
4e9f878805
10 files changed
+370
No files matched your search
@@ -92,4 +92,25 @@ describe("workProductService", () => {
|
||||
expect(txUpdate).toHaveBeenCalledTimes(2);
|
||||
expect(result?.reviewState).toBe("ready_for_review");
|
||||
});
|
||||
|
||||
it("lists company work products filtered by type and limit", async () => {
|
||||
const rows = [
|
||||
createWorkProductRow({ id: "artifact-2", type: "artifact", updatedAt: new Date("2026-03-18T00:00:00.000Z") }),
|
||||
createWorkProductRow({ id: "artifact-1", type: "artifact", updatedAt: new Date("2026-03-17T00:00:00.000Z") }),
|
||||
];
|
||||
const limit = vi.fn(async () => rows);
|
||||
const orderBy = vi.fn(() => ({ limit }));
|
||||
const where = vi.fn(() => ({ orderBy }));
|
||||
const from = vi.fn(() => ({ where }));
|
||||
const select = vi.fn(() => ({ from }));
|
||||
|
||||
const svc = workProductService({ select } as any);
|
||||
const result = await svc.listForCompany("company-1", { type: "artifact", limit: 2 });
|
||||
|
||||
expect(select).toHaveBeenCalledTimes(1);
|
||||
expect(where).toHaveBeenCalledTimes(1);
|
||||
expect(orderBy).toHaveBeenCalledTimes(1);
|
||||
expect(limit).toHaveBeenCalledWith(2);
|
||||
expect(result.map((item) => item.id)).toEqual(["artifact-2", "artifact-1"]);
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
feedbackTraceStatusSchema,
|
||||
feedbackVoteValueSchema,
|
||||
upsertIssueFeedbackVoteSchema,
|
||||
issueWorkProductTypeSchema,
|
||||
linkIssueApprovalSchema,
|
||||
issueDocumentKeySchema,
|
||||
ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
|
||||
@@ -868,6 +869,32 @@ export function issueRoutes(
|
||||
res.json(workProducts);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/work-products", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
||||
const typeRaw = typeof req.query.type === "string" ? req.query.type.trim() : "";
|
||||
const type = typeRaw
|
||||
? issueWorkProductTypeSchema.safeParse(typeRaw)
|
||||
: { success: true as const, data: undefined };
|
||||
if (!type.success) {
|
||||
res.status(400).json({ error: "Invalid work product type", details: type.error.issues });
|
||||
return;
|
||||
}
|
||||
|
||||
const limitRaw = typeof req.query.limit === "string" ? Number.parseInt(req.query.limit, 10) : null;
|
||||
if (limitRaw !== null && (!Number.isFinite(limitRaw) || limitRaw <= 0)) {
|
||||
res.status(400).json({ error: "Invalid limit" });
|
||||
return;
|
||||
}
|
||||
|
||||
const workProducts = await workProductsSvc.listForCompany(companyId, {
|
||||
type: type.data,
|
||||
limit: limitRaw ?? undefined,
|
||||
});
|
||||
res.json(workProducts);
|
||||
});
|
||||
|
||||
router.get("/issues/:id/documents", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
|
||||
@@ -32,6 +32,32 @@ function toIssueWorkProduct(row: IssueWorkProductRow): IssueWorkProduct {
|
||||
|
||||
export function workProductService(db: Db) {
|
||||
return {
|
||||
listForCompany: async (
|
||||
companyId: string,
|
||||
filters?: {
|
||||
type?: IssueWorkProduct["type"];
|
||||
limit?: number;
|
||||
},
|
||||
) => {
|
||||
const query = db
|
||||
.select()
|
||||
.from(issueWorkProducts)
|
||||
.where(
|
||||
filters?.type
|
||||
? and(
|
||||
eq(issueWorkProducts.companyId, companyId),
|
||||
eq(issueWorkProducts.type, filters.type),
|
||||
)
|
||||
: eq(issueWorkProducts.companyId, companyId),
|
||||
)
|
||||
.orderBy(desc(issueWorkProducts.updatedAt));
|
||||
|
||||
const rows = filters?.limit && filters.limit > 0
|
||||
? await query.limit(filters.limit)
|
||||
: await query;
|
||||
return rows.map(toIssueWorkProduct);
|
||||
},
|
||||
|
||||
listForIssue: async (issueId: string) => {
|
||||
const rows = await db
|
||||
.select()
|
||||
|
||||
@@ -18,6 +18,7 @@ import { RoutineDetail } from "./pages/RoutineDetail";
|
||||
import { UserProfile } from "./pages/UserProfile";
|
||||
import { ExecutionWorkspaceDetail } from "./pages/ExecutionWorkspaceDetail";
|
||||
import { Goals } from "./pages/Goals";
|
||||
import { Artifacts } from "./pages/Artifacts";
|
||||
import { GoalDetail } from "./pages/GoalDetail";
|
||||
import { Approvals } from "./pages/Approvals";
|
||||
import { ApprovalDetail } from "./pages/ApprovalDetail";
|
||||
@@ -106,6 +107,7 @@ function boardRoutes() {
|
||||
<Route path="execution-workspaces/:workspaceId/runtime-logs" element={<ExecutionWorkspaceDetail />} />
|
||||
<Route path="execution-workspaces/:workspaceId/issues" element={<ExecutionWorkspaceDetail />} />
|
||||
<Route path="goals" element={<Goals />} />
|
||||
<Route path="artifacts" element={<Artifacts />} />
|
||||
<Route path="goals/:goalId" element={<GoalDetail />} />
|
||||
<Route path="approvals" element={<Navigate to="/approvals/pending" replace />} />
|
||||
<Route path="approvals/pending" element={<Approvals />} />
|
||||
|
||||
@@ -29,4 +29,12 @@ describe("issuesApi.list", () => {
|
||||
|
||||
expect(mockApi.get).toHaveBeenCalledWith("/issues/issue-1/deliverables");
|
||||
});
|
||||
|
||||
it("calls the company work products endpoint with filters", async () => {
|
||||
await issuesApi.listCompanyWorkProducts("company-1", { type: "artifact", limit: 24 });
|
||||
|
||||
expect(mockApi.get).toHaveBeenCalledWith(
|
||||
"/companies/company-1/work-products?type=artifact&limit=24",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -165,6 +165,19 @@ export const issuesApi = {
|
||||
unlinkApproval: (id: string, approvalId: string) =>
|
||||
api.delete<{ ok: true }>(`/issues/${id}/approvals/${approvalId}`),
|
||||
getDeliverables: (id: string) => api.get<IssueDeliverablesResponse>(`/issues/${id}/deliverables`),
|
||||
listCompanyWorkProducts: (
|
||||
companyId: string,
|
||||
filters?: {
|
||||
type?: string;
|
||||
limit?: number;
|
||||
},
|
||||
) => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.type) params.set("type", filters.type);
|
||||
if (filters?.limit) params.set("limit", String(filters.limit));
|
||||
const qs = params.toString();
|
||||
return api.get<IssueWorkProduct[]>(`/companies/${companyId}/work-products${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
listWorkProducts: (id: string) => api.get<IssueWorkProduct[]>(`/issues/${id}/work-products`),
|
||||
createWorkProduct: (id: string, data: Record<string, unknown>) =>
|
||||
api.post<IssueWorkProduct>(`/issues/${id}/work-products`, data),
|
||||
|
||||
@@ -8,6 +8,7 @@ interface PageSkeletonProps {
|
||||
| "dashboard"
|
||||
| "approvals"
|
||||
| "costs"
|
||||
| "grid"
|
||||
| "inbox"
|
||||
| "org-chart";
|
||||
}
|
||||
@@ -72,6 +73,26 @@ export function PageSkeleton({ variant = "list" }: PageSkeletonProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "grid") {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-36" />
|
||||
<Skeleton className="h-4 w-72" />
|
||||
</div>
|
||||
<Skeleton className="h-7 w-24" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 2xl:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-[28rem] w-full rounded-2xl" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "inbox") {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
Package,
|
||||
Inbox,
|
||||
CircleDot,
|
||||
Target,
|
||||
@@ -104,6 +105,7 @@ export function Sidebar() {
|
||||
{showWorkspacesLink ? (
|
||||
<SidebarNavItem to="/workspaces" label="Workspaces" icon={GitBranch} />
|
||||
) : null}
|
||||
<SidebarNavItem to="/artifacts" label="Artifacts" icon={Package} />
|
||||
</SidebarSection>
|
||||
|
||||
<SidebarProjects />
|
||||
|
||||
@@ -81,6 +81,9 @@ export const queryKeys = {
|
||||
list: (companyId: string) => ["goals", companyId] as const,
|
||||
detail: (id: string) => ["goals", "detail", id] as const,
|
||||
},
|
||||
artifacts: {
|
||||
list: (companyId: string) => ["artifacts", companyId] as const,
|
||||
},
|
||||
budgets: {
|
||||
overview: (companyId: string) => ["budgets", "overview", companyId] as const,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "@/lib/router";
|
||||
import type { IssueWorkProduct } from "@paperclipai/shared";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
|
||||
import { projectUrl, relativeTime } from "../lib/utils";
|
||||
import { ArrowUpRight, CircleDot, Download, FileImage, Hexagon, Package } from "lucide-react";
|
||||
|
||||
function labelize(value: string | null | undefined) {
|
||||
if (!value) return null;
|
||||
return value.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
function readMetadataString(product: IssueWorkProduct, key: string) {
|
||||
const value = product.metadata?.[key];
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
function readMetadataNumber(product: IssueWorkProduct, key: string) {
|
||||
const value = product.metadata?.[key];
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function formatByteSize(byteSize: number | null) {
|
||||
if (!byteSize || byteSize <= 0) return null;
|
||||
if (byteSize < 1024 * 1024) return `${(byteSize / 1024).toFixed(1)} KB`;
|
||||
return `${(byteSize / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function isImageArtifact(product: IssueWorkProduct) {
|
||||
const contentType = readMetadataString(product, "contentType");
|
||||
return Boolean(contentType?.startsWith("image/"));
|
||||
}
|
||||
|
||||
function ArtifactCard({
|
||||
artifact,
|
||||
issueTitle,
|
||||
issueRef,
|
||||
projectTitle,
|
||||
projectHref,
|
||||
}: {
|
||||
artifact: IssueWorkProduct;
|
||||
issueTitle: string;
|
||||
issueRef: string;
|
||||
projectTitle: string | null;
|
||||
projectHref: string | null;
|
||||
}) {
|
||||
const contentType = readMetadataString(artifact, "contentType");
|
||||
const sizeLabel = formatByteSize(readMetadataNumber(artifact, "byteSize"));
|
||||
const href = artifact.url;
|
||||
const imagePreview = isImageArtifact(artifact) && href ? href : null;
|
||||
|
||||
return (
|
||||
<article className="group overflow-hidden rounded-2xl border border-border bg-card shadow-sm transition-shadow hover:shadow-md">
|
||||
{imagePreview ? (
|
||||
<div className="aspect-[16/10] overflow-hidden border-b border-border bg-muted/40">
|
||||
<img
|
||||
src={imagePreview}
|
||||
alt={artifact.title}
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.02]"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex aspect-[16/10] items-center justify-center border-b border-border bg-gradient-to-br from-muted/70 via-background to-muted/40">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
{contentType?.startsWith("image/") ? (
|
||||
<FileImage className="h-10 w-10 text-muted-foreground" />
|
||||
) : (
|
||||
<Package className="h-10 w-10 text-muted-foreground" />
|
||||
)}
|
||||
<p className="max-w-[14rem] text-xs text-muted-foreground">
|
||||
{contentType ?? "Artifact"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">Artifact</Badge>
|
||||
<Badge variant="secondary">{artifact.provider}</Badge>
|
||||
{artifact.status ? <Badge variant="secondary">{labelize(artifact.status)}</Badge> : null}
|
||||
{artifact.isPrimary ? <Badge variant="secondary">Primary</Badge> : null}
|
||||
{sizeLabel ? <Badge variant="secondary">{sizeLabel}</Badge> : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="line-clamp-2 text-base font-semibold">{artifact.title}</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Updated {relativeTime(artifact.updatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{artifact.summary ? (
|
||||
<p className="line-clamp-3 text-sm text-muted-foreground">{artifact.summary}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 rounded-xl border border-border/70 bg-muted/20 p-3">
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<CircleDot className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Issue</p>
|
||||
<Link to={createIssueDetailPath(issueRef)} className="line-clamp-2 font-medium hover:underline">
|
||||
{issueTitle}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{projectHref && projectTitle ? (
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<Hexagon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Project</p>
|
||||
<Link to={projectHref} className="line-clamp-1 font-medium hover:underline">
|
||||
{projectTitle}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{href ? (
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<a href={href} target="_blank" rel="noreferrer">
|
||||
{imagePreview ? <ArrowUpRight className="mr-1.5 h-4 w-4" /> : <Download className="mr-1.5 h-4 w-4" />}
|
||||
{imagePreview ? "Open" : "Download"}
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button asChild size="sm" variant="ghost">
|
||||
<Link to={createIssueDetailPath(issueRef)}>Open issue</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function Artifacts() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([{ label: "Artifacts" }]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
const { data: artifacts, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.artifacts.list(selectedCompanyId!),
|
||||
queryFn: () => issuesApi.listCompanyWorkProducts(selectedCompanyId!, { type: "artifact" }),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const { data: issues } = useQuery({
|
||||
queryKey: queryKeys.issues.list(selectedCompanyId!),
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const { data: projects } = useQuery({
|
||||
queryKey: queryKeys.projects.list(selectedCompanyId!),
|
||||
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const issueMap = useMemo(() => {
|
||||
const map = new Map<string, { title: string; ref: string }>();
|
||||
for (const issue of issues ?? []) {
|
||||
map.set(issue.id, {
|
||||
title: issue.title,
|
||||
ref: issue.identifier ?? issue.id,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}, [issues]);
|
||||
|
||||
const projectMap = useMemo(() => {
|
||||
const map = new Map<string, { title: string; href: string }>();
|
||||
for (const project of projects ?? []) {
|
||||
map.set(project.id, {
|
||||
title: project.name,
|
||||
href: projectUrl(project),
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}, [projects]);
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <EmptyState icon={Package} message="Select a company to view artifacts." />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <PageSkeleton variant="grid" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold tracking-tight">Artifacts</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Generated issue outputs across the company, sorted by most recent update.
|
||||
</p>
|
||||
</div>
|
||||
{artifacts && artifacts.length > 0 ? (
|
||||
<Badge variant="secondary" className="h-fit px-3 py-1 text-xs font-medium">
|
||||
{artifacts.length} artifact{artifacts.length === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? <p className="text-sm text-destructive">{error.message}</p> : null}
|
||||
|
||||
{!isLoading && (artifacts?.length ?? 0) === 0 ? (
|
||||
<EmptyState icon={Package} message="No artifacts yet." />
|
||||
) : null}
|
||||
|
||||
{artifacts && artifacts.length > 0 ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 2xl:grid-cols-3">
|
||||
{artifacts.map((artifact) => {
|
||||
const issue = issueMap.get(artifact.issueId);
|
||||
const project = artifact.projectId ? projectMap.get(artifact.projectId) ?? null : null;
|
||||
return (
|
||||
<ArtifactCard
|
||||
key={artifact.id}
|
||||
artifact={artifact}
|
||||
issueTitle={issue?.title ?? artifact.issueId.slice(0, 8)}
|
||||
issueRef={issue?.ref ?? artifact.issueId}
|
||||
projectTitle={project?.title ?? null}
|
||||
projectHref={project?.href ?? null}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user