mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 22:32:04 +00:00
add more filters about process status
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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" />
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user