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
39 changed files with 834 additions and 534 deletions

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>
);
}
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);
})
.finally(() => this.setState({ isLoading: false }));
};
interface Props {
onClose?: () => void;
}
export default connect((state: RootState) => ({
user: state.user,
}))(ContactForm);
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;
})
.catch((resp) => {
if (!resp.errors) {
logger.warn('Error sending feedback', resp);
}
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;
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
/>
<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,
const onChangeLang = useCallback(
(lang: string) => {
dispatch(changeLang(lang));
// TODO: await language change and close popup, but not earlier than after 300ms
setTimeout(onClose, 300);
},
)(LanguageSwitcher),
);
[dispatch, onClose],
);
return (
<LanguageSwitcherPopup
locales={LOCALES}
activeLocale={selectedLocale}
onSelect={onChangeLang}
onClose={onClose}
/>
);
};
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,52 +1,45 @@
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}>
<Form onSubmit={onSubmit} form={form}>
<div className={popupStyles.header}>
<h2 className={popupStyles.headerTitle}>
<Message key="title" defaultMessage="Confirm your action" />
</h2>
<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={styles.body}>
<span className={styles.lockIcon} />
<div className={styles.description}>
<Message key="description" defaultMessage="To complete action enter the account password" />
</div>
<div className={clsx(popupStyles.body, styles.body)}>
<span className={styles.lockIcon} />
<Input
{...form.bindField('password')}
type="password"
required
autoFocus
color="green"
skin="light"
center
/>
</div>
<div className={styles.description}>
<Message key="description" defaultMessage="To complete action enter the account password" />
</div>
<Input
{...form.bindField('password')}
type="password"
required
autoFocus
color="green"
skin="light"
center
/>
</div>
<Button color="green" label={labels.continue} block type="submit" />
</Form>
</div>
</div>
<Button type="submit" color="green" label={<Message key="continue" defaultMessage="Continue" />} block />
</Form>
</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 {
@media (min-width: 655px) {
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;
}
}