Просмотр исходного кода

Copy tags when cloning a project (#14987)

GitOrigin-RevId: 4cdca0ef2f26bf6bba02b675b0ef02ba8da881e2
Alf Eaton 3 лет назад
Родитель
Сommit
15475cdb3c
21 измененных файлов с 315 добавлено и 21 удалено
  1. 11 1
      services/web/app/src/Features/Project/ProjectController.js
  2. 19 1
      services/web/app/src/Features/Project/ProjectDuplicator.js
  3. 20 0
      services/web/app/src/Features/Tags/TagsHandler.js
  4. 1 0
      services/web/app/views/project/editor/meta.pug
  5. 38 3
      services/web/frontend/js/features/clone-project-modal/components/clone-project-modal-content.js
  6. 9 0
      services/web/frontend/js/features/clone-project-modal/components/clone-project-modal.js
  7. 31 0
      services/web/frontend/js/features/clone-project-modal/components/clone-project-tag.tsx
  8. 6 1
      services/web/frontend/js/features/clone-project-modal/components/editor-clone-project-modal-wrapper.js
  9. 22 5
      services/web/frontend/js/features/project-list/components/table/cells/action-buttons/copy-project-button.tsx
  10. 10 3
      services/web/frontend/js/features/project-list/components/table/project-tools/menu-items/copy-project-menu-item.tsx
  11. 4 3
      services/web/frontend/js/features/project-list/context/project-list-context.tsx
  12. 11 0
      services/web/frontend/js/features/project-list/hooks/use-project-tags.tsx
  13. 18 0
      services/web/frontend/js/shared/context/project-context.js
  14. 26 1
      services/web/frontend/stories/project-list/project-list.stories.tsx
  15. 10 1
      services/web/frontend/stylesheets/app/project-list-react.less
  16. 1 0
      services/web/test/frontend/features/clone-project-modal/components/clone-project-modal.test.js
  17. 18 0
      services/web/test/frontend/features/project-list/fixtures/tags-data.ts
  18. 10 2
      services/web/test/unit/src/Project/ProjectControllerTests.js
  19. 9 0
      services/web/test/unit/src/Project/ProjectDuplicatorTests.js
  20. 29 0
      services/web/test/unit/src/Tags/TagsHandlerTests.js
  21. 12 0
      services/web/types/project/dashboard/api.d.ts

+ 11 - 1
services/web/app/src/Features/Project/ProjectController.js

@@ -37,6 +37,7 @@ const { hasAdminAccess } = require('../Helpers/AdminAuthorizationHelper')
 const InstitutionsFeatures = require('../Institutions/InstitutionsFeatures')
 const InstitutionsFeatures = require('../Institutions/InstitutionsFeatures')
 const ProjectAuditLogHandler = require('./ProjectAuditLogHandler')
 const ProjectAuditLogHandler = require('./ProjectAuditLogHandler')
 const PublicAccessLevels = require('../Authorization/PublicAccessLevels')
 const PublicAccessLevels = require('../Authorization/PublicAccessLevels')
+const TagsHandler = require('../Tags/TagsHandler')
 
 
 /**
 /**
  * @typedef {import("./types").GetProjectsRequest} GetProjectsRequest
  * @typedef {import("./types").GetProjectsRequest} GetProjectsRequest
@@ -253,7 +254,7 @@ const ProjectController = {
     res.setTimeout(5 * 60 * 1000) // allow extra time for the copy to complete
     res.setTimeout(5 * 60 * 1000) // allow extra time for the copy to complete
     metrics.inc('cloned-project')
     metrics.inc('cloned-project')
     const projectId = req.params.Project_id
     const projectId = req.params.Project_id
-    const { projectName } = req.body
+    const { projectName, tags } = req.body
     logger.debug({ projectId, projectName }, 'cloning project')
     logger.debug({ projectId, projectName }, 'cloning project')
     if (!SessionManager.isUserLoggedIn(req.session)) {
     if (!SessionManager.isUserLoggedIn(req.session)) {
       return res.json({ redir: '/register' })
       return res.json({ redir: '/register' })
@@ -264,6 +265,7 @@ const ProjectController = {
       currentUser,
       currentUser,
       projectId,
       projectId,
       projectName,
       projectName,
+      tags,
       (err, project) => {
       (err, project) => {
         if (err != null) {
         if (err != null) {
           OError.tag(err, 'error cloning project', {
           OError.tag(err, 'error cloning project', {
@@ -739,6 +741,12 @@ const ProjectController = {
             }
             }
           )
           )
         },
         },
+        projectTags(cb) {
+          if (!userId) {
+            return cb(null, [])
+          }
+          TagsHandler.getTagsForProject(userId, projectId, cb)
+        },
       },
       },
       (
       (
         err,
         err,
@@ -757,6 +765,7 @@ const ProjectController = {
           sourceEditorToolbarAssigment,
           sourceEditorToolbarAssigment,
           historyViewAssignment,
           historyViewAssignment,
           reviewPanelAssignment,
           reviewPanelAssignment,
+          projectTags,
         }
         }
       ) => {
       ) => {
         if (err != null) {
         if (err != null) {
@@ -944,6 +953,7 @@ const ProjectController = {
               isReviewPanelReact: reviewPanelAssignment.variant === 'react',
               isReviewPanelReact: reviewPanelAssignment.variant === 'react',
               showPersonalAccessToken,
               showPersonalAccessToken,
               hasTrackChangesFeature: Features.hasFeature('track-changes'),
               hasTrackChangesFeature: Features.hasFeature('track-changes'),
+              projectTags,
             })
             })
             timer.done()
             timer.done()
           }
           }

+ 19 - 1
services/web/app/src/Features/Project/ProjectDuplicator.js

@@ -17,6 +17,7 @@ const ProjectOptionsHandler = require('./ProjectOptionsHandler')
 const SafePath = require('./SafePath')
 const SafePath = require('./SafePath')
 const TpdsProjectFlusher = require('../ThirdPartyDataStore/TpdsProjectFlusher')
 const TpdsProjectFlusher = require('../ThirdPartyDataStore/TpdsProjectFlusher')
 const _ = require('lodash')
 const _ = require('lodash')
+const TagsHandler = require('../Tags/TagsHandler')
 
 
 module.exports = {
 module.exports = {
   duplicate: callbackify(duplicate),
   duplicate: callbackify(duplicate),
@@ -25,7 +26,7 @@ module.exports = {
   },
   },
 }
 }
 
 
-async function duplicate(owner, originalProjectId, newProjectName) {
+async function duplicate(owner, originalProjectId, newProjectName, tags = []) {
   await DocumentUpdaterHandler.promises.flushProjectToMongo(originalProjectId)
   await DocumentUpdaterHandler.promises.flushProjectToMongo(originalProjectId)
   const originalProject = await ProjectGetter.promises.getProject(
   const originalProject = await ProjectGetter.promises.getProject(
     originalProjectId,
     originalProjectId,
@@ -50,6 +51,14 @@ async function duplicate(owner, originalProjectId, newProjectName) {
   ])
   ])
   segmentation.duplicatedFromProject = originalProjectId
   segmentation.duplicatedFromProject = originalProjectId
 
 
+  // count the number of tags before and after, for analytics
+  segmentation['original-tags'] =
+    await TagsHandler.promises.countTagsForProject(
+      owner._id,
+      originalProject._id
+    )
+  segmentation['updated-tags'] = tags.length
+
   // remove any leading or trailing spaces
   // remove any leading or trailing spaces
   newProjectName = newProjectName.trim()
   newProjectName = newProjectName.trim()
 
 
@@ -88,6 +97,14 @@ async function duplicate(owner, originalProjectId, newProjectName) {
       newProject: { version: projectVersion },
       newProject: { version: projectVersion },
     })
     })
     await TpdsProjectFlusher.promises.flushProjectToTpds(newProject._id)
     await TpdsProjectFlusher.promises.flushProjectToTpds(newProject._id)
+
+    if (tags?.length > 0) {
+      await TagsHandler.promises.addProjectToTags(
+        owner._id,
+        tags.map(tag => tag.id),
+        newProject._id
+      )
+    }
   } catch (err) {
   } catch (err) {
     // Clean up broken clone on error.
     // Clean up broken clone on error.
     // Make sure we delete the new failed project, not the original one!
     // Make sure we delete the new failed project, not the original one!
@@ -98,6 +115,7 @@ async function duplicate(owner, originalProjectId, newProjectName) {
       newProjectId: newProject._id,
       newProjectId: newProject._id,
     })
     })
   }
   }
+
   return newProject
   return newProject
 }
 }
 
 

+ 20 - 0
services/web/app/src/Features/Tags/TagsHandler.js

@@ -7,6 +7,14 @@ async function getAllTags(userId) {
   return Tag.find({ user_id: userId })
   return Tag.find({ user_id: userId })
 }
 }
 
 
+async function countTagsForProject(userId, projectId) {
+  return Tag.count({ user_id: userId, project_ids: projectId })
+}
+
+async function getTagsForProject(userId, projectId) {
+  return Tag.find({ user_id: userId, project_ids: projectId }, '-project_ids')
+}
+
 async function createTag(userId, name, color, options = {}) {
 async function createTag(userId, name, color, options = {}) {
   if (name.length > MAX_TAG_LENGTH) {
   if (name.length > MAX_TAG_LENGTH) {
     if (options.truncate) {
     if (options.truncate) {
@@ -119,26 +127,38 @@ async function removeProjectFromAllTags(userId, projectId) {
   await Tag.updateMany(searchOps, deleteOperation)
   await Tag.updateMany(searchOps, deleteOperation)
 }
 }
 
 
+async function addProjectToTags(userId, tagIds, projectId) {
+  const searchOps = { user_id: userId, _id: { $in: tagIds } }
+  const insertOperation = { $addToSet: { project_ids: projectId } }
+  await Tag.updateMany(searchOps, insertOperation)
+}
+
 module.exports = {
 module.exports = {
   getAllTags: callbackify(getAllTags),
   getAllTags: callbackify(getAllTags),
+  countTagsForProject: callbackify(countTagsForProject),
+  getTagsForProject: callbackify(getTagsForProject),
   createTag: callbackify(createTag),
   createTag: callbackify(createTag),
   renameTag: callbackify(renameTag),
   renameTag: callbackify(renameTag),
   editTag: callbackify(editTag),
   editTag: callbackify(editTag),
   deleteTag: callbackify(deleteTag),
   deleteTag: callbackify(deleteTag),
   addProjectToTag: callbackify(addProjectToTag),
   addProjectToTag: callbackify(addProjectToTag),
   addProjectsToTag: callbackify(addProjectsToTag),
   addProjectsToTag: callbackify(addProjectsToTag),
+  addProjectToTags: callbackify(addProjectToTags),
   removeProjectFromTag: callbackify(removeProjectFromTag),
   removeProjectFromTag: callbackify(removeProjectFromTag),
   removeProjectsFromTag: callbackify(removeProjectsFromTag),
   removeProjectsFromTag: callbackify(removeProjectsFromTag),
   addProjectToTagName: callbackify(addProjectToTagName),
   addProjectToTagName: callbackify(addProjectToTagName),
   removeProjectFromAllTags: callbackify(removeProjectFromAllTags),
   removeProjectFromAllTags: callbackify(removeProjectFromAllTags),
   promises: {
   promises: {
     getAllTags,
     getAllTags,
+    countTagsForProject,
+    getTagsForProject,
     createTag,
     createTag,
     renameTag,
     renameTag,
     editTag,
     editTag,
     deleteTag,
     deleteTag,
     addProjectToTag,
     addProjectToTag,
     addProjectsToTag,
     addProjectsToTag,
+    addProjectToTags,
     removeProjectFromTag,
     removeProjectFromTag,
     removeProjectsFromTag,
     removeProjectsFromTag,
     addProjectToTagName,
     addProjectToTagName,

+ 1 - 0
services/web/app/views/project/editor/meta.pug

@@ -40,6 +40,7 @@ meta(name="ol-isReviewPanelReact", data-type="boolean" content=isReviewPanelReac
 meta(name="ol-hasTrackChangesFeature", data-type="boolean" content=hasTrackChangesFeature)
 meta(name="ol-hasTrackChangesFeature", data-type="boolean" content=hasTrackChangesFeature)
 meta(name="ol-mathJax3Path" content=mathJax3Path)
 meta(name="ol-mathJax3Path" content=mathJax3Path)
 meta(name="ol-completedTutorials", data-type="json" content=user.completedTutorials)
 meta(name="ol-completedTutorials", data-type="json" content=user.completedTutorials)
+meta(name="ol-projectTags" data-type="json" content=projectTags)
 
 
 - var fileActionI18n = ['edited', 'renamed', 'created', 'deleted'].reduce((acc, i) => {acc[i] = translate('file_action_' + i); return acc}, {})
 - var fileActionI18n = ['edited', 'renamed', 'created', 'deleted'].reduce((acc, i) => {acc[i] = translate('file_action_' + i); return acc}, {})
 meta(name="ol-fileActionI18n" data-type="json" content=fileActionI18n)
 meta(name="ol-fileActionI18n" data-type="json" content=fileActionI18n)

+ 38 - 3
services/web/frontend/js/features/clone-project-modal/components/clone-project-modal-content.js

@@ -1,6 +1,6 @@
 /* eslint-disable jsx-a11y/no-autofocus */
 /* eslint-disable jsx-a11y/no-autofocus */
 import PropTypes from 'prop-types'
 import PropTypes from 'prop-types'
-import { useMemo, useState } from 'react'
+import { useCallback, useMemo, useState } from 'react'
 import { useTranslation } from 'react-i18next'
 import { useTranslation } from 'react-i18next'
 import {
 import {
   Modal,
   Modal,
@@ -11,6 +11,7 @@ import {
   FormGroup,
   FormGroup,
 } from 'react-bootstrap'
 } from 'react-bootstrap'
 import { postJSON } from '../../../infrastructure/fetch-json'
 import { postJSON } from '../../../infrastructure/fetch-json'
+import { CloneProjectTag } from './clone-project-tag'
 
 
 export default function CloneProjectModalContent({
 export default function CloneProjectModalContent({
   handleHide,
   handleHide,
@@ -19,6 +20,7 @@ export default function CloneProjectModalContent({
   handleAfterCloned,
   handleAfterCloned,
   projectId,
   projectId,
   projectName,
   projectName,
+  projectTags,
 }) {
 }) {
   const { t } = useTranslation()
   const { t } = useTranslation()
 
 
@@ -27,6 +29,8 @@ export default function CloneProjectModalContent({
     `${projectName} (Copy)`
     `${projectName} (Copy)`
   )
   )
 
 
+  const [clonedProjectTags, setClonedProjectTags] = useState(projectTags)
+
   // valid if the cloned project has a name
   // valid if the cloned project has a name
   const valid = useMemo(
   const valid = useMemo(
     () => clonedProjectName.trim().length > 0,
     () => clonedProjectName.trim().length > 0,
@@ -46,11 +50,14 @@ export default function CloneProjectModalContent({
 
 
     // clone the project
     // clone the project
     postJSON(`/project/${projectId}/clone`, {
     postJSON(`/project/${projectId}/clone`, {
-      body: { projectName: clonedProjectName },
+      body: {
+        projectName: clonedProjectName,
+        tags: clonedProjectTags.map(tag => ({ id: tag._id })),
+      },
     })
     })
       .then(data => {
       .then(data => {
         // open the cloned project
         // open the cloned project
-        handleAfterCloned(data)
+        handleAfterCloned(data, clonedProjectTags)
       })
       })
       .catch(({ response, data }) => {
       .catch(({ response, data }) => {
         if (response?.status === 400) {
         if (response?.status === 400) {
@@ -64,6 +71,10 @@ export default function CloneProjectModalContent({
       })
       })
   }
   }
 
 
+  const removeTag = useCallback(tag => {
+    setClonedProjectTags(value => value.filter(item => item._id !== tag._id))
+  }, [])
+
   return (
   return (
     <>
     <>
       <Modal.Header closeButton>
       <Modal.Header closeButton>
@@ -87,6 +98,23 @@ export default function CloneProjectModalContent({
               autoFocus
               autoFocus
             />
             />
           </FormGroup>
           </FormGroup>
+
+          {clonedProjectTags.length > 0 && (
+            <FormGroup className="clone-project-tag">
+              <ControlLabel htmlFor="clone-project-tags-list">
+                {t('tags')}:{' '}
+              </ControlLabel>
+              <div role="listbox" id="clone-project-tags-list">
+                {clonedProjectTags.map(tag => (
+                  <CloneProjectTag
+                    key={tag._id}
+                    tag={tag}
+                    removeTag={removeTag}
+                  />
+                ))}
+              </div>
+            </FormGroup>
+          )}
         </form>
         </form>
 
 
         {error && (
         {error && (
@@ -126,4 +154,11 @@ CloneProjectModalContent.propTypes = {
   handleAfterCloned: PropTypes.func.isRequired,
   handleAfterCloned: PropTypes.func.isRequired,
   projectId: PropTypes.string,
   projectId: PropTypes.string,
   projectName: PropTypes.string,
   projectName: PropTypes.string,
+  projectTags: PropTypes.arrayOf(
+    PropTypes.shape({
+      _id: PropTypes.string.isRequired,
+      name: PropTypes.string.isRequired,
+      color: PropTypes.string,
+    })
+  ),
 }
 }

+ 9 - 0
services/web/frontend/js/features/clone-project-modal/components/clone-project-modal.js

@@ -9,6 +9,7 @@ function CloneProjectModal({
   handleAfterCloned,
   handleAfterCloned,
   projectId,
   projectId,
   projectName,
   projectName,
+  projectTags,
 }) {
 }) {
   const [inFlight, setInFlight] = useState(false)
   const [inFlight, setInFlight] = useState(false)
 
 
@@ -35,6 +36,7 @@ function CloneProjectModal({
         handleAfterCloned={handleAfterCloned}
         handleAfterCloned={handleAfterCloned}
         projectId={projectId}
         projectId={projectId}
         projectName={projectName}
         projectName={projectName}
+        projectTags={projectTags}
       />
       />
     </AccessibleModal>
     </AccessibleModal>
   )
   )
@@ -46,6 +48,13 @@ CloneProjectModal.propTypes = {
   handleAfterCloned: PropTypes.func.isRequired,
   handleAfterCloned: PropTypes.func.isRequired,
   projectId: PropTypes.string,
   projectId: PropTypes.string,
   projectName: PropTypes.string,
   projectName: PropTypes.string,
+  projectTags: PropTypes.arrayOf(
+    PropTypes.shape({
+      _id: PropTypes.string.isRequired,
+      name: PropTypes.string.isRequired,
+      color: PropTypes.string,
+    })
+  ),
 }
 }
 
 
 export default memo(CloneProjectModal)
 export default memo(CloneProjectModal)

+ 31 - 0
services/web/frontend/js/features/clone-project-modal/components/clone-project-tag.tsx

@@ -0,0 +1,31 @@
+import { FC } from 'react'
+import { Tag } from '../../../../../app/src/Features/Tags/types'
+import { getTagColor } from '@/features/project-list/util/tag'
+import Icon from '@/shared/components/icon'
+import { useTranslation } from 'react-i18next'
+
+export const CloneProjectTag: FC<{
+  tag: Tag
+  removeTag: (tag: Tag) => void
+}> = ({ tag, removeTag }) => {
+  const { t } = useTranslation()
+
+  return (
+    <div className="tag-label" role="option" aria-selected>
+      <span className="label label-default tag-label-name">
+        <span style={{ color: getTagColor(tag) }}>
+          <Icon type="circle" aria-hidden />
+        </span>{' '}
+        {tag.name}
+      </span>
+      <button
+        type="button"
+        className="label label-default tag-label-remove"
+        onClick={() => removeTag(tag)}
+        aria-label={t('remove_tag', { tagName: tag.name })}
+      >
+        <span aria-hidden="true">×</span>
+      </button>
+    </div>
+  )
+}

+ 6 - 1
services/web/frontend/js/features/clone-project-modal/components/editor-clone-project-modal-wrapper.js

@@ -6,7 +6,11 @@ import CloneProjectModal from './clone-project-modal'
 
 
 const EditorCloneProjectModalWrapper = React.memo(
 const EditorCloneProjectModalWrapper = React.memo(
   function EditorCloneProjectModalWrapper({ show, handleHide, openProject }) {
   function EditorCloneProjectModalWrapper({ show, handleHide, openProject }) {
-    const { _id: projectId, name: projectName } = useProjectContext()
+    const {
+      _id: projectId,
+      name: projectName,
+      tags: projectTags,
+    } = useProjectContext()
 
 
     if (!projectName) {
     if (!projectName) {
       // wait for useProjectContext
       // wait for useProjectContext
@@ -19,6 +23,7 @@ const EditorCloneProjectModalWrapper = React.memo(
           handleAfterCloned={openProject}
           handleAfterCloned={openProject}
           projectId={projectId}
           projectId={projectId}
           projectName={projectName}
           projectName={projectName}
+          projectTags={projectTags}
         />
         />
       )
       )
     }
     }

+ 22 - 5
services/web/frontend/js/features/project-list/components/table/cells/action-buttons/copy-project-button.tsx

@@ -6,7 +6,11 @@ import CloneProjectModal from '../../../../../clone-project-modal/components/clo
 import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
 import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
 import { useProjectListContext } from '../../../../context/project-list-context'
 import { useProjectListContext } from '../../../../context/project-list-context'
 import * as eventTracking from '../../../../../../infrastructure/event-tracking'
 import * as eventTracking from '../../../../../../infrastructure/event-tracking'
-import { Project } from '../../../../../../../../types/project/dashboard/api'
+import {
+  ClonedProject,
+  Project,
+} from '../../../../../../../../types/project/dashboard/api'
+import { useProjectTags } from '@/features/project-list/hooks/use-project-tags'
 
 
 type CopyButtonProps = {
 type CopyButtonProps = {
   project: Project
   project: Project
@@ -14,12 +18,16 @@ type CopyButtonProps = {
 }
 }
 
 
 function CopyProjectButton({ project, children }: CopyButtonProps) {
 function CopyProjectButton({ project, children }: CopyButtonProps) {
-  const { addClonedProjectToViewData, updateProjectViewData } =
-    useProjectListContext()
+  const {
+    addClonedProjectToViewData,
+    addProjectToTagInView,
+    updateProjectViewData,
+  } = useProjectListContext()
   const { t } = useTranslation()
   const { t } = useTranslation()
   const text = t('copy')
   const text = t('copy')
   const [showModal, setShowModal] = useState(false)
   const [showModal, setShowModal] = useState(false)
   const isMounted = useIsMounted()
   const isMounted = useIsMounted()
+  const projectTags = useProjectTags(project.id)
 
 
   const handleOpenModal = useCallback(() => {
   const handleOpenModal = useCallback(() => {
     setShowModal(true)
     setShowModal(true)
@@ -32,13 +40,21 @@ function CopyProjectButton({ project, children }: CopyButtonProps) {
   }, [isMounted])
   }, [isMounted])
 
 
   const handleAfterCloned = useCallback(
   const handleAfterCloned = useCallback(
-    clonedProject => {
+    (clonedProject: ClonedProject, tags: { _id: string }[]) => {
       eventTracking.sendMB('project-list-page-interaction', { action: 'clone' })
       eventTracking.sendMB('project-list-page-interaction', { action: 'clone' })
       addClonedProjectToViewData(clonedProject)
       addClonedProjectToViewData(clonedProject)
+      for (const tag of tags) {
+        addProjectToTagInView(tag._id, clonedProject.project_id)
+      }
       updateProjectViewData({ ...project, selected: false })
       updateProjectViewData({ ...project, selected: false })
       setShowModal(false)
       setShowModal(false)
     },
     },
-    [addClonedProjectToViewData, project, updateProjectViewData]
+    [
+      addClonedProjectToViewData,
+      addProjectToTagInView,
+      project,
+      updateProjectViewData,
+    ]
   )
   )
 
 
   if (project.archived || project.trashed) return null
   if (project.archived || project.trashed) return null
@@ -52,6 +68,7 @@ function CopyProjectButton({ project, children }: CopyButtonProps) {
         handleAfterCloned={handleAfterCloned}
         handleAfterCloned={handleAfterCloned}
         projectId={project.id}
         projectId={project.id}
         projectName={project.name}
         projectName={project.name}
+        projectTags={projectTags}
       />
       />
     </>
     </>
   )
   )

+ 10 - 3
services/web/frontend/js/features/project-list/components/table/project-tools/menu-items/copy-project-menu-item.tsx

@@ -5,18 +5,20 @@ import CloneProjectModal from '../../../../../clone-project-modal/components/clo
 import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
 import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
 import { useProjectListContext } from '../../../../context/project-list-context'
 import { useProjectListContext } from '../../../../context/project-list-context'
 import * as eventTracking from '../../../../../../infrastructure/event-tracking'
 import * as eventTracking from '../../../../../../infrastructure/event-tracking'
-import { Project } from '../../../../../../../../types/project/dashboard/api'
+import { ClonedProject } from '../../../../../../../../types/project/dashboard/api'
+import { useProjectTags } from '@/features/project-list/hooks/use-project-tags'
 
 
 function CopyProjectMenuItem() {
 function CopyProjectMenuItem() {
   const {
   const {
     addClonedProjectToViewData,
     addClonedProjectToViewData,
+    addProjectToTagInView,
     updateProjectViewData,
     updateProjectViewData,
     selectedProjects,
     selectedProjects,
   } = useProjectListContext()
   } = useProjectListContext()
   const { t } = useTranslation()
   const { t } = useTranslation()
-
   const [showModal, setShowModal] = useState(false)
   const [showModal, setShowModal] = useState(false)
   const isMounted = useIsMounted()
   const isMounted = useIsMounted()
+  const projectTags = useProjectTags(selectedProjects[0]?.id)
 
 
   const handleOpenModal = useCallback(() => {
   const handleOpenModal = useCallback(() => {
     setShowModal(true)
     setShowModal(true)
@@ -29,10 +31,13 @@ function CopyProjectMenuItem() {
   }, [isMounted])
   }, [isMounted])
 
 
   const handleAfterCloned = useCallback(
   const handleAfterCloned = useCallback(
-    (clonedProject: Project) => {
+    (clonedProject: ClonedProject, tags: { _id: string }[]) => {
       const project = selectedProjects[0]
       const project = selectedProjects[0]
       eventTracking.sendMB('project-list-page-interaction', { action: 'clone' })
       eventTracking.sendMB('project-list-page-interaction', { action: 'clone' })
       addClonedProjectToViewData(clonedProject)
       addClonedProjectToViewData(clonedProject)
+      for (const tag of tags) {
+        addProjectToTagInView(tag._id, clonedProject.project_id)
+      }
       updateProjectViewData({ ...project, selected: false })
       updateProjectViewData({ ...project, selected: false })
 
 
       if (isMounted.current) {
       if (isMounted.current) {
@@ -43,6 +48,7 @@ function CopyProjectMenuItem() {
       isMounted,
       isMounted,
       selectedProjects,
       selectedProjects,
       addClonedProjectToViewData,
       addClonedProjectToViewData,
+      addProjectToTagInView,
       updateProjectViewData,
       updateProjectViewData,
     ]
     ]
   )
   )
@@ -59,6 +65,7 @@ function CopyProjectMenuItem() {
         handleAfterCloned={handleAfterCloned}
         handleAfterCloned={handleAfterCloned}
         projectId={selectedProjects[0].id}
         projectId={selectedProjects[0].id}
         projectName={selectedProjects[0].name}
         projectName={selectedProjects[0].name}
+        projectTags={projectTags}
       />
       />
       <MenuItem onClick={handleOpenModal}>{t('make_a_copy')}</MenuItem>
       <MenuItem onClick={handleOpenModal}>{t('make_a_copy')}</MenuItem>
     </>
     </>

+ 4 - 3
services/web/frontend/js/features/project-list/context/project-list-context.tsx

@@ -20,6 +20,7 @@ import {
 } from 'react'
 } from 'react'
 import { Tag } from '../../../../../app/src/Features/Tags/types'
 import { Tag } from '../../../../../app/src/Features/Tags/types'
 import {
 import {
+  ClonedProject,
   GetProjectsResponseBody,
   GetProjectsResponseBody,
   Project,
   Project,
   Sort,
   Sort,
@@ -69,7 +70,7 @@ const filters: FilterMap = {
 export const UNCATEGORIZED_KEY = 'uncategorized'
 export const UNCATEGORIZED_KEY = 'uncategorized'
 
 
 export type ProjectListContextValue = {
 export type ProjectListContextValue = {
-  addClonedProjectToViewData: (project: Project) => void
+  addClonedProjectToViewData: (project: ClonedProject) => void
   selectOrUnselectAllProjects: React.Dispatch<React.SetStateAction<boolean>>
   selectOrUnselectAllProjects: React.Dispatch<React.SetStateAction<boolean>>
   visibleProjects: Project[]
   visibleProjects: Project[]
   totalProjectsCount: number
   totalProjectsCount: number
@@ -375,7 +376,7 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
   )
   )
 
 
   const addClonedProjectToViewData = useCallback(
   const addClonedProjectToViewData = useCallback(
-    project => {
+    (project: ClonedProject) => {
       // clone API not using camelCase and does not return all data
       // clone API not using camelCase and does not return all data
 
 
       const owner = {
       const owner = {
@@ -385,7 +386,7 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
         lastName: project.owner?.last_name,
         lastName: project.owner?.last_name,
       }
       }
 
 
-      const clonedProject = {
+      const clonedProject: Project = {
         ...project,
         ...project,
         id: project.project_id,
         id: project.project_id,
         owner,
         owner,

+ 11 - 0
services/web/frontend/js/features/project-list/hooks/use-project-tags.tsx

@@ -0,0 +1,11 @@
+import { useProjectListContext } from '@/features/project-list/context/project-list-context'
+import { useMemo } from 'react'
+
+export const useProjectTags = (projectId: string) => {
+  const { tags } = useProjectListContext()
+
+  return useMemo(
+    () => tags.filter(tag => tag.project_ids?.includes(projectId)),
+    [tags, projectId]
+  )
+}

+ 18 - 0
services/web/frontend/js/shared/context/project-context.js

@@ -1,6 +1,7 @@
 import { createContext, useContext, useMemo } from 'react'
 import { createContext, useContext, useMemo } from 'react'
 import PropTypes from 'prop-types'
 import PropTypes from 'prop-types'
 import useScopeValue from '../hooks/use-scope-value'
 import useScopeValue from '../hooks/use-scope-value'
+import getMeta from '@/utils/meta'
 
 
 const ProjectContext = createContext()
 const ProjectContext = createContext()
 
 
@@ -34,6 +35,13 @@ export const projectShape = {
     email: PropTypes.string.isRequired,
     email: PropTypes.string.isRequired,
   }),
   }),
   useNewCompileTimeoutUI: PropTypes.string,
   useNewCompileTimeoutUI: PropTypes.string,
+  tags: PropTypes.arrayOf(
+    PropTypes.shape({
+      _id: PropTypes.string.isRequired,
+      name: PropTypes.string.isRequired,
+      color: PropTypes.string,
+    })
+  ).isRequired,
 }
 }
 
 
 ProjectContext.Provider.propTypes = {
 ProjectContext.Provider.propTypes = {
@@ -83,6 +91,14 @@ export function ProjectProvider({ children }) {
     showNewCompileTimeoutUI,
     showNewCompileTimeoutUI,
   } = project || projectFallback
   } = project || projectFallback
 
 
+  const tags = useMemo(
+    () =>
+      getMeta('ol-projectTags', [])
+        // `tag.name` data may be null for some old users
+        .map(tag => ({ ...tag, name: tag.name ?? '' })),
+    []
+  )
+
   // temporary override for new compile timeout
   // temporary override for new compile timeout
   const forceNewCompileTimeout = new URLSearchParams(
   const forceNewCompileTimeout = new URLSearchParams(
     window.location.search
     window.location.search
@@ -106,6 +122,7 @@ export function ProjectProvider({ children }) {
       owner,
       owner,
       showNewCompileTimeoutUI:
       showNewCompileTimeoutUI:
         newCompileTimeoutOverride || showNewCompileTimeoutUI,
         newCompileTimeoutOverride || showNewCompileTimeoutUI,
+      tags,
     }
     }
   }, [
   }, [
     _id,
     _id,
@@ -118,6 +135,7 @@ export function ProjectProvider({ children }) {
     owner,
     owner,
     showNewCompileTimeoutUI,
     showNewCompileTimeoutUI,
     newCompileTimeoutOverride,
     newCompileTimeoutOverride,
+    tags,
   ])
   ])
 
 
   return (
   return (

+ 26 - 1
services/web/frontend/stories/project-list/project-list.stories.tsx

@@ -1,7 +1,13 @@
 import ProjectListTable from '../../js/features/project-list/components/table/project-list-table'
 import ProjectListTable from '../../js/features/project-list/components/table/project-list-table'
 import { ProjectListProvider } from '../../js/features/project-list/context/project-list-context'
 import { ProjectListProvider } from '../../js/features/project-list/context/project-list-context'
 import useFetchMock from '../hooks/use-fetch-mock'
 import useFetchMock from '../hooks/use-fetch-mock'
-import { projectsData } from '../../../test/frontend/features/project-list/fixtures/projects-data'
+import {
+  copyableProject,
+  projectsData,
+} from '../../../test/frontend/features/project-list/fixtures/projects-data'
+import { useMeta } from '../hooks/use-meta'
+import { tags } from '../../../test/frontend/features/project-list/fixtures/tags-data'
+import uuid from 'uuid'
 
 
 const MOCK_DELAY = 500
 const MOCK_DELAY = 500
 
 
@@ -13,6 +19,25 @@ export const Interactive = (args: any) => {
       { projects: projectsData, totalSize: projectsData.length },
       { projects: projectsData, totalSize: projectsData.length },
       { delay: MOCK_DELAY }
       { delay: MOCK_DELAY }
     )
     )
+    fetchMock.post(
+      'express:/project/:projectId/clone',
+      () => ({
+        project_id: uuid.v4(),
+        name: copyableProject.name,
+        lastUpdated: new Date().toISOString(),
+        owner: {
+          _id: copyableProject.owner?.id,
+          email: copyableProject.owner?.id,
+          first_name: copyableProject.owner?.firstName,
+          last_name: copyableProject.owner?.lastName,
+        },
+      }),
+      { delay: MOCK_DELAY }
+    )
+  })
+
+  useMeta({
+    'ol-tags': tags,
   })
   })
 
 
   return (
   return (

+ 10 - 1
services/web/frontend/stylesheets/app/project-list-react.less

@@ -464,7 +464,8 @@
       .text-overflow();
       .text-overflow();
     }
     }
 
 
-    .dash-cell-tag {
+    .dash-cell-tag,
+    .clone-project-tag {
       .tag-label {
       .tag-label {
         padding: 14px 0;
         padding: 14px 0;
         cursor: pointer;
         cursor: pointer;
@@ -979,3 +980,11 @@
     left: 0;
     left: 0;
   }
   }
 }
 }
+
+.clone-project-tag {
+  display: flex;
+
+  &:hover .label.tag-label-name {
+    background-color: @tag-bg-color;
+  }
+}

+ 1 - 0
services/web/test/frontend/features/clone-project-modal/components/clone-project-modal.test.js

@@ -87,6 +87,7 @@ describe('<EditorCloneProjectModalWrapper />', function () {
 
 
     expect(JSON.parse(options.body)).to.deep.equal({
     expect(JSON.parse(options.body)).to.deep.equal({
       projectName: 'A Cloned Project',
       projectName: 'A Cloned Project',
+      tags: [],
     })
     })
 
 
     expect(openProject).to.be.calledOnce
     expect(openProject).to.be.calledOnce

+ 18 - 0
services/web/test/frontend/features/project-list/fixtures/tags-data.ts

@@ -0,0 +1,18 @@
+import { Tag } from '../../../../../app/src/Features/Tags/types'
+
+export const tags: Tag[] = [
+  {
+    _id: 'tag-1',
+    name: 'foo',
+    color: '#f00',
+    user_id: '624333f147cfd8002622a1d3',
+    project_ids: ['62f17f594641b405ca2b3264'],
+  },
+  {
+    _id: 'tag-2',
+    name: 'bar',
+    color: '#0f0',
+    user_id: '624333f147cfd8002622a1d3',
+    project_ids: ['62f17f594641b405ca2b3264'],
+  },
+]

+ 10 - 2
services/web/test/unit/src/Project/ProjectControllerTests.js

@@ -43,7 +43,7 @@ describe('ProjectController', function () {
       findArchivedProjects: sinon.stub(),
       findArchivedProjects: sinon.stub(),
     }
     }
     this.ProjectDuplicator = {
     this.ProjectDuplicator = {
-      duplicate: sinon.stub().callsArgWith(3, null, { _id: this.project_id }),
+      duplicate: sinon.stub().callsArgWith(4, null, { _id: this.project_id }),
     }
     }
     this.ProjectCreationHandler = {
     this.ProjectCreationHandler = {
       createExampleProject: sinon
       createExampleProject: sinon
@@ -60,7 +60,15 @@ describe('ProjectController', function () {
         .stub()
         .stub()
         .callsArgWith(1, null, false),
         .callsArgWith(1, null, false),
     }
     }
-    this.TagsHandler = { getAllTags: sinon.stub() }
+    this.TagsHandler = {
+      getTagsForProject: sinon.stub().callsArgWith(2, null, [
+        {
+          name: 'test',
+          project_ids: [this.project_id],
+        },
+      ]),
+      addProjectToTags: sinon.stub(),
+    }
     this.UserModel = { findById: sinon.stub(), updateOne: sinon.stub() }
     this.UserModel = { findById: sinon.stub(), updateOne: sinon.stub() }
     this.AuthorizationManager = {
     this.AuthorizationManager = {
       getPrivilegeLevelForProject: sinon.stub(),
       getPrivilegeLevelForProject: sinon.stub(),

+ 9 - 0
services/web/test/unit/src/Project/ProjectDuplicatorTests.js

@@ -143,6 +143,14 @@ describe('ProjectDuplicator', function () {
         copyFile: sinon.stub().resolves(this.filestoreUrl),
         copyFile: sinon.stub().resolves(this.filestoreUrl),
       },
       },
     }
     }
+    this.TagsHandler = {
+      promises: {
+        addProjectToTags: sinon.stub().resolves({
+          _id: 'project-1',
+        }),
+        countTagsForProject: sinon.stub().resolves(1),
+      },
+    }
     this.ProjectCreationHandler = {
     this.ProjectCreationHandler = {
       promises: {
       promises: {
         createBlankProject: sinon.stub().resolves(this.newBlankProject),
         createBlankProject: sinon.stub().resolves(this.newBlankProject),
@@ -216,6 +224,7 @@ describe('ProjectDuplicator', function () {
         './ProjectLocator': this.ProjectLocator,
         './ProjectLocator': this.ProjectLocator,
         './ProjectOptionsHandler': this.ProjectOptionsHandler,
         './ProjectOptionsHandler': this.ProjectOptionsHandler,
         '../ThirdPartyDataStore/TpdsProjectFlusher': this.TpdsProjectFlusher,
         '../ThirdPartyDataStore/TpdsProjectFlusher': this.TpdsProjectFlusher,
+        '../Tags/TagsHandler': this.TagsHandler,
       },
       },
     })
     })
   })
   })

+ 29 - 0
services/web/test/unit/src/Tags/TagsHandlerTests.js

@@ -291,6 +291,35 @@ describe('TagsHandler', function () {
     })
     })
   })
   })
 
 
+  describe('addProjectToTags', function () {
+    it('should add the project id to each tag', function (done) {
+      const tagIds = []
+
+      this.TagMock.expects('updateMany')
+        .once()
+        .withArgs(
+          {
+            user_id: this.userId,
+            _id: { $in: tagIds },
+          },
+          {
+            $addToSet: { project_ids: this.projectId },
+          }
+        )
+        .resolves()
+      this.TagsHandler.addProjectToTags(
+        this.userId,
+        tagIds,
+        this.projectId,
+        (err, result) => {
+          expect(err).to.not.exist
+          this.TagMock.verify()
+          done()
+        }
+      )
+    })
+  })
+
   describe('deleteTag', function () {
   describe('deleteTag', function () {
     describe('with a valid tag_id', function () {
     describe('with a valid tag_id', function () {
       it('should call remove in mongo', function (done) {
       it('should call remove in mongo', function (done) {

+ 12 - 0
services/web/types/project/dashboard/api.d.ts

@@ -57,3 +57,15 @@ export type GetProjectsResponseBody = {
   totalSize: number
   totalSize: number
   projects: Project[]
   projects: Project[]
 }
 }
+
+export type ClonedProject = {
+  project_id: string
+  name: string
+  lastUpdated: string
+  owner: {
+    _id: string
+    email: string
+    first_name: string
+    last_name: string
+  }
+}