Add universal filter to tag details page (#690)
* Add universal filter to tag details page * fixup! Add universal filter to tag details page
This commit is contained in:
parent
442ace69bb
commit
67d8f9e0dd
2 changed files with 249 additions and 71 deletions
|
|
@ -135,9 +135,6 @@ test.describe('Today', () => {
|
||||||
} else {
|
} else {
|
||||||
// If section not visible, the settings might be hiding it
|
// If section not visible, the settings might be hiding it
|
||||||
// Skip this assertion but don't fail the test
|
// Skip this assertion but don't fail the test
|
||||||
console.log(
|
|
||||||
'Overdue section not visible - may be hidden by settings'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up
|
// Clean up
|
||||||
|
|
@ -195,9 +192,6 @@ test.describe('Today', () => {
|
||||||
await expect(dueTodayTask).toBeVisible();
|
await expect(dueTodayTask).toBeVisible();
|
||||||
} else {
|
} else {
|
||||||
// If section not visible, the settings might be hiding it
|
// If section not visible, the settings might be hiding it
|
||||||
console.log(
|
|
||||||
'Due Today section not visible - may be hidden by settings'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up
|
// Clean up
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { Task } from '../../entities/Task';
|
||||||
import { Note } from '../../entities/Note';
|
import { Note } from '../../entities/Note';
|
||||||
import { Project } from '../../entities/Project';
|
import { Project } from '../../entities/Project';
|
||||||
import TaskList from '../Task/TaskList';
|
import TaskList from '../Task/TaskList';
|
||||||
|
import GroupedTaskList from '../Task/GroupedTaskList';
|
||||||
import ProjectItem from '../Project/ProjectItem';
|
import ProjectItem from '../Project/ProjectItem';
|
||||||
import ProjectShareModal from '../Project/ProjectShareModal';
|
import ProjectShareModal from '../Project/ProjectShareModal';
|
||||||
import TagModal from './TagModal';
|
import TagModal from './TagModal';
|
||||||
|
|
@ -40,7 +41,10 @@ const TagDetails: React.FC = () => {
|
||||||
// Search, filter, and sort state
|
// Search, filter, and sort state
|
||||||
const [taskSearchQuery, setTaskSearchQuery] = useState<string>('');
|
const [taskSearchQuery, setTaskSearchQuery] = useState<string>('');
|
||||||
const [isSearchExpanded, setIsSearchExpanded] = useState(false);
|
const [isSearchExpanded, setIsSearchExpanded] = useState(false);
|
||||||
const [showCompleted, setShowCompleted] = useState(false);
|
const [taskStatusFilter, setTaskStatusFilter] = useState<
|
||||||
|
'all' | 'active' | 'completed'
|
||||||
|
>('active');
|
||||||
|
const [groupBy, setGroupBy] = useState<'none' | 'project'>('none');
|
||||||
const [orderBy, setOrderBy] = useState<string>('created_at:desc');
|
const [orderBy, setOrderBy] = useState<string>('created_at:desc');
|
||||||
|
|
||||||
// Filter projects by current tag
|
// Filter projects by current tag
|
||||||
|
|
@ -52,6 +56,27 @@ const TagDetails: React.FC = () => {
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const projectLookupList = useMemo(() => {
|
||||||
|
const map = new Map<string, Project>();
|
||||||
|
const addProject = (project?: Project | null) => {
|
||||||
|
if (!project) return;
|
||||||
|
const key =
|
||||||
|
(project.uid && `uid-${project.uid}`) ??
|
||||||
|
(project.id !== undefined && project.id !== null
|
||||||
|
? `id-${project.id}`
|
||||||
|
: undefined);
|
||||||
|
if (!key) return;
|
||||||
|
if (!map.has(key)) {
|
||||||
|
map.set(key, project);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
allProjects.forEach(addProject);
|
||||||
|
projects.forEach(addProject);
|
||||||
|
|
||||||
|
return Array.from(map.values());
|
||||||
|
}, [allProjects, projects]);
|
||||||
|
|
||||||
// State for ProjectItem components
|
// State for ProjectItem components
|
||||||
const [activeDropdown, setActiveDropdown] = useState<number | null>(null);
|
const [activeDropdown, setActiveDropdown] = useState<number | null>(null);
|
||||||
const [hoveredNoteId, setHoveredNoteId] = useState<string | null>(null);
|
const [hoveredNoteId, setHoveredNoteId] = useState<string | null>(null);
|
||||||
|
|
@ -89,7 +114,7 @@ const TagDetails: React.FC = () => {
|
||||||
let filteredTasks: Task[];
|
let filteredTasks: Task[];
|
||||||
|
|
||||||
// Filter by completion status
|
// Filter by completion status
|
||||||
if (showCompleted) {
|
if (taskStatusFilter === 'completed') {
|
||||||
filteredTasks = tasks.filter(
|
filteredTasks = tasks.filter(
|
||||||
(task: Task) =>
|
(task: Task) =>
|
||||||
task.status === 'done' ||
|
task.status === 'done' ||
|
||||||
|
|
@ -97,7 +122,7 @@ const TagDetails: React.FC = () => {
|
||||||
task.status === 2 ||
|
task.status === 2 ||
|
||||||
task.status === 3
|
task.status === 3
|
||||||
);
|
);
|
||||||
} else {
|
} else if (taskStatusFilter === 'active') {
|
||||||
filteredTasks = tasks.filter(
|
filteredTasks = tasks.filter(
|
||||||
(task: Task) =>
|
(task: Task) =>
|
||||||
task.status !== 'done' &&
|
task.status !== 'done' &&
|
||||||
|
|
@ -105,6 +130,8 @@ const TagDetails: React.FC = () => {
|
||||||
task.status !== 2 &&
|
task.status !== 2 &&
|
||||||
task.status !== 3
|
task.status !== 3
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
filteredTasks = tasks;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by search query
|
// Filter by search query
|
||||||
|
|
@ -169,7 +196,7 @@ const TagDetails: React.FC = () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
return sortedTasks;
|
return sortedTasks;
|
||||||
}, [tasks, showCompleted, taskSearchQuery, orderBy, t]);
|
}, [tasks, taskStatusFilter, taskSearchQuery, orderBy, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchTagData = async () => {
|
const fetchTagData = async () => {
|
||||||
|
|
@ -215,6 +242,16 @@ const TagDetails: React.FC = () => {
|
||||||
fetchTagData();
|
fetchTagData();
|
||||||
}, [uidSlug, t]);
|
}, [uidSlug, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const savedOrderBy =
|
||||||
|
localStorage.getItem('order_by') || 'created_at:desc';
|
||||||
|
setOrderBy(savedOrderBy);
|
||||||
|
const savedGroupBy =
|
||||||
|
(localStorage.getItem('tasks_group_by') as 'none' | 'project') ||
|
||||||
|
'none';
|
||||||
|
setGroupBy(savedGroupBy);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Setup native event listener for edit button to avoid React event system conflicts
|
// Setup native event listener for edit button to avoid React event system conflicts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const button = editButtonRef.current;
|
const button = editButtonRef.current;
|
||||||
|
|
@ -299,6 +336,14 @@ const TagDetails: React.FC = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTaskCompletionToggle = (updatedTask: Task) => {
|
||||||
|
setTasks((prevTasks) =>
|
||||||
|
prevTasks.map((task) =>
|
||||||
|
task.id === updatedTask.id ? updatedTask : task
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const getCompletionPercentage = (project: Project) => {
|
const getCompletionPercentage = (project: Project) => {
|
||||||
return (project as any).completion_percentage || 0;
|
return (project as any).completion_percentage || 0;
|
||||||
};
|
};
|
||||||
|
|
@ -340,6 +385,22 @@ const TagDetails: React.FC = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSortChange = (order: string) => {
|
||||||
|
setOrderBy(order);
|
||||||
|
localStorage.setItem('order_by', order);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGroupByChange = (value: 'none' | 'project') => {
|
||||||
|
setGroupBy(value);
|
||||||
|
localStorage.setItem('tasks_group_by', value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStatusChange = (value: 'all' | 'active' | 'completed') => {
|
||||||
|
setTaskStatusFilter(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showCompletedTasks = taskStatusFilter !== 'active';
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
|
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
|
||||||
|
|
@ -499,74 +560,197 @@ const TagDetails: React.FC = () => {
|
||||||
<IconSortDropdown
|
<IconSortDropdown
|
||||||
options={sortOptions}
|
options={sortOptions}
|
||||||
value={orderBy}
|
value={orderBy}
|
||||||
onChange={setOrderBy}
|
onChange={handleSortChange}
|
||||||
ariaLabel={t('tasks.sortTasks', 'Sort tasks')}
|
ariaLabel={t('tasks.sortTasks', 'Sort tasks')}
|
||||||
title={t('tasks.sortTasks', 'Sort tasks')}
|
title={t('tasks.sortTasks', 'Sort tasks')}
|
||||||
dropdownLabel={t('tasks.sortBy', 'Sort by')}
|
dropdownLabel={t('tasks.sortBy', 'Sort by')}
|
||||||
extraContent={
|
footerContent={
|
||||||
<button
|
<div className="space-y-3">
|
||||||
type="button"
|
<div>
|
||||||
onClick={() => setShowCompleted((v) => !v)}
|
<div className="px-3 py-2 text-xs font-bold text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700">
|
||||||
className="w-full flex items-center justify-between text-sm text-gray-700 dark:text-gray-300"
|
{t('tasks.groupBy', 'Group by')}
|
||||||
aria-pressed={showCompleted}
|
</div>
|
||||||
aria-label={
|
<div className="py-1">
|
||||||
showCompleted
|
{['none', 'project'].map((val) => (
|
||||||
? t(
|
<button
|
||||||
'tasks.hideCompleted',
|
key={val}
|
||||||
'Hide completed tasks'
|
onClick={() =>
|
||||||
)
|
handleGroupByChange(
|
||||||
: t(
|
val as
|
||||||
'tasks.showCompleted',
|
| 'none'
|
||||||
'Show completed tasks'
|
| 'project'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
title={
|
className={`w-full text-left px-3 py-2 text-sm transition-colors flex items-center justify-between ${
|
||||||
showCompleted
|
groupBy === val
|
||||||
? t(
|
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400'
|
||||||
'tasks.hideCompleted',
|
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||||
'Hide completed tasks'
|
}`}
|
||||||
)
|
>
|
||||||
: t(
|
<span>
|
||||||
'tasks.showCompleted',
|
{val === 'project'
|
||||||
'Show completed tasks'
|
? t(
|
||||||
)
|
'tasks.groupByProject',
|
||||||
}
|
'Project'
|
||||||
>
|
)
|
||||||
<span>
|
: t(
|
||||||
{t(
|
'tasks.grouping.none',
|
||||||
'tasks.showCompleted',
|
'None'
|
||||||
'Show completed'
|
)}
|
||||||
)}
|
</span>
|
||||||
</span>
|
{groupBy === val && (
|
||||||
<span
|
<CheckIcon className="h-4 w-4" />
|
||||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
)}
|
||||||
showCompleted
|
</button>
|
||||||
? 'bg-blue-600'
|
))}
|
||||||
: 'bg-gray-200 dark:bg-gray-600'
|
</div>
|
||||||
}`}
|
</div>
|
||||||
>
|
<div>
|
||||||
<span
|
<div className="px-3 py-2 text-xs font-bold text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-900/50 border-t border-b border-gray-200 dark:border-gray-700">
|
||||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
{t('tasks.show', 'Show')}
|
||||||
showCompleted
|
</div>
|
||||||
? 'translate-x-4'
|
<div className="py-1 space-y-1">
|
||||||
: 'translate-x-0.5'
|
{[
|
||||||
}`}
|
{
|
||||||
/>
|
key: 'active',
|
||||||
</span>
|
label: t(
|
||||||
</button>
|
'tasks.open',
|
||||||
|
'Open'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'all',
|
||||||
|
label: t(
|
||||||
|
'tasks.all',
|
||||||
|
'All'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'completed',
|
||||||
|
label: t(
|
||||||
|
'tasks.completed',
|
||||||
|
'Completed'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
].map((opt) => {
|
||||||
|
const isActive =
|
||||||
|
taskStatusFilter ===
|
||||||
|
opt.key;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
handleStatusChange(
|
||||||
|
opt.key as
|
||||||
|
| 'all'
|
||||||
|
| 'active'
|
||||||
|
| 'completed'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className={`w-full text-left px-3 py-2 text-sm transition-colors flex items-center justify-between ${
|
||||||
|
isActive
|
||||||
|
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400'
|
||||||
|
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{opt.label}</span>
|
||||||
|
{isActive && (
|
||||||
|
<CheckIcon className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="px-3 py-2 text-xs font-bold text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-900/50 border-t border-b border-gray-200 dark:border-gray-700">
|
||||||
|
{t('tasks.direction', 'Direction')}
|
||||||
|
</div>
|
||||||
|
<div className="py-1">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
key: 'asc',
|
||||||
|
label: t(
|
||||||
|
'tasks.ascending',
|
||||||
|
'Ascending'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'desc',
|
||||||
|
label: t(
|
||||||
|
'tasks.descending',
|
||||||
|
'Descending'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
].map((dir) => {
|
||||||
|
const currentDirection =
|
||||||
|
orderBy.split(':')[1] ||
|
||||||
|
'asc';
|
||||||
|
const isActive =
|
||||||
|
currentDirection ===
|
||||||
|
dir.key;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={dir.key}
|
||||||
|
onClick={() => {
|
||||||
|
const [field] =
|
||||||
|
orderBy.split(
|
||||||
|
':'
|
||||||
|
);
|
||||||
|
handleSortChange(
|
||||||
|
`${field}:${dir.key}`
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className={`w-full text-left px-3 py-2 text-sm transition-colors flex items-center justify-between ${
|
||||||
|
isActive
|
||||||
|
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400'
|
||||||
|
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{dir.label}</span>
|
||||||
|
{isActive && (
|
||||||
|
<CheckIcon className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{displayTasks.length > 0 ? (
|
{displayTasks.length > 0 ? (
|
||||||
<TaskList
|
groupBy === 'project' ? (
|
||||||
tasks={displayTasks}
|
<GroupedTaskList
|
||||||
onTaskUpdate={handleTaskUpdate}
|
tasks={displayTasks}
|
||||||
onTaskDelete={handleTaskDelete}
|
groupBy="project"
|
||||||
projects={[]} // Empty since we're viewing by tag
|
onTaskUpdate={handleTaskUpdate}
|
||||||
hideProjectName={false}
|
onTaskCompletionToggle={
|
||||||
onToggleToday={handleToggleToday}
|
handleTaskCompletionToggle
|
||||||
showCompletedTasks={showCompleted}
|
}
|
||||||
/>
|
onTaskDelete={handleTaskDelete}
|
||||||
|
projects={projectLookupList}
|
||||||
|
hideProjectName={false}
|
||||||
|
onToggleToday={handleToggleToday}
|
||||||
|
showCompletedTasks={showCompletedTasks}
|
||||||
|
searchQuery={taskSearchQuery}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TaskList
|
||||||
|
tasks={displayTasks}
|
||||||
|
onTaskUpdate={handleTaskUpdate}
|
||||||
|
onTaskCompletionToggle={
|
||||||
|
handleTaskCompletionToggle
|
||||||
|
}
|
||||||
|
onTaskDelete={handleTaskDelete}
|
||||||
|
projects={projectLookupList}
|
||||||
|
hideProjectName={false}
|
||||||
|
onToggleToday={handleToggleToday}
|
||||||
|
showCompletedTasks={showCompletedTasks}
|
||||||
|
/>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{t('tasks.noTasksAvailable', 'No tasks available.')}
|
{t('tasks.noTasksAvailable', 'No tasks available.')}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue