diff --git a/.babelrc b/.babelrc index 5f3130eafe..7ef51374cd 100644 --- a/.babelrc +++ b/.babelrc @@ -51,7 +51,16 @@ "development": { "plugins": [ "babel-plugin-styled-components", - "react-hot-loader/babel" + "react-refresh/babel" + ], + "presets": [ + [ + "@babel/preset-react", + { + "development": true, + "runtime": "automatic" + } + ] ] } }, diff --git a/.eslintrc b/.eslintrc index 02de296973..06ed7ddb93 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,5 +1,5 @@ { - "extends": ["airbnb", "prettier"], + "extends": ["airbnb", "prettier", "plugin:storybook/recommended"], "parser": "@babel/eslint-parser", "env": { "browser": true, @@ -20,6 +20,7 @@ "no-console": 0, "no-alert": 0, "no-underscore-dangle": 0, + "no-useless-catch": 2, "max-len": [1, 120, 2, {"ignoreComments": true, "ignoreTemplateLiterals": true}], "quote-props": [1, "as-needed"], "no-unused-vars": [1, {"vars": "local", "args": "none"}], diff --git a/.storybook/main.js b/.storybook/main.js index b7d42a9a16..1d892d7bbe 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -1,29 +1,35 @@ -const path = require('path'); - -module.exports = { +/** @type { import('@storybook/react-webpack5').StorybookConfig } */ +const config = { stories: ['../client/**/*.stories.(jsx|mdx)'], addons: [ - '@storybook/addon-actions', - '@storybook/addon-docs', - '@storybook/addon-knobs', '@storybook/addon-links', - 'storybook-addon-theme-playground/dist/register' + '@storybook/addon-essentials', + '@storybook/addon-interactions' ], - webpackFinal: async config => { - // do mutation to the config - - const rules = config.module.rules; - - // modify storybook's file-loader rule to avoid conflicts with svgr - const fileLoaderRule = rules.find(rule => rule.test.test('.svg')); - fileLoaderRule.exclude = path.resolve(__dirname, '../client'); + framework: { + name: '@storybook/react-webpack5', + options: {} + }, + docs: { + autodocs: 'tag' + }, + async webpackFinal(config) { + // https://storybook.js.org/docs/react/builders/webpack + // this modifies the existing image rule to exclude .svg files + // since we want to handle those files with @svgr/webpack + const imageRule = config.module.rules.find(rule => rule.test.test('.svg')) + imageRule.exclude = /\.svg$/ - // use svgr for svg files - rules.push({ + // configure .svg files to be loaded with @svgr/webpack + config.module.rules.push({ test: /\.svg$/, - use: ["@svgr/webpack"], + use: ['@svgr/webpack'] }) - return config; + return config }, }; + +export default config; + + diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html new file mode 100644 index 0000000000..cd078bfceb --- /dev/null +++ b/.storybook/preview-head.html @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/.storybook/preview.js b/.storybook/preview.js index da39403108..0c8026ae60 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -1,31 +1,22 @@ import React from 'react'; -import { addDecorator, addParameters } from '@storybook/react'; -import { withKnobs } from "@storybook/addon-knobs"; -import { withThemePlayground } from 'storybook-addon-theme-playground'; -import { ThemeProvider } from "styled-components"; +import { Provider } from 'react-redux'; -import theme, { Theme } from '../client/theme'; +import ThemeProvider from '../client/modules/App/components/ThemeProvider'; +import configureStore from '../client/store'; +import '../client/i18n-test'; +import '../client/styles/build/css/main.css' -addDecorator(withKnobs); +const initialState = window.__INITIAL_STATE__; -const themeConfigs = Object.values(Theme).map( - name => { - return { name, theme: theme[name] }; - } -); +const store = configureStore(initialState); -addDecorator(withThemePlayground({ - theme: themeConfigs, - provider: ThemeProvider -})); +export const decorators = [ + (Story) => ( + + + + + + ), +] -addParameters({ - options: { - /** - * display the top-level grouping as a "root" in the sidebar - */ - showRoots: true, - }, -}) - -// addDecorator(storyFn => {storyFn()}); diff --git a/client/browserHistory.js b/client/browserHistory.js new file mode 100644 index 0000000000..0bf3a5fdd6 --- /dev/null +++ b/client/browserHistory.js @@ -0,0 +1,5 @@ +import { createBrowserHistory } from 'history'; + +const browserHistory = createBrowserHistory(); + +export default browserHistory; diff --git a/client/common/Button.jsx b/client/common/Button.jsx index b7f3c3bddd..d6dd18491e 100644 --- a/client/common/Button.jsx +++ b/client/common/Button.jsx @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import styled from 'styled-components'; -import { Link } from 'react-router'; +import { Link } from 'react-router-dom'; import { remSize, prop } from '../theme'; diff --git a/client/common/Button.stories.jsx b/client/common/Button.stories.jsx index 6ad1ddddb5..d11634ae28 100644 --- a/client/common/Button.stories.jsx +++ b/client/common/Button.stories.jsx @@ -1,22 +1,22 @@ import React from 'react'; import { action } from '@storybook/addon-actions'; -import { boolean, text } from '@storybook/addon-knobs'; import Button from './Button'; import { GithubIcon, DropdownArrowIcon, PlusIcon } from './icons'; export default { title: 'Common/Button', - component: Button + component: Button, + args: { + children: 'this is the button', + label: 'submit', + disabled: false + } }; -export const AllFeatures = () => ( - ); diff --git a/client/common/ButtonOrLink.jsx b/client/common/ButtonOrLink.jsx index f2c31e1c6e..924f108024 100644 --- a/client/common/ButtonOrLink.jsx +++ b/client/common/ButtonOrLink.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Link } from 'react-router'; +import { Link } from 'react-router-dom'; import PropTypes from 'prop-types'; /** diff --git a/client/common/ButtonOrLink.test.jsx b/client/common/ButtonOrLink.test.jsx index 8086ed9ace..0e6fb093b4 100644 --- a/client/common/ButtonOrLink.test.jsx +++ b/client/common/ButtonOrLink.test.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, screen, fireEvent } from '../test-utils'; +import { render, screen, fireEvent, waitFor, history } from '../test-utils'; import ButtonOrLink from './ButtonOrLink'; describe('ButtonOrLink', () => { @@ -25,8 +25,12 @@ describe('ButtonOrLink', () => { expect(link).toHaveAttribute('href', 'https://p5js.org'); }); - it('can render an internal link with react-router', () => { + it('can render an internal link with react-router', async () => { render(About); - // TODO: how can this be tested? Needs a router provider? + + const link = screen.getByText('About'); + fireEvent.click(link); + + await waitFor(() => expect(history.location.pathname).toEqual('/about')); }); }); diff --git a/client/common/icons.jsx b/client/common/icons.jsx index ff526c63f4..571dec3a74 100644 --- a/client/common/icons.jsx +++ b/client/common/icons.jsx @@ -13,6 +13,8 @@ import DropdownArrow from '../images/down-filled-triangle.svg'; import Preferences from '../images/preferences.svg'; import Play from '../images/triangle-arrow-right.svg'; import More from '../images/more.svg'; +import Editor from '../images/editor.svg'; +import Account from '../images/account.svg'; import Code from '../images/code.svg'; import Save from '../images/save.svg'; import Terminal from '../images/terminal.svg'; @@ -83,6 +85,8 @@ export const GoogleIcon = withLabel(Google); export const PlusIcon = withLabel(Plus); export const CloseIcon = withLabel(Close); export const ExitIcon = withLabel(Exit); +export const EditorIcon = withLabel(Editor); +export const AccountIcon = withLabel(Account); export const DropdownArrowIcon = withLabel(DropdownArrow); export const PreferencesIcon = withLabel(Preferences); export const PlayIcon = withLabel(Play); diff --git a/client/common/icons.stories.jsx b/client/common/icons.stories.jsx index 3df22e3196..5e4dfa76a9 100644 --- a/client/common/icons.stories.jsx +++ b/client/common/icons.stories.jsx @@ -1,16 +1,20 @@ import React from 'react'; -import { select } from '@storybook/addon-knobs'; import * as icons from './icons'; export default { title: 'Common/Icons', - component: icons + component: icons, + argTypes: { + variant: { + options: Object.keys(icons), + control: { type: 'select' }, + default: icons.CircleFolderIcon + } + } }; -export const AllIcons = () => { - const names = Object.keys(icons); - - const SelectedIcon = icons[select('name', names, names[0])]; +export const Icons = (args) => { + const SelectedIcon = icons[args.variant || 'CircleInfoIcon']; return ; }; diff --git a/client/components/Nav.jsx b/client/components/Nav.jsx deleted file mode 100644 index 6e6a350d5e..0000000000 --- a/client/components/Nav.jsx +++ /dev/null @@ -1,425 +0,0 @@ -import { sortBy } from 'lodash'; -import PropTypes from 'prop-types'; -import React from 'react'; -import { withTranslation } from 'react-i18next'; -import { connect } from 'react-redux'; -import { Link, withRouter } from 'react-router'; -import { availableLanguages, languageKeyToLabel } from '../i18n'; -import * as IDEActions from '../modules/IDE/actions/ide'; -import * as toastActions from '../modules/IDE/actions/toast'; -import * as projectActions from '../modules/IDE/actions/project'; -import { - setAllAccessibleOutput, - setLanguage -} from '../modules/IDE/actions/preferences'; -import { logoutUser } from '../modules/User/actions'; - -import getConfig from '../utils/getConfig'; -import { metaKeyName, metaKey } from '../utils/metaKey'; -import { getIsUserOwner } from '../modules/IDE/selectors/users'; -import { selectSketchPath } from '../modules/IDE/selectors/project'; - -import CaretLeftIcon from '../images/left-arrow.svg'; -import LogoIcon from '../images/p5js-logo-small.svg'; -import NavDropdownMenu from './Nav/NavDropdownMenu'; -import NavMenuItem from './Nav/NavMenuItem'; -import NavBar from './Nav/NavBar'; - -class Nav extends React.PureComponent { - constructor(props) { - super(props); - this.handleSave = this.handleSave.bind(this); - this.handleNew = this.handleNew.bind(this); - this.handleDuplicate = this.handleDuplicate.bind(this); - this.handleShare = this.handleShare.bind(this); - this.handleDownload = this.handleDownload.bind(this); - this.handleLangSelection = this.handleLangSelection.bind(this); - } - - handleNew() { - const { unsavedChanges, warnIfUnsavedChanges } = this.props; - if (!unsavedChanges) { - this.props.showToast(1500); - this.props.setToastText('Toast.OpenedNewSketch'); - this.props.newProject(); - } else if (warnIfUnsavedChanges && warnIfUnsavedChanges()) { - this.props.showToast(1500); - this.props.setToastText('Toast.OpenedNewSketch'); - this.props.newProject(); - } - } - - handleSave() { - if (this.props.user.authenticated) { - this.props.saveProject(this.props.cmController.getContent()); - } else { - this.props.showErrorModal('forceAuthentication'); - } - } - - handleDuplicate() { - this.props.cloneProject(); - } - - handleLangSelection(event) { - this.props.setLanguage(event.target.value); - this.props.showToast(1500); - this.props.setToastText('Toast.LangChange'); - } - - handleDownload() { - this.props.autosaveProject(); - projectActions.exportProjectAsZip(this.props.project.id); - } - - handleShare() { - const { username } = this.props.params; - this.props.showShareModal( - this.props.project.id, - this.props.project.name, - username - ); - } - - renderDashboardMenu() { - return ( - - ); - } - - renderProjectMenu() { - const replaceCommand = - metaKey === 'Ctrl' ? `${metaKeyName}+H` : `${metaKeyName}+⌥+F`; - return ( - - ); - } - - renderLanguageMenu() { - return ( - - {sortBy(availableLanguages).map((key) => ( - - {languageKeyToLabel(key)} - - ))} - - ); - } - - renderUnauthenticatedUserMenu() { - return ( - - ); - } - - renderAuthenticatedUserMenu() { - return ( - - ); - } - - renderUserMenu() { - const isLoginEnabled = getConfig('LOGIN_ENABLED'); - const isAuthenticated = this.props.user.authenticated; - - if (isLoginEnabled && isAuthenticated) { - return this.renderAuthenticatedUserMenu(); - } else if (isLoginEnabled && !isAuthenticated) { - return this.renderUnauthenticatedUserMenu(); - } - - return null; - } - - renderLeftLayout() { - switch (this.props.layout) { - case 'dashboard': - return this.renderDashboardMenu(); - case 'project': - default: - return this.renderProjectMenu(); - } - } - - render() { - return ( - - {this.renderLeftLayout()} - {this.renderUserMenu()} - - ); - } -} - -Nav.propTypes = { - newProject: PropTypes.func.isRequired, - showToast: PropTypes.func.isRequired, - setToastText: PropTypes.func.isRequired, - saveProject: PropTypes.func.isRequired, - autosaveProject: PropTypes.func.isRequired, - cloneProject: PropTypes.func.isRequired, - user: PropTypes.shape({ - authenticated: PropTypes.bool.isRequired, - username: PropTypes.string, - id: PropTypes.string - }).isRequired, - project: PropTypes.shape({ - id: PropTypes.string, - name: PropTypes.string, - owner: PropTypes.shape({ - id: PropTypes.string - }) - }), - logoutUser: PropTypes.func.isRequired, - showShareModal: PropTypes.func.isRequired, - showErrorModal: PropTypes.func.isRequired, - unsavedChanges: PropTypes.bool.isRequired, - warnIfUnsavedChanges: PropTypes.func, - showKeyboardShortcutModal: PropTypes.func.isRequired, - cmController: PropTypes.shape({ - tidyCode: PropTypes.func, - showFind: PropTypes.func, - showReplace: PropTypes.func, - getContent: PropTypes.func - }), - startSketch: PropTypes.func.isRequired, - stopSketch: PropTypes.func.isRequired, - newFile: PropTypes.func.isRequired, - newFolder: PropTypes.func.isRequired, - layout: PropTypes.oneOf(['dashboard', 'project']), - rootFile: PropTypes.shape({ - id: PropTypes.string.isRequired - }).isRequired, - params: PropTypes.shape({ - username: PropTypes.string - }), - t: PropTypes.func.isRequired, - setLanguage: PropTypes.func.isRequired, - language: PropTypes.string.isRequired, - isUserOwner: PropTypes.bool.isRequired, - editorLink: PropTypes.string -}; - -Nav.defaultProps = { - project: { - id: undefined, - owner: undefined - }, - cmController: {}, - layout: 'project', - warnIfUnsavedChanges: undefined, - params: { - username: undefined - }, - editorLink: '/' -}; - -function mapStateToProps(state) { - return { - project: state.project, - user: state.user, - unsavedChanges: state.ide.unsavedChanges, - rootFile: state.files.filter((file) => file.name === 'root')[0], - language: state.preferences.language, - isUserOwner: getIsUserOwner(state), - editorLink: selectSketchPath(state) - }; -} - -const mapDispatchToProps = { - ...IDEActions, - ...projectActions, - ...toastActions, - logoutUser, - setAllAccessibleOutput, - setLanguage -}; - -export default withTranslation()( - withRouter(connect(mapStateToProps, mapDispatchToProps)(Nav)) -); -export { Nav as NavComponent }; diff --git a/client/components/Nav.unit.test.jsx b/client/components/Nav.unit.test.jsx deleted file mode 100644 index ba78b96c73..0000000000 --- a/client/components/Nav.unit.test.jsx +++ /dev/null @@ -1,69 +0,0 @@ -import React from 'react'; -import { render, reduxRender } from '../test-utils'; - -import Nav, { NavComponent } from './Nav'; - -// jest.mock('../i18n'); - -describe('Nav', () => { - const props = { - newProject: jest.fn(), - saveProject: jest.fn(), - autosaveProject: jest.fn(), - exportProjectAsZip: jest.fn(), - cloneProject: jest.fn(), - user: { - authenticated: true, - username: 'new-user', - id: 'new-user' - }, - project: { - id: 'new-project', - owner: { - id: 'new-user' - } - }, - logoutUser: jest.fn(), - newFile: jest.fn(), - newFolder: jest.fn(), - showShareModal: jest.fn(), - showErrorModal: jest.fn(), - unsavedChanges: false, - warnIfUnsavedChanges: jest.fn(), - showKeyboardShortcutModal: jest.fn(), - cmController: { - tidyCode: jest.fn(), - showFind: jest.fn(), - findNext: jest.fn(), - findPrev: jest.fn(), - showReplace: jest.fn() - }, - startSketch: jest.fn(), - stopSketch: jest.fn(), - setAllAccessibleOutput: jest.fn(), - showToast: jest.fn(), - setToastText: jest.fn(), - rootFile: { - id: 'root-file' - }, - t: jest.fn(), - setLanguage: jest.fn(), - language: 'en-US', - isUserOwner: true - }; - - it('renders correctly', () => { - const { asFragment } = render(); - expect(asFragment()).toMatchSnapshot(); - }); - - it('renders editor version', () => { - const { asFragment } = reduxRender(