mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 19:00:38 +09:00
Add generic issue-linked board approvals
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
6b4f3b56e4
commit
365b6d9bd8
10 changed files with 345 additions and 39 deletions
|
|
@ -123,6 +123,9 @@ export function Approvals() {
|
|||
onReject={() => rejectMutation.mutate(approval.id)}
|
||||
detailLink={`/approvals/${approval.id}`}
|
||||
isPending={approveMutation.isPending || rejectMutation.isPending}
|
||||
pendingAction={
|
||||
approveMutation.isPending ? "approve" : rejectMutation.isPending ? "reject" : null
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { pickTextColorForPillBg } from "@/lib/color-contrast";
|
|||
import { Link, useLocation, useNavigate, useParams } from "@/lib/router";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { approvalsApi } from "../api/approvals";
|
||||
import { activityApi } from "../api/activity";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
|
|
@ -37,6 +38,7 @@ import {
|
|||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils";
|
||||
import { InlineEditor } from "../components/InlineEditor";
|
||||
import { ApprovalCard } from "../components/ApprovalCard";
|
||||
import { CommentThread } from "../components/CommentThread";
|
||||
import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
|
||||
import { IssueProperties } from "../components/IssueProperties";
|
||||
|
|
@ -47,7 +49,6 @@ import { ImageGalleryModal } from "../components/ImageGalleryModal";
|
|||
import { ScrollToBottom } from "../components/ScrollToBottom";
|
||||
import { StatusIcon } from "../components/StatusIcon";
|
||||
import { PriorityIcon } from "../components/PriorityIcon";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { Identity } from "../components/Identity";
|
||||
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
|
||||
import { PluginLauncherOutlet } from "@/plugins/launchers";
|
||||
|
|
@ -303,6 +304,10 @@ export function IssueDetail() {
|
|||
const [secondaryOpen, setSecondaryOpen] = useState({
|
||||
approvals: false,
|
||||
});
|
||||
const [pendingApprovalAction, setPendingApprovalAction] = useState<{
|
||||
approvalId: string;
|
||||
action: "approve" | "reject";
|
||||
} | null>(null);
|
||||
const [attachmentError, setAttachmentError] = useState<string | null>(null);
|
||||
const [attachmentDragActive, setAttachmentDragActive] = useState(false);
|
||||
const [galleryOpen, setGalleryOpen] = useState(false);
|
||||
|
|
@ -659,6 +664,39 @@ export function IssueDetail() {
|
|||
},
|
||||
});
|
||||
|
||||
const approvalDecision = useMutation({
|
||||
mutationFn: async ({ approvalId, action }: { approvalId: string; action: "approve" | "reject" }) => {
|
||||
if (action === "approve") {
|
||||
return approvalsApi.approve(approvalId);
|
||||
}
|
||||
return approvalsApi.reject(approvalId);
|
||||
},
|
||||
onMutate: ({ approvalId, action }) => {
|
||||
setPendingApprovalAction({ approvalId, action });
|
||||
},
|
||||
onSuccess: (_approval, variables) => {
|
||||
invalidateIssue();
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.detail(variables.approvalId) });
|
||||
if (resolvedCompanyId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(resolvedCompanyId) });
|
||||
}
|
||||
pushToast({
|
||||
title: variables.action === "approve" ? "Approval approved" : "Approval rejected",
|
||||
tone: "success",
|
||||
});
|
||||
},
|
||||
onError: (err, variables) => {
|
||||
pushToast({
|
||||
title: variables.action === "approve" ? "Approval failed" : "Rejection failed",
|
||||
body: err instanceof Error ? err.message : "Unable to update approval",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
onSettled: () => {
|
||||
setPendingApprovalAction(null);
|
||||
},
|
||||
});
|
||||
|
||||
const addComment = useMutation({
|
||||
mutationFn: ({ body, reopen, interrupt }: { body: string; reopen?: boolean; interrupt?: boolean }) =>
|
||||
issuesApi.addComment(issueId!, body, reopen, interrupt),
|
||||
|
|
@ -1543,6 +1581,7 @@ export function IssueDetail() {
|
|||
<CommentThread
|
||||
comments={timelineComments}
|
||||
queuedComments={queuedComments}
|
||||
linkedApprovals={linkedApprovals}
|
||||
feedbackVotes={feedbackVotes}
|
||||
feedbackDataSharingPreference={feedbackDataSharingPreference}
|
||||
feedbackTermsUrl={FEEDBACK_TERMS_URL}
|
||||
|
|
@ -1550,6 +1589,13 @@ export function IssueDetail() {
|
|||
timelineEvents={timelineEvents}
|
||||
companyId={issue.companyId}
|
||||
projectId={issue.projectId}
|
||||
onApproveApproval={async (approvalId) => {
|
||||
await approvalDecision.mutateAsync({ approvalId, action: "approve" });
|
||||
}}
|
||||
onRejectApproval={async (approvalId) => {
|
||||
await approvalDecision.mutateAsync({ approvalId, action: "reject" });
|
||||
}}
|
||||
pendingApprovalAction={pendingApprovalAction}
|
||||
issueStatus={issue.status}
|
||||
agentMap={agentMap}
|
||||
currentUserId={currentUserId}
|
||||
|
|
@ -1703,20 +1749,21 @@ export function IssueDetail() {
|
|||
<CollapsibleContent>
|
||||
<div className="border-t border-border divide-y divide-border">
|
||||
{linkedApprovals.map((approval) => (
|
||||
<Link
|
||||
key={approval.id}
|
||||
to={`/approvals/${approval.id}`}
|
||||
className="flex items-center justify-between px-3 py-2 text-xs hover:bg-accent/20 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={approval.status} />
|
||||
<span className="font-medium">
|
||||
{approval.type.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}
|
||||
</span>
|
||||
<span className="font-mono text-muted-foreground">{approval.id.slice(0, 8)}</span>
|
||||
</div>
|
||||
<span className="text-muted-foreground">{relativeTime(approval.createdAt)}</span>
|
||||
</Link>
|
||||
<div key={approval.id} className="px-3 py-3">
|
||||
<ApprovalCard
|
||||
approval={approval}
|
||||
requesterAgent={approval.requestedByAgentId ? agentMap.get(approval.requestedByAgentId) ?? null : null}
|
||||
onApprove={() => approvalDecision.mutate({ approvalId: approval.id, action: "approve" })}
|
||||
onReject={() => approvalDecision.mutate({ approvalId: approval.id, action: "reject" })}
|
||||
detailLink={`/approvals/${approval.id}`}
|
||||
isPending={pendingApprovalAction?.approvalId === approval.id}
|
||||
pendingAction={
|
||||
pendingApprovalAction?.approvalId === approval.id
|
||||
? pendingApprovalAction.action
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue