Split the Dashboard from All Tasks. #10
10 changed files with 572 additions and 23 deletions
|
|
@ -94,6 +94,15 @@ impl TaskModel {
|
||||||
.await?;
|
.await?;
|
||||||
Ok(tasks)
|
Ok(tasks)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn list_by_status(pool: &SqlitePool, status: TaskStatus) -> Result<Vec<TaskModel>> {
|
||||||
|
let tasks =
|
||||||
|
sqlx::query_as("SELECT * FROM tasks t WHERE t.status = $1 ORDER BY created_at DESC")
|
||||||
|
.bind(status)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(tasks)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
use axum::{
|
use axum::{
|
||||||
Json, Router,
|
Json, Router,
|
||||||
extract::{Path, State},
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
};
|
};
|
||||||
|
|
@ -40,10 +40,19 @@ pub async fn create_task(
|
||||||
Ok((StatusCode::CREATED, Json(model)))
|
Ok((StatusCode::CREATED, Json(model)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct Filters {
|
||||||
|
status: Option<TaskStatus>,
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn list_tasks(
|
pub async fn list_tasks(
|
||||||
State(pool): State<Pool<Sqlite>>,
|
State(pool): State<Pool<Sqlite>>,
|
||||||
|
Query(filters): Query<Filters>,
|
||||||
) -> Result<(StatusCode, Json<Vec<TaskModel>>), AppError> {
|
) -> Result<(StatusCode, Json<Vec<TaskModel>>), AppError> {
|
||||||
let tasks = TaskModel::list_all(&pool).await?;
|
let tasks = match filters.status {
|
||||||
|
Some(status) => TaskModel::list_by_status(&pool, status).await?,
|
||||||
|
None => TaskModel::list_all(&pool).await?,
|
||||||
|
};
|
||||||
|
|
||||||
Ok((StatusCode::OK, Json(tasks)))
|
Ok((StatusCode::OK, Json(tasks)))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
import {
|
import {
|
||||||
Menu as MenuIcon,
|
Menu as MenuIcon,
|
||||||
Dashboard as DashboardIcon,
|
Dashboard as DashboardIcon,
|
||||||
|
List as ListIcon,
|
||||||
Add as AddIcon,
|
Add as AddIcon,
|
||||||
Settings as SettingsIcon,
|
Settings as SettingsIcon,
|
||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
|
|
@ -41,10 +42,15 @@ export default function Layout({ children, loading = false }: LayoutProps) {
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{
|
{
|
||||||
text: 'Tasks',
|
text: 'Dashboard',
|
||||||
icon: <DashboardIcon />,
|
icon: <DashboardIcon />,
|
||||||
path: '/',
|
path: '/',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: 'All Tasks',
|
||||||
|
icon: <ListIcon />,
|
||||||
|
path: '/all-tasks',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
text: 'Settings',
|
text: 'Settings',
|
||||||
icon: <SettingsIcon />,
|
icon: <SettingsIcon />,
|
||||||
|
|
@ -58,8 +64,8 @@ export default function Layout({ children, loading = false }: LayoutProps) {
|
||||||
{menuItems.map(item => (
|
{menuItems.map(item => (
|
||||||
<ListItem key={item.text} disablePadding>
|
<ListItem key={item.text} disablePadding>
|
||||||
<ListItemButton
|
<ListItemButton
|
||||||
component="a"
|
component={Link}
|
||||||
href={item.path}
|
to={item.path}
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
mx: 1,
|
mx: 1,
|
||||||
|
|
@ -220,7 +226,6 @@ export default function Layout({ children, loading = false }: LayoutProps) {
|
||||||
<Toolbar />
|
<Toolbar />
|
||||||
{children}
|
{children}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
380
frontend/app/components/TaskList.test.tsx
Normal file
380
frontend/app/components/TaskList.test.tsx
Normal file
|
|
@ -0,0 +1,380 @@
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
|
import { MemoryRouter } from 'react-router'
|
||||||
|
import { TaskList } from './TaskList'
|
||||||
|
import type { Task } from '~/types/task'
|
||||||
|
import { TaskStatus } from '~/types/task'
|
||||||
|
import { useTasks } from '~/hooks/useTasks'
|
||||||
|
|
||||||
|
// Mock the useTasks hook
|
||||||
|
vi.mock('~/hooks/useTasks')
|
||||||
|
const mockUseTasks = useTasks as unknown as ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
|
// Sample task data for testing
|
||||||
|
const mockTasks: Task[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: 'Alpha Task',
|
||||||
|
description: 'First task description',
|
||||||
|
status: TaskStatus.Todo,
|
||||||
|
created_at: '2023-01-01T00:00:00Z',
|
||||||
|
updated_at: '2023-01-01T00:00:00Z',
|
||||||
|
completed_at: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
title: 'Beta Task',
|
||||||
|
description: 'Second task description',
|
||||||
|
status: TaskStatus.Done,
|
||||||
|
created_at: '2023-01-02T00:00:00Z',
|
||||||
|
updated_at: '2023-01-02T00:00:00Z',
|
||||||
|
completed_at: '2023-01-02T01:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
title: 'Charlie Task',
|
||||||
|
description: null,
|
||||||
|
status: TaskStatus.Backlog,
|
||||||
|
created_at: '2023-01-03T00:00:00Z',
|
||||||
|
updated_at: '2023-01-03T00:00:00Z',
|
||||||
|
completed_at: null,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const defaultMockUseTasks = {
|
||||||
|
tasks: mockTasks,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
lastFetch: new Date(),
|
||||||
|
fetchTasks: vi.fn(),
|
||||||
|
createTask: vi.fn(),
|
||||||
|
refreshTasks: vi.fn(),
|
||||||
|
deleteTask: vi.fn().mockResolvedValue(true),
|
||||||
|
getTaskById: vi.fn(),
|
||||||
|
filterTasksByStatus: vi.fn(),
|
||||||
|
clearError: vi.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderTaskList = (initialTasks?: Task[]) => {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<TaskList initialTasks={initialTasks} />
|
||||||
|
</MemoryRouter>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TaskList Sorting Functionality', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockUseTasks.mockReturnValue(defaultMockUseTasks)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Default Sorting', () => {
|
||||||
|
it('should default to created_desc sorting (newest first)', () => {
|
||||||
|
renderTaskList()
|
||||||
|
|
||||||
|
// Tasks should be ordered by creation date descending (newest first)
|
||||||
|
const taskRows = screen.getAllByRole('row')
|
||||||
|
// Skip header row (index 0)
|
||||||
|
const firstTaskRow = taskRows[1]
|
||||||
|
const secondTaskRow = taskRows[2]
|
||||||
|
const thirdTaskRow = taskRows[3]
|
||||||
|
|
||||||
|
// Charlie Task (2023-01-03) should be first
|
||||||
|
expect(firstTaskRow).toHaveTextContent('Charlie Task')
|
||||||
|
// Beta Task (2023-01-02) should be second
|
||||||
|
expect(secondTaskRow).toHaveTextContent('Beta Task')
|
||||||
|
// Alpha Task (2023-01-01) should be third
|
||||||
|
expect(thirdTaskRow).toHaveTextContent('Alpha Task')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show correct sort indicator for created column by default', () => {
|
||||||
|
renderTaskList()
|
||||||
|
|
||||||
|
const createdHeader = screen.getByRole('button', { name: /created/i })
|
||||||
|
expect(createdHeader).toHaveClass('Mui-active')
|
||||||
|
|
||||||
|
// Should have descending indicator (direction class)
|
||||||
|
expect(createdHeader).toHaveClass('MuiTableSortLabel-directionDesc')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Title Column Sorting', () => {
|
||||||
|
it('should sort by title ascending when clicked first time', async () => {
|
||||||
|
renderTaskList()
|
||||||
|
|
||||||
|
const titleHeader = screen.getByRole('button', { name: /title/i })
|
||||||
|
fireEvent.click(titleHeader)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const taskRows = screen.getAllByRole('row')
|
||||||
|
// Skip header row
|
||||||
|
const firstTaskRow = taskRows[1]
|
||||||
|
const secondTaskRow = taskRows[2]
|
||||||
|
const thirdTaskRow = taskRows[3]
|
||||||
|
|
||||||
|
// Should be in alphabetical order
|
||||||
|
expect(firstTaskRow).toHaveTextContent('Alpha Task')
|
||||||
|
expect(secondTaskRow).toHaveTextContent('Beta Task')
|
||||||
|
expect(thirdTaskRow).toHaveTextContent('Charlie Task')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should sort by title descending when clicked second time', async () => {
|
||||||
|
renderTaskList()
|
||||||
|
|
||||||
|
const titleHeader = screen.getByRole('button', { name: /title/i })
|
||||||
|
|
||||||
|
// First click - ascending
|
||||||
|
fireEvent.click(titleHeader)
|
||||||
|
|
||||||
|
// Second click - descending
|
||||||
|
fireEvent.click(titleHeader)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const taskRows = screen.getAllByRole('row')
|
||||||
|
const firstTaskRow = taskRows[1]
|
||||||
|
const secondTaskRow = taskRows[2]
|
||||||
|
const thirdTaskRow = taskRows[3]
|
||||||
|
|
||||||
|
// Should be in reverse alphabetical order
|
||||||
|
expect(firstTaskRow).toHaveTextContent('Charlie Task')
|
||||||
|
expect(secondTaskRow).toHaveTextContent('Beta Task')
|
||||||
|
expect(thirdTaskRow).toHaveTextContent('Alpha Task')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show correct sort indicators for title column', async () => {
|
||||||
|
renderTaskList()
|
||||||
|
|
||||||
|
const titleHeader = screen.getByRole('button', { name: /title/i })
|
||||||
|
|
||||||
|
// Initially not active
|
||||||
|
expect(titleHeader).not.toHaveClass('Mui-active')
|
||||||
|
|
||||||
|
// First click - ascending
|
||||||
|
fireEvent.click(titleHeader)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(titleHeader).toHaveClass('Mui-active')
|
||||||
|
expect(titleHeader).toHaveClass('MuiTableSortLabel-directionAsc')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Second click - descending
|
||||||
|
fireEvent.click(titleHeader)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(titleHeader).toHaveClass('Mui-active')
|
||||||
|
expect(titleHeader).toHaveClass('MuiTableSortLabel-directionDesc')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Status Column Sorting', () => {
|
||||||
|
it('should sort by status priority (Todo, Backlog, Done) when clicked', async () => {
|
||||||
|
renderTaskList()
|
||||||
|
|
||||||
|
const statusHeader = screen.getByRole('button', { name: /status/i })
|
||||||
|
fireEvent.click(statusHeader)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const taskRows = screen.getAllByRole('row')
|
||||||
|
const firstTaskRow = taskRows[1]
|
||||||
|
const secondTaskRow = taskRows[2]
|
||||||
|
const thirdTaskRow = taskRows[3]
|
||||||
|
|
||||||
|
// Should be ordered by status priority
|
||||||
|
expect(firstTaskRow).toHaveTextContent('Alpha Task') // Todo
|
||||||
|
expect(secondTaskRow).toHaveTextContent('Charlie Task') // Backlog
|
||||||
|
expect(thirdTaskRow).toHaveTextContent('Beta Task') // Done
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show correct sort indicator for status column', async () => {
|
||||||
|
renderTaskList()
|
||||||
|
|
||||||
|
const statusHeader = screen.getByRole('button', { name: /status/i })
|
||||||
|
|
||||||
|
// Initially not active
|
||||||
|
expect(statusHeader).not.toHaveClass('Mui-active')
|
||||||
|
|
||||||
|
fireEvent.click(statusHeader)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(statusHeader).toHaveClass('Mui-active')
|
||||||
|
expect(statusHeader).toHaveClass('MuiTableSortLabel-directionAsc')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Created Column Sorting', () => {
|
||||||
|
it('should toggle between ascending and descending when clicked', async () => {
|
||||||
|
renderTaskList()
|
||||||
|
|
||||||
|
const createdHeader = screen.getByRole('button', { name: /created/i })
|
||||||
|
|
||||||
|
// First click should change to ascending (oldest first)
|
||||||
|
fireEvent.click(createdHeader)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const taskRows = screen.getAllByRole('row')
|
||||||
|
const firstTaskRow = taskRows[1]
|
||||||
|
const thirdTaskRow = taskRows[3]
|
||||||
|
|
||||||
|
expect(firstTaskRow).toHaveTextContent('Alpha Task') // 2023-01-01
|
||||||
|
expect(thirdTaskRow).toHaveTextContent('Charlie Task') // 2023-01-03
|
||||||
|
})
|
||||||
|
|
||||||
|
// Second click should change back to descending (newest first)
|
||||||
|
fireEvent.click(createdHeader)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const taskRows = screen.getAllByRole('row')
|
||||||
|
const firstTaskRow = taskRows[1]
|
||||||
|
const thirdTaskRow = taskRows[3]
|
||||||
|
|
||||||
|
expect(firstTaskRow).toHaveTextContent('Charlie Task') // 2023-01-03
|
||||||
|
expect(thirdTaskRow).toHaveTextContent('Alpha Task') // 2023-01-01
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Sort Persistence', () => {
|
||||||
|
it('should maintain sort order after clicking different headers', async () => {
|
||||||
|
renderTaskList()
|
||||||
|
|
||||||
|
// Sort by title ascending
|
||||||
|
const titleHeader = screen.getByRole('button', { name: /title/i })
|
||||||
|
fireEvent.click(titleHeader)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const taskRows = screen.getAllByRole('row')
|
||||||
|
expect(taskRows[1]).toHaveTextContent('Alpha Task')
|
||||||
|
expect(taskRows[2]).toHaveTextContent('Beta Task')
|
||||||
|
expect(taskRows[3]).toHaveTextContent('Charlie Task')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sort by status
|
||||||
|
const statusHeader = screen.getByRole('button', { name: /status/i })
|
||||||
|
fireEvent.click(statusHeader)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const taskRows = screen.getAllByRole('row')
|
||||||
|
// Should be ordered by status priority: Todo, Backlog, Done
|
||||||
|
expect(taskRows[1]).toHaveTextContent('Alpha Task') // Todo
|
||||||
|
expect(taskRows[2]).toHaveTextContent('Charlie Task') // Backlog
|
||||||
|
expect(taskRows[3]).toHaveTextContent('Beta Task') // Done
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Empty State', () => {
|
||||||
|
it('should show empty state message when no tasks', () => {
|
||||||
|
mockUseTasks.mockReturnValue({
|
||||||
|
...defaultMockUseTasks,
|
||||||
|
tasks: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
renderTaskList()
|
||||||
|
|
||||||
|
// Should show empty state
|
||||||
|
expect(screen.getByText(/No tasks found/i)).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Create your first task to get started!/i)
|
||||||
|
).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Table headers should not be rendered in empty state
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('button', { name: /title/i })
|
||||||
|
).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Mobile Responsive Sorting', () => {
|
||||||
|
it('should show sortable headers on mobile', () => {
|
||||||
|
renderTaskList()
|
||||||
|
|
||||||
|
// All sortable headers should be visible
|
||||||
|
expect(screen.getByRole('button', { name: /title/i })).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: /status/i })
|
||||||
|
).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: /created/i })
|
||||||
|
).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Description column should be visible but not sortable (no button)
|
||||||
|
expect(screen.getByText('Description')).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('button', { name: /description/i })
|
||||||
|
).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Tooltip Functionality', () => {
|
||||||
|
it('should show cursor help styling on created date with tooltip', () => {
|
||||||
|
renderTaskList()
|
||||||
|
|
||||||
|
// Find created date elements (they should have relative dates like "2y ago")
|
||||||
|
const dateElements = screen.getAllByText(/ago$|Today|Yesterday/)
|
||||||
|
|
||||||
|
// Verify at least one date element exists with proper cursor styling
|
||||||
|
expect(dateElements.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Check that date elements have cursor help styling
|
||||||
|
dateElements.forEach(element => {
|
||||||
|
expect(element).toHaveStyle('cursor: help')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should have tooltip structure in place for completed dates', () => {
|
||||||
|
renderTaskList()
|
||||||
|
|
||||||
|
// Find the table to ensure it rendered
|
||||||
|
const table = screen.getByRole('table')
|
||||||
|
expect(table).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Verify that the TaskList component rendered without errors
|
||||||
|
// The tooltip functionality would be tested in e2e tests for actual hover behavior
|
||||||
|
expect(screen.getByText('Beta Task')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Loading and Error States', () => {
|
||||||
|
it('should not render sort headers during loading', () => {
|
||||||
|
mockUseTasks.mockReturnValue({
|
||||||
|
...defaultMockUseTasks,
|
||||||
|
loading: true,
|
||||||
|
tasks: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
renderTaskList()
|
||||||
|
|
||||||
|
// Should show loading spinner instead of table
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('button', { name: /title/i })
|
||||||
|
).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Loading...')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not render sort headers during error state', () => {
|
||||||
|
mockUseTasks.mockReturnValue({
|
||||||
|
...defaultMockUseTasks,
|
||||||
|
error: 'Failed to load tasks',
|
||||||
|
tasks: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
renderTaskList()
|
||||||
|
|
||||||
|
// Should show error message instead of table
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('button', { name: /title/i })
|
||||||
|
).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/Error loading tasks/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -25,6 +25,8 @@ import {
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogContentText,
|
DialogContentText,
|
||||||
DialogActions,
|
DialogActions,
|
||||||
|
TableSortLabel,
|
||||||
|
Tooltip,
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import { Delete as DeleteIcon } from '@mui/icons-material'
|
import { Delete as DeleteIcon } from '@mui/icons-material'
|
||||||
import { useTasks } from '~/hooks/useTasks'
|
import { useTasks } from '~/hooks/useTasks'
|
||||||
|
|
@ -122,6 +124,19 @@ export function TaskList({ className, initialTasks }: TaskListProps) {
|
||||||
return `${Math.floor(diffInDays / 365)}y ago`
|
return `${Math.floor(diffInDays / 365)}y ago`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatFullTimestamp = (dateString: string) => {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
return date.toLocaleString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
timeZoneName: 'short',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const handleSelectAll = (checked: boolean) => {
|
const handleSelectAll = (checked: boolean) => {
|
||||||
if (checked) {
|
if (checked) {
|
||||||
setSelectedTaskIds(new Set(filteredAndSortedTasks.map(task => task.id)))
|
setSelectedTaskIds(new Set(filteredAndSortedTasks.map(task => task.id)))
|
||||||
|
|
@ -165,6 +180,43 @@ export function TaskList({ className, initialTasks }: TaskListProps) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSort = (field: string) => {
|
||||||
|
let newSortBy: SortOption
|
||||||
|
|
||||||
|
switch (field) {
|
||||||
|
case 'title':
|
||||||
|
newSortBy = sortBy === 'title_asc' ? 'title_desc' : 'title_asc'
|
||||||
|
break
|
||||||
|
case 'status':
|
||||||
|
newSortBy = 'status'
|
||||||
|
break
|
||||||
|
case 'created':
|
||||||
|
newSortBy = sortBy === 'created_asc' ? 'created_desc' : 'created_asc'
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSortBy(newSortBy)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSortDirection = (field: string): 'asc' | 'desc' | false => {
|
||||||
|
switch (field) {
|
||||||
|
case 'title':
|
||||||
|
if (sortBy === 'title_asc') return 'asc'
|
||||||
|
if (sortBy === 'title_desc') return 'desc'
|
||||||
|
return false
|
||||||
|
case 'status':
|
||||||
|
return sortBy === 'status' ? 'asc' : false
|
||||||
|
case 'created':
|
||||||
|
if (sortBy === 'created_asc') return 'asc'
|
||||||
|
if (sortBy === 'created_desc') return 'desc'
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const isAllSelected =
|
const isAllSelected =
|
||||||
filteredAndSortedTasks.length > 0 &&
|
filteredAndSortedTasks.length > 0 &&
|
||||||
filteredAndSortedTasks.every(task => selectedTaskIds.has(task.id))
|
filteredAndSortedTasks.every(task => selectedTaskIds.has(task.id))
|
||||||
|
|
@ -287,15 +339,35 @@ export function TaskList({ className, initialTasks }: TaskListProps) {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell sx={{ fontWeight: 'bold' }}>Title</TableCell>
|
<TableCell sx={{ fontWeight: 'bold' }}>
|
||||||
|
<TableSortLabel
|
||||||
|
active={getSortDirection('title') !== false}
|
||||||
|
direction={getSortDirection('title') || 'asc'}
|
||||||
|
onClick={() => handleSort('title')}
|
||||||
|
>
|
||||||
|
Title
|
||||||
|
</TableSortLabel>
|
||||||
|
</TableCell>
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<TableCell sx={{ fontWeight: 'bold' }}>Description</TableCell>
|
<TableCell sx={{ fontWeight: 'bold' }}>Description</TableCell>
|
||||||
)}
|
)}
|
||||||
<TableCell sx={{ fontWeight: 'bold', width: 100 }}>
|
<TableCell sx={{ fontWeight: 'bold', width: 100 }}>
|
||||||
Status
|
<TableSortLabel
|
||||||
|
active={getSortDirection('status') !== false}
|
||||||
|
direction={getSortDirection('status') || 'asc'}
|
||||||
|
onClick={() => handleSort('status')}
|
||||||
|
>
|
||||||
|
Status
|
||||||
|
</TableSortLabel>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell sx={{ fontWeight: 'bold', width: 120 }}>
|
<TableCell sx={{ fontWeight: 'bold', width: 120 }}>
|
||||||
Created
|
<TableSortLabel
|
||||||
|
active={getSortDirection('created') !== false}
|
||||||
|
direction={getSortDirection('created') || 'asc'}
|
||||||
|
onClick={() => handleSort('created')}
|
||||||
|
>
|
||||||
|
Created
|
||||||
|
</TableSortLabel>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<TableCell sx={{ fontWeight: 'bold', width: 120 }}>
|
<TableCell sx={{ fontWeight: 'bold', width: 120 }}>
|
||||||
|
|
@ -383,17 +455,41 @@ export function TaskList({ className, initialTasks }: TaskListProps) {
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Tooltip
|
||||||
{formatCompactDate(task.created_at)}
|
title={formatFullTimestamp(task.created_at)}
|
||||||
</Typography>
|
arrow
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ cursor: 'help' }}
|
||||||
|
>
|
||||||
|
{formatCompactDate(task.created_at)}
|
||||||
|
</Typography>
|
||||||
|
</Tooltip>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Typography variant="body2" color="text.secondary">
|
{task.completed_at ? (
|
||||||
{task.completed_at
|
<Tooltip
|
||||||
? formatCompactDate(task.completed_at)
|
title={formatFullTimestamp(task.completed_at)}
|
||||||
: '—'}
|
arrow
|
||||||
</Typography>
|
placement="top"
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ cursor: 'help' }}
|
||||||
|
>
|
||||||
|
{formatCompactDate(task.completed_at)}
|
||||||
|
</Typography>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
—
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { type RouteConfig, index, route } from '@react-router/dev/routes'
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
index('routes/home.tsx'),
|
index('routes/home.tsx'),
|
||||||
|
route('all-tasks', 'routes/all-tasks.tsx'),
|
||||||
route('task/new', 'routes/task.new.tsx'),
|
route('task/new', 'routes/task.new.tsx'),
|
||||||
route('*', 'routes/$.tsx'),
|
route('*', 'routes/$.tsx'),
|
||||||
] satisfies RouteConfig
|
] satisfies RouteConfig
|
||||||
|
|
|
||||||
47
frontend/app/routes/all-tasks.tsx
Normal file
47
frontend/app/routes/all-tasks.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import type { Route } from './+types/all-tasks'
|
||||||
|
import { Box, Typography, Container } from '@mui/material'
|
||||||
|
import { TaskList } from '~/components/TaskList'
|
||||||
|
import type { Task } from '~/types/task'
|
||||||
|
|
||||||
|
export function meta(_: Route.MetaArgs) {
|
||||||
|
return [
|
||||||
|
{ title: "Captain's Log - All Tasks" },
|
||||||
|
{ name: 'description', content: 'Complete Task List' },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loader(): Promise<{ tasks: Task[] }> {
|
||||||
|
try {
|
||||||
|
// Fetch all tasks from the backend API during SSR
|
||||||
|
const apiUrl = process.env.API_URL || 'http://localhost:3000'
|
||||||
|
const response = await fetch(`${apiUrl}/api/tasks`, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('Failed to fetch tasks:', response.statusText)
|
||||||
|
return { tasks: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks = await response.json()
|
||||||
|
return { tasks }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching tasks during SSR:', error)
|
||||||
|
return { tasks: [] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AllTasks({ loaderData }: Route.ComponentProps) {
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg">
|
||||||
|
<Box sx={{ py: 4 }}>
|
||||||
|
<Typography variant="h1" component="h1" gutterBottom>
|
||||||
|
All Tasks
|
||||||
|
</Typography>
|
||||||
|
<TaskList initialTasks={loaderData.tasks} />
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,7 @@ describe('Home component', () => {
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
)
|
)
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole('heading', { level: 1, name: /Tasks/i })
|
screen.getByRole('heading', { level: 1, name: /Dashboard/i })
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
|
|
||||||
// TaskList component should be rendered with empty state
|
// TaskList component should be rendered with empty state
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import type { Task } from '~/types/task'
|
||||||
|
|
||||||
export function meta(_: Route.MetaArgs) {
|
export function meta(_: Route.MetaArgs) {
|
||||||
return [
|
return [
|
||||||
{ title: "Captain's Log - Tasks" },
|
{ title: "Captain's Log - Dashboard" },
|
||||||
{ name: 'description', content: 'Task Dashboard' },
|
{ name: 'description', content: 'Task Dashboard' },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -14,7 +14,7 @@ export async function loader(): Promise<{ tasks: Task[] }> {
|
||||||
try {
|
try {
|
||||||
// Fetch tasks from the backend API during SSR
|
// Fetch tasks from the backend API during SSR
|
||||||
const apiUrl = process.env.API_URL || 'http://localhost:3000'
|
const apiUrl = process.env.API_URL || 'http://localhost:3000'
|
||||||
const response = await fetch(`${apiUrl}/api/tasks`, {
|
const response = await fetch(`${apiUrl}/api/tasks?status=todo`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
|
|
@ -38,7 +38,7 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
||||||
<Container maxWidth="lg">
|
<Container maxWidth="lg">
|
||||||
<Box sx={{ py: 4 }}>
|
<Box sx={{ py: 4 }}>
|
||||||
<Typography variant="h1" component="h1" gutterBottom>
|
<Typography variant="h1" component="h1" gutterBottom>
|
||||||
Tasks
|
Dashboard
|
||||||
</Typography>
|
</Typography>
|
||||||
<TaskList initialTasks={loaderData.tasks} />
|
<TaskList initialTasks={loaderData.tasks} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type {
|
import type {
|
||||||
Task,
|
Task,
|
||||||
|
TaskStatus,
|
||||||
CreateTaskRequest,
|
CreateTaskRequest,
|
||||||
UpdateTaskRequest,
|
UpdateTaskRequest,
|
||||||
ApiError,
|
ApiError,
|
||||||
|
|
@ -81,8 +82,9 @@ class ApiClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async listTasks(): Promise<Task[]> {
|
async listTasks(status?: TaskStatus): Promise<Task[]> {
|
||||||
return this.fetchWrapper<Task[]>('/tasks')
|
const url = status ? `/tasks?status=${status}` : '/tasks'
|
||||||
|
return this.fetchWrapper<Task[]>(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTask(id: string): Promise<Task> {
|
async getTask(id: string): Promise<Task> {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue