Add multiselect.
This commit is contained in:
parent
843d2a8c7b
commit
b09a072fb9
7 changed files with 235 additions and 21 deletions
|
|
@ -17,9 +17,18 @@ import {
|
|||
TableRow,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
Checkbox,
|
||||
Toolbar,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogActions,
|
||||
} from '@mui/material'
|
||||
import { Delete as DeleteIcon } from '@mui/icons-material'
|
||||
import { useTasks } from '~/hooks/useTasks'
|
||||
import { TaskStatus } from '~/types/task'
|
||||
import { TaskStatus, type Task } from '~/types/task'
|
||||
import LoadingSpinner from './LoadingSpinner'
|
||||
|
||||
type SortOption =
|
||||
|
|
@ -31,12 +40,18 @@ type SortOption =
|
|||
|
||||
interface TaskListProps {
|
||||
className?: string
|
||||
initialTasks?: Task[]
|
||||
}
|
||||
|
||||
export function TaskList({ className }: TaskListProps) {
|
||||
const { tasks, loading, error } = useTasks()
|
||||
export function TaskList({ className, initialTasks }: TaskListProps) {
|
||||
const { tasks, loading, error, deleteTask } = useTasks({
|
||||
autoFetch: !initialTasks,
|
||||
initialData: initialTasks,
|
||||
})
|
||||
const [statusFilter, setStatusFilter] = useState<TaskStatus | 'all'>('all')
|
||||
const [sortBy, setSortBy] = useState<SortOption>('created_desc')
|
||||
const [selectedTaskIds, setSelectedTaskIds] = useState<Set<string>>(new Set())
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const theme = useTheme()
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
|
||||
|
||||
|
|
@ -107,6 +122,54 @@ export function TaskList({ className }: TaskListProps) {
|
|||
return `${Math.floor(diffInDays / 365)}y ago`
|
||||
}
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedTaskIds(new Set(filteredAndSortedTasks.map(task => task.id)))
|
||||
} else {
|
||||
setSelectedTaskIds(new Set())
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectTask = (taskId: string, checked: boolean) => {
|
||||
const newSelected = new Set(selectedTaskIds)
|
||||
if (checked) {
|
||||
newSelected.add(taskId)
|
||||
} else {
|
||||
newSelected.delete(taskId)
|
||||
}
|
||||
setSelectedTaskIds(newSelected)
|
||||
}
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
Array.from(selectedTaskIds).map(taskId => deleteTask(taskId))
|
||||
)
|
||||
|
||||
// Check if all deletions were successful
|
||||
const allSuccessful = results.every(result => result === true)
|
||||
|
||||
if (allSuccessful) {
|
||||
setSelectedTaskIds(new Set())
|
||||
setDeleteDialogOpen(false)
|
||||
} else {
|
||||
console.error('Some tasks failed to delete')
|
||||
// Clear selection of successfully deleted tasks
|
||||
const failedTaskIds = Array.from(selectedTaskIds).filter(
|
||||
(taskId, index) => !results[index]
|
||||
)
|
||||
setSelectedTaskIds(new Set(failedTaskIds))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting tasks:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const isAllSelected =
|
||||
filteredAndSortedTasks.length > 0 &&
|
||||
filteredAndSortedTasks.every(task => selectedTaskIds.has(task.id))
|
||||
const isIndeterminate = selectedTaskIds.size > 0 && !isAllSelected
|
||||
|
||||
if (loading) {
|
||||
return <LoadingSpinner />
|
||||
}
|
||||
|
|
@ -171,6 +234,31 @@ export function TaskList({ className }: TaskListProps) {
|
|||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Bulk Actions Toolbar */}
|
||||
{selectedTaskIds.size > 0 && (
|
||||
<Paper sx={{ mb: 2 }}>
|
||||
<Toolbar sx={{ pl: 2, pr: 1 }}>
|
||||
<Typography
|
||||
sx={{ flex: '1 1 100%' }}
|
||||
color="inherit"
|
||||
variant="subtitle1"
|
||||
component="div"
|
||||
>
|
||||
{selectedTaskIds.size} task{selectedTaskIds.size > 1 ? 's' : ''}{' '}
|
||||
selected
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
startIcon={<DeleteIcon />}
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Toolbar>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Task Table */}
|
||||
{filteredAndSortedTasks.length === 0 ? (
|
||||
<Paper sx={{ p: 4, textAlign: 'center' }}>
|
||||
|
|
@ -188,6 +276,17 @@ export function TaskList({ className }: TaskListProps) {
|
|||
<Table size="small" sx={{ '& .MuiTableCell-root': { py: 1 } }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell padding="checkbox">
|
||||
<Checkbox
|
||||
color="primary"
|
||||
indeterminate={isIndeterminate}
|
||||
checked={isAllSelected}
|
||||
onChange={e => handleSelectAll(e.target.checked)}
|
||||
inputProps={{
|
||||
'aria-label': 'select all tasks',
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>Title</TableCell>
|
||||
{!isMobile && (
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>Description</TableCell>
|
||||
|
|
@ -214,11 +313,27 @@ export function TaskList({ className }: TaskListProps) {
|
|||
backgroundColor: 'action.hover',
|
||||
},
|
||||
'&:last-child td, &:last-child th': { border: 0 },
|
||||
backgroundColor: selectedTaskIds.has(task.id)
|
||||
? 'action.selected'
|
||||
: 'inherit',
|
||||
}}
|
||||
>
|
||||
<TableCell padding="checkbox">
|
||||
<Checkbox
|
||||
color="primary"
|
||||
checked={selectedTaskIds.has(task.id)}
|
||||
onChange={e =>
|
||||
handleSelectTask(task.id, e.target.checked)
|
||||
}
|
||||
inputProps={{
|
||||
'aria-labelledby': `task-${task.id}`,
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Box>
|
||||
<Typography
|
||||
id={`task-${task.id}`}
|
||||
variant="body1"
|
||||
component="div"
|
||||
sx={{
|
||||
|
|
@ -287,6 +402,31 @@ export function TaskList({ className }: TaskListProps) {
|
|||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog
|
||||
open={deleteDialogOpen}
|
||||
onClose={() => setDeleteDialogOpen(false)}
|
||||
aria-labelledby="delete-dialog-title"
|
||||
aria-describedby="delete-dialog-description"
|
||||
>
|
||||
<DialogTitle id="delete-dialog-title">
|
||||
Delete {selectedTaskIds.size} task
|
||||
{selectedTaskIds.size > 1 ? 's' : ''}?
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText id="delete-dialog-description">
|
||||
This action cannot be undone. Are you sure you want to delete the
|
||||
selected task{selectedTaskIds.size > 1 ? 's' : ''}?
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleBulkDelete} color="error" autoFocus>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ interface UseTasksState {
|
|||
interface UseTasksActions {
|
||||
fetchTasks: () => Promise<void>
|
||||
createTask: (data: CreateTaskRequest) => Promise<Task | null>
|
||||
deleteTask: (id: string) => Promise<boolean>
|
||||
refreshTasks: () => Promise<void>
|
||||
clearError: () => void
|
||||
getTaskById: (id: string) => Task | undefined
|
||||
|
|
@ -22,18 +23,19 @@ interface UseTasksActions {
|
|||
interface UseTasksOptions {
|
||||
autoFetch?: boolean
|
||||
refreshInterval?: number
|
||||
initialData?: Task[]
|
||||
}
|
||||
|
||||
export function useTasks(
|
||||
options: UseTasksOptions = {}
|
||||
): UseTasksState & UseTasksActions {
|
||||
const { autoFetch = true, refreshInterval } = options
|
||||
const { autoFetch = true, refreshInterval, initialData } = options
|
||||
|
||||
const [state, setState] = useState<UseTasksState>({
|
||||
tasks: [],
|
||||
tasks: initialData || [],
|
||||
loading: false,
|
||||
error: null,
|
||||
lastFetch: null,
|
||||
lastFetch: initialData ? new Date() : null,
|
||||
})
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
|
|
@ -89,6 +91,27 @@ export function useTasks(
|
|||
[]
|
||||
)
|
||||
|
||||
const deleteTask = useCallback(async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
await apiClient.deleteTask(id)
|
||||
|
||||
// Remove the task from the local state immediately
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
tasks: prev.tasks.filter(task => task.id !== id),
|
||||
}))
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
const apiError = error as ApiError
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: apiError.message,
|
||||
}))
|
||||
return false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const refreshTasks = useCallback(async () => {
|
||||
// Force refresh without showing loading state if tasks already exist
|
||||
const showLoading = state.tasks.length === 0
|
||||
|
|
@ -153,6 +176,7 @@ export function useTasks(
|
|||
...state,
|
||||
fetchTasks,
|
||||
createTask,
|
||||
deleteTask,
|
||||
refreshTasks,
|
||||
clearError,
|
||||
getTaskById,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import { type RouteConfig, index } from '@react-router/dev/routes'
|
||||
import { type RouteConfig, index, route } from '@react-router/dev/routes'
|
||||
|
||||
export default [index('routes/home.tsx')] satisfies RouteConfig
|
||||
export default [
|
||||
index('routes/home.tsx'),
|
||||
route('*', 'routes/$.tsx'),
|
||||
] satisfies RouteConfig
|
||||
|
|
|
|||
25
frontend/app/routes/$.tsx
Normal file
25
frontend/app/routes/$.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { redirect } from 'react-router'
|
||||
import type { Route } from './+types/$'
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const url = new URL(request.url)
|
||||
|
||||
// Handle React DevTools and other development files
|
||||
if (
|
||||
url.pathname.endsWith('.js.map') ||
|
||||
url.pathname.includes('installHook') ||
|
||||
url.pathname.startsWith('/__') ||
|
||||
url.pathname.startsWith('/node_modules/')
|
||||
) {
|
||||
// Return a 404 response for these dev-only requests
|
||||
throw new Response('Not Found', { status: 404 })
|
||||
}
|
||||
|
||||
// For any other unmatched routes, redirect to home
|
||||
return redirect('/')
|
||||
}
|
||||
|
||||
export default function CatchAll() {
|
||||
// This component should never render since we always redirect or throw
|
||||
return null
|
||||
}
|
||||
|
|
@ -4,12 +4,12 @@ import Home from './home'
|
|||
|
||||
describe('Home component', () => {
|
||||
it('should render task management interface', () => {
|
||||
render(<Home />)
|
||||
expect(screen.getByText(/Tasks/i)).toBeInTheDocument()
|
||||
const mockLoaderData = { tasks: [] }
|
||||
render(<Home loaderData={mockLoaderData} />)
|
||||
expect(
|
||||
screen.getByText(/GTD-inspired task management system/i)
|
||||
screen.getByRole('heading', { level: 1, name: /Tasks/i })
|
||||
).toBeInTheDocument()
|
||||
// TaskList component should be rendered (initially shows loading state)
|
||||
expect(screen.getByText(/Loading.../i)).toBeInTheDocument()
|
||||
// TaskList component should be rendered with empty state
|
||||
expect(screen.getByText(/No tasks found/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,27 +1,46 @@
|
|||
import type { Route } from './+types/home'
|
||||
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 - Tasks" },
|
||||
{ name: 'description', content: 'GTD-inspired task management system' },
|
||||
{ name: 'description', content: 'Task Dashboard' },
|
||||
]
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
export async function loader(): Promise<{ tasks: Task[] }> {
|
||||
try {
|
||||
// Fetch 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 Home({ loaderData }: Route.ComponentProps) {
|
||||
return (
|
||||
<Container maxWidth="lg">
|
||||
<Box sx={{ py: 4 }}>
|
||||
<Typography variant="h1" component="h1" gutterBottom>
|
||||
Tasks
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 4 }}>
|
||||
Your GTD-inspired task management system. Capture everything, see only
|
||||
what matters.
|
||||
</Typography>
|
||||
|
||||
<TaskList />
|
||||
<TaskList initialTasks={loaderData.tasks} />
|
||||
</Box>
|
||||
</Container>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -12,4 +12,7 @@ export default defineConfig({
|
|||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
sourcemap: true,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue