Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 3 additions & 1 deletion src/app/queries/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export function useProjects(
yearId: string,
kind?: 'project' | 'idea',
group?: string,
category?: string,
search?: string,
cursor?: string,
) {
Expand All @@ -47,10 +48,11 @@ export function useProjects(
});
if (kind) query.set('kind', kind);
if (group) query.set('group', group);
if (category) query.set('category', category);
if (search) query.set('q', search);
if (cursor) query.set('cursor', cursor);
return useQuery({
queryKey: ['projects', yearId, kind, group, search, cursor ?? null],
queryKey: ['projects', yearId, kind, group, category, search, cursor ?? null],
queryFn: () => apiRequest<ProjectsResponse>(`/projects?${query}`),
placeholderData: keepPreviousData,
});
Expand Down
21 changes: 21 additions & 0 deletions src/app/routes/ProjectsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {
const {yearId} = useParams<{yearId: string}>();
const [kind, setKind] = useState<'project' | 'idea'>('project');
const [group, setGroup] = useState('');
const [category, setCategory] = useState('');
Comment thread
sentry[bot] marked this conversation as resolved.
const [searchInput, setSearchInput] = useState('');
const [search, setSearch] = useState('');
const [cursor, setCursor] = useState<string | undefined>();
Expand All @@ -49,6 +50,7 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {
yearId,
kind,
kind === 'project' ? group || undefined : undefined,
kind === 'project' ? category || undefined : undefined,
search || undefined,
cursor,
);
Expand Down Expand Up @@ -226,6 +228,25 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {
</select>
</label>
)}
{kind === 'project' && ballot.data && ballot.data.categories.length > 0 && (
<label>
<span>Award category</span>
<select
value={category}
onChange={(event) => {
setCategory(event.target.value);
resetPagination();
}}
>
<option value="">All award categories</option>
{ballot.data.categories.map((item) => (
<option value={item.id} key={item.id}>
{item.name}
</option>
))}
</select>
</label>
)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty state ignores category filter

Low Severity

The empty-results copy only treats search and hasVideoOnly as active filters. With only an award category selected, users still get the “try another group…” message, which does not reflect the filter that actually emptied the list.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f0133ae. Configure here.

<div className="projectViewToggle" role="group" aria-label="Project view">
{(['grid', 'list'] as const).map((option) => (
<button
Expand Down
10 changes: 10 additions & 0 deletions src/worker/repositories/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ export async function listProjects(
yearId: string;
kind?: 'project' | 'idea';
groupId?: string;
categoryId?: string;
search?: string;
limit: number;
offset: number;
Expand All @@ -186,6 +187,15 @@ export async function listProjects(
conditions.push('p.group_id = ?');
bindings.push(options.groupId);
}
if (options.categoryId) {
conditions.push(
`EXISTS (
SELECT 1 FROM project_nominations pn
WHERE pn.project_id = p.id AND pn.award_category_id = ?
)`,
);
bindings.push(options.categoryId);
}
Comment thread
cursor[bot] marked this conversation as resolved.
let relevanceOrder = '';
if (options.search) {
const escapedSearch = escapeLikePattern(options.search);
Expand Down
1 change: 1 addition & 0 deletions src/worker/routes/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ projectsRoutes.get('/', async (c) => {
yearId,
kind,
groupId: c.req.query('group'),
categoryId: c.req.query('category'),
search,
limit,
offset,
Expand Down
27 changes: 27 additions & 0 deletions test/app/routes.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -918,6 +918,33 @@ describe('clickable project routes', () => {
);
});

it('filters projects by eligible award category', async () => {
mockProjectsOverview({
categories: [
{id: 'delight', yearId: '2026', name: 'Delight'},
{id: 'impact', yearId: '2026', name: 'Impact'},
],
projects: [projectFixture],
});

renderRoute(<ProjectsPage />, '/years/2026/projects', '/years/:yearId/projects');
await screen.findByRole('heading', {name: 'A small machine'});

await userEvent.selectOptions(
await screen.findByLabelText('Award category'),
'delight',
);

await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith(
expect.stringMatching(
/\/api\/projects\?(?=.*year=2026)(?=.*kind=project)(?=.*category=delight)/,
),
undefined,
),
);
});

it('live-updates server search without replacing the current list', async () => {
let resolveSearch!: (response: Response) => void;
const pendingSearch = new Promise<Response>((resolve) => {
Expand Down
26 changes: 26 additions & 0 deletions test/projects/projects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,32 @@ describe('project and history APIs', () => {
]);
});

it('filters projects by eligible award category', async () => {
const delight = await createProject(memberToken, {
name: 'Delightful project',
nominationCategoryIds: [categoryId],
});
await createProject(memberToken, {
name: 'Craft project',
nominationCategoryIds: [secondCategoryId],
});
const both = await createProject(memberToken, {
name: 'Both categories',
nominationCategoryIds: [categoryId, secondCategoryId],
});

const matches = await api(
`/projects?year=${yearId}&kind=project&category=${categoryId}`,
memberToken,
);

expect(matches.status).toBe(200);
expect(matches.body.projects.map((project: {id: string}) => project.id)).toEqual([
both.id,
delight.id,
]);
});

it('searches titles and descriptions before pagination with relevant results first', async () => {
const exact = await createProject(memberToken, {
name: 'Signal',
Expand Down
Loading