Added User Edit Feature

This commit is contained in:
Sianida26 2024-01-26 01:26:31 +07:00
parent e9db884fc5
commit 764f677637
22 changed files with 477 additions and 46 deletions

View File

@ -29,6 +29,7 @@
"client-only": "^0.0.1",
"clsx": "^2.1.0",
"jsonwebtoken": "^9.0.2",
"mantine-form-zod-resolver": "^1.1.0",
"next": "14.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",

View File

@ -65,6 +65,9 @@ dependencies:
jsonwebtoken:
specifier: ^9.0.2
version: 9.0.2
mantine-form-zod-resolver:
specifier: ^1.1.0
version: 1.1.0(@mantine/form@7.4.2)(zod@3.22.4)
next:
specifier: 14.1.0
version: 14.1.0(react-dom@18.2.0)(react@18.2.0)(sass@1.70.0)
@ -2421,6 +2424,17 @@ packages:
semver: 6.3.1
dev: false
/mantine-form-zod-resolver@1.1.0(@mantine/form@7.4.2)(zod@3.22.4):
resolution: {integrity: sha512-hidTuYq6agSF5XbkcVVcr0mkGs9ki/x8OC9ldZMxGLVGja6bdl+x4k1hCNrigCG90DBoMDnu0bo3hprGBBlUZA==}
engines: {node: '>=16.6.0'}
peerDependencies:
'@mantine/form': '>=7.0.0'
zod: '>=3.0.0'
dependencies:
'@mantine/form': 7.4.2(react@18.2.0)
zod: 3.22.4
dev: false
/merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}

View File

@ -0,0 +1,9 @@
import React from 'react'
// TODO: Implement Delete Modal
export default function DeleteModal() {
return (
<div>DeleteModal</div>
)
}

View File

@ -0,0 +1,3 @@
import DeleteModal from "./DeleteModal";
export default DeleteModal;

View File

@ -0,0 +1,11 @@
import React from 'react'
import { FormModal } from '..'
import { UserFormData } from '@/features/dashboard/users/formSchemas/userFormDataSchema'
interface Props {
data: UserFormData
}
export default function DetailModal(props: Props) {
return <FormModal readonly modalTitle='Detail User' data={props.data} />
}

View File

@ -0,0 +1,3 @@
import DetailModal from "./DetailModal";
export default DetailModal;

View File

@ -0,0 +1,11 @@
import React from 'react'
import { FormModal } from '..'
import { UserFormData } from '@/features/dashboard/users/formSchemas/userFormDataSchema'
interface Props {
data: UserFormData
}
export default function EditModal(props: Props) {
return <FormModal modalTitle='Edit User' data={props.data} />
}

View File

@ -0,0 +1,3 @@
import EditModal from "./EditModal";
export default EditModal;

View File

