forked from cardosofelipe/pragma-stack
- 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
333 lines
9.4 KiB
TypeScript
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) });
|
|
},
|
|
});
|
|
}
|