Просмотр исходного кода

Merge pull request #3446 from overleaf/ae-clone-project-modal

Migrate left menu Clone Project modal to React

GitOrigin-RevId: ad113e2b40de4007def513d40551d55bb0c913db
Alf Eaton 5 лет назад
Родитель
Сommit
0135236df8

+ 8 - 30
services/web/app/views/project/editor/left-menu.pug

@@ -34,7 +34,7 @@ aside#left-menu.full-size(
 	span(ng-show="!anonymous")
 	span(ng-show="!anonymous")
 		h4 #{translate("actions")}
 		h4 #{translate("actions")}
 		ul.list-unstyled.nav
 		ul.list-unstyled.nav
-			li(ng-controller="CloneProjectController")
+			li(ng-controller="LeftMenuCloneProjectModalController")
 				a(
 				a(
 					href,
 					href,
 					ng-click="openCloneProjectModal()"
 					ng-click="openCloneProjectModal()"
@@ -42,6 +42,13 @@ aside#left-menu.full-size(
 					i.fa.fa-fw.fa-copy
 					i.fa.fa-fw.fa-copy
 					|    #{translate("copy_project")}
 					|    #{translate("copy_project")}
 
 
+				clone-project-modal(
+					handle-hide="handleHide"
+					project-id="projectId"
+					project-name="projectName"
+					show="show"
+				)
+
 			!= moduleIncludes("editorLeftMenu:actions", locals)
 			!= moduleIncludes("editorLeftMenu:actions", locals)
 			li(ng-controller="WordCountController")
 			li(ng-controller="WordCountController")
 				a(href, ng-if="pdf.url" ,ng-click="openWordCountModal()")
 				a(href, ng-if="pdf.url" ,ng-click="openWordCountModal()")
@@ -227,35 +234,6 @@ aside#left-menu.full-size(
 	ng-cloak
 	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')
 script(type='text/ng-template', id='wordCountModalTemplate')
 	.modal-header
 	.modal-header
 		h3 #{translate("word_count")}
 		h3 #{translate("word_count")}

+ 4 - 0
services/web/frontend/extracted-translation-keys.json

@@ -88,6 +88,10 @@
   "we_cant_find_any_sections_or_subsections_in_this_file",
   "we_cant_find_any_sections_or_subsections_in_this_file",
   "your_message",
   "your_message",
   "your_project_has_errors",
   "your_project_has_errors",
+  "copy_project",
+  "copying",
+  "copy",
+  "new_name",
   "recompile_from_scratch",
   "recompile_from_scratch",
   "run_syntax_check_now",
   "run_syntax_check_now",
   "toggle_compile_options_menu",
   "toggle_compile_options_menu",

+ 97 - 0
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 (
+    <>
+      <Modal.Header closeButton>
+        <Modal.Title>{t('copy_project')}</Modal.Title>
+      </Modal.Header>
+
+      <Modal.Body>
+        <form id="clone-project-form" onSubmit={handleSubmit}>
+          <FormGroup>
+            <ControlLabel htmlFor="cloned-project-name">
+              {t('new_name')}
+            </ControlLabel>
+
+            <FormControl
+              id="cloned-project-name"
+              type="text"
+              placeholder="New Project Name"
+              required
+              value={clonedProjectName}
+              onChange={event => setClonedProjectName(event.target.value)}
+            />
+          </FormGroup>
+        </form>
+
+        {error && (
+          <Alert bsStyle="danger">
+            {error.message || t('generic_something_went_wrong')}
+          </Alert>
+        )}
+      </Modal.Body>
+
+      <Modal.Footer>
+        <Button type="button" disabled={inFlight} onClick={cancel}>
+          {t('cancel')}
+        </Button>
+
+        <Button
+          form="clone-project-form"
+          type="submit"
+          bsStyle="primary"
+          disabled={inFlight || !valid}
+        >
+          {inFlight ? <span>{t('copying')}…</span> : <span>{t('copy')}</span>}
+        </Button>
+      </Modal.Footer>
+    </>
+  )
+}
+
+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

+ 80 - 0
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 (
+    <Modal show={show} onHide={cancel}>
+      <CloneProjectModalContent
+        cloneProject={cloneProject}
+        error={error}
+        cancel={cancel}
+        inFlight={inFlight}
+        projectName={projectName}
+      />
+    </Modal>
+  )
+}
+
+CloneProjectModal.propTypes = {
+  handleHide: PropTypes.func.isRequired,
+  projectId: PropTypes.string.isRequired,
+  projectName: PropTypes.string,
+  show: PropTypes.bool.isRequired
+}
+
+export default CloneProjectModal

