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
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
112 changes: 103 additions & 9 deletions src/app/routes/ProjectsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {useEffect, useState} from 'react';
import {useEffect, useRef, useState} from 'react';
import {Link, useParams} from 'wouter';

import type {BallotStatusResponse} from '../../shared/administration';
Expand Down Expand Up @@ -37,17 +37,39 @@ 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 resultStart = useRef<HTMLElement | null>(null);
const paginationRequestPending = useRef(false);
const year = useYear(yearId);
const ballot = useBallotStatus(yearId, year.data?.year.votingEnabled ?? false);
const projects = useProjects(
yearId,
kind,
kind === 'project' ? group || undefined : undefined,
search || undefined,
cursor,
);
const error = year.error ?? projects.error;
const voteCategoriesByProject = selectedCategoriesByProject(ballot.data);
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 pageStatus = projects.isPlaceholderData
? 'loading page…'
: pageProjects.length
? `showing ${pageStart}–${pageEnd}${nextCursor ? '+' : ''}`
: `no ${kind === 'idea' ? 'ideas' : 'projects'} found on this page`;

const resetPagination = () => {
paginationRequestPending.current = false;
setCursor(undefined);
setCursorHistory([]);
};

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

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

useEffect(() => {
if (
!paginationRequestPending.current ||
projects.isFetching ||
projects.isPlaceholderData
) {
return;
}
paginationRequestPending.current = false;
resultStart.current?.focus();
}, [cursor, projects.isFetching, projects.isPlaceholderData]);

return (
<QueryState loading={year.isLoading || projects.isLoading} error={error}>
{!year.data ? (
Expand Down Expand Up @@ -118,13 +156,17 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {
value={searchInput}
maxLength={100}
placeholder="Search titles and descriptions"
onChange={(event) => setSearchInput(event.target.value)}
onChange={(event) => {
paginationRequestPending.current = false;
setSearchInput(event.target.value);
}}
/>
{search && (
<button
type="button"
className="textAction"
onClick={() => {
resetPagination();
setSearchInput('');
setSearch('');
}}
Expand All @@ -133,7 +175,10 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {
</button>
)}
{projects.isFetching && (
<span className="projectSearchStatus" role="status">
<span
className="projectSearchStatus"
role={showPagination ? undefined : 'status'}
>
updating…
</span>
)}
Expand All @@ -143,13 +188,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 @@ -160,7 +211,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 @@ -206,8 +260,13 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {
</div>
</section>
)}
{!projects.data?.projects.length ? (
<section className="emptyState">
{!pageProjects.length ? (
<section
className="emptyState"
aria-label={`${kind} results`}
ref={resultStart}
tabIndex={-1}
>
<span>∅</span>
<h2>No {kind === 'idea' ? 'ideas' : 'projects'} found</h2>
<p>
Expand All @@ -220,8 +279,10 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {
<section
className={view === 'grid' ? 'projectGrid' : 'projectList'}
aria-label={`${kind} list`}
ref={resultStart}
tabIndex={-1}
>
{projects.data.projects.map((project) => (
{pageProjects.map((project) => (
<ProjectCard
project={project}
view={view}
Expand All @@ -231,6 +292,39 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {
))}
</section>
)}
{showPagination && (
<nav className="projectPagination" aria-label="Project pages">
<p role="status">{pageStatus}</p>
<div>
<button
type="button"
className="textAction"
disabled={cursorHistory.length === 0 || projects.isFetching}
onClick={() => {
const previous = cursorHistory[cursorHistory.length - 1];
paginationRequestPending.current = true;
setCursorHistory((history) => history.slice(0, -1));
setCursor(previous);
}}
>
previous
</button>
<button
type="button"
className="textAction"
disabled={!nextCursor || projects.isFetching}
onClick={() => {
if (!nextCursor) return;
paginationRequestPending.current = true;
setCursorHistory((history) => [...history, cursor]);
setCursor(nextCursor);
}}
>
next
</button>
</div>
</nav>
)}
</main>
)}
</QueryState>
Expand Down
32 changes: 32 additions & 0 deletions src/app/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -829,6 +829,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 @@ -3130,6 +3154,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
37 changes: 23 additions & 14 deletions src/worker/repositories/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,24 +535,33 @@ async function assertUsersExist(db: D1Database, ids: string[]) {
}
}

// D1 allows at most 100 bound parameters per query.
// https://developers.cloudflare.com/d1/platform/limits/
const D1_MAX_BOUND_PARAMETERS = 100;

async function membersByProjectIds(db: D1Database, ids: string[]) {
const result = new Map<string, ProjectMember[]>();
if (!ids.length) return result;
const placeholders = ids.map(() => '?').join(',');
const {results} = await db
.prepare(
`SELECT pm.project_id, u.id, u.email, u.display_name, u.avatar_url, u.is_admin
FROM project_members pm JOIN users u ON u.id = pm.user_id
WHERE pm.project_id IN (${placeholders})
ORDER BY u.display_name COLLATE NOCASE, u.id`,
)
.bind(...ids)
.all<MemberRow>();
for (const row of results) {
const members = result.get(row.project_id) ?? [];
members.push(mapMember(row));
result.set(row.project_id, members);

for (let offset = 0; offset < ids.length; offset += D1_MAX_BOUND_PARAMETERS) {
const chunk = ids.slice(offset, offset + D1_MAX_BOUND_PARAMETERS);
const placeholders = chunk.map(() => '?').join(',');
const {results} = await db
.prepare(
`SELECT pm.project_id, u.id, u.email, u.display_name, u.avatar_url, u.is_admin
FROM project_members pm JOIN users u ON u.id = pm.user_id
WHERE pm.project_id IN (${placeholders})
ORDER BY u.display_name COLLATE NOCASE, u.id`,
)
.bind(...chunk)
.all<MemberRow>();
for (const row of results) {
const members = result.get(row.project_id) ?? [];
members.push(mapMember(row));
result.set(row.project_id, members);
}
}

return result;
}

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
Loading
Loading