Fix bug 675 (#689)
* Add group by filter and URL encode * fixup! Add group by filter and URL encode
This commit is contained in:
parent
b3612dc0f2
commit
442ace69bb
2 changed files with 384 additions and 59 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue