diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js
index 5978072fe5..435e6b1bd5 100644
--- a/services/web/app/src/Features/Project/ProjectController.js
+++ b/services/web/app/src/Features/Project/ProjectController.js
@@ -277,6 +277,7 @@ const ProjectController = {
}
res.json({
name: project.name,
+ lastUpdated: project.lastUpdated,
project_id: project._id,
owner_ref: project.owner_ref,
owner: {
diff --git a/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/copy-project-button.tsx b/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/copy-project-button.tsx
index 322cc9ee8f..9780f1be04 100644
--- a/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/copy-project-button.tsx
+++ b/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 { 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 (
-
-
-
+ <>
+
+
+
+
+
+ >
)
}
diff --git a/services/web/frontend/js/features/project-list/context/project-list-context.tsx b/services/web/frontend/js/features/project-list/context/project-list-context.tsx
index fbd9bebbea..82e9f3ce03 100644
--- a/services/web/frontend/js/features/project-list/context/project-list-context.tsx
+++ b/services/web/frontend/js/features/project-list/context/project-list-context.tsx
@@ -50,6 +50,7 @@ const filters: FilterMap = {
export const UNCATEGORIZED_KEY = 'uncategorized'
type ProjectListContextValue = {
+ addClonedProjectToViewData: (project: Project) => void
visibleProjects: Project[]
setVisibleProjects: React.Dispatch>
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(
() => ({
addTag,
+ addClonedProjectToViewData,
deleteTag,
error,
filter,
@@ -260,6 +284,7 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
}),
[
addTag,
+ addClonedProjectToViewData,
deleteTag,
error,
filter,
diff --git a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/copy-project-button.test.tsx b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/copy-project-button.test.tsx
index cec874c974..20704adb9a 100644
--- a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/copy-project-button.test.tsx
+++ b/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 { 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('', function () {
+ afterEach(function () {
+ resetProjectListContextFetch()
+ })
it('renders tooltip for button', function () {
- render()
+ renderWithProjectListContext(
+
+ )
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()
+ renderWithProjectListContext(
+
+ )
expect(screen.queryByLabelText('Copy')).to.be.null
})
it('does not render the button when project is trashed', function () {
- render()
+ renderWithProjectListContext()
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(
+
+ )
+ 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()
+ })
})