From a542ba1ffafeec13a81cc8e57bfa88e927c06b03 Mon Sep 17 00:00:00 2001 From: sianida26 Date: Sat, 1 Jun 2024 21:43:09 +0700 Subject: [PATCH] Added search and other --- apps/backend/src/routes/dev/route.ts | 63 +++-- apps/backend/src/routes/users/route.ts | 61 ++++- apps/frontend/src/components/PageTemplate.tsx | 229 ++++++++++++++++ .../usersManagement/modals/UserFormModal.tsx | 255 ++++++++---------- .../usersManagement/queries/userQueries.ts | 39 ++- .../usersManagement/tables/UsersTable.tsx | 74 ----- .../usersManagement/tables/columns.tsx | 25 +- apps/frontend/src/routeTree.gen.ts | 61 ++++- .../_dashboardLayout/users/index.lazy.tsx | 55 ++-- .../routes/_dashboardLayout/users/index.tsx | 18 ++ .../src/types/ExtractQueryDataType.d.ts | 9 + 11 files changed, 586 insertions(+), 303 deletions(-) create mode 100644 apps/frontend/src/components/PageTemplate.tsx delete mode 100644 apps/frontend/src/modules/usersManagement/tables/UsersTable.tsx create mode 100644 apps/frontend/src/routes/_dashboardLayout/users/index.tsx create mode 100644 apps/frontend/src/types/ExtractQueryDataType.d.ts diff --git a/apps/backend/src/routes/dev/route.ts b/apps/backend/src/routes/dev/route.ts index 65e8bf8..8291ea1 100644 --- a/apps/backend/src/routes/dev/route.ts +++ b/apps/backend/src/routes/dev/route.ts @@ -7,6 +7,10 @@ import compressImage from "../../utils/compressImage"; import checkPermission from "../../middlewares/checkPermission"; import { createId } from "@paralleldrive/cuid2"; import { writeFileSync } from "fs"; +import db from "../../drizzle"; +import { users } from "../../drizzle/schema/users"; +import { isNull, sql } from "drizzle-orm"; +import { unionAll } from "drizzle-orm/pg-core"; const fileSchema = z.object({ file: z.instanceof(File), @@ -15,26 +19,49 @@ const fileSchema = z.object({ const devRoutes = new Hono() .use(authInfo) .use(checkPermission("dev-routes")) - .get("/middleware", async (c) => { - return c.json({ - message: "Middleware works!", - }); - }) - .post("/file", requestValidator("form", fileSchema), async (c) => { - const { file } = c.req.valid("form"); + .get( + "/test", + checkPermission("users.readAll"), + requestValidator( + "query", + z.object({ + includeTrashed: z.string().default("false"), + withMetadata: z.string().default("false"), + page: z.coerce.number().int().min(1).optional(), + limit: z.coerce.number().int().min(1).max(1000).optional(), + }) + ), + async (c) => { + const userQuery = db + .select({ + id: users.id, + name: users.name, + email: users.email, + username: users.username, + isEnabled: users.isEnabled, + createdAt: users.createdAt, + updatedAt: users.updatedAt, + fullCount: sql`null`, + }) + .from(users); - const buffer = await compressImage({ - inputFile: file, - targetSize: 500 * 1024, - }); + const totalCount = db + .select({ + id: sql`null`, + name: sql`null`, + email: sql`null`, + username: sql`null`, + isEnabled: sql`null`, + createdAt: sql`null`, + updatedAt: sql`null`, + fullCount: sql`count(*)`, + }) + .from(users); - const filename = `${createId()}.jpg`; + const result = await unionAll(userQuery, totalCount); - writeFileSync(`images/${filename}`, buffer); - - return c.json({ - message: `File saved! name: ${filename}`, - }); - }); + return c.json(result); + } + ); export default devRoutes; diff --git a/apps/backend/src/routes/users/route.ts b/apps/backend/src/routes/users/route.ts index 0b391dc..45ea595 100644 --- a/apps/backend/src/routes/users/route.ts +++ b/apps/backend/src/routes/users/route.ts @@ -1,4 +1,4 @@ -import { and, eq, isNull } from "drizzle-orm"; +import { and, count, eq, ilike, isNull, or, sql } from "drizzle-orm"; import { Hono } from "hono"; import { z } from "zod"; @@ -12,6 +12,7 @@ import HonoEnv from "../../types/HonoEnv"; import requestValidator from "../../utils/requestValidator"; import authInfo from "../../middlewares/authInfo"; import checkPermission from "../../middlewares/checkPermission"; +import { unionAll } from "drizzle-orm/mysql-core"; export const userFormSchema = z.object({ name: z.string().min(1).max(255), @@ -44,20 +45,40 @@ export const userUpdateSchema = userFormSchema.extend({ const usersRoute = new Hono() .use(authInfo) + /** + * Get All Users (With Metadata) + * + * Query params: + * - includeTrashed: boolean (default: false)\ + * - withMetadata: boolean + */ .get( "/", checkPermission("users.readAll"), requestValidator( "query", z.object({ - includeTrashed: z.string().default("false"), + includeTrashed: z + .string() + .optional() + .transform((v) => v?.toLowerCase() === "true"), + withMetadata: z + .string() + .optional() + .transform((v) => v?.toLowerCase() === "true"), + page: z.coerce.number().int().min(0).default(0), + limit: z.coerce.number().int().min(1).max(1000).default(1), + q: z.string().default(""), }) ), async (c) => { - const includeTrashed = - c.req.query("includeTrashed")?.toLowerCase() === "true"; + const { includeTrashed, page, limit, q } = c.req.valid("query"); - let usersData = await db + const totalCountQuery = includeTrashed + ? sql`(SELECT count(*) FROM ${users})` + : sql`(SELECT count(*) FROM ${users} WHERE ${users.deletedAt} IS NULL)`; + + const result = await db .select({ id: users.id, name: users.name, @@ -67,13 +88,34 @@ const usersRoute = new Hono() createdAt: users.createdAt, updatedAt: users.updatedAt, ...(includeTrashed ? { deletedAt: users.deletedAt } : {}), - - // password: users.password, + fullCount: totalCountQuery, }) .from(users) - .where(!includeTrashed ? isNull(users.deletedAt) : undefined); + .where( + and( + includeTrashed ? undefined : isNull(users.deletedAt), + q + ? or( + ilike(users.name, q), + ilike(users.username, q), + ilike(users.email, q), + eq(users.id, q) + ) + : undefined + ) + ) + .offset(page * limit) + .limit(limit); - return c.json(usersData); + return c.json({ + data: result.map((d) => ({ ...d, fullCount: undefined })), + _metadata: { + currentPage: page, + totalPages: Math.ceil(result[0]?.fullCount ?? 0 / limit), + totalItems: Number(result[0]?.fullCount) ?? 0, + perPage: limit, + }, + }); } ) //get user by id @@ -292,5 +334,4 @@ const usersRoute = new Hono() message: "User restored successfully", }); }); - export default usersRoute; diff --git a/apps/frontend/src/components/PageTemplate.tsx b/apps/frontend/src/components/PageTemplate.tsx new file mode 100644 index 0000000..3107229 --- /dev/null +++ b/apps/frontend/src/components/PageTemplate.tsx @@ -0,0 +1,229 @@ +/* eslint-disable no-mixed-spaces-and-tabs */ +import { + Button, + Card, + Flex, + Pagination, + Select, + Stack, + Text, + TextInput, + Title, +} from "@mantine/core"; +import { Link } from "@tanstack/react-router"; +import React, { ReactNode, useState } from "react"; +import { TbPlus, TbSearch } from "react-icons/tb"; +import DashboardTable from "./DashboardTable"; +import { + ColumnDef, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { + QueryKey, + UseQueryOptions, + keepPreviousData, + useQuery, +} from "@tanstack/react-query"; +import { useDebouncedCallback } from "@mantine/hooks"; + +type PaginatedResponse> = { + data: Array; + _metadata: { + currentPage: number; + totalPages: number; + perPage: number; + totalItems: number; + }; +}; + +//ref: https://x.com/TkDodo/status/1491451513264574501 +type Props< + TQueryKey extends QueryKey, + TQueryFnData extends Record, + TError, + TData extends Record = TQueryFnData, +> = { + title: string; + createButton?: string | true | React.ReactNode; + modals?: React.ReactNode[]; + queryOptions: ( + page: number, + limit: number, + q?: string + ) => UseQueryOptions< + PaginatedResponse, + TError, + PaginatedResponse, + TQueryKey + >; + columnDefs: ColumnDef[]; +}; + +/** + * Creates a "Create New" button or returns the provided React node. + * + * @param property - The property that determines the type of button to create. It can be a boolean, string, or React node. + * @returns The create button element. + */ +const createCreateButton = ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + property: Props["createButton"] = true +) => { + if (property === true) { + return ( + + ); + } else if (typeof property === "string") { + return ( + + ); + } else { + return property; + } +}; + +/** + * PageTemplate component for displaying a paginated table with search and filter functionality. + + * @param props - The properties object. + * @returns The rendered PageTemplate component. + */ +export default function PageTemplate< + TQueryKey extends QueryKey, + TQueryFnData extends Record, + TError, + TData extends Record = TQueryFnData, +>(props: Props) { + const [filterOptions, setFilterOptions] = useState({ + page: 0, + limit: 10, + q: "", + }); + + // const [deboucedSearchQuery] = useDebouncedValue(filterOptions.q, 500); + + const query = useQuery({ + ...(typeof props.queryOptions === "function" + ? props.queryOptions( + filterOptions.page, + filterOptions.limit, + filterOptions.q + ) + : props.queryOptions), + placeholderData: keepPreviousData, + }); + + const table = useReactTable({ + data: query.data?.data ?? [], + columns: props.columnDefs, + getCoreRowModel: getCoreRowModel(), + defaultColumn: { + cell: (props) => {props.getValue() as ReactNode}, + }, + }); + + /** + * Handles the change in search query input with debounce. + * + * @param value - The new search query value. + */ + const handleSearchQueryChange = useDebouncedCallback((value: string) => { + setFilterOptions((prev) => ({ + page: 0, + limit: prev.limit, + q: value, + })); + }, 500); + + /** + * Handles the change in page number. + * + * @param page - The new page number. + */ + const handlePageChange = (page: number) => { + setFilterOptions((prev) => ({ + page: page - 1, + limit: prev.limit, + q: prev.q, + })); + }; + + return ( + + {props.title} + + {/* Top Section */} + + {createCreateButton(props.createButton)} + + + {/* Table Functionality */} +
+ {/* Search */} +
+ } + value={filterOptions.q} + onChange={(e) => + handleSearchQueryChange(e.target.value) + } + placeholder="Search..." + /> +
+ + {/* Table */} + + + {/* Pagination */} + {query.data && ( +
+