Merge branch 'release-1.0.4' into release

This commit is contained in:
Cassie Tarakajian 2020-07-13 16:09:36 -04:00
commit 9767a749cd
77 changed files with 9185 additions and 2869 deletions

3
.github/FUNDING.yml vendored
View file

@ -1 +1,2 @@
custom: https://processingfoundation.org/support github: processing
custom: https://processingfoundation.org/

View file

@ -14,6 +14,7 @@ COPY .babelrc index.js nodemon.json ./
COPY ./webpack ./webpack COPY ./webpack ./webpack
COPY client ./client COPY client ./client
COPY server ./server COPY server ./server
COPY translations/locales ./translations/locales
CMD ["npm", "start"] CMD ["npm", "start"]
FROM development as build FROM development as build

View file

@ -1,51 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import SortArrowUp from '../images/sort-arrow-up.svg';
import SortArrowDown from '../images/sort-arrow-down.svg';
import Github from '../images/github.svg';
import Google from '../images/google.svg';
import Plus from '../images/plus-icon.svg';
import Close from '../images/close.svg';
import DropdownArrow from '../images/down-filled-triangle.svg';
// HOC that adds the right web accessibility props
// https://www.scottohara.me/blog/2019/05/22/contextual-images-svgs-and-a11y.html
// could also give these a default size, color, etc. based on the theme
// Need to add size to these - like small icon, medium icon, large icon. etc.
function withLabel(SvgComponent) {
const Icon = (props) => {
const { 'aria-label': ariaLabel } = props;
if (ariaLabel) {
return (<SvgComponent
{...props}
aria-label={ariaLabel}
role="img"
focusable="false"
/>);
}
return (<SvgComponent
{...props}
aria-hidden
focusable="false"
/>);
};
Icon.propTypes = {
'aria-label': PropTypes.string
};
Icon.defaultProps = {
'aria-label': null
};
return Icon;
}
export const SortArrowUpIcon = withLabel(SortArrowUp);
export const SortArrowDownIcon = withLabel(SortArrowDown);
export const GithubIcon = withLabel(Github);
export const GoogleIcon = withLabel(Google);
export const PlusIcon = withLabel(Plus);
export const CloseIcon = withLabel(Close);
export const DropdownArrowIcon = withLabel(DropdownArrow);

View file

@ -1,18 +0,0 @@
import React from 'react';
import { select } from '@storybook/addon-knobs';
import * as icons from './icons';
export default {
title: 'Common/Icons',
component: icons
};
export const AllIcons = () => {
const names = Object.keys(icons);
const SelectedIcon = icons[select('name', names, names[0])];
return (
<SelectedIcon />
);
};

View file

