Allow to filter user by display_name

This commit is contained in:
notmd
2023-01-18 15:19:00 +07:00
parent c912caaf76
commit 801ad553b8
9 changed files with 361 additions and 145 deletions
+158
View File
@@ -0,0 +1,158 @@
import {
Box,
Button,
Card,
CardBody,
Flex,
FormControl,
FormLabel,
Input,
Popover,
PopoverArrow,
PopoverBody,
PopoverCloseButton,
PopoverContent,
PopoverTrigger,
Spacer,
Table,
TableCaption,
TableContainer,
Tbody,
Td,
Th,
Thead,
Tr,
useDisclosure,
} from "@chakra-ui/react";
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { ChangeEvent, ReactNode } from "react";
import { FaFilter } from "react-icons/fa";
import { useDebouncedCallback } from "use-debounce";
export type DataTableColumnDef<T> = ColumnDef<T> & {
filterable?: boolean;
};
// TODO: stricter type
export type FilterItem = {
id: string;
value: string;
};
export type DataTableProps<T> = {
data: T[];
columns: DataTableColumnDef<T>[];
caption?: string;
filterValues?: FilterItem[];
onNextClick?: () => void;
onPreviousClick?: () => void;
onFilterChange?: (items: FilterItem[]) => void;
};
export const DataTable = <T,>({
data,
columns,
caption,
filterValues = [],
onNextClick,
onPreviousClick,
onFilterChange,
}: DataTableProps<T>) => {
const { getHeaderGroups, getRowModel } = useReactTable<T>({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
const handleFilterChange = (value: FilterItem) => {
const idx = filterValues.findIndex((oldValue) => oldValue.id === value.id);
let newValues: FilterItem[] = [];
if (idx === -1) {
newValues = [...filterValues, value];
} else {
newValues = filterValues.map((oldValue) => (oldValue.id === value.id ? value : oldValue));
}
onFilterChange(newValues);
};
return (
<Card>
<CardBody>
<Flex mb="2">
<Button onClick={onPreviousClick}>Previous</Button>
<Spacer />
<Button onClick={onNextClick}>Next</Button>
</Flex>
<TableContainer>
<Table variant="simple">
<TableCaption>{caption}</TableCaption>
<Thead>
{getHeaderGroups().map((headerGroup) => (
<Tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<Th key={header.id}>
<Box display="flex" alignItems="center">
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
{(header.column.columnDef as DataTableColumnDef<T>).filterable && (
<FilterModal
value={filterValues.find((value) => value.id === header.id)?.value ?? ""}
onChange={(value) => handleFilterChange({ id: header.id, value })}
label={flexRender(header.column.columnDef.header, header.getContext())}
></FilterModal>
)}
</Box>
</Th>
))}
</Tr>
))}
</Thead>
<Tbody>
{getRowModel().rows.map((row) => (
<Tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<Td key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</Td>
))}
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</CardBody>
</Card>
);
};
const FilterModal = ({
label,
onChange,
value,
}: {
label: ReactNode;
onChange: (val: string) => void;
value: string;
}) => {
const { isOpen, onOpen, onClose } = useDisclosure();
const handleInputChange = useDebouncedCallback((e: ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value);
}, 500);
return (
<Popover isOpen={isOpen} onOpen={onOpen} onClose={onClose}>
<PopoverTrigger>
<Button variant={"unstyled"} ml="2">
<FaFilter></FaFilter>
</Button>
</PopoverTrigger>
<PopoverContent w="fit-content">
<PopoverArrow />
<PopoverCloseButton />
<PopoverBody mt="4">
<FormControl>
<FormLabel>{label}</FormLabel>
<Input onChange={handleInputChange} defaultValue={value}></Input>
</FormControl>
</PopoverBody>
</PopoverContent>
</Popover>
);
};
+133
View File
@@ -0,0 +1,133 @@
import { IconButton, useToast } from "@chakra-ui/react";
import { createColumnHelper } from "@tanstack/react-table";
import Link from "next/link";
import { memo, useState } from "react";
import { FaPen } from "react-icons/fa";
import { get } from "src/lib/api";
import type { User } from "src/types/Users";
import useSWR from "swr";
import { DataTable, DataTableColumnDef, FilterItem } from "./DataTable";
interface Pagination {
/**
* The user's `display_name` used for pagination.
*/
cursor: string;
/**
* The pagination direction.
*/
direction: "forward" | "back";
}
const columnHelper = createColumnHelper<User>();
const columns: DataTableColumnDef<User>[] = [
columnHelper.accessor("user_id", {
header: "ID",
}),
columnHelper.accessor("id", {
header: "Auth ID",
}),
columnHelper.accessor("auth_method", {
header: "Auth Method",
}),
{
...columnHelper.accessor("display_name", {
header: "Name",
}),
filterable: true,
},
columnHelper.accessor("role", {
header: "Role",
}),
columnHelper.accessor((user) => user.user_id, {
cell: ({ getValue }) => (
<IconButton
as={Link}
href={`/admin/manage_user/${getValue()}`}
aria-label="Manage"
icon={<FaPen></FaPen>}
></IconButton>
),
header: "Update",
}),
];
export const UserTable = memo(function UserTable() {
const toast = useToast();
const [pagination, setPagination] = useState<Pagination>({ cursor: "", direction: "forward" });
const [users, setUsers] = useState<User[]>([]);
const [filterValues, setFilterValues] = useState<FilterItem[]>([]);
// Fetch and save the users.
// This follows useSWR's recommendation for simple pagination:
// https://swr.vercel.app/docs/pagination#when-to-use-useswr
const display_name = filterValues.find((value) => value.id === "display_name")?.value ?? "";
useSWR(
`/api/admin/users?direction=${pagination.direction}&cursor=${pagination.cursor}&display_name=${display_name}`,
get,
{
onSuccess: (data) => {
// When no more users can be found, trigger a toast to indicate why no
// changes have taken place. We have to maintain a non-empty set of
// users otherwise we can't paginate using a cursor (since we've lost the
// cursor).
if (data.length === 0) {
toast({
title: "No more users",
status: "warning",
duration: 1000,
isClosable: true,
});
return;
}
setUsers(data);
},
}
);
const toPreviousPage = () => {
if (users.length >= 0) {
setPagination({
cursor: users[0].user_id,
direction: "back",
});
} else {
toast({
title: "Can not paginate when no users are found",
status: "warning",
duration: 1000,
isClosable: true,
});
}
};
const toNextPage = () => {
if (users.length >= 0) {
setPagination({
cursor: users[users.length - 1].user_id,
direction: "forward",
});
} else {
toast({
title: "Can not paginate when no users are found",
status: "warning",
duration: 1000,
isClosable: true,
});
}
};
return (
<DataTable
data={users}
columns={columns}
caption="Users"
onNextClick={toNextPage}
onPreviousClick={toPreviousPage}
filterValues={filterValues}
onFilterChange={setFilterValues}
></DataTable>
);
});
-137
View File
@@ -1,137 +0,0 @@
import {
Button,
Flex,
Spacer,
Stack,
Table,
TableCaption,
TableContainer,
Tbody,
Td,
Th,
Thead,
Tr,
useToast,
} from "@chakra-ui/react";
import Link from "next/link";
import { useState } from "react";
import { get } from "src/lib/api";
import type { User } from "src/types/Users";
import useSWR from "swr";
interface Pagination {
/**
* The user's `display_name` used for pagination.
*/
cursor: string;
/**
* The pagination direction.
*/
direction: "forward" | "back";
}
/**
* Fetches users from the users api route and then presents them in a simple Chakra table.
*/
const UsersCell = () => {
const toast = useToast();
const [pagination, setPagination] = useState<Pagination>({ cursor: "", direction: "forward" });
const [users, setUsers] = useState<User[]>([]);
// Fetch and save the users.
// This follows useSWR's recommendation for simple pagination:
// https://swr.vercel.app/docs/pagination#when-to-use-useswr
useSWR(`/api/admin/users?direction=${pagination.direction}&cursor=${pagination.cursor}`, get, {
onSuccess: (data) => {
// When no more users can be found, trigger a toast to indicate why no
// changes have taken place. We have to maintain a non-empty set of
// users otherwise we can't paginate using a cursor (since we've lost the
// cursor).
if (data.length === 0) {
toast({
title: "No more users",
status: "warning",
duration: 1000,
isClosable: true,
});
return;
}
setUsers(data);
},
});
const toPreviousPage = () => {
if (users.length >= 0) {
setPagination({
cursor: users[0].display_name,
direction: "back",
});
} else {
toast({
title: "Can not paginate when no users are found",
status: "warning",
duration: 1000,
isClosable: true,
});
}
};
const toNextPage = () => {
if (users.length >= 0) {
setPagination({
cursor: users[users.length - 1].display_name,
direction: "forward",
});
} else {
toast({
title: "Can not paginate when no users are found",
status: "warning",
duration: 1000,
isClosable: true,
});
}
};
// Present users in a naive table.
return (
<Stack>
<Flex p="2">
<Button onClick={toPreviousPage}>Previous</Button>
<Spacer />
<Button onClick={toNextPage}>Next</Button>
</Flex>
<TableContainer>
<Table variant="simple">
<TableCaption>Users</TableCaption>
<Thead>
<Tr>
<Th>Id</Th>
<Th>Auth Id</Th>
<Th>Auth Method</Th>
<Th>Name</Th>
<Th>Role</Th>
<Th>Update</Th>
</Tr>
</Thead>
<Tbody>
{users.map(({ id, user_id, auth_method, display_name, role }) => (
<Tr key={user_id}>
<Td>{user_id}</Td>
<Td>{id}</Td>
<Td>{auth_method}</Td>
<Td>{display_name}</Td>
<Td>{role}</Td>
<Td>
<Link href={`/admin/manage_user/${user_id}`}>Manage</Link>
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</Stack>
);
};
export default UsersCell;