0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-13 21:30:30 -05:00

refactor(console): list pages (#3753)

This commit is contained in:
Xiao Yijun 2023-04-28 12:05:43 +08:00 committed by GitHub
parent 467c6d8321
commit 858854b9b1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 392 additions and 367 deletions

View file

@ -0,0 +1,59 @@
import classNames from 'classnames';
import { type ReactNode } from 'react';
import { type FieldValues, type FieldPath } from 'react-hook-form';
import Plus from '@/assets/images/plus.svg';
import { type Props as ButtonProps } from '@/components/Button';
import { type Props as CardTitleProps } from '@/components/CardTitle';
import PageMeta, { type Props as PageMetaProps } from '@/components/PageMeta';
import Table, { type Props as TableProps } from '@/components/Table';
import * as pageLayout from '@/scss/page-layout.module.scss';
import Button from '../Button';
import CardTitle from '../CardTitle';
type CreateButtonProps = {
title: ButtonProps['title'];
onClick: ButtonProps['onClick'];
};
type Props<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
title: CardTitleProps;
pageMeta?: PageMetaProps;
createButton?: CreateButtonProps;
subHeader?: ReactNode;
table: TableProps<TFieldValues, TName>;
widgets: ReactNode;
className?: string;
};
function ListPage<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
title,
pageMeta,
createButton,
subHeader,
table,
widgets,
className,
}: Props<TFieldValues, TName>) {
return (
<div className={classNames(pageLayout.container, className)}>
{pageMeta && <PageMeta {...pageMeta} />}
<div className={pageLayout.headline}>
<CardTitle {...title} />
{createButton && <Button icon={<Plus />} type="primary" size="large" {...createButton} />}
</div>
{subHeader}
<Table className={pageLayout.table} {...table} />
{widgets}
</div>
);
}
export default ListPage;

View file

@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next';
import { mainTitle } from '@/consts/tenants'; import { mainTitle } from '@/consts/tenants';
type Props = { export type Props = {
titleKey: AdminConsoleKey | AdminConsoleKey[]; titleKey: AdminConsoleKey | AdminConsoleKey[];
// eslint-disable-next-line react/boolean-prop-naming // eslint-disable-next-line react/boolean-prop-naming
trackPageView?: boolean; trackPageView?: boolean;

View file

@ -15,7 +15,7 @@ import TableLoading from './TableLoading';
import * as styles from './index.module.scss'; import * as styles from './index.module.scss';
import type { Column, RowGroup } from './types'; import type { Column, RowGroup } from './types';
type Props< export type Props<
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues> TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = { > = {

View file

@ -3,7 +3,3 @@
.icon { .icon {
flex-shrink: 0; flex-shrink: 0;
} }
.pagination {
margin-top: _.unit(4);
}

View file

@ -9,22 +9,16 @@ import useSWR from 'swr';
import ApiResourceDark from '@/assets/images/api-resource-dark.svg'; import ApiResourceDark from '@/assets/images/api-resource-dark.svg';
import ApiResource from '@/assets/images/api-resource.svg'; import ApiResource from '@/assets/images/api-resource.svg';
import Plus from '@/assets/images/plus.svg';
import Button from '@/components/Button';
import CardTitle from '@/components/CardTitle';
import CopyToClipboard from '@/components/CopyToClipboard'; import CopyToClipboard from '@/components/CopyToClipboard';
import EmptyDataPlaceholder from '@/components/EmptyDataPlaceholder'; import EmptyDataPlaceholder from '@/components/EmptyDataPlaceholder';
import ItemPreview from '@/components/ItemPreview'; import ItemPreview from '@/components/ItemPreview';
import PageMeta from '@/components/PageMeta'; import ListPage from '@/components/ListPage';
import Pagination from '@/components/Pagination';
import Table from '@/components/Table';
import { defaultPageSize } from '@/consts'; import { defaultPageSize } from '@/consts';
import { ApiResourceDetailsTabs } from '@/consts/page-tabs'; import { ApiResourceDetailsTabs } from '@/consts/page-tabs';
import type { RequestError } from '@/hooks/use-api'; import type { RequestError } from '@/hooks/use-api';
import useSearchParametersWatcher from '@/hooks/use-search-parameters-watcher'; import useSearchParametersWatcher from '@/hooks/use-search-parameters-watcher';
import useTheme from '@/hooks/use-theme'; import useTheme from '@/hooks/use-theme';
import * as modalStyles from '@/scss/modal.module.scss'; import * as modalStyles from '@/scss/modal.module.scss';
import * as resourcesStyles from '@/scss/resources.module.scss';
import { buildUrl } from '@/utils/url'; import { buildUrl } from '@/utils/url';
import CreateForm from './components/CreateForm'; import CreateForm from './components/CreateForm';
@ -60,22 +54,61 @@ function ApiResources() {
const ResourceIcon = theme === Theme.Light ? ApiResource : ApiResourceDark; const ResourceIcon = theme === Theme.Light ? ApiResource : ApiResourceDark;
return ( return (
<div className={resourcesStyles.container}> <ListPage
<PageMeta titleKey="api_resources.page_title" /> title={{
<div className={resourcesStyles.headline}> title: 'api_resources.title',
<CardTitle title="api_resources.title" subtitle="api_resources.subtitle" /> subtitle: 'api_resources.subtitle',
<Button }}
title="api_resources.create" pageMeta={{ titleKey: 'api_resources.page_title' }}
type="primary" createButton={{
size="large" title: 'api_resources.create',
icon={<Plus />} onClick: () => {
onClick={() => { navigate({
navigate({ pathname: createApiResourcePathname,
pathname: createApiResourcePathname, search,
search, });
}); },
}} }}
/> table={{
rowGroups: [{ key: 'apiResources', data: apiResources }],
rowIndexKey: 'id',
isLoading,
errorMessage: error?.body?.message ?? error?.message,
columns: [
{
title: t('api_resources.api_name'),
dataIndex: 'name',
colSpan: 6,
render: ({ id, name }) => (
<ItemPreview
title={name}
icon={<ResourceIcon className={styles.icon} />}
to={buildDetailsPathname(id)}
/>
),
},
{
title: t('api_resources.api_identifier'),
dataIndex: 'indicator',
colSpan: 10,
render: ({ indicator }) => <CopyToClipboard value={indicator} variant="text" />,
},
],
placeholder: <EmptyDataPlaceholder />,
rowClickHandler: ({ id }) => {
navigate(buildDetailsPathname(id));
},
onRetry: async () => mutate(undefined, true),
pagination: {
page,
totalCount,
pageSize,
onChange: (page) => {
updateSearchParameters({ page });
},
},
}}
widgets={
<Modal <Modal
shouldCloseOnEsc shouldCloseOnEsc
isOpen={isCreateNew} isOpen={isCreateNew}
@ -105,49 +138,8 @@ function ApiResources() {
}} }}
/> />
</Modal> </Modal>
</div> }
<Table />
className={resourcesStyles.table}
rowGroups={[{ key: 'apiResources', data: apiResources }]}
rowIndexKey="id"
isLoading={isLoading}
errorMessage={error?.body?.message ?? error?.message}
columns={[
{
title: t('api_resources.api_name'),
dataIndex: 'name',
colSpan: 6,
render: ({ id, name }) => (
<ItemPreview
title={name}
icon={<ResourceIcon className={styles.icon} />}
to={buildDetailsPathname(id)}
/>
),
},
{
title: t('api_resources.api_identifier'),
dataIndex: 'indicator',
colSpan: 10,
render: ({ indicator }) => <CopyToClipboard value={indicator} variant="text" />,
},
]}
placeholder={<EmptyDataPlaceholder />}
rowClickHandler={({ id }) => {
navigate(buildDetailsPathname(id));
}}
onRetry={async () => mutate(undefined, true)}
/>
<Pagination
page={page}
totalCount={totalCount}
pageSize={pageSize}
className={styles.pagination}
onChange={(page) => {
updateSearchParameters({ page });
}}
/>
</div>
); );
} }

View file

@ -4,10 +4,6 @@
flex-shrink: 0; flex-shrink: 0;
} }
.pagination {
margin-top: _.unit(4);
}
.applicationName { .applicationName {
width: 360px; width: 360px;
} }

View file