+ 38 - 0
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)
+      }
+    })
+  }
+})

+ 4 - 2
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/CloneProjectController'
 import './controllers/CloneProjectModalController'
 import './controllers/CloneProjectModalController'
+
+// React
+import '../../features/clone-project-modal/controllers/left-menu-clone-project-modal-controller'

+ 67 - 0
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 => (
+  <div className="modal-dialog">
+    <div className="modal-content">
+      <CloneProjectModalContent {...args} />
+    </div>
+  </div>
+)
+Form.args = {
+  inFlight: false,
+  error: false
+}
+
+export const Loading = args => (
+  <div className="modal-dialog">
+    <div className="modal-content">
+      <CloneProjectModalContent {...args} />
+    </div>
+  </div>
+)
+Loading.args = {
+  inFlight: true,
+  error: false
+}
+
+export const LoadingError = args => (
+  <div className="modal-dialog">
+    <div className="modal-content">
+      <CloneProjectModalContent {...args} />
+    </div>
+  </div>
+)
+LoadingError.args = {
+  inFlight: false,
+  error: true
+}
+
+export const LoadingErrorMessage = args => (
+  <div className="modal-dialog">
+    <div className="modal-content">
+      <CloneProjectModalContent {...args} />
+    </div>
+  </div>
+)
+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' }
+  }
+}

+ 237 - 0
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('<CloneProjectModal />', 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(
+      <CloneProjectModal
+        handleHide={handleHide}
+        projectId="project-1"
+        projectName="A Project"
+        show
+      />
+    )
+
+    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(
+      <CloneProjectModal
+        handleHide={handleHide}
+        projectId="project-2"
+        projectName="A Project"
+        show
+      />
+    )
+
+    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(
+      <CloneProjectModal
+        handleHide={handleHide}
+        projectId="project-3"
+        projectName="A Project"
+        show
+      />
+    )
+
+    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('<CloneProjectModalContent />', function() {
+  it('renders the translated modal title', async function() {
+    render(
+      <CloneProjectModalContent
+        cloneProject={cloneProject}
+        cancel={cancel}
+        inFlight={false}
+      />
+    )
+
+    await screen.findByText('Copy Project')
+  })
+
+  it('shows the copy button', async function() {
+    render(
+      <CloneProjectModalContent
+        cloneProject={cloneProject}
+        cancel={cancel}
+        inFlight={false}
+      />
+    )
+
+    const button = await screen.findByRole('button', { name: 'Copy' })
+
+    expect(button.disabled).to.be.false
+  })
+
+  it('disables the copy button when loading', async function() {
+    render(
+      <CloneProjectModalContent
+        cloneProject={cloneProject}
+        cancel={cancel}
+        inFlight
+      />
+    )
+
+    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(
+      <CloneProjectModalContent
+        cloneProject={cloneProject}
+        cancel={cancel}
+        inFlight={false}
+        error
+      />
+    )
+
+    await screen.findByText('Sorry, something went wrong')
+  })
+
+  it('renders a specific error message', async function() {
+    render(
+      <CloneProjectModalContent
+        cloneProject={cloneProject}
+        cancel={cancel}
+        inFlight={false}
+        error={{
+          message: 'Uh oh!'
+        }}
+      />
+    )
+
+    await screen.findByText('Uh oh!')
+  })
+
+  it('displays a project name', async function() {
+    render(
+      <CloneProjectModalContent
+        cloneProject={cloneProject}
+        cancel={cancel}
+        inFlight={false}
+        projectName="A copy of a project"
+      />
+    )
+
+    const input = await screen.getByLabelText('New Name')
+
+    expect(input.value).to.equal('A copy of a project (Copy)')
+  })
+})