@ -0,0 +1,154 @@
"use client";
import React, { useState } from "react";
import { useForm } from "@mantine/form";
import { useRouter } from "next/router";
import {
Avatar,
Button,
Center,
Flex,
Modal,
ScrollArea,
Stack,
TextInput,
Title,
} from "@mantine/core";
import { TbDeviceFloppy } from "react-icons/tb";
import editUser from "@/features/dashboard/users/actions/editUser";
import userFormDataSchema, {
UserFormData,
} from "@/features/dashboard/users/formSchemas/userFormDataSchema";
import { showNotification } from "@/utils/notifications";
import stringToColorHex from "@/utils/stringToColorHex";
import { zodResolver } from "@mantine/form";
interface Props {
readonly?: boolean;
modalTitle: string;
data: UserFormData;
}
export default function FormModal(props: Props) {
const router = useRouter();
const [isSubmitting, setSubmitting] = useState(false);
const form = useForm<UserFormData>({
initialValues: props.data,
validate: zodResolver(userFormDataSchema),
});
/**
* Closes the modal. It won't close if a submission is in progress.
*/
const closeModal = () => {
if (isSubmitting) return;
router
.replace("?")
.then(() => {})
.catch(() => {});
};
/**
* Handles the form submission.
*
* @param data The data from the form.
*/
const handleSubmit = (data: UserFormData) => {
setSubmitting(true);
editUser(data)
.then((response) => {
if (response.success) {
showNotification(response.message);
router
.replace("?")
.then(() => {})
.catch(() => {});
return;
} else {
if (response.errors) {
form.setErrors(response.errors);
return;
}
showNotification(response.message);
return;
}
})
.catch(() => {
//TODO: Handle Error
})
.finally(() => {
setSubmitting(false);
});
};
return (
<Modal
opened
onClose={closeModal}
title={<Title order={3}>{props.modalTitle}</Title>}
scrollAreaComponent={ScrollArea.Autosize}
>
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack mt="sm" gap="lg" px="lg">
{/* Avatar */}
<Center>
<Avatar
color={stringToColorHex(props.data.id)}
src={props.data.photoProfileUrl}
size={120}
>
{props.data.name?.[0].toUpperCase()}
</Avatar>
</Center>
{/* ID */}
<TextInput
label="ID"
readOnly
variant="filled"
{...form.getInputProps("id")}
/>
{/* Name */}
<TextInput
data-autofocus
label="Name"
readOnly={props.readonly}
disabled={isSubmitting}
{...form.getInputProps("name")}
/>
{/* Email */}
<TextInput
label="Email"
readOnly={props.readonly}
disabled={isSubmitting}
{...form.getInputProps("email")}
/>
{/* Buttons */}
<Flex justify="flex-end" align="center" gap="lg" mt="lg">
<Button
variant="outline"
onClick={closeModal}
disabled={isSubmitting}
>
Close
</Button>
{!props.readonly && (
<Button
variant="filled"
leftSection={<TbDeviceFloppy size={20} />}
type="submit"
loading={isSubmitting}
>
Save
</Button>
)}
</Flex>
</Stack>
</form>
</Modal>
);
}

View File

@ -0,0 +1,3 @@
import FormModal from "./FormModal";
export default FormModal;

View File

@ -0,0 +1,4 @@
export { default as DeleteModal } from "./DeleteModal"
export { default as DetailModal } from "./DetailModal"
export { default as EditModal } from "./EditModal"
export { default as FormModal } from "./FormModal"

View File

@ -1,5 +1,5 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import React from "react";
import {
flexRender,
@ -7,8 +7,7 @@ import {
useReactTable,
} from "@tanstack/react-table";
import columns, { UserRow } from "./columns";
import { Table, TableThead, Text } from "@mantine/core";
import { showErrorNotification } from "@/utils/notifications";
import { Table, Text } from "@mantine/core";
interface Props {
users: UserRow[]
@ -26,40 +25,42 @@ export default function UsersTable({users}: Props) {
});
return (
<Table verticalSpacing="sm" horizontalSpacing="xs">
{/* Thead */}
<Table.Thead>
{table.getHeaderGroups().map((headerGroup) => (
<Table.Tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<Table.Th key={header.id} style={{maxWidth: `${header.column.columnDef.maxSize}px`, width: `${header.getSize()}`}}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</Table.Th>
))}
</Table.Tr>
))}
</Table.Thead>
<>
<Table verticalSpacing="xs" horizontalSpacing="xs">
{/* Thead */}
<Table.Thead>
{table.getHeaderGroups().map((headerGroup) => (
<Table.Tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<Table.Th key={header.id} style={{maxWidth: `${header.column.columnDef.maxSize}px`, width: `${header.getSize()}`}}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</Table.Th>
))}
</Table.Tr>
))}
</Table.Thead>
{/* TBody */}
<Table.Tbody>
{table.getRowModel().rows.map((row) => (
<Table.Tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<Table.Td key={cell.id} style={{maxWidth: `${cell.column.columnDef.maxSize}px`}}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</Table.Td>
))}
</Table.Tr>
))}
</Table.Tbody>
</Table>
{/* TBody */}
<Table.Tbody>
{table.getRowModel().rows.map((row) => (
<Table.Tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<Table.Td key={cell.id} style={{maxWidth: `${cell.column.columnDef.maxSize}px`}}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</Table.Td>
))}
</Table.Tr>
))}
</Table.Tbody>
</Table>
</>
);
}

View File

