diff --git a/client/modules/IDE/components/Header/Toolbar.jsx b/client/modules/IDE/components/Header/Toolbar.jsx index 35470e97ad..46aeb9a652 100644 --- a/client/modules/IDE/components/Header/Toolbar.jsx +++ b/client/modules/IDE/components/Header/Toolbar.jsx @@ -1,253 +1,191 @@ +import React, { useRef, useState } from 'react'; +import classNames from 'classnames'; import PropTypes from 'prop-types'; -import React from 'react'; -import { connect } from 'react-redux'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; import { Link } from 'react-router-dom'; -import classNames from 'classnames'; -import { withTranslation } from 'react-i18next'; -import * as IDEActions from '../../actions/ide'; -import * as preferenceActions from '../../actions/preferences'; -import * as projectActions from '../../actions/project'; +import { + hideEditProjectName, + showEditProjectName +} from '../../actions/project'; +import { + openPreferences, + startAccessibleSketch, + startSketch, + stopSketch +} from '../../actions/ide'; +import { + setAutorefresh, + setGridOutput, + setTextOutput +} from '../../actions/preferences'; +import { useSketchActions } from '../../hooks'; import PlayIcon from '../../../../images/play.svg'; import StopIcon from '../../../../images/stop.svg'; import PreferencesIcon from '../../../../images/preferences.svg'; import EditProjectNameIcon from '../../../../images/pencil.svg'; -class Toolbar extends React.Component { - constructor(props) { - super(props); - this.handleKeyPress = this.handleKeyPress.bind(this); - this.handleProjectNameChange = this.handleProjectNameChange.bind(this); - this.handleProjectNameClick = this.handleProjectNameClick.bind(this); - this.handleProjectNameSave = this.handleProjectNameSave.bind(this); +const Toolbar = (props) => { + const { isPlaying, infiniteLoop, preferencesIsVisible } = useSelector( + (state) => state.ide + ); + const project = useSelector((state) => state.project); + const autorefresh = useSelector((state) => state.preferences.autorefresh); + const dispatch = useDispatch(); - this.state = { - projectNameInputValue: props.project.name - }; - } + const { t } = useTranslation(); + const { changeSketchName, canEditProjectName } = useSketchActions(); + + const projectNameInputRef = useRef(); + const [nameInputValue, setNameInputValue] = useState(project.name); - handleKeyPress(event) { + function handleKeyPress(event) { if (event.key === 'Enter') { - this.props.hideEditProjectName(); - this.projectNameInput.blur(); + dispatch(hideEditProjectName()); + projectNameInputRef.current?.blur(); } } - handleProjectNameChange(event) { - this.setState({ projectNameInputValue: event.target.value }); - } - - handleProjectNameClick() { - if (this.canEditProjectName) { - this.props.showEditProjectName(); + function handleProjectNameClick() { + if (canEditProjectName) { + dispatch(showEditProjectName()); setTimeout(() => { - this.projectNameInput?.focus(); + projectNameInputRef.current?.focus(); }, 140); } } - handleProjectNameSave() { - const newProjectName = this.state.projectNameInputValue.trim(); - 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(); - } + function handleProjectNameSave() { + const newName = nameInputValue; + if (newName.length > 0) { + dispatch(hideEditProjectName()); + changeSketchName(newName); } } - canEditProjectName() { - return ( - (this.props.owner && - this.props.owner.username && - this.props.owner.username === this.props.currentUser) || - !this.props.owner || - !this.props.owner.username - ); - } - - render() { - const playButtonClass = classNames({ - 'toolbar__play-button': true, - 'toolbar__play-button--selected': this.props.isPlaying - }); - const stopButtonClass = classNames({ - 'toolbar__stop-button': true, - 'toolbar__stop-button--selected': !this.props.isPlaying - }); - const preferencesButtonClass = classNames({ - 'toolbar__preferences-button': true, - 'toolbar__preferences-button--selected': this.props.preferencesIsVisible - }); - const nameContainerClass = classNames({ - 'toolbar__project-name-container': true, - 'toolbar__project-name-container--editing': this.props.project - .isEditingName - }); - - const canEditProjectName = this.canEditProjectName(); - - return ( -
- - + + +
+ { + dispatch(setAutorefresh(event.target.checked)); }} - aria-label={this.props.t('Toolbar.PlayOnlyVisualSketchARIA')} - title={this.props.t('Toolbar.PlaySketchARIA')} - disabled={this.props.infiniteLoop} - > -
+
+ setNameInputValue(e.target.value)} + onBlur={handleProjectNameSave} + onKeyPress={handleKeyPress} + /> + {(() => { + if (project.owner) { + return ( +

+ {t('Toolbar.By')}{' '} + + {project.owner.username} + +

+ ); + } + return null; + })()}
- ); - } -} + +
+ ); +}; Toolbar.propTypes = { - isPlaying: PropTypes.bool.isRequired, - preferencesIsVisible: PropTypes.bool.isRequired, - stopSketch: PropTypes.func.isRequired, - setProjectName: PropTypes.func.isRequired, - openPreferences: PropTypes.func.isRequired, - owner: PropTypes.shape({ - username: PropTypes.string - }), - project: PropTypes.shape({ - name: PropTypes.string.isRequired, - isEditingName: PropTypes.bool, - id: PropTypes.string - }).isRequired, - showEditProjectName: PropTypes.func.isRequired, - hideEditProjectName: PropTypes.func.isRequired, - infiniteLoop: PropTypes.bool.isRequired, - autorefresh: PropTypes.bool.isRequired, - setAutorefresh: PropTypes.func.isRequired, - setTextOutput: PropTypes.func.isRequired, - setGridOutput: PropTypes.func.isRequired, - startSketch: PropTypes.func.isRequired, - startAccessibleSketch: PropTypes.func.isRequired, - saveProject: PropTypes.func.isRequired, - currentUser: PropTypes.string, - t: PropTypes.func.isRequired, syncFileContent: PropTypes.func.isRequired }; -Toolbar.defaultProps = { - owner: undefined, - currentUser: undefined -}; - -function mapStateToProps(state) { - return { - autorefresh: state.preferences.autorefresh, - currentUser: state.user.username, - infiniteLoop: state.ide.infiniteLoop, - isPlaying: state.ide.isPlaying, - owner: state.project.owner, - preferencesIsVisible: state.ide.preferencesIsVisible, - project: state.project - }; -} - -const mapDispatchToProps = { - ...IDEActions, - ...preferenceActions, - ...projectActions -}; - -export const ToolbarComponent = withTranslation()(Toolbar); -export default connect(mapStateToProps, mapDispatchToProps)(ToolbarComponent); +export default Toolbar; diff --git a/client/modules/IDE/components/Header/Toolbar.unit.test.jsx b/client/modules/IDE/components/Header/Toolbar.unit.test.jsx index cd3365d62d..82b4cb7f4c 100644 --- a/client/modules/IDE/components/Header/Toolbar.unit.test.jsx +++ b/client/modules/IDE/components/Header/Toolbar.unit.test.jsx @@ -1,73 +1,86 @@ +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; import React from 'react'; import lodash from 'lodash'; -import { fireEvent, render, screen, waitFor } from '../../../../test-utils'; -import { ToolbarComponent } from './Toolbar'; - -const renderComponent = (extraProps = {}) => { - const props = lodash.merge( +import { + fireEvent, + reduxRender, + screen, + waitFor +} from '../../../../test-utils'; +import { selectProjectName } from '../../selectors/project'; +import ToolbarComponent from './Toolbar'; + +const server = setupServer( + rest.put(`/projects/id`, (req, res, ctx) => res(ctx.json(req.body))) +); + +beforeAll(() => server.listen()); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +const renderComponent = (extraState = {}) => { + const initialState = 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(), - syncFileContent: jest.fn(), - currentUser: 'me', - originalProjectName: 'testname', - - owner: { - username: 'me' + ide: { + isPlaying: false + }, + user: { + authenticated: true, + username: 'me', + id: 'userId' }, project: { name: 'testname', - isEditingName: false, - id: 'id' - }, - t: jest.fn() + id: 'id', + owner: { + username: 'me', + id: 'userId' + } + } }, - extraProps + extraState ); - render(); + const props = { + syncFileContent: jest.fn() + }; - return props; + return { + ...props, + ...reduxRender(, { initialState }) + }; }; describe('', () => { it('sketch owner can switch to sketch name editing mode', async () => { - const props = renderComponent(); + renderComponent(); const sketchName = screen.getByLabelText('Edit sketch name'); fireEvent.click(sketchName); - await waitFor(() => expect(props.showEditProjectName).toHaveBeenCalled()); + await waitFor(() => { + expect(screen.getByLabelText('New sketch name')).toHaveFocus(); + expect(screen.getByLabelText('New sketch name')).toBeEnabled(); + }); }); it("non-owner can't switch to sketch editing mode", async () => { - const props = renderComponent({ currentUser: 'not-me' }); + renderComponent({ user: { username: 'not-me', id: 'not-me' } }); const sketchName = screen.getByLabelText('Edit sketch name'); fireEvent.click(sketchName); expect(sketchName).toBeDisabled(); await waitFor(() => - expect(props.showEditProjectName).not.toHaveBeenCalled() + // expect(screen.getByLabelText('New sketch name').disabled).toBe(true) + expect(screen.getByLabelText('New sketch name')).toBeDisabled() ); }); it('sketch owner can change name', async () => { - const props = renderComponent({ project: { isEditingName: true } }); + const { store } = renderComponent(); const sketchNameInput = screen.getByLabelText('New sketch name'); fireEvent.change(sketchNameInput, { @@ -76,38 +89,38 @@ describe('', () => { fireEvent.blur(sketchNameInput); await waitFor(() => - expect(props.setProjectName).toHaveBeenCalledWith('my new sketch name') + expect(selectProjectName(store.getState())).toBe('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 { store } = renderComponent(); 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()); + await waitFor(() => + expect(selectProjectName(store.getState())).toBe('testname') + ); }); it('sketch is stopped when stop button is clicked', async () => { - const props = renderComponent({ isPlaying: true }); + const { store } = renderComponent({ ide: { isPlaying: true } }); const stopButton = screen.getByLabelText('Stop sketch'); fireEvent.click(stopButton); - await waitFor(() => expect(props.stopSketch).toHaveBeenCalled()); + await waitFor(() => expect(store.getState().ide.isPlaying).toBe(false)); }); it('sketch is started when play button is clicked', async () => { - const props = renderComponent(); + const { store } = renderComponent(); const playButton = screen.getByLabelText('Play only visual sketch'); fireEvent.click(playButton); - await waitFor(() => expect(props.startSketch).toHaveBeenCalled()); + await waitFor(() => expect(store.getState().ide.isPlaying).toBe(true)); }); it('sketch content is synched when play button is clicked', async () => { diff --git a/client/modules/IDE/hooks/useSketchActions.js b/client/modules/IDE/hooks/useSketchActions.js index c03abb2665..10f548b1a4 100644 --- a/client/modules/IDE/hooks/useSketchActions.js +++ b/client/modules/IDE/hooks/useSketchActions.js @@ -10,12 +10,13 @@ import { } from '../actions/project'; import { showToast } from '../actions/toast'; import { showErrorModal, showShareModal } from '../actions/ide'; +import { selectCanEditSketch } from '../selectors/users'; 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 canEditProjectName = useSelector(selectCanEditSketch); const dispatch = useDispatch(); const { t } = useTranslation(); const params = useParams(); @@ -56,16 +57,6 @@ const useSketchActions = () => { } } - function canEditProjectName() { - return ( - (project.owner && - project.owner.username && - project.owner.username === currentUser) || - !project.owner || - !project.owner.username - ); - } - return { newSketch, saveSketch, diff --git a/client/modules/IDE/selectors/project.js b/client/modules/IDE/selectors/project.js index 26197f3b00..bb536e3242 100644 --- a/client/modules/IDE/selectors/project.js +++ b/client/modules/IDE/selectors/project.js @@ -2,6 +2,7 @@ import { createSelector } from 'reselect'; export const selectProjectOwner = (state) => state.project.owner; export const selectProjectId = (state) => state.project.id; +export const selectProjectName = (state) => state.project.name; export const selectSketchPath = createSelector( selectProjectOwner,