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:
parent
64d2fa5a63
commit
701bd1b16d
6 changed files with 204 additions and 83 deletions
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -4,3 +4,4 @@ export * from './logs';
|
|||
|
||||
export const themeStorageKey = 'logto:admin_console:theme';
|
||||
export const requestTimeout = 20_000;
|
||||
export const defaultPageSize = 20;
|
||||
|
|
62
packages/console/src/hooks/use-table-search-params.ts
Normal file
62
packages/console/src/hooks/use-table-search-params.ts
Normal 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 */
|
|
@ -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 });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
|
Loading…
Add table
Reference in a new issue