Extract general popups markup to its own component

Split popups controllers into separate components
Implemented storybooks for all project's popups
This commit is contained in:
ErickSkrauch
2020-07-06 19:29:56 +03:00
parent 28ccab8a98
commit 82abe0a746
39 changed files with 834 additions and 534 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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