Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
11 changes: 9 additions & 2 deletions src/app/queries/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,18 +32,25 @@ export function useYear(yearId: string) {
});
}

export const PROJECTS_PAGE_SIZE = 250;

export function useProjects(
yearId: string,
kind?: 'project' | 'idea',
group?: string,
search?: string,
cursor?: string,
) {
const query = new URLSearchParams({year: yearId, limit: '50'});
const query = new URLSearchParams({
year: yearId,
limit: String(PROJECTS_PAGE_SIZE),
});
if (kind) query.set('kind', kind);
if (group) query.set('group', group);
if (search) query.set('q', search);
if (cursor) query.set('cursor', cursor);
return useQuery({
queryKey: ['projects', yearId, kind, group, search],
queryKey: ['projects', yearId, kind, group, search, cursor ?? null],
queryFn: () => apiRequest<ProjectsResponse>(`/projects?${query}`),
placeholderData: keepPreviousData,
});
Expand Down
87 changes: 75 additions & 12 deletions src/app/routes/ProjectsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,29 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {
const [group, setGroup] = useState('');
const [searchInput, setSearchInput] = useState('');
const [search, setSearch] = useState('');
const [cursor, setCursor] = useState<string | undefined>();
const [cursorHistory, setCursorHistory] = useState<Array<string | undefined>>([]);
const [view, setView] = useState<ProjectsView>(getProjectsView);
const year = useYear(yearId);
const projects = useProjects(
yearId,
kind,
kind === 'project' ? group || undefined : undefined,
search || undefined,
cursor,
);
const error = year.error ?? projects.error;
const pageProjects = projects.data?.projects ?? [];
const nextCursor = projects.data?.nextCursor ?? null;
const pageOffset = cursor ? Number(cursor) : 0;
const pageStart = pageOffset + 1;
const pageEnd = pageOffset + pageProjects.length;
const showPagination = Boolean(cursor || nextCursor);

const resetPagination = () => {
setCursor(undefined);
setCursorHistory([]);
};

useEffect(() => {
const timeout = window.setTimeout(() => {
Expand All @@ -52,6 +66,10 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {
return () => window.clearTimeout(timeout);
}, [searchInput]);

useEffect(() => {
resetPagination();
}, [yearId, search]);

return (
<QueryState loading={year.isLoading || projects.isLoading} error={error}>
{!year.data ? (
Expand Down Expand Up @@ -136,13 +154,19 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {
<div className="segmented">
<button
className={kind === 'project' ? 'active' : ''}
onClick={() => setKind('project')}
onClick={() => {
setKind('project');
resetPagination();
}}
>
Projects <span>{year.data.year.projectCount}</span>
</button>
<button
className={kind === 'idea' ? 'active' : ''}
onClick={() => setKind('idea')}
onClick={() => {
setKind('idea');
resetPagination();
}}
>
Ideas <span>{year.data.year.ideaCount}</span>
</button>
Expand All @@ -153,7 +177,10 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {
<span>Group</span>
<select
value={group}
onChange={(event) => setGroup(event.target.value)}
onChange={(event) => {
setGroup(event.target.value);
resetPagination();
}}
>
<option value="">All groups</option>
{year.data.groups.map((item) => (
Expand Down Expand Up @@ -199,7 +226,7 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {
</div>
</section>
)}
{!projects.data?.projects.length ? (
{!pageProjects.length ? (
<section className="emptyState">
<span>∅</span>
<h2>No {kind === 'idea' ? 'ideas' : 'projects'} found</h2>
Expand All @@ -210,14 +237,50 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {
</p>
</section>
) : (
<section
className={view === 'grid' ? 'projectGrid' : 'projectList'}
aria-label={`${kind} list`}
>
{projects.data.projects.map((project) => (
<ProjectCard project={project} view={view} key={project.id} />
))}
</section>
<>
<section
className={view === 'grid' ? 'projectGrid' : 'projectList'}
aria-label={`${kind} list`}
>
{pageProjects.map((project) => (
<ProjectCard project={project} view={view} key={project.id} />
))}
</section>
{showPagination && (
<nav className="projectPagination" aria-label="Project pages">
<p>
showing {pageStart}–{pageEnd}
{nextCursor ? '+' : ''}
</p>
<div>
<button
type="button"
className="textAction"
disabled={cursorHistory.length === 0 || projects.isFetching}
onClick={() => {
const previous = cursorHistory[cursorHistory.length - 1];
setCursorHistory((history) => history.slice(0, -1));
setCursor(previous);
}}
>
previous
</button>
<button
type="button"
className="textAction"
disabled={!nextCursor || projects.isFetching}
onClick={() => {
if (!nextCursor) return;
setCursorHistory((history) => [...history, cursor]);
setCursor(nextCursor);
}}
>
next
</button>
</div>
</nav>
)}
</>
)}
</main>
)}
Expand Down
32 changes: 32 additions & 0 deletions src/app/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,30 @@ main {
align-items: center;
justify-content: flex-end;
}
.projectPagination {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: center;
justify-content: space-between;
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--line);
}
.projectPagination p {
margin: 0;
color: var(--muted);
font-size: 0.85rem;
}
.projectPagination > div {
display: flex;
gap: 0.75rem;
align-items: center;
}
.projectPagination button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.projectViewToggle {
display: inline-flex;
gap: 2px;
Expand Down Expand Up @@ -2769,6 +2793,14 @@ kbd {
width: 100%;
justify-content: space-between;
}
.projectPagination {
align-items: stretch;
flex-direction: column;
}
.projectPagination > div {
width: 100%;
justify-content: space-between;
}
.projectRow {
grid-template-areas:
'name name'
Expand Down
2 changes: 1 addition & 1 deletion src/worker/routes/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ projectsRoutes.get('/', async (c) => {
throw new ServiceError('VALIDATION_FAILED', 'Kind query is invalid', 400);
}
const kind = kindQuery === 'project' || kindQuery === 'idea' ? kindQuery : undefined;
const limit = boundedInteger(c.req.query('limit'), 24, 1, 50, 'Limit');
const limit = boundedInteger(c.req.query('limit'), 24, 1, 250, 'Limit');
Comment thread
cursor[bot] marked this conversation as resolved.
const offset = boundedInteger(c.req.query('cursor'), 0, 0, 100_000, 'Cursor');
const search = boundedSearch(c.req.query('q'));
const response: ProjectsResponse = await listProjects(c.env.DB, {
Expand Down
76 changes: 76 additions & 0 deletions test/app/routes.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,82 @@ describe('clickable project routes', () => {
).toBe('true');
});

it('requests a 250-item page and paginates with next/previous controls', async () => {
fetchMock.mockImplementation(async (input) => {
const url = input instanceof Request ? input.url : input.toString();
if (url.includes('/api/years/2026')) {
return json({
year: {
id: '2026',
votingEnabled: false,
submissionsClosed: false,
projectCount: 251,
ideaCount: 0,
groupCount: 0,
participantCount: 251,
},
groups: [],
awards: [],
});
}

const requestUrl = new URL(url, 'https://hackweek.test');
expect(requestUrl.searchParams.get('limit')).toBe('250');
const cursor = requestUrl.searchParams.get('cursor');
if (cursor === '250') {
return json({
projects: [{...projectFixture, id: 'project-251', name: 'Project 251'}],
nextCursor: null,
});
}

return json({
projects: Array.from({length: 250}, (_, index) => ({
...projectFixture,
id: `project-${index + 1}`,
name: `Project ${index + 1}`,
})),
nextCursor: '250',
});
});

renderRoute(<ProjectsPage />, '/years/2026/projects', '/years/:yearId/projects');

expect(await screen.findByRole('heading', {name: 'Project 1'})).toBeTruthy();
expect(screen.getByRole('region', {name: 'project list'}).children).toHaveLength(250);
expect(screen.getByText('showing 1–250+')).toBeTruthy();
expect(screen.getByRole('button', {name: 'previous'}).hasAttribute('disabled')).toBe(
true,
);

await userEvent.click(screen.getByRole('button', {name: 'next'}));

expect(await screen.findByRole('heading', {name: 'Project 251'})).toBeTruthy();
expect(screen.getByText('showing 251–251')).toBeTruthy();
expect(screen.getByRole('button', {name: 'next'}).hasAttribute('disabled')).toBe(
true,
);

await userEvent.click(screen.getByRole('button', {name: 'previous'}));

expect(await screen.findByRole('heading', {name: 'Project 1'})).toBeTruthy();
expect(screen.getByRole('button', {name: 'previous'}).hasAttribute('disabled')).toBe(
true,
);
expect(fetchMock).toHaveBeenCalledWith(
expect.stringMatching(
/\/api\/projects\?(?=.*year=2026)(?=.*limit=250)(?!.*cursor=)/,
),
undefined,
);
expect(fetchMock).toHaveBeenCalledWith(
expect.stringMatching(
/\/api\/projects\?(?=.*year=2026)(?=.*limit=250)(?=.*cursor=250)/,
),
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
Loading