diff --git a/packages/console/src/components/ListPage/index.tsx b/packages/console/src/components/ListPage/index.tsx new file mode 100644 index 000000000..b71ba63d1 --- /dev/null +++ b/packages/console/src/components/ListPage/index.tsx @@ -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 = FieldPath +> = { + title: CardTitleProps; + pageMeta?: PageMetaProps; + createButton?: CreateButtonProps; + subHeader?: ReactNode; + table: TableProps; + widgets: ReactNode; + className?: string; +}; + +function ListPage< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>({ + title, + pageMeta, + createButton, + subHeader, + table, + widgets, + className, +}: Props) { + return ( +
+ {pageMeta && } +
+ + {createButton &&
+ {subHeader} + + {widgets} + + ); +} + +export default ListPage; diff --git a/packages/console/src/components/PageMeta/index.tsx b/packages/console/src/components/PageMeta/index.tsx index 73cc3e561..62f674572 100644 --- a/packages/console/src/components/PageMeta/index.tsx +++ b/packages/console/src/components/PageMeta/index.tsx @@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next'; import { mainTitle } from '@/consts/tenants'; -type Props = { +export type Props = { titleKey: AdminConsoleKey | AdminConsoleKey[]; // eslint-disable-next-line react/boolean-prop-naming trackPageView?: boolean; diff --git a/packages/console/src/components/Table/index.tsx b/packages/console/src/components/Table/index.tsx index 7dd5ed96b..91f7b4091 100644 --- a/packages/console/src/components/Table/index.tsx +++ b/packages/console/src/components/Table/index.tsx @@ -15,7 +15,7 @@ import TableLoading from './TableLoading'; import * as styles from './index.module.scss'; import type { Column, RowGroup } from './types'; -type Props< +export type Props< TFieldValues extends FieldValues = FieldValues, TName extends FieldPath = FieldPath > = { diff --git a/packages/console/src/pages/ApiResources/index.module.scss b/packages/console/src/pages/ApiResources/index.module.scss index e53b71f50..b600047dd 100644 --- a/packages/console/src/pages/ApiResources/index.module.scss +++ b/packages/console/src/pages/ApiResources/index.module.scss @@ -3,7 +3,3 @@ .icon { flex-shrink: 0; } - -.pagination { - margin-top: _.unit(4); -} diff --git a/packages/console/src/pages/ApiResources/index.tsx b/packages/console/src/pages/ApiResources/index.tsx index 73f130e12..7f43183a5 100644 --- a/packages/console/src/pages/ApiResources/index.tsx +++ b/packages/console/src/pages/ApiResources/index.tsx @@ -9,22 +9,16 @@ import useSWR from 'swr'; import ApiResourceDark from '@/assets/images/api-resource-dark.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 EmptyDataPlaceholder from '@/components/EmptyDataPlaceholder'; import ItemPreview from '@/components/ItemPreview'; -import PageMeta from '@/components/PageMeta'; -import Pagination from '@/components/Pagination'; -import Table from '@/components/Table'; +import ListPage from '@/components/ListPage'; import { defaultPageSize } from '@/consts'; import { ApiResourceDetailsTabs } from '@/consts/page-tabs'; import type { RequestError } from '@/hooks/use-api'; import useSearchParametersWatcher from '@/hooks/use-search-parameters-watcher'; import useTheme from '@/hooks/use-theme'; import * as modalStyles from '@/scss/modal.module.scss'; -import * as resourcesStyles from '@/scss/resources.module.scss'; import { buildUrl } from '@/utils/url'; import CreateForm from './components/CreateForm'; @@ -60,22 +54,61 @@ function ApiResources() { const ResourceIcon = theme === Theme.Light ? ApiResource : ApiResourceDark; return ( -
- -
- -
-
( - } - to={buildDetailsPathname(id)} - /> - ), - }, - { - title: t('api_resources.api_identifier'), - dataIndex: 'indicator', - colSpan: 10, - render: ({ indicator }) => , - }, - ]} - placeholder={} - rowClickHandler={({ id }) => { - navigate(buildDetailsPathname(id)); - }} - onRetry={async () => mutate(undefined, true)} - /> - { - updateSearchParameters({ page }); - }} - /> - + } + /> ); } diff --git a/packages/console/src/pages/Applications/index.module.scss b/packages/console/src/pages/Applications/index.module.scss index e2565e3ea..8c7fb150e 100644 --- a/packages/console/src/pages/Applications/index.module.scss +++ b/packages/console/src/pages/Applications/index.module.scss @@ -4,10 +4,6 @@ flex-shrink: 0; } -.pagination { - margin-top: _.unit(4); -} - .applicationName { width: 360px; } diff --git a/packages/console/src/pages/Applications/index.tsx b/packages/console/src/pages/Applications/index.tsx index cfa66cd09..eca008ec8 100644 --- a/packages/console/src/pages/Applications/index.tsx +++ b/packages/console/src/pages/Applications/index.tsx @@ -5,19 +5,13 @@ import { useTranslation } from 'react-i18next'; import { useLocation, useNavigate } from 'react-router-dom'; import useSWR from 'swr'; -import Plus from '@/assets/images/plus.svg'; import ApplicationIcon from '@/components/ApplicationIcon'; -import Button from '@/components/Button'; -import CardTitle from '@/components/CardTitle'; import CopyToClipboard from '@/components/CopyToClipboard'; import ItemPreview from '@/components/ItemPreview'; -import PageMeta from '@/components/PageMeta'; -import Pagination from '@/components/Pagination'; -import Table from '@/components/Table'; +import ListPage from '@/components/ListPage'; import { defaultPageSize } from '@/consts'; import type { RequestError } from '@/hooks/use-api'; import useSearchParametersWatcher from '@/hooks/use-search-parameters-watcher'; -import * as resourcesStyles from '@/scss/resources.module.scss'; import { applicationTypeI18nKey } from '@/types/applications'; import { buildUrl } from '@/utils/url'; @@ -63,30 +57,27 @@ function Applications() { }; return ( -
- -
- -
-
{ + navigate({ + pathname: createApplicationPathname, + search, + }); + }, + }} + table={{ + rowGroups: [{ key: 'applications', data: applications }], + rowIndexKey: 'id', + isLoading, + errorMessage: error?.body?.message ?? error?.message, + columns: [ { title: t('applications.application_name'), dataIndex: 'name', @@ -106,44 +97,45 @@ function Applications() { colSpan: 10, render: ({ id }) => , }, - ]} - placeholder={ + ], + placeholder: ( { await mutateApplicationList(newApp); navigate(buildNavigatePathPostAppCreation(newApp), { replace: true }); }} /> - } - rowClickHandler={({ id }) => { + ), + rowClickHandler: ({ id }) => { navigate(buildDetailsPathname(id)); - }} - onRetry={async () => mutate(undefined, true)} - /> - { - updateSearchParameters({ page }); - }} - /> - { - if (newApp) { - navigate(buildNavigatePathPostAppCreation(newApp), { replace: true }); + }, + onRetry: async () => mutate(undefined, true), + pagination: { + page, + totalCount, + pageSize, + onChange: (page) => { + updateSearchParameters({ page }); + }, + }, + }} + widgets={ + { + if (newApp) { + navigate(buildNavigatePathPostAppCreation(newApp), { replace: true }); - return; - } - navigate({ - pathname: applicationsPathname, - search, - }); - }} - /> - + return; + } + navigate({ + pathname: applicationsPathname, + search, + }); + }} + /> + } + /> ); } diff --git a/packages/console/src/pages/AuditLogs/index.tsx b/packages/console/src/pages/AuditLogs/index.tsx index 17335e947..66ef6a813 100644 --- a/packages/console/src/pages/AuditLogs/index.tsx +++ b/packages/console/src/pages/AuditLogs/index.tsx @@ -3,16 +3,16 @@ import { withAppInsights } from '@logto/app-insights/react'; import AuditLogTable from '@/components/AuditLogTable'; import CardTitle from '@/components/CardTitle'; import PageMeta from '@/components/PageMeta'; -import * as resourcesStyles from '@/scss/resources.module.scss'; +import * as pageLayout from '@/scss/page-layout.module.scss'; function AuditLogs() { return ( -
+
-
+
- +
); } diff --git a/packages/console/src/pages/Connectors/index.tsx b/packages/console/src/pages/Connectors/index.tsx index fbad46703..272a139ed 100644 --- a/packages/console/src/pages/Connectors/index.tsx +++ b/packages/console/src/pages/Connectors/index.tsx @@ -2,7 +2,6 @@ import { withAppInsights } from '@logto/app-insights/react'; import { ConnectorType } from '@logto/schemas'; import type { ConnectorFactoryResponse } from '@logto/schemas'; import { conditional } from '@silverhand/essentials'; -import classNames from 'classnames'; import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; 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 SocialConnectorEmpty from '@/assets/images/social-connector-empty.svg'; import Button from '@/components/Button'; -import CardTitle from '@/components/CardTitle'; -import PageMeta from '@/components/PageMeta'; +import ListPage from '@/components/ListPage'; import TabNav, { TabNavItem } from '@/components/TabNav'; -import Table from '@/components/Table'; import TablePlaceholder from '@/components/Table/TablePlaceholder'; import { defaultEmailConnectorGroup, defaultSmsConnectorGroup } from '@/consts'; 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 useDocumentationUrl from '@/hooks/use-documentation-url'; import DemoConnectorNotice from '@/onboarding/components/DemoConnectorNotice'; -import * as resourcesStyles from '@/scss/resources.module.scss'; import ConnectorDeleteButton from './components/ConnectorDeleteButton'; import ConnectorName from './components/ConnectorName'; @@ -102,126 +98,130 @@ function Connectors() { }, [factoryId, factories]); return ( - <> - -
-
- - {isSocial && ( -
- - - {t('connectors.tab_email_sms')} - {t('connectors.tab_social')} - - {hasDemoConnector && } -
( - - ), - }, - { - title: t('connectors.connector_type'), - dataIndex: 'type', - colSpan: 5, - render: (connectorGroup) => , - }, - { - title: , - dataIndex: 'status', - colSpan: 4, - render: (connectorGroup) => , - }, - { - title: null, - dataIndex: 'delete', - colSpan: 1, - render: (connectorGroup) => - connectorGroup.isDemo ? ( - - ) : null, - }, - ]} - isRowClickable={({ connectors }) => Boolean(connectors[0]) && !connectors[0]?.isDemo} - 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 && ( - } - imageDark={} - title="connectors.placeholder_title" - description="connectors.placeholder_description" - learnMoreLink={getDocumentationUrl( - '/docs/recipes/configure-connectors/configure-social-connector' - )} - action={ -
{ + navigate({ pathname: createRolePathname, search }); + }, + }} + table={{ + rowGroups: [{ key: 'roles', data: roles }], + rowIndexKey: 'id', + isLoading, + errorMessage: error?.body?.message ?? error?.message, + columns: [ { title: t('roles.role_name'), dataIndex: 'name', @@ -102,11 +94,11 @@ function Roles() { ), }, - ]} - rowClickHandler={({ id }) => { + ], + rowClickHandler: ({ id }) => { navigate(buildDetailsPathname(id)); - }} - filter={ + }, + filter: ( - } - pagination={{ + ), + pagination: { page, totalCount, pageSize, onChange: (page) => { updateSearchParameters({ page }); }, - }} - placeholder={ + }, + placeholder: ( } imageDark={} @@ -149,17 +141,19 @@ function Roles() { /> } /> - } - onRetry={async () => mutate(undefined, true)} - /> - {isOnCreatePage && ( - { - navigate({ pathname: rolesPathname, search }); - }} - /> - )} - + ), + onRetry: async () => mutate(undefined, true), + }} + widgets={ + isOnCreatePage && ( + { + navigate({ pathname: rolesPathname, search }); + }} + /> + ) + } + /> ); } diff --git a/packages/console/src/pages/Users/index.tsx b/packages/console/src/pages/Users/index.tsx index bb3be818a..8c85f31fb 100644 --- a/packages/console/src/pages/Users/index.tsx +++ b/packages/console/src/pages/Users/index.tsx @@ -10,19 +10,16 @@ import UsersEmptyDark from '@/assets/images/users-empty-dark.svg'; import UsersEmpty from '@/assets/images/users-empty.svg'; import ApplicationName from '@/components/ApplicationName'; import Button from '@/components/Button'; -import CardTitle from '@/components/CardTitle'; import DateTime from '@/components/DateTime'; import ItemPreview from '@/components/ItemPreview'; -import PageMeta from '@/components/PageMeta'; +import ListPage from '@/components/ListPage'; import Search from '@/components/Search'; -import Table from '@/components/Table'; import TablePlaceholder from '@/components/Table/TablePlaceholder'; import UserAvatar from '@/components/UserAvatar'; import { defaultPageSize } from '@/consts'; import { UserDetailsTabs } from '@/consts/page-tabs'; import type { RequestError } from '@/hooks/use-api'; import useSearchParametersWatcher from '@/hooks/use-search-parameters-watcher'; -import * as resourcesStyles from '@/scss/resources.module.scss'; import { buildUrl, formatSearchKeyword } from '@/utils/url'; import { getUserTitle, getUserSubtitle } from '@/utils/user'; @@ -57,43 +54,27 @@ function Users() { const [users, totalCount] = data ?? []; return ( -
- -
- -
-
{ + navigate({ + pathname: createUserPathname, + search, + }); + }, + }} + table={{ + rowGroups: [{ key: 'users', data: users }], + rowIndexKey: 'id', + isLoading, + errorMessage: error?.body?.message ?? error?.message, + columns: [ { title: t('users.user_name'), dataIndex: 'name', @@ -125,8 +106,8 @@ function Users() { colSpan: 5, render: ({ lastSignInAt }) => {lastSignInAt}, }, - ]} - filter={ + ], + filter: ( - } - placeholder={ + ), + placeholder: ( } imageDark={} @@ -161,21 +142,36 @@ function Users() { /> } /> - } - rowClickHandler={({ id }) => { + ), + rowClickHandler: ({ id }) => { navigate(buildDetailsPathname(id)); - }} - pagination={{ + }, + pagination: { page, pageSize, totalCount, onChange: (page) => { updateSearchParameters({ page }); }, - }} - onRetry={async () => mutate(undefined, true)} - /> - + }, + onRetry: async () => mutate(undefined, true), + }} + widgets={ + isCreateNew && ( + { + navigate({ + pathname: usersPathname, + search, + }); + }} + onCreate={() => { + void mutate(); + }} + /> + ) + } + /> ); } diff --git a/packages/console/src/scss/resources.module.scss b/packages/console/src/scss/page-layout.module.scss similarity index 100% rename from packages/console/src/scss/resources.module.scss rename to packages/console/src/scss/page-layout.module.scss