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 CROWDIN_API_KEY=abc

View File

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

View File

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

View File

@ -1,202 +1,43 @@
import React from 'react'; import React, { ComponentType, useCallback, useRef, useState } from 'react';
import { connect } from 'react-redux'; import { useSelector } from 'react-redux';
import clsx from 'clsx';
import { FormattedMessage as Message, defineMessages } from 'react-intl'; import { send as sendFeedback } from 'app/services/api/feedback';
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 { RootState } from 'app/reducers'; import { RootState } from 'app/reducers';
import logger from 'app/services/logger'; 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 = { interface Props {
// TODO: сюда позже проставить реальные id категорий с backend onClose?: () => void;
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>
);
} }
renderForm() { const ContactForm: ComponentType<Props> = ({ onClose }) => {
const { form } = this; const userEmail = useSelector((state: RootState) => state.user.email);
const { user } = this.props; const usedEmailRef = useRef(userEmail); // Use ref to avoid unneeded redraw
const { isLoading } = this.state; const [isSent, setIsSent] = useState<boolean>(false);
const onSubmit = useCallback(
return ( (params: Parameters<typeof sendFeedback>[0]): Promise<void> =>
<Form form={form} onSubmit={this.onSubmit} isLoading={isLoading}> sendFeedback(params)
<div className={popupStyles.body}> .then(() => {
<div className={styles.philosophicalThought}> setIsSent(true);
<Message usedEmailRef.current = params.email;
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);
}) })
.finally(() => this.setState({ isLoading: false })); .catch((resp) => {
}; if (!resp.errors) {
logger.warn('Error sending feedback', resp);
} }
export default connect((state: RootState) => ({ throw resp;
user: state.user, }),
}))(ContactForm); [],
);
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 React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { create as createPopup } from 'app/components/ui/popup/actions'; 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> & { type Props = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
createContactPopup: () => void; 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 */ /* Form state */
.contactForm { .contactFormBoundings {
composes: popupWrapper from '~app/components/ui/popup/popup.scss';
@include popupBounding(500px); @include popupBounding(500px);
} }
.body {
padding: $popupPadding;
}
.philosophicalThought { .philosophicalThought {
font-family: $font-family-title; font-family: $font-family-title;
font-size: 19px; font-size: 19px;
@ -45,14 +47,12 @@
/* Success State */ /* Success State */
.successState { .successStateBoundings {
composes: popupWrapper from '~app/components/ui/popup/popup.scss';
@include popupBounding(320px); @include popupBounding(320px);
} }
.successBody { .successBody {
composes: body from '~app/components/ui/popup/popup.scss'; composes: body;
text-align: center; text-align: center;
} }
@ -61,6 +61,7 @@
@extend .formDisclaimer; @extend .formDisclaimer;
margin-bottom: 15px; margin-bottom: 15px;
padding: 0 20px;
} }
.successIcon { .successIcon {
@ -77,9 +78,3 @@
color: #444; color: #444;
font-size: 18px; font-size: 18px;
} }
/* Common */
.footer {
margin-top: 0;
}

View File

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

View File

@ -1,181 +1,37 @@
import React from 'react'; import React, { ComponentType, useCallback } from 'react';
import { FormattedMessage as Message, injectIntl, IntlShape } from 'react-intl'; import { useDispatch, useSelector } from 'react-redux';
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 styles from './languageSwitcher.scss'; import LOCALES from 'app/i18n';
import LanguageList from './LanguageList'; import { changeLang } from 'app/components/user/actions';
import { RootState } from 'app/reducers'; import { RootState } from 'app/reducers';
const translateUrl = 'http://ely.by/translate'; import LanguageSwitcherPopup from './LanguageSwitcherPopup';
export interface LocaleData { type Props = {
code: string; onClose?: () => void;
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;
}>;
}; };
interface Props extends OwnProps { const LanguageSwitcher: ComponentType<Props> = ({ onClose = () => {} }) => {
intl: IntlShape; const selectedLocale = useSelector((state: RootState) => state.i18n.locale);
selectedLocale: string; const dispatch = useDispatch();
changeLang: (lang: string) => void;
}
class LanguageSwitcher extends React.Component< const onChangeLang = useCallback(
Props, (lang: string) => {
{ dispatch(changeLang(lang));
filter: string; // TODO: await language change and close popup, but not earlier than after 300ms
filteredLangs: LocalesMap; setTimeout(onClose, 300);
} },
> { [dispatch, onClose],
state = { );
filter: '',
filteredLangs: this.props.langs,
};
static defaultProps = {
langs: LANGS,
onClose() {},
};
render() {
const { selectedLocale, onClose, intl } = this.props;
const { filteredLangs } = this.state;
return ( return (
<div <LanguageSwitcherPopup
className={styles.languageSwitcher} locales={LOCALES}
data-testid="language-switcher" activeLocale={selectedLocale}
data-e2e-active-locale={selectedLocale} onSelect={onChangeLang}
> onClose={onClose}
<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
/> />
<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() { export default LanguageSwitcher;
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),
);

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

View File

@ -8,17 +8,14 @@
} }
} }
.languageSwitcher { .boundings {
composes: popupWrapper from '~app/components/ui/popup/popup.scss';
@include popupBounding(400px); @include popupBounding(400px);
} }
.languageSwitcherBody { .body {
composes: body from '~app/components/ui/popup/popup.scss';
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: $popupPadding;
max-height: calc(100vh - 132px); max-height: calc(100vh - 132px);
@media screen and (min-height: 630px) { @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 React, { ComponentType } from 'react';
import { defineMessages, FormattedMessage as Message } from 'react-intl'; import { FormattedMessage as Message } from 'react-intl';
import clsx from 'clsx';
import Popup from 'app/components/ui/popup';
import { Form, Button, Input, FormModel } from 'app/components/ui/form'; import { Form, Button, Input, FormModel } from 'app/components/ui/form';
import popupStyles from 'app/components/ui/popup/popup.scss';
import styles from './passwordRequestForm.scss'; import styles from './passwordRequestForm.scss';
const labels = defineMessages({
continue: 'Continue',
});
interface Props { interface Props {
form: FormModel; form: FormModel;
onSubmit: (form: FormModel) => void; onSubmit?: (form: FormModel) => void;
} }
const PasswordRequestForm: ComponentType<Props> = ({ form, onSubmit }) => ( const PasswordRequestForm: ComponentType<Props> = ({ form, onSubmit }) => (
<div className={styles.requestPasswordForm} data-testid="password-request-form"> <Popup
<div className={popupStyles.popup}> title={<Message key="title" defaultMessage="Confirm your action" />}
wrapperClassName={styles.boundings}
isClosable={false}
data-testid="password-request-form"
>
<Form onSubmit={onSubmit} form={form}> <Form onSubmit={onSubmit} form={form}>
<div className={popupStyles.header}> <div className={styles.body}>
<h2 className={popupStyles.headerTitle}>
<Message key="title" defaultMessage="Confirm your action" />
</h2>
</div>
<div className={clsx(popupStyles.body, styles.body)}>
<span className={styles.lockIcon} /> <span className={styles.lockIcon} />
<div className={styles.description}> <div className={styles.description}>
@ -43,10 +36,10 @@ const PasswordRequestForm: ComponentType<Props> = ({ form, onSubmit }) => (
center center
/> />
</div> </div>
<Button color="green" label={labels.continue} block type="submit" />
<Button type="submit" color="green" label={<Message key="continue" defaultMessage="Continue" />} block />
</Form> </Form>
</div> </Popup>
</div>
); );
export default PasswordRequestForm; 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'; @import '~app/components/ui/popup/popup.scss';
.requestPasswordForm { .boundings {
composes: popupWrapper from '~app/components/ui/popup/popup.scss';
@include popupBounding(280px); @include popupBounding(280px);
} }
.body { .body {
padding: $popupPadding;
text-align: center; text-align: center;
} }

View File

@ -14,12 +14,12 @@ interface BaseProps {
} }
interface PropsWithoutForm extends BaseProps { interface PropsWithoutForm extends BaseProps {
onSubmit: (form: FormData) => Promise<void> | void; onSubmit?: (form: FormData) => Promise<void> | void;
} }
interface PropsWithForm extends BaseProps { interface PropsWithForm extends BaseProps {
form: FormModel; form: FormModel;
onSubmit: (form: FormModel) => Promise<void> | void; onSubmit?: (form: FormModel) => Promise<void> | void;
} }
type Props = PropsWithoutForm | PropsWithForm; type Props = PropsWithoutForm | PropsWithForm;
@ -134,8 +134,10 @@ export default class Form extends React.Component<Props, State> {
let result: Promise<void> | void; let result: Promise<void> | void;
if (hasForm(this.props)) { if (hasForm(this.props)) {
// @ts-ignore this prop has default value
result = this.props.onSubmit(this.props.form); result = this.props.onSubmit(this.props.form);
} else { } else {
// @ts-ignore this prop has default value
result = this.props.onSubmit(new FormData(form)); 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'); const event = new Event('keyup');
// @ts-ignore // @ts-ignore
event.which = 27; event.code = 'Escape';
document.dispatchEvent(event); document.dispatchEvent(event);
uxpect(props.destroy, 'was called once'); uxpect(props.destroy, 'was called once');
@ -135,7 +135,7 @@ describe('<PopupStack />', () => {
const event = new Event('keyup'); const event = new Event('keyup');
// @ts-ignore // @ts-ignore
event.which = 27; event.code = 'Escape';
document.dispatchEvent(event); document.dispatchEvent(event);
uxpect(props.destroy, 'was called once'); uxpect(props.destroy, 'was called once');
@ -157,7 +157,7 @@ describe('<PopupStack />', () => {
const event = new Event('keyup'); const event = new Event('keyup');
// @ts-ignore // @ts-ignore
event.which = 27; event.code = 'Escape';
document.dispatchEvent(event); document.dispatchEvent(event);
uxpect(props.destroy, 'was not called'); 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 { TransitionGroup, CSSTransition } from 'react-transition-group';
import { browserHistory } from 'app/services/history'; import { browserHistory } from 'app/services/history';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
@ -17,17 +17,17 @@ interface Props {
export class PopupStack extends React.Component<Props> { export class PopupStack extends React.Component<Props> {
unlistenTransition: () => void; unlistenTransition: () => void;
componentDidMount() { componentDidMount(): void {
document.addEventListener('keyup', this.onKeyPress); document.addEventListener('keyup', this.onKeyPress);
this.unlistenTransition = browserHistory.listen(this.onRouteLeave); this.unlistenTransition = browserHistory.listen(this.onRouteLeave);
} }
componentWillUnmount() { componentWillUnmount(): void {
document.removeEventListener('keyup', this.onKeyPress); document.removeEventListener('keyup', this.onKeyPress);
this.unlistenTransition(); this.unlistenTransition();
} }
render() { render(): ReactNode {
const { popups } = this.props; const { popups } = this.props;
return ( return (
@ -57,11 +57,11 @@ export class PopupStack extends React.Component<Props> {
} }
onClose(popup: PopupConfig) { onClose(popup: PopupConfig) {
return () => this.props.destroy(popup); return (): void => this.props.destroy(popup);
} }
onOverlayClick(popup: PopupConfig) { onOverlayClick(popup: PopupConfig) {
return (event: React.MouseEvent<HTMLDivElement>) => { return (event: React.MouseEvent<HTMLDivElement>): void => {
if (event.target !== event.currentTarget || popup.disableOverlayClose) { if (event.target !== event.currentTarget || popup.disableOverlayClose) {
return; return;
} }
@ -72,7 +72,7 @@ export class PopupStack extends React.Component<Props> {
}; };
} }
popStack() { popStack(): void {
const [popup] = this.props.popups.slice(-1); const [popup] = this.props.popups.slice(-1);
if (popup && !popup.disableOverlayClose) { if (popup && !popup.disableOverlayClose) {
@ -80,14 +80,14 @@ export class PopupStack extends React.Component<Props> {
} }
} }
onKeyPress = (event: KeyboardEvent) => { onKeyPress = (event: KeyboardEvent): void => {
if (event.which === 27) { if (event.code === 'Escape') {
// ESC key // ESC key
this.popStack(); this.popStack();
} }
}; };
onRouteLeave = (nextLocation: Location) => { onRouteLeave = (nextLocation: Location): void => {
if (nextLocation) { if (nextLocation) {
this.popStack(); 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/colors.scss';
@import '~app/components/ui/fonts.scss'; @import '~app/components/ui/fonts.scss';
$popupPadding: 20px; // Отступ контента внутри попапа $popupPadding: 20px; // Default content padding
$popupMargin: 20px; // Отступ попапа от краёв окна $popupMargin: 20px; // Outer popup margins
@mixin popupBounding($width, $padding: null) { @mixin popupBounding($width, $padding: null) {
@if ($padding == null) { @if ($padding == null) {
@ -88,10 +88,12 @@ $popupMargin: 20px; // Отступ попапа от краёв окна
} }
.body { .body {
padding: $popupPadding;
} }
.close { .close {
composes: close from '~app/components/ui/icons.scss';
position: absolute; position: absolute;
right: 0; right: 0;
top: 0; top: 0;
@ -107,13 +109,20 @@ $popupMargin: 20px; // Отступ попапа от краёв окна
color: rgba(#000, 0.6); color: rgba(#000, 0.6);
background: rgba(#fff, 0.75); background: rgba(#fff, 0.75);
} }
}
@media (min-width: 655px) { @media (min-width: 655px) {
.close {
position: fixed; position: fixed;
padding: 35px; 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-motion": "^0.0.29",
"@types/react-transition-group": "^4.2.4", "@types/react-transition-group": "^4.2.4",
"@types/webfontloader": "^1.6.30", "@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 { connect } from 'react-redux';
import { refreshUserData } from 'app/components/accounts/actions'; import { refreshUserData } from 'app/components/accounts/actions';
import { create as createPopup } from 'app/components/ui/popup/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 logger from 'app/services/logger';
import { browserHistory } from 'app/services/history'; import { browserHistory } from 'app/services/history';
import { FooterMenu } from 'app/components/footerMenu'; import { FooterMenu } from 'app/components/footerMenu';
@ -163,6 +163,7 @@ export default connect(
return <PasswordRequestForm form={form} onSubmit={onSubmit} />; return <PasswordRequestForm form={form} onSubmit={onSubmit} />;
}, },
// TODO: this property should be automatically extracted from the popup's isClosable prop
disableOverlayClose: true, disableOverlayClose: true,
}), }),
); );

View File

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

View File

@ -1,12 +1,12 @@
import request from 'app/services/request'; import request from 'app/services/request';
export default { interface SendFeedbackParams {
send({ subject = '', email = '', message = '', category = '' }) { subject: string;
return request.post('/api/feedback', { email: string;
subject, message: string;
email, category: string | number;
message, }
category,
}); 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 { hot } from 'react-hot-loader/root';
import React from 'react'; import React, { ComponentType } from 'react';
import { Route, Switch } from 'react-router-dom'; import { Route, Switch } from 'react-router-dom';
import { History } from 'history';
import { Store } from 'app/reducers'; import { Store } from 'app/reducers';
import AuthFlowRoute from 'app/containers/AuthFlowRoute'; import AuthFlowRoute from 'app/containers/AuthFlowRoute';
import RootPage from 'app/pages/root/RootPage'; import RootPage from 'app/pages/root/RootPage';
@ -9,11 +9,18 @@ import { ComponentLoader } from 'app/components/ui/loader';
import ContextProvider from './ContextProvider'; import ContextProvider from './ContextProvider';
import type { History } from 'history';
const SuccessOauthPage = React.lazy(() => const SuccessOauthPage = React.lazy(() =>
import(/* webpackChunkName: "page-oauth-success" */ 'app/pages/auth/SuccessOauthPage'), 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}> <ContextProvider store={store} history={history}>
<React.Suspense fallback={<ComponentLoader />}> <React.Suspense fallback={<ComponentLoader />}>
<Switch> <Switch>

View File

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

View File

@ -1,16 +1,26 @@
import React from 'react'; import React, { ComponentType } from 'react';
import { createMemoryHistory } from 'history'; import { createMemoryHistory } from 'history';
import { DeepPartial } from 'utility-types';
import storeFactory from 'app/storeFactory'; import storeFactory from 'app/storeFactory';
import { RootState } from 'app/reducers';
import ContextProvider from './ContextProvider'; 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 TestContextProvider: ComponentType<Props> = ({ state = {}, children, ...props }) => {
const store = React.useMemo(storeFactory, []); const store = React.useMemo(() => storeFactory(state), []);
const history = React.useMemo(createMemoryHistory, []); const history = React.useMemo(createMemoryHistory, []);
return <ContextProvider store={store} history={history} {...props} />; return (
} <ContextProvider store={store} history={history} {...props}>
{children}
</ContextProvider>
);
};
export default TestContextProvider; export default TestContextProvider;

View File

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

View File

@ -1,5 +1,5 @@
describe('Change locale', () => { describe('Change locale', () => {
it('should change locale from footer', () => { it('should change locale from the footer', () => {
cy.visit('/'); cy.visit('/');
cy.findByTestId('footer').contains('Site language').click(); 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('be.visible');
cy.findByTestId('language-switcher').should('have.attr', 'data-e2e-active-locale', 'en'); 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'); 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('be.visible');
cy.findByTestId('language-switcher').should('have.attr', 'data-e2e-active-locale', 'be'); 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('language-switcher').should('not.be.visible');
cy.findByTestId('footer').should('contain', 'Site language'); 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.login({ accounts: ['default'] }).then(({ accounts: [account] }) => {
cy.server(); cy.server();
cy.route({ cy.route({
@ -39,7 +39,7 @@ describe('Change locale', () => {
cy.findByTestId('language-switcher').should('be.visible'); cy.findByTestId('language-switcher').should('be.visible');
cy.findByTestId('language-switcher').should('have.attr', 'data-e2e-active-locale', 'en'); 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'); 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" resolved "https://registry.yarnpkg.com/utila/-/utila-0.4.0.tgz#8a16a05d445657a3aea5eecc5b12a4fa5379772c"
integrity sha1-ihagXURWV6Oupe7MWxKk+lN5dyw= 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: utils-merge@1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"