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:
2025-12-30 23:46:50 +01:00
parent 9b41571967
commit 551dbb7293
67 changed files with 8879 additions and 0 deletions

View File

@@ -0,0 +1,15 @@
/**
* Issue Management Hooks
*
* @module features/issues/hooks
*/
export {
useIssues,
useIssue,
useUpdateIssue,
useUpdateIssueStatus,
useBulkIssueAction,
useSyncIssue,
issueKeys,
} from './useIssues';

View 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) });
},
});
}