0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-10 22:22:45 -05:00

feat(console): connector sender test (#367)

This commit is contained in:
Xiao Yijun 2022-03-14 11:11:37 +08:00 committed by GitHub
parent 29b52b2a88
commit 043b20a05a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 361 additions and 21 deletions

View file

@ -9,6 +9,7 @@ type Props<T> = {
content: ReactNode;
domRef: RefObject<Nullable<T>>;
className?: string;
behavior?: 'visibleOnHover' | 'visibleByDefault';
};
type Position = {
@ -16,10 +17,15 @@ type Position = {
left: number;
};
const Tooltip = <T extends Element>({ content, domRef, className }: Props<T>) => {
const Tooltip = <T extends Element>({
content,
domRef,
className,
behavior = 'visibleOnHover',
}: Props<T>) => {
const [tooltipDom, setTooltipDom] = useState<HTMLDivElement>();
const [position, setPosition] = useState<Position>();
const isVisible = position !== undefined;
const positionCaculated = position !== undefined;
useEffect(() => {
if (!domRef.current) {
@ -28,6 +34,14 @@ const Tooltip = <T extends Element>({ content, domRef, className }: Props<T>) =>
const dom = domRef.current;
if (behavior === 'visibleByDefault') {
const { top, left, width } = domRef.current.getBoundingClientRect();
const { scrollTop, scrollLeft } = document.documentElement;
setPosition({ top: top + scrollTop - 12, left: left + scrollLeft + width / 2 });
return;
}
const enterHandler = () => {
if (domRef.current) {
const { top, left, width } = domRef.current.getBoundingClientRect();
@ -47,10 +61,10 @@ const Tooltip = <T extends Element>({ content, domRef, className }: Props<T>) =>
dom.removeEventListener('mouseenter', enterHandler);
dom.removeEventListener('mouseleave', leaveHandler);
};
}, [domRef]);
}, [domRef, behavior]);
useEffect(() => {
if (!isVisible) {
if (!positionCaculated) {
if (tooltipDom) {
tooltipDom.remove();
setTooltipDom(undefined);
@ -66,7 +80,7 @@ const Tooltip = <T extends Element>({ content, domRef, className }: Props<T>) =>
}
return () => tooltipDom?.remove();
}, [isVisible, tooltipDom]);
}, [positionCaculated, tooltipDom]);
if (!tooltipDom || !position) {
return null;

View file

@ -0,0 +1,35 @@
@use '@/scss/underscore' as _;
.fields {
display: flex;
align-items: flex-end;
margin-bottom: _.unit(1);
.textField {
@include _.form-text-field;
}
.send {
margin-left: _.unit(1);
margin-bottom: 1px;
}
}
.error {
font: var(--font-body-2);
color: var(--color-error);
}
.description {
font: var(--font-body-2);
color: var(--color-component-caption);
}
div.successTooltip {
background: #008a71;
color: #fff;
&::after {
border-top-color: #008a71;
}
}

View file

@ -0,0 +1,119 @@
import { ConnectorType } from '@logto/schemas';
import classNames from 'classnames';
import ky from 'ky';
import React, { useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import Button from '@/components/Button';
import FormField from '@/components/FormField';
import TextInput from '@/components/TextInput';
import Tooltip from '@/components/Tooltip';
import { phoneRegEx, emailRegEx } from '@/utils/regex';
import * as styles from './index.module.scss';
type Props = {
connectorType: Exclude<ConnectorType, ConnectorType.Social>;
};
type FormData = {
sendTo: string;
};
const SenderTester = ({ connectorType }: Props) => {
const buttonPosReference = useRef(null);
const [showTooltip, setShowTooltip] = useState(false);
const [submitting, setSubmitting] = useState(false);
const {
handleSubmit,
register,
formState: {
errors: { sendTo: inputError },
},
} = useForm<FormData>();
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const isSms = connectorType === ConnectorType.SMS;
useEffect(() => {
if (!showTooltip) {
return;
}
const tooltipTimeout = setTimeout(() => {
setShowTooltip(false);
setSubmitting(false);
}, 2000);
return () => {
clearTimeout(tooltipTimeout);
};
}, [showTooltip]);
const onSubmit = handleSubmit(async (formData) => {
const { sendTo } = formData;
setSubmitting(true);
const data = isSms ? { phone: sendTo } : { email: sendTo };
try {
await ky
.post(`/api/connectors/test/${connectorType.toLowerCase()}`, {
json: data,
})
.json();
setShowTooltip(true);
} catch (error: unknown) {
console.error(error);
setSubmitting(false);
}
});
return (
<form onSubmit={onSubmit}>
<div className={styles.fields}>
<FormField
isRequired
title={
isSms
? 'admin_console.connector_details.test_sms_sender'
: 'admin_console.connector_details.test_email_sender'
}
className={styles.textField}
>
<TextInput
hasError={Boolean(inputError?.message)}
{...register('sendTo', {
required: true,
pattern: {
value: isSms ? phoneRegEx : emailRegEx,
message: t('connector_details.send_error_invalid_format'),
},
})}
/>
</FormField>
<div ref={buttonPosReference} className={styles.send}>
<Button
htmlType="submit"
disabled={submitting}
title="admin_console.connector_details.send"
type="primary"
/>
</div>
{showTooltip && (
<Tooltip
behavior="visibleByDefault"
domRef={buttonPosReference}
className={styles.successTooltip}
content={t('connector_details.test_message_sent')}
/>
)}
</div>
<div className={classNames(inputError?.message ? styles.error : styles.description)}>
{inputError?.message ? inputError.message : t('connector_details.test_sender_description')}
</div>
</form>
);
};
export default SenderTester;

View file

@ -50,6 +50,12 @@
}
}
.container .body {
> :not(:first-child) {
margin-top: _.unit(4);
}
}
.readme {
background: var(--color-card-background);
padding: _.unit(6);
@ -65,10 +71,6 @@
}
}
.space {
margin-bottom: _.unit(4);
}
.actions {
border-top: 1px solid var(--color-border);
display: flex;

View file

@ -1,4 +1,4 @@
import { ConnectorDTO, RequestErrorBody } from '@logto/schemas';
import { ConnectorDTO, ConnectorType, RequestErrorBody } from '@logto/schemas';
import ky, { HTTPError } from 'ky';
import React, { useEffect, useState } from 'react';
import { toast } from 'react-hot-toast';
@ -18,6 +18,7 @@ import Close from '@/icons/Close';
import * as drawerStyles from '@/scss/drawer.module.scss';
import { RequestError } from '@/swr';
import SenderTester from './components/SenderTester';
import * as styles from './index.module.scss';
const ConnectorDetails = () => {
@ -125,13 +126,12 @@ const ConnectorDetails = () => {
</Card>
)}
{data && (
<Card>
<Card className={styles.body}>
<TabNav>
<TabNavLink href={`/connectors/${connectorId ?? ''}`}>
{t('connector_details.tab_settings')}
</TabNavLink>
</TabNav>
<div className={styles.space} />
<CodeEditor
language="json"
value={config}
@ -139,6 +139,9 @@ const ConnectorDetails = () => {
setConfig(value);
}}
/>
{data.metadata.type !== ConnectorType.Social && (
<SenderTester connectorType={data.metadata.type} />
)}
{saveError && <div>{saveError}</div>}
<div className={styles.actions}>
<Button

View file

@ -0,0 +1,3 @@
// TODO - LOG-1876: Share Regex Between Logto Core and Front-End
export const emailRegEx = /^\S+@\S+\.\S+$/;
export const phoneRegEx = /^[1-9]\d{10}$/;

View file

@ -36,7 +36,7 @@ export interface SocialConnector extends BaseConnector {
export type SocialConnectorInstance = SocialConnector & { connector: Connector };
type EmailMessageTypes = {
export type EmailMessageTypes = {
SignIn: {
code: string;
};

View file

@ -4,6 +4,8 @@ import {
ConnectorError,
ConnectorErrorCodes,
ConnectorMetadata,
EmailConnectorInstance,
EmailMessageTypes,
ValidateConfig,
} from '@/connectors/types';
import RequestError from '@/errors/RequestError';
@ -26,6 +28,9 @@ const findConnectorByIdPlaceHolder = jest.fn() as jest.MockedFunction<
const getConnectorInstanceByIdPlaceHolder = jest.fn() as jest.MockedFunction<
(connectorId: string) => Promise<ConnectorInstance>
>;
const getConnectorInstanceByTypePlaceHolder = jest.fn() as jest.MockedFunction<
(type: ConnectorType) => Promise<ConnectorInstance>
>;
const getConnectorInstancesPlaceHolder = jest.fn() as jest.MockedFunction<
() => Promise<ConnectorInstance[]>
>;
@ -38,6 +43,8 @@ jest.mock('@/queries/connector', () => ({
jest.mock('@/connectors', () => ({
getConnectorInstanceById: async (connectorId: string) =>
getConnectorInstanceByIdPlaceHolder(connectorId),
getConnectorInstanceByType: async (type: ConnectorType) =>
getConnectorInstanceByTypePlaceHolder(type),
getConnectorInstances: async () => getConnectorInstancesPlaceHolder(),
}));
@ -452,4 +459,92 @@ describe('connector route', () => {
expect(response).toHaveProperty('statusCode', 200);
});
});
describe('POST /connectors/test/email', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should get email connector and send message', async () => {
const mockedEmailConnector: EmailConnectorInstance = {
connector: {
id: 'connector_0',
enabled: true,
config: {},
createdAt: 1_234_567_890_123,
},
metadata: {
id: 'connector_0',
type: ConnectorType.Email,
name: {},
logo: './logo.png',
description: {},
},
// eslint-disable-next-line @typescript-eslint/no-empty-function
validateConfig: async (_config: any) => {},
sendMessage: async (
address: string,
type: keyof EmailMessageTypes,
_payload: EmailMessageTypes[typeof type]
// eslint-disable-next-line @typescript-eslint/no-empty-function
): Promise<any> => {},
};
getConnectorInstanceByTypePlaceHolder.mockImplementationOnce(async (_: ConnectorType) => {
return mockedEmailConnector;
});
const sendMessageSpy = jest.spyOn(mockedEmailConnector, 'sendMessage');
const response = await connectorRequest
.post('/connectors/test/email')
.send({ email: 'test@email.com' });
expect(getConnectorInstanceByTypePlaceHolder).toHaveBeenCalledWith(ConnectorType.Email);
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
expect(response).toHaveProperty('statusCode', 204);
});
});
describe('POST /connectors/test/sms', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should get SMS connector and send message', async () => {
const mockedEmailConnector: EmailConnectorInstance = {
connector: {
id: 'connector_0',
enabled: true,
config: {},
createdAt: 1_234_567_890_123,
},
metadata: {
id: 'connector_0',
type: ConnectorType.SMS,
name: {},
logo: './logo.png',
description: {},
},
// eslint-disable-next-line @typescript-eslint/no-empty-function
validateConfig: async (_config: any) => {},
sendMessage: async (
address: string,
type: keyof EmailMessageTypes,
_payload: EmailMessageTypes[typeof type]
// eslint-disable-next-line @typescript-eslint/no-empty-function
): Promise<any> => {},
};
getConnectorInstanceByTypePlaceHolder.mockImplementationOnce(async (_: ConnectorType) => {
return mockedEmailConnector;
});
const sendMessageSpy = jest.spyOn(mockedEmailConnector, 'sendMessage');
const response = await connectorRequest
.post('/connectors/test/sms')
.send({ phone: '12345678901' });
expect(getConnectorInstanceByTypePlaceHolder).toHaveBeenCalledWith(ConnectorType.SMS);
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
expect(response).toHaveProperty('statusCode', 204);
});
});
});

View file

@ -1,11 +1,20 @@
import { ConnectorDTO, Connectors, ConnectorType } from '@logto/schemas';
import { object, string } from 'zod';
import { getConnectorInstances, getConnectorInstanceById } from '@/connectors';
import { ConnectorInstance } from '@/connectors/types';
import {
getConnectorInstances,
getConnectorInstanceById,
getConnectorInstanceByType,
} from '@/connectors';
import {
ConnectorInstance,
EmailConnectorInstance,
SmsConnectorInstance,
} from '@/connectors/types';
import koaGuard from '@/middleware/koa-guard';
import { updateConnector } from '@/queries/connector';
import assertThat from '@/utils/assert-that';
import { emailRegEx, phoneRegEx } from '@/utils/regex';
import { AuthedRouter } from './types';
@ -117,4 +126,52 @@ export default function connectorRoutes<T extends AuthedRouter>(router: T) {
return next();
}
);
router.post(
'/connectors/test/email',
koaGuard({
body: object({
email: string().regex(emailRegEx),
}),
}),
async (ctx, next) => {
const { email } = ctx.guard.body;
const connector = await getConnectorInstanceByType<EmailConnectorInstance>(
ConnectorType.Email
);
// TODO - LOG-1875: SMS & Email Template for Test
await connector.sendMessage(email, 'Test', {
code: 'email-test',
});
ctx.status = 204;
return next();
}
);
router.post(
'/connectors/test/sms',
koaGuard({
body: object({
phone: string().regex(phoneRegEx),
}),
}),
async (ctx, next) => {
const { phone } = ctx.guard.body;
const connector = await getConnectorInstanceByType<SmsConnectorInstance>(ConnectorType.SMS);
// TODO - LOG-1875: SMS & Email Template for Test
await connector.sendMessage(phone, 'Test', {
code: '123456',
});
ctx.status = 204;
return next();
}
);
}

View file

@ -74,6 +74,9 @@ const translation = {
},
},
},
application_details: {
back_to_applications: 'Back to Applications',
},
api_resources: {
title: 'API Resources',
subtitle: 'Define APIs that you can consume from your authorized applications.',
@ -107,9 +110,6 @@ const translation = {
social: 'Social',
},
},
application_details: {
back_to_applications: 'Back to Applications',
},
connector_details: {
back_to_connectors: 'Back to Connectors',
check_readme: 'Check README',
@ -118,6 +118,12 @@ const translation = {
save_error_empty_config: 'Please enter config.',
save_error_json_parse_error: 'Please enter valid JSON.',
save_success: 'Saved!',
send: 'Send',
send_error_invalid_format: 'Invalid input',
test_email_sender: 'Test your email sender',
test_sms_sender: 'Test your SMS sender',
test_message_sent: 'Test Message Sent!',
test_sender_description: 'Test sender description',
},
},
};

View file

@ -76,6 +76,9 @@ const translation = {
},
},
},
application_details: {
back_to_applications: '返回应用集',
},
api_resources: {
title: 'API Resources',
subtitle: 'Define APIs that you can consume from your authorized applications.',
@ -109,9 +112,6 @@ const translation = {
social: '社会化登录',
},
},
application_details: {
back_to_applications: '返回应用集',
},
connector_details: {
back_to_connectors: '返回连接器',
check_readme: '查看文档',
@ -120,6 +120,12 @@ const translation = {
save_error_empty_config: '请输入配置内容。',
save_error_json_parse_error: '请输入符合 JSON 格式的配置。',
save_success: '保存成功',
send: 'Send',
send_error_invalid_format: 'Invalid input',
test_email_sender: 'Test your email sender',
test_sms_sender: 'Test your SMS sender',
test_message_sent: 'Test Message Sent!',
test_sender_description: 'Test sender description',
},
},
};