Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow to filter user by display_name #821

Merged
merged 18 commits into from
Jan 22, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 2 additions & 2 deletions backend/oasst_backend/user_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,10 +161,10 @@ def query_users(
users = users.order_by(User.display_name)

if gt:
users = users.filter(User.display_name > gt)
users = users.filter(User.id > gt)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure what the intention here is, but it doesn't look right.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought we want to cursor based on the user id?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the user id column monotonically increasing?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UUID V4 isn't guaranteed to be monotonically increasing. I think only UUID V6 or V7 have that property. V4 is random in it's ordering.


if lt:
users = users.filter(User.display_name < lt)
users = users.filter(User.id < lt).order_by(None).order_by(User.id.desc())

if limit is not None:
users = users.limit(limit)
Expand Down
45 changes: 45 additions & 0 deletions website/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"@next/font": "^13.1.0",
"@prisma/client": "^4.7.1",
"@tailwindcss/forms": "^0.5.3",
"@tanstack/react-table": "^8.7.6",
"autoprefixer": "^10.4.13",
"axios": "^1.2.1",
"boolean": "^3.2.0",
Expand Down
158 changes: 158 additions & 0 deletions website/src/components/DataTable.tsx
Original file line number Diff line number Diff line change
@@ -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 ?? ""}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If filterValues was an object and not an array could we avoid using find and findIndex?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The filter API is temporary for now. It will change significantly in the next PR. I want to have something like MUI Grid. It will be an array to support multiple filters in 1 column.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you,

I was mainly referring to a suggestion to use object for items and not an array which would enable memorable look up of filters.

const filters = {
  linkOperator: 'is',
  items: {
    1: {
      id: 1,
      columnField: 'rating',
      operatorValue: '>',
      value: '4',
    },
    2: {
      id: 2,
      columnField: 'isAdmin',
      operatorValue: 'is',
      value: 'true',
    },
  },
}

const getFilterById = (id) => filters.items[id]

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 changes: 133 additions & 0 deletions website/src/components/UserTable.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
});