mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
feat(artifacts): star documents from headers/cards + Starred tab (PAP-14811)
P2 UI for per-user document stars (parent PAP-14541, on top of P1
PAP-14810 server/contract). All star affordances reuse StarToggle and the
resource-membership optimistic machinery.
- api/hooks: add resourceMembershipsApi.updateDocument (PUT
.../resource-memberships/me/documents/:id { starred }) and a
useDocumentStarMutation hook with an applyDocumentStarChange optimistic
update over starredDocumentIds/documentStarredAt.
- IssueDocumentsSection: StarToggle in the DocumentFrameHeader actionsSlot
(covers IssueDetail, CaseDetail, SkillStudio).
- ArtifactCard: star on document-source cards (id decodes to document:<id>),
visible-at-rest when starred, hover-reveal otherwise.
- ArtifactsPanel DocumentViewer: star in the viewer header.
- Artifacts page: URL-driven Starred tab (?starred=1) that composes with q,
resets kind + grouping (documents-only V1), with a dedicated empty state.
Tests: artifacts query serialization, Starred-tab param wiring, and the
optimistic flip + rollback for the document star mutation. UI typecheck green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
c39d350de6
commit
07d2c4d379
12 files changed
+589
-50
No files matched your search
@@ -75,6 +75,18 @@ describe("artifactsApi.list", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("omits the starred param when false", async () => {
|
||||
await artifactsApi.list("company-1", { starred: false });
|
||||
expect(mockApi.get).toHaveBeenCalledWith("/companies/company-1/artifacts");
|
||||
});
|
||||
|
||||
it("serializes starred as the boolean-coercible string and composes with search", async () => {
|
||||
await artifactsApi.list("company-1", { starred: true, q: "plan" });
|
||||
expect(mockApi.get).toHaveBeenCalledWith(
|
||||
"/companies/company-1/artifacts?q=plan&starred=true",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves groups and selectedGroup from the envelope", async () => {
|
||||
const artifact = sampleArtifact();
|
||||
const group = {
|
||||
|
||||
@@ -39,6 +39,11 @@ export interface ListArtifactsParams {
|
||||
groupBy?: CompanyArtifactGroupBy;
|
||||
/** When grouping, selects a single stack to expand into its artifacts. */
|
||||
groupIssueId?: string;
|
||||
/**
|
||||
* Restrict to artifacts the current viewer has starred (documents only in V1).
|
||||
* Requires a board viewer server-side; other actors get an empty list.
|
||||
*/
|
||||
starred?: boolean;
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
}
|
||||
@@ -50,6 +55,10 @@ function buildArtifactsQuery(params?: ListArtifactsParams): string {
|
||||
if (params?.q) search.set("q", params.q);
|
||||
if (params?.groupBy && params.groupBy !== "none") search.set("groupBy", params.groupBy);
|
||||
if (params?.groupIssueId) search.set("groupIssueId", params.groupIssueId);
|
||||
// Server coerces the string "true" into the boolean filter (see
|
||||
// companyArtifactsQuerySchema); the page's shareable `?starred=1` param is
|
||||
// translated here so the two encodings stay decoupled.
|
||||
if (params?.starred) search.set("starred", "true");
|
||||
if (params?.limit != null) search.set("limit", String(params.limit));
|
||||
if (params?.cursor) search.set("cursor", params.cursor);
|
||||
const qs = search.toString();
|
||||
|
||||
@@ -18,4 +18,9 @@ export const resourceMembershipsApi = {
|
||||
`/companies/${companyId}/resource-memberships/me/agents/${agentId}`,
|
||||
data,
|
||||
),
|
||||
updateDocument: (companyId: string, documentId: string, data: { starred: boolean }) =>
|
||||
api.put<ResourceMembershipUpdateResult>(
|
||||
`/companies/${companyId}/resource-memberships/me/documents/${documentId}`,
|
||||
data,
|
||||
),
|
||||
};
|
||||
@@ -4,6 +4,8 @@ import type { IssueWorkProduct } from "@paperclipai/shared";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
import { StarToggle } from "./StarToggle";
|
||||
import { isStarred, useDocumentStarMutation, useResourceMemberships } from "../hooks/useResourceMemberships";
|
||||
import { cn } from "../lib/utils";
|
||||
import {
|
||||
FileText,
|
||||
@@ -264,6 +266,9 @@ function DocumentViewer({
|
||||
queryFn: () => issuesApi.getDocument(taskId, docKey),
|
||||
});
|
||||
|
||||
const membershipsQuery = useResourceMemberships(doc?.companyId ?? null);
|
||||
const documentStarMutation = useDocumentStarMutation(doc?.companyId ?? null);
|
||||
|
||||
const needsAction = status === "ready_for_review" || reviewState === "needs_board_review";
|
||||
const isApproved = status === "approved" || reviewState === "approved";
|
||||
const isRejected = status === "changes_requested" || reviewState === "changes_requested";
|
||||
@@ -275,6 +280,25 @@ function DocumentViewer({
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<h3 className="text-sm font-semibold flex-1 truncate">{title}</h3>
|
||||
{doc ? (
|
||||
<StarToggle
|
||||
size="row"
|
||||
starred={isStarred(membershipsQuery.data, "document", doc.id)}
|
||||
pending={
|
||||
documentStarMutation.isPending &&
|
||||
documentStarMutation.variables?.documentId === doc.id
|
||||
}
|
||||
error={
|
||||
documentStarMutation.isError &&
|
||||
documentStarMutation.variables?.documentId === doc.id
|
||||
}
|
||||
resourceName={title}
|
||||
revealClassName="opacity-100"
|
||||
onToggle={(next) =>
|
||||
documentStarMutation.mutate({ documentId: doc.id, documentName: title, starred: next })
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<button onClick={onBack} className="text-muted-foreground hover:text-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
@@ -11,6 +11,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { IssueDocumentsSection } from "./IssueDocumentsSection";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
|
||||
// The document StarToggle uses the shared resource-membership mutation, which
|
||||
// pulls in toast feedback; stub the throwing hook so these provider-less renders
|
||||
// don't need a ToastProvider (all other real exports stay intact).
|
||||
vi.mock("../context/ToastContext", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../context/ToastContext")>()),
|
||||
useToastActions: () => ({ pushToast: vi.fn(), dismissToast: vi.fn() }),
|
||||
}));
|
||||
|
||||
const mockIssuesApi = vi.hoisted(() => ({
|
||||
listDocuments: vi.fn(),
|
||||
listDocumentRevisions: vi.fn(),
|
||||
|
||||
@@ -38,6 +38,8 @@ import { Check, Copy, Diff, Download, FilePenLine, FileText, Lock, MoreHorizonta
|
||||
import { DocumentDiffModal } from "./DocumentDiffModal";
|
||||
import { DocumentFrameHeader, type DocumentFrameHeaderRevisionActor } from "./DocumentFrameHeader";
|
||||
import { SourceTrustBadge } from "./SourceTrustBadge";
|
||||
import { StarToggle } from "./StarToggle";
|
||||
import { isStarred, useDocumentStarMutation, useResourceMemberships } from "../hooks/useResourceMemberships";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
type DraftState = {
|
||||
@@ -451,6 +453,12 @@ export function IssueDocumentsSection({
|
||||
return map;
|
||||
}, [feedbackVotes]);
|
||||
|
||||
// Per-viewer document stars. All documents in a section share a company, so we
|
||||
// derive it once and drive a single optimistic mutation keyed by document id.
|
||||
const starCompanyId = sortedDocuments[0]?.companyId ?? null;
|
||||
const membershipsQuery = useResourceMemberships(starCompanyId);
|
||||
const documentStarMutation = useDocumentStarMutation(starCompanyId);
|
||||
|
||||
const hasRealPlan = sortedDocuments.some((doc) => doc.key === "plan");
|
||||
const isEmpty = sortedDocuments.length === 0 && !documentSubject.legacyPlanDocument;
|
||||
const newDocumentKeyError =
|
||||
@@ -1058,6 +1066,27 @@ export function IssueDocumentsSection({
|
||||
titleSlot={showTitle ? <p className="mt-2 text-sm font-medium">{displayedTitle}</p> : null}
|
||||
actionsSlot={
|
||||
<>
|
||||
<StarToggle
|
||||
size="row"
|
||||
starred={isStarred(membershipsQuery.data, "document", doc.id)}
|
||||
pending={
|
||||
documentStarMutation.isPending &&
|
||||
documentStarMutation.variables?.documentId === doc.id
|
||||
}
|
||||
error={
|
||||
documentStarMutation.isError &&
|
||||
documentStarMutation.variables?.documentId === doc.id
|
||||
}
|
||||
resourceName={displayedTitle.trim() || doc.key}
|
||||
revealClassName="opacity-100"
|
||||
onToggle={(next) =>
|
||||
documentStarMutation.mutate({
|
||||
documentId: doc.id,
|
||||
documentName: displayedTitle.trim() || doc.key,
|
||||
starred: next,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{canManageDocumentLocks ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -3,6 +3,20 @@ import { Download, ExternalLink, Paperclip, Play } from "lucide-react";
|
||||
import type { CompanyArtifact } from "@/api/artifacts";
|
||||
import { Link } from "@/lib/router";
|
||||
import { cn, formatDate } from "@/lib/utils";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import {
|
||||
isStarred,
|
||||
useDocumentStarMutation,
|
||||
useResourceMemberships,
|
||||
} from "@/hooks/useResourceMemberships";
|
||||
import { StarToggle } from "@/components/StarToggle";
|
||||
|
||||
/** Document artifact ids are `document:<documents.id>`; other sources have no star. */
|
||||
function documentIdFromArtifactId(artifact: CompanyArtifact): string | null {
|
||||
if (artifact.source !== "document") return null;
|
||||
const prefix = "document:";
|
||||
return artifact.id.startsWith(prefix) ? artifact.id.slice(prefix.length) : null;
|
||||
}
|
||||
|
||||
interface ArtifactCardProps {
|
||||
artifact: CompanyArtifact;
|
||||
@@ -190,7 +204,35 @@ function SecondaryAction({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Star control for a document artifact card. Rendered only for `document`
|
||||
* sources (mounted conditionally so the membership hooks stay off non-document
|
||||
* cards). Reuses the shared row StarToggle + resource-membership optimistic
|
||||
* machinery. Lives outside the hover-reveal group so a starred document stays
|
||||
* visibly marked at rest while unstarred stars reveal on hover/focus.
|
||||
*/
|
||||
function ArtifactCardStar({ documentId, name }: { documentId: string; name: string }) {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const membershipsQuery = useResourceMemberships(selectedCompanyId);
|
||||
const starMutation = useDocumentStarMutation(selectedCompanyId);
|
||||
const starred = isStarred(membershipsQuery.data, "document", documentId);
|
||||
const pending = starMutation.isPending && starMutation.variables?.documentId === documentId;
|
||||
const error = starMutation.isError && starMutation.variables?.documentId === documentId;
|
||||
|
||||
return (
|
||||
<StarToggle
|
||||
size="row"
|
||||
starred={starred}
|
||||
pending={pending}
|
||||
error={error}
|
||||
resourceName={name}
|
||||
onToggle={(next) => starMutation.mutate({ documentId, documentName: name, starred: next })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArtifactCard({ artifact }: ArtifactCardProps) {
|
||||
const documentId = documentIdFromArtifactId(artifact);
|
||||
return (
|
||||
<Link
|
||||
// design-allow(card-pattern): navigation <Link> card; Card renders a div and would break anchor semantics (C5a Run 3)
|
||||
@@ -210,17 +252,20 @@ export function ArtifactCard({ artifact }: ArtifactCardProps) {
|
||||
>
|
||||
{artifact.title}
|
||||
</h3>
|
||||
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
||||
{artifact.openPath ? (
|
||||
<SecondaryAction href={artifact.openPath} title="Open file in new tab">
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</SecondaryAction>
|
||||
) : null}
|
||||
{artifact.downloadPath ? (
|
||||
<SecondaryAction href={artifact.downloadPath} download title="Download file">
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</SecondaryAction>
|
||||
) : null}
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
{documentId ? <ArtifactCardStar documentId={documentId} name={artifact.title} /> : null}
|
||||
<div className="flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
||||
{artifact.openPath ? (
|
||||
<SecondaryAction href={artifact.openPath} title="Open file in new tab">
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</SecondaryAction>
|
||||
) : null}
|
||||
{artifact.downloadPath ? (
|
||||
<SecondaryAction href={artifact.downloadPath} download title="Download file">
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</SecondaryAction>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ResourceMemberships } from "@paperclipai/shared";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const membershipsApiMock = vi.hoisted(() => ({
|
||||
listMine: vi.fn(),
|
||||
updateDocument: vi.fn(),
|
||||
}));
|
||||
|
||||
const pushToast = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../api/resourceMemberships", () => ({
|
||||
resourceMembershipsApi: membershipsApiMock,
|
||||
}));
|
||||
|
||||
vi.mock("../context/ToastContext", () => ({
|
||||
useToastActions: () => ({ pushToast }),
|
||||
}));
|
||||
|
||||
import { useDocumentStarMutation, useResourceMemberships } from "./useResourceMemberships";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
|
||||
const MINE_KEY = queryKeys.resourceMemberships.mine("company-1");
|
||||
|
||||
function emptyMemberships(): ResourceMemberships {
|
||||
return {
|
||||
projectMemberships: {},
|
||||
agentMemberships: {},
|
||||
starredProjectIds: [],
|
||||
starredAgentIds: [],
|
||||
starredDocumentIds: [],
|
||||
projectStarredAt: {},
|
||||
agentStarredAt: {},
|
||||
documentStarredAt: {},
|
||||
updatedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function act(callback: () => void | Promise<void>) {
|
||||
let result: void | Promise<void> = undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
await result;
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
type HarnessHandle = { mutate: ReturnType<typeof useDocumentStarMutation>["mutate"] };
|
||||
|
||||
function Harness({ onReady }: { onReady: (handle: HarnessHandle) => void }) {
|
||||
useResourceMemberships("company-1");
|
||||
const mutation = useDocumentStarMutation("company-1");
|
||||
onReady({ mutate: mutation.mutate });
|
||||
return <div data-testid="harness" />;
|
||||
}
|
||||
|
||||
function starredIds(queryClient: QueryClient): string[] {
|
||||
return queryClient.getQueryData<ResourceMemberships>(MINE_KEY)?.starredDocumentIds ?? [];
|
||||
}
|
||||
|
||||
describe("useDocumentStarMutation", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot> | null;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = null;
|
||||
membershipsApiMock.listMine.mockReset();
|
||||
membershipsApiMock.updateDocument.mockReset();
|
||||
pushToast.mockReset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) {
|
||||
await act(async () => { root?.unmount(); });
|
||||
}
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function render(onReady: (handle: HarnessHandle) => void) {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root!.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Harness onReady={onReady} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
// Let the initial memberships query settle into the cache.
|
||||
await act(async () => { await flush(); });
|
||||
return queryClient;
|
||||
}
|
||||
|
||||
it("optimistically stars a document before the server responds and confirms on success", async () => {
|
||||
membershipsApiMock.listMine.mockResolvedValue(emptyMemberships());
|
||||
let resolveUpdate: (() => void) | null = null;
|
||||
membershipsApiMock.updateDocument.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveUpdate = () =>
|
||||
resolve({
|
||||
resourceType: "document",
|
||||
resourceId: "doc-1",
|
||||
state: "joined",
|
||||
starredAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
let handle: HarnessHandle | null = null;
|
||||
const queryClient = await render((h) => { handle = h; });
|
||||
expect(starredIds(queryClient)).not.toContain("doc-1");
|
||||
|
||||
await act(async () => {
|
||||
handle!.mutate({ documentId: "doc-1", documentName: "Launch Brief", starred: true });
|
||||
await flush();
|
||||
});
|
||||
|
||||
// Optimistic flip is applied to the cache while the request is still in flight.
|
||||
expect(starredIds(queryClient)).toContain("doc-1");
|
||||
expect(membershipsApiMock.updateDocument).toHaveBeenCalledWith("company-1", "doc-1", { starred: true });
|
||||
|
||||
// Server truth now reflects the star; the post-settle refetch stays consistent.
|
||||
membershipsApiMock.listMine.mockResolvedValue({
|
||||
...emptyMemberships(),
|
||||
starredDocumentIds: ["doc-1"],
|
||||
documentStarredAt: { "doc-1": new Date().toISOString() },
|
||||
});
|
||||
await act(async () => {
|
||||
resolveUpdate?.();
|
||||
await flush();
|
||||
});
|
||||
// Server confirmation (starredAt set) keeps the star in place.
|
||||
expect(starredIds(queryClient)).toContain("doc-1");
|
||||
});
|
||||
|
||||
it("rolls back the optimistic unstar and toasts when the server rejects", async () => {
|
||||
membershipsApiMock.listMine.mockResolvedValue({
|
||||
...emptyMemberships(),
|
||||
starredDocumentIds: ["doc-1"],
|
||||
documentStarredAt: { "doc-1": new Date().toISOString() },
|
||||
});
|
||||
membershipsApiMock.updateDocument.mockRejectedValue(new Error("nope"));
|
||||
|
||||
let handle: HarnessHandle | null = null;
|
||||
const queryClient = await render((h) => { handle = h; });
|
||||
expect(starredIds(queryClient)).toContain("doc-1");
|
||||
|
||||
await act(async () => {
|
||||
handle!.mutate({ documentId: "doc-1", documentName: "Launch Brief", starred: false });
|
||||
await flush();
|
||||
});
|
||||
|
||||
// Rolled back to the pre-mutation (starred) snapshot after the rejection.
|
||||
expect(starredIds(queryClient)).toContain("doc-1");
|
||||
expect(pushToast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: "Couldn't unstar Launch Brief.", tone: "error" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -98,6 +98,40 @@ function applyMembershipChange(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an optimistic document star change to the cached memberships snapshot.
|
||||
* Documents have no join/leave state — they only carry a per-viewer star, so
|
||||
* this mirrors the `document` half of the server rules: starring prepends the id
|
||||
* (newest-first, matching starredAt DESC) and stamps `documentStarredAt`;
|
||||
* unstarring drops both.
|
||||
*/
|
||||
function applyDocumentStarChange(
|
||||
current: ResourceMemberships | undefined,
|
||||
documentId: string,
|
||||
starred: boolean,
|
||||
): ResourceMemberships {
|
||||
const base = current ?? emptyMemberships();
|
||||
const currentIds = base.starredDocumentIds ?? [];
|
||||
const nextStarredAt = { ...(base.documentStarredAt ?? {}) };
|
||||
const previouslyStarred = currentIds.includes(documentId);
|
||||
|
||||
let starredIds = currentIds;
|
||||
if (starred && !previouslyStarred) {
|
||||
starredIds = [documentId, ...currentIds];
|
||||
nextStarredAt[documentId] = new Date().toISOString();
|
||||
} else if (!starred && previouslyStarred) {
|
||||
starredIds = currentIds.filter((id) => id !== documentId);
|
||||
delete nextStarredAt[documentId];
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
starredDocumentIds: starredIds,
|
||||
documentStarredAt: nextStarredAt,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
export function resourceMembershipState(
|
||||
memberships: ResourceMemberships | undefined,
|
||||
resourceType: JoinableResourceType,
|
||||
@@ -197,3 +231,59 @@ export function useResourceMembershipMutation(companyId: string | null | undefin
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type DocumentStarVariables = {
|
||||
documentId: string;
|
||||
/** Human-readable document name, used for the error toast. */
|
||||
documentName: string;
|
||||
starred: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Optimistic star/unstar for documents. Mirrors {@link useResourceMembershipMutation}
|
||||
* but talks to the document-only route (documents have no join/leave state) and
|
||||
* touches the `starredDocumentIds` / `documentStarredAt` cache fields.
|
||||
*/
|
||||
export function useDocumentStarMutation(companyId: string | null | undefined) {
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToastActions();
|
||||
const queryKey = queryKeys.resourceMemberships.mine(companyId ?? "__none__");
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (variables: DocumentStarVariables) => {
|
||||
if (!companyId) throw new Error("Select a company first.");
|
||||
return resourceMembershipsApi.updateDocument(companyId, variables.documentId, {
|
||||
starred: variables.starred,
|
||||
});
|
||||
},
|
||||
onMutate: async (variables) => {
|
||||
await queryClient.cancelQueries({ queryKey });
|
||||
const previous = queryClient.getQueryData<ResourceMemberships>(queryKey);
|
||||
queryClient.setQueryData<ResourceMemberships>(
|
||||
queryKey,
|
||||
applyDocumentStarChange(previous, variables.documentId, variables.starred),
|
||||
);
|
||||
return { previous };
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(queryKey, context.previous);
|
||||
}
|
||||
pushToast({
|
||||
title: `Couldn't ${variables.starred ? "star" : "unstar"} ${variables.documentName}.`,
|
||||
body: error instanceof Error ? error.message : "Try again.",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
queryClient.setQueryData<ResourceMemberships>(
|
||||
queryKey,
|
||||
// Loose null-check: a missing or null starredAt both mean "not starred".
|
||||
(current) => applyDocumentStarChange(current, result.resourceId, result.starredAt != null),
|
||||
);
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -275,6 +275,7 @@ export const queryKeys = {
|
||||
q?: string,
|
||||
groupBy?: string,
|
||||
groupIssueId?: string,
|
||||
starred?: boolean,
|
||||
) =>
|
||||
[
|
||||
"artifacts",
|
||||
@@ -283,6 +284,7 @@ export const queryKeys = {
|
||||
q ?? "",
|
||||
groupBy ?? "none",
|
||||
groupIssueId ?? "",
|
||||
starred ? "starred" : "all",
|
||||
] as const,
|
||||
},
|
||||
budgets: {
|
||||
|
||||
@@ -182,6 +182,7 @@ describe("Artifacts page", () => {
|
||||
q: undefined,
|
||||
groupBy: "task",
|
||||
groupIssueId: undefined,
|
||||
starred: false,
|
||||
limit: 30,
|
||||
cursor: undefined,
|
||||
});
|
||||
@@ -228,6 +229,7 @@ describe("Artifacts page", () => {
|
||||
q: "launch",
|
||||
groupBy: "task",
|
||||
groupIssueId: undefined,
|
||||
starred: false,
|
||||
limit: 30,
|
||||
cursor: undefined,
|
||||
});
|
||||
@@ -285,6 +287,7 @@ describe("Artifacts page", () => {
|
||||
q: undefined,
|
||||
groupBy: "none",
|
||||
groupIssueId: undefined,
|
||||
starred: false,
|
||||
limit: 30,
|
||||
cursor: "cursor-2",
|
||||
});
|
||||
@@ -325,6 +328,7 @@ describe("Artifacts page", () => {
|
||||
q: undefined,
|
||||
groupBy: "none",
|
||||
groupIssueId: undefined,
|
||||
starred: false,
|
||||
limit: 30,
|
||||
cursor: undefined,
|
||||
});
|
||||
@@ -353,6 +357,7 @@ describe("Artifacts page", () => {
|
||||
q: undefined,
|
||||
groupBy: "task",
|
||||
groupIssueId: undefined,
|
||||
starred: false,
|
||||
limit: 30,
|
||||
cursor: undefined,
|
||||
});
|
||||
@@ -386,6 +391,7 @@ describe("Artifacts page", () => {
|
||||
q: undefined,
|
||||
groupBy: "task",
|
||||
groupIssueId: "issue-1",
|
||||
starred: false,
|
||||
limit: 30,
|
||||
cursor: undefined,
|
||||
});
|
||||
@@ -402,6 +408,95 @@ describe("Artifacts page", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("requests starred documents from the ?starred=1 URL, ignoring kind and grouping", async () => {
|
||||
artifactsApiMock.list.mockResolvedValue({
|
||||
artifacts: [sampleArtifact({ id: "document:doc-1", title: "Starred Brief" })],
|
||||
nextCursor: null,
|
||||
});
|
||||
|
||||
const { root } = renderArtifacts(container, ["/artifacts?starred=1&kind=image&groupBy=task"]);
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(artifactsApiMock.list).toHaveBeenCalledWith("company-1", {
|
||||
kind: "all",
|
||||
q: undefined,
|
||||
groupBy: "none",
|
||||
groupIssueId: undefined,
|
||||
starred: true,
|
||||
limit: 30,
|
||||
cursor: undefined,
|
||||
});
|
||||
const starredTab = container.querySelector('[data-testid="artifact-starred-tab"]') as HTMLElement;
|
||||
expect(starredTab.getAttribute("aria-selected")).toBe("true");
|
||||
// The grouping control is hidden in the documents-only Starred view.
|
||||
expect(container.querySelector('[data-testid="artifact-group-control"]')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="artifact-card"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
flushSync(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("composes the search query with the Starred filter", async () => {
|
||||
artifactsApiMock.list.mockResolvedValue({ artifacts: [], nextCursor: null });
|
||||
|
||||
const { root } = renderArtifacts(container, ["/artifacts?starred=1&q=launch"]);
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(artifactsApiMock.list).toHaveBeenCalledWith("company-1", {
|
||||
kind: "all",
|
||||
q: "launch",
|
||||
groupBy: "none",
|
||||
groupIssueId: undefined,
|
||||
starred: true,
|
||||
limit: 30,
|
||||
cursor: undefined,
|
||||
});
|
||||
// Empty state guides the user to star a document.
|
||||
expect(container.textContent).toContain("No starred documents match this search.");
|
||||
});
|
||||
|
||||
flushSync(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("enters the Starred view when the Starred tab is clicked", async () => {
|
||||
artifactsApiMock.list.mockResolvedValue({ artifacts: [], groups: [sampleGroup()], nextCursor: null });
|
||||
|
||||
const { root } = renderArtifacts(container);
|
||||
|
||||
const starredTab = await (async () => {
|
||||
let tab: HTMLElement | null = null;
|
||||
await waitForAssertion(() => {
|
||||
tab = container.querySelector('[data-testid="artifact-starred-tab"]');
|
||||
expect(tab).not.toBeNull();
|
||||
});
|
||||
return tab as unknown as HTMLElement;
|
||||
})();
|
||||
|
||||
flushSync(() => {
|
||||
starredTab.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(artifactsApiMock.list).toHaveBeenLastCalledWith("company-1", {
|
||||
kind: "all",
|
||||
q: undefined,
|
||||
groupBy: "none",
|
||||
groupIssueId: undefined,
|
||||
starred: true,
|
||||
limit: 30,
|
||||
cursor: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
flushSync(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the media filter when grouping", async () => {
|
||||
artifactsApiMock.list.mockResolvedValue({
|
||||
artifacts: [],
|
||||
@@ -417,6 +512,7 @@ describe("Artifacts page", () => {
|
||||
q: undefined,
|
||||
groupBy: "task",
|
||||
groupIssueId: undefined,
|
||||
starred: false,
|
||||
limit: 30,
|
||||
cursor: undefined,
|
||||
});
|
||||
|
||||
+83
-39
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { ArrowLeft, Check, Layers, Package, Search, X } from "lucide-react";
|
||||
import { ArrowLeft, Check, Layers, Package, Search, Star, X } from "lucide-react";
|
||||
import type { To } from "react-router-dom";
|
||||
import {
|
||||
artifactsApi,
|
||||
@@ -66,10 +66,13 @@ export function Artifacts() {
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const kind = parseKind(searchParams.get("kind"));
|
||||
const starred = searchParams.get("starred") === "1";
|
||||
// Starred is documents-only in V1: it ignores the kind filter and grouping so
|
||||
// the tab always renders a flat list of the viewer's starred documents.
|
||||
const kind = starred ? "all" : parseKind(searchParams.get("kind"));
|
||||
const query = searchParams.get("q") ?? "";
|
||||
const groupBy = parseGroupBy(searchParams.get("groupBy"));
|
||||
const groupIssueId = searchParams.get("groupIssueId") ?? undefined;
|
||||
const groupBy = starred ? "none" : parseGroupBy(searchParams.get("groupBy"));
|
||||
const groupIssueId = starred ? undefined : (searchParams.get("groupIssueId") ?? undefined);
|
||||
|
||||
const [draftQuery, setDraftQuery] = useState(query);
|
||||
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -117,6 +120,9 @@ export function Artifacts() {
|
||||
const selectKind = useCallback(
|
||||
(value: ArtifactKindFilter) => {
|
||||
updateParams((next) => {
|
||||
// Selecting any kind tab exits the Starred view (they are mutually
|
||||
// exclusive: Starred resets the kind filter in V1).
|
||||
next.delete("starred");
|
||||
if (value === "all") next.delete("kind");
|
||||
else next.set("kind", value);
|
||||
});
|
||||
@@ -124,6 +130,17 @@ export function Artifacts() {
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
const selectStarred = useCallback(() => {
|
||||
updateParams((next) => {
|
||||
// Starred is documents-only: drop the kind filter and any grouping so the
|
||||
// tab is a clean, shareable flat list (`?starred=1` composes with `q`).
|
||||
next.set("starred", "1");
|
||||
next.delete("kind");
|
||||
next.delete("groupBy");
|
||||
next.delete("groupIssueId");
|
||||
});
|
||||
}, [updateParams]);
|
||||
|
||||
const selectGroupBy = useCallback(
|
||||
(value: ArtifactGroupBy) => {
|
||||
updateParams((next) => {
|
||||
@@ -177,13 +194,14 @@ export function Artifacts() {
|
||||
fetchNextPage,
|
||||
error,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: queryKeys.artifacts.list(selectedCompanyId!, kind, query, groupBy, groupIssueId),
|
||||
queryKey: queryKeys.artifacts.list(selectedCompanyId!, kind, query, groupBy, groupIssueId, starred),
|
||||
queryFn: ({ pageParam }) =>
|
||||
artifactsApi.list(selectedCompanyId!, {
|
||||
kind,
|
||||
q: query || undefined,
|
||||
groupBy,
|
||||
groupIssueId,
|
||||
starred,
|
||||
limit: ARTIFACTS_PAGE_SIZE,
|
||||
cursor: pageParam,
|
||||
}),
|
||||
@@ -233,7 +251,11 @@ export function Artifacts() {
|
||||
const showGroupCards = viewingStackList;
|
||||
const items = showGroupCards ? groups : artifacts;
|
||||
|
||||
const emptyMessage = showGroupCards
|
||||
const emptyMessage = starred
|
||||
? searching
|
||||
? "No starred documents match this search."
|
||||
: "No starred documents yet — star a document from its header or card."
|
||||
: showGroupCards
|
||||
? searching
|
||||
? "No artifact stacks match this search."
|
||||
: "No artifact stacks yet."
|
||||
@@ -270,37 +292,40 @@ export function Artifacts() {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label={`Group artifacts (currently ${artifactGroupByLabel(groupBy)})`}
|
||||
title="Group artifacts"
|
||||
data-testid="artifact-group-control"
|
||||
data-group-by={groupBy}
|
||||
className={cn("h-8 w-8 shrink-0", grouping && "bg-accent")}
|
||||
>
|
||||
<Layers className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuLabel>Group by</DropdownMenuLabel>
|
||||
{ARTIFACT_GROUP_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
data-testid={`artifact-group-option-${option.value}`}
|
||||
aria-selected={groupBy === option.value}
|
||||
onSelect={() => selectGroupBy(option.value)}
|
||||
className="justify-between"
|
||||
{/* Grouping is meaningless in the documents-only Starred view. */}
|
||||
{!starred ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label={`Group artifacts (currently ${artifactGroupByLabel(groupBy)})`}
|
||||
title="Group artifacts"
|
||||
data-testid="artifact-group-control"
|
||||
data-group-by={groupBy}
|
||||
className={cn("h-8 w-8 shrink-0", grouping && "bg-accent")}
|
||||
>
|
||||
{option.label}
|
||||
{groupBy === option.value ? <Check className="h-3.5 w-3.5" /> : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Layers className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuLabel>Group by</DropdownMenuLabel>
|
||||
{ARTIFACT_GROUP_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
data-testid={`artifact-group-option-${option.value}`}
|
||||
aria-selected={groupBy === option.value}
|
||||
onSelect={() => selectGroupBy(option.value)}
|
||||
className="justify-between"
|
||||
>
|
||||
{option.label}
|
||||
{groupBy === option.value ? <Check className="h-3.5 w-3.5" /> : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5" role="tablist" aria-label="Filter artifacts by type">
|
||||
{ARTIFACT_KIND_FILTERS.map((filter) => (
|
||||
@@ -308,11 +333,11 @@ export function Artifacts() {
|
||||
key={filter.value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={kind === filter.value}
|
||||
aria-selected={!starred && kind === filter.value}
|
||||
onClick={() => selectKind(filter.value)}
|
||||
className={cn(
|
||||
"rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
kind === filter.value
|
||||
!starred && kind === filter.value
|
||||
? "bg-accent text-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||
)}
|
||||
@@ -320,6 +345,25 @@ export function Artifacts() {
|
||||
{filter.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={starred}
|
||||
onClick={selectStarred}
|
||||
data-testid="artifact-starred-tab"
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
starred
|
||||
? "bg-accent text-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Star
|
||||
className={cn("h-3.5 w-3.5", starred && "fill-amber-500 text-amber-500")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Starred
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -348,7 +392,7 @@ export function Artifacts() {
|
||||
{isLoading ? (
|
||||
<PageSkeleton variant="list" />
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState icon={showGroupCards ? Layers : Package} message={emptyMessage} />
|
||||
<EmptyState icon={starred ? Star : showGroupCards ? Layers : Package} message={emptyMessage} />
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
|
||||
Reference in new issue
Block a user