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 Previous from './Previous';
|
||||||
import * as styles from './index.module.scss';
|
import * as styles from './index.module.scss';
|
||||||
|
|
||||||
type Props = {
|
export type Props = {
|
||||||
pageIndex: number;
|
pageIndex: number;
|
||||||
totalCount?: number;
|
totalCount?: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
|
|
|
@ -1,9 +1,16 @@
|
||||||
@use '@/scss/underscore' as _;
|
@use '@/scss/underscore' as _;
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableContainer {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
.filterContainer {
|
.filterContainer {
|
||||||
background-color: var(--color-layer-1);
|
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 { Fragment } from 'react';
|
||||||
import type { FieldPath, FieldValues } from 'react-hook-form';
|
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 TableEmpty from './TableEmpty';
|
||||||
import TableError from './TableError';
|
import TableError from './TableError';
|
||||||
import TableLoading from './TableLoading';
|
import TableLoading from './TableLoading';
|
||||||
|
@ -31,6 +34,7 @@ type Props<
|
||||||
headerClassName?: string;
|
headerClassName?: string;
|
||||||
bodyClassName?: string;
|
bodyClassName?: string;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
|
pagination?: PaginationProps;
|
||||||
placeholder?: TablePlaceholder;
|
placeholder?: TablePlaceholder;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
onRetry?: () => void;
|
onRetry?: () => void;
|
||||||
|
@ -50,6 +54,7 @@ const Table = <
|
||||||
headerClassName,
|
headerClassName,
|
||||||
bodyClassName,
|
bodyClassName,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
pagination,
|
||||||
placeholder,
|
placeholder,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
onRetry,
|
onRetry,
|
||||||
|
@ -62,84 +67,87 @@ const Table = <
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames(styles.container, className)}>
|
<div className={classNames(styles.container, className)}>
|
||||||
{filter && (
|
<div className={styles.tableContainer}>
|
||||||
<div className={styles.filterContainer}>
|
{filter && (
|
||||||
<div className={styles.filter}>{filter}</div>
|
<div className={styles.filterContainer}>
|
||||||
</div>
|
<div className={styles.filter}>{filter}</div>
|
||||||
)}
|
</div>
|
||||||
<table
|
|
||||||
className={classNames(
|
|
||||||
styles.headerTable,
|
|
||||||
filter && styles.hideTopBorderRadius,
|
|
||||||
headerClassName
|
|
||||||
)}
|
)}
|
||||||
>
|
<table
|
||||||
<thead>
|
className={classNames(
|
||||||
<tr>
|
styles.headerTable,
|
||||||
{columns.map(({ title, colSpan, dataIndex }) => (
|
filter && styles.hideTopBorderRadius,
|
||||||
<th key={dataIndex} colSpan={colSpan}>
|
headerClassName
|
||||||
{title}
|
)}
|
||||||
</th>
|
>
|
||||||
))}
|
<thead>
|
||||||
</tr>
|
<tr>
|
||||||
</thead>
|
{columns.map(({ title, colSpan, dataIndex }) => (
|
||||||
</table>
|
<th key={dataIndex} colSpan={colSpan}>
|
||||||
<div className={classNames(styles.bodyTable, bodyClassName)}>
|
{title}
|
||||||
<table>
|
</th>
|
||||||
<tbody>
|
))}
|
||||||
{isLoading && <TableLoading columns={columns.length} />}
|
</tr>
|
||||||
{!hasData && errorMessage && (
|
</thead>
|
||||||
<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>
|
</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>
|
</div>
|
||||||
|
{pagination && <Pagination className={styles.pagination} {...pagination} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -4,3 +4,4 @@ export * from './logs';
|
||||||
|
|
||||||
export const themeStorageKey = 'logto:admin_console:theme';
|
export const themeStorageKey = 'logto:admin_console:theme';
|
||||||
export const requestTimeout = 20_000;
|
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 { useTranslation } from 'react-i18next';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
@ -7,9 +8,12 @@ import Plus from '@/assets/images/plus.svg';
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import CardTitle from '@/components/CardTitle';
|
import CardTitle from '@/components/CardTitle';
|
||||||
import ItemPreview from '@/components/ItemPreview';
|
import ItemPreview from '@/components/ItemPreview';
|
||||||
|
import Search from '@/components/Search';
|
||||||
import Table from '@/components/Table';
|
import Table from '@/components/Table';
|
||||||
import type { RequestError } from '@/hooks/use-api';
|
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 * as pageStyles from '@/scss/resources.module.scss';
|
||||||
|
import { buildUrl } from '@/utilities/url';
|
||||||
|
|
||||||
import CreateRoleModal from './components/CreateRoleModal';
|
import CreateRoleModal from './components/CreateRoleModal';
|
||||||
|
|
||||||
|
@ -19,11 +23,26 @@ const buildDetailsPathname = (id: string) => `${rolesPathname}/${id}`;
|
||||||
|
|
||||||
const Roles = () => {
|
const Roles = () => {
|
||||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||||
const { pathname } = useLocation();
|
const { pathname, search } = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isOnCreatePage = pathname === createRolePathname;
|
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 (
|
return (
|
||||||
<div className={pageStyles.container}>
|
<div className={pageStyles.container}>
|
||||||
|
@ -35,7 +54,7 @@ const Roles = () => {
|
||||||
type="primary"
|
type="primary"
|
||||||
size="large"
|
size="large"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigate(createRolePathname);
|
navigate({ pathname: createRolePathname, search });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -62,13 +81,33 @@ const Roles = () => {
|
||||||
rowClickHandler={({ id }) => {
|
rowClickHandler={({ id }) => {
|
||||||
navigate(buildDetailsPathname(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={{
|
placeholder={{
|
||||||
content: (
|
content: (
|
||||||
<Button
|
<Button
|
||||||
title="roles.create"
|
title="roles.create"
|
||||||
type="outline"
|
type="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigate(createRolePathname);
|
navigate({ pathname: createRolePathname, search });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
|
@ -78,7 +117,7 @@ const Roles = () => {
|
||||||
{isOnCreatePage && (
|
{isOnCreatePage && (
|
||||||
<CreateRoleModal
|
<CreateRoleModal
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
navigate(rolesPathname);
|
navigate({ pathname: rolesPathname, search });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
Loading…
Add table
Reference in a new issue