0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-06 20:40:08 -05:00

refactor(console): connectors table (#2813)

This commit is contained in:
Xiao Yijun 2023-01-04 16:43:58 +08:00 committed by GitHub
parent 45e72f173c
commit be69a81478
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 268 additions and 230 deletions

View file

@ -15,7 +15,7 @@
margin-bottom: _.unit(2); margin-bottom: _.unit(2);
} }
.content { .description {
font: var(--font-body-medium); font: var(--font-body-medium);
color: var(--color-neutral-50); color: var(--color-neutral-50);
margin-bottom: _.unit(2); margin-bottom: _.unit(2);

View file

@ -10,13 +10,13 @@ import * as styles from './TableEmpty.module.scss';
type Props = { type Props = {
title?: string; title?: string;
content?: string; description?: string;
image?: ReactNode; image?: ReactNode;
children?: ReactNode; children?: ReactNode;
columns: number; columns: number;
}; };
const TableEmpty = ({ title, content, image, children, columns }: Props) => { const TableEmpty = ({ title, description, image, children, columns }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const theme = useTheme(); const theme = useTheme();
@ -26,7 +26,7 @@ const TableEmpty = ({ title, content, image, children, columns }: Props) => {
<div className={styles.tableEmpty}> <div className={styles.tableEmpty}>
{image ?? (theme === AppearanceMode.LightMode ? <Empty /> : <EmptyDark />)} {image ?? (theme === AppearanceMode.LightMode ? <Empty /> : <EmptyDark />)}
<div className={styles.title}>{title ?? t('errors.empty')}</div> <div className={styles.title}>{title ?? t('errors.empty')}</div>
{content && <div className={styles.content}>{content}</div>} {description && <div className={styles.description}>{description}</div>}
{children} {children}
</div> </div>
</td> </td>

View file

@ -1,3 +1,4 @@
import { conditional } from '@silverhand/essentials';
import classNames from 'classnames'; import classNames from 'classnames';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { Fragment } from 'react'; import { Fragment } from 'react';
@ -9,6 +10,13 @@ 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';
export type TablePlaceholder = {
title?: string;
description?: string;
image?: ReactNode;
content?: ReactNode;
};
type Props< type Props<
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues> TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
@ -16,12 +24,13 @@ type Props<
rowGroups: Array<RowGroup<TFieldValues>>; rowGroups: Array<RowGroup<TFieldValues>>;
columns: Array<Column<TFieldValues>>; columns: Array<Column<TFieldValues>>;
rowIndexKey: TName; rowIndexKey: TName;
onClickRow?: (row: TFieldValues) => void; isRowClickable?: (row: TFieldValues) => boolean;
rowClickHandler?: (row: TFieldValues) => void;
className?: string; className?: string;
headerClassName?: string; headerClassName?: string;
bodyClassName?: string; bodyClassName?: string;
isLoading?: boolean; isLoading?: boolean;
placeholder?: ReactNode; placeholder?: TablePlaceholder;
errorMessage?: string; errorMessage?: string;
onRetry?: () => void; onRetry?: () => void;
}; };
@ -33,7 +42,8 @@ const Table = <
rowGroups, rowGroups,
columns, columns,
rowIndexKey, rowIndexKey,
onClickRow, rowClickHandler,
isRowClickable = () => Boolean(rowClickHandler),
className, className,
headerClassName, headerClassName,
bodyClassName, bodyClassName,
@ -69,7 +79,14 @@ const Table = <
<TableError columns={columns.length} content={errorMessage} onRetry={onRetry} /> <TableError columns={columns.length} content={errorMessage} onRetry={onRetry} />
)} )}
{!isLoading && !hasData && placeholder && ( {!isLoading && !hasData && placeholder && (
<TableEmpty columns={columns.length}>{placeholder}</TableEmpty> <TableEmpty
columns={columns.length}
title={placeholder.title}
description={placeholder.description}
image={placeholder.image}
>
{placeholder.content}
</TableEmpty>
)} )}
{rowGroups.map(({ key, label, labelClassName, data }) => ( {rowGroups.map(({ key, label, labelClassName, data }) => (
<Fragment key={key}> <Fragment key={key}>
@ -80,21 +97,31 @@ const Table = <
</td> </td>
</tr> </tr>
)} )}
{data?.map((row) => ( {data?.map((row) => {
<tr const rowClickable = isRowClickable(row);
key={row[rowIndexKey]}
className={classNames(onClickRow && styles.clickable)} const onClick = conditional(
onClick={() => { rowClickable &&
onClickRow?.(row); rowClickHandler &&
}} (() => {
> rowClickHandler(row);
{columns.map(({ dataIndex, colSpan, className, render }) => ( })
<td key={dataIndex} colSpan={colSpan} className={className}> );
{render(row)}
</td> return (
))} <tr
</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> </Fragment>
))} ))}
</tbody> </tbody>

View file

@ -3,6 +3,7 @@ import { ConnectorPlatform, ConnectorType } from '@logto/schemas';
import EmailConnector from '@/assets/images/connector-email.svg'; import EmailConnector from '@/assets/images/connector-email.svg';
import SmsConnectorIcon from '@/assets/images/connector-sms.svg'; import SmsConnectorIcon from '@/assets/images/connector-sms.svg';
import type { ConnectorGroup } from '@/types/connector';
type TitlePlaceHolder = { type TitlePlaceHolder = {
[key in ConnectorType]: AdminConsoleKey; [key in ConnectorType]: AdminConsoleKey;
@ -32,3 +33,25 @@ export const connectorPlaceholderIcon: ConnectorPlaceholderIcon = Object.freeze(
[ConnectorType.Sms]: SmsConnectorIcon, [ConnectorType.Sms]: SmsConnectorIcon,
[ConnectorType.Email]: EmailConnector, [ConnectorType.Email]: EmailConnector,
} as const); } as const);
export const defaultSmsConnectorGroup: ConnectorGroup = {
id: 'default-sms-connector',
type: ConnectorType.Sms,
connectors: [],
name: { en: '' },
description: { en: '' },
logo: '',
logoDark: null,
target: '',
};
export const defaultEmailConnectorGroup: ConnectorGroup = {
id: 'default-email-connector',
type: ConnectorType.Email,
connectors: [],
name: { en: '' },
description: { en: '' },
logo: '',
logoDark: null,
target: '',
};

View file

@ -23,12 +23,14 @@ const useConnectorInUse = () => {
const relatedIdentifier = const relatedIdentifier =
type === ConnectorType.Email ? SignInIdentifier.Email : SignInIdentifier.Sms; type === ConnectorType.Email ? SignInIdentifier.Email : SignInIdentifier.Sms;
return ( const usedInSignUp =
data.signIn.methods.some( data.signUp.identifiers.includes(relatedIdentifier) && data.signUp.verify;
({ identifier, verificationCode }) => verificationCode && identifier === relatedIdentifier
) || const usedInSignIn = data.signIn.methods.some(
(data.signUp.identifiers.includes(relatedIdentifier) && data.signUp.verify) ({ identifier, verificationCode }) => verificationCode && identifier === relatedIdentifier
); );
return usedInSignUp || usedInSignIn;
}, },
[data] [data]
); );

View file

@ -118,19 +118,21 @@ const ApiResources = () => {
render: ({ indicator }) => <CopyToClipboard value={indicator} variant="text" />, render: ({ indicator }) => <CopyToClipboard value={indicator} variant="text" />,
}, },
]} ]}
placeholder={ placeholder={{
<Button content: (
title="api_resources.create" <Button
type="outline" title="api_resources.create"
onClick={() => { type="outline"
navigate({ onClick={() => {
pathname: createApiResourcePathname, navigate({
search, pathname: createApiResourcePathname,
}); search,
}} });
/> }}
} />
onClickRow={({ id }) => { ),
}}
rowClickHandler={({ id }) => {
navigate(buildDetailsPathname(id)); navigate(buildDetailsPathname(id));
}} }}
onRetry={async () => mutate(undefined, true)} onRetry={async () => mutate(undefined, true)}

View file

@ -112,19 +112,21 @@ const Applications = () => {
render: ({ id }) => <CopyToClipboard value={id} variant="text" />, render: ({ id }) => <CopyToClipboard value={id} variant="text" />,
}, },
]} ]}
placeholder={ placeholder={{
<Button content: (
title="applications.create" <Button
type="outline" title="applications.create"
onClick={() => { type="outline"
navigate({ onClick={() => {
pathname: createApplicationPathname, navigate({
search, pathname: createApplicationPathname,
}); search,
}} });
/> }}
} />
onClickRow={({ id }) => { ),
}}
rowClickHandler={({ id }) => {
navigate(buildDetailsPathname(id)); navigate(buildDetailsPathname(id));
}} }}
onRetry={async () => mutate(undefined, true)} onRetry={async () => mutate(undefined, true)}

View file

