Consolidate category/project filters into a shared TaskFiltersContext + add List filter
Was duplicating categoryFilter/projectFilter useState + filter logic in every view (Board/List/Today/Shared/Assigned/Dispatch). Moved to one global context (same pattern as SearchContext) with a single <TaskFilterBar/> drop-in, and added the missing List filter dimension on top. List filter is deliberately omitted (showList=false) on ListView, Shared, Assigned, and Dispatch -- ListView is already scoped to one list by its route, and the other three show tasks whose list_id doesn't belong to the viewer's own lists (shared tasks live in their owner's lists; assigned tasks are always unfiled after transfer; dispatch is cross-user) -- filtering any of them by "my lists" could only ever return zero results.
This commit is contained in:
parent
4eab3c29ed
commit
3145c1fc07
9 changed files with 137 additions and 52 deletions
|
|
@ -9,6 +9,7 @@ import { StatusesProvider } from './context/StatusesContext'
|
|||
import { CategoriesProvider } from './context/CategoriesContext'
|
||||
import { ProjectsProvider } from './context/ProjectsContext'
|
||||
import { SearchProvider } from './context/SearchContext'
|
||||
import { TaskFiltersProvider } from './context/TaskFiltersContext'
|
||||
import { TaskDrawerProvider } from './components/TaskDrawer'
|
||||
import TodayView from './pages/TodayView'
|
||||
import ListView from './pages/ListView'
|
||||
|
|
@ -30,6 +31,7 @@ export default function App() {
|
|||
<CategoriesProvider>
|
||||
<ProjectsProvider>
|
||||
<SearchProvider>
|
||||
<TaskFiltersProvider>
|
||||
<TaskDrawerProvider>
|
||||
<Layout>
|
||||
<Routes>
|
||||
|
|
@ -47,6 +49,7 @@ export default function App() {
|
|||
</Routes>
|
||||
</Layout>
|
||||
</TaskDrawerProvider>
|
||||
</TaskFiltersProvider>
|
||||
</SearchProvider>
|
||||
</ProjectsProvider>
|
||||
</CategoriesProvider>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { useState } from 'react'
|
||||
import { Check, ChevronDown, Tag, FolderKanban, type LucideIcon } from 'lucide-react'
|
||||
import { Check, ChevronDown, Tag, FolderKanban, ListChecks, type LucideIcon } from 'lucide-react'
|
||||
import type { Priority } from '../types'
|
||||
import { PRIORITY_LABELS } from '../types'
|
||||
import { useCategories } from '../context/CategoriesContext'
|
||||
import { useProjects } from '../context/ProjectsContext'
|
||||
import { useLists } from '../context/ListsContext'
|
||||
import { useTaskFilters } from '../context/TaskFiltersContext'
|
||||
|
||||
export function PriorityBadge({ priority }: { priority: Priority }) {
|
||||
if (priority === 'medium') return null
|
||||
|
|
@ -75,6 +77,34 @@ export function ProjectFilter({ selected, onChange }: { selected: number[]; onCh
|
|||
return <FilterChips items={projects} selected={selected} onChange={onChange} label="Project" icon={FolderKanban} />
|
||||
}
|
||||
|
||||
export function ListFilter({ selected, onChange }: { selected: number[]; onChange: (ids: number[]) => void }) {
|
||||
const { lists } = useLists()
|
||||
return <FilterChips items={lists} selected={selected} onChange={onChange} label="List" icon={ListChecks} />
|
||||
}
|
||||
|
||||
// One drop-in filter row for category + project + (optionally) list, reading
|
||||
// straight from the global TaskFiltersContext — the "easy way to filter any
|
||||
// view" version. Pass showList={false} for views already scoped to one list
|
||||
// (e.g. ListView itself), where a list chip would be redundant/confusing.
|
||||
export function TaskFilterBar({ showList = true }: { showList?: boolean }) {
|
||||
const { categoryIds, setCategoryIds, projectIds, setProjectIds, listIds, setListIds } = useTaskFilters()
|
||||
return (
|
||||
<div className="filter-row">
|
||||
<div className="filter-section">
|
||||
<CategoryFilter selected={categoryIds} onChange={setCategoryIds} />
|
||||
</div>
|
||||
<div className="filter-section">
|
||||
<ProjectFilter selected={projectIds} onChange={setProjectIds} />
|
||||
</div>
|
||||
{showList && (
|
||||
<div className="filter-section">
|
||||
<ListFilter selected={listIds} onChange={setListIds} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function formatDate(iso: string | null | undefined): string {
|
||||
if (!iso) return '—'
|
||||
return new Date(iso).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })
|
||||
|
|
|
|||
47
frontend/src/context/TaskFiltersContext.tsx
Normal file
47
frontend/src/context/TaskFiltersContext.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { createContext, useContext, useState, useCallback } from 'react'
|
||||
import type { Task } from '../types'
|
||||
|
||||
interface TaskFiltersCtx {
|
||||
categoryIds: number[]; setCategoryIds: (ids: number[]) => void
|
||||
projectIds: number[]; setProjectIds: (ids: number[]) => void
|
||||
listIds: number[]; setListIds: (ids: number[]) => void
|
||||
categoryMatches: (t: Task) => boolean
|
||||
projectMatches: (t: Task) => boolean
|
||||
listMatches: (t: Task) => boolean
|
||||
// All three combined — the common case for views that show every dimension.
|
||||
matches: (t: Task) => boolean
|
||||
}
|
||||
|
||||
const Ctx = createContext<TaskFiltersCtx | null>(null)
|
||||
|
||||
export function useTaskFilters() {
|
||||
const ctx = useContext(Ctx)
|
||||
if (!ctx) throw new Error('useTaskFilters must be used inside TaskFiltersProvider')
|
||||
return ctx
|
||||
}
|
||||
|
||||
// Global (not per-view) so a filter selection persists as you move between
|
||||
// Board/Today/Shared/etc — same pattern as SearchContext.
|
||||
export function TaskFiltersProvider({ children }: { children: React.ReactNode }) {
|
||||
const [categoryIds, setCategoryIds] = useState<number[]>([])
|
||||
const [projectIds, setProjectIds] = useState<number[]>([])
|
||||
const [listIds, setListIds] = useState<number[]>([])
|
||||
|
||||
const categoryMatches = useCallback((t: Task) =>
|
||||
categoryIds.length === 0 || (t.category_id !== null && categoryIds.includes(t.category_id)), [categoryIds])
|
||||
const projectMatches = useCallback((t: Task) =>
|
||||
projectIds.length === 0 || (t.project_id !== null && projectIds.includes(t.project_id)), [projectIds])
|
||||
const listMatches = useCallback((t: Task) =>
|
||||
listIds.length === 0 || (t.list_id !== null && listIds.includes(t.list_id)), [listIds])
|
||||
const matches = useCallback((t: Task) => categoryMatches(t) && projectMatches(t) && listMatches(t),
|
||||
[categoryMatches, projectMatches, listMatches])
|
||||
|
||||
return (
|
||||
<Ctx.Provider value={{
|
||||
categoryIds, setCategoryIds, projectIds, setProjectIds, listIds, setListIds,
|
||||
categoryMatches, projectMatches, listMatches, matches,
|
||||
}}>
|
||||
{children}
|
||||
</Ctx.Provider>
|
||||
)
|
||||
}
|
||||
|
|
@ -132,6 +132,7 @@ html, body, #root { height: 100%; margin: 0; font-size: 14px; }
|
|||
.top-bar { display: flex; }
|
||||
.app-shell { flex-direction: column; }
|
||||
.field-row { flex-direction: column; }
|
||||
.filter-row { flex-direction: column; }
|
||||
}
|
||||
|
||||
.menu-backdrop {
|
||||
|
|
|
|||
|
|
@ -5,17 +5,17 @@ import { fetchTasks, updateTask } from '../api'
|
|||
import { useTaskDrawer } from '../components/TaskDrawer'
|
||||
import { useStatuses } from '../context/StatusesContext'
|
||||
import { useSearch } from '../context/SearchContext'
|
||||
import { PriorityBadge, dueInfo, CategoryFilter, ProjectFilter } from '../components/shared'
|
||||
import { useTaskFilters } from '../context/TaskFiltersContext'
|
||||
import { PriorityBadge, dueInfo, TaskFilterBar } from '../components/shared'
|
||||
|
||||
export default function BoardView() {
|
||||
const { openTask, changeVersion } = useTaskDrawer()
|
||||
const { statuses } = useStatuses()
|
||||
const { debouncedQuery } = useSearch()
|
||||
const { matches } = useTaskFilters()
|
||||
const [tasks, setTasks] = useState<Task[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [over, setOver] = useState<string | null>(null)
|
||||
const [categoryFilter, setCategoryFilter] = useState<number[]>([])
|
||||
const [projectFilter, setProjectFilter] = useState<number[]>([])
|
||||
const dragId = useRef<number | null>(null)
|
||||
|
||||
const load = useCallback(() => {
|
||||
|
|
@ -35,17 +35,14 @@ export default function BoardView() {
|
|||
load()
|
||||
}
|
||||
|
||||
const visibleTasks = tasks
|
||||
.filter(t => categoryFilter.length === 0 || (t.category_id !== null && categoryFilter.includes(t.category_id)))
|
||||
.filter(t => projectFilter.length === 0 || (t.project_id !== null && projectFilter.includes(t.project_id)))
|
||||
const visibleTasks = tasks.filter(matches)
|
||||
|
||||
return (
|
||||
<div className="page" style={{ maxWidth: 1200 }}>
|
||||
<div className="page-header">
|
||||
<h1><Columns3 size={18} strokeWidth={1.75} style={{ verticalAlign: -3, marginRight: 6 }} />Board</h1>
|
||||
</div>
|
||||
<CategoryFilter selected={categoryFilter} onChange={setCategoryFilter} />
|
||||
<ProjectFilter selected={projectFilter} onChange={setProjectFilter} />
|
||||
<TaskFilterBar />
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
<div className="board">
|
||||
|
|
|
|||
|
|
@ -4,8 +4,9 @@ import type { Task } from '../types'
|
|||
import { fetchTasks, reallocateTask } from '../api'
|
||||
import { useAuth } from '../components/AuthGate'
|
||||
import { useSearch } from '../context/SearchContext'
|
||||
import { useTaskFilters } from '../context/TaskFiltersContext'
|
||||
import { can } from '../types'
|
||||
import { dueInfo, PriorityBadge } from '../components/shared'
|
||||
import { dueInfo, PriorityBadge, TaskFilterBar } from '../components/shared'
|
||||
|
||||
// A flat, cross-user list for the 'reallocate' cap — deliberately not routed
|
||||
// through the shared task drawer, since GET /api/tasks/:id only grants
|
||||
|
|
@ -14,6 +15,9 @@ import { dueInfo, PriorityBadge } from '../components/shared'
|
|||
export default function DispatchView() {
|
||||
const { user } = useAuth()
|
||||
const { debouncedQuery } = useSearch()
|
||||
// No list filter here either — cross-user list_id values belong to
|
||||
// arbitrary owners, not "my lists" (see FilteredListView for the same call).
|
||||
const { categoryMatches, projectMatches } = useTaskFilters()
|
||||
const [tasks, setTasks] = useState<Task[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [movingId, setMovingId] = useState<number | null>(null)
|
||||
|
|
@ -36,7 +40,7 @@ export default function DispatchView() {
|
|||
load()
|
||||
}
|
||||
|
||||
const open = tasks.filter(t => !t.is_done)
|
||||
const open = tasks.filter(t => !t.is_done && categoryMatches(t) && projectMatches(t))
|
||||
|
||||
return (
|
||||
<div className="page" style={{ maxWidth: 900 }}>
|
||||
|
|
@ -47,6 +51,7 @@ export default function DispatchView() {
|
|||
Every open task across every user — move any of them to someone else. Only shows title/owner/status; open the
|
||||
full task from that person's own view for details.
|
||||
</div>
|
||||
<TaskFilterBar showList={false} />
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
{open.length === 0 && <div className="empty-state">Nothing open anywhere.</div>}
|
||||
|
|
@ -66,6 +71,12 @@ export default function DispatchView() {
|
|||
{t.category_name}
|
||||
</span>
|
||||
)}
|
||||
{t.project_name && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<span className="list-dot" style={{ background: t.project_colour, width: 8, height: 8 }} />
|
||||
{t.project_name}
|
||||
</span>
|
||||
)}
|
||||
{due && <span className={`due-chip${due.overdue ? ' overdue' : ''}`}>{due.label}</span>}
|
||||
<PriorityBadge priority={t.priority} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@ import { fetchTasks, updateTask } from '../api'
|
|||
import { useTaskDrawer } from '../components/TaskDrawer'
|
||||
import { useStatuses } from '../context/StatusesContext'
|
||||
import { useSearch } from '../context/SearchContext'
|
||||
import { useTaskFilters } from '../context/TaskFiltersContext'
|
||||
import TaskRow from '../components/TaskRow'
|
||||
import { CategoryFilter, ProjectFilter } from '../components/shared'
|
||||
import { TaskFilterBar } from '../components/shared'
|
||||
|
||||
const ICONS = { shared: Share2, assigned: UserCheck }
|
||||
|
||||
|
|
@ -14,10 +15,12 @@ export default function FilteredListView({ view, title }: { view: 'shared' | 'as
|
|||
const { openTask, changeVersion } = useTaskDrawer()
|
||||
const { firstDoneValue, firstNotDoneValue } = useStatuses()
|
||||
const { debouncedQuery } = useSearch()
|
||||
// No list filter here — a shared task belongs to its owner's list (not
|
||||
// mine), and an assigned task is always unfiled (list_id null) right after
|
||||
// transfer, so filtering either by "my lists" could only ever return zero.
|
||||
const { categoryMatches, projectMatches } = useTaskFilters()
|
||||
const [tasks, setTasks] = useState<Task[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [categoryFilter, setCategoryFilter] = useState<number[]>([])
|
||||
const [projectFilter, setProjectFilter] = useState<number[]>([])
|
||||
const Icon = ICONS[view]
|
||||
|
||||
const load = useCallback(() => {
|
||||
|
|
@ -32,9 +35,7 @@ export default function FilteredListView({ view, title }: { view: 'shared' | 'as
|
|||
load()
|
||||
}
|
||||
|
||||
const visible = tasks
|
||||
.filter(t => categoryFilter.length === 0 || (t.category_id !== null && categoryFilter.includes(t.category_id)))
|
||||
.filter(t => projectFilter.length === 0 || (t.project_id !== null && projectFilter.includes(t.project_id)))
|
||||
const visible = tasks.filter(t => categoryMatches(t) && projectMatches(t))
|
||||
const open = visible.filter(t => !t.is_done)
|
||||
const done = visible.filter(t => t.is_done)
|
||||
|
||||
|
|
@ -43,8 +44,7 @@ export default function FilteredListView({ view, title }: { view: 'shared' | 'as
|
|||
<div className="page-header">
|
||||
<h1><Icon size={18} strokeWidth={1.75} style={{ verticalAlign: -3, marginRight: 6 }} />{title}</h1>
|
||||
</div>
|
||||
<CategoryFilter selected={categoryFilter} onChange={setCategoryFilter} />
|
||||
<ProjectFilter selected={projectFilter} onChange={setProjectFilter} />
|
||||
<TaskFilterBar showList={false} />
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
{open.length === 0 && done.length === 0 && (
|
||||
|
|
|
|||
|
|
@ -6,10 +6,11 @@ import { fetchTasks, createTask, updateTask, reorderTasks } from '../api'
|
|||
import { useLists } from '../context/ListsContext'
|
||||
import { useStatuses } from '../context/StatusesContext'
|
||||
import { useSearch } from '../context/SearchContext'
|
||||
import { useTaskFilters } from '../context/TaskFiltersContext'
|
||||
import { useTaskDrawer } from '../components/TaskDrawer'
|
||||
import TaskRow from '../components/TaskRow'
|
||||
import ListEditor from '../components/ListEditor'
|
||||
import { CategoryFilter, ProjectFilter } from '../components/shared'
|
||||
import { TaskFilterBar } from '../components/shared'
|
||||
|
||||
export default function ListView() {
|
||||
const { id } = useParams()
|
||||
|
|
@ -17,6 +18,9 @@ export default function ListView() {
|
|||
const { lists } = useLists()
|
||||
const { firstDoneValue, firstNotDoneValue } = useStatuses()
|
||||
const { debouncedQuery } = useSearch()
|
||||
// List filter is irrelevant here — the route itself already scopes to one
|
||||
// list — so only category/project matter for this view.
|
||||
const { categoryMatches, projectMatches } = useTaskFilters()
|
||||
const { openTask, changeVersion } = useTaskDrawer()
|
||||
const list = lists.find(l => l.id === listId)
|
||||
|
||||
|
|
@ -24,8 +28,6 @@ export default function ListView() {
|
|||
const [error, setError] = useState<string | null>(null)
|
||||
const [newTitle, setNewTitle] = useState('')
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [categoryFilter, setCategoryFilter] = useState<number[]>([])
|
||||
const [projectFilter, setProjectFilter] = useState<number[]>([])
|
||||
const dragId = useRef<number | null>(null)
|
||||
|
||||
const load = useCallback(() => {
|
||||
|
|
@ -62,9 +64,7 @@ export default function ListView() {
|
|||
reorderTasks(ids).catch(() => load())
|
||||
}
|
||||
|
||||
const visible = tasks
|
||||
.filter(t => categoryFilter.length === 0 || (t.category_id !== null && categoryFilter.includes(t.category_id)))
|
||||
.filter(t => projectFilter.length === 0 || (t.project_id !== null && projectFilter.includes(t.project_id)))
|
||||
const visible = tasks.filter(t => categoryMatches(t) && projectMatches(t))
|
||||
const open = visible.filter(t => !t.is_done)
|
||||
const done = visible.filter(t => t.is_done)
|
||||
|
||||
|
|
@ -81,8 +81,7 @@ export default function ListView() {
|
|||
</button>
|
||||
)}
|
||||
</div>
|
||||
<CategoryFilter selected={categoryFilter} onChange={setCategoryFilter} />
|
||||
<ProjectFilter selected={projectFilter} onChange={setProjectFilter} />
|
||||
<TaskFilterBar showList={false} />
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
<div className="task-add">
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@ import { fetchTasks, updateTask } from '../api'
|
|||
import { useTaskDrawer } from '../components/TaskDrawer'
|
||||
import { useStatuses } from '../context/StatusesContext'
|
||||
import { useSearch } from '../context/SearchContext'
|
||||
import { useTaskFilters } from '../context/TaskFiltersContext'
|
||||
import TaskRow from '../components/TaskRow'
|
||||
import { CategoryFilter, ProjectFilter } from '../components/shared'
|
||||
import { TaskFilterBar } from '../components/shared'
|
||||
|
||||
function startOfDay(d: Date) { const x = new Date(d); x.setHours(0, 0, 0, 0); return x }
|
||||
|
||||
|
|
@ -14,10 +15,9 @@ export default function TodayView() {
|
|||
const { openTask, changeVersion } = useTaskDrawer()
|
||||
const { firstDoneValue, firstNotDoneValue } = useStatuses()
|
||||
const { debouncedQuery } = useSearch()
|
||||
const { matches } = useTaskFilters()
|
||||
const [tasks, setTasks] = useState<Task[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [categoryFilter, setCategoryFilter] = useState<number[]>([])
|
||||
const [projectFilter, setProjectFilter] = useState<number[]>([])
|
||||
|
||||
const load = useCallback(() => {
|
||||
fetchTasks({ view: 'today', search: debouncedQuery || undefined }).then(setTasks).catch(e => setError(e.message))
|
||||
|
|
@ -31,9 +31,7 @@ export default function TodayView() {
|
|||
load()
|
||||
}
|
||||
|
||||
const visible = tasks
|
||||
.filter(t => categoryFilter.length === 0 || (t.category_id !== null && categoryFilter.includes(t.category_id)))
|
||||
.filter(t => projectFilter.length === 0 || (t.project_id !== null && projectFilter.includes(t.project_id)))
|
||||
const visible = tasks.filter(matches)
|
||||
|
||||
const now = new Date()
|
||||
const todayStart = startOfDay(now)
|
||||
|
|
@ -54,8 +52,7 @@ export default function TodayView() {
|
|||
<div className="page-header">
|
||||
<h1><CalendarDays size={18} strokeWidth={1.75} style={{ verticalAlign: -3, marginRight: 6 }} />Today</h1>
|
||||
</div>
|
||||
<CategoryFilter selected={categoryFilter} onChange={setCategoryFilter} />
|
||||
<ProjectFilter selected={projectFilter} onChange={setProjectFilter} />
|
||||
<TaskFilterBar />
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
{visible.length === 0 && (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue