Fix bug 675 (#689)

* Add group by filter and URL encode

* fixup! Add group by filter and URL encode
This commit is contained in:
Chris 2025-12-09 12:11:21 +02:00 committed by GitHub
parent b3612dc0f2
commit 442ace69bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 384 additions and 59 deletions

View file

@ -30,6 +30,14 @@ interface TaskGroup {
instances: Task[];
}
interface ProjectGroup {
key: string;
projectId?: number;
projectUid?: string;
tasks: Task[];
order: number;
}
const GroupedTaskList: React.FC<GroupedTaskListProps> = ({
tasks,
groupedTasks,
@ -62,15 +70,7 @@ const GroupedTaskList: React.FC<GroupedTaskListProps> = ({
// Filter tasks based on completion status
const filteredTasks = showCompletedTasks
? tasks.filter((task) => {
// Show only completed tasks (done=2 or archived=3)
const isCompleted =
task.status === 'done' ||
task.status === 'archived' ||
task.status === 2 ||
task.status === 3;
return isCompleted;
})
? tasks
: tasks.filter((task) => {
// Show only non-completed tasks
const isCompleted =
@ -144,15 +144,7 @@ const GroupedTaskList: React.FC<GroupedTaskListProps> = ({
Object.entries(groupedTasks).forEach(([groupName, groupTasks]) => {
// Filter by completion status
let filteredTasks = showCompletedTasks
? groupTasks.filter((task) => {
// Show only completed tasks
const isCompleted =
task.status === 'done' ||
task.status === 'archived' ||
task.status === 2 ||
task.status === 3;
return isCompleted;
})
? groupTasks
: groupTasks.filter((task) => {
// Show only non-completed tasks
const isCompleted =
@ -184,16 +176,20 @@ const GroupedTaskList: React.FC<GroupedTaskListProps> = ({
const groupedByProject = useMemo(() => {
if (groupBy !== 'project') return null;
const normalizeProjectId = (
value: number | string | null | undefined
): number | undefined => {
if (typeof value === 'number') return value;
if (typeof value === 'string') {
const parsed = Number(value);
return Number.isNaN(parsed) ? undefined : parsed;
}
return undefined;
};
// Apply completion filter
const filtered = showCompletedTasks
? tasks.filter((task) => {
const isCompleted =
task.status === 'done' ||
task.status === 'archived' ||
task.status === 2 ||
task.status === 3;
return isCompleted;
})
? tasks
: tasks.filter((task) => {
const isCompleted =
task.status === 'done' ||
@ -212,19 +208,53 @@ const GroupedTaskList: React.FC<GroupedTaskListProps> = ({
)
: filtered;
const byProject = new Map<string | number, Task[]>();
const getGroupKey = (
projectId?: number,
projectUid?: string
): string => {
if (projectId !== undefined && projectId !== null) {
return `id-${projectId}`;
}
if (projectUid) {
return `uid-${projectUid}`;
}
return 'no_project';
};
const byProject = new Map<string, ProjectGroup>();
filteredBySearch.forEach((task) => {
const key = task.project_id || 'no_project';
const arr = byProject.get(key) || [];
arr.push(task);
byProject.set(key, arr);
const resolvedProjectId =
normalizeProjectId(task.project_id) ??
normalizeProjectId(task.Project?.id);
const resolvedProjectUid =
task.project_uid || task.Project?.uid || undefined;
const key = getGroupKey(resolvedProjectId, resolvedProjectUid);
if (!byProject.has(key)) {
byProject.set(key, {
key,
projectId: resolvedProjectId,
projectUid: resolvedProjectUid,
tasks: [],
order: byProject.size,
});
}
byProject.get(key)!.tasks.push(task);
});
return Array.from(byProject.entries()).map(
([projectId, projectTasks]) => ({
projectId,
tasks: projectTasks,
})
);
const groups = Array.from(byProject.values());
groups.sort((a, b) => {
if (a.key === 'no_project' && b.key !== 'no_project') {
return -1;
}
if (b.key === 'no_project' && a.key !== 'no_project') {
return 1;
}
return a.order - b.order;
});
return groups;
}, [groupBy, tasks, showCompletedTasks, searchQuery]);
const toggleRecurringGroup = (templateId: number) => {
@ -363,10 +393,28 @@ const GroupedTaskList: React.FC<GroupedTaskListProps> = ({
{/* Standalone tasks */}
{groupBy === 'project' && groupedByProject
? groupedByProject.map(
({ projectId, tasks: projectTasks }, index) => {
(
{ key, projectId, projectUid, tasks: projectTasks },
index
) => {
const matchingProject = projects.find((p) => {
if (
projectId !== undefined &&
projectId !== null &&
p.id === projectId
) {
return true;
}
if (projectUid && p.uid === projectUid) {
return true;
}
return false;
});
const projectName =
projects.find((p) => p.id === projectId)?.name ||
(projectId === 'no_project'
matchingProject?.name ||
projectTasks[0]?.Project?.name ||
(key === 'no_project'
? t('tasks.noProject', 'No project')
: t(
'tasks.unknownProject',
@ -374,7 +422,7 @@ const GroupedTaskList: React.FC<GroupedTaskListProps> = ({
));
return (
<div
key={String(projectId)}
key={key}
className={`space-y-1.5 pb-4 mb-2 border-b border-gray-200/50 dark:border-gray-800/60 last:border-b-0 ${index > 0 ? 'pt-4' : ''}`}
>
<div className="flex items-center justify-between px-1 text-base font-semibold text-gray-900 dark:text-gray-100">

View file

@ -1,5 +1,11 @@
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import React, {
useState,
useEffect,
useRef,
useMemo,
useCallback,
} from 'react';
import { useParams, useNavigate, Link, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
TrashIcon,
@ -16,12 +22,14 @@ import { Task } from '../entities/Task';
import { Note } from '../entities/Note';
import { Project } from '../entities/Project';
import TaskList from './Task/TaskList';
import GroupedTaskList from './Task/GroupedTaskList';
import ProjectItem from './Project/ProjectItem';
import ConfirmDialog from './Shared/ConfirmDialog';
import { searchUniversal } from '../utils/searchService';
import { getApiPath } from '../config/paths';
import { SortOption } from './Shared/SortFilterButton';
import IconSortDropdown from './Shared/IconSortDropdown';
import { useStore } from '../store/useStore';
interface View {
id: number;
@ -41,10 +49,42 @@ const ViewDetail: React.FC = () => {
const { t } = useTranslation();
const { uid } = useParams<{ uid: string }>();
const navigate = useNavigate();
const location = useLocation();
const [view, setView] = useState<View | null>(null);
const [tasks, setTasks] = useState<Task[]>([]);
const [notes, setNotes] = useState<Note[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const globalProjects = useStore((state) => state.projectsStore.projects);
const updateQueryParams = useCallback(
(updates: Record<string, string | null>) => {
const params = new URLSearchParams(location.search);
let shouldNavigate = false;
Object.entries(updates).forEach(([key, value]) => {
if (value === null || value === '') {
if (params.has(key)) {
params.delete(key);
shouldNavigate = true;
}
} else if (params.get(key) !== value) {
params.set(key, value);
shouldNavigate = true;
}
});
if (shouldNavigate) {
const searchString = params.toString();
navigate(
{
pathname: location.pathname,
search: searchString ? `?${searchString}` : '',
},
{ replace: true }
);
}
},
[location.pathname, location.search, navigate]
);
const [isLoading, setIsLoading] = useState(true);
const [isEditingName, setIsEditingName] = useState(false);
const [editedName, setEditedName] = useState('');
@ -58,6 +98,7 @@ const ViewDetail: React.FC = () => {
'all' | 'active' | 'completed'
>('active');
const [orderBy, setOrderBy] = useState<string>('created_at:desc');
const [groupBy, setGroupBy] = useState<'none' | 'project'>('none');
// Pagination state
const [offset, setOffset] = useState(0);
@ -84,6 +125,50 @@ const ViewDetail: React.FC = () => {
{ value: 'created_at:desc', label: t('sort.created_at', 'Created At') },
];
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);
}
};
globalProjects.forEach(addProject);
projects.forEach(addProject);
return Array.from(map.values());
}, [globalProjects, projects]);
const projectLookupMap = useMemo(() => {
const byId = new Map<number, Project>();
const byUid = new Map<string, Project>();
projectLookupList.forEach((project) => {
if (project.id !== undefined && project.id !== null) {
const numericId =
typeof project.id === 'string'
? Number(project.id)
: project.id;
if (!Number.isNaN(numericId)) {
byId.set(numericId, project);
}
}
if (project.uid) {
byUid.set(project.uid, project);
}
});
return { byId, byUid };
}, [projectLookupList]);
// Filter and sort tasks
const displayTasks = useMemo(() => {
let filteredTasks: Task[];
@ -171,13 +256,147 @@ const ViewDetail: React.FC = () => {
return 0;
});
return sortedTasks;
}, [tasks, taskStatusFilter, taskSearchQuery, orderBy, t]);
return sortedTasks.map((task) => {
if (task.Project) {
return task;
}
let matchedProject: Project | undefined;
if (
task.project_id !== undefined &&
task.project_id !== null &&
typeof task.project_id !== 'string'
) {
matchedProject = projectLookupMap.byId.get(task.project_id);
}
if (!matchedProject && task.project_uid) {
matchedProject = projectLookupMap.byUid.get(task.project_uid);
}
if (!matchedProject && typeof task.project_id === 'string') {
const numericId = Number(task.project_id);
if (!Number.isNaN(numericId)) {
matchedProject = projectLookupMap.byId.get(numericId);
}
}
return matchedProject
? {
...task,
Project: matchedProject,
}
: task;
});
}, [
tasks,
taskStatusFilter,
taskSearchQuery,
orderBy,
t,
projectLookupMap,
]);
const handleSortChange = useCallback(
(newOrderBy: string, options?: { skipUrlUpdate?: boolean }) => {
setOrderBy(newOrderBy);
if (!options?.skipUrlUpdate) {
updateQueryParams({
order_by:
newOrderBy === 'created_at:desc' ? null : newOrderBy,
});
}
},
[updateQueryParams]
);
const handleStatusChange = useCallback(
(
status: 'all' | 'active' | 'completed',
options?: { skipUrlUpdate?: boolean }
) => {
setTaskStatusFilter(status);
if (!options?.skipUrlUpdate) {
updateQueryParams({
status: status === 'active' ? null : status,
});
}
},
[updateQueryParams]
);
const handleGroupByChange = useCallback(
(value: 'none' | 'project', options?: { skipUrlUpdate?: boolean }) => {
setGroupBy(value);
if (uid) {
localStorage.setItem(`view_${uid}_group_by`, value);
}
if (!options?.skipUrlUpdate) {
updateQueryParams({
group_by: value === 'none' ? null : value,
});
}
},
[uid, updateQueryParams]
);
const handleSearchChange = useCallback(
(value: string, options?: { skipUrlUpdate?: boolean }) => {
setTaskSearchQuery(value);
if (!options?.skipUrlUpdate) {
updateQueryParams({
search: value.trim() ? value : null,
});
}
},
[updateQueryParams]
);
const showCompletedTasks = taskStatusFilter !== 'active';
useEffect(() => {
fetchViewAndResults();
}, [uid]);
useEffect(() => {
const params = new URLSearchParams(location.search);
const urlOrderBy = params.get('order_by') || 'created_at:desc';
handleSortChange(urlOrderBy, { skipUrlUpdate: true });
const urlStatus = params.get('status');
const normalizedStatus =
urlStatus === 'completed'
? 'completed'
: urlStatus === 'all'
? 'all'
: 'active';
handleStatusChange(normalizedStatus, { skipUrlUpdate: true });
const urlGroup =
params.get('group_by') === 'project' ? 'project' : 'none';
setGroupBy((prev) => {
if (prev !== urlGroup) {
if (uid) {
localStorage.setItem(`view_${uid}_group_by`, urlGroup);
}
return urlGroup;
}
return prev;
});
const urlSearch = params.get('search') || '';
if (urlSearch) {
setIsSearchExpanded(true);
}
handleSearchChange(urlSearch, { skipUrlUpdate: true });
}, [
location.search,
uid,
handleSortChange,
handleStatusChange,
handleSearchChange,
]);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@ -552,12 +771,51 @@ const ViewDetail: React.FC = () => {
<IconSortDropdown
options={sortOptions}
value={orderBy}
onChange={setOrderBy}
onChange={handleSortChange}
ariaLabel={t('views.sortTasks', 'Sort tasks')}
title={t('views.sortTasks', 'Sort tasks')}
dropdownLabel={t('tasks.sortBy', 'Sort by')}
footerContent={
<div className="space-y-3">
<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-b border-gray-200 dark:border-gray-700">
{t('tasks.groupBy', 'Group by')}
</div>
<div className="py-1">
{['none', 'project'].map((val) => (
<button
key={val}
onClick={() =>
handleGroupByChange(
val as
| 'none'
| 'project'
)
}
className={`w-full text-left px-3 py-2 text-sm transition-colors flex items-center justify-between ${
groupBy === val
? '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>
{val === 'project'
? t(
'tasks.groupByProject',
'Project'
)
: t(
'tasks.grouping.none',
'None'
)}
</span>
{groupBy === val && (
<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.show', 'Show')}
@ -594,7 +852,7 @@ const ViewDetail: React.FC = () => {
key={opt.key}
type="button"
onClick={() =>
setTaskStatusFilter(
handleStatusChange(
opt.key as
| 'all'
| 'active'
@ -651,7 +909,7 @@ const ViewDetail: React.FC = () => {
orderBy.split(
':'
);
setOrderBy(
handleSortChange(
`${field}:${dir.key}`
);
}}
@ -878,7 +1136,7 @@ const ViewDetail: React.FC = () => {
'Search tasks...'
)}
value={taskSearchQuery}
onChange={(e) => setTaskSearchQuery(e.target.value)}
onChange={(e) => handleSearchChange(e.target.value)}
className="w-full bg-transparent border-none focus:ring-0 focus:outline-none dark:text-white"
/>
</div>
@ -890,16 +1148,35 @@ const ViewDetail: React.FC = () => {
<h3 className="text-lg font-light text-gray-900 dark:text-white mb-4">
{t('tasks.title')} ({displayTasks.length})
</h3>
<TaskList
tasks={displayTasks}
onTaskUpdate={handleTaskUpdate}
onTaskCompletionToggle={handleTaskCompletionToggle}
onTaskDelete={handleTaskDelete}
projects={projects}
hideProjectName={false}
onToggleToday={handleToggleToday}
showCompletedTasks={taskStatusFilter !== 'active'}
/>
{groupBy === 'project' ? (
<GroupedTaskList
tasks={displayTasks}
groupBy="project"
onTaskUpdate={handleTaskUpdate}
onTaskCompletionToggle={
handleTaskCompletionToggle
}
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}
/>
)}
{/* Load more button */}
{hasMore && (
<div className="flex justify-center pt-4">