diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json
index 8c258af5af..55b9e86e77 100644
--- a/services/web/frontend/extracted-translations.json
+++ b/services/web/frontend/extracted-translations.json
@@ -354,6 +354,7 @@
"logs_and_output_files": "",
"looks_like_youre_at": "",
"main_file_not_found": "",
+ "make_a_copy": "",
"make_email_primary_description": "",
"make_primary": "",
"make_private": "",
diff --git a/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/project-tools-more-dropdown-button.tsx b/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/project-tools-more-dropdown-button.tsx
index af0270baf0..4f1c558b31 100644
--- a/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/project-tools-more-dropdown-button.tsx
+++ b/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/project-tools-more-dropdown-button.tsx
@@ -2,6 +2,7 @@ import { memo } from 'react'
import { Dropdown } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import ControlledDropdown from '../../../../../../shared/components/controlled-dropdown'
+import CopyProjectMenuItem from '../menu-items/copy-project-menu-item'
import RenameProjectMenuItem from '../menu-items/rename-project-menu-item'
function ProjectToolsMoreDropdownButton() {
@@ -11,6 +12,7 @@ function ProjectToolsMoreDropdownButton() {
{t('more')}
+
)
diff --git a/services/web/frontend/js/features/project-list/components/table/project-tools/menu-items/copy-project-menu-item.tsx b/services/web/frontend/js/features/project-list/components/table/project-tools/menu-items/copy-project-menu-item.tsx
new file mode 100644
index 0000000000..bd4b5239c0
--- /dev/null
+++ b/services/web/frontend/js/features/project-list/components/table/project-tools/menu-items/copy-project-menu-item.tsx
@@ -0,0 +1,61 @@
+import { memo, useCallback, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { MenuItem } from 'react-bootstrap'
+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'
+import { Project } from '../../../../../../../../types/project/dashboard/api'
+
+function CopyProjectMenuItem() {
+ const {
+ addClonedProjectToViewData,
+ updateProjectViewData,
+ selectedProjects,
+ } = useProjectListContext()
+ const { t } = useTranslation()
+
+ const [showModal, setShowModal] = useState(false)
+ const isMounted = useIsMounted()
+
+ const handleOpenModal = useCallback(() => {
+ setShowModal(true)
+ }, [])
+
+ const handleCloseModal = useCallback(() => {
+ if (isMounted.current) {
+ setShowModal(false)
+ }
+ }, [isMounted])
+
+ const handleAfterCloned = (clonedProject: Project) => {
+ const project = selectedProjects[0]
+ eventTracking.send(
+ 'project-list-page-interaction',
+ 'project action',
+ 'Clone'
+ )
+ addClonedProjectToViewData(clonedProject)
+ updateProjectViewData({ ...project, selected: false })
+ setShowModal(false)
+ }
+
+ if (selectedProjects.length !== 1) return null
+
+ if (selectedProjects[0].archived || selectedProjects[0].trashed) return null
+
+ return (
+ <>
+
+
+ >
+ )
+}
+
+export default memo(CopyProjectMenuItem)
diff --git a/services/web/locales/en.json b/services/web/locales/en.json
index e5b4a8aa3b..c498de0ad0 100644
--- a/services/web/locales/en.json
+++ b/services/web/locales/en.json
@@ -1904,5 +1904,6 @@
"overleaf_labs": "Overleaf Labs",
"show_x_more": "Show __x__ more",
"show_x_more_projects": "Show __x__ more projects",
- "showing_x_out_of_n_projects": "Showing __x__ out of __n__ projects."
+ "showing_x_out_of_n_projects": "Showing __x__ out of __n__ projects.",
+ "make_a_copy": "Make a copy"
}
diff --git a/services/web/test/frontend/features/project-list/components/project-list-root.test.tsx b/services/web/test/frontend/features/project-list/components/project-list-root.test.tsx
index e9432793fe..0ee1532531 100644
--- a/services/web/test/frontend/features/project-list/components/project-list-root.test.tsx
+++ b/services/web/test/frontend/features/project-list/components/project-list-root.test.tsx
@@ -341,7 +341,7 @@ describe('', function () {
.null
})
- it('opens the rename modal, validates name, and can rename the project', async function () {
+ it('opens the rename modal, and can rename the project, and view updated', async function () {
fetchMock.post(`express:/project/:id/rename`, {
status: 200,
})
@@ -396,6 +396,54 @@ describe('', function () {
const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
expect(allCheckboxesChecked.length).to.equal(0)
})
+
+ it('opens the copy modal, can copy the project, and view updated', async function () {
+ const tableRows = screen.getAllByRole('row')
+ const linkForProjectToCopy = within(tableRows[1]).getByRole('link')
+ const projectNameToCopy = linkForProjectToCopy.textContent || '' // needed for type checking
+ screen.findByText(projectNameToCopy) // make sure not just empty string
+ const copiedProjectName = `${projectNameToCopy} (Copy)`
+ fetchMock.post(`express:/project/:id/clone`, {
+ status: 200,
+ body: {
+ name: copiedProjectName,
+ lastUpdated: new Date(),
+ project_id: userId,
+ owner_ref: userId,
+ owner,
+ id: '6328e14abec0df019fce0be5',
+ lastUpdatedBy: owner,
+ accessLevel: 'owner',
+ source: 'owner',
+ trashed: false,
+ archived: false,
+ },
+ })
+
+ await waitFor(() => {
+ const moreDropdown =
+ within(actionsToolbar).getByText('More')
+ fireEvent.click(moreDropdown)
+ })
+
+ const copyButton =
+ within(actionsToolbar).getByText('Make a copy')
+ fireEvent.click(copyButton)
+
+ // confirm in modal
+ const copyConfirmButton = document.querySelector(
+ 'button[type="submit"]'
+ ) as HTMLElement
+ fireEvent.click(copyConfirmButton)
+
+ await fetchMock.flush(true)
+ expect(fetchMock.done()).to.be.true
+
+ expect(sendSpy).to.be.calledOnce
+ expect(sendSpy).calledWith('project-list-page-interaction')
+
+ screen.findByText(copiedProjectName)
+ })
})
})
@@ -443,7 +491,6 @@ describe('', function () {
fireEvent.click(copyButton)
// confirm in modal
- // const copyConfirmButton = screen.getByText('Copy')
const copyConfirmButton = document.querySelector(
'button[type="submit"]'
) as HTMLElement
@@ -452,6 +499,9 @@ describe('', function () {
await fetchMock.flush(true)
expect(fetchMock.done()).to.be.true
+ expect(sendSpy).to.be.calledOnce
+ expect(sendSpy).calledWith('project-list-page-interaction')
+
expect(screen.queryByText(copiedProjectName)).to.be.null
const yourProjectFilter = screen.getAllByText('Your Projects')[0]