From 0135236df8a0769584f06f2c6975cd095dff68c4 Mon Sep 17 00:00:00 2001 From: Alf Eaton <75253002+aeaton-overleaf@users.noreply.github.com> Date: Wed, 6 Jan 2021 10:30:08 +0000 Subject: [PATCH] Merge pull request #3446 from overleaf/ae-clone-project-modal Migrate left menu Clone Project modal to React GitOrigin-RevId: ad113e2b40de4007def513d40551d55bb0c913db --- .../app/views/project/editor/left-menu.pug | 38 +-- .../frontend/extracted-translation-keys.json | 4 + .../components/clone-project-modal-content.js | 97 +++++++ .../components/clone-project-modal.js | 80 ++++++ ...eft-menu-clone-project-modal-controller.js | 38 +++ services/web/frontend/js/ide/clone/index.js | 6 +- .../stories/clone-project-modal.stories.js | 67 +++++ .../components/clone-project-modal.test.js | 237 ++++++++++++++++++ 8 files changed, 535 insertions(+), 32 deletions(-) create mode 100644 services/web/frontend/js/features/clone-project-modal/components/clone-project-modal-content.js create mode 100644 services/web/frontend/js/features/clone-project-modal/components/clone-project-modal.js create mode 100644 services/web/frontend/js/features/clone-project-modal/controllers/left-menu-clone-project-modal-controller.js create mode 100644 services/web/frontend/stories/clone-project-modal.stories.js create mode 100644 services/web/test/frontend/features/clone-project-modal/components/clone-project-modal.test.js diff --git a/services/web/app/views/project/editor/left-menu.pug b/services/web/app/views/project/editor/left-menu.pug index 020bd33091..8abffea540 100644 --- a/services/web/app/views/project/editor/left-menu.pug +++ b/services/web/app/views/project/editor/left-menu.pug @@ -34,7 +34,7 @@ aside#left-menu.full-size( span(ng-show="!anonymous") h4 #{translate("actions")} ul.list-unstyled.nav - li(ng-controller="CloneProjectController") + li(ng-controller="LeftMenuCloneProjectModalController") a( href, ng-click="openCloneProjectModal()" @@ -42,6 +42,13 @@ aside#left-menu.full-size( i.fa.fa-fw.fa-copy |    #{translate("copy_project")} + clone-project-modal( + handle-hide="handleHide" + project-id="projectId" + project-name="projectName" + show="show" + ) + != moduleIncludes("editorLeftMenu:actions", locals) li(ng-controller="WordCountController") a(href, ng-if="pdf.url" ,ng-click="openWordCountModal()") @@ -227,35 +234,6 @@ aside#left-menu.full-size( ng-cloak ) -script(type='text/ng-template', id='cloneProjectModalTemplate') - .modal-header - h3 #{translate("copy_project")} - .modal-body - .alert.alert-danger(ng-show="state.error.message") {{ state.error.message}} - .alert.alert-danger(ng-show="state.error && !state.error.message") #{translate("generic_something_went_wrong")} - form(name="cloneProjectForm", novalidate) - .form-group - label #{translate("new_name")} - input.form-control( - type="text", - placeholder="New Project Name", - required, - ng-model="inputs.projectName", - on-enter="clone()", - focus-on="open" - ) - .modal-footer - button.btn.btn-default( - ng-disabled="state.inflight" - ng-click="cancel()" - ) #{translate("cancel")} - button.btn.btn-primary( - ng-disabled="cloneProjectForm.$invalid || state.inflight" - ng-click="clone()" - ) - span(ng-hide="state.inflight") #{translate("copy")} - span(ng-show="state.inflight") #{translate("copying")}… - script(type='text/ng-template', id='wordCountModalTemplate') .modal-header h3 #{translate("word_count")} diff --git a/services/web/frontend/extracted-translation-keys.json b/services/web/frontend/extracted-translation-keys.json index 3d453847e2..6a6dff3194 100644 --- a/services/web/frontend/extracted-translation-keys.json +++ b/services/web/frontend/extracted-translation-keys.json @@ -88,6 +88,10 @@ "we_cant_find_any_sections_or_subsections_in_this_file", "your_message", "your_project_has_errors", + "copy_project", + "copying", + "copy", + "new_name", "recompile_from_scratch", "run_syntax_check_now", "toggle_compile_options_menu", diff --git a/services/web/frontend/js/features/clone-project-modal/components/clone-project-modal-content.js b/services/web/frontend/js/features/clone-project-modal/components/clone-project-modal-content.js new file mode 100644 index 0000000000..59aca9bd57 --- /dev/null +++ b/services/web/frontend/js/features/clone-project-modal/components/clone-project-modal-content.js @@ -0,0 +1,97 @@ +import React, { useMemo, useState } from 'react' +import { + Modal, + Alert, + Button, + FormGroup, + ControlLabel, + FormControl +} from 'react-bootstrap' +import PropTypes from 'prop-types' +import { useTranslation } from 'react-i18next' + +function CloneProjectModalContent({ + cloneProject, + projectName = '', + error, + cancel, + inFlight +}) { + const { t } = useTranslation() + + const [clonedProjectName, setClonedProjectName] = useState( + `${projectName} (Copy)` + ) + + const valid = useMemo(() => !!clonedProjectName, [clonedProjectName]) + + function handleSubmit(event) { + event.preventDefault() + if (valid) { + cloneProject(clonedProjectName) + } + } + + return ( + <> + + {t('copy_project')} + + + +
+ + + {t('new_name')} + + + setClonedProjectName(event.target.value)} + /> + +
+ + {error && ( + + {error.message || t('generic_something_went_wrong')} + + )} +
+ + + + + + + + ) +} + +CloneProjectModalContent.propTypes = { + cloneProject: PropTypes.func.isRequired, + error: PropTypes.oneOfType([ + PropTypes.bool, + PropTypes.shape({ + message: PropTypes.string + }) + ]), + cancel: PropTypes.func.isRequired, + inFlight: PropTypes.bool.isRequired, + projectName: PropTypes.string +} + +export default CloneProjectModalContent diff --git a/services/web/frontend/js/features/clone-project-modal/components/clone-project-modal.js b/services/web/frontend/js/features/clone-project-modal/components/clone-project-modal.js new file mode 100644 index 0000000000..639a823e25 --- /dev/null +++ b/services/web/frontend/js/features/clone-project-modal/components/clone-project-modal.js @@ -0,0 +1,80 @@ +import React, { useCallback, useEffect, useState } from 'react' +import { Modal } from 'react-bootstrap' +import PropTypes from 'prop-types' +import CloneProjectModalContent from './clone-project-modal-content' + +function CloneProjectModal({ handleHide, show, projectId, projectName }) { + const [inFlight, setInFlight] = useState(false) + const [error, setError] = useState() + + // reset error when the modal is opened + useEffect(() => { + if (show) { + setError(undefined) + } + }, [show]) + + // clone the project when the form is submitted + const cloneProject = useCallback( + cloneName => { + setInFlight(true) + + fetch(`/project/${projectId}/clone`, { + method: 'POST', + body: JSON.stringify({ + _csrf: window.csrfToken, + projectName: cloneName + }), + headers: { + 'Content-Type': 'application/json' + } + }) + .then(async response => { + if (response.ok) { + const { project_id: clonedProjectId } = await response.json() + window.location.assign(`/project/${clonedProjectId}`) + } else { + if (response.status === 400) { + setError({ message: await response.text() }) + } else { + setError(true) + } + setInFlight(false) + } + }) + .catch(() => { + setError(true) + setInFlight(false) + }) + }, + [projectId] + ) + + // close the modal if not in flight + const cancel = useCallback(() => { + if (!inFlight) { + handleHide() + } + }, [handleHide, inFlight]) + + return ( + + + + ) +} + +CloneProjectModal.propTypes = { + handleHide: PropTypes.func.isRequired, + projectId: PropTypes.string.isRequired, + projectName: PropTypes.string, + show: PropTypes.bool.isRequired +} + +export default CloneProjectModal diff --git a/services/web/frontend/js/features/clone-project-modal/controllers/left-menu-clone-project-modal-controller.js b/services/web/frontend/js/features/clone-project-modal/controllers/left-menu-clone-project-modal-controller.js new file mode 100644 index 0000000000..cc9eb9f23d --- /dev/null +++ b/services/web/frontend/js/features/clone-project-modal/controllers/left-menu-clone-project-modal-controller.js @@ -0,0 +1,38 @@ +import App from '../../../base' +import { react2angular } from 'react2angular' + +import CloneProjectModal from '../components/clone-project-modal' + +App.component('cloneProjectModal', react2angular(CloneProjectModal)) + +export default App.controller('LeftMenuCloneProjectModalController', function( + $scope, + ide +) { + $scope.show = false + $scope.projectId = ide.$scope.project_id + + $scope.handleHide = () => { + $scope.$applyAsync(() => { + $scope.show = false + }) + } + + $scope.openCloneProjectModal = () => { + $scope.$applyAsync(() => { + const { project } = ide.$scope + + if (project) { + $scope.projectId = project._id + $scope.projectName = project.name + + $scope.show = true + + // TODO: is this needed + window.setTimeout(() => { + $scope.$broadcast('open') + }, 200) + } + }) + } +}) diff --git a/services/web/frontend/js/ide/clone/index.js b/services/web/frontend/js/ide/clone/index.js index 27dd78a967..8e23276be2 100644 --- a/services/web/frontend/js/ide/clone/index.js +++ b/services/web/frontend/js/ide/clone/index.js @@ -1,4 +1,6 @@ -// TODO: This file was created by bulk-decaffeinate. -// Fix any style issues and re-enable lint. +// Angular import './controllers/CloneProjectController' import './controllers/CloneProjectModalController' + +// React +import '../../features/clone-project-modal/controllers/left-menu-clone-project-modal-controller' diff --git a/services/web/frontend/stories/clone-project-modal.stories.js b/services/web/frontend/stories/clone-project-modal.stories.js new file mode 100644 index 0000000000..d4486bf95e --- /dev/null +++ b/services/web/frontend/stories/clone-project-modal.stories.js @@ -0,0 +1,67 @@ +import React from 'react' + +import CloneProjectModalContent from '../js/features/clone-project-modal/components/clone-project-modal-content' + +// NOTE: CloneProjectModalContent is wrapped in modal classes, without modal behaviours + +export const Form = args => ( +
+
+ +
+
+) +Form.args = { + inFlight: false, + error: false +} + +export const Loading = args => ( +
+
+ +
+
+) +Loading.args = { + inFlight: true, + error: false +} + +export const LoadingError = args => ( +
+
+ +
+
+) +LoadingError.args = { + inFlight: false, + error: true +} + +export const LoadingErrorMessage = args => ( +
+
+ +
+
+) +LoadingErrorMessage.args = { + inFlight: false, + error: { + message: 'The chosen project name is already in use' + } +} + +export default { + title: 'Clone Project Modal', + component: CloneProjectModalContent, + args: { + projectName: 'Project Title' + }, + argTypes: { + cloneProject: { action: 'cloneProject' }, + cancel: { action: 'cancel' } + } +} diff --git a/services/web/test/frontend/features/clone-project-modal/components/clone-project-modal.test.js b/services/web/test/frontend/features/clone-project-modal/components/clone-project-modal.test.js new file mode 100644 index 0000000000..ba6b876836 --- /dev/null +++ b/services/web/test/frontend/features/clone-project-modal/components/clone-project-modal.test.js @@ -0,0 +1,237 @@ +import React from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import { expect } from 'chai' +import CloneProjectModalContent from '../../../../../frontend/js/features/clone-project-modal/components/clone-project-modal-content' +import CloneProjectModal from '../../../../../frontend/js/features/clone-project-modal/components/clone-project-modal' +import sinon from 'sinon' +import fetchMock from 'fetch-mock' + +const cancel = sinon.stub() +const cloneProject = sinon.stub() +const handleHide = sinon.stub() + +describe('', function() { + afterEach(function() { + fetchMock.reset() + }) + + it('posts the generated project name', async function() { + const matcher = 'express:/project/:projectId/clone' + + fetchMock.postOnce( + matcher, + () => { + return { + project_id: 'test' + } + }, + { + body: { + projectName: 'A Project (Copy)' + } + } + ) + + render( + + ) + + const button = await screen.findByRole('button', { + name: 'Copy', + hidden: true // TODO: this shouldn't be needed + }) + + const cancelButton = await screen.findByRole('button', { + name: 'Cancel', + hidden: true // TODO: this shouldn't be needed + }) + + expect(button.disabled).to.be.false + expect(cancelButton.disabled).to.be.false + + fireEvent.click(button) + + expect(fetchMock.done(matcher)).to.be.true + // TODO: window.location? + + const errorMessage = screen.queryByText('Sorry, something went wrong') + expect(errorMessage).to.be.null + + expect(button.disabled).to.be.true + expect(cancelButton.disabled).to.be.true + }) + + it('handles a generic error response', async function() { + const matcher = 'express:/project/:projectId/clone' + + fetchMock.postOnce(matcher, { + status: 500, + body: 'There was an error!' + }) + + render( + + ) + + const button = await screen.findByRole('button', { + name: 'Copy', + hidden: true // TODO: this shouldn't be needed + }) + + const cancelButton = await screen.findByRole('button', { + name: 'Cancel', + hidden: true // TODO: this shouldn't be needed + }) + + expect(button.disabled).to.be.false + expect(cancelButton.disabled).to.be.false + + fireEvent.click(button) + + expect(fetchMock.done(matcher)).to.be.true + + await screen.findByText('Sorry, something went wrong') + + expect(button.disabled).to.be.false + expect(cancelButton.disabled).to.be.false + }) + + it('handles a specific error response', async function() { + const matcher = 'express:/project/:projectId/clone' + + fetchMock.postOnce(matcher, { + status: 400, + body: 'There was an error!' + }) + + render( + + ) + + const button = await screen.findByRole('button', { + name: 'Copy', + hidden: true // TODO: this shouldn't be needed + }) + + const cancelButton = await screen.findByRole('button', { + name: 'Cancel', + hidden: true // TODO: this shouldn't be needed + }) + + expect(button.disabled).to.be.false + expect(cancelButton.disabled).to.be.false + + fireEvent.click(button) + + expect(fetchMock.done(matcher)).to.be.true + + await screen.findByText('There was an error!') + + expect(button.disabled).to.be.false + expect(cancelButton.disabled).to.be.false + }) +}) + +describe('', function() { + it('renders the translated modal title', async function() { + render( + + ) + + await screen.findByText('Copy Project') + }) + + it('shows the copy button', async function() { + render( + + ) + + const button = await screen.findByRole('button', { name: 'Copy' }) + + expect(button.disabled).to.be.false + }) + + it('disables the copy button when loading', async function() { + render( + + ) + + const button = await screen.findByText( + (content, element) => + element.nodeName === 'BUTTON' && + element.textContent.trim().match(/^Copying…$/) + ) + + expect(button.disabled).to.be.true + }) + + it('renders a generic error message', async function() { + render( + + ) + + await screen.findByText('Sorry, something went wrong') + }) + + it('renders a specific error message', async function() { + render( + + ) + + await screen.findByText('Uh oh!') + }) + + it('displays a project name', async function() { + render( + + ) + + const input = await screen.getByLabelText('New Name') + + expect(input.value).to.equal('A copy of a project (Copy)') + }) +})