feat: Add Language Tools Page (fix #2777)

This commit is contained in:
Matthew Riley MacPherson 2017-09-30 03:17:37 +01:00
Родитель 09de2f1969
Коммит 98ff5903aa
7 изменённых файлов: 506 добавлений и 2 удалений

Просмотреть файл

@ -193,6 +193,7 @@
"react-redux": "5.0.6",
"react-router": "2.8.1",
"react-router-scroll": "0.4.3",
"react-super-responsive-table": "0.3.0",
"react-textarea-autosize": "5.1.0",
"redux": "3.7.2",
"redux-connect": "4.0.2",

Просмотреть файл

@ -0,0 +1,241 @@
/* @flow */
import classNames from 'classnames';
import React from 'react';
import { connect } from 'react-redux';
import { compose } from 'redux';
import { Table, Thead, Tbody, Tr, Th, Td } from 'react-super-responsive-table';
import 'react-super-responsive-table/src/SuperResponsiveTableStyle.css';
import { setViewContext } from 'amo/actions/viewContext';
import Link from 'amo/components/Link';
import { withErrorHandler } from 'core/errorHandler';
import {
ADDON_TYPE_DICT,
ADDON_TYPE_LANG,
VIEW_CONTEXT_LANGUAGE_TOOLS,
} from 'core/constants';
import languages from 'core/languages';
import translate from 'core/i18n/translate';
import { fetchLanguageTools } from 'core/reducers/addons';
import Card from 'ui/components/Card';
import LoadingText from 'ui/components/LoadingText';
import type { ErrorHandlerType } from 'core/errorHandler';
import type { AddonState } from 'core/reducers/addons';
import type { AddonType } from 'core/types/addons';
import './styles.scss';
type ListItemPropTypes = {
addons?: Array<AddonType>,
};
export const LanguageToolList = ({ addons }: ListItemPropTypes) => {
if (!addons) {
return null;
}
return (
<ul className="LanguageTools-addon-list">
{addons.map((addon) => {
return (
<li key={addon.slug}>
<Link to={`/addon/${addon.slug}/`}>
{addon.name}
</Link>
</li>
);
})}
</ul>
);
};
type PropTypes = {|
addons: Array<AddonType>,
dispatch: Function,
errorHandler: ErrorHandlerType,
i18n: Object,
lang: string,
|};
export class LanguageToolsBase extends React.Component {
componentWillMount() {
const { addons, dispatch, errorHandler } = this.props;
dispatch(setViewContext(VIEW_CONTEXT_LANGUAGE_TOOLS));
if (addons === null) {
dispatch(fetchLanguageTools({ errorHandlerId: errorHandler.id }));
}
}
languageToolsInYourLocale() {
const { addons, i18n, lang } = this.props;
const languageToolsInYourLocale = addons ? addons.filter((addon) => {
return addon.target_locale === lang;
}) : null;
// This means we've loaded add-ons but there aren't any available in this
// user's locale.
if (
addons &&
(!languageToolsInYourLocale || !languageToolsInYourLocale.length)
) {
return null;
}
return (
<div className="LanguageTools-in-your-locale">
<h2 className="LanguageTools-header">
{i18n.gettext('Available for your locale')}
</h2>
{addons && languageToolsInYourLocale ? (
<ul className="LanguageTools-in-your-locale-list">
{languageToolsInYourLocale.map((addon) => {
return (
<li
className="LanguageTools-in-your-locale-list-item"
key={addon.slug}
>
<Link
className={
`LanguageTools-in-your-locale-list-item--${addon.type}`
}
to={`/addon/${addon.slug}/`}
>
{addon.name}
</Link>
</li>
);
})}
</ul>
) : (
<ul>
<li><LoadingText width={20} /></li>
<li><LoadingText width={20} /></li>
</ul>
)}
</div>
);
}
props: PropTypes;
render() {
const { addons, errorHandler, i18n } = this.props;
return (
<Card
className="LanguageTools"
header={i18n.gettext('Dictionaries and Language Packs')}
>
{errorHandler.renderErrorIfPresent()}
<p>
{i18n.gettext(`Installing a dictionary add-on will add a new language
option to your spell-checker, which checks your spelling as you
type in Firefox.`)}
</p>
<p>
{i18n.gettext(`Language packs change your browser's interface
language, including menu options and settings.`)}
</p>
{this.languageToolsInYourLocale()}
<h2 className="LanguageTools-header">{i18n.gettext('All Locales')}</h2>
<Table className="LanguageTools-table">
<Thead>
<Tr className="LanguageTools-header-row">
<Th className="LanguageTools-header-cell">
{i18n.gettext('Locale Name')}
</Th>
<Th className="LanguageTools-header-cell">
{i18n.gettext('Language Packs')}
</Th>
<Th className="LanguageTools-header-cell">
{i18n.gettext('Dictionaries')}
</Th>
</Tr>
</Thead>
<Tbody>
{addons && addons.length ? Object.keys(languages).map((langKey) => {
const toolsInLocale = addons ? addons
.filter((addon) => {
return addon.target_locale === langKey;
}) : null;
// This means there are no language tools available in this
// known locale.
if (!toolsInLocale || !toolsInLocale.length) {
return null;
}
const dictionaries = toolsInLocale.filter((addon) => {
return addon.type === ADDON_TYPE_DICT;
});
const languagePacks = toolsInLocale.filter((addon) => {
return addon.type === ADDON_TYPE_LANG;
});
return (
<Tr
className={classNames(
'LanguageTools-table-row',
`LanguageTools-lang-${langKey}`,
)}
key={langKey}
>
<Td lang={langKey}>{languages[langKey].native}</Td>
<Td className={`LanguageTools-lang-${langKey}-languagePacks`}>
{languagePacks.length ?
<LanguageToolList addons={languagePacks} /> : null}
</Td>
<Td className={`LanguageTools-lang-${langKey}-dictionaries`}>
{dictionaries.length ?
<LanguageToolList addons={dictionaries} /> : null}
</Td>
</Tr>
);
}) : Array(50).fill(<Tr>
<Td><LoadingText /></Td>
<Td><LoadingText /></Td>
<Td><LoadingText /></Td>
</Tr>)}
</Tbody>
</Table>
</Card>
);
}
}
export const mapStateToProps = (
state: {|
addons: AddonState,
api: Object,
|}
) => {
const { addons } = state;
const languageToolAddons = addons && Object.values(addons).length ?
Object.values(addons).filter((addon) => {
// I don't know why we need to check for type but flow complains if we
// don't. 🤷🏼‍
return addon && addon.type && (
addon.type === ADDON_TYPE_DICT || addon.type === ADDON_TYPE_LANG
);
}) : null;
return {
addons: languageToolAddons && languageToolAddons.length ?
languageToolAddons : null,
lang: state.api.lang,
};
};
export default compose(
withErrorHandler({ name: 'LanguageTools' }),
connect(mapStateToProps),
translate(),
)(LanguageToolsBase);

Просмотреть файл

@ -0,0 +1,46 @@
@import "~photon-colors/colors";
@import "~core/css/inc/mixins";
@import "~ui/css/vars";
.LanguageTools {
margin: 12px;
}
.LanguageTools-table {
width: 100%;
}
.LanguageTools-table-row {
border-bottom: 1px solid $grey-50;
&:nth-child(even) {
background: $grey-20;
}
}
.LanguageTools-header-row {
background: $grey-30;
}
.LanguageTools-header-cell {
@include text-align-start();
font-size: $font-size-m;
}
.LanguageTools-addon-list {
list-style: none;
margin: 0;
padding: 0;
}
.LanguageTools-table {
.responsiveTable tbody tr {
padding-top: 0;
}
th,
td {
padding: 6px;
}
}

Просмотреть файл

@ -18,6 +18,7 @@ import Category from './components/Category';
import FeaturedAddons from './components/FeaturedAddons';
import Home from './components/Home';
import LandingPage from './components/LandingPage';
import LanguageTools from './components/LanguageTools';
import NotAuthorized from './components/ErrorPage/NotAuthorized';
import NotFound from './components/ErrorPage/NotFound';
import ReviewGuide from './components/StaticPages/ReviewGuide';
@ -44,6 +45,7 @@ export default (
<Route path=":visibleAddonType/categories/" component={CategoriesPage} />
<Route path=":visibleAddonType/featured/" component={FeaturedAddons} />
<Route path=":visibleAddonType/:slug/" component={Category} />
<Route path="language-tools/" component={LanguageTools} />
<Route path="search/" component={SearchPage} />
<Route
path="401/"

Просмотреть файл

@ -274,7 +274,7 @@ export function createInternalAddon(
const initialState = {};
type AddonState = {
export type AddonState = {
[addonSlug: string]: AddonType,
};

Просмотреть файл

@ -0,0 +1,208 @@
import { shallow } from 'enzyme';
import React from 'react';
import LanguageTools, {
LanguageToolsBase,
LanguageToolList,
mapStateToProps,
} from 'amo/components/LanguageTools';
import Link from 'amo/components/Link';
import { ADDON_TYPE_DICT, ADDON_TYPE_LANG } from 'core/constants';
import { loadAddonResults } from 'core/reducers/addons';
import {
createFakeAddon,
dispatchClientMetadata,
fakeAddon,
} from 'tests/unit/amo/helpers';
import {
createFakeLanguageAddon,
fakeI18n,
shallowUntilTarget,
} from 'tests/unit/helpers';
import LoadingText from 'ui/components/LoadingText';
describe(__filename, () => {
const addons = [
createFakeLanguageAddon({
name: 'Scottish Language Pack (with Irn-Bru)',
target_locale: 'en-GB',
type: ADDON_TYPE_LANG,
}),
createFakeLanguageAddon({
name: 'Old stuffy English',
target_locale: 'en-GB',
type: ADDON_TYPE_DICT,
}),
createFakeLanguageAddon({
name: 'English Language Pack with Extra Us',
target_locale: 'en-GB',
type: ADDON_TYPE_LANG,
}),
createFakeLanguageAddon({
name: 'Cool new English',
target_locale: 'en-US',
type: ADDON_TYPE_DICT,
}),
createFakeLanguageAddon({
name: 'le French Dictionary',
target_locale: 'fr',
type: ADDON_TYPE_DICT,
}),
createFakeLanguageAddon({
name: 'French Language Pack',
target_locale: 'fr',
type: ADDON_TYPE_LANG,
}),
createFakeLanguageAddon({
name: 'اُردو',
target_locale: 'ur',
type: ADDON_TYPE_DICT,
}),
createFakeLanguageAddon({
name: '正體中文 (繁體)',
target_locale: 'zh-TW',
type: ADDON_TYPE_LANG,
}),
];
function renderShallow({
i18n = fakeI18n(),
store = dispatchClientMetadata().store,
...props
} = {}) {
return shallowUntilTarget(
<LanguageTools i18n={i18n} store={store} {...props} />,
LanguageToolsBase
);
}
it('renders LoadingText if addons are not set', () => {
const root = renderShallow();
expect(root.find(LoadingText)).not.toHaveLength(0);
});
it('renders LoadingText if addons are empty', () => {
const { store } = dispatchClientMetadata();
store.dispatch(loadAddonResults({ addons: {} }));
const root = renderShallow({ store });
expect(root.find(LoadingText)).not.toHaveLength(0);
});
it('renders LoadingText if there are addons but no language addons', () => {
const { store } = dispatchClientMetadata();
store.dispatch(loadAddonResults({
addons: {
[fakeAddon.slug]: createFakeAddon(fakeAddon),
},
}));
const root = renderShallow({ store });
expect(root.find(LoadingText)).not.toHaveLength(0);
});
it('renders language tools in your locale', () => {
const { store } = dispatchClientMetadata({ lang: 'fr' });
store.dispatch(loadAddonResults({ addons }));
const root = renderShallow({ store });
const dictionary = root.find(
`.LanguageTools-in-your-locale-list-item--${ADDON_TYPE_DICT}`
);
const langPack = root.find(
`.LanguageTools-in-your-locale-list-item--${ADDON_TYPE_LANG}`
);
expect(root.find(LoadingText)).toHaveLength(0);
expect(root.find('.LanguageTools-in-your-locale')).toHaveLength(1);
expect(dictionary).toHaveLength(1);
expect(dictionary.find(Link))
.toHaveProp('children', 'le French Dictionary');
expect(langPack).toHaveLength(1);
expect(langPack.find(Link))
.toHaveProp('children', 'French Language Pack');
});
it('omits "language tools in your locale" section if none available', () => {
const { store } = dispatchClientMetadata({ lang: 'pt-BR' });
store.dispatch(loadAddonResults({ addons }));
const root = renderShallow({ store });
expect(root.find('.LanguageTools-in-your-locale')).toHaveLength(0);
});
it('renders language packs in the table view for the right language', () => {
const { store } = dispatchClientMetadata();
store.dispatch(loadAddonResults({ addons }));
const root = renderShallow({ store });
expect(root.find('.LanguageTools-lang-en-GB')).toHaveLength(1);
expect(root.find('.LanguageTools-lang-en-US')).toHaveLength(1);
expect(root.find('.LanguageTools-lang-fr')).toHaveLength(1);
expect(root.find('.LanguageTools-lang-ur')).toHaveLength(1);
expect(root.find('.LanguageTools-lang-zh-TW')).toHaveLength(1);
});
it('renders multiple addons in a list using LanguageToolList', () => {
const { store } = dispatchClientMetadata();
store.dispatch(loadAddonResults({ addons }));
const root = renderShallow({ store });
const dictionaryList = root
.find('.LanguageTools-lang-en-GB-dictionaries')
.find(LanguageToolList);
const languagePackList = root
.find('.LanguageTools-lang-en-GB-languagePacks')
.find(LanguageToolList);
expect(dictionaryList).toHaveLength(1);
expect(languagePackList).toHaveLength(1);
});
it('does not render languages we know of but do not have addons for', () => {
const { store } = dispatchClientMetadata();
store.dispatch(loadAddonResults({ addons }));
const root = renderShallow({ store });
expect(root.find('.LanguageTools-lang-es')).toHaveLength(0);
});
describe('LanguageToolList', () => {
it('renders a LanguageToolList', () => {
const { store } = dispatchClientMetadata({ lang: 'en-GB' });
store.dispatch(loadAddonResults({ addons }));
const languageTools = mapStateToProps(store.getState()).addons;
const languageToolsInYourLocale = languageTools.filter((addon) => {
return addon.target_locale === store.getState().api.lang;
});
const dictionaries = languageToolsInYourLocale.filter((addon) => {
return addon.type === ADDON_TYPE_DICT;
});
const languagePacks = languageToolsInYourLocale.filter((addon) => {
return addon.type === ADDON_TYPE_LANG;
});
const dictionaryList = shallow(
<LanguageToolList addons={dictionaries} />
);
const languagePackList = shallow(
<LanguageToolList addons={languagePacks} />
);
expect(dictionaryList.find('.LanguageTools-addon-list'))
.toHaveLength(1);
expect(languagePackList.find('.LanguageTools-addon-list'))
.toHaveLength(1);
expect(dictionaryList.find('li')).toHaveLength(1);
expect(languagePackList.find('li')).toHaveLength(2);
});
});
it('renders nothing if addons are null', () => {
const root = shallow(<LanguageToolList addons={null} />);
expect(root.find('.LanguageTools-addon-list')).toHaveLength(0);
});
});

Просмотреть файл

@ -1650,7 +1650,7 @@ clap@^1.0.9:
dependencies:
chalk "^1.1.3"
classnames@2.2.5, classnames@^2.2.3:
classnames@2.2.5, classnames@^2.1.2, classnames@^2.2.3:
version "2.2.5"
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.5.tgz#fb3801d453467649ef3603c7d61a02bd129bde6d"
@ -6869,6 +6869,12 @@ react-side-effect@~0.3.0:
dependencies:
fbjs "0.1.0-alpha.10"
react-super-responsive-table@0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/react-super-responsive-table/-/react-super-responsive-table-0.3.0.tgz#7d025781d2b9deb16d3b300363ccf6a6ffc2d124"
dependencies:
classnames "^2.1.2"
react-test-renderer@^15.5.4:
version "15.6.1"
resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-15.6.1.tgz#026f4a5bb5552661fd2cc4bbcd0d4bc8a35ebf7e"