From cd52ebc5c17f31a8466f472fe0627ea6a4569083 Mon Sep 17 00:00:00 2001 From: thom Date: Thu, 14 Nov 2024 20:40:05 -0800 Subject: [PATCH 01/19] basic user table working with sort and search and pagination all handled back-end --- src/backend/routers/user.ts | 74 +++++++++++++- src/components/navbar/NavBar.tsx | 2 + src/components/table/table.tsx | 2 +- src/components/table/table2.tsx | 166 +++++++++++++++++++++++++++++++ src/pages/admin/index.tsx | 68 ++++++++++++- 5 files changed, 306 insertions(+), 6 deletions(-) create mode 100644 src/components/table/table2.tsx diff --git a/src/backend/routers/user.ts b/src/backend/routers/user.ts index 0c0bfdb3..f284b70a 100644 --- a/src/backend/routers/user.ts +++ b/src/backend/routers/user.ts @@ -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) => { @@ -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 */ diff --git a/src/components/navbar/NavBar.tsx b/src/components/navbar/NavBar.tsx index 4bc04c20..5bd641d0 100644 --- a/src/components/navbar/NavBar.tsx +++ b/src/components/navbar/NavBar.tsx @@ -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"; @@ -112,6 +113,7 @@ export default function NavBar() { icon={} text="Settings" /> + } text="Admin" /> } text="Logout" diff --git a/src/components/table/table.tsx b/src/components/table/table.tsx index bbd0cb7b..563cd371 100644 --- a/src/components/table/table.tsx +++ b/src/components/table/table.tsx @@ -264,7 +264,7 @@ interface EnhancedTableProps { */ export default function EnhancedTable< Person extends StudentWithIep | Para, - Column extends HeadCell, + Column extends HeadCell >({ people, onSubmit, headCells, type }: EnhancedTableProps) { const router = useRouter(); diff --git a/src/components/table/table2.tsx b/src/components/table/table2.tsx new file mode 100644 index 00000000..161f137f --- /dev/null +++ b/src/components/table/table2.tsx @@ -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 { + id: keyof T; + label: string; + hasInput?: boolean; +} + +interface TableProps { + data: T[]; + columns: Column[]; + 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({ + data, + columns, + type, + onRowClick, + page = 1, + totalPages = 1, + onPageChange, + sortBy, + sortOrder, + onSort, + onSearch, + searchTerm = "", +}: TableProps) { + const [localSearchTerm, setLocalSearchTerm] = useState(searchTerm); + + const handleKeyDown = (event: React.KeyboardEvent) => { + 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 ( + + +

{type}

+ setLocalSearchTerm(e.target.value)} + onKeyDown={handleKeyDown} + InputProps={{ + startAdornment: , + }} + /> +
+ + + + + + {columns.map((column) => ( + + handleRequestSort(column.id)} + className={styles.headerLabel} + > + {column.label} + {sortBy === column.id ? ( + + {sortOrder === "desc" + ? "sorted descending" + : "sorted ascending"} + + ) : null} + + + ))} + + + + {data.map((row, index) => ( + onRowClick?.(row)} + > + {columns.map((column) => ( + + {row[column.id]} + + ))} + + ))} + +
+
+ + {onPageChange && ( + + + + Page {page} of {totalPages} + + + + )} +
+ ); +} diff --git a/src/pages/admin/index.tsx b/src/pages/admin/index.tsx index 31129bd1..53100b0e 100644 --- a/src/pages/admin/index.tsx +++ b/src/pages/admin/index.tsx @@ -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("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, + 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
Loading...
; + + const columns: Column[] = [ + { 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 (
-

Admin Utilities

- Postgres info +

Admin Utilities

+ + data={data?.users ?? []} + columns={columns} + type="Users" + onRowClick={handleRowClick} + page={page} + totalPages={data?.totalPages ?? 1} + onPageChange={setPage} + sortBy={sortBy} + sortOrder={sortOrder} + onSort={handleSort} + onSearch={handleSearch} + searchTerm={searchTerm} + />
); }; From 38c89c2601a2c49df35267f8c3a911e95c129071 Mon Sep 17 00:00:00 2001 From: thom Date: Thu, 14 Nov 2024 21:22:16 -0800 Subject: [PATCH 02/19] feat: adding users --- src/backend/routers/user.ts | 37 +++++++++++ src/components/table/table2.tsx | 82 +++++++++++++++++++++++- src/pages/admin/index.tsx | 107 +++++++++++++++++++++++++++++--- 3 files changed, 215 insertions(+), 11 deletions(-) diff --git a/src/backend/routers/user.ts b/src/backend/routers/user.ts index f284b70a..42e1102e 100644 --- a/src/backend/routers/user.ts +++ b/src/backend/routers/user.ts @@ -14,6 +14,15 @@ const paginationInput = z.object({ search: z.string().optional(), }); +const createUserSchema = z.object({ + first_name: z.string(), + last_name: z.string(), + email: z.string().email(), + role: z + .enum(["ADMIN", "CASE_MANAGER", "PARA"]) + .transform((role) => role.toLowerCase()), +}); + export const user = router({ getMe: hasAuthenticated.query(async (req) => { const { userId } = req.ctx.auth; @@ -106,4 +115,32 @@ export const user = router({ return result.length > 0; }), + + createUser: hasAdmin.input(createUserSchema).mutation(async (req) => { + const { first_name, last_name, email, role } = req.input; + + // Check if user already exists + const existingUser = await req.ctx.db + .selectFrom("user") + .where("email", "=", email) + .selectAll() + .executeTakeFirst(); + + if (existingUser) { + throw new Error("User with this email already exists"); + } + + const user = await req.ctx.db + .insertInto("user") + .values({ + first_name, + last_name, + email, + role, + }) + .returningAll() + .executeTakeFirstOrThrow(); + + return user; + }), }); diff --git a/src/components/table/table2.tsx b/src/components/table/table2.tsx index 161f137f..87684d3a 100644 --- a/src/components/table/table2.tsx +++ b/src/components/table/table2.tsx @@ -27,13 +27,13 @@ export interface BaseEntity { export interface Column { id: keyof T; label: string; - hasInput?: boolean; + renderInput?: (value: any, onChange: (value: any) => void) => React.ReactNode; } interface TableProps { data: T[]; columns: Column[]; - type: "Students" | "Staff" | "Users"; + type: string; onRowClick?: (row: T) => void; page?: number; totalPages?: number; @@ -43,6 +43,8 @@ interface TableProps { onSort: (sortBy: keyof T, sortOrder: "asc" | "desc") => void; onSearch?: (search: string) => void; searchTerm?: string; + onAdd?: (data: Omit) => Promise; + showAddRow?: boolean; } const StyledTableRow = styled(TableRow)(() => ({ @@ -68,8 +70,12 @@ export function Table2({ onSort, onSearch, searchTerm = "", + onAdd, + showAddRow = false, }: TableProps) { const [localSearchTerm, setLocalSearchTerm] = useState(searchTerm); + const [newRowData, setNewRowData] = useState>({}); + const [isAddingRow, setIsAddingRow] = useState(false); const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key === "Enter") { @@ -83,10 +89,30 @@ export function Table2({ onSort(property, isAsc ? "desc" : "asc"); }; + const handleAddRow = async (e: React.FormEvent) => { + e.preventDefault(); + if (onAdd) { + try { + await onAdd(newRowData as Omit); + setNewRowData({}); + setIsAddingRow(false); + } catch (error) { + console.error(error); + } + } + }; + return ( -

{type}

+ +

{type}

+ {onAdd && !isAddingRow && showAddRow && ( + + )} +
({ + {isAddingRow && ( + + +
+ + {columns.map((column) => ( + + {column.renderInput?.( + newRowData[column.id], + (value: T[keyof T]) => + setNewRowData((prev) => ({ + ...prev, + [column.id]: value, + })) + ) ?? ( + + setNewRowData((prev) => ({ + ...prev, + [column.id]: e.target.value, + })) + } + /> + )} + + ))} + + + + + +
+
+
+ )} {data.map((row, index) => ( { + const utils = trpc.useContext(); const router = useRouter(); const [page, setPage] = useState(1); const [sortBy, setSortBy] = useState("first_name"); const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); - const [searchTerm] = useState(""); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); + const [searchTerm, setSearchTerm] = useState(""); const pageSize = 10; const { data, isLoading } = trpc.user.getUsers.useQuery({ @@ -23,26 +37,101 @@ const AdminHome: React.FC = () => { pageSize, sortBy, sortOrder, - search: debouncedSearchTerm, + search: searchTerm, + }); + + const createUserMutation = trpc.user.createUser.useMutation({ + onSuccess: async () => { + await utils.user.getUsers.invalidate(); + }, }); + const handleAddUser = async (userData: Omit) => { + try { + await createUserMutation.mutateAsync({ + ...userData, + role: userData.role || "PARA", // Set default role if needed + }); + } catch (error) { + console.error(error); + } + }; + const handleSort = (newSortBy: keyof User, newSortOrder: "asc" | "desc") => { setSortBy(newSortBy); setSortOrder(newSortOrder); }; const handleSearch = (search: string) => { - setDebouncedSearchTerm(search); + setSearchTerm(search); setPage(1); // Reset to first page when searching }; if (isLoading) return
Loading...
; const columns: Column[] = [ - { id: "first_name", label: "First Name" }, - { id: "last_name", label: "Last Name" }, - { id: "email", label: "Email" }, - { id: "role", label: "Role" }, + { + id: "first_name", + label: "First Name", + renderInput: (value, onChange) => ( + onChange(e.target.value)} + /> + ), + }, + { + id: "last_name", + label: "Last Name", + renderInput: (value, onChange) => ( + onChange(e.target.value)} + /> + ), + }, + { + id: "email", + label: "Email", + renderInput: (value, onChange) => ( + onChange(e.target.value)} + /> + ), + }, + { + id: "role", + label: "Role", + renderInput: (value, onChange) => ( + + Role + + + ), + }, ]; const handleRowClick = async (user: User) => { @@ -65,6 +154,8 @@ const AdminHome: React.FC = () => { onSort={handleSort} onSearch={handleSearch} searchTerm={searchTerm} + onAdd={handleAddUser} + showAddRow={true} /> ); From 0ff2db0e4d7e420c9f9e4176f88a36992e050d1c Mon Sep 17 00:00:00 2001 From: thom Date: Fri, 15 Nov 2024 11:35:21 -0800 Subject: [PATCH 03/19] fix: types --- src/components/table/table2.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/table/table2.tsx b/src/components/table/table2.tsx index 87684d3a..e4b9a9e1 100644 --- a/src/components/table/table2.tsx +++ b/src/components/table/table2.tsx @@ -24,10 +24,13 @@ export interface BaseEntity { [key: string]: string | number | undefined; } -export interface Column { +export interface Column { id: keyof T; label: string; - renderInput?: (value: any, onChange: (value: any) => void) => React.ReactNode; + renderInput?: ( + value: T[keyof T] | undefined, + onChange: (value: T[keyof T]) => void + ) => React.ReactNode; } interface TableProps { From 726b5c5407fc8863357d5c7292b9b459fcc0fc24 Mon Sep 17 00:00:00 2001 From: thom Date: Fri, 15 Nov 2024 12:21:24 -0800 Subject: [PATCH 04/19] feat: edit users --- src/backend/routers/user.ts | 52 +++++++++++ src/components/table/table2.tsx | 5 +- src/pages/admin/index.tsx | 13 +-- src/pages/users/[user_id].tsx | 158 ++++++++++++++++++++++++++++++++ src/types/auth.ts | 13 +++ 5 files changed, 232 insertions(+), 9 deletions(-) create mode 100644 src/pages/users/[user_id].tsx diff --git a/src/backend/routers/user.ts b/src/backend/routers/user.ts index 42e1102e..7a1f8f39 100644 --- a/src/backend/routers/user.ts +++ b/src/backend/routers/user.ts @@ -1,5 +1,6 @@ import { hasAuthenticated, hasAdmin, router } from "../trpc"; import { z } from "zod"; +import { UserType, ROLE_OPTIONS } from "@/types/auth"; const sortOrderSchema = z.enum(["asc", "desc"]).default("asc"); const sortBySchema = z @@ -23,6 +24,8 @@ const createUserSchema = z.object({ .transform((role) => role.toLowerCase()), }); +const roleValues = ROLE_OPTIONS.map((r) => r.value) as [string, ...string[]]; + export const user = router({ getMe: hasAuthenticated.query(async (req) => { const { userId } = req.ctx.auth; @@ -143,4 +146,53 @@ export const user = router({ return user; }), + + getUserById: hasAdmin + .input(z.object({ user_id: z.string() })) + .query(async (req) => { + const { user_id } = req.input; + + return await req.ctx.db + .selectFrom("user") + .selectAll() + .where("user_id", "=", user_id) + .executeTakeFirstOrThrow(); + }), + + editUser: hasAdmin + .input( + z.object({ + user_id: z.string(), + first_name: z.string(), + last_name: z.string(), + email: z.string().email(), + role: z.enum(roleValues).transform((role) => { + switch (role) { + case "ADMIN": + return UserType.Admin; + case "CASE_MANAGER": + return UserType.CaseManager; + case "PARA": + return UserType.Para; + default: + return UserType.User; + } + }), + }) + ) + .mutation(async (req) => { + const { user_id, first_name, last_name, email, role } = req.input; + + return await req.ctx.db + .updateTable("user") + .set({ + first_name, + last_name, + email: email.toLowerCase(), + role, + }) + .where("user_id", "=", user_id) + .returningAll() + .executeTakeFirstOrThrow(); + }), }); diff --git a/src/components/table/table2.tsx b/src/components/table/table2.tsx index e4b9a9e1..011977ca 100644 --- a/src/components/table/table2.tsx +++ b/src/components/table/table2.tsx @@ -31,6 +31,7 @@ export interface Column { value: T[keyof T] | undefined, onChange: (value: T[keyof T]) => void ) => React.ReactNode; + renderCell?: (value: T[keyof T]) => React.ReactNode; } interface TableProps { @@ -215,7 +216,9 @@ export function Table2({ > {columns.map((column) => ( - {row[column.id]} + {column.renderCell + ? column.renderCell(row[column.id]) + : row[column.id]} ))}
diff --git a/src/pages/admin/index.tsx b/src/pages/admin/index.tsx index 85d9f80f..62f308df 100644 --- a/src/pages/admin/index.tsx +++ b/src/pages/admin/index.tsx @@ -3,6 +3,7 @@ import { trpc } from "@/client/lib/trpc"; import { Table2, Column, BaseEntity } from "@/components/table/table2"; import { useRouter } from "next/router"; import { useState } from "react"; +import { ROLE_OPTIONS } from "@/types/auth"; import { TextField, FormControl, @@ -11,18 +12,13 @@ import { MenuItem, } from "@mui/material"; import { SelectChangeEvent } from "@mui/material/Select"; +import { getRoleLabel } from "@/types/auth"; interface User extends BaseEntity { user_id: string; role: string; } -const ROLE_OPTIONS = [ - { label: "Para", value: "PARA" }, - { label: "Case Manager", value: "CASE_MANAGER" }, - { label: "Admin", value: "ADMIN" }, -] as const; - const AdminHome: React.FC = () => { const utils = trpc.useContext(); const router = useRouter(); @@ -109,11 +105,12 @@ const AdminHome: React.FC = () => { { id: "role", label: "Role", + renderCell: (value) => getRoleLabel(value as string), renderInput: (value, onChange) => ( Role + + + + + + + + + +
+ + + + ); +}; + +export default ViewUserPage; diff --git a/src/types/auth.ts b/src/types/auth.ts index 8d805845..2023952e 100644 --- a/src/types/auth.ts +++ b/src/types/auth.ts @@ -25,3 +25,16 @@ export enum UserType { CaseManager = "case_manager", Admin = "admin", } + +export const ROLE_OPTIONS = [ + { label: "Para", value: "PARA" }, + { label: "Case Manager", value: "CASE_MANAGER" }, + { label: "Admin", value: "ADMIN" }, +] as const; + +export function getRoleLabel(role: string): string { + const option = ROLE_OPTIONS.find( + (opt) => opt.value.toLowerCase() === role.toLowerCase() + ); + return option?.label || role; +} From df0d875837535ff10c655e4af018a40ebe12b7cb Mon Sep 17 00:00:00 2001 From: thom Date: Fri, 15 Nov 2024 13:03:45 -0800 Subject: [PATCH 05/19] feat: edit user, role dropdown --- .../design_system/dropdown/Dropdown.tsx | 2 +- src/pages/admin/index.tsx | 1 - src/pages/users/[user_id].tsx | 150 +++++++++++------- 3 files changed, 90 insertions(+), 63 deletions(-) diff --git a/src/components/design_system/dropdown/Dropdown.tsx b/src/components/design_system/dropdown/Dropdown.tsx index c4625605..b2a831c3 100644 --- a/src/components/design_system/dropdown/Dropdown.tsx +++ b/src/components/design_system/dropdown/Dropdown.tsx @@ -24,7 +24,7 @@ interface DropdownProps { optionDisabled?: string[]; } -const Dropdown = ({ +export const Dropdown = ({ itemList, selectedOption, setSelectedOption, diff --git a/src/pages/admin/index.tsx b/src/pages/admin/index.tsx index 62f308df..5f8cfe08 100644 --- a/src/pages/admin/index.tsx +++ b/src/pages/admin/index.tsx @@ -137,7 +137,6 @@ const AdminHome: React.FC = () => { return (
-

Admin Utilities

data={data?.users ?? []} columns={columns} diff --git a/src/pages/users/[user_id].tsx b/src/pages/users/[user_id].tsx index 63de3fa0..2f0751ef 100644 --- a/src/pages/users/[user_id].tsx +++ b/src/pages/users/[user_id].tsx @@ -1,5 +1,6 @@ import { trpc } from "@/client/lib/trpc"; -import { Box, Button, Container, Modal, Stack } from "@mui/material"; +import { Box, Button, Container, Modal, Stack, TextField } from "@mui/material"; +import Typography from "@mui/material/Typography"; import { useRouter } from "next/router"; import { useState } from "react"; import { UserType, ROLE_OPTIONS } from "@/types/auth"; @@ -8,6 +9,7 @@ import $button from "@/components/design_system/button/Button.module.css"; import $Form from "@/styles/Form.module.css"; import $input from "@/styles/Input.module.css"; import { getRoleLabel } from "@/types/auth"; +import { Dropdown } from "@/components/design_system/dropdown/Dropdown"; interface UserFormData { first_name: string; @@ -42,18 +44,21 @@ const ViewUserPage = () => { onSuccess: () => utils.user.getUserById.invalidate(), }); + const [selectedRole, setSelectedRole] = useState( + user?.role.toUpperCase() || "" + ); + const handleEditUser = (e: React.FormEvent) => { e.preventDefault(); const formData = new FormData(e.currentTarget); if (!user) return; - const role = formData.get("role") as string; const userData: UserFormData = { first_name: formData.get("firstName") as string, last_name: formData.get("lastName") as string, email: formData.get("email") as string, - role: role as UserType, + role: selectedRole as UserType, }; editMutation.mutate({ @@ -74,7 +79,9 @@ const ViewUserPage = () => {

{user.first_name} {user.last_name}

- + @@ -89,65 +96,86 @@ const ViewUserPage = () => { - -

Edit User

-
-
- - - - -
- - - - -
+ + + + + + + + + + + + + + + + + + + + + + + +
From 601dbb2a5284ea0b09709af435011661aa76df3b Mon Sep 17 00:00:00 2001 From: thom Date: Fri, 15 Nov 2024 15:40:37 -0800 Subject: [PATCH 06/19] fix: styling + search query counts/pagination --- src/backend/routers/user.ts | 4 ++-- src/components/design_system/dropdown/Dropdown.tsx | 9 ++++++--- src/pages/admin/index.tsx | 2 +- src/pages/users/[user_id].tsx | 3 ++- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/backend/routers/user.ts b/src/backend/routers/user.ts index 7a1f8f39..5be10135 100644 --- a/src/backend/routers/user.ts +++ b/src/backend/routers/user.ts @@ -73,13 +73,13 @@ export const user = router({ } // Separate count query - const countQuery = req.ctx.db + let 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) => + countQuery = countQuery.where((eb) => eb.or([ eb("first_name", "ilike", `%${search}%`), eb("last_name", "ilike", `%${search}%`), diff --git a/src/components/design_system/dropdown/Dropdown.tsx b/src/components/design_system/dropdown/Dropdown.tsx index b2a831c3..69940c96 100644 --- a/src/components/design_system/dropdown/Dropdown.tsx +++ b/src/components/design_system/dropdown/Dropdown.tsx @@ -38,7 +38,6 @@ export const Dropdown = ({ }; return ( - // Minimum styles used. More can be defined in className. {label} @@ -48,8 +47,13 @@ export const Dropdown = ({ value={selectedOption} label={label} onChange={handleChange} - // Allow disabling of form disabled={formDisabled} + MenuProps={{ + PaperProps: { + elevation: 1, + sx: { maxHeight: 300 }, + }, + }} > {itemList?.map((item) => ( {item.label} diff --git a/src/pages/admin/index.tsx b/src/pages/admin/index.tsx index 5f8cfe08..3cef60ff 100644 --- a/src/pages/admin/index.tsx +++ b/src/pages/admin/index.tsx @@ -26,7 +26,7 @@ const AdminHome: React.FC = () => { const [sortBy, setSortBy] = useState("first_name"); const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); const [searchTerm, setSearchTerm] = useState(""); - const pageSize = 10; + const pageSize = 2; const { data, isLoading } = trpc.user.getUsers.useQuery({ page, diff --git a/src/pages/users/[user_id].tsx b/src/pages/users/[user_id].tsx index 2f0751ef..ec05de0e 100644 --- a/src/pages/users/[user_id].tsx +++ b/src/pages/users/[user_id].tsx @@ -150,8 +150,9 @@ const ViewUserPage = () => { itemList={ROLE_OPTIONS} selectedOption={selectedRole} setSelectedOption={setSelectedRole} - label="Role" + label="Role *" className={$CompassModal.editModalTextfield} + required /> From a59419ec3f048e03538b64349b43faf8e80dc1b5 Mon Sep 17 00:00:00 2001 From: thom Date: Fri, 15 Nov 2024 15:40:51 -0800 Subject: [PATCH 07/19] fix: styling --- src/theme.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/theme.ts b/src/theme.ts index 5bbc2bfa..2be8399e 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -202,5 +202,36 @@ export const compassTheme = createTheme({ }), }, }, + MuiSelect: { + styleOverrides: { + root: { + "& .MuiOutlinedInput-notchedOutline": { + borderColor: "var(--primary)", + borderWidth: "1px", + }, + "&.Mui-focused": { + "& .MuiOutlinedInput-notchedOutline": { + borderColor: "var(--primary)", + borderWidth: "2px", + }, + }, + "&:hover:not(.Mui-focused)": { + "& .MuiOutlinedInput-notchedOutline": { + borderColor: "var(--grey-10)", + }, + }, + }, + }, + }, + MuiInputLabel: { + styleOverrides: { + root: { + color: "var(--grey-10)", + "&.Mui-focused": { + color: "var(--primary)", + }, + }, + }, + }, }, }); From e6dd57eb2f0530985a7265c3dbf6de2a8eab9417 Mon Sep 17 00:00:00 2001 From: thom Date: Sat, 16 Nov 2024 12:32:21 -0800 Subject: [PATCH 08/19] fix: role init + sharing types backend/frontend --- src/backend/routers/user.ts | 4 ++-- src/pages/admin/index.tsx | 13 +++++++++---- src/pages/users/[user_id].tsx | 12 ++++++++---- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/backend/routers/user.ts b/src/backend/routers/user.ts index 5be10135..d359fda8 100644 --- a/src/backend/routers/user.ts +++ b/src/backend/routers/user.ts @@ -2,8 +2,8 @@ import { hasAuthenticated, hasAdmin, router } from "../trpc"; import { z } from "zod"; import { UserType, ROLE_OPTIONS } from "@/types/auth"; -const sortOrderSchema = z.enum(["asc", "desc"]).default("asc"); -const sortBySchema = z +export const sortOrderSchema = z.enum(["asc", "desc"]).default("asc"); +export const sortBySchema = z .enum(["first_name", "last_name", "email", "role"]) .default("first_name"); diff --git a/src/pages/admin/index.tsx b/src/pages/admin/index.tsx index 3cef60ff..d3b11fbf 100644 --- a/src/pages/admin/index.tsx +++ b/src/pages/admin/index.tsx @@ -13,18 +13,23 @@ import { } from "@mui/material"; import { SelectChangeEvent } from "@mui/material/Select"; import { getRoleLabel } from "@/types/auth"; +import { sortBySchema, sortOrderSchema } from "@/backend/routers/user"; +import { z } from "zod"; interface User extends BaseEntity { user_id: string; role: string; } +type SortBy = z.infer; +type SortOrder = z.infer; + const AdminHome: React.FC = () => { const utils = trpc.useContext(); const router = useRouter(); const [page, setPage] = useState(1); - const [sortBy, setSortBy] = useState("first_name"); - const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); + const [sortBy, setSortBy] = useState("first_name"); + const [sortOrder, setSortOrder] = useState("asc"); const [searchTerm, setSearchTerm] = useState(""); const pageSize = 2; @@ -46,14 +51,14 @@ const AdminHome: React.FC = () => { try { await createUserMutation.mutateAsync({ ...userData, - role: userData.role || "PARA", // Set default role if needed + role: userData.role || "PARA", }); } catch (error) { console.error(error); } }; - const handleSort = (newSortBy: keyof User, newSortOrder: "asc" | "desc") => { + const handleSort = (newSortBy: SortBy, newSortOrder: SortOrder) => { setSortBy(newSortBy); setSortOrder(newSortOrder); }; diff --git a/src/pages/users/[user_id].tsx b/src/pages/users/[user_id].tsx index ec05de0e..d7f41741 100644 --- a/src/pages/users/[user_id].tsx +++ b/src/pages/users/[user_id].tsx @@ -2,7 +2,7 @@ import { trpc } from "@/client/lib/trpc"; import { Box, Button, Container, Modal, Stack, TextField } from "@mui/material"; import Typography from "@mui/material/Typography"; import { useRouter } from "next/router"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { UserType, ROLE_OPTIONS } from "@/types/auth"; import $CompassModal from "@/components/design_system/modal/CompassModal.module.css"; import $button from "@/components/design_system/button/Button.module.css"; @@ -44,9 +44,13 @@ const ViewUserPage = () => { onSuccess: () => utils.user.getUserById.invalidate(), }); - const [selectedRole, setSelectedRole] = useState( - user?.role.toUpperCase() || "" - ); + const [selectedRole, setSelectedRole] = useState(""); + + useEffect(() => { + if (user?.role) { + setSelectedRole(user.role.toUpperCase()); + } + }, [user?.role]); const handleEditUser = (e: React.FormEvent) => { e.preventDefault(); From 5fc0eba008cc303630dd1a29433896684f6706c9 Mon Sep 17 00:00:00 2001 From: thom Date: Sat, 16 Nov 2024 12:33:02 -0800 Subject: [PATCH 09/19] fix: lint --- src/pages/users/[user_id].tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/pages/users/[user_id].tsx b/src/pages/users/[user_id].tsx index d7f41741..1e0ea20e 100644 --- a/src/pages/users/[user_id].tsx +++ b/src/pages/users/[user_id].tsx @@ -6,8 +6,6 @@ import { useState, useEffect } from "react"; import { UserType, ROLE_OPTIONS } from "@/types/auth"; import $CompassModal from "@/components/design_system/modal/CompassModal.module.css"; import $button from "@/components/design_system/button/Button.module.css"; -import $Form from "@/styles/Form.module.css"; -import $input from "@/styles/Input.module.css"; import { getRoleLabel } from "@/types/auth"; import { Dropdown } from "@/components/design_system/dropdown/Dropdown"; From 9e70782add57daf9a8f529fcb8682caa6559baed Mon Sep 17 00:00:00 2001 From: thom Date: Sat, 16 Nov 2024 17:53:18 -0800 Subject: [PATCH 10/19] feat: better error handling ux + toas fixes --- src/backend/routers/user.ts | 6 ++- src/components/CustomToast.tsx | 52 ++++++++++++-------------- src/components/styles/Toast.module.css | 1 + src/pages/_app.tsx | 50 +++++++++++++++++-------- 4 files changed, 64 insertions(+), 45 deletions(-) diff --git a/src/backend/routers/user.ts b/src/backend/routers/user.ts index d359fda8..2355ed18 100644 --- a/src/backend/routers/user.ts +++ b/src/backend/routers/user.ts @@ -1,6 +1,7 @@ import { hasAuthenticated, hasAdmin, router } from "../trpc"; import { z } from "zod"; import { UserType, ROLE_OPTIONS } from "@/types/auth"; +import { TRPCError } from "@trpc/server"; export const sortOrderSchema = z.enum(["asc", "desc"]).default("asc"); export const sortBySchema = z @@ -130,7 +131,10 @@ export const user = router({ .executeTakeFirst(); if (existingUser) { - throw new Error("User with this email already exists"); + throw new TRPCError({ + code: "BAD_REQUEST", + message: "User with this email already exists", + }); } const user = await req.ctx.db diff --git a/src/components/CustomToast.tsx b/src/components/CustomToast.tsx index fea3e8e2..31afc256 100644 --- a/src/components/CustomToast.tsx +++ b/src/components/CustomToast.tsx @@ -1,43 +1,37 @@ -import React, { useState } from "react"; +import React from "react"; import styles from "./styles/Toast.module.css"; import Image from "next/image"; interface CustomToastProps { errorMessage: string; + onClose: () => void; } -const CustomToast = ({ errorMessage }: CustomToastProps) => { - const [showToast, setShowToast] = useState(true); - +const CustomToast = ({ errorMessage, onClose }: CustomToastProps) => { const handleCloseToast = () => { - setShowToast(false); + onClose(); }; return ( - <> - {showToast && ( -
-
- Error Img -
{errorMessage ?? null}
- - -
-
- )} - +
+
+ Error Img +
{errorMessage}
+ +
+
); }; diff --git a/src/components/styles/Toast.module.css b/src/components/styles/Toast.module.css index 33aa4e5a..2c12d370 100644 --- a/src/components/styles/Toast.module.css +++ b/src/components/styles/Toast.module.css @@ -3,6 +3,7 @@ bottom: 0; right: 0; width: 400px; + z-index: 9999; } .customToast { diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 57a411e9..92c912e6 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -3,10 +3,10 @@ import { SessionProvider } from "next-auth/react"; import type { AppProps } from "next/app"; import { trpc } from "@/client/lib/trpc"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { httpBatchLink, loggerLink } from "@trpc/client"; +import { httpBatchLink, loggerLink, TRPCClientError } from "@trpc/client"; import { useState } from "react"; import "../styles/globals.css"; -import { QueryCache } from "@tanstack/react-query"; +import { QueryCache, MutationCache } from "@tanstack/react-query"; import toast from "react-hot-toast"; import Head from "next/head"; import superjson from "superjson"; @@ -17,6 +17,7 @@ import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns"; import { StyledEngineProvider, ThemeProvider } from "@mui/material/styles"; import { compassTheme as theme } from "@/theme"; import { FontProvider } from "@/components/font-provider"; +import { AppRouter } from "@/backend/routers/_app"; interface CustomPageProps { session: Session; @@ -42,20 +43,21 @@ export default function App({ () => new QueryClient({ queryCache: new QueryCache({ - onError: (error) => { - if (error instanceof Error) { - const errorMessages: { [key: string]: string } = { - BAD_REQUEST: "400: Bad request, please try again", - UNAUTHORIZED: "401: Unauthorized Error", - NOT_FOUND: "404: Page not found", - }; - - const defaultMessage = "An error occured. Please try again"; - const msg = errorMessages[error.message] || defaultMessage; - setErrorMessage(msg); - } + onError: (error: unknown) => { + handleTRPCError(error); + }, + }), + mutationCache: new MutationCache({ + onError: (error: unknown) => { + handleTRPCError(error); }, }), + defaultOptions: { + queries: { + retry: false, + refetchOnWindowFocus: false, + }, + }, }) ); @@ -77,6 +79,21 @@ export default function App({ }) ); + const handleTRPCError = (error: unknown) => { + const trpcError = error as TRPCClientError; + const errorCode = trpcError.data?.code as keyof typeof errorMessages; + const errorMessage = trpcError.message; + + const errorMessages = { + BAD_REQUEST: errorMessage || "400: Bad request, please try again", + UNAUTHORIZED: "401: Unauthorized Error", + NOT_FOUND: "404: Page not found", + FORBIDDEN: "403: Access denied", + } as const; + + setErrorMessage(errorMessages[errorCode] ?? "An unexpected error occurred"); + }; + return ( <> @@ -92,7 +109,10 @@ export default function App({ {errorMessage && ( - + setErrorMessage("")} + /> )} From ae967c19361596c31340dc0b0585f703762ad772 Mon Sep 17 00:00:00 2001 From: thom Date: Sun, 17 Nov 2024 08:33:20 -0800 Subject: [PATCH 11/19] fix: table2 refactor + breadcrumb + linting --- .../design_system/breadcrumbs/Breadcrumbs.tsx | 7 +- src/components/table/renderers.tsx | 63 ++++++++++ src/components/table/table2.tsx | 115 +++++------------- src/components/table/types.ts | 40 ++++++ src/pages/{users => admin}/[user_id].tsx | 0 src/pages/admin/index.tsx | 78 +++--------- 6 files changed, 155 insertions(+), 148 deletions(-) create mode 100644 src/components/table/renderers.tsx create mode 100644 src/components/table/types.ts rename src/pages/{users => admin}/[user_id].tsx (100%) diff --git a/src/components/design_system/breadcrumbs/Breadcrumbs.tsx b/src/components/design_system/breadcrumbs/Breadcrumbs.tsx index 7c2cb884..0bc7ed00 100644 --- a/src/components/design_system/breadcrumbs/Breadcrumbs.tsx +++ b/src/components/design_system/breadcrumbs/Breadcrumbs.tsx @@ -7,6 +7,7 @@ import $breadcrumbs from "./Breadcrumbs.module.css"; type Student = SelectableForTable<"student">; type Para = SelectableForTable<"user">; +type User = SelectableForTable<"user">; const BreadcrumbsNav = () => { const router = useRouter(); @@ -22,8 +23,12 @@ const BreadcrumbsNav = () => { { user_id: paths[2] }, { enabled: Boolean(paths[2] && paths[1] === "staff") } ); + const { data: user } = trpc.user.getUserById.useQuery( + { user_id: paths[2] }, + { enabled: Boolean(paths[2] && paths[1] === "admin") } + ); - const personData: Student | Para | undefined = student || para; + const personData: Student | Para | User | undefined = student || para || user; // An array of breadcrumbs fixed to students/staff as the first index. This will be modified depending on how the address bar will be displayed. const breadcrumbs = paths.map((path, index) => { diff --git a/src/components/table/renderers.tsx b/src/components/table/renderers.tsx new file mode 100644 index 00000000..c124db7f --- /dev/null +++ b/src/components/table/renderers.tsx @@ -0,0 +1,63 @@ +import { TextField } from "@mui/material"; +import { Dropdown } from "@/components/design_system/dropdown/Dropdown"; +import { ColumnDefinition, BaseEntity, SelectOption } from "./types"; + +export function renderTableInput( + column: ColumnDefinition, + value: T[keyof T] | undefined, + onChange: (value: T[keyof T]) => void +): React.ReactNode { + switch (column.type) { + case "text": + return ( + onChange(e.target.value as T[keyof T])} + /> + ); + case "number": + return ( + onChange(Number(e.target.value) as T[keyof T])} + /> + ); + case "select": + return ( + onChange(newValue as T[keyof T])} + label={column.label} + /> + ); + default: + return String(value || ""); + } +} + +export function renderTableCell( + column: ColumnDefinition, + value: T[keyof T] +): React.ReactNode { + if (column.customRender) { + return column.customRender(value); + } + + switch (column.type) { + case "select": + return ( + column.options?.find((opt) => opt.value === value)?.label || + String(value) + ); + case "date": + return value instanceof Date ? value.toLocaleDateString() : String(value); + default: + return String(value || ""); + } +} diff --git a/src/components/table/table2.tsx b/src/components/table/table2.tsx index 011977ca..689ae593 100644 --- a/src/components/table/table2.tsx +++ b/src/components/table/table2.tsx @@ -7,49 +7,17 @@ import { TableHead, TableRow, Box, - TextField, Button, TableSortLabel, + TextField, } 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 { - id: keyof T; - label: string; - renderInput?: ( - value: T[keyof T] | undefined, - onChange: (value: T[keyof T]) => void - ) => React.ReactNode; - renderCell?: (value: T[keyof T]) => React.ReactNode; -} - -interface TableProps { - data: T[]; - columns: Column[]; - type: string; - 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; - onAdd?: (data: Omit) => Promise; - showAddRow?: boolean; -} +import { TableProps, BaseEntity } from "./types"; +import { renderTableInput, renderTableCell } from "./renderers"; +import $table from "./Table.module.css"; +import $button from "@/components/design_system/button/Button.module.css"; const StyledTableRow = styled(TableRow)(() => ({ "&:nth-of-type(odd)": { @@ -96,30 +64,18 @@ export function Table2({ const handleAddRow = async (e: React.FormEvent) => { e.preventDefault(); if (onAdd) { - try { - await onAdd(newRowData as Omit); - setNewRowData({}); - setIsAddingRow(false); - } catch (error) { - console.error(error); - } + await onAdd(newRowData as Omit); + setIsAddingRow(false); + setNewRowData({}); } }; return ( - - - -

{type}

- {onAdd && !isAddingRow && showAddRow && ( - - )} -
+ + setLocalSearchTerm(e.target.value)} onKeyDown={handleKeyDown} @@ -127,32 +83,36 @@ export function Table2({ startAdornment: , }} /> + {showAddRow && !isAddingRow && ( + + )} - + {columns.map((column) => ( - + handleRequestSort(column.id)} - className={styles.headerLabel} + className={$table.headerLabel} > {column.label} - {sortBy === column.id ? ( + {sortBy === column.id && ( {sortOrder === "desc" ? "sorted descending" : "sorted ascending"} - ) : null} + )} ))} @@ -161,32 +121,21 @@ export function Table2({ {isAddingRow && ( - +
{columns.map((column) => ( - {column.renderInput?.( + {renderTableInput( + column, newRowData[column.id], - (value: T[keyof T]) => + (value) => setNewRowData((prev) => ({ ...prev, [column.id]: value, })) - ) ?? ( - - setNewRowData((prev) => ({ - ...prev, - [column.id]: e.target.value, - })) - } - /> )} ))} @@ -209,16 +158,14 @@ export function Table2({
)} - {data.map((row, index) => ( + {data.map((row) => ( onRowClick?.(row)} > {columns.map((column) => ( - {column.renderCell - ? column.renderCell(row[column.id]) - : row[column.id]} + {renderTableCell(column, row[column.id])} ))} diff --git a/src/components/table/types.ts b/src/components/table/types.ts new file mode 100644 index 00000000..19356bc8 --- /dev/null +++ b/src/components/table/types.ts @@ -0,0 +1,40 @@ +export type ColumnType = "text" | "number" | "select" | "date"; + +export interface BaseEntity { + id?: string | number; + first_name: string; + last_name: string; + email: string; + [key: string]: string | number | Date | undefined; +} + +export interface SelectOption { + value: string; + label: string; +} + +export interface ColumnDefinition { + id: keyof T; + label: string; + type: ColumnType; + options?: SelectOption[]; + validation?: (value: T[keyof T]) => boolean; + customRender?: (value: T[keyof T]) => React.ReactNode; +} + +export interface TableProps { + data: T[]; + columns: ColumnDefinition[]; + type: string; + 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; + onAdd?: (data: Omit) => Promise; + showAddRow?: boolean; +} diff --git a/src/pages/users/[user_id].tsx b/src/pages/admin/[user_id].tsx similarity index 100% rename from src/pages/users/[user_id].tsx rename to src/pages/admin/[user_id].tsx diff --git a/src/pages/admin/index.tsx b/src/pages/admin/index.tsx index d3b11fbf..d8fca26b 100644 --- a/src/pages/admin/index.tsx +++ b/src/pages/admin/index.tsx @@ -1,17 +1,10 @@ import { requiresAdminAuth } from "@/client/lib/protected-page"; import { trpc } from "@/client/lib/trpc"; -import { Table2, Column, BaseEntity } from "@/components/table/table2"; +import { Table2 } from "@/components/table/table2"; +import { ColumnDefinition, BaseEntity } from "@/components/table/types"; import { useRouter } from "next/router"; import { useState } from "react"; import { ROLE_OPTIONS } from "@/types/auth"; -import { - TextField, - FormControl, - InputLabel, - Select, - MenuItem, -} from "@mui/material"; -import { SelectChangeEvent } from "@mui/material/Select"; import { getRoleLabel } from "@/types/auth"; import { sortBySchema, sortOrderSchema } from "@/backend/routers/user"; import { z } from "zod"; @@ -31,7 +24,7 @@ const AdminHome: React.FC = () => { const [sortBy, setSortBy] = useState("first_name"); const [sortOrder, setSortOrder] = useState("asc"); const [searchTerm, setSearchTerm] = useState(""); - const pageSize = 2; + const pageSize = 10; const { data, isLoading } = trpc.user.getUsers.useQuery({ page, @@ -65,80 +58,39 @@ const AdminHome: React.FC = () => { const handleSearch = (search: string) => { setSearchTerm(search); - setPage(1); // Reset to first page when searching + setPage(1); }; - if (isLoading) return
Loading...
; + const handleRowClick = async (user: User) => { + await router.push(`/admin/${user.user_id}`); + }; - const columns: Column[] = [ + const columns: ColumnDefinition[] = [ { id: "first_name", label: "First Name", - renderInput: (value, onChange) => ( - onChange(e.target.value)} - /> - ), + type: "text", }, { id: "last_name", label: "Last Name", - renderInput: (value, onChange) => ( - onChange(e.target.value)} - /> - ), + type: "text", }, { id: "email", label: "Email", - renderInput: (value, onChange) => ( - onChange(e.target.value)} - /> - ), + type: "text", }, { id: "role", label: "Role", - renderCell: (value) => getRoleLabel(value as string), - renderInput: (value, onChange) => ( - - Role - - - ), + type: "select", + options: ROLE_OPTIONS, + customRender: (value) => (value ? getRoleLabel(value as string) : ""), }, ]; - const handleRowClick = async (user: User) => { - await router.push(`/users/${user.user_id}`); - }; + if (isLoading) return
Loading...
; return (
From 775b4d048cd14cf0aa78780e6e05246f6837b691 Mon Sep 17 00:00:00 2001 From: thom Date: Sun, 17 Nov 2024 08:41:41 -0800 Subject: [PATCH 12/19] fix: prettier --- src/backend/scripts/run-with-gcp-metadata.ts | 2 +- src/components/design_system/button/Button.module.css | 3 +-- src/components/taskCard/taskCard.tsx | 8 ++++---- src/pages/benchmarks/[benchmark_id]/index.tsx | 4 ++-- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/backend/scripts/run-with-gcp-metadata.ts b/src/backend/scripts/run-with-gcp-metadata.ts index 2a81b364..2be44ba5 100644 --- a/src/backend/scripts/run-with-gcp-metadata.ts +++ b/src/backend/scripts/run-with-gcp-metadata.ts @@ -18,7 +18,7 @@ const runWithGcpMetadata = async () => { const [projectId, regionPath, { access_token }]: [ string, string, - { access_token: string }, + { access_token: string } ] = await Promise.all([ gcpMetadata.project("project-id"), gcpMetadata.instance("region"), diff --git a/src/components/design_system/button/Button.module.css b/src/components/design_system/button/Button.module.css index 80c0e252..f9950e62 100644 --- a/src/components/design_system/button/Button.module.css +++ b/src/components/design_system/button/Button.module.css @@ -18,8 +18,7 @@ } .default:hover { - box-shadow: - 0px 1px 2px 0px rgba(0, 0, 0, 0.3), + box-shadow: 0px 1px 2px 0px rgba(0, 0, 0, 0.3), 0px 1px 3px 1px rgba(0, 0, 0, 0.15); background-color: var(--primary-50); } diff --git a/src/components/taskCard/taskCard.tsx b/src/components/taskCard/taskCard.tsx index 0ac27a0e..2d080217 100644 --- a/src/components/taskCard/taskCard.tsx +++ b/src/components/taskCard/taskCard.tsx @@ -56,10 +56,10 @@ const TaskCard = ({ task, isPara }: TaskCardProps) => { {!task.seen ? "NEW" : completionRate >= 100 - ? "DONE" - : `DUE: ${ - task.due_date ? format(task.due_date, "MM-dd-yyyy") : "N/A" - }`} + ? "DONE" + : `DUE: ${ + task.due_date ? format(task.due_date, "MM-dd-yyyy") : "N/A" + }`}
{task.first_name} {task.last_name} diff --git a/src/pages/benchmarks/[benchmark_id]/index.tsx b/src/pages/benchmarks/[benchmark_id]/index.tsx index e3706ca6..83c5b1a1 100644 --- a/src/pages/benchmarks/[benchmark_id]/index.tsx +++ b/src/pages/benchmarks/[benchmark_id]/index.tsx @@ -273,8 +273,8 @@ const BenchmarkPage = () => { {hasInputChanged || updateTrialMutation.isLoading ? "Saving..." : updateTrialMutation.isError - ? "uh oh" - : "Saved to Cloud"} + ? "uh oh" + : "Saved to Cloud"}
From 320ae4976034db31a5fcb9f54c033282299a209e Mon Sep 17 00:00:00 2001 From: thom Date: Sun, 17 Nov 2024 11:30:23 -0800 Subject: [PATCH 13/19] fix: prettier --- src/backend/scripts/run-with-gcp-metadata.ts | 2 +- src/components/design_system/button/Button.module.css | 3 ++- src/components/table/table.tsx | 2 +- src/components/taskCard/taskCard.tsx | 8 ++++---- src/pages/benchmarks/[benchmark_id]/index.tsx | 4 ++-- 5 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/backend/scripts/run-with-gcp-metadata.ts b/src/backend/scripts/run-with-gcp-metadata.ts index 2be44ba5..2a81b364 100644 --- a/src/backend/scripts/run-with-gcp-metadata.ts +++ b/src/backend/scripts/run-with-gcp-metadata.ts @@ -18,7 +18,7 @@ const runWithGcpMetadata = async () => { const [projectId, regionPath, { access_token }]: [ string, string, - { access_token: string } + { access_token: string }, ] = await Promise.all([ gcpMetadata.project("project-id"), gcpMetadata.instance("region"), diff --git a/src/components/design_system/button/Button.module.css b/src/components/design_system/button/Button.module.css index f9950e62..80c0e252 100644 --- a/src/components/design_system/button/Button.module.css +++ b/src/components/design_system/button/Button.module.css @@ -18,7 +18,8 @@ } .default:hover { - box-shadow: 0px 1px 2px 0px rgba(0, 0, 0, 0.3), + box-shadow: + 0px 1px 2px 0px rgba(0, 0, 0, 0.3), 0px 1px 3px 1px rgba(0, 0, 0, 0.15); background-color: var(--primary-50); } diff --git a/src/components/table/table.tsx b/src/components/table/table.tsx index 563cd371..bbd0cb7b 100644 --- a/src/components/table/table.tsx +++ b/src/components/table/table.tsx @@ -264,7 +264,7 @@ interface EnhancedTableProps { */ export default function EnhancedTable< Person extends StudentWithIep | Para, - Column extends HeadCell + Column extends HeadCell, >({ people, onSubmit, headCells, type }: EnhancedTableProps) { const router = useRouter(); diff --git a/src/components/taskCard/taskCard.tsx b/src/components/taskCard/taskCard.tsx index 2d080217..0ac27a0e 100644 --- a/src/components/taskCard/taskCard.tsx +++ b/src/components/taskCard/taskCard.tsx @@ -56,10 +56,10 @@ const TaskCard = ({ task, isPara }: TaskCardProps) => { {!task.seen ? "NEW" : completionRate >= 100 - ? "DONE" - : `DUE: ${ - task.due_date ? format(task.due_date, "MM-dd-yyyy") : "N/A" - }`} + ? "DONE" + : `DUE: ${ + task.due_date ? format(task.due_date, "MM-dd-yyyy") : "N/A" + }`}
{task.first_name} {task.last_name} diff --git a/src/pages/benchmarks/[benchmark_id]/index.tsx b/src/pages/benchmarks/[benchmark_id]/index.tsx index 83c5b1a1..e3706ca6 100644 --- a/src/pages/benchmarks/[benchmark_id]/index.tsx +++ b/src/pages/benchmarks/[benchmark_id]/index.tsx @@ -273,8 +273,8 @@ const BenchmarkPage = () => { {hasInputChanged || updateTrialMutation.isLoading ? "Saving..." : updateTrialMutation.isError - ? "uh oh" - : "Saved to Cloud"} + ? "uh oh" + : "Saved to Cloud"}
From 3f926e91837ecb89bbef3379f993e0365aa797e7 Mon Sep 17 00:00:00 2001 From: thom Date: Sun, 17 Nov 2024 21:50:28 -0800 Subject: [PATCH 14/19] fix: types --- src/backend/routers/user.ts | 6 ++---- src/components/table/types.ts | 4 +++- src/pages/admin/[user_id].tsx | 3 +-- src/pages/admin/index.tsx | 20 ++++++++++++-------- src/types/auth.ts | 3 +++ 5 files changed, 21 insertions(+), 15 deletions(-) diff --git a/src/backend/routers/user.ts b/src/backend/routers/user.ts index 2355ed18..67a41a48 100644 --- a/src/backend/routers/user.ts +++ b/src/backend/routers/user.ts @@ -1,6 +1,6 @@ import { hasAuthenticated, hasAdmin, router } from "../trpc"; import { z } from "zod"; -import { UserType, ROLE_OPTIONS } from "@/types/auth"; +import { UserType, ROLE_OPTIONS, Roles } from "@/types/auth"; import { TRPCError } from "@trpc/server"; export const sortOrderSchema = z.enum(["asc", "desc"]).default("asc"); @@ -20,9 +20,7 @@ const createUserSchema = z.object({ first_name: z.string(), last_name: z.string(), email: z.string().email(), - role: z - .enum(["ADMIN", "CASE_MANAGER", "PARA"]) - .transform((role) => role.toLowerCase()), + role: z.string().transform((role) => role.toUpperCase() as Roles), }); const roleValues = ROLE_OPTIONS.map((r) => r.value) as [string, ...string[]]; diff --git a/src/components/table/types.ts b/src/components/table/types.ts index 19356bc8..f8acd060 100644 --- a/src/components/table/types.ts +++ b/src/components/table/types.ts @@ -1,3 +1,5 @@ +import { Roles } from "@/types/auth"; + export type ColumnType = "text" | "number" | "select" | "date"; export interface BaseEntity { @@ -5,7 +7,7 @@ export interface BaseEntity { first_name: string; last_name: string; email: string; - [key: string]: string | number | Date | undefined; + role: Roles; } export interface SelectOption { diff --git a/src/pages/admin/[user_id].tsx b/src/pages/admin/[user_id].tsx index 1e0ea20e..ac0ac2a0 100644 --- a/src/pages/admin/[user_id].tsx +++ b/src/pages/admin/[user_id].tsx @@ -149,12 +149,11 @@ const ViewUserPage = () => { diff --git a/src/pages/admin/index.tsx b/src/pages/admin/index.tsx index d8fca26b..b67ddf54 100644 --- a/src/pages/admin/index.tsx +++ b/src/pages/admin/index.tsx @@ -4,14 +4,17 @@ import { Table2 } from "@/components/table/table2"; import { ColumnDefinition, BaseEntity } from "@/components/table/types"; import { useRouter } from "next/router"; import { useState } from "react"; -import { ROLE_OPTIONS } from "@/types/auth"; +import { ROLE_OPTIONS, Roles } from "@/types/auth"; import { getRoleLabel } from "@/types/auth"; import { sortBySchema, sortOrderSchema } from "@/backend/routers/user"; import { z } from "zod"; interface User extends BaseEntity { user_id: string; - role: string; + first_name: string; + last_name: string; + email: string; + role: Roles; } type SortBy = z.infer; @@ -21,7 +24,8 @@ const AdminHome: React.FC = () => { const utils = trpc.useContext(); const router = useRouter(); const [page, setPage] = useState(1); - const [sortBy, setSortBy] = useState("first_name"); + const [sortBy, setSortBy] = + useState>("first_name"); const [sortOrder, setSortOrder] = useState("asc"); const [searchTerm, setSearchTerm] = useState(""); const pageSize = 10; @@ -29,7 +33,7 @@ const AdminHome: React.FC = () => { const { data, isLoading } = trpc.user.getUsers.useQuery({ page, pageSize, - sortBy, + sortBy: sortBy as SortBy, sortOrder, search: searchTerm, }); @@ -51,7 +55,7 @@ const AdminHome: React.FC = () => { } }; - const handleSort = (newSortBy: SortBy, newSortOrder: SortOrder) => { + const handleSort = (newSortBy: keyof User, newSortOrder: SortOrder) => { setSortBy(newSortBy); setSortOrder(newSortOrder); }; @@ -85,7 +89,7 @@ const AdminHome: React.FC = () => { id: "role", label: "Role", type: "select", - options: ROLE_OPTIONS, + options: [...ROLE_OPTIONS], customRender: (value) => (value ? getRoleLabel(value as string) : ""), }, ]; @@ -95,14 +99,14 @@ const AdminHome: React.FC = () => { return (
- data={data?.users ?? []} + data={(data?.users as User[]) ?? []} columns={columns} type="Users" onRowClick={handleRowClick} page={page} totalPages={data?.totalPages ?? 1} onPageChange={setPage} - sortBy={sortBy} + sortBy={sortBy as SortBy} sortOrder={sortOrder} onSort={handleSort} onSearch={handleSearch} diff --git a/src/types/auth.ts b/src/types/auth.ts index 2023952e..1e3c77c6 100644 --- a/src/types/auth.ts +++ b/src/types/auth.ts @@ -27,11 +27,14 @@ export enum UserType { } export const ROLE_OPTIONS = [ + { label: "User", value: "USER" }, { label: "Para", value: "PARA" }, { label: "Case Manager", value: "CASE_MANAGER" }, { label: "Admin", value: "ADMIN" }, ] as const; +export type Roles = (typeof ROLE_OPTIONS)[number]["value"]; + export function getRoleLabel(role: string): string { const option = ROLE_OPTIONS.find( (opt) => opt.value.toLowerCase() === role.toLowerCase() From 31b87a5809ba0c9bc38d6bb2dabf0d48548274d5 Mon Sep 17 00:00:00 2001 From: thom Date: Tue, 19 Nov 2024 08:01:37 -0800 Subject: [PATCH 15/19] fix: some cleanup + user can't edit their own role --- src/backend/routers/user.ts | 15 +++++++++++++++ src/components/table/renderers.tsx | 6 +++--- src/components/table/table2.tsx | 4 ++-- src/components/table/types.ts | 6 +++--- src/pages/admin/index.tsx | 4 ++-- 5 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/backend/routers/user.ts b/src/backend/routers/user.ts index 67a41a48..60427a5b 100644 --- a/src/backend/routers/user.ts +++ b/src/backend/routers/user.ts @@ -185,6 +185,21 @@ export const user = router({ .mutation(async (req) => { const { user_id, first_name, last_name, email, role } = req.input; + const { userId } = req.ctx.auth; + + const dbUser = await req.ctx.db + .selectFrom("user") + .where("user_id", "=", user_id) + .selectAll() + .executeTakeFirstOrThrow(); + + if (userId === user_id && dbUser.role !== role) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "You cannot change your own role", + }); + } + return await req.ctx.db .updateTable("user") .set({ diff --git a/src/components/table/renderers.tsx b/src/components/table/renderers.tsx index c124db7f..bd468017 100644 --- a/src/components/table/renderers.tsx +++ b/src/components/table/renderers.tsx @@ -1,8 +1,8 @@ import { TextField } from "@mui/material"; import { Dropdown } from "@/components/design_system/dropdown/Dropdown"; -import { ColumnDefinition, BaseEntity, SelectOption } from "./types"; +import { ColumnDefinition, UserBase, SelectOption } from "./types"; -export function renderTableInput( +export function renderTableInput( column: ColumnDefinition, value: T[keyof T] | undefined, onChange: (value: T[keyof T]) => void @@ -41,7 +41,7 @@ export function renderTableInput( } } -export function renderTableCell( +export function renderTableCell( column: ColumnDefinition, value: T[keyof T] ): React.ReactNode { diff --git a/src/components/table/table2.tsx b/src/components/table/table2.tsx index 689ae593..b5dbbf34 100644 --- a/src/components/table/table2.tsx +++ b/src/components/table/table2.tsx @@ -14,7 +14,7 @@ import { import { styled } from "@mui/material/styles"; import { visuallyHidden } from "@mui/utils"; import SearchIcon from "@mui/icons-material/Search"; -import { TableProps, BaseEntity } from "./types"; +import { TableProps, UserBase } from "./types"; import { renderTableInput, renderTableCell } from "./renderers"; import $table from "./Table.module.css"; import $button from "@/components/design_system/button/Button.module.css"; @@ -29,7 +29,7 @@ const StyledTableRow = styled(TableRow)(() => ({ }, })); -export function Table2({ +export function Table2({ data, columns, type, diff --git a/src/components/table/types.ts b/src/components/table/types.ts index f8acd060..e37b588b 100644 --- a/src/components/table/types.ts +++ b/src/components/table/types.ts @@ -2,7 +2,7 @@ import { Roles } from "@/types/auth"; export type ColumnType = "text" | "number" | "select" | "date"; -export interface BaseEntity { +export interface UserBase { id?: string | number; first_name: string; last_name: string; @@ -15,7 +15,7 @@ export interface SelectOption { label: string; } -export interface ColumnDefinition { +export interface ColumnDefinition { id: keyof T; label: string; type: ColumnType; @@ -24,7 +24,7 @@ export interface ColumnDefinition { customRender?: (value: T[keyof T]) => React.ReactNode; } -export interface TableProps { +export interface TableProps { data: T[]; columns: ColumnDefinition[]; type: string; diff --git a/src/pages/admin/index.tsx b/src/pages/admin/index.tsx index b67ddf54..dc472664 100644 --- a/src/pages/admin/index.tsx +++ b/src/pages/admin/index.tsx @@ -1,7 +1,7 @@ import { requiresAdminAuth } from "@/client/lib/protected-page"; import { trpc } from "@/client/lib/trpc"; import { Table2 } from "@/components/table/table2"; -import { ColumnDefinition, BaseEntity } from "@/components/table/types"; +import { ColumnDefinition, UserBase } from "@/components/table/types"; import { useRouter } from "next/router"; import { useState } from "react"; import { ROLE_OPTIONS, Roles } from "@/types/auth"; @@ -9,7 +9,7 @@ import { getRoleLabel } from "@/types/auth"; import { sortBySchema, sortOrderSchema } from "@/backend/routers/user"; import { z } from "zod"; -interface User extends BaseEntity { +interface User extends UserBase { user_id: string; first_name: string; last_name: string; From e387b90211db4bc5402f08e8fc49ca0f6186da71 Mon Sep 17 00:00:00 2001 From: thom Date: Mon, 2 Dec 2024 18:44:49 -0800 Subject: [PATCH 16/19] fix: rename new table --- src/components/table/{table2.tsx => PaginatedTable.tsx} | 2 +- src/pages/admin/index.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) rename src/components/table/{table2.tsx => PaginatedTable.tsx} (99%) diff --git a/src/components/table/table2.tsx b/src/components/table/PaginatedTable.tsx similarity index 99% rename from src/components/table/table2.tsx rename to src/components/table/PaginatedTable.tsx index b5dbbf34..67f195a7 100644 --- a/src/components/table/table2.tsx +++ b/src/components/table/PaginatedTable.tsx @@ -29,7 +29,7 @@ const StyledTableRow = styled(TableRow)(() => ({ }, })); -export function Table2({ +export function PaginatedTable({ data, columns, type, diff --git a/src/pages/admin/index.tsx b/src/pages/admin/index.tsx index dc472664..9e6cafa2 100644 --- a/src/pages/admin/index.tsx +++ b/src/pages/admin/index.tsx @@ -1,6 +1,6 @@ import { requiresAdminAuth } from "@/client/lib/protected-page"; import { trpc } from "@/client/lib/trpc"; -import { Table2 } from "@/components/table/table2"; +import { PaginatedTable } from "@/components/table/PaginatedTable"; import { ColumnDefinition, UserBase } from "@/components/table/types"; import { useRouter } from "next/router"; import { useState } from "react"; @@ -98,7 +98,7 @@ const AdminHome: React.FC = () => { return (
- + data={(data?.users as User[]) ?? []} columns={columns} type="Users" From 6f21de92a45ec45fe02616033b81a6a992cb3971 Mon Sep 17 00:00:00 2001 From: thom Date: Tue, 3 Dec 2024 08:01:14 -0800 Subject: [PATCH 17/19] fix: code cleanup + case handling of roles cleaned up --- src/backend/routers/user.ts | 8 ++++---- src/pages/admin/[user_id].tsx | 2 +- src/pages/admin/index.tsx | 2 +- src/types/auth.ts | 8 ++++---- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/backend/routers/user.ts b/src/backend/routers/user.ts index 60427a5b..dc53ad29 100644 --- a/src/backend/routers/user.ts +++ b/src/backend/routers/user.ts @@ -20,7 +20,7 @@ const createUserSchema = z.object({ first_name: z.string(), last_name: z.string(), email: z.string().email(), - role: z.string().transform((role) => role.toUpperCase() as Roles), + role: z.string(), }); const roleValues = ROLE_OPTIONS.map((r) => r.value) as [string, ...string[]]; @@ -170,11 +170,11 @@ export const user = router({ email: z.string().email(), role: z.enum(roleValues).transform((role) => { switch (role) { - case "ADMIN": + case "admin": return UserType.Admin; - case "CASE_MANAGER": + case "case_manager": return UserType.CaseManager; - case "PARA": + case "para": return UserType.Para; default: return UserType.User; diff --git a/src/pages/admin/[user_id].tsx b/src/pages/admin/[user_id].tsx index ac0ac2a0..d71fd1e8 100644 --- a/src/pages/admin/[user_id].tsx +++ b/src/pages/admin/[user_id].tsx @@ -46,7 +46,7 @@ const ViewUserPage = () => { useEffect(() => { if (user?.role) { - setSelectedRole(user.role.toUpperCase()); + setSelectedRole(user.role); } }, [user?.role]); diff --git a/src/pages/admin/index.tsx b/src/pages/admin/index.tsx index 9e6cafa2..195f49ff 100644 --- a/src/pages/admin/index.tsx +++ b/src/pages/admin/index.tsx @@ -48,7 +48,7 @@ const AdminHome: React.FC = () => { try { await createUserMutation.mutateAsync({ ...userData, - role: userData.role || "PARA", + role: userData.role || "para", }); } catch (error) { console.error(error); diff --git a/src/types/auth.ts b/src/types/auth.ts index 1e3c77c6..05e9bc6c 100644 --- a/src/types/auth.ts +++ b/src/types/auth.ts @@ -27,10 +27,10 @@ export enum UserType { } export const ROLE_OPTIONS = [ - { label: "User", value: "USER" }, - { label: "Para", value: "PARA" }, - { label: "Case Manager", value: "CASE_MANAGER" }, - { label: "Admin", value: "ADMIN" }, + { label: "User", value: UserType.User }, + { label: "Para", value: UserType.Para }, + { label: "Case Manager", value: UserType.CaseManager }, + { label: "Admin", value: UserType.Admin }, ] as const; export type Roles = (typeof ROLE_OPTIONS)[number]["value"]; From a09581633352d14cdd9beb328c56880da38a05f0 Mon Sep 17 00:00:00 2001 From: thom Date: Tue, 3 Dec 2024 08:32:43 -0800 Subject: [PATCH 18/19] fix: unused import --- src/backend/routers/user.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/routers/user.ts b/src/backend/routers/user.ts index dc53ad29..a279477b 100644 --- a/src/backend/routers/user.ts +++ b/src/backend/routers/user.ts @@ -1,6 +1,6 @@ import { hasAuthenticated, hasAdmin, router } from "../trpc"; import { z } from "zod"; -import { UserType, ROLE_OPTIONS, Roles } from "@/types/auth"; +import { UserType, ROLE_OPTIONS } from "@/types/auth"; import { TRPCError } from "@trpc/server"; export const sortOrderSchema = z.enum(["asc", "desc"]).default("asc"); From 4c5474b3b91eeb2b3e0732d7faa4a217bad7a45a Mon Sep 17 00:00:00 2001 From: Francis Li Date: Tue, 10 Dec 2024 18:28:39 -0800 Subject: [PATCH 19/19] Fix lint errors --- src/backend/routers/user.ts | 2 +- src/components/design_system/breadcrumbs/Breadcrumbs.tsx | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/backend/routers/user.ts b/src/backend/routers/user.ts index a279477b..16910d21 100644 --- a/src/backend/routers/user.ts +++ b/src/backend/routers/user.ts @@ -193,7 +193,7 @@ export const user = router({ .selectAll() .executeTakeFirstOrThrow(); - if (userId === user_id && dbUser.role !== role) { + if (userId === user_id && dbUser.role !== (role as string)) { throw new TRPCError({ code: "BAD_REQUEST", message: "You cannot change your own role", diff --git a/src/components/design_system/breadcrumbs/Breadcrumbs.tsx b/src/components/design_system/breadcrumbs/Breadcrumbs.tsx index 0bc7ed00..0a447ddc 100644 --- a/src/components/design_system/breadcrumbs/Breadcrumbs.tsx +++ b/src/components/design_system/breadcrumbs/Breadcrumbs.tsx @@ -7,7 +7,6 @@ import $breadcrumbs from "./Breadcrumbs.module.css"; type Student = SelectableForTable<"student">; type Para = SelectableForTable<"user">; -type User = SelectableForTable<"user">; const BreadcrumbsNav = () => { const router = useRouter(); @@ -28,7 +27,7 @@ const BreadcrumbsNav = () => { { enabled: Boolean(paths[2] && paths[1] === "admin") } ); - const personData: Student | Para | User | undefined = student || para || user; + const personData: Student | Para | undefined = student || para || user; // An array of breadcrumbs fixed to students/staff as the first index. This will be modified depending on how the address bar will be displayed. const breadcrumbs = paths.map((path, index) => {