mirror of
https://github.com/elyby/accounts-frontend.git
synced 2024-11-23 00:22:57 +05:30
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:
parent
28ccab8a98
commit
82abe0a746
2
.env.tpl
2
.env.tpl
@ -1,4 +1,4 @@
|
||||
SENTRY_DSN=https://<key>@sentry.io/<project>
|
||||
SENTRY_DSN=
|
||||
|
||||
CROWDIN_API_KEY=abc
|
||||
|
||||
|
@ -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';
|
||||
|
@ -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>,
|
||||
);
|
||||
|
||||
|
@ -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: 'E‑mail',
|
||||
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 E‑mail:"
|
||||
/>
|
||||
</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;
|
||||
|
16
packages/app/components/contact/ContactFormPopup.story.tsx
Normal file
16
packages/app/components/contact/ContactFormPopup.story.tsx
Normal 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')}
|
||||
/>
|
||||
));
|
146
packages/app/components/contact/ContactFormPopup.test.tsx
Normal file
146
packages/app/components/contact/ContactFormPopup.test.tsx
Normal 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: 'E‑mail',
|
||||
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', 'E‑mail is invalid');
|
||||
});
|
||||
});
|
||||
});
|
129
packages/app/components/contact/ContactFormPopup.tsx
Normal file
129
packages/app/components/contact/ContactFormPopup.tsx
Normal 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: 'E‑mail',
|
||||
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;
|
@ -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;
|
||||
|
@ -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')} />
|
||||
));
|
38
packages/app/components/contact/SuccessContactFormPopup.tsx
Normal file
38
packages/app/components/contact/SuccessContactFormPopup.tsx
Normal 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 E‑mail:"
|
||||
/>
|
||||
</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;
|
@ -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;
|
||||
}
|
||||
|
@ -1 +1,2 @@
|
||||
export { default } from './ContactForm';
|
||||
export { default as ContactLink } from './ContactLink';
|
||||
|
@ -1,181 +1,37 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage as Message, injectIntl, IntlShape } from 'react-intl';
|
||||
import clsx from 'clsx';
|
||||
import { connect } from 'react-redux';
|
||||
import { changeLang } from 'app/components/user/actions';
|
||||
import LANGS from 'app/i18n';
|
||||
import formStyles from 'app/components/ui/form/form.scss';
|
||||
import popupStyles from 'app/components/ui/popup/popup.scss';
|
||||
import icons from 'app/components/ui/icons.scss';
|
||||
import React, { ComponentType, useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import styles from './languageSwitcher.scss';
|
||||
import LanguageList from './LanguageList';
|
||||
import LOCALES from 'app/i18n';
|
||||
import { changeLang } from 'app/components/user/actions';
|
||||
import { RootState } from 'app/reducers';
|
||||
|
||||
const translateUrl = 'http://ely.by/translate';
|
||||
import LanguageSwitcherPopup from './LanguageSwitcherPopup';
|
||||
|
||||
export interface LocaleData {
|
||||
code: string;
|
||||
name: string;
|
||||
englishName: string;
|
||||
progress: number;
|
||||
isReleased: boolean;
|
||||
}
|
||||
|
||||
export type LocalesMap = Record<string, LocaleData>;
|
||||
|
||||
type OwnProps = {
|
||||
onClose: () => void;
|
||||
langs: LocalesMap;
|
||||
emptyCaptions: Array<{
|
||||
src: string;
|
||||
caption: string;
|
||||
}>;
|
||||
type Props = {
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
interface Props extends OwnProps {
|
||||
intl: IntlShape;
|
||||
selectedLocale: string;
|
||||
changeLang: (lang: string) => void;
|
||||
}
|
||||
const LanguageSwitcher: ComponentType<Props> = ({ onClose = () => {} }) => {
|
||||
const selectedLocale = useSelector((state: RootState) => state.i18n.locale);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
class LanguageSwitcher extends React.Component<
|
||||
Props,
|
||||
{
|
||||
filter: string;
|
||||
filteredLangs: LocalesMap;
|
||||
}
|
||||
> {
|
||||
state = {
|
||||
filter: '',
|
||||
filteredLangs: this.props.langs,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
langs: LANGS,
|
||||
onClose() {},
|
||||
};
|
||||
|
||||
render() {
|
||||
const { selectedLocale, onClose, intl } = this.props;
|
||||
const { filteredLangs } = this.state;
|
||||
const onChangeLang = useCallback(
|
||||
(lang: string) => {
|
||||
dispatch(changeLang(lang));
|
||||
// TODO: await language change and close popup, but not earlier than after 300ms
|
||||
setTimeout(onClose, 300);
|
||||
},
|
||||
[dispatch, onClose],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.languageSwitcher}
|
||||
data-testid="language-switcher"
|
||||
data-e2e-active-locale={selectedLocale}
|
||||
>
|
||||
<div className={popupStyles.popup}>
|
||||
<div className={popupStyles.header}>
|
||||
<h2 className={popupStyles.headerTitle}>
|
||||
<Message key="siteLanguage" defaultMessage="Site language" />
|
||||
</h2>
|
||||
<span className={clsx(icons.close, popupStyles.close)} onClick={onClose} />
|
||||
</div>
|
||||
|
||||
<div className={styles.languageSwitcherBody}>
|
||||
<div className={styles.searchBox}>
|
||||
<input
|
||||
className={clsx(formStyles.lightTextField, formStyles.greenTextField)}
|
||||
placeholder={intl.formatMessage({
|
||||
key: 'startTyping',
|
||||
defaultMessage: 'Start typing…',
|
||||
})}
|
||||
onChange={this.onFilterUpdate}
|
||||
onKeyPress={this.onFilterKeyPress()}
|
||||
autoFocus
|
||||
<LanguageSwitcherPopup
|
||||
locales={LOCALES}
|
||||
activeLocale={selectedLocale}
|
||||
onSelect={onChangeLang}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<span className={styles.searchIcon} />
|
||||
</div>
|
||||
|
||||
<LanguageList
|
||||
selectedLocale={selectedLocale}
|
||||
langs={filteredLangs}
|
||||
onChangeLang={this.onChangeLang}
|
||||
/>
|
||||
|
||||
<div className={styles.improveTranslates}>
|
||||
<div className={styles.improveTranslatesIcon} />
|
||||
<div className={styles.improveTranslatesContent}>
|
||||
<div className={styles.improveTranslatesTitle}>
|
||||
<Message key="improveTranslates" defaultMessage="Improve Ely.by translation" />
|
||||
</div>
|
||||
<div className={styles.improveTranslatesText}>
|
||||
<Message
|
||||
key="improveTranslatesDescription"
|
||||
defaultMessage="Ely.by’s localization is a community effort. If you want to improve the translation of Ely.by, we'd love your help."
|
||||
/>{' '}
|
||||
<a href={translateUrl} target="_blank">
|
||||
<Message
|
||||
key="improveTranslatesParticipate"
|
||||
defaultMessage="Click here to participate."
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
onChangeLang = this.changeLang.bind(this);
|
||||
|
||||
changeLang(lang: string) {
|
||||
this.props.changeLang(lang);
|
||||
|
||||
setTimeout(this.props.onClose, 300);
|
||||
}
|
||||
|
||||
onFilterUpdate = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const filter = event.currentTarget.value.trim().toLowerCase();
|
||||
const { langs } = this.props;
|
||||
|
||||
const result = Object.keys(langs).reduce((previous, key) => {
|
||||
if (
|
||||
langs[key].englishName.toLowerCase().indexOf(filter) === -1 &&
|
||||
langs[key].name.toLowerCase().indexOf(filter) === -1
|
||||
) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
previous[key] = langs[key];
|
||||
|
||||
return previous;
|
||||
}, {} as typeof langs);
|
||||
|
||||
this.setState({
|
||||
filter,
|
||||
filteredLangs: result,
|
||||
});
|
||||
};
|
||||
|
||||
onFilterKeyPress() {
|
||||
return (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key !== 'Enter' || this.state.filter === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
const locales = Object.keys(this.state.filteredLangs);
|
||||
|
||||
if (locales.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.changeLang(locales[0]);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default injectIntl(
|
||||
connect(
|
||||
(state: RootState) => ({
|
||||
selectedLocale: state.i18n.locale,
|
||||
}),
|
||||
{
|
||||
changeLang,
|
||||
},
|
||||
)(LanguageSwitcher),
|
||||
);
|
||||
export default LanguageSwitcher;
|
||||
|
@ -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')}
|
||||
/>
|
||||
);
|
||||
});
|
@ -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.by’s 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;
|
@ -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),
|
@ -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;
|
||||
|
@ -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) {
|
||||
|
@ -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')} />;
|
||||
});
|
@ -1,32 +1,25 @@
|
||||
import React, { ComponentType } from 'react';
|
||||
import { defineMessages, FormattedMessage as Message } from 'react-intl';
|
||||
import clsx from 'clsx';
|
||||
import { FormattedMessage as Message } from 'react-intl';
|
||||
|
||||
import Popup from 'app/components/ui/popup';
|
||||
import { Form, Button, Input, FormModel } from 'app/components/ui/form';
|
||||
import popupStyles from 'app/components/ui/popup/popup.scss';
|
||||
|
||||
import styles from './passwordRequestForm.scss';
|
||||
|
||||
const labels = defineMessages({
|
||||
continue: 'Continue',
|
||||
});
|
||||
|
||||
interface Props {
|
||||
form: FormModel;
|
||||
onSubmit: (form: FormModel) => void;
|
||||
onSubmit?: (form: FormModel) => void;
|
||||
}
|
||||
|
||||
const PasswordRequestForm: ComponentType<Props> = ({ form, onSubmit }) => (
|
||||
<div className={styles.requestPasswordForm} data-testid="password-request-form">
|
||||
<div className={popupStyles.popup}>
|
||||
<Popup
|
||||
title={<Message key="title" defaultMessage="Confirm your action" />}
|
||||
wrapperClassName={styles.boundings}
|
||||
isClosable={false}
|
||||
data-testid="password-request-form"
|
||||
>
|
||||
<Form onSubmit={onSubmit} form={form}>
|
||||
<div className={popupStyles.header}>
|
||||
<h2 className={popupStyles.headerTitle}>
|
||||
<Message key="title" defaultMessage="Confirm your action" />
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className={clsx(popupStyles.body, styles.body)}>
|
||||
<div className={styles.body}>
|
||||
<span className={styles.lockIcon} />
|
||||
|
||||
<div className={styles.description}>
|
||||
@ -43,10 +36,10 @@ const PasswordRequestForm: ComponentType<Props> = ({ form, onSubmit }) => (
|
||||
center
|
||||
/>
|
||||
</div>
|
||||
<Button color="green" label={labels.continue} block type="submit" />
|
||||
|
||||
<Button type="submit" color="green" label={<Message key="continue" defaultMessage="Continue" />} block />
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
|
||||
export default PasswordRequestForm;
|
||||
|
@ -0,0 +1 @@
|
||||
export { default } from './PasswordRequestForm';
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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));
|
||||
}
|
||||
|
||||
|
37
packages/app/components/ui/popup/Popup.tsx
Normal file
37
packages/app/components/ui/popup/Popup.tsx
Normal 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;
|
@ -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');
|
||||
|
@ -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();
|
||||
}
|
||||
|
2
packages/app/components/ui/popup/index.ts
Normal file
2
packages/app/components/ui/popup/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { default } from './Popup';
|
||||
export { default as PopupStack } from './PopupStack';
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -45,6 +45,7 @@
|
||||
"@types/react-motion": "^0.0.29",
|
||||
"@types/react-transition-group": "^4.2.4",
|
||||
"@types/webfontloader": "^1.6.30",
|
||||
"@types/webpack-env": "^1.15.2"
|
||||
"@types/webpack-env": "^1.15.2",
|
||||
"utility-types": "^3.10.0"
|
||||
}
|
||||
}
|
||||
|
@ -3,7 +3,7 @@ import { Route, Switch, Redirect } from 'react-router-dom';
|
||||
import { connect } from 'react-redux';
|
||||
import { refreshUserData } from 'app/components/accounts/actions';
|
||||
import { create as createPopup } from 'app/components/ui/popup/actions';
|
||||
import PasswordRequestForm from 'app/components/profile/passwordRequestForm/PasswordRequestForm';
|
||||
import PasswordRequestForm from 'app/components/profile/passwordRequestForm';
|
||||
import logger from 'app/services/logger';
|
||||
import { browserHistory } from 'app/services/history';
|
||||
import { FooterMenu } from 'app/components/footerMenu';
|
||||
@ -163,6 +163,7 @@ export default connect(
|
||||
|
||||
return <PasswordRequestForm form={form} onSubmit={onSubmit} />;
|
||||
},
|
||||
// TODO: this property should be automatically extracted from the popup's isClosable prop
|
||||
disableOverlayClose: true,
|
||||
}),
|
||||
);
|
||||
|
@ -10,7 +10,7 @@ import { ScrollIntoView } from 'app/components/ui/scroll';
|
||||
import PrivateRoute from 'app/containers/PrivateRoute';
|
||||
import AuthFlowRoute from 'app/containers/AuthFlowRoute';
|
||||
import Userbar from 'app/components/userbar/Userbar';
|
||||
import PopupStack from 'app/components/ui/popup/PopupStack';
|
||||
import { PopupStack } from 'app/components/ui/popup';
|
||||
import * as loader from 'app/services/loader';
|
||||
import { getActiveAccount } from 'app/components/accounts/reducer';
|
||||
import { User } from 'app/components/user';
|
||||
|
@ -1,12 +1,12 @@
|
||||
import request from 'app/services/request';
|
||||
|
||||
export default {
|
||||
send({ subject = '', email = '', message = '', category = '' }) {
|
||||
return request.post('/api/feedback', {
|
||||
subject,
|
||||
email,
|
||||
message,
|
||||
category,
|
||||
});
|
||||
},
|
||||
};
|
||||
interface SendFeedbackParams {
|
||||
subject: string;
|
||||
email: string;
|
||||
message: string;
|
||||
category: string | number;
|
||||
}
|
||||
|
||||
export function send(params: SendFeedbackParams) {
|
||||
return request.post('/api/feedback', params);
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { hot } from 'react-hot-loader/root';
|
||||
import React from 'react';
|
||||
import React, { ComponentType } from 'react';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import { History } from 'history';
|
||||
|
||||
import { Store } from 'app/reducers';
|
||||
import AuthFlowRoute from 'app/containers/AuthFlowRoute';
|
||||
import RootPage from 'app/pages/root/RootPage';
|
||||
@ -9,11 +9,18 @@ import { ComponentLoader } from 'app/components/ui/loader';
|
||||
|
||||
import ContextProvider from './ContextProvider';
|
||||
|
||||
import type { History } from 'history';
|
||||
|
||||
const SuccessOauthPage = React.lazy(() =>
|
||||
import(/* webpackChunkName: "page-oauth-success" */ 'app/pages/auth/SuccessOauthPage'),
|
||||
);
|
||||
|
||||
const App = ({ store, history }: { store: Store; history: History<any> }) => (
|
||||
interface Props {
|
||||
store: Store;
|
||||
history: History;
|
||||
}
|
||||
|
||||
const App: ComponentType<Props> = ({ store, history }) => (
|
||||
<ContextProvider store={store} history={history}>
|
||||
<React.Suspense fallback={<ComponentLoader />}>
|
||||
<Switch>
|
||||
|
@ -1,12 +1,19 @@
|
||||
import React from 'react';
|
||||
import React, { ComponentType } from 'react';
|
||||
import { Provider as ReduxProvider } from 'react-redux';
|
||||
import { Router } from 'react-router-dom';
|
||||
import { HelmetProvider } from 'react-helmet-async';
|
||||
import { History } from 'history';
|
||||
|
||||
import { IntlProvider } from 'app/components/i18n';
|
||||
import { Store } from 'app/reducers';
|
||||
|
||||
function ContextProvider({ children, store, history }: { children: React.ReactNode; store: Store; history: any }) {
|
||||
return (
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
store: Store;
|
||||
history: History;
|
||||
}
|
||||
|
||||
const ContextProvider: ComponentType<Props> = ({ children, store, history }) => (
|
||||
<HelmetProvider>
|
||||
<ReduxProvider store={store}>
|
||||
<IntlProvider>
|
||||
@ -14,7 +21,6 @@ function ContextProvider({ children, store, history }: { children: React.ReactNo
|
||||
</IntlProvider>
|
||||
</ReduxProvider>
|
||||
</HelmetProvider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default ContextProvider;
|
||||
|
@ -1,16 +1,26 @@
|
||||
import React from 'react';
|
||||
import React, { ComponentType } from 'react';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { DeepPartial } from 'utility-types';
|
||||
|
||||
import storeFactory from 'app/storeFactory';
|
||||
import { RootState } from 'app/reducers';
|
||||
|
||||
import ContextProvider from './ContextProvider';
|
||||
|
||||
type ContextProps = React.ComponentProps<typeof ContextProvider>;
|
||||
type NotOverriddenProps = Omit<React.ComponentProps<typeof ContextProvider>, 'store' | 'history'>;
|
||||
type Props = NotOverriddenProps & {
|
||||
state?: DeepPartial<RootState>;
|
||||
};
|
||||
|
||||
function TestContextProvider(props: Partial<ContextProps> & { children: ContextProps['children'] }) {
|
||||
const store = React.useMemo(storeFactory, []);
|
||||
const TestContextProvider: ComponentType<Props> = ({ state = {}, children, ...props }) => {
|
||||
const store = React.useMemo(() => storeFactory(state), []);
|
||||
const history = React.useMemo(createMemoryHistory, []);
|
||||
|
||||
return <ContextProvider store={store} history={history} {...props} />;
|
||||
}
|
||||
return (
|
||||
<ContextProvider store={store} history={history} {...props}>
|
||||
{children}
|
||||
</ContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default TestContextProvider;
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { createStore, applyMiddleware, compose } from 'redux';
|
||||
import { createStore, applyMiddleware, compose, StoreEnhancer } from 'redux';
|
||||
// midleware, который позволяет возвращать из экшенов функции
|
||||
// это полезно для работы с асинхронными действиями,
|
||||
// а также дает возможность проверить какие-либо условия перед запуском экшена
|
||||
@ -8,13 +8,13 @@ import persistState from 'redux-localstorage';
|
||||
|
||||
import reducers, { Store } from 'app/reducers';
|
||||
|
||||
export default function storeFactory(): Store {
|
||||
export default function storeFactory(preloadedState = {}): Store {
|
||||
const middlewares = applyMiddleware(thunk);
|
||||
const persistStateEnhancer = persistState(['accounts', 'user'], {
|
||||
key: 'redux-storage',
|
||||
});
|
||||
|
||||
let enhancer;
|
||||
let enhancer: StoreEnhancer;
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
enhancer = compose(middlewares, persistStateEnhancer);
|
||||
@ -23,7 +23,7 @@ export default function storeFactory(): Store {
|
||||
enhancer = composeEnhancers(middlewares, persistStateEnhancer);
|
||||
}
|
||||
|
||||
const store = createStore(reducers, {}, enhancer) as Store;
|
||||
const store = createStore(reducers, preloadedState, enhancer) as Store;
|
||||
|
||||
// Hot reload reducers
|
||||
if (module.hot && typeof module.hot.accept === 'function') {
|
||||
|
@ -35,7 +35,7 @@ describe('feedback popup', () => {
|
||||
cy.findByTestId('feedbackPopup').should('contain', 'Your message was received');
|
||||
cy.findByTestId('feedbackPopup').should('contain', account1.email);
|
||||
|
||||
cy.findByTestId('feedback-popup-close-button').click();
|
||||
cy.findByTestId('feedbackPopup').find('button').contains('Close').click();
|
||||
|
||||
cy.findByTestId('feedbackPopup').should('not.be.visible');
|
||||
});
|
||||
|
@ -1,5 +1,5 @@
|
||||
describe('Change locale', () => {
|
||||
it('should change locale from footer', () => {
|
||||
it('should change locale from the footer', () => {
|
||||
cy.visit('/');
|
||||
|
||||
cy.findByTestId('footer').contains('Site language').click();
|
||||
@ -7,7 +7,7 @@ describe('Change locale', () => {
|
||||
cy.findByTestId('language-switcher').should('be.visible');
|
||||
cy.findByTestId('language-switcher').should('have.attr', 'data-e2e-active-locale', 'en');
|
||||
|
||||
cy.findByTestId('language-list').contains('Belarusian').click();
|
||||
cy.findByTestId('languages-list-item').contains('Belarusian').click();
|
||||
|
||||
cy.findByTestId('language-switcher').should('not.be.visible');
|
||||
|
||||
@ -16,13 +16,13 @@ describe('Change locale', () => {
|
||||
cy.findByTestId('language-switcher').should('be.visible');
|
||||
cy.findByTestId('language-switcher').should('have.attr', 'data-e2e-active-locale', 'be');
|
||||
|
||||
cy.findByTestId('language-list').contains('English').click();
|
||||
cy.findByTestId('languages-list-item').contains('English').click();
|
||||
|
||||
cy.findByTestId('language-switcher').should('not.be.visible');
|
||||
cy.findByTestId('footer').should('contain', 'Site language');
|
||||
});
|
||||
|
||||
it('should change locale from profile', () => {
|
||||
it('should change locale from the profile', () => {
|
||||
cy.login({ accounts: ['default'] }).then(({ accounts: [account] }) => {
|
||||
cy.server();
|
||||
cy.route({
|
||||
@ -39,7 +39,7 @@ describe('Change locale', () => {
|
||||
cy.findByTestId('language-switcher').should('be.visible');
|
||||
cy.findByTestId('language-switcher').should('have.attr', 'data-e2e-active-locale', 'en');
|
||||
|
||||
cy.findByTestId('language-list').contains('Belarusian').click();
|
||||
cy.findByTestId('languages-list-item').contains('Belarusian').click();
|
||||
|
||||
cy.wait('@language').its('requestBody').should('eq', 'lang=be');
|
||||
|
||||
|
@ -15896,6 +15896,11 @@ utila@^0.4.0, utila@~0.4:
|
||||
resolved "https://registry.yarnpkg.com/utila/-/utila-0.4.0.tgz#8a16a05d445657a3aea5eecc5b12a4fa5379772c"
|
||||
integrity sha1-ihagXURWV6Oupe7MWxKk+lN5dyw=
|
||||
|
||||
utility-types@^3.10.0:
|
||||
version "3.10.0"
|
||||
resolved "https://registry.yarnpkg.com/utility-types/-/utility-types-3.10.0.tgz#ea4148f9a741015f05ed74fd615e1d20e6bed82b"
|
||||
integrity sha512-O11mqxmi7wMKCo6HKFt5AhO4BwY3VV68YU07tgxfz8zJTIxr4BpsezN49Ffwy9j3ZpwwJp4fkRwjRzq3uWE6Rg==
|
||||
|
||||
utils-merge@1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
|
||||
|
Loading…
Reference in New Issue
Block a user