Skip to content

Commit 7adefc6

Browse files
crespoferdanrmzz
andauthored
[#396] Add Blade issue list page (#433)
Co-authored-by: unknown <danielram2023@gmail.com>
1 parent 9716b55 commit 7adefc6

6 files changed

Lines changed: 381 additions & 2 deletions

File tree

Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
"use client";
2+
3+
import { useMemo, useState } from "react";
4+
import Link from "next/link";
5+
import {
6+
CheckCircle2,
7+
CircleDot,
8+
Pencil,
9+
SlidersHorizontal,
10+
} from "lucide-react";
11+
12+
import type { ISSUE } from "@forge/consts";
13+
import { Button } from "@forge/ui/button";
14+
import { toast } from "@forge/ui/toast";
15+
16+
import { CreateEditDialog } from "~/app/_components/issues/create-edit-dialog";
17+
import { IssueFetcherPane } from "~/app/_components/issues/issue-fetcher-pane";
18+
import { StatusSelect } from "~/app/_components/issues/issue-form-fields";
19+
import IssueTemplateDialog from "~/app/_components/issues/issue-template-dialog";
20+
import { api } from "~/trpc/react";
21+
22+
function formatStatus(status: string) {
23+
return status
24+
.toLowerCase()
25+
.replace(/_/g, " ")
26+
.replace(/\b\w/g, (char) => char.toUpperCase());
27+
}
28+
29+
function formatDate(value: Date | null) {
30+
if (!value) return "No due date";
31+
return new Date(value).toLocaleDateString();
32+
}
33+
34+
function formatUpdatedAt(value: Date | string | null | undefined) {
35+
const parsed =
36+
value instanceof Date
37+
? value
38+
: typeof value === "string"
39+
? new Date(value)
40+
: null;
41+
if (!parsed || Number.isNaN(parsed.getTime())) {
42+
return "Unable to load last updated";
43+
}
44+
return parsed.toLocaleString([], {
45+
month: "short",
46+
day: "numeric",
47+
hour: "numeric",
48+
minute: "2-digit",
49+
});
50+
}
51+
52+
function formatTeamLabel(roleName: string) {
53+
const trimmed = roleName.replace(/\s+team$/i, "").trim();
54+
return trimmed || roleName;
55+
}
56+
57+
function isOverdueIssue(issue: ISSUE.IssueFetcherPaneIssue) {
58+
if (issue.status === "FINISHED" || !issue.date) return false;
59+
const dueDate = new Date(issue.date);
60+
const todayStart = new Date();
61+
todayStart.setHours(0, 0, 0, 0);
62+
return dueDate < todayStart;
63+
}
64+
65+
export function IssuesList() {
66+
const [isFiltersOpen, setIsFiltersOpen] = useState(false);
67+
const [statusOverrides, setStatusOverrides] = useState<
68+
Record<string, (typeof ISSUE.ISSUE_STATUS)[number]>
69+
>({});
70+
const [paneData, setPaneData] = useState<ISSUE.IssueFetcherPaneData | null>(
71+
null,
72+
);
73+
const utils = api.useUtils();
74+
const deleteIssueMutation = api.issues.deleteIssue.useMutation({
75+
onSuccess: async () => {
76+
await utils.issues.invalidate();
77+
paneData?.refresh();
78+
toast.success("Issue deleted successfully");
79+
},
80+
onError: () => {
81+
toast.error("Failed to delete issue");
82+
},
83+
});
84+
const updateIssueMutation = api.issues.updateIssue.useMutation({
85+
onSuccess: async () => {
86+
await utils.issues.invalidate();
87+
paneData?.refresh();
88+
},
89+
onError: () => {
90+
toast.error("Failed to update issue status");
91+
},
92+
});
93+
94+
const issues = useMemo(() => paneData?.issues ?? [], [paneData?.issues]);
95+
const isLoading = paneData?.isLoading ?? true;
96+
const error = paneData?.error ?? null;
97+
98+
const openCount = useMemo(
99+
() => issues.filter((issue) => issue.status !== "FINISHED").length,
100+
[issues],
101+
);
102+
const closedCount = issues.length - openCount;
103+
104+
const filters = paneData?.filters;
105+
106+
const activeFilters = useMemo(() => {
107+
if (!filters) return [];
108+
const tags: string[] = [];
109+
if (filters.statusFilter !== "all")
110+
tags.push(formatStatus(filters.statusFilter));
111+
if (filters.teamFilter !== "all") tags.push("Team selected");
112+
if (filters.issueKind !== "all")
113+
tags.push(
114+
filters.issueKind === "task" ? "Tasks only" : "Event-linked only",
115+
);
116+
if (filters.rootOnly) tags.push("Root only");
117+
if (filters.dateFrom) tags.push("From " + filters.dateFrom);
118+
if (filters.dateTo) tags.push("To " + filters.dateTo);
119+
if (filters.searchTerm.trim())
120+
tags.push('Search "' + filters.searchTerm.trim() + '"');
121+
return tags;
122+
}, [filters]);
123+
124+
return (
125+
<section className="mx-auto w-full max-w-6xl space-y-4 py-4">
126+
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
127+
<div className="flex items-center gap-4 rounded-md border bg-muted/20 px-3 py-2">
128+
<div className="flex items-center gap-2 text-sm font-medium">
129+
<CircleDot className="h-4 w-4 text-emerald-500" />
130+
<span>{openCount} Open</span>
131+
</div>
132+
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
133+
<CheckCircle2 className="h-4 w-4" />
134+
<span>{closedCount} Closed</span>
135+
</div>
136+
</div>
137+
<div className="flex flex-wrap items-center gap-2">
138+
<CreateEditDialog intent="create">
139+
<Button>Create issue</Button>
140+
</CreateEditDialog>
141+
<IssueTemplateDialog />
142+
<Button variant="outline" onClick={() => setIsFiltersOpen(true)}>
143+
<SlidersHorizontal className="mr-2 h-4 w-4" />
144+
Filters
145+
</Button>
146+
</div>
147+
</div>
148+
149+
{activeFilters.length > 0 && (
150+
<div className="flex flex-wrap gap-2">
151+
{activeFilters.map((tag) => (
152+
<span
153+
key={tag}
154+
className="rounded-full border bg-background px-2.5 py-1 text-xs text-muted-foreground"
155+
>
156+
{tag}
157+
</span>
158+
))}
159+
</div>
160+
)}
161+
162+
<div className="overflow-hidden rounded-lg border">
163+
<div className="hidden grid-cols-[minmax(0,1fr)_190px_88px_36px] gap-2 border-b bg-muted/30 px-4 py-2 text-xs font-medium uppercase tracking-wide text-muted-foreground md:grid">
164+
<span>Issue</span>
165+
<span className="justify-self-start">Status</span>
166+
<span className="justify-self-start">Due</span>
167+
<span className="justify-self-center">Edit</span>
168+
</div>
169+
170+
{isLoading && (
171+
<div className="px-4 py-8 text-sm text-muted-foreground">
172+
Loading issues...
173+
</div>
174+
)}
175+
176+
{!isLoading && error && (
177+
<div className="px-4 py-8 text-sm text-destructive">
178+
Unable to load issues. Please try again.
179+
</div>
180+
)}
181+
182+
{!isLoading && !error && issues.length === 0 && (
183+
<div className="px-4 py-8 text-sm text-muted-foreground">
184+
No issues match your current filters.
185+
</div>
186+
)}
187+
188+
{!isLoading &&
189+
!error &&
190+
issues.map((issue) => (
191+
<div
192+
key={issue.id}
193+
className="grid gap-2 border-b px-4 py-3 transition-colors hover:bg-muted/30 md:grid-cols-[minmax(0,1fr)_190px_88px_36px] md:items-center md:gap-2"
194+
>
195+
<div className="min-w-0 space-y-1">
196+
<Link
197+
href={"/issues/" + issue.id}
198+
className="inline font-medium leading-tight text-foreground hover:underline"
199+
>
200+
{issue.name}
201+
</Link>
202+
<div className="text-xs text-muted-foreground">
203+
{formatUpdatedAt(issue.updatedAt)}{" "}
204+
{formatTeamLabel(
205+
paneData?.roleNameById.get(issue.team) ?? issue.team,
206+
)}
207+
</div>
208+
</div>
209+
210+
<div className="text-sm text-muted-foreground md:justify-self-start">
211+
<span className="mr-2 text-xs font-medium uppercase tracking-wide text-muted-foreground md:hidden">
212+
Status
213+
</span>
214+
<StatusSelect
215+
value={statusOverrides[issue.id] ?? issue.status}
216+
className="h-8 min-w-[160px]"
217+
onValueChange={(nextStatus) => {
218+
const previousStatus =
219+
statusOverrides[issue.id] ?? issue.status;
220+
if (nextStatus === previousStatus) return;
221+
222+
setStatusOverrides((prev) => ({
223+
...prev,
224+
[issue.id]: nextStatus,
225+
}));
226+
updateIssueMutation.mutate(
227+
{
228+
id: issue.id,
229+
status: nextStatus,
230+
},
231+
{
232+
onError: () => {
233+
setStatusOverrides((prev) => ({
234+
...prev,
235+
[issue.id]: previousStatus,
236+
}));
237+
},
238+
onSettled: () => {
239+
setStatusOverrides((prev) => {
240+
const { [issue.id]: _removed, ...rest } = prev;
241+
return rest;
242+
});
243+
},
244+
},
245+
);
246+
}}
247+
/>
248+
</div>
249+
250+
<div className="text-sm text-muted-foreground md:justify-self-start">
251+
<span className="mr-2 text-xs font-medium uppercase tracking-wide text-muted-foreground md:hidden">
252+
Due
253+
</span>
254+
<span
255+
className={
256+
isOverdueIssue(issue)
257+
? "font-medium text-red-900 dark:text-red-500"
258+
: undefined
259+
}
260+
>
261+
{formatDate(issue.date)}
262+
</span>
263+
</div>
264+
265+
<div className="flex items-center justify-start gap-2 md:justify-center">
266+
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground md:hidden">
267+
Edit
268+
</span>
269+
<CreateEditDialog
270+
intent="edit"
271+
initialValues={{
272+
id: issue.id,
273+
status: issue.status,
274+
name: issue.name,
275+
description: issue.description,
276+
links: issue.links ?? [],
277+
date: issue.date ?? undefined,
278+
priority: issue.priority,
279+
team: issue.team,
280+
parent: issue.parent ?? undefined,
281+
isEvent: issue.event !== null,
282+
event: issue.event,
283+
}}
284+
onDelete={(values) => {
285+
if (!values.id || deleteIssueMutation.isPending) return;
286+
deleteIssueMutation.mutate({ id: values.id });
287+
}}
288+
>
289+
<Button
290+
type="button"
291+
variant="outline"
292+
size="icon"
293+
className="size-8 shrink-0"
294+
aria-label={`Edit issue ${issue.name}`}
295+
>
296+
<Pencil className="h-4 w-4" />
297+
</Button>
298+
</CreateEditDialog>
299+
</div>
300+
</div>
301+
))}
302+
</div>
303+
304+
<IssueFetcherPane
305+
open={isFiltersOpen}
306+
onOpenChange={setIsFiltersOpen}
307+
onDataChange={setPaneData}
308+
/>
309+
</section>
310+
);
311+
}

apps/blade/src/app/_components/issues/issue-dialog-utils.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,22 @@ export function getStatusLabel(status: string) {
2828
.replace(/\b\w/g, (char) => char.toUpperCase());
2929
}
3030

31+
function taskDueDateBasis(dateValue: string | Date): Date {
32+
if (dateValue instanceof Date) {
33+
return new Date(dateValue.getTime());
34+
}
35+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateValue.trim());
36+
if (match) {
37+
const y = Number(match[1]);
38+
const m = Number(match[2]);
39+
const d = Number(match[3]);
40+
return new Date(y, m - 1, d);
41+
}
42+
return new Date(dateValue);
43+
}
44+
3145
export function normalizeTaskDueDate(dateValue?: string | Date) {
32-
const dueDate = dateValue ? new Date(dateValue) : new Date();
46+
const dueDate = dateValue ? taskDueDateBasis(dateValue) : new Date();
3347
if (Number.isNaN(dueDate.getTime())) {
3448
const fallback = new Date();
3549
fallback.setHours(ISSUE.TASK_DUE_HOURS, ISSUE.TASK_DUE_MINUTES, 0, 0);
@@ -42,7 +56,11 @@ export function normalizeTaskDueDate(dateValue?: string | Date) {
4256

4357
export function getTaskDueDateInputValue(dateValue: Date | undefined) {
4458
if (!dateValue) return "";
45-
return normalizeTaskDueDate(dateValue).toISOString().slice(0, 10);
59+
const d = normalizeTaskDueDate(dateValue);
60+
const y = d.getFullYear();
61+
const m = String(d.getMonth() + 1).padStart(2, "0");
62+
const day = String(d.getDate()).padStart(2, "0");
63+
return `${y}-${m}-${day}`;
4664
}
4765

4866
export function parseTimeTo12h(timeValue?: string): {
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { notFound, redirect } from "next/navigation";
2+
3+
import { auth } from "@forge/auth";
4+
5+
import { IssuesList } from "~/app/_components/issue-list/issues-list";
6+
import { SessionNavbar } from "~/app/_components/navigation/session-navbar";
7+
import { SIGN_IN_PATH } from "~/consts";
8+
import { api, HydrateClient } from "~/trpc/server";
9+
10+
export default async function IssueListPage() {
11+
const session = await auth();
12+
if (!session) redirect(SIGN_IN_PATH);
13+
14+
const hasAccess = await api.roles.hasPermission({
15+
and: [
16+
"READ_ISSUES",
17+
"EDIT_ISSUES",
18+
"EDIT_ISSUE_TEMPLATES",
19+
"READ_ISSUE_TEMPLATES",
20+
],
21+
});
22+
if (!hasAccess) notFound();
23+
24+
return (
25+
<HydrateClient>
26+
<SessionNavbar />
27+
<main className="px-4 pb-4 md:px-6 md:pb-6">
28+
<IssuesList />
29+
</main>
30+
</HydrateClient>
31+
);
32+
}

packages/api/src/routers/issues.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,16 @@ export const issuesRouter = {
353353
}
354354
}
355355

356+
if (
357+
Object.keys(updateData).length === 0 &&
358+
(teamVisibilityIds !== undefined || assigneeIds !== undefined)
359+
) {
360+
await db
361+
.update(Issue)
362+
.set({ updatedAt: new Date() })
363+
.where(eq(Issue.id, id));
364+
}
365+
356366
return db.query.Issue.findFirst({
357367
where: (t, { eq }) => eq(t.id, id),
358368
with: {

0 commit comments

Comments
 (0)