feat(sidebar): improve project task grouping
This commit is contained in:
parent
cca644a1d2
commit
416c4acf04
4 changed files with 539 additions and 27 deletions
|
|
@ -33,6 +33,14 @@ import { AnimatedHeightReveal } from '../team/activity/AnimatedHeightReveal';
|
||||||
import { type ComboboxOption } from '../ui/combobox';
|
import { type ComboboxOption } from '../ui/combobox';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||||
|
|
||||||
|
import {
|
||||||
|
canProjectGroupShowLess,
|
||||||
|
canProjectGroupShowMore,
|
||||||
|
getNextProjectGroupVisibleCount,
|
||||||
|
getPreviousProjectGroupVisibleCount,
|
||||||
|
getProjectGroupVisibleCount,
|
||||||
|
syncProjectGroupVisibleCountByKey,
|
||||||
|
} from './projectGroupPagination';
|
||||||
import { SidebarTaskItem } from './SidebarTaskItem';
|
import { SidebarTaskItem } from './SidebarTaskItem';
|
||||||
import { TaskContextMenu } from './TaskContextMenu';
|
import { TaskContextMenu } from './TaskContextMenu';
|
||||||
import { TaskFiltersPopover } from './TaskFiltersPopover';
|
import { TaskFiltersPopover } from './TaskFiltersPopover';
|
||||||
|
|
@ -207,6 +215,9 @@ export const GlobalTaskList = ({
|
||||||
const [sortPopoverOpen, setSortPopoverOpen] = useState(false);
|
const [sortPopoverOpen, setSortPopoverOpen] = useState(false);
|
||||||
const [showArchived, setShowArchived] = useState(false);
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
const [renamingTaskKey, setRenamingTaskKey] = useState<string | null>(null);
|
const [renamingTaskKey, setRenamingTaskKey] = useState<string | null>(null);
|
||||||
|
const [projectRequestedVisibleCountByKey, setProjectRequestedVisibleCountByKey] = useState<
|
||||||
|
Record<string, number>
|
||||||
|
>({});
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
const hasFetchedRef = useRef(false);
|
const hasFetchedRef = useRef(false);
|
||||||
const readState = useReadStateSnapshot();
|
const readState = useReadStateSnapshot();
|
||||||
|
|
@ -221,21 +232,23 @@ export const GlobalTaskList = ({
|
||||||
return new Set<string>();
|
return new Set<string>();
|
||||||
}
|
}
|
||||||
|
|
||||||
// First load: seed all known IDs, no animations
|
// eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true.
|
||||||
if (isInitialTaskLoadRef.current) {
|
if (isInitialTaskLoadRef.current) {
|
||||||
isInitialTaskLoadRef.current = false;
|
isInitialTaskLoadRef.current = false;
|
||||||
for (const t of globalTasks) {
|
for (const t of globalTasks) {
|
||||||
|
// eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true.
|
||||||
knownTaskIdsRef.current.add(`${t.teamName}:${t.id}`);
|
knownTaskIdsRef.current.add(`${t.teamName}:${t.id}`);
|
||||||
}
|
}
|
||||||
return new Set<string>();
|
return new Set<string>();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subsequent updates: detect truly new tasks
|
|
||||||
const newIds = new Set<string>();
|
const newIds = new Set<string>();
|
||||||
for (const t of globalTasks) {
|
for (const t of globalTasks) {
|
||||||
const key = `${t.teamName}:${t.id}`;
|
const key = `${t.teamName}:${t.id}`;
|
||||||
|
// eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true.
|
||||||
if (!knownTaskIdsRef.current.has(key)) {
|
if (!knownTaskIdsRef.current.has(key)) {
|
||||||
newIds.add(key);
|
newIds.add(key);
|
||||||
|
// eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true.
|
||||||
knownTaskIdsRef.current.add(key);
|
knownTaskIdsRef.current.add(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -326,6 +339,11 @@ export const GlobalTaskList = ({
|
||||||
|
|
||||||
// Resolve project filter from filters state
|
// Resolve project filter from filters state
|
||||||
const selectedProjectPath = filters.projectPath;
|
const selectedProjectPath = filters.projectPath;
|
||||||
|
const hasArchivedTasks = useMemo(
|
||||||
|
() => globalTasks.some((t) => taskLocalState.isArchived(t.teamName, t.id)),
|
||||||
|
[globalTasks, taskLocalState]
|
||||||
|
);
|
||||||
|
const effectiveShowArchived = showArchived && hasArchivedTasks;
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
let result = globalTasks;
|
let result = globalTasks;
|
||||||
|
|
@ -345,7 +363,7 @@ export const GlobalTaskList = ({
|
||||||
}
|
}
|
||||||
result = applySearch(result, searchQuery);
|
result = applySearch(result, searchQuery);
|
||||||
// Archive filtering
|
// Archive filtering
|
||||||
if (showArchived) {
|
if (effectiveShowArchived) {
|
||||||
result = result.filter((t) => taskLocalState.isArchived(t.teamName, t.id));
|
result = result.filter((t) => taskLocalState.isArchived(t.teamName, t.id));
|
||||||
} else {
|
} else {
|
||||||
result = result.filter((t) => !taskLocalState.isArchived(t.teamName, t.id));
|
result = result.filter((t) => !taskLocalState.isArchived(t.teamName, t.id));
|
||||||
|
|
@ -353,29 +371,16 @@ export const GlobalTaskList = ({
|
||||||
return result;
|
return result;
|
||||||
}, [
|
}, [
|
||||||
globalTasks,
|
globalTasks,
|
||||||
filters.projectPath,
|
selectedProjectPath,
|
||||||
filters.statusIds,
|
filters.statusIds,
|
||||||
filters.teamName,
|
filters.teamName,
|
||||||
filters.readFilter,
|
filters.readFilter,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
readState,
|
readState,
|
||||||
showArchived,
|
effectiveShowArchived,
|
||||||
taskLocalState,
|
taskLocalState,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Check if any archived tasks exist (before archive filtering) to conditionally show the toggle
|
|
||||||
const hasArchivedTasks = useMemo(
|
|
||||||
() => globalTasks.some((t) => taskLocalState.isArchived(t.teamName, t.id)),
|
|
||||||
[globalTasks, taskLocalState]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Reset showArchived when archive becomes empty
|
|
||||||
useEffect(() => {
|
|
||||||
if (showArchived && !hasArchivedTasks) {
|
|
||||||
setShowArchived(false);
|
|
||||||
}
|
|
||||||
}, [showArchived, hasArchivedTasks]);
|
|
||||||
|
|
||||||
// Split into pinned and normal (non-pinned) tasks
|
// Split into pinned and normal (non-pinned) tasks
|
||||||
const pinnedTasks = useMemo(
|
const pinnedTasks = useMemo(
|
||||||
() => filtered.filter((t) => taskLocalState.isPinned(t.teamName, t.id)),
|
() => filtered.filter((t) => taskLocalState.isPinned(t.teamName, t.id)),
|
||||||
|
|
@ -400,6 +405,19 @@ export const GlobalTaskList = ({
|
||||||
[projectGroups]
|
[projectGroups]
|
||||||
);
|
);
|
||||||
const timeGroupKeys = useMemo(() => categories.map((c) => c), [categories]);
|
const timeGroupKeys = useMemo(() => categories.map((c) => c), [categories]);
|
||||||
|
const projectGroupVisibility = useMemo(
|
||||||
|
() =>
|
||||||
|
projectGroups.map((group) => ({
|
||||||
|
projectKey: group.projectKey,
|
||||||
|
taskCount: group.tasks.length,
|
||||||
|
})),
|
||||||
|
[projectGroups]
|
||||||
|
);
|
||||||
|
const projectVisibleCountByKey = useMemo(
|
||||||
|
() =>
|
||||||
|
syncProjectGroupVisibleCountByKey(projectRequestedVisibleCountByKey, projectGroupVisibility),
|
||||||
|
[projectRequestedVisibleCountByKey, projectGroupVisibility]
|
||||||
|
);
|
||||||
|
|
||||||
const projectCollapsed = useCollapsedGroups('project', projectGroupKeys);
|
const projectCollapsed = useCollapsedGroups('project', projectGroupKeys);
|
||||||
const timeCollapsed = useCollapsedGroups('time', timeGroupKeys);
|
const timeCollapsed = useCollapsedGroups('time', timeGroupKeys);
|
||||||
|
|
@ -499,7 +517,7 @@ export const GlobalTaskList = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pinned tasks section */}
|
{/* Pinned tasks section */}
|
||||||
{pinnedTasks.length > 0 && !showArchived && (
|
{pinnedTasks.length > 0 && !effectiveShowArchived && (
|
||||||
<div className="shrink-0 border-b" style={{ borderColor: 'var(--color-border)' }}>
|
<div className="shrink-0 border-b" style={{ borderColor: 'var(--color-border)' }}>
|
||||||
<div className="flex items-center gap-1 px-2 py-1">
|
<div className="flex items-center gap-1 px-2 py-1">
|
||||||
<Pin className="size-3 text-text-muted" />
|
<Pin className="size-3 text-text-muted" />
|
||||||
|
|
@ -562,7 +580,7 @@ export const GlobalTaskList = ({
|
||||||
onClick={() => setShowArchived(!showArchived)}
|
onClick={() => setShowArchived(!showArchived)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded p-0.5 transition-colors',
|
'rounded p-0.5 transition-colors',
|
||||||
showArchived
|
effectiveShowArchived
|
||||||
? 'bg-surface-raised text-text-secondary'
|
? 'bg-surface-raised text-text-secondary'
|
||||||
: 'text-text-muted hover:text-text-secondary'
|
: 'text-text-muted hover:text-text-secondary'
|
||||||
)}
|
)}
|
||||||
|
|
@ -571,7 +589,7 @@ export const GlobalTaskList = ({
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">
|
<TooltipContent side="top">
|
||||||
{showArchived ? 'Hide archived' : 'Show archived'}
|
{effectiveShowArchived ? 'Hide archived' : 'Show archived'}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -627,14 +645,25 @@ export const GlobalTaskList = ({
|
||||||
if (group.tasks.length === 0) return null;
|
if (group.tasks.length === 0) return null;
|
||||||
const isGroupCollapsed = projectCollapsed.isCollapsed(group.projectKey);
|
const isGroupCollapsed = projectCollapsed.isCollapsed(group.projectKey);
|
||||||
const groupColor = projectColor(group.projectLabel);
|
const groupColor = projectColor(group.projectLabel);
|
||||||
|
const visibleCount = getProjectGroupVisibleCount(
|
||||||
|
projectVisibleCountByKey[group.projectKey],
|
||||||
|
group.tasks.length
|
||||||
|
);
|
||||||
|
const visibleTasks = group.tasks.slice(0, visibleCount);
|
||||||
|
const showMoreVisible = canProjectGroupShowMore(visibleCount, group.tasks.length);
|
||||||
|
const showLessVisible = canProjectGroupShowLess(visibleCount, group.tasks.length);
|
||||||
let lastTeam: string | null = null;
|
let lastTeam: string | null = null;
|
||||||
return (
|
return (
|
||||||
<div key={group.projectKey}>
|
<div key={group.projectKey}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => projectCollapsed.toggle(group.projectKey)}
|
onClick={() => projectCollapsed.toggle(group.projectKey)}
|
||||||
className="hover:bg-surface-raised/40 sticky top-0 z-10 flex w-full cursor-pointer items-center gap-1 px-2 py-1.5 text-[11px] font-semibold transition-colors"
|
className="hover:bg-surface-raised/40 sticky top-0 z-10 flex w-full cursor-pointer items-center gap-1.5 p-2 transition-colors"
|
||||||
style={{ backgroundColor: 'var(--color-surface-sidebar)' }}
|
style={{
|
||||||
|
backgroundColor: 'var(--color-surface-sidebar)',
|
||||||
|
backgroundImage: `linear-gradient(90deg, ${groupColor.glow} 0%, transparent 80%)`,
|
||||||
|
boxShadow: `inset 2px 0 0 ${groupColor.border}, inset 0 -1px 0 var(--color-border)`,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{isGroupCollapsed ? (
|
{isGroupCollapsed ? (
|
||||||
<ChevronRight className="size-3 shrink-0 text-text-muted" />
|
<ChevronRight className="size-3 shrink-0 text-text-muted" />
|
||||||
|
|
@ -642,11 +671,14 @@ export const GlobalTaskList = ({
|
||||||
<ChevronDown className="size-3 shrink-0 text-text-muted" />
|
<ChevronDown className="size-3 shrink-0 text-text-muted" />
|
||||||
)}
|
)}
|
||||||
<Folder
|
<Folder
|
||||||
className="size-3 shrink-0"
|
className="size-3.5 shrink-0"
|
||||||
style={{ color: groupColor.border }}
|
style={{ color: groupColor.icon }}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<span className="truncate" style={{ color: groupColor.text }}>
|
<span
|
||||||
|
className="truncate text-[13px] font-bold leading-none"
|
||||||
|
style={{ color: groupColor.icon }}
|
||||||
|
>
|
||||||
{group.projectLabel}
|
{group.projectLabel}
|
||||||
</span>
|
</span>
|
||||||
<span className="ml-auto shrink-0 text-[10px] font-normal text-text-muted">
|
<span className="ml-auto shrink-0 text-[10px] font-normal text-text-muted">
|
||||||
|
|
@ -654,7 +686,7 @@ export const GlobalTaskList = ({
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{!isGroupCollapsed &&
|
{!isGroupCollapsed &&
|
||||||
group.tasks.map((task) => {
|
visibleTasks.map((task) => {
|
||||||
const showTeamHeader = task.teamName !== lastTeam;
|
const showTeamHeader = task.teamName !== lastTeam;
|
||||||
lastTeam = task.teamName;
|
lastTeam = task.teamName;
|
||||||
return (
|
return (
|
||||||
|
|
@ -691,6 +723,44 @@ export const GlobalTaskList = ({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{!isGroupCollapsed && (showMoreVisible || showLessVisible) && (
|
||||||
|
<div className="flex items-center gap-2 px-3 pb-2 pt-1">
|
||||||
|
{showMoreVisible && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-[11px] font-medium text-text-muted transition-colors hover:text-text"
|
||||||
|
onClick={() =>
|
||||||
|
setProjectRequestedVisibleCountByKey((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[group.projectKey]: getNextProjectGroupVisibleCount(
|
||||||
|
projectVisibleCountByKey[group.projectKey],
|
||||||
|
group.tasks.length
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Show more
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{showLessVisible && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-[11px] font-medium text-text-muted transition-colors hover:text-text"
|
||||||
|
onClick={() =>
|
||||||
|
setProjectRequestedVisibleCountByKey((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[group.projectKey]: getPreviousProjectGroupVisibleCount(
|
||||||
|
projectVisibleCountByKey[group.projectKey],
|
||||||
|
group.tasks.length
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Show less
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
89
src/renderer/components/sidebar/projectGroupPagination.ts
Normal file
89
src/renderer/components/sidebar/projectGroupPagination.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
export const PROJECT_GROUP_PAGE_SIZE = 5;
|
||||||
|
|
||||||
|
export interface ProjectGroupVisibilityDescriptor {
|
||||||
|
projectKey: string;
|
||||||
|
taskCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProjectGroupVisibleCount(
|
||||||
|
visibleCount: number | undefined,
|
||||||
|
taskCount: number
|
||||||
|
): number {
|
||||||
|
if (taskCount <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minimumVisibleCount = Math.min(PROJECT_GROUP_PAGE_SIZE, taskCount);
|
||||||
|
if (visibleCount == null || !Number.isFinite(visibleCount)) {
|
||||||
|
return minimumVisibleCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedVisibleCount = Math.floor(visibleCount);
|
||||||
|
return Math.min(taskCount, Math.max(minimumVisibleCount, normalizedVisibleCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNextProjectGroupVisibleCount(
|
||||||
|
visibleCount: number | undefined,
|
||||||
|
taskCount: number
|
||||||
|
): number {
|
||||||
|
const currentVisibleCount = getProjectGroupVisibleCount(visibleCount, taskCount);
|
||||||
|
if (currentVisibleCount >= taskCount) {
|
||||||
|
return currentVisibleCount;
|
||||||
|
}
|
||||||
|
return Math.min(taskCount, currentVisibleCount + PROJECT_GROUP_PAGE_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPreviousProjectGroupVisibleCount(
|
||||||
|
visibleCount: number | undefined,
|
||||||
|
taskCount: number
|
||||||
|
): number {
|
||||||
|
const currentVisibleCount = getProjectGroupVisibleCount(visibleCount, taskCount);
|
||||||
|
const minimumVisibleCount = Math.min(PROJECT_GROUP_PAGE_SIZE, taskCount);
|
||||||
|
return Math.max(minimumVisibleCount, currentVisibleCount - PROJECT_GROUP_PAGE_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canProjectGroupShowMore(
|
||||||
|
visibleCount: number | undefined,
|
||||||
|
taskCount: number
|
||||||
|
): boolean {
|
||||||
|
return getProjectGroupVisibleCount(visibleCount, taskCount) < taskCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canProjectGroupShowLess(
|
||||||
|
visibleCount: number | undefined,
|
||||||
|
taskCount: number
|
||||||
|
): boolean {
|
||||||
|
if (taskCount <= PROJECT_GROUP_PAGE_SIZE) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return getProjectGroupVisibleCount(visibleCount, taskCount) > PROJECT_GROUP_PAGE_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncProjectGroupVisibleCountByKey(
|
||||||
|
previousVisibleCountByKey: Record<string, number>,
|
||||||
|
groups: readonly ProjectGroupVisibilityDescriptor[]
|
||||||
|
): Record<string, number> {
|
||||||
|
let changed = false;
|
||||||
|
const nextVisibleCountByKey: Record<string, number> = {};
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
const nextVisibleCount = getProjectGroupVisibleCount(
|
||||||
|
previousVisibleCountByKey[group.projectKey],
|
||||||
|
group.taskCount
|
||||||
|
);
|
||||||
|
|
||||||
|
if (nextVisibleCount > 0) {
|
||||||
|
nextVisibleCountByKey[group.projectKey] = nextVisibleCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previousVisibleCountByKey[group.projectKey] !== nextVisibleCount) {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(previousVisibleCountByKey).length !== Object.keys(nextVisibleCountByKey).length) {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed ? nextVisibleCountByKey : previousVisibleCountByKey;
|
||||||
|
}
|
||||||
278
test/renderer/components/sidebar/GlobalTaskList.test.ts
Normal file
278
test/renderer/components/sidebar/GlobalTaskList.test.ts
Normal file
|
|
@ -0,0 +1,278 @@
|
||||||
|
import React, { act } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { GlobalTask } from '../../../../src/shared/types';
|
||||||
|
|
||||||
|
interface StoreState {
|
||||||
|
globalTasks: GlobalTask[];
|
||||||
|
globalTasksLoading: boolean;
|
||||||
|
globalTasksInitialized: boolean;
|
||||||
|
fetchAllTasks: ReturnType<typeof vi.fn>;
|
||||||
|
softDeleteTask: ReturnType<typeof vi.fn>;
|
||||||
|
projects: { path: string; name: string; sessions: unknown[]; totalSessions?: number }[];
|
||||||
|
viewMode: 'flat' | 'grouped';
|
||||||
|
repositoryGroups: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
totalSessions: number;
|
||||||
|
worktrees: { path: string }[];
|
||||||
|
}[];
|
||||||
|
teams: { teamName: string; displayName: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const storeState = {} as StoreState;
|
||||||
|
const toggleCollapsedGroup = vi.fn();
|
||||||
|
const taskLocalState = {
|
||||||
|
isPinned: vi.fn(() => false),
|
||||||
|
isArchived: vi.fn(() => false),
|
||||||
|
getRenamedSubject: vi.fn(() => undefined),
|
||||||
|
togglePin: vi.fn(),
|
||||||
|
toggleArchive: vi.fn(),
|
||||||
|
renameTask: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('../../../../src/renderer/store', () => ({
|
||||||
|
useStore: (selector: (state: StoreState) => unknown) => selector(storeState),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('zustand/react/shallow', () => ({
|
||||||
|
useShallow: <T,>(selector: T) => selector,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../../src/renderer/components/common/ConfirmDialog', () => ({
|
||||||
|
confirm: vi.fn(() => Promise.resolve(true)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../../src/renderer/hooks/useCollapsedGroups', () => ({
|
||||||
|
useCollapsedGroups: () => ({
|
||||||
|
isCollapsed: () => false,
|
||||||
|
toggle: toggleCollapsedGroup,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../../src/renderer/hooks/useTaskLocalState', () => ({
|
||||||
|
useTaskLocalState: () => taskLocalState,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../../src/renderer/components/team/activity/AnimatedHeightReveal', () => ({
|
||||||
|
AnimatedHeightReveal: ({ children }: React.PropsWithChildren) =>
|
||||||
|
React.createElement(React.Fragment, null, children),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../../src/renderer/components/sidebar/TaskContextMenu', () => ({
|
||||||
|
TaskContextMenu: ({ children }: React.PropsWithChildren) =>
|
||||||
|
React.createElement(React.Fragment, null, children),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../../src/renderer/components/sidebar/SidebarTaskItem', () => ({
|
||||||
|
SidebarTaskItem: ({ task }: { task: GlobalTask }) =>
|
||||||
|
React.createElement('div', { 'data-testid': 'sidebar-task-item' }, task.subject),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../../src/renderer/components/sidebar/TaskFiltersPopover', () => ({
|
||||||
|
TaskFiltersPopover: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../../src/renderer/components/ui/popover', () => ({
|
||||||
|
Popover: ({ children }: React.PropsWithChildren) => React.createElement(React.Fragment, null, children),
|
||||||
|
PopoverTrigger: ({ children }: React.PropsWithChildren) =>
|
||||||
|
React.createElement(React.Fragment, null, children),
|
||||||
|
PopoverContent: ({ children }: React.PropsWithChildren) =>
|
||||||
|
React.createElement(React.Fragment, null, children),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../../src/renderer/components/ui/tooltip', () => ({
|
||||||
|
Tooltip: ({ children }: React.PropsWithChildren) => React.createElement(React.Fragment, null, children),
|
||||||
|
TooltipTrigger: ({ children }: React.PropsWithChildren) =>
|
||||||
|
React.createElement(React.Fragment, null, children),
|
||||||
|
TooltipContent: ({ children }: React.PropsWithChildren) =>
|
||||||
|
React.createElement(React.Fragment, null, children),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('lucide-react', () => {
|
||||||
|
const Icon = (props: React.SVGProps<SVGSVGElement>) => React.createElement('svg', props);
|
||||||
|
return {
|
||||||
|
Archive: Icon,
|
||||||
|
ArrowUpDown: Icon,
|
||||||
|
Check: Icon,
|
||||||
|
ChevronDown: Icon,
|
||||||
|
ChevronRight: Icon,
|
||||||
|
Folder: Icon,
|
||||||
|
ListTodo: Icon,
|
||||||
|
Pin: Icon,
|
||||||
|
Search: Icon,
|
||||||
|
X: Icon,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { GlobalTaskList } from '../../../../src/renderer/components/sidebar/GlobalTaskList';
|
||||||
|
|
||||||
|
function flushMicrotasks(): Promise<void> {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
function findButton(host: HTMLElement, label: string): HTMLButtonElement | null {
|
||||||
|
return Array.from(host.querySelectorAll('button')).find(
|
||||||
|
(button) => button.textContent?.trim() === label
|
||||||
|
) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleSubjects(host: HTMLElement): string[] {
|
||||||
|
return Array.from(host.querySelectorAll('[data-testid="sidebar-task-item"]')).map(
|
||||||
|
(node) => node.textContent ?? ''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTask(index: number, overrides: Partial<GlobalTask> = {}): GlobalTask {
|
||||||
|
const timestamp = String(60 - index).padStart(2, '0');
|
||||||
|
return {
|
||||||
|
id: `task-${index}`,
|
||||||
|
displayId: `task${index}`,
|
||||||
|
teamName: 'alpha-team',
|
||||||
|
teamDisplayName: 'Alpha Team',
|
||||||
|
subject: `Task ${index}`,
|
||||||
|
description: '',
|
||||||
|
status: 'in_progress',
|
||||||
|
owner: 'alice',
|
||||||
|
createdAt: `2026-04-18T10:${timestamp}:00.000Z`,
|
||||||
|
updatedAt: `2026-04-18T10:${timestamp}:00.000Z`,
|
||||||
|
reviewState: 'none',
|
||||||
|
reviewNotes: [],
|
||||||
|
blockedBy: [],
|
||||||
|
blocks: [],
|
||||||
|
comments: [],
|
||||||
|
attachments: [],
|
||||||
|
workIntervals: [],
|
||||||
|
kanbanColumnId: null,
|
||||||
|
projectPath: '/workspace/hookplex',
|
||||||
|
...overrides,
|
||||||
|
} as GlobalTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GlobalTaskList project grouping', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
storeState.globalTasks = [];
|
||||||
|
storeState.globalTasksLoading = false;
|
||||||
|
storeState.globalTasksInitialized = true;
|
||||||
|
storeState.fetchAllTasks = vi.fn(() => Promise.resolve(undefined));
|
||||||
|
storeState.softDeleteTask = vi.fn(() => Promise.resolve(undefined));
|
||||||
|
storeState.projects = [];
|
||||||
|
storeState.viewMode = 'flat';
|
||||||
|
storeState.repositoryGroups = [];
|
||||||
|
storeState.teams = [{ teamName: 'alpha-team', displayName: 'Alpha Team' }];
|
||||||
|
toggleCollapsedGroup.mockReset();
|
||||||
|
taskLocalState.isPinned.mockClear();
|
||||||
|
taskLocalState.isArchived.mockClear();
|
||||||
|
taskLocalState.getRenamedSubject.mockClear();
|
||||||
|
taskLocalState.togglePin.mockClear();
|
||||||
|
taskLocalState.toggleArchive.mockClear();
|
||||||
|
taskLocalState.renameTask.mockClear();
|
||||||
|
localStorage.clear();
|
||||||
|
localStorage.setItem('sidebarTasksGrouping', 'project');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows five tasks first, then expands and collapses with Show more and Show less', async () => {
|
||||||
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
storeState.globalTasks = Array.from({ length: 6 }, (_, index) => makeTask(index + 1));
|
||||||
|
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(React.createElement(GlobalTaskList));
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(visibleSubjects(host)).toEqual(['Task 1', 'Task 2', 'Task 3', 'Task 4', 'Task 5']);
|
||||||
|
expect(findButton(host, 'Show more')).not.toBeNull();
|
||||||
|
expect(findButton(host, 'Show less')).toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
findButton(host, 'Show more')?.click();
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(visibleSubjects(host)).toEqual(['Task 1', 'Task 2', 'Task 3', 'Task 4', 'Task 5', 'Task 6']);
|
||||||
|
expect(findButton(host, 'Show less')).not.toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
findButton(host, 'Show less')?.click();
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(visibleSubjects(host)).toEqual(['Task 1', 'Task 2', 'Task 3', 'Task 4', 'Task 5']);
|
||||||
|
expect(findButton(host, 'Show less')).toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the hard visible limit when new tasks arrive after expansion', async () => {
|
||||||
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
storeState.globalTasks = Array.from({ length: 10 }, (_, index) => makeTask(index + 1));
|
||||||
|
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(React.createElement(GlobalTaskList));
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
findButton(host, 'Show more')?.click();
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(visibleSubjects(host)).toHaveLength(10);
|
||||||
|
expect(findButton(host, 'Show less')).not.toBeNull();
|
||||||
|
|
||||||
|
storeState.globalTasks = [
|
||||||
|
makeTask(0, {
|
||||||
|
id: 'task-new',
|
||||||
|
displayId: 'task-new',
|
||||||
|
subject: 'Task 0',
|
||||||
|
createdAt: '2026-04-18T11:00:00.000Z',
|
||||||
|
updatedAt: '2026-04-18T11:00:00.000Z',
|
||||||
|
}),
|
||||||
|
...Array.from({ length: 10 }, (_, index) => makeTask(index + 1)),
|
||||||
|
];
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(React.createElement(GlobalTaskList));
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(visibleSubjects(host)).toHaveLength(10);
|
||||||
|
expect(visibleSubjects(host)).toEqual([
|
||||||
|
'Task 0',
|
||||||
|
'Task 1',
|
||||||
|
'Task 2',
|
||||||
|
'Task 3',
|
||||||
|
'Task 4',
|
||||||
|
'Task 5',
|
||||||
|
'Task 6',
|
||||||
|
'Task 7',
|
||||||
|
'Task 8',
|
||||||
|
'Task 9',
|
||||||
|
]);
|
||||||
|
expect(visibleSubjects(host)).not.toContain('Task 10');
|
||||||
|
expect(findButton(host, 'Show more')).not.toBeNull();
|
||||||
|
expect(findButton(host, 'Show less')).not.toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
import {
|
||||||
|
PROJECT_GROUP_PAGE_SIZE,
|
||||||
|
canProjectGroupShowLess,
|
||||||
|
canProjectGroupShowMore,
|
||||||
|
getNextProjectGroupVisibleCount,
|
||||||
|
getPreviousProjectGroupVisibleCount,
|
||||||
|
getProjectGroupVisibleCount,
|
||||||
|
syncProjectGroupVisibleCountByKey,
|
||||||
|
} from '../../../../src/renderer/components/sidebar/projectGroupPagination';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
describe('projectGroupPagination', () => {
|
||||||
|
it('defaults to the first page and respects small groups', () => {
|
||||||
|
expect(getProjectGroupVisibleCount(undefined, 0)).toBe(0);
|
||||||
|
expect(getProjectGroupVisibleCount(undefined, 3)).toBe(3);
|
||||||
|
expect(getProjectGroupVisibleCount(undefined, 12)).toBe(PROJECT_GROUP_PAGE_SIZE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands in steps of five and clamps to the group size', () => {
|
||||||
|
let visibleCount = getProjectGroupVisibleCount(undefined, 17);
|
||||||
|
expect(visibleCount).toBe(5);
|
||||||
|
|
||||||
|
visibleCount = getNextProjectGroupVisibleCount(visibleCount, 17);
|
||||||
|
expect(visibleCount).toBe(10);
|
||||||
|
|
||||||
|
visibleCount = getNextProjectGroupVisibleCount(visibleCount, 17);
|
||||||
|
expect(visibleCount).toBe(15);
|
||||||
|
|
||||||
|
visibleCount = getNextProjectGroupVisibleCount(visibleCount, 17);
|
||||||
|
expect(visibleCount).toBe(17);
|
||||||
|
|
||||||
|
expect(canProjectGroupShowMore(visibleCount, 17)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses in steps of five and never goes below the first page', () => {
|
||||||
|
expect(getPreviousProjectGroupVisibleCount(15, 17)).toBe(10);
|
||||||
|
expect(getPreviousProjectGroupVisibleCount(10, 17)).toBe(5);
|
||||||
|
expect(getPreviousProjectGroupVisibleCount(5, 17)).toBe(5);
|
||||||
|
|
||||||
|
expect(canProjectGroupShowLess(5, 17)).toBe(false);
|
||||||
|
expect(canProjectGroupShowLess(10, 17)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps existing counts when the group shrinks and removes missing groups', () => {
|
||||||
|
const previousVisibleCounts = {
|
||||||
|
active: 15,
|
||||||
|
compact: 7,
|
||||||
|
removed: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
syncProjectGroupVisibleCountByKey(previousVisibleCounts, [
|
||||||
|
{ projectKey: 'active', taskCount: 9 },
|
||||||
|
{ projectKey: 'compact', taskCount: 4 },
|
||||||
|
])
|
||||||
|
).toEqual({
|
||||||
|
active: 9,
|
||||||
|
compact: 4,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the same object when nothing changes', () => {
|
||||||
|
const previousVisibleCounts = {
|
||||||
|
active: 10,
|
||||||
|
compact: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextVisibleCounts = syncProjectGroupVisibleCountByKey(previousVisibleCounts, [
|
||||||
|
{ projectKey: 'active', taskCount: 12 },
|
||||||
|
{ projectKey: 'compact', taskCount: 4 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(nextVisibleCounts).toBe(previousVisibleCounts);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue