Copy tags when cloning a project (#14987)

GitOrigin-RevId: 4cdca0ef2f26bf6bba02b675b0ef02ba8da881e2
This commit is contained in:
Alf Eaton 2023-09-28 11:12:18 +01:00 committed by Copybot
parent 04900349e6
commit 15475cdb3c
21 changed files with 315 additions and 21 deletions

View file

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

View file

@ -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
}

View file

@ -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,

View file

@ -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)

View file

@ -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 (
<>
<Modal.Header closeButton>
@ -87,6 +98,23 @@ export default function CloneProjectModalContent({
autoFocus
/>
</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>
{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,
})
),
}

View file

@ -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}
/>
</AccessibleModal>
)
@ -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)

View file

@ -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>
)
}

View file

@ -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}
/>
)
}

View file

@ -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}
/>
</>
)

View file

@ -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}
/>
<MenuItem onClick={handleOpenModal}>{t('make_a_copy')}</MenuItem>
</>

View file

@ -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<React.SetStateAction<boolean>>
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,

View file

@ -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]
)
}

View file

@ -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 (

View file

@ -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 (

View file

@ -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;
}
}

View file

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

View file

@ -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'],
},
]

View file

@ -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(),

View file

@ -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,
},
})
})

View file

@ -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) {

View file

@ -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
}
}