mirror of
https://github.com/elyby/accounts-frontend.git
synced 2024-11-26 16:52:06 +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
|
CROWDIN_API_KEY=abc
|
||||||
|
|
||||||
|
@ -21,7 +21,7 @@ import {
|
|||||||
} from 'app/services/api/signup';
|
} from 'app/services/api/signup';
|
||||||
import dispatchBsod from 'app/components/ui/bsod/dispatchBsod';
|
import dispatchBsod from 'app/components/ui/bsod/dispatchBsod';
|
||||||
import { create as createPopup } from 'app/components/ui/popup/actions';
|
import { create as createPopup } from 'app/components/ui/popup/actions';
|
||||||
import ContactForm from 'app/components/contact/ContactForm';
|
import ContactForm from 'app/components/contact';
|
||||||
import { Account } from 'app/components/accounts/reducer';
|
import { Account } from 'app/components/accounts/reducer';
|
||||||
import { ThunkAction, Dispatch } from 'app/reducers';
|
import { ThunkAction, Dispatch } from 'app/reducers';
|
||||||
import { Resp } from 'app/services/request';
|
import { Resp } from 'app/services/request';
|
||||||
|
@ -2,11 +2,11 @@ import React from 'react';
|
|||||||
import expect from 'app/test/unexpected';
|
import expect from 'app/test/unexpected';
|
||||||
import sinon from 'sinon';
|
import sinon from 'sinon';
|
||||||
import { render, fireEvent, waitFor, screen } from '@testing-library/react';
|
import { render, fireEvent, waitFor, screen } from '@testing-library/react';
|
||||||
import feedback from 'app/services/api/feedback';
|
import * as feedback from 'app/services/api/feedback';
|
||||||
import { User } from 'app/components/user';
|
import { User } from 'app/components/user';
|
||||||
import { TestContextProvider } from 'app/shell';
|
import { TestContextProvider } from 'app/shell';
|
||||||
|
|
||||||
import { ContactForm } from './ContactForm';
|
import ContactForm from './ContactForm';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sinon.stub(feedback, 'send').returns(Promise.resolve() as any);
|
sinon.stub(feedback, 'send').returns(Promise.resolve() as any);
|
||||||
@ -18,11 +18,9 @@ afterEach(() => {
|
|||||||
|
|
||||||
describe('ContactForm', () => {
|
describe('ContactForm', () => {
|
||||||
it('should contain Form', () => {
|
it('should contain Form', () => {
|
||||||
const user = {} as User;
|
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<TestContextProvider>
|
<TestContextProvider>
|
||||||
<ContactForm user={user} />
|
<ContactForm />
|
||||||
</TestContextProvider>,
|
</TestContextProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -49,14 +47,14 @@ describe('ContactForm', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('when rendered with user', () => {
|
describe('when rendered with user', () => {
|
||||||
const user = {
|
const user: Pick<User, 'email'> = {
|
||||||
email: 'foo@bar.com',
|
email: 'foo@bar.com',
|
||||||
} as User;
|
};
|
||||||
|
|
||||||
it('should render email field with user email', () => {
|
it('should render email field with user email', () => {
|
||||||
render(
|
render(
|
||||||
<TestContextProvider>
|
<TestContextProvider state={{ user }}>
|
||||||
<ContactForm user={user} />
|
<ContactForm />
|
||||||
</TestContextProvider>,
|
</TestContextProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -65,13 +63,13 @@ describe('ContactForm', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should submit and then hide form and display success message', async () => {
|
it('should submit and then hide form and display success message', async () => {
|
||||||
const user = {
|
const user: Pick<User, 'email'> = {
|
||||||
email: 'foo@bar.com',
|
email: 'foo@bar.com',
|
||||||
} as User;
|
};
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<TestContextProvider>
|
<TestContextProvider state={{ user }}>
|
||||||
<ContactForm user={user} />
|
<ContactForm />
|
||||||
</TestContextProvider>,
|
</TestContextProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -113,9 +111,9 @@ describe('ContactForm', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should show validation messages', async () => {
|
it('should show validation messages', async () => {
|
||||||
const user = {
|
const user: Pick<User, 'email'> = {
|
||||||
email: 'foo@bar.com',
|
email: 'foo@bar.com',
|
||||||
} as User;
|
};
|
||||||
|
|
||||||
(feedback.send as any).callsFake(() =>
|
(feedback.send as any).callsFake(() =>
|
||||||
Promise.reject({
|
Promise.reject({
|
||||||
@ -125,8 +123,8 @@ describe('ContactForm', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<TestContextProvider>
|
<TestContextProvider state={{ user }}>
|
||||||
<ContactForm user={user} />
|
<ContactForm />
|
||||||
</TestContextProvider>,
|
</TestContextProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -1,202 +1,43 @@
|
|||||||
import React from 'react';
|
import React, { ComponentType, useCallback, useRef, useState } from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import clsx from 'clsx';
|
|
||||||
import { FormattedMessage as Message, defineMessages } from 'react-intl';
|
import { send as sendFeedback } from 'app/services/api/feedback';
|
||||||
import { Input, TextArea, Button, Form, FormModel, Dropdown } from 'app/components/ui/form';
|
|
||||||
import feedback from 'app/services/api/feedback';
|
|
||||||
import icons from 'app/components/ui/icons.scss';
|
|
||||||
import popupStyles from 'app/components/ui/popup/popup.scss';
|
|
||||||
import { RootState } from 'app/reducers';
|
import { RootState } from 'app/reducers';
|
||||||
import logger from 'app/services/logger';
|
import logger from 'app/services/logger';
|
||||||
import { User } from 'app/components/user';
|
|
||||||
|
|
||||||
import styles from './contactForm.scss';
|
import ContactFormPopup from './ContactFormPopup';
|
||||||
|
import SuccessContactFormPopup from './SuccessContactFormPopup';
|
||||||
|
|
||||||
const CONTACT_CATEGORIES = {
|
interface Props {
|
||||||
// TODO: сюда позже проставить реальные id категорий с backend
|
onClose?: () => void;
|
||||||
0: <Message key="cannotAccessMyAccount" defaultMessage="Can not access my account" />,
|
|
||||||
1: <Message key="foundBugOnSite" defaultMessage="I found a bug on the site" />,
|
|
||||||
2: <Message key="improvementsSuggestion" defaultMessage="I have a suggestion for improving the functional" />,
|
|
||||||
3: <Message key="integrationQuestion" defaultMessage="Service integration question" />,
|
|
||||||
4: <Message key="other" defaultMessage="Other" />,
|
|
||||||
};
|
|
||||||
|
|
||||||
const labels = defineMessages({
|
|
||||||
subject: 'Subject',
|
|
||||||
email: '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 ContactForm: ComponentType<Props> = ({ onClose }) => {
|
||||||
const { form } = this;
|
const userEmail = useSelector((state: RootState) => state.user.email);
|
||||||
const { user } = this.props;
|
const usedEmailRef = useRef(userEmail); // Use ref to avoid unneeded redraw
|
||||||
const { isLoading } = this.state;
|
const [isSent, setIsSent] = useState<boolean>(false);
|
||||||
|
const onSubmit = useCallback(
|
||||||
return (
|
(params: Parameters<typeof sendFeedback>[0]): Promise<void> =>
|
||||||
<Form form={form} onSubmit={this.onSubmit} isLoading={isLoading}>
|
sendFeedback(params)
|
||||||
<div className={popupStyles.body}>
|
.then(() => {
|
||||||
<div className={styles.philosophicalThought}>
|
setIsSent(true);
|
||||||
<Message
|
usedEmailRef.current = params.email;
|
||||||
key="philosophicalThought"
|
|
||||||
defaultMessage="Properly formulated question — half of the answer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.formDisclaimer}>
|
|
||||||
<Message
|
|
||||||
key="disclaimer"
|
|
||||||
defaultMessage="Please formulate your feedback providing as much useful information, as possible to help us understand your problem and solve it"
|
|
||||||
/>
|
|
||||||
<br />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.pairInputRow}>
|
|
||||||
<div className={styles.pairInput}>
|
|
||||||
<Input {...form.bindField('subject')} required label={labels.subject} skin="light" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.pairInput}>
|
|
||||||
<Input
|
|
||||||
{...form.bindField('email')}
|
|
||||||
required
|
|
||||||
label={labels.email}
|
|
||||||
type="email"
|
|
||||||
skin="light"
|
|
||||||
defaultValue={user.email}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.formMargin}>
|
|
||||||
<Dropdown
|
|
||||||
{...form.bindField('category')}
|
|
||||||
label={labels.whichQuestion}
|
|
||||||
items={CONTACT_CATEGORIES}
|
|
||||||
block
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TextArea
|
|
||||||
{...form.bindField('message')}
|
|
||||||
required
|
|
||||||
label={labels.message}
|
|
||||||
skin="light"
|
|
||||||
minRows={6}
|
|
||||||
maxRows={6}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.footer}>
|
|
||||||
<Button label={labels.send} block type="submit" disabled={isLoading} />
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
renderSuccess() {
|
|
||||||
const { lastEmail: email } = this.state;
|
|
||||||
const { onClose } = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className={styles.successBody}>
|
|
||||||
<span className={styles.successIcon} />
|
|
||||||
<div className={styles.successDescription}>
|
|
||||||
<Message
|
|
||||||
key="youMessageReceived"
|
|
||||||
defaultMessage="Your message was received. We will respond to you shortly. The answer will come to your 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 }));
|
.catch((resp) => {
|
||||||
};
|
if (!resp.errors) {
|
||||||
|
logger.warn('Error sending feedback', resp);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect((state: RootState) => ({
|
throw resp;
|
||||||
user: state.user,
|
}),
|
||||||
}))(ContactForm);
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return isSent ? (
|
||||||
|
<SuccessContactFormPopup email={usedEmailRef.current} onClose={onClose} />
|
||||||
|
) : (
|
||||||
|
<ContactFormPopup initEmail={userEmail} onSubmit={onSubmit} onClose={onClose} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContactForm;
|
||||||
|
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 React from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { create as createPopup } from 'app/components/ui/popup/actions';
|
import { create as createPopup } from 'app/components/ui/popup/actions';
|
||||||
import ContactForm from './ContactForm';
|
import ContactForm from 'app/components/contact';
|
||||||
|
|
||||||
type Props = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
|
type Props = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
|
||||||
createContactPopup: () => void;
|
createContactPopup: () => void;
|
||||||
|
@ -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 */
|
/* Form state */
|
||||||
|
|
||||||
.contactForm {
|
.contactFormBoundings {
|
||||||
composes: popupWrapper from '~app/components/ui/popup/popup.scss';
|
|
||||||
|
|
||||||
@include popupBounding(500px);
|
@include popupBounding(500px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
padding: $popupPadding;
|
||||||
|
}
|
||||||
|
|
||||||
.philosophicalThought {
|
.philosophicalThought {
|
||||||
font-family: $font-family-title;
|
font-family: $font-family-title;
|
||||||
font-size: 19px;
|
font-size: 19px;
|
||||||
@ -45,14 +47,12 @@
|
|||||||
|
|
||||||
/* Success State */
|
/* Success State */
|
||||||
|
|
||||||
.successState {
|
.successStateBoundings {
|
||||||
composes: popupWrapper from '~app/components/ui/popup/popup.scss';
|
|
||||||
|
|
||||||
@include popupBounding(320px);
|
@include popupBounding(320px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.successBody {
|
.successBody {
|
||||||
composes: body from '~app/components/ui/popup/popup.scss';
|
composes: body;
|
||||||
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@ -61,6 +61,7 @@
|
|||||||
@extend .formDisclaimer;
|
@extend .formDisclaimer;
|
||||||
|
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
|
padding: 0 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.successIcon {
|
.successIcon {
|
||||||
@ -77,9 +78,3 @@
|
|||||||
color: #444;
|
color: #444;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Common */
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
|
@ -1 +1,2 @@
|
|||||||
|
export { default } from './ContactForm';
|
||||||
export { default as ContactLink } from './ContactLink';
|
export { default as ContactLink } from './ContactLink';
|
||||||
|
@ -1,181 +1,37 @@
|
|||||||
import React from 'react';
|
import React, { ComponentType, useCallback } from 'react';
|
||||||
import { FormattedMessage as Message, injectIntl, IntlShape } from 'react-intl';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import clsx from 'clsx';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { changeLang } from 'app/components/user/actions';
|
|
||||||
import LANGS from 'app/i18n';
|
|
||||||
import formStyles from 'app/components/ui/form/form.scss';
|
|
||||||
import popupStyles from 'app/components/ui/popup/popup.scss';
|
|
||||||
import icons from 'app/components/ui/icons.scss';
|
|
||||||
|
|
||||||
import styles from './languageSwitcher.scss';
|
import LOCALES from 'app/i18n';
|
||||||
import LanguageList from './LanguageList';
|
import { changeLang } from 'app/components/user/actions';
|
||||||
import { RootState } from 'app/reducers';
|
import { RootState } from 'app/reducers';
|
||||||
|
|
||||||
const translateUrl = 'http://ely.by/translate';
|
import LanguageSwitcherPopup from './LanguageSwitcherPopup';
|
||||||
|
|
||||||
export interface LocaleData {
|
type Props = {
|
||||||
code: string;
|
onClose?: () => void;
|
||||||
name: string;
|
|
||||||
englishName: string;
|
|
||||||
progress: number;
|
|
||||||
isReleased: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type LocalesMap = Record<string, LocaleData>;
|
|
||||||
|
|
||||||
type OwnProps = {
|
|
||||||
onClose: () => void;
|
|
||||||
langs: LocalesMap;
|
|
||||||
emptyCaptions: Array<{
|
|
||||||
src: string;
|
|
||||||
caption: string;
|
|
||||||
}>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Props extends OwnProps {
|
const LanguageSwitcher: ComponentType<Props> = ({ onClose = () => {} }) => {
|
||||||
intl: IntlShape;
|
const selectedLocale = useSelector((state: RootState) => state.i18n.locale);
|
||||||
selectedLocale: string;
|
const dispatch = useDispatch();
|
||||||
changeLang: (lang: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
class LanguageSwitcher extends React.Component<
|
const onChangeLang = useCallback(
|
||||||
Props,
|
(lang: string) => {
|
||||||
{
|
dispatch(changeLang(lang));
|
||||||
filter: string;
|
// TODO: await language change and close popup, but not earlier than after 300ms
|
||||||
filteredLangs: LocalesMap;
|
setTimeout(onClose, 300);
|
||||||
}
|
},
|
||||||
> {
|
[dispatch, onClose],
|
||||||
state = {
|
);
|
||||||
filter: '',
|
|
||||||
filteredLangs: this.props.langs,
|
|
||||||
};
|
|
||||||
|
|
||||||
static defaultProps = {
|
|
||||||
langs: LANGS,
|
|
||||||
onClose() {},
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { selectedLocale, onClose, intl } = this.props;
|
|
||||||
const { filteredLangs } = this.state;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<LanguageSwitcherPopup
|
||||||
className={styles.languageSwitcher}
|
locales={LOCALES}
|
||||||
data-testid="language-switcher"
|
activeLocale={selectedLocale}
|
||||||
data-e2e-active-locale={selectedLocale}
|
onSelect={onChangeLang}
|
||||||
>
|
onClose={onClose}
|
||||||
<div className={popupStyles.popup}>
|
|
||||||
<div className={popupStyles.header}>
|
|
||||||
<h2 className={popupStyles.headerTitle}>
|
|
||||||
<Message key="siteLanguage" defaultMessage="Site language" />
|
|
||||||
</h2>
|
|
||||||
<span className={clsx(icons.close, popupStyles.close)} onClick={onClose} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.languageSwitcherBody}>
|
|
||||||
<div className={styles.searchBox}>
|
|
||||||
<input
|
|
||||||
className={clsx(formStyles.lightTextField, formStyles.greenTextField)}
|
|
||||||
placeholder={intl.formatMessage({
|
|
||||||
key: 'startTyping',
|
|
||||||
defaultMessage: 'Start typing…',
|
|
||||||
})}
|
|
||||||
onChange={this.onFilterUpdate}
|
|
||||||
onKeyPress={this.onFilterKeyPress()}
|
|
||||||
autoFocus
|
|
||||||
/>
|
/>
|
||||||
<span className={styles.searchIcon} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<LanguageList
|
|
||||||
selectedLocale={selectedLocale}
|
|
||||||
langs={filteredLangs}
|
|
||||||
onChangeLang={this.onChangeLang}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className={styles.improveTranslates}>
|
|
||||||
<div className={styles.improveTranslatesIcon} />
|
|
||||||
<div className={styles.improveTranslatesContent}>
|
|
||||||
<div className={styles.improveTranslatesTitle}>
|
|
||||||
<Message key="improveTranslates" defaultMessage="Improve Ely.by translation" />
|
|
||||||
</div>
|
|
||||||
<div className={styles.improveTranslatesText}>
|
|
||||||
<Message
|
|
||||||
key="improveTranslatesDescription"
|
|
||||||
defaultMessage="Ely.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() {
|
export default LanguageSwitcher;
|
||||||
return (event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
if (event.key !== 'Enter' || this.state.filter === '') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const locales = Object.keys(this.state.filteredLangs);
|
|
||||||
|
|
||||||
if (locales.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.changeLang(locales[0]);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default injectIntl(
|
|
||||||
connect(
|
|
||||||
(state: RootState) => ({
|
|
||||||
selectedLocale: state.i18n.locale,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
changeLang,
|
|
||||||
},
|
|
||||||
)(LanguageSwitcher),
|
|
||||||
);
|
|
||||||
|
@ -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 clsx from 'clsx';
|
||||||
|
|
||||||
import LocaleItem from './LocaleItem';
|
import LocaleItem from './LocaleItem';
|
||||||
import { LocalesMap } from './LanguageSwitcher';
|
import { LocalesMap } from './LanguageSwitcherPopup';
|
||||||
import styles from './languageSwitcher.scss';
|
import styles from './languageSwitcher.scss';
|
||||||
|
|
||||||
import thatFuckingPumpkin from './images/that_fucking_pumpkin.svg';
|
import thatFuckingPumpkin from './images/that_fucking_pumpkin.svg';
|
||||||
@ -50,17 +50,21 @@ const emptyCaptions: ReadonlyArray<EmptyCaption> = [
|
|||||||
|
|
||||||
const itemHeight = 51;
|
const itemHeight = 51;
|
||||||
|
|
||||||
export default class LanguageList extends React.Component<{
|
export default class LanguagesList extends React.Component<{
|
||||||
|
locales: LocalesMap;
|
||||||
selectedLocale: string;
|
selectedLocale: string;
|
||||||
langs: LocalesMap;
|
onChangeLang?: (lang: string) => void;
|
||||||
onChangeLang: (lang: string) => void;
|
|
||||||
}> {
|
}> {
|
||||||
emptyListStateInner: HTMLDivElement | null;
|
emptyListStateInner: HTMLDivElement | null;
|
||||||
|
|
||||||
|
static defaultProps = {
|
||||||
|
onChangeLang: (): void => {},
|
||||||
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { selectedLocale, langs } = this.props;
|
const { selectedLocale, locales } = this.props;
|
||||||
const isListEmpty = Object.keys(langs).length === 0;
|
const isListEmpty = Object.keys(locales).length === 0;
|
||||||
const firstLocale = Object.keys(langs)[0] || null;
|
const firstLocale = Object.keys(locales)[0] || null;
|
||||||
const emptyCaption = this.getEmptyCaption();
|
const emptyCaption = this.getEmptyCaption();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -71,7 +75,7 @@ export default class LanguageList extends React.Component<{
|
|||||||
willEnter={this.willEnter}
|
willEnter={this.willEnter}
|
||||||
>
|
>
|
||||||
{(items) => (
|
{(items) => (
|
||||||
<div className={styles.languagesList} data-testid="language-list">
|
<div className={styles.languagesList} data-testid="languages-list-item">
|
||||||
<div
|
<div
|
||||||
className={clsx(styles.emptyLanguagesListWrapper, {
|
className={clsx(styles.emptyLanguagesListWrapper, {
|
||||||
[styles.emptyLanguagesListVisible]: isListEmpty,
|
[styles.emptyLanguagesListVisible]: isListEmpty,
|
||||||
@ -126,17 +130,18 @@ export default class LanguageList extends React.Component<{
|
|||||||
return (event) => {
|
return (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
|
// @ts-ignore has defaultProps value
|
||||||
this.props.onChangeLang(lang);
|
this.props.onChangeLang(lang);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
getItemsWithDefaultStyles = (): Array<TransitionPlainStyle> => {
|
getItemsWithDefaultStyles = (): Array<TransitionPlainStyle> => {
|
||||||
return Object.keys({ ...this.props.langs }).reduce(
|
return Object.keys({ ...this.props.locales }).reduce(
|
||||||
(previous, key) => [
|
(previous, key) => [
|
||||||
...previous,
|
...previous,
|
||||||
{
|
{
|
||||||
key,
|
key,
|
||||||
data: this.props.langs[key],
|
data: this.props.locales[key],
|
||||||
style: {
|
style: {
|
||||||
height: itemHeight,
|
height: itemHeight,
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
@ -148,12 +153,12 @@ export default class LanguageList extends React.Component<{
|
|||||||
};
|
};
|
||||||
|
|
||||||
getItemsWithStyles = (): Array<TransitionStyle> => {
|
getItemsWithStyles = (): Array<TransitionStyle> => {
|
||||||
return Object.keys({ ...this.props.langs }).reduce(
|
return Object.keys({ ...this.props.locales }).reduce(
|
||||||
(previous, key) => [
|
(previous, key) => [
|
||||||
...previous,
|
...previous,
|
||||||
{
|
{
|
||||||
key,
|
key,
|
||||||
data: this.props.langs[key],
|
data: this.props.locales[key],
|
||||||
style: {
|
style: {
|
||||||
height: spring(itemHeight, presets.gentle),
|
height: spring(itemHeight, presets.gentle),
|
||||||
opacity: spring(1, presets.gentle),
|
opacity: spring(1, presets.gentle),
|
@ -3,7 +3,7 @@ import { localeFlags } from 'app/components/i18n';
|
|||||||
import { FormattedMessage as Message } from 'react-intl';
|
import { FormattedMessage as Message } from 'react-intl';
|
||||||
|
|
||||||
import styles from './languageSwitcher.scss';
|
import styles from './languageSwitcher.scss';
|
||||||
import { LocaleData } from './LanguageSwitcher';
|
import { LocaleData } from './LanguageSwitcherPopup';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
locale: LocaleData;
|
locale: LocaleData;
|
||||||
|
@ -8,17 +8,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.languageSwitcher {
|
.boundings {
|
||||||
composes: popupWrapper from '~app/components/ui/popup/popup.scss';
|
|
||||||
|
|
||||||
@include popupBounding(400px);
|
@include popupBounding(400px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.languageSwitcherBody {
|
.body {
|
||||||
composes: body from '~app/components/ui/popup/popup.scss';
|
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
padding: $popupPadding;
|
||||||
max-height: calc(100vh - 132px);
|
max-height: calc(100vh - 132px);
|
||||||
|
|
||||||
@media screen and (min-height: 630px) {
|
@media screen and (min-height: 630px) {
|
||||||
|
@ -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 React, { ComponentType } from 'react';
|
||||||
import { defineMessages, FormattedMessage as Message } from 'react-intl';
|
import { FormattedMessage as Message } from 'react-intl';
|
||||||
import clsx from 'clsx';
|
|
||||||
|
|
||||||
|
import Popup from 'app/components/ui/popup';
|
||||||
import { Form, Button, Input, FormModel } from 'app/components/ui/form';
|
import { Form, Button, Input, FormModel } from 'app/components/ui/form';
|
||||||
import popupStyles from 'app/components/ui/popup/popup.scss';
|
|
||||||
|
|
||||||
import styles from './passwordRequestForm.scss';
|
import styles from './passwordRequestForm.scss';
|
||||||
|
|
||||||
const labels = defineMessages({
|
|
||||||
continue: 'Continue',
|
|
||||||
});
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
form: FormModel;
|
form: FormModel;
|
||||||
onSubmit: (form: FormModel) => void;
|
onSubmit?: (form: FormModel) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PasswordRequestForm: ComponentType<Props> = ({ form, onSubmit }) => (
|
const PasswordRequestForm: ComponentType<Props> = ({ form, onSubmit }) => (
|
||||||
<div className={styles.requestPasswordForm} data-testid="password-request-form">
|
<Popup
|
||||||
<div className={popupStyles.popup}>
|
title={<Message key="title" defaultMessage="Confirm your action" />}
|
||||||
|
wrapperClassName={styles.boundings}
|
||||||
|
isClosable={false}
|
||||||
|
data-testid="password-request-form"
|
||||||
|
>
|
||||||
<Form onSubmit={onSubmit} form={form}>
|
<Form onSubmit={onSubmit} form={form}>
|
||||||
<div className={popupStyles.header}>
|
<div className={styles.body}>
|
||||||
<h2 className={popupStyles.headerTitle}>
|
|
||||||
<Message key="title" defaultMessage="Confirm your action" />
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={clsx(popupStyles.body, styles.body)}>
|
|
||||||
<span className={styles.lockIcon} />
|
<span className={styles.lockIcon} />
|
||||||
|
|
||||||
<div className={styles.description}>
|
<div className={styles.description}>
|
||||||
@ -43,10 +36,10 @@ const PasswordRequestForm: ComponentType<Props> = ({ form, onSubmit }) => (
|
|||||||
center
|
center
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button color="green" label={labels.continue} block type="submit" />
|
|
||||||
|
<Button type="submit" color="green" label={<Message key="continue" defaultMessage="Continue" />} block />
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</Popup>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export default PasswordRequestForm;
|
export default PasswordRequestForm;
|
||||||
|
@ -0,0 +1 @@
|
|||||||
|
export { default } from './PasswordRequestForm';
|
@ -1,12 +1,11 @@
|
|||||||
@import '~app/components/ui/popup/popup.scss';
|
@import '~app/components/ui/popup/popup.scss';
|
||||||
|
|
||||||
.requestPasswordForm {
|
.boundings {
|
||||||
composes: popupWrapper from '~app/components/ui/popup/popup.scss';
|
|
||||||
|
|
||||||
@include popupBounding(280px);
|
@include popupBounding(280px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.body {
|
.body {
|
||||||
|
padding: $popupPadding;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -14,12 +14,12 @@ interface BaseProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface PropsWithoutForm extends BaseProps {
|
interface PropsWithoutForm extends BaseProps {
|
||||||
onSubmit: (form: FormData) => Promise<void> | void;
|
onSubmit?: (form: FormData) => Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PropsWithForm extends BaseProps {
|
interface PropsWithForm extends BaseProps {
|
||||||
form: FormModel;
|
form: FormModel;
|
||||||
onSubmit: (form: FormModel) => Promise<void> | void;
|
onSubmit?: (form: FormModel) => Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = PropsWithoutForm | PropsWithForm;
|
type Props = PropsWithoutForm | PropsWithForm;
|
||||||
@ -134,8 +134,10 @@ export default class Form extends React.Component<Props, State> {
|
|||||||
let result: Promise<void> | void;
|
let result: Promise<void> | void;
|
||||||
|
|
||||||
if (hasForm(this.props)) {
|
if (hasForm(this.props)) {
|
||||||
|
// @ts-ignore this prop has default value
|
||||||
result = this.props.onSubmit(this.props.form);
|
result = this.props.onSubmit(this.props.form);
|
||||||
} else {
|
} else {
|
||||||
|
// @ts-ignore this prop has default value
|
||||||
result = this.props.onSubmit(new FormData(form));
|
result = this.props.onSubmit(new FormData(form));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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');
|
const event = new Event('keyup');
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
event.which = 27;
|
event.code = 'Escape';
|
||||||
document.dispatchEvent(event);
|
document.dispatchEvent(event);
|
||||||
|
|
||||||
uxpect(props.destroy, 'was called once');
|
uxpect(props.destroy, 'was called once');
|
||||||
@ -135,7 +135,7 @@ describe('<PopupStack />', () => {
|
|||||||
|
|
||||||
const event = new Event('keyup');
|
const event = new Event('keyup');
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
event.which = 27;
|
event.code = 'Escape';
|
||||||
document.dispatchEvent(event);
|
document.dispatchEvent(event);
|
||||||
|
|
||||||
uxpect(props.destroy, 'was called once');
|
uxpect(props.destroy, 'was called once');
|
||||||
@ -157,7 +157,7 @@ describe('<PopupStack />', () => {
|
|||||||
|
|
||||||
const event = new Event('keyup');
|
const event = new Event('keyup');
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
event.which = 27;
|
event.code = 'Escape';
|
||||||
document.dispatchEvent(event);
|
document.dispatchEvent(event);
|
||||||
|
|
||||||
uxpect(props.destroy, 'was not called');
|
uxpect(props.destroy, 'was not called');
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { ReactNode } from 'react';
|
||||||
import { TransitionGroup, CSSTransition } from 'react-transition-group';
|
import { TransitionGroup, CSSTransition } from 'react-transition-group';
|
||||||
import { browserHistory } from 'app/services/history';
|
import { browserHistory } from 'app/services/history';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
@ -17,17 +17,17 @@ interface Props {
|
|||||||
export class PopupStack extends React.Component<Props> {
|
export class PopupStack extends React.Component<Props> {
|
||||||
unlistenTransition: () => void;
|
unlistenTransition: () => void;
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount(): void {
|
||||||
document.addEventListener('keyup', this.onKeyPress);
|
document.addEventListener('keyup', this.onKeyPress);
|
||||||
this.unlistenTransition = browserHistory.listen(this.onRouteLeave);
|
this.unlistenTransition = browserHistory.listen(this.onRouteLeave);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount(): void {
|
||||||
document.removeEventListener('keyup', this.onKeyPress);
|
document.removeEventListener('keyup', this.onKeyPress);
|
||||||
this.unlistenTransition();
|
this.unlistenTransition();
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render(): ReactNode {
|
||||||
const { popups } = this.props;
|
const { popups } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -57,11 +57,11 @@ export class PopupStack extends React.Component<Props> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onClose(popup: PopupConfig) {
|
onClose(popup: PopupConfig) {
|
||||||
return () => this.props.destroy(popup);
|
return (): void => this.props.destroy(popup);
|
||||||
}
|
}
|
||||||
|
|
||||||
onOverlayClick(popup: PopupConfig) {
|
onOverlayClick(popup: PopupConfig) {
|
||||||
return (event: React.MouseEvent<HTMLDivElement>) => {
|
return (event: React.MouseEvent<HTMLDivElement>): void => {
|
||||||
if (event.target !== event.currentTarget || popup.disableOverlayClose) {
|
if (event.target !== event.currentTarget || popup.disableOverlayClose) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -72,7 +72,7 @@ export class PopupStack extends React.Component<Props> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
popStack() {
|
popStack(): void {
|
||||||
const [popup] = this.props.popups.slice(-1);
|
const [popup] = this.props.popups.slice(-1);
|
||||||
|
|
||||||
if (popup && !popup.disableOverlayClose) {
|
if (popup && !popup.disableOverlayClose) {
|
||||||
@ -80,14 +80,14 @@ export class PopupStack extends React.Component<Props> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onKeyPress = (event: KeyboardEvent) => {
|
onKeyPress = (event: KeyboardEvent): void => {
|
||||||
if (event.which === 27) {
|
if (event.code === 'Escape') {
|
||||||
// ESC key
|
// ESC key
|
||||||
this.popStack();
|
this.popStack();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onRouteLeave = (nextLocation: Location) => {
|
onRouteLeave = (nextLocation: Location): void => {
|
||||||
if (nextLocation) {
|
if (nextLocation) {
|
||||||
this.popStack();
|
this.popStack();
|
||||||
}
|
}
|
||||||
|
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/colors.scss';
|
||||||
@import '~app/components/ui/fonts.scss';
|
@import '~app/components/ui/fonts.scss';
|
||||||
|
|
||||||
$popupPadding: 20px; // Отступ контента внутри попапа
|
$popupPadding: 20px; // Default content padding
|
||||||
$popupMargin: 20px; // Отступ попапа от краёв окна
|
$popupMargin: 20px; // Outer popup margins
|
||||||
|
|
||||||
@mixin popupBounding($width, $padding: null) {
|
@mixin popupBounding($width, $padding: null) {
|
||||||
@if ($padding == null) {
|
@if ($padding == null) {
|
||||||
@ -88,10 +88,12 @@ $popupMargin: 20px; // Отступ попапа от краёв окна
|
|||||||
}
|
}
|
||||||
|
|
||||||
.body {
|
.body {
|
||||||
padding: $popupPadding;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.close {
|
.close {
|
||||||
|
composes: close from '~app/components/ui/icons.scss';
|
||||||
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
@ -107,13 +109,20 @@ $popupMargin: 20px; // Отступ попапа от краёв окна
|
|||||||
color: rgba(#000, 0.6);
|
color: rgba(#000, 0.6);
|
||||||
background: rgba(#fff, 0.75);
|
background: rgba(#fff, 0.75);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 655px) {
|
@media (min-width: 655px) {
|
||||||
.close {
|
|
||||||
position: fixed;
|
position: fixed;
|
||||||
padding: 35px;
|
padding: 35px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.trEnter &,
|
||||||
|
.trExit & {
|
||||||
|
// don't show the close during transition, because transform forces "position: fixed"
|
||||||
|
// to layout relative container, instead of body
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(100%);
|
||||||
|
transition: 0s;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -161,14 +170,3 @@ $popupInitPosition: translateY(10%) rotateX(-8deg);
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.trEnter,
|
|
||||||
.trExit {
|
|
||||||
.close {
|
|
||||||
// do not show close during transition, because transform forces position: fixed
|
|
||||||
// to layout relative container, instead of body
|
|
||||||
opacity: 0;
|
|
||||||
transform: translate(100%);
|
|
||||||
transition: 0s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -45,6 +45,7 @@
|
|||||||
"@types/react-motion": "^0.0.29",
|
"@types/react-motion": "^0.0.29",
|
||||||
"@types/react-transition-group": "^4.2.4",
|
"@types/react-transition-group": "^4.2.4",
|
||||||
"@types/webfontloader": "^1.6.30",
|
"@types/webfontloader": "^1.6.30",
|
||||||
"@types/webpack-env": "^1.15.2"
|
"@types/webpack-env": "^1.15.2",
|
||||||
|
"utility-types": "^3.10.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,7 @@ import { Route, Switch, Redirect } from 'react-router-dom';
|
|||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { refreshUserData } from 'app/components/accounts/actions';
|
import { refreshUserData } from 'app/components/accounts/actions';
|
||||||
import { create as createPopup } from 'app/components/ui/popup/actions';
|
import { create as createPopup } from 'app/components/ui/popup/actions';
|
||||||
import PasswordRequestForm from 'app/components/profile/passwordRequestForm/PasswordRequestForm';
|
import PasswordRequestForm from 'app/components/profile/passwordRequestForm';
|
||||||
import logger from 'app/services/logger';
|
import logger from 'app/services/logger';
|
||||||
import { browserHistory } from 'app/services/history';
|
import { browserHistory } from 'app/services/history';
|
||||||
import { FooterMenu } from 'app/components/footerMenu';
|
import { FooterMenu } from 'app/components/footerMenu';
|
||||||
@ -163,6 +163,7 @@ export default connect(
|
|||||||
|
|
||||||
return <PasswordRequestForm form={form} onSubmit={onSubmit} />;
|
return <PasswordRequestForm form={form} onSubmit={onSubmit} />;
|
||||||
},
|
},
|
||||||
|
// TODO: this property should be automatically extracted from the popup's isClosable prop
|
||||||
disableOverlayClose: true,
|
disableOverlayClose: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
@ -10,7 +10,7 @@ import { ScrollIntoView } from 'app/components/ui/scroll';
|
|||||||
import PrivateRoute from 'app/containers/PrivateRoute';
|
import PrivateRoute from 'app/containers/PrivateRoute';
|
||||||
import AuthFlowRoute from 'app/containers/AuthFlowRoute';
|
import AuthFlowRoute from 'app/containers/AuthFlowRoute';
|
||||||
import Userbar from 'app/components/userbar/Userbar';
|
import Userbar from 'app/components/userbar/Userbar';
|
||||||
import PopupStack from 'app/components/ui/popup/PopupStack';
|
import { PopupStack } from 'app/components/ui/popup';
|
||||||
import * as loader from 'app/services/loader';
|
import * as loader from 'app/services/loader';
|
||||||
import { getActiveAccount } from 'app/components/accounts/reducer';
|
import { getActiveAccount } from 'app/components/accounts/reducer';
|
||||||
import { User } from 'app/components/user';
|
import { User } from 'app/components/user';
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import request from 'app/services/request';
|
import request from 'app/services/request';
|
||||||
|
|
||||||
export default {
|
interface SendFeedbackParams {
|
||||||
send({ subject = '', email = '', message = '', category = '' }) {
|
subject: string;
|
||||||
return request.post('/api/feedback', {
|
email: string;
|
||||||
subject,
|
message: string;
|
||||||
email,
|
category: string | number;
|
||||||
message,
|
}
|
||||||
category,
|
|
||||||
});
|
export function send(params: SendFeedbackParams) {
|
||||||
},
|
return request.post('/api/feedback', params);
|
||||||
};
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { hot } from 'react-hot-loader/root';
|
import { hot } from 'react-hot-loader/root';
|
||||||
import React from 'react';
|
import React, { ComponentType } from 'react';
|
||||||
import { Route, Switch } from 'react-router-dom';
|
import { Route, Switch } from 'react-router-dom';
|
||||||
import { History } from 'history';
|
|
||||||
import { Store } from 'app/reducers';
|
import { Store } from 'app/reducers';
|
||||||
import AuthFlowRoute from 'app/containers/AuthFlowRoute';
|
import AuthFlowRoute from 'app/containers/AuthFlowRoute';
|
||||||
import RootPage from 'app/pages/root/RootPage';
|
import RootPage from 'app/pages/root/RootPage';
|
||||||
@ -9,11 +9,18 @@ import { ComponentLoader } from 'app/components/ui/loader';
|
|||||||
|
|
||||||
import ContextProvider from './ContextProvider';
|
import ContextProvider from './ContextProvider';
|
||||||
|
|
||||||
|
import type { History } from 'history';
|
||||||
|
|
||||||
const SuccessOauthPage = React.lazy(() =>
|
const SuccessOauthPage = React.lazy(() =>
|
||||||
import(/* webpackChunkName: "page-oauth-success" */ 'app/pages/auth/SuccessOauthPage'),
|
import(/* webpackChunkName: "page-oauth-success" */ 'app/pages/auth/SuccessOauthPage'),
|
||||||
);
|
);
|
||||||
|
|
||||||
const App = ({ store, history }: { store: Store; history: History<any> }) => (
|
interface Props {
|
||||||
|
store: Store;
|
||||||
|
history: History;
|
||||||
|
}
|
||||||
|
|
||||||
|
const App: ComponentType<Props> = ({ store, history }) => (
|
||||||
<ContextProvider store={store} history={history}>
|
<ContextProvider store={store} history={history}>
|
||||||
<React.Suspense fallback={<ComponentLoader />}>
|
<React.Suspense fallback={<ComponentLoader />}>
|
||||||
<Switch>
|
<Switch>
|
||||||
|
@ -1,12 +1,19 @@
|
|||||||
import React from 'react';
|
import React, { ComponentType } from 'react';
|
||||||
import { Provider as ReduxProvider } from 'react-redux';
|
import { Provider as ReduxProvider } from 'react-redux';
|
||||||
import { Router } from 'react-router-dom';
|
import { Router } from 'react-router-dom';
|
||||||
import { HelmetProvider } from 'react-helmet-async';
|
import { HelmetProvider } from 'react-helmet-async';
|
||||||
|
import { History } from 'history';
|
||||||
|
|
||||||
import { IntlProvider } from 'app/components/i18n';
|
import { IntlProvider } from 'app/components/i18n';
|
||||||
import { Store } from 'app/reducers';
|
import { Store } from 'app/reducers';
|
||||||
|
|
||||||
function ContextProvider({ children, store, history }: { children: React.ReactNode; store: Store; history: any }) {
|
interface Props {
|
||||||
return (
|
children: React.ReactNode;
|
||||||
|
store: Store;
|
||||||
|
history: History;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ContextProvider: ComponentType<Props> = ({ children, store, history }) => (
|
||||||
<HelmetProvider>
|
<HelmetProvider>
|
||||||
<ReduxProvider store={store}>
|
<ReduxProvider store={store}>
|
||||||
<IntlProvider>
|
<IntlProvider>
|
||||||
@ -15,6 +22,5 @@ function ContextProvider({ children, store, history }: { children: React.ReactNo
|
|||||||
</ReduxProvider>
|
</ReduxProvider>
|
||||||
</HelmetProvider>
|
</HelmetProvider>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
export default ContextProvider;
|
export default ContextProvider;
|
||||||
|
@ -1,16 +1,26 @@
|
|||||||
import React from 'react';
|
import React, { ComponentType } from 'react';
|
||||||
import { createMemoryHistory } from 'history';
|
import { createMemoryHistory } from 'history';
|
||||||
|
import { DeepPartial } from 'utility-types';
|
||||||
|
|
||||||
import storeFactory from 'app/storeFactory';
|
import storeFactory from 'app/storeFactory';
|
||||||
|
import { RootState } from 'app/reducers';
|
||||||
|
|
||||||
import ContextProvider from './ContextProvider';
|
import ContextProvider from './ContextProvider';
|
||||||
|
|
||||||
type ContextProps = React.ComponentProps<typeof ContextProvider>;
|
type NotOverriddenProps = Omit<React.ComponentProps<typeof ContextProvider>, 'store' | 'history'>;
|
||||||
|
type Props = NotOverriddenProps & {
|
||||||
|
state?: DeepPartial<RootState>;
|
||||||
|
};
|
||||||
|
|
||||||
function TestContextProvider(props: Partial<ContextProps> & { children: ContextProps['children'] }) {
|
const TestContextProvider: ComponentType<Props> = ({ state = {}, children, ...props }) => {
|
||||||
const store = React.useMemo(storeFactory, []);
|
const store = React.useMemo(() => storeFactory(state), []);
|
||||||
const history = React.useMemo(createMemoryHistory, []);
|
const history = React.useMemo(createMemoryHistory, []);
|
||||||
|
|
||||||
return <ContextProvider store={store} history={history} {...props} />;
|
return (
|
||||||
}
|
<ContextProvider store={store} history={history} {...props}>
|
||||||
|
{children}
|
||||||
|
</ContextProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default TestContextProvider;
|
export default TestContextProvider;
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { createStore, applyMiddleware, compose } from 'redux';
|
import { createStore, applyMiddleware, compose, StoreEnhancer } from 'redux';
|
||||||
// midleware, который позволяет возвращать из экшенов функции
|
// midleware, который позволяет возвращать из экшенов функции
|
||||||
// это полезно для работы с асинхронными действиями,
|
// это полезно для работы с асинхронными действиями,
|
||||||
// а также дает возможность проверить какие-либо условия перед запуском экшена
|
// а также дает возможность проверить какие-либо условия перед запуском экшена
|
||||||
@ -8,13 +8,13 @@ import persistState from 'redux-localstorage';
|
|||||||
|
|
||||||
import reducers, { Store } from 'app/reducers';
|
import reducers, { Store } from 'app/reducers';
|
||||||
|
|
||||||
export default function storeFactory(): Store {
|
export default function storeFactory(preloadedState = {}): Store {
|
||||||
const middlewares = applyMiddleware(thunk);
|
const middlewares = applyMiddleware(thunk);
|
||||||
const persistStateEnhancer = persistState(['accounts', 'user'], {
|
const persistStateEnhancer = persistState(['accounts', 'user'], {
|
||||||
key: 'redux-storage',
|
key: 'redux-storage',
|
||||||
});
|
});
|
||||||
|
|
||||||
let enhancer;
|
let enhancer: StoreEnhancer;
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
enhancer = compose(middlewares, persistStateEnhancer);
|
enhancer = compose(middlewares, persistStateEnhancer);
|
||||||
@ -23,7 +23,7 @@ export default function storeFactory(): Store {
|
|||||||
enhancer = composeEnhancers(middlewares, persistStateEnhancer);
|
enhancer = composeEnhancers(middlewares, persistStateEnhancer);
|
||||||
}
|
}
|
||||||
|
|
||||||
const store = createStore(reducers, {}, enhancer) as Store;
|
const store = createStore(reducers, preloadedState, enhancer) as Store;
|
||||||
|
|
||||||
// Hot reload reducers
|
// Hot reload reducers
|
||||||
if (module.hot && typeof module.hot.accept === 'function') {
|
if (module.hot && typeof module.hot.accept === 'function') {
|
||||||
|
@ -35,7 +35,7 @@ describe('feedback popup', () => {
|
|||||||
cy.findByTestId('feedbackPopup').should('contain', 'Your message was received');
|
cy.findByTestId('feedbackPopup').should('contain', 'Your message was received');
|
||||||
cy.findByTestId('feedbackPopup').should('contain', account1.email);
|
cy.findByTestId('feedbackPopup').should('contain', account1.email);
|
||||||
|
|
||||||
cy.findByTestId('feedback-popup-close-button').click();
|
cy.findByTestId('feedbackPopup').find('button').contains('Close').click();
|
||||||
|
|
||||||
cy.findByTestId('feedbackPopup').should('not.be.visible');
|
cy.findByTestId('feedbackPopup').should('not.be.visible');
|
||||||
});
|
});
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
describe('Change locale', () => {
|
describe('Change locale', () => {
|
||||||
it('should change locale from footer', () => {
|
it('should change locale from the footer', () => {
|
||||||
cy.visit('/');
|
cy.visit('/');
|
||||||
|
|
||||||
cy.findByTestId('footer').contains('Site language').click();
|
cy.findByTestId('footer').contains('Site language').click();
|
||||||
@ -7,7 +7,7 @@ describe('Change locale', () => {
|
|||||||
cy.findByTestId('language-switcher').should('be.visible');
|
cy.findByTestId('language-switcher').should('be.visible');
|
||||||
cy.findByTestId('language-switcher').should('have.attr', 'data-e2e-active-locale', 'en');
|
cy.findByTestId('language-switcher').should('have.attr', 'data-e2e-active-locale', 'en');
|
||||||
|
|
||||||
cy.findByTestId('language-list').contains('Belarusian').click();
|
cy.findByTestId('languages-list-item').contains('Belarusian').click();
|
||||||
|
|
||||||
cy.findByTestId('language-switcher').should('not.be.visible');
|
cy.findByTestId('language-switcher').should('not.be.visible');
|
||||||
|
|
||||||
@ -16,13 +16,13 @@ describe('Change locale', () => {
|
|||||||
cy.findByTestId('language-switcher').should('be.visible');
|
cy.findByTestId('language-switcher').should('be.visible');
|
||||||
cy.findByTestId('language-switcher').should('have.attr', 'data-e2e-active-locale', 'be');
|
cy.findByTestId('language-switcher').should('have.attr', 'data-e2e-active-locale', 'be');
|
||||||
|
|
||||||
cy.findByTestId('language-list').contains('English').click();
|
cy.findByTestId('languages-list-item').contains('English').click();
|
||||||
|
|
||||||
cy.findByTestId('language-switcher').should('not.be.visible');
|
cy.findByTestId('language-switcher').should('not.be.visible');
|
||||||
cy.findByTestId('footer').should('contain', 'Site language');
|
cy.findByTestId('footer').should('contain', 'Site language');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should change locale from profile', () => {
|
it('should change locale from the profile', () => {
|
||||||
cy.login({ accounts: ['default'] }).then(({ accounts: [account] }) => {
|
cy.login({ accounts: ['default'] }).then(({ accounts: [account] }) => {
|
||||||
cy.server();
|
cy.server();
|
||||||
cy.route({
|
cy.route({
|
||||||
@ -39,7 +39,7 @@ describe('Change locale', () => {
|
|||||||
cy.findByTestId('language-switcher').should('be.visible');
|
cy.findByTestId('language-switcher').should('be.visible');
|
||||||
cy.findByTestId('language-switcher').should('have.attr', 'data-e2e-active-locale', 'en');
|
cy.findByTestId('language-switcher').should('have.attr', 'data-e2e-active-locale', 'en');
|
||||||
|
|
||||||
cy.findByTestId('language-list').contains('Belarusian').click();
|
cy.findByTestId('languages-list-item').contains('Belarusian').click();
|
||||||
|
|
||||||
cy.wait('@language').its('requestBody').should('eq', 'lang=be');
|
cy.wait('@language').its('requestBody').should('eq', 'lang=be');
|
||||||
|
|
||||||
|
@ -15896,6 +15896,11 @@ utila@^0.4.0, utila@~0.4:
|
|||||||
resolved "https://registry.yarnpkg.com/utila/-/utila-0.4.0.tgz#8a16a05d445657a3aea5eecc5b12a4fa5379772c"
|
resolved "https://registry.yarnpkg.com/utila/-/utila-0.4.0.tgz#8a16a05d445657a3aea5eecc5b12a4fa5379772c"
|
||||||
integrity sha1-ihagXURWV6Oupe7MWxKk+lN5dyw=
|
integrity sha1-ihagXURWV6Oupe7MWxKk+lN5dyw=
|
||||||
|
|
||||||
|
utility-types@^3.10.0:
|
||||||
|
version "3.10.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/utility-types/-/utility-types-3.10.0.tgz#ea4148f9a741015f05ed74fd615e1d20e6bed82b"
|
||||||
|
integrity sha512-O11mqxmi7wMKCo6HKFt5AhO4BwY3VV68YU07tgxfz8zJTIxr4BpsezN49Ffwy9j3ZpwwJp4fkRwjRzq3uWE6Rg==
|
||||||
|
|
||||||
utils-merge@1.0.1:
|
utils-merge@1.0.1:
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
|
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
|
||||||
|
Loading…
Reference in New Issue
Block a user