@ -1,12 +1,17 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import styled from 'styled-components';
import { prop } from '../theme';
import SortArrowUp from '../images/sort-arrow-up.svg'; import SortArrowUp from '../images/sort-arrow-up.svg';
import SortArrowDown from '../images/sort-arrow-down.svg'; import SortArrowDown from '../images/sort-arrow-down.svg';
import Github from '../images/github.svg'; import Github from '../images/github.svg';
import Google from '../images/google.svg'; import Google from '../images/google.svg';
import Plus from '../images/plus-icon.svg'; import Plus from '../images/plus-icon.svg';
import Close from '../images/close.svg'; import Close from '../images/close.svg';
import Exit from '../images/exit.svg';
import DropdownArrow from '../images/down-filled-triangle.svg'; import DropdownArrow from '../images/down-filled-triangle.svg';
import Preferences from '../images/preferences.svg';
import Play from '../images/triangle-arrow-right.svg';
// HOC that adds the right web accessibility props // HOC that adds the right web accessibility props
// https://www.scottohara.me/blog/2019/05/22/contextual-images-svgs-and-a11y.html // https://www.scottohara.me/blog/2019/05/22/contextual-images-svgs-and-a11y.html
@ -15,16 +20,33 @@ import DropdownArrow from '../images/down-filled-triangle.svg';
// Need to add size to these - like small icon, medium icon, large icon. etc. // Need to add size to these - like small icon, medium icon, large icon. etc.
function withLabel(SvgComponent) { function withLabel(SvgComponent) {
const Icon = (props) => { const Icon = (props) => {
const StyledIcon = styled(SvgComponent)`
&&& {
color: ${prop('Icon.default')};
& g, & path, & polygon {
opacity: 1;
fill: ${prop('Icon.default')};
}
&:hover {
color: ${prop('Icon.hover')};
& g, & path, & polygon {
opacity: 1;
fill: ${prop('Icon.hover')};
}
}
}
`;
const { 'aria-label': ariaLabel } = props; const { 'aria-label': ariaLabel } = props;
if (ariaLabel) { if (ariaLabel) {
return (<SvgComponent return (<StyledIcon
{...props} {...props}
aria-label={ariaLabel} aria-label={ariaLabel}
role="img" role="img"
focusable="false" focusable="false"
/>); />);
} }
return (<SvgComponent return (<StyledIcon
{...props} {...props}
aria-hidden aria-hidden
focusable="false" focusable="false"
@ -48,4 +70,7 @@ export const GithubIcon = withLabel(Github);
export const GoogleIcon = withLabel(Google); export const GoogleIcon = withLabel(Google);
export const PlusIcon = withLabel(Plus); export const PlusIcon = withLabel(Plus);
export const CloseIcon = withLabel(Close); export const CloseIcon = withLabel(Close);
export const ExitIcon = withLabel(Exit);
export const DropdownArrowIcon = withLabel(DropdownArrow); export const DropdownArrowIcon = withLabel(DropdownArrow);
export const PreferencesIcon = withLabel(Preferences);
export const PlayIcon = withLabel(Play);

View file

@ -4,20 +4,21 @@ import { connect } from 'react-redux';
import { withRouter } from 'react-router'; 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 i18next from 'i18next';
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';
import { setAllAccessibleOutput } from '../modules/IDE/actions/preferences'; import { setAllAccessibleOutput } from '../modules/IDE/actions/preferences';
import { logoutUser } from '../modules/User/actions'; import { logoutUser } from '../modules/User/actions';
import getConfig from '../utils/getConfig';
import { metaKeyName, } from '../utils/metaKey'; import { metaKeyName, } from '../utils/metaKey';
import CaretLeftIcon from '../images/left-arrow.svg'; import CaretLeftIcon from '../images/left-arrow.svg';
import TriangleIcon from '../images/down-filled-triangle.svg'; import TriangleIcon from '../images/down-filled-triangle.svg';
import LogoIcon from '../images/p5js-logo-small.svg'; import LogoIcon from '../images/p5js-logo-small.svg';
const __process = (typeof global !== 'undefined' ? global : window).process;
class Nav extends React.PureComponent { class Nav extends React.PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
@ -56,6 +57,10 @@ class Nav extends React.PureComponent {
this.handleFocusForHelp = this.handleFocus.bind(this, 'help'); this.handleFocusForHelp = this.handleFocus.bind(this, 'help');
this.toggleDropdownForAccount = this.toggleDropdown.bind(this, 'account'); this.toggleDropdownForAccount = this.toggleDropdown.bind(this, 'account');
this.handleFocusForAccount = this.handleFocus.bind(this, 'account'); this.handleFocusForAccount = this.handleFocus.bind(this, 'account');
this.toggleDropdownForLang = this.toggleDropdown.bind(this, 'lang');
this.handleFocusForLang = this.handleFocus.bind(this, 'lang');
this.handleLangSelection = this.handleLangSelection.bind(this);
this.closeDropDown = this.closeDropDown.bind(this); this.closeDropDown = this.closeDropDown.bind(this);
} }
@ -164,6 +169,13 @@ class Nav extends React.PureComponent {
this.setDropdown('none'); this.setDropdown('none');
} }
handleLangSelection(event) {
i18next.changeLanguage(event.target.value);
this.props.showToast(1500);
this.props.setToastText('LangChange');
this.setDropdown('none');
}
handleLogout() { handleLogout() {
this.props.logoutUser(); this.props.logoutUser();
this.setDropdown('none'); this.setDropdown('none');
@ -234,7 +246,7 @@ class Nav extends React.PureComponent {
<Link to="/" className="nav__back-link"> <Link to="/" className="nav__back-link">
<CaretLeftIcon className="nav__back-icon" focusable="false" aria-hidden="true" /> <CaretLeftIcon className="nav__back-icon" focusable="false" aria-hidden="true" />
<span className="nav__item-header"> <span className="nav__item-header">
Back to Editor {this.props.t('BackEditor')}
</span> </span>
</Link> </Link>
</li> </li>
@ -259,7 +271,7 @@ class Nav extends React.PureComponent {
} }
}} }}
> >
<span className="nav__item-header">File</span> <span className="nav__item-header">{this.props.t('File')}</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">
@ -269,17 +281,17 @@ class Nav extends React.PureComponent {
onFocus={this.handleFocusForFile} onFocus={this.handleFocusForFile}
onBlur={this.handleBlur} onBlur={this.handleBlur}
> >
New {this.props.t('New')}
</button> </button>
</li> </li>
{ __process.env.LOGIN_ENABLED && (!this.props.project.owner || this.isUserOwner()) && { getConfig('LOGIN_ENABLED') && (!this.props.project.owner || this.isUserOwner()) &&
<li className="nav__dropdown-item"> <li className="nav__dropdown-item">
<button <button
onClick={this.handleSave} onClick={this.handleSave}
onFocus={this.handleFocusForFile} onFocus={this.handleFocusForFile}
onBlur={this.handleBlur} onBlur={this.handleBlur}
> >
Save {this.props.t('Save')}
<span className="nav__keyboard-shortcut">{metaKeyName}+S</span> <span className="nav__keyboard-shortcut">{metaKeyName}+S</span>
</button> </button>
</li> } </li> }
@ -290,7 +302,7 @@ class Nav extends React.PureComponent {
onFocus={this.handleFocusForFile} onFocus={this.handleFocusForFile}
onBlur={this.handleBlur} onBlur={this.handleBlur}
> >
Duplicate {this.props.t('Duplicate')}
</button> </button>
</li> } </li> }
{ this.props.project.id && { this.props.project.id &&
@ -300,7 +312,7 @@ class Nav extends React.PureComponent {
onFocus={this.handleFocusForFile} onFocus={this.handleFocusForFile}
onBlur={this.handleBlur} onBlur={this.handleBlur}
> >
Share {this.props.t('Share')}
</button> </button>
</li> } </li> }
{ this.props.project.id && { this.props.project.id &&
@ -310,7 +322,7 @@ class Nav extends React.PureComponent {
onFocus={this.handleFocusForFile} onFocus={this.handleFocusForFile}
onBlur={this.handleBlur} onBlur={this.handleBlur}
> >
Download {this.props.t('Download')}
</button> </button>
</li> } </li> }
{ this.props.user.authenticated && { this.props.user.authenticated &&
@ -321,10 +333,10 @@ class Nav extends React.PureComponent {
onBlur={this.handleBlur} onBlur={this.handleBlur}
onClick={this.setDropdownForNone} onClick={this.setDropdownForNone}
> >
Open {this.props.t('Open')}
</Link> </Link>
</li> } </li> }
{__process.env.UI_COLLECTIONS_ENABLED && {getConfig('UI_COLLECTIONS_ENABLED') &&
this.props.user.authenticated && this.props.user.authenticated &&
this.props.project.id && this.props.project.id &&
<li className="nav__dropdown-item"> <li className="nav__dropdown-item">
@ -334,10 +346,10 @@ class Nav extends React.PureComponent {
onBlur={this.handleBlur} onBlur={this.handleBlur}
onClick={this.setDropdownForNone} onClick={this.setDropdownForNone}
> >
Add to Collection {this.props.t('AddToCollection')}
</Link> </Link>
</li>} </li>}
{ __process.env.EXAMPLES_ENABLED && { getConfig('EXAMPLES_ENABLED') &&
<li className="nav__dropdown-item"> <li className="nav__dropdown-item">
<Link <Link
to="/p5/sketches" to="/p5/sketches"
@ -345,7 +357,7 @@ class Nav extends React.PureComponent {
onBlur={this.handleBlur} onBlur={this.handleBlur}
onClick={this.setDropdownForNone} onClick={this.setDropdownForNone}
> >
Examples {this.props.t('Examples')}
</Link> </Link>
</li> } </li> }
</ul> </ul>
@ -361,7 +373,7 @@ class Nav extends React.PureComponent {
} }
}} }}
> >
<span className="nav__item-header">Edit</span> <span className="nav__item-header">{this.props.t('Edit')}</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" >
@ -374,7 +386,7 @@ class Nav extends React.PureComponent {
onFocus={this.handleFocusForEdit} onFocus={this.handleFocusForEdit}
onBlur={this.handleBlur} onBlur={this.handleBlur}
> >
Tidy Code {this.props.t('TidyCode')}
<span className="nav__keyboard-shortcut">{'\u21E7'}+Tab</span> <span className="nav__keyboard-shortcut">{'\u21E7'}+Tab</span>
</button> </button>
</li> </li>
@ -384,7 +396,7 @@ class Nav extends React.PureComponent {
onFocus={this.handleFocusForEdit} onFocus={this.handleFocusForEdit}
onBlur={this.handleBlur} onBlur={this.handleBlur}
> >
Find {this.props.t('Find')}
<span className="nav__keyboard-shortcut">{metaKeyName}+F</span> <span className="nav__keyboard-shortcut">{metaKeyName}+F</span>
</button> </button>
</li> </li>
@ -394,7 +406,7 @@ class Nav extends React.PureComponent {
onFocus={this.handleFocusForEdit} onFocus={this.handleFocusForEdit}
onBlur={this.handleBlur} onBlur={this.handleBlur}
> >
Find Next {this.props.t('FindNext')}
<span className="nav__keyboard-shortcut">{metaKeyName}+G</span> <span className="nav__keyboard-shortcut">{metaKeyName}+G</span>
</button> </button>
</li> </li>
@ -404,7 +416,7 @@ class Nav extends React.PureComponent {
onFocus={this.handleFocusForEdit} onFocus={this.handleFocusForEdit}
onBlur={this.handleBlur} onBlur={this.handleBlur}
> >
Find Previous {this.props.t('FindPrevious')}
<span className="nav__keyboard-shortcut">{'\u21E7'}+{metaKeyName}+G</span> <span className="nav__keyboard-shortcut">{'\u21E7'}+{metaKeyName}+G</span>
</button> </button>
</li> </li>
@ -421,7 +433,7 @@ class Nav extends React.PureComponent {
} }
}} }}
> >
<span className="nav__item-header">Sketch</span> <span className="nav__item-header">{this.props.t('Sketch')}</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">
@ -431,7 +443,7 @@ class Nav extends React.PureComponent {
onFocus={this.handleFocusForSketch} onFocus={this.handleFocusForSketch}
onBlur={this.handleBlur} onBlur={this.handleBlur}
> >
Add File {this.props.t('AddFile')}
</button> </button>
</li> </li>
<li className="nav__dropdown-item"> <li className="nav__dropdown-item">
@ -440,7 +452,7 @@ class Nav extends React.PureComponent {
onFocus={this.handleFocusForSketch} onFocus={this.handleFocusForSketch}
onBlur={this.handleBlur} onBlur={this.handleBlur}
> >
Add Folder {this.props.t('AddFolder')}
</button> </button>
</li> </li>
<li className="nav__dropdown-item"> <li className="nav__dropdown-item">
@ -449,7 +461,7 @@ class Nav extends React.PureComponent {
onFocus={this.handleFocusForSketch} onFocus={this.handleFocusForSketch}
onBlur={this.handleBlur} onBlur={this.handleBlur}
> >
Run {this.props.t('Run')}
<span className="nav__keyboard-shortcut">{metaKeyName}+Enter</span> <span className="nav__keyboard-shortcut">{metaKeyName}+Enter</span>
</button> </button>
</li> </li>
@ -459,7 +471,7 @@ class Nav extends React.PureComponent {
onFocus={this.handleFocusForSketch} onFocus={this.handleFocusForSketch}
onBlur={this.handleBlur} onBlur={this.handleBlur}
> >
Stop {this.props.t('Stop')}
<span className="nav__keyboard-shortcut">{'\u21E7'}+{metaKeyName}+Enter</span> <span className="nav__keyboard-shortcut">{'\u21E7'}+{metaKeyName}+Enter</span>
</button> </button>
</li> </li>
@ -496,7 +508,7 @@ class Nav extends React.PureComponent {
} }
}} }}
> >
<span className="nav__item-header">Help</span> <span className="nav__item-header">{this.props.t('Help')}</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">
@ -506,7 +518,7 @@ class Nav extends React.PureComponent {
onBlur={this.handleBlur} onBlur={this.handleBlur}
onClick={this.handleKeyboardShortcuts} onClick={this.handleKeyboardShortcuts}
> >
Keyboard Shortcuts {this.props.t('KeyboardShortcuts')}
</button> </button>
</li> </li>
<li className="nav__dropdown-item"> <li className="nav__dropdown-item">
@ -517,7 +529,7 @@ class Nav extends React.PureComponent {
onFocus={this.handleFocusForHelp} onFocus={this.handleFocusForHelp}
onBlur={this.handleBlur} onBlur={this.handleBlur}
onClick={this.setDropdownForNone} onClick={this.setDropdownForNone}
>Reference >{this.props.t('Reference')}
</a> </a>
</li> </li>
<li className="nav__dropdown-item"> <li className="nav__dropdown-item">
@ -527,7 +539,7 @@ class Nav extends React.PureComponent {
onBlur={this.handleBlur} onBlur={this.handleBlur}
onClick={this.setDropdownForNone} onClick={this.setDropdownForNone}
> >
About {this.props.t('About')}
</Link> </Link>
</li> </li>
</ul> </ul>
@ -536,18 +548,73 @@ class Nav extends React.PureComponent {
); );
} }
renderLanguageMenu(navDropdownState) {
return (
<ul className="nav__items-right" title="user-menu">
<li className={navDropdownState.lang}>
<button
onClick={this.toggleDropdownForLang}
onBlur={this.handleBlur}
onFocus={this.clearHideTimeout}
onMouseOver={() => {
if (this.state.dropdownOpen !== 'none') {
this.setDropdown('lang');
}
}}
>
<span className="nav__item-header"> {this.props.t('Lang')}</span>
<TriangleIcon className="nav__item-header-triangle" focusable="false" aria-hidden="true" />
</button>
<ul className="nav__dropdown">
<li className="nav__dropdown-item">
<button
onFocus={this.handleFocusForLang}
onBlur={this.handleBlur}
value="it"
onClick={e => this.handleLangSelection(e)}
>
Italian (Test Fallback)
</button>
</li>
<li className="nav__dropdown-item">
<button
onFocus={this.handleFocusForLang}
onBlur={this.handleBlur}
value="en-US"
onClick={e => this.handleLangSelection(e)}
>English
</button>
</li>
<li className="nav__dropdown-item">
<button
onFocus={this.handleFocusForLang}
onBlur={this.handleBlur}
value="es-419"
onClick={e => this.handleLangSelection(e)}
>
Español
</button>
</li>
</ul>
</li>
</ul>
);
}
renderUnauthenticatedUserMenu(navDropdownState) { renderUnauthenticatedUserMenu(navDropdownState) {
return ( return (
<ul className="nav__items-right" title="user-menu"> <ul className="nav__items-right" title="user-menu">
<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">Log in</span> <span className="nav__item-header">{this.props.t('Login')}</span>
</Link> </Link>
</li> </li>
<span className="nav__item-or">or</span> <span className="nav__item-or">{this.props.t('LoginOr')}</span>
<li className="nav__item"> <li className="nav__item">
<Link to="/signup" className="nav__auth-button"> <Link to="/signup" className="nav__auth-button">
<span className="nav__item-header">Sign up</span> <span className="nav__item-header">{this.props.t('SignUp')}</span>
</Link> </Link>
</li> </li>
</ul> </ul>
@ -558,7 +625,7 @@ class Nav extends React.PureComponent {
return ( return (
<ul className="nav__items-right" title="user-menu"> <ul className="nav__items-right" title="user-menu">
<li className="nav__item"> <li className="nav__item">
<span>Hello, {this.props.user.username}!</span> <span>{this.props.t('Hello')}, {this.props.user.username}!</span>
</li> </li>
<span className="nav__item-spacer">|</span> <span className="nav__item-spacer">|</span>
<li className={navDropdownState.account}> <li className={navDropdownState.account}>
@ -573,7 +640,7 @@ class Nav extends React.PureComponent {
} }
}} }}
> >
My Account {this.props.t('MyAccount')}
<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">
@ -584,10 +651,10 @@ class Nav extends React.PureComponent {
onBlur={this.handleBlur} onBlur={this.handleBlur}
onClick={this.setDropdownForNone} onClick={this.setDropdownForNone}
> >
My sketches {this.props.t('MySketches')}
</Link> </Link>
</li> </li>
{__process.env.UI_COLLECTIONS_ENABLED && {getConfig('UI_COLLECTIONS_ENABLED') &&
<li className="nav__dropdown-item"> <li className="nav__dropdown-item">
<Link <Link
to={`/${this.props.user.username}/collections`} to={`/${this.props.user.username}/collections`}
@ -595,7 +662,7 @@ class Nav extends React.PureComponent {
onBlur={this.handleBlur} onBlur={this.handleBlur}
onClick={this.setDropdownForNone} onClick={this.setDropdownForNone}
> >
My collections {this.props.t('MyCollections')}
</Link> </Link>
</li> </li>
} }
@ -606,7 +673,7 @@ class Nav extends React.PureComponent {
onBlur={this.handleBlur} onBlur={this.handleBlur}
onClick={this.setDropdownForNone} onClick={this.setDropdownForNone}
> >
My assets {this.props.t('MyAssets')}
</Link> </Link>
</li> </li>
<li className="nav__dropdown-item"> <li className="nav__dropdown-item">
@ -616,7 +683,7 @@ class Nav extends React.PureComponent {
onBlur={this.handleBlur} onBlur={this.handleBlur}
onClick={this.setDropdownForNone} onClick={this.setDropdownForNone}
> >
Settings {this.props.t('Settings')}
</Link> </Link>
</li> </li>
<li className="nav__dropdown-item"> <li className="nav__dropdown-item">
@ -625,7 +692,7 @@ class Nav extends React.PureComponent {
onFocus={this.handleFocusForAccount} onFocus={this.handleFocusForAccount}
onBlur={this.handleBlur} onBlur={this.handleBlur}
> >
Log out {this.props.t('LogOut')}
</button> </button>
</li> </li>
</ul> </ul>
@ -635,7 +702,7 @@ class Nav extends React.PureComponent {
} }
renderUserMenu(navDropdownState) { renderUserMenu(navDropdownState) {
const isLoginEnabled = __process.env.LOGIN_ENABLED; const isLoginEnabled = getConfig('LOGIN_ENABLED');
const isAuthenticated = this.props.user.authenticated; const isAuthenticated = this.props.user.authenticated;
if (isLoginEnabled && isAuthenticated) { if (isLoginEnabled && isAuthenticated) {
@ -678,6 +745,10 @@ class Nav extends React.PureComponent {
account: classNames({ account: classNames({
'nav__item': true, 'nav__item': true,
'nav__item--open': this.state.dropdownOpen === 'account' 'nav__item--open': this.state.dropdownOpen === 'account'
}),
lang: classNames({
'nav__item': true,
'nav__item--open': this.state.dropdownOpen === 'lang'
}) })
}; };
@ -685,6 +756,7 @@ 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>
@ -735,7 +807,9 @@ Nav.propTypes = {
}).isRequired, }).isRequired,
params: PropTypes.shape({ params: PropTypes.shape({
username: PropTypes.string username: PropTypes.string
}) }),
t: PropTypes.func.isRequired
}; };
Nav.defaultProps = { Nav.defaultProps = {
@ -768,5 +842,5 @@ const mapDispatchToProps = {
setAllAccessibleOutput setAllAccessibleOutput
}; };
export default withRouter(connect(mapStateToProps, mapDispatchToProps)(Nav)); export default withTranslation('WebEditor')(withRouter(connect(mapStateToProps, mapDispatchToProps)(Nav)));
export { Nav as NavComponent }; export { Nav as NavComponent };

View file

@ -1,183 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import { FileNode } from '../../modules/IDE/components/FileNode';
describe('<FileNode />', () => {
let component;
let props = {};
let input;
let renameTriggerButton;
const changeName = (newFileName) => {
renameTriggerButton.simulate('click');
input.simulate('change', { target: { value: newFileName } });
input.simulate('blur');
};
const getState = () => component.state();
const getUpdatedName = () => getState().updatedName;
describe('with valid props, regardless of filetype', () => {
['folder', 'file'].forEach((fileType) => {
beforeEach(() => {
props = {
...props,
id: '0',
name: 'test.jsx',
fileType,
canEdit: true,
children: [],
authenticated: false,
setSelectedFile: jest.fn(),
deleteFile: jest.fn(),
updateFileName: jest.fn(),
resetSelectedFile: jest.fn(),
newFile: jest.fn(),
newFolder: jest.fn(),
showFolderChildren: jest.fn(),
hideFolderChildren: jest.fn(),
openUploadFileModal: jest.fn(),
setProjectName: jest.fn(),
};
component = shallow(<FileNode {...props} />);
});
describe('when changing name', () => {
beforeEach(() => {
input = component.find('.sidebar__file-item-input');
renameTriggerButton = component
.find('.sidebar__file-item-option')
.first();
component.setState({ isEditing: true });
});
describe('to an empty name', () => {
const newName = '';
beforeEach(() => changeName(newName));
it('should not save', () => expect(props.updateFileName).not.toHaveBeenCalled());
it('should reset name', () => expect(getUpdatedName()).toEqual(props.name));
});
});
});
});
describe('as file with valid props', () => {
beforeEach(() => {
props = {
...props,
id: '0',
name: 'test.jsx',
fileType: 'file',
canEdit: true,
children: [],
authenticated: false,
setSelectedFile: jest.fn(),
deleteFile: jest.fn(),
updateFileName: jest.fn(),
resetSelectedFile: jest.fn(),
newFile: jest.fn(),
newFolder: jest.fn(),
showFolderChildren: jest.fn(),
hideFolderChildren: jest.fn(),
openUploadFileModal: jest.fn()
};
component = shallow(<FileNode {...props} />);
});
describe('when changing name', () => {
beforeEach(() => {
input = component.find('.sidebar__file-item-input');
renameTriggerButton = component
.find('.sidebar__file-item-option')
.first();
component.setState({ isEditing: true });
});
it('should render', () => expect(component).toBeDefined());
// it('should debug', () => console.log(component.debug()));
describe('to a valid filename', () => {
const newName = 'newname.jsx';
beforeEach(() => changeName(newName));
it('should save the name', () => {
expect(props.updateFileName).toBeCalledWith(props.id, newName);
});
});
// Failure Scenarios
describe('to an extensionless filename', () => {
const newName = 'extensionless';
beforeEach(() => changeName(newName));
});
it('should not save', () => expect(props.setProjectName).not.toHaveBeenCalled());
it('should reset name', () => expect(getUpdatedName()).toEqual(props.name));
describe('to different extension', () => {
const newName = 'name.gif';
beforeEach(() => changeName(newName));
it('should not save', () => expect(props.setProjectName).not.toHaveBeenCalled());
it('should reset name', () => expect(getUpdatedName()).toEqual(props.name));
});
describe('to just an extension', () => {
const newName = '.jsx';
beforeEach(() => changeName(newName));
it('should not save', () => expect(props.updateFileName).not.toHaveBeenCalled());
it('should reset name', () => expect(getUpdatedName()).toEqual(props.name));
});
});
});
describe('as folder with valid props', () => {
beforeEach(() => {
props = {
...props,
id: '0',
children: [],
name: 'filename',
fileType: 'folder',
canEdit: true,
authenticated: false,
setSelectedFile: jest.fn(),
deleteFile: jest.fn(),
updateFileName: jest.fn(),
resetSelectedFile: jest.fn(),
newFile: jest.fn(),
newFolder: jest.fn(),
showFolderChildren: jest.fn(),
hideFolderChildren: jest.fn(),
openUploadFileModal: jest.fn()
};
component = shallow(<FileNode {...props} />);
});
describe('when changing name', () => {
beforeEach(() => {
input = component.find('.sidebar__file-item-input');
renameTriggerButton = component
.find('.sidebar__file-item-option')
.first();
component.setState({ isEditing: true });
});
describe('to a foldername', () => {
const newName = 'newfoldername';
beforeEach(() => changeName(newName));
it('should save', () => expect(props.updateFileName).toBeCalledWith(props.id, newName));
it('should update name', () => expect(getUpdatedName()).toEqual(newName));
});
describe('to a filename', () => {
const newName = 'filename.jsx';
beforeEach(() => changeName(newName));
it('should not save', () => expect(props.updateFileName).not.toHaveBeenCalled());
it('should reset name', () => expect(getUpdatedName()).toEqual(props.name));
});
});
});
});

View file

@ -1,9 +1,9 @@
import React from 'react'; import React from 'react';
import { shallow } from 'enzyme'; import { render } from '@testing-library/react';
import renderer from 'react-test-renderer';
import { NavComponent } from './../Nav';
import { NavComponent } from '../Nav';
describe('Nav', () => { describe('Nav', () => {
const props = { const props = {
@ -44,19 +44,12 @@ describe('Nav', () => {
setToastText: jest.fn(), setToastText: jest.fn(),
rootFile: { rootFile: {
id: 'root-file' id: 'root-file'
} },
t: jest.fn()
}; };
const getWrapper = () => shallow(<NavComponent {...props} />);
test('it renders main navigation', () => {
const nav = getWrapper();
expect(nav.exists('.nav')).toEqual(true);
});
it('renders correctly', () => { it('renders correctly', () => {
const tree = renderer const { asFragment } = render(<NavComponent {...props} />);
.create(<NavComponent {...props} />) expect(asFragment()).toMatchSnapshot();
.toJSON();
expect(tree).toMatchSnapshot();
}); });
}); });

View file

@ -1,105 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import { ToolbarComponent } from '../../modules/IDE/components/Toolbar';
const initialProps = {
isPlaying: false,
preferencesIsVisible: false,
stopSketch: jest.fn(),
setProjectName: jest.fn(),
openPreferences: jest.fn(),
showEditProjectName: jest.fn(),
hideEditProjectName: jest.fn(),
infiniteLoop: false,
autorefresh: false,
setAutorefresh: jest.fn(),
setTextOutput: jest.fn(),
setGridOutput: jest.fn(),
startSketch: jest.fn(),
startAccessibleSketch: jest.fn(),
saveProject: jest.fn(),
currentUser: 'me',
originalProjectName: 'testname',
owner: {
username: 'me'
},
project: {
name: 'testname',
isEditingName: false,
id: 'id',
},
};
describe('<ToolbarComponent />', () => {
let component;
let props = initialProps;
let input;
let renameTriggerButton;
const changeName = (newFileName) => {
component.find('.toolbar__project-name').simulate('click', { preventDefault: jest.fn() });
input = component.find('.toolbar__project-name-input');
renameTriggerButton = component.find('.toolbar__edit-name-button');
renameTriggerButton.simulate('click');
input.simulate('change', { target: { value: newFileName } });
input.simulate('blur');
};
const setProps = (additionalProps) => {
props = {
...props,
...additionalProps,
project: {
...props.project,
...(additionalProps || {}).project
},
};
};
// Test Cases
describe('with valid props', () => {
beforeEach(() => {
setProps();
component = shallow(<ToolbarComponent {...props} />);
});
it('renders', () => expect(component).toBeDefined());
describe('when use owns sketch', () => {
beforeEach(() => setProps({ currentUser: props.owner.username }));
describe('when changing sketch name', () => {
beforeEach(() => {
setProps({
project: { isEditingName: true, name: 'testname' },
setProjectName: jest.fn(name => component.setProps({ project: { name } })),
});
component = shallow(<ToolbarComponent {...props} />);
});
describe('to a valid name', () => {
beforeEach(() => changeName('hello'));
it('should save', () => expect(props.setProjectName).toBeCalledWith('hello'));
});
describe('to an empty name', () => {
beforeEach(() => changeName(''));
it('should set name to empty', () => expect(props.setProjectName).toBeCalledWith(''));
it(
'should detect empty name and revert to original',
() => expect(props.setProjectName).toHaveBeenLastCalledWith(initialProps.project.name)
);
});
});
});
describe('when user does not own sketch', () => {
beforeEach(() => setProps({ currentUser: 'not-the-owner' }));
it('should disable edition', () => expect(component.find('.toolbar__edit-name-button')).toEqual({}));
});
});
});

View file

@ -1,346 +1,219 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Nav renders correctly 1`] = ` exports[`Nav renders correctly 1`] = `
<header> <DocumentFragment>
<header>
<nav <nav
className="nav" class="nav"
title="main-navigation" title="main-navigation"
> >
<ul <ul
className="nav__items-left" class="nav__items-left"
> >
<li <li
className="nav__item-logo" class="nav__item-logo"
> >
<test-file-stub <test-file-stub
aria-label="p5.js Logo" aria-label="p5.js Logo"
className="svg__logo" classname="svg__logo"
focusable="false" focusable="false"
role="img" role="img"
/> />
</li> </li>
<li <li
className="nav__item" class="nav__item"
>
<button
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOver={[Function]}
> >
<button>
<span <span
className="nav__item-header" class="nav__item-header"
> />
File
</span>
<test-file-stub <test-file-stub
aria-hidden="true" aria-hidden="true"
className="nav__item-header-triangle" classname="nav__item-header-triangle"
focusable="false" focusable="false"
/> />
</button> </button>
<ul <ul
className="nav__dropdown" class="nav__dropdown"
> >
<li <li
className="nav__dropdown-item" class="nav__dropdown-item"
> >
<button <button />
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
>
New
</button>
</li> </li>
<li <li
className="nav__dropdown-item" class="nav__dropdown-item"
> >
<button <button />
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
>
Duplicate
</button>
</li> </li>
<li <li
className="nav__dropdown-item" class="nav__dropdown-item"
> >
<button <button />
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
>
Share
</button>
</li> </li>
<li <li
className="nav__dropdown-item" class="nav__dropdown-item"
> >
<button <button />
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
>
Download
</button>
</li> </li>
<li <li
className="nav__dropdown-item" class="nav__dropdown-item"
> >
<a <a />
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
style={Object {}}
>
Open
</a>
</li> </li>
</ul> </ul>
</li> </li>
<li <li
className="nav__item" class="nav__item"
>
<button
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOver={[Function]}
> >
<button>
<span <span
className="nav__item-header" class="nav__item-header"
> />
Edit
</span>
<test-file-stub <test-file-stub
aria-hidden="true" aria-hidden="true"
className="nav__item-header-triangle" classname="nav__item-header-triangle"
focusable="false" focusable="false"
/> />
</button> </button>
<ul <ul
className="nav__dropdown" class="nav__dropdown"
> >
<li <li
className="nav__dropdown-item" class="nav__dropdown-item"
> >
<button <button>
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
>
Tidy Code
<span <span
className="nav__keyboard-shortcut" class="nav__keyboard-shortcut"
> >
⇧+Tab
+Tab
</span> </span>
</button> </button>
</li> </li>
<li <li
className="nav__dropdown-item" class="nav__dropdown-item"
> >
<button <button>
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
>
Find
<span <span
className="nav__keyboard-shortcut" class="nav__keyboard-shortcut"
> >
⌃+F
+F
</span> </span>
</button> </button>
</li> </li>
<li <li
className="nav__dropdown-item" class="nav__dropdown-item"
> >
<button <button>
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
>
Find Next
<span <span
className="nav__keyboard-shortcut" class="nav__keyboard-shortcut"
> >
⌃+G
+G
</span> </span>
</button> </button>
</li> </li>
<li <li
className="nav__dropdown-item" class="nav__dropdown-item"
> >
<button <button>
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
>
Find Previous
<span <span
className="nav__keyboard-shortcut" class="nav__keyboard-shortcut"
> >
⇧+⌃+G
+
+G
</span> </span>
</button> </button>
</li> </li>
</ul> </ul>
</li> </li>
<li <li
className="nav__item" class="nav__item"
>
<button
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOver={[Function]}
> >
<button>
<span <span
className="nav__item-header" class="nav__item-header"
> />
Sketch
</span>
<test-file-stub <test-file-stub
aria-hidden="true" aria-hidden="true"
className="nav__item-header-triangle" classname="nav__item-header-triangle"
focusable="false" focusable="false"
/> />
</button> </button>
<ul <ul
className="nav__dropdown" class="nav__dropdown"
> >
<li <li
className="nav__dropdown-item" class="nav__dropdown-item"
> >
<button <button />
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
>
Add File
</button>
</li> </li>
<li <li
className="nav__dropdown-item" class="nav__dropdown-item"
> >
<button <button />
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
>
Add Folder
</button>
</li> </li>
<li <li
className="nav__dropdown-item" class="nav__dropdown-item"
> >
<button <button>
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
>
Run
<span <span
className="nav__keyboard-shortcut" class="nav__keyboard-shortcut"
> >
⌃+Enter
+Enter
</span> </span>
</button> </button>
</li> </li>
<li <li
className="nav__dropdown-item" class="nav__dropdown-item"
> >
<button <button>
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
>
Stop
<span <span
className="nav__keyboard-shortcut" class="nav__keyboard-shortcut"
> >
⇧+⌃+Enter
+
+Enter
</span> </span>
</button> </button>
</li> </li>
</ul> </ul>
</li> </li>
<li <li
className="nav__item" class="nav__item"
>
<button
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOver={[Function]}
> >
<button>
<span <span
className="nav__item-header" class="nav__item-header"
> />
Help
</span>
<test-file-stub <test-file-stub
aria-hidden="true" aria-hidden="true"
className="nav__item-header-triangle" classname="nav__item-header-triangle"
focusable="false" focusable="false"
/> />
</button> </button>
<ul <ul
className="nav__dropdown" class="nav__dropdown"
> >
<li <li
className="nav__dropdown-item" class="nav__dropdown-item"
> >
<button <button />
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
>
Keyboard Shortcuts
</button>
</li> </li>
<li <li
className="nav__dropdown-item" class="nav__dropdown-item"
> >
<a <a
href="https://p5js.org/reference/" href="https://p5js.org/reference/"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
rel="noopener noreferrer" rel="noopener noreferrer"
target="_blank" target="_blank"
> />
Reference
</a>
</li> </li>
<li <li
className="nav__dropdown-item" class="nav__dropdown-item"
> >
<a <a />
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
style={Object {}}
>
About
</a>
</li> </li>
</ul> </ul>
</li> </li>
</ul> </ul>
</nav> </nav>
</header> </header>
</DocumentFragment>
`; `;

View file

@ -0,0 +1,20 @@
import React from 'react';
import styled from 'styled-components';
import { prop, remSize } from '../../theme';
const background = prop('MobilePanel.default.background');
const textColor = prop('primaryTextColor');
const Footer = styled.div`
position: fixed;
width: 100%;
background: ${background};
color: ${textColor};
padding: ${remSize(12)};
padding-left: ${remSize(32)};
z-index: 1;
bottom: 0;
`;
export default Footer;

View file

@ -0,0 +1,80 @@
import React from 'react';
import styled from 'styled-components';
import PropTypes from 'prop-types';
import { prop, remSize } from '../../theme';
const background = prop('MobilePanel.default.background');
const textColor = prop('primaryTextColor');
const HeaderDiv = styled.div`
position: fixed;
width: 100%;
background: ${props => (props.transparent ? 'transparent' : background)};
color: ${textColor};
padding: ${remSize(12)};
padding-left: ${remSize(16)};
padding-right: ${remSize(16)};
z-index: 1;
display: flex;
flex: 1;
flex-direction: row;
justify-content: flex-start;
align-items: center;
// TODO:
svg {
max-height: ${remSize(32)};
padding: ${remSize(4)}
}
`;
const IconContainer = styled.div`
margin-left: ${props => (props.leftButton ? remSize(32) : remSize(4))};
display: flex;
`;
const TitleContainer = styled.div`
margin-left: ${remSize(4)};
margin-right: auto;
${props => props.padded && `h2{
padding-top: ${remSize(10)};
padding-bottom: ${remSize(10)};
}`}
`;
const Header = ({
title, subtitle, leftButton, children, transparent
}) => (
<HeaderDiv transparent={transparent}>
{leftButton}
<TitleContainer padded={subtitle === null}>
{title && <h2>{title}</h2>}
{subtitle && <h3>{subtitle}</h3>}
</TitleContainer>
<IconContainer>
{children}
</IconContainer>
</HeaderDiv>
);
Header.propTypes = {
title: PropTypes.string,
subtitle: PropTypes.string,
leftButton: PropTypes.element,
children: PropTypes.oneOfType([PropTypes.element, PropTypes.arrayOf(PropTypes.element)]),
transparent: PropTypes.bool
};
Header.defaultProps = {
title: null,
subtitle: null,
leftButton: null,
children: [],
transparent: false
};
export default Header;

View file

@ -0,0 +1,8 @@
import React from 'react';
import styled from 'styled-components';
import { remSize } from '../../theme';
export default styled.div`
z-index: 0;
margin-top: ${remSize(16)};
`;

View file

@ -0,0 +1,31 @@
import React from 'react';
import PropTypes from 'prop-types';
import styled from 'styled-components';
import Button from '../../common/Button';
import { remSize } from '../../theme';
const ButtonWrapper = styled(Button)`
width: ${remSize(48)};
> svg {
width: 100%;
height: 100%;
}
`;
const IconButton = (props) => {
const { icon, ...otherProps } = props;
const Icon = icon;
return (<ButtonWrapper
iconBefore={<Icon />}
kind={Button.kinds.inline}
focusable="false"
{...otherProps}
/>);
};
IconButton.propTypes = {
icon: PropTypes.func.isRequired
};
export default IconButton;

View file

@ -0,0 +1,19 @@
import React from 'react';
import PropTypes from 'prop-types';
const Screen = ({ children, fullscreen }) => (
<div className={fullscreen && 'fullscreen-preview'}>
{children}
</div>
);
Screen.defaultProps = {
fullscreen: false
};
Screen.propTypes = {
children: PropTypes.node.isRequired,
fullscreen: PropTypes.bool
};
export default Screen;

View file

@ -0,0 +1,67 @@
import React from 'react';
import PropTypes from 'prop-types';
import styled from 'styled-components';
import { prop, remSize } from '../../theme';
const PreferenceTitle = styled.h4.attrs(props => ({ ...props, className: 'preference__title' }))`
color: ${prop('primaryTextColor')};
`;
const Preference = styled.div.attrs(props => ({ ...props, className: 'preference' }))`
flex-wrap: nowrap !important;
align-items: baseline !important;
justify-items: space-between;
`;
const OptionLabel = styled.label.attrs({ className: 'preference__option' })`
font-size: ${remSize(14)} !important;
`;
const PreferencePicker = ({
title, value, onSelect, options,
}) => (
<Preference>
<PreferenceTitle>{title}</PreferenceTitle>
<div className="preference__options">
{options.map(option => (
<React.Fragment key={`${option.name}-${option.id}`} >
<input
type="radio"
onChange={() => onSelect(option.value)}
aria-label={option.ariaLabel}
name={option.name}
key={`${option.name}-${option.id}-input`}
id={option.id}
className="preference__radio-button"
value={option.value}
checked={value === option.value}
/>
<OptionLabel
key={`${option.name}-${option.id}-label`}
htmlFor={option.id}
>
{option.label}
</OptionLabel>
</React.Fragment>))}
</div>
</Preference>
);
PreferencePicker.defaultProps = {
options: []
};
PreferencePicker.propTypes = {
title: PropTypes.string.isRequired,
value: PropTypes.oneOfType([PropTypes.bool, PropTypes.string]).isRequired,
options: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string,
name: PropTypes.string,
label: PropTypes.string,
ariaLabel: PropTypes.string,
})),
onSelect: PropTypes.func.isRequired,
};
export default PreferencePicker;

38
client/i18n.js Normal file
View file

@ -0,0 +1,38 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import Backend from 'i18next-http-backend';
const fallbackLng = ['en-US'];
const availableLanguages = ['en-US', 'es-419'];
const options = {
loadPath: 'locales/{{lng}}/translations.json',
requestOptions: { // used for fetch, can also be a function (payload) => ({ method: 'GET' })
mode: 'no-cors'
},
allowMultiLoading: false, // set loadPath: '/locales/resources.json?lng={{lng}}&ns={{ns}}' to adapt to multiLoading
};
i18n
.use(initReactI18next) // pass the i18n instance to react-i18next.
.use(LanguageDetector)// to detect the language from currentBrowser
.use(Backend) // to fetch the data from server
.init({
lng: 'en-US',
defaultNS: 'WebEditor',
fallbackLng, // if user computer language is not on the list of available languages, than we will be using the fallback language specified earlier
debug: false,
backend: options,
getAsync: false,
initImmediate: false,
useSuspense: true,
whitelist: availableLanguages,
interpolation: {
escapeValue: false, // react already safes from xss
},
saveMissing: false, // if a key is not found AND this flag is set to true, i18next will call the handler missingKeyHandler
missingKeyHandler: false // function(lng, ns, key, fallbackValue) { } custom logic about how to handle the missing keys
});
export default i18n;

View file

@ -1,4 +1,4 @@
import React from 'react'; import React, { Suspense } from 'react';
import { render } from 'react-dom'; import { render } from 'react-dom';
import { hot } from 'react-hot-loader/root'; import { hot } from 'react-hot-loader/root';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
@ -7,6 +7,8 @@ import { Router, browserHistory } from 'react-router';
import configureStore from './store'; import configureStore from './store';
import routes from './routes'; import routes from './routes';
import ThemeProvider from './modules/App/components/ThemeProvider'; import ThemeProvider from './modules/App/components/ThemeProvider';
import Loader from './modules/App/components/loader';
import i18n from './i18n';
require('./styles/main.scss'); require('./styles/main.scss');
@ -29,6 +31,8 @@ const App = () => (
const HotApp = hot(App); const HotApp = hot(App);
render( render(
<HotApp />, <Suspense fallback={(<Loader />)}>
<HotApp />
</Suspense>,
document.getElementById('root') document.getElementById('root')
); );

5
client/jest.setup.js Normal file
View file

@ -0,0 +1,5 @@
import '@babel/polyfill';
// See: https://github.com/testing-library/jest-dom
// eslint-disable-next-line import/no-extraneous-dependencies
import '@testing-library/jest-dom';

View file

@ -1,11 +1,10 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import getConfig from '../../utils/getConfig';
import DevTools from './components/DevTools'; import DevTools from './components/DevTools';
import { setPreviousPath } from '../IDE/actions/ide'; import { setPreviousPath } from '../IDE/actions/ide';
const __process = (typeof global !== 'undefined' ? global : window).process;
class App extends React.Component { class App extends React.Component {
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
@ -35,7 +34,7 @@ class App extends React.Component {
render() { render() {
return ( return (
<div className="app"> <div className="app">
{this.state.isMounted && !window.devToolsExtension && __process.env.NODE_ENV === 'development' && <DevTools />} {this.state.isMounted && !window.devToolsExtension && getConfig('NODE_ENV') === 'development' && <DevTools />}
{this.props.children} {this.props.children}
</div> </div>
); );

View file

@ -1,10 +1,7 @@
import axios from 'axios'; import apiClient from '../../../utils/apiClient';
import * as ActionTypes from '../../../constants'; import * as ActionTypes from '../../../constants';
import { startLoader, stopLoader } from './loader'; import { startLoader, stopLoader } from './loader';
const __process = (typeof global !== 'undefined' ? global : window).process;
const ROOT_URL = __process.env.API_URL;
function setAssets(assets, totalSize) { function setAssets(assets, totalSize) {
return { return {
type: ActionTypes.SET_ASSETS, type: ActionTypes.SET_ASSETS,
@ -16,7 +13,7 @@ function setAssets(assets, totalSize) {
export function getAssets() { export function getAssets() {
return (dispatch) => { return (dispatch) => {
dispatch(startLoader()); dispatch(startLoader());
axios.get(`${ROOT_URL}/S3/objects`, { withCredentials: true }) apiClient.get('/S3/objects')
.then((response) => { .then((response) => {
dispatch(setAssets(response.data.assets, response.data.totalSize)); dispatch(setAssets(response.data.assets, response.data.totalSize));
dispatch(stopLoader()); dispatch(stopLoader());
@ -39,7 +36,7 @@ export function deleteAsset(assetKey) {
export function deleteAssetRequest(assetKey) { export function deleteAssetRequest(assetKey) {
return (dispatch) => { return (dispatch) => {
axios.delete(`${ROOT_URL}/S3/${assetKey}`, { withCredentials: true }) apiClient.delete(`/S3/${assetKey}`)
.then((response) => { .then((response) => {
dispatch(deleteAsset(assetKey)); dispatch(deleteAsset(assetKey));
}) })

View file

@ -1,11 +1,9 @@
import axios from 'axios';
import { browserHistory } from 'react-router'; import { browserHistory } from 'react-router';
import apiClient from '../../../utils/apiClient';
import * as ActionTypes from '../../../constants'; import * as ActionTypes from '../../../constants';
import { startLoader, stopLoader } from './loader'; import { startLoader, stopLoader } from './loader';
import { setToastText, showToast } from './toast'; import { setToastText, showToast } from './toast';
const __process = (typeof global !== 'undefined' ? global : window).process;
const ROOT_URL = __process.env.API_URL;
const TOAST_DISPLAY_TIME_MS = 1500; const TOAST_DISPLAY_TIME_MS = 1500;
@ -15,11 +13,11 @@ export function getCollections(username) {
dispatch(startLoader()); dispatch(startLoader());
let url; let url;
if (username) { if (username) {
url = `${ROOT_URL}/${username}/collections`; url = `/${username}/collections`;
} else { } else {
url = `${ROOT_URL}/collections`; url = '/collections';
} }
axios.get(url, { withCredentials: true }) apiClient.get(url)
.then((response) => { .then((response) => {
dispatch({ dispatch({
type: ActionTypes.SET_COLLECTIONS, type: ActionTypes.SET_COLLECTIONS,
@ -41,8 +39,8 @@ export function getCollections(username) {
export function createCollection(collection) { export function createCollection(collection) {
return (dispatch) => { return (dispatch) => {
dispatch(startLoader()); dispatch(startLoader());
const url = `${ROOT_URL}/collections`; const url = '/collections';
return axios.post(url, collection, { withCredentials: true }) return apiClient.post(url, collection)
.then((response) => { .then((response) => {
dispatch({ dispatch({
type: ActionTypes.CREATE_COLLECTION type: ActionTypes.CREATE_COLLECTION
@ -73,8 +71,8 @@ export function createCollection(collection) {
export function addToCollection(collectionId, projectId) { export function addToCollection(collectionId, projectId) {
return (dispatch) => { return (dispatch) => {
dispatch(startLoader()); dispatch(startLoader());
const url = `${ROOT_URL}/collections/${collectionId}/${projectId}`; const url = `/collections/${collectionId}/${projectId}`;
return axios.post(url, { withCredentials: true }) return apiClient.post(url)
.then((response) => { .then((response) => {
dispatch({ dispatch({
type: ActionTypes.ADD_TO_COLLECTION, type: ActionTypes.ADD_TO_COLLECTION,
@ -105,8 +103,8 @@ export function addToCollection(collectionId, projectId) {
export function removeFromCollection(collectionId, projectId) { export function removeFromCollection(collectionId, projectId) {
return (dispatch) => { return (dispatch) => {
dispatch(startLoader()); dispatch(startLoader());
const url = `${ROOT_URL}/collections/${collectionId}/${projectId}`; const url = `/collections/${collectionId}/${projectId}`;
return axios.delete(url, { withCredentials: true }) return apiClient.delete(url)
.then((response) => { .then((response) => {
dispatch({ dispatch({
type: ActionTypes.REMOVE_FROM_COLLECTION, type: ActionTypes.REMOVE_FROM_COLLECTION,
@ -136,8 +134,8 @@ export function removeFromCollection(collectionId, projectId) {
export function editCollection(collectionId, { name, description }) { export function editCollection(collectionId, { name, description }) {
return (dispatch) => { return (dispatch) => {
const url = `${ROOT_URL}/collections/${collectionId}`; const url = `/collections/${collectionId}`;
return axios.patch(url, { name, description }, { withCredentials: true }) return apiClient.patch(url, { name, description })
.then((response) => { .then((response) => {
dispatch({ dispatch({
type: ActionTypes.EDIT_COLLECTION, type: ActionTypes.EDIT_COLLECTION,
@ -159,8 +157,8 @@ export function editCollection(collectionId, { name, description }) {
export function deleteCollection(collectionId) { export function deleteCollection(collectionId) {
return (dispatch) => { return (dispatch) => {
const url = `${ROOT_URL}/collections/${collectionId}`; const url = `/collections/${collectionId}`;
return axios.delete(url, { withCredentials: true }) return apiClient.delete(url)
.then((response) => { .then((response) => {
dispatch({ dispatch({
type: ActionTypes.DELETE_COLLECTION, type: ActionTypes.DELETE_COLLECTION,

View file

@ -1,13 +1,11 @@
import axios from 'axios';
import objectID from 'bson-objectid'; import objectID from 'bson-objectid';
import blobUtil from 'blob-util'; import blobUtil from 'blob-util';
import { reset } from 'redux-form'; import { reset } from 'redux-form';
import apiClient from '../../../utils/apiClient';
import * as ActionTypes from '../../../constants'; import * as ActionTypes from '../../../constants';
import { setUnsavedChanges, closeNewFolderModal, closeNewFileModal } from './ide'; import { setUnsavedChanges, closeNewFolderModal, closeNewFileModal } from './ide';
import { setProjectSavedTime } from './project'; import { setProjectSavedTime } from './project';
const __process = (typeof global !== 'undefined' ? global : window).process;
const ROOT_URL = __process.env.API_URL;
function appendToFilename(filename, string) { function appendToFilename(filename, string) {
const dotIndex = filename.lastIndexOf('.'); const dotIndex = filename.lastIndexOf('.');
@ -50,7 +48,7 @@ export function createFile(formProps) {
parentId, parentId,
children: [] children: []
}; };
axios.post(`${ROOT_URL}/projects/${state.project.id}/files`, postParams, { withCredentials: true }) apiClient.post(`/projects/${state.project.id}/files`, postParams)
.then((response) => { .then((response) => {
dispatch({ dispatch({
type: ActionTypes.CREATE_FILE, type: ActionTypes.CREATE_FILE,
@ -106,7 +104,7 @@ export function createFolder(formProps) {
parentId, parentId,
fileType: 'folder' fileType: 'folder'
}; };
axios.post(`${ROOT_URL}/projects/${state.project.id}/files`, postParams, { withCredentials: true }) apiClient.post(`/projects/${state.project.id}/files`, postParams)
.then((response) => { .then((response) => {
dispatch({ dispatch({
type: ActionTypes.CREATE_FILE, type: ActionTypes.CREATE_FILE,
@ -161,7 +159,7 @@ export function deleteFile(id, parentId) {
parentId parentId
} }
}; };
axios.delete(`${ROOT_URL}/projects/${state.project.id}/files/${id}`, deleteConfig, { withCredentials: true }) apiClient.delete(`/projects/${state.project.id}/files/${id}`, deleteConfig)
.then(() => { .then(() => {
dispatch({ dispatch({
type: ActionTypes.DELETE_FILE, type: ActionTypes.DELETE_FILE,

View file

@ -1,11 +1,8 @@
import axios from 'axios'; import apiClient from '../../../utils/apiClient';
import * as ActionTypes from '../../../constants'; import * as ActionTypes from '../../../constants';
const __process = (typeof global !== 'undefined' ? global : window).process;
const ROOT_URL = __process.env.API_URL;
function updatePreferences(formParams, dispatch) { function updatePreferences(formParams, dispatch) {
axios.put(`${ROOT_URL}/preferences`, formParams, { withCredentials: true }) apiClient.put('/preferences', formParams)
.then(() => { .then(() => {
}) })
.catch((error) => { .catch((error) => {

View file

@ -1,8 +1,9 @@
import { browserHistory } from 'react-router'; import { browserHistory } from 'react-router';
import axios from 'axios';
import objectID from 'bson-objectid'; import objectID from 'bson-objectid';
import each from 'async/each'; import each from 'async/each';
import isEqual from 'lodash/isEqual'; import isEqual from 'lodash/isEqual';
import apiClient from '../../../utils/apiClient';
import getConfig from '../../../utils/getConfig';
import * as ActionTypes from '../../../constants'; import * as ActionTypes from '../../../constants';
import { showToast, setToastText } from './toast'; import { showToast, setToastText } from './toast';
import { import {
@ -14,8 +15,7 @@ import {
} from './ide'; } from './ide';
import { clearState, saveState } from '../../../persistState'; import { clearState, saveState } from '../../../persistState';
const __process = (typeof global !== 'undefined' ? global : window).process; const ROOT_URL = getConfig('API_URL');
const ROOT_URL = __process.env.API_URL;
export function setProject(project) { export function setProject(project) {
return { return {
@ -52,7 +52,7 @@ export function setNewProject(project) {
export function getProject(id, username) { export function getProject(id, username) {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch(justOpenedProject()); dispatch(justOpenedProject());
axios.get(`${ROOT_URL}/${username}/projects/${id}`, { withCredentials: true }) apiClient.get(`/${username}/projects/${id}`)
.then((response) => { .then((response) => {
dispatch(setProject(response.data)); dispatch(setProject(response.data));
dispatch(setUnsavedChanges(false)); dispatch(setUnsavedChanges(false));
@ -142,7 +142,7 @@ export function saveProject(selectedFile = null, autosave = false) {
fileToUpdate.content = selectedFile.content; fileToUpdate.content = selectedFile.content;
} }
if (state.project.id) { if (state.project.id) {
return axios.put(`${ROOT_URL}/projects/${state.project.id}`, formParams, { withCredentials: true }) return apiClient.put(`/projects/${state.project.id}`, formParams)
.then((response) => { .then((response) => {
dispatch(endSavingProject()); dispatch(endSavingProject());
dispatch(setUnsavedChanges(false)); dispatch(setUnsavedChanges(false));
@ -155,18 +155,20 @@ export function saveProject(selectedFile = null, autosave = false) {
if (!autosave) { if (!autosave) {
if (state.ide.justOpenedProject && state.preferences.autosave) { if (state.ide.justOpenedProject && state.preferences.autosave) {
dispatch(showToast(5500)); dispatch(showToast(5500));
dispatch(setToastText('Project saved.')); dispatch(setToastText('Sketch saved.'));
setTimeout(() => dispatch(setToastText('Autosave enabled.')), 1500); setTimeout(() => dispatch(setToastText('Autosave enabled.')), 1500);
dispatch(resetJustOpenedProject()); dispatch(resetJustOpenedProject());
} else { } else {
dispatch(showToast(1500)); dispatch(showToast(1500));
dispatch(setToastText('Project saved.')); dispatch(setToastText('Sketch saved.'));
} }
} }
}) })
.catch((error) => { .catch((error) => {
const { response } = error; const { response } = error;
dispatch(endSavingProject()); dispatch(endSavingProject());
dispatch(setToastText('Failed to save sketch.'));
dispatch(showToast(1500));
if (response.status === 403) { if (response.status === 403) {
dispatch(showErrorModal('staleSession')); dispatch(showErrorModal('staleSession'));
} else if (response.status === 409) { } else if (response.status === 409) {
@ -177,7 +179,7 @@ export function saveProject(selectedFile = null, autosave = false) {
}); });
} }
return axios.post(`${ROOT_URL}/projects`, formParams, { withCredentials: true }) return apiClient.post('/projects', formParams)
.then((response) => { .then((response) => {
dispatch(endSavingProject()); dispatch(endSavingProject());
const { hasChanges, synchedProject } = getSynchedProject(getState(), response.data); const { hasChanges, synchedProject } = getSynchedProject(getState(), response.data);
@ -195,18 +197,20 @@ export function saveProject(selectedFile = null, autosave = false) {
if (!autosave) { if (!autosave) {
if (state.preferences.autosave) { if (state.preferences.autosave) {
dispatch(showToast(5500)); dispatch(showToast(5500));
dispatch(setToastText('Project saved.')); dispatch(setToastText('Sketch saved.'));
setTimeout(() => dispatch(setToastText('Autosave enabled.')), 1500); setTimeout(() => dispatch(setToastText('Autosave enabled.')), 1500);
dispatch(resetJustOpenedProject()); dispatch(resetJustOpenedProject());
} else { } else {
dispatch(showToast(1500)); dispatch(showToast(1500));
dispatch(setToastText('Project saved.')); dispatch(setToastText('Sketch saved.'));
} }
} }
}) })
.catch((error) => { .catch((error) => {
const { response } = error; const { response } = error;
dispatch(endSavingProject()); dispatch(endSavingProject());
dispatch(setToastText('Failed to save sketch.'));
dispatch(showToast(1500));
if (response.status === 403) { if (response.status === 403) {
dispatch(showErrorModal('staleSession')); dispatch(showErrorModal('staleSession'));
} else { } else {
@ -260,7 +264,7 @@ export function cloneProject(id) {
if (!id) { if (!id) {
resolve(getState()); resolve(getState());
} else { } else {
fetch(`${ROOT_URL}/projects/${id}`) apiClient.get(`/projects/${id}`)
.then(res => res.json()) .then(res => res.json())
.then(data => resolve({ .then(data => resolve({
files: data.files, files: data.files,
@ -287,7 +291,7 @@ export function cloneProject(id) {
const formParams = { const formParams = {
url: file.url url: file.url
}; };
axios.post(`${ROOT_URL}/S3/copy`, formParams, { withCredentials: true }) apiClient.post('/S3/copy', formParams)
.then((response) => { .then((response) => {
file.url = response.data.url; file.url = response.data.url;
callback(null); callback(null);
@ -298,7 +302,7 @@ export function cloneProject(id) {
}, (err) => { }, (err) => {
// if not errors in duplicating the files on S3, then duplicate it // if not errors in duplicating the files on S3, then duplicate it
const formParams = Object.assign({}, { name: `${state.project.name} copy` }, { files: newFiles }); const formParams = Object.assign({}, { name: `${state.project.name} copy` }, { files: newFiles });
axios.post(`${ROOT_URL}/projects`, formParams, { withCredentials: true }) apiClient.post('/projects', formParams)
.then((response) => { .then((response) => {
browserHistory.push(`/${response.data.user.username}/sketches/${response.data.id}`); browserHistory.push(`/${response.data.user.username}/sketches/${response.data.id}`);
dispatch(setNewProject(response.data)); dispatch(setNewProject(response.data));
@ -337,7 +341,7 @@ export function setProjectSavedTime(updatedAt) {
export function changeProjectName(id, newName) { export function changeProjectName(id, newName) {
return (dispatch, getState) => { return (dispatch, getState) => {
const state = getState(); const state = getState();
axios.put(`${ROOT_URL}/projects/${id}`, { name: newName }, { withCredentials: true }) apiClient.put(`/projects/${id}`, { name: newName })
.then((response) => { .then((response) => {
if (response.status === 200) { if (response.status === 200) {
dispatch({ dispatch({
@ -364,7 +368,7 @@ export function changeProjectName(id, newName) {
export function deleteProject(id) { export function deleteProject(id) {
return (dispatch, getState) => { return (dispatch, getState) => {
axios.delete(`${ROOT_URL}/projects/${id}`, { withCredentials: true }) apiClient.delete(`/projects/${id}`)
.then(() => { .then(() => {
const state = getState(); const state = getState();
if (id === state.project.id) { if (id === state.project.id) {

View file

@ -1,21 +1,18 @@
import axios from 'axios'; import apiClient from '../../../utils/apiClient';
import * as ActionTypes from '../../../constants'; import * as ActionTypes from '../../../constants';
import { startLoader, stopLoader } from './loader'; import { startLoader, stopLoader } from './loader';
const __process = (typeof global !== 'undefined' ? global : window).process;
const ROOT_URL = __process.env.API_URL;
// eslint-disable-next-line // eslint-disable-next-line
export function getProjects(username) { export function getProjects(username) {
return (dispatch) => { return (dispatch) => {
dispatch(startLoader()); dispatch(startLoader());
let url; let url;
if (username) { if (username) {
url = `${ROOT_URL}/${username}/projects`; url = `/${username}/projects`;
} else { } else {
url = `${ROOT_URL}/projects`; url = '/projects';
} }
axios.get(url, { withCredentials: true }) apiClient.get(url)
.then((response) => { .then((response) => {
dispatch({ dispatch({
type: ActionTypes.SET_PROJECTS, type: ActionTypes.SET_PROJECTS,

View file

@ -1,11 +1,10 @@
import axios from 'axios'; import apiClient from '../../../utils/apiClient';
import getConfig from '../../../utils/getConfig';
import { createFile } from './files'; import { createFile } from './files';
import { TEXT_FILE_REGEX } from '../../../../server/utils/fileUtils'; import { TEXT_FILE_REGEX } from '../../../../server/utils/fileUtils';
const __process = (typeof global !== 'undefined' ? global : window).process; const s3BucketHttps = getConfig('S3_BUCKET_URL_BASE') ||
const s3BucketHttps = __process.env.S3_BUCKET_URL_BASE || `https://s3-${getConfig('AWS_REGION')}.amazonaws.com/${getConfig('S3_BUCKET')}/`;
`https://s3-${__process.env.AWS_REGION}.amazonaws.com/${__process.env.S3_BUCKET}/`;
const ROOT_URL = __process.env.API_URL;
const MAX_LOCAL_FILE_SIZE = 80000; // bytes, aka 80 KB const MAX_LOCAL_FILE_SIZE = 80000; // bytes, aka 80 KB
function localIntercept(file, options = {}) { function localIntercept(file, options = {}) {
@ -46,18 +45,13 @@ export function dropzoneAcceptCallback(userId, file, done) {
}); });
} else { } else {
file.postData = []; // eslint-disable-line file.postData = []; // eslint-disable-line
axios.post( apiClient.post('/S3/sign', {
`${ROOT_URL}/S3/sign`, {
name: file.name, name: file.name,
type: file.type, type: file.type,
size: file.size, size: file.size,
userId userId
// _csrf: document.getElementById('__createPostToken').value // _csrf: document.getElementById('__createPostToken').value
}, })
{
withCredentials: true
}
)
.then((response) => { .then((response) => {
file.custom_status = 'ready'; // eslint-disable-line file.custom_status = 'ready'; // eslint-disable-line
file.postData = response.data; // eslint-disable-line file.postData = response.data; // eslint-disable-line

View file

@ -1,15 +1,16 @@
import React from 'react'; import React from 'react';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { useTranslation } from 'react-i18next';
import SquareLogoIcon from '../../../images/p5js-square-logo.svg'; import SquareLogoIcon from '../../../images/p5js-square-logo.svg';
// import PlayIcon from '../../../images/play.svg'; // import PlayIcon from '../../../images/play.svg';
import AsteriskIcon from '../../../images/p5-asterisk.svg'; import AsteriskIcon from '../../../images/p5-asterisk.svg';
function About(props) { function About(props) {
const { t } = useTranslation();
return ( return (
<div className="about__content"> <div className="about__content">
<Helmet> <Helmet>
<title>p5.js Web Editor | About</title> <title>p5.js Web Editor | About </title>
</Helmet> </Helmet>
<div className="about__content-column"> <div className="about__content-column">
<SquareLogoIcon className="about__logo" role="img" aria-label="p5.js Logo" focusable="false" /> <SquareLogoIcon className="about__logo" role="img" aria-label="p5.js Logo" focusable="false" />
@ -25,7 +26,7 @@ function About(props) {
</p> */} </p> */}
</div> </div>
<div className="about__content-column"> <div className="about__content-column">
<h3 className="about__content-column-title">New to p5.js?</h3> <h3 className="about__content-column-title">{t('NewP5')}</h3>
<p className="about__content-column-list"> <p className="about__content-column-list">
<a <a
href="https://p5js.org/examples/" href="https://p5js.org/examples/"
@ -33,7 +34,7 @@ function About(props) {
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<AsteriskIcon className="about__content-column-asterisk" aria-hidden="true" focusable="false" /> <AsteriskIcon className="about__content-column-asterisk" aria-hidden="true" focusable="false" />
Examples {t('Examples')}
</a> </a>
</p> </p>
<p className="about__content-column-list"> <p className="about__content-column-list">
@ -43,12 +44,12 @@ function About(props) {
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<AsteriskIcon className="about__content-column-asterisk" aria-hidden="true" focusable="false" /> <AsteriskIcon className="about__content-column-asterisk" aria-hidden="true" focusable="false" />
Learn {t('Learn')}
</a> </a>
</p> </p>
</div> </div>
<div className="about__content-column"> <div className="about__content-column">
<h3 className="about__content-column-title">Resources</h3> <h3 className="about__content-column-title">{t('Resources')}</h3>
<p className="about__content-column-list"> <p className="about__content-column-list">
<a <a
href="https://p5js.org/libraries/" href="https://p5js.org/libraries/"
@ -56,7 +57,7 @@ function About(props) {
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<AsteriskIcon className="about__content-column-asterisk" aria-hidden="true" focusable="false" /> <AsteriskIcon className="about__content-column-asterisk" aria-hidden="true" focusable="false" />
Libraries {t('Libraries')}
</a> </a>
</p> </p>
<p className="about__content-column-list"> <p className="about__content-column-list">
@ -66,7 +67,7 @@ function About(props) {
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<AsteriskIcon className="about__content-column-asterisk" aria-hidden="true" focusable="false" /> <AsteriskIcon className="about__content-column-asterisk" aria-hidden="true" focusable="false" />
Reference {t('Reference')}
</a> </a>
</p> </p>
<p className="about__content-column-list"> <p className="about__content-column-list">
@ -76,7 +77,7 @@ function About(props) {
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<AsteriskIcon className="about__content-column-asterisk" aria-hidden="true" focusable="false" /> <AsteriskIcon className="about__content-column-asterisk" aria-hidden="true" focusable="false" />
Forum {t('Forum')}
</a> </a>
</p> </p>
</div> </div>
@ -86,7 +87,7 @@ function About(props) {
href="https://github.com/processing/p5.js-web-editor" href="https://github.com/processing/p5.js-web-editor"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
>Contribute >{t('Contribute')}
</a> </a>
</p> </p>
<p className="about__footer-list"> <p className="about__footer-list">
@ -94,7 +95,7 @@ function About(props) {
href="https://github.com/processing/p5.js-web-editor/issues/new" href="https://github.com/processing/p5.js-web-editor/issues/new"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
>Report a bug >{t('Report')}
</a> </a>
</p> </p>
<p className="about__footer-list"> <p className="about__footer-list">

View file

@ -3,8 +3,9 @@ import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import prettyBytes from 'pretty-bytes'; import prettyBytes from 'pretty-bytes';
const __process = (typeof global !== 'undefined' ? global : window).process; import getConfig from '../../../utils/getConfig';
const limit = __process.env.UPLOAD_LIMIT || 250000000;
const limit = getConfig('UPLOAD_LIMIT') || 250000000;
const MAX_SIZE_B = limit; const MAX_SIZE_B = limit;
const formatPercent = (percent) => { const formatPercent = (percent) => {

View file

@ -206,12 +206,14 @@ export class FileNode extends React.Component {
</div> </div>
} }
<button <button
aria-label="Name"
className="sidebar__file-item-name" className="sidebar__file-item-name"
onClick={this.handleFileClick} onClick={this.handleFileClick}
> >
{this.state.updatedName} {this.state.updatedName}
</button> </button>
<input <input
data-testid="input"
type="text" type="text"
className="sidebar__file-item-input" className="sidebar__file-item-input"
value={this.state.updatedName} value={this.state.updatedName}

View file

@ -0,0 +1,31 @@
import React from 'react';
import { action } from '@storybook/addon-actions';
import { FileNode } from './FileNode';
export default {
title: 'IDE/FileNode',
component: FileNode
};
export const Show = () => (
<FileNode
id="nodeId"
parantId="parentId"
name="File name"
fileType="jpeg"
isSelectedFile
isFolderClosed={false}
setSelectedFile={action('setSelectedFile')}
deleteFile={action('deleteFile')}
updateFileName={action('updateFileName')}
resetSelectedFile={action('resetSelectedFile')}
newFile={action('newFile')}
newFolder={action('newFolder')}
showFolderChildren={action('showFolderChildren')}
hideFolderChildren={action('hideFolderChildren')}
openUploadFileModal={action('openUploadFileModal')}
canEdit
authenticated
/>
);

View file

@ -0,0 +1,127 @@
import React from 'react';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { FileNode } from './FileNode';
describe('<FileNode />', () => {
const changeName = (newFileName) => {
const renameButton = screen.getByText(/Rename/i);
fireEvent.click(renameButton);
const input = screen.getByTestId('input');
fireEvent.change(input, { target: { value: newFileName } });
fireEvent.blur(input);
};
const expectFileNameToBe = async (expectedName) => {
const name = screen.getByLabelText(/Name/i);
await waitFor(() => within(name).queryByText(expectedName));
};
const renderFileNode = (fileType, extraProps = {}) => {
const props = {
...extraProps,
id: '0',
name: fileType === 'folder' ? 'afolder' : 'test.jsx',
fileType,
canEdit: true,
children: [],
authenticated: false,
setSelectedFile: jest.fn(),
deleteFile: jest.fn(),
updateFileName: jest.fn(),
resetSelectedFile: jest.fn(),
newFile: jest.fn(),
newFolder: jest.fn(),
showFolderChildren: jest.fn(),
hideFolderChildren: jest.fn(),
openUploadFileModal: jest.fn(),
setProjectName: jest.fn(),
};
render(<FileNode {...props} />);
return props;
};
describe('fileType: file', () => {
it('cannot change to an empty name', async () => {
const props = renderFileNode('file');
changeName('');
await waitFor(() => expect(props.updateFileName).not.toHaveBeenCalled());
await expectFileNameToBe(props.name);
});
it('can change to a valid filename', async () => {
const newName = 'newname.jsx';
const props = renderFileNode('file');
changeName(newName);
await waitFor(() => expect(props.updateFileName).toHaveBeenCalledWith(props.id, newName));
await expectFileNameToBe(newName);
});
it('must have an extension', async () => {
const newName = 'newname';
const props = renderFileNode('file');
changeName(newName);
await waitFor(() => expect(props.updateFileName).not.toHaveBeenCalled());
await expectFileNameToBe(props.name);
});
it('can change to a different extension', async () => {
const newName = 'newname.gif';
const props = renderFileNode('file');
changeName(newName);
await waitFor(() => expect(props.updateFileName).not.toHaveBeenCalled());
await expectFileNameToBe(props.name);
});
it('cannot be just an extension', async () => {
const newName = '.jsx';
const props = renderFileNode('file');
changeName(newName);
await waitFor(() => expect(props.updateFileName).not.toHaveBeenCalled());
await expectFileNameToBe(props.name);
});
});
describe('fileType: folder', () => {
it('cannot change to an empty name', async () => {
const props = renderFileNode('folder');
changeName('');
await waitFor(() => expect(props.updateFileName).not.toHaveBeenCalled());
await expectFileNameToBe(props.name);
});
it('can change to another name', async () => {
const newName = 'foldername';
const props = renderFileNode('folder');
changeName(newName);
await waitFor(() => expect(props.updateFileName).toHaveBeenCalledWith(props.id, newName));
await expectFileNameToBe(newName);
});
it('cannot have a file extension', async () => {
const newName = 'foldername.jsx';
const props = renderFileNode('folder');
changeName(newName);
await waitFor(() => expect(props.updateFileName).not.toHaveBeenCalled());
await expectFileNameToBe(props.name);
});
});
});

View file

@ -4,11 +4,11 @@ import Dropzone from 'dropzone';
import { bindActionCreators } from 'redux'; import { bindActionCreators } from 'redux';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import * as UploaderActions from '../actions/uploader'; import * as UploaderActions from '../actions/uploader';
import getConfig from '../../../utils/getConfig';
import { fileExtensionsAndMimeTypes } from '../../../../server/utils/fileUtils'; import { fileExtensionsAndMimeTypes } from '../../../../server/utils/fileUtils';
const __process = (typeof global !== 'undefined' ? global : window).process; const s3Bucket = getConfig('S3_BUCKET_URL_BASE') ||
const s3Bucket = __process.env.S3_BUCKET_URL_BASE || `https://s3-${getConfig('AWS_REGION')}.amazonaws.com/${getConfig('S3_BUCKET')}/`;
`https://s3-${__process.env.AWS_REGION}.amazonaws.com/${__process.env.S3_BUCKET}/`;
class FileUploader extends React.Component { class FileUploader extends React.Component {
componentDidMount() { componentDidMount() {

View file

@ -1,53 +1,55 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next';
import { metaKeyName, } from '../../../utils/metaKey'; import { metaKeyName, } from '../../../utils/metaKey';
function KeyboardShortcutModal() { function KeyboardShortcutModal() {
const { t } = useTranslation();
return ( return (
<div className="keyboard-shortcuts"> <div className="keyboard-shortcuts">
<h3 className="keyboard-shortcuts__title">Code Editing</h3> <h3 className="keyboard-shortcuts__title">{t('CodeEditing')}</h3>
<p className="keyboard-shortcuts__description"> <p className="keyboard-shortcuts__description">
Code editing keyboard shortcuts follow <a href="https://shortcuts.design/toolspage-sublimetext.html" target="_blank" rel="noopener noreferrer">Sublime Text shortcuts</a>. {t('Code editing keyboard shortcuts follow')} <a href="https://shortcuts.design/toolspage-sublimetext.html" target="_blank" rel="noopener noreferrer">{t('Sublime Text shortcuts')}</a>.
</p> </p>
<ul className="keyboard-shortcuts__list"> <ul className="keyboard-shortcuts__list">
<li className="keyboard-shortcut-item"> <li className="keyboard-shortcut-item">
<span className="keyboard-shortcut__command">{'\u21E7'} + Tab</span> <span className="keyboard-shortcut__command">{'\u21E7'} + Tab</span>
<span>Tidy</span> <span>{t('Tidy')}</span>
</li> </li>
<li className="keyboard-shortcut-item"> <li className="keyboard-shortcut-item">
<span className="keyboard-shortcut__command"> <span className="keyboard-shortcut__command">
{metaKeyName} + F {metaKeyName} + F
</span> </span>
<span>Find Text</span> <span>{t('FindText')}</span>
</li> </li>
<li className="keyboard-shortcut-item"> <li className="keyboard-shortcut-item">
<span className="keyboard-shortcut__command"> <span className="keyboard-shortcut__command">
{metaKeyName} + G {metaKeyName} + G
</span> </span>
<span>Find Next Text Match</span> <span>{t('FindNextTextMatch')}</span>
</li> </li>
<li className="keyboard-shortcut-item"> <li className="keyboard-shortcut-item">
<span className="keyboard-shortcut__command"> <span className="keyboard-shortcut__command">
{metaKeyName} + {'\u21E7'} + G {metaKeyName} + {'\u21E7'} + G
</span> </span>
<span>Find Previous Text Match</span> <span>{t('FindPreviousTextMatch')}</span>
</li> </li>
<li className="keyboard-shortcut-item"> <li className="keyboard-shortcut-item">
<span className="keyboard-shortcut__command"> <span className="keyboard-shortcut__command">
{metaKeyName} + [ {metaKeyName} + [
</span> </span>
<span>Indent Code Left</span> <span>{t('IndentCodeLeft')}</span>
</li> </li>
<li className="keyboard-shortcut-item"> <li className="keyboard-shortcut-item">
<span className="keyboard-shortcut__command"> <span className="keyboard-shortcut__command">
{metaKeyName} + ] {metaKeyName} + ]
</span> </span>
<span>Indent Code Right</span> <span>{t('IndentCodeRight')}</span>
</li> </li>
<li className="keyboard-shortcut-item"> <li className="keyboard-shortcut-item">
<span className="keyboard-shortcut__command"> <span className="keyboard-shortcut__command">
{metaKeyName} + / {metaKeyName} + /
</span> </span>
<span>Comment Line</span> <span>{t('CommentLine')}</span>
</li> </li>
</ul> </ul>
<h3 className="keyboard-shortcuts__title">General</h3> <h3 className="keyboard-shortcuts__title">General</h3>
@ -56,31 +58,31 @@ function KeyboardShortcutModal() {
<span className="keyboard-shortcut__command"> <span className="keyboard-shortcut__command">
{metaKeyName} + S {metaKeyName} + S
</span> </span>
<span>Save</span> <span>{t('Save')}</span>
</li> </li>
<li className="keyboard-shortcut-item"> <li className="keyboard-shortcut-item">
<span className="keyboard-shortcut__command"> <span className="keyboard-shortcut__command">
{metaKeyName} + Enter {metaKeyName} + Enter
</span> </span>
<span>Start Sketch</span> <span>{t('StartSketch')}</span>
</li> </li>
<li className="keyboard-shortcut-item"> <li className="keyboard-shortcut-item">
<span className="keyboard-shortcut__command"> <span className="keyboard-shortcut__command">
{metaKeyName} + {'\u21E7'} + Enter {metaKeyName} + {'\u21E7'} + Enter
</span> </span>
<span>Stop Sketch</span> <span>{t('StopSketch')}</span>
</li> </li>
<li className="keyboard-shortcut-item"> <li className="keyboard-shortcut-item">
<span className="keyboard-shortcut__command"> <span className="keyboard-shortcut__command">
{metaKeyName} + {'\u21E7'} + 1 {metaKeyName} + {'\u21E7'} + 1
</span> </span>
<span>Turn on Accessible Output</span> <span>{t('TurnOnAccessibleOutput')}</span>
</li> </li>
<li className="keyboard-shortcut-item"> <li className="keyboard-shortcut-item">
<span className="keyboard-shortcut__command"> <span className="keyboard-shortcut__command">
{metaKeyName} + {'\u21E7'} + 2 {metaKeyName} + {'\u21E7'} + 2
</span> </span>
<span>Turn off Accessible Output</span> <span>{t('TurnOffAccessibleOutput')}</span>
</li> </li>
</ul> </ul>
</div> </div>

View file

@ -2,6 +2,7 @@ import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { Tab, Tabs, TabList, TabPanel } from 'react-tabs'; import { Tab, Tabs, TabList, TabPanel } from 'react-tabs';
import { withTranslation } from 'react-i18next';
// import { bindActionCreators } from 'redux'; // import { bindActionCreators } from 'redux';
// import { connect } from 'react-redux'; // import { connect } from 'react-redux';
// import * as PreferencesActions from '../actions/preferences'; // import * as PreferencesActions from '../actions/preferences';
@ -98,13 +99,13 @@ class Preferences extends React.Component {
<Tabs> <Tabs>
<TabList> <TabList>
<div className="tabs__titles"> <div className="tabs__titles">
<Tab><h4 className="tabs__title">General Settings</h4></Tab> <Tab><h4 className="tabs__title">{this.props.t('GeneralSettings')}</h4></Tab>
<Tab><h4 className="tabs__title">Accessibility</h4></Tab> <Tab><h4 className="tabs__title">{this.props.t('Accessibility')}</h4></Tab>
</div> </div>
</TabList> </TabList>
<TabPanel> <TabPanel>
<div className="preference"> <div className="preference">
<h4 className="preference__title">Theme</h4> <h4 className="preference__title">{this.props.t('Theme')}</h4>
<div className="preference__options"> <div className="preference__options">
<input <input
type="radio" type="radio"
@ -116,7 +117,7 @@ class Preferences extends React.Component {
value="light" value="light"
checked={this.props.theme === 'light'} checked={this.props.theme === 'light'}
/> />
<label htmlFor="light-theme-on" className="preference__option">Light</label> <label htmlFor="light-theme-on" className="preference__option">{this.props.t('Light')}</label>
<input <input
type="radio" type="radio"
onChange={() => this.props.setTheme('dark')} onChange={() => this.props.setTheme('dark')}
@ -127,7 +128,7 @@ class Preferences extends React.Component {
value="dark" value="dark"
checked={this.props.theme === 'dark'} checked={this.props.theme === 'dark'}
/> />
<label htmlFor="dark-theme-on" className="preference__option">Dark</label> <label htmlFor="dark-theme-on" className="preference__option">{this.props.t('Dark')}</label>
<input <input
type="radio" type="radio"
onChange={() => this.props.setTheme('contrast')} onChange={() => this.props.setTheme('contrast')}
@ -138,11 +139,11 @@ class Preferences extends React.Component {
value="contrast" value="contrast"
checked={this.props.theme === 'contrast'} checked={this.props.theme === 'contrast'}
/> />
<label htmlFor="high-contrast-theme-on" className="preference__option">High Contrast</label> <label htmlFor="high-contrast-theme-on" className="preference__option">{this.props.t('HighContrast')}</label>
</div> </div>
</div> </div>
<div className="preference"> <div className="preference">
<h4 className="preference__title">Text size</h4> <h4 className="preference__title">{this.props.t('TextSize')}</h4>
<button <button
className="preference__minus-button" className="preference__minus-button"
onClick={this.decreaseFontSize} onClick={this.decreaseFontSize}
@ -150,7 +151,7 @@ class Preferences extends React.Component {
disabled={this.state.fontSize <= 8} disabled={this.state.fontSize <= 8}
> >
<MinusIcon focusable="false" aria-hidden="true" /> <MinusIcon focusable="false" aria-hidden="true" />
<h6 className="preference__label">Decrease</h6> <h6 className="preference__label">{this.props.t('Decrease')}</h6>
</button> </button>
<form onSubmit={this.onFontInputSubmit}> <form onSubmit={this.onFontInputSubmit}>
<input <input
@ -171,11 +172,11 @@ class Preferences extends React.Component {
disabled={this.state.fontSize >= 36} disabled={this.state.fontSize >= 36}
> >
<PlusIcon focusable="false" aria-hidden="true" /> <PlusIcon focusable="false" aria-hidden="true" />
<h6 className="preference__label">Increase</h6> <h6 className="preference__label">{this.props.t('Increase')}</h6>
</button> </button>
</div> </div>
<div className="preference"> <div className="preference">
<h4 className="preference__title">Autosave</h4> <h4 className="preference__title">{this.props.t('Autosave')}</h4>
<div className="preference__options"> <div className="preference__options">
<input <input
type="radio" type="radio"
@ -187,7 +188,7 @@ class Preferences extends React.Component {
value="On" value="On"
checked={this.props.autosave} checked={this.props.autosave}
/> />
<label htmlFor="autosave-on" className="preference__option">On</label> <label htmlFor="autosave-on" className="preference__option">{this.props.t('On')}</label>
<input <input
type="radio" type="radio"
onChange={() => this.props.setAutosave(false)} onChange={() => this.props.setAutosave(false)}
@ -198,11 +199,11 @@ class Preferences extends React.Component {
value="Off" value="Off"
checked={!this.props.autosave} checked={!this.props.autosave}
/> />
<label htmlFor="autosave-off" className="preference__option">Off</label> <label htmlFor="autosave-off" className="preference__option">{this.props.t('Off')}</label>
</div> </div>
</div> </div>
<div className="preference"> <div className="preference">
<h4 className="preference__title">Word Wrap</h4> <h4 className="preference__title">{this.props.t('WordWrap')}</h4>
<div className="preference__options"> <div className="preference__options">
<input <input
type="radio" type="radio"
@ -214,7 +215,7 @@ class Preferences extends React.Component {
value="On" value="On"
checked={this.props.linewrap} checked={this.props.linewrap}
/> />
<label htmlFor="linewrap-on" className="preference__option">On</label> <label htmlFor="linewrap-on" className="preference__option">{this.props.t('On')}</label>
<input <input
type="radio" type="radio"
onChange={() => this.props.setLinewrap(false)} onChange={() => this.props.setLinewrap(false)}
@ -225,13 +226,13 @@ class Preferences extends React.Component {
value="Off" value="Off"
checked={!this.props.linewrap} checked={!this.props.linewrap}
/> />
<label htmlFor="linewrap-off" className="preference__option">Off</label> <label htmlFor="linewrap-off" className="preference__option">{this.props.t('Off')}</label>
</div> </div>
</div> </div>
</TabPanel> </TabPanel>
<TabPanel> <TabPanel>
<div className="preference"> <div className="preference">
<h4 className="preference__title">Line numbers</h4> <h4 className="preference__title">{this.props.t('LineNumbers')}</h4>
<div className="preference__options"> <div className="preference__options">
<input <input
type="radio" type="radio"
@ -243,7 +244,7 @@ class Preferences extends React.Component {
value="On" value="On"
checked={this.props.lineNumbers} checked={this.props.lineNumbers}
/> />
<label htmlFor="line-numbers-on" className="preference__option">On</label> <label htmlFor="line-numbers-on" className="preference__option">{this.props.t('On')}</label>
<input <input
type="radio" type="radio"
onChange={() => this.props.setLineNumbers(false)} onChange={() => this.props.setLineNumbers(false)}
@ -254,11 +255,11 @@ class Preferences extends React.Component {
value="Off" value="Off"
checked={!this.props.lineNumbers} checked={!this.props.lineNumbers}
/> />
<label htmlFor="line-numbers-off" className="preference__option">Off</label> <label htmlFor="line-numbers-off" className="preference__option">{this.props.t('Off')}</label>
</div> </div>
</div> </div>
<div className="preference"> <div className="preference">
<h4 className="preference__title">Lint warning sound</h4> <h4 className="preference__title">{this.props.t('LintWarningSound')}</h4>
<div className="preference__options"> <div className="preference__options">
<input <input
type="radio" type="radio"
@ -270,7 +271,7 @@ class Preferences extends React.Component {
value="On" value="On"
checked={this.props.lintWarning} checked={this.props.lintWarning}
/> />
<label htmlFor="lint-warning-on" className="preference__option">On</label> <label htmlFor="lint-warning-on" className="preference__option">{this.props.t('On')}</label>
<input <input
type="radio" type="radio"
onChange={() => this.props.setLintWarning(false)} onChange={() => this.props.setLintWarning(false)}
@ -281,19 +282,19 @@ class Preferences extends React.Component {
value="Off" value="Off"
checked={!this.props.lintWarning} checked={!this.props.lintWarning}
/> />
<label htmlFor="lint-warning-off" className="preference__option">Off</label> <label htmlFor="lint-warning-off" className="preference__option">{this.props.t('Off')}</label>
<button <button
className="preference__preview-button" className="preference__preview-button"
onClick={() => beep.play()} onClick={() => beep.play()}
aria-label="preview sound" aria-label="preview sound"
> >
Preview sound {this.props.t('PreviewSound')}
</button> </button>
</div> </div>
</div> </div>
<div className="preference"> <div className="preference">
<h4 className="preference__title">Accessible text-based canvas</h4> <h4 className="preference__title">{this.props.t('AccessibleTextBasedCanvas')}</h4>
<h6 className="preference__subtitle">Used with screen reader</h6> <h6 className="preference__subtitle">{this.props.t('UsedScreenReader')}</h6>
<div className="preference__options"> <div className="preference__options">
<input <input
@ -307,7 +308,7 @@ class Preferences extends React.Component {
value="On" value="On"
checked={(this.props.textOutput)} checked={(this.props.textOutput)}
/> />
<label htmlFor="text-output-on" className="preference__option preference__canvas">Plain-text</label> <label htmlFor="text-output-on" className="preference__option preference__canvas">{this.props.t('PlainText')}</label>
<input <input
type="checkbox" type="checkbox"
onChange={(event) => { onChange={(event) => {
@ -319,7 +320,7 @@ class Preferences extends React.Component {
value="On" value="On"
checked={(this.props.gridOutput)} checked={(this.props.gridOutput)}
/> />
<label htmlFor="table-output-on" className="preference__option preference__canvas">Table-text</label> <label htmlFor="table-output-on" className="preference__option preference__canvas">{this.props.t('TableText')}</label>
<input <input
type="checkbox" type="checkbox"
onChange={(event) => { onChange={(event) => {
@ -331,7 +332,7 @@ class Preferences extends React.Component {
value="On" value="On"
checked={(this.props.soundOutput)} checked={(this.props.soundOutput)}
/> />
<label htmlFor="sound-output-on" className="preference__option preference__canvas">Sound</label> <label htmlFor="sound-output-on" className="preference__option preference__canvas">{this.props.t('Sound')}</label>
</div> </div>
</div> </div>
</TabPanel> </TabPanel>
@ -360,6 +361,7 @@ Preferences.propTypes = {
setLintWarning: PropTypes.func.isRequired, setLintWarning: PropTypes.func.isRequired,
theme: PropTypes.string.isRequired, theme: PropTypes.string.isRequired,
setTheme: PropTypes.func.isRequired, setTheme: PropTypes.func.isRequired,
t: PropTypes.func.isRequired,
}; };
export default Preferences; export default withTranslation('WebEditor')(Preferences);

View file

@ -22,6 +22,23 @@ import {
import { hijackConsoleErrorsScript, startTag, getAllScriptOffsets } import { hijackConsoleErrorsScript, startTag, getAllScriptOffsets }
from '../../../utils/consoleUtils'; from '../../../utils/consoleUtils';
const shouldRenderSketch = (props, prevProps = undefined) => {
const { isPlaying, previewIsRefreshing, fullView } = props;
// if the user explicitly clicks on the play button
if (isPlaying && previewIsRefreshing) return true;
if (!prevProps) return false;
return (props.isPlaying !== prevProps.isPlaying // if sketch starts or stops playing, want to rerender
|| props.isAccessibleOutputPlaying !== prevProps.isAccessibleOutputPlaying // if user switches textoutput preferences
|| props.textOutput !== prevProps.textOutput
|| props.gridOutput !== prevProps.gridOutput
|| props.soundOutput !== prevProps.soundOutput
|| (fullView && props.files[0].id !== prevProps.files[0].id));
};
class PreviewFrame extends React.Component { class PreviewFrame extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
@ -30,53 +47,25 @@ class PreviewFrame extends React.Component {
componentDidMount() { componentDidMount() {
window.addEventListener('message', this.handleConsoleEvent); window.addEventListener('message', this.handleConsoleEvent);
const props = {
...this.props,
previewIsRefreshing: this.props.previewIsRefreshing,
isAccessibleOutputPlaying: this.props.isAccessibleOutputPlaying
};
if (shouldRenderSketch(props)) this.renderSketch();
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
// if sketch starts or stops playing, want to rerender if (shouldRenderSketch(this.props, prevProps)) this.renderSketch();
if (this.props.isPlaying !== prevProps.isPlaying) {
this.renderSketch();
return;
}
// if the user explicitly clicks on the play button
if (this.props.isPlaying && this.props.previewIsRefreshing) {
this.renderSketch();
return;
}
// if user switches textoutput preferences
if (this.props.isAccessibleOutputPlaying !== prevProps.isAccessibleOutputPlaying) {
this.renderSketch();
return;
}
if (this.props.textOutput !== prevProps.textOutput) {
this.renderSketch();
return;
}
if (this.props.gridOutput !== prevProps.gridOutput) {
this.renderSketch();
return;
}
if (this.props.soundOutput !== prevProps.soundOutput) {
this.renderSketch();
return;
}
if (this.props.fullView && this.props.files[0].id !== prevProps.files[0].id) {
this.renderSketch();
}
// small bug - if autorefresh is on, and the usr changes files // small bug - if autorefresh is on, and the usr changes files
// in the sketch, preview will reload // in the sketch, preview will reload
} }
componentWillUnmount() { componentWillUnmount() {
window.removeEventListener('message', this.handleConsoleEvent); window.removeEventListener('message', this.handleConsoleEvent);
ReactDOM.unmountComponentAtNode(this.iframeElement.contentDocument.body); const iframeBody = this.iframeElement.contentDocument.body;
if (iframeBody) { ReactDOM.unmountComponentAtNode(iframeBody); }
} }
handleConsoleEvent(messageEvent) { handleConsoleEvent(messageEvent) {
@ -249,16 +238,18 @@ class PreviewFrame extends React.Component {
jsFileStrings.forEach((jsFileString) => { jsFileStrings.forEach((jsFileString) => {
if (jsFileString.match(MEDIA_FILE_QUOTED_REGEX)) { if (jsFileString.match(MEDIA_FILE_QUOTED_REGEX)) {
const filePath = jsFileString.substr(1, jsFileString.length - 2); const filePath = jsFileString.substr(1, jsFileString.length - 2);
const quoteCharacter = jsFileString.substr(0, 1);
const resolvedFile = resolvePathToFile(filePath, files); const resolvedFile = resolvePathToFile(filePath, files);
if (resolvedFile) { if (resolvedFile) {
if (resolvedFile.url) { if (resolvedFile.url) {
newContent = newContent.replace(filePath, resolvedFile.url); newContent = newContent.replace(jsFileString, quoteCharacter + resolvedFile.url + quoteCharacter);
} else if (resolvedFile.name.match(PLAINTEXT_FILE_REGEX)) { } else if (resolvedFile.name.match(PLAINTEXT_FILE_REGEX)) {
// could also pull file from API instead of using bloburl // could also pull file from API instead of using bloburl
const blobURL = getBlobUrl(resolvedFile); const blobURL = getBlobUrl(resolvedFile);
this.props.setBlobUrl(resolvedFile, blobURL); this.props.setBlobUrl(resolvedFile, blobURL);
const filePathRegex = new RegExp(filePath, 'gi');
newContent = newContent.replace(filePathRegex, blobURL); newContent = newContent.replace(jsFileString, quoteCharacter + blobURL + quoteCharacter);
} }
} }
} }
@ -274,10 +265,11 @@ class PreviewFrame extends React.Component {
cssFileStrings.forEach((cssFileString) => { cssFileStrings.forEach((cssFileString) => {
if (cssFileString.match(MEDIA_FILE_QUOTED_REGEX)) { if (cssFileString.match(MEDIA_FILE_QUOTED_REGEX)) {
const filePath = cssFileString.substr(1, cssFileString.length - 2); const filePath = cssFileString.substr(1, cssFileString.length - 2);
const quoteCharacter = cssFileString.substr(0, 1);
const resolvedFile = resolvePathToFile(filePath, files); const resolvedFile = resolvePathToFile(filePath, files);
if (resolvedFile) { if (resolvedFile) {
if (resolvedFile.url) { if (resolvedFile.url) {
newContent = newContent.replace(filePath, resolvedFile.url); newContent = newContent.replace(cssFileString, quoteCharacter + resolvedFile.url + quoteCharacter);
} }
} }
} }
@ -353,6 +345,8 @@ class PreviewFrame extends React.Component {
'preview-frame': true, 'preview-frame': true,
'preview-frame--full-view': this.props.fullView 'preview-frame--full-view': this.props.fullView
}); });
const sandboxAttributes =
'allow-scripts allow-pointer-lock allow-same-origin allow-popups allow-forms allow-modals allow-downloads';
return ( return (
<iframe <iframe
id="canvas_frame" id="canvas_frame"
@ -362,7 +356,7 @@ class PreviewFrame extends React.Component {
frameBorder="0" frameBorder="0"
title="sketch preview" title="sketch preview"
ref={(element) => { this.iframeElement = element; }} ref={(element) => { this.iframeElement = element; }}
sandbox="allow-scripts allow-pointer-lock allow-same-origin allow-popups allow-forms allow-modals" sandbox={sandboxAttributes}
/> />
); );
} }
@ -393,7 +387,7 @@ PreviewFrame.propTypes = {
clearConsole: PropTypes.func.isRequired, clearConsole: PropTypes.func.isRequired,
cmController: PropTypes.shape({ cmController: PropTypes.shape({
getContent: PropTypes.func getContent: PropTypes.func
}) }),
}; };
PreviewFrame.defaultProps = { PreviewFrame.defaultProps = {

View file

@ -2,15 +2,17 @@ import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import { bindActionCreators } from 'redux'; import { bindActionCreators } from 'redux';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { useTranslation } from 'react-i18next';
import * as ToastActions from '../actions/toast'; import * as ToastActions from '../actions/toast';
import ExitIcon from '../../../images/exit.svg'; import ExitIcon from '../../../images/exit.svg';
function Toast(props) { function Toast(props) {
const { t } = useTranslation('WebEditor');
return ( return (
<section className="toast"> <section className="toast">
<p> <p>
{props.text} {t(props.text)}
</p> </p>
<button className="toast__close" onClick={props.hideToast} aria-label="Close Alert" > <button className="toast__close" onClick={props.hideToast} aria-label="Close Alert" >
<ExitIcon focusable="false" aria-hidden="true" /> <ExitIcon focusable="false" aria-hidden="true" />

View file

@ -17,21 +17,36 @@ class Toolbar extends React.Component {
super(props); super(props);
this.handleKeyPress = this.handleKeyPress.bind(this); this.handleKeyPress = this.handleKeyPress.bind(this);
this.handleProjectNameChange = this.handleProjectNameChange.bind(this); this.handleProjectNameChange = this.handleProjectNameChange.bind(this);
this.handleProjectNameSave = this.handleProjectNameSave.bind(this);
this.state = {
projectNameInputValue: props.project.name,
};
} }
handleKeyPress(event) { handleKeyPress(event) {
if (event.key === 'Enter') { if (event.key === 'Enter') {
this.props.hideEditProjectName(); this.props.hideEditProjectName();
this.projectNameInput.blur();
} }
} }
handleProjectNameChange(event) { handleProjectNameChange(event) {
this.props.setProjectName(event.target.value); this.setState({ projectNameInputValue: event.target.value });
} }
validateProjectName() { handleProjectNameSave() {
if ((this.props.project.name.trim()).length === 0) { const newProjectName = this.state.projectNameInputValue.trim();
this.props.setProjectName(this.originalProjectName); if (newProjectName.length === 0) {
this.setState({
projectNameInputValue: this.props.project.name,
});
} else {
this.props.setProjectName(newProjectName);
this.props.hideEditProjectName();
if (this.props.project.id) {
this.props.saveProject();
}
} }
} }
@ -108,7 +123,6 @@ class Toolbar extends React.Component {
className="toolbar__project-name" className="toolbar__project-name"
onClick={() => { onClick={() => {
if (canEditProjectName) { if (canEditProjectName) {
this.originalProjectName = this.props.project.name;
this.props.showEditProjectName(); this.props.showEditProjectName();
setTimeout(() => this.projectNameInput.focus(), 0); setTimeout(() => this.projectNameInput.focus(), 0);
} }
@ -130,16 +144,11 @@ class Toolbar extends React.Component {
type="text" type="text"
maxLength="128" maxLength="128"
className="toolbar__project-name-input" className="toolbar__project-name-input"
value={this.props.project.name} aria-label="New sketch name"
value={this.state.projectNameInputValue}
onChange={this.handleProjectNameChange} onChange={this.handleProjectNameChange}
ref={(element) => { this.projectNameInput = element; }} ref={(element) => { this.projectNameInput = element; }}
onBlur={() => { onBlur={this.handleProjectNameSave}
this.validateProjectName();
this.props.hideEditProjectName();
if (this.props.project.id) {
this.props.saveProject();
}
}}
onKeyPress={this.handleKeyPress} onKeyPress={this.handleKeyPress}
/> />
{(() => { // eslint-disable-line {(() => { // eslint-disable-line

View file

@ -0,0 +1,84 @@
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import lodash from 'lodash';
import { ToolbarComponent } from './Toolbar';
const renderComponent = (extraProps = {}) => {
const props = lodash.merge({
isPlaying: false,
preferencesIsVisible: false,
stopSketch: jest.fn(),
setProjectName: jest.fn(),
openPreferences: jest.fn(),
showEditProjectName: jest.fn(),
hideEditProjectName: jest.fn(),
infiniteLoop: false,
autorefresh: false,
setAutorefresh: jest.fn(),
setTextOutput: jest.fn(),
setGridOutput: jest.fn(),
startSketch: jest.fn(),
startAccessibleSketch: jest.fn(),
saveProject: jest.fn(),
currentUser: 'me',
originalProjectName: 'testname',
owner: {
username: 'me'
},
project: {
name: 'testname',
isEditingName: false,
id: 'id',
},
}, extraProps);
render(<ToolbarComponent {...props} />);
return props;
};
describe('<ToolbarComponent />', () => {
it('sketch owner can switch to sketch name editing mode', async () => {
const props = renderComponent();
const sketchName = screen.getByLabelText('Edit sketch name');
fireEvent.click(sketchName);
await waitFor(() => expect(props.showEditProjectName).toHaveBeenCalled());
});
it('non-owner can\t switch to sketch editing mode', async () => {
const props = renderComponent({ currentUser: 'not-me' });
const sketchName = screen.getByLabelText('Edit sketch name');
fireEvent.click(sketchName);
expect(sketchName).toBeDisabled();
await waitFor(() => expect(props.showEditProjectName).not.toHaveBeenCalled());
});
it('sketch owner can change name', async () => {
const props = renderComponent({ project: { isEditingName: true } });
const sketchNameInput = screen.getByLabelText('New sketch name');
fireEvent.change(sketchNameInput, { target: { value: 'my new sketch name' } });
fireEvent.blur(sketchNameInput);
await waitFor(() => expect(props.setProjectName).toHaveBeenCalledWith('my new sketch name'));
await waitFor(() => expect(props.saveProject).toHaveBeenCalled());
});
it('sketch owner can\'t change to empty name', async () => {
const props = renderComponent({ project: { isEditingName: true } });
const sketchNameInput = screen.getByLabelText('New sketch name');
fireEvent.change(sketchNameInput, { target: { value: '' } });
fireEvent.blur(sketchNameInput);
await waitFor(() => expect(props.setProjectName).not.toHaveBeenCalled());
await waitFor(() => expect(props.saveProject).not.toHaveBeenCalled());
});
});

View file

@ -3,12 +3,12 @@ import PropTypes from 'prop-types';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { Link } from 'react-router'; import { Link } from 'react-router';
import prettyBytes from 'pretty-bytes'; import prettyBytes from 'pretty-bytes';
import getConfig from '../../../utils/getConfig';
import FileUploader from './FileUploader'; import FileUploader from './FileUploader';
import { getreachedTotalSizeLimit } from '../selectors/users'; import { getreachedTotalSizeLimit } from '../selectors/users';
import ExitIcon from '../../../images/exit.svg'; import ExitIcon from '../../../images/exit.svg';
const __process = (typeof global !== 'undefined' ? global : window).process; const limit = getConfig('UPLOAD_LIMIT') || 250000000;
const limit = __process.env.UPLOAD_LIMIT || 250000000;
const limitText = prettyBytes(limit); const limitText = prettyBytes(limit);
class UploadFileModal extends React.Component { class UploadFileModal extends React.Component {

View file

@ -3,6 +3,7 @@ import React from 'react';
import { bindActionCreators } from 'redux'; import { bindActionCreators } from 'redux';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { withRouter } from 'react-router'; import { withRouter } from 'react-router';
import { withTranslation } from 'react-i18next';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import SplitPane from 'react-split-pane'; import SplitPane from 'react-split-pane';
import Editor from '../components/Editor'; import Editor from '../components/Editor';
@ -34,11 +35,38 @@ import AddToCollectionList from '../components/AddToCollectionList';
import Feedback from '../components/Feedback'; import Feedback from '../components/Feedback';
import { CollectionSearchbar } from '../components/Searchbar'; import { CollectionSearchbar } from '../components/Searchbar';
function getTitle(props) {
const { id } = props.project;
return id ? `p5.js Web Editor | ${props.project.name}` : 'p5.js Web Editor';
}
function isUserOwner(props) {
return props.project.owner && props.project.owner.id === props.user.id;
}
function warnIfUnsavedChanges(props) { // eslint-disable-line
const { route } = props.route;
if (route && (route.action === 'PUSH' && (route.pathname === '/login' || route.pathname === '/signup'))) {
// don't warn
props.persistState();
window.onbeforeunload = null;
} else if (route && (props.location.pathname === '/login' || props.location.pathname === '/signup')) {
// don't warn
props.persistState();
window.onbeforeunload = null;
} else if (props.ide.unsavedChanges) {
if (!window.confirm(props.t('WarningUnsavedChanges'))) {
return false;
}
props.setUnsavedChanges(false);
return true;
}
}
class IDEView extends React.Component { class IDEView extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.handleGlobalKeydown = this.handleGlobalKeydown.bind(this); this.handleGlobalKeydown = this.handleGlobalKeydown.bind(this);
this.warnIfUnsavedChanges = this.warnIfUnsavedChanges.bind(this);
this.state = { this.state = {
consoleSize: props.ide.consoleIsExpanded ? 150 : 29, consoleSize: props.ide.consoleIsExpanded ? 150 : 29,
@ -62,9 +90,9 @@ class IDEView extends React.Component {
this.isMac = navigator.userAgent.toLowerCase().indexOf('mac') !== -1; this.isMac = navigator.userAgent.toLowerCase().indexOf('mac') !== -1;
document.addEventListener('keydown', this.handleGlobalKeydown, false); document.addEventListener('keydown', this.handleGlobalKeydown, false);
this.props.router.setRouteLeaveHook(this.props.route, route => this.warnIfUnsavedChanges(route)); this.props.router.setRouteLeaveHook(this.props.route, this.handleUnsavedChanges);
window.onbeforeunload = () => this.warnIfUnsavedChanges(); window.onbeforeunload = this.handleUnsavedChanges;
this.autosaveInterval = null; this.autosaveInterval = null;
} }
@ -92,7 +120,7 @@ class IDEView extends React.Component {
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (this.isUserOwner() && this.props.project.id) { if (isUserOwner(this.props) && this.props.project.id) {
if (this.props.preferences.autosave && this.props.ide.unsavedChanges && !this.props.ide.justOpenedProject) { if (this.props.preferences.autosave && this.props.ide.unsavedChanges && !this.props.ide.justOpenedProject) {
if ( if (
this.props.selectedFile.name === prevProps.selectedFile.name && this.props.selectedFile.name === prevProps.selectedFile.name &&
@ -113,7 +141,7 @@ class IDEView extends React.Component {
} }
if (this.props.route.path !== prevProps.route.path) { if (this.props.route.path !== prevProps.route.path) {
this.props.router.setRouteLeaveHook(this.props.route, route => this.warnIfUnsavedChanges(route)); this.props.router.setRouteLeaveHook(this.props.route, () => warnIfUnsavedChanges(this.props));
} }
} }
@ -123,21 +151,12 @@ class IDEView extends React.Component {
this.autosaveInterval = null; this.autosaveInterval = null;
} }
getTitle = () => {
const { id } = this.props.project;
return id ? `p5.js Web Editor | ${this.props.project.name}` : 'p5.js Web Editor';
}
isUserOwner() {
return this.props.project.owner && this.props.project.owner.id === this.props.user.id;
}
handleGlobalKeydown(e) { handleGlobalKeydown(e) {
// 83 === s // 83 === s
if (e.keyCode === 83 && ((e.metaKey && this.isMac) || (e.ctrlKey && !this.isMac))) { if (e.keyCode === 83 && ((e.metaKey && this.isMac) || (e.ctrlKey && !this.isMac))) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (this.isUserOwner() || (this.props.user.authenticated && !this.props.project.owner)) { if (isUserOwner(this.props) || (this.props.user.authenticated && !this.props.project.owner)) {
this.props.saveProject(this.cmController.getContent()); this.props.saveProject(this.cmController.getContent());
} else if (this.props.user.authenticated) { } else if (this.props.user.authenticated) {
this.props.cloneProject(); this.props.cloneProject();
@ -186,39 +205,23 @@ class IDEView extends React.Component {
} }
} }
warnIfUnsavedChanges(route) { // eslint-disable-line handleUnsavedChanges = () => warnIfUnsavedChanges(this.props);
if (route && (route.action === 'PUSH' && (route.pathname === '/login' || route.pathname === '/signup'))) {
// don't warn
this.props.persistState();
window.onbeforeunload = null;
} else if (route && (this.props.location.pathname === '/login' || this.props.location.pathname === '/signup')) {
// don't warn
this.props.persistState();
window.onbeforeunload = null;
} else if (this.props.ide.unsavedChanges) {
if (!window.confirm('Are you sure you want to leave this page? You have unsaved changes.')) {
return false;
}
this.props.setUnsavedChanges(false);
return true;
}
}
render() { render() {
return ( return (
<div className="ide"> <div className="ide">
<Helmet> <Helmet>
<title>{this.getTitle()}</title> <title>{getTitle(this.props)}</title>
</Helmet> </Helmet>
{this.props.toast.isVisible && <Toast />} {this.props.toast.isVisible && <Toast />}
<Nav <Nav
warnIfUnsavedChanges={this.warnIfUnsavedChanges} warnIfUnsavedChanges={this.handleUnsavedChanges}
cmController={this.cmController} cmController={this.cmController}
/> />
<Toolbar /> <Toolbar key={this.props.project.id} />
{this.props.ide.preferencesIsVisible && {this.props.ide.preferencesIsVisible &&
<Overlay <Overlay
title="Settings" title={this.props.t('Settings')}
ariaLabel="settings" ariaLabel="settings"
closeOverlay={this.props.closePreferences} closeOverlay={this.props.closePreferences}
> >
@ -313,7 +316,7 @@ class IDEView extends React.Component {
isExpanded={this.props.ide.sidebarIsExpanded} isExpanded={this.props.ide.sidebarIsExpanded}
expandSidebar={this.props.expandSidebar} expandSidebar={this.props.expandSidebar}
collapseSidebar={this.props.collapseSidebar} collapseSidebar={this.props.collapseSidebar}
isUserOwner={this.isUserOwner()} isUserOwner={isUserOwner(this.props)}
clearConsole={this.props.clearConsole} clearConsole={this.props.clearConsole}
consoleEvents={this.props.console} consoleEvents={this.props.console}
showRuntimeErrorWarning={this.props.showRuntimeErrorWarning} showRuntimeErrorWarning={this.props.showRuntimeErrorWarning}
@ -334,7 +337,7 @@ class IDEView extends React.Component {
</SplitPane> </SplitPane>
<section className="preview-frame-holder"> <section className="preview-frame-holder">
<header className="preview-frame__header"> <header className="preview-frame__header">
<h2 className="preview-frame__title">Preview</h2> <h2 className="preview-frame__title">{this.props.t('Preview')}</h2>
</header> </header>
<div className="preview-frame__content"> <div className="preview-frame__content">
<div className="preview-frame-overlay" ref={(element) => { this.overlay = element; }}> <div className="preview-frame-overlay" ref={(element) => { this.overlay = element; }}>
@ -395,7 +398,7 @@ class IDEView extends React.Component {
} }
{ this.props.location.pathname === '/about' && { this.props.location.pathname === '/about' &&
<Overlay <Overlay
title="About" title={this.props.t('About')}
previousPath={this.props.ide.previousPath} previousPath={this.props.ide.previousPath}
ariaLabel="about" ariaLabel="about"
> >
@ -441,7 +444,7 @@ class IDEView extends React.Component {
} }
{this.props.ide.keyboardShortcutVisible && {this.props.ide.keyboardShortcutVisible &&
<Overlay <Overlay
title="Keyboard Shortcuts" title={this.props.t('KeyboardShortcuts')}
ariaLabel="keyboard shortcuts" ariaLabel="keyboard shortcuts"
closeOverlay={this.props.closeKeyboardShortcutModal} closeOverlay={this.props.closeKeyboardShortcutModal}
> >
@ -604,12 +607,12 @@ IDEView.propTypes = {
showErrorModal: PropTypes.func.isRequired, showErrorModal: PropTypes.func.isRequired,
hideErrorModal: PropTypes.func.isRequired, hideErrorModal: PropTypes.func.isRequired,
clearPersistedState: PropTypes.func.isRequired, clearPersistedState: PropTypes.func.isRequired,
persistState: PropTypes.func.isRequired,
showRuntimeErrorWarning: PropTypes.func.isRequired, showRuntimeErrorWarning: PropTypes.func.isRequired,
hideRuntimeErrorWarning: PropTypes.func.isRequired, hideRuntimeErrorWarning: PropTypes.func.isRequired,
startSketch: PropTypes.func.isRequired, startSketch: PropTypes.func.isRequired,
openUploadFileModal: PropTypes.func.isRequired, openUploadFileModal: PropTypes.func.isRequired,
closeUploadFileModal: PropTypes.func.isRequired closeUploadFileModal: PropTypes.func.isRequired,
t: PropTypes.func.isRequired
}; };
function mapStateToProps(state) { function mapStateToProps(state) {
@ -646,4 +649,6 @@ function mapDispatchToProps(dispatch) {
); );
} }
export default withRouter(connect(mapStateToProps, mapDispatchToProps)(IDEView));
export default withTranslation('WebEditor')(withRouter(connect(mapStateToProps, mapDispatchToProps)(IDEView)));

View file

@ -0,0 +1,250 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { withRouter } from 'react-router';
import { useState } from 'react';
// Imports to be Refactored
import { bindActionCreators } from 'redux';
import * as FileActions from '../actions/files';
import * as IDEActions from '../actions/ide';
import * as ProjectActions from '../actions/project';
import * as EditorAccessibilityActions from '../actions/editorAccessibility';
import * as PreferencesActions from '../actions/preferences';
import * as UserActions from '../../User/actions';
import * as ToastActions from '../actions/toast';
import * as ConsoleActions from '../actions/console';
import { getHTMLFile } from '../reducers/files';
// Local Imports
import Editor from '../components/Editor';
import { PreferencesIcon, PlayIcon, ExitIcon } from '../../../common/icons';
import IconButton from '../../../components/mobile/IconButton';
import Header from '../../../components/mobile/Header';
import Screen from '../../../components/mobile/MobileScreen';
import Footer from '../../../components/mobile/Footer';
import IDEWrapper from '../../../components/mobile/IDEWrapper';
const isUserOwner = ({ project, user }) => (project.owner && project.owner.id === user.id);
const MobileIDEView = (props) => {
const {
preferences, ide, editorAccessibility, project, updateLintMessage, clearLintMessage,
selectedFile, updateFileContent, files,
closeEditorOptions, showEditorOptions, showKeyboardShortcutModal, setUnsavedChanges,
startRefreshSketch, stopSketch, expandSidebar, collapseSidebar, clearConsole, console,
showRuntimeErrorWarning, hideRuntimeErrorWarning, startSketch
} = props;
const [tmController, setTmController] = useState(null); // eslint-disable-line
const [overlay, setOverlay] = useState(null); // eslint-disable-line
return (
<Screen fullscreen>
<Header
title={project.name}
subtitle={selectedFile.name}
leftButton={
<IconButton to="/mobile" icon={ExitIcon} aria-label="Return to original editor" />
}
>
<IconButton
to="/mobile/preferences"
onClick={() => setOverlay('preferences')}
icon={PreferencesIcon}
aria-label="Open preferences menu"
/>
<IconButton to="/mobile/preview" onClick={() => { startSketch(); }} icon={PlayIcon} aria-label="Run sketch" />
</Header>
<IDEWrapper>
<Editor
lintWarning={preferences.lintWarning}
linewrap={preferences.linewrap}
lintMessages={editorAccessibility.lintMessages}
updateLintMessage={updateLintMessage}
clearLintMessage={clearLintMessage}
file={selectedFile}
updateFileContent={updateFileContent}
fontSize={preferences.fontSize}
lineNumbers={preferences.lineNumbers}
files={files}
editorOptionsVisible={ide.editorOptionsVisible}
showEditorOptions={showEditorOptions}
closeEditorOptions={closeEditorOptions}
showKeyboardShortcutModal={showKeyboardShortcutModal}
setUnsavedChanges={setUnsavedChanges}
isPlaying={ide.isPlaying}
theme={preferences.theme}
startRefreshSketch={startRefreshSketch}
stopSketch={stopSketch}
autorefresh={preferences.autorefresh}
unsavedChanges={ide.unsavedChanges}
projectSavedTime={project.updatedAt}
isExpanded={ide.sidebarIsExpanded}
expandSidebar={expandSidebar}
collapseSidebar={collapseSidebar}
isUserOwner={isUserOwner(props)}
clearConsole={clearConsole}
consoleEvents={console}
showRuntimeErrorWarning={showRuntimeErrorWarning}
hideRuntimeErrorWarning={hideRuntimeErrorWarning}
runtimeErrorWarningVisible={ide.runtimeErrorWarningVisible}
provideController={setTmController}
/>
</IDEWrapper>
<Footer><h2>Bottom Bar</h2></Footer>
</Screen>
);
};
MobileIDEView.propTypes = {
preferences: PropTypes.shape({
fontSize: PropTypes.number.isRequired,
autosave: PropTypes.bool.isRequired,
linewrap: PropTypes.bool.isRequired,
lineNumbers: PropTypes.bool.isRequired,
lintWarning: PropTypes.bool.isRequired,
textOutput: PropTypes.bool.isRequired,
gridOutput: PropTypes.bool.isRequired,
soundOutput: PropTypes.bool.isRequired,
theme: PropTypes.string.isRequired,
autorefresh: PropTypes.bool.isRequired
}).isRequired,
ide: PropTypes.shape({
isPlaying: PropTypes.bool.isRequired,
isAccessibleOutputPlaying: PropTypes.bool.isRequired,
consoleEvent: PropTypes.array,
modalIsVisible: PropTypes.bool.isRequired,
sidebarIsExpanded: PropTypes.bool.isRequired,
consoleIsExpanded: PropTypes.bool.isRequired,
preferencesIsVisible: PropTypes.bool.isRequired,
projectOptionsVisible: PropTypes.bool.isRequired,
newFolderModalVisible: PropTypes.bool.isRequired,
shareModalVisible: PropTypes.bool.isRequired,
shareModalProjectId: PropTypes.string.isRequired,
shareModalProjectName: PropTypes.string.isRequired,
shareModalProjectUsername: PropTypes.string.isRequired,
editorOptionsVisible: PropTypes.bool.isRequired,
keyboardShortcutVisible: PropTypes.bool.isRequired,
unsavedChanges: PropTypes.bool.isRequired,
infiniteLoop: PropTypes.bool.isRequired,
previewIsRefreshing: PropTypes.bool.isRequired,
infiniteLoopMessage: PropTypes.string.isRequired,
projectSavedTime: PropTypes.string,
previousPath: PropTypes.string.isRequired,
justOpenedProject: PropTypes.bool.isRequired,
errorType: PropTypes.string,
runtimeErrorWarningVisible: PropTypes.bool.isRequired,
uploadFileModalVisible: PropTypes.bool.isRequired
}).isRequired,
editorAccessibility: PropTypes.shape({
lintMessages: PropTypes.array.isRequired,
}).isRequired,
project: PropTypes.shape({
id: PropTypes.string,
name: PropTypes.string.isRequired,
owner: PropTypes.shape({
username: PropTypes.string,
id: PropTypes.string
}),
updatedAt: PropTypes.string
}).isRequired,
startSketch: PropTypes.func.isRequired,
updateLintMessage: PropTypes.func.isRequired,
clearLintMessage: PropTypes.func.isRequired,
selectedFile: PropTypes.shape({
id: PropTypes.string.isRequired,
content: PropTypes.string.isRequired,
name: PropTypes.string.isRequired
}).isRequired,
updateFileContent: PropTypes.func.isRequired,
files: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
content: PropTypes.string.isRequired
})).isRequired,
closeEditorOptions: PropTypes.func.isRequired,
showEditorOptions: PropTypes.func.isRequired,
showKeyboardShortcutModal: PropTypes.func.isRequired,
setUnsavedChanges: PropTypes.func.isRequired,
startRefreshSketch: PropTypes.func.isRequired,
stopSketch: PropTypes.func.isRequired,
expandSidebar: PropTypes.func.isRequired,
collapseSidebar: PropTypes.func.isRequired,
clearConsole: PropTypes.func.isRequired,
console: PropTypes.arrayOf(PropTypes.shape({
method: PropTypes.string.isRequired,
args: PropTypes.arrayOf(PropTypes.string)
})).isRequired,
showRuntimeErrorWarning: PropTypes.func.isRequired,
hideRuntimeErrorWarning: PropTypes.func.isRequired,
user: PropTypes.shape({
authenticated: PropTypes.bool.isRequired,
id: PropTypes.string,
username: PropTypes.string
}).isRequired,
};
function mapStateToProps(state) {
return {
files: state.files,
selectedFile: state.files.find(file => file.isSelectedFile) ||
state.files.find(file => file.name === 'sketch.js') ||
state.files.find(file => file.name !== 'root'),
htmlFile: getHTMLFile(state.files),
ide: state.ide,
preferences: state.preferences,
editorAccessibility: state.editorAccessibility,
user: state.user,
project: state.project,
toast: state.toast,
console: state.console
};
}
function mapDispatchToProps(dispatch) {
return bindActionCreators(
Object.assign(
{},
EditorAccessibilityActions,
FileActions,
ProjectActions,
IDEActions,
PreferencesActions,
UserActions,
ToastActions,
ConsoleActions
),
dispatch
);
}
export default withRouter(connect(mapStateToProps, mapDispatchToProps)(MobileIDEView));

View file

@ -37,6 +37,11 @@ const getSortedCollections = createSelector(
return orderBy(collections, 'name', 'desc'); return orderBy(collections, 'name', 'desc');
} }
return orderBy(collections, 'name', 'asc'); return orderBy(collections, 'name', 'asc');
} else if (field === 'numItems') {
if (direction === DIRECTION.DESC) {
return orderBy(collections, 'items.length', 'desc');
}
return orderBy(collections, 'items.length', 'asc');
} }
const sortedCollections = [...collections].sort((a, b) => { const sortedCollections = [...collections].sort((a, b) => {
const result = const result =

View file

@ -1,10 +1,10 @@
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import getConfig from '../../../utils/getConfig';
const __process = (typeof global !== 'undefined' ? global : window).process;
const getAuthenticated = state => state.user.authenticated; const getAuthenticated = state => state.user.authenticated;
const getTotalSize = state => state.user.totalSize; const getTotalSize = state => state.user.totalSize;
const getAssetsTotalSize = state => state.assets.totalSize; const getAssetsTotalSize = state => state.assets.totalSize;
const limit = __process.env.UPLOAD_LIMIT || 250000000; const limit = getConfig('UPLOAD_LIMIT') || 250000000;
export const getCanUploadMedia = createSelector( export const getCanUploadMedia = createSelector(
getAuthenticated, getAuthenticated,

View file

@ -0,0 +1,226 @@
import React from 'react';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { withRouter } from 'react-router';
import PropTypes from 'prop-types';
import styled from 'styled-components';
import * as PreferencesActions from '../IDE/actions/preferences';
import * as IdeActions from '../IDE/actions/ide';
import IconButton from '../../components/mobile/IconButton';
import Screen from '../../components/mobile/MobileScreen';
import Header from '../../components/mobile/Header';
import PreferencePicker from '../../components/mobile/PreferencePicker';
import { ExitIcon } from '../../common/icons';
import { remSize, prop } from '../../theme';
const Content = styled.div`
z-index: 0;
margin-top: ${remSize(68)};
`;
const SettingsHeader = styled(Header)`
background: transparent;
`;
const SectionHeader = styled.h2`
color: ${prop('primaryTextColor')};
padding-top: ${remSize(32)};
`;
const SectionSubeader = styled.h3`
color: ${prop('primaryTextColor')};
`;
const MobilePreferences = (props) => {
const {
setTheme, setAutosave, setLinewrap, setTextOutput, setGridOutput, setSoundOutput, lineNumbers, lintWarning
} = props;
const {
theme, autosave, linewrap, textOutput, gridOutput, soundOutput, setLineNumbers, setLintWarning
} = props;
const generalSettings = [
{
title: 'Theme',
value: theme,
options: [
{
value: 'light', label: 'light', ariaLabel: 'light theme on', name: 'light theme', id: 'light-theme-on'
},
{
value: 'dark', label: 'dark', ariaLabel: 'dark theme on', name: 'dark theme', id: 'dark-theme-on'
},
{
value: 'contrast',
label: 'contrast',
ariaLabel: 'contrast theme on',
name: 'contrast theme',
id: 'contrast-theme-on'
}
],
onSelect: x => setTheme(x) // setTheme
},
{
title: 'Autosave',
value: autosave,
options: [
{
value: true, label: 'On', ariaLabel: 'autosave on', name: 'autosave', id: 'autosave-on'
},
{
value: false, label: 'Off', ariaLabel: 'autosave off', name: 'autosave', id: 'autosave-off'
},
],
onSelect: x => setAutosave(x) // setAutosave
},
{
title: 'Word Wrap',
value: linewrap,
options: [
{
value: true, label: 'On', ariaLabel: 'linewrap on', name: 'linewrap', id: 'linewrap-on'
},
{
value: false, label: 'Off', ariaLabel: 'linewrap off', name: 'linewrap', id: 'linewrap-off'
},
],
onSelect: x => setLinewrap(x)
}
];
const outputSettings = [
{
title: 'Plain-text',
value: textOutput,
options: [
{
value: true, label: 'On', ariaLabel: 'text output on', name: 'text output', id: 'text-output-on'
},
{
value: false, label: 'Off', ariaLabel: 'text output off', name: 'text output', id: 'text-output-off'
},
],
onSelect: x => setTextOutput(x)
},
{
title: 'Table-text',
value: gridOutput,
options: [
{
value: true, label: 'On', ariaLabel: 'table output on', name: 'table output', id: 'table-output-on'
},
{
value: false, label: 'Off', ariaLabel: 'table output off', name: 'table output', id: 'table-output-off'
},
],
onSelect: x => setGridOutput(x)
},
{
title: 'Sound',
value: soundOutput,
options: [
{
value: true, label: 'On', ariaLabel: 'sound output on', name: 'sound output', id: 'sound-output-on'
},
{
value: false, label: 'Off', ariaLabel: 'sound output off', name: 'sound output', id: 'sound-output-off'
},
],
onSelect: x => setSoundOutput(x)
},
];
const accessibilitySettings = [
{
title: 'Line Numbers',
value: lineNumbers,
options: [
{
value: true, label: 'On', ariaLabel: 'line numbers on', name: 'line numbers', id: 'line-numbers-on'
},
{
value: false, label: 'Off', ariaLabel: 'line numbers off', name: 'line numbers', id: 'line-numbers-off'
},
],
onSelect: x => setLineNumbers(x)
},
{
title: 'Lint Warning Sound',
value: lintWarning,
options: [
{
value: true, label: 'On', ariaLabel: 'lint warning on', name: 'lint warning', id: 'lint-warning-on'
},
{
value: false, label: 'Off', ariaLabel: 'lint warning off', name: 'lint warning', id: 'lint-warning-off'
},
],
onSelect: x => setLintWarning(x)
},
];
return (
<Screen fullscreen>
<section>
<SettingsHeader transparent title="Preferences">
<IconButton to="/mobile" icon={ExitIcon} aria-label="Return to ide view" />
</SettingsHeader>
<section className="preferences">
<Content>
<SectionHeader>General Settings</SectionHeader>
{ generalSettings.map(option => <PreferencePicker key={`${option.title}wrapper`} {...option} />) }
<SectionHeader>Accessibility</SectionHeader>
{ accessibilitySettings.map(option => <PreferencePicker key={`${option.title}wrapper`} {...option} />) }
<SectionHeader>Accessible Output</SectionHeader>
<SectionSubeader>Used with screen reader</SectionSubeader>
{ outputSettings.map(option => <PreferencePicker key={`${option.title}wrapper`} {...option} />) }
</Content>
</section>
</section>
</Screen>);
};
MobilePreferences.propTypes = {
fontSize: PropTypes.number.isRequired,
lineNumbers: PropTypes.bool.isRequired,
autosave: PropTypes.bool.isRequired,
linewrap: PropTypes.bool.isRequired,
textOutput: PropTypes.bool.isRequired,
gridOutput: PropTypes.bool.isRequired,
soundOutput: PropTypes.bool.isRequired,
lintWarning: PropTypes.bool.isRequired,
theme: PropTypes.string.isRequired,
setLinewrap: PropTypes.func.isRequired,
setLintWarning: PropTypes.func.isRequired,
setTheme: PropTypes.func.isRequired,
setFontSize: PropTypes.func.isRequired,
setLineNumbers: PropTypes.func.isRequired,
setAutosave: PropTypes.func.isRequired,
setTextOutput: PropTypes.func.isRequired,
setGridOutput: PropTypes.func.isRequired,
setSoundOutput: PropTypes.func.isRequired,
};
const mapStateToProps = state => ({
...state.preferences,
});
const mapDispatchToProps = dispatch => bindActionCreators({
...PreferencesActions,
...IdeActions
}, dispatch);
export default withRouter(connect(mapStateToProps, mapDispatchToProps)(MobilePreferences));

View file

@ -0,0 +1,184 @@
import React from 'react';
import PropTypes from 'prop-types';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import styled from 'styled-components';
import Header from '../../components/mobile/Header';
import IconButton from '../../components/mobile/IconButton';
import PreviewFrame from '../IDE/components/PreviewFrame';
import Screen from '../../components/mobile/MobileScreen';
import * as ProjectActions from '../IDE/actions/project';
import * as IDEActions from '../IDE/actions/ide';
import * as PreferencesActions from '../IDE/actions/preferences';
import * as ConsoleActions from '../IDE/actions/console';
import * as FilesActions from '../IDE/actions/files';
import { getHTMLFile } from '../IDE/reducers/files';
import { ExitIcon } from '../../common/icons';
import { remSize } from '../../theme';
const Content = styled.div`
z-index: 0;
margin-top: ${remSize(68)};
`;
const MobileSketchView = (props) => {
// TODO: useSelector requires react-redux ^7.1.0
// const htmlFile = useSelector(state => getHTMLFile(state.files));
// const jsFiles = useSelector(state => getJSFiles(state.files));
// const cssFiles = useSelector(state => getCSSFiles(state.files));
// const files = useSelector(state => state.files);
const {
htmlFile, files, selectedFile, projectName
} = props;
// Actions
const {
setTextOutput, setGridOutput, setSoundOutput,
endSketchRefresh, stopSketch,
dispatchConsoleEvent, expandConsole, clearConsole,
setBlobUrl,
} = props;
const { preferences, ide } = props;
return (
<Screen fullscreen>
<Header
leftButton={
<IconButton to="/mobile" icon={ExitIcon} aria-label="Return to original editor" />
}
title={projectName}
/>
<Content>
<PreviewFrame
htmlFile={htmlFile}
files={files}
head={<link type="text/css" rel="stylesheet" href="/preview-styles.css" />}
content={selectedFile.content}
isPlaying
isAccessibleOutputPlaying={ide.isAccessibleOutputPlaying}
previewIsRefreshing={ide.previewIsRefreshing}
textOutput={preferences.textOutput}
gridOutput={preferences.gridOutput}
soundOutput={preferences.soundOutput}
autorefresh={preferences.autorefresh}
setTextOutput={setTextOutput}
setGridOutput={setGridOutput}
setSoundOutput={setSoundOutput}
dispatchConsoleEvent={dispatchConsoleEvent}
endSketchRefresh={endSketchRefresh}
stopSketch={stopSketch}
setBlobUrl={setBlobUrl}
expandConsole={expandConsole}
clearConsole={clearConsole}
/>
</Content>
</Screen>);
};
MobileSketchView.propTypes = {
params: PropTypes.shape({
project_id: PropTypes.string,
username: PropTypes.string
}).isRequired,
htmlFile: PropTypes.shape({
id: PropTypes.string.isRequired,
content: PropTypes.string.isRequired,
name: PropTypes.string.isRequired
}).isRequired,
files: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string.isRequired,
content: PropTypes.string.isRequired,
name: PropTypes.string.isRequired
})).isRequired,
selectedFile: PropTypes.shape({
id: PropTypes.string.isRequired,
content: PropTypes.string.isRequired,
name: PropTypes.string.isRequired
}).isRequired,
preferences: PropTypes.shape({
fontSize: PropTypes.number.isRequired,
autosave: PropTypes.bool.isRequired,
linewrap: PropTypes.bool.isRequired,
lineNumbers: PropTypes.bool.isRequired,
lintWarning: PropTypes.bool.isRequired,
textOutput: PropTypes.bool.isRequired,
gridOutput: PropTypes.bool.isRequired,
soundOutput: PropTypes.bool.isRequired,
theme: PropTypes.string.isRequired,
autorefresh: PropTypes.bool.isRequired
}).isRequired,
ide: PropTypes.shape({
isPlaying: PropTypes.bool.isRequired,
isAccessibleOutputPlaying: PropTypes.bool.isRequired,
consoleEvent: PropTypes.array,
modalIsVisible: PropTypes.bool.isRequired,
sidebarIsExpanded: PropTypes.bool.isRequired,
consoleIsExpanded: PropTypes.bool.isRequired,
preferencesIsVisible: PropTypes.bool.isRequired,
projectOptionsVisible: PropTypes.bool.isRequired,
newFolderModalVisible: PropTypes.bool.isRequired,
shareModalVisible: PropTypes.bool.isRequired,
shareModalProjectId: PropTypes.string.isRequired,
shareModalProjectName: PropTypes.string.isRequired,
shareModalProjectUsername: PropTypes.string.isRequired,
editorOptionsVisible: PropTypes.bool.isRequired,
keyboardShortcutVisible: PropTypes.bool.isRequired,
unsavedChanges: PropTypes.bool.isRequired,
infiniteLoop: PropTypes.bool.isRequired,
previewIsRefreshing: PropTypes.bool.isRequired,
infiniteLoopMessage: PropTypes.string.isRequired,
projectSavedTime: PropTypes.string,
previousPath: PropTypes.string.isRequired,
justOpenedProject: PropTypes.bool.isRequired,
errorType: PropTypes.string,
runtimeErrorWarningVisible: PropTypes.bool.isRequired,
uploadFileModalVisible: PropTypes.bool.isRequired
}).isRequired,
projectName: PropTypes.string.isRequired,
setTextOutput: PropTypes.func.isRequired,
setGridOutput: PropTypes.func.isRequired,
setSoundOutput: PropTypes.func.isRequired,
dispatchConsoleEvent: PropTypes.func.isRequired,
endSketchRefresh: PropTypes.func.isRequired,
stopSketch: PropTypes.func.isRequired,
setBlobUrl: PropTypes.func.isRequired,
expandConsole: PropTypes.func.isRequired,
clearConsole: PropTypes.func.isRequired,
};
function mapStateToProps(state) {
return {
htmlFile: getHTMLFile(state.files),
projectName: state.project.name,
files: state.files,
ide: state.ide,
preferences: state.preferences,
selectedFile: state.files.find(file => file.isSelectedFile) ||
state.files.find(file => file.name === 'sketch.js') ||
state.files.find(file => file.name !== 'root'),
};
}
function mapDispatchToProps(dispatch) {
return bindActionCreators({
...ProjectActions, ...IDEActions, ...PreferencesActions, ...ConsoleActions, ...FilesActions
}, dispatch);
}
export default connect(mapStateToProps, mapDispatchToProps)(MobileSketchView);

View file

@ -1,13 +1,9 @@
import { browserHistory } from 'react-router'; import { browserHistory } from 'react-router';
import axios from 'axios';
import * as ActionTypes from '../../constants'; import * as ActionTypes from '../../constants';
import apiClient from '../../utils/apiClient';
import { showErrorModal, justOpenedProject } from '../IDE/actions/ide'; import { showErrorModal, justOpenedProject } from '../IDE/actions/ide';
import { showToast, setToastText } from '../IDE/actions/toast'; import { showToast, setToastText } from '../IDE/actions/toast';
const __process = (typeof global !== 'undefined' ? global : window).process;
const ROOT_URL = __process.env.API_URL;
export function authError(error) { export function authError(error) {
return { return {
type: ActionTypes.AUTH_ERROR, type: ActionTypes.AUTH_ERROR,
@ -17,7 +13,7 @@ export function authError(error) {
export function signUpUser(previousPath, formValues) { export function signUpUser(previousPath, formValues) {
return (dispatch) => { return (dispatch) => {
axios.post(`${ROOT_URL}/signup`, formValues, { withCredentials: true }) apiClient.post('/signup', formValues)
.then((response) => { .then((response) => {
dispatch({ dispatch({
type: ActionTypes.AUTH_USER, type: ActionTypes.AUTH_USER,
@ -34,7 +30,7 @@ export function signUpUser(previousPath, formValues) {
} }
export function loginUser(formValues) { export function loginUser(formValues) {
return axios.post(`${ROOT_URL}/login`, formValues, { withCredentials: true }); return apiClient.post('/login', formValues);
} }
export function loginUserSuccess(user) { export function loginUserSuccess(user) {
@ -74,7 +70,7 @@ export function validateAndLoginUser(previousPath, formProps, dispatch) {
export function getUser() { export function getUser() {
return (dispatch) => { return (dispatch) => {
axios.get(`${ROOT_URL}/session`, { withCredentials: true }) apiClient.get('/session')
.then((response) => { .then((response) => {
dispatch({ dispatch({
type: ActionTypes.AUTH_USER, type: ActionTypes.AUTH_USER,
@ -95,7 +91,7 @@ export function getUser() {
export function validateSession() { export function validateSession() {
return (dispatch, getState) => { return (dispatch, getState) => {
axios.get(`${ROOT_URL}/session`, { withCredentials: true }) apiClient.get('/session')
.then((response) => { .then((response) => {
const state = getState(); const state = getState();
if (state.user.username !== response.data.username) { if (state.user.username !== response.data.username) {
@ -113,7 +109,7 @@ export function validateSession() {
export function logoutUser() { export function logoutUser() {
return (dispatch) => { return (dispatch) => {
axios.get(`${ROOT_URL}/logout`, { withCredentials: true }) apiClient.get('/logout')
.then(() => { .then(() => {
dispatch({ dispatch({
type: ActionTypes.UNAUTH_USER type: ActionTypes.UNAUTH_USER
@ -131,7 +127,7 @@ export function initiateResetPassword(formValues) {
dispatch({ dispatch({
type: ActionTypes.RESET_PASSWORD_INITIATE type: ActionTypes.RESET_PASSWORD_INITIATE
}); });
axios.post(`${ROOT_URL}/reset-password`, formValues, { withCredentials: true }) apiClient.post('/reset-password', formValues)
.then(() => { .then(() => {
// do nothing // do nothing
}) })
@ -150,7 +146,7 @@ export function initiateVerification() {
dispatch({ dispatch({
type: ActionTypes.EMAIL_VERIFICATION_INITIATE type: ActionTypes.EMAIL_VERIFICATION_INITIATE
}); });
axios.post(`${ROOT_URL}/verify/send`, {}, { withCredentials: true }) apiClient.post('/verify/send', {})
.then(() => { .then(() => {
// do nothing // do nothing
}) })
@ -170,7 +166,7 @@ export function verifyEmailConfirmation(token) {
type: ActionTypes.EMAIL_VERIFICATION_VERIFY, type: ActionTypes.EMAIL_VERIFICATION_VERIFY,
state: 'checking', state: 'checking',
}); });
return axios.get(`${ROOT_URL}/verify?t=${token}`, {}, { withCredentials: true }) return apiClient.get(`/verify?t=${token}`, {})
.then(response => dispatch({ .then(response => dispatch({
type: ActionTypes.EMAIL_VERIFICATION_VERIFIED, type: ActionTypes.EMAIL_VERIFICATION_VERIFIED,
message: response.data, message: response.data,
@ -194,7 +190,7 @@ export function resetPasswordReset() {
export function validateResetPasswordToken(token) { export function validateResetPasswordToken(token) {
return (dispatch) => { return (dispatch) => {
axios.get(`${ROOT_URL}/reset-password/${token}`) apiClient.get(`/reset-password/${token}`)
.then(() => { .then(() => {
// do nothing if the token is valid // do nothing if the token is valid
}) })
@ -206,7 +202,7 @@ export function validateResetPasswordToken(token) {
export function updatePassword(token, formValues) { export function updatePassword(token, formValues) {
return (dispatch) => { return (dispatch) => {
axios.post(`${ROOT_URL}/reset-password/${token}`, formValues) apiClient.post(`/reset-password/${token}`, formValues)
.then((response) => { .then((response) => {
dispatch(loginUserSuccess(response.data)); dispatch(loginUserSuccess(response.data));
browserHistory.push('/'); browserHistory.push('/');
@ -226,7 +222,7 @@ export function updateSettingsSuccess(user) {
export function updateSettings(formValues) { export function updateSettings(formValues) {
return dispatch => return dispatch =>
axios.put(`${ROOT_URL}/account`, formValues, { withCredentials: true }) apiClient.put('/account', formValues)
.then((response) => { .then((response) => {
dispatch(updateSettingsSuccess(response.data)); dispatch(updateSettingsSuccess(response.data));
browserHistory.push('/'); browserHistory.push('/');
@ -248,7 +244,7 @@ export function createApiKeySuccess(user) {
export function createApiKey(label) { export function createApiKey(label) {
return dispatch => return dispatch =>
axios.post(`${ROOT_URL}/account/api-keys`, { label }, { withCredentials: true }) apiClient.post('/account/api-keys', { label })
.then((response) => { .then((response) => {
dispatch(createApiKeySuccess(response.data)); dispatch(createApiKeySuccess(response.data));
}) })
@ -260,7 +256,7 @@ export function createApiKey(label) {
export function removeApiKey(keyId) { export function removeApiKey(keyId) {
return dispatch => return dispatch =>
axios.delete(`${ROOT_URL}/account/api-keys/${keyId}`, { withCredentials: true }) apiClient.delete(`/account/api-keys/${keyId}`)
.then((response) => { .then((response) => {
dispatch({ dispatch({
type: ActionTypes.API_KEY_REMOVED, type: ActionTypes.API_KEY_REMOVED,

View file

@ -71,41 +71,50 @@ ShareURL.propTypes = {
value: PropTypes.string.isRequired, value: PropTypes.string.isRequired,
}; };
class CollectionItemRowBase extends React.Component { const CollectionItemRowBase = ({
handleSketchRemove = () => { collection, item, isOwner, removeFromCollection
if (window.confirm(`Are you sure you want to remove "${this.props.item.project.name}" from this collection?`)) { }) => {
this.props.removeFromCollection(this.props.collection.id, this.props.item.project.id); const projectIsDeleted = item.isDeleted;
}
}
render() { const handleSketchRemove = () => {
const { item } = this.props; const name = projectIsDeleted ? 'deleted sketch' : item.project.name;
const sketchOwnerUsername = item.project.user.username;
const sketchUrl = `/${item.project.user.username}/sketches/${item.project.id}`; if (window.confirm(`Are you sure you want to remove "${name}" from this collection?`)) {
removeFromCollection(collection.id, item.projectId);
}
};
const name = projectIsDeleted ? <span>Sketch was deleted</span> : (
<Link to={`/${item.project.user.username}/sketches/${item.projectId}`}>
{item.project.name}
</Link>
);
const sketchOwnerUsername = projectIsDeleted ? null : item.project.user.username;
return ( return (
<tr <tr
className="sketches-table__row" className={`sketches-table__row ${projectIsDeleted ? 'is-deleted' : ''}`}
> >
<th scope="row"> <th scope="row">
<Link to={sketchUrl}> {name}
{item.project.name}
</Link>
</th> </th>
<td>{format(new Date(item.createdAt), 'MMM D, YYYY h:mm A')}</td> <td>{format(new Date(item.createdAt), 'MMM D, YYYY h:mm A')}</td>
<td>{sketchOwnerUsername}</td> <td>{sketchOwnerUsername}</td>
<td className="collection-row__action-column "> <td className="collection-row__action-column ">
{isOwner &&
<button <button
className="collection-row__remove-button" className="collection-row__remove-button"
onClick={this.handleSketchRemove} onClick={handleSketchRemove}
aria-label="Remove sketch from collection" aria-label="Remove sketch from collection"
> >
<RemoveIcon focusable="false" aria-hidden="true" /> <RemoveIcon focusable="false" aria-hidden="true" />
</button> </button>
}
</td> </td>
</tr>); </tr>);
} };
}
CollectionItemRowBase.propTypes = { CollectionItemRowBase.propTypes = {
collection: PropTypes.shape({ collection: PropTypes.shape({
@ -114,14 +123,17 @@ CollectionItemRowBase.propTypes = {
}).isRequired, }).isRequired,
item: PropTypes.shape({ item: PropTypes.shape({
createdAt: PropTypes.string.isRequired, createdAt: PropTypes.string.isRequired,
projectId: PropTypes.string.isRequired,
isDeleted: PropTypes.bool.isRequired,
project: PropTypes.shape({ project: PropTypes.shape({
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
user: PropTypes.shape({ user: PropTypes.shape({
username: PropTypes.string.isRequired username: PropTypes.string.isRequired
}) }),
}).isRequired, }).isRequired,
}).isRequired, }).isRequired,
isOwner: PropTypes.bool.isRequired,
user: PropTypes.shape({ user: PropTypes.shape({
username: PropTypes.string, username: PropTypes.string,
authenticated: PropTypes.bool.isRequired authenticated: PropTypes.bool.isRequired
@ -342,6 +354,7 @@ class Collection extends React.Component {
render() { render() {
const title = this.hasCollection() ? this.getCollectionName() : null; const title = this.hasCollection() ? this.getCollectionName() : null;
const isOwner = this.isOwner();
return ( return (
<main className="collection-container" data-has-items={this.hasCollectionItems() ? 'true' : 'false'}> <main className="collection-container" data-has-items={this.hasCollectionItems() ? 'true' : 'false'}>
@ -372,6 +385,7 @@ class Collection extends React.Component {
user={this.props.user} user={this.props.user}
username={this.getUsername()} username={this.getUsername()}
collection={this.props.collection} collection={this.props.collection}
isOwner={isOwner}
/>))} />))}
</tbody> </tbody>
</table> </table>

View file

@ -3,18 +3,15 @@ import React from 'react';
import { reduxForm } from 'redux-form'; import { reduxForm } from 'redux-form';
import { bindActionCreators } from 'redux'; import { bindActionCreators } from 'redux';
import { Tab, Tabs, TabList, TabPanel } from 'react-tabs'; import { Tab, Tabs, TabList, TabPanel } from 'react-tabs';
import axios from 'axios';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { updateSettings, initiateVerification, createApiKey, removeApiKey } from '../actions'; import { updateSettings, initiateVerification, createApiKey, removeApiKey } from '../actions';
import AccountForm from '../components/AccountForm'; import AccountForm from '../components/AccountForm';
import apiClient from '../../../utils/apiClient';
import { validateSettings } from '../../../utils/reduxFormUtils'; import { validateSettings } from '../../../utils/reduxFormUtils';
import SocialAuthButton from '../components/SocialAuthButton'; import SocialAuthButton from '../components/SocialAuthButton';
import APIKeyForm from '../components/APIKeyForm'; import APIKeyForm from '../components/APIKeyForm';
import Nav from '../../../components/Nav'; import Nav from '../../../components/Nav';
const __process = (typeof global !== 'undefined' ? global : window).process;
const ROOT_URL = __process.env.API_URL;
function SocialLoginPanel(props) { function SocialLoginPanel(props) {
return ( return (
<React.Fragment> <React.Fragment>
@ -96,7 +93,7 @@ function asyncValidate(formProps, dispatch, props) {
const queryParams = {}; const queryParams = {};
queryParams[fieldToValidate] = formProps[fieldToValidate]; queryParams[fieldToValidate] = formProps[fieldToValidate];
queryParams.check_type = fieldToValidate; queryParams.check_type = fieldToValidate;
return axios.get(`${ROOT_URL}/signup/duplicate_check`, { params: queryParams }) return apiClient.get('/signup/duplicate_check', { params: queryParams })
.then((response) => { .then((response) => {
if (response.data.exists) { if (response.data.exists) {
const error = {}; const error = {};

View file

@ -1,19 +1,16 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import { bindActionCreators } from 'redux'; import { bindActionCreators } from 'redux';
import axios from 'axios';
import { Link, browserHistory } from 'react-router'; import { Link, browserHistory } from 'react-router';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { reduxForm } from 'redux-form'; import { reduxForm } from 'redux-form';
import * as UserActions from '../actions'; import * as UserActions from '../actions';
import SignupForm from '../components/SignupForm'; import SignupForm from '../components/SignupForm';
import apiClient from '../../../utils/apiClient';
import { validateSignup } from '../../../utils/reduxFormUtils'; import { validateSignup } from '../../../utils/reduxFormUtils';
import SocialAuthButton from '../components/SocialAuthButton'; import SocialAuthButton from '../components/SocialAuthButton';
import Nav from '../../../components/Nav'; import Nav from '../../../components/Nav';
const __process = (typeof global !== 'undefined' ? global : window).process;
const ROOT_URL = __process.env.API_URL;
class SignupView extends React.Component { class SignupView extends React.Component {
gotoHomePage = () => { gotoHomePage = () => {
browserHistory.push('/'); browserHistory.push('/');
@ -86,7 +83,7 @@ function asyncValidate(formProps, dispatch, props) {
const queryParams = {}; const queryParams = {};
queryParams[fieldToValidate] = formProps[fieldToValidate]; queryParams[fieldToValidate] = formProps[fieldToValidate];
queryParams.check_type = fieldToValidate; queryParams.check_type = fieldToValidate;
return axios.get(`${ROOT_URL}/signup/duplicate_check`, { params: queryParams }) return apiClient.get('/signup/duplicate_check', { params: queryParams })
.then((response) => { .then((response) => {
if (response.data.exists) { if (response.data.exists) {
errors[fieldToValidate] = response.data.message; errors[fieldToValidate] = response.data.message;

View file

@ -2,6 +2,9 @@ import { Route, IndexRoute } from 'react-router';
import React from 'react'; import React from 'react';
import App from './modules/App/App'; import App from './modules/App/App';
import IDEView from './modules/IDE/pages/IDEView'; import IDEView from './modules/IDE/pages/IDEView';
import MobileIDEView from './modules/IDE/pages/MobileIDEView';
import MobileSketchView from './modules/Mobile/MobileSketchView';
import MobilePreferences from './modules/Mobile/MobilePreferences';
import FullView from './modules/IDE/pages/FullView'; import FullView from './modules/IDE/pages/FullView';
import LoginView from './modules/User/pages/LoginView'; import LoginView from './modules/User/pages/LoginView';
import SignupView from './modules/User/pages/SignupView'; import SignupView from './modules/User/pages/SignupView';
@ -20,7 +23,11 @@ const checkAuth = (store) => {
store.dispatch(getUser()); store.dispatch(getUser());
}; };
// TODO: This short-circuit seems unnecessary - using the mobile <Switch /> navigator (future) should prevent this from being called
const onRouteChange = (store) => { const onRouteChange = (store) => {
const path = window.location.pathname;
if (path.includes('/mobile')) return;
store.dispatch(stopSketch()); store.dispatch(stopSketch());
}; };
@ -49,6 +56,10 @@ const routes = store => (
<Route path="/:username/collections/create" component={DashboardView} /> <Route path="/:username/collections/create" component={DashboardView} />
<Route path="/:username/collections/:collection_id" component={CollectionView} /> <Route path="/:username/collections/:collection_id" component={CollectionView} />
<Route path="/about" component={IDEView} /> <Route path="/about" component={IDEView} />
<Route path="/mobile" component={MobileIDEView} />
<Route path="/mobile/preview" component={MobileSketchView} />
<Route path="/mobile/preferences" component={MobilePreferences} />
</Route> </Route>
); );

View file

@ -3,15 +3,14 @@ import thunk from 'redux-thunk';
import DevTools from './modules/App/components/DevTools'; import DevTools from './modules/App/components/DevTools';
import rootReducer from './reducers'; import rootReducer from './reducers';
import { clearState, loadState } from './persistState'; import { clearState, loadState } from './persistState';
import getConfig from './utils/getConfig';
const __process = (typeof global !== 'undefined' ? global : window).process;
export default function configureStore(initialState) { export default function configureStore(initialState) {
const enhancers = [ const enhancers = [
applyMiddleware(thunk), applyMiddleware(thunk),
]; ];
if (__process.env.CLIENT && __process.env.NODE_ENV === 'development') { if (getConfig('CLIENT') && getConfig('NODE_ENV') === 'development') {
// Enable DevTools only when rendering on client and during development. // Enable DevTools only when rendering on client and during development.
enhancers.push(window.devToolsExtension ? window.devToolsExtension() : DevTools.instrument()); enhancers.push(window.devToolsExtension ? window.devToolsExtension() : DevTools.instrument());
} }

View file

@ -28,7 +28,7 @@
top: 0; top: 0;
left: 0; left: 0;
bottom: 0; bottom: 0;
width: calc(var(--percent) * 100%); width: calc(var(--percent, 1) * 100%);
@include themify() { @include themify() {
background-color: getThemifyVariable('progress-bar-active-color'); background-color: getThemifyVariable('progress-bar-active-color');

View file

@ -85,6 +85,10 @@
} }
} }
.sketches-table__row.is-deleted > * {
font-style: italic;
}
.sketches-table thead { .sketches-table thead {
font-size: #{12 / $base-font-size}rem; font-size: #{12 / $base-font-size}rem;
@include themify() { @include themify() {

View file

@ -85,6 +85,17 @@ export default {
border: grays.middleLight, border: grays.middleLight,
}, },
}, },
Icon: {
default: grays.middleGray,
hover: grays.darker
},
MobilePanel: {
default: {
foreground: colors.black,
background: grays.light,
border: grays.middleLight,
},
}
}, },
[Theme.dark]: { [Theme.dark]: {
colors, colors,
@ -113,6 +124,17 @@ export default {
border: grays.middleDark, border: grays.middleDark,
}, },
}, },
Icon: {
default: grays.middleLight,
hover: grays.lightest
},
MobilePanel: {
default: {
foreground: grays.light,
background: grays.dark,
border: grays.middleDark,
},
}
}, },
[Theme.contrast]: { [Theme.contrast]: {
colors, colors,
@ -141,5 +163,16 @@ export default {
border: grays.middleDark, border: grays.middleDark,
}, },
}, },
Icon: {
default: grays.mediumLight,
hover: colors.yellow
},
MobilePanel: {
default: {
foreground: grays.light,
background: grays.dark,
border: grays.middleDark,
},
}
}, },
}; };

17
client/utils/apiClient.js Normal file
View file

@ -0,0 +1,17 @@
import axios from 'axios';
import getConfig from './getConfig';
const ROOT_URL = getConfig('API_URL');
/**
* Configures an Axios instance with the correct API URL
*/
function createClientInstance() {
return axios.create({
baseURL: ROOT_URL,
withCredentials: true
});
}
export default createClientInstance();

View file

@ -60,7 +60,7 @@ export const getAllScriptOffsets = (htmlFile) => {
if (ind === -1) { if (ind === -1) {
foundJSScript = false; foundJSScript = false;
} else { } else {
endFilenameInd = htmlFile.indexOf('.js', ind + startTag.length + 3); endFilenameInd = htmlFile.indexOf('.js', ind + startTag.length + 1);
filename = htmlFile.substring(ind + startTag.length, endFilenameInd); filename = htmlFile.substring(ind + startTag.length, endFilenameInd);
lineOffset = htmlFile.substring(0, ind).split('\n').length + hijackConsoleErrorsScriptLength; lineOffset = htmlFile.substring(0, ind).split('\n').length + hijackConsoleErrorsScriptLength;
offs.push([lineOffset, filename]); offs.push([lineOffset, filename]);

24
client/utils/getConfig.js Normal file
View file

@ -0,0 +1,24 @@
function isTestEnvironment() {
// eslint-disable-next-line no-use-before-define
return getConfig('NODE_ENV', { warn: false }) === 'test';
}
/**
* Returns config item from environment
*/
function getConfig(key, options = { warn: !isTestEnvironment() }) {
if (key == null) {
throw new Error('"key" must be provided to getConfig()');
}
const env = (typeof global !== 'undefined' ? global : window)?.process?.env || {};
const value = env[key];
if (value == null && options?.warn !== false) {
console.warn(`getConfig("${key}") returned null`);
}
return value;
}
export default getConfig;

View file

@ -0,0 +1,28 @@
import getConfig from './getConfig';
describe('utils/getConfig()', () => {
beforeEach(() => {
delete global.process.env.CONFIG_TEST_KEY_NAME;
delete window.process.env.CONFIG_TEST_KEY_NAME;
});
it('throws if key is not defined', () => {
expect(() => getConfig(/* key is missing */)).toThrow(/must be provided/);
});
it('fetches from global.process', () => {
global.process.env.CONFIG_TEST_KEY_NAME = 'editor.p5js.org';
expect(getConfig('CONFIG_TEST_KEY_NAME')).toBe('editor.p5js.org');
});
it('fetches from window.process', () => {
window.process.env.CONFIG_TEST_KEY_NAME = 'editor.p5js.org';
expect(getConfig('CONFIG_TEST_KEY_NAME')).toBe('editor.p5js.org');
});
it('warns but does not throw if no value found', () => {
expect(() => getConfig('CONFIG_TEST_KEY_NAME')).not.toThrow();
});
});

View file

@ -69,9 +69,8 @@ Note that this is optional, unless you are working on the part of the applicatio
If your S3 bucket is in the US East (N Virginia) region (us-east-1), you'll If your S3 bucket is in the US East (N Virginia) region (us-east-1), you'll
need to set a custom URL base for it, because it does not follow the standard need to set a custom URL base for it, because it does not follow the standard
naming pattern as the rest of the regions. Instead, add the following to your naming pattern as the rest of the regions. Instead, add the following to your
environment/.env file: environment/.env file, changing `BUCKET_NAME` to your bucket name. This is necessary because this override is currently treated as the full path to the bucket rather than as a proper base url:
`S3_BUCKET_URL_BASE=https://s3.amazonaws.com/{BUCKET_NAME}/`
`S3_BUCKET_URL_BASE=https://s3.amazonaws.com`
If you've configured your S3 bucket and DNS records to use a custom domain If you've configured your S3 bucket and DNS records to use a custom domain
name, you can also set it using this variable. I.e.: name, you can also set it using this variable. I.e.:

View file

@ -13,19 +13,19 @@ This project release guide is based on
1. `$ git checkout develop` 1. `$ git checkout develop`
2. `$ git checkout -b release-<newversion>` 2. `$ git checkout -b release-<newversion>`
3. Do all of the release branch testing necessary. This could be as simple as running `npm test:ci`, or it could take user testing over a few days. 3. Do all of the release branch testing necessary. This could be as simple as running `npm test:ci`, or it could take user testing over a few days.
4. `$ git checkout release` 4. `$ npm version <newversion>` (see [npm-version](https://docs.npmjs.com/cli/version) for valid values of <newversion>).
5. `$ git merge --no-ff release-<newversion>` 5. `$ git checkout release`
6. `$ npm version <newversion>` (see [npm-version](https://docs.npmjs.com/cli/version) for valid values of <newversion>). 6. `$ git merge --no-ff release-<newversion>`
7. `$ git push && git push --tags` 7. `$ git push && git push --tags`
8. `$ git checkout develop` 8. `$ git checkout develop`
9. `$ git merge --no-ff release-<newversion>` 9. Create a release on GitHub. Make sure that you release from the `release` branch! You can do this in one of two ways:
10. Create a release on GitHub. You can do this in one of two ways:
1. (Preferred) Use the [`hub` command line tool](https://hub.github.com/). You can automate adding all commit messages since the last release with the following command: 1. (Preferred) Use the [`hub` command line tool](https://hub.github.com/). You can automate adding all commit messages since the last release with the following command:
```sh ```sh
$ hub release create -d -m "<newversion>" -m "$(git log `git describe --tags --abbrev=0 HEAD^`..HEAD --oneline)" <newversion>` $ hub release create -d -m "<newversion>" -m "$(git log `git describe --tags --abbrev=0 HEAD^`..HEAD --oneline)" <newversion>`
``` ```
Note that this creates a draft release, which you can then edit on GitHub. This allows you to create release notes from the list of commit messages, but then edit these notes as you wish. Note that this creates a draft release, which you can then edit on GitHub. This allows you to create release notes from the list of commit messages, but then edit these notes as you wish.
2. [Draft a new release on Github](https://github.com/processing/p5.js-web-editor/releases/new). 2. [Draft a new release on Github](https://github.com/processing/p5.js-web-editor/releases/new).
10. `$ git merge --no-ff release-<newversion>`
Travis CI will automatically deploy the release to production, as well as push a production tagged Docker image to DockerHub. Travis CI will automatically deploy the release to production, as well as push a production tagged Docker image to DockerHub.

View file

@ -1,8 +0,0 @@
import { configure } from 'enzyme'
import Adapter from 'enzyme-adapter-react-16'
import '@babel/polyfill'
import mongoose from 'mongoose'
mongoose.Promise = global.Promise;
configure({ adapter: new Adapter() })

8725
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{ {
"name": "p5.js-web-editor", "name": "p5.js-web-editor",
"version": "1.0.3", "version": "1.0.4",
"description": "The web editor for p5.js.", "description": "The web editor for p5.js.",
"scripts": { "scripts": {
"clean": "rimraf dist", "clean": "rimraf dist",
@ -36,12 +36,30 @@
] ]
}, },
"jest": { "jest": {
"setupFiles": [ "projects": [
"<rootDir>/jest.setup.js" {
"displayName": "server",
"testEnvironment": "node",
"setupFilesAfterEnv": [
"<rootDir>/server/jest.setup.js"
],
"testMatch": [
"<rootDir>/server/**/*.test.(js|jsx)"
]
},
{
"displayName": "client",
"setupFilesAfterEnv": [
"<rootDir>/client/jest.setup.js"
], ],
"moduleNameMapper": { "moduleNameMapper": {
"^.+\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/client/__test__/mocks/fileMock.js" "^.+\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/client/__test__/mocks/fileMock.js"
},
"testMatch": [
"<rootDir>/client/**/*.test.(js|jsx)"
]
} }
]
}, },
"main": "index.js", "main": "index.js",
"author": "Cassie Tarakajian", "author": "Cassie Tarakajian",
@ -78,15 +96,14 @@
"@storybook/addons": "^5.3.6", "@storybook/addons": "^5.3.6",
"@storybook/react": "^5.3.6", "@storybook/react": "^5.3.6",
"@svgr/webpack": "^5.4.0", "@svgr/webpack": "^5.4.0",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.2.1",
"babel-core": "^7.0.0-bridge.0", "babel-core": "^7.0.0-bridge.0",
"babel-eslint": "^9.0.0", "babel-eslint": "^9.0.0",
"babel-jest": "^24.9.0",
"babel-loader": "^8.0.0", "babel-loader": "^8.0.0",
"babel-plugin-transform-react-remove-prop-types": "^0.2.12", "babel-plugin-transform-react-remove-prop-types": "^0.2.12",
"css-loader": "^3.4.2", "css-loader": "^3.4.2",
"cssnano": "^4.1.10", "cssnano": "^4.1.10",
"enzyme": "^3.11.0",
"enzyme-adapter-react-16": "^1.15.2",
"eslint": "^4.19.1", "eslint": "^4.19.1",
"eslint-config-airbnb": "^16.1.0", "eslint-config-airbnb": "^16.1.0",
"eslint-plugin-import": "^2.20.2", "eslint-plugin-import": "^2.20.2",
@ -94,10 +111,10 @@
"eslint-plugin-react": "^7.18.3", "eslint-plugin-react": "^7.18.3",
"file-loader": "^2.0.0", "file-loader": "^2.0.0",
"husky": "^4.2.5", "husky": "^4.2.5",
"jest": "^24.9.0", "jest": "^26.0.1",
"lint-staged": "^10.1.3", "lint-staged": "^10.1.3",
"mini-css-extract-plugin": "^0.8.2", "mini-css-extract-plugin": "^0.8.2",
"node-sass": "^4.13.1", "node-sass": "^4.14.1",
"nodemon": "^1.19.4", "nodemon": "^1.19.4",
"optimize-css-assets-webpack-plugin": "^5.0.3", "optimize-css-assets-webpack-plugin": "^5.0.3",
"postcss-cssnext": "^3.1.0", "postcss-cssnext": "^3.1.0",
@ -137,6 +154,7 @@
"connect-mongo": "^1.3.2", "connect-mongo": "^1.3.2",
"console-feed": "^2.8.11", "console-feed": "^2.8.11",
"cookie-parser": "^1.4.3", "cookie-parser": "^1.4.3",
"copy-webpack-plugin": "^6.0.3",
"cors": "^2.8.5", "cors": "^2.8.5",
"cross-env": "^5.2.1", "cross-env": "^5.2.1",
"csslint": "^1.0.5", "csslint": "^1.0.5",
@ -151,12 +169,15 @@
"express-session": "^1.17.0", "express-session": "^1.17.0",
"friendly-words": "^1.1.10", "friendly-words": "^1.1.10",
"htmlhint": "^0.10.1", "htmlhint": "^0.10.1",
"i18next": "^19.4.5",
"i18next-browser-languagedetector": "^4.2.0",
"i18next-http-backend": "^1.0.15",
"is-url": "^1.2.4", "is-url": "^1.2.4",
"jest-express": "^1.11.0", "jest-express": "^1.11.0",
"js-beautify": "^1.10.3", "js-beautify": "^1.10.3",
"jsdom": "^9.8.3", "jsdom": "^9.8.3",
"jshint": "^2.11.0", "jshint": "^2.11.0",
"lodash": "^4.17.15", "lodash": "^4.17.19",
"loop-protect": "github:catarak/loop-protect", "loop-protect": "github:catarak/loop-protect",
"mime-types": "^2.1.26", "mime-types": "^2.1.26",
"mjml": "^3.3.2", "mjml": "^3.3.2",
@ -178,6 +199,7 @@
"react-dom": "^16.12.0", "react-dom": "^16.12.0",
"react-helmet": "^5.1.3", "react-helmet": "^5.1.3",
"react-hot-loader": "^4.12.19", "react-hot-loader": "^4.12.19",
"react-i18next": "^11.5.0",
"react-redux": "^5.1.2", "react-redux": "^5.1.2",
"react-router": "^3.2.5", "react-router": "^3.2.5",
"react-split-pane": "^0.1.89", "react-split-pane": "^0.1.89",

View file

@ -32,7 +32,7 @@ export default function addProjectToCollection(req, res) {
return null; return null;
} }
const projectInCollection = collection.items.find(p => p.project._id === project._id); const projectInCollection = collection.items.find(p => p.projectId === project._id);
if (projectInCollection) { if (projectInCollection) {
sendFailure(404, 'Project already in collection'); sendFailure(404, 'Project already in collection');

View file

@ -23,7 +23,7 @@ export default function addProjectToCollection(req, res) {
return null; return null;
} }
const project = collection.items.find(p => p.project._id === projectId); const project = collection.items.find(p => p.projectId === projectId);
if (project != null) { if (project != null) {
project.remove(); project.remove();

View file

@ -34,6 +34,7 @@ export function serveProject(req, res) {
resolveScripts(sketchDoc, files); resolveScripts(sketchDoc, files);
resolveStyles(sketchDoc, files); resolveStyles(sketchDoc, files);
res.setHeader('Cache-Control', 'public, max-age=0');
res.send(serializeDocument(sketchDoc)); res.send(serializeDocument(sketchDoc));
}); });
}); });

4
server/jest.setup.js Normal file
View file

@ -0,0 +1,4 @@
import '@babel/polyfill';
import mongoose from 'mongoose';
mongoose.Promise = global.Promise;

View file

@ -15,6 +15,14 @@ collectedProjectSchema.virtual('id').get(function getId() {
return this._id.toHexString(); return this._id.toHexString();
}); });
collectedProjectSchema.virtual('projectId').get(function projectId() {
return this.populated('project');
});
collectedProjectSchema.virtual('isDeleted').get(function isDeleted() {
return this.project == null;
});
collectedProjectSchema.set('toJSON', { collectedProjectSchema.set('toJSON', {
virtuals: true virtuals: true
}); });

View file

@ -114,9 +114,19 @@ router.get('/about', (req, res) => {
res.send(renderIndex()); res.send(renderIndex());
}); });
router.get('/feedback', (req, res) => { if (process.env.MOBILE_ENABLED) {
router.get('/mobile', (req, res) => {
res.send(renderIndex()); res.send(renderIndex());
}); });
router.get('/mobile/preview', (req, res) => {
res.send(renderIndex());
});
router.get('/mobile/preferences', (req, res) => {
res.send(renderIndex());
});
}
router.get('/:username/collections/create', (req, res) => { router.get('/:username/collections/create', (req, res) => {
userExists(req.params.username, (exists) => { userExists(req.params.username, (exists) => {

View file

@ -73,7 +73,7 @@ function getCategories() {
function getSketchesInCategories(categories) { function getSketchesInCategories(categories) {
return Q.all(categories.map((category) => { return Q.all(categories.map((category) => {
const options = { const options = {
url: `${category.url.replace('?ref=master', '')}?client_id=${clientId}&client_secret=${clientSecret}`, url: `${category.url.replace('?ref=main', '')}?client_id=${clientId}&client_secret=${clientSecret}`,
method: 'GET', method: 'GET',
headers, headers,
json: true json: true
@ -107,7 +107,7 @@ function getSketchesInCategories(categories) {
function getSketchContent(projectsInAllCategories) { function getSketchContent(projectsInAllCategories) {
return Q.all(projectsInAllCategories.map(projectsInOneCategory => Q.all(projectsInOneCategory.map((project) => { return Q.all(projectsInAllCategories.map(projectsInOneCategory => Q.all(projectsInOneCategory.map((project) => {
const options = { const options = {
url: `${project.sketchUrl.replace('?ref=master', '')}?client_id=${clientId}&client_secret=${clientSecret}`, url: `${project.sketchUrl.replace('?ref=main', '')}?client_id=${clientId}&client_secret=${clientSecret}`,
method: 'GET', method: 'GET',
headers headers
}; };
@ -264,7 +264,7 @@ function createProjectsInP5user(projectsInAllCategories) {
const fileID = objectID().toHexString(); const fileID = objectID().toHexString();
newProject.files.push({ newProject.files.push({
name: assetName, name: assetName,
url: `https://cdn.jsdelivr.net/gh/processing/p5.js-website@master/src/data/examples/assets/${assetName}`, url: `https://cdn.jsdelivr.net/gh/processing/p5.js-website@main/src/data/examples/assets/${assetName}`,
id: fileID, id: fileID,
_id: fileID, _id: fileID,
children: [], children: [],

View file

@ -75,10 +75,11 @@ app.use(corsMiddleware);
app.options('*', corsMiddleware); app.options('*', corsMiddleware);
// Body parser, cookie parser, sessions, serve public assets // Body parser, cookie parser, sessions, serve public assets
app.use('/locales', Express.static(path.resolve(__dirname, '../dist/static/locales'), { cacheControl: false }));
app.use(Express.static(path.resolve(__dirname, '../dist/static'), { app.use(Express.static(path.resolve(__dirname, '../dist/static'), {
maxAge: process.env.STATIC_MAX_AGE || (process.env.NODE_ENV === 'production' ? '1d' : '0') maxAge: process.env.STATIC_MAX_AGE || (process.env.NODE_ENV === 'production' ? '1d' : '0')
})); }));
app.use(bodyParser.urlencoded({ limit: '50mb', extended: true })); app.use(bodyParser.urlencoded({ limit: '50mb', extended: true }));
app.use(bodyParser.json({ limit: '50mb' })); app.use(bodyParser.json({ limit: '50mb' }));
app.use(cookieParser()); app.use(cookieParser());

View file

@ -32,6 +32,9 @@ export function renderIndex() {
window.process.env.UI_ACCESS_TOKEN_ENABLED = ${process.env.UI_ACCESS_TOKEN_ENABLED === 'false' ? false : true}; window.process.env.UI_ACCESS_TOKEN_ENABLED = ${process.env.UI_ACCESS_TOKEN_ENABLED === 'false' ? false : true};
window.process.env.UI_COLLECTIONS_ENABLED = ${process.env.UI_COLLECTIONS_ENABLED === 'false' ? false : true}; window.process.env.UI_COLLECTIONS_ENABLED = ${process.env.UI_COLLECTIONS_ENABLED === 'false' ? false : true};
window.process.env.UPLOAD_LIMIT = ${process.env.UPLOAD_LIMIT ? `${process.env.UPLOAD_LIMIT}` : undefined}; window.process.env.UPLOAD_LIMIT = ${process.env.UPLOAD_LIMIT ? `${process.env.UPLOAD_LIMIT}` : undefined};
window.process.env.MOBILE_ENABLED = ${process.env.MOBILE_ENABLED ? `${process.env.MOBILE_ENABLED}` : undefined};
window.process.env.TRANSLATIONS_ENABLED = ${process.env.TRANSLATIONS_ENABLED === 'true' ? true :false};
</script> </script>
</head> </head>
<body> <body>

View file

@ -0,0 +1,115 @@
{
"Contribute": "Contribute",
"NewP5": "New to p5.js?",
"Report": "Report a bug",
"Learn": "Learn",
"About": "About",
"Resources": "Resources",
"Libraries": "Libraries",
"Forum": "Forum",
"File": "File",
"New": "New",
"Save": "Save",
"Share": "Share",
"Duplicate": "Duplicate",
"Examples": "Examples",
"Edit": "Edit",
"TidyCode": "Tidy Code",
"Find": "Find",
"AddToCollection": "Add to Collection",
"FindNext": "Find Next",
"FindPrevious": "Find Previous",
"Sketch": "Sketch",
"AddFile": "Add File",
"AddFolder": "Add Folder",
"Run": "Run",
"Stop": "Stop",
"Help": "Help",
"KeyboardShortcuts": "Keyboard Shortcuts",
"Reference": "Reference",
"Tidy": "Tidy",
"Lang": "Language",
"FindNextMatch": "Find Next Match",
"FindPrevMatch": "Find Previous Match",
"IndentCodeLeft": "Indent Code Left",
"IndentCodeRight": "Indent Code Right",
"CommentLine": "Comment Line",
"StartSketch": "Start Sketch",
"StopSketch": "StopSketch",
"TurnOnAccessibleOutput": "Turn On Accessible Output",
"TurnOffAccessibleOutput": "Turn Off Accessible Output",
"ToogleSidebar": "Toogle Sidebar",
"ToogleConsole": "Toogle Console",
"Preview": "Preview",
"Auto-refresh": "Auto-refresh",
"Console": "Console",
"Settings": "Settings",
"GeneralSettings": "General settings",
"Theme": "Theme",
"Light": "Light",
"Dark": "Dark",
"HighContrast": "High Contrast",
"TextSize": "Text Size",
"Decrease": "Decrease",
"Increase": "Increase",
"IndentationAmount": "Indentation amount",
"Autosave": "Autosave",
"On": "On",
"Off": "Off",
"SketchSettings": "Sketch Settings",
"SecurityProtocol": "Security Protocol",
"ServeOverHTTPS": "Serve over HTTPS",
"Accessibility": "Accessibility",
"LintWarningSound": "Lint warning sound",
"PreviewSound": "Preview sound",
"AccessibleTextBasedCanvas": "Accessible text-based canvas",
"UsedScreenReader": "Used with screen reader",
"PlainText": "Plain-text",
"TableText": "Table-text",
"Sound": "Sound",
"WordWrap": "Word Wrap",
"LineNumbers": "Line numbers",
"LangChange": "Language changed",
"Welcome": "Welcome",
"Login": "Log in",
"LoginOr": "or",
"SignUp": "Sign up",
"Email": "email",
"Username": "username",
"LoginGithub": "Login with Github",
"LoginGoogle": "Login with Google",
"DontHaveAccount": "Don't have an account?",
"ForgotPassword": "Forgot your password?",
"ResetPassword": "Reset your password",
"BackEditor": "Back to Editor",
"UsernameSplit": "User Name",
"Password": "Password",
"ConfirmPassword": "Confirm Password",
"OpenedNewSketch": "Opened new sketch.",
"Hello": "Hello",
"MyAccount": "My Account",
"My":"My",
"Sketches": "My sketches",
"Collections": "My collections",
"Asset": "Asset",
"MyAssets": "My assets",
"TitleAbout": "p5.js Web Editor | About",
"CodeEditing": "Code Editing",
"Error": "Error",
"In order to save": "In order to save",
"you must be logged in": "you must be logged in",
"Please": "please",
"Find in files": "Find in files",
"Create": "Create",
"enter a name": "enter a name",
"Add": "Add",
"Folder": "Folder",
"FindText": "Find Text",
"FindNextTextMatch": "Find Next Text Match",
"FindPreviousTextMatch": "Find Previous Text Match",
"Code editing keyboard shortcuts follow": "Code editing keyboard shortcuts follow",
"Sublime Text shortcuts": "Sublime Text shortcuts",
"WarningUnsavedChanges": "Are you sure you want to leave this page? You have unsaved changes."
}

View file

@ -0,0 +1,113 @@
{
"Contribute": "Contribuir",
"NewP5": "¿Empezando con p5.js?",
"Report": "Reporta un error",
"Learn": "Aprende",
"About": "Acerca de",
"Resources": "Recursos",
"Libraries": "Bibliotecas",
"Forum": "Foro",
"File": "Archivo",
"New": "Nuevo",
"Save": "Guardar",
"Share": "Compartir",
"Duplicate": "Duplicar",
"Examples": "Ejemplos",
"Edit": "Editar",
"TidyCode": "Ordenar código",
"Find": "Buscar",
"AddToCollection": "Agregar a colección",
"FindNext": "Buscar siguiente",
"FindPrevious": "Buscar anterior",
"Sketch": "Bosquejo",
"AddFile": "Agregar archivo",
"AddFolder": "Agregar directorio",
"Run": "Ejecutar",
"Stop": "Detener",
"Help": "Ayuda",
"KeyboardShortcuts": "Atajos",
"Reference": "Referencia",
"Tidy": "Ordenar",
"Lang": "Lenguaje",
"FindNextMatch": "Encontrar siguiente ocurrencia",
"FindPrevMatch": "Encontrar ocurrencia previa",
"IndentCodeLeft": "Indentar código a la izquierda",
"IndentCodeRight": "Indentar código a la derecha",
"CommentLine": "Comentar línea de código",
"StartSketch": "Iniciar bosquejo",
"StopSketch": "Detener bosquejo",
"TurnOnAccessibleOutput": "Activar salida accesible",
"TurnOffAccessibleOutput": "Desactivar salida accesible",
"ToogleSidebar": "Alternar barra de deslizamiento",
"ToogleConsole": "Alternar consola",
"Preview": "Vista previa",
"Auto-refresh": "Auto-refrescar",
"Console": "Consola",
"Settings": "Configuración",
"GeneralSettings": "Configuración general",
"Theme": "Modo de visualización",
"Light": "Claro",
"Dark": "Oscuro",
"HighContrast": "Alto contraste",
"TextSize": "Tamaño del texto",
"Decrease": "Disminuir",
"Increase": "Aumentar",
"IndentationAmount": "Cantidad de indentación",
"Autosave": "Grabar automáticamente",
"On": "Activar",
"Off": "Desactivar",
"SketchSettings": "Configuración del bosquejo",
"SecurityProtocol": "Protocolo de seguridad",
"ServeOverHTTPS": "Usar HTTPS",
"Accessibility": "Accesibilidad",
"LintWarningSound": "Sonido de alarma Lint",
"PreviewSound": "Probar sonido",
"AccessibleTextBasedCanvas": "Lienzo accesible por texto",
"UsedScreenReader": "Uso con screen reader",
"PlainText": "Texto sin formato",
"TableText": "Tablero de texto",
"Sound": "Sonido",
"WordWrap": "Ajuste automático de línea",
"LineNumbers": "Número de línea",
"LangChange": "Lenguaje cambiado",
"Welcome": "Bienvenida",
"Login": "Ingresa",
"LoginOr": "o",
"SignUp": "registráte",
"email": "correo electrónico",
"username": "nombre de usuario",
"LoginGithub": "Ingresa con Github",
"LoginGoogle": "Ingresa con Google",
"DontHaveAccount": "No tienes cuenta?",
"ForgotPassword": "¿Olvidaste tu contraseña?",
"ResetPassword": "Regenera tu contraseña",
"BackEditor": "Regresa al editor",
"UsernameSplit": "Nombre de usuario",
"Password": "Contraseña",
"ConfirmPassword": "Confirma la contraseña",
"OpenedNewSketch": "Creaste nuevo bosquejo.",
"Hello": "Hola",
"MyAccount": "Mi Cuenta",
"My": "Mi",
"MySketches": "Mis bosquejos",
"MyCollections":"Mis colecciones",
"Asset": "Asset",
"MyAssets": "Mis assets",
"TitleAbout": "Editor Web p5.js | Acerca de",
"CodeEditing": "Editando Código",
"Error": "Error",
"In order to save": "Para guardar",
"you must be logged in": "debes ingresar a tu cuenta",
"Please": "Por favor",
"Find in files": "Encontrar en archivos",
"Create": "Create",
"enter a name": "enter a name",
"Add": "Add",
"Folder": "Directorio",
"FindText": "Encontrar texto",
"FindNextTextMatch": "Encontrar la siguiente ocurrencia de texto",
"FindPreviousTextMatch": "Encontrar la ocurrencia previa de texto",
"Code editing keyboard shortcuts follow": "Los atajos para edición son como",
"Sublime Text shortcuts": "los atajos de Sublime Text ",
"WarningUnsavedChanges": "¿Estás seguro de que quieres salir de la página? Tienes cambios sin guardar."
}

View file

@ -1,5 +1,6 @@
const webpack = require('webpack'); const webpack = require('webpack');
const path = require('path'); const path = require('path');
const CopyWebpackPlugin = require('copy-webpack-plugin')
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
require('dotenv').config(); require('dotenv').config();
@ -40,7 +41,13 @@ module.exports = {
'process.env': { 'process.env': {
NODE_ENV: JSON.stringify('development') NODE_ENV: JSON.stringify('development')
} }
}) }),
new CopyWebpackPlugin({
patterns: [
{from: path.resolve(__dirname, '../translations/locales') , to: path.resolve(__dirname, 'locales')}
]
}
)
], ],
module: { module: {
rules: [ rules: [

View file

@ -8,6 +8,7 @@ const cssnext = require('postcss-cssnext');
const postcssFocus = require('postcss-focus'); const postcssFocus = require('postcss-focus');
const postcssReporter = require('postcss-reporter'); const postcssReporter = require('postcss-reporter');
const cssnano = require('cssnano'); const cssnano = require('cssnano');
const CopyWebpackPlugin = require('copy-webpack-plugin')
if (process.env.NODE_ENV === "development") { if (process.env.NODE_ENV === "development") {
require('dotenv').config(); require('dotenv').config();
} }
@ -144,7 +145,13 @@ module.exports = [{
}), }),
new MiniCssExtractPlugin({ new MiniCssExtractPlugin({
filename: 'app.[hash].css', filename: 'app.[hash].css',
}) }),
new CopyWebpackPlugin({
patterns: [
{from: path.resolve(__dirname, '../translations/locales') , to: path.resolve(__dirname, '../dist/static/locales')}
]
}
)
] ]
}, },
{ {