diff --git a/client/constants.js b/client/constants.js index ec0e4107ac..565d716fa6 100644 --- a/client/constants.js +++ b/client/constants.js @@ -30,8 +30,6 @@ export const PROJECT_SAVE_SUCCESS = 'PROJECT_SAVE_SUCCESS'; export const PROJECT_SAVE_FAIL = 'PROJECT_SAVE_FAIL'; export const NEW_PROJECT = 'NEW_PROJECT'; export const RESET_PROJECT = 'RESET_PROJECT'; -export const SHOW_EDIT_PROJECT_NAME = 'SHOW_EDIT_PROJECT_NAME'; -export const HIDE_EDIT_PROJECT_NAME = 'HIDE_EDIT_PROJECT_NAME'; export const SET_PROJECT = 'SET_PROJECT'; export const SET_PROJECTS = 'SET_PROJECTS'; diff --git a/client/modules/IDE/actions/project.js b/client/modules/IDE/actions/project.js index 9a528a34f6..3709d07a2e 100644 --- a/client/modules/IDE/actions/project.js +++ b/client/modules/IDE/actions/project.js @@ -351,18 +351,6 @@ export function cloneProject(project) { }; } -export function showEditProjectName() { - return { - type: ActionTypes.SHOW_EDIT_PROJECT_NAME - }; -} - -export function hideEditProjectName() { - return { - type: ActionTypes.HIDE_EDIT_PROJECT_NAME - }; -} - export function setProjectSavedTime(updatedAt) { return { type: ActionTypes.SET_PROJECT_SAVED_TIME, diff --git a/client/modules/IDE/components/EditableInput.jsx b/client/modules/IDE/components/EditableInput.jsx index 871c6470a5..405cc61920 100644 --- a/client/modules/IDE/components/EditableInput.jsx +++ b/client/modules/IDE/components/EditableInput.jsx @@ -11,7 +11,9 @@ function EditableInput({ emptyPlaceholder, InputComponent, inputProps, - onChange + onChange, + disabled, + 'aria-label': ariaLabel }) { const [isEditing, setIsEditing] = React.useState(false); const [currentValue, setCurrentValue] = React.useState(value || ''); @@ -19,12 +21,14 @@ function EditableInput({ const hasValue = currentValue !== ''; const classes = `editable-input editable-input--${ isEditing ? 'is-editing' : 'is-not-editing' - } editable-input--${hasValue ? 'has-value' : 'has-placeholder'}`; - const inputRef = React.createRef(); + } editable-input--${hasValue ? 'has-value' : 'has-placeholder'} ${ + disabled ? 'editable-input--disabled' : '' + }`; + const inputRef = React.useRef(); const { t } = useTranslation(); React.useEffect(() => { if (isEditing) { - inputRef.current.focus(); + inputRef.current?.focus(); } }, [isEditing]); @@ -32,6 +36,11 @@ function EditableInput({ setIsEditing(true); } + function cancelEditing() { + setIsEditing(false); + setCurrentValue(value); + } + function doneEditing() { setIsEditing(false); @@ -51,6 +60,8 @@ function EditableInput({ function checkForKeyAction(event) { if (event.key === 'Enter') { doneEditing(); + } else if (event.key === 'Escape' || event.key === 'Esc') { + cancelEditing(); } } @@ -59,7 +70,11 @@ function EditableInput({ - - -
- { - this.props.setAutorefresh(event.target.checked); - }} - /> - -
-
- - { - this.projectNameInput = element; - }} - onBlur={this.handleProjectNameSave} - onKeyPress={this.handleKeyPress} - /> - {(() => { - if (this.props.owner) { - return ( -

- {this.props.t('Toolbar.By')}{' '} - - {this.props.owner.username} - -

- ); - } - return null; - })()} -
- - - ); - } +import StopIcon from '../../../images/stop.svg'; +import { + openPreferences, + startAccessibleSketch, + startSketch, + stopSketch +} from '../actions/ide'; +import { + setAutorefresh, + setGridOutput, + setTextOutput +} from '../actions/preferences'; +import { saveProject, setProjectName } from '../actions/project'; +import { + selectProjectId, + selectProjectName, + selectProjectOwner +} from '../selectors/project'; +import { selectCanEditSketch } from '../selectors/users'; +import EditableInput from './EditableInput'; + +export function PlayButton({ syncFileContent }) { + const { t } = useTranslation(); + const dispatch = useDispatch(); + + const isPlaying = useSelector((state) => state.ide.isPlaying); + const infiniteLoop = useSelector((state) => state.ide.infiniteLoop); + + return ( + <> + + + + ); } -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, +PlayButton.propTypes = { syncFileContent: PropTypes.func.isRequired }; -Toolbar.defaultProps = { - owner: undefined, - currentUser: undefined -}; +export function StopButton() { + const { t } = useTranslation(); + const dispatch = useDispatch(); + + const isPlaying = useSelector((state) => state.ide.isPlaying); + + return ( + + ); +} + +export function AutoRefreshCheckbox() { + const { t } = useTranslation(); + const dispatch = useDispatch(); + + const autorefresh = useSelector((state) => state.preferences.autorefresh); + + return ( +
+ { + dispatch(setAutorefresh(event.target.checked)); + }} + /> + +
+ ); +} + +export function EditableProjectName() { + const { t } = useTranslation(); + const dispatch = useDispatch(); + + const projectId = useSelector(selectProjectId); + const projectName = useSelector(selectProjectName); + const canEditProjectName = useSelector(selectCanEditSketch); -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 handleProjectNameSave = (value) => { + const newProjectName = value.trim(); + dispatch(setProjectName(newProjectName)); + if (projectId) { + dispatch(saveProject()); + } }; + + return ( + text.trim().length > 0} + onChange={handleProjectNameSave} + /> + ); +} + +export function ProjectOwner() { + const { t } = useTranslation(); + + const owner = useSelector(selectProjectOwner); + + if (!owner) return null; + + return ( +

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

+ ); } -const mapDispatchToProps = { - ...IDEActions, - ...preferenceActions, - ...projectActions +export function PreferencesButton() { + const { t } = useTranslation(); + const dispatch = useDispatch(); + + const preferencesIsVisible = useSelector( + (state) => state.ide.preferencesIsVisible + ); + + return ( + + ); +} + +function Toolbar({ syncFileContent }) { + return ( +
+ + + +
+ + +
+ +
+ ); +} + +Toolbar.propTypes = { + syncFileContent: PropTypes.func.isRequired }; -export const ToolbarComponent = withTranslation()(Toolbar); -export default connect(mapStateToProps, mapDispatchToProps)(ToolbarComponent); +export default Toolbar; diff --git a/client/modules/IDE/components/Toolbar.unit.test.jsx b/client/modules/IDE/components/Toolbar.unit.test.jsx index d5e83dd69d..c5f1b01fae 100644 --- a/client/modules/IDE/components/Toolbar.unit.test.jsx +++ b/client/modules/IDE/components/Toolbar.unit.test.jsx @@ -1,73 +1,80 @@ +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'; +import { fireEvent, reduxRender, screen, waitFor } from '../../../test-utils'; +import { selectProjectName } from '../selectors/project'; +import ToolbarComponent from './Toolbar'; -const renderComponent = (extraProps = {}) => { - const props = lodash.merge( +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')).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 +83,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/reducers/project.js b/client/modules/IDE/reducers/project.js index df0da82411..89a03529e6 100644 --- a/client/modules/IDE/reducers/project.js +++ b/client/modules/IDE/reducers/project.js @@ -37,10 +37,6 @@ const project = (state, action) => { }; case ActionTypes.RESET_PROJECT: return initialState(); - case ActionTypes.SHOW_EDIT_PROJECT_NAME: - return Object.assign({}, state, { isEditingName: true }); - case ActionTypes.HIDE_EDIT_PROJECT_NAME: - return Object.assign({}, state, { isEditingName: false }); case ActionTypes.SET_PROJECT_SAVED_TIME: return Object.assign({}, state, { updatedAt: action.value }); case ActionTypes.START_SAVING_PROJECT: 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, diff --git a/client/styles/components/_editable-input.scss b/client/styles/components/_editable-input.scss index f750af0d8a..07e157b613 100644 --- a/client/styles/components/_editable-input.scss +++ b/client/styles/components/_editable-input.scss @@ -6,6 +6,7 @@ button.editable-input__label { display: flex; + align-items: center; @include themify() { color: getThemifyVariable('inactive-text-color'); @@ -35,6 +36,13 @@ button.editable-input__label { height: 1.5rem; } +.editable-input--disabled { + pointer-events: none; + .editable-input__icon { + display: none; + } +} + .editable-input--is-not-editing .editable-input__input, .editable-input--is-editing .editable-input__label { display: none; diff --git a/client/styles/components/_toolbar.scss b/client/styles/components/_toolbar.scss index cd74ec8f6f..e4b9b86742 100644 --- a/client/styles/components/_toolbar.scss +++ b/client/styles/components/_toolbar.scss @@ -98,40 +98,23 @@ } .toolbar__project-name-container { - @include themify() { - border-color: getThemifyVariable('inactive-text-color'); - } margin-left: #{10 / $base-font-size}rem; padding-left: #{10 / $base-font-size}rem; display: flex; align-items: center; } -.toolbar__project-name { +.toolbar .editable-input__label { @include themify() { color: getThemifyVariable('secondary-text-color'); - &:hover { - color: getThemifyVariable('logo-color'); - & .toolbar__edit-name-button path { - fill: getThemifyVariable('logo-color'); - } + & path { + fill: getThemifyVariable('secondary-text-color'); } } - cursor: pointer; - display: flex; - align-items: center; - - .toolbar__project-name-container--editing & { - display: none; - } } -.toolbar__project-name-input { - display: none; - border: 0px; - .toolbar__project-name-container--editing & { - display: block; - } +.toolbar .editable-input__input { + border: 0; } .toolbar__project-owner { @@ -160,15 +143,3 @@ color:getThemifyVariable('logo-color'); } } - -.toolbar__edit-name-button { - display: inline-block; - vertical-align: top; - width: #{18 / $base-font-size}rem; - height: #{18 / $base-font-size}rem; - @include themify() { - & path { - fill: getThemifyVariable('secondary-text-color'); - } - } -} diff --git a/client/test-utils.js b/client/test-utils.js index ffabcb0df2..b1f452769e 100644 --- a/client/test-utils.js +++ b/client/test-utils.js @@ -14,15 +14,14 @@ import { render } from '@testing-library/react'; import React from 'react'; import PropTypes from 'prop-types'; -import { createStore } from 'redux'; import { Provider } from 'react-redux'; import { I18nextProvider } from 'react-i18next'; import { ThemeProvider as StyledThemeProvider } from 'styled-components'; import i18n from './i18n-test'; -import rootReducer from './reducers'; import ThemeProvider from './modules/App/components/ThemeProvider'; +import configureStore from './store'; import theme, { Theme } from './theme'; // re-export everything @@ -42,11 +41,7 @@ Providers.propTypes = { function reduxRender( ui, - { - initialState, - store = createStore(rootReducer, initialState), - ...renderOptions - } = {} + { initialState, store = configureStore(initialState), ...renderOptions } = {} ) { function Wrapper({ children }) { return ( @@ -62,7 +57,10 @@ function reduxRender( children: PropTypes.element.isRequired }; - return render(ui, { wrapper: Wrapper, ...renderOptions }); + return { + store, + ...render(ui, { wrapper: Wrapper, ...renderOptions }) + }; } const customRender = (ui, options) =>