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

[web] Dash copy project modal

GitOrigin-RevId: 965a4ff74cb623955933cb266fb5f51d5e728986
This commit is contained in:
Davinder Singh
2022-09-15 10:59:24 +01:00
committed by Copybot
parent 9cde88a9e8
commit 3da4ff169b
4 changed files with 128 additions and 16 deletions

View File

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

View File

@@ -1,30 +1,74 @@
import { useTranslation } from 'react-i18next'
import { memo } from 'react'
import { memo, useCallback, useState } from 'react'
import { Project } from '../../../../../../../../types/project/dashboard/api'
import Icon from '../../../../../../shared/components/icon'
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 = {
project: Project
}
function CopyProjectButton({ project }: CopyButtonProps) {
const { addClonedProjectToViewData } = useProjectListContext()
const { t } = useTranslation()
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
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}
/>
</>
)
}

View File

@@ -50,6 +50,7 @@ const filters: FilterMap = {
export const UNCATEGORIZED_KEY = 'uncategorized'
type ProjectListContextValue = {
addClonedProjectToViewData: (project: Project) => void
visibleProjects: Project[]
setVisibleProjects: React.Dispatch<React.SetStateAction<Project[]>>
totalProjectsCount: number
@@ -153,7 +154,6 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
} else {
filteredProjects = _.filter(filteredProjects, filters[filter])
}
setVisibleProjects(filteredProjects)
}, [
loadedProjects,
@@ -212,6 +212,29 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
[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(
(project: Project) => {
const projects = loadedProjects.map((p: Project) => {
@@ -238,6 +261,7 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
const value = useMemo<ProjectListContextValue>(
() => ({
addTag,
addClonedProjectToViewData,
deleteTag,
error,
filter,
@@ -260,6 +284,7 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
}),
[
addTag,
addClonedProjectToViewData,
deleteTag,
error,
filter,

View File

@@ -1,27 +1,69 @@
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 {
archivedProject,
copyableProject,
trashedProject,
} from '../../../../fixtures/projects-data'
import {
renderWithProjectListContext,
resetProjectListContextFetch,
} from '../../../../helpers/render-with-context'
import fetchMock from 'fetch-mock'
describe('<CopyProjectButton />', function () {
afterEach(function () {
resetProjectListContextFetch()
})
it('renders tooltip for button', function () {
render(<CopyProjectButton project={copyableProject} />)
renderWithProjectListContext(
<CopyProjectButton project={copyableProject} />
)
const btn = screen.getByLabelText('Copy')
fireEvent.mouseOver(btn)
screen.getByRole('tooltip', { name: 'Copy' })
})
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
})
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
})
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()
})
})