🔀 merge from develop

This commit is contained in:
ghalestrilo 2020-08-28 10:20:38 -03:00
commit d7106fedef
26 changed files with 320 additions and 102 deletions

View file

@ -1,11 +1,13 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { withTranslation } from 'react-i18next';
import AddIcon from '../images/plus.svg'; import AddIcon from '../images/plus.svg';
import RemoveIcon from '../images/minus.svg'; import RemoveIcon from '../images/minus.svg';
const AddRemoveButton = ({ type, onClick }) => { const AddRemoveButton = ({ type, onClick, t }) => {
const alt = type === 'add' ? 'Add to collection' : 'Remove from collection'; const alt = type === 'add' ? t('AddRemoveButton.AltAddARIA') : t('AddRemoveButton.AltRemoveARIA');
const Icon = type === 'add' ? AddIcon : RemoveIcon; const Icon = type === 'add' ? AddIcon : RemoveIcon;
return ( return (
@ -22,6 +24,7 @@ const AddRemoveButton = ({ type, onClick }) => {
AddRemoveButton.propTypes = { AddRemoveButton.propTypes = {
type: PropTypes.oneOf(['add', 'remove']).isRequired, type: PropTypes.oneOf(['add', 'remove']).isRequired,
onClick: PropTypes.func.isRequired, onClick: PropTypes.func.isRequired,
t: PropTypes.func.isRequired
}; };
export default AddRemoveButton; export default withTranslation()(AddRemoveButton);

View file

@ -5,7 +5,7 @@ import { withRouter } from 'react-router';
import { Link } from 'react-router'; import { Link } from 'react-router';
import classNames from 'classnames'; import classNames from 'classnames';
import { withTranslation } from 'react-i18next'; import { withTranslation } from 'react-i18next';
import i18next from 'i18next'; import { languageKeyToLabel } from '../i18n';
import * as IDEActions from '../modules/IDE/actions/ide'; import * as IDEActions from '../modules/IDE/actions/ide';
import * as toastActions from '../modules/IDE/actions/toast'; import * as toastActions from '../modules/IDE/actions/toast';
import * as projectActions from '../modules/IDE/actions/project'; import * as projectActions from '../modules/IDE/actions/project';
@ -549,7 +549,7 @@ class Nav extends React.PureComponent {
renderLanguageMenu(navDropdownState) { renderLanguageMenu(navDropdownState) {
return ( return (
<ul className="nav__items-right" title="user-menu"> <React.Fragment>
<li className={navDropdownState.lang}> <li className={navDropdownState.lang}>
<button <button
onClick={this.toggleDropdownForLang} onClick={this.toggleDropdownForLang}
@ -561,7 +561,7 @@ class Nav extends React.PureComponent {
} }
}} }}
> >
<span className="nav__item-header"> {this.props.t('Nav.Lang')}</span> <span className="nav__item-header"> {languageKeyToLabel(this.props.language)}</span>
<TriangleIcon className="nav__item-header-triangle" focusable="false" aria-hidden="true" /> <TriangleIcon className="nav__item-header-triangle" focusable="false" aria-hidden="true" />
</button> </button>
<ul className="nav__dropdown"> <ul className="nav__dropdown">
@ -597,7 +597,7 @@ class Nav extends React.PureComponent {
</li> </li>
</ul> </ul>
</li> </li>
</ul> </React.Fragment>
); );
} }
@ -605,6 +605,7 @@ class Nav extends React.PureComponent {
renderUnauthenticatedUserMenu(navDropdownState) { renderUnauthenticatedUserMenu(navDropdownState) {
return ( return (
<ul className="nav__items-right" title="user-menu"> <ul className="nav__items-right" title="user-menu">
{getConfig('TRANSLATIONS_ENABLED') && this.renderLanguageMenu(navDropdownState)}
<li className="nav__item"> <li className="nav__item">
<Link to="/login" className="nav__auth-button"> <Link to="/login" className="nav__auth-button">
<span className="nav__item-header">{this.props.t('Nav.Login')}</span> <span className="nav__item-header">{this.props.t('Nav.Login')}</span>
@ -623,10 +624,7 @@ class Nav extends React.PureComponent {
renderAuthenticatedUserMenu(navDropdownState) { renderAuthenticatedUserMenu(navDropdownState) {
return ( return (
<ul className="nav__items-right" title="user-menu"> <ul className="nav__items-right" title="user-menu">
<li className="nav__item"> {getConfig('TRANSLATIONS_ENABLED') && this.renderLanguageMenu(navDropdownState)}
<span>{this.props.t('Nav.Auth.Hello')}, {this.props.user.username}!</span>
</li>
<span className="nav__item-spacer">|</span>
<li className={navDropdownState.account}> <li className={navDropdownState.account}>
<button <button
className="nav__item-header" className="nav__item-header"
@ -639,7 +637,7 @@ class Nav extends React.PureComponent {
} }
}} }}
> >
{this.props.t('Nav.Auth.MyAccount')} <span>{this.props.t('Nav.Auth.Hello')}, {this.props.user.username}!</span>
<TriangleIcon className="nav__item-header-triangle" focusable="false" aria-hidden="true" /> <TriangleIcon className="nav__item-header-triangle" focusable="false" aria-hidden="true" />
</button> </button>
<ul className="nav__dropdown"> <ul className="nav__dropdown">
@ -755,7 +753,6 @@ class Nav extends React.PureComponent {
<header> <header>
<nav className="nav" title="main-navigation" ref={(node) => { this.node = node; }}> <nav className="nav" title="main-navigation" ref={(node) => { this.node = node; }}>
{this.renderLeftLayout(navDropdownState)} {this.renderLeftLayout(navDropdownState)}
{getConfig('TRANSLATIONS_ENABLED') && this.renderLanguageMenu(navDropdownState)}
{this.renderUserMenu(navDropdownState)} {this.renderUserMenu(navDropdownState)}
</nav> </nav>
</header> </header>
@ -809,6 +806,7 @@ Nav.propTypes = {
}), }),
t: PropTypes.func.isRequired, t: PropTypes.func.isRequired,
setLanguage: PropTypes.func.isRequired, setLanguage: PropTypes.func.isRequired,
language: PropTypes.string.isRequired,
}; };
Nav.defaultProps = { Nav.defaultProps = {
@ -829,7 +827,8 @@ function mapStateToProps(state) {
project: state.project, project: state.project,
user: state.user, user: state.user,
unsavedChanges: state.ide.unsavedChanges, unsavedChanges: state.ide.unsavedChanges,
rootFile: state.files.filter(file => file.name === 'root')[0] rootFile: state.files.filter(file => file.name === 'root')[0],
language: state.preferences.language
}; };
} }

View file

@ -1,22 +1,23 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { withTranslation } from 'react-i18next';
import LogoIcon from '../images/p5js-logo-small.svg'; import LogoIcon from '../images/p5js-logo-small.svg';
import CodeIcon from '../images/code.svg'; import CodeIcon from '../images/code.svg';
const PreviewNav = ({ owner, project }) => ( const PreviewNav = ({ owner, project, t }) => (
<nav className="nav preview-nav"> <nav className="nav preview-nav">
<div className="nav__items-left"> <div className="nav__items-left">
<div className="nav__item-logo"> <div className="nav__item-logo">
<LogoIcon role="img" aria-label="p5.js Logo" focusable="false" className="svg__logo" /> <LogoIcon role="img" aria-label={t('Common.p5logoARIA')} focusable="false" className="svg__logo" />
</div> </div>
<Link className="nav__item" to={`/${owner.username}/sketches/${project.id}`}>{project.name}</Link> <Link className="nav__item" to={`/${owner.username}/sketches/${project.id}`}>{project.name}</Link>
<p className="toolbar__project-owner">by</p> <p className="toolbar__project-owner">{t('PreviewNav.ByUser')}</p>
<Link className="nav__item" to={`/${owner.username}/sketches/`}>{owner.username}</Link> <Link className="nav__item" to={`/${owner.username}/sketches/`}>{owner.username}</Link>
</div> </div>
<div className="nav__items-right"> <div className="nav__items-right">
<Link to={`/${owner.username}/sketches/${project.id}`} aria-label="Edit Sketch" > <Link to={`/${owner.username}/sketches/${project.id}`} aria-label={t('PreviewNav.EditSketchARIA')} >
<CodeIcon className="preview-nav__editor-svg" focusable="false" aria-hidden="true" /> <CodeIcon className="preview-nav__editor-svg" focusable="false" aria-hidden="true" />
</Link> </Link>
</div> </div>
@ -31,6 +32,7 @@ PreviewNav.propTypes = {
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,
}).isRequired, }).isRequired,
t: PropTypes.func.isRequired
}; };
export default PreviewNav; export default withTranslation()(PreviewNav);

