Skip to content

Commit cf5318d

Browse files
committed
add filter for question tags + loading skeleton state
1 parent e46ae15 commit cf5318d

File tree

11 files changed

+367
-110
lines changed

11 files changed

+367
-110
lines changed

peerprep-fe/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"@radix-ui/react-dropdown-menu": "^2.1.2",
1717
"@radix-ui/react-icons": "^1.3.0",
1818
"@radix-ui/react-popover": "^1.1.1",
19+
"@radix-ui/react-scroll-area": "^1.2.0",
1920
"@radix-ui/react-select": "^2.1.1",
2021
"@radix-ui/react-slot": "^1.1.0",
2122
"axios": "^1.7.7",

peerprep-fe/pnpm-lock.yaml

Lines changed: 31 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

peerprep-fe/src/app/(main)/components/LoggedIn.tsx

Lines changed: 9 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,21 @@
11
'use client';
2-
import { useEffect, useState, useCallback } from 'react';
3-
import axiosQuestionClient from '@/network/axiosClient';
2+
import { useFilteredProblems } from '@/hooks/useFilteredProblems';
43
import FilterBar from './filter/FilterBar';
54
import ProblemTable from './problems/ProblemTable';
6-
import { Problem } from '@/types/types';
75

86
export default function LoggedIn() {
9-
const [problems, setProblems] = useState<Problem[]>([]);
10-
11-
const fetchProblems = useCallback(async (params?: URLSearchParams) => {
12-
try {
13-
const url = params ? `/questions?${params.toString()}` : '/questions';
14-
const response = await axiosQuestionClient.get(url);
15-
setProblems(response.data);
16-
} catch (error) {
17-
console.error('Error fetching problems:', error);
18-
}
19-
}, []);
20-
21-
useEffect(() => {
22-
fetchProblems();
23-
}, [fetchProblems]);
7+
const { problems, filters, updateFilter, removeFilter, isLoading } =
8+
useFilteredProblems();
249

2510
return (
2611
<div className="min-h-screen bg-gray-900 p-6 pt-24 text-gray-100">
2712
<div className="mx-auto max-w-7xl">
28-
<FilterBar fetchProblems={fetchProblems} />
29-
<ProblemTable problems={problems} />
13+
<FilterBar
14+
filters={filters}
15+
updateFilter={updateFilter}
16+
removeFilter={removeFilter}
17+
/>
18+
<ProblemTable problems={problems} isLoading={isLoading} />
3019
</div>
3120
</div>
3221
);

peerprep-fe/src/app/(main)/components/filter/FilterBar.tsx

Lines changed: 39 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22
import { Button } from '@/components/ui/button';
33
import { Input } from '@/components/ui/input';
44
import { Settings } from 'lucide-react';
5-
import { useCallback, useEffect } from 'react';
6-
import { useSearchStore } from '@/state/useSearchStore';
75
import { FilterSelect } from './FilterSelect';
86
import { FilterBadge } from './FilterBadge';
9-
import { useQueryState, parseAsArrayOf, parseAsString } from 'nuqs';
7+
import { TopicsPopover } from './TopicsPopover';
8+
import { FilterState } from '@/hooks/useFilteredProblems';
9+
import { useState, useEffect } from 'react';
10+
import { useDebounce } from '@/hooks/useDebounce';
1011

1112
const DIFFICULTY_OPTIONS = [
1213
{ value: '1', label: 'Easy' },
@@ -19,86 +20,53 @@ const STATUS_OPTIONS = [
1920
{ value: 'solved', label: 'Solved' },
2021
];
2122

22-
// TODO: replace with backend fetched list
23-
const TOPIC_OPTIONS = [
24-
{ value: 'array', label: 'Array' },
25-
{ value: 'string', label: 'String' },
26-
{ value: 'dp', label: 'Dynamic Programming' },
27-
];
28-
2923
interface FilterBarProps {
30-
fetchProblems: (params: URLSearchParams) => Promise<void>;
24+
filters: FilterState;
25+
updateFilter: (
26+
key: keyof FilterState,
27+
value: string | string[] | null,
28+
) => void;
29+
removeFilter: (key: keyof FilterState, value?: string) => void;
3130
}
3231

33-
export default function FilterBar({ fetchProblems }: FilterBarProps) {
34-
const [difficulty, setDifficulty] = useQueryState('difficulty');
35-
const [status, setStatus] = useQueryState('status');
36-
const [topics, setTopics] = useQueryState(
37-
'topics',
38-
parseAsArrayOf(parseAsString),
39-
);
40-
const { searchTerm, setSearchTerm } = useSearchStore();
41-
42-
const handleFilterChange = useCallback(
43-
(key: string, value: string | string[]) => {
44-
if (key === 'difficulty') {
45-
setDifficulty(value === 'all' ? null : (value as string));
46-
} else if (key === 'status') {
47-
setStatus(value === 'all' ? null : (value as string));
48-
} else if (key === 'topics') {
49-
setTopics(value.length ? (value as string[]) : null);
50-
}
51-
},
52-
[setDifficulty, setStatus, setTopics],
53-
);
32+
export default function FilterBar({
33+
filters,
34+
updateFilter,
35+
removeFilter,
36+
}: FilterBarProps) {
37+
const [searchTerm, setSearchTerm] = useState('');
38+
const debouncedSearchTerm = useDebounce(searchTerm, 300); // 300ms delay
5439

55-
const removeFilter = useCallback(
56-
(key: string, value?: string) => {
57-
if (key === 'difficulty') {
58-
setDifficulty(null);
59-
} else if (key === 'status') {
60-
setStatus(null);
61-
} else if (key === 'topics') {
62-
setTopics((prev) => prev?.filter((t) => t !== value) ?? null);
63-
}
64-
},
65-
[setDifficulty, setStatus, setTopics],
66-
);
40+
/**
41+
* Debounce so that search filters does not call backend for
42+
* every single character input, but only after 300ms of no input
43+
*/
44+
useEffect(() => {
45+
updateFilter('search', debouncedSearchTerm);
46+
}, [debouncedSearchTerm, updateFilter]);
6747

6848
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
6949
setSearchTerm(e.target.value);
7050
};
7151

72-
useEffect(() => {
73-
const params = new URLSearchParams();
74-
if (difficulty) params.append('difficulty', difficulty);
75-
if (status) params.append('status', status);
76-
if (topics) topics.forEach((topic) => params.append('topics', topic));
77-
if (searchTerm) params.append('search', searchTerm);
78-
fetchProblems(params);
79-
}, [difficulty, status, topics, searchTerm, fetchProblems]);
80-
8152
return (
8253
<div className="mb-6">
8354
<div className="mb-4 flex flex-wrap gap-4">
8455
<FilterSelect
8556
placeholder="Difficulty"
8657
options={DIFFICULTY_OPTIONS}
87-
onChange={(value) => handleFilterChange('difficulty', value)}
88-
value={difficulty || ''}
58+
onChange={(value) => updateFilter('difficulty', value)}
59+
value={filters.difficulty || ''}
8960
/>
9061
<FilterSelect
9162
placeholder="Status"
9263
options={STATUS_OPTIONS}
93-
onChange={(value) => handleFilterChange('status', value)}
94-
value={status || ''}
64+
onChange={(value) => updateFilter('status', value)}
65+
value={filters.status || ''}
9566
/>
96-
<FilterSelect
97-
placeholder="Topics"
98-
options={TOPIC_OPTIONS}
99-
onChange={(value) => handleFilterChange('topics', value)}
100-
value={topics || []}
101-
isMulti
67+
<TopicsPopover
68+
selectedTopics={filters.topics || []}
69+
onChange={(value) => updateFilter('topics', value)}
10270
/>
10371
<div className="flex-grow">
10472
<Input
@@ -120,33 +88,32 @@ export default function FilterBar({ fetchProblems }: FilterBarProps) {
12088
</Button>
12189
</div>
12290
<div className="flex flex-wrap gap-2">
123-
{difficulty && (
91+
{filters.difficulty && (
12492
<FilterBadge
12593
filterType="difficulty"
12694
value={
127-
DIFFICULTY_OPTIONS.find((opt) => opt.value === difficulty)
95+
DIFFICULTY_OPTIONS.find((opt) => opt.value === filters.difficulty)
12896
?.label || ''
12997
}
13098
onRemove={() => removeFilter('difficulty')}
13199
/>
132100
)}
133-
{status && (
101+
{filters.status && (
134102
<FilterBadge
135103
filterType="status"
136104
value={
137-
STATUS_OPTIONS.find((opt) => opt.value === status)?.label || ''
105+
STATUS_OPTIONS.find((opt) => opt.value === filters.status)
106+
?.label || ''
138107
}
139108
onRemove={() => removeFilter('status')}
140109
/>
141110
)}
142-
{topics &&
143-
topics.map((topic) => (
111+
{filters.topics &&
112+
filters.topics.map((topic) => (
144113
<FilterBadge
145114
key={`topics-${topic}`}
146115
filterType="topics"
147-
value={
148-
TOPIC_OPTIONS.find((opt) => opt.value === topic)?.label || topic
149-
}
116+
value={topic}
150117
onRemove={() => removeFilter('topics', topic)}
151118
/>
152119
))}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
'use client';
2+
import { useState, useEffect } from 'react';
3+
import { axiosQuestionClient } from '@/network/axiosClient';
4+
import {
5+
Popover,
6+
PopoverContent,
7+
PopoverTrigger,
8+
} from '@/components/ui/popover';
9+
import { Button } from '@/components/ui/button';
10+
import { Check, ChevronsUpDown } from 'lucide-react';
11+
import { cn } from '@/lib/utils';
12+
import { Input } from '@/components/ui/input';
13+
import { ScrollArea } from '@/components/ui/scroll-area';
14+
15+
interface TopicsPopoverProps {
16+
selectedTopics: string[];
17+
onChange: (value: string[]) => void;
18+
}
19+
20+
export function TopicsPopover({
21+
selectedTopics,
22+
onChange,
23+
}: TopicsPopoverProps) {
24+
const [open, setOpen] = useState(false);
25+
const [topics, setTopics] = useState<string[]>([]);
26+
const [searchTerm, setSearchTerm] = useState('');
27+
28+
useEffect(() => {
29+
const fetchTopics = async () => {
30+
try {
31+
const response = await axiosQuestionClient.get('/questions/tags');
32+
setTopics(response.data);
33+
} catch (error) {
34+
console.error('Error fetching topics:', error);
35+
}
36+
};
37+
38+
fetchTopics();
39+
}, []);
40+
41+
const filteredTopics = topics.filter((topic) =>
42+
topic.toLowerCase().includes(searchTerm.toLowerCase()),
43+
);
44+
45+
return (
46+
<Popover open={open} onOpenChange={setOpen}>
47+
<PopoverTrigger asChild>
48+
<Button
49+
variant="outline"
50+
role="combobox"
51+
aria-expanded={open}
52+
className="w-[200px] justify-between border-gray-700 bg-gray-800"
53+
>
54+
{selectedTopics.length > 0
55+
? `${selectedTopics.length} topics selected`
56+
: 'Select topics'}
57+
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
58+
</Button>
59+
</PopoverTrigger>
60+
<PopoverContent className="w-[200px] p-0">
61+
<div className="p-2">
62+
<Input
63+
placeholder="Search topics..."
64+
value={searchTerm}
65+
onChange={(e) => setSearchTerm(e.target.value)}
66+
className="mb-2"
67+
/>
68+
</div>
69+
<ScrollArea className="h-[300px]">
70+
{filteredTopics.length === 0 ? (
71+
<p className="p-2 text-sm text-muted-foreground">No topic found.</p>
72+
) : (
73+
<div className="grid gap-1 p-2">
74+
{filteredTopics.map((topic) => (
75+
<Button
76+
key={topic}
77+
variant="ghost"
78+
className="justify-start"
79+
onClick={() => {
80+
const newSelectedTopics = selectedTopics.includes(topic)
81+
? selectedTopics.filter((t) => t !== topic)
82+
: [...selectedTopics, topic];
83+
onChange(newSelectedTopics);
84+
}}
85+
>
86+
<Check
87+
className={cn(
88+
'mr-2 h-4 w-4',
89+
selectedTopics.includes(topic)
90+
? 'opacity-100'
91+
: 'opacity-0',
92+
)}
93+
/>
94+
{topic}
95+
</Button>
96+
))}
97+
</div>
98+
)}
99+
</ScrollArea>
100+
</PopoverContent>
101+
</Popover>
102+
);
103+
}

0 commit comments

Comments
 (0)