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 all 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
1 change: 1 addition & 0 deletions backend/oasst_backend/user_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ def query_users_ordered_by_display_name(
limit: Optional[int] = 100,
desc: bool = False,
) -> list[User]:

Copy link
Collaborator

Choose a reason for hiding this comment

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

this newline triggers my review as code owner. it's a very beautiful newline. I approve ;)

if not self.api_client.trusted:
if not api_client_id:
# Let unprivileged api clients query their own users without api_client_id being set
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",
"accept-language-parser": "^1.5.0",
"autoprefixer": "^10.4.13",
"axios": "^1.2.1",
Expand Down
166 changes: 166 additions & 0 deletions website/src/components/DataTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
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;
disableNext?: boolean;
disablePrevious?: boolean;
};

export const DataTable = <T,>({
data,
columns,
caption,
filterValues = [],
onNextClick,
onPreviousClick,
onFilterChange,
disableNext,
disablePrevious,
}: 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} disabled={disablePrevious}>
Previous
</Button>
<Spacer />
<Button onClick={onNextClick} disabled={disableNext}>
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>
);
};
108 changes: 108 additions & 0 deletions website/src/components/UserTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { IconButton } 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 { FetchUsersResponse } from "src/lib/oasst_api_client";
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 [pagination, setPagination] = useState<Pagination>({ cursor: "", direction: "forward" });
const [filterValues, setFilterValues] = useState<FilterItem[]>([]);
const handleFilterValuesChange = (values: FilterItem[]) => {
setFilterValues(values);
setPagination((old) => ({ ...old, cursor: "" }));
};
// 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 ?? "";
const { data, error } = useSWR<FetchUsersResponse<User>>(
`/api/admin/users?direction=${pagination.direction}&cursor=${pagination.cursor}&searchDisplayName=${display_name}&sortKey=display_name`,
get,
{
keepPreviousData: true,
}
);

const toPreviousPage = () => {
setPagination({
cursor: data.prev,
direction: "back",
});
};

const toNextPage = () => {
setPagination({
cursor: data.next,
direction: "forward",
});
};

return (
<>
<DataTable
data={data?.items || []}
columns={columns}
caption="Users"
onNextClick={toNextPage}
onPreviousClick={toPreviousPage}
disableNext={!data?.next}
disablePrevious={!data?.prev}
filterValues={filterValues}
onFilterChange={handleFilterValuesChange}
></DataTable>
{error && "Unable to load users."}
</>
);
});