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 (
-
- -
-
-
- -
-
-
-
- {this.props.t('Nav.BackEditor')}
-
-
-
-
- );
+ 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();
expect(asFragment()).toMatchSnapshot();
diff --git a/client/modules/IDE/components/Header/__snapshots__/Nav.unit.test.jsx.snap b/client/modules/IDE/components/Header/__snapshots__/Nav.unit.test.jsx.snap
index bbd34fb994..3270194fab 100644
--- a/client/modules/IDE/components/Header/__snapshots__/Nav.unit.test.jsx.snap
+++ b/client/modules/IDE/components/Header/__snapshots__/Nav.unit.test.jsx.snap
@@ -1,214 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`Nav renders correctly 1`] = `
-
-
-
-
-
-`;
-
exports[`Nav renders dashboard version 1`] = `
diff --git a/client/modules/IDE/components/Header/index.jsx b/client/modules/IDE/components/Header/index.jsx
new file mode 100644
index 0000000000..36ed3d8d7f
--- /dev/null
+++ b/client/modules/IDE/components/Header/index.jsx
@@ -0,0 +1,34 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { useSelector } from 'react-redux';
+import MediaQuery from 'react-responsive';
+import Nav from './Nav';
+import Toolbar from './Toolbar';
+
+const Header = (props) => {
+ const project = useSelector((state) => state.project);
+
+ return (
+
+
+
+ {(matches) => {
+ if (matches)
+ return (
+
+ );
+ return null;
+ }}
+
+
+ );
+};
+
+Header.propTypes = {
+ syncFileContent: PropTypes.func.isRequired
+};
+
+export default Header;
diff --git a/client/modules/IDE/hooks/index.js b/client/modules/IDE/hooks/index.js
new file mode 100644
index 0000000000..4e4e15a565
--- /dev/null
+++ b/client/modules/IDE/hooks/index.js
@@ -0,0 +1 @@
+export { default as useSketchActions } from './useSketchActions';
diff --git a/client/modules/IDE/hooks/useSketchActions.js b/client/modules/IDE/hooks/useSketchActions.js
new file mode 100644
index 0000000000..c03abb2665
--- /dev/null
+++ b/client/modules/IDE/hooks/useSketchActions.js
@@ -0,0 +1,79 @@
+import { useDispatch, useSelector } from 'react-redux';
+import { useTranslation } from 'react-i18next';
+import { useParams } from 'react-router';
+import {
+ autosaveProject,
+ exportProjectAsZip,
+ newProject,
+ saveProject,
+ setProjectName
+} from '../actions/project';
+import { showToast } from '../actions/toast';
+import { showErrorModal, showShareModal } from '../actions/ide';
+
+const useSketchActions = () => {
+ const unsavedChanges = useSelector((state) => state.ide.unsavedChanges);
+ const authenticated = useSelector((state) => state.user.authenticated);
+ const project = useSelector((state) => state.project);
+ const currentUser = useSelector((state) => state.user.username);
+ const dispatch = useDispatch();
+ const { t } = useTranslation();
+ const params = useParams();
+
+ function newSketch() {
+ if (!unsavedChanges) {
+ dispatch(showToast('Toast.OpenedNewSketch'));
+ dispatch(newProject());
+ } else if (window.confirm(t('Nav.WarningUnsavedChanges'))) {
+ dispatch(showToast('Toast.OpenedNewSketch'));
+ dispatch(newProject());
+ }
+ }
+
+ function saveSketch(cmController) {
+ if (authenticated) {
+ dispatch(saveProject(cmController?.getContent()));
+ } else {
+ dispatch(showErrorModal('forceAuthentication'));
+ }
+ }
+
+ function downloadSketch() {
+ dispatch(autosaveProject());
+ dispatch(exportProjectAsZip(project.id));
+ }
+
+ function shareSketch() {
+ const { username } = params;
+ dispatch(showShareModal(project.id, project.name, username));
+ }
+
+ function changeSketchName(name) {
+ const newProjectName = name.trim();
+ if (newProjectName.length > 0) {
+ dispatch(setProjectName(newProjectName));
+ if (project.id) dispatch(saveProject());
+ }
+ }
+
+ function canEditProjectName() {
+ return (
+ (project.owner &&
+ project.owner.username &&
+ project.owner.username === currentUser) ||
+ !project.owner ||
+ !project.owner.username
+ );
+ }
+
+ return {
+ newSketch,
+ saveSketch,
+ downloadSketch,
+ shareSketch,
+ changeSketchName,
+ canEditProjectName
+ };
+};
+
+export default useSketchActions;
diff --git a/client/modules/IDE/pages/IDEView.jsx b/client/modules/IDE/pages/IDEView.jsx
index f0b1ba4397..24ff858d99 100644
--- a/client/modules/IDE/pages/IDEView.jsx
+++ b/client/modules/IDE/pages/IDEView.jsx
@@ -74,6 +74,8 @@ function WarnIfUnsavedChanges() {
);
}
+export const CmControllerContext = React.createContext({});
+
class IDEView extends React.Component {
constructor(props) {
super(props);
@@ -257,7 +259,9 @@ class IDEView extends React.Component {
-
+
+
+