mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-06 14:02:36 +01:00
Deferred polish from the PAP-10568 UX review. First-class `resolved` suggestion status (backend): - Add `resolved` to DOCUMENT_SUGGESTION_STATUSES + resolved-by/at audit columns (schema + migration 0101) and a `resolvedSuggestions` count. - New POST .../suggestions/:id/resolve endpoint + service method that sets status without editing the document, distinct from reject (disagreement). - Wire the rail's Resolve action to the new endpoint; show a Resolved badge. Mobile FAB safe-area inset (§5.8): lift the review FAB above the iOS home indicator with bottom-[max(env(safe-area-inset-bottom),1rem)]. Platform-aware hotkey hint: RailEmpty shows Ctrl+Shift+M on Windows/Linux and ⌘⇧M on macOS/iOS via a new isApplePlatform() helper. (Autosave indicator wiring is left for when document autosave lands — the header slot already exists.) Tests: resolveSuggestion service test (terminal status, no revision created), SuggestionCard resolved-badge test, platform-aware empty-state hint test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
153 lines
5.4 KiB
TypeScript
153 lines
5.4 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { flushSync } from "react-dom";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { DocumentReviewIndexCounts } from "@paperclipai/shared";
|
|
import { DoneReviewingDialog, type DoneReviewingHandoff } from "./DoneReviewingDialog";
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
async function act(callback: () => void | Promise<void>) {
|
|
let result: void | Promise<void> = undefined;
|
|
flushSync(() => {
|
|
result = callback();
|
|
});
|
|
await result;
|
|
}
|
|
|
|
let container: HTMLDivElement;
|
|
let root: Root;
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
});
|
|
|
|
afterEach(() => {
|
|
act(() => root.unmount());
|
|
container.remove();
|
|
});
|
|
|
|
function counts(overrides: Partial<DocumentReviewIndexCounts> = {}): DocumentReviewIndexCounts {
|
|
return {
|
|
unresolved: 0,
|
|
openAnchoredThreads: 2,
|
|
openReviewThreads: 1,
|
|
pendingSuggestions: 3,
|
|
resolvedAnchoredThreads: 0,
|
|
resolvedReviewThreads: 0,
|
|
acceptedSuggestions: 0,
|
|
rejectedSuggestions: 0,
|
|
resolvedSuggestions: 0,
|
|
staleAnchors: 1,
|
|
orphanedAnchors: 4,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
// Radix Dialog portals into document.body, so query there rather than `container`.
|
|
function q<T extends Element = HTMLElement>(selector: string): T | null {
|
|
return document.body.querySelector<T>(selector);
|
|
}
|
|
|
|
function setTextarea(el: HTMLTextAreaElement, value: string) {
|
|
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")?.set;
|
|
setter?.call(el, value);
|
|
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
}
|
|
|
|
describe("DoneReviewingDialog", () => {
|
|
it("renders the review summary counts", async () => {
|
|
await act(() =>
|
|
root.render(
|
|
<DoneReviewingDialog open onOpenChange={() => {}} counts={counts()} onSubmit={() => {}} />,
|
|
),
|
|
);
|
|
expect(q('[data-testid="done-reviewing-dialog"]')).not.toBeNull();
|
|
// Open comments = anchored(2) + review(1) = 3
|
|
expect(q('[data-testid="done-reviewing-count-open-comments"]')?.textContent).toBe("3");
|
|
expect(q('[data-testid="done-reviewing-count-suggestions"]')?.textContent).toBe("3");
|
|
expect(q('[data-testid="done-reviewing-count-stale"]')?.textContent).toBe("1");
|
|
expect(q('[data-testid="done-reviewing-count-orphaned"]')?.textContent).toBe("4");
|
|
});
|
|
|
|
it("submits with default toggles and the trimmed overall comment", async () => {
|
|
const onSubmit = vi.fn<(h: DoneReviewingHandoff) => void>();
|
|
await act(() =>
|
|
root.render(<DoneReviewingDialog open onOpenChange={() => {}} counts={counts()} onSubmit={onSubmit} />),
|
|
);
|
|
|
|
const textarea = q<HTMLTextAreaElement>('[data-testid="done-reviewing-overall"]')!;
|
|
await act(() => setTextarea(textarea, " ship it "));
|
|
await act(() => q<HTMLButtonElement>('[data-testid="done-reviewing-submit"]')!.click());
|
|
|
|
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
expect(onSubmit.mock.calls[0][0]).toEqual({
|
|
overallComment: "ship it",
|
|
commentOnLinkedIssue: true,
|
|
wakeOwner: true,
|
|
});
|
|
});
|
|
|
|
it("'Just close' closes without submitting", async () => {
|
|
const onSubmit = vi.fn();
|
|
const onOpenChange = vi.fn();
|
|
await act(() =>
|
|
root.render(
|
|
<DoneReviewingDialog open onOpenChange={onOpenChange} counts={counts()} onSubmit={onSubmit} />,
|
|
),
|
|
);
|
|
await act(() => q<HTMLButtonElement>('[data-testid="done-reviewing-just-close"]')!.click());
|
|
expect(onSubmit).not.toHaveBeenCalled();
|
|
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
});
|
|
|
|
it("disables the owner wake toggle and forces wakeOwner false when no owner", async () => {
|
|
const onSubmit = vi.fn<(h: DoneReviewingHandoff) => void>();
|
|
await act(() =>
|
|
root.render(
|
|
<DoneReviewingDialog
|
|
open
|
|
onOpenChange={() => {}}
|
|
counts={counts()}
|
|
canWakeOwner={false}
|
|
onSubmit={onSubmit}
|
|
/>,
|
|
),
|
|
);
|
|
const wakeToggle = q<HTMLButtonElement>('[data-testid="done-reviewing-toggle-wake"]')!;
|
|
expect(wakeToggle.disabled).toBe(true);
|
|
expect(wakeToggle.getAttribute("aria-checked")).toBe("false");
|
|
|
|
await act(() => q<HTMLButtonElement>('[data-testid="done-reviewing-submit"]')!.click());
|
|
expect(onSubmit.mock.calls[0][0].wakeOwner).toBe(false);
|
|
});
|
|
|
|
it("lets the reviewer turn off the linked-issue comment", async () => {
|
|
const onSubmit = vi.fn<(h: DoneReviewingHandoff) => void>();
|
|
await act(() =>
|
|
root.render(<DoneReviewingDialog open onOpenChange={() => {}} counts={counts()} onSubmit={onSubmit} />),
|
|
);
|
|
await act(() => q<HTMLButtonElement>('[data-testid="done-reviewing-toggle-comment"]')!.click());
|
|
await act(() => q<HTMLButtonElement>('[data-testid="done-reviewing-submit"]')!.click());
|
|
expect(onSubmit.mock.calls[0][0].commentOnLinkedIssue).toBe(false);
|
|
});
|
|
|
|
it("shows the clean-handoff hint when there is no open feedback", async () => {
|
|
await act(() =>
|
|
root.render(
|
|
<DoneReviewingDialog
|
|
open
|
|
onOpenChange={() => {}}
|
|
counts={counts({ openAnchoredThreads: 0, openReviewThreads: 0, pendingSuggestions: 0, staleAnchors: 0, orphanedAnchors: 0 })}
|
|
onSubmit={() => {}}
|
|
/>,
|
|
),
|
|
);
|
|
expect(q('[data-testid="done-reviewing-clean"]')).not.toBeNull();
|
|
});
|
|
});
|