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() + }) })