Skip to content
Merged
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
13 changes: 12 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,
hasVideo?: boolean,
cursor?: string,
Expand All @@ -48,11 +49,21 @@ 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 (hasVideo) query.set('hasVideo', 'true');
if (cursor) query.set('cursor', cursor);
return useQuery({
queryKey: ['projects', yearId, kind, group, search, hasVideo, cursor ?? null],
queryKey: [
'projects',
yearId,
kind,
group,
category,
search,
hasVideo,
cursor ?? null,
],
queryFn: () => apiRequest<ProjectsResponse>(`/projects?${query}`),
placeholderData: keepPreviousData,
});
Expand Down
22 changes: 22 additions & 0 deletions src/app/routes/ProjectsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {
const [searchParams, setSearchParams] = useSearchParams();
const [kind, setKind] = useState<'project' | 'idea'>('project');
const group = searchParams.get('group') ?? '';
const [category, setCategory] = useState('');
Comment thread
sentry[bot] marked this conversation as resolved.
const [hasVideoOnly, setHasVideoOnly] = useState(false);
const [searchInput, setSearchInput] = useState('');
const [search, setSearch] = useState('');
Expand All @@ -52,6 +53,7 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {
yearId,
kind,
kind === 'project' ? group || undefined : undefined,
kind === 'project' ? category || undefined : undefined,
search || undefined,
kind === 'project' && hasVideoOnly ? true : undefined,
cursor,
Expand Down Expand Up @@ -217,6 +219,7 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {
className={kind === 'idea' ? 'active' : ''}
onClick={() => {
setKind('idea');
setCategory('');
setHasVideoOnly(false);
resetPagination();
}}
Expand All @@ -241,6 +244,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.

{kind === 'project' && (
<label className="projectVideoFilter">
<input
Expand Down
17 changes: 17 additions & 0 deletions src/worker/repositories/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ export async function listProjects(
yearId: string;
kind?: 'project' | 'idea';
groupId?: string;
categoryId?: string;
search?: string;
hasVideo?: boolean;
limit: number;
Expand All @@ -216,6 +217,22 @@ export async function listProjects(
conditions.push(readyVideoExistsSql);
countConditions.push(readyVideoExistsSql);
}
if (options.categoryId) {
// Empty nominations mean the project is open to every award category.
const categoryCondition = `(
NOT EXISTS (
SELECT 1 FROM project_nominations pn WHERE pn.project_id = p.id
)
OR EXISTS (
SELECT 1 FROM project_nominations pn
WHERE pn.project_id = p.id AND pn.award_category_id = ?
)
)`;
conditions.push(categoryCondition);
countConditions.push(categoryCondition);
bindings.push(options.categoryId);
countBindings.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 @@ -41,6 +41,7 @@ projectsRoutes.get('/', async (c) => {
yearId,
kind,
groupId: c.req.query('group'),
categoryId: c.req.query('category'),
search,
hasVideo: hasVideoQuery === 'true',
limit,
Expand Down
35 changes: 35 additions & 0 deletions test/app/routes.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1237,6 +1237,41 @@ 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,
),
);

await userEvent.click(screen.getByRole('button', {name: /Ideas/}));
expect(screen.queryByLabelText('Award category')).toBeNull();
await userEvent.click(screen.getByRole('button', {name: /Projects/}));
const categorySelect = await screen.findByLabelText('Award category');
expect(categorySelect).toBeInstanceOf(HTMLSelectElement);
if (!(categorySelect instanceof HTMLSelectElement)) throw new Error();
expect(categorySelect.value).toBe('');
});

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

it('filters projects by eligible award category', async () => {
const open = await createProject(memberToken, {
name: 'Open project',
nominationCategoryIds: [],
});
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,
open.id,
]);
expect(matches.body.projectCount).toBe(3);
expect(matches.body.ideaCount).toBe(0);
});

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