View file

@ -46,7 +46,8 @@ describe('Nav', () => {
id: 'root-file' id: 'root-file'
}, },
t: jest.fn(), t: jest.fn(),
setLanguage: jest.fn() setLanguage: jest.fn(),
language: 'en-US'
}; };
it('renders correctly', () => { it('renders correctly', () => {

26
client/i18n-test.js Normal file
View file

@ -0,0 +1,26 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import translations from '../translations/locales/en-US/translations.json';
i18n
.use(initReactI18next)
.init({
lng: 'en-US',
fallbackLng: 'en-US',
// have a common namespace used around the full app
ns: ['translations'],
defaultNS: 'translations',
debug: false,
interpolation: {
escapeValue: false, // not needed for react!!
},
resources: { 'en-US': { translations } },
});
export default i18n;

View file

@ -6,6 +6,14 @@ import Backend from 'i18next-http-backend';
const fallbackLng = ['en-US']; const fallbackLng = ['en-US'];
const availableLanguages = ['en-US', 'es-419']; const availableLanguages = ['en-US', 'es-419'];
export function languageKeyToLabel(lang) {
const languageMap = {
'en-US': 'English',
'es-419': 'Español'
};
return languageMap[lang];
}
const options = { const options = {
loadPath: '/locales/{{lng}}/translations.json', loadPath: '/locales/{{lng}}/translations.json',
requestOptions: { // used for fetch, can also be a function (payload) => ({ method: 'GET' }) requestOptions: { // used for fetch, can also be a function (payload) => ({ method: 'GET' })

View file

@ -3,21 +3,3 @@ import '@babel/polyfill';
// See: https://github.com/testing-library/jest-dom // See: https://github.com/testing-library/jest-dom
// eslint-disable-next-line import/no-extraneous-dependencies // eslint-disable-next-line import/no-extraneous-dependencies
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import lodash from 'lodash';
// For testing, we use en-US and provide a mock implementation
// of t() that finds the correct translation
import translations from '../translations/locales/en-US/translations.json';
// This function name needs to be prefixed with "mock" so that Jest doesn't
// complain that it's out-of-scope in the mock below
const mockTranslate = key => lodash.get(translations, key);
jest.mock('react-i18next', () => ({
// this mock makes sure any components using the translate HoC receive the t function as a prop
withTranslation: () => (Component) => {
Component.defaultProps = { ...Component.defaultProps, t: mockTranslate };
return Component;
},
}));

View file

@ -5,6 +5,7 @@ import { bindActionCreators } from 'redux';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import prettyBytes from 'pretty-bytes'; import prettyBytes from 'pretty-bytes';
import { withTranslation } from 'react-i18next';
import Loader from '../../App/components/loader'; import Loader from '../../App/components/loader';
import * as AssetActions from '../actions/assets'; import * as AssetActions from '../actions/assets';
@ -85,7 +86,7 @@ class AssetListRowBase extends React.Component {
onClick={this.toggleOptions} onClick={this.toggleOptions}
onBlur={this.onBlurComponent} onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent} onFocus={this.onFocusComponent}
aria-label="Toggle Open/Close Asset Options" aria-label={this.props.t('AssetList.ToggleOpenCloseARIA')}
> >
<DownFilledTriangleIcon focusable="false" aria-hidden="true" /> <DownFilledTriangleIcon focusable="false" aria-hidden="true" />
</button> </button>
@ -100,7 +101,7 @@ class AssetListRowBase extends React.Component {
onBlur={this.onBlurComponent} onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent} onFocus={this.onFocusComponent}
> >
Delete {this.props.t('AssetList.Delete')}
</button> </button>
</li> </li>
<li> <li>
@ -111,7 +112,7 @@ class AssetListRowBase extends React.Component {
onFocus={this.onFocusComponent} onFocus={this.onFocusComponent}
className="asset-table__action-option" className="asset-table__action-option"
> >
Open in New Tab {this.props.t('AssetList.OpenNewTab')}
</Link> </Link>
</li> </li>
</ul>} </ul>}
@ -131,7 +132,8 @@ AssetListRowBase.propTypes = {
size: PropTypes.number.isRequired size: PropTypes.number.isRequired
}).isRequired, }).isRequired,
deleteAssetRequest: PropTypes.func.isRequired, deleteAssetRequest: PropTypes.func.isRequired,
username: PropTypes.string.isRequired username: PropTypes.string.isRequired,
t: PropTypes.func.isRequired
}; };
function mapStateToPropsAssetListRow(state) { function mapStateToPropsAssetListRow(state) {
@ -153,7 +155,7 @@ class AssetList extends React.Component {
} }
getAssetsTitle() { getAssetsTitle() {
return 'p5.js Web Editor | My assets'; return this.props.t('AssetList.Title');
} }
hasAssets() { hasAssets() {
@ -167,13 +169,13 @@ class AssetList extends React.Component {
renderEmptyTable() { renderEmptyTable() {
if (!this.props.loading && this.props.assetList.length === 0) { if (!this.props.loading && this.props.assetList.length === 0) {
return (<p className="asset-table__empty">No uploaded assets.</p>); return (<p className="asset-table__empty">{this.props.t('AssetList.NoUploadedAssets')}</p>);
} }
return null; return null;
} }
render() { render() {
const { assetList } = this.props; const { assetList, t } = this.props;
return ( return (
<article className="asset-table-container"> <article className="asset-table-container">
<Helmet> <Helmet>
@ -185,9 +187,9 @@ class AssetList extends React.Component {
<table className="asset-table"> <table className="asset-table">
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>{t('AssetList.HeaderName')}</th>
<th>Size</th> <th>{t('AssetList.HeaderSize')}</th>
<th>Sketch</th> <th>{t('AssetList.HeaderSketch')}</th>
<th scope="col"></th> <th scope="col"></th>
</tr> </tr>
</thead> </thead>
@ -212,7 +214,8 @@ AssetList.propTypes = {
sketchId: PropTypes.string sketchId: PropTypes.string
})).isRequired, })).isRequired,
getAssets: PropTypes.func.isRequired, getAssets: PropTypes.func.isRequired,
loading: PropTypes.bool.isRequired loading: PropTypes.bool.isRequired,
t: PropTypes.func.isRequired
}; };
function mapStateToProps(state) { function mapStateToProps(state) {
@ -227,4 +230,4 @@ function mapDispatchToProps(dispatch) {
return bindActionCreators(Object.assign({}, AssetActions), dispatch); return bindActionCreators(Object.assign({}, AssetActions), dispatch);
} }
export default connect(mapStateToProps, mapDispatchToProps)(AssetList); export default withTranslation()(connect(mapStateToProps, mapDispatchToProps)(AssetList));

View file

@ -2,6 +2,7 @@ import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import Clipboard from 'clipboard'; import Clipboard from 'clipboard';
import classNames from 'classnames'; import classNames from 'classnames';
import { withTranslation } from 'react-i18next';
import ShareIcon from '../../../images/share.svg'; import ShareIcon from '../../../images/share.svg';
@ -45,7 +46,7 @@ class CopyableInput extends React.Component {
<div className={copyableInputClass}> <div className={copyableInputClass}>
<div <div
className="copyable-input__value-container tooltipped-no-delay" className="copyable-input__value-container tooltipped-no-delay"
aria-label="Copied to Clipboard!" aria-label={this.props.t('CopyableInput.CopiedARIA')}
ref={(element) => { this.tooltip = element; }} ref={(element) => { this.tooltip = element; }}
onMouseLeave={this.onMouseLeaveHandler} onMouseLeave={this.onMouseLeaveHandler}
> >
@ -69,7 +70,7 @@ class CopyableInput extends React.Component {
rel="noopener noreferrer" rel="noopener noreferrer"
href={value} href={value}
className="copyable-input__preview" className="copyable-input__preview"
aria-label={`Open ${label} view in new tab`} aria-label={this.props.t('CopyableInput.CopiedARIA', { label })}
> >
<ShareIcon focusable="false" aria-hidden="true" /> <ShareIcon focusable="false" aria-hidden="true" />
</a> </a>
@ -82,11 +83,12 @@ class CopyableInput extends React.Component {
CopyableInput.propTypes = { CopyableInput.propTypes = {
label: PropTypes.string.isRequired, label: PropTypes.string.isRequired,
value: PropTypes.string.isRequired, value: PropTypes.string.isRequired,
hasPreviewLink: PropTypes.bool hasPreviewLink: PropTypes.bool,
t: PropTypes.func.isRequired
}; };
CopyableInput.defaultProps = { CopyableInput.defaultProps = {
hasPreviewLink: false hasPreviewLink: false
}; };
export default CopyableInput; export default withTranslation()(CopyableInput);

View file

@ -1,8 +1,10 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import { withTranslation } from 'react-i18next';
import i18next from 'i18next';
import EditIcon from '../../../images/pencil.svg'; import EditIcon from '../../../images/pencil.svg';
// TODO I think this needs a description prop so that it's accessible // TODO I think this needs a description prop so that it's accessible
function EditableInput({ function EditableInput({
validate, validate,
@ -58,7 +60,7 @@ function EditableInput({
<button <button
className="editable-input__label" className="editable-input__label"
onClick={beginEditing} onClick={beginEditing}
aria-label={`Edit ${displayValue} value`} aria-label={this.props.t('EditableInput.EditValue', { display: displayValue })}
> >
<span>{displayValue}</span> <span>{displayValue}</span>
<EditIcon <EditIcon
@ -84,7 +86,7 @@ function EditableInput({
} }
EditableInput.defaultProps = { EditableInput.defaultProps = {
emptyPlaceholder: 'No value', emptyPlaceholder: i18next.t('EditableInput.EmptyPlaceholder'),
InputComponent: 'input', InputComponent: 'input',
inputProps: {}, inputProps: {},
validate: () => true, validate: () => true,
@ -99,6 +101,7 @@ EditableInput.propTypes = {
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,
validate: PropTypes.func, validate: PropTypes.func,
value: PropTypes.string, value: PropTypes.string,
t: PropTypes.func.isRequired
}; };
export default EditableInput; export default withTranslation()(EditableInput);

View file

@ -2,6 +2,7 @@ import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import CodeMirror from 'codemirror'; import CodeMirror from 'codemirror';
import beautifyJS from 'js-beautify'; import beautifyJS from 'js-beautify';
import { withTranslation } from 'react-i18next';
import 'codemirror/mode/css/css'; import 'codemirror/mode/css/css';
import 'codemirror/addon/selection/active-line'; import 'codemirror/addon/selection/active-line';
import 'codemirror/addon/lint/lint'; import 'codemirror/addon/lint/lint';
@ -148,7 +149,7 @@ class Editor extends React.Component {
}, 1000)); }, 1000));
this._cm.on('keyup', () => { this._cm.on('keyup', () => {
const temp = `line ${parseInt((this._cm.getCursor().line) + 1, 10)}`; const temp = this.props.t('Editor.KeyUpLineNumber', { lineNumber: parseInt((this._cm.getCursor().line) + 1, 10) });
document.getElementById('current-line').innerHTML = temp; document.getElementById('current-line').innerHTML = temp;
}); });
@ -329,14 +330,14 @@ class Editor extends React.Component {
<section className={editorSectionClass} > <section className={editorSectionClass} >
<header className="editor__header"> <header className="editor__header">
<button <button
aria-label="Open Sketch files navigation" aria-label={this.props.t('Editor.OpenSketchARIA')}
className="sidebar__contract" className="sidebar__contract"
onClick={this.props.collapseSidebar} onClick={this.props.collapseSidebar}
> >
<LeftArrowIcon focusable="false" aria-hidden="true" /> <LeftArrowIcon focusable="false" aria-hidden="true" />
</button> </button>
<button <button
aria-label="Close sketch files navigation" aria-label={this.props.t('Editor.CloseSketchARIA')}
className="sidebar__expand" className="sidebar__expand"
onClick={this.props.expandSidebar} onClick={this.props.expandSidebar}
> >
@ -347,7 +348,7 @@ class Editor extends React.Component {
{this.props.file.name} {this.props.file.name}
<span className="editor__unsaved-changes"> <span className="editor__unsaved-changes">
{this.props.unsavedChanges ? {this.props.unsavedChanges ?
<UnsavedChangesDotIcon role="img" aria-label="Sketch has unsaved changes" focusable="false" /> : <UnsavedChangesDotIcon role="img" aria-label={this.props.t('Editor.UnsavedChangesARIA')} focusable="false" /> :
null} null}
</span> </span>
</span> </span>
@ -415,7 +416,8 @@ Editor.propTypes = {
showRuntimeErrorWarning: PropTypes.func.isRequired, showRuntimeErrorWarning: PropTypes.func.isRequired,
hideRuntimeErrorWarning: PropTypes.func.isRequired, hideRuntimeErrorWarning: PropTypes.func.isRequired,
runtimeErrorWarningVisible: PropTypes.bool.isRequired, runtimeErrorWarningVisible: PropTypes.bool.isRequired,
provideController: PropTypes.func.isRequired provideController: PropTypes.func.isRequired,
t: PropTypes.func.isRequired
}; };
Editor.defaultProps = { Editor.defaultProps = {
@ -466,4 +468,4 @@ function mapDispatchToProps(dispatch) {
); );
} }
export default connect(mapStateToProps, mapDispatchToProps)(Editor); export default withTranslation()(connect(mapStateToProps, mapDispatchToProps)(Editor));

View file

@ -1,5 +1,6 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import { withTranslation } from 'react-i18next';
class EditorAccessibility extends React.Component { class EditorAccessibility extends React.Component {
componentDidMount() { componentDidMount() {
@ -17,14 +18,14 @@ class EditorAccessibility extends React.Component {
</li>)); </li>));
}); });
} else { } else {
messages.push(<li key={0}> There are no lint messages </li>); messages.push(<li key={0}>{this.props.t('EditorAccessibility.NoLintMessages')}</li>);
} }
return ( return (
<div className="editor-accessibility"> <div className="editor-accessibility">
<ul className="editor-lintmessages" title="lint messages"> <ul className="editor-lintmessages" title="lint messages">
{messages} {messages}
</ul> </ul>
<p> Current line <p> {this.props.t('EditorAccessibility.CurrentLine')}
<span className="editor-linenumber" aria-live="polite" aria-atomic="true" id="current-line"> </span> <span className="editor-linenumber" aria-live="polite" aria-atomic="true" id="current-line"> </span>
</p> </p>
</div> </div>
@ -39,6 +40,7 @@ EditorAccessibility.propTypes = {
message: PropTypes.string.isRequired, message: PropTypes.string.isRequired,
id: PropTypes.number.isRequired id: PropTypes.number.isRequired
})).isRequired, })).isRequired,
t: PropTypes.func.isRequired
}; };
export default EditorAccessibility; export default withTranslation()(EditorAccessibility);

