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
7 changes: 4 additions & 3 deletions src/auth/user-directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ export async function listUsers(actorId: string, search: UserSearch): Promise<Us
}
const where = and(...filters);
const page = search.page ?? 1;
const pageSize = search.pageSize ?? USER_PAGE_SIZE;

const [{ total }] = await transaction.select({ total: count() }).from(user).where(where);
const rows = await transaction
Expand Down Expand Up @@ -253,8 +254,8 @@ export async function listUsers(actorId: string, search: UserSearch): Promise<Us
: [asc(user.name)]),
asc(user.id),
)
.limit(USER_PAGE_SIZE)
.offset((page - 1) * USER_PAGE_SIZE);
.limit(pageSize)
.offset((page - 1) * pageSize);

const ids = rows.map((row) => row.id);
const linked = ids.length
Expand Down Expand Up @@ -333,7 +334,7 @@ export async function listUsers(actorId: string, search: UserSearch): Promise<Us
}),
total,
page,
pageSize: USER_PAGE_SIZE,
pageSize,
membership: { socio: groups.socio.source, direttivo: groups.direttivo.source },
};
},
Expand Down
16 changes: 13 additions & 3 deletions src/auth/users.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vite-plus/test";
import {
USER_PAGE_SIZE,
hasUserFilters,
isPlaceholderEmail,
parseUserSearch,
Expand All @@ -24,23 +25,32 @@ describe("user directory filters", () => {
});

it("drops malformed values instead of refusing the whole request", () => {
const params = new URLSearchParams("q=%20%20&sort=random&page=0&student=maybe&extra=1");
const params = new URLSearchParams(
"q=%20%20&sort=random&page=0&pageSize=30&student=maybe&extra=1",
);
expect(parseUserSearch(params)).toEqual({});
expect(parseUserSearch(new URLSearchParams("page=two"))).toEqual({});
});

it("round-trips through the API query string", () => {
const search = { q: "123456789", student: "yes", page: 2 } as const;
const search = { q: "123456789", student: "yes", page: 2, pageSize: 50 } as const;
expect(parseUserSearch(userSearchParams(search))).toEqual(search);
});

it("defaults to 25 rows and accepts the admin page sizes", () => {
expect(USER_PAGE_SIZE).toBe(25);
for (const pageSize of [10, 20, 25, 50, 100]) {
expect(parseUserSearch(new URLSearchParams(`pageSize=${pageSize}`)).pageSize).toBe(pageSize);
}
});

it("keeps a numeric search from the page URL as text", () => {
// The router parses `?q=123456789` into a number before validating it.
expect(userSearchSchema.parse({ q: 123456789 })).toEqual({ q: "123456789" });
});

it("tells filters apart from sorting and paging", () => {
expect(hasUserFilters({ sort: "newest", page: 2 })).toBe(false);
expect(hasUserFilters({ sort: "newest", page: 2, pageSize: 50 })).toBe(false);
expect(hasUserFilters({ telegram: "no" })).toBe(true);
expect(hasUserFilters({ role: "moderator" })).toBe(true);
});
Expand Down
9 changes: 8 additions & 1 deletion src/auth/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ export type GroupTrait = "socio" | "direttivo";

export const USER_SORTS = ["name", "newest", "oldest"] as const;

export const USER_PAGE_SIZE = 50;
export const USER_PAGE_SIZES = [10, 20, 25, 50, 100] as const;
export const USER_PAGE_SIZE = 25;

// The router parses search values as JSON, so a Telegram ID typed into the search box
// arrives as a number.
Expand All @@ -48,6 +49,12 @@ export const userSearchSchema = z.object({
role: text(MAX_KEY_LENGTH),
sort: z.enum(USER_SORTS).optional().catch(undefined),
page: z.coerce.number().int().min(1).max(100_000).optional().catch(undefined),
pageSize: z.coerce
.number()
.int()
.refine((value) => USER_PAGE_SIZES.some((size) => size === value))
.optional()
.catch(undefined),
socio: presence,
direttivo: presence,
student: presence,
Expand Down
104 changes: 64 additions & 40 deletions src/routes/users/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
type UserListPage,
type UserSearch,
type UserTrait,
USER_PAGE_SIZES,
USER_TRAITS,
hasUserFilters,
userSearchSchema,
Expand Down Expand Up @@ -351,7 +352,10 @@ function UsersIndex() {
className="h-7 rounded-full px-3"
onClick={() => {
setQuery("");
void navigate({ search: { sort: search.sort }, replace: true });
void navigate({
search: { sort: search.sort, pageSize: search.pageSize },
replace: true,
});
}}
>
<X aria-hidden="true" />
Expand Down Expand Up @@ -490,45 +494,65 @@ function UsersIndex() {
</Card>
)}

{data.total > data.pageSize && (
<nav aria-label="Pages" className="flex items-center justify-between gap-3">
<Button variant="outline" disabled={data.page === 1} asChild={data.page > 1}>
{data.page > 1 ? (
<Link
from={Route.fullPath}
search={(previous) => ({ ...previous, page: data.page - 1 })}
>
<ChevronLeft aria-hidden="true" />
Previous
</Link>
) : (
<>
<ChevronLeft aria-hidden="true" />
Previous
</>
)}
</Button>
<span className="text-sm text-muted-foreground">
{first}–{last} of {data.total}
</span>
<Button variant="outline" disabled={!hasNext} asChild={hasNext}>
{hasNext ? (
<Link
from={Route.fullPath}
search={(previous) => ({ ...previous, page: data.page + 1 })}
>
Next
<ChevronRight aria-hidden="true" />
</Link>
) : (
<>
Next
<ChevronRight aria-hidden="true" />
</>
)}
</Button>
</nav>
)}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Rows per page</span>
<Select
value={String(data.pageSize)}
onValueChange={(value) => update({ pageSize: Number(value) })}
>
<SelectTrigger aria-label="Rows per page" className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
{USER_PAGE_SIZES.map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{data.total > data.pageSize && (
<nav aria-label="Pages" className="flex items-center gap-3">
<Button variant="outline" disabled={data.page === 1} asChild={data.page > 1}>
{data.page > 1 ? (
<Link
from={Route.fullPath}
search={(previous) => ({ ...previous, page: data.page - 1 })}
>
<ChevronLeft aria-hidden="true" />
Previous
</Link>
) : (
<>
<ChevronLeft aria-hidden="true" />
Previous
</>
)}
</Button>
<span className="text-sm text-muted-foreground">
{first}–{last} of {data.total}
</span>
<Button variant="outline" disabled={!hasNext} asChild={hasNext}>
{hasNext ? (
<Link
from={Route.fullPath}
search={(previous) => ({ ...previous, page: data.page + 1 })}
>
Next
<ChevronRight aria-hidden="true" />
</Link>
) : (
<>
Next
<ChevronRight aria-hidden="true" />
</>
)}
</Button>
</nav>
)}
</div>
</div>
);
}
Loading