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..b8775ae8d --- /dev/null +++ b/apps/blade/src/app/_components/issues/issue-fetcher-pane.tsx @@ -0,0 +1,432 @@ +"use client"; + +import * as React from "react"; +import { useCallback, useId, useMemo, useState } from "react"; + +import { ISSUE } from "@forge/consts"; +import { Button } from "@forge/ui/button"; +import { Checkbox } from "@forge/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@forge/ui/dialog"; +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"; + +interface IssueFetcherPaneProps { + actions?: React.ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; + onClose?: () => void; + setIssues?: React.Dispatch< + React.SetStateAction + >; + onDataChange?: (data: ISSUE.IssueFetcherPaneData) => void; + children?: React.ReactNode; +} + +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, + children, + onClose, + onDataChange, + onOpenChange, + open, + setIssues, + } = props; + const [internalOpen, setInternalOpen] = useState(false); + const isControlled = open !== undefined; + const isOpen = isControlled ? open : internalOpen; + const [filters, setFilters] = useState( + ISSUE.DEFAULT_ISSUE_FILTERS, + ); + const statusSelectId = useId(); + const teamSelectId = useId(); + const typeSelectId = useId(); + const searchInputId = useId(); + const dateFromInputId = useId(); + const dateToInputId = useId(); + const rootOnlyCheckboxId = useId(); + const headerId = useId(); + const descriptionId = useId(); + + const rolesQuery = api.roles.getAllLinks.useQuery(undefined, { + refetchOnWindowFocus: false, + }); + const { + data: rolesData, + refetch: rolesRefetch, + isLoading: rolesIsLoading, + error: rolesError, + } = rolesQuery; + + const dateRangeError = useMemo(() => { + const parsedDateFrom = parseLocalDate(filters.dateFrom, false); + const parsedDateTo = parseLocalDate(filters.dateTo, true); + + if (parsedDateFrom && parsedDateTo && parsedDateFrom > parsedDateTo) { + return "Date From must be on or before Date To."; + } + + return null; + }, [filters.dateFrom, filters.dateTo]); + + 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); + const hasInvertedDateRange = + parsedDateFrom && parsedDateTo && parsedDateFrom > parsedDateTo; + + if (!hasInvertedDateRange) { + 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: issuesData, + refetch: issuesRefetch, + isLoading: issuesIsLoading, + error: issuesError, + } = issuesQuery; + const combinedIsLoading = rolesIsLoading || issuesIsLoading; + const combinedError = rolesError ?? issuesError; + const combinedErrorMessage = dateRangeError ?? combinedError?.message ?? null; + const isReady = !combinedIsLoading && !combinedError && !dateRangeError; + + const roles = useMemo(() => rolesData ?? [], [rolesData]); + const roleNameById = useMemo( + () => new Map(roles.map((role) => [role.id, role.name])), + [roles], + ); + + const allIssues = useMemo( + () => (issuesData ?? []) as ISSUE.IssueFetcherPaneIssue[], + [issuesData], + ); + + const blockedParentIds = useMemo(() => { + const blockedParents = new Set(); + + for (const issue of allIssues) { + if (issue.parent && issue.status !== "FINISHED") { + blockedParents.add(issue.parent); + } + } + + 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([rolesRefetch(), issuesRefetch()]); + }, [issuesRefetch, rolesRefetch]); + + 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, + ], + ); + + React.useEffect(() => { + setIssues?.(isReady ? issues : []); + }, [isReady, issues, setIssues]); + + React.useEffect(() => { + onDataChange?.(data); + }, [data, onDataChange]); + + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + if (isControlled) { + onOpenChange?.(nextOpen); + } else { + setInternalOpen(nextOpen); + } + + if (!nextOpen) { + onClose?.(); + } + }, + [isControlled, onClose, onOpenChange], + ); + + const content = ( + + +

+ Shared Issue Controls +

+ + 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, + })) + } + /> +
+ + {dateRangeError ? ( +
+

+ {dateRangeError} +

+
+ ) : null} + +
+ +
+
+ +
+ + setFilters((previous) => ({ + ...previous, + rootOnly: checked === true, + })) + } + /> + +
+
+
+
+ ); + + return ( + + {children && React.isValidElement(children) ? ( + {children} + ) : null} + {content} + + ); +} diff --git a/packages/consts/src/issue.ts b/packages/consts/src/issue.ts index 6c79153d2..f9b312d30 100644 --- a/packages/consts/src/issue.ts +++ b/packages/consts/src/issue.ts @@ -26,7 +26,56 @@ export const EVENT_TIME_MINUTES = Array.from({ length: 12 }, (_, i) => ); export const EVENT_TIME_AM_PM_OPTIONS = ["AM", "PM"] as const; -//Dialog +export type StatusFilter = "all" | (typeof ISSUE_STATUS)[number]; +export 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_STATUS)[number]; + name: string; + description: string; + links: string[] | null; + event: string | null; + date: Date | null; + priority: (typeof 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; +} + +export const DEFAULT_ISSUE_FILTERS: IssueFilters = { + statusFilter: "all", + teamFilter: "all", + searchTerm: "", + dateFrom: "", + dateTo: "", + rootOnly: true, + issueKind: "all", +}; + +// Dialog export interface CreateEditDialogProps { open: boolean;