mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-05 01:50:27 +08:00
- Implemented `DiscoveryPage` with session management and findings display. - Added tests for `DiscoveryPage` to ensure proper rendering and functionality. - Created `QueuePage` for managing issue execution queues with stats and actions. - Added tests for `QueuePage` to verify UI elements and translations. - Introduced `useIssues` hooks for fetching and managing issue data. - Added loading skeletons and error handling for better user experience. - Created `vite-env.d.ts` for TypeScript support in Vite environment.
469 lines
14 KiB
TypeScript
469 lines
14 KiB
TypeScript
// ========================================
|
|
// useIssues Hook
|
|
// ========================================
|
|
// TanStack Query hooks for issues with queue management
|
|
|
|
import { useQuery, useMutation, useQueryClient, type UseQueryResult } from '@tanstack/react-query';
|
|
import {
|
|
fetchIssues,
|
|
fetchIssueHistory,
|
|
fetchIssueQueue,
|
|
createIssue,
|
|
updateIssue,
|
|
deleteIssue,
|
|
activateQueue,
|
|
deactivateQueue,
|
|
deleteQueue as deleteQueueApi,
|
|
mergeQueues as mergeQueuesApi,
|
|
fetchDiscoveries,
|
|
fetchDiscoveryFindings,
|
|
type Issue,
|
|
type IssueQueue,
|
|
type IssuesResponse,
|
|
type DiscoverySession,
|
|
type Finding,
|
|
} from '../lib/api';
|
|
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
|
import { workspaceQueryKeys } from '@/lib/queryKeys';
|
|
import { useState, useMemo } from 'react';
|
|
|
|
// Query key factory
|
|
export const issuesKeys = {
|
|
all: ['issues'] as const,
|
|
lists: () => [...issuesKeys.all, 'list'] as const,
|
|
list: (filters?: IssuesFilter) => [...issuesKeys.lists(), filters] as const,
|
|
history: () => [...issuesKeys.all, 'history'] as const,
|
|
queue: () => [...issuesKeys.all, 'queue'] as const,
|
|
details: () => [...issuesKeys.all, 'detail'] as const,
|
|
detail: (id: string) => [...issuesKeys.details(), id] as const,
|
|
};
|
|
|
|
// Default stale time: 30 seconds
|
|
const STALE_TIME = 30 * 1000;
|
|
|
|
export interface IssuesFilter {
|
|
status?: Issue['status'][];
|
|
priority?: Issue['priority'][];
|
|
search?: string;
|
|
includeHistory?: boolean;
|
|
}
|
|
|
|
export interface UseIssuesOptions {
|
|
filter?: IssuesFilter;
|
|
projectPath?: string;
|
|
staleTime?: number;
|
|
enabled?: boolean;
|
|
refetchInterval?: number;
|
|
}
|
|
|
|
export interface UseIssuesReturn {
|
|
issues: Issue[];
|
|
historyIssues: Issue[];
|
|
allIssues: Issue[];
|
|
issuesByStatus: Record<Issue['status'], Issue[]>;
|
|
issuesByPriority: Record<Issue['priority'], Issue[]>;
|
|
openCount: number;
|
|
criticalCount: number;
|
|
isLoading: boolean;
|
|
isFetching: boolean;
|
|
error: Error | null;
|
|
refetch: () => Promise<void>;
|
|
invalidate: () => Promise<void>;
|
|
}
|
|
|
|
/**
|
|
* Hook for fetching and filtering issues
|
|
*/
|
|
export function useIssues(options: UseIssuesOptions = {}): UseIssuesReturn {
|
|
const { filter, staleTime = STALE_TIME, enabled = true, refetchInterval = 0 } = options;
|
|
const queryClient = useQueryClient();
|
|
const projectPath = useWorkflowStore(selectProjectPath);
|
|
|
|
// Only enable query when projectPath is available
|
|
const queryEnabled = enabled && !!projectPath;
|
|
|
|
const issuesQuery = useQuery({
|
|
queryKey: workspaceQueryKeys.issuesList(projectPath),
|
|
queryFn: () => fetchIssues(projectPath),
|
|
staleTime,
|
|
enabled: queryEnabled,
|
|
refetchInterval: refetchInterval > 0 ? refetchInterval : false,
|
|
retry: 2,
|
|
});
|
|
|
|
const historyQuery = useQuery({
|
|
queryKey: workspaceQueryKeys.issuesHistory(projectPath),
|
|
queryFn: () => fetchIssueHistory(projectPath),
|
|
staleTime,
|
|
enabled: queryEnabled && (filter?.includeHistory ?? false),
|
|
retry: 2,
|
|
});
|
|
|
|
const allIssues = issuesQuery.data?.issues ?? [];
|
|
const historyIssues = historyQuery.data?.issues ?? [];
|
|
|
|
// Apply filters
|
|
const filteredIssues = (() => {
|
|
let issues = [...allIssues];
|
|
|
|
if (filter?.includeHistory) {
|
|
issues = [...issues, ...historyIssues];
|
|
}
|
|
|
|
if (filter?.status && filter.status.length > 0) {
|
|
issues = issues.filter((i) => filter.status!.includes(i.status));
|
|
}
|
|
|
|
if (filter?.priority && filter.priority.length > 0) {
|
|
issues = issues.filter((i) => filter.priority!.includes(i.priority));
|
|
}
|
|
|
|
if (filter?.search) {
|
|
const searchLower = filter.search.toLowerCase();
|
|
issues = issues.filter(
|
|
(i) =>
|
|
i.id.toLowerCase().includes(searchLower) ||
|
|
i.title.toLowerCase().includes(searchLower) ||
|
|
i.context?.toLowerCase().includes(searchLower)
|
|
);
|
|
}
|
|
|
|
return issues;
|
|
})();
|
|
|
|
// Group by status
|
|
const issuesByStatus: Record<Issue['status'], Issue[]> = {
|
|
open: [],
|
|
in_progress: [],
|
|
resolved: [],
|
|
closed: [],
|
|
completed: [],
|
|
};
|
|
|
|
for (const issue of allIssues) {
|
|
// Defensive check: only push if the status key exists
|
|
if (issue.status in issuesByStatus) {
|
|
issuesByStatus[issue.status].push(issue);
|
|
}
|
|
}
|
|
|
|
// Group by priority
|
|
const issuesByPriority: Record<Issue['priority'], Issue[]> = {
|
|
low: [],
|
|
medium: [],
|
|
high: [],
|
|
critical: [],
|
|
};
|
|
|
|
for (const issue of allIssues) {
|
|
// Defensive check: only push if the priority key exists
|
|
if (issue.priority in issuesByPriority) {
|
|
issuesByPriority[issue.priority].push(issue);
|
|
}
|
|
}
|
|
|
|
const refetch = async () => {
|
|
await Promise.all([issuesQuery.refetch(), historyQuery.refetch()]);
|
|
};
|
|
|
|
const invalidate = async () => {
|
|
if (projectPath) {
|
|
await queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issues(projectPath) });
|
|
}
|
|
};
|
|
|
|
return {
|
|
issues: filteredIssues,
|
|
historyIssues,
|
|
allIssues,
|
|
issuesByStatus,
|
|
issuesByPriority,
|
|
openCount: issuesByStatus.open.length + issuesByStatus.in_progress.length,
|
|
criticalCount: issuesByPriority.critical.length,
|
|
isLoading: issuesQuery.isLoading,
|
|
isFetching: issuesQuery.isFetching || historyQuery.isFetching,
|
|
error: issuesQuery.error || historyQuery.error,
|
|
refetch,
|
|
invalidate,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Hook for fetching issue queue
|
|
*/
|
|
export function useIssueQueue(): UseQueryResult<IssueQueue> {
|
|
const projectPath = useWorkflowStore(selectProjectPath);
|
|
return useQuery<IssueQueue>({
|
|
queryKey: projectPath ? workspaceQueryKeys.issueQueue(projectPath) : ['issueQueue', 'no-project'],
|
|
queryFn: () => fetchIssueQueue(projectPath),
|
|
staleTime: STALE_TIME,
|
|
enabled: !!projectPath,
|
|
retry: 2,
|
|
});
|
|
}
|
|
|
|
// ========== Mutations ==========
|
|
|
|
export interface UseCreateIssueReturn {
|
|
createIssue: (input: { title: string; context?: string; priority?: Issue['priority'] }) => Promise<Issue>;
|
|
isCreating: boolean;
|
|
error: Error | null;
|
|
}
|
|
|
|
export function useCreateIssue(): UseCreateIssueReturn {
|
|
const queryClient = useQueryClient();
|
|
const projectPath = useWorkflowStore(selectProjectPath);
|
|
|
|
const mutation = useMutation({
|
|
mutationFn: createIssue,
|
|
onSuccess: () => {
|
|
// Invalidate issues cache to trigger refetch
|
|
queryClient.invalidateQueries({ queryKey: projectPath ? workspaceQueryKeys.issues(projectPath) : ['issues'] });
|
|
},
|
|
});
|
|
|
|
return {
|
|
createIssue: mutation.mutateAsync,
|
|
isCreating: mutation.isPending,
|
|
error: mutation.error,
|
|
};
|
|
}
|
|
|
|
export interface UseUpdateIssueReturn {
|
|
updateIssue: (issueId: string, input: Partial<Issue>) => Promise<Issue>;
|
|
isUpdating: boolean;
|
|
error: Error | null;
|
|
}
|
|
|
|
export function useUpdateIssue(): UseUpdateIssueReturn {
|
|
const queryClient = useQueryClient();
|
|
const projectPath = useWorkflowStore(selectProjectPath);
|
|
|
|
const mutation = useMutation({
|
|
mutationFn: ({ issueId, input }: { issueId: string; input: Partial<Issue> }) =>
|
|
updateIssue(issueId, input),
|
|
onSuccess: () => {
|
|
// Invalidate issues cache to trigger refetch
|
|
queryClient.invalidateQueries({ queryKey: projectPath ? workspaceQueryKeys.issues(projectPath) : ['issues'] });
|
|
},
|
|
});
|
|
|
|
return {
|
|
updateIssue: (issueId, input) => mutation.mutateAsync({ issueId, input }),
|
|
isUpdating: mutation.isPending,
|
|
error: mutation.error,
|
|
};
|
|
}
|
|
|
|
export interface UseDeleteIssueReturn {
|
|
deleteIssue: (issueId: string) => Promise<void>;
|
|
isDeleting: boolean;
|
|
error: Error | null;
|
|
}
|
|
|
|
export function useDeleteIssue(): UseDeleteIssueReturn {
|
|
const queryClient = useQueryClient();
|
|
const projectPath = useWorkflowStore(selectProjectPath);
|
|
|
|
const mutation = useMutation({
|
|
mutationFn: deleteIssue,
|
|
onSuccess: () => {
|
|
// Invalidate to ensure sync with server
|
|
queryClient.invalidateQueries({ queryKey: projectPath ? workspaceQueryKeys.issues(projectPath) : ['issues'] });
|
|
},
|
|
});
|
|
|
|
return {
|
|
deleteIssue: mutation.mutateAsync,
|
|
isDeleting: mutation.isPending,
|
|
error: mutation.error,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Combined hook for all issue mutations
|
|
*/
|
|
export function useIssueMutations() {
|
|
const create = useCreateIssue();
|
|
const update = useUpdateIssue();
|
|
const remove = useDeleteIssue();
|
|
|
|
return {
|
|
createIssue: create.createIssue,
|
|
updateIssue: update.updateIssue,
|
|
deleteIssue: remove.deleteIssue,
|
|
isCreating: create.isCreating,
|
|
isUpdating: update.isUpdating,
|
|
isDeleting: remove.isDeleting,
|
|
isMutating: create.isCreating || update.isUpdating || remove.isDeleting,
|
|
};
|
|
}
|
|
|
|
// ========== Queue Mutations ==========
|
|
|
|
export interface UseQueueMutationsReturn {
|
|
activateQueue: (queueId: string) => Promise<void>;
|
|
deactivateQueue: () => Promise<void>;
|
|
deleteQueue: (queueId: string) => Promise<void>;
|
|
mergeQueues: (sourceId: string, targetId: string) => Promise<void>;
|
|
isActivating: boolean;
|
|
isDeactivating: boolean;
|
|
isDeleting: boolean;
|
|
isMerging: boolean;
|
|
isMutating: boolean;
|
|
}
|
|
|
|
export function useQueueMutations(): UseQueueMutationsReturn {
|
|
const queryClient = useQueryClient();
|
|
const projectPath = useWorkflowStore(selectProjectPath);
|
|
|
|
const activateMutation = useMutation({
|
|
mutationFn: (queueId: string) => activateQueue(queueId, projectPath),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) });
|
|
},
|
|
});
|
|
|
|
const deactivateMutation = useMutation({
|
|
mutationFn: () => deactivateQueue(projectPath),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) });
|
|
},
|
|
});
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: (queueId: string) => deleteQueueApi(queueId, projectPath),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) });
|
|
},
|
|
});
|
|
|
|
const mergeMutation = useMutation({
|
|
mutationFn: ({ sourceId, targetId }: { sourceId: string; targetId: string }) =>
|
|
mergeQueuesApi(sourceId, targetId, projectPath),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) });
|
|
},
|
|
});
|
|
|
|
return {
|
|
activateQueue: activateMutation.mutateAsync,
|
|
deactivateQueue: deactivateMutation.mutateAsync,
|
|
deleteQueue: deleteMutation.mutateAsync,
|
|
mergeQueues: (sourceId, targetId) => mergeMutation.mutateAsync({ sourceId, targetId }),
|
|
isActivating: activateMutation.isPending,
|
|
isDeactivating: deactivateMutation.isPending,
|
|
isDeleting: deleteMutation.isPending,
|
|
isMerging: mergeMutation.isPending,
|
|
isMutating: activateMutation.isPending || deactivateMutation.isPending || deleteMutation.isPending || mergeMutation.isPending,
|
|
};
|
|
}
|
|
|
|
// ========== Discovery Hook ==========
|
|
|
|
export interface FindingFilters {
|
|
severity?: 'critical' | 'high' | 'medium' | 'low';
|
|
type?: string;
|
|
search?: string;
|
|
}
|
|
|
|
export interface UseIssueDiscoveryReturn {
|
|
sessions: DiscoverySession[];
|
|
activeSession: DiscoverySession | null;
|
|
findings: Finding[];
|
|
filteredFindings: Finding[];
|
|
isLoadingSessions: boolean;
|
|
isLoadingFindings: boolean;
|
|
error: Error | null;
|
|
filters: FindingFilters;
|
|
setFilters: (filters: FindingFilters) => void;
|
|
selectSession: (sessionId: string) => void;
|
|
refetchSessions: () => void;
|
|
exportFindings: () => void;
|
|
}
|
|
|
|
export function useIssueDiscovery(options?: { refetchInterval?: number }): UseIssueDiscoveryReturn {
|
|
const { refetchInterval = 0 } = options ?? {};
|
|
const queryClient = useQueryClient();
|
|
const projectPath = useWorkflowStore(selectProjectPath);
|
|
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
|
const [filters, setFilters] = useState<FindingFilters>({});
|
|
|
|
const sessionsQuery = useQuery({
|
|
queryKey: workspaceQueryKeys.discoveries(projectPath),
|
|
queryFn: () => fetchDiscoveries(projectPath),
|
|
staleTime: STALE_TIME,
|
|
enabled: !!projectPath,
|
|
refetchInterval: refetchInterval > 0 ? refetchInterval : false,
|
|
retry: 2,
|
|
});
|
|
|
|
const findingsQuery = useQuery({
|
|
queryKey: activeSessionId ? ['discoveryFindings', activeSessionId, projectPath] : ['discoveryFindings', 'no-session'],
|
|
queryFn: () => activeSessionId ? fetchDiscoveryFindings(activeSessionId, projectPath) : [],
|
|
staleTime: STALE_TIME,
|
|
enabled: !!activeSessionId && !!projectPath,
|
|
retry: 2,
|
|
});
|
|
|
|
const activeSession = useMemo(
|
|
() => sessionsQuery.data?.find(s => s.id === activeSessionId) ?? null,
|
|
[sessionsQuery.data, activeSessionId]
|
|
);
|
|
|
|
const filteredFindings = useMemo(() => {
|
|
let findings = findingsQuery.data ?? [];
|
|
if (filters.severity) {
|
|
findings = findings.filter(f => f.severity === filters.severity);
|
|
}
|
|
if (filters.type) {
|
|
findings = findings.filter(f => f.type === filters.type);
|
|
}
|
|
if (filters.search) {
|
|
const searchLower = filters.search.toLowerCase();
|
|
findings = findings.filter(f =>
|
|
f.title.toLowerCase().includes(searchLower) ||
|
|
f.description.toLowerCase().includes(searchLower)
|
|
);
|
|
}
|
|
return findings;
|
|
}, [findingsQuery.data, filters]);
|
|
|
|
const selectSession = (sessionId: string) => {
|
|
setActiveSessionId(sessionId);
|
|
};
|
|
|
|
const exportFindings = () => {
|
|
if (!activeSessionId || !findingsQuery.data) return;
|
|
const data = {
|
|
session: activeSession,
|
|
findings: findingsQuery.data,
|
|
exported_at: new Date().toISOString(),
|
|
};
|
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `discovery-${activeSessionId}.json`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
return {
|
|
sessions: sessionsQuery.data ?? [],
|
|
activeSession,
|
|
findings: findingsQuery.data ?? [],
|
|
filteredFindings,
|
|
isLoadingSessions: sessionsQuery.isLoading,
|
|
isLoadingFindings: findingsQuery.isLoading,
|
|
error: sessionsQuery.error || findingsQuery.error,
|
|
filters,
|
|
setFilters,
|
|
selectSession,
|
|
refetchSessions: () => {
|
|
sessionsQuery.refetch();
|
|
},
|
|
exportFindings,
|
|
};
|
|
}
|