@ -1,6 +1,6 @@
import type { ConnectorResponse } from '@logto/schemas';
import { ConnectorType } from '@logto/schemas'; import { ConnectorType } from '@logto/schemas';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import Button from '@/components/Button'; import Button from '@/components/Button';
import ConnectorLogo from '@/components/ConnectorLogo'; import ConnectorLogo from '@/components/ConnectorLogo';
@ -13,18 +13,19 @@ import {
} from '@/consts/connectors'; } from '@/consts/connectors';
import { ConnectorsTabs } from '@/consts/page-tabs'; import { ConnectorsTabs } from '@/consts/page-tabs';
import ConnectorPlatformIcon from '@/icons/ConnectorPlatformIcon'; import ConnectorPlatformIcon from '@/icons/ConnectorPlatformIcon';
import type { ConnectorGroup } from '@/types/connector';
import * as styles from './index.module.scss'; import * as styles from './index.module.scss';
type Props = { type Props = {
type: ConnectorType; connectorGroup: ConnectorGroup;
connectors: ConnectorResponse[];
onClickSetup?: () => void;
}; };
const ConnectorName = ({ type, connectors, onClickSetup }: Props) => { const ConnectorName = ({ connectorGroup }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { type, connectors } = connectorGroup;
const connector = connectors[0]; const connector = connectors[0];
const navigate = useNavigate();
if (!connector) { if (!connector) {
const PlaceholderIcon = connectorPlaceholderIcon[type]; const PlaceholderIcon = connectorPlaceholderIcon[type];
@ -35,7 +36,12 @@ const ConnectorName = ({ type, connectors, onClickSetup }: Props) => {
<div className={styles.previewTitle}> <div className={styles.previewTitle}>
<div>{t(connectorTitlePlaceHolder[type])}</div> <div>{t(connectorTitlePlaceHolder[type])}</div>
{type !== ConnectorType.Social && ( {type !== ConnectorType.Social && (
<Button title="general.set_up" onClick={onClickSetup} /> <Button
title="general.set_up"
onClick={() => {
navigate(`/connectors/${ConnectorsTabs.Passwordless}/create/${type}`);
}}
/>
)} )}
</div> </div>
} }

View file

@ -1,85 +0,0 @@
import type { ConnectorFactoryResponse, ConnectorResponse } from '@logto/schemas';
import { ConnectorType } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import useSWR from 'swr';
import Status from '@/components/Status';
import UnnamedTrans from '@/components/UnnamedTrans';
import { connectorTitlePlaceHolder } from '@/consts/connectors';
import { ConnectorsTabs } from '@/consts/page-tabs';
import useConnectorInUse from '@/hooks/use-connector-in-use';
import * as tableStyles from '@/scss/table.module.scss';
import ConnectorName from '../ConnectorName';
type Props = {
type: ConnectorType;
connectors: ConnectorResponse[];
onClickSetup?: () => void;
};
const ConnectorRow = ({ type, connectors, onClickSetup }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const firstConnector = connectors[0];
const { isConnectorInUse } = useConnectorInUse();
const inUse = isConnectorInUse(firstConnector);
const navigate = useNavigate();
const showSetupButton = type !== ConnectorType.Social && !firstConnector;
const standardConnectors = connectors.filter(({ isStandard }) => isStandard);
if (standardConnectors.length > 1) {
throw new Error('More than one standard connectors with the same target is not supported.');
}
const firstStandardConnector = standardConnectors[0];
const { data: connectorFactory } = useSWR<ConnectorFactoryResponse>(
firstStandardConnector && `/api/connector-factories/${firstStandardConnector.connectorId}`
);
const handleClickRow = () => {
if (showSetupButton || !firstConnector) {
return;
}
navigate(
`/connectors/${
firstConnector.type === ConnectorType.Social
? ConnectorsTabs.Social
: ConnectorsTabs.Passwordless
}/${firstConnector.id}`
);
};
const connectorTypeColumn = useMemo(() => {
if (!firstStandardConnector) {
return t(connectorTitlePlaceHolder[type]);
}
return connectorFactory && <UnnamedTrans resource={connectorFactory.name} />;
}, [type, connectorFactory, t, firstStandardConnector]);
return (
<tr className={conditional(!showSetupButton && tableStyles.clickable)} onClick={handleClickRow}>
<td>
<ConnectorName type={type} connectors={connectors} onClickSetup={onClickSetup} />
</td>
<td>{connectorTypeColumn}</td>
<td>
{conditional(
firstConnector && (
<Status status={inUse ? 'enabled' : 'disabled'}>
{t('connectors.connector_status', {
context: inUse ? 'in_use' : 'not_in_use',
})}
</Status>
)
) ?? '-'}
</td>
</tr>
);
};
export default ConnectorRow;

View file

@ -0,0 +1,31 @@
import { useTranslation } from 'react-i18next';
import Status from '@/components/Status';
import useConnectorInUse from '@/hooks/use-connector-in-use';
import type { ConnectorGroup } from '@/types/connector';
type Props = {
connectorGroup: ConnectorGroup;
};
const ConnectorStatus = ({ connectorGroup }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { connectors } = connectorGroup;
const { isConnectorInUse } = useConnectorInUse();
const firstConnector = connectors[0];
const inUse = isConnectorInUse(firstConnector);
return firstConnector ? (
<Status status={inUse ? 'enabled' : 'disabled'}>
{t('connectors.connector_status', {
context: inUse ? 'in_use' : 'not_in_use',
})}
</Status>
) : (
<span>-</span>
);
};
export default ConnectorStatus;

View file

@ -0,0 +1,39 @@
import type { ConnectorFactoryResponse } from '@logto/schemas';
import { useTranslation } from 'react-i18next';
import useSWR from 'swr';
import UnnamedTrans from '@/components/UnnamedTrans';
import { connectorTitlePlaceHolder } from '@/consts/connectors';
import type { ConnectorGroup } from '@/types/connector';
type Props = {
connectorGroup: ConnectorGroup;
};
const ConnectorTypeColumn = ({ connectorGroup: { type, connectors } }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const standardConnectors = connectors.filter(({ isStandard }) => isStandard);
if (standardConnectors.length > 1) {
throw new Error('More than one standard connectors with the same target is not supported.');
}
const firstStandardConnector = standardConnectors[0];
const { data: connectorFactory } = useSWR<ConnectorFactoryResponse>(
firstStandardConnector && `/api/connector-factories/${firstStandardConnector.connectorId}`
);
if (!firstStandardConnector) {
return <>{t(connectorTitlePlaceHolder[type])}</>;
}
if (!connectorFactory) {
return null;
}
return <UnnamedTrans resource={connectorFactory.name} />;
};
export default ConnectorTypeColumn;

View file

@ -11,17 +11,18 @@ 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 CardTitle from '@/components/CardTitle';
import TabNav, { TabNavItem } from '@/components/TabNav'; import TabNav, { TabNavItem } from '@/components/TabNav';
import TableEmpty from '@/components/Table/TableEmpty'; import type { TablePlaceholder } from '@/components/Table';
import TableError from '@/components/Table/TableError'; import Table from '@/components/Table';
import TableLoading from '@/components/Table/TableLoading'; import { defaultEmailConnectorGroup, defaultSmsConnectorGroup } from '@/consts';
import { ConnectorsTabs } from '@/consts/page-tabs'; import { ConnectorsTabs } from '@/consts/page-tabs';
import useConnectorGroups from '@/hooks/use-connector-groups'; import useConnectorGroups from '@/hooks/use-connector-groups';
import { useTheme } from '@/hooks/use-theme'; import { useTheme } from '@/hooks/use-theme';
import * as resourcesStyles from '@/scss/resources.module.scss'; import * as resourcesStyles from '@/scss/resources.module.scss';
import * as tableStyles from '@/scss/table.module.scss';
import ConnectorRow from './components/ConnectorRow'; import ConnectorName from './components/ConnectorName';
import ConnectorStatus from './components/ConnectorStatus';
import ConnectorStatusField from './components/ConnectorStatusField'; import ConnectorStatusField from './components/ConnectorStatusField';
import ConnectorTypeColumn from './components/ConnectorTypeColumn';
import CreateForm from './components/CreateForm'; import CreateForm from './components/CreateForm';
import SignInExperienceSetupNotice from './components/SignInExperienceSetupNotice'; import SignInExperienceSetupNotice from './components/SignInExperienceSetupNotice';
import * as styles from './index.module.scss'; import * as styles from './index.module.scss';
@ -53,25 +54,39 @@ const Connectors = () => {
const theme = useTheme(); const theme = useTheme();
const isLightMode = theme === AppearanceMode.LightMode; const isLightMode = theme === AppearanceMode.LightMode;
const emailConnector = useMemo(() => { const passwordlessConnectors = useMemo(() => {
const emailConnectorGroup = data?.find(({ type }) => type === ConnectorType.Email); const smsConnector =
data?.find(({ type }) => type === ConnectorType.Sms) ?? defaultSmsConnectorGroup;
return emailConnectorGroup?.connectors[0]; const emailConnector =
data?.find(({ type }) => type === ConnectorType.Email) ?? defaultEmailConnectorGroup;
return [smsConnector, emailConnector];
}, [data]); }, [data]);
const smsConnector = useMemo(() => { const socialConnectors = useMemo(
const smsConnectorGroup = data?.find(({ type }) => type === ConnectorType.Sms); () => data?.filter(({ type }) => type === ConnectorType.Social),
[data]
);
return smsConnectorGroup?.connectors[0]; const connectors = isSocial ? socialConnectors : passwordlessConnectors;
}, [data]);
const socialConnectorGroups = useMemo(() => { const placeholder: TablePlaceholder | undefined = conditional(
if (!isSocial) { isSocial && {
return; title: t('connectors.type.social'),
description: t('connectors.social_connector_eg'),
image: isLightMode ? <SocialConnectorEmpty /> : <SocialConnectorEmptyDark />,
content: (
<Button
title="connectors.create"
type="outline"
onClick={() => {
navigate(buildCreatePathname(ConnectorType.Social));
}}
/>
),
} }
);
return data?.filter(({ type }) => type === ConnectorType.Social);
}, [data, isSocial]);
return ( return (
<> <>
@ -95,73 +110,49 @@ const Connectors = () => {
<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>
<div className={resourcesStyles.table}> <Table
<div className={tableStyles.scrollable}> className={resourcesStyles.table}
<table className={classNames(!data && tableStyles.empty)}> rowIndexKey="id"
<colgroup> rowGroups={[{ key: 'connectors', data: connectors }]}
<col className={styles.connectorName} /> columns={[
<col /> {
<col /> title: t('connectors.connector_name'),
</colgroup> dataIndex: 'name',
<thead> colSpan: 6,
<tr> render: (connectorGroup) => <ConnectorName connectorGroup={connectorGroup} />,
<th>{t('connectors.connector_name')}</th> },
<th>{t('connectors.connector_type')}</th> {
<th> title: t('connectors.connector_type'),
<ConnectorStatusField /> dataIndex: 'type',
</th> colSpan: 5,
</tr> render: (connectorGroup) => <ConnectorTypeColumn connectorGroup={connectorGroup} />,
</thead> },
<tbody> {
{!data && error && ( title: <ConnectorStatusField />,
<TableError dataIndex: 'status',
columns={3} colSpan: 5,
content={error.body?.message ?? error.message} render: (connectorGroup) => <ConnectorStatus connectorGroup={connectorGroup} />,
onRetry={async () => mutate(undefined, true)} },
/> ]}
)} isRowClickable={({ connectors }) => Boolean(connectors[0])}
{isLoading && <TableLoading columns={3} />} rowClickHandler={({ connectors }) => {
{socialConnectorGroups?.length === 0 && ( const firstConnector = connectors[0];
<TableEmpty
columns={3} if (!firstConnector) {
title={t('connectors.type.social')} return;
content={t('connectors.social_connector_eg')} }
image={isLightMode ? <SocialConnectorEmpty /> : <SocialConnectorEmptyDark />}
> const { type, id } = firstConnector;
<Button
title="connectors.create" navigate(
type="outline" `${type === ConnectorType.Social ? socialPathname : passwordlessPathname}/${id}`
onClick={() => { );
navigate(buildCreatePathname(ConnectorType.Social)); }}
}} isLoading={isLoading}
/> errorMessage={error?.body?.message ?? error?.message}
</TableEmpty> placeholder={placeholder}
)} onRetry={async () => mutate(undefined, true)}
{!isLoading && !isSocial && ( />
<ConnectorRow
connectors={smsConnector ? [smsConnector] : []}
type={ConnectorType.Sms}
onClickSetup={() => {
navigate(buildCreatePathname(ConnectorType.Sms));
}}
/>
)}
{!isLoading && !isSocial && (
<ConnectorRow
connectors={emailConnector ? [emailConnector] : []}
type={ConnectorType.Email}
onClickSetup={() => {
navigate(buildCreatePathname(ConnectorType.Email));
}}
/>
)}
{socialConnectorGroups?.map(({ connectors, id }) => (
<ConnectorRow key={id} connectors={connectors} type={ConnectorType.Social} />
))}
</tbody>
</table>
</div>
</div>
</div> </div>
{Boolean(createConnectorType) && ( {Boolean(createConnectorType) && (
<CreateForm <CreateForm