View file

@ -1,12 +1,13 @@
import React from 'react'; import React from 'react';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { withTranslation } from 'react-i18next';
import GitHubLogo from '../../../images/github.svg'; import GitHubLogo from '../../../images/github.svg';
function Feedback(props) { function Feedback(props) {
return ( return (
<div className="feedback__content"> <div className="feedback__content">
<Helmet> <Helmet>
<title>p5.js Web Editor | Feedback</title> <title>{this.props.t('Feedback.Title')}</title>
</Helmet> </Helmet>
<div className="feedback__content-pane"> <div className="feedback__content-pane">
<h2 className="feedback__content-pane-header"> <h2 className="feedback__content-pane-header">
@ -47,4 +48,4 @@ function Feedback(props) {
); );
} }
export default Feedback; export default withTranslation()(Feedback);

View file

@ -67,7 +67,7 @@ FileName.propTypes = {
name: PropTypes.string.isRequired name: PropTypes.string.isRequired
}; };
export class FileNode extends React.Component { class FileNode extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
@ -419,4 +419,7 @@ const TranslatedFileNode = withTranslation()(FileNode);
const ConnectedFileNode = connect(mapStateToProps, mapDispatchToProps)(TranslatedFileNode); const ConnectedFileNode = connect(mapStateToProps, mapDispatchToProps)(TranslatedFileNode);
export default ConnectedFileNode; export {
TranslatedFileNode as FileNode,
ConnectedFileNode as default
};

View file

@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { fireEvent, render, screen, waitFor, within } from '../../../test-utils';
import { FileNode } from './FileNode'; import { FileNode } from './FileNode';
describe('<FileNode />', () => { describe('<FileNode />', () => {

View file

@ -8,7 +8,7 @@ import Icons from './Icons';
const Item = ({ const Item = ({
isAdded, onSelect, name, url, t isAdded, onSelect, name, url, t
}) => { }) => {
const buttonLabel = isAdded ? 'Remove from collection' : 'Add to collection'; const buttonLabel = isAdded ? t('QuickAddList.ButtonRemoveARIA') : t('QuickAddList.ButtonAddToCollectionARIA');
return ( return (
<li className="quick-add__item" onClick={onSelect}> { /* eslint-disable-line */ } <li className="quick-add__item" onClick={onSelect}> { /* eslint-disable-line */ }
<button className="quick-add__item-toggle" onClick={onSelect} aria-label={buttonLabel}> <button className="quick-add__item-toggle" onClick={onSelect} aria-label={buttonLabel}>

View file

@ -1,14 +1,16 @@
import { bindActionCreators } from 'redux'; import { bindActionCreators } from 'redux';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import i18next from 'i18next';
import * as SortingActions from '../../actions/sorting'; import * as SortingActions from '../../actions/sorting';
import Searchbar from './Searchbar'; import Searchbar from './Searchbar';
const scope = 'collection'; const scope = 'collection';
function mapStateToProps(state) { function mapStateToProps(state) {
return { return {
searchLabel: 'Search collections...', searchLabel: i18next.t('Searchbar.SearchCollection'),
searchTerm: state.search[`${scope}SearchTerm`], searchTerm: state.search[`${scope}SearchTerm`],
}; };
} }

View file

@ -1,9 +1,11 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import { throttle } from 'lodash'; import { throttle } from 'lodash';
import { withTranslation } from 'react-i18next';
import i18next from 'i18next';
import SearchIcon from '../../../../images/magnifyingglass.svg'; import SearchIcon from '../../../../images/magnifyingglass.svg';
class Searchbar extends React.Component { class Searchbar extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
@ -50,7 +52,7 @@ class Searchbar extends React.Component {
<button <button
className="searchbar__clear-button" className="searchbar__clear-button"
onClick={this.handleResetSearch} onClick={this.handleResetSearch}
>clear >{this.props.t('Searchbar.ClearTerm')}
</button> </button>
</div> </div>
); );
@ -62,10 +64,11 @@ Searchbar.propTypes = {
setSearchTerm: PropTypes.func.isRequired, setSearchTerm: PropTypes.func.isRequired,
resetSearchTerm: PropTypes.func.isRequired, resetSearchTerm: PropTypes.func.isRequired,
searchLabel: PropTypes.string, searchLabel: PropTypes.string,
t: PropTypes.func.isRequired
}; };
Searchbar.defaultProps = { Searchbar.defaultProps = {
searchLabel: 'Search sketches...', searchLabel: i18next.t('Searchbar.SearchSketch')
}; };
export default Searchbar; export default withTranslation()(Searchbar);

View file

@ -1,5 +1,6 @@
import { bindActionCreators } from 'redux'; import { bindActionCreators } from 'redux';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import i18next from 'i18next';
import * as SortingActions from '../../actions/sorting'; import * as SortingActions from '../../actions/sorting';
import Searchbar from './Searchbar'; import Searchbar from './Searchbar';
@ -8,6 +9,7 @@ const scope = 'sketch';
function mapStateToProps(state) { function mapStateToProps(state) {
return { return {
searchLabel: i18next.t('Searchbar.SearchSketch'),
searchTerm: state.search[`${scope}SearchTerm`], searchTerm: state.search[`${scope}SearchTerm`],
}; };
} }

View file

@ -3,6 +3,7 @@ import differenceInMilliseconds from 'date-fns/difference_in_milliseconds';
import distanceInWordsToNow from 'date-fns/distance_in_words_to_now'; import distanceInWordsToNow from 'date-fns/distance_in_words_to_now';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import { withTranslation } from 'react-i18next';
class Timer extends React.Component { class Timer extends React.Component {
constructor(props) { constructor(props) {
@ -23,17 +24,19 @@ class Timer extends React.Component {
showSavedTime() { showSavedTime() {
const now = new Date(); const now = new Date();
if (Math.abs(differenceInMilliseconds(now, this.props.projectSavedTime) < 10000)) { if (Math.abs(differenceInMilliseconds(now, this.props.projectSavedTime) < 10000)) {
return 'Saved: just now'; return this.props.t('Timer.SavedJustNow');
} else if (differenceInMilliseconds(now, this.props.projectSavedTime) < 20000) { } else if (differenceInMilliseconds(now, this.props.projectSavedTime) < 20000) {
return 'Saved: 15 seconds ago'; return this.props.t('Timer.Saved15Seconds');
} else if (differenceInMilliseconds(now, this.props.projectSavedTime) < 30000) { } else if (differenceInMilliseconds(now, this.props.projectSavedTime) < 30000) {
return 'Saved: 25 seconds ago'; return this.props.t('Timer.Saved25Seconds');
} else if (differenceInMilliseconds(now, this.props.projectSavedTime) < 46000) { } else if (differenceInMilliseconds(now, this.props.projectSavedTime) < 46000) {
return 'Saved: 35 seconds ago'; return this.props.t('Timer.Saved35Seconds');
} }
return `Saved: ${distanceInWordsToNow(this.props.projectSavedTime, {
const timeAgo = distanceInWordsToNow(this.props.projectSavedTime, {
includeSeconds: true includeSeconds: true
})} ago`; });
return this.props.t('Timer.SavedAgo', { timeAgo });
} }
render() { render() {
@ -51,11 +54,12 @@ class Timer extends React.Component {
Timer.propTypes = { Timer.propTypes = {
projectSavedTime: PropTypes.string.isRequired, projectSavedTime: PropTypes.string.isRequired,
isUserOwner: PropTypes.bool isUserOwner: PropTypes.bool,
t: PropTypes.func.isRequired
}; };
Timer.defaultProps = { Timer.defaultProps = {
isUserOwner: false isUserOwner: false
}; };
export default Timer; export default withTranslation()(Timer);

View file

@ -86,7 +86,7 @@ class Toolbar extends React.Component {
this.props.setTextOutput(true); this.props.setTextOutput(true);
this.props.setGridOutput(true); this.props.setGridOutput(true);
}} }}
aria-label="Play sketch" aria-label={this.props.t('Toolbar.PlaySketchARIA')}
disabled={this.props.infiniteLoop} disabled={this.props.infiniteLoop}
> >
<PlayIcon focusable="false" aria-hidden="true" /> <PlayIcon focusable="false" aria-hidden="true" />
@ -94,7 +94,7 @@ class Toolbar extends React.Component {
<button <button
className={playButtonClass} className={playButtonClass}
onClick={this.props.startSketch} onClick={this.props.startSketch}
aria-label="Play only visual sketch" aria-label={this.props.t('Toolbar.PlayOnlyVisualSketchARIA')}
disabled={this.props.infiniteLoop} disabled={this.props.infiniteLoop}
> >
<PlayIcon focusable="false" aria-hidden="true" /> <PlayIcon focusable="false" aria-hidden="true" />
@ -102,7 +102,7 @@ class Toolbar extends React.Component {
<button <button
className={stopButtonClass} className={stopButtonClass}
onClick={this.props.stopSketch} onClick={this.props.stopSketch}
aria-label="Stop sketch" aria-label={this.props.t('Toolbar.StopSketchARIA')}
> >
<StopIcon focusable="false" aria-hidden="true" /> <StopIcon focusable="false" aria-hidden="true" />
</button> </button>
@ -129,7 +129,7 @@ class Toolbar extends React.Component {
} }
}} }}
disabled={!canEditProjectName} disabled={!canEditProjectName}
aria-label="Edit sketch name" aria-label={this.props.t('Toolbar.EditSketchARIA')}
> >
<span>{this.props.project.name}</span> <span>{this.props.project.name}</span>
{ {
@ -145,7 +145,7 @@ class Toolbar extends React.Component {
type="text" type="text"
maxLength="128" maxLength="128"
className="toolbar__project-name-input" className="toolbar__project-name-input"
aria-label="New sketch name" aria-label={this.props.t('Toolbar.NewSketchNameARIA')}
value={this.state.projectNameInputValue} value={this.state.projectNameInputValue}
onChange={this.handleProjectNameChange} onChange={this.handleProjectNameChange}
ref={(element) => { this.projectNameInput = element; }} ref={(element) => { this.projectNameInput = element; }}
@ -165,7 +165,7 @@ class Toolbar extends React.Component {
<button <button
className={preferencesButtonClass} className={preferencesButtonClass}
onClick={this.props.openPreferences} onClick={this.props.openPreferences}
aria-label="Open Preferences" aria-label={this.props.t('Toolbar.OpenPreferencesARIA')}
> >
<PreferencesIcon focusable="false" aria-hidden="true" /> <PreferencesIcon focusable="false" aria-hidden="true" />
</button> </button>
@ -200,6 +200,7 @@ Toolbar.propTypes = {
saveProject: PropTypes.func.isRequired, saveProject: PropTypes.func.isRequired,
currentUser: PropTypes.string, currentUser: PropTypes.string,
t: PropTypes.func.isRequired t: PropTypes.func.isRequired
}; };
Toolbar.defaultProps = { Toolbar.defaultProps = {
@ -225,6 +226,5 @@ const mapDispatchToProps = {
...projectActions, ...projectActions,
}; };
export const ToolbarComponent = Toolbar; export const ToolbarComponent = withTranslation()(Toolbar);
// export default connect(mapStateToProps, mapDispatchToProps)(Toolbar); export default connect(mapStateToProps, mapDispatchToProps)(ToolbarComponent);
export default withTranslation()(connect(mapStateToProps, mapDispatchToProps)(Toolbar));

View file

@ -1,8 +1,7 @@
import React from 'react'; import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import lodash from 'lodash'; import lodash from 'lodash';
import { fireEvent, render, screen, waitFor } from '../../../test-utils';
import { ToolbarComponent } from './Toolbar'; import { ToolbarComponent } from './Toolbar';
const renderComponent = (extraProps = {}) => { const renderComponent = (extraProps = {}) => {

40
client/test-utils.js Normal file
View file

@ -0,0 +1,40 @@
/**
* This file re-exports @testing-library but ensures that
* any calls to render have translations available.
*
* This means tested components will be able to call
* `t()` and have the translations of the default
* language
*
* See: https://react.i18next.com/misc/testing#testing-without-stubbing
*/
// eslint-disable-next-line import/no-extraneous-dependencies
import { render } from '@testing-library/react';
import React from 'react';
import PropTypes from 'prop-types';
import { I18nextProvider } from 'react-i18next';
import i18n from './i18n-test';
// re-export everything
// eslint-disable-next-line import/no-extraneous-dependencies
export * from '@testing-library/react';
const Providers = ({ children }) => (
// eslint-disable-next-line react/jsx-filename-extension
<I18nextProvider i18n={i18n}>
{children}
</I18nextProvider>
);
Providers.propTypes = {
children: PropTypes.element.isRequired,
};
const customRender = (ui, options) =>
render(ui, { wrapper: Providers, ...options });
// override render method
export { customRender as render };

View file

@ -38,6 +38,22 @@ export const hijackConsoleErrorsScript = (offs) => {
}], '*'); }], '*');
return false; return false;
}; };
// catch rejected promises
window.onunhandledrejection = function (event) {
if (event.reason && event.reason.message && event.reason.stack){
var errorNum = event.reason.stack.split('about:srcdoc:')[1].split(':')[0];
var fileInfo = getScriptOff(errorNum);
var data = event.reason.message + ' (' + fileInfo[1] + ': line ' + fileInfo[0] + ')';
window.parent.postMessage([{
log: [{
method: 'error',
data: [data],
id: Date.now().toString()
}],
source: fileInfo[1]
}], '*');
}
};
`; `;
return s; return s;
}; };
@ -46,7 +62,7 @@ export const startTag = '@fs-';
export const getAllScriptOffsets = (htmlFile) => { export const getAllScriptOffsets = (htmlFile) => {
const offs = []; const offs = [];
const hijackConsoleErrorsScriptLength = 36; const hijackConsoleErrorsScriptLength = 52;
const embeddedJSStart = 'script crossorigin=""'; const embeddedJSStart = 'script crossorigin=""';
let foundJSScript = true; let foundJSScript = true;
let foundEmbeddedJS = true; let foundEmbeddedJS = true;

View file

@ -341,6 +341,30 @@
"Verified": "All done, your email address has been verified.", "Verified": "All done, your email address has been verified.",
"InvalidState": "Something went wrong." "InvalidState": "Something went wrong."
}, },
"AssetList": {
"Title": "p5.js Web Editor | My assets",
"ToggleOpenCloseARIA": "Toggle Open/Close Asset Options",
"Delete": "Delete",
"OpenNewTab": "Open in New Tab",
"NoUploadedAssets": "No uploaded assets.",
"HeaderName": "Name",
"HeaderSize": "Size",
"HeaderSketch": "Sketch"
},
"Feedback": {
"Title": "p5.js Web Editor | Feedback",
"ViaGithubHeader": "Via Github Issues",
"ViaGithubDescription": "If you're familiar with Github, this is our preferred method for receiving bug reports and feedback.",
"GoToGithub": "Go to Github",
"ViaGoogleHeader": "Via Google Form",
"ViaGoogleDescription": "You can also submit this quick form.",
"GoToForm": "Go to Form"
},
"Searchbar": {
"SearchSketch": "Search sketches...",
"SearchCollection": "Search collections...",
"ClearTerm": "clear"
},
"UploadFileModal": { "UploadFileModal": {
"Title": "Upload File", "Title": "Upload File",
"CloseButtonARIA": "Close upload file modal", "CloseButtonARIA": "Close upload file modal",
@ -444,8 +468,8 @@
"AriaLabel": "Close {{title}} overlay" "AriaLabel": "Close {{title}} overlay"
}, },
"QuickAddList":{ "QuickAddList":{
"ButtonLabelRemove": "Remove from collection", "ButtonRemoveARIA": "Remove from collection",
"ButtonLabelAddToCollection": "Add to collection", "ButtonAddToCollectionARIA": "Add to collection",
"View": "View" "View": "View"
}, },
"SketchList": { "SketchList": {
@ -474,5 +498,38 @@
"Title": "p5.js Web Editor | My sketches", "Title": "p5.js Web Editor | My sketches",
"AnothersTitle": "p5.js Web Editor | {{anotheruser}}'s sketches", "AnothersTitle": "p5.js Web Editor | {{anotheruser}}'s sketches",
"NoCollections": "No collections." "NoCollections": "No collections."
},
"Editor": {
"OpenSketchARIA": "Open Sketch files navigation",
"CloseSketchARIA": "Close Sketch files navigation",
"UnsavedChangesARIA": "Sketch has unsaved changes",
"KeyUpLineNumber": "line {{lineNumber}}"
},
"EditorAccessibility": {
"NoLintMessages": "There are no lint messages ",
"CurrentLine": " Current line"
},
"Timer": {
"SavedJustNow": "Saved: just now",
"Saved15Seconds": "Saved: 15 seconds ago",
"Saved25Seconds": "Saved: 25 seconds ago",
"Saved35Seconds": "Saved: 35 seconds ago",
"SavedAgo": "Saved: {{timeAgo}} ago"
},
"AddRemoveButton": {
"AltAddARIA": "Add to collection",
"AltRemoveARIA": "Remove from collection"
},
"CopyableInput": {
"CopiedARIA": "Copied to Clipboard!",
"OpenViewTabARIA": "Open {{label}} view in new tab"
},
"EditableInput": {
"EditValue": "Edit {{display}} value",
"EmptyPlaceholder": "No value"
},
"PreviewNav": {
"EditSketchARIA": "Edit Sketch",
"ByUser": "by"
} }
} }

View file

@ -341,6 +341,30 @@
"Verified": "Concluido, tu correo electrónico ha sido verificado.", "Verified": "Concluido, tu correo electrónico ha sido verificado.",
"InvalidState": "Algo salió mal." "InvalidState": "Algo salió mal."
}, },
"AssetList": {
"Title": "Editor Web p5.js | Mis assets",
"ToggleOpenCloseARIA": "Alternar Abrir/Cerrar Opciones de Asset",
"Delete": "Borrar",
"OpenNewTab": "Abrir en Nueva Pestaña",
"NoUploadedAssets": "No has subido archivos.",
"HeaderName": "Nombre",
"HeaderSize": "Tamaño",
"HeaderSketch": "Bosquejo"
},
"Feedback": {
"Title": "Editor Web p5.js | Retroalimentación",
"ViaGithubHeader": "Vía Github Issues",
"ViaGithubDescription": " Si estas familiarizado con Github, este es nuestro método favorito para recibir reporte de errores y retroalimentación.",
"GoToGithub": "Ir a Github",
"ViaGoogleHeader": "Vía Formulario de Google",
"ViaGoogleDescription": "También puedes enviar tu retroalimetnación usando esta vía rapida.",
"GoToForm": "Ir a Formulario"
},
"Searchbar": {
"SearchSketch": "Buscar en bosquejos...",
"SearchCollection": "Buscar en colecciones...",
"ClearTerm": "limpiar"
},
"UploadFileModal": { "UploadFileModal": {
"Title": "Subir Archivo", "Title": "Subir Archivo",
"CloseButtonARIA": "Cerrar diálogo para subir archivo", "CloseButtonARIA": "Cerrar diálogo para subir archivo",
@ -444,8 +468,8 @@
"AriaLabel": "Cerrar la capa {{title}}" "AriaLabel": "Cerrar la capa {{title}}"
}, },
"QuickAddList":{ "QuickAddList":{
"ButtonLabelRemove": "Remove from collection", "ButtonRemoveARIA": "Remover de colección",
"ButtonLabelAddToCollection": "Add to collection", "ButtonAddToCollectionARIA": "Agregar a colección",
"View": "Ver" "View": "Ver"
}, },
"SketchList": { "SketchList": {
@ -474,5 +498,38 @@
"Title": "p5.js Web Editor | Mis bosquejos", "Title": "p5.js Web Editor | Mis bosquejos",
"AnothersTitle": "Editor Web p5.js | Bosquejos de {{anotheruser}}", "AnothersTitle": "Editor Web p5.js | Bosquejos de {{anotheruser}}",
"NoCollections": "No hay colecciones." "NoCollections": "No hay colecciones."
},
"Editor": {
"OpenSketchARIA": "Abrir navegación de archivos de bosquejo",
"CloseSketchARIA": "Cerrar navegación de archivos de bosquejo",
"UnsavedChangesARIA": "El bosquejo tiene cambios sin guardar",
"KeyUpLineNumber": "línea {{lineNumber}}"
},
"EditorAccessibility": {
"NoLintMessages": "No hay mensajes de Lint",
"CurrentLine": " Línea actual"
},
"Timer": {
"SavedJustNow": "Guardado: justo ahora",
"Saved15Seconds": "Guardado: hace 15 segundos",
"Saved25Seconds": "Guardado: hace 25 segundos",
"Saved35Seconds": "Guardado: hace 35 segundos",
"SavedAgo": "Guardado: hace {{timeAgo}}"
},
"AddRemoveButton": {
"AltAddARIA": "Agregar a colección",
"AltRemoveARIA": "Remover de colección"
},
"CopyableInput": {
"CopiedARIA": "¡Copiado en el portapapeles!",
"OpenViewTabARIA": "Abrir la vista {{label}} en nueva pestaña"
},
"EditableInput": {
"EditValue": "Editar valor de {{display}}",
"EmptyPlaceholder": "Sin valor"
},
"PreviewNav": {
"EditSketchARIA": "Editar Bosquejo",
"ByUser": "por"
} }
} }