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..18b287c39 --- /dev/null +++ b/apps/blade/src/app/_components/issues/issue-fetcher-pane-playground.tsx @@ -0,0 +1,102 @@ +"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, + ); + const isReady = fetcherData !== null; + + return ( +
+ + +
+
+

Playground View

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

+ Waiting for fetcher state... +

+ ) : 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..31eac1bdd --- /dev/null +++ b/apps/blade/src/app/_components/issues/issue-fetcher-pane.tsx @@ -0,0 +1,381 @@ +"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 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, + }); + + 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 } = 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( + () => 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 Promise.all([rolesQuery.refetch(), issuesQuery.refetch()]); + }, [issuesQuery, rolesQuery]); + + const data = useMemo( + () => ({ + issues: isReady ? issues : [], + blockedParentIds: isReady ? blockedParentIds : new Set(), + roleNameById: isReady ? roleNameById : new Map(), + isLoading: combinedIsLoading, + error: combinedErrorMessage, + refresh, + filters, + }), + [ + blockedParentIds, + combinedErrorMessage, + combinedIsLoading, + filters, + isReady, + issues, + refresh, + roleNameById, + ], + ); + + useEffect(() => { + setIssues?.(isReady ? issues : []); + }, [isReady, 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} + +
+
+ {combinedIsLoading + ? "Loading issues..." + : combinedErrorMessage + ? combinedErrorMessage + : `${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, + })) + } + /> + +
+
+ ); +}