diff --git a/client/modules/IDE/actions/toast.js b/client/modules/IDE/actions/toast.js index 25f472e14b..b1e43d9e59 100644 --- a/client/modules/IDE/actions/toast.js +++ b/client/modules/IDE/actions/toast.js @@ -6,8 +6,24 @@ export function hideToast() { }; } -export function showToast(time) { +/** + * Temporary fix until #2206 is merged. + * Supports legacy two-action syntax: + * dispatch(setToastText('Toast.SketchFailedSave')); + * dispatch(showToast(1500)); + * And also supports proposed single-action syntax with message and optional timeout. + * dispatch(showToast('Toast.SketchFailedSave')); + * dispatch(showToast('Toast.SketchSaved', 5500)); + */ +export function showToast(textOrTime, timeout = 1500) { return (dispatch) => { + let time = timeout; + if (typeof textOrTime === 'string') { + // eslint-disable-next-line no-use-before-define + dispatch(setToastText(textOrTime)); + } else { + time = textOrTime; + } dispatch({ type: ActionTypes.SHOW_TOAST }); diff --git a/client/modules/IDE/components/Header/Nav.jsx b/client/modules/IDE/components/Header/Nav.jsx index 997308fea5..3750033aaa 100644 --- a/client/modules/IDE/components/Header/Nav.jsx +++ b/client/modules/IDE/components/Header/Nav.jsx @@ -1,414 +1,318 @@ +import React, { useContext } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; 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 } from 'react-router-dom'; -import { availableLanguages, languageKeyToLabel } from '../../../../i18n'; -import * as IDEActions from '../../actions/ide'; -import * as toastActions from '../../actions/toast'; -import * as projectActions from '../../actions/project'; -import { setAllAccessibleOutput, setLanguage } from '../../actions/preferences'; -import { logoutUser } from '../../../User/actions'; - -import getConfig from '../../../../utils/getConfig'; -import { metaKeyName, metaKey } from '../../../../utils/metaKey'; -import { getIsUserOwner } from '../../selectors/users'; -import { selectSketchPath } from '../../selectors/project'; +import PropTypes from 'prop-types'; -import CaretLeftIcon from '../../../../images/left-arrow.svg'; -import LogoIcon from '../../../../images/p5js-logo-small.svg'; +import { useTranslation } from 'react-i18next'; import NavDropdownMenu from '../../../../components/Nav/NavDropdownMenu'; import NavMenuItem from '../../../../components/Nav/NavMenuItem'; +import { availableLanguages, languageKeyToLabel } from '../../../../i18n'; +import getConfig from '../../../../utils/getConfig'; +import { showToast } from '../../actions/toast'; +import { setLanguage } from '../../actions/preferences'; import NavBar from '../../../../components/Nav/NavBar'; +import CaretLeftIcon from '../../../../images/left-arrow.svg'; +import LogoIcon from '../../../../images/p5js-logo-small.svg'; +import { selectSketchPath } from '../../selectors/project'; +import { metaKey, metaKeyName } from '../../../../utils/metaKey'; +import { useSketchActions } from '../../hooks'; +import { getAuthenticated, getIsUserOwner } from '../../selectors/users'; +import { cloneProject } from '../../actions/project'; +import { + newFile, + newFolder, + showKeyboardShortcutModal, + startSketch, + stopSketch +} from '../../actions/ide'; +import { logoutUser } from '../../../User/actions'; +import { CmControllerContext } from '../../pages/IDEView'; -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); - } +const Nav = ({ layout }) => ( + + + + +); - handleNew() { - const { unsavedChanges } = this.props; - if (!unsavedChanges) { - this.props.showToast(1500); - this.props.setToastText('Toast.OpenedNewSketch'); - this.props.newProject(); - } else if (window.confirm(this.props.t('Nav.WarningUnsavedChanges'))) { - this.props.showToast(1500); - this.props.setToastText('Toast.OpenedNewSketch'); - this.props.newProject(); - } - } +Nav.propTypes = { + layout: PropTypes.oneOf(['dashboard', 'project']) +}; - handleSave() { - if (this.props.user.authenticated) { - this.props.saveProject(this.props.cmController.getContent()); - } else { - this.props.showErrorModal('forceAuthentication'); - } - } +Nav.defaultProps = { + layout: 'project' +}; - handleDuplicate() { - this.props.cloneProject(); +const LeftLayout = (props) => { + switch (props.layout) { + case 'dashboard': + return ; + case 'project': + default: + return ; } +}; - handleLangSelection(event) { - this.props.setLanguage(event.target.value); - this.props.showToast(1500); - this.props.setToastText('Toast.LangChange'); - } +LeftLayout.propTypes = { + layout: PropTypes.oneOf(['dashboard', 'project']) +}; - handleDownload() { - this.props.autosaveProject(); - projectActions.exportProjectAsZip(this.props.project.id); - } +LeftLayout.defaultProps = { + layout: 'project' +}; - handleShare() { - this.props.showShareModal( - this.props.project.id, - this.props.project.name, - this.props.project.owner.username - ); - } +const UserMenu = () => { + const isLoginEnabled = getConfig('LOGIN_ENABLED'); + const isAuthenticated = useSelector(getAuthenticated); - renderDashboardMenu() { - return ( - - ); + if (isLoginEnabled && isAuthenticated) { + return ; + } else if (isLoginEnabled && !isAuthenticated) { + return ; } - renderProjectMenu() { - const replaceCommand = - metaKey === 'Ctrl' ? `${metaKeyName}+H` : `${metaKeyName}+⌥+F`; - return ( -
    -
  • - { + const { t } = useTranslation(); + const editorLink = useSelector(selectSketchPath); + return ( +
      +
    • + +
    • +
    • + +
    • - - - {this.props.t('Nav.File.New')} - - - {this.props.t('Common.Save')} - {metaKeyName}+S - - - {this.props.t('Nav.File.Duplicate')} - - - {this.props.t('Nav.File.Share')} - - - {this.props.t('Nav.File.Download')} - - - {this.props.t('Nav.File.Open')} - - - {this.props.t('Nav.File.AddToCollection')} - - - {this.props.t('Nav.File.Examples')} - - - - - {this.props.t('Nav.Edit.TidyCode')} - - {metaKeyName}+{'\u21E7'}+F - - - - {this.props.t('Nav.Edit.Find')} - {metaKeyName}+F - - - {this.props.t('Nav.Edit.Replace')} - {replaceCommand} - - - - this.props.newFile(this.props.rootFile.id)} - > - {this.props.t('Nav.Sketch.AddFile')} - - this.props.newFolder(this.props.rootFile.id)} - > - {this.props.t('Nav.Sketch.AddFolder')} - - - {this.props.t('Nav.Sketch.Run')} - {metaKeyName}+Enter - - - {this.props.t('Nav.Sketch.Stop')} - - {'\u21E7'}+{metaKeyName}+Enter - - - - - - {this.props.t('Nav.Help.KeyboardShortcuts')} - - - {this.props.t('Nav.Help.Reference')} - - - {this.props.t('Nav.Help.About')} - - -
    - ); - } + {t('Nav.BackEditor')} + +
  • +
+ ); +}; - renderLanguageMenu() { - return ( - - {sortBy(availableLanguages).map((key) => ( - - {languageKeyToLabel(key)} - - ))} - - ); - } +const ProjectMenu = (props) => { + const isUserOwner = useSelector(getIsUserOwner); + const project = useSelector((state) => state.project); + const user = useSelector((state) => state.user); - renderUnauthenticatedUserMenu() { - return ( -
    - {getConfig('TRANSLATIONS_ENABLED') && this.renderLanguageMenu()} -
  • - - - {this.props.t('Nav.Login')} - - -
  • - {this.props.t('Nav.LoginOr')} -
  • - - - {this.props.t('Nav.SignUp')} - - -
  • -
- ); - } + const isUnsaved = !project?.id; - renderAuthenticatedUserMenu() { - return ( -
    - {getConfig('TRANSLATIONS_ENABLED') && this.renderLanguageMenu()} - - {this.props.t('Nav.Auth.Hello')}, {this.props.user.username}! - - } - > - - {this.props.t('Nav.Auth.MySketches')} - - - {this.props.t('Nav.Auth.MyCollections')} - - - {this.props.t('Nav.Auth.MyAssets')} - - - {this.props.t('Preferences.Settings')} - - - {this.props.t('Nav.Auth.LogOut')} - - -
- ); - } + // TODO: use selectRootFile selector + const rootFile = useSelector( + (state) => state.files.filter((file) => file.name === 'root')[0] + ); - renderUserMenu() { - const isLoginEnabled = getConfig('LOGIN_ENABLED'); - const isAuthenticated = this.props.user.authenticated; + const cmRef = useContext(CmControllerContext); - if (isLoginEnabled && isAuthenticated) { - return this.renderAuthenticatedUserMenu(); - } else if (isLoginEnabled && !isAuthenticated) { - return this.renderUnauthenticatedUserMenu(); - } + const dispatch = useDispatch(); - return null; - } + const { t } = useTranslation(); + const { + newSketch, + saveSketch, + downloadSketch, + shareSketch + } = useSketchActions(); - renderLeftLayout() { - switch (this.props.layout) { - case 'dashboard': - return this.renderDashboardMenu(); - case 'project': - default: - return this.renderProjectMenu(); - } - } + const replaceCommand = + metaKey === 'Ctrl' ? `${metaKeyName}+H` : `${metaKeyName}+⌥+F`; - render() { - return ( - - {this.renderLeftLayout()} - {this.renderUserMenu()} - - ); + return ( +
    +
  • + +
  • + + {t('Nav.File.New')} + saveSketch(cmRef.current)} + > + {t('Common.Save')} + {metaKeyName}+S + + dispatch(cloneProject())} + > + {t('Nav.File.Duplicate')} + + + {t('Nav.File.Share')} + + + {t('Nav.File.Download')} + + + {t('Nav.File.Open')} + + + {t('Nav.File.AddToCollection')} + + + {t('Nav.File.Examples')} + + + + + {t('Nav.Edit.TidyCode')} + + {metaKeyName}+{'\u21E7'}+F + + + + {t('Nav.Edit.Find')} + {metaKeyName}+F + + + {t('Nav.Edit.Replace')} + {replaceCommand} + + + + dispatch(newFile(rootFile.id))}> + {t('Nav.Sketch.AddFile')} + + dispatch(newFolder(rootFile.id))}> + {t('Nav.Sketch.AddFolder')} + + dispatch(startSketch())}> + {t('Nav.Sketch.Run')} + {metaKeyName}+Enter + + dispatch(stopSketch())}> + {t('Nav.Sketch.Stop')} + + {'\u21E7'}+{metaKeyName}+Enter + + + + + dispatch(showKeyboardShortcutModal())}> + {t('Nav.Help.KeyboardShortcuts')} + + + {t('Nav.Help.Reference')} + + {t('Nav.Help.About')} + +
