From 14b9eb1f8e15c6788dcf3f833d4c11f3b84bee8d Mon Sep 17 00:00:00 2001 From: Jomak-x Date: Wed, 25 Mar 2026 18:36:51 -0400 Subject: [PATCH 1/3] add reusable issue fetcher pane --- .../issues/issue-fetcher-pane-playground.tsx | 95 +++++ .../_components/issues/issue-fetcher-pane.tsx | 356 ++++++++++++++++++ 2 files changed, 451 insertions(+) create mode 100644 apps/blade/src/app/_components/issues/issue-fetcher-pane-playground.tsx create mode 100644 apps/blade/src/app/_components/issues/issue-fetcher-pane.tsx diff --git a/apps/blade/src/app/_components/issues/issue-fetcher-pane-playground.tsx b/apps/blade/src/app/_components/issues/issue-fetcher-pane-playground.tsx new file mode 100644 index 000000000..3710c49df --- /dev/null +++ b/apps/blade/src/app/_components/issues/issue-fetcher-pane-playground.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { useState } from "react"; + +import type { + IssueFetcherPaneData, + IssueFetcherPaneIssue, +} from "~/app/_components/issues/issue-fetcher-pane"; +import { IssueFetcherPane } from "~/app/_components/issues/issue-fetcher-pane"; + +export function IssueFetcherPanePlayground() { + const [issues, setIssues] = useState([]); + const [fetcherData, setFetcherData] = useState( + null, + ); + + return ( +
+ + +
+
+

Playground View

+ + Showing {issues.length} issue(s) + +
+ + {fetcherData?.isLoading ? ( +

Loading issues...

+ ) : fetcherData?.error ? ( +

{fetcherData.error}

+ ) : issues.length === 0 ? ( +

+ No issues match the current filters. +

+ ) : ( +
+ {issues.map((issue) => ( +
+
+

{issue.name}

+ + {issue.status} + + + {issue.priority} + + {issue.event && ( + + EVENT-LINKED + + )} + {fetcherData?.blockedParentIds.has(issue.id) && ( + + BLOCKED + + )} +
+ +

+ {issue.description} +

+ +
+

+ Team:{" "} + {fetcherData?.roleNameById.get(issue.team) ?? issue.team} +

+

+ Due:{" "} + {issue.date ? new Date(issue.date).toLocaleString() : "N/A"} +

+

+ Subtask: {issue.parent ? "Yes" : "No"} +

+

+ Visible To: {issue.teamVisibility.length}{" "} + role(s) +

+

+ Assignees: {issue.userAssignments.length} +

+

+ ID: {issue.id} +

+
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/apps/blade/src/app/_components/issues/issue-fetcher-pane.tsx b/apps/blade/src/app/_components/issues/issue-fetcher-pane.tsx new file mode 100644 index 000000000..e53bb5642 --- /dev/null +++ b/apps/blade/src/app/_components/issues/issue-fetcher-pane.tsx @@ -0,0 +1,356 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { ISSUE } from "@forge/consts"; +import { Button } from "@forge/ui/button"; +import { Checkbox } from "@forge/ui/checkbox"; +import { Input } from "@forge/ui/input"; +import { Label } from "@forge/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@forge/ui/select"; +import { api } from "~/trpc/react"; + +type StatusFilter = "all" | (typeof ISSUE.ISSUE_STATUS)[number]; +type IssueKindFilter = "all" | "task" | "event_linked"; + +export interface IssueFilters { + statusFilter: StatusFilter; + teamFilter: string; + searchTerm: string; + dateFrom: string; + dateTo: string; + rootOnly: boolean; + issueKind: IssueKindFilter; +} + +export interface IssueFetcherPaneIssue { + id: string; + status: (typeof ISSUE.ISSUE_STATUS)[number]; + name: string; + description: string; + links: string[] | null; + event: string | null; + date: Date | null; + priority: (typeof ISSUE.PRIORITY)[number]; + team: string; + parent: string | null; + creator: string; + teamVisibility: { teamId: string }[]; + userAssignments: { userId: string }[]; +} + +export interface IssueFetcherPaneData { + issues: IssueFetcherPaneIssue[]; + blockedParentIds: Set; + roleNameById: Map; + isLoading: boolean; + error: string | null; + refresh: () => void; + filters: IssueFilters; +} + +interface IssueFetcherPaneProps { + actions?: React.ReactNode; + setIssues?: React.Dispatch>; + onDataChange?: (data: IssueFetcherPaneData) => void; +} + +export const DEFAULT_ISSUE_FILTERS: IssueFilters = { + statusFilter: "all", + teamFilter: "all", + searchTerm: "", + dateFrom: "", + dateTo: "", + rootOnly: true, + issueKind: "all", +}; + +function parseLocalDate(value: string, endOfDay: boolean) { + if (!value) return undefined; + const date = new Date(`${value}T${endOfDay ? "23:59:59.999" : "00:00:00"}`); + return Number.isNaN(date.getTime()) ? undefined : date; +} + +export function IssueFetcherPane(props: IssueFetcherPaneProps) { + const { actions, onDataChange, setIssues } = props; + const [filters, setFilters] = useState(DEFAULT_ISSUE_FILTERS); + + const rolesQuery = api.roles.getAllLinks.useQuery(undefined, { + refetchOnWindowFocus: false, + }); + + const queryInput = useMemo(() => { + const input: { + status?: (typeof ISSUE.ISSUE_STATUS)[number]; + teamId?: string; + dateFrom?: Date; + dateTo?: Date; + } = {}; + + if (filters.statusFilter !== "all") input.status = filters.statusFilter; + if (filters.teamFilter !== "all") input.teamId = filters.teamFilter; + + const parsedDateFrom = parseLocalDate(filters.dateFrom, false); + const parsedDateTo = parseLocalDate(filters.dateTo, true); + if (parsedDateFrom) input.dateFrom = parsedDateFrom; + if (parsedDateTo) input.dateTo = parsedDateTo; + + return Object.keys(input).length > 0 ? input : undefined; + }, [filters]); + + const issuesQuery = api.issues.getAllIssues.useQuery(queryInput, { + refetchOnWindowFocus: false, + }); + const { data: fetchedIssues, error: issuesError, isLoading, refetch } = + issuesQuery; + const issueErrorMessage = issuesError?.message ?? null; + + const roles = useMemo(() => rolesQuery.data ?? [], [rolesQuery.data]); + const roleNameById = useMemo( + () => new Map(roles.map((role) => [role.id, role.name])), + [roles], + ); + + const allIssues = useMemo( + () => (fetchedIssues ?? []) as IssueFetcherPaneIssue[], + [fetchedIssues], + ); + + const blockedParentIds = useMemo(() => { + const childrenByParent = new Map(); + + for (const issue of allIssues) { + if (!issue.parent) continue; + const current = childrenByParent.get(issue.parent) ?? []; + childrenByParent.set(issue.parent, [...current, issue]); + } + + const blockedParents = new Set(); + for (const [parentId, children] of childrenByParent.entries()) { + if (children.some((child) => child.status !== "FINISHED")) { + blockedParents.add(parentId); + } + } + + return blockedParents; + }, [allIssues]); + + const issues = useMemo(() => { + const term = filters.searchTerm.trim().toLowerCase(); + + return allIssues.filter((issue) => { + const matchesSearch = + !term || + `${issue.name} ${issue.description} ${issue.id}` + .toLowerCase() + .includes(term); + + const matchesKind = + filters.issueKind === "all" + ? true + : filters.issueKind === "task" + ? !issue.event + : !!issue.event; + + const matchesRoot = !filters.rootOnly || !issue.parent; + + return matchesSearch && matchesKind && matchesRoot; + }); + }, [allIssues, filters.issueKind, filters.rootOnly, filters.searchTerm]); + + const refresh = useCallback(() => { + void refetch(); + }, [refetch]); + + const data = useMemo( + () => ({ + issues, + blockedParentIds, + roleNameById, + isLoading, + error: issueErrorMessage, + refresh, + filters, + }), + [blockedParentIds, filters, isLoading, issueErrorMessage, issues, refresh, roleNameById], + ); + + useEffect(() => { + setIssues?.(issues); + }, [issues, setIssues]); + + useEffect(() => { + onDataChange?.(data); + }, [data, onDataChange]); + + return ( +
+
+

Issue Fetcher Pane

+

+ Shared filter + fetch controller for issues. Use this as the single + data source, then hand the filtered result to list, kanban, or + calendar views from the parent. +

+
+ +
+
+ {actions} + +
+
+ {data.isLoading + ? "Loading issues..." + : data.error + ? data.error + : `${data.issues.length} issue(s) ready for parent views`} +
+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + setFilters((previous) => ({ + ...previous, + searchTerm: event.target.value, + })) + } + /> +
+ +
+ + + setFilters((previous) => ({ + ...previous, + dateFrom: event.target.value, + })) + } + /> +
+ +
+ + + setFilters((previous) => ({ + ...previous, + dateTo: event.target.value, + })) + } + /> +
+ +
+ +
+
+ +
+ + setFilters((previous) => ({ + ...previous, + rootOnly: checked === true, + })) + } + /> + +
+
+ ); +} From 14ecd0b4225492b92f3d883e1d92814c84bfd126 Mon Sep 17 00:00:00 2001 From: Jomak-x Date: Wed, 25 Mar 2026 19:01:39 -0400 Subject: [PATCH 2/3] format issue fetcher pane --- .../_components/issues/issue-fetcher-pane.tsx | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/apps/blade/src/app/_components/issues/issue-fetcher-pane.tsx b/apps/blade/src/app/_components/issues/issue-fetcher-pane.tsx index e53bb5642..ca0067080 100644 --- a/apps/blade/src/app/_components/issues/issue-fetcher-pane.tsx +++ b/apps/blade/src/app/_components/issues/issue-fetcher-pane.tsx @@ -14,6 +14,7 @@ import { SelectTrigger, SelectValue, } from "@forge/ui/select"; + import { api } from "~/trpc/react"; type StatusFilter = "all" | (typeof ISSUE.ISSUE_STATUS)[number]; @@ -107,8 +108,12 @@ export function IssueFetcherPane(props: IssueFetcherPaneProps) { const issuesQuery = api.issues.getAllIssues.useQuery(queryInput, { refetchOnWindowFocus: false, }); - const { data: fetchedIssues, error: issuesError, isLoading, refetch } = - issuesQuery; + const { + data: fetchedIssues, + error: issuesError, + isLoading, + refetch, + } = issuesQuery; const issueErrorMessage = issuesError?.message ?? null; const roles = useMemo(() => rolesQuery.data ?? [], [rolesQuery.data]); @@ -178,7 +183,15 @@ export function IssueFetcherPane(props: IssueFetcherPaneProps) { refresh, filters, }), - [blockedParentIds, filters, isLoading, issueErrorMessage, issues, refresh, roleNameById], + [ + blockedParentIds, + filters, + isLoading, + issueErrorMessage, + issues, + refresh, + roleNameById, + ], ); useEffect(() => { From 7e3bf7d5eb83c3fd39a0fb6d1185dffbda8b7ac8 Mon Sep 17 00:00:00 2001 From: Jomak-x Date: Wed, 25 Mar 2026 19:19:08 -0400 Subject: [PATCH 3/3] fixed coderabbit reviews --- .../issues/issue-fetcher-pane-playground.tsx | 21 +++-- .../_components/issues/issue-fetcher-pane.tsx | 76 +++++++++++-------- 2 files changed, 58 insertions(+), 39 deletions(-) diff --git a/apps/blade/src/app/_components/issues/issue-fetcher-pane-playground.tsx b/apps/blade/src/app/_components/issues/issue-fetcher-pane-playground.tsx index 3710c49df..18b287c39 100644 --- a/apps/blade/src/app/_components/issues/issue-fetcher-pane-playground.tsx +++ b/apps/blade/src/app/_components/issues/issue-fetcher-pane-playground.tsx @@ -13,6 +13,7 @@ export function IssueFetcherPanePlayground() { const [fetcherData, setFetcherData] = useState( null, ); + const isReady = fetcherData !== null; return (
@@ -21,14 +22,20 @@ export function IssueFetcherPanePlayground() {

Playground View

- - Showing {issues.length} issue(s) - + {isReady && ( + + Showing {issues.length} issue(s) + + )}
- {fetcherData?.isLoading ? ( + {!isReady ? ( +

+ Waiting for fetcher state... +

+ ) : fetcherData.isLoading ? (

Loading issues...

- ) : fetcherData?.error ? ( + ) : fetcherData.error ? (

{fetcherData.error}

) : issues.length === 0 ? (

@@ -51,7 +58,7 @@ export function IssueFetcherPanePlayground() { EVENT-LINKED )} - {fetcherData?.blockedParentIds.has(issue.id) && ( + {fetcherData.blockedParentIds.has(issue.id) && ( BLOCKED @@ -65,7 +72,7 @@ export function IssueFetcherPanePlayground() {

Team:{" "} - {fetcherData?.roleNameById.get(issue.team) ?? issue.team} + {fetcherData.roleNameById.get(issue.team) ?? issue.team}

Due:{" "} diff --git a/apps/blade/src/app/_components/issues/issue-fetcher-pane.tsx b/apps/blade/src/app/_components/issues/issue-fetcher-pane.tsx index ca0067080..31eac1bdd 100644 --- a/apps/blade/src/app/_components/issues/issue-fetcher-pane.tsx +++ b/apps/blade/src/app/_components/issues/issue-fetcher-pane.tsx @@ -81,6 +81,13 @@ function parseLocalDate(value: string, endOfDay: boolean) { export function IssueFetcherPane(props: IssueFetcherPaneProps) { const { actions, onDataChange, setIssues } = props; const [filters, setFilters] = useState(DEFAULT_ISSUE_FILTERS); + const statusSelectId = "issue-fetcher-status-select"; + const teamSelectId = "issue-fetcher-team-select"; + const typeSelectId = "issue-fetcher-type-select"; + const searchInputId = "issue-fetcher-search-input"; + const dateFromInputId = "issue-fetcher-date-from-input"; + const dateToInputId = "issue-fetcher-date-to-input"; + const rootOnlyCheckboxId = "issue-fetcher-root-only-checkbox"; const rolesQuery = api.roles.getAllLinks.useQuery(undefined, { refetchOnWindowFocus: false, @@ -108,13 +115,11 @@ export function IssueFetcherPane(props: IssueFetcherPaneProps) { const issuesQuery = api.issues.getAllIssues.useQuery(queryInput, { refetchOnWindowFocus: false, }); - const { - data: fetchedIssues, - error: issuesError, - isLoading, - refetch, - } = issuesQuery; - const issueErrorMessage = issuesError?.message ?? null; + const { data: fetchedIssues } = issuesQuery; + const combinedIsLoading = rolesQuery.isLoading || issuesQuery.isLoading; + const combinedError = rolesQuery.error ?? issuesQuery.error; + const combinedErrorMessage = combinedError?.message ?? null; + const isReady = !combinedIsLoading && !combinedError; const roles = useMemo(() => rolesQuery.data ?? [], [rolesQuery.data]); const roleNameById = useMemo( @@ -170,24 +175,25 @@ export function IssueFetcherPane(props: IssueFetcherPaneProps) { }, [allIssues, filters.issueKind, filters.rootOnly, filters.searchTerm]); const refresh = useCallback(() => { - void refetch(); - }, [refetch]); + void Promise.all([rolesQuery.refetch(), issuesQuery.refetch()]); + }, [issuesQuery, rolesQuery]); const data = useMemo( () => ({ - issues, - blockedParentIds, - roleNameById, - isLoading, - error: issueErrorMessage, + issues: isReady ? issues : [], + blockedParentIds: isReady ? blockedParentIds : new Set(), + roleNameById: isReady ? roleNameById : new Map(), + isLoading: combinedIsLoading, + error: combinedErrorMessage, refresh, filters, }), [ blockedParentIds, + combinedErrorMessage, + combinedIsLoading, filters, - isLoading, - issueErrorMessage, + isReady, issues, refresh, roleNameById, @@ -195,8 +201,8 @@ export function IssueFetcherPane(props: IssueFetcherPaneProps) { ); useEffect(() => { - setIssues?.(issues); - }, [issues, setIssues]); + setIssues?.(isReady ? issues : []); + }, [isReady, issues, setIssues]); useEffect(() => { onDataChange?.(data); @@ -221,17 +227,17 @@ export function IssueFetcherPane(props: IssueFetcherPaneProps) {

- {data.isLoading + {combinedIsLoading ? "Loading issues..." - : data.error - ? data.error - : `${data.issues.length} issue(s) ready for parent views`} + : combinedErrorMessage + ? combinedErrorMessage + : `${issues.length} issue(s) ready for parent views`}
- + setFilters((previous) => ({ ...previous, teamFilter: value })) } > - + @@ -278,7 +284,7 @@ export function IssueFetcherPane(props: IssueFetcherPaneProps) {
- + @@ -314,8 +321,9 @@ export function IssueFetcherPane(props: IssueFetcherPaneProps) {
- + @@ -328,8 +336,9 @@ export function IssueFetcherPane(props: IssueFetcherPaneProps) {
- + @@ -354,6 +363,7 @@ export function IssueFetcherPane(props: IssueFetcherPaneProps) {
setFilters((previous) => ({ @@ -362,7 +372,9 @@ export function IssueFetcherPane(props: IssueFetcherPaneProps) { })) } /> - +
);