forked from cardosofelipe/pragma-stack
feat(frontend): implement main dashboard page (#48)
Implement the main dashboard / projects list page for Syndarix as the landing page after login. The implementation includes: Dashboard Components: - QuickStats: Overview cards showing active projects, agents, issues, approvals - ProjectsSection: Grid/list view with filtering and sorting controls - ProjectCardGrid: Rich project cards for grid view - ProjectRowList: Compact rows for list view - ActivityFeed: Real-time activity sidebar with connection status - PerformanceCard: Performance metrics display - EmptyState: Call-to-action for new users - ProjectStatusBadge: Status indicator with icons - ComplexityIndicator: Visual complexity dots - ProgressBar: Accessible progress bar component Features: - Projects grid/list view with view mode toggle - Filter by status (all, active, paused, completed, archived) - Sort by recent, name, progress, or issues - Quick stats overview with counts - Real-time activity feed sidebar with live/reconnecting status - Performance metrics card - Create project button linking to wizard - Responsive layout for mobile/desktop - Loading skeleton states - Empty state for new users API Integration: - useProjects hook for fetching projects (mock data until backend ready) - useDashboardStats hook for statistics - TanStack Query for caching and data fetching Testing: - 37 unit tests covering all dashboard components - E2E test suite for dashboard functionality - Accessibility tests (keyboard nav, aria attributes, heading hierarchy) Technical: - TypeScript strict mode compliance - ESLint passing - WCAG AA accessibility compliance - Mobile-first responsive design - Dark mode support via semantic tokens - Follows design system guidelines
This commit is contained in:
332
frontend/src/features/issues/hooks/useIssues.ts
Normal file
332
frontend/src/features/issues/hooks/useIssues.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* 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 = { 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) });
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user