@ -5,19 +5,13 @@ 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';
import Plus from '@/assets/images/plus.svg';
import ApplicationIcon from '@/components/ApplicationIcon'; import ApplicationIcon from '@/components/ApplicationIcon';
import Button from '@/components/Button';
import CardTitle from '@/components/CardTitle';
import CopyToClipboard from '@/components/CopyToClipboard'; import CopyToClipboard from '@/components/CopyToClipboard';
import ItemPreview from '@/components/ItemPreview'; import ItemPreview from '@/components/ItemPreview';
import PageMeta from '@/components/PageMeta'; import ListPage from '@/components/ListPage';
import Pagination from '@/components/Pagination';
import Table from '@/components/Table';
import { defaultPageSize } from '@/consts'; import { defaultPageSize } from '@/consts';
import type { RequestError } from '@/hooks/use-api'; import type { RequestError } from '@/hooks/use-api';
import useSearchParametersWatcher from '@/hooks/use-search-parameters-watcher'; import useSearchParametersWatcher from '@/hooks/use-search-parameters-watcher';
import * as resourcesStyles from '@/scss/resources.module.scss';
import { applicationTypeI18nKey } from '@/types/applications'; import { applicationTypeI18nKey } from '@/types/applications';
import { buildUrl } from '@/utils/url'; import { buildUrl } from '@/utils/url';
@ -63,30 +57,27 @@ function Applications() {
}; };
return ( return (
<div className={resourcesStyles.container}> <ListPage
<PageMeta titleKey="applications.title" /> title={{
<div className={resourcesStyles.headline}> title: 'applications.title',
<CardTitle title="applications.title" subtitle="applications.subtitle" /> subtitle: 'applications.subtitle',
<Button }}
icon={<Plus />} pageMeta={{ titleKey: 'applications.title' }}
title="applications.create" createButton={{
type="primary" title: 'applications.create',
size="large" onClick: () => {
onClick={() => { navigate({
navigate({ pathname: createApplicationPathname,
pathname: createApplicationPathname, search,
search, });
}); },
}} }}
/> table={{
</div> rowGroups: [{ key: 'applications', data: applications }],
<Table rowIndexKey: 'id',
className={resourcesStyles.table} isLoading,
rowGroups={[{ key: 'applications', data: applications }]} errorMessage: error?.body?.message ?? error?.message,
rowIndexKey="id" columns: [
isLoading={isLoading}
errorMessage={error?.body?.message ?? error?.message}
columns={[
{ {
title: t('applications.application_name'), title: t('applications.application_name'),
dataIndex: 'name', dataIndex: 'name',
@ -106,44 +97,45 @@ function Applications() {
colSpan: 10, colSpan: 10,
render: ({ id }) => <CopyToClipboard value={id} variant="text" />, render: ({ id }) => <CopyToClipboard value={id} variant="text" />,
}, },
]} ],
placeholder={ placeholder: (
<ApplicationsPlaceholder <ApplicationsPlaceholder
onCreate={async (newApp) => { onCreate={async (newApp) => {
await mutateApplicationList(newApp); await mutateApplicationList(newApp);
navigate(buildNavigatePathPostAppCreation(newApp), { replace: true }); navigate(buildNavigatePathPostAppCreation(newApp), { replace: true });
}} }}
/> />
} ),
rowClickHandler={({ id }) => { rowClickHandler: ({ id }) => {
navigate(buildDetailsPathname(id)); navigate(buildDetailsPathname(id));
}} },
onRetry={async () => mutate(undefined, true)} onRetry: async () => mutate(undefined, true),
/> pagination: {
<Pagination page,
page={page} totalCount,
totalCount={totalCount} pageSize,
pageSize={pageSize} onChange: (page) => {
className={styles.pagination} updateSearchParameters({ page });
onChange={(page) => { },
updateSearchParameters({ page }); },
}} }}
/> widgets={
<CreateForm <CreateForm
isOpen={isShowingCreationForm} isOpen={isShowingCreationForm}
onClose={async (newApp) => { onClose={async (newApp) => {
if (newApp) { if (newApp) {
navigate(buildNavigatePathPostAppCreation(newApp), { replace: true }); navigate(buildNavigatePathPostAppCreation(newApp), { replace: true });
return; return;
} }
navigate({ navigate({
pathname: applicationsPathname, pathname: applicationsPathname,
search, search,
}); });
}} }}
/> />
</div> }
/>
); );
} }

View file

