Skip to content

Commit

Permalink
basic user table working with sort and search and pagination all hand…
Browse files Browse the repository at this point in the history
…led back-end
  • Loading branch information
thomhickey committed Nov 15, 2024
1 parent 4ea9cbc commit cd52ebc
Show file tree
Hide file tree
Showing 5 changed files with 306 additions and 6 deletions.
74 changes: 73 additions & 1 deletion src/backend/routers/user.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,18 @@
import { hasAuthenticated, router } from "../trpc";
import { hasAuthenticated, hasAdmin, router } from "../trpc";
import { z } from "zod";

const sortOrderSchema = z.enum(["asc", "desc"]).default("asc");
const sortBySchema = z
.enum(["first_name", "last_name", "email", "role"])
.default("first_name");

const paginationInput = z.object({
page: z.number().min(1).default(1),
pageSize: z.number().min(1).default(10),
sortBy: sortBySchema,
sortOrder: sortOrderSchema,
search: z.string().optional(),
});

export const user = router({
getMe: hasAuthenticated.query(async (req) => {
Expand All @@ -20,6 +34,64 @@ export const user = router({
return user;
}),

getUsers: hasAdmin.input(paginationInput).query(async (req) => {
const { page, pageSize, sortBy, sortOrder, search } = req.input;
const offset = (page - 1) * pageSize;

let baseQuery = req.ctx.db
.selectFrom("user")
.select([
"user_id",
"first_name",
"last_name",
"email",
"image_url",
"role",
]);

if (search) {
baseQuery = baseQuery.where((eb) =>
eb.or([
eb("first_name", "ilike", `%${search}%`),
eb("last_name", "ilike", `%${search}%`),
eb("email", "ilike", `%${search}%`),
eb("role", "ilike", `%${search}%`),
])
);
}

// Separate count query
const countQuery = req.ctx.db
.selectFrom("user")
.select(req.ctx.db.fn.countAll().as("count"));

// Apply search filter to count query if exists
if (search) {
countQuery.where((eb) =>
eb.or([
eb("first_name", "ilike", `%${search}%`),
eb("last_name", "ilike", `%${search}%`),
eb("email", "ilike", `%${search}%`),
eb("role", "ilike", `%${search}%`),
])
);
}

const [users, totalCount] = await Promise.all([
baseQuery
.orderBy(sortBy, sortOrder)
.limit(pageSize)
.offset(offset)
.execute(),
countQuery.executeTakeFirst(),
]);

return {
users,
totalCount: Number(totalCount?.count ?? 0),
totalPages: Math.ceil(Number(totalCount?.count ?? 0) / pageSize),
};
}),
/**
* @returns Whether the current user is a case manager
*/
Expand Down
2 changes: 2 additions & 0 deletions src/components/navbar/NavBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import PeopleOutline from "@mui/icons-material/PeopleOutline";
import Logout from "@mui/icons-material/Logout";
import MenuIcon from "@mui/icons-material/Menu";
import SchoolOutlined from "@mui/icons-material/SchoolOutlined";
import AdminPanelSettings from "@mui/icons-material/AdminPanelSettings";
import ContentPaste from "@mui/icons-material/ContentPaste";
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
import AppBar from "@mui/material/AppBar";
Expand Down Expand Up @@ -112,6 +113,7 @@ export default function NavBar() {
icon={<SettingsOutlined />}
text="Settings"
/>
<NavItem href="/admin" icon={<AdminPanelSettings />} text="Admin" />
<NavItem
icon={<Logout />}
text="Logout"
Expand Down
2 changes: 1 addition & 1 deletion src/components/table/table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ interface EnhancedTableProps<Person, Column> {
*/
export default function EnhancedTable<
Person extends StudentWithIep | Para,
Column extends HeadCell,
Column extends HeadCell
>({ people, onSubmit, headCells, type }: EnhancedTableProps<Person, Column>) {
const router = useRouter();

Expand Down
166 changes: 166 additions & 0 deletions src/components/table/table2.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import React, { useState } from "react";
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Box,
TextField,
Button,
TableSortLabel,
} from "@mui/material";
import { styled } from "@mui/material/styles";
import styles from "./Table.module.css";
import { visuallyHidden } from "@mui/utils";
import SearchIcon from "@mui/icons-material/Search";

export interface BaseEntity {
id?: string | number;
first_name: string;
last_name: string;
email: string;
[key: string]: string | number | undefined;
}

export interface Column<T> {
id: keyof T;
label: string;
hasInput?: boolean;
}

interface TableProps<T extends BaseEntity> {
data: T[];
columns: Column<T>[];
type: "Students" | "Staff" | "Users";
onRowClick?: (row: T) => void;
page?: number;
totalPages?: number;
onPageChange?: (page: number) => void;
sortBy: keyof T;
sortOrder: "asc" | "desc";
onSort: (sortBy: keyof T, sortOrder: "asc" | "desc") => void;
onSearch?: (search: string) => void;
searchTerm?: string;
}

const StyledTableRow = styled(TableRow)(() => ({
"&:nth-of-type(odd)": {
backgroundColor: "var(--grey-90)",
},
"&:hover": {
backgroundColor: "lightgray",
cursor: "pointer",
},
}));

export function Table2<T extends BaseEntity>({
data,
columns,
type,
onRowClick,
page = 1,
totalPages = 1,
onPageChange,
sortBy,
sortOrder,
onSort,
onSearch,
searchTerm = "",
}: TableProps<T>) {
const [localSearchTerm, setLocalSearchTerm] = useState(searchTerm);

const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
event.preventDefault();
onSearch?.(localSearchTerm);
}
};

const handleRequestSort = (property: keyof T) => {
const isAsc = sortBy === property && sortOrder === "asc";
onSort(property, isAsc ? "desc" : "asc");
};

return (
<Box sx={{ width: "100%" }}>
<Box sx={{ mb: 2, display: "flex", justifyContent: "space-between" }}>
<h3>{type}</h3>
<TextField
size="small"
placeholder="Search..."
value={localSearchTerm}
onChange={(e) => setLocalSearchTerm(e.target.value)}
onKeyDown={handleKeyDown}
InputProps={{
startAdornment: <SearchIcon />,
}}
/>
</Box>

<TableContainer>
<Table>
<TableHead className={styles.header}>
<TableRow>
{columns.map((column) => (
<TableCell
key={column.id.toString()}
align="left"
sortDirection={sortBy === column.id ? sortOrder : false}
>
<TableSortLabel
active={sortBy === column.id}
direction={sortBy === column.id ? sortOrder : "asc"}
onClick={() => handleRequestSort(column.id)}
className={styles.headerLabel}
>
{column.label}
{sortBy === column.id ? (
<Box component="span" sx={visuallyHidden}>
{sortOrder === "desc"
? "sorted descending"
: "sorted ascending"}
</Box>
) : null}
</TableSortLabel>
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{data.map((row, index) => (
<StyledTableRow
key={row.id || index}
onClick={() => onRowClick?.(row)}
>
{columns.map((column) => (
<TableCell key={column.id.toString()}>
{row[column.id]}
</TableCell>
))}
</StyledTableRow>
))}
</TableBody>
</Table>
</TableContainer>

{onPageChange && (
<Box sx={{ mt: 2, display: "flex", justifyContent: "center", gap: 2 }}>
<Button disabled={page === 1} onClick={() => onPageChange(page - 1)}>
Previous
</Button>
<Box sx={{ display: "flex", alignItems: "center" }}>
Page {page} of {totalPages}
</Box>
<Button
disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)}
>
Next
</Button>
</Box>
)}
</Box>
);
}
68 changes: 64 additions & 4 deletions src/pages/admin/index.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,71 @@
import { requiresAdminAuth } from "@/client/lib/protected-page";
import Link from "next/link";
import { trpc } from "@/client/lib/trpc";
import { Table2, Column, BaseEntity } from "@/components/table/table2";
import { useRouter } from "next/router";
import { useState } from "react";

interface User extends BaseEntity {
user_id: string;
role: string;
}

const AdminHome: React.FC = () => {
const router = useRouter();
const [page, setPage] = useState(1);
const [sortBy, setSortBy] = useState<keyof User>("first_name");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [searchTerm] = useState("");
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
const pageSize = 10;

const { data, isLoading } = trpc.user.getUsers.useQuery({
page,
pageSize,
sortBy,

Check failure on line 24 in src/pages/admin/index.tsx

View workflow job for this annotation

GitHub Actions / type-check

Type 'keyof User' is not assignable to type '"role" | "first_name" | "last_name" | "email" | undefined'.
sortOrder,
search: debouncedSearchTerm,
});

const handleSort = (newSortBy: keyof User, newSortOrder: "asc" | "desc") => {
setSortBy(newSortBy);
setSortOrder(newSortOrder);
};

const handleSearch = (search: string) => {
setDebouncedSearchTerm(search);
setPage(1); // Reset to first page when searching
};

if (isLoading) return <div>Loading...</div>;

const columns: Column<User>[] = [
{ id: "first_name", label: "First Name" },
{ id: "last_name", label: "Last Name" },
{ id: "email", label: "Email" },
{ id: "role", label: "Role" },
];

const handleRowClick = async (user: User) => {
await router.push(`/staff/${user.user_id}`);
};

const AdminHome = () => {
return (
<div>
<h1>Admin Utilities</h1>
<Link href="/admin/postgres">Postgres info</Link>
<h3>Admin Utilities</h3>
<Table2<User>
data={data?.users ?? []}

Check failure on line 56 in src/pages/admin/index.tsx

View workflow job for this annotation

GitHub Actions / type-check

Type '{ role: string; user_id: string; first_name: string; last_name: string; email: string; image_url: string | null; }[]' is not assignable to type 'User[]'.
columns={columns}
type="Users"
onRowClick={handleRowClick}
page={page}
totalPages={data?.totalPages ?? 1}
onPageChange={setPage}
sortBy={sortBy}
sortOrder={sortOrder}
onSort={handleSort}
onSearch={handleSearch}
searchTerm={searchTerm}
/>
</div>
);
};
Expand Down

0 comments on commit cd52ebc

Please sign in to comment.