Skip to content

Commit

Permalink
[Closes #451] Admin panel + new PaginatedTable component + some fixes (
Browse files Browse the repository at this point in the history
…#474)

* basic user table working with sort and search and pagination all handled back-end

* feat: adding users

* fix: types

* feat: edit users

* feat: edit user, role dropdown

* fix: styling + search query counts/pagination

* fix: styling

* fix: role init + sharing types backend/frontend

* fix: lint

* feat: better error handling ux + toas fixes

* fix: table2 refactor + breadcrumb + linting

* fix: prettier

* fix: prettier

* fix: types

* fix: some cleanup + user can't edit their own role

* fix: rename new table

* fix: code cleanup + case handling of roles cleaned up

* fix: unused import

* Fix lint errors

---------

Co-authored-by: Francis Li <francisli@users.noreply.github.com>
Co-authored-by: Francis Li <mail@francisli.com>
  • Loading branch information
3 people committed Dec 11, 2024
1 parent be28754 commit 7f858b9
Show file tree
Hide file tree
Showing 14 changed files with 899 additions and 54 deletions.
180 changes: 179 additions & 1 deletion src/backend/routers/user.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,29 @@
import { hasAuthenticated, router } from "../trpc";
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
.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(),
});

const createUserSchema = z.object({
first_name: z.string(),
last_name: z.string(),
email: z.string().email(),
role: z.string(),
});

const roleValues = ROLE_OPTIONS.map((r) => r.value) as [string, ...string[]];

export const user = router({
getMe: hasAuthenticated.query(async (req) => {
Expand All @@ -20,6 +45,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
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 = 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 All @@ -34,4 +117,99 @@ 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 TRPCError({
code: "BAD_REQUEST",
message: "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;
}),

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;

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 as string)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "You cannot change your own role",
});
}

return await req.ctx.db
.updateTable("user")
.set({
first_name,
last_name,
email: email.toLowerCase(),
role,
})
.where("user_id", "=", user_id)
.returningAll()
.executeTakeFirstOrThrow();
}),
});
52 changes: 23 additions & 29 deletions src/components/CustomToast.tsx
Original file line number Diff line number Diff line change
@@ -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 && (
<div className={styles.customToastWrapper}>
<div className={styles.customToast}>
<Image
src="/img/error.filled.svg"
alt="Error Img"
width={24}
height={24}
></Image>
<div>{errorMessage ?? null}</div>

<button className={styles.closeButton} onClick={handleCloseToast}>
<Image
src="/img/cross-outline.svg"
alt="Close Toast"
width={24}
height={24}
></Image>
</button>
</div>
</div>
)}
</>
<div className={styles.customToastWrapper}>
<div className={styles.customToast}>
<Image
src="/img/error.filled.svg"
alt="Error Img"
width={24}
height={24}
/>
<div>{errorMessage}</div>
<button className={styles.closeButton} onClick={handleCloseToast}>
<Image
src="/img/cross-outline.svg"
alt="Close Toast"
width={24}
height={24}
/>
</button>
</div>
</div>
);
};

Expand Down
6 changes: 5 additions & 1 deletion src/components/design_system/breadcrumbs/Breadcrumbs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,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 | 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) => {
Expand Down
11 changes: 7 additions & 4 deletions src/components/design_system/dropdown/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ interface DropdownProps {
optionDisabled?: string[];
}

const Dropdown = ({
export const Dropdown = ({
itemList,
selectedOption,
setSelectedOption,
Expand All @@ -38,7 +38,6 @@ const Dropdown = ({
};

return (
// Minimum styles used. More can be defined in className.
<Box sx={{ minWidth: 120, maxWidth: "fit-content" }} className={className}>
<FormControl fullWidth>
<InputLabel id="dropdown-label">{label}</InputLabel>
Expand All @@ -48,8 +47,13 @@ const Dropdown = ({
value={selectedOption}
label={label}
onChange={handleChange}
// Allow disabling of form
disabled={formDisabled}
MenuProps={{
PaperProps: {
elevation: 1,
sx: { maxHeight: 300 },
},
}}
>
{itemList?.map((item) => (
<MenuItem
Expand All @@ -58,7 +62,6 @@ const Dropdown = ({
className={`${
selectedOption === item.value ? $dropdown.selected : ""
} ${$dropdown.default}`}
// Allow disabling of named keys used as an array of strings
disabled={optionDisabled?.includes(item.value)}
>
{item.label}
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
1 change: 1 addition & 0 deletions src/components/styles/Toast.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
bottom: 0;
right: 0;
width: 400px;
z-index: 9999;
}

.customToast {
Expand Down
Loading

0 comments on commit 7f858b9

Please sign in to comment.