@ -3,16 +3,16 @@ import { withAppInsights } from '@logto/app-insights/react';
import AuditLogTable from '@/components/AuditLogTable'; import AuditLogTable from '@/components/AuditLogTable';
import CardTitle from '@/components/CardTitle'; import CardTitle from '@/components/CardTitle';
import PageMeta from '@/components/PageMeta'; import PageMeta from '@/components/PageMeta';
import * as resourcesStyles from '@/scss/resources.module.scss'; import * as pageLayout from '@/scss/page-layout.module.scss';
function AuditLogs() { function AuditLogs() {
return ( return (
<div className={resourcesStyles.container}> <div className={pageLayout.container}>
<PageMeta titleKey="logs.page_title" /> <PageMeta titleKey="logs.page_title" />
<div className={resourcesStyles.headline}> <div className={pageLayout.headline}>
<CardTitle title="logs.title" subtitle="logs.subtitle" /> <CardTitle title="logs.title" subtitle="logs.subtitle" />
</div> </div>
<AuditLogTable className={resourcesStyles.table} /> <AuditLogTable className={pageLayout.table} />
</div> </div>
); );
} }

View file

@ -2,7 +2,6 @@ import { withAppInsights } from '@logto/app-insights/react';
import { ConnectorType } from '@logto/schemas'; import { ConnectorType } from '@logto/schemas';
import type { ConnectorFactoryResponse } from '@logto/schemas'; import type { ConnectorFactoryResponse } from '@logto/schemas';
import { conditional } from '@silverhand/essentials'; import { conditional } from '@silverhand/essentials';
import classNames from 'classnames';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
@ -12,10 +11,8 @@ import Plus from '@/assets/images/plus.svg';
import SocialConnectorEmptyDark from '@/assets/images/social-connector-empty-dark.svg'; import SocialConnectorEmptyDark from '@/assets/images/social-connector-empty-dark.svg';
import SocialConnectorEmpty from '@/assets/images/social-connector-empty.svg'; import SocialConnectorEmpty from '@/assets/images/social-connector-empty.svg';
import Button from '@/components/Button'; import Button from '@/components/Button';
import CardTitle from '@/components/CardTitle'; import ListPage from '@/components/ListPage';
import PageMeta from '@/components/PageMeta';
import TabNav, { TabNavItem } from '@/components/TabNav'; import TabNav, { TabNavItem } from '@/components/TabNav';
import Table from '@/components/Table';
import TablePlaceholder from '@/components/Table/TablePlaceholder'; import TablePlaceholder from '@/components/Table/TablePlaceholder';
import { defaultEmailConnectorGroup, defaultSmsConnectorGroup } from '@/consts'; import { defaultEmailConnectorGroup, defaultSmsConnectorGroup } from '@/consts';
import { ConnectorsTabs } from '@/consts/page-tabs'; import { ConnectorsTabs } from '@/consts/page-tabs';
@ -23,7 +20,6 @@ import type { RequestError } from '@/hooks/use-api';
import useConnectorGroups from '@/hooks/use-connector-groups'; import useConnectorGroups from '@/hooks/use-connector-groups';
import useDocumentationUrl from '@/hooks/use-documentation-url'; import useDocumentationUrl from '@/hooks/use-documentation-url';
import DemoConnectorNotice from '@/onboarding/components/DemoConnectorNotice'; import DemoConnectorNotice from '@/onboarding/components/DemoConnectorNotice';
import * as resourcesStyles from '@/scss/resources.module.scss';
import ConnectorDeleteButton from './components/ConnectorDeleteButton'; import ConnectorDeleteButton from './components/ConnectorDeleteButton';
import ConnectorName from './components/ConnectorName'; import ConnectorName from './components/ConnectorName';
@ -102,126 +98,130 @@ function Connectors() {
}, [factoryId, factories]); }, [factoryId, factories]);
return ( return (
<> <ListPage
<PageMeta titleKey="connectors.page_title" /> className={styles.container}
<div className={classNames(resourcesStyles.container, styles.container)}> title={{
<div className={resourcesStyles.headline}> title: 'connectors.title',
<CardTitle title="connectors.title" subtitle="connectors.subtitle" /> subtitle: 'connectors.subtitle',
{isSocial && ( }}
<Button pageMeta={{ titleKey: 'connectors.page_title' }}
title="connectors.create" createButton={conditional(
type="primary" isSocial && {
size="large" title: 'connectors.create',
icon={<Plus />} onClick: () => {
onClick={() => { navigate(buildCreatePathname(ConnectorType.Social));
navigate(buildCreatePathname(ConnectorType.Social)); },
}} }
/> )}
)} subHeader={
</div> <>
<SignInExperienceSetupNotice /> <SignInExperienceSetupNotice />
<TabNav className={styles.tabs}> <TabNav className={styles.tabs}>
<TabNavItem href={passwordlessPathname}>{t('connectors.tab_email_sms')}</TabNavItem> <TabNavItem href={passwordlessPathname}>{t('connectors.tab_email_sms')}</TabNavItem>
<TabNavItem href={socialPathname}>{t('connectors.tab_social')}</TabNavItem> <TabNavItem href={socialPathname}>{t('connectors.tab_social')}</TabNavItem>
</TabNav> </TabNav>
{hasDemoConnector && <DemoConnectorNotice />} {hasDemoConnector && <DemoConnectorNotice />}
<Table </>
className={resourcesStyles.table} }
rowIndexKey="id" table={{
rowGroups={[{ key: 'connectors', data: connectors }]} rowIndexKey: 'id',
columns={[ rowGroups: [{ key: 'connectors', data: connectors }],
{ columns: [
title: t('connectors.connector_name'), {
dataIndex: 'name', title: t('connectors.connector_name'),
colSpan: 6, dataIndex: 'name',
render: (connectorGroup) => ( colSpan: 6,
<ConnectorName connectorGroup={connectorGroup} isDemo={connectorGroup.isDemo} /> render: (connectorGroup) => (
), <ConnectorName connectorGroup={connectorGroup} isDemo={connectorGroup.isDemo} />
}, ),
{ },
title: t('connectors.connector_type'), {
dataIndex: 'type', title: t('connectors.connector_type'),
colSpan: 5, dataIndex: 'type',
render: (connectorGroup) => <ConnectorTypeColumn connectorGroup={connectorGroup} />, colSpan: 5,
}, render: (connectorGroup) => <ConnectorTypeColumn connectorGroup={connectorGroup} />,
{ },
title: <ConnectorStatusField />, {
dataIndex: 'status', title: <ConnectorStatusField />,
colSpan: 4, dataIndex: 'status',
render: (connectorGroup) => <ConnectorStatus connectorGroup={connectorGroup} />, colSpan: 4,
}, render: (connectorGroup) => <ConnectorStatus connectorGroup={connectorGroup} />,
{ },
title: null, {
dataIndex: 'delete', title: null,
colSpan: 1, dataIndex: 'delete',
render: (connectorGroup) => colSpan: 1,
connectorGroup.isDemo ? ( render: (connectorGroup) =>
<ConnectorDeleteButton connectorGroup={connectorGroup} /> connectorGroup.isDemo ? (
) : null, <ConnectorDeleteButton connectorGroup={connectorGroup} />
}, ) : null,
]} },
isRowClickable={({ connectors }) => Boolean(connectors[0]) && !connectors[0]?.isDemo} ],
rowClickHandler={({ connectors }) => { isRowClickable: ({ connectors }) => Boolean(connectors[0]) && !connectors[0]?.isDemo,
const firstConnector = connectors[0]; rowClickHandler: ({ connectors }) => {
const firstConnector = connectors[0];
if (!firstConnector) {
return;
}
const { type, id } = firstConnector;
navigate(
`${type === ConnectorType.Social ? socialPathname : passwordlessPathname}/${id}`
);
}}
isLoading={isLoading}
errorMessage={error?.body?.message ?? error?.message}
placeholder={
isSocial && (
<TablePlaceholder
image={<SocialConnectorEmpty />}
imageDark={<SocialConnectorEmptyDark />}
title="connectors.placeholder_title"
description="connectors.placeholder_description"
learnMoreLink={getDocumentationUrl(
'/docs/recipes/configure-connectors/configure-social-connector'
)}
action={
<Button
title="connectors.create"
type="primary"
size="large"
icon={<Plus />}
onClick={() => {
navigate(buildCreatePathname(ConnectorType.Social));
}}
/>
}
/>
)
}
onRetry={async () => mutate(undefined, true)}
/>
</div>
<CreateForm
isOpen={Boolean(createConnectorType)}
type={createConnectorType}
onClose={(id) => {
if (createConnectorType && id) {
navigate(buildGuidePathname(createConnectorType, id), { replace: true });
if (!firstConnector) {
return; return;
} }
navigate(`${basePathname}/${tab}`);
}} const { type, id } = firstConnector;
/>
<Guide navigate(
connector={connectorToShowInGuide} `${type === ConnectorType.Social ? socialPathname : passwordlessPathname}/${id}`
onClose={() => { );
navigate(`${basePathname}/${tab}`); },
}} isLoading,
/> errorMessage: error?.body?.message ?? error?.message,
</> placeholder: conditional(
isSocial && (
<TablePlaceholder
image={<SocialConnectorEmpty />}
imageDark={<SocialConnectorEmptyDark />}
title="connectors.placeholder_title"
description="connectors.placeholder_description"
learnMoreLink={getDocumentationUrl(
'/docs/recipes/configure-connectors/configure-social-connector'
)}
action={
<Button
title="connectors.create"
type="primary"
size="large"
icon={<Plus />}
onClick={() => {
navigate(buildCreatePathname(ConnectorType.Social));
}}
/>
}
/>
)
),
onRetry: async () => mutate(undefined, true),
}}
widgets={
<>
<CreateForm
isOpen={Boolean(createConnectorType)}
type={createConnectorType}
onClose={(id) => {
if (createConnectorType && id) {
navigate(buildGuidePathname(createConnectorType, id), { replace: true });
return;
}
navigate(`${basePathname}/${tab}`);
}}
/>
<Guide
connector={connectorToShowInGuide}
onClose={() => {
navigate(`${basePathname}/${tab}`);
}}
/>
</>
}
/>
); );
} }

