feat: Add Close Issue Action
This commit is contained in:
parent
d85912c4c9
commit
7b20d2c26d
|
|
@ -3247,7 +3247,7 @@ export default function BoardDetailPage() {
|
||||||
|
|
||||||
{canRead && boardId ? (
|
{canRead && boardId ? (
|
||||||
<div className="flex w-full flex-col gap-4">
|
<div className="flex w-full flex-col gap-4">
|
||||||
<BoardForgejoIssuesPanel boardId={boardId} />
|
<BoardForgejoIssuesPanel boardId={boardId} canClose={canWrite} />
|
||||||
<BoardForgejoRepositoryLinks boardId={boardId} canWrite={canWrite} />
|
<BoardForgejoRepositoryLinks boardId={boardId} canWrite={canWrite} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,11 @@ import { AlertCircle } from "lucide-react";
|
||||||
|
|
||||||
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ApiError } from "@/api/mutator";
|
||||||
|
import {
|
||||||
|
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||||
|
useGetMyMembershipApiV1OrganizationsMeMemberGet,
|
||||||
|
} from "@/api/generated/organizations/organizations";
|
||||||
import {
|
import {
|
||||||
getForgejoIssues,
|
getForgejoIssues,
|
||||||
getForgejoRepositories,
|
getForgejoRepositories,
|
||||||
|
|
@ -79,6 +84,15 @@ export default function GitIssuesPage() {
|
||||||
parsePositiveInteger(searchParams.get("page")),
|
parsePositiveInteger(searchParams.get("page")),
|
||||||
);
|
);
|
||||||
const limit = 30;
|
const limit = 30;
|
||||||
|
const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet<
|
||||||
|
getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||||
|
ApiError
|
||||||
|
>({
|
||||||
|
query: {
|
||||||
|
enabled: true,
|
||||||
|
refetchOnMount: "always",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|
@ -158,6 +172,19 @@ export default function GitIssuesPage() {
|
||||||
|
|
||||||
const staleCutoffMs = useMemo(() => Date.now() - STALE_ISSUE_MS, []);
|
const staleCutoffMs = useMemo(() => Date.now() - STALE_ISSUE_MS, []);
|
||||||
const recentClosedCutoffMs = useMemo(() => Date.now() - RECENT_CLOSED_MS, []);
|
const recentClosedCutoffMs = useMemo(() => Date.now() - RECENT_CLOSED_MS, []);
|
||||||
|
const canCloseIssues = useMemo(() => {
|
||||||
|
if (membershipQuery.data?.status !== 200) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const member = membershipQuery.data.data;
|
||||||
|
if (["owner", "admin"].includes(member.role)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (member.all_boards_write) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return (member.board_access ?? []).some((entry) => entry.can_write);
|
||||||
|
}, [membershipQuery.data]);
|
||||||
const visibleIssues = useMemo(() => {
|
const visibleIssues = useMemo(() => {
|
||||||
if (staleOnly) {
|
if (staleOnly) {
|
||||||
return issues.filter((issue) => isStaleOpenIssue(issue, staleCutoffMs));
|
return issues.filter((issue) => isStaleOpenIssue(issue, staleCutoffMs));
|
||||||
|
|
@ -263,6 +290,7 @@ export default function GitIssuesPage() {
|
||||||
issues={visibleIssues}
|
issues={visibleIssues}
|
||||||
repositories={repos}
|
repositories={repos}
|
||||||
isLoading={isLoadingIssues}
|
isLoading={isLoadingIssues}
|
||||||
|
canClose={canCloseIssues}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,17 +8,18 @@ import {
|
||||||
getForgejoIssues,
|
getForgejoIssues,
|
||||||
type ForgejoIssue,
|
type ForgejoIssue,
|
||||||
type ForgejoRepository,
|
type ForgejoRepository,
|
||||||
type ForgejoIssueListResponse,
|
|
||||||
} from "@/lib/api-forgejo";
|
} from "@/lib/api-forgejo";
|
||||||
import { ForgejoIssuesTable } from "@/components/git/ForgejoIssuesTable";
|
import { ForgejoIssuesTable } from "@/components/git/ForgejoIssuesTable";
|
||||||
|
|
||||||
interface BoardForgejoIssuesPanelProps {
|
interface BoardForgejoIssuesPanelProps {
|
||||||
boardId: string;
|
boardId: string;
|
||||||
|
canClose?: boolean;
|
||||||
repositories?: ForgejoRepository[];
|
repositories?: ForgejoRepository[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BoardForgejoIssuesPanel({
|
export function BoardForgejoIssuesPanel({
|
||||||
boardId,
|
boardId,
|
||||||
|
canClose = false,
|
||||||
repositories = [],
|
repositories = [],
|
||||||
}: BoardForgejoIssuesPanelProps) {
|
}: BoardForgejoIssuesPanelProps) {
|
||||||
const [issues, setIssues] = useState<ForgejoIssue[]>([]);
|
const [issues, setIssues] = useState<ForgejoIssue[]>([]);
|
||||||
|
|
@ -36,7 +37,7 @@ export function BoardForgejoIssuesPanel({
|
||||||
try {
|
try {
|
||||||
const [linksResult, issuesResult] = await Promise.all([
|
const [linksResult, issuesResult] = await Promise.all([
|
||||||
getBoardForgejoRepositories(boardId),
|
getBoardForgejoRepositories(boardId),
|
||||||
getForgejoIssues({ board_id: boardId, state: "open", limit: 50 }) as Promise<ForgejoIssueListResponse>,
|
getForgejoIssues({ board_id: boardId, state: "open", limit: 50 }),
|
||||||
]);
|
]);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const links = Array.isArray(linksResult)
|
const links = Array.isArray(linksResult)
|
||||||
|
|
@ -70,7 +71,7 @@ export function BoardForgejoIssuesPanel({
|
||||||
board_id: boardId,
|
board_id: boardId,
|
||||||
state: "open",
|
state: "open",
|
||||||
limit: 50,
|
limit: 50,
|
||||||
}) as ForgejoIssueListResponse;
|
});
|
||||||
setIssues(issuesResult.items);
|
setIssues(issuesResult.items);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
setError(
|
||||||
|
|
@ -124,6 +125,7 @@ export function BoardForgejoIssuesPanel({
|
||||||
issues={issues}
|
issues={issues}
|
||||||
repositories={repositories}
|
repositories={repositories}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
canClose={canClose}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { closeForgejoIssue } from "@/lib/api-forgejo";
|
||||||
|
|
||||||
type CloseForgejoIssueDialogProps = {
|
type CloseForgejoIssueDialogProps = {
|
||||||
issue: ForgejoIssue | null;
|
issue: ForgejoIssue | null;
|
||||||
|
repositoryName: string;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onCloseSuccess: () => void;
|
onCloseSuccess: () => void;
|
||||||
|
|
@ -23,6 +24,7 @@ type CloseForgejoIssueDialogProps = {
|
||||||
|
|
||||||
export function CloseForgejoIssueDialog({
|
export function CloseForgejoIssueDialog({
|
||||||
issue,
|
issue,
|
||||||
|
repositoryName,
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onCloseSuccess,
|
onCloseSuccess,
|
||||||
|
|
@ -54,12 +56,12 @@ export function CloseForgejoIssueDialog({
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Close Git Project issue</DialogTitle>
|
<DialogTitle>Close Git Project issue</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Pipeline will mark issue{" "}
|
Confirm closing{" "}
|
||||||
<span className="font-mono font-semibold text-strong">
|
<span className="font-mono font-semibold text-strong">
|
||||||
#{issue.forgejo_issue_number}
|
{repositoryName}#{issue.forgejo_issue_number}
|
||||||
</span>{" "}
|
</span>
|
||||||
as closed in the connected Git provider and refresh the local issue
|
. Pipeline will close it in the connected Git provider and refresh
|
||||||
cache.
|
the local issue cache.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3">
|
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3">
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,20 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type ColumnDef,
|
type ColumnDef,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
import { CircleDot, ExternalLink } from "lucide-react";
|
import { CircleDot, ExternalLink, XCircle } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { DataTable } from "@/components/tables/DataTable";
|
import { DataTable } from "@/components/tables/DataTable";
|
||||||
import type { ForgejoIssue, ForgejoIssueLabel } from "@/lib/api-forgejo";
|
import type { ForgejoIssue, ForgejoIssueLabel } from "@/lib/api-forgejo";
|
||||||
import type { ForgejoRepository } from "@/lib/api-forgejo";
|
import type { ForgejoRepository } from "@/lib/api-forgejo";
|
||||||
|
import { CloseForgejoIssueDialog } from "@/components/git/CloseForgejoIssueDialog";
|
||||||
|
|
||||||
/** Normalize a Forgejo label color to a valid 6-char hex string or null. */
|
/** Normalize a Forgejo label color to a valid 6-char hex string or null. */
|
||||||
function normalizeLabelColor(raw: string | null | undefined): string | null {
|
function normalizeLabelColor(raw: string | null | undefined): string | null {
|
||||||
|
|
@ -55,6 +57,7 @@ export type ForgejoIssuesTableProps = {
|
||||||
issues: ForgejoIssue[];
|
issues: ForgejoIssue[];
|
||||||
repositories: ForgejoRepository[];
|
repositories: ForgejoRepository[];
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
|
canClose?: boolean;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -62,8 +65,12 @@ export function ForgejoIssuesTable({
|
||||||
issues,
|
issues,
|
||||||
repositories,
|
repositories,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
onRefresh: _onRefresh,
|
canClose = false,
|
||||||
|
onRefresh,
|
||||||
}: ForgejoIssuesTableProps) {
|
}: ForgejoIssuesTableProps) {
|
||||||
|
const [issueToClose, setIssueToClose] = useState<ForgejoIssue | null>(null);
|
||||||
|
const [isCloseDialogOpen, setIsCloseDialogOpen] = useState(false);
|
||||||
|
|
||||||
const repositoryNameById = useMemo(() => {
|
const repositoryNameById = useMemo(() => {
|
||||||
const map = new Map<string, string>();
|
const map = new Map<string, string>();
|
||||||
for (const repository of repositories) {
|
for (const repository of repositories) {
|
||||||
|
|
@ -74,7 +81,6 @@ export function ForgejoIssuesTable({
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [repositories]);
|
}, [repositories]);
|
||||||
|
|
||||||
const columns: ColumnDef<ForgejoIssue>[] = useMemo(
|
const columns: ColumnDef<ForgejoIssue>[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
|
|
@ -189,8 +195,37 @@ export function ForgejoIssuesTable({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: "Actions",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const issue = row.original;
|
||||||
|
const canShowClose =
|
||||||
|
canClose && issue.state === "open" && !issue.is_pull_request;
|
||||||
|
if (!canShowClose) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const repositoryName =
|
||||||
|
repositoryNameById.get(issue.repository_id) ?? issue.repository_id;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0 text-[color:var(--danger)] hover:bg-[color:rgba(248,113,113,0.08)]"
|
||||||
|
title={`Close ${repositoryName}#${issue.forgejo_issue_number}`}
|
||||||
|
onClick={() => {
|
||||||
|
setIssueToClose(issue);
|
||||||
|
setIsCloseDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XCircle className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
[repositoryNameById],
|
[canClose, repositoryNameById],
|
||||||
);
|
);
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: issues,
|
data: issues,
|
||||||
|
|
@ -214,6 +249,23 @@ export function ForgejoIssuesTable({
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<CloseForgejoIssueDialog
|
||||||
|
issue={issueToClose}
|
||||||
|
repositoryName={
|
||||||
|
issueToClose
|
||||||
|
? (repositoryNameById.get(issueToClose.repository_id) ??
|
||||||
|
issueToClose.repository_id)
|
||||||
|
: "Repository"
|
||||||
|
}
|
||||||
|
open={isCloseDialogOpen}
|
||||||
|
onOpenChange={(nextOpen) => {
|
||||||
|
setIsCloseDialogOpen(nextOpen);
|
||||||
|
if (!nextOpen) {
|
||||||
|
setIssueToClose(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onCloseSuccess={onRefresh}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue