Extract general popups markup to its own component

Split popups controllers into separate components
Implemented storybooks for all project's popups
This commit is contained in:
ErickSkrauch 2020-07-06 19:29:56 +03:00
parent 28ccab8a98
commit 82abe0a746
No known key found for this signature in database
GPG Key ID: 669339FCBB30EE0E
39 changed files with 834 additions and 534 deletions

View File

@ -1,4 +1,4 @@
SENTRY_DSN=https://<key>@sentry.io/<project>
SENTRY_DSN=
CROWDIN_API_KEY=abc

View File

@ -21,7 +21,7 @@ import {
} from 'app/services/api/signup';
import dispatchBsod from 'app/components/ui/bsod/dispatchBsod';
import { create as createPopup } from 'app/components/ui/popup/actions';
import ContactForm from 'app/components/contact/ContactForm';
import ContactForm from 'app/components/contact';
import { Account } from 'app/components/accounts/reducer';
import { ThunkAction, Dispatch } from 'app/reducers';
import { Resp } from 'app/services/request';

View File

@ -2,11 +2,11 @@ import React from 'react';
import expect from 'app/test/unexpected';
import sinon from 'sinon';
import { render, fireEvent, waitFor, screen } from '@testing-library/react';
import feedback from 'app/services/api/feedback';
import * as feedback from 'app/services/api/feedback';
import { User } from 'app/components/user';
import { TestContextProvider } from 'app/shell';
import { ContactForm } from './ContactForm';
import ContactForm from './ContactForm';
beforeEach(() => {
sinon.stub(feedback, 'send').returns(Promise.resolve() as any);
@ -18,11 +18,9 @@ afterEach(() => {
describe('ContactForm', () => {
it('should contain Form', () => {
const user = {} as User;
render(
<TestContextProvider>
<ContactForm user={user} />
<ContactForm />
</TestContextProvider>,
);
@ -49,14 +47,14 @@ describe('ContactForm', () => {
});
describe('when rendered with user', () => {
const user = {
const user: Pick<User, 'email'> = {
email: 'foo@bar.com',
} as User;
};
it('should render email field with user email', () => {
render(
<TestContextProvider>
<ContactForm user={user} />
<TestContextProvider state={{ user }}>
<ContactForm />
</TestContextProvider>,
);
@ -65,13 +63,13 @@ describe('ContactForm', () => {
});
it('should submit and then hide form and display success message', async () => {
const user = {
const user: Pick<User, 'email'> = {
email: 'foo@bar.com',
} as User;
};
render(
<TestContextProvider>
<ContactForm user={user} />
<TestContextProvider state={{ user }}>
<ContactForm />
</TestContextProvider>,
);
@ -113,9 +111,9 @@ describe('ContactForm', () => {
});
it('should show validation messages', async () => {
const user = {
const user: Pick<User, 'email'> = {
email: 'foo@bar.com',
} as User;
};
(feedback.send as any).callsFake(() =>
Promise.reject({
@ -125,8 +123,8 @@ describe('ContactForm', () => {
);
render(
<TestContextProvider>
<ContactForm user={user} />
<TestContextProvider state={{ user }}>
<ContactForm />
</TestContextProvider>,
);

View File

@ -1,202 +1,43 @@
import React from 'react';
import { connect } from 'react-redux';
import clsx from 'clsx';
import { FormattedMessage as Message, defineMessages } from 'react-intl';
import { Input, TextArea, Button, Form, FormModel, Dropdown } from 'app/components/ui/form';
import feedback from 'app/services/api/feedback';
import icons from 'app/components/ui/icons.scss';
import popupStyles from 'app/components/ui/popup/popup.scss';
import React, { ComponentType, useCallback, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import { send as sendFeedback } from 'app/services/api/feedback';
import { RootState } from 'app/reducers';
import logger from 'app/services/logger';
import { User } from 'app/components/user';
import styles from './contactForm.scss';
import ContactFormPopup from './ContactFormPopup';
import SuccessContactFormPopup from './SuccessContactFormPopup';
const CONTACT_CATEGORIES = {
// TODO: сюда позже проставить реальные id категорий с backend
0: <Message key="cannotAccessMyAccount" defaultMessage="Can not access my account" />,
1: <Message key="foundBugOnSite" defaultMessage="I found a bug on the site" />,
2: <Message key="improvementsSuggestion" defaultMessage="I have a suggestion for improving the functional" />,
3: <Message key="integrationQuestion" defaultMessage="Service integration question" />,
4: <Message key="other" defaultMessage="Other" />,
};
const labels = defineMessages({
subject: 'Subject',
email: 'Email',
message: 'Message',
whichQuestion: 'What are you interested in?',
send: 'Send',
close: 'Close',
});
export class ContactForm extends React.Component<
{
onClose: () => void;
user: User;
},
{
isLoading: boolean;
isSuccessfullySent: boolean;
lastEmail: string | null;
}
> {
static defaultProps = {
onClose() {},
};
state = {
isLoading: false,
isSuccessfullySent: false,
lastEmail: null,
};
form = new FormModel();
render() {
const { isSuccessfullySent } = this.state || {};
const { onClose } = this.props;
return (
<div data-testid="feedbackPopup" className={isSuccessfullySent ? styles.successState : styles.contactForm}>
<div className={popupStyles.popup}>
<div className={popupStyles.header}>
<h2 className={popupStyles.headerTitle}>
<Message key="title" defaultMessage="Feedback form" />
</h2>
<span
className={clsx(icons.close, popupStyles.close)}
onClick={onClose}
data-testid="feedback-popup-close"
/>
</div>
{isSuccessfullySent ? this.renderSuccess() : this.renderForm()}
</div>
</div>
);
interface Props {
onClose?: () => void;
}
renderForm() {
const { form } = this;
const { user } = this.props;
const { isLoading } = this.state;
return (
<Form form={form} onSubmit={this.onSubmit} isLoading={isLoading}>
<div className={popupStyles.body}>
<div className={styles.philosophicalThought}>
<Message
key="philosophicalThought"
defaultMessage="Properly formulated question — half of the answer"
/>
</div>
<div className={styles.formDisclaimer}>
<Message
key="disclaimer"
defaultMessage="Please formulate your feedback providing as much useful information, as possible to help us understand your problem and solve it"
/>
<br />
</div>
<div className={styles.pairInputRow}>
<div className={styles.pairInput}>
<Input {...form.bindField('subject')} required label={labels.subject} skin="light" />
</div>
<div className={styles.pairInput}>
<Input
{...form.bindField('email')}
required
label={labels.email}
type="email"
skin="light"
defaultValue={user.email}
/>
</div>
</div>
<div className={styles.formMargin}>
<Dropdown
{...form.bindField('category')}
label={labels.whichQuestion}
items={CONTACT_CATEGORIES}
block
/>
</div>
<TextArea
{...form.bindField('message')}
required
label={labels.message}
skin="light"
minRows={6}
maxRows={6}
/>
</div>
<div className={styles.footer}>
<Button label={labels.send} block type="submit" disabled={isLoading} />
</div>
</Form>
);
}
renderSuccess() {
const { lastEmail: email } = this.state;
const { onClose } = this.props;
return (
<div>
<div className={styles.successBody}>
<span className={styles.successIcon} />
<div className={styles.successDescription}>
<Message
key="youMessageReceived"
defaultMessage="Your message was received. We will respond to you shortly. The answer will come to your Email:"
/>
</div>
<div className={styles.sentToEmail}>{email}</div>
</div>
<div className={styles.footer}>
<Button label={labels.close} block onClick={onClose} data-testid="feedback-popup-close-button" />
</div>
</div>
);
}
onSubmit = (): Promise<void> => {
if (this.state.isLoading) {
return Promise.resolve();
}
this.setState({ isLoading: true });
return feedback
.send(this.form.serialize())
.then(() =>
this.setState({
isSuccessfullySent: true,
lastEmail: this.form.value('email'),
}),
)
.catch((resp) => {
if (resp.errors) {
this.form.setErrors(resp.errors);
return;
}
logger.warn('Error sending feedback', resp);
const ContactForm: ComponentType<Props> = ({ onClose }) => {
const userEmail = useSelector((state: RootState) => state.user.email);
const usedEmailRef = useRef(userEmail); // Use ref to avoid unneeded redraw
const [isSent, setIsSent] = useState<boolean>(false);
const onSubmit = useCallback(
(params: Parameters<typeof sendFeedback>[0]): Promise<void> =>
sendFeedback(params)
.then(() => {
setIsSent(true);
usedEmailRef.current = params.email;
})
.finally(() => this.setState({ isLoading: false }));
};
.catch((resp) => {
if (!resp.errors) {
logger.warn('Error sending feedback', resp);
}
export default connect((state: RootState) => ({
user: state.user,
}))(ContactForm);
throw resp;
}),
[],
);
return isSent ? (
<SuccessContactFormPopup email={usedEmailRef.current} onClose={onClose} />
) : (
<ContactFormPopup initEmail={userEmail} onSubmit={onSubmit} onClose={onClose} />
);
};
export default ContactForm;

View File

@ -0,0 +1,16 @@
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import ContactFormPopup from './ContactFormPopup';
storiesOf('Components/Popups', module).add('ContactFormPopup', () => (
<ContactFormPopup
onSubmit={(params) => {
action('onSubmit')(params);
return Promise.resolve();
}}
onClose={action('onClose')}
/>
));

View File

@ -0,0 +1,146 @@
import React from 'react';
import sinon from 'sinon';
import { render, fireEvent, waitFor, screen } from '@testing-library/react';
import expect from 'app/test/unexpected';
import * as feedback from 'app/services/api/feedback';
import { TestContextProvider } from 'app/shell';
import ContactFormPopup from './ContactFormPopup';
beforeEach(() => {
sinon.stub(feedback, 'send').returns(Promise.resolve() as any);
});
afterEach(() => {
(feedback.send as any).restore();
});
describe('ContactFormPopup', () => {
const email = 'foo@bar.com';
it('should contain Form', () => {
render(
<TestContextProvider>
<ContactFormPopup />
</TestContextProvider>,
);
expect(screen.getAllByRole('textbox').length, 'to be greater than', 1);
expect(screen.getByRole('button', { name: /Send/ }), 'to have property', 'type', 'submit');
[
{
label: 'subject',
name: 'subject',
},
{
label: 'Email',
name: 'email',
},
{
label: 'message',
name: 'message',
},
].forEach((el) => {
expect(screen.getByLabelText(el.label, { exact: false }), 'to have property', 'name', el.name);
});
});
describe('when email provided', () => {
it('should render email field with user email', () => {
render(
<TestContextProvider>
<ContactFormPopup initEmail={email} />
</TestContextProvider>,
);
expect(screen.getByDisplayValue(email), 'to be a', HTMLInputElement);
});
});
it('should call the onSubmit callback when submitted', async () => {
let onSubmitResolvePromise: Function;
const onSubmitMock = jest.fn(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
(form): Promise<void> =>
new Promise((resolve) => {
onSubmitResolvePromise = resolve;
}),
);
render(
<TestContextProvider>
<ContactFormPopup initEmail={email} onSubmit={onSubmitMock} />
</TestContextProvider>,
);
fireEvent.change(screen.getByLabelText(/subject/i), {
target: {
value: 'subject',
},
});
fireEvent.change(screen.getByLabelText(/message/i), {
target: {
value: 'the message',
},
});
const button = screen.getByRole('button', { name: 'Send' });
expect(button, 'to have property', 'disabled', false);
fireEvent.click(button);
expect(button, 'to have property', 'disabled', true);
expect(onSubmitMock.mock.calls.length, 'to be', 1);
expect(onSubmitMock.mock.calls[0][0], 'to equal', {
subject: 'subject',
email,
category: '',
message: 'the message',
});
// @ts-ignore
onSubmitResolvePromise();
await waitFor(() => {
expect(button, 'to have property', 'disabled', false);
});
});
it('should display error messages', async () => {
const onSubmitMock = jest.fn(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
(form): Promise<void> =>
Promise.reject({
success: false,
errors: { email: 'error.email_invalid' },
}),
);
render(
<TestContextProvider>
<ContactFormPopup initEmail="invalid@email" onSubmit={onSubmitMock} />
</TestContextProvider>,
);
fireEvent.change(screen.getByLabelText(/subject/i), {
target: {
value: 'subject',
},
});
fireEvent.change(screen.getByLabelText(/message/i), {
target: {
value: 'the message',
},
});
fireEvent.click(screen.getByRole('button', { name: 'Send' }));
await waitFor(() => {
expect(screen.getByRole('alert'), 'to have property', 'innerHTML', 'Email is invalid');
});
});
});

View File

@ -0,0 +1,129 @@
import React, { ComponentType, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FormattedMessage as Message, defineMessages } from 'react-intl';
import Popup from 'app/components/ui/popup';
import { Input, TextArea, Button, Form, FormModel, Dropdown } from 'app/components/ui/form';
import styles from './contactForm.scss';
const CONTACT_CATEGORIES = {
// TODO: сюда позже проставить реальные id категорий с backend
0: <Message key="cannotAccessMyAccount" defaultMessage="Can not access my account" />,
1: <Message key="foundBugOnSite" defaultMessage="I found a bug on the site" />,
2: <Message key="improvementsSuggestion" defaultMessage="I have a suggestion for improving the functional" />,
3: <Message key="integrationQuestion" defaultMessage="Service integration question" />,
4: <Message key="other" defaultMessage="Other" />,
};
const labels = defineMessages({
subject: 'Subject',
email: 'Email',
message: 'Message',
whichQuestion: 'What are you interested in?',
});
interface Props {
initEmail?: string;
onSubmit?: (params: { subject: string; email: string; category: string; message: string }) => Promise<void>;
onClose?: () => void;
}
const ContactFormPopup: ComponentType<Props> = ({ initEmail = '', onSubmit, onClose }) => {
const form = useMemo(() => new FormModel(), []);
const [isLoading, setIsLoading] = useState<boolean>(false);
const isMountedRef = useRef<boolean>(true);
const onSubmitCallback = useCallback((): Promise<void> => {
if (isLoading || !onSubmit) {
return Promise.resolve();
}
setIsLoading(true);
return (
// @ts-ignore serialize() returns Record<string, string>, but we exactly know returning keys
onSubmit(form.serialize())
.catch((resp) => {
if (resp.errors) {
form.setErrors(resp.errors);
return;
}
})
.finally(() => isMountedRef.current && setIsLoading(false))
);
}, [isLoading, onSubmit]);
useEffect(
() => () => {
isMountedRef.current = false;
},
[],
);
return (
<Popup
title={<Message key="title" defaultMessage="Feedback form" />}
wrapperClassName={styles.contactFormBoundings}
onClose={onClose}
data-testid="feedbackPopup"
>
<Form form={form} onSubmit={onSubmitCallback} isLoading={isLoading}>
<div className={styles.body}>
<div className={styles.philosophicalThought}>
<Message
key="philosophicalThought"
defaultMessage="Properly formulated question — half of the answer"
/>
</div>
<div className={styles.formDisclaimer}>
<Message
key="disclaimer"
defaultMessage="Please formulate your feedback providing as much useful information, as possible to help us understand your problem and solve it"
/>
<br />
</div>
<div className={styles.pairInputRow}>
<div className={styles.pairInput}>
<Input {...form.bindField('subject')} required label={labels.subject} skin="light" />
</div>
<div className={styles.pairInput}>
<Input
{...form.bindField('email')}
required
label={labels.email}
type="email"
skin="light"
defaultValue={initEmail}
/>
</div>
</div>
<div className={styles.formMargin}>
<Dropdown
{...form.bindField('category')}
label={labels.whichQuestion}
items={CONTACT_CATEGORIES}
block
/>
</div>
<TextArea
{...form.bindField('message')}
required
label={labels.message}
skin="light"
minRows={6}
maxRows={6}
/>
</div>
<Button label={<Message key="send" defaultMessage="Send" />} block type="submit" disabled={isLoading} />
</Form>
</Popup>
);
};
export default ContactFormPopup;

View File

@ -1,7 +1,7 @@
import React from 'react';
import { connect } from 'react-redux';
import { create as createPopup } from 'app/components/ui/popup/actions';
import ContactForm from './ContactForm';
import ContactForm from 'app/components/contact';
type Props = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
createContactPopup: () => void;

View File

@ -0,0 +1,9 @@
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import SuccessContactFormPopup from './SuccessContactFormPopup';
storiesOf('Components/Popups', module).add('SuccessContactFormPopup', () => (
<SuccessContactFormPopup email="email@ely.by" onClose={action('onClose')} />
));

View File

@ -0,0 +1,38 @@
import React, { ComponentProps, ComponentType } from 'react';
import { FormattedMessage as Message } from 'react-intl';
import Popup from 'app/components/ui/popup';
import { Button } from 'app/components/ui/form';
import styles from './contactForm.scss';
interface Props {
email: string;
onClose?: ComponentProps<typeof Popup>['onClose'];
}
const SuccessContactFormPopup: ComponentType<Props> = ({ email, onClose }) => (
<Popup
title={<Message key="title" defaultMessage="Feedback form" />}
wrapperClassName={styles.successStateBoundings}
onClose={onClose}
data-testid="feedbackPopup"
>
<div className={styles.successBody}>
<span className={styles.successIcon} />
<div className={styles.successDescription}>
<Message
key="youMessageReceived"
defaultMessage="Your message was received. We will respond to you shortly. The answer will come to your Email:"
/>
</div>
<div className={styles.sentToEmail}>{email}</div>
</div>
<div className={styles.footer}>
<Button label={<Message key="close" defaultMessage="Close" />} block onClick={onClose} />
</div>
</Popup>
);
export default SuccessContactFormPopup;

View File

@ -4,12 +4,14 @@
/* Form state */
.contactForm {
composes: popupWrapper from '~app/components/ui/popup/popup.scss';
.contactFormBoundings {
@include popupBounding(500px);
}
.body {
padding: $popupPadding;
}
.philosophicalThought {
font-family: $font-family-title;
font-size: 19px;
@ -45,14 +47,12 @@
/* Success State */
.successState {
composes: popupWrapper from '~app/components/ui/popup/popup.scss';
.successStateBoundings {
@include popupBounding(320px);
}
.successBody {
composes: body from '~app/components/ui/popup/popup.scss';
composes: body;
text-align: center;
}
@ -61,6 +61,7 @@
@extend .formDisclaimer;
margin-bottom: 15px;
padding: 0 20px;
}
.successIcon {
@ -77,9 +78,3 @@
color: #444;
font-size: 18px;
}
/* Common */
.footer {
margin-top: 0;
}

View File

@ -1 +1,2 @@
export { default } from './ContactForm';
export { default as ContactLink } from './ContactLink';

View File

@ -1,181 +1,37 @@
import React from 'react';
import { FormattedMessage as Message, injectIntl, IntlShape } from 'react-intl';
import clsx from 'clsx';
import { connect } from 'react-redux';
import { changeLang } from 'app/components/user/actions';
import LANGS from 'app/i18n';
import formStyles from 'app/components/ui/form/form.scss';
import popupStyles from 'app/components/ui/popup/popup.scss';
import icons from 'app/components/ui/icons.scss';
import React, { ComponentType, useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import styles from './languageSwitcher.scss';
import LanguageList from './LanguageList';
import LOCALES from 'app/i18n';
import { changeLang } from 'app/components/user/actions';
import { RootState } from 'app/reducers';
const translateUrl = 'http://ely.by/translate';
import LanguageSwitcherPopup from './LanguageSwitcherPopup';
export interface LocaleData {
code: string;
name: string;
englishName: string;
progress: number;
isReleased: boolean;
}
export type LocalesMap = Record<string, LocaleData>;
type OwnProps = {
onClose: () => void;
langs: LocalesMap;
emptyCaptions: Array<{
src: string;
caption: string;
}>;
type Props = {
onClose?: () => void;
};
interface Props extends OwnProps {
intl: IntlShape;
selectedLocale: string;
changeLang: (lang: string) => void;
}
const LanguageSwitcher: ComponentType<Props> = ({ onClose = () => {} }) => {
const selectedLocale = useSelector((state: RootState) => state.i18n.locale);
const dispatch = useDispatch();
class LanguageSwitcher extends React.Component<
Props,
{
filter: string;
filteredLangs: LocalesMap;
}
> {
state = {
filter: '',
filteredLangs: this.props.langs,
};
static defaultProps = {
langs: LANGS,
onClose() {},
};
render() {
const { selectedLocale, onClose, intl } = this.props;
const { filteredLangs } = this.state;
const onChangeLang = useCallback(
(lang: string) => {
dispatch(changeLang(lang));
// TODO: await language change and close popup, but not earlier than after 300ms
setTimeout(onClose, 300);
},
[dispatch, onClose],
);
return (
<div
className={styles.languageSwitcher}
data-testid="language-switcher"
data-e2e-active-locale={selectedLocale}
>
<div className={popupStyles.popup}>
<div className={popupStyles.header}>
<h2 className={popupStyles.headerTitle}>
<Message key="siteLanguage" defaultMessage="Site language" />
</h2>
<span className={clsx(icons.close, popupStyles.close)} onClick={onClose} />
</div>
<div className={styles.languageSwitcherBody}>
<div className={styles.searchBox}>
<input
className={clsx(formStyles.lightTextField, formStyles.greenTextField)}
placeholder={intl.formatMessage({
key: 'startTyping',
defaultMessage: 'Start typing…',
})}
onChange={this.onFilterUpdate}
onKeyPress={this.onFilterKeyPress()}
autoFocus
<LanguageSwitcherPopup
locales={LOCALES}
activeLocale={selectedLocale}
onSelect={onChangeLang}
onClose={onClose}
/>
<span className={styles.searchIcon} />
</div>
<LanguageList
selectedLocale={selectedLocale}
langs={filteredLangs}
onChangeLang={this.onChangeLang}
/>
<div className={styles.improveTranslates}>
<div className={styles.improveTranslatesIcon} />
<div className={styles.improveTranslatesContent}>
<div className={styles.improveTranslatesTitle}>
<Message key="improveTranslates" defaultMessage="Improve Ely.by translation" />
</div>
<div className={styles.improveTranslatesText}>
<Message
key="improveTranslatesDescription"
defaultMessage="Ely.bys localization is a community effort. If you want to improve the translation of Ely.by, we'd love your help."
/>{' '}
<a href={translateUrl} target="_blank">
<Message
key="improveTranslatesParticipate"
defaultMessage="Click here to participate."
/>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
onChangeLang = this.changeLang.bind(this);
changeLang(lang: string) {
this.props.changeLang(lang);
setTimeout(this.props.onClose, 300);
}
onFilterUpdate = (event: React.ChangeEvent<HTMLInputElement>) => {
const filter = event.currentTarget.value.trim().toLowerCase();
const { langs } = this.props;
const result = Object.keys(langs).reduce((previous, key) => {
if (
langs[key].englishName.toLowerCase().indexOf(filter) === -1 &&
langs[key].name.toLowerCase().indexOf(filter) === -1
) {
return previous;
}
previous[key] = langs[key];
return previous;
}, {} as typeof langs);
this.setState({
filter,
filteredLangs: result,
});
};
onFilterKeyPress() {
return (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key !== 'Enter' || this.state.filter === '') {
return;
}
const locales = Object.keys(this.state.filteredLangs);
if (locales.length === 0) {
return;
}
this.changeLang(locales[0]);
};
}
}
export default injectIntl(
connect(
(state: RootState) => ({
selectedLocale: state.i18n.locale,
}),
{
changeLang,
},
)(LanguageSwitcher),
);
export default LanguageSwitcher;

View File

@ -0,0 +1,76 @@
import React, { useState } from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import LanguageSwitcherPopup from './LanguageSwitcherPopup';
storiesOf('Components/Popups', module).add('LanguageSwitcherPopup', () => {
const [activeLocale, setActiveLocale] = useState('en');
return (
<LanguageSwitcherPopup
locales={{
// Released and completely translated language
be: {
code: 'be',
name: 'Беларуская',
englishName: 'Belarusian',
progress: 100,
isReleased: true,
},
// Not released, but completely translated language
cs: {
code: 'cs',
name: 'Čeština',
englishName: 'Czech',
progress: 100,
isReleased: false,
},
// English (:
en: {
code: 'en',
name: 'English',
englishName: 'English',
progress: 100,
isReleased: true,
},
// Released, but not completely translated language
id: {
code: 'id',
name: 'Bahasa Indonesia',
englishName: 'Indonesian',
progress: 97,
isReleased: true,
},
// A few more languages just to create a scroll and to test some interesting characters
pt: {
code: 'pt',
name: 'Português do Brasil',
englishName: 'Portuguese, Brazilian',
progress: 99,
isReleased: true,
},
vi: {
code: 'vi',
name: 'Tiếng Việt',
englishName: 'Vietnamese',
progress: 99,
isReleased: true,
},
zh: {
code: 'zh',
name: '简体中文',
englishName: 'Simplified Chinese',
progress: 99,
isReleased: true,
},
}}
activeLocale={activeLocale}
onSelect={(locale) => {
action('onSelect')(locale);
setActiveLocale(locale);
}}
onClose={action('onClose')}
/>
);
});

View File

@ -0,0 +1,114 @@
import React, { ChangeEventHandler, ComponentType, KeyboardEventHandler, useCallback, useState } from 'react';
import { FormattedMessage as Message, useIntl } from 'react-intl';
import clsx from 'clsx';
import formStyles from 'app/components/ui/form/form.scss';
import Popup from 'app/components/ui/popup';
import styles from './languageSwitcher.scss';
import LanguagesList from './LanguagesList';
const translateUrl = 'http://ely.by/translate';
export interface LocaleData {
code: string;
name: string;
englishName: string;
progress: number;
isReleased: boolean;
}
export type LocalesMap = Record<string, LocaleData>;
interface Props {
locales: LocalesMap;
activeLocale: string;
onSelect?: (lang: string) => void;
onClose?: () => void;
}
const LanguageSwitcherPopup: ComponentType<Props> = ({ locales, activeLocale, onSelect, onClose }) => {
const intl = useIntl();
const [filter, setFilter] = useState<string>('');
const filteredLocales = Object.keys(locales).reduce((acc, key) => {
if (
locales[key].englishName.toLowerCase().includes(filter) ||
locales[key].name.toLowerCase().includes(filter)
) {
acc[key] = locales[key];
}
return acc;
}, {} as typeof locales);
const onFilterChange = useCallback<ChangeEventHandler<HTMLInputElement>>(
(event) => {
setFilter(event.currentTarget.value.trim().toLowerCase());
},
[setFilter],
);
const onFilterKeyPress = useCallback<KeyboardEventHandler<HTMLInputElement>>(
(event) => {
if (event.key !== 'Enter' || filter === '') {
return;
}
const localesKeys = Object.keys(filteredLocales);
if (localesKeys.length === 0) {
return;
}
onSelect && onSelect(localesKeys[0]);
},
[filter, filteredLocales, onSelect],
);
return (
<Popup
title={<Message key="siteLanguage" defaultMessage="Site language" />}
wrapperClassName={styles.boundings}
bodyClassName={styles.body}
onClose={onClose}
data-testid="language-switcher"
data-e2e-active-locale={activeLocale}
>
<div className={styles.searchBox}>
<input
className={clsx(formStyles.lightTextField, formStyles.greenTextField)}
placeholder={intl.formatMessage({
key: 'startTyping',
defaultMessage: 'Start typing…',
})}
onChange={onFilterChange}
onKeyPress={onFilterKeyPress}
autoFocus
/>
<span className={styles.searchIcon} />
</div>
<LanguagesList selectedLocale={activeLocale} locales={filteredLocales} onChangeLang={onSelect} />
<div className={styles.improveTranslates}>
<div className={styles.improveTranslatesIcon} />
<div className={styles.improveTranslatesContent}>
<div className={styles.improveTranslatesTitle}>
<Message key="improveTranslates" defaultMessage="Improve Ely.by translation" />
</div>
<div className={styles.improveTranslatesText}>
<Message
key="improveTranslatesDescription"
defaultMessage="Ely.bys localization is a community effort. If you want to improve the translation of Ely.by, we'd love your help."
/>{' '}
<a href={translateUrl} target="_blank">
<Message key="improveTranslatesParticipate" defaultMessage="Click here to participate." />
</a>
</div>
</div>
</div>
</Popup>
);
};
export default LanguageSwitcherPopup;

View File

@ -12,7 +12,7 @@ import { FormattedMessage as Message } from 'react-intl';
import clsx from 'clsx';
import LocaleItem from './LocaleItem';
import { LocalesMap } from './LanguageSwitcher';
import { LocalesMap } from './LanguageSwitcherPopup';
import styles from './languageSwitcher.scss';
import thatFuckingPumpkin from './images/that_fucking_pumpkin.svg';
@ -50,17 +50,21 @@ const emptyCaptions: ReadonlyArray<EmptyCaption> = [
const itemHeight = 51;
export default class LanguageList extends React.Component<{
export default class LanguagesList extends React.Component<{
locales: LocalesMap;
selectedLocale: string;
langs: LocalesMap;
onChangeLang: (lang: string) => void;
onChangeLang?: (lang: string) => void;
}> {
emptyListStateInner: HTMLDivElement | null;
static defaultProps = {
onChangeLang: (): void => {},
};
render() {
const { selectedLocale, langs } = this.props;
const isListEmpty = Object.keys(langs).length === 0;
const firstLocale = Object.keys(langs)[0] || null;
const { selectedLocale, locales } = this.props;
const isListEmpty = Object.keys(locales).length === 0;
const firstLocale = Object.keys(locales)[0] || null;
const emptyCaption = this.getEmptyCaption();
return (
@ -71,7 +75,7 @@ export default class LanguageList extends React.Component<{
willEnter={this.willEnter}
>
{(items) => (
<div className={styles.languagesList} data-testid="language-list">
<div className={styles.languagesList} data-testid="languages-list-item">
<div
className={clsx(styles.emptyLanguagesListWrapper, {
[styles.emptyLanguagesListVisible]: isListEmpty,
@ -126,17 +130,18 @@ export default class LanguageList extends React.Component<{
return (event) => {
event.preventDefault();
// @ts-ignore has defaultProps value
this.props.onChangeLang(lang);
};
}
getItemsWithDefaultStyles = (): Array<TransitionPlainStyle> => {
return Object.keys({ ...this.props.langs }).reduce(
return Object.keys({ ...this.props.locales }).reduce(
(previous, key) => [
...previous,
{
key,
data: this.props.langs[key],
data: this.props.locales[key],
style: {
height: itemHeight,
opacity: 1,
@ -148,12 +153,12 @@ export default class LanguageList extends React.Component<{
};
getItemsWithStyles = (): Array<TransitionStyle> => {
return Object.keys({ ...this.props.langs }).reduce(
return Object.keys({ ...this.props.locales }).reduce(
(previous, key) => [
...previous,
{
key,
data: this.props.langs[key],
data: this.props.locales[key],
style: {
height: spring(itemHeight, presets.gentle),
opacity: spring(1, presets.gentle),

View File

@ -3,7 +3,7 @@ import { localeFlags } from 'app/components/i18n';
import { FormattedMessage as Message } from 'react-intl';
import styles from './languageSwitcher.scss';
import { LocaleData } from './LanguageSwitcher';
import { LocaleData } from './LanguageSwitcherPopup';
interface Props {
locale: LocaleData;

View File

@ -8,17 +8,14 @@
}
}
.languageSwitcher {
composes: popupWrapper from '~app/components/ui/popup/popup.scss';
.boundings {
@include popupBounding(400px);
}
.languageSwitcherBody {
composes: body from '~app/components/ui/popup/popup.scss';
.body {
display: flex;
flex-direction: column;
padding: $popupPadding;
max-height: calc(100vh - 132px);
@media screen and (min-height: 630px) {

View File

@ -0,0 +1,17 @@
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { FormModel } from 'app/components/ui/form';
import PasswordRequestForm from './PasswordRequestForm';
storiesOf('Components/Popups/PasswordRequestForm', module)
.add('empty', () => <PasswordRequestForm form={new FormModel()} onSubmit={action('onSubmit')} />)
.add('with error', () => {
const form = new FormModel();
form.bindField('password');
form.errors['password'] = 'error.password_incorrect';
return <PasswordRequestForm form={form} onSubmit={action('onSubmit')} />;
});

View File

@ -1,32 +1,25 @@
import React, { ComponentType } from 'react';
import { defineMessages, FormattedMessage as Message } from 'react-intl';
import clsx from 'clsx';
import { FormattedMessage as Message } from 'react-intl';
import Popup from 'app/components/ui/popup';
import { Form, Button, Input, FormModel } from 'app/components/ui/form';
import popupStyles from 'app/components/ui/popup/popup.scss';
import styles from './passwordRequestForm.scss';
const labels = defineMessages({
continue: 'Continue',
});
interface Props {
form: FormModel;
onSubmit: (form: FormModel) => void;
onSubmit?: (form: FormModel) => void;
}
const PasswordRequestForm: ComponentType<Props> = ({ form, onSubmit }) => (
<div className={styles.requestPasswordForm} data-testid="password-request-form">
<div className={popupStyles.popup}>
<Popup
title={<Message key="title" defaultMessage="Confirm your action" />}
wrapperClassName={styles.boundings}
isClosable={false}
data-testid="password-request-form"
>
<Form onSubmit={onSubmit} form={form}>
<div className={popupStyles.header}>
<h2 className={popupStyles.headerTitle}>
<Message key="title" defaultMessage="Confirm your action" />
</h2>
</div>
<div className={clsx(popupStyles.body, styles.body)}>
<div className={styles.body}>
<span className={styles.lockIcon} />
<div className={styles.description}>
@ -43,10 +36,10 @@ const PasswordRequestForm: ComponentType<Props> = ({ form, onSubmit }) => (
center
/>
</div>
<Button color="green" label={labels.continue} block type="submit" />
<Button type="submit" color="green" label={<Message key="continue" defaultMessage="Continue" />} block />
</Form>
</div>
</div>
</Popup>
);
export default PasswordRequestForm;

View File

@ -0,0 +1 @@
export { default } from './PasswordRequestForm';

View File

@ -1,12 +1,11 @@
@import '~app/components/ui/popup/popup.scss';
.requestPasswordForm {
composes: popupWrapper from '~app/components/ui/popup/popup.scss';
.boundings {
@include popupBounding(280px);
}
.body {
padding: $popupPadding;
text-align: center;
}

View File

@ -14,12 +14,12 @@ interface BaseProps {
}
interface PropsWithoutForm extends BaseProps {
onSubmit: (form: FormData) => Promise<void> | void;
onSubmit?: (form: FormData) => Promise<void> | void;
}
interface PropsWithForm extends BaseProps {
form: FormModel;
onSubmit: (form: FormModel) => Promise<void> | void;
onSubmit?: (form: FormModel) => Promise<void> | void;
}
type Props = PropsWithoutForm | PropsWithForm;
@ -134,8 +134,10 @@ export default class Form extends React.Component<Props, State> {
let result: Promise<void> | void;
if (hasForm(this.props)) {
// @ts-ignore this prop has default value
result = this.props.onSubmit(this.props.form);
} else {
// @ts-ignore this prop has default value
result = this.props.onSubmit(new FormData(form));
}

View File

@ -0,0 +1,37 @@
import React, { ComponentType, MouseEventHandler, ReactNode } from 'react';
import clsx from 'clsx';
import styles from './popup.scss';
interface Props {
title: ReactNode;
wrapperClassName?: string;
popupClassName?: string;
bodyClassName?: string;
isClosable?: boolean;
onClose?: MouseEventHandler<HTMLSpanElement>;
}
const Popup: ComponentType<Props> = ({
title,
wrapperClassName,
popupClassName,
bodyClassName,
isClosable = true,
onClose,
children,
...props // Passthrough the data-params for testing purposes
}) => (
<div className={clsx(styles.popupWrapper, wrapperClassName)} {...props}>
<div className={clsx(styles.popup, popupClassName)}>
<div className={styles.header}>
<h2 className={styles.headerTitle}>{title}</h2>
{isClosable ? <span className={styles.close} onClick={onClose} data-testid="popup-close" /> : ''}
</div>
<div className={clsx(styles.body, bodyClassName)}>{children}</div>
</div>
</div>
);
export default Popup;

View File

@ -110,7 +110,7 @@ describe('<PopupStack />', () => {
const event = new Event('keyup');
// @ts-ignore
event.which = 27;
event.code = 'Escape';
document.dispatchEvent(event);
uxpect(props.destroy, 'was called once');
@ -135,7 +135,7 @@ describe('<PopupStack />', () => {
const event = new Event('keyup');
// @ts-ignore
event.which = 27;
event.code = 'Escape';
document.dispatchEvent(event);
uxpect(props.destroy, 'was called once');
@ -157,7 +157,7 @@ describe('<PopupStack />', () => {
const event = new Event('keyup');
// @ts-ignore
event.which = 27;
event.code = 'Escape';
document.dispatchEvent(event);
uxpect(props.destroy, 'was not called');

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { ReactNode } from 'react';
import { TransitionGroup, CSSTransition } from 'react-transition-group';
import { browserHistory } from 'app/services/history';
import { connect } from 'react-redux';
@ -17,17 +17,17 @@ interface Props {
export class PopupStack extends React.Component<Props> {
unlistenTransition: () => void;
componentDidMount() {
componentDidMount(): void {
document.addEventListener('keyup', this.onKeyPress);
this.unlistenTransition = browserHistory.listen(this.onRouteLeave);
}
componentWillUnmount() {
componentWillUnmount(): void {
document.removeEventListener('keyup', this.onKeyPress);
this.unlistenTransition();
}
render() {
render(): ReactNode {
const { popups } = this.props;
return (
@ -57,11 +57,11 @@ export class PopupStack extends React.Component<Props> {
}
onClose(popup: PopupConfig) {
return () => this.props.destroy(popup);
return (): void => this.props.destroy(popup);
}
onOverlayClick(popup: PopupConfig) {
return (event: React.MouseEvent<HTMLDivElement>) => {
return (event: React.MouseEvent<HTMLDivElement>): void => {
if (event.target !== event.currentTarget || popup.disableOverlayClose) {
return;
}
@ -72,7 +72,7 @@ export class PopupStack extends React.Component<Props> {
};
}
popStack() {
popStack(): void {
const [popup] = this.props.popups.slice(-1);
if (popup && !popup.disableOverlayClose) {
@ -80,14 +80,14 @@ export class PopupStack extends React.Component<Props> {
}
}
onKeyPress = (event: KeyboardEvent) => {
if (event.which === 27) {
onKeyPress = (event: KeyboardEvent): void => {
if (event.code === 'Escape') {
// ESC key
this.popStack();
}
};
onRouteLeave = (nextLocation: Location) => {
onRouteLeave = (nextLocation: Location): void => {
if (nextLocation) {
this.popStack();
}

View File

@ -0,0 +1,2 @@
export { default } from './Popup';
export { default as PopupStack } from './PopupStack';

View File

@ -1,8 +1,8 @@
@import '~app/components/ui/colors.scss';
@import '~app/components/ui/fonts.scss';
$popupPadding: 20px; // Отступ контента внутри попапа
$popupMargin: 20px; // Отступ попапа от краёв окна
$popupPadding: 20px; // Default content padding
$popupMargin: 20px; // Outer popup margins
@mixin popupBounding($width, $padding: null) {
@if ($padding == null) {
@ -88,10 +88,12 @@ $popupMargin: 20px; // Отступ попапа от краёв окна
}
.body {
padding: $popupPadding;
}
.close {
composes: close from '~app/components/ui/icons.scss';
position: absolute;
right: 0;
top: 0;
@ -107,13 +109,20 @@ $popupMargin: 20px; // Отступ попапа от краёв окна
color: rgba(#000, 0.6);
background: rgba(#fff, 0.75);
}
}
@media (min-width: 655px) {
.close {
position: fixed;
padding: 35px;
}
.trEnter &,
.trExit & {
// don't show the close during transition, because transform forces "position: fixed"
// to layout relative container, instead of body
opacity: 0;
transform: translate(100%);
transition: 0s;
}
}
/**
@ -161,14 +170,3 @@ $popupInitPosition: translateY(10%) rotateX(-8deg);
}
}
}
.trEnter,
.trExit {
.close {
// do not show close during transition, because transform forces position: fixed
// to layout relative container, instead of body
opacity: 0;
transform: translate(100%);
transition: 0s;
}
}

View File

@ -45,6 +45,7 @@
"@types/react-motion": "^0.0.29",
"@types/react-transition-group": "^4.2.4",
"@types/webfontloader": "^1.6.30",
"@types/webpack-env": "^1.15.2"
"@types/webpack-env": "^1.15.2",
"utility-types": "^3.10.0"
}
}

View File

@ -3,7 +3,7 @@ import { Route, Switch, Redirect } from 'react-router-dom';
import { connect } from 'react-redux';
import { refreshUserData } from 'app/components/accounts/actions';
import { create as createPopup } from 'app/components/ui/popup/actions';
import PasswordRequestForm from 'app/components/profile/passwordRequestForm/PasswordRequestForm';
import PasswordRequestForm from 'app/components/profile/passwordRequestForm';
import logger from 'app/services/logger';
import { browserHistory } from 'app/services/history';
import { FooterMenu } from 'app/components/footerMenu';
@ -163,6 +163,7 @@ export default connect(
return <PasswordRequestForm form={form} onSubmit={onSubmit} />;
},
// TODO: this property should be automatically extracted from the popup's isClosable prop
disableOverlayClose: true,
}),
);

View File

@ -10,7 +10,7 @@ import { ScrollIntoView } from 'app/components/ui/scroll';
import PrivateRoute from 'app/containers/PrivateRoute';
import AuthFlowRoute from 'app/containers/AuthFlowRoute';
import Userbar from 'app/components/userbar/Userbar';
import PopupStack from 'app/components/ui/popup/PopupStack';
import { PopupStack } from 'app/components/ui/popup';
import * as loader from 'app/services/loader';
import { getActiveAccount } from 'app/components/accounts/reducer';
import { User } from 'app/components/user';

View File

@ -1,12 +1,12 @@
import request from 'app/services/request';
export default {
send({ subject = '', email = '', message = '', category = '' }) {
return request.post('/api/feedback', {
subject,
email,
message,
category,
});
},
};
interface SendFeedbackParams {
subject: string;
email: string;
message: string;
category: string | number;
}
export function send(params: SendFeedbackParams) {
return request.post('/api/feedback', params);
}

View File

@ -1,7 +1,7 @@
import { hot } from 'react-hot-loader/root';
import React from 'react';
import React, { ComponentType } from 'react';
import { Route, Switch } from 'react-router-dom';
import { History } from 'history';
import { Store } from 'app/reducers';
import AuthFlowRoute from 'app/containers/AuthFlowRoute';
import RootPage from 'app/pages/root/RootPage';
@ -9,11 +9,18 @@ import { ComponentLoader } from 'app/components/ui/loader';
import ContextProvider from './ContextProvider';
import type { History } from 'history';
const SuccessOauthPage = React.lazy(() =>
import(/* webpackChunkName: "page-oauth-success" */ 'app/pages/auth/SuccessOauthPage'),
);
const App = ({ store, history }: { store: Store; history: History<any> }) => (
interface Props {
store: Store;
history: History;
}
const App: ComponentType<Props> = ({ store, history }) => (
<ContextProvider store={store} history={history}>
<React.Suspense fallback={<ComponentLoader />}>
<Switch>

View File

@ -1,12 +1,19 @@
import React from 'react';
import React, { ComponentType } from 'react';
import { Provider as ReduxProvider } from 'react-redux';
import { Router } from 'react-router-dom';
import { HelmetProvider } from 'react-helmet-async';
import { History } from 'history';
import { IntlProvider } from 'app/components/i18n';
import { Store } from 'app/reducers';
function ContextProvider({ children, store, history }: { children: React.ReactNode; store: Store; history: any }) {
return (
interface Props {
children: React.ReactNode;
store: Store;
history: History;
}
const ContextProvider: ComponentType<Props> = ({ children, store, history }) => (
<HelmetProvider>
<ReduxProvider store={store}>
<IntlProvider>
@ -15,6 +22,5 @@ function ContextProvider({ children, store, history }: { children: React.ReactNo
</ReduxProvider>
</HelmetProvider>
);
}
export default ContextProvider;

View File

@ -1,16 +1,26 @@
import React from 'react';
import React, { ComponentType } from 'react';
import { createMemoryHistory } from 'history';
import { DeepPartial } from 'utility-types';
import storeFactory from 'app/storeFactory';
import { RootState } from 'app/reducers';
import ContextProvider from './ContextProvider';
type ContextProps = React.ComponentProps<typeof ContextProvider>;
type NotOverriddenProps = Omit<React.ComponentProps<typeof ContextProvider>, 'store' | 'history'>;
type Props = NotOverriddenProps & {
state?: DeepPartial<RootState>;
};
function TestContextProvider(props: Partial<ContextProps> & { children: ContextProps['children'] }) {
const store = React.useMemo(storeFactory, []);
const TestContextProvider: ComponentType<Props> = ({ state = {}, children, ...props }) => {
const store = React.useMemo(() => storeFactory(state), []);
const history = React.useMemo(createMemoryHistory, []);
return <ContextProvider store={store} history={history} {...props} />;
}
return (
<ContextProvider store={store} history={history} {...props}>
{children}
</ContextProvider>
);
};
export default TestContextProvider;

View File

@ -1,4 +1,4 @@
import { createStore, applyMiddleware, compose } from 'redux';
import { createStore, applyMiddleware, compose, StoreEnhancer } from 'redux';
// midleware, который позволяет возвращать из экшенов функции
// это полезно для работы с асинхронными действиями,
// а также дает возможность проверить какие-либо условия перед запуском экшена
@ -8,13 +8,13 @@ import persistState from 'redux-localstorage';
import reducers, { Store } from 'app/reducers';
export default function storeFactory(): Store {
export default function storeFactory(preloadedState = {}): Store {
const middlewares = applyMiddleware(thunk);
const persistStateEnhancer = persistState(['accounts', 'user'], {
key: 'redux-storage',
});
let enhancer;
let enhancer: StoreEnhancer;
if (process.env.NODE_ENV === 'production') {
enhancer = compose(middlewares, persistStateEnhancer);
@ -23,7 +23,7 @@ export default function storeFactory(): Store {
enhancer = composeEnhancers(middlewares, persistStateEnhancer);
}
const store = createStore(reducers, {}, enhancer) as Store;
const store = createStore(reducers, preloadedState, enhancer) as Store;
// Hot reload reducers
if (module.hot && typeof module.hot.accept === 'function') {

View File

@ -35,7 +35,7 @@ describe('feedback popup', () => {
cy.findByTestId('feedbackPopup').should('contain', 'Your message was received');
cy.findByTestId('feedbackPopup').should('contain', account1.email);
cy.findByTestId('feedback-popup-close-button').click();
cy.findByTestId('feedbackPopup').find('button').contains('Close').click();
cy.findByTestId('feedbackPopup').should('not.be.visible');
});

View File

@ -1,5 +1,5 @@
describe('Change locale', () => {
it('should change locale from footer', () => {
it('should change locale from the footer', () => {
cy.visit('/');
cy.findByTestId('footer').contains('Site language').click();
@ -7,7 +7,7 @@ describe('Change locale', () => {
cy.findByTestId('language-switcher').should('be.visible');
cy.findByTestId('language-switcher').should('have.attr', 'data-e2e-active-locale', 'en');
cy.findByTestId('language-list').contains('Belarusian').click();
cy.findByTestId('languages-list-item').contains('Belarusian').click();
cy.findByTestId('language-switcher').should('not.be.visible');
@ -16,13 +16,13 @@ describe('Change locale', () => {
cy.findByTestId('language-switcher').should('be.visible');
cy.findByTestId('language-switcher').should('have.attr', 'data-e2e-active-locale', 'be');
cy.findByTestId('language-list').contains('English').click();
cy.findByTestId('languages-list-item').contains('English').click();
cy.findByTestId('language-switcher').should('not.be.visible');
cy.findByTestId('footer').should('contain', 'Site language');
});
it('should change locale from profile', () => {
it('should change locale from the profile', () => {
cy.login({ accounts: ['default'] }).then(({ accounts: [account] }) => {
cy.server();
cy.route({
@ -39,7 +39,7 @@ describe('Change locale', () => {
cy.findByTestId('language-switcher').should('be.visible');
cy.findByTestId('language-switcher').should('have.attr', 'data-e2e-active-locale', 'en');
cy.findByTestId('language-list').contains('Belarusian').click();
cy.findByTestId('languages-list-item').contains('Belarusian').click();
cy.wait('@language').its('requestBody').should('eq', 'lang=be');

View File

@ -15896,6 +15896,11 @@ utila@^0.4.0, utila@~0.4:
resolved "https://registry.yarnpkg.com/utila/-/utila-0.4.0.tgz#8a16a05d445657a3aea5eecc5b12a4fa5379772c"
integrity sha1-ihagXURWV6Oupe7MWxKk+lN5dyw=
utility-types@^3.10.0:
version "3.10.0"
resolved "https://registry.yarnpkg.com/utility-types/-/utility-types-3.10.0.tgz#ea4148f9a741015f05ed74fd615e1d20e6bed82b"
integrity sha512-O11mqxmi7wMKCo6HKFt5AhO4BwY3VV68YU07tgxfz8zJTIxr4BpsezN49Ffwy9j3ZpwwJp4fkRwjRzq3uWE6Rg==
utils-merge@1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"