View file

@ -16,7 +16,7 @@ import { useStaticApi } from '@/hooks/use-api';
import useCurrentUser from '@/hooks/use-current-user'; import useCurrentUser from '@/hooks/use-current-user';
import useSwrFetcher from '@/hooks/use-swr-fetcher'; import useSwrFetcher from '@/hooks/use-swr-fetcher';
import useUserAssetsService from '@/hooks/use-user-assets-service'; import useUserAssetsService from '@/hooks/use-user-assets-service';
import * as resourcesStyles from '@/scss/resources.module.scss'; import * as pageLayout from '@/scss/page-layout.module.scss';
import BasicUserInfoSection from './components/BasicUserInfoSection'; import BasicUserInfoSection from './components/BasicUserInfoSection';
import CardContent from './components/CardContent'; import CardContent from './components/CardContent';
@ -43,9 +43,9 @@ function Profile() {
const showLoadingSkeleton = isLoadingUser || isLoadingConnectors || isUserAssetServiceLoading; const showLoadingSkeleton = isLoadingUser || isLoadingConnectors || isUserAssetServiceLoading;
return ( return (
<div className={resourcesStyles.container}> <div className={pageLayout.container}>
<PageMeta titleKey="profile.page_title" /> <PageMeta titleKey="profile.page_title" />
<div className={resourcesStyles.headline}> <div className={pageLayout.headline}>
<CardTitle title="profile.title" subtitle="profile.description" /> <CardTitle title="profile.title" subtitle="profile.description" />
</div> </div>
{showLoadingSkeleton && <Skeleton />} {showLoadingSkeleton && <Skeleton />}

View file

@ -9,17 +9,14 @@ import Plus from '@/assets/images/plus.svg';
import RolesEmptyDark from '@/assets/images/roles-empty-dark.svg'; import RolesEmptyDark from '@/assets/images/roles-empty-dark.svg';
import RolesEmpty from '@/assets/images/roles-empty.svg'; import RolesEmpty from '@/assets/images/roles-empty.svg';
import Button from '@/components/Button'; import Button from '@/components/Button';
import CardTitle from '@/components/CardTitle';
import ItemPreview from '@/components/ItemPreview'; import ItemPreview from '@/components/ItemPreview';
import PageMeta from '@/components/PageMeta'; import ListPage from '@/components/ListPage';
import Search from '@/components/Search'; import Search from '@/components/Search';
import Table from '@/components/Table';
import TablePlaceholder from '@/components/Table/TablePlaceholder'; import TablePlaceholder from '@/components/Table/TablePlaceholder';
import { defaultPageSize } from '@/consts'; import { defaultPageSize } from '@/consts';
import type { RequestError } from '@/hooks/use-api'; import type { RequestError } from '@/hooks/use-api';
import useDocumentationUrl from '@/hooks/use-documentation-url'; import useDocumentationUrl from '@/hooks/use-documentation-url';
import useSearchParametersWatcher from '@/hooks/use-search-parameters-watcher'; import useSearchParametersWatcher from '@/hooks/use-search-parameters-watcher';
import * as pageStyles from '@/scss/resources.module.scss';
import { buildUrl, formatSearchKeyword } from '@/utils/url'; import { buildUrl, formatSearchKeyword } from '@/utils/url';
import AssignedUsers from './components/AssignedUsers'; import AssignedUsers from './components/AssignedUsers';
@ -57,31 +54,26 @@ function Roles() {
const [roles, totalCount] = data ?? []; const [roles, totalCount] = data ?? [];
return ( return (
<div className={pageStyles.container}> <ListPage
<PageMeta titleKey="roles.page_title" /> title={{
<div className={pageStyles.headline}> title: 'roles.title',
<CardTitle subtitle: 'roles.subtitle',
title="roles.title" learnMoreLink:
subtitle="roles.subtitle" 'https://docs.logto.io/docs/recipes/rbac/manage-permissions-and-roles#manage-roles',
learnMoreLink="https://docs.logto.io/docs/recipes/rbac/manage-permissions-and-roles#manage-roles" }}
/> pageMeta={{ titleKey: 'roles.page_title' }}
<Button createButton={{
icon={<Plus />} title: 'roles.create',
title="roles.create" onClick: () => {
type="primary" navigate({ pathname: createRolePathname, search });
size="large" },
onClick={() => { }}
navigate({ pathname: createRolePathname, search }); table={{
}} rowGroups: [{ key: 'roles', data: roles }],
/> rowIndexKey: 'id',
</div> isLoading,
<Table errorMessage: error?.body?.message ?? error?.message,
className={pageStyles.table} columns: [
rowGroups={[{ key: 'roles', data: roles }]}
rowIndexKey="id"
isLoading={isLoading}
errorMessage={error?.body?.message ?? error?.message}
columns={[
{ {
title: t('roles.role_name'), title: t('roles.role_name'),
dataIndex: 'name', dataIndex: 'name',
@ -102,11 +94,11 @@ function Roles() {
<AssignedUsers users={featuredUsers} count={usersCount} /> <AssignedUsers users={featuredUsers} count={usersCount} />
), ),
}, },
]} ],
rowClickHandler={({ id }) => { rowClickHandler: ({ id }) => {
navigate(buildDetailsPathname(id)); navigate(buildDetailsPathname(id));
}} },
filter={ filter: (
<Search <Search
inputClassName={styles.search} inputClassName={styles.search}
placeholder={t('roles.search')} placeholder={t('roles.search')}
@ -119,16 +111,16 @@ function Roles() {
updateSearchParameters({ keyword: '', page: 1 }); updateSearchParameters({ keyword: '', page: 1 });
}} }}
/> />
} ),
pagination={{ pagination: {
page, page,
totalCount, totalCount,
pageSize, pageSize,
onChange: (page) => { onChange: (page) => {
updateSearchParameters({ page }); updateSearchParameters({ page });
}, },
}} },
placeholder={ placeholder: (
<TablePlaceholder <TablePlaceholder
image={<RolesEmpty />} image={<RolesEmpty />}
imageDark={<RolesEmptyDark />} imageDark={<RolesEmptyDark />}
@ -149,17 +141,19 @@ function Roles() {
/> />
} }
/> />
} ),
onRetry={async () => mutate(undefined, true)} onRetry: async () => mutate(undefined, true),
/> }}
{isOnCreatePage && ( widgets={
<CreateRoleModal isOnCreatePage && (
onClose={() => { <CreateRoleModal
navigate({ pathname: rolesPathname, search }); onClose={() => {
}} navigate({ pathname: rolesPathname, search });
/> }}
)} />
</div> )
}
/>
); );
} }