@ -24,14 +24,14 @@ const columns = [
columnHelper.accessor('name', {
header: "Name",
cell: (props) => <Group>
<Avatar color={stringToColorHex(props.row.original.id)} src={props.row.original.photoUrl}>{props.getValue()?.[0].toUpperCase()}</Avatar>
<Text>{props.getValue()}</Text>
<Avatar color={stringToColorHex(props.row.original.id)} src={props.row.original.photoUrl} size={26}>{props.getValue()?.[0].toUpperCase()}</Avatar>
<Text size="sm" fw={500}>{props.getValue()}</Text>
</Group>
}),
columnHelper.accessor('email', {
header: "Email",
cell: (props) => <Anchor href={`mailto:${props.getValue()}`} component={Link}>{props.getValue()}</Anchor>
cell: (props) => <Anchor href={`mailto:${props.getValue()}`} size="sm" component={Link}>{props.getValue()}</Anchor>
}),
columnHelper.display({
@ -51,21 +51,21 @@ const columns = [
{/* Detail */}
<Tooltip label="Detail">
<ActionIcon variant="light" color="green" component={Link} href={`/dashboard/users/detail/${props.row.original.id}`}>
<ActionIcon variant="light" color="green" component={Link} href={`?detail=${props.row.original.id}`}>
<TbEye />
</ActionIcon>
</Tooltip>
{/* Edit */}
<Tooltip label="Edit">
<ActionIcon variant="light" color="yellow" component={Link} href={`/dashboard/users/edit/${props.row.original.id}`}>
<ActionIcon variant="light" color="yellow" component={Link} href={`?edit=${props.row.original.id}`}>
<TbPencil />
</ActionIcon>
</Tooltip>
{/* Delete */}
<Tooltip label="Delete">
<ActionIcon variant="light" color="red">
<ActionIcon variant="light" color="red" component={Link} href={`?delete=${props.row.original.id}`}>
<TbTrash />
</ActionIcon>
</Tooltip>

View File

@ -4,19 +4,55 @@ import UsersTable from "./_tables/UsersTable/UsersTable";
import checkPermission from "@/features/auth/tools/checkPermission";
import { redirect } from "next/navigation";
import getUsers from "@/features/dashboard/users/data/getUsers";
import { DeleteModal, DetailModal, EditModal } from "./_modals";
import getUserDetailById from "@/features/dashboard/users/data/getUserDetailById";
export default async function UsersPage() {
interface Props {
searchParams: {
detail?: string;
edit?: string;
delete?: string;
}
}
export default async function UsersPage({searchParams}: Props) {
// Check for permission and return error component if not permitted
if (!await checkPermission("authenticated-only")) return <div>Error</div>
const users = await getUsers()
/**
* Renders the appropriate modal based on the search parameters.
*
* @returns A modal component or null.
*/
const renderModal = async () => {
if (searchParams.detail){
const userDetail = await getUserDetailById(searchParams.detail)
return <DetailModal data={userDetail} />
}
if (searchParams.edit){
const userDetail = await getUserDetailById(searchParams.edit)
return <EditModal data={userDetail} />
}
if (searchParams.delete){
return <DeleteModal />
}
return null;
}
return (
<Stack className="flex flex-col">
<Title order={1}>Users</Title>
<Card>
<UsersTable users={users} />
</Card>
{await renderModal()}
</Stack>
);
}

View File

@ -3,6 +3,7 @@ import { Inter } from "next/font/google";
import "./globals.css";
import "@mantine/core/styles.css";
import '@mantine/notifications/styles.css';
import { ColorSchemeScript, MantineProvider } from "@mantine/core";
import { AuthContextProvider } from "@/features/auth/contexts/AuthContext";

6
src/config/cookie.ts Normal file
View File

@ -0,0 +1,6 @@
const cookieConfig = {
notificationKey: "n",
notificationMaxAge: 10,
} as const;
export default cookieConfig;

View File

@ -0,0 +1,69 @@
"use server";
import prisma from "@/db";
import userFormDataSchema, { UserFormData } from "../formSchemas/userFormDataSchema";
import checkPermission from "@/features/auth/tools/checkPermission";
import { revalidatePath } from "next/cache";
import mapObjectToFirstValue from "@/utils/mapObjectToFirstValue";
/**
* Edits user data in the database based on the provided form data.
*
* @param formData The user data to be updated.
* @returns A promise that resolves to an object indicating the success or failure of the operation.
*/
export default async function editUser(formData: UserFormData) {
// Check user permission
if (!await checkPermission("authenticated-only")) return {
success: false,
message: "Unauthorized"
}
// Validate form data
const validatedFields = userFormDataSchema.safeParse(formData);
if (!validatedFields.success){
return {
success: false,
message: "Invalid Form Data",
errors: mapObjectToFirstValue(validatedFields.error.flatten().fieldErrors)
} as const
}
// Check for valid ID
if (!validatedFields.data.id){
return {
success: false,
message: "Invalid Form Data",
errors: {
id: "Invalid ID"
}
} as const
}
// Update user data in the database
try {
await prisma.user.update({
where: { id: validatedFields.data.id },
data: {
email: validatedFields.data.email,
name: validatedFields.data.name,
}
});
// Revalidate the cache
revalidatePath(".");
return {
success: true,
message: `User ${validatedFields.data.name} has been successfully updated`
};
} catch (error) {
// Consider handling specific database errors here
console.error('Error updating user data', error);
return {
success: false,
message: "Error updating user data"
};
}
}

View File

@ -0,0 +1,45 @@
import "server-only"
import prisma from "@/db";
import { notFound } from "next/navigation";
import checkPermission from "@/features/auth/tools/checkPermission";
import { unauthorized } from "@/BaseError";
/**
* Retrieves detailed information of a user by their ID.
*
* @param id The unique identifier of the user.
* @returns The user's detailed information or an error response.
*/
export default async function getUserDetailById(id: string){
// Check user permission
if (!checkPermission("authenticated-only")) return unauthorized();
// Retrieve user data from the database
const user = await prisma.user.findFirst({
where: { id },
select: {
id: true,
email: true,
name: true,
photoProfile: {
select: {
path: true
}
},
}
})
// Check if user exists
if (!user) return notFound();
// Format user data
const formattedUser = {
id: user.id,
email: user.email ?? "",
name: user.name ?? "",
photoProfileUrl: user.photoProfile?.path ?? ""
}
return formattedUser;
}

View File

@ -18,7 +18,6 @@ const getUsers = async () => {
},
name: true,
},
where: {},
})
const result = users.map((user) => ({

View File

@ -0,0 +1,17 @@
import { z } from "zod";
export interface UserFormData {
id: string;
name: string;
photoProfileUrl: string;
email: string;
}
const userFormDataSchema = z.object({
id: z.string().nullable(),
name: z.string(),
photoProfileUrl: z.union([z.string(), z.null()]),
email: z.string().email(),
});
export default userFormDataSchema;

View File

@ -0,0 +1,16 @@
/**
* Maps each key of an object to the first value in its corresponding array.
*
* @template T The object type, where each key has an array of strings as its value.
* @param someObject The object to be transformed.
* @returns An object with the same keys, but each key maps to the first string in the original array.
*/
const mapObjectToFirstValue = <T extends { [key: string]: string[] }>(
someObject: T
): { [K in keyof T]: string } =>
Object.entries(someObject).reduce((prev, [k, v]) => {
prev[k as keyof T] = v[0];
return prev;
}, {} as { [K in keyof T]: string });
export default mapObjectToFirstValue;

View File

@ -1,9 +1,34 @@
import cookieConfig from "@/config/cookie";
import { NotificationData, notifications } from "@mantine/notifications";
export type NotificationType = "success" | "error";
/**
* Shows an error notification. This function is deprecated and should be replaced by `showNotification`.
*
* @deprecated
* @param [message="Error"] The message to be displayed in the notification.
* @param notificationData Optional additional data for the notification.
*/
export const showErrorNotification = (message: string = "Error", notificationData?: NotificationData) => {
notifications.show({
message,
color: "red",
...notificationData,
})
}
}
/**
* Shows a notification with configurable type and message.
*
* @param message The message to be displayed in the notification.
* @param [type="success"] The type of the notification, either "success" or "error".
* @param notificationData Optional additional data for the notification.
*/
export const showNotification = (message: string, type: NotificationType = "success", notificationData?: NotificationData) => {
notifications.show({
message,
color: type === "error" ? "red" : "green",
...notificationData
})
}