add more filters about process status

This commit is contained in:
James
2025-12-22 19:06:05 -05:00
parent b3c321ce02
commit 4dd00a98e4
4 changed files with 193 additions and 17 deletions

View File

@@ -67,7 +67,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dagre": "^0.8.5",
"apps/ui/src/components/views/graph-view/hooks/use-graph-filter.ts": "^0.8.5",
"dotenv": "^17.2.3",
"geist": "^1.5.1",
"lucide-react": "^0.562.0",

View File

@@ -4,9 +4,40 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Switch } from '@/components/ui/switch';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Filter, X, Eye, EyeOff, ChevronDown } from 'lucide-react';
import {
Filter,
X,
Eye,
EyeOff,
ChevronDown,
Play,
Pause,
Clock,
CheckCircle2,
CircleDot,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { GraphFilterState } from '../hooks/use-graph-filter';
import {
GraphFilterState,
STATUS_FILTER_OPTIONS,
StatusFilterValue,
} from '../hooks/use-graph-filter';
// Status display configuration
const statusDisplayConfig: Record<
StatusFilterValue,
{ label: string; icon: typeof Play; colorClass: string }
> = {
running: { label: 'Running', icon: Play, colorClass: 'text-[var(--status-in-progress)]' },
paused: { label: 'Paused', icon: Pause, colorClass: 'text-[var(--status-warning)]' },
backlog: { label: 'Backlog', icon: Clock, colorClass: 'text-muted-foreground' },
waiting_approval: {
label: 'Waiting Approval',
icon: CircleDot,
colorClass: 'text-[var(--status-waiting)]',
},
verified: { label: 'Verified', icon: CheckCircle2, colorClass: 'text-[var(--status-success)]' },
};
interface GraphFilterControlsProps {
filterState: GraphFilterState;
@@ -14,6 +45,7 @@ interface GraphFilterControlsProps {
hasActiveFilter: boolean;
onSearchQueryChange: (query: string) => void;
onCategoriesChange: (categories: string[]) => void;
onStatusesChange: (statuses: string[]) => void;
onNegativeFilterChange: (isNegative: boolean) => void;
onClearFilters: () => void;
}
@@ -24,10 +56,14 @@ export function GraphFilterControls({
hasActiveFilter,
onSearchQueryChange,
onCategoriesChange,
onStatusesChange,
onNegativeFilterChange,
onClearFilters,
}: GraphFilterControlsProps) {
const { selectedCategories, isNegativeFilter } = filterState;
const { selectedCategories, selectedStatuses, isNegativeFilter } = filterState;
// Suppress unused variable warning - onSearchQueryChange is used by parent for search input
void onSearchQueryChange;
const handleCategoryToggle = (category: string) => {
if (selectedCategories.includes(category)) {
@@ -45,6 +81,22 @@ export function GraphFilterControls({
}
};
const handleStatusToggle = (status: string) => {
if (selectedStatuses.includes(status)) {
onStatusesChange(selectedStatuses.filter((s) => s !== status));
} else {
onStatusesChange([...selectedStatuses, status]);
}
};
const handleSelectAllStatuses = () => {
if (selectedStatuses.length === STATUS_FILTER_OPTIONS.length) {
onStatusesChange([]);
} else {
onStatusesChange([...STATUS_FILTER_OPTIONS]);
}
};
const categoryButtonLabel =
selectedCategories.length === 0
? 'All Categories'
@@ -52,6 +104,14 @@ export function GraphFilterControls({
? selectedCategories[0]
: `${selectedCategories.length} Categories`;
const statusButtonLabel =
selectedStatuses.length === 0
? 'All Statuses'
: selectedStatuses.length === 1
? statusDisplayConfig[selectedStatuses[0] as StatusFilterValue]?.label ||
selectedStatuses[0]
: `${selectedStatuses.length} Statuses`;
return (
<Panel position="top-left" className="flex items-center gap-2">
<TooltipProvider delayDuration={200}>
@@ -130,6 +190,74 @@ export function GraphFilterControls({
</PopoverContent>
</Popover>
{/* Status Filter Dropdown */}
<Popover>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
'h-8 px-2 gap-1.5',
selectedStatuses.length > 0 && 'bg-brand-500/20 text-brand-500'
)}
>
<CircleDot className="w-4 h-4" />
<span className="text-xs max-w-[120px] truncate">{statusButtonLabel}</span>
<ChevronDown className="w-3 h-3 opacity-50" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>Filter by Status</TooltipContent>
</Tooltip>
<PopoverContent align="start" className="w-56 p-2">
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground px-2 py-1">Status</div>
{/* Select All option */}
<div
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-accent cursor-pointer"
onClick={handleSelectAllStatuses}
>
<Checkbox
checked={selectedStatuses.length === STATUS_FILTER_OPTIONS.length}
onCheckedChange={handleSelectAllStatuses}
/>
<span className="text-sm font-medium">
{selectedStatuses.length === STATUS_FILTER_OPTIONS.length
? 'Deselect All'
: 'Select All'}
</span>
</div>
<div className="h-px bg-border" />
{/* Status list */}
<div className="space-y-0.5">
{STATUS_FILTER_OPTIONS.map((status) => {
const config = statusDisplayConfig[status];
const StatusIcon = config.icon;
return (
<div
key={status}
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-accent cursor-pointer"
onClick={() => handleStatusToggle(status)}
>
<Checkbox
checked={selectedStatuses.includes(status)}
onCheckedChange={() => handleStatusToggle(status)}
/>
<StatusIcon className={cn('w-3.5 h-3.5', config.colorClass)} />
<span className="text-sm">{config.label}</span>
</div>
);
})}
</div>
</div>
</PopoverContent>
</Popover>
{/* Divider */}
<div className="h-6 w-px bg-border" />

View File

@@ -66,19 +66,21 @@ function GraphCanvasInner({
const [isLocked, setIsLocked] = useState(false);
const [layoutDirection, setLayoutDirection] = useState<'LR' | 'TB'>('LR');
// Filter state (category and negative toggle are local to graph view)
// Filter state (category, status, and negative toggle are local to graph view)
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
const [selectedStatuses, setSelectedStatuses] = useState<string[]>([]);
const [isNegativeFilter, setIsNegativeFilter] = useState(false);
// Combined filter state
const filterState: GraphFilterState = {
searchQuery,
selectedCategories,
selectedStatuses,
isNegativeFilter,
};
// Calculate filter results
const filterResult = useGraphFilter(features, filterState);
const filterResult = useGraphFilter(features, filterState, runningAutoTasks);
// Transform features to nodes and edges with filter results
const { nodes: initialNodes, edges: initialEdges } = useGraphNodes({
@@ -117,6 +119,7 @@ function GraphCanvasInner({
const handleClearFilters = useCallback(() => {
onSearchQueryChange('');
setSelectedCategories([]);
setSelectedStatuses([]);
setIsNegativeFilter(false);
}, [onSearchQueryChange]);
@@ -195,6 +198,7 @@ function GraphCanvasInner({
hasActiveFilter={filterResult.hasActiveFilter}
onSearchQueryChange={onSearchQueryChange}
onCategoriesChange={setSelectedCategories}
onStatusesChange={setSelectedStatuses}
onNegativeFilterChange={setIsNegativeFilter}
onClearFilters={handleClearFilters}
/>

View File

@@ -4,9 +4,21 @@ import { Feature } from '@/store/app-store';
export interface GraphFilterState {
searchQuery: string;
selectedCategories: string[];
selectedStatuses: string[];
isNegativeFilter: boolean;
}
// Available status filter values
export const STATUS_FILTER_OPTIONS = [
'running',
'paused',
'backlog',
'waiting_approval',
'verified',
] as const;
export type StatusFilterValue = (typeof STATUS_FILTER_OPTIONS)[number];
export interface GraphFilterResult {
matchedNodeIds: Set<string>;
highlightedNodeIds: Set<string>;
@@ -29,7 +41,10 @@ function getAncestors(
const feature = featureMap.get(featureId);
if (!feature?.dependencies) return;
for (const depId of feature.dependencies) {
const deps = feature.dependencies as string[] | undefined;
if (!deps) return;
for (const depId of deps) {
if (featureMap.has(depId)) {
getAncestors(depId, featureMap, visited);
}
@@ -44,7 +59,8 @@ function getDescendants(featureId: string, features: Feature[], visited: Set<str
visited.add(featureId);
for (const feature of features) {
if (feature.dependencies?.includes(featureId)) {
const deps = feature.dependencies as string[] | undefined;
if (deps?.includes(featureId)) {
getDescendants(feature.id, features, visited);
}
}
@@ -58,9 +74,10 @@ function getHighlightedEdges(highlightedNodeIds: Set<string>, features: Feature[
for (const feature of features) {
if (!highlightedNodeIds.has(feature.id)) continue;
if (!feature.dependencies) continue;
const deps = feature.dependencies as string[] | undefined;
if (!deps) continue;
for (const depId of feature.dependencies) {
for (const depId of deps) {
if (highlightedNodeIds.has(depId)) {
edges.add(`${depId}->${feature.id}`);
}
@@ -71,13 +88,24 @@ function getHighlightedEdges(highlightedNodeIds: Set<string>, features: Feature[
}
/**
* Hook to calculate graph filter results based on search query, categories, and filter mode
* Gets the effective status of a feature (accounting for running state)
*/
function getEffectiveStatus(feature: Feature, runningAutoTasks: string[]): StatusFilterValue {
if (feature.status === 'in_progress') {
return runningAutoTasks.includes(feature.id) ? 'running' : 'paused';
}
return feature.status as StatusFilterValue;
}
/**
* Hook to calculate graph filter results based on search query, categories, statuses, and filter mode
*/
export function useGraphFilter(
features: Feature[],
filterState: GraphFilterState
filterState: GraphFilterState,
runningAutoTasks: string[] = []
): GraphFilterResult {
const { searchQuery, selectedCategories, isNegativeFilter } = filterState;
const { searchQuery, selectedCategories, selectedStatuses, isNegativeFilter } = filterState;
return useMemo(() => {
// Extract all unique categories
@@ -88,7 +116,9 @@ export function useGraphFilter(
const normalizedQuery = searchQuery.toLowerCase().trim();
const hasSearchQuery = normalizedQuery.length > 0;
const hasCategoryFilter = selectedCategories.length > 0;
const hasActiveFilter = hasSearchQuery || hasCategoryFilter || isNegativeFilter;
const hasStatusFilter = selectedStatuses.length > 0;
const hasActiveFilter =
hasSearchQuery || hasCategoryFilter || hasStatusFilter || isNegativeFilter;
// If no filters active, return empty sets (show all nodes normally)
if (!hasActiveFilter) {
@@ -108,6 +138,7 @@ export function useGraphFilter(
for (const feature of features) {
let matchesSearch = true;
let matchesCategory = true;
let matchesStatus = true;
// Check search query match (title or description)
if (hasSearchQuery) {
@@ -121,8 +152,14 @@ export function useGraphFilter(
matchesCategory = selectedCategories.includes(feature.category);
}
// Both conditions must be true for a match
if (matchesSearch && matchesCategory) {
// Check status match
if (hasStatusFilter) {
const effectiveStatus = getEffectiveStatus(feature, runningAutoTasks);
matchesStatus = selectedStatuses.includes(effectiveStatus);
}
// All conditions must be true for a match
if (matchesSearch && matchesCategory && matchesStatus) {
matchedNodeIds.add(feature.id);
}
}
@@ -161,5 +198,12 @@ export function useGraphFilter(
availableCategories,
hasActiveFilter: true,
};
}, [features, searchQuery, selectedCategories, isNegativeFilter]);
}, [
features,
searchQuery,
selectedCategories,
selectedStatuses,
isNegativeFilter,
runningAutoTasks,
]);
}