+ ); +}; + +const LanguageMenu = () => { + const language = useSelector((state) => state.preferences.language); + const dispatch = useDispatch(); + + function handleLangSelection(event) { + dispatch(setLanguage(event.target.value)); + dispatch(showToast('Toast.LangChange')); } -} -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, - username: PropTypes.string - }) - }), - logoutUser: PropTypes.func.isRequired, - showShareModal: PropTypes.func.isRequired, - showErrorModal: PropTypes.func.isRequired, - unsavedChanges: PropTypes.bool.isRequired, - 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, - t: PropTypes.func.isRequired, - setLanguage: PropTypes.func.isRequired, - language: PropTypes.string.isRequired, - isUserOwner: PropTypes.bool.isRequired, - editorLink: PropTypes.string + return ( + + {sortBy(availableLanguages).map((key) => ( + // eslint-disable-next-line react/jsx-no-bind + + {languageKeyToLabel(key)} + + ))} + + ); }; -Nav.defaultProps = { - project: { - id: undefined, - owner: undefined - }, - cmController: {}, - layout: 'project', - editorLink: '/' +const UnauthenticatedUserMenu = () => { + const { t } = useTranslation(); + return ( +
    + {getConfig('TRANSLATIONS_ENABLED') && } +
  • + + + {t('Nav.Login')} + + +
  • + {t('Nav.LoginOr')} +
  • + + + {t('Nav.SignUp')} + + +
  • +
+ ); }; -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 AuthenticatedUserMenu = () => { + const username = useSelector((state) => state.user.username); -const mapDispatchToProps = { - ...IDEActions, - ...projectActions, - ...toastActions, - logoutUser, - setAllAccessibleOutput, - setLanguage + const { t } = useTranslation(); + const dispatch = useDispatch(); + + return ( +
    + {getConfig('TRANSLATIONS_ENABLED') && } + + {t('Nav.Auth.Hello')}, {username}! + + } + > + + {t('Nav.Auth.MySketches')} + + + {t('Nav.Auth.MyCollections')} + + + {t('Nav.Auth.MyAssets')} + + {t('Preferences.Settings')} + dispatch(logoutUser())}> + {t('Nav.Auth.LogOut')} + + +
+ ); }; -export default withTranslation()( - connect(mapStateToProps, mapDispatchToProps)(Nav) -); -export { Nav as NavComponent }; +export default Nav; diff --git a/client/modules/IDE/components/Header/Nav.unit.test.jsx b/client/modules/IDE/components/Header/Nav.unit.test.jsx index 679e6f083d..3b2d0b6b1f 100644 --- a/client/modules/IDE/components/Header/Nav.unit.test.jsx +++ b/client/modules/IDE/components/Header/Nav.unit.test.jsx @@ -1,62 +1,11 @@ import React from 'react'; -import { render, reduxRender } from '../../../../test-utils'; +import { reduxRender } from '../../../../test-utils'; -import Nav, { NavComponent } from './Nav'; +import Nav 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(