mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 22:24:16 +01:00
Show document annotation save failures
This commit is contained in:
1 parent
4625611ffc
commit
b2090ec351
2 files changed
+151
-19
No files matched your search
@@ -105,6 +105,7 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [composerValue, setComposerValue] = useState("");
|
||||
const [replyDrafts, setReplyDrafts] = useState<Record<string, string>>({});
|
||||
const [mutationError, setMutationError] = useState<string | null>(null);
|
||||
const composerRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const bodyTestId = props.isMobile ? "document-annotation-panel" : undefined;
|
||||
const annotationTarget = useMemo<DocumentAnnotationTarget>(() => {
|
||||
@@ -182,6 +183,7 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
|
||||
onMutate: async (body: string) => {
|
||||
const anchor = props.pendingAnchor;
|
||||
if (!anchor || !props.baseRevisionId) return undefined;
|
||||
setMutationError(null);
|
||||
await queryClient.cancelQueries({ queryKey: annotationsQueryKey });
|
||||
const previous = queryClient.getQueryData<DocumentAnnotationThreadWithComments[]>(annotationsQueryKey);
|
||||
const optimisticThread = buildOptimisticThread({
|
||||
@@ -199,16 +201,16 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
|
||||
annotationsQueryKey,
|
||||
(current) => [...(current ?? []), optimisticThread],
|
||||
);
|
||||
// Clear the composer immediately — the optimistic thread now carries the text.
|
||||
props.onClearPendingAnchor();
|
||||
setComposerValue("");
|
||||
props.onFocusThread(optimisticThread.id);
|
||||
return { previous, optimisticId: optimisticThread.id };
|
||||
},
|
||||
onError: (_error, _body, context) => {
|
||||
onError: (error, _body, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(annotationsQueryKey, context.previous);
|
||||
}
|
||||
setMutationError(error instanceof Error && error.message
|
||||
? error.message
|
||||
: "Failed to create comment.");
|
||||
},
|
||||
onSuccess: (thread, _body, context) => {
|
||||
// Swap the optimistic placeholder for the real thread before refetch settles.
|
||||
@@ -218,6 +220,9 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
|
||||
entry.id === context?.optimisticId ? thread : entry,
|
||||
),
|
||||
);
|
||||
props.onClearPendingAnchor();
|
||||
setComposerValue("");
|
||||
setMutationError(null);
|
||||
props.onFocusThread(thread.id);
|
||||
},
|
||||
onSettled: () => invalidateAll(),
|
||||
@@ -228,6 +233,7 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
|
||||
documentAnnotationsApi.addCommentForTarget(annotationTarget, threadId, { body }),
|
||||
// Optimistically append the reply so it stays on screen through the round-trip.
|
||||
onMutate: async ({ threadId, body }) => {
|
||||
setMutationError(null);
|
||||
await queryClient.cancelQueries({ queryKey: annotationsQueryKey });
|
||||
const previous = queryClient.getQueryData<DocumentAnnotationThreadWithComments[]>(annotationsQueryKey);
|
||||
const optimisticComment = buildOptimisticComment({
|
||||
@@ -244,13 +250,19 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
|
||||
: thread,
|
||||
),
|
||||
);
|
||||
setReplyDrafts((current) => ({ ...current, [threadId]: "" }));
|
||||
return { previous };
|
||||
},
|
||||
onError: (_error, _variables, context) => {
|
||||
onError: (error, _variables, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(annotationsQueryKey, context.previous);
|
||||
}
|
||||
setMutationError(error instanceof Error && error.message
|
||||
? error.message
|
||||
: "Failed to add reply.");
|
||||
},
|
||||
onSuccess: (_comment, variables) => {
|
||||
setReplyDrafts((current) => ({ ...current, [variables.threadId]: "" }));
|
||||
setMutationError(null);
|
||||
},
|
||||
onSettled: () => invalidateAll(),
|
||||
});
|
||||
@@ -259,6 +271,7 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
|
||||
mutationFn: ({ threadId, status }: { threadId: string; status: DocumentAnnotationThreadStatus }) =>
|
||||
documentAnnotationsApi.updateStatusForTarget(annotationTarget, threadId, status),
|
||||
onMutate: async ({ threadId, status }) => {
|
||||
setMutationError(null);
|
||||
await queryClient.cancelQueries({ queryKey: annotationsQueryKey });
|
||||
const previous = queryClient.getQueryData<DocumentAnnotationThreadWithComments[]>(annotationsQueryKey);
|
||||
queryClient.setQueryData<DocumentAnnotationThreadWithComments[]>(
|
||||
@@ -269,11 +282,15 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
|
||||
);
|
||||
return { previous };
|
||||
},
|
||||
onError: (_error, _variables, context) => {
|
||||
onError: (error, _variables, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(annotationsQueryKey, context.previous);
|
||||
}
|
||||
setMutationError(error instanceof Error && error.message
|
||||
? error.message
|
||||
: "Failed to update comment status.");
|
||||
},
|
||||
onSuccess: () => setMutationError(null),
|
||||
onSettled: () => invalidateAll(),
|
||||
});
|
||||
|
||||
@@ -336,6 +353,14 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
|
||||
{props.newCommentDisabledReason}
|
||||
</p>
|
||||
) : null}
|
||||
{mutationError ? (
|
||||
<p
|
||||
data-testid="document-annotation-error"
|
||||
className="border-b border-border bg-destructive/10 px-3 py-1.5 text-[11px] text-destructive"
|
||||
>
|
||||
{mutationError}
|
||||
</p>
|
||||
) : null}
|
||||
<div ref={listScrollRef} className="min-h-0 flex-1 overflow-y-auto bg-popover px-3 py-2">
|
||||
{visibleThreads.length === 0 ? null : (
|
||||
<ul className="space-y-2">
|
||||
@@ -409,11 +434,6 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
|
||||
disabled={props.newCommentDisabled}
|
||||
className="resize-y rounded-none text-sm"
|
||||
/>
|
||||
{createThread.isError ? (
|
||||
<p className="mt-1 text-xs text-destructive">
|
||||
{(createThread.error as Error).message || "Failed to create comment"}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-2 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -13,13 +13,31 @@ import {
|
||||
IssueDocumentAnnotations,
|
||||
} from "./IssueDocumentAnnotations";
|
||||
|
||||
const mockAnnotationsApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
get: vi.fn(),
|
||||
create: vi.fn(),
|
||||
addComment: vi.fn(),
|
||||
updateStatus: vi.fn(),
|
||||
}));
|
||||
const mockAnnotationsApi = vi.hoisted(() => {
|
||||
const api = {
|
||||
list: vi.fn(),
|
||||
listForTarget: vi.fn(),
|
||||
get: vi.fn(),
|
||||
getForTarget: vi.fn(),
|
||||
create: vi.fn(),
|
||||
createForTarget: vi.fn(),
|
||||
addComment: vi.fn(),
|
||||
addCommentForTarget: vi.fn(),
|
||||
updateStatus: vi.fn(),
|
||||
updateStatusForTarget: vi.fn(),
|
||||
};
|
||||
api.listForTarget.mockImplementation((target, options) =>
|
||||
target.kind === "issue" ? api.list(target.issueId, target.documentKey, options) : api.list(target.routineId, target.documentKey, options));
|
||||
api.getForTarget.mockImplementation((target, threadId) =>
|
||||
target.kind === "issue" ? api.get(target.issueId, target.documentKey, threadId) : api.get(target.routineId, target.documentKey, threadId));
|
||||
api.createForTarget.mockImplementation((target, data) =>
|
||||
target.kind === "issue" ? api.create(target.issueId, target.documentKey, data) : api.create(target.routineId, target.documentKey, data));
|
||||
api.addCommentForTarget.mockImplementation((target, threadId, data) =>
|
||||
target.kind === "issue" ? api.addComment(target.issueId, target.documentKey, threadId, data) : api.addComment(target.routineId, target.documentKey, threadId, data));
|
||||
api.updateStatusForTarget.mockImplementation((target, threadId, status) =>
|
||||
target.kind === "issue" ? api.updateStatus(target.issueId, target.documentKey, threadId, status) : api.updateStatus(target.routineId, target.documentKey, threadId, status));
|
||||
return api;
|
||||
});
|
||||
|
||||
const mockPendingAnchor = vi.hoisted(() => ({
|
||||
selector: {
|
||||
@@ -714,6 +732,54 @@ describe("IssueDocumentAnnotations", () => {
|
||||
expect(mockAnnotationsApi.list.mock.calls.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("keeps the composer visible with the draft when creating a thread fails", async () => {
|
||||
mockAnnotationsApi.list.mockResolvedValue([]);
|
||||
mockAnnotationsApi.create.mockRejectedValue(new Error("Annotation anchor does not match the current document revision"));
|
||||
const root = createRoot(container);
|
||||
const queryClient = makeQueryClient();
|
||||
const doc = makeDoc();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Harness doc={doc} initialPanelOpen />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const selectButton = container.querySelector('[data-testid="mock-annotation-selection"]') as HTMLButtonElement | null;
|
||||
expect(selectButton).not.toBeNull();
|
||||
await act(async () => {
|
||||
selectButton!.click();
|
||||
});
|
||||
await flush();
|
||||
|
||||
const composer = container.querySelector('[data-testid="document-annotation-composer"]') as HTMLTextAreaElement | null;
|
||||
expect(composer).not.toBeNull();
|
||||
await act(async () => {
|
||||
setTextareaValue(composer!, "New anchored comment");
|
||||
});
|
||||
await flush();
|
||||
|
||||
const submit = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Comment",
|
||||
);
|
||||
expect(submit).not.toBeUndefined();
|
||||
await act(async () => {
|
||||
submit!.click();
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const composerAfterFailure = container.querySelector('[data-testid="document-annotation-composer"]') as HTMLTextAreaElement | null;
|
||||
expect(composerAfterFailure).not.toBeNull();
|
||||
expect(composerAfterFailure!.value).toBe("New anchored comment");
|
||||
expect(container.querySelector('[data-testid="document-annotation-error"]')?.textContent)
|
||||
.toContain("Annotation anchor does not match the current document revision");
|
||||
});
|
||||
|
||||
it("submits a new anchored comment with ⌘↵", async () => {
|
||||
mockAnnotationsApi.list.mockResolvedValue([]);
|
||||
mockAnnotationsApi.create.mockResolvedValue(makeThread({ id: "created-1" }));
|
||||
@@ -787,6 +853,52 @@ describe("IssueDocumentAnnotations", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a reply draft visible when submitting the reply fails", async () => {
|
||||
mockAnnotationsApi.list.mockResolvedValue([makeThread({ id: "open-1" })]);
|
||||
mockAnnotationsApi.addComment.mockRejectedValue(new Error("Failed to add reply"));
|
||||
const root = createRoot(container);
|
||||
const queryClient = makeQueryClient();
|
||||
const doc = makeDoc();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Harness doc={doc} initialPanelOpen />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const openThread = container.querySelector('[data-thread-id="open-1"]') as HTMLElement | null;
|
||||
expect(openThread).not.toBeNull();
|
||||
await act(async () => openThread!.click());
|
||||
await flush();
|
||||
|
||||
const reply = container.querySelector(
|
||||
'[data-testid="document-annotation-reply-open-1"]',
|
||||
) as HTMLTextAreaElement | null;
|
||||
expect(reply).not.toBeNull();
|
||||
await act(async () => setTextareaValue(reply!, "Reply should stay visible"));
|
||||
await flush();
|
||||
|
||||
const replyButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Reply",
|
||||
);
|
||||
expect(replyButton).not.toBeUndefined();
|
||||
await act(async () => replyButton!.click());
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const replyAfterFailure = container.querySelector(
|
||||
'[data-testid="document-annotation-reply-open-1"]',
|
||||
) as HTMLTextAreaElement | null;
|
||||
expect(replyAfterFailure).not.toBeNull();
|
||||
expect(replyAfterFailure!.value).toBe("Reply should stay visible");
|
||||
expect(container.querySelector('[data-testid="document-annotation-error"]')?.textContent)
|
||||
.toContain("Failed to add reply");
|
||||
});
|
||||
|
||||
it("shows resolve and reopen actions and updates thread status", async () => {
|
||||
mockAnnotationsApi.list.mockResolvedValue([
|
||||
makeThread({ id: "open-1" }),
|
||||
|
||||
Reference in new issue
Block a user