View file

@ -10,19 +10,16 @@ import UsersEmptyDark from '@/assets/images/users-empty-dark.svg';
import UsersEmpty from '@/assets/images/users-empty.svg'; import UsersEmpty from '@/assets/images/users-empty.svg';
import ApplicationName from '@/components/ApplicationName'; import ApplicationName from '@/components/ApplicationName';
import Button from '@/components/Button'; import Button from '@/components/Button';
import CardTitle from '@/components/CardTitle';
import DateTime from '@/components/DateTime'; import DateTime from '@/components/DateTime';
import ItemPreview from '@/components/ItemPreview'; import ItemPreview from '@/components/ItemPreview';
import PageMeta from '@/components/PageMeta'; import ListPage from '@/components/ListPage';
import Search from '@/components/Search'; import Search from '@/components/Search';
import Table from '@/components/Table';
import TablePlaceholder from '@/components/Table/TablePlaceholder'; import TablePlaceholder from '@/components/Table/TablePlaceholder';
import UserAvatar from '@/components/UserAvatar'; import UserAvatar from '@/components/UserAvatar';
import { defaultPageSize } from '@/consts'; import { defaultPageSize } from '@/consts';
import { UserDetailsTabs } from '@/consts/page-tabs'; import { UserDetailsTabs } from '@/consts/page-tabs';
import type { RequestError } from '@/hooks/use-api'; import type { RequestError } from '@/hooks/use-api';
import useSearchParametersWatcher from '@/hooks/use-search-parameters-watcher'; import useSearchParametersWatcher from '@/hooks/use-search-parameters-watcher';
import * as resourcesStyles from '@/scss/resources.module.scss';
import { buildUrl, formatSearchKeyword } from '@/utils/url'; import { buildUrl, formatSearchKeyword } from '@/utils/url';
import { getUserTitle, getUserSubtitle } from '@/utils/user'; import { getUserTitle, getUserSubtitle } from '@/utils/user';
@ -57,43 +54,27 @@ function Users() {
const [users, totalCount] = data ?? []; const [users, totalCount] = data ?? [];
return ( return (
<div className={resourcesStyles.container}> <ListPage
<PageMeta titleKey="users.page_title" /> title={{
<div className={resourcesStyles.headline}> title: 'users.title',
<CardTitle title="users.title" subtitle="users.subtitle" /> subtitle: 'users.subtitle',
<Button }}
title="users.create" pageMeta={{ titleKey: 'users.page_title' }}
size="large" createButton={{
type="primary" title: 'users.create',
icon={<Plus />} onClick: () => {
onClick={() => { navigate({
navigate({ pathname: createUserPathname,
pathname: createUserPathname, search,
search, });
}); },
}} }}
/> table={{
{isCreateNew && ( rowGroups: [{ key: 'users', data: users }],
<CreateForm rowIndexKey: 'id',
onClose={() => { isLoading,
navigate({ errorMessage: error?.body?.message ?? error?.message,
pathname: usersPathname, columns: [
search,
});
}}
onCreate={() => {
void mutate();
}}
/>
)}
</div>
<Table
className={resourcesStyles.table}
rowGroups={[{ key: 'users', data: users }]}
rowIndexKey="id"
isLoading={isLoading}
errorMessage={error?.body?.message ?? error?.message}
columns={[
{ {
title: t('users.user_name'), title: t('users.user_name'),
dataIndex: 'name', dataIndex: 'name',
@ -125,8 +106,8 @@ function Users() {
colSpan: 5, colSpan: 5,
render: ({ lastSignInAt }) => <DateTime>{lastSignInAt}</DateTime>, render: ({ lastSignInAt }) => <DateTime>{lastSignInAt}</DateTime>,
}, },
]} ],
filter={ filter: (
<Search <Search
inputClassName={styles.searchInput} inputClassName={styles.searchInput}
placeholder={t('users.search')} placeholder={t('users.search')}
@ -139,8 +120,8 @@ function Users() {
updateSearchParameters({ keyword: '', page: 1 }); updateSearchParameters({ keyword: '', page: 1 });
}} }}
/> />
} ),
placeholder={ placeholder: (
<TablePlaceholder <TablePlaceholder
image={<UsersEmpty />} image={<UsersEmpty />}
imageDark={<UsersEmptyDark />} imageDark={<UsersEmptyDark />}
@ -161,21 +142,36 @@ function Users() {
/> />
} }
/> />
} ),
rowClickHandler={({ id }) => { rowClickHandler: ({ id }) => {
navigate(buildDetailsPathname(id)); navigate(buildDetailsPathname(id));
}} },
pagination={{ pagination: {
page, page,
pageSize, pageSize,
totalCount, totalCount,
onChange: (page) => { onChange: (page) => {
updateSearchParameters({ page }); updateSearchParameters({ page });
}, },
}} },
onRetry={async () => mutate(undefined, true)} onRetry: async () => mutate(undefined, true),
/> }}
</div> widgets={
isCreateNew && (
<CreateForm
onClose={() => {
navigate({
pathname: usersPathname,
search,
});
}}
onCreate={() => {
void mutate();
}}
/>
)
}
/>
); );
} }