mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 20:03:37 +00:00
feat: add filtering capabilities to GitHub issues view
- Implemented a comprehensive filtering system for GitHub issues, allowing users to filter by state, labels, assignees, and validation status. - Introduced a new IssuesFilterControls component for managing filter options. - Updated the GitHubIssuesView to utilize the new filtering logic, enhancing the user experience by providing clearer visibility into matching issues. - Added hooks for filtering logic and state management, ensuring efficient updates and rendering of filtered issues. These changes aim to improve the usability of the issues view by enabling users to easily navigate and manage their issues based on specific criteria.
This commit is contained in:
@@ -1,20 +1,26 @@
|
||||
// @ts-nocheck
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { CircleDot, RefreshCw } from 'lucide-react';
|
||||
import { CircleDot, RefreshCw, SearchX } from 'lucide-react';
|
||||
import { getElectronAPI, GitHubIssue, IssueValidationResult } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { LoadingState } from '@/components/ui/loading-state';
|
||||
import { ErrorState } from '@/components/ui/error-state';
|
||||
import { cn, pathsEqual } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import { useGithubIssues, useIssueValidation } from './github-issues-view/hooks';
|
||||
import { useGithubIssues, useIssueValidation, useIssuesFilter } from './github-issues-view/hooks';
|
||||
import { IssueRow, IssueDetailPanel, IssuesListHeader } from './github-issues-view/components';
|
||||
import { ValidationDialog } from './github-issues-view/dialogs';
|
||||
import { formatDate, getFeaturePriority } from './github-issues-view/utils';
|
||||
import { useModelOverride } from '@/components/shared';
|
||||
import type { ValidateIssueOptions } from './github-issues-view/types';
|
||||
import type {
|
||||
ValidateIssueOptions,
|
||||
IssuesFilterState,
|
||||
IssuesStateFilter,
|
||||
} from './github-issues-view/types';
|
||||
import { DEFAULT_ISSUES_FILTER_STATE } from './github-issues-view/types';
|
||||
|
||||
const logger = createLogger('GitHubIssuesView');
|
||||
|
||||
@@ -26,6 +32,9 @@ export function GitHubIssuesView() {
|
||||
const [pendingRevalidateOptions, setPendingRevalidateOptions] =
|
||||
useState<ValidateIssueOptions | null>(null);
|
||||
|
||||
// Filter state
|
||||
const [filterState, setFilterState] = useState<IssuesFilterState>(DEFAULT_ISSUES_FILTER_STATE);
|
||||
|
||||
const { currentProject, getCurrentWorktree, worktreesByProject } = useAppStore();
|
||||
|
||||
// Model override for validation
|
||||
@@ -44,6 +53,37 @@ export function GitHubIssuesView() {
|
||||
onShowValidationDialogChange: setShowValidationDialog,
|
||||
});
|
||||
|
||||
// Combine all issues for filtering
|
||||
const allIssues = useMemo(() => [...openIssues, ...closedIssues], [openIssues, closedIssues]);
|
||||
|
||||
// Apply filter to issues
|
||||
const filterResult = useIssuesFilter(allIssues, filterState, cachedValidations);
|
||||
|
||||
// Filter issues based on matched results
|
||||
const filteredOpenIssues = useMemo(
|
||||
() => openIssues.filter((issue) => filterResult.matchedIssueNumbers.has(issue.number)),
|
||||
[openIssues, filterResult.matchedIssueNumbers]
|
||||
);
|
||||
|
||||
const filteredClosedIssues = useMemo(
|
||||
() => closedIssues.filter((issue) => filterResult.matchedIssueNumbers.has(issue.number)),
|
||||
[closedIssues, filterResult.matchedIssueNumbers]
|
||||
);
|
||||
|
||||
// Filter state change handlers
|
||||
const handleStateFilterChange = useCallback((stateFilter: IssuesStateFilter) => {
|
||||
setFilterState((prev) => ({ ...prev, stateFilter }));
|
||||
}, []);
|
||||
|
||||
const handleLabelsChange = useCallback((selectedLabels: string[]) => {
|
||||
setFilterState((prev) => ({ ...prev, selectedLabels }));
|
||||
}, []);
|
||||
|
||||
// Clear all filters to default state
|
||||
const handleClearFilters = useCallback(() => {
|
||||
setFilterState(DEFAULT_ISSUES_FILTER_STATE);
|
||||
}, []);
|
||||
|
||||
// Get current branch from selected worktree
|
||||
const currentBranch = useMemo(() => {
|
||||
if (!currentProject?.path) return '';
|
||||
@@ -130,7 +170,10 @@ export function GitHubIssuesView() {
|
||||
return <ErrorState error={error} title="Failed to Load Issues" onRetry={refresh} />;
|
||||
}
|
||||
|
||||
const totalIssues = openIssues.length + closedIssues.length;
|
||||
const totalIssues = filteredOpenIssues.length + filteredClosedIssues.length;
|
||||
const totalUnfilteredIssues = openIssues.length + closedIssues.length;
|
||||
const isFilteredEmpty =
|
||||
totalIssues === 0 && totalUnfilteredIssues > 0 && filterResult.hasActiveFilter;
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
@@ -143,10 +186,21 @@ export function GitHubIssuesView() {
|
||||
>
|
||||
{/* Header */}
|
||||
<IssuesListHeader
|
||||
openCount={openIssues.length}
|
||||
closedCount={closedIssues.length}
|
||||
openCount={filteredOpenIssues.length}
|
||||
closedCount={filteredClosedIssues.length}
|
||||
totalOpenCount={openIssues.length}
|
||||
totalClosedCount={closedIssues.length}
|
||||
hasActiveFilter={filterResult.hasActiveFilter}
|
||||
refreshing={refreshing}
|
||||
onRefresh={refresh}
|
||||
compact={!!selectedIssue}
|
||||
filterProps={{
|
||||
stateFilter: filterState.stateFilter,
|
||||
selectedLabels: filterState.selectedLabels,
|
||||
availableLabels: filterResult.availableLabels,
|
||||
onStateFilterChange: handleStateFilterChange,
|
||||
onLabelsChange: handleLabelsChange,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Issues List */}
|
||||
@@ -154,15 +208,35 @@ export function GitHubIssuesView() {
|
||||
{totalIssues === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center p-6">
|
||||
<div className="p-4 rounded-full bg-muted/50 mb-4">
|
||||
<CircleDot className="h-8 w-8 text-muted-foreground" />
|
||||
{isFilteredEmpty ? (
|
||||
<SearchX className="h-8 w-8 text-muted-foreground" />
|
||||
) : (
|
||||
<CircleDot className="h-8 w-8 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-base font-medium mb-2">No Issues</h2>
|
||||
<p className="text-sm text-muted-foreground">This repository has no issues yet.</p>
|
||||
<h2 className="text-base font-medium mb-2">
|
||||
{isFilteredEmpty ? 'No Matching Issues' : 'No Issues'}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{isFilteredEmpty
|
||||
? 'No issues match your current filters.'
|
||||
: 'This repository has no issues yet.'}
|
||||
</p>
|
||||
{isFilteredEmpty && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClearFilters}
|
||||
className="text-xs"
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{/* Open Issues */}
|
||||
{openIssues.map((issue) => (
|
||||
{filteredOpenIssues.map((issue) => (
|
||||
<IssueRow
|
||||
key={issue.number}
|
||||
issue={issue}
|
||||
@@ -176,12 +250,12 @@ export function GitHubIssuesView() {
|
||||
))}
|
||||
|
||||
{/* Closed Issues Section */}
|
||||
{closedIssues.length > 0 && (
|
||||
{filteredClosedIssues.length > 0 && (
|
||||
<>
|
||||
<div className="px-4 py-2 bg-muted/30 text-xs font-medium text-muted-foreground">
|
||||
Closed Issues ({closedIssues.length})
|
||||
Closed Issues ({filteredClosedIssues.length})
|
||||
</div>
|
||||
{closedIssues.map((issue) => (
|
||||
{filteredClosedIssues.map((issue) => (
|
||||
<IssueRow
|
||||
key={issue.number}
|
||||
issue={issue}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { IssueRow } from './issue-row';
|
||||
export { IssueDetailPanel } from './issue-detail-panel';
|
||||
export { IssuesListHeader } from './issues-list-header';
|
||||
export { IssuesFilterControls } from './issues-filter-controls';
|
||||
export { CommentItem } from './comment-item';
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { ChevronDown, Tag, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { IssuesStateFilter } from '../types';
|
||||
import { ISSUES_STATE_FILTER_OPTIONS } from '../types';
|
||||
|
||||
interface IssuesFilterControlsProps {
|
||||
/** Current state filter value */
|
||||
stateFilter: IssuesStateFilter;
|
||||
/** Currently selected labels */
|
||||
selectedLabels: string[];
|
||||
/** Available labels to choose from (typically from useIssuesFilter result) */
|
||||
availableLabels: string[];
|
||||
/** Callback when state filter changes */
|
||||
onStateFilterChange: (filter: IssuesStateFilter) => void;
|
||||
/** Callback when labels selection changes */
|
||||
onLabelsChange: (labels: string[]) => void;
|
||||
/** Whether the controls are disabled (e.g., during loading) */
|
||||
disabled?: boolean;
|
||||
/** Whether to use compact layout (stacked vertically) */
|
||||
compact?: boolean;
|
||||
/** Additional class name for the container */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Human-readable labels for state filter options */
|
||||
const STATE_FILTER_LABELS: Record<IssuesStateFilter, string> = {
|
||||
open: 'Open',
|
||||
closed: 'Closed',
|
||||
all: 'All',
|
||||
};
|
||||
|
||||
export function IssuesFilterControls({
|
||||
stateFilter,
|
||||
selectedLabels,
|
||||
availableLabels,
|
||||
onStateFilterChange,
|
||||
onLabelsChange,
|
||||
disabled = false,
|
||||
compact = false,
|
||||
className,
|
||||
}: IssuesFilterControlsProps) {
|
||||
/**
|
||||
* Handles toggling a label in the selection.
|
||||
* If the label is already selected, it removes it; otherwise, it adds it.
|
||||
*/
|
||||
const handleLabelToggle = (label: string) => {
|
||||
const isSelected = selectedLabels.includes(label);
|
||||
if (isSelected) {
|
||||
onLabelsChange(selectedLabels.filter((l) => l !== label));
|
||||
} else {
|
||||
onLabelsChange([...selectedLabels, label]);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clears all selected labels.
|
||||
*/
|
||||
const handleClearLabels = () => {
|
||||
onLabelsChange([]);
|
||||
};
|
||||
|
||||
const hasSelectedLabels = selectedLabels.length > 0;
|
||||
const hasAvailableLabels = availableLabels.length > 0;
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-2', className)}>
|
||||
{/* Filter Controls Row */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* State Filter Select */}
|
||||
<Select
|
||||
value={stateFilter}
|
||||
onValueChange={(value) => onStateFilterChange(value as IssuesStateFilter)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className={cn('h-8 text-sm', compact ? 'w-[90px]' : 'w-[110px]')}>
|
||||
<SelectValue placeholder="State" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ISSUES_STATE_FILTER_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{STATE_FILTER_LABELS[option]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Labels Filter Dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild disabled={disabled || !hasAvailableLabels}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn('h-8 gap-1.5', hasSelectedLabels && 'border-primary/50 bg-primary/5')}
|
||||
disabled={disabled || !hasAvailableLabels}
|
||||
>
|
||||
<Tag className="h-3.5 w-3.5" />
|
||||
<span>Labels</span>
|
||||
{hasSelectedLabels && (
|
||||
<Badge variant="secondary" size="sm" className="ml-1 px-1.5 py-0">
|
||||
{selectedLabels.length}
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56 max-h-64 overflow-y-auto">
|
||||
<DropdownMenuLabel className="flex items-center justify-between">
|
||||
<span>Filter by label</span>
|
||||
{hasSelectedLabels && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 px-1.5 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={handleClearLabels}
|
||||
>
|
||||
<X className="h-3 w-3 mr-0.5" />
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{availableLabels.map((label) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={label}
|
||||
checked={selectedLabels.includes(label)}
|
||||
onCheckedChange={() => handleLabelToggle(label)}
|
||||
onSelect={(e) => e.preventDefault()} // Prevent dropdown from closing
|
||||
>
|
||||
{label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
{!hasAvailableLabels && (
|
||||
<div className="px-2 py-1.5 text-sm text-muted-foreground">No labels available</div>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Selected Labels Display - shown on separate row */}
|
||||
{hasSelectedLabels && (
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{selectedLabels.slice(0, compact ? 2 : 3).map((label) => (
|
||||
<Badge
|
||||
key={label}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1 cursor-pointer hover:bg-destructive/10 hover:border-destructive/50"
|
||||
onClick={() => handleLabelToggle(label)}
|
||||
>
|
||||
{label}
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</Badge>
|
||||
))}
|
||||
{selectedLabels.length > (compact ? 2 : 3) && (
|
||||
<Badge variant="muted" size="sm">
|
||||
+{selectedLabels.length - (compact ? 2 : 3)} more
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +1,100 @@
|
||||
import { CircleDot, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { IssuesStateFilter } from '../types';
|
||||
import { IssuesFilterControls } from './issues-filter-controls';
|
||||
|
||||
interface IssuesListHeaderProps {
|
||||
openCount: number;
|
||||
closedCount: number;
|
||||
/** Total open issues count (unfiltered) - used to show "X of Y" when filtered */
|
||||
totalOpenCount?: number;
|
||||
/** Total closed issues count (unfiltered) - used to show "X of Y" when filtered */
|
||||
totalClosedCount?: number;
|
||||
/** Whether any filter is currently active */
|
||||
hasActiveFilter?: boolean;
|
||||
refreshing: boolean;
|
||||
onRefresh: () => void;
|
||||
/** Whether the list is in compact mode (e.g., when detail panel is open) */
|
||||
compact?: boolean;
|
||||
/** Optional filter state and handlers - when provided, filter controls are rendered */
|
||||
filterProps?: {
|
||||
stateFilter: IssuesStateFilter;
|
||||
selectedLabels: string[];
|
||||
availableLabels: string[];
|
||||
onStateFilterChange: (filter: IssuesStateFilter) => void;
|
||||
onLabelsChange: (labels: string[]) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export function IssuesListHeader({
|
||||
openCount,
|
||||
closedCount,
|
||||
totalOpenCount,
|
||||
totalClosedCount,
|
||||
hasActiveFilter = false,
|
||||
refreshing,
|
||||
onRefresh,
|
||||
compact = false,
|
||||
filterProps,
|
||||
}: IssuesListHeaderProps) {
|
||||
const totalIssues = openCount + closedCount;
|
||||
|
||||
// Format the counts subtitle based on filter state
|
||||
const getCountsSubtitle = () => {
|
||||
if (totalIssues === 0) {
|
||||
return hasActiveFilter ? 'No matching issues' : 'No issues found';
|
||||
}
|
||||
|
||||
// When filters are active and we have total counts, show "X of Y" format
|
||||
if (hasActiveFilter && totalOpenCount !== undefined && totalClosedCount !== undefined) {
|
||||
const openText =
|
||||
openCount === totalOpenCount
|
||||
? `${openCount} open`
|
||||
: `${openCount} of ${totalOpenCount} open`;
|
||||
const closedText =
|
||||
closedCount === totalClosedCount
|
||||
? `${closedCount} closed`
|
||||
: `${closedCount} of ${totalClosedCount} closed`;
|
||||
return `${openText}, ${closedText}`;
|
||||
}
|
||||
|
||||
// Default format when no filters active
|
||||
return `${openCount} open, ${closedCount} closed`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-green-500/10">
|
||||
<CircleDot className="h-5 w-5 text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold">Issues</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{totalIssues === 0 ? 'No issues found' : `${openCount} open, ${closedCount} closed`}
|
||||
</p>
|
||||
<div className="border-b border-border">
|
||||
{/* Top row: Title and refresh button */}
|
||||
<div className="flex items-center justify-between p-4 pb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-green-500/10">
|
||||
<CircleDot className="h-5 w-5 text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold">Issues</h1>
|
||||
<p className="text-xs text-muted-foreground">{getCountsSubtitle()}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={onRefresh} disabled={refreshing}>
|
||||
<RefreshCw className={cn('h-4 w-4', refreshing && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={onRefresh} disabled={refreshing}>
|
||||
<RefreshCw className={cn('h-4 w-4', refreshing && 'animate-spin')} />
|
||||
</Button>
|
||||
|
||||
{/* Filter controls row (optional) */}
|
||||
{filterProps && (
|
||||
<div className="px-4 pb-3 pt-1">
|
||||
<IssuesFilterControls
|
||||
stateFilter={filterProps.stateFilter}
|
||||
selectedLabels={filterProps.selectedLabels}
|
||||
availableLabels={filterProps.availableLabels}
|
||||
onStateFilterChange={filterProps.onStateFilterChange}
|
||||
onLabelsChange={filterProps.onLabelsChange}
|
||||
disabled={refreshing}
|
||||
compact={compact}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { useGithubIssues } from './use-github-issues';
|
||||
export { useIssueValidation } from './use-issue-validation';
|
||||
export { useIssueComments } from './use-issue-comments';
|
||||
export { useIssuesFilter } from './use-issues-filter';
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { GitHubIssue, StoredValidation } from '@/lib/electron';
|
||||
import type { IssuesFilterState, IssuesFilterResult, IssuesValidationStatus } from '../types';
|
||||
import { isValidationStale } from '../utils';
|
||||
|
||||
/**
|
||||
* Determines the validation status of an issue based on its cached validation.
|
||||
*/
|
||||
function getValidationStatus(
|
||||
issueNumber: number,
|
||||
cachedValidations: Map<number, StoredValidation>
|
||||
): IssuesValidationStatus | null {
|
||||
const validation = cachedValidations.get(issueNumber);
|
||||
if (!validation) {
|
||||
return 'not_validated';
|
||||
}
|
||||
if (isValidationStale(validation.validatedAt)) {
|
||||
return 'stale';
|
||||
}
|
||||
return 'validated';
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a search query matches an issue's searchable content.
|
||||
* Searches through title and body (case-insensitive).
|
||||
*/
|
||||
function matchesSearchQuery(issue: GitHubIssue, normalizedQuery: string): boolean {
|
||||
if (!normalizedQuery) return true;
|
||||
|
||||
const titleMatch = issue.title?.toLowerCase().includes(normalizedQuery);
|
||||
const bodyMatch = issue.body?.toLowerCase().includes(normalizedQuery);
|
||||
|
||||
return titleMatch || bodyMatch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an issue matches the state filter (open/closed/all).
|
||||
* Note: GitHub CLI returns state in uppercase (OPEN/CLOSED), so we compare case-insensitively.
|
||||
*/
|
||||
function matchesStateFilter(
|
||||
issue: GitHubIssue,
|
||||
stateFilter: IssuesFilterState['stateFilter']
|
||||
): boolean {
|
||||
if (stateFilter === 'all') return true;
|
||||
return issue.state.toLowerCase() === stateFilter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an issue matches any of the selected labels.
|
||||
* Returns true if no labels are selected (no filter) or if any selected label matches.
|
||||
*/
|
||||
function matchesLabels(issue: GitHubIssue, selectedLabels: string[]): boolean {
|
||||
if (selectedLabels.length === 0) return true;
|
||||
|
||||
const issueLabels = issue.labels.map((l) => l.name);
|
||||
return selectedLabels.some((label) => issueLabels.includes(label));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an issue matches any of the selected assignees.
|
||||
* Returns true if no assignees are selected (no filter) or if any selected assignee matches.
|
||||
*/
|
||||
function matchesAssignees(issue: GitHubIssue, selectedAssignees: string[]): boolean {
|
||||
if (selectedAssignees.length === 0) return true;
|
||||
|
||||
const issueAssignees = issue.assignees?.map((a) => a.login) ?? [];
|
||||
return selectedAssignees.some((assignee) => issueAssignees.includes(assignee));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an issue matches any of the selected milestones.
|
||||
* Returns true if no milestones are selected (no filter) or if any selected milestone matches.
|
||||
* Note: GitHub issues may not have milestone data in the current schema, this is a placeholder.
|
||||
*/
|
||||
function matchesMilestones(issue: GitHubIssue, selectedMilestones: string[]): boolean {
|
||||
if (selectedMilestones.length === 0) return true;
|
||||
|
||||
// GitHub issues in the current schema don't have milestone field
|
||||
// This is a placeholder for future milestone support
|
||||
// For now, issues with no milestone won't match if a milestone filter is active
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an issue matches the validation status filter.
|
||||
*/
|
||||
function matchesValidationStatus(
|
||||
issue: GitHubIssue,
|
||||
validationStatusFilter: IssuesValidationStatus | null,
|
||||
cachedValidations: Map<number, StoredValidation>
|
||||
): boolean {
|
||||
if (!validationStatusFilter) return true;
|
||||
|
||||
const status = getValidationStatus(issue.number, cachedValidations);
|
||||
return status === validationStatusFilter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts all unique labels from a list of issues.
|
||||
*/
|
||||
function extractAvailableLabels(issues: GitHubIssue[]): string[] {
|
||||
const labelsSet = new Set<string>();
|
||||
for (const issue of issues) {
|
||||
for (const label of issue.labels) {
|
||||
labelsSet.add(label.name);
|
||||
}
|
||||
}
|
||||
return Array.from(labelsSet).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts all unique assignees from a list of issues.
|
||||
*/
|
||||
function extractAvailableAssignees(issues: GitHubIssue[]): string[] {
|
||||
const assigneesSet = new Set<string>();
|
||||
for (const issue of issues) {
|
||||
for (const assignee of issue.assignees ?? []) {
|
||||
assigneesSet.add(assignee.login);
|
||||
}
|
||||
}
|
||||
return Array.from(assigneesSet).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts all unique milestones from a list of issues.
|
||||
* Note: Currently returns empty array as milestone is not in the GitHubIssue schema.
|
||||
*/
|
||||
function extractAvailableMilestones(_issues: GitHubIssue[]): string[] {
|
||||
// GitHub issues in the current schema don't have milestone field
|
||||
// This is a placeholder for future milestone support
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if any filter is currently active.
|
||||
*/
|
||||
function hasActiveFilterCheck(filterState: IssuesFilterState): boolean {
|
||||
const {
|
||||
searchQuery,
|
||||
stateFilter,
|
||||
selectedLabels,
|
||||
selectedAssignees,
|
||||
selectedMilestones,
|
||||
validationStatusFilter,
|
||||
} = filterState;
|
||||
|
||||
// Note: stateFilter 'open' is the default, so we consider it "not active" for UI purposes
|
||||
// Only 'closed' or 'all' are considered active filters
|
||||
const hasStateFilter = stateFilter !== 'open';
|
||||
const hasSearchQuery = searchQuery.trim().length > 0;
|
||||
const hasLabelFilter = selectedLabels.length > 0;
|
||||
const hasAssigneeFilter = selectedAssignees.length > 0;
|
||||
const hasMilestoneFilter = selectedMilestones.length > 0;
|
||||
const hasValidationFilter = validationStatusFilter !== null;
|
||||
|
||||
return (
|
||||
hasSearchQuery ||
|
||||
hasStateFilter ||
|
||||
hasLabelFilter ||
|
||||
hasAssigneeFilter ||
|
||||
hasMilestoneFilter ||
|
||||
hasValidationFilter
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to filter GitHub issues based on the current filter state.
|
||||
*
|
||||
* This hook follows the same pattern as useGraphFilter but is tailored for GitHub issues.
|
||||
* It computes matched issues and extracts available filter options from all issues.
|
||||
*
|
||||
* @param issues - Combined array of all issues (open + closed) to filter
|
||||
* @param filterState - Current filter state including search, labels, assignees, etc.
|
||||
* @param cachedValidations - Map of issue numbers to their cached validation results
|
||||
* @returns Filter result containing matched issue numbers and available filter options
|
||||
*/
|
||||
export function useIssuesFilter(
|
||||
issues: GitHubIssue[],
|
||||
filterState: IssuesFilterState,
|
||||
cachedValidations: Map<number, StoredValidation> = new Map()
|
||||
): IssuesFilterResult {
|
||||
const {
|
||||
searchQuery,
|
||||
stateFilter,
|
||||
selectedLabels,
|
||||
selectedAssignees,
|
||||
selectedMilestones,
|
||||
validationStatusFilter,
|
||||
} = filterState;
|
||||
|
||||
return useMemo(() => {
|
||||
// Extract available options from all issues (for filter dropdown population)
|
||||
const availableLabels = extractAvailableLabels(issues);
|
||||
const availableAssignees = extractAvailableAssignees(issues);
|
||||
const availableMilestones = extractAvailableMilestones(issues);
|
||||
|
||||
// Check if any filter is active
|
||||
const hasActiveFilter = hasActiveFilterCheck(filterState);
|
||||
|
||||
// Normalize search query for case-insensitive matching
|
||||
const normalizedQuery = searchQuery.toLowerCase().trim();
|
||||
|
||||
// Filter issues based on all criteria
|
||||
const matchedIssueNumbers = new Set<number>();
|
||||
|
||||
for (const issue of issues) {
|
||||
// All conditions must be true for a match
|
||||
const matchesAllFilters =
|
||||
matchesSearchQuery(issue, normalizedQuery) &&
|
||||
matchesStateFilter(issue, stateFilter) &&
|
||||
matchesLabels(issue, selectedLabels) &&
|
||||
matchesAssignees(issue, selectedAssignees) &&
|
||||
matchesMilestones(issue, selectedMilestones) &&
|
||||
matchesValidationStatus(issue, validationStatusFilter, cachedValidations);
|
||||
|
||||
if (matchesAllFilters) {
|
||||
matchedIssueNumbers.add(issue.number);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
matchedIssueNumbers,
|
||||
availableLabels,
|
||||
availableAssignees,
|
||||
availableMilestones,
|
||||
hasActiveFilter,
|
||||
matchedCount: matchedIssueNumbers.size,
|
||||
};
|
||||
}, [
|
||||
issues,
|
||||
searchQuery,
|
||||
stateFilter,
|
||||
selectedLabels,
|
||||
selectedAssignees,
|
||||
selectedMilestones,
|
||||
validationStatusFilter,
|
||||
cachedValidations,
|
||||
]);
|
||||
}
|
||||
@@ -1,6 +1,111 @@
|
||||
import type { GitHubIssue, StoredValidation, GitHubComment } from '@/lib/electron';
|
||||
import type { ModelId, LinkedPRInfo, PhaseModelEntry } from '@automaker/types';
|
||||
|
||||
// ============================================================================
|
||||
// Issues Filter State Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Available sort columns for issues list
|
||||
*/
|
||||
export const ISSUES_SORT_COLUMNS = [
|
||||
'title',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'comments',
|
||||
'number',
|
||||
] as const;
|
||||
|
||||
export type IssuesSortColumn = (typeof ISSUES_SORT_COLUMNS)[number];
|
||||
|
||||
/**
|
||||
* Sort direction options
|
||||
*/
|
||||
export type IssuesSortDirection = 'asc' | 'desc';
|
||||
|
||||
/**
|
||||
* Available issue state filter values
|
||||
*/
|
||||
export const ISSUES_STATE_FILTER_OPTIONS = ['open', 'closed', 'all'] as const;
|
||||
|
||||
export type IssuesStateFilter = (typeof ISSUES_STATE_FILTER_OPTIONS)[number];
|
||||
|
||||
/**
|
||||
* Validation status filter values for filtering issues by validation state
|
||||
*/
|
||||
export const ISSUES_VALIDATION_STATUS_OPTIONS = ['validated', 'not_validated', 'stale'] as const;
|
||||
|
||||
export type IssuesValidationStatus = (typeof ISSUES_VALIDATION_STATUS_OPTIONS)[number];
|
||||
|
||||
/**
|
||||
* Sort configuration for issues list
|
||||
*/
|
||||
export interface IssuesSortConfig {
|
||||
column: IssuesSortColumn;
|
||||
direction: IssuesSortDirection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main filter state interface for the GitHub Issues view
|
||||
*
|
||||
* This interface defines all filterable/sortable state for the issues list.
|
||||
* It follows the same pattern as GraphFilterState but is tailored for GitHub issues.
|
||||
*/
|
||||
export interface IssuesFilterState {
|
||||
/** Search query for filtering by issue title or body */
|
||||
searchQuery: string;
|
||||
/** Filter by issue state (open/closed/all) */
|
||||
stateFilter: IssuesStateFilter;
|
||||
/** Filter by selected labels (matches any) */
|
||||
selectedLabels: string[];
|
||||
/** Filter by selected assignees (matches any) */
|
||||
selectedAssignees: string[];
|
||||
/** Filter by selected milestones (matches any) */
|
||||
selectedMilestones: string[];
|
||||
/** Filter by validation status */
|
||||
validationStatusFilter: IssuesValidationStatus | null;
|
||||
/** Current sort configuration */
|
||||
sortConfig: IssuesSortConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of applying filters to the issues list
|
||||
*/
|
||||
export interface IssuesFilterResult {
|
||||
/** Set of issue numbers that match the current filters */
|
||||
matchedIssueNumbers: Set<number>;
|
||||
/** Available labels from all issues (for filter dropdown population) */
|
||||
availableLabels: string[];
|
||||
/** Available assignees from all issues (for filter dropdown population) */
|
||||
availableAssignees: string[];
|
||||
/** Available milestones from all issues (for filter dropdown population) */
|
||||
availableMilestones: string[];
|
||||
/** Whether any filter is currently active */
|
||||
hasActiveFilter: boolean;
|
||||
/** Total count of matched issues */
|
||||
matchedCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default values for IssuesFilterState
|
||||
*/
|
||||
export const DEFAULT_ISSUES_FILTER_STATE: IssuesFilterState = {
|
||||
searchQuery: '',
|
||||
stateFilter: 'open',
|
||||
selectedLabels: [],
|
||||
selectedAssignees: [],
|
||||
selectedMilestones: [],
|
||||
validationStatusFilter: null,
|
||||
sortConfig: {
|
||||
column: 'updated_at',
|
||||
direction: 'desc',
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Component Props Types
|
||||
// ============================================================================
|
||||
|
||||
export interface IssueRowProps {
|
||||
issue: GitHubIssue;
|
||||
isSelected: boolean;
|
||||
|
||||
@@ -2,6 +2,7 @@ export { useAutoMode } from './use-auto-mode';
|
||||
export { useBoardBackgroundSettings } from './use-board-background-settings';
|
||||
export { useElectronAgent } from './use-electron-agent';
|
||||
export { useGuidedPrompts } from './use-guided-prompts';
|
||||
export { useIssuesFilter } from '@/components/views/github-issues-view/hooks/use-issues-filter';
|
||||
export { useKeyboardShortcuts } from './use-keyboard-shortcuts';
|
||||
export { useMessageQueue } from './use-message-queue';
|
||||
export { useOSDetection, type OperatingSystem, type OSDetectionResult } from './use-os-detection';
|
||||
|
||||
Reference in New Issue
Block a user