mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 02:40:39 +09:00
Add issue document revision restore flow
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
66aa65f8f7
commit
b0b9809732
13 changed files with 12345 additions and 141 deletions
|
|
@ -1,6 +1,6 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Issue, IssueDocument } from "@paperclipai/shared";
|
||||
import type { DocumentRevision, Issue, IssueDocument } from "@paperclipai/shared";
|
||||
import { useLocation } from "@/lib/router";
|
||||
import { ApiError } from "../api/client";
|
||||
import { issuesApi } from "../api/issues";
|
||||
|
|
@ -15,6 +15,9 @@ import {
|
|||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
|
@ -84,6 +87,17 @@ function downloadDocumentFile(key: string, body: string) {
|
|||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function getRevisionActorLabel(revision: DocumentRevision) {
|
||||
if (revision.createdByUserId) return "board";
|
||||
if (revision.createdByAgentId) return "agent";
|
||||
return "system";
|
||||
}
|
||||
|
||||
function documentHasUnsavedChanges(doc: IssueDocument, draft: DraftState | null) {
|
||||
if (!draft || draft.isNew || draft.key !== doc.key) return false;
|
||||
return draft.body !== doc.body || (doc.title ?? "") !== draft.title;
|
||||
}
|
||||
|
||||
export function IssueDocumentsSection({
|
||||
issue,
|
||||
canDeleteDocuments,
|
||||
|
|
@ -107,6 +121,8 @@ export function IssueDocumentsSection({
|
|||
const [autosaveDocumentKey, setAutosaveDocumentKey] = useState<string | null>(null);
|
||||
const [copiedDocumentKey, setCopiedDocumentKey] = useState<string | null>(null);
|
||||
const [highlightDocumentKey, setHighlightDocumentKey] = useState<string | null>(null);
|
||||
const [revisionMenuOpenKey, setRevisionMenuOpenKey] = useState<string | null>(null);
|
||||
const [selectedRevisionIds, setSelectedRevisionIds] = useState<Record<string, string | null>>({});
|
||||
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const copiedDocumentTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const hasScrolledToHashRef = useRef(false);
|
||||
|
|
@ -122,10 +138,28 @@ export function IssueDocumentsSection({
|
|||
queryFn: () => issuesApi.listDocuments(issue.id),
|
||||
});
|
||||
|
||||
const invalidateIssueDocuments = () => {
|
||||
const { data: activeDocumentRevisions, isFetching: isFetchingDocumentRevisions } = useQuery({
|
||||
queryKey: revisionMenuOpenKey
|
||||
? queryKeys.issues.documentRevisions(issue.id, revisionMenuOpenKey)
|
||||
: ["issues", "document-revisions", issue.id, "__idle__"],
|
||||
queryFn: async () => {
|
||||
if (!revisionMenuOpenKey) return [];
|
||||
return issuesApi.listDocumentRevisions(issue.id, revisionMenuOpenKey);
|
||||
},
|
||||
enabled: Boolean(revisionMenuOpenKey),
|
||||
});
|
||||
|
||||
const invalidateIssueDocuments = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.documents(issue.id) });
|
||||
};
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
Array.isArray(query.queryKey)
|
||||
&& query.queryKey[0] === "issues"
|
||||
&& query.queryKey[1] === "document-revisions"
|
||||
&& query.queryKey[2] === issue.id,
|
||||
});
|
||||
}, [issue.id, queryClient]);
|
||||
|
||||
const upsertDocument = useMutation({
|
||||
mutationFn: async (nextDraft: DraftState) =>
|
||||
|
|
@ -149,6 +183,22 @@ export function IssueDocumentsSection({
|
|||
},
|
||||
});
|
||||
|
||||
const restoreDocumentRevision = useMutation({
|
||||
mutationFn: ({ key, revisionId }: { key: string; revisionId: string }) =>
|
||||
issuesApi.restoreDocumentRevision(issue.id, key, revisionId),
|
||||
onSuccess: (_document, variables) => {
|
||||
setSelectedRevisionIds((current) => ({ ...current, [variables.key]: null }));
|
||||
setDraft((current) => current?.key === variables.key ? null : current);
|
||||
setDocumentConflict((current) => current?.key === variables.key ? null : current);
|
||||
resetAutosaveState();
|
||||
setError(null);
|
||||
invalidateIssueDocuments();
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err instanceof Error ? err.message : "Failed to restore document revision");
|
||||
},
|
||||
});
|
||||
|
||||
const sortedDocuments = useMemo(() => {
|
||||
return [...(documents ?? [])].sort((a, b) => {
|
||||
if (a.key === "plan" && b.key !== "plan") return -1;
|
||||
|
|
@ -391,6 +441,38 @@ export function IssueDocumentsSection({
|
|||
}
|
||||
}, []);
|
||||
|
||||
const getDocumentRevisions = useCallback((key: string) => {
|
||||
const cached = queryClient.getQueryData<DocumentRevision[]>(queryKeys.issues.documentRevisions(issue.id, key));
|
||||
if (cached) return cached;
|
||||
if (revisionMenuOpenKey === key) return activeDocumentRevisions ?? [];
|
||||
return [];
|
||||
}, [activeDocumentRevisions, issue.id, queryClient, revisionMenuOpenKey]);
|
||||
|
||||
const returnToLatestRevision = useCallback((key: string) => {
|
||||
setSelectedRevisionIds((current) => ({ ...current, [key]: null }));
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const previewRevision = useCallback((doc: IssueDocument, revisionId: string) => {
|
||||
const revisions = getDocumentRevisions(doc.key);
|
||||
const selectedRevision = revisions.find((revision) => revision.id === revisionId);
|
||||
if (!selectedRevision) return;
|
||||
if (selectedRevision.id === doc.latestRevisionId) {
|
||||
returnToLatestRevision(doc.key);
|
||||
return;
|
||||
}
|
||||
if (documentConflict?.key === doc.key || documentHasUnsavedChanges(doc, draft)) {
|
||||
setError("Save or cancel your local changes before viewing an older revision.");
|
||||
return;
|
||||
}
|
||||
resetAutosaveState();
|
||||
setDraft((current) => current?.key === doc.key ? null : current);
|
||||
setDocumentConflict((current) => current?.key === doc.key ? null : current);
|
||||
setFoldedDocumentKeys((current) => current.filter((entry) => entry !== doc.key));
|
||||
setSelectedRevisionIds((current) => ({ ...current, [doc.key]: selectedRevision.id }));
|
||||
setError(null);
|
||||
}, [documentConflict, draft, getDocumentRevisions, resetAutosaveState, returnToLatestRevision]);
|
||||
|
||||
const handleDraftBlur = async (event: React.FocusEvent<HTMLDivElement>) => {
|
||||
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
|
||||
if (autosaveDebounceRef.current) {
|
||||
|
|
@ -623,7 +705,19 @@ export function IssueDocumentsSection({
|
|||
const activeDraft = draft?.key === doc.key && !draft.isNew ? draft : null;
|
||||
const activeConflict = documentConflict?.key === doc.key ? documentConflict : null;
|
||||
const isFolded = foldedDocumentKeys.includes(doc.key);
|
||||
const showTitle = !isPlanKey(doc.key) && !!doc.title?.trim() && !titlesMatchKey(doc.title, doc.key);
|
||||
const revisionHistory = getDocumentRevisions(doc.key);
|
||||
const selectedRevisionId = selectedRevisionIds[doc.key] ?? null;
|
||||
const selectedHistoricalRevision = selectedRevisionId
|
||||
? revisionHistory.find((revision) => revision.id === selectedRevisionId) ?? null
|
||||
: null;
|
||||
const isHistoricalPreview = Boolean(selectedHistoricalRevision);
|
||||
const displayedTitle = selectedHistoricalRevision
|
||||
? selectedHistoricalRevision.title ?? ""
|
||||
: activeDraft?.title ?? doc.title ?? "";
|
||||
const displayedBody = selectedHistoricalRevision?.body ?? activeDraft?.body ?? doc.body;
|
||||
const displayedRevisionNumber = selectedHistoricalRevision?.revisionNumber ?? doc.latestRevisionNumber;
|
||||
const displayedUpdatedAt = selectedHistoricalRevision?.createdAt ?? doc.updatedAt;
|
||||
const showTitle = !isPlanKey(doc.key) && !!displayedTitle.trim() && !titlesMatchKey(displayedTitle, doc.key);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -649,14 +743,68 @@ export function IssueDocumentsSection({
|
|||
<span className="shrink-0 rounded-full border border-border px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{doc.key}
|
||||
</span>
|
||||
<DropdownMenu
|
||||
open={revisionMenuOpenKey === doc.key}
|
||||
onOpenChange={(open) => setRevisionMenuOpenKey(open ? doc.key : null)}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-auto px-1.5 py-0 text-[11px] font-normal text-muted-foreground hover:text-foreground",
|
||||
isHistoricalPreview && "text-amber-300 hover:text-amber-200",
|
||||
)}
|
||||
>
|
||||
rev {displayedRevisionNumber}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-72">
|
||||
<DropdownMenuLabel>Revision history</DropdownMenuLabel>
|
||||
{revisionMenuOpenKey === doc.key && isFetchingDocumentRevisions && revisionHistory.length === 0 ? (
|
||||
<DropdownMenuItem disabled>Loading revisions...</DropdownMenuItem>
|
||||
) : revisionHistory.length > 0 ? (
|
||||
<DropdownMenuRadioGroup value={selectedRevisionId ?? doc.latestRevisionId ?? ""}>
|
||||
{revisionHistory.map((revision) => {
|
||||
const isCurrentRevision = revision.id === doc.latestRevisionId;
|
||||
return (
|
||||
<DropdownMenuRadioItem
|
||||
key={revision.id}
|
||||
value={revision.id}
|
||||
onSelect={() => previewRevision(doc, revision.id)}
|
||||
className="items-start"
|
||||
>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">rev {revision.revisionNumber}</span>
|
||||
{isCurrentRevision ? (
|
||||
<span className="rounded-full border border-border px-1.5 py-0.5 text-[10px] uppercase tracking-[0.12em] text-muted-foreground">
|
||||
Current
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{relativeTime(revision.createdAt)} • {getRevisionActorLabel(revision)}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuRadioItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuRadioGroup>
|
||||
) : (
|
||||
<DropdownMenuItem disabled>No revisions yet</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<a
|
||||
href={`#document-${encodeURIComponent(doc.key)}`}
|
||||
className="truncate text-[11px] text-muted-foreground transition-colors hover:text-foreground hover:underline"
|
||||
>
|
||||
rev {doc.latestRevisionNumber} • updated {relativeTime(doc.updatedAt)}
|
||||
updated {relativeTime(displayedUpdatedAt)}
|
||||
</a>
|
||||
</div>
|
||||
{showTitle && <p className="mt-2 text-sm font-medium">{doc.title}</p>}
|
||||
{showTitle && <p className="mt-2 text-sm font-medium">{displayedTitle}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
|
|
@ -667,7 +815,7 @@ export function IssueDocumentsSection({
|
|||
copiedDocumentKey === doc.key && "text-foreground",
|
||||
)}
|
||||
title={copiedDocumentKey === doc.key ? "Copied" : "Copy document"}
|
||||
onClick={() => void copyDocumentBody(doc.key, activeDraft?.body ?? doc.body)}
|
||||
onClick={() => void copyDocumentBody(doc.key, displayedBody)}
|
||||
>
|
||||
{copiedDocumentKey === doc.key ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
|
|
@ -688,7 +836,7 @@ export function IssueDocumentsSection({
|
|||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => downloadDocumentFile(doc.key, activeDraft?.body ?? doc.body)}
|
||||
onClick={() => downloadDocumentFile(doc.key, displayedBody)}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
Download document
|
||||
|
|
@ -711,23 +859,64 @@ export function IssueDocumentsSection({
|
|||
{!isFolded ? (
|
||||
<div
|
||||
className="mt-3 space-y-3"
|
||||
onFocusCapture={() => {
|
||||
if (!activeDraft) {
|
||||
beginEdit(doc.key);
|
||||
}
|
||||
}}
|
||||
onBlurCapture={async (event) => {
|
||||
if (activeDraft) {
|
||||
await handleDraftBlur(event);
|
||||
}
|
||||
}}
|
||||
onKeyDown={async (event) => {
|
||||
if (activeDraft) {
|
||||
await handleDraftKeyDown(event);
|
||||
}
|
||||
}}
|
||||
onFocusCapture={!isHistoricalPreview
|
||||
? () => {
|
||||
if (!activeDraft) {
|
||||
beginEdit(doc.key);
|
||||
}
|
||||
}
|
||||
: undefined}
|
||||
onBlurCapture={!isHistoricalPreview
|
||||
? async (event) => {
|
||||
if (activeDraft) {
|
||||
await handleDraftBlur(event);
|
||||
}
|
||||
}
|
||||
: undefined}
|
||||
onKeyDown={!isHistoricalPreview
|
||||
? async (event) => {
|
||||
if (activeDraft) {
|
||||
await handleDraftKeyDown(event);
|
||||
}
|
||||
}
|
||||
: undefined}
|
||||
>
|
||||
{activeConflict && (
|
||||
{isHistoricalPreview && selectedHistoricalRevision && (
|
||||
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-amber-200">
|
||||
Viewing revision {selectedHistoricalRevision.revisionNumber}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This is a historical preview. Restoring it creates a new latest revision and keeps history append-only.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => returnToLatestRevision(doc.key)}
|
||||
>
|
||||
Return to latest
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => restoreDocumentRevision.mutate({
|
||||
key: doc.key,
|
||||
revisionId: selectedHistoricalRevision.id,
|
||||
})}
|
||||
disabled={restoreDocumentRevision.isPending}
|
||||
>
|
||||
{restoreDocumentRevision.isPending && restoreDocumentRevision.variables?.key === doc.key
|
||||
? "Restoring..."
|
||||
: "Restore this revision"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{activeConflict && !isHistoricalPreview && (
|
||||
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
|
|
@ -788,7 +977,7 @@ export function IssueDocumentsSection({
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
{activeDraft && !isPlanKey(doc.key) && (
|
||||
{activeDraft && !isPlanKey(doc.key) && !isHistoricalPreview && (
|
||||
<Input
|
||||
value={activeDraft.title}
|
||||
onChange={(event) => {
|
||||
|
|
@ -800,47 +989,57 @@ export function IssueDocumentsSection({
|
|||
)}
|
||||
<div
|
||||
className={`${documentBodyShellClassName} ${documentBodyPaddingClassName} ${
|
||||
activeDraft ? "" : "hover:bg-accent/10"
|
||||
activeDraft || isHistoricalPreview ? "" : "hover:bg-accent/10"
|
||||
}`}
|
||||
>
|
||||
<MarkdownEditor
|
||||
value={activeDraft?.body ?? doc.body}
|
||||
onChange={(body) => {
|
||||
markDocumentDirty(doc.key);
|
||||
setDraft((current) => {
|
||||
if (current && current.key === doc.key && !current.isNew) {
|
||||
return { ...current, body };
|
||||
}
|
||||
return {
|
||||
key: doc.key,
|
||||
title: doc.title ?? "",
|
||||
body,
|
||||
baseRevisionId: doc.latestRevisionId,
|
||||
isNew: false,
|
||||
};
|
||||
});
|
||||
}}
|
||||
placeholder="Markdown body"
|
||||
bordered={false}
|
||||
className="bg-transparent"
|
||||
contentClassName={documentBodyContentClassName}
|
||||
mentions={mentions}
|
||||
imageUploadHandler={imageUploadHandler}
|
||||
onSubmit={() => void commitDraft(activeDraft ?? draft, { clearAfterSave: false, trackAutosave: true })}
|
||||
/>
|
||||
{isHistoricalPreview ? (
|
||||
<div className="rounded-md border border-amber-500/20 bg-background/50 p-3">
|
||||
{renderBody(displayedBody, documentBodyContentClassName)}
|
||||
</div>
|
||||
) : (
|
||||
<MarkdownEditor
|
||||
value={displayedBody}
|
||||
onChange={(body) => {
|
||||
markDocumentDirty(doc.key);
|
||||
setDraft((current) => {
|
||||
if (current && current.key === doc.key && !current.isNew) {
|
||||
return { ...current, body };
|
||||
}
|
||||
return {
|
||||
key: doc.key,
|
||||
title: doc.title ?? "",
|
||||
body,
|
||||
baseRevisionId: doc.latestRevisionId,
|
||||
isNew: false,
|
||||
};
|
||||
});
|
||||
}}
|
||||
placeholder="Markdown body"
|
||||
bordered={false}
|
||||
className="bg-transparent"
|
||||
contentClassName={documentBodyContentClassName}
|
||||
mentions={mentions}
|
||||
imageUploadHandler={imageUploadHandler}
|
||||
onSubmit={() => void commitDraft(activeDraft ?? draft, { clearAfterSave: false, trackAutosave: true })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-h-4 items-center justify-end px-1">
|
||||
<span
|
||||
className={`text-[11px] transition-opacity duration-150 ${
|
||||
activeConflict
|
||||
isHistoricalPreview
|
||||
? "text-amber-300"
|
||||
: activeConflict
|
||||
? "text-amber-300"
|
||||
: autosaveState === "error"
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground"
|
||||
} ${activeDraft ? "opacity-100" : "opacity-0"}`}
|
||||
} ${activeDraft || isHistoricalPreview ? "opacity-100" : "opacity-0"}`}
|
||||
>
|
||||
{activeDraft
|
||||
? activeConflict
|
||||
{isHistoricalPreview
|
||||
? "Viewing historical revision"
|
||||
: activeDraft
|
||||
? activeConflict
|
||||
? "Out of date"
|
||||
: autosaveDocumentKey === doc.key
|
||||
? autosaveState === "saving"
|
||||
|
|
@ -851,7 +1050,7 @@ export function IssueDocumentsSection({
|
|||
? "Could not save"
|
||||
: ""
|
||||
: ""
|
||||
: ""}
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue