Răsfoiți Sursa

Merge pull request #9586 from overleaf/jel-dash-copy-project

[web] Dash copy project modal

GitOrigin-RevId: 965a4ff74cb623955933cb266fb5f51d5e728986
Davinder Singh 3 ani în urmă
părinte
comite
3da4ff169b

+ 1 - 0
services/web/app/src/Features/Project/ProjectController.js

@@ -277,6 +277,7 @@ const ProjectController = {
         }
         }
         res.json({
         res.json({
           name: project.name,
           name: project.name,
+          lastUpdated: project.lastUpdated,
           project_id: project._id,
           project_id: project._id,
           owner_ref: project.owner_ref,
           owner_ref: project.owner_ref,
           owner: {
           owner: {

+ 55 - 11
services/web/frontend/js/features/project-list/components/table/cells/action-buttons/copy-project-button.tsx

@@ -1,30 +1,74 @@
 import { useTranslation } from 'react-i18next'
 import { useTranslation } from 'react-i18next'
-import { memo } from 'react'
+import { memo, useCallback, useState } from 'react'
 import { Project } from '../../../../../../../../types/project/dashboard/api'
 import { Project } from '../../../../../../../../types/project/dashboard/api'
 import Icon from '../../../../../../shared/components/icon'
 import Icon from '../../../../../../shared/components/icon'
 import Tooltip from '../../../../../../shared/components/tooltip'
 import Tooltip from '../../../../../../shared/components/tooltip'
+import CloneProjectModal from '../../../../../clone-project-modal/components/clone-project-modal'
+import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
+import { useProjectListContext } from '../../../../context/project-list-context'
+import * as eventTracking from '../../../../../../infrastructure/event-tracking'
 
 
 type CopyButtonProps = {
 type CopyButtonProps = {
   project: Project
   project: Project
 }
 }
 
 
 function CopyProjectButton({ project }: CopyButtonProps) {
 function CopyProjectButton({ project }: CopyButtonProps) {
+  const { addClonedProjectToViewData } = useProjectListContext()
   const { t } = useTranslation()
   const { t } = useTranslation()
   const text = t('copy')
   const text = t('copy')
+  const [showModal, setShowModal] = useState(false)
+  const isMounted = useIsMounted()
+
+  const handleOpenModal = useCallback(() => {
+    setShowModal(true)
+  }, [])
+
+  const handleCloseModal = useCallback(() => {
+    if (isMounted.current) {
+      setShowModal(false)
+    }
+  }, [isMounted])
+
+  const handleAfterCloned = useCallback(
+    project => {
+      eventTracking.send(
+        'project-list-page-interaction',
+        'project action',
+        'Clone'
+      )
+      addClonedProjectToViewData(project)
+      setShowModal(false)
+    },
+    [addClonedProjectToViewData]
+  )
 
 
   if (project.archived || project.trashed) return null
   if (project.archived || project.trashed) return null
 
 
   return (
   return (
-    <Tooltip
-      key={`tooltip-copy-project-${project.id}`}
-      id={`tooltip-copy-project-${project.id}`}
-      description={text}
-      overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
-    >
-      <button className="btn btn-link action-btn" aria-label={text}>
-        <Icon type="files-o" />
-      </button>
-    </Tooltip>
+    <>
+      <Tooltip
+        key={`tooltip-copy-project-${project.id}`}
+        id={`tooltip-copy-project-${project.id}`}
+        description={text}
+        overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
+      >
+        <button
+          className="btn btn-link action-btn"
+          aria-label={text}
+          onClick={handleOpenModal}
+        >
+          <Icon type="files-o" />
+        </button>
+      </Tooltip>
+
+      <CloneProjectModal
+        show={showModal}
+        handleHide={handleCloseModal}
+        handleAfterCloned={handleAfterCloned}
+        projectId={project.id}
+        projectName={project.name}
+      />
+    </>
   )
   )
 }
 }
 
 

+ 26 - 1
services/web/frontend/js/features/project-list/context/project-list-context.tsx

@@ -50,6 +50,7 @@ const filters: FilterMap = {
 export const UNCATEGORIZED_KEY = 'uncategorized'
 export const UNCATEGORIZED_KEY = 'uncategorized'
 
 
 type ProjectListContextValue = {
 type ProjectListContextValue = {
+  addClonedProjectToViewData: (project: Project) => void
   visibleProjects: Project[]
   visibleProjects: Project[]
   setVisibleProjects: React.Dispatch<React.SetStateAction<Project[]>>
   setVisibleProjects: React.Dispatch<React.SetStateAction<Project[]>>
   totalProjectsCount: number
   totalProjectsCount: number
@@ -153,7 +154,6 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
     } else {
     } else {
       filteredProjects = _.filter(filteredProjects, filters[filter])
       filteredProjects = _.filter(filteredProjects, filters[filter])
     }
     }
-
     setVisibleProjects(filteredProjects)
     setVisibleProjects(filteredProjects)
   }, [
   }, [
     loadedProjects,
     loadedProjects,
@@ -212,6 +212,29 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
     [setTags]
     [setTags]
   )
   )
 
 
+  const addClonedProjectToViewData = useCallback(
+    project => {
+      // clone API not using camelCase and does not return all data
+      project.id = project.project_id
+      const owner = {
+        id: project.owner?._id,
+        email: project.owner?.email,
+        firstName: project.owner?.first_name,
+        lastName: project.owner?.last_name,
+      }
+      project.owner = owner
+      project.lastUpdatedBy = project.owner
+      project.source = 'owner'
+      project.trashed = false
+      project.archived = false
+      const projects = [...loadedProjects]
+      projects.push(project)
+      setLoadedProjects(projects)
+      // to do: sort projects after loaded projects updated, otherwise, it's at bottom of list
+    },
+    [loadedProjects, setLoadedProjects]
+  )
+
   const updateProjectViewData = useCallback(
   const updateProjectViewData = useCallback(
     (project: Project) => {
     (project: Project) => {
       const projects = loadedProjects.map((p: Project) => {
       const projects = loadedProjects.map((p: Project) => {
@@ -238,6 +261,7 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
   const value = useMemo<ProjectListContextValue>(
   const value = useMemo<ProjectListContextValue>(
     () => ({
     () => ({
       addTag,
       addTag,
+      addClonedProjectToViewData,
       deleteTag,
       deleteTag,
       error,
       error,
       filter,
       filter,
@@ -260,6 +284,7 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
     }),
     }),
     [
     [
       addTag,
       addTag,
+      addClonedProjectToViewData,
       deleteTag,
       deleteTag,
       error,
       error,
       filter,
       filter,

+ 46 - 4
services/web/test/frontend/features/project-list/components/table/cells/action-buttons/copy-project-button.test.tsx

@@ -1,27 +1,69 @@
 import { expect } from 'chai'
 import { expect } from 'chai'
-import { fireEvent, render, screen } from '@testing-library/react'
+import { fireEvent, screen } from '@testing-library/react'
 import CopyProjectButton from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/copy-project-button'
 import CopyProjectButton from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/copy-project-button'
 import {
 import {
   archivedProject,
   archivedProject,
   copyableProject,
   copyableProject,
   trashedProject,
   trashedProject,
 } from '../../../../fixtures/projects-data'
 } from '../../../../fixtures/projects-data'
+import {
+  renderWithProjectListContext,
+  resetProjectListContextFetch,
+} from '../../../../helpers/render-with-context'
+import fetchMock from 'fetch-mock'
 
 
 describe('<CopyProjectButton />', function () {
 describe('<CopyProjectButton />', function () {
+  afterEach(function () {
+    resetProjectListContextFetch()
+  })
   it('renders tooltip for button', function () {
   it('renders tooltip for button', function () {
-    render(<CopyProjectButton project={copyableProject} />)
+    renderWithProjectListContext(
+      <CopyProjectButton project={copyableProject} />
+    )
     const btn = screen.getByLabelText('Copy')
     const btn = screen.getByLabelText('Copy')
     fireEvent.mouseOver(btn)
     fireEvent.mouseOver(btn)
     screen.getByRole('tooltip', { name: 'Copy' })
     screen.getByRole('tooltip', { name: 'Copy' })
   })
   })
 
 
   it('does not render the button when project is archived', function () {
   it('does not render the button when project is archived', function () {
-    render(<CopyProjectButton project={archivedProject} />)
+    renderWithProjectListContext(
+      <CopyProjectButton project={archivedProject} />
+    )
     expect(screen.queryByLabelText('Copy')).to.be.null
     expect(screen.queryByLabelText('Copy')).to.be.null
   })
   })
 
 
   it('does not render the button when project is trashed', function () {
   it('does not render the button when project is trashed', function () {
-    render(<CopyProjectButton project={trashedProject} />)
+    renderWithProjectListContext(<CopyProjectButton project={trashedProject} />)
     expect(screen.queryByLabelText('Copy')).to.be.null
     expect(screen.queryByLabelText('Copy')).to.be.null
   })
   })
+
+  it('opens the modal and copies the project ', async function () {
+    fetchMock.post(
+      `express:/project/${copyableProject.id}/clone`,
+      {
+        status: 200,
+      },
+      { delay: 0 }
+    )
+    renderWithProjectListContext(
+      <CopyProjectButton project={copyableProject} />
+    )
+    const btn = screen.getByLabelText('Copy')
+    fireEvent.click(btn)
+    screen.getByText('Copy Project')
+    screen.getByLabelText('New Name')
+    screen.getByDisplayValue(`${copyableProject.name} (Copy)`)
+    const copyBtn = screen.getByText('Copy') as HTMLButtonElement
+    fireEvent.click(copyBtn)
+    expect(copyBtn.disabled).to.be.true
+    // verify cloned
+    await fetchMock.flush(true)
+    expect(fetchMock.done()).to.be.true
+    const requests = fetchMock.calls()
+    // first mock call is to get list of projects in projectlistcontext
+    const [requestUrl, requestHeaders] = requests[1]
+    expect(requestUrl).to.equal(`/project/${copyableProject.id}/clone`)
+    expect(requestHeaders?.method).to.equal('POST')
+    fetchMock.reset()
+  })
 })
 })