Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { buttonVariants } from '@/components/ui/button';
import { BottomBarProps } from './bottom-bar-utils';
import { Icons } from '@/components/ui/icons';
import { MultiSelectFilter } from '@/components/ui/multi-select';
import { StatsMultiSelectFilter } from '../Tasks/StatsMultiSelectFilter';
import {
Popover,
PopoverTrigger,
Expand Down Expand Up @@ -59,7 +60,7 @@ const BottomBar: React.FC<BottomBarProps> = ({
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-4 flex flex-col gap-4 bg-background border shadow-lg rounded-lg">
<MultiSelectFilter
<StatsMultiSelectFilter
title="Projects"
options={projects}
selectedValues={selectedProjects}
Expand All @@ -73,7 +74,7 @@ const BottomBar: React.FC<BottomBarProps> = ({
onSelectionChange={setSelectedStatus}
className="min-w-[200px]"
/>
<MultiSelectFilter
<StatsMultiSelectFilter
title="Tags"
options={tags}
selectedValues={selectedTags}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,20 @@ jest.mock('@/components/ui/multi-select', () => ({
)),
}));

// Mock the StatsMultiSelectFilter component
jest.mock('../../Tasks/StatsMultiSelectFilter', () => ({
StatsMultiSelectFilter: jest.fn(({ title, selectedValues }) => (
<div data-testid={`multiselect-${title.toLowerCase()}`}>
<span data-testid={`multiselect-title-${title.toLowerCase()}`}>
{title}
</span>
<span data-testid={`multiselect-count-${title.toLowerCase()}`}>
{selectedValues.length}
</span>
</div>
)),
}));

const mockProps: BottomBarProps = {
projects: ['Project A', 'Project B'],
selectedProjects: ['Project A'],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import * as React from 'react';
import { Check, ChevronsUpDown } from 'lucide-react';

import { cn } from '@/components/utils/utils';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';

const ALL_ITEMS_VALUE = '__ALL__';

interface StatsMultiSelectFilterProps {
id?: string;
title: string;
options: string[];
selectedValues: string[];
onSelectionChange: (values: string[]) => void;
className?: string;
icon?: React.ReactNode;
}

export function StatsMultiSelectFilter({
id,
title,
options,
selectedValues,
onSelectionChange,
className,
icon,
}: StatsMultiSelectFilterProps) {
const [open, setOpen] = React.useState(false);

const handleSelect = (value: string) => {
if (value === ALL_ITEMS_VALUE) {
onSelectionChange([]);
setOpen(false);
return;
}
const newSelectedValues = selectedValues.includes(value)
? selectedValues.filter((item) => item !== value)
: [...selectedValues, value];
onSelectionChange(newSelectedValues);
};

return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
id={id}
variant="outline"
role="combobox"
aria-expanded={open}
className={cn(
'w-full justify-between h-auto min-h-[40px]',
className
)}
>
<div className="flex flex-wrap gap-1 items-center">
<span className="font-medium">{title}</span>
</div>
<div className="flex flex-wrap items-center">
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
{icon && <span>{icon}</span>}
</div>
</Button>
</PopoverTrigger>

<PopoverContent className="w-full md:w-[200px] p-0">
<Command>
<CommandInput placeholder={`Search ${title.toLowerCase()}...`} />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
<CommandItem
key={ALL_ITEMS_VALUE}
onSelect={() => handleSelect(ALL_ITEMS_VALUE)}
className="text-muted-foreground cursor-pointer"
>
All {title}
</CommandItem>
{options.map((option) => {
const isSelected = selectedValues.includes(option);

// Parse option for stats formatting
const parts = option.split(' ');
const hasStats = parts.length === 3;

return (
<CommandItem
key={option}
onSelect={() => handleSelect(option)}
>
<Check
className={cn(
'mr-2 h-4 w-4',
isSelected ? 'opacity-100' : 'opacity-0'
)}
/>
{hasStats ? (
<>
<span>{parts[0]}</span>
<span className="ml-3 text-muted-foreground text-xs">
{parts[1]} {parts[2]}
</span>
</>
) : (
option
)}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
115 changes: 100 additions & 15 deletions frontend/src/components/HomeComponents/Tasks/Tasks.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import {
useEffect,
useState,
useCallback,
useMemo,
useRef,
Dispatch,
SetStateAction,
} from 'react';
import { Task } from '../../utils/types';
import { ReportsView } from './ReportsView';
import Fuse from 'fuse.js';
Expand Down Expand Up @@ -57,10 +65,14 @@ import {
sortTasksById,
getTimeSinceLastSync,
hashKey,
aggregateProjectStats,
aggregateTagStats,
buildLabelMaps,
} from './tasks-utils';
import Pagination from './Pagination';
import { url } from '@/components/utils/URLs';
import { MultiSelectFilter } from '@/components/ui/multi-select';
import { StatsMultiSelectFilter } from './StatsMultiSelectFilter';
import BottomBar from '../BottomBar/BottomBar';
import {
addTaskToBackend,
Expand Down Expand Up @@ -143,6 +155,75 @@ export const Tasks = (
const [hotkeysEnabled, setHotkeysEnabled] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);

const projectStats = useMemo(() => aggregateProjectStats(tasks), [tasks]);
const tagStats = useMemo(() => aggregateTagStats(tasks), [tasks]);

const {
options: projectDisplayOptions,
valueToDisplay: projectValueToDisplay,
displayToValue: projectDisplayToValue,
} = useMemo(
() => buildLabelMaps(uniqueProjects, projectStats),
[uniqueProjects, projectStats]
);

const {
options: tagDisplayOptions,
valueToDisplay: tagValueToDisplay,
displayToValue: tagDisplayToValue,
} = useMemo(
() => buildLabelMaps(uniqueTags, tagStats),
[uniqueTags, tagStats]
);

const selectedProjectDisplayValues = useMemo(
() =>
selectedProjects
.map((project) => projectValueToDisplay[project])
.filter((label): label is string => Boolean(label)),
[selectedProjects, projectValueToDisplay]
);

const selectedTagDisplayValues = useMemo(
() =>
selectedTags
.map((tag) => tagValueToDisplay[tag])
.filter((label): label is string => Boolean(label)),
[selectedTags, tagValueToDisplay]
);

const handleProjectSelectionChange = useCallback<
Dispatch<SetStateAction<string[]>>
>(
(valueOrUpdater) => {
if (typeof valueOrUpdater === 'function') {
setSelectedProjects(valueOrUpdater);
return;
}
const rawValues = valueOrUpdater
.map((label) => projectDisplayToValue[label])
.filter((value): value is string => Boolean(value));
setSelectedProjects(rawValues);
},
[projectDisplayToValue, setSelectedProjects]
);

const handleTagSelectionChange = useCallback<
Dispatch<SetStateAction<string[]>>
>(
(valueOrUpdater) => {
if (typeof valueOrUpdater === 'function') {
setSelectedTags(valueOrUpdater);
return;
}
const rawValues = valueOrUpdater
.map((label) => tagDisplayToValue[label])
.filter((value): value is string => Boolean(value));
setSelectedTags(rawValues);
},
[tagDisplayToValue, setSelectedTags]
);

const isOverdue = (due?: string) => {
if (!due) return false;

Expand Down Expand Up @@ -900,15 +981,15 @@ export const Tasks = (
className="container py-24 pl-1 pr-1 md:pr-4 md:pl-4 sm:py-32"
>
<BottomBar
projects={uniqueProjects}
selectedProjects={selectedProjects}
setSelectedProject={setSelectedProjects}
projects={projectDisplayOptions}
selectedProjects={selectedProjectDisplayValues}
setSelectedProject={handleProjectSelectionChange}
status={['pending', 'completed', 'deleted']}
selectedStatuses={selectedStatuses}
setSelectedStatus={setSelectedStatuses}
selectedTags={selectedTags}
tags={uniqueTags}
setSelectedTag={setSelectedTags}
selectedTags={selectedTagDisplayValues}
tags={tagDisplayOptions}
setSelectedTag={handleTagSelectionChange}
/>

<h2
Expand Down Expand Up @@ -971,12 +1052,14 @@ export const Tasks = (
data-testid="task-search-bar"
icon={<Key lable="f" />}
/>
<MultiSelectFilter
<StatsMultiSelectFilter
id="projects"
title="Projects"
options={uniqueProjects}
selectedValues={selectedProjects}
onSelectionChange={setSelectedProjects}
options={projectDisplayOptions}
selectedValues={selectedProjectDisplayValues}
onSelectionChange={(values) =>
handleProjectSelectionChange(values)
}
className="flex-1 min-w-[140px]"
icon={<Key lable="p" />}
/>
Expand All @@ -989,12 +1072,14 @@ export const Tasks = (
className="flex-1 min-w-[140px]"
icon={<Key lable="s" />}
/>
<MultiSelectFilter
<StatsMultiSelectFilter
id="tags"
title="Tags"
options={uniqueTags}
selectedValues={selectedTags}
onSelectionChange={setSelectedTags}
options={tagDisplayOptions}
selectedValues={selectedTagDisplayValues}
onSelectionChange={(values) =>
handleTagSelectionChange(values)
}
className="flex-1 min-w-[140px]"
icon={<Key lable="t" />}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,17 @@ jest.mock('@/components/ui/multi-select', () => ({
)),
}));

jest.mock('../StatsMultiSelectFilter', () => ({
StatsMultiSelectFilter: jest.fn(({ title, options }) => (
<div data-testid={`stats-multiselect-${title.toLowerCase()}`}>
Mocked Stats MultiSelect: {title}
<div data-testid={`options-${title.toLowerCase()}`}>
{JSON.stringify(options)}
</div>
</div>
)),
}));

jest.mock('../../BottomBar/BottomBar', () => {
return jest.fn(() => <div>Mocked BottomBar</div>);
});
Expand Down Expand Up @@ -142,6 +153,37 @@ describe('Tasks Component', () => {
expect(screen.getByText('Mocked BottomBar')).toBeInTheDocument();
});

test('displays completion stats inside filter dropdown options', async () => {
const { StatsMultiSelectFilter } = require('../StatsMultiSelectFilter');
render(<Tasks {...mockProps} />);

expect(await screen.findByText('Task 1')).toBeInTheDocument();

const projectCall = StatsMultiSelectFilter.mock.calls.find(
([props]: [{ title: string }]) => props.title === 'Projects'
);
expect(projectCall).toBeTruthy();
const projectOptions = projectCall![0].options;
expect(
projectOptions.some(
(opt: string) =>
opt.includes('ProjectA') && opt.includes('0/6') && opt.includes('0%')
)
).toBe(true);

const tagCall = StatsMultiSelectFilter.mock.calls.find(
([props]: [{ title: string }]) => props.title === 'Tags'
);
expect(tagCall).toBeTruthy();
const tagOptions = tagCall![0].options;
expect(
tagOptions.some(
(opt: string) =>
opt.includes('tag1') && opt.includes('0/4') && opt.includes('0%')
)
).toBe(true);
});

test('renders the "Tasks per Page" dropdown with default value', async () => {
render(<Tasks {...mockProps} />);

Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/HomeComponents/Tasks/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const COMPLETED_STATUS = 'completed';
Loading
Loading