Files
syndarix/frontend/src/features/issues/hooks/useIssues.ts
Felipe Cardoso 1bf11e985c fix(frontend): align issue types with backend enums
- Fix IssueStatus: remove 'done', keep 'closed'
- Add IssuePriority 'critical' level
- Add IssueType enum (epic, story, task, bug)
- Update constants, hooks, and mocks to match
- Fix StatusWorkflow component icons
2025-12-31 12:47:52 +01:00

333 lines
9.4 KiB
TypeScript

/**
* Issue Management React Query Hooks
*
* Hooks for fetching and mutating issues.
* Uses TanStack Query for server state management.
*
* Note: Until backend API is implemented, these hooks use mock data.
* The API integration points are marked for future implementation.
*
* @module features/issues/hooks/useIssues
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type {
IssueSummary,
IssueDetail,
IssueFilters,
IssueSort,
IssueUpdateRequest,
IssueBulkActionRequest,
PaginatedIssuesResponse,
} from '../types';
import { mockIssues, mockIssueDetail } from '../mocks';
/**
* Query keys for issues
*/
export const issueKeys = {
all: ['issues'] as const,
lists: () => [...issueKeys.all, 'list'] as const,
list: (projectId: string, filters?: IssueFilters, sort?: IssueSort) =>
[...issueKeys.lists(), projectId, filters, sort] as const,
details: () => [...issueKeys.all, 'detail'] as const,
detail: (issueId: string) => [...issueKeys.details(), issueId] as const,
};
/**
* Mock filtering and sorting logic
* This simulates server-side filtering until API is ready
*/
function filterAndSortIssues(
issues: IssueSummary[],
filters?: IssueFilters,
sort?: IssueSort
): IssueSummary[] {
let result = [...issues];
if (filters) {
// Search filter
if (filters.search) {
const searchLower = filters.search.toLowerCase();
result = result.filter(
(issue) =>
issue.title.toLowerCase().includes(searchLower) ||
issue.description.toLowerCase().includes(searchLower) ||
issue.number.toString().includes(searchLower)
);
}
// Status filter
if (filters.status && filters.status !== 'all') {
result = result.filter((issue) => issue.status === filters.status);
}
// Priority filter
if (filters.priority && filters.priority !== 'all') {
result = result.filter((issue) => issue.priority === filters.priority);
}
// Sprint filter
if (filters.sprint && filters.sprint !== 'all') {
if (filters.sprint === 'backlog') {
result = result.filter((issue) => !issue.sprint);
} else {
result = result.filter((issue) => issue.sprint === filters.sprint);
}
}
// Assignee filter
if (filters.assignee && filters.assignee !== 'all') {
if (filters.assignee === 'unassigned') {
result = result.filter((issue) => !issue.assignee);
} else {
result = result.filter((issue) => issue.assignee?.id === filters.assignee);
}
}
}
// Sorting
if (sort) {
const direction = sort.direction === 'asc' ? 1 : -1;
result.sort((a, b) => {
switch (sort.field) {
case 'number':
return (a.number - b.number) * direction;
case 'priority': {
const priorityOrder = { critical: 4, high: 3, medium: 2, low: 1 };
return (priorityOrder[a.priority] - priorityOrder[b.priority]) * direction;
}
case 'updated_at':
return (new Date(a.updated_at).getTime() - new Date(b.updated_at).getTime()) * direction;
case 'created_at':
return (new Date(a.created_at).getTime() - new Date(b.created_at).getTime()) * direction;
default:
return 0;
}
});
}
return result;
}
/**
* Hook to fetch paginated issues for a project
*
* @param projectId - Project ID
* @param filters - Optional filters
* @param sort - Optional sort configuration
* @param page - Page number (1-based)
* @param pageSize - Number of items per page
*/
export function useIssues(
projectId: string,
filters?: IssueFilters,
sort?: IssueSort,
page: number = 1,
pageSize: number = 25
) {
return useQuery({
queryKey: issueKeys.list(projectId, filters, sort),
queryFn: async (): Promise<PaginatedIssuesResponse> => {
// TODO: Replace with actual API call when backend is ready
// const response = await getProjectIssues({
// path: { project_id: projectId },
// query: { ...filters, ...sort, page, page_size: pageSize },
// });
// Simulate API delay
await new Promise((resolve) => setTimeout(resolve, 300));
const filteredIssues = filterAndSortIssues(mockIssues, filters, sort);
const start = (page - 1) * pageSize;
const paginatedIssues = filteredIssues.slice(start, start + pageSize);
return {
data: paginatedIssues,
pagination: {
total: filteredIssues.length,
page,
page_size: pageSize,
total_pages: Math.ceil(filteredIssues.length / pageSize),
has_next: start + pageSize < filteredIssues.length,
has_prev: page > 1,
},
};
},
staleTime: 30000, // 30 seconds
});
}
/**
* Hook to fetch a single issue detail
*
* @param issueId - Issue ID
*/
export function useIssue(issueId: string) {
return useQuery({
queryKey: issueKeys.detail(issueId),
queryFn: async (): Promise<IssueDetail> => {
// TODO: Replace with actual API call when backend is ready
// const response = await getIssue({
// path: { issue_id: issueId },
// });
// Simulate API delay
await new Promise((resolve) => setTimeout(resolve, 200));
// Return mock detail for any issue ID
return {
...mockIssueDetail,
id: issueId,
};
},
staleTime: 30000,
enabled: !!issueId,
});
}
/**
* Hook to update an issue
*/
export function useUpdateIssue() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
issueId,
data,
}: {
issueId: string;
data: IssueUpdateRequest;
}): Promise<IssueDetail> => {
// TODO: Replace with actual API call when backend is ready
// const response = await updateIssue({
// path: { issue_id: issueId },
// body: data,
// });
// Simulate API delay
await new Promise((resolve) => setTimeout(resolve, 300));
// Return updated mock data - only apply non-label fields from data
return {
...mockIssueDetail,
id: issueId,
title: data.title || mockIssueDetail.title,
description: data.description || mockIssueDetail.description,
status: data.status || mockIssueDetail.status,
priority: data.priority || mockIssueDetail.priority,
sprint: data.sprint !== undefined ? data.sprint : mockIssueDetail.sprint,
due_date: data.due_date !== undefined ? data.due_date : mockIssueDetail.due_date,
};
},
onSuccess: (data) => {
// Invalidate and update cache
queryClient.invalidateQueries({ queryKey: issueKeys.lists() });
queryClient.setQueryData(issueKeys.detail(data.id), data);
},
});
}
/**
* Hook to update issue status (optimistic update)
*/
export function useUpdateIssueStatus() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
issueId,
status,
}: {
issueId: string;
status: IssueUpdateRequest['status'];
}): Promise<IssueDetail> => {
// TODO: Replace with actual API call
await new Promise((resolve) => setTimeout(resolve, 300));
return {
...mockIssueDetail,
id: issueId,
status: status || mockIssueDetail.status,
};
},
onMutate: async ({ issueId, status }) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: issueKeys.detail(issueId) });
// Snapshot previous value
const previousIssue = queryClient.getQueryData<IssueDetail>(issueKeys.detail(issueId));
// Optimistically update
if (previousIssue && status) {
queryClient.setQueryData(issueKeys.detail(issueId), {
...previousIssue,
status,
});
}
return { previousIssue };
},
onError: (_err, { issueId }, context) => {
// Rollback on error
if (context?.previousIssue) {
queryClient.setQueryData(issueKeys.detail(issueId), context.previousIssue);
}
},
onSettled: () => {
// Invalidate lists to refetch
queryClient.invalidateQueries({ queryKey: issueKeys.lists() });
},
});
}
/**
* Hook for bulk actions on issues
*/
export function useBulkIssueAction() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (request: IssueBulkActionRequest): Promise<{ affected_count: number }> => {
// TODO: Replace with actual API call
await new Promise((resolve) => setTimeout(resolve, 500));
return { affected_count: request.issue_ids.length };
},
onSuccess: () => {
// Invalidate all issue queries
queryClient.invalidateQueries({ queryKey: issueKeys.all });
},
});
}
/**
* Hook to sync an issue with external tracker
*/
export function useSyncIssue() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ issueId }: { issueId: string }): Promise<IssueSummary> => {
// TODO: Replace with actual API call
// const response = await syncIssue({
// path: { issue_id: issueId },
// body: { direction: 'bidirectional' },
// });
await new Promise((resolve) => setTimeout(resolve, 1000));
const issue = mockIssues.find((i) => i.id === issueId);
return {
...(issue || mockIssues[0]),
sync_status: 'synced',
};
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: issueKeys.lists() });
queryClient.invalidateQueries({ queryKey: issueKeys.detail(data.id) });
},
});
}