Added search and other
This commit is contained in:
parent
c6f9f930e7
commit
a542ba1ffa
|
|
@ -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<HonoEnv>()
|
||||
.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<number>`null`,
|
||||
})
|
||||
.from(users);
|
||||
|
||||
const buffer = await compressImage({
|
||||
inputFile: file,
|
||||
targetSize: 500 * 1024,
|
||||
});
|
||||
const totalCount = db
|
||||
.select({
|
||||
id: sql<string>`null`,
|
||||
name: sql<string>`null`,
|
||||
email: sql<string>`null`,
|
||||
username: sql<string>`null`,
|
||||
isEnabled: sql<boolean>`null`,
|
||||
createdAt: sql<Date>`null`,
|
||||
updatedAt: sql<Date>`null`,
|
||||
fullCount: sql<number>`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;
|
||||
|
|
|
|||
|
|
@ -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<HonoEnv>()
|
||||
.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<number>`(SELECT count(*) FROM ${users})`
|
||||
: sql<number>`(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<HonoEnv>()
|
|||
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<HonoEnv>()
|
|||
message: "User restored successfully",
|
||||
});
|
||||
});
|
||||
|
||||
export default usersRoute;
|
||||
|
|
|
|||
229
apps/frontend/src/components/PageTemplate.tsx
Normal file
229
apps/frontend/src/components/PageTemplate.tsx
Normal file
|
|
@ -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<T extends Record<string, unknown>> = {
|
||||
data: Array<T>;
|
||||
_metadata: {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
perPage: number;
|
||||
totalItems: number;
|
||||
};
|
||||
};
|
||||
|
||||
//ref: https://x.com/TkDodo/status/1491451513264574501
|
||||
type Props<
|
||||
TQueryKey extends QueryKey,
|
||||
TQueryFnData extends Record<string, unknown>,
|
||||
TError,
|
||||
TData extends Record<string, unknown> = TQueryFnData,
|
||||
> = {
|
||||
title: string;
|
||||
createButton?: string | true | React.ReactNode;
|
||||
modals?: React.ReactNode[];
|
||||
queryOptions: (
|
||||
page: number,
|
||||
limit: number,
|
||||
q?: string
|
||||
) => UseQueryOptions<
|
||||
PaginatedResponse<TQueryFnData>,
|
||||
TError,
|
||||
PaginatedResponse<TData>,
|
||||
TQueryKey
|
||||
>;
|
||||
columnDefs: ColumnDef<any>[];
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<any, any, any>["createButton"] = true
|
||||
) => {
|
||||
if (property === true) {
|
||||
return (
|
||||
<Button
|
||||
leftSection={<TbPlus />}
|
||||
component={Link}
|
||||
search={{ create: true }}
|
||||
>
|
||||
Create New
|
||||
</Button>
|
||||
);
|
||||
} else if (typeof property === "string") {
|
||||
return (
|
||||
<Button
|
||||
leftSection={<TbPlus />}
|
||||
component={Link}
|
||||
search={{ create: true }}
|
||||
>
|
||||
{property}
|
||||
</Button>
|
||||
);
|
||||
} 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<string, unknown>,
|
||||
TError,
|
||||
TData extends Record<string, unknown> = TQueryFnData,
|
||||
>(props: Props<TQueryKey, TQueryFnData, TError, TData>) {
|
||||
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) => <Text>{props.getValue() as ReactNode}</Text>,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<Stack>
|
||||
<Title order={1}>{props.title}</Title>
|
||||
<Card>
|
||||
{/* Top Section */}
|
||||
<Flex justify="flex-end">
|
||||
{createCreateButton(props.createButton)}
|
||||
</Flex>
|
||||
|
||||
{/* Table Functionality */}
|
||||
<div className="flex flex-col">
|
||||
{/* Search */}
|
||||
<div className="flex pb-4">
|
||||
<TextInput
|
||||
leftSection={<TbSearch />}
|
||||
value={filterOptions.q}
|
||||
onChange={(e) =>
|
||||
handleSearchQueryChange(e.target.value)
|
||||
}
|
||||
placeholder="Search..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<DashboardTable table={table} />
|
||||
|
||||
{/* Pagination */}
|
||||
{query.data && (
|
||||
<div className="pt-4 flex-wrap flex items-center gap-4">
|
||||
<Select
|
||||
label="Per Page"
|
||||
data={["5", "10", "50", "100", "500", "1000"]}
|
||||
allowDeselect={false}
|
||||
defaultValue="10"
|
||||
searchValue={filterOptions.limit.toString()}
|
||||
onChange={(value) =>
|
||||
setFilterOptions((prev) => ({
|
||||
page: prev.page,
|
||||
limit: parseInt(value ?? "10"),
|
||||
q: prev.q,
|
||||
}))
|
||||
}
|
||||
checkIconPosition="right"
|
||||
className="w-20"
|
||||
/>
|
||||
<Pagination
|
||||
value={filterOptions.page + 1}
|
||||
total={query.data._metadata.totalPages}
|
||||
onChange={handlePageChange}
|
||||
/>
|
||||
<Text c="dimmed" size="sm">
|
||||
Showing {query.data.data.length} of{" "}
|
||||
{query.data._metadata.totalItems}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The Modals */}
|
||||
{props.modals?.map((modal, index) => (
|
||||
<React.Fragment key={index}>{modal}</React.Fragment>
|
||||
))}
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,71 +5,87 @@ import {
|
|||
Center,
|
||||
Flex,
|
||||
Modal,
|
||||
MultiSelect,
|
||||
PasswordInput,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { getRouteApi, useSearch } from "@tanstack/react-router";
|
||||
import { getRouteApi } from "@tanstack/react-router";
|
||||
import { createUser, updateUser } from "../queries/userQueries";
|
||||
import { TbDeviceFloppy } from "react-icons/tb";
|
||||
import client from "../../../honoClient";
|
||||
import { getUserByIdQueryOptions } from "../queries/userQueries";
|
||||
import { useEffect } from "react";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import FormResponseError from "@/errors/FormResponseError";
|
||||
import createInputComponents from "@/utils/createInputComponents";
|
||||
|
||||
/**
|
||||
* Change this
|
||||
*/
|
||||
const routeApi = getRouteApi("/_dashboardLayout/users/");
|
||||
|
||||
export default function UserFormModal() {
|
||||
const searchParams = useSearch({ from: "/_dashboardLayout/users/" }) as {
|
||||
detail: string;
|
||||
edit: string;
|
||||
create: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* DON'T CHANGE FOLLOWING:
|
||||
*/
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const userId = searchParams.detail || searchParams.edit;
|
||||
const navigate = routeApi.useNavigate();
|
||||
|
||||
const rolesQuery = useQuery({
|
||||
queryKey: ["roles"],
|
||||
queryFn: async () => {
|
||||
const res = await client.roles.$get();
|
||||
const searchParams = routeApi.useSearch();
|
||||
|
||||
if (res.ok) {
|
||||
return await res.json();
|
||||
}
|
||||
const dataId = searchParams.detail || searchParams.edit;
|
||||
|
||||
throw new Error(await res.text());
|
||||
const isModalOpen = Boolean(dataId || searchParams.create);
|
||||
|
||||
const detailId = searchParams.detail;
|
||||
const editId = searchParams.edit;
|
||||
|
||||
const formType = detailId ? "detail" : editId ? "edit" : "create";
|
||||
|
||||
/**
|
||||
* CHANGE FOLLOWING:
|
||||
*/
|
||||
|
||||
const userQuery = useQuery(getUserByIdQueryOptions(dataId));
|
||||
|
||||
const modalTitle =
|
||||
formType.charAt(0).toUpperCase() + formType.slice(1) + " User";
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
id: "",
|
||||
email: "",
|
||||
name: "",
|
||||
username: "",
|
||||
photoProfileUrl: "",
|
||||
password: "",
|
||||
roles: [] as string[],
|
||||
},
|
||||
});
|
||||
|
||||
const userQuery = useQuery({
|
||||
queryKey: ["users", userId],
|
||||
enabled: Boolean(userId),
|
||||
queryFn: async () => {
|
||||
if (!userId) return null;
|
||||
const res = await client.users[":id"].$get({
|
||||
param: {
|
||||
id: userId,
|
||||
},
|
||||
query: {},
|
||||
});
|
||||
useEffect(() => {
|
||||
const data = userQuery.data;
|
||||
|
||||
if (res.ok) {
|
||||
console.log("ok");
|
||||
return await res.json();
|
||||
}
|
||||
console.log("not ok");
|
||||
throw new Error(await res.text());
|
||||
},
|
||||
});
|
||||
if (!data) {
|
||||
form.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
const isModalOpen =
|
||||
Boolean(userId && userQuery.data) || searchParams.create;
|
||||
form.setValues({
|
||||
id: data.id,
|
||||
email: data.email ?? "",
|
||||
name: data.name,
|
||||
photoProfileUrl: "",
|
||||
username: data.username,
|
||||
password: "",
|
||||
roles: data.roles.map((v) => v.id), //only extract the id
|
||||
});
|
||||
|
||||
form.setErrors({});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [userQuery.data]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationKey: ["usersMutation"],
|
||||
|
|
@ -100,51 +116,6 @@ export default function UserFormModal() {
|
|||
},
|
||||
});
|
||||
|
||||
const navigate = routeApi.useNavigate();
|
||||
|
||||
const detailId = searchParams.detail;
|
||||
const editId = searchParams.edit;
|
||||
|
||||
const formType = detailId ? "detail" : editId ? "edit" : "create";
|
||||
const modalTitle =
|
||||
formType.charAt(0).toUpperCase() + formType.slice(1) + " User";
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
id: "",
|
||||
email: "",
|
||||
name: "",
|
||||
username: "",
|
||||
photoProfileUrl: "",
|
||||
password: "",
|
||||
roles: [] as string[],
|
||||
},
|
||||
// validate: zodResolver(userFormDataSchema),
|
||||
// validateInputOnChange: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const data = userQuery.data;
|
||||
|
||||
if (!data) {
|
||||
form.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
form.setValues({
|
||||
id: data.id,
|
||||
email: data.email ?? "",
|
||||
name: data.name,
|
||||
photoProfileUrl: "",
|
||||
username: data.username,
|
||||
password: "",
|
||||
roles: data.roles.map((v) => v.id), //only extract the id
|
||||
});
|
||||
|
||||
form.setErrors({});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [userQuery.data]);
|
||||
|
||||
const handleSubmit = async (values: typeof form.values) => {
|
||||
if (formType === "detail") return;
|
||||
|
||||
|
|
@ -183,6 +154,22 @@ export default function UserFormModal() {
|
|||
navigate({ search: {} });
|
||||
};
|
||||
|
||||
/**
|
||||
* YOU MIGHT NOT NEED FOLLOWING:
|
||||
*/
|
||||
const rolesQuery = useQuery({
|
||||
queryKey: ["roles"],
|
||||
queryFn: async () => {
|
||||
const res = await client.roles.$get();
|
||||
|
||||
if (res.ok) {
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
throw new Error(await res.text());
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={isModalOpen}
|
||||
|
|
@ -205,59 +192,51 @@ export default function UserFormModal() {
|
|||
</Center>
|
||||
</Stack>
|
||||
|
||||
{form.values.id && (
|
||||
<TextInput
|
||||
label="ID"
|
||||
readOnly
|
||||
variant="filled"
|
||||
disabled={mutation.isPending}
|
||||
{...form.getInputProps("id")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
data-autofocus
|
||||
label="Name"
|
||||
readOnly={formType === "detail"}
|
||||
disabled={mutation.isPending}
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Username"
|
||||
readOnly={formType === "detail"}
|
||||
disabled={mutation.isPending}
|
||||
{...form.getInputProps("username")}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Email"
|
||||
readOnly={formType === "detail"}
|
||||
disabled={mutation.isPending}
|
||||
{...form.getInputProps("email")}
|
||||
/>
|
||||
|
||||
{formType === "create" && (
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
disabled={mutation.isPending}
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Role */}
|
||||
<MultiSelect
|
||||
label="Roles"
|
||||
readOnly={formType === "detail"}
|
||||
disabled={mutation.isPending}
|
||||
value={form.values.roles}
|
||||
onChange={(values) => form.setFieldValue("roles", values)}
|
||||
data={rolesQuery.data?.map((role) => ({
|
||||
value: role.id,
|
||||
label: role.name,
|
||||
}))}
|
||||
error={form.errors.roles}
|
||||
/>
|
||||
{createInputComponents({
|
||||
disableAll: mutation.isPending,
|
||||
readonlyAll: formType === "detail",
|
||||
inputs: [
|
||||
{
|
||||
type: "text",
|
||||
readOnly: true,
|
||||
variant: "filled",
|
||||
...form.getInputProps("id"),
|
||||
hidden: !form.values.id,
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
label: "Name",
|
||||
...form.getInputProps("name"),
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
label: "Username",
|
||||
...form.getInputProps("username"),
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
label: "Email",
|
||||
...form.getInputProps("email"),
|
||||
},
|
||||
{
|
||||
type: "password",
|
||||
label: "Password",
|
||||
hidden: formType !== "create",
|
||||
},
|
||||
{
|
||||
type: "multi-select",
|
||||
label: "Roles",
|
||||
value: form.values.roles,
|
||||
onChange: (values) =>
|
||||
form.setFieldValue("roles", values),
|
||||
data: rolesQuery.data?.map((role) => ({
|
||||
value: role.id,
|
||||
label: role.name,
|
||||
})),
|
||||
error: form.errors.roles,
|
||||
},
|
||||
],
|
||||
})}
|
||||
|
||||
{/* Buttons */}
|
||||
<Flex justify="flex-end" align="center" gap="lg" mt="lg">
|
||||
|
|
|
|||
|
|
@ -3,18 +3,35 @@ import fetchRPC from "@/utils/fetchRPC";
|
|||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { InferRequestType } from "hono";
|
||||
|
||||
export const userQueryOptions = queryOptions({
|
||||
queryKey: ["users"],
|
||||
queryFn: () => fetchUsers(),
|
||||
});
|
||||
export const userQueryOptions = (page: number, limit: number, q?: string) =>
|
||||
queryOptions({
|
||||
queryKey: ["users", { page, limit, q }],
|
||||
queryFn: () =>
|
||||
fetchRPC(
|
||||
client.users.$get({
|
||||
query: {
|
||||
limit: String(limit),
|
||||
page: String(page),
|
||||
q,
|
||||
},
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
export const fetchUsers = async () => {
|
||||
return await fetchRPC(
|
||||
client.users.$get({
|
||||
query: {},
|
||||
})
|
||||
);
|
||||
};
|
||||
export const getUserByIdQueryOptions = (userId: string | undefined) =>
|
||||
queryOptions({
|
||||
queryKey: ["user", userId],
|
||||
queryFn: () =>
|
||||
fetchRPC(
|
||||
client.users[":id"].$get({
|
||||
param: {
|
||||
id: userId!,
|
||||
},
|
||||
query: {},
|
||||
})
|
||||
),
|
||||
enabled: Boolean(userId),
|
||||
});
|
||||
|
||||
export const createUser = async (
|
||||
form: InferRequestType<typeof client.users.$post>["form"]
|
||||
|
|
|
|||
|
|
@ -1,74 +0,0 @@
|
|||
import { Button, Flex, Text } from "@mantine/core";
|
||||
import { Link, getRouteApi } from "@tanstack/react-router";
|
||||
import React from "react";
|
||||
import { TbPlus } from "react-icons/tb";
|
||||
import DashboardTable from "../../../components/DashboardTable";
|
||||
import { getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
||||
import createColumns from "./columns";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { userQueryOptions } from "../queries/userQueries";
|
||||
import UserFormModal from "../modals/UserFormModal";
|
||||
import UserDeleteModal from "../modals/UserDeleteModal";
|
||||
|
||||
const routeApi = getRouteApi("/_dashboardLayout/users/");
|
||||
|
||||
export default function UsersTable() {
|
||||
const navigate = routeApi.useNavigate();
|
||||
|
||||
const usersQuery = useSuspenseQuery(userQueryOptions);
|
||||
|
||||
const table = useReactTable({
|
||||
data: usersQuery.data,
|
||||
columns: createColumns({
|
||||
permissions: {
|
||||
create: true,
|
||||
read: true,
|
||||
delete: true,
|
||||
update: true,
|
||||
},
|
||||
actions: {
|
||||
detail: (id: string) =>
|
||||
navigate({
|
||||
search: {
|
||||
detail: id,
|
||||
},
|
||||
}),
|
||||
edit: (id: string) =>
|
||||
navigate({
|
||||
search: {
|
||||
edit: id,
|
||||
},
|
||||
}),
|
||||
delete: (id: string) =>
|
||||
navigate({
|
||||
search: {
|
||||
delete: id,
|
||||
},
|
||||
}),
|
||||
},
|
||||
}),
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
defaultColumn: {
|
||||
cell: (props) => <Text>{props.getValue() as React.ReactNode}</Text>,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex justify="flex-end">
|
||||
<Button
|
||||
leftSection={<TbPlus />}
|
||||
component={Link}
|
||||
search={{ create: true }}
|
||||
>
|
||||
New User
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
<DashboardTable table={table} />
|
||||
|
||||
<UserFormModal />
|
||||
<UserDeleteModal />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -10,17 +10,12 @@ import { Link } from "@tanstack/react-router";
|
|||
|
||||
interface ColumnOptions {
|
||||
permissions: Partial<CrudPermission>;
|
||||
actions: {
|
||||
detail: (id: string) => void;
|
||||
edit: (id: string) => void;
|
||||
delete: (id: string, name: string) => void;
|
||||
};
|
||||
}
|
||||
|
||||
const createColumns = (options: ColumnOptions) => {
|
||||
const columnHelper =
|
||||
createColumnHelper<
|
||||
InferResponseType<typeof client.users.$get>[number]
|
||||
InferResponseType<typeof client.users.$get>["data"][number]
|
||||
>();
|
||||
|
||||
const columns = [
|
||||
|
|
@ -70,7 +65,11 @@ const createColumns = (options: ColumnOptions) => {
|
|||
columnHelper.display({
|
||||
id: "status",
|
||||
header: "Status",
|
||||
cell: () => <Badge color="green">Active</Badge>,
|
||||
cell: (props) => (
|
||||
<Badge color={props.row.original.isEnabled ? "green" : "gray"}>
|
||||
{props.row.original.isEnabled ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
),
|
||||
}),
|
||||
|
||||
columnHelper.display({
|
||||
|
|
@ -86,27 +85,21 @@ const createColumns = (options: ColumnOptions) => {
|
|||
{
|
||||
label: "Detail",
|
||||
permission: options.permissions.read,
|
||||
action: () =>
|
||||
options.actions.detail(props.row.original.id),
|
||||
action: `?detail=${props.row.original.id}`,
|
||||
color: "green",
|
||||
icon: <TbEye />,
|
||||
},
|
||||
{
|
||||
label: "Edit",
|
||||
permission: options.permissions.update,
|
||||
action: () =>
|
||||
options.actions.edit(props.row.original.id),
|
||||
action: `?edit=${props.row.original.id}`,
|
||||
color: "yellow",
|
||||
icon: <TbPencil />,
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
permission: options.permissions.delete,
|
||||
action: () =>
|
||||
options.actions.delete(
|
||||
props.row.original.id,
|
||||
props.row.original.name ?? ""
|
||||
),
|
||||
action: `?delete=${props.row.original.id}`,
|
||||
color: "red",
|
||||
icon: <TbTrash />,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { createFileRoute } from '@tanstack/react-router'
|
|||
|
||||
import { Route as rootRoute } from './routes/__root'
|
||||
import { Route as DashboardLayoutImport } from './routes/_dashboardLayout'
|
||||
import { Route as DashboardLayoutUsersIndexImport } from './routes/_dashboardLayout/users/index'
|
||||
import { Route as DashboardLayoutDashboardIndexImport } from './routes/_dashboardLayout/dashboard/index'
|
||||
|
||||
// Create Virtual Routes
|
||||
|
|
@ -21,9 +22,6 @@ import { Route as DashboardLayoutDashboardIndexImport } from './routes/_dashboar
|
|||
const IndexLazyImport = createFileRoute('/')()
|
||||
const LogoutIndexLazyImport = createFileRoute('/logout/')()
|
||||
const LoginIndexLazyImport = createFileRoute('/login/')()
|
||||
const DashboardLayoutUsersIndexLazyImport = createFileRoute(
|
||||
'/_dashboardLayout/users/',
|
||||
)()
|
||||
|
||||
// Create/Update Routes
|
||||
|
||||
|
|
@ -47,13 +45,12 @@ const LoginIndexLazyRoute = LoginIndexLazyImport.update({
|
|||
getParentRoute: () => rootRoute,
|
||||
} as any).lazy(() => import('./routes/login/index.lazy').then((d) => d.Route))
|
||||
|
||||
const DashboardLayoutUsersIndexLazyRoute =
|
||||
DashboardLayoutUsersIndexLazyImport.update({
|
||||
path: '/users/',
|
||||
getParentRoute: () => DashboardLayoutRoute,
|
||||
} as any).lazy(() =>
|
||||
import('./routes/_dashboardLayout/users/index.lazy').then((d) => d.Route),
|
||||
)
|
||||
const DashboardLayoutUsersIndexRoute = DashboardLayoutUsersIndexImport.update({
|
||||
path: '/users/',
|
||||
getParentRoute: () => DashboardLayoutRoute,
|
||||
} as any).lazy(() =>
|
||||
import('./routes/_dashboardLayout/users/index.lazy').then((d) => d.Route),
|
||||
)
|
||||
|
||||
const DashboardLayoutDashboardIndexRoute =
|
||||
DashboardLayoutDashboardIndexImport.update({
|
||||
|
|
@ -104,7 +101,7 @@ declare module '@tanstack/react-router' {
|
|||
id: '/_dashboardLayout/users/'
|
||||
path: '/users'
|
||||
fullPath: '/users'
|
||||
preLoaderRoute: typeof DashboardLayoutUsersIndexLazyImport
|
||||
preLoaderRoute: typeof DashboardLayoutUsersIndexImport
|
||||
parentRoute: typeof DashboardLayoutImport
|
||||
}
|
||||
}
|
||||
|
|
@ -116,10 +113,50 @@ export const routeTree = rootRoute.addChildren({
|
|||
IndexLazyRoute,
|
||||
DashboardLayoutRoute: DashboardLayoutRoute.addChildren({
|
||||
DashboardLayoutDashboardIndexRoute,
|
||||
DashboardLayoutUsersIndexLazyRoute,
|
||||
DashboardLayoutUsersIndexRoute,
|
||||
}),
|
||||
LoginIndexLazyRoute,
|
||||
LogoutIndexLazyRoute,
|
||||
})
|
||||
|
||||
/* prettier-ignore-end */
|
||||
|
||||
/* ROUTE_MANIFEST_START
|
||||
{
|
||||
"routes": {
|
||||
"__root__": {
|
||||
"filePath": "__root.tsx",
|
||||
"children": [
|
||||
"/",
|
||||
"/_dashboardLayout",
|
||||
"/login/",
|
||||
"/logout/"
|
||||
]
|
||||
},
|
||||
"/": {
|
||||
"filePath": "index.lazy.tsx"
|
||||
},
|
||||
"/_dashboardLayout": {
|
||||
"filePath": "_dashboardLayout.tsx",
|
||||
"children": [
|
||||
"/_dashboardLayout/dashboard/",
|
||||
"/_dashboardLayout/users/"
|
||||
]
|
||||
},
|
||||
"/login/": {
|
||||
"filePath": "login/index.lazy.tsx"
|
||||
},
|
||||
"/logout/": {
|
||||
"filePath": "logout/index.lazy.tsx"
|
||||
},
|
||||
"/_dashboardLayout/dashboard/": {
|
||||
"filePath": "_dashboardLayout/dashboard/index.tsx",
|
||||
"parent": "/_dashboardLayout"
|
||||
},
|
||||
"/_dashboardLayout/users/": {
|
||||
"filePath": "_dashboardLayout/users/index.tsx",
|
||||
"parent": "/_dashboardLayout"
|
||||
}
|
||||
}
|
||||
}
|
||||
ROUTE_MANIFEST_END */
|
||||
|
|
|
|||
|
|
@ -1,33 +1,40 @@
|
|||
import { Card, Stack, Title } from "@mantine/core";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import UsersTable from "../../../modules/usersManagement/tables/UsersTable";
|
||||
import { z } from "zod";
|
||||
import { userQueryOptions } from "@/modules/usersManagement/queries/userQueries";
|
||||
import PageTemplate from "@/components/PageTemplate";
|
||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import UserFormModal from "@/modules/usersManagement/modals/UserFormModal";
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
import ExtractQueryDataType from "@/types/ExtractQueryDataType";
|
||||
|
||||
const searchParamSchema = z.object({
|
||||
create: z.boolean().default(false).optional(),
|
||||
edit: z.string().default("").optional(),
|
||||
delete: z.string().default("").optional(),
|
||||
detail: z.string().default("").optional(),
|
||||
});
|
||||
|
||||
export const Route = createFileRoute("/_dashboardLayout/users/")({
|
||||
export const Route = createLazyFileRoute("/_dashboardLayout/users/")({
|
||||
component: UsersPage,
|
||||
|
||||
validateSearch: searchParamSchema,
|
||||
|
||||
loader: ({ context: { queryClient } }) => {
|
||||
queryClient.ensureQueryData(userQueryOptions);
|
||||
},
|
||||
});
|
||||
|
||||
type DataType = ExtractQueryDataType<typeof userQueryOptions>;
|
||||
|
||||
const columnHelper = createColumnHelper<DataType>();
|
||||
|
||||
export default function UsersPage() {
|
||||
return (
|
||||
<Stack>
|
||||
<Title order={1}>Users</Title>
|
||||
<Card>
|
||||
<UsersTable />
|
||||
</Card>
|
||||
</Stack>
|
||||
<PageTemplate
|
||||
title="Users"
|
||||
queryOptions={userQueryOptions}
|
||||
modals={[<UserFormModal />]}
|
||||
columnDefs={[
|
||||
columnHelper.display({
|
||||
id: "sequence",
|
||||
header: "#",
|
||||
cell: (props) => props.row.index + 1,
|
||||
size: 1,
|
||||
}),
|
||||
|
||||
columnHelper.accessor("email", {
|
||||
header: "Email",
|
||||
}),
|
||||
|
||||
columnHelper.accessor("email", {
|
||||
header: "Email",
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
18
apps/frontend/src/routes/_dashboardLayout/users/index.tsx
Normal file
18
apps/frontend/src/routes/_dashboardLayout/users/index.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { userQueryOptions } from "@/modules/usersManagement/queries/userQueries";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { z } from "zod";
|
||||
|
||||
const searchParamSchema = z.object({
|
||||
create: z.boolean().default(false).optional(),
|
||||
edit: z.string().default("").optional(),
|
||||
delete: z.string().default("").optional(),
|
||||
detail: z.string().default("").optional(),
|
||||
});
|
||||
|
||||
export const Route = createFileRoute("/_dashboardLayout/users/")({
|
||||
validateSearch: searchParamSchema,
|
||||
|
||||
loader: ({ context: { queryClient } }) => {
|
||||
queryClient.ensureQueryData(userQueryOptions(0, 10));
|
||||
},
|
||||
});
|
||||
9
apps/frontend/src/types/ExtractQueryDataType.d.ts
vendored
Normal file
9
apps/frontend/src/types/ExtractQueryDataType.d.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { UseQueryOptions } from "@tanstack/react-query";
|
||||
|
||||
type ExtractDataType<T> =
|
||||
T extends UseQueryOptions<infer U, any, any, any> ? U : never;
|
||||
|
||||
type ExtractQueryDataType<T> = ExtractDataType<ReturnType<T>>["data"][number];
|
||||
|
||||
export default ExtractQueryDataType;
|
||||
Loading…
Reference in New Issue
Block a user