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

Refactor Clone Project modal (#4748)

GitOrigin-RevId: 03f5164d117713efd78c9806bdda6e6d5c90f6dc
Alf Eaton 5 лет назад
Родитель
Сommit
69482d9d3c

+ 0 - 2
services/web/app/views/project/editor/left-menu.pug

@@ -44,8 +44,6 @@ aside#left-menu.full-size(
 
 				clone-project-modal(
 					handle-hide="handleHide"
-					project-id="projectId"
-					project-name="projectName"
 					open-project="openProject"
 					show="show"
 				)

+ 63 - 44
services/web/frontend/js/features/clone-project-modal/components/clone-project-modal-content.js

@@ -1,5 +1,6 @@
 import PropTypes from 'prop-types'
-import { Trans } from 'react-i18next'
+import { useMemo, useState } from 'react'
+import { useTranslation } from 'react-i18next'
 import {
   Modal,
   Alert,
@@ -8,37 +9,70 @@ import {
   FormControl,
   FormGroup,
 } from 'react-bootstrap'
-import AccessibleModal from '../../../shared/components/accessible-modal'
+import { useProjectContext } from '../../../shared/context/project-context'
+import { postJSON } from '../../../infrastructure/fetch-json'
 
 export default function CloneProjectModalContent({
-  animation = true,
-  show,
-  cancel,
-  handleSubmit,
-  clonedProjectName,
-  setClonedProjectName,
-  error,
+  handleHide,
   inFlight,
-  valid,
+  setInFlight,
+  openProject,
 }) {
+  const { _id: projectId, name: projectName } = useProjectContext()
+  const { t } = useTranslation()
+
+  const [error, setError] = useState()
+  const [clonedProjectName, setClonedProjectName] = useState(
+    `${projectName} (Copy)`
+  )
+
+  // valid if the cloned project has a name
+  const valid = useMemo(() => clonedProjectName.trim().length > 0, [
+    clonedProjectName,
+  ])
+
+  // form submission: clone the project if the name is valid
+  const handleSubmit = event => {
+    event.preventDefault()
+
+    if (!valid) {
+      return
+    }
+
+    setError(false)
+    setInFlight(true)
+
+    // clone the project
+    postJSON(`/project/${projectId}/clone`, {
+      body: { projectName: clonedProjectName },
+    })
+      .then(data => {
+        // open the cloned project
+        openProject(data.project_id)
+      })
+      .catch(({ response, data }) => {
+        if (response?.status === 400) {
+          setError(data.message)
+        } else {
+          setError(true)
+        }
+      })
+      .finally(() => {
+        setInFlight(false)
+      })
+  }
+
   return (
-    <AccessibleModal
-      animation={animation}
-      show={show}
-      onHide={cancel}
-      id="clone-project-modal"
-    >
+    <>
       <Modal.Header closeButton>
-        <Modal.Title>
-          <Trans i18nKey="copy_project" />
-        </Modal.Title>
+        <Modal.Title>{t('copy_project')}</Modal.Title>
       </Modal.Header>
 
       <Modal.Body>
         <form id="clone-project-form" onSubmit={handleSubmit}>
           <FormGroup>
             <ControlLabel htmlFor="clone-project-form-name">
-              <Trans i18nKey="new_name" />
+              {t('new_name')}
             </ControlLabel>
 
             <FormControl
@@ -54,18 +88,14 @@ export default function CloneProjectModalContent({
 
         {error && (
           <Alert bsStyle="danger">
-            {error.length ? (
-              error
-            ) : (
-              <Trans i18nKey="generic_something_went_wrong" />
-            )}
+            {error.length ? error : t('generic_something_went_wrong')}
           </Alert>
         )}
       </Modal.Body>
 
       <Modal.Footer>
-        <Button type="button" disabled={inFlight} onClick={cancel}>
-          <Trans i18nKey="cancel" />
+        <Button type="button" disabled={inFlight} onClick={handleHide}>
+          {t('cancel')}
         </Button>
 
         <Button
@@ -74,26 +104,15 @@ export default function CloneProjectModalContent({
           bsStyle="primary"
           disabled={inFlight || !valid}
         >
-          {inFlight ? (
-            <>
-              <Trans i18nKey="copying" />…
-            </>
-          ) : (
-            <Trans i18nKey="copy" />
-          )}
+          {inFlight ? <>{t('copying')}…</> : t('copy')}
         </Button>
       </Modal.Footer>
-    </AccessibleModal>
+    </>
   )
 }
 CloneProjectModalContent.propTypes = {
-  animation: PropTypes.bool,
-  show: PropTypes.bool.isRequired,
-  cancel: PropTypes.func.isRequired,
-  handleSubmit: PropTypes.func.isRequired,
-  clonedProjectName: PropTypes.string,
-  setClonedProjectName: PropTypes.func.isRequired,
-  error: PropTypes.oneOfType([PropTypes.bool, PropTypes.string]),
-  inFlight: PropTypes.bool.isRequired,
-  valid: PropTypes.bool.isRequired,
+  handleHide: PropTypes.func.isRequired,
+  inFlight: PropTypes.bool,
+  setInFlight: PropTypes.func.isRequired,
+  openProject: PropTypes.func.isRequired,
 }

+ 21 - 69
services/web/frontend/js/features/clone-project-modal/components/clone-project-modal.js

@@ -1,92 +1,44 @@
-import { useCallback, useEffect, useMemo, useState } from 'react'
+import React, { useCallback, useState } from 'react'
 import PropTypes from 'prop-types'
-import { cloneProject } from '../utils/api'
 import CloneProjectModalContent from './clone-project-modal-content'
+import AccessibleModal from '../../../shared/components/accessible-modal'
+import withErrorBoundary from '../../../infrastructure/error-boundary'
 
-function CloneProjectModal({
+const CloneProjectModal = React.memo(function CloneProjectModal({
   show,
   handleHide,
-  projectId,
-  projectName = '',
   openProject,
 }) {
   const [inFlight, setInFlight] = useState(false)
-  const [error, setError] = useState()
-  const [clonedProjectName, setClonedProjectName] = useState('')
 
-  // set the cloned project name when the modal opens
-  useEffect(() => {
-    if (show) {
-      setClonedProjectName(`${projectName} (Copy)`)
-    }
-  }, [show, projectName])
-
-  // reset error when the modal is opened
-  useEffect(() => {
-    if (show) {
-      setError(undefined)
-    }
-  }, [show])
-
-  // close the modal if not in flight
-  const cancel = useCallback(() => {
+  const onHide = useCallback(() => {
     if (!inFlight) {
       handleHide()
     }
   }, [handleHide, inFlight])
 
-  // valid if the cloned project has a name
-  const valid = useMemo(() => !!clonedProjectName, [clonedProjectName])
-
-  // form submission: clone the project if the name is valid
-  const handleSubmit = event => {
-    event.preventDefault()
-
-    if (!valid) {
-      return
-    }
-
-    setError(false)
-    setInFlight(true)
-
-    // clone the project
-    cloneProject(projectId, clonedProjectName)
-      .then(data => {
-        // open the cloned project
-        openProject(data.project_id)
-      })
-      .catch(({ response, data }) => {
-        if (response?.status === 400) {
-          setError(data.message)
-        } else {
-          setError(true)
-        }
-      })
-      .finally(() => {
-        setInFlight(false)
-      })
-  }
-
   return (
-    <CloneProjectModalContent
+    <AccessibleModal
+      animation
       show={show}
-      cancel={cancel}
-      inFlight={inFlight}
-      valid={valid}
-      error={error}
-      clonedProjectName={clonedProjectName}
-      setClonedProjectName={setClonedProjectName}
-      handleSubmit={handleSubmit}
-    />
+      onHide={onHide}
+      id="clone-project-modal"
+      backdrop="static"
+    >
+      <CloneProjectModalContent
+        handleHide={onHide}
+        inFlight={inFlight}
+        setInFlight={setInFlight}
+        openProject={openProject}
+      />
+    </AccessibleModal>
   )
-}
+})
 
 CloneProjectModal.propTypes = {
   handleHide: PropTypes.func.isRequired,
-  projectId: PropTypes.string.isRequired,
-  projectName: PropTypes.string,
-  openProject: PropTypes.func.isRequired,
   show: PropTypes.bool.isRequired,
+  openProject: PropTypes.func.isRequired,
 }
 
-export default CloneProjectModal
+export default withErrorBoundary(CloneProjectModal)

+ 15 - 17
services/web/frontend/js/features/clone-project-modal/controllers/left-menu-clone-project-modal-controller.js

@@ -1,15 +1,12 @@
 import App from '../../../base'
 import { react2angular } from 'react2angular'
-
 import CloneProjectModal from '../components/clone-project-modal'
-
-App.component('cloneProjectModal', react2angular(CloneProjectModal))
+import { rootContext } from '../../../shared/context/root-context'
 
 export default App.controller(
   'LeftMenuCloneProjectModalController',
-  function ($scope, ide) {
+  function ($scope) {
     $scope.show = false
-    $scope.projectId = ide.$scope.project_id
 
     $scope.handleHide = () => {
       $scope.$applyAsync(() => {
@@ -17,21 +14,22 @@ export default App.controller(
       })
     }
 
-    $scope.openProject = projectId => {
-      window.location.assign(`/project/${projectId}`)
-    }
-
     $scope.openCloneProjectModal = () => {
       $scope.$applyAsync(() => {
-        const { project } = ide.$scope
-
-        if (project) {
-          $scope.projectId = project._id
-          $scope.projectName = project.name
-
-          $scope.show = true
-        }
+        $scope.show = true
       })
     }
+
+    $scope.openProject = projectId => {
+      window.location.assign(`/project/${projectId}`)
+    }
   }
 )
+
+App.component(
+  'cloneProjectModal',
+  react2angular(
+    rootContext.use(CloneProjectModal),
+    Object.keys(CloneProjectModal.propTypes)
+  )
+)

+ 0 - 9
services/web/frontend/js/features/clone-project-modal/utils/api.js

@@ -1,9 +0,0 @@
-import { postJSON } from '../../../infrastructure/fetch-json'
-
-export function cloneProject(projectId, cloneName) {
-  return postJSON(`/project/${projectId}/clone`, {
-    body: {
-      projectName: cloneName,
-    },
-  })
-}

+ 0 - 44
services/web/frontend/stories/clone-project-modal-content.stories.js

@@ -1,44 +0,0 @@
-import CloneProjectModalContent from '../js/features/clone-project-modal/components/clone-project-modal-content'
-
-export const Basic = args => {
-  return <CloneProjectModalContent {...args} />
-}
-
-export const Invalid = args => {
-  return (
-    <CloneProjectModalContent {...args} clonedProjectName="" valid={false} />
-  )
-}
-
-export const Inflight = args => {
-  return <CloneProjectModalContent {...args} inFlight />
-}
-
-export const GenericError = args => {
-  return <CloneProjectModalContent {...args} error />
-}
-
-export const SpecificError = args => {
-  return (
-    <CloneProjectModalContent {...args} error="There was a specific error" />
-  )
-}
-
-export default {
-  title: 'Modals / Clone Project / Content',
-  component: CloneProjectModalContent,
-  args: {
-    animation: false,
-    projectId: 'original-project',
-    clonedProjectName: 'Project Title',
-    show: true,
-    error: false,
-    inFlight: false,
-    valid: true,
-  },
-  argTypes: {
-    cancel: { action: 'cancel' },
-    handleSubmit: { action: 'submit' },
-    setClonedProjectName: { action: 'set project name' },
-  },
-}

+ 30 - 45
services/web/frontend/stories/clone-project-modal.stories.js

@@ -1,68 +1,53 @@
-import PropTypes from 'prop-types'
-
-import CloneProjectModal from '../js/features/clone-project-modal/components/clone-project-modal'
 import useFetchMock from './hooks/use-fetch-mock'
+import { withContextRoot } from './utils/with-context-root'
+import CloneProjectModal from '../js/features/clone-project-modal/components/clone-project-modal'
 
-export const Interactive = ({
-  mockResponse = 200,
-  mockResponseDelay = 500,
-  ...args
-}) => {
+const project = { _id: 'original-project', name: 'Project Title' }
+
+export const Success = args => {
   useFetchMock(fetchMock => {
     fetchMock.post(
       'express:/project/:projectId/clone',
-      () => {
-        switch (mockResponse) {
-          case 400:
-            return { status: 400, body: 'The project name is not valid' }
+      { status: 200 },
+      { delay: 250 }
+    )
+  })
+
+  return withContextRoot(<CloneProjectModal {...args} />, { project })
+}
 
-          default:
-            return mockResponse
-        }
-      },
-      { delay: mockResponseDelay }
+export const GenericErrorResponse = args => {
+  useFetchMock(fetchMock => {
+    fetchMock.post(
+      'express:/project/:projectId/clone',
+      { status: 500 },
+      { delay: 250 }
     )
   })
 
-  return <CloneProjectModal {...args} />
+  return withContextRoot(<CloneProjectModal {...args} />, { project })
 }
-Interactive.propTypes = {
-  mockResponse: PropTypes.number,
-  mockResponseDelay: PropTypes.number,
+
+export const SpecificErrorResponse = args => {
+  useFetchMock(fetchMock => {
+    fetchMock.post(
+      'express:/project/:projectId/clone',
+      { status: 400, body: 'The project name is not valid' },
+      { delay: 250 }
+    )
+  })
+
+  return withContextRoot(<CloneProjectModal {...args} />, { project })
 }
 
 export default {
   title: 'Modals / Clone Project',
   component: CloneProjectModal,
   args: {
-    projectId: 'original-project',
-    projectName: 'Project Title',
     show: true,
   },
   argTypes: {
     handleHide: { action: 'close modal' },
     openProject: { action: 'open project' },
-    mockResponse: {
-      name: 'Mock Response Status',
-      type: { name: 'number', required: false },
-      description: 'The status code that should be returned by the mock server',
-      defaultValue: 200,
-      control: {
-        type: 'radio',
-        options: [200, 500, 400],
-      },
-    },
-    mockResponseDelay: {
-      name: 'Mock Response Delay',
-      type: { name: 'number', required: false },
-      description: 'The delay before returning a response from the mock server',
-      defaultValue: 500,
-      control: {
-        type: 'range',
-        min: 0,
-        max: 2500,
-        step: 250,
-      },
-    },
   },
 }

+ 50 - 14
services/web/test/frontend/features/clone-project-modal/components/clone-project-modal.test.js

@@ -1,24 +1,36 @@
-import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { fireEvent, screen, waitFor } from '@testing-library/react'
 import { expect } from 'chai'
-import CloneProjectModal from '../../../../../frontend/js/features/clone-project-modal/components/clone-project-modal'
 import sinon from 'sinon'
 import fetchMock from 'fetch-mock'
+import CloneProjectModal from '../../../../../frontend/js/features/clone-project-modal/components/clone-project-modal'
+import { renderWithEditorContext } from '../../../helpers/render-with-context'
 
 describe('<CloneProjectModal />', function () {
-  afterEach(function () {
+  beforeEach(function () {
+    fetchMock.reset()
+  })
+
+  after(function () {
     fetchMock.reset()
   })
 
-  const modalProps = {
-    handleHide: sinon.stub(),
-    projectId: 'project-1',
-    projectName: 'Test Project',
-    openProject: sinon.stub(),
-    show: true,
+  const project = {
+    _id: 'project-1',
+    name: 'Test Project',
   }
 
   it('renders the translated modal title', async function () {
-    render(<CloneProjectModal {...modalProps} />)
+    const handleHide = sinon.stub()
+    const openProject = sinon.stub()
+
+    renderWithEditorContext(
+      <CloneProjectModal
+        handleHide={handleHide}
+        openProject={openProject}
+        show
+      />,
+      { scope: { project } }
+    )
 
     await screen.findByText('Copy Project')
   })
@@ -28,14 +40,22 @@ describe('<CloneProjectModal />', function () {
       'express:/project/:projectId/clone',
       {
         status: 200,
-        body: { project_id: modalProps.projectId },
+        body: { project_id: 'cloned-project' },
       },
       { delay: 10 }
     )
 
+    const handleHide = sinon.stub()
     const openProject = sinon.stub()
 
-    render(<CloneProjectModal {...modalProps} openProject={openProject} />)
+    renderWithEditorContext(
+      <CloneProjectModal
+        handleHide={handleHide}
+        openProject={openProject}
+        show
+      />,
+      { scope: { project } }
+    )
 
     const cancelButton = await screen.findByRole('button', { name: 'Cancel' })
     expect(cancelButton.disabled).to.be.false
@@ -88,9 +108,17 @@ describe('<CloneProjectModal />', function () {
       body: 'There was an error!',
     })
 
+    const handleHide = sinon.stub()
     const openProject = sinon.stub()
 
-    render(<CloneProjectModal {...modalProps} openProject={openProject} />)
+    renderWithEditorContext(
+      <CloneProjectModal
+        handleHide={handleHide}
+        openProject={openProject}
+        show
+      />,
+      { scope: { project } }
+    )
 
     const button = await screen.findByRole('button', { name: 'Copy' })
     expect(button.disabled).to.be.false
@@ -117,9 +145,17 @@ describe('<CloneProjectModal />', function () {
       body: 'There was an error!',
     })
 
+    const handleHide = sinon.stub()
     const openProject = sinon.stub()
 
-    render(<CloneProjectModal {...modalProps} openProject={openProject} />)
+    renderWithEditorContext(
+      <CloneProjectModal
+        handleHide={handleHide}
+        openProject={openProject}
+        show
+      />,
+      { scope: { project } }
+    )
 
     const button = await screen.findByRole('button', { name: 'Copy' })
     expect(button.disabled).to.be.false