mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-23 17:19:37 +02:00
Merge pull request #9586 from overleaf/jel-dash-copy-project
[web] Dash copy project modal GitOrigin-RevId: 965a4ff74cb623955933cb266fb5f51d5e728986
This commit is contained in:
@@ -277,6 +277,7 @@ const ProjectController = {
|
||||
}
|
||||
res.json({
|
||||
name: project.name,
|
||||
lastUpdated: project.lastUpdated,
|
||||
project_id: project._id,
|
||||
owner_ref: project.owner_ref,
|
||||
owner: {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user