diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index 912da763f3..65165f3780 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/services/web/app/src/Features/Project/ProjectController.js @@ -37,6 +37,7 @@ const { hasAdminAccess } = require('../Helpers/AdminAuthorizationHelper') const InstitutionsFeatures = require('../Institutions/InstitutionsFeatures') const ProjectAuditLogHandler = require('./ProjectAuditLogHandler') const PublicAccessLevels = require('../Authorization/PublicAccessLevels') +const TagsHandler = require('../Tags/TagsHandler') /** * @typedef {import("./types").GetProjectsRequest} GetProjectsRequest @@ -253,7 +254,7 @@ const ProjectController = { res.setTimeout(5 * 60 * 1000) // allow extra time for the copy to complete metrics.inc('cloned-project') const projectId = req.params.Project_id - const { projectName } = req.body + const { projectName, tags } = req.body logger.debug({ projectId, projectName }, 'cloning project') if (!SessionManager.isUserLoggedIn(req.session)) { return res.json({ redir: '/register' }) @@ -264,6 +265,7 @@ const ProjectController = { currentUser, projectId, projectName, + tags, (err, project) => { if (err != null) { 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, @@ -757,6 +765,7 @@ const ProjectController = { sourceEditorToolbarAssigment, historyViewAssignment, reviewPanelAssignment, + projectTags, } ) => { if (err != null) { @@ -944,6 +953,7 @@ const ProjectController = { isReviewPanelReact: reviewPanelAssignment.variant === 'react', showPersonalAccessToken, hasTrackChangesFeature: Features.hasFeature('track-changes'), + projectTags, }) timer.done() } diff --git a/services/web/app/src/Features/Project/ProjectDuplicator.js b/services/web/app/src/Features/Project/ProjectDuplicator.js index 26e6834cf9..deefa7e380 100644 --- a/services/web/app/src/Features/Project/ProjectDuplicator.js +++ b/services/web/app/src/Features/Project/ProjectDuplicator.js @@ -17,6 +17,7 @@ const ProjectOptionsHandler = require('./ProjectOptionsHandler') const SafePath = require('./SafePath') const TpdsProjectFlusher = require('../ThirdPartyDataStore/TpdsProjectFlusher') const _ = require('lodash') +const TagsHandler = require('../Tags/TagsHandler') module.exports = { 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) const originalProject = await ProjectGetter.promises.getProject( originalProjectId, @@ -50,6 +51,14 @@ async function duplicate(owner, originalProjectId, newProjectName) { ]) 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 newProjectName = newProjectName.trim() @@ -88,6 +97,14 @@ async function duplicate(owner, originalProjectId, newProjectName) { newProject: { version: projectVersion }, }) 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) { // Clean up broken clone on error. // 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, }) } + return newProject } diff --git a/services/web/app/src/Features/Tags/TagsHandler.js b/services/web/app/src/Features/Tags/TagsHandler.js index 9f0ba6a0cd..aacdbe8995 100644 --- a/services/web/app/src/Features/Tags/TagsHandler.js +++ b/services/web/app/src/Features/Tags/TagsHandler.js @@ -7,6 +7,14 @@ async function getAllTags(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 = {}) { if (name.length > MAX_TAG_LENGTH) { if (options.truncate) { @@ -119,26 +127,38 @@ async function removeProjectFromAllTags(userId, projectId) { 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 = { getAllTags: callbackify(getAllTags), + countTagsForProject: callbackify(countTagsForProject), + getTagsForProject: callbackify(getTagsForProject), createTag: callbackify(createTag), renameTag: callbackify(renameTag), editTag: callbackify(editTag), deleteTag: callbackify(deleteTag), addProjectToTag: callbackify(addProjectToTag), addProjectsToTag: callbackify(addProjectsToTag), + addProjectToTags: callbackify(addProjectToTags), removeProjectFromTag: callbackify(removeProjectFromTag), removeProjectsFromTag: callbackify(removeProjectsFromTag), addProjectToTagName: callbackify(addProjectToTagName), removeProjectFromAllTags: callbackify(removeProjectFromAllTags), promises: { getAllTags, + countTagsForProject, + getTagsForProject, createTag, renameTag, editTag, deleteTag, addProjectToTag, addProjectsToTag, + addProjectToTags, removeProjectFromTag, removeProjectsFromTag, addProjectToTagName, diff --git a/services/web/app/views/project/editor/meta.pug b/services/web/app/views/project/editor/meta.pug index 0c9c72eb76..cb976e3fbe 100644 --- a/services/web/app/views/project/editor/meta.pug +++ b/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-mathJax3Path" content=mathJax3Path) 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}, {}) meta(name="ol-fileActionI18n" data-type="json" content=fileActionI18n) diff --git a/services/web/frontend/js/features/clone-project-modal/components/clone-project-modal-content.js b/services/web/frontend/js/features/clone-project-modal/components/clone-project-modal-content.js index 55b8187806..32df28afca 100644 --- a/services/web/frontend/js/features/clone-project-modal/components/clone-project-modal-content.js +++ b/services/web/frontend/js/features/clone-project-modal/components/clone-project-modal-content.js @@ -1,6 +1,6 @@ /* eslint-disable jsx-a11y/no-autofocus */ import PropTypes from 'prop-types' -import { useMemo, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { Modal, @@ -11,6 +11,7 @@ import { FormGroup, } from 'react-bootstrap' import { postJSON } from '../../../infrastructure/fetch-json' +import { CloneProjectTag } from './clone-project-tag' export default function CloneProjectModalContent({ handleHide, @@ -19,6 +20,7 @@ export default function CloneProjectModalContent({ handleAfterCloned, projectId, projectName, + projectTags, }) { const { t } = useTranslation() @@ -27,6 +29,8 @@ export default function CloneProjectModalContent({ `${projectName} (Copy)` ) + const [clonedProjectTags, setClonedProjectTags] = useState(projectTags) + // valid if the cloned project has a name const valid = useMemo( () => clonedProjectName.trim().length > 0, @@ -46,11 +50,14 @@ export default function CloneProjectModalContent({ // clone the project postJSON(`/project/${projectId}/clone`, { - body: { projectName: clonedProjectName }, + body: { + projectName: clonedProjectName, + tags: clonedProjectTags.map(tag => ({ id: tag._id })), + }, }) .then(data => { // open the cloned project - handleAfterCloned(data) + handleAfterCloned(data, clonedProjectTags) }) .catch(({ response, data }) => { 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 ( <> @@ -87,6 +98,23 @@ export default function CloneProjectModalContent({ autoFocus /> + + {clonedProjectTags.length > 0 && ( + + + {t('tags')}:{' '} + +
+ {clonedProjectTags.map(tag => ( + + ))} +
+
+ )} {error && ( @@ -126,4 +154,11 @@ CloneProjectModalContent.propTypes = { handleAfterCloned: PropTypes.func.isRequired, projectId: PropTypes.string, projectName: PropTypes.string, + projectTags: PropTypes.arrayOf( + PropTypes.shape({ + _id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + color: PropTypes.string, + }) + ), } diff --git a/services/web/frontend/js/features/clone-project-modal/components/clone-project-modal.js b/services/web/frontend/js/features/clone-project-modal/components/clone-project-modal.js index bf5c93a72b..a4442bf91b 100644 --- a/services/web/frontend/js/features/clone-project-modal/components/clone-project-modal.js +++ b/services/web/frontend/js/features/clone-project-modal/components/clone-project-modal.js @@ -9,6 +9,7 @@ function CloneProjectModal({ handleAfterCloned, projectId, projectName, + projectTags, }) { const [inFlight, setInFlight] = useState(false) @@ -35,6 +36,7 @@ function CloneProjectModal({ handleAfterCloned={handleAfterCloned} projectId={projectId} projectName={projectName} + projectTags={projectTags} /> ) @@ -46,6 +48,13 @@ CloneProjectModal.propTypes = { handleAfterCloned: PropTypes.func.isRequired, projectId: 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) diff --git a/services/web/frontend/js/features/clone-project-modal/components/clone-project-tag.tsx b/services/web/frontend/js/features/clone-project-modal/components/clone-project-tag.tsx new file mode 100644 index 0000000000..f32243d7c8 --- /dev/null +++ b/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 ( +
+ + + + {' '} + {tag.name} + + +
+ ) +} diff --git a/services/web/frontend/js/features/clone-project-modal/components/editor-clone-project-modal-wrapper.js b/services/web/frontend/js/features/clone-project-modal/components/editor-clone-project-modal-wrapper.js index 6b16326366..2ebc6edece 100644 --- a/services/web/frontend/js/features/clone-project-modal/components/editor-clone-project-modal-wrapper.js +++ b/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( function EditorCloneProjectModalWrapper({ show, handleHide, openProject }) { - const { _id: projectId, name: projectName } = useProjectContext() + const { + _id: projectId, + name: projectName, + tags: projectTags, + } = useProjectContext() if (!projectName) { // wait for useProjectContext @@ -19,6 +23,7 @@ const EditorCloneProjectModalWrapper = React.memo( handleAfterCloned={openProject} projectId={projectId} projectName={projectName} + projectTags={projectTags} /> ) } 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 ae17e4c383..55f1fb2453 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 @@ -6,7 +6,11 @@ import CloneProjectModal from '../../../../../clone-project-modal/components/clo 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' +import { + ClonedProject, + Project, +} from '../../../../../../../../types/project/dashboard/api' +import { useProjectTags } from '@/features/project-list/hooks/use-project-tags' type CopyButtonProps = { project: Project @@ -14,12 +18,16 @@ type CopyButtonProps = { } function CopyProjectButton({ project, children }: CopyButtonProps) { - const { addClonedProjectToViewData, updateProjectViewData } = - useProjectListContext() + const { + addClonedProjectToViewData, + addProjectToTagInView, + updateProjectViewData, + } = useProjectListContext() const { t } = useTranslation() const text = t('copy') const [showModal, setShowModal] = useState(false) const isMounted = useIsMounted() + const projectTags = useProjectTags(project.id) const handleOpenModal = useCallback(() => { setShowModal(true) @@ -32,13 +40,21 @@ function CopyProjectButton({ project, children }: CopyButtonProps) { }, [isMounted]) const handleAfterCloned = useCallback( - clonedProject => { + (clonedProject: ClonedProject, tags: { _id: string }[]) => { eventTracking.sendMB('project-list-page-interaction', { action: 'clone' }) addClonedProjectToViewData(clonedProject) + for (const tag of tags) { + addProjectToTagInView(tag._id, clonedProject.project_id) + } updateProjectViewData({ ...project, selected: false }) setShowModal(false) }, - [addClonedProjectToViewData, project, updateProjectViewData] + [ + addClonedProjectToViewData, + addProjectToTagInView, + project, + updateProjectViewData, + ] ) if (project.archived || project.trashed) return null @@ -52,6 +68,7 @@ function CopyProjectButton({ project, children }: CopyButtonProps) { handleAfterCloned={handleAfterCloned} projectId={project.id} projectName={project.name} + projectTags={projectTags} /> ) 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 index 04c75d6952..5196cbd667 100644 --- 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 @@ -5,18 +5,20 @@ import CloneProjectModal from '../../../../../clone-project-modal/components/clo 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' +import { ClonedProject } from '../../../../../../../../types/project/dashboard/api' +import { useProjectTags } from '@/features/project-list/hooks/use-project-tags' function CopyProjectMenuItem() { const { addClonedProjectToViewData, + addProjectToTagInView, updateProjectViewData, selectedProjects, } = useProjectListContext() const { t } = useTranslation() - const [showModal, setShowModal] = useState(false) const isMounted = useIsMounted() + const projectTags = useProjectTags(selectedProjects[0]?.id) const handleOpenModal = useCallback(() => { setShowModal(true) @@ -29,10 +31,13 @@ function CopyProjectMenuItem() { }, [isMounted]) const handleAfterCloned = useCallback( - (clonedProject: Project) => { + (clonedProject: ClonedProject, tags: { _id: string }[]) => { const project = selectedProjects[0] eventTracking.sendMB('project-list-page-interaction', { action: 'clone' }) addClonedProjectToViewData(clonedProject) + for (const tag of tags) { + addProjectToTagInView(tag._id, clonedProject.project_id) + } updateProjectViewData({ ...project, selected: false }) if (isMounted.current) { @@ -43,6 +48,7 @@ function CopyProjectMenuItem() { isMounted, selectedProjects, addClonedProjectToViewData, + addProjectToTagInView, updateProjectViewData, ] ) @@ -59,6 +65,7 @@ function CopyProjectMenuItem() { handleAfterCloned={handleAfterCloned} projectId={selectedProjects[0].id} projectName={selectedProjects[0].name} + projectTags={projectTags} /> {t('make_a_copy')} 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 596e8c206f..e11f502894 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 @@ -20,6 +20,7 @@ import { } from 'react' import { Tag } from '../../../../../app/src/Features/Tags/types' import { + ClonedProject, GetProjectsResponseBody, Project, Sort, @@ -69,7 +70,7 @@ const filters: FilterMap = { export const UNCATEGORIZED_KEY = 'uncategorized' export type ProjectListContextValue = { - addClonedProjectToViewData: (project: Project) => void + addClonedProjectToViewData: (project: ClonedProject) => void selectOrUnselectAllProjects: React.Dispatch> visibleProjects: Project[] totalProjectsCount: number @@ -375,7 +376,7 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) { ) const addClonedProjectToViewData = useCallback( - project => { + (project: ClonedProject) => { // clone API not using camelCase and does not return all data const owner = { @@ -385,7 +386,7 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) { lastName: project.owner?.last_name, } - const clonedProject = { + const clonedProject: Project = { ...project, id: project.project_id, owner, diff --git a/services/web/frontend/js/features/project-list/hooks/use-project-tags.tsx b/services/web/frontend/js/features/project-list/hooks/use-project-tags.tsx new file mode 100644 index 0000000000..b5fb137cf3 --- /dev/null +++ b/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] + ) +} diff --git a/services/web/frontend/js/shared/context/project-context.js b/services/web/frontend/js/shared/context/project-context.js index a1eafe2507..ad599955cc 100644 --- a/services/web/frontend/js/shared/context/project-context.js +++ b/services/web/frontend/js/shared/context/project-context.js @@ -1,6 +1,7 @@ import { createContext, useContext, useMemo } from 'react' import PropTypes from 'prop-types' import useScopeValue from '../hooks/use-scope-value' +import getMeta from '@/utils/meta' const ProjectContext = createContext() @@ -34,6 +35,13 @@ export const projectShape = { email: PropTypes.string.isRequired, }), useNewCompileTimeoutUI: PropTypes.string, + tags: PropTypes.arrayOf( + PropTypes.shape({ + _id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + color: PropTypes.string, + }) + ).isRequired, } ProjectContext.Provider.propTypes = { @@ -83,6 +91,14 @@ export function ProjectProvider({ children }) { showNewCompileTimeoutUI, } = 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 const forceNewCompileTimeout = new URLSearchParams( window.location.search @@ -106,6 +122,7 @@ export function ProjectProvider({ children }) { owner, showNewCompileTimeoutUI: newCompileTimeoutOverride || showNewCompileTimeoutUI, + tags, } }, [ _id, @@ -118,6 +135,7 @@ export function ProjectProvider({ children }) { owner, showNewCompileTimeoutUI, newCompileTimeoutOverride, + tags, ]) return ( diff --git a/services/web/frontend/stories/project-list/project-list.stories.tsx b/services/web/frontend/stories/project-list/project-list.stories.tsx index 90d6b11b2e..eaae919eb4 100644 --- a/services/web/frontend/stories/project-list/project-list.stories.tsx +++ b/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 { ProjectListProvider } from '../../js/features/project-list/context/project-list-context' 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 @@ -13,6 +19,25 @@ export const Interactive = (args: any) => { { projects: projectsData, totalSize: projectsData.length }, { 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 ( diff --git a/services/web/frontend/stylesheets/app/project-list-react.less b/services/web/frontend/stylesheets/app/project-list-react.less index 74ddc76f7e..c6c2a73de6 100644 --- a/services/web/frontend/stylesheets/app/project-list-react.less +++ b/services/web/frontend/stylesheets/app/project-list-react.less @@ -464,7 +464,8 @@ .text-overflow(); } - .dash-cell-tag { + .dash-cell-tag, + .clone-project-tag { .tag-label { padding: 14px 0; cursor: pointer; @@ -979,3 +980,11 @@ left: 0; } } + +.clone-project-tag { + display: flex; + + &:hover .label.tag-label-name { + background-color: @tag-bg-color; + } +} diff --git a/services/web/test/frontend/features/clone-project-modal/components/clone-project-modal.test.js b/services/web/test/frontend/features/clone-project-modal/components/clone-project-modal.test.js index 22f8411896..4c6d49f752 100644 --- a/services/web/test/frontend/features/clone-project-modal/components/clone-project-modal.test.js +++ b/services/web/test/frontend/features/clone-project-modal/components/clone-project-modal.test.js @@ -87,6 +87,7 @@ describe('', function () { expect(JSON.parse(options.body)).to.deep.equal({ projectName: 'A Cloned Project', + tags: [], }) expect(openProject).to.be.calledOnce diff --git a/services/web/test/frontend/features/project-list/fixtures/tags-data.ts b/services/web/test/frontend/features/project-list/fixtures/tags-data.ts new file mode 100644 index 0000000000..bf6e9da749 --- /dev/null +++ b/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'], + }, +] diff --git a/services/web/test/unit/src/Project/ProjectControllerTests.js b/services/web/test/unit/src/Project/ProjectControllerTests.js index c414eac189..5da882c3a3 100644 --- a/services/web/test/unit/src/Project/ProjectControllerTests.js +++ b/services/web/test/unit/src/Project/ProjectControllerTests.js @@ -43,7 +43,7 @@ describe('ProjectController', function () { findArchivedProjects: sinon.stub(), } this.ProjectDuplicator = { - duplicate: sinon.stub().callsArgWith(3, null, { _id: this.project_id }), + duplicate: sinon.stub().callsArgWith(4, null, { _id: this.project_id }), } this.ProjectCreationHandler = { createExampleProject: sinon @@ -60,7 +60,15 @@ describe('ProjectController', function () { .stub() .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.AuthorizationManager = { getPrivilegeLevelForProject: sinon.stub(), diff --git a/services/web/test/unit/src/Project/ProjectDuplicatorTests.js b/services/web/test/unit/src/Project/ProjectDuplicatorTests.js index 0f3b785873..cd3bc83a6a 100644 --- a/services/web/test/unit/src/Project/ProjectDuplicatorTests.js +++ b/services/web/test/unit/src/Project/ProjectDuplicatorTests.js @@ -143,6 +143,14 @@ describe('ProjectDuplicator', function () { copyFile: sinon.stub().resolves(this.filestoreUrl), }, } + this.TagsHandler = { + promises: { + addProjectToTags: sinon.stub().resolves({ + _id: 'project-1', + }), + countTagsForProject: sinon.stub().resolves(1), + }, + } this.ProjectCreationHandler = { promises: { createBlankProject: sinon.stub().resolves(this.newBlankProject), @@ -216,6 +224,7 @@ describe('ProjectDuplicator', function () { './ProjectLocator': this.ProjectLocator, './ProjectOptionsHandler': this.ProjectOptionsHandler, '../ThirdPartyDataStore/TpdsProjectFlusher': this.TpdsProjectFlusher, + '../Tags/TagsHandler': this.TagsHandler, }, }) }) diff --git a/services/web/test/unit/src/Tags/TagsHandlerTests.js b/services/web/test/unit/src/Tags/TagsHandlerTests.js index f3f6d5fb8a..b68bed71ae 100644 --- a/services/web/test/unit/src/Tags/TagsHandlerTests.js +++ b/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('with a valid tag_id', function () { it('should call remove in mongo', function (done) { diff --git a/services/web/types/project/dashboard/api.d.ts b/services/web/types/project/dashboard/api.d.ts index 536ba90f3f..a05af68e5b 100644 --- a/services/web/types/project/dashboard/api.d.ts +++ b/services/web/types/project/dashboard/api.d.ts @@ -57,3 +57,15 @@ export type GetProjectsResponseBody = { totalSize: number projects: Project[] } + +export type ClonedProject = { + project_id: string + name: string + lastUpdated: string + owner: { + _id: string + email: string + first_name: string + last_name: string + } +}