0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -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);
}
.content {
.description {
font: var(--font-body-medium);
color: var(--color-neutral-50);
margin-bottom: _.unit(2);

View file

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

View file

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

View file

@ -3,6 +3,7 @@ import { ConnectorPlatform, ConnectorType } from '@logto/schemas';
import EmailConnector from '@/assets/images/connector-email.svg';
import SmsConnectorIcon from '@/assets/images/connector-sms.svg';
import type { ConnectorGroup } from '@/types/connector';
type TitlePlaceHolder = {
[key in ConnectorType]: AdminConsoleKey;
@ -32,3 +33,25 @@ export const connectorPlaceholderIcon: ConnectorPlaceholderIcon = Object.freeze(
[ConnectorType.Sms]: SmsConnectorIcon,
[ConnectorType.Email]: EmailConnector,
} 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 =
type === ConnectorType.Email ? SignInIdentifier.Email : SignInIdentifier.Sms;
return (
data.signIn.methods.some(
const usedInSignUp =
data.signUp.identifiers.includes(relatedIdentifier) && data.signUp.verify;
const usedInSignIn = data.signIn.methods.some(
({ identifier, verificationCode }) => verificationCode && identifier === relatedIdentifier
) ||
(data.signUp.identifiers.includes(relatedIdentifier) && data.signUp.verify)
);
return usedInSignUp || usedInSignIn;
},
[data]
);

View file

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

View file

@ -112,7 +112,8 @@ const Applications = () => {
render: ({ id }) => <CopyToClipboard value={id} variant="text" />,
},
]}
placeholder={
placeholder={{
content: (
<Button
title="applications.create"
type="outline"
@ -123,8 +124,9 @@ const Applications = () => {
});
}}
/>
}
onClickRow={({ id }) => {
),
}}
rowClickHandler={({ id }) => {
navigate(buildDetailsPathname(id));
}}
onRetry={async () => mutate(undefined, true)}

View file

@ -1,6 +1,6 @@
import type { ConnectorResponse } from '@logto/schemas';
import { ConnectorType } from '@logto/schemas';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import Button from '@/components/Button';
import ConnectorLogo from '@/components/ConnectorLogo';
@ -13,18 +13,19 @@ import {
} from '@/consts/connectors';
import { ConnectorsTabs } from '@/consts/page-tabs';
import ConnectorPlatformIcon from '@/icons/ConnectorPlatformIcon';
import type { ConnectorGroup } from '@/types/connector';
import * as styles from './index.module.scss';
type Props = {
type: ConnectorType;
connectors: ConnectorResponse[];
onClickSetup?: () => void;
connectorGroup: ConnectorGroup;
};
const ConnectorName = ({ type, connectors, onClickSetup }: Props) => {
const ConnectorName = ({ connectorGroup }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { type, connectors } = connectorGroup;
const connector = connectors[0];
const navigate = useNavigate();
if (!connector) {
const PlaceholderIcon = connectorPlaceholderIcon[type];
@ -35,7 +36,12 @@ const ConnectorName = ({ type, connectors, onClickSetup }: Props) => {
<div className={styles.previewTitle}>
<div>{t(connectorTitlePlaceHolder[type])}</div>
{type !== ConnectorType.Social && (
<Button title="general.set_up" onClick={onClickSetup} />
<Button
title="general.set_up"
onClick={() => {
navigate(`/connectors/${ConnectorsTabs.Passwordless}/create/${type}`);
}}
/>
)}
</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 CardTitle from '@/components/CardTitle';
import TabNav, { TabNavItem } from '@/components/TabNav';
import TableEmpty from '@/components/Table/TableEmpty';
import TableError from '@/components/Table/TableError';
import TableLoading from '@/components/Table/TableLoading';
import type { TablePlaceholder } from '@/components/Table';
import Table from '@/components/Table';
import { defaultEmailConnectorGroup, defaultSmsConnectorGroup } from '@/consts';
import { ConnectorsTabs } from '@/consts/page-tabs';
import useConnectorGroups from '@/hooks/use-connector-groups';
import { useTheme } from '@/hooks/use-theme';
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 ConnectorTypeColumn from './components/ConnectorTypeColumn';
import CreateForm from './components/CreateForm';
import SignInExperienceSetupNotice from './components/SignInExperienceSetupNotice';
import * as styles from './index.module.scss';
@ -53,25 +54,39 @@ const Connectors = () => {
const theme = useTheme();
const isLightMode = theme === AppearanceMode.LightMode;
const emailConnector = useMemo(() => {
const emailConnectorGroup = data?.find(({ type }) => type === ConnectorType.Email);
const passwordlessConnectors = useMemo(() => {
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]);
const smsConnector = useMemo(() => {
const smsConnectorGroup = data?.find(({ type }) => type === ConnectorType.Sms);
const socialConnectors = useMemo(
() => data?.filter(({ type }) => type === ConnectorType.Social),
[data]
);
return smsConnectorGroup?.connectors[0];
}, [data]);
const connectors = isSocial ? socialConnectors : passwordlessConnectors;
const socialConnectorGroups = useMemo(() => {
if (!isSocial) {
return;
const placeholder: TablePlaceholder | undefined = conditional(
isSocial && {
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 (
<>
@ -95,73 +110,49 @@ const Connectors = () => {
<TabNavItem href={passwordlessPathname}>{t('connectors.tab_email_sms')}</TabNavItem>
<TabNavItem href={socialPathname}>{t('connectors.tab_social')}</TabNavItem>
</TabNav>
<div className={resourcesStyles.table}>
<div className={tableStyles.scrollable}>
<table className={classNames(!data && tableStyles.empty)}>
<colgroup>
<col className={styles.connectorName} />
<col />
<col />
</colgroup>
<thead>
<tr>
<th>{t('connectors.connector_name')}</th>
<th>{t('connectors.connector_type')}</th>
<th>
<ConnectorStatusField />
</th>
</tr>
</thead>
<tbody>
{!data && error && (
<TableError
columns={3}
content={error.body?.message ?? error.message}
<Table
className={resourcesStyles.table}
rowIndexKey="id"
rowGroups={[{ key: 'connectors', data: connectors }]}
columns={[
{
title: t('connectors.connector_name'),
dataIndex: 'name',
colSpan: 6,
render: (connectorGroup) => <ConnectorName connectorGroup={connectorGroup} />,
},
{
title: t('connectors.connector_type'),
dataIndex: 'type',
colSpan: 5,
render: (connectorGroup) => <ConnectorTypeColumn connectorGroup={connectorGroup} />,
},
{
title: <ConnectorStatusField />,
dataIndex: 'status',
colSpan: 5,
render: (connectorGroup) => <ConnectorStatus connectorGroup={connectorGroup} />,
},
]}
isRowClickable={({ connectors }) => Boolean(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={placeholder}
onRetry={async () => mutate(undefined, true)}
/>
)}
{isLoading && <TableLoading columns={3} />}
{socialConnectorGroups?.length === 0 && (
<TableEmpty
columns={3}
title={t('connectors.type.social')}
content={t('connectors.social_connector_eg')}
image={isLightMode ? <SocialConnectorEmpty /> : <SocialConnectorEmptyDark />}
>
<Button
title="connectors.create"
type="outline"
onClick={() => {
navigate(buildCreatePathname(ConnectorType.Social));
}}
/>
</TableEmpty>
)}
{!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>
{Boolean(createConnectorType) && (
<CreateForm