From 7fef977cdd400458538c5f64dcc6b583ba2ce367 Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Sat, 11 Apr 2026 00:39:09 -0400 Subject: [PATCH] Add search, sort, and filter system for pulls, issues, and reviews pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement a reusable FilterBar component with instant text search, sort dropdown, and Linear-style two-level filter pills (field picker → value picker) for Repository, Author, and Status fields. Filter state persists via cookies read server-side to avoid SSR flash. --- .../src/components/filters/filter-bar.tsx | 564 ++++++++++++++++++ .../src/components/filters/filter-cookie.ts | 62 ++ .../dashboard/src/components/filters/index.ts | 7 + .../src/components/filters/issue-filters.ts | 143 +++++ .../src/components/filters/pull-filters.ts | 148 +++++ .../components/filters/use-list-filters.ts | 258 ++++++++ .../src/routes/_protected/issues.tsx | 40 +- .../dashboard/src/routes/_protected/pulls.tsx | 49 +- .../src/routes/_protected/reviews.tsx | 38 +- packages/icons/src/index.ts | 3 + 10 files changed, 1296 insertions(+), 16 deletions(-) create mode 100644 apps/dashboard/src/components/filters/filter-bar.tsx create mode 100644 apps/dashboard/src/components/filters/filter-cookie.ts create mode 100644 apps/dashboard/src/components/filters/index.ts create mode 100644 apps/dashboard/src/components/filters/issue-filters.ts create mode 100644 apps/dashboard/src/components/filters/pull-filters.ts create mode 100644 apps/dashboard/src/components/filters/use-list-filters.ts diff --git a/apps/dashboard/src/components/filters/filter-bar.tsx b/apps/dashboard/src/components/filters/filter-bar.tsx new file mode 100644 index 0000000..a74eab1 --- /dev/null +++ b/apps/dashboard/src/components/filters/filter-bar.tsx @@ -0,0 +1,564 @@ +import { + CheckIcon, + ChevronDownIcon, + CloseIcon, + PlusSignIcon, + Remove01Icon, + SearchIcon, + SortIcon, +} from "@diffkit/icons"; +import { cn } from "@diffkit/ui/lib/utils"; +import { memo, useEffect, useRef, useState } from "react"; +import type { + ActiveFilter, + FilterableItem, + FilterDefinition, + FilterOption, + ListFilterState, + SortOption, +} from "./use-list-filters"; + +// ── Main FilterBar ───────────────────────────────────────────────────── + +export const FilterBar = memo(function FilterBar({ + state, +}: { + state: ListFilterState; +}) { + return ( +
+
+ + +
+
+ {state.activeFilters.map((filter) => { + const def = state.filterDefs.find((d) => d.id === filter.fieldId); + if (!def) return null; + const options = state.availableOptions.get(filter.fieldId) ?? []; + return ( + + ); + })} + + {state.hasActiveFilters && ( + + )} +
+
+ ); +}) as (props: { + state: ListFilterState; +}) => React.ReactNode; + +// ── Search Input ─────────────────────────────────────────────────────── + +function SearchInput({ + value, + onChange, +}: { + value: string; + onChange: (v: string) => void; +}) { + return ( +
+ + onChange(e.target.value)} + placeholder="Search by title, author, repo…" + className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground/60" + /> + {value && ( + + )} +
+ ); +} + +// ── Sort Dropdown ────────────────────────────────────────────────────── + +function SortDropdown({ + options, + value, + onChange, +}: { + options: SortOption[]; + value: string; + onChange: (id: string) => void; +}) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + const current = options.find((o) => o.id === value); + + return ( +
+ + {open && ( + setOpen(false)} anchorRef={ref}> + {options.map((opt) => ( + + ))} + + )} +
+ ); +} + +// ── Filter Pill ──────────────────────────────────────────────────────── + +function FilterPill({ + filter, + definition, + options, + onRemoveValue, + onRemove, + onAdd, +}: { + filter: ActiveFilter; + definition: FilterDefinition; + options: FilterOption[]; + onRemoveValue: (fieldId: string, value: string) => void; + onRemove: (fieldId: string) => void; + onAdd: (fieldId: string, value: string) => void; +}) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + const selectedOptions = options.filter((o) => filter.values.has(o.value)); + + return ( +
+ + + {open && ( + { + if (filter.values.has(value)) { + onRemoveValue(filter.fieldId, value); + } else { + onAdd(filter.fieldId, value); + } + }} + onClose={() => setOpen(false)} + anchorRef={ref} + /> + )} +
+ ); +} + +// ── Add Filter Button ────────────────────────────────────────────────── + +function AddFilterButton({ + filterDefs, + activeFilters, + availableOptions, + onAdd, + onRemoveValue, +}: { + filterDefs: FilterDefinition[]; + activeFilters: ActiveFilter[]; + availableOptions: Map; + onAdd: (fieldId: string, value: string) => void; + onRemoveValue: (fieldId: string, value: string) => void; +}) { + const [open, setOpen] = useState(false); + const [selectedFieldId, setSelectedFieldId] = useState(null); + const ref = useRef(null); + const close = () => { + setOpen(false); + setSelectedFieldId(null); + }; + + const activeFieldIds = new Set(activeFilters.map((f) => f.fieldId)); + const selectedOptions = selectedFieldId + ? (availableOptions.get(selectedFieldId) ?? []) + : []; + const selectedValues = + activeFilters.find((f) => f.fieldId === selectedFieldId)?.values ?? + new Set(); + + return ( +
+ + {open && ( + <> + {/* biome-ignore lint/a11y/useKeyWithClickEvents: backdrop dismiss */} + {/* biome-ignore lint/a11y/noStaticElementInteractions: backdrop dismiss */} +
+
+ + {selectedFieldId && ( + { + if (selectedValues.has(value)) { + onRemoveValue(selectedFieldId, value); + } else { + onAdd(selectedFieldId, value); + } + }} + /> + )} +
+ + )} +
+ ); +} + +// ── Field Picker (inline, no backdrop) ───────────────────────────────── + +function FieldPickerInline({ + filterDefs, + activeFieldIds, + selectedFieldId, + onSelect, +}: { + filterDefs: FilterDefinition[]; + activeFieldIds: Set; + selectedFieldId: string | null; + onSelect: (fieldId: string) => void; +}) { + const [query, setQuery] = useState(""); + const inputRef = useRef(null); + useEffect(() => { + inputRef.current?.focus(); + }, []); + const filtered = filterDefs.filter((d) => + d.label.toLowerCase().includes(query.toLowerCase()), + ); + + return ( +
+
+ + setQuery(e.target.value)} + placeholder="Filter…" + className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground/60" + /> +
+
+ {filtered.map((def) => ( + + ))} + {filtered.length === 0 && ( +

+ No matching filters +

+ )} +
+
+ ); +} + +// ── Value Picker (inline, no backdrop) ───────────────────────────────── + +function ValuePickerInline({ + options, + selectedValues, + onToggle, +}: { + options: FilterOption[]; + selectedValues: Set; + onToggle: (value: string) => void; +}) { + const [query, setQuery] = useState(""); + const inputRef = useRef(null); + useEffect(() => { + inputRef.current?.focus(); + }, []); + const filtered = options.filter((o) => + o.label.toLowerCase().includes(query.toLowerCase()), + ); + + return ( +
+
+ + setQuery(e.target.value)} + placeholder="Search…" + className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground/60" + /> +
+
+ {filtered.map((opt) => { + const isSelected = selectedValues.has(opt.value); + return ( + + ); + })} + {filtered.length === 0 && ( +

+ No matching options +

+ )} +
+
+ ); +} + +// ── Value Picker Dropdown ────────────────────────────────────────────── + +function ValuePicker({ + options, + selectedValues, + onToggle, + onClose, + anchorRef, +}: { + options: FilterOption[]; + selectedValues: Set; + onToggle: (value: string) => void; + onClose: () => void; + anchorRef: React.RefObject; +}) { + const [query, setQuery] = useState(""); + const inputRef = useRef(null); + useEffect(() => { + inputRef.current?.focus(); + }, []); + const filtered = options.filter((o) => + o.label.toLowerCase().includes(query.toLowerCase()), + ); + + return ( + +
+ + setQuery(e.target.value)} + placeholder="Search…" + className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground/60" + /> +
+
+ {filtered.map((opt) => { + const isSelected = selectedValues.has(opt.value); + return ( + + ); + })} + {filtered.length === 0 && ( +

+ No matching options +

+ )} +
+
+ ); +} + +// ── Dropdown Panel (shared) ──────────────────────────────────────────── + +function DropdownPanel({ + children, + onClose, + anchorRef: _anchorRef, +}: { + children: React.ReactNode; + onClose: () => void; + anchorRef: React.RefObject; +}) { + return ( + <> + {/* biome-ignore lint/a11y/useKeyWithClickEvents: backdrop dismiss */} + {/* biome-ignore lint/a11y/noStaticElementInteractions: backdrop dismiss */} +
+
+ {children} +
+ + ); +} diff --git a/apps/dashboard/src/components/filters/filter-cookie.ts b/apps/dashboard/src/components/filters/filter-cookie.ts new file mode 100644 index 0000000..8dd83d5 --- /dev/null +++ b/apps/dashboard/src/components/filters/filter-cookie.ts @@ -0,0 +1,62 @@ +import { createServerFn } from "@tanstack/react-start"; + +const COOKIE_NAME = "diffkit-filters"; +const MAX_AGE = 60 * 60 * 24 * 365; // 1 year + +type SerializedPageState = { + searchQuery: string; + activeFilters: { fieldId: string; values: string[] }[]; + sortId: string; +}; + +export type SerializedFilterStore = Record; + +// ── Server: read cookie ──────────────────────────────────────────────── + +export const getFilterCookie = createServerFn({ method: "GET" }).handler( + async (): Promise => { + const { getRequest } = await import("@tanstack/react-start/server"); + const request = getRequest(); + const cookieHeader = request.headers.get("cookie") ?? ""; + return parseCookieValue(cookieHeader); + }, +); + +function parseCookieValue(cookieHeader: string): SerializedFilterStore { + try { + for (const cookie of cookieHeader.split(";")) { + const [rawName, ...rawValue] = cookie.trim().split("="); + if (rawName === COOKIE_NAME) { + const decoded = decodeURIComponent(rawValue.join("=")); + const parsed = JSON.parse(decoded); + if ( + typeof parsed === "object" && + parsed !== null && + !Array.isArray(parsed) + ) { + return parsed as SerializedFilterStore; + } + } + } + } catch { + // corrupted cookie — return empty + } + return {}; +} + +// ── Client: write cookie ─────────────────────────────────────────────── + +export function writeFilterCookie(store: SerializedFilterStore) { + try { + const value = encodeURIComponent(JSON.stringify(store)); + // biome-ignore lint/suspicious/noDocumentCookie: Cookie Store API not available in all browsers + document.cookie = `${COOKIE_NAME}=${value}; path=/; max-age=${MAX_AGE}; SameSite=Lax`; + } catch { + // silently ignore + } +} + +export function readFilterCookieClient(): SerializedFilterStore { + if (typeof document === "undefined") return {}; + return parseCookieValue(document.cookie); +} diff --git a/apps/dashboard/src/components/filters/index.ts b/apps/dashboard/src/components/filters/index.ts new file mode 100644 index 0000000..bf6370c --- /dev/null +++ b/apps/dashboard/src/components/filters/index.ts @@ -0,0 +1,7 @@ +export { FilterBar } from "./filter-bar"; +export type { SerializedFilterStore } from "./filter-cookie"; +export { getFilterCookie } from "./filter-cookie"; +export { issueFilterDefs, issueSortOptions } from "./issue-filters"; +export { pullFilterDefs, pullSortOptions } from "./pull-filters"; +export type { FilterableItem, ListFilterState } from "./use-list-filters"; +export { applyFilters, useListFilters } from "./use-list-filters"; diff --git a/apps/dashboard/src/components/filters/issue-filters.ts b/apps/dashboard/src/components/filters/issue-filters.ts new file mode 100644 index 0000000..6a54bd8 --- /dev/null +++ b/apps/dashboard/src/components/filters/issue-filters.ts @@ -0,0 +1,143 @@ +import { + CircleIcon, + FolderLibraryIcon, + IssuesIcon, + UserCircleIcon, +} from "@diffkit/icons"; +import { createElement } from "react"; +import type { + FilterableItem, + FilterDefinition, + SortOption, +} from "./use-list-filters"; + +type IssueFilterableItem = FilterableItem & { + stateReason: string | null; +}; + +function asIssue(item: FilterableItem): IssueFilterableItem { + return item as IssueFilterableItem; +} + +export const issueFilterDefs: FilterDefinition[] = [ + { + id: "repo", + label: "Repository", + icon: FolderLibraryIcon, + extractOptions: (items) => { + const repos = new Map(); + for (const item of items) { + const name = item.repository.fullName; + if (!repos.has(name)) repos.set(name, name); + } + return [...repos.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([value, label]) => ({ value, label })); + }, + match: (item, values) => values.has(item.repository.fullName), + }, + { + id: "author", + label: "Author", + icon: UserCircleIcon, + extractOptions: (items) => { + const authors = new Map(); + for (const item of items) { + if (item.author && !authors.has(item.author.login)) { + authors.set(item.author.login, item.author); + } + } + return [...authors.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([login, author]) => ({ + value: login, + label: login, + icon: createElement("img", { + src: author.avatarUrl, + alt: login, + className: "size-4 rounded-full", + }), + })); + }, + match: (item, values) => + item.author ? values.has(item.author.login) : false, + }, + { + id: "status", + label: "Status", + icon: CircleIcon, + extractOptions: (items) => { + const statuses = new Set(); + for (const item of items) { + const issue = asIssue(item); + if (issue.state === "closed") { + statuses.add( + issue.stateReason === "not_planned" ? "not_planned" : "completed", + ); + } else { + statuses.add("open"); + } + } + const all = [ + { value: "open", label: "Open" }, + { value: "completed", label: "Closed (completed)" }, + { value: "not_planned", label: "Closed (not planned)" }, + ]; + const colorMap: Record = { + open: "text-green-500", + completed: "text-purple-500", + not_planned: "text-muted-foreground", + }; + return all + .filter((s) => statuses.has(s.value)) + .map((s) => ({ + value: s.value, + label: s.label, + icon: createElement(IssuesIcon, { + size: 14, + className: colorMap[s.value], + }), + })); + }, + match: (item, values) => { + const issue = asIssue(item); + if (issue.state === "closed") { + return issue.stateReason === "not_planned" + ? values.has("not_planned") + : values.has("completed"); + } + return values.has("open"); + }, + }, +]; + +export const issueSortOptions: SortOption[] = [ + { + id: "updated", + label: "Recently updated", + compare: (a, b) => + new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), + }, + { + id: "created", + label: "Newest first", + compare: (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + }, + { + id: "created-asc", + label: "Oldest first", + compare: (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + }, + { + id: "comments", + label: "Most comments", + compare: (a, b) => b.comments - a.comments, + }, + { + id: "title", + label: "Title A–Z", + compare: (a, b) => a.title.localeCompare(b.title), + }, +]; diff --git a/apps/dashboard/src/components/filters/pull-filters.ts b/apps/dashboard/src/components/filters/pull-filters.ts new file mode 100644 index 0000000..ef10e25 --- /dev/null +++ b/apps/dashboard/src/components/filters/pull-filters.ts @@ -0,0 +1,148 @@ +import { + CircleIcon, + FolderLibraryIcon, + GitMergeIcon, + GitPullRequestClosedIcon, + GitPullRequestDraftIcon, + GitPullRequestIcon, + UserCircleIcon, +} from "@diffkit/icons"; +import { createElement } from "react"; +import type { + FilterableItem, + FilterDefinition, + SortOption, +} from "./use-list-filters"; + +type PullFilterableItem = FilterableItem & { + isDraft: boolean; + mergedAt: string | null; +}; + +function asPull(item: FilterableItem): PullFilterableItem { + return item as PullFilterableItem; +} + +export const pullFilterDefs: FilterDefinition[] = [ + { + id: "repo", + label: "Repository", + icon: FolderLibraryIcon, + extractOptions: (items) => { + const repos = new Map(); + for (const item of items) { + const name = item.repository.fullName; + if (!repos.has(name)) repos.set(name, name); + } + return [...repos.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([value, label]) => ({ value, label })); + }, + match: (item, values) => values.has(item.repository.fullName), + }, + { + id: "author", + label: "Author", + icon: UserCircleIcon, + extractOptions: (items) => { + const authors = new Map(); + for (const item of items) { + if (item.author && !authors.has(item.author.login)) { + authors.set(item.author.login, item.author); + } + } + return [...authors.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([login, author]) => ({ + value: login, + label: login, + icon: createElement("img", { + src: author.avatarUrl, + alt: login, + className: "size-4 rounded-full", + }), + })); + }, + match: (item, values) => + item.author ? values.has(item.author.login) : false, + }, + { + id: "status", + label: "Status", + icon: CircleIcon, + extractOptions: (items) => { + const statuses = new Set(); + for (const item of items) { + const pull = asPull(item); + if (pull.mergedAt) statuses.add("merged"); + else if (pull.state === "closed") statuses.add("closed"); + else if (pull.isDraft) statuses.add("draft"); + else statuses.add("open"); + } + const all: { + value: string; + label: string; + icon: React.ComponentType<{ size?: number; className?: string }>; + }[] = [ + { value: "open", label: "Open", icon: GitPullRequestIcon }, + { value: "draft", label: "Draft", icon: GitPullRequestDraftIcon }, + { value: "merged", label: "Merged", icon: GitMergeIcon }, + { value: "closed", label: "Closed", icon: GitPullRequestClosedIcon }, + ]; + const colorMap: Record = { + open: "text-green-500", + draft: "text-muted-foreground", + merged: "text-purple-500", + closed: "text-red-500", + }; + return all + .filter((s) => statuses.has(s.value)) + .map((s) => ({ + value: s.value, + label: s.label, + icon: createElement(s.icon, { + size: 14, + className: colorMap[s.value], + }), + })); + }, + match: (item, values) => { + const pull = asPull(item); + if (pull.mergedAt) return values.has("merged"); + if (pull.state === "closed") return values.has("closed"); + if (pull.isDraft) return values.has("draft"); + return values.has("open"); + }, + }, +]; + +export const pullSortOptions: SortOption[] = [ + { + id: "updated", + label: "Recently updated", + compare: (a, b) => + new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), + }, + { + id: "created", + label: "Newest first", + compare: (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + }, + { + id: "created-asc", + label: "Oldest first", + compare: (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + }, + { + id: "comments", + label: "Most comments", + compare: (a, b) => b.comments - a.comments, + }, + { + id: "title", + label: "Title A–Z", + compare: (a, b) => a.title.localeCompare(b.title), + }, +]; diff --git a/apps/dashboard/src/components/filters/use-list-filters.ts b/apps/dashboard/src/components/filters/use-list-filters.ts new file mode 100644 index 0000000..b7d736c --- /dev/null +++ b/apps/dashboard/src/components/filters/use-list-filters.ts @@ -0,0 +1,258 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { SerializedFilterStore } from "./filter-cookie"; +import { readFilterCookieClient, writeFilterCookie } from "./filter-cookie"; + +// ── Types ────────────────────────────────────────────────────────────── + +export type FilterDefinition = { + id: string; + label: string; + icon: React.ComponentType<{ size?: number; strokeWidth?: number }>; + /** Extract all possible values from the full item list */ + extractOptions: (items: FilterableItem[]) => FilterOption[]; + /** Test whether an item matches a set of selected values */ + match: (item: FilterableItem, values: Set) => boolean; +}; + +export type FilterOption = { + value: string; + label: string; + icon?: React.ReactNode; +}; + +export type ActiveFilter = { + fieldId: string; + values: Set; +}; + +export type SortOption = { + id: string; + label: string; + compare: (a: FilterableItem, b: FilterableItem) => number; +}; + +export type FilterableItem = { + id: number; + title: string; + updatedAt: string; + createdAt: string; + comments: number; + author: { login: string; avatarUrl: string } | null; + repository: { fullName: string }; + state: string; + [key: string]: unknown; +}; + +// ── Deserialize from cookie store ────────────────────────────────────── + +function readPageState( + store: SerializedFilterStore, + pageId: string, + defaultSortId: string, +): { searchQuery: string; activeFilters: ActiveFilter[]; sortId: string } { + const page = store[pageId]; + if (!page || typeof page !== "object") { + return { searchQuery: "", activeFilters: [], sortId: defaultSortId }; + } + + const searchQuery = + typeof page.searchQuery === "string" ? page.searchQuery : ""; + const sortId = typeof page.sortId === "string" ? page.sortId : defaultSortId; + + let activeFilters: ActiveFilter[] = []; + if (Array.isArray(page.activeFilters)) { + activeFilters = page.activeFilters + .filter( + (f): f is { fieldId: string; values: string[] } => + typeof f === "object" && + f !== null && + typeof f.fieldId === "string" && + Array.isArray(f.values) && + f.values.every((v: unknown) => typeof v === "string"), + ) + .map((f) => ({ fieldId: f.fieldId, values: new Set(f.values) })); + } + + return { searchQuery, activeFilters, sortId }; +} + +function writePageState( + pageId: string, + state: { searchQuery: string; activeFilters: ActiveFilter[]; sortId: string }, +) { + const store = readFilterCookieClient(); + store[pageId] = { + searchQuery: state.searchQuery, + activeFilters: state.activeFilters.map((f) => ({ + fieldId: f.fieldId, + values: [...f.values], + })), + sortId: state.sortId, + }; + writeFilterCookie(store); +} + +// ── Hook ─────────────────────────────────────────────────────────────── + +export function useListFilters({ + pageId, + items, + filterDefs, + sortOptions, + defaultSortId, + initialStore, +}: { + /** Unique key for this page (e.g. "pulls", "issues", "reviews") */ + pageId: string; + /** All items (used to extract filter options) */ + items: T[]; + filterDefs: FilterDefinition[]; + sortOptions: SortOption[]; + defaultSortId: string; + /** Pre-read cookie store from the loader (avoids flash) */ + initialStore?: SerializedFilterStore; +}) { + const [state, setState] = useState(() => + readPageState(initialStore ?? {}, pageId, defaultSortId), + ); + + // Validate sortId against available options + const sortId = sortOptions.some((o) => o.id === state.sortId) + ? state.sortId + : defaultSortId; + + // Persist to cookie on change + const isInitial = useRef(true); + useEffect(() => { + if (isInitial.current) { + isInitial.current = false; + return; + } + writePageState(pageId, state); + }, [pageId, state]); + + const availableOptions = useMemo(() => { + const map = new Map(); + for (const def of filterDefs) { + map.set(def.id, def.extractOptions(items)); + } + return map; + }, [items, filterDefs]); + + const setSearchQuery = useCallback((searchQuery: string) => { + setState((prev) => ({ ...prev, searchQuery })); + }, []); + + const setSortId = useCallback((sortId: string) => { + setState((prev) => ({ ...prev, sortId })); + }, []); + + const addFilter = useCallback((fieldId: string, value: string) => { + setState((prev) => { + const existing = prev.activeFilters.find((f) => f.fieldId === fieldId); + if (existing) { + return { + ...prev, + activeFilters: prev.activeFilters.map((f) => + f.fieldId === fieldId + ? { ...f, values: new Set([...f.values, value]) } + : f, + ), + }; + } + return { + ...prev, + activeFilters: [ + ...prev.activeFilters, + { fieldId, values: new Set([value]) }, + ], + }; + }); + }, []); + + const removeFilterValue = useCallback((fieldId: string, value: string) => { + setState((prev) => ({ + ...prev, + activeFilters: prev.activeFilters + .map((f) => { + if (f.fieldId !== fieldId) return f; + const next = new Set(f.values); + next.delete(value); + return { ...f, values: next }; + }) + .filter((f) => f.values.size > 0), + })); + }, []); + + const removeFilter = useCallback((fieldId: string) => { + setState((prev) => ({ + ...prev, + activeFilters: prev.activeFilters.filter((f) => f.fieldId !== fieldId), + })); + }, []); + + const clearAllFilters = useCallback(() => { + setState((prev) => ({ + ...prev, + searchQuery: "", + activeFilters: [], + })); + }, []); + + const hasActiveFilters = + state.activeFilters.length > 0 || state.searchQuery.length > 0; + + return { + searchQuery: state.searchQuery, + setSearchQuery, + activeFilters: state.activeFilters, + sortId, + setSortId, + availableOptions, + addFilter, + removeFilterValue, + removeFilter, + clearAllFilters, + hasActiveFilters, + sortOptions, + filterDefs, + }; +} + +export type ListFilterState = ReturnType< + typeof useListFilters +>; + +/** Apply current filter state to an array of items */ +export function applyFilters( + items: T[], + state: ListFilterState, +): T[] { + const query = state.searchQuery.toLowerCase().trim(); + const sortFn = + state.sortOptions.find((s) => s.id === state.sortId)?.compare ?? + state.sortOptions[0].compare; + + let result: T[] = items; + + // Text search + if (query) { + result = result.filter( + (item) => + item.title.toLowerCase().includes(query) || + item.repository.fullName.toLowerCase().includes(query) || + (item.author?.login.toLowerCase().includes(query) ?? false), + ); + } + + // Active filters (AND across fields, OR within a field) + for (const filter of state.activeFilters) { + if (filter.values.size === 0) continue; + const def = state.filterDefs.find((d) => d.id === filter.fieldId); + if (!def) continue; + result = result.filter((item) => def.match(item, filter.values)); + } + + // Sort + return [...result].sort(sortFn); +} diff --git a/apps/dashboard/src/routes/_protected/issues.tsx b/apps/dashboard/src/routes/_protected/issues.tsx index 0b2ea96..9bd3a96 100644 --- a/apps/dashboard/src/routes/_protected/issues.tsx +++ b/apps/dashboard/src/routes/_protected/issues.tsx @@ -7,9 +7,18 @@ import { memo, type RefObject, useEffect, + useMemo, useRef, useState, } from "react"; +import { + applyFilters, + FilterBar, + getFilterCookie, + issueFilterDefs, + issueSortOptions, + useListFilters, +} from "#/components/filters"; import { IssueRow } from "#/components/issues/issue-row"; import { DashboardContentLoading } from "#/components/layouts/dashboard-content-loading"; import { githubMyIssuesQueryOptions } from "#/lib/github.query"; @@ -20,9 +29,11 @@ import { useHasMounted } from "#/lib/use-has-mounted"; export const Route = createFileRoute("/_protected/issues")({ loader: async ({ context }) => { const scope = { userId: context.user.id }; - await context.queryClient.ensureQueryData( - githubMyIssuesQueryOptions(scope), - ); + const [, filterStore] = await Promise.all([ + context.queryClient.ensureQueryData(githubMyIssuesQueryOptions(scope)), + getFilterCookie(), + ]); + return { filterStore }; }, pendingComponent: DashboardContentLoading, head: ({ match }) => @@ -37,6 +48,7 @@ export const Route = createFileRoute("/_protected/issues")({ }); function IssuesPage() { + const { filterStore } = Route.useLoaderData(); const { user } = Route.useRouteContext(); const scope = { userId: user.id }; const hasMounted = useHasMounted(); @@ -46,6 +58,21 @@ function IssuesPage() { enabled: hasMounted, }); + const allIssues = useMemo(() => { + if (!query.data) return []; + const d = query.data; + return [...d.assigned, ...d.authored, ...d.mentioned]; + }, [query.data]); + + const filterState = useListFilters({ + pageId: "issues", + items: allIssues, + filterDefs: issueFilterDefs, + sortOptions: issueSortOptions, + defaultSortId: "updated", + initialStore: filterStore, + }); + if (query.error) throw query.error; if (query.data) { const data = query.data; @@ -54,19 +81,19 @@ function IssuesPage() { id: "assigned", title: "Assigned", icon: InboxIcon, - issues: data.assigned, + issues: applyFilters(data.assigned, filterState), }, { id: "authored", title: "Authored", icon: IssuesIcon, - issues: data.authored, + issues: applyFilters(data.authored, filterState), }, { id: "mentioned", title: "Mentioned", icon: CommentIcon, - issues: data.mentioned, + issues: applyFilters(data.mentioned, filterState), }, ]; const totalIssues = groups.reduce( @@ -100,6 +127,7 @@ function IssuesPage() {
+ {groups.map((group) => ( { const scope = { userId: context.user.id }; - await context.queryClient.ensureQueryData(githubMyPullsQueryOptions(scope)); + const [, filterStore] = await Promise.all([ + context.queryClient.ensureQueryData(githubMyPullsQueryOptions(scope)), + getFilterCookie(), + ]); + return { filterStore }; }, pendingComponent: DashboardContentLoading, head: ({ match }) => @@ -41,6 +54,7 @@ export const Route = createFileRoute("/_protected/pulls")({ }); function PullRequestsPage() { + const { filterStore } = Route.useLoaderData(); const { user } = Route.useRouteContext(); const scope = { userId: user.id }; const hasMounted = useHasMounted(); @@ -50,6 +64,28 @@ function PullRequestsPage() { enabled: hasMounted, }); + // Flatten all pulls for filter option extraction + const allPulls = useMemo(() => { + if (!query.data) return []; + const d = query.data; + return [ + ...d.reviewRequested, + ...d.assigned, + ...d.authored, + ...d.mentioned, + ...d.involved, + ]; + }, [query.data]); + + const filterState = useListFilters({ + pageId: "pulls", + items: allPulls, + filterDefs: pullFilterDefs, + sortOptions: pullSortOptions, + defaultSortId: "updated", + initialStore: filterStore, + }); + if (query.error) throw query.error; if (query.data) { const data = query.data; @@ -58,31 +94,31 @@ function PullRequestsPage() { id: "review-requested", title: "Review requested", icon: ReviewsIcon, - pulls: data.reviewRequested, + pulls: applyFilters(data.reviewRequested, filterState), }, { id: "assigned", title: "Assigned", icon: InboxIcon, - pulls: data.assigned, + pulls: applyFilters(data.assigned, filterState), }, { id: "authored", title: "Authored", icon: GitPullRequestIcon, - pulls: data.authored, + pulls: applyFilters(data.authored, filterState), }, { id: "mentioned", title: "Mentioned", icon: CommentIcon, - pulls: data.mentioned, + pulls: applyFilters(data.mentioned, filterState), }, { id: "involved", title: "Involved", icon: GitBranchIcon, - pulls: data.involved, + pulls: applyFilters(data.involved, filterState), }, ]; const totalPulls = groups.reduce( @@ -121,6 +157,7 @@ function PullRequestsPage() {
+ {groups.map((group) => ( { const scope = { userId: context.user.id }; - await context.queryClient.ensureQueryData(githubMyPullsQueryOptions(scope)); + const [, filterStore] = await Promise.all([ + context.queryClient.ensureQueryData(githubMyPullsQueryOptions(scope)), + getFilterCookie(), + ]); + return { filterStore }; }, pendingComponent: DashboardContentLoading, head: ({ match }) => @@ -26,6 +38,7 @@ export const Route = createFileRoute("/_protected/reviews")({ }); function ReviewsPage() { + const { filterStore } = Route.useLoaderData(); const { user } = Route.useRouteContext(); const scope = { userId: user.id }; const hasMounted = useHasMounted(); @@ -35,9 +48,23 @@ function ReviewsPage() { enabled: hasMounted, }); + const rawReviews = useMemo( + () => query.data?.reviewRequested ?? [], + [query.data], + ); + + const filterState = useListFilters({ + pageId: "reviews", + items: rawReviews, + filterDefs: pullFilterDefs, + sortOptions: pullSortOptions, + defaultSortId: "updated", + initialStore: filterStore, + }); + if (query.error) throw query.error; if (query.data) { - const reviews = query.data.reviewRequested; + const reviews = applyFilters(rawReviews, filterState); return (
@@ -65,9 +92,12 @@ function ReviewsPage() {
+ {reviews.length === 0 ? (

- No review requests right now — you're all caught up. + {filterState.hasActiveFilters + ? "No reviews match your filters." + : "No review requests right now — you're all caught up."}

) : (
diff --git a/packages/icons/src/index.ts b/packages/icons/src/index.ts index 5f5b001..9f058d7 100644 --- a/packages/icons/src/index.ts +++ b/packages/icons/src/index.ts @@ -4,6 +4,7 @@ export { AddCircleHalfDotIcon as IssuesIcon, AlertCircleIcon, + ArrangeIcon as SortIcon, ArrowDown01Icon as ChevronDownIcon, ArrowLeft01Icon as ChevronLeftIcon, ArrowReloadHorizontalIcon as RefreshCwIcon, @@ -42,6 +43,7 @@ export { Notification01Icon as NotificationIcon, PencilEdit01Icon as EditIcon, PlusSignIcon, + Remove01Icon, Search01Icon as SearchIcon, Settings01Icon as SettingsIcon, SidebarLeftIcon as PanelLeftIcon, @@ -50,6 +52,7 @@ export { Tick02Icon as CheckIcon, Tick02Icon as TickIcon, UserAdd01Icon as UserAddIcon, + UserCircleIcon, ViewIcon, } from "@hugeicons/react"; export { GitHubLogo, GitHubWordmarkLogo } from "./brand-logos";