0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-17 22:04:19 -05:00

refactor(console): support pagination and search on the roles page (#2908)

This commit is contained in:
Xiao Yijun 2023-01-11 15:42:32 +08:00 committed by GitHub
parent 64d2fa5a63
commit 701bd1b16d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 204 additions and 83 deletions

View file

@ -10,7 +10,7 @@ import Next from './Next';
import Previous from './Previous';
import * as styles from './index.module.scss';
type Props = {
export type Props = {
pageIndex: number;
totalCount?: number;
pageSize: number;

View file

@ -1,9 +1,16 @@
@use '@/scss/underscore' as _;
.container {
display: flex;
flex-direction: column;
overflow: hidden;
}
.tableContainer {
overflow: hidden;
display: flex;
flex-direction: column;
flex: 1;
.filterContainer {
background-color: var(--color-layer-1);
@ -88,3 +95,7 @@
}
}
}
.pagination {
margin-top: _.unit(4);
}

View file

@ -4,6 +4,9 @@ import type { ReactNode } from 'react';
import { Fragment } from 'react';
import type { FieldPath, FieldValues } from 'react-hook-form';
import type { Props as PaginationProps } from '@/components/Pagination';
import Pagination from '@/components/Pagination';
import TableEmpty from './TableEmpty';
import TableError from './TableError';
import TableLoading from './TableLoading';
@ -31,6 +34,7 @@ type Props<
headerClassName?: string;
bodyClassName?: string;
isLoading?: boolean;
pagination?: PaginationProps;
placeholder?: TablePlaceholder;
errorMessage?: string;
onRetry?: () => void;
@ -50,6 +54,7 @@ const Table = <
headerClassName,
bodyClassName,
isLoading,
pagination,
placeholder,
errorMessage,
onRetry,
@ -62,84 +67,87 @@ const Table = <
return (
<div className={classNames(styles.container, className)}>
{filter && (
<div className={styles.filterContainer}>
<div className={styles.filter}>{filter}</div>
</div>
)}
<table
className={classNames(
styles.headerTable,
filter && styles.hideTopBorderRadius,
headerClassName
<div className={styles.tableContainer}>
{filter && (
<div className={styles.filterContainer}>
<div className={styles.filter}>{filter}</div>
</div>
)}
>
<thead>
<tr>
{columns.map(({ title, colSpan, dataIndex }) => (
<th key={dataIndex} colSpan={colSpan}>
{title}
</th>
))}
</tr>
</thead>
</table>
<div className={classNames(styles.bodyTable, bodyClassName)}>
<table>
<tbody>
{isLoading && <TableLoading columns={columns.length} />}
{!hasData && errorMessage && (
<TableError columns={columns.length} content={errorMessage} onRetry={onRetry} />
)}
{!isLoading && !hasData && placeholder && (
<TableEmpty
columns={columns.length}
title={placeholder.title}
description={placeholder.description}
image={placeholder.image}
>
{placeholder.content}
</TableEmpty>
)}
{rowGroups.map(({ key, label, labelClassName, data }) => (
<Fragment key={key}>
{label && (
<tr>
<td colSpan={totalColspan} className={labelClassName}>
{label}
</td>
</tr>
)}
{data?.map((row) => {
const rowClickable = isRowClickable(row);
const onClick = conditional(
rowClickable &&
rowClickHandler &&
(() => {
rowClickHandler(row);
})
);
return (
<tr
key={row[rowIndexKey]}
className={classNames(rowClickable && styles.clickable)}
onClick={onClick}
>
{columns.map(({ dataIndex, colSpan, className, render }) => (
<td key={dataIndex} colSpan={colSpan} className={className}>
{render(row)}
</td>
))}
</tr>
);
})}
</Fragment>
))}
</tbody>
<table
className={classNames(
styles.headerTable,
filter && styles.hideTopBorderRadius,
headerClassName
)}
>
<thead>
<tr>
{columns.map(({ title, colSpan, dataIndex }) => (
<th key={dataIndex} colSpan={colSpan}>
{title}
</th>
))}
</tr>
</thead>
</table>
<div className={classNames(styles.bodyTable, bodyClassName)}>
<table>
<tbody>
{isLoading && <TableLoading columns={columns.length} />}
{!hasData && errorMessage && (
<TableError columns={columns.length} content={errorMessage} onRetry={onRetry} />
)}
{!isLoading && !hasData && placeholder && (
<TableEmpty
columns={columns.length}
title={placeholder.title}
description={placeholder.description}
image={placeholder.image}
>
{placeholder.content}
</TableEmpty>
)}
{rowGroups.map(({ key, label, labelClassName, data }) => (
<Fragment key={key}>
{label && (
<tr>
<td colSpan={totalColspan} className={labelClassName}>
{label}
</td>
</tr>
)}
{data?.map((row) => {
const rowClickable = isRowClickable(row);
const onClick = conditional(
rowClickable &&
rowClickHandler &&
(() => {
rowClickHandler(row);
})
);
return (
<tr
key={row[rowIndexKey]}
className={classNames(rowClickable && styles.clickable)}
onClick={onClick}
>
{columns.map(({ dataIndex, colSpan, className, render }) => (
<td key={dataIndex} colSpan={colSpan} className={className}>
{render(row)}
</td>
))}
</tr>
);
})}
</Fragment>
))}
</tbody>
</table>
</div>
</div>
{pagination && <Pagination className={styles.pagination} {...pagination} />}
</div>
);
};

View file

@ -4,3 +4,4 @@ export * from './logs';
export const themeStorageKey = 'logto:admin_console:theme';
export const requestTimeout = 20_000;
export const defaultPageSize = 20;

View file

@ -0,0 +1,62 @@
/* eslint-disable unicorn/prevent-abbreviations */
import type { Optional } from '@silverhand/essentials';
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { defaultPageSize } from '@/consts';
type Props = Optional<{
pageSize?: number;
}>;
const pageIndexKey = 'page';
const keywordKey = 'keyword';
export const formatKeyword = (keyword: string) => `%${keyword}%`;
const useTableSearchParams = ({ pageSize = defaultPageSize }: Props = {}) => {
const [searchParams, setSearchParams] = useSearchParams();
const [searchParamsState, setSearchParamsState] = useState<URLSearchParams>(searchParams);
useEffect(() => {
setSearchParams(searchParamsState);
}, [searchParamsState, setSearchParams]);
const setPageIndex = (pageIndex: number) => {
setSearchParamsState((previousParams) => {
const params = new URLSearchParams(previousParams);
params.set(pageIndexKey, String(pageIndex));
return params;
});
};
const setKeyword = (value: string) => {
setSearchParamsState((previousParams) => {
const params = new URLSearchParams(previousParams);
if (value) {
params.set(keywordKey, value);
} else {
params.delete(keywordKey);
}
return params;
});
};
return {
pagination: {
pageIndex: Number(searchParamsState.get(pageIndexKey) ?? 1),
pageSize,
setPageIndex,
},
search: {
keyword: searchParamsState.get(keywordKey) ?? '',
setKeyword,
},
};
};
export default useTableSearchParams;
/* eslint-enable unicorn/prevent-abbreviations */

View file

@ -1,4 +1,5 @@
import type { Role } from '@logto/schemas';
import type { RoleResponse } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router-dom';
import useSWR from 'swr';
@ -7,9 +8,12 @@ import Plus from '@/assets/images/plus.svg';
import Button from '@/components/Button';
import CardTitle from '@/components/CardTitle';
import ItemPreview from '@/components/ItemPreview';
import Search from '@/components/Search';
import Table from '@/components/Table';
import type { RequestError } from '@/hooks/use-api';
import useTableSearchParams, { formatKeyword } from '@/hooks/use-table-search-params';
import * as pageStyles from '@/scss/resources.module.scss';
import { buildUrl } from '@/utilities/url';
import CreateRoleModal from './components/CreateRoleModal';
@ -19,11 +23,26 @@ const buildDetailsPathname = (id: string) => `${rolesPathname}/${id}`;
const Roles = () => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { pathname } = useLocation();
const { pathname, search } = useLocation();
const navigate = useNavigate();
const isOnCreatePage = pathname === createRolePathname;
const { data: roles, error, mutate } = useSWR<Role[], RequestError>(`/api/roles`);
const isLoading = !roles && !error;
const {
pagination: { pageIndex, pageSize, setPageIndex },
search: { keyword, setKeyword },
} = useTableSearchParams();
const url = buildUrl('/api/roles', {
page: String(pageIndex),
page_size: String(pageSize),
...conditional(keyword && { search: formatKeyword(keyword) }),
});
const { data, error, mutate } = useSWR<[RoleResponse[], number], RequestError>(url);
const isLoading = !data && !error;
const [roles, totalCount] = data ?? [];
return (
<div className={pageStyles.container}>
@ -35,7 +54,7 @@ const Roles = () => {
type="primary"
size="large"
onClick={() => {
navigate(createRolePathname);
navigate({ pathname: createRolePathname, search });
}}
/>
</div>
@ -62,13 +81,33 @@ const Roles = () => {
rowClickHandler={({ id }) => {
navigate(buildDetailsPathname(id));
}}
filter={
<Search
defaultValue={keyword}
isClearable={Boolean(keyword)}
onSearch={(value) => {
setKeyword(value);
setPageIndex(1);
}}
onClearSearch={() => {
setKeyword('');
setPageIndex(1);
}}
/>
}
pagination={{
pageIndex,
totalCount,
pageSize,
onChange: setPageIndex,
}}
placeholder={{
content: (
<Button
title="roles.create"
type="outline"
onClick={() => {
navigate(createRolePathname);
navigate({ pathname: createRolePathname, search });
}}
/>
),
@ -78,7 +117,7 @@ const Roles = () => {
{isOnCreatePage && (
<CreateRoleModal
onClose={() => {
navigate(rolesPathname);
navigate({ pathname: rolesPathname, search });
}}
/>
)}