Merge pull request #9794 from overleaf/ab-endpoint-add-remove-tag-multiple-projects

[web] Handle adding/removing multiple projects from a tag at once

GitOrigin-RevId: 7d052fa9930035286f8ce41433d6c3959817148a
This commit is contained in:
Timothée Alby
2022-10-18 08:03:17 +00:00
committed by Copybot
parent fbd588eea6
commit eb35e2c19b
19 changed files with 469 additions and 142 deletions
@@ -35,6 +35,14 @@ async function addProjectToTag(req, res) {
res.status(204).end() res.status(204).end()
} }
async function addProjectsToTag(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session)
const { tagId } = req.params
const { projectIds } = req.body
await TagsHandler.promises.addProjectsToTag(userId, tagId, projectIds)
res.status(204).end()
}
async function removeProjectFromTag(req, res, next) { async function removeProjectFromTag(req, res, next) {
const userId = SessionManager.getLoggedInUserId(req.session) const userId = SessionManager.getLoggedInUserId(req.session)
const { tagId, projectId } = req.params const { tagId, projectId } = req.params
@@ -42,6 +50,14 @@ async function removeProjectFromTag(req, res, next) {
res.status(204).end() res.status(204).end()
} }
async function removeProjectsFromTag(req, res, next) {
const userId = SessionManager.getLoggedInUserId(req.session)
const { tagId } = req.params
const { projectIds } = req.body
await TagsHandler.promises.removeProjectsFromTag(userId, tagId, projectIds)
res.status(204).end()
}
async function deleteTag(req, res) { async function deleteTag(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session) const userId = SessionManager.getLoggedInUserId(req.session)
const { tagId } = req.params const { tagId } = req.params
@@ -65,7 +81,9 @@ module.exports = {
getAllTags: expressify(getAllTags), getAllTags: expressify(getAllTags),
createTag: expressify(createTag), createTag: expressify(createTag),
addProjectToTag: expressify(addProjectToTag), addProjectToTag: expressify(addProjectToTag),
addProjectsToTag: expressify(addProjectsToTag),
removeProjectFromTag: expressify(removeProjectFromTag), removeProjectFromTag: expressify(removeProjectFromTag),
removeProjectsFromTag: expressify(removeProjectsFromTag),
deleteTag: expressify(deleteTag), deleteTag: expressify(deleteTag),
renameTag: expressify(renameTag), renameTag: expressify(renameTag),
} }
@@ -79,6 +79,18 @@ function removeProjectFromTag(userId, tagId, projectId, callback) {
Tag.updateOne(searchOps, deleteOperation, callback) Tag.updateOne(searchOps, deleteOperation, callback)
} }
function removeProjectsFromTag(userId, tagId, projectIds, callback) {
if (!callback) {
callback = function () {}
}
const searchOps = {
_id: tagId,
user_id: userId,
}
const deleteOperation = { $pullAll: { project_ids: projectIds } }
Tag.updateOne(searchOps, deleteOperation, callback)
}
function addProjectToTag(userId, tagId, projectId, callback) { function addProjectToTag(userId, tagId, projectId, callback) {
if (!callback) { if (!callback) {
callback = function () {} callback = function () {}
@@ -91,6 +103,18 @@ function addProjectToTag(userId, tagId, projectId, callback) {
Tag.findOneAndUpdate(searchOps, insertOperation, callback) Tag.findOneAndUpdate(searchOps, insertOperation, callback)
} }
function addProjectsToTag(userId, tagId, projectIds, callback) {
if (!callback) {
callback = function () {}
}
const searchOps = {
_id: tagId,
user_id: userId,
}
const insertOperation = { $addToSet: { project_ids: { $each: projectIds } } }
Tag.findOneAndUpdate(searchOps, insertOperation, callback)
}
function addProjectToTagName(userId, name, projectId, callback) { function addProjectToTagName(userId, name, projectId, callback) {
if (!callback) { if (!callback) {
callback = function () {} callback = function () {}
@@ -115,8 +139,10 @@ const TagsHandler = {
renameTag, renameTag,
deleteTag, deleteTag,
updateTagUserIds, updateTagUserIds,
removeProjectFromTag,
addProjectToTag, addProjectToTag,
addProjectsToTag,
removeProjectFromTag,
removeProjectsFromTag,
addProjectToTagName, addProjectToTagName,
removeProjectFromAllTags, removeProjectFromAllTags,
} }
+40
View File
@@ -791,6 +791,11 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
maxRequests: 30, maxRequests: 30,
timeInterval: 60, timeInterval: 60,
}), }),
validate({
body: Joi.object({
name: Joi.string().required(),
}),
}),
TagsController.createTag TagsController.createTag
) )
webRouter.post( webRouter.post(
@@ -801,6 +806,11 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
maxRequests: 30, maxRequests: 30,
timeInterval: 60, timeInterval: 60,
}), }),
validate({
body: Joi.object({
name: Joi.string().required(),
}),
}),
TagsController.renameTag TagsController.renameTag
) )
webRouter.delete( webRouter.delete(
@@ -823,6 +833,21 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
}), }),
TagsController.addProjectToTag TagsController.addProjectToTag
) )
webRouter.post(
'/tag/:tagId/projects',
AuthenticationController.requireLogin(),
RateLimiterMiddleware.rateLimit({
endpointName: 'add-projects-to-tag',
maxRequests: 30,
timeInterval: 60,
}),
validate({
body: Joi.object({
projectIds: Joi.array().items(Joi.string()).required(),
}),
}),
TagsController.addProjectsToTag
)
webRouter.delete( webRouter.delete(
'/tag/:tagId/project/:projectId', '/tag/:tagId/project/:projectId',
AuthenticationController.requireLogin(), AuthenticationController.requireLogin(),
@@ -833,6 +858,21 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
}), }),
TagsController.removeProjectFromTag TagsController.removeProjectFromTag
) )
webRouter.delete(
'/tag/:tagId/projects',
AuthenticationController.requireLogin(),
RateLimiterMiddleware.rateLimit({
endpointName: 'remove-projects-from-tag',
maxRequests: 30,
timeInterval: 60,
}),
validate({
body: Joi.object({
projectIds: Joi.array().items(Joi.string()).required(),
}),
}),
TagsController.removeProjectsFromTag
)
webRouter.get( webRouter.get(
'/notifications', '/notifications',
@@ -75,7 +75,7 @@ export default function CreateTagModal({
className="form-control" className="form-control"
type="text" type="text"
placeholder="New Tag Name" placeholder="New Tag Name"
name="new-tag-name" name="new-tag-form-name"
required required
onChange={e => setTagName(e.target.value)} onChange={e => setTagName(e.target.value)}
/> />
@@ -6,7 +6,7 @@ import ControlledDropdown from '../../../../../../shared/components/controlled-d
import Icon from '../../../../../../shared/components/icon' import Icon from '../../../../../../shared/components/icon'
import { useProjectListContext } from '../../../../context/project-list-context' import { useProjectListContext } from '../../../../context/project-list-context'
import useTag from '../../../../hooks/use-tag' import useTag from '../../../../hooks/use-tag'
import { addProjectToTag, removeProjectFromTag } from '../../../../util/api' import { addProjectsToTag, removeProjectsFromTag } from '../../../../util/api'
function TagsDropdown() { function TagsDropdown() {
const { const {
@@ -30,12 +30,14 @@ function TagsDropdown() {
(e, tagId) => { (e, tagId) => {
e.preventDefault() e.preventDefault()
const tag = tags.find(tag => tag._id === tagId) const tag = tags.find(tag => tag._id === tagId)
const projectIds = []
for (const selectedProject of selectedProjects) { for (const selectedProject of selectedProjects) {
if (!tag?.project_ids?.includes(selectedProject.id)) { if (!tag?.project_ids?.includes(selectedProject.id)) {
addProjectToTagInView(tagId, selectedProject.id) addProjectToTagInView(tagId, selectedProject.id)
addProjectToTag(tagId, selectedProject.id) projectIds.push(selectedProject.id)
} }
} }
addProjectsToTag(tagId, projectIds)
}, },
[tags, selectedProjects, addProjectToTagInView] [tags, selectedProjects, addProjectToTagInView]
) )
@@ -45,8 +47,11 @@ function TagsDropdown() {
e.preventDefault() e.preventDefault()
for (const selectedProject of selectedProjects) { for (const selectedProject of selectedProjects) {
removeProjectFromTagInView(tagId, selectedProject.id) removeProjectFromTagInView(tagId, selectedProject.id)
removeProjectFromTag(tagId, selectedProject.id)
} }
removeProjectsFromTag(
tagId,
selectedProjects.map(project => project.id)
)
}, },
[selectedProjects, removeProjectFromTagInView] [selectedProjects, removeProjectFromTagInView]
) )
@@ -78,7 +83,11 @@ function TagsDropdown() {
return ( return (
<> <>
<ControlledDropdown id="tags"> <ControlledDropdown id="tags">
<Dropdown.Toggle bsStyle="default" title={t('tags')}> <Dropdown.Toggle
bsStyle="default"
title={t('tags')}
aria-label={t('tags')}
>
<Icon type="folder-open" /> <Icon type="folder-open" />
</Dropdown.Toggle> </Dropdown.Toggle>
<Dropdown.Menu className="dropdown-menu-right"> <Dropdown.Menu className="dropdown-menu-right">
@@ -6,7 +6,7 @@ import RenameTagModal from '../components/modals/rename-tag-modal'
import DeleteTagModal from '../components/modals/delete-tag-modal' import DeleteTagModal from '../components/modals/delete-tag-modal'
import EditTagModal from '../components/modals/edit-tag-modal' import EditTagModal from '../components/modals/edit-tag-modal'
import { find } from 'lodash' import { find } from 'lodash'
import { addProjectToTag } from '../util/api' import { addProjectsToTag } from '../util/api'
function useTag() { function useTag() {
const { const {
@@ -41,8 +41,11 @@ function useTag() {
addTag(tag) addTag(tag)
for (const selectedProject of selectedProjects) { for (const selectedProject of selectedProjects) {
addProjectToTagInView(tag._id, selectedProject.id) addProjectToTagInView(tag._id, selectedProject.id)
addProjectToTag(tag._id, selectedProject.id)
} }
addProjectsToTag(
tag._id,
selectedProjects.map(project => project.id)
)
}, },
[addTag, selectedProjects, addProjectToTagInView] [addTag, selectedProjects, addProjectToTagInView]
) )
@@ -25,14 +25,26 @@ export function deleteTag(tagId: string) {
return deleteJSON(`/tag/${tagId}`) return deleteJSON(`/tag/${tagId}`)
} }
export function addProjectToTag(tagId: string, projectId: string) { export function addProjectsToTag(tagId: string, projectIds: string[]) {
return postJSON(`/tag/${tagId}/project/${projectId}`) return postJSON(`/tag/${tagId}/projects`, {
body: {
projectIds,
},
})
} }
export function removeProjectFromTag(tagId: string, projectId: string) { export function removeProjectFromTag(tagId: string, projectId: string) {
return deleteJSON(`/tag/${tagId}/project/${projectId}`) return deleteJSON(`/tag/${tagId}/project/${projectId}`)
} }
export function removeProjectsFromTag(tagId: string, projectIds: string[]) {
return deleteJSON(`/tag/${tagId}/projects`, {
body: {
projectIds,
},
})
}
export function archiveProject(projectId: string) { export function archiveProject(projectId: string) {
return postJSON(`/project/${projectId}/archive`) return postJSON(`/project/${projectId}/archive`)
} }
@@ -6,6 +6,7 @@ import ProjectListRoot from '../../../../../frontend/js/features/project-list/co
import { renderWithProjectListContext } from '../helpers/render-with-context' import { renderWithProjectListContext } from '../helpers/render-with-context'
import * as eventTracking from '../../../../../frontend/js/infrastructure/event-tracking' import * as eventTracking from '../../../../../frontend/js/infrastructure/event-tracking'
import { import {
projectsData,
owner, owner,
archivedProjects, archivedProjects,
makeLongProjectList, makeLongProjectList,
@@ -23,7 +24,15 @@ describe('<ProjectListRoot />', function () {
global.localStorage.clear() global.localStorage.clear()
sendSpy = sinon.spy(eventTracking, 'send') sendSpy = sinon.spy(eventTracking, 'send')
window.metaAttributesCache = new Map() window.metaAttributesCache = new Map()
window.metaAttributesCache.set('ol-tags', []) this.tagId = '999fff999fff'
this.tagName = 'First tag name'
window.metaAttributesCache.set('ol-tags', [
{
_id: this.tagId,
name: this.tagName,
project_ids: [projectsData[0].id, projectsData[1].id],
},
])
window.metaAttributesCache.set('ol-ExposedSettings', { window.metaAttributesCache.set('ol-ExposedSettings', {
templateLinks: [], templateLinks: [],
}) })
@@ -138,16 +147,16 @@ describe('<ProjectListRoot />', function () {
fireEvent.click(confirmBtn) fireEvent.click(confirmBtn)
expect(confirmBtn.disabled).to.be.true expect(confirmBtn.disabled).to.be.true
await fetchMock.flush(true) await waitFor(
expect(fetchMock.done()).to.be.true () =>
expect(fetchMock.called(`/project/${project1Id}/archive`)).to.be
const requests = fetchMock.calls() .true
const [projectRequest1Url, projectRequest1Headers] = requests[2] )
expect(projectRequest1Url).to.equal(`/project/${project1Id}/archive`) await waitFor(
expect(projectRequest1Headers?.method).to.equal('POST') () =>
const [projectRequest2Url, projectRequest2Headers] = requests[3] expect(fetchMock.called(`/project/${project2Id}/archive`)).to.be
expect(projectRequest2Url).to.equal(`/project/${project2Id}/archive`) .true
expect(projectRequest2Headers?.method).to.equal('POST') )
}) })
it('opens trash modal for all selected projects and trashes all', async function () { it('opens trash modal for all selected projects and trashes all', async function () {
@@ -173,16 +182,16 @@ describe('<ProjectListRoot />', function () {
fireEvent.click(confirmBtn) fireEvent.click(confirmBtn)
expect(confirmBtn.disabled).to.be.true expect(confirmBtn.disabled).to.be.true
await fetchMock.flush(true) await waitFor(
expect(fetchMock.done()).to.be.true () =>
expect(fetchMock.called(`/project/${project1Id}/trash`)).to.be
const requests = fetchMock.calls() .true
const [projectRequest1Url, projectRequest1Headers] = requests[2] )
expect(projectRequest1Url).to.equal(`/project/${project1Id}/trash`) await waitFor(
expect(projectRequest1Headers?.method).to.equal('POST') () =>
const [projectRequest2Url, projectRequest2Headers] = requests[3] expect(fetchMock.called(`/project/${project2Id}/trash`)).to.be
expect(projectRequest2Url).to.equal(`/project/${project2Id}/trash`) .true
expect(projectRequest2Headers?.method).to.equal('POST') )
}) })
it('only checks the projects that are viewable when there is a load more button', async function () { it('only checks the projects that are viewable when there is a load more button', async function () {
@@ -354,6 +363,141 @@ describe('<ProjectListRoot />', function () {
}) })
}) })
describe('tags dropdown', function () {
beforeEach(async function () {
allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
// first one is the select all checkbox
fireEvent.click(allCheckboxes[1])
fireEvent.click(allCheckboxes[2])
actionsToolbar = screen.getAllByRole('toolbar')[0]
this.newTagName = 'Some tag name'
this.newTagId = 'abc123def456'
})
it('opens the tags dropdown and creates a new tag', async function () {
fetchMock.post(`express:/tag`, {
status: 200,
body: {
_id: this.newTagId,
name: this.newTagName,
project_ids: [],
},
})
fetchMock.post(`express:/tag/:id/projects`, {
status: 204,
})
await waitFor(() => {
const tagsDropdown = within(actionsToolbar).getByLabelText('Tags')
fireEvent.click(tagsDropdown)
})
screen.getByText('Add to folder')
const newTagButton = screen.getByText('Create New Folder')
fireEvent.click(newTagButton)
const modal = screen.getAllByRole('dialog')[0]
const input = within(modal).getByRole<HTMLInputElement>('textbox')
fireEvent.change(input, {
target: { value: this.newTagName },
})
const createButton = within(modal).getByRole('button', {
name: 'Create',
})
fireEvent.click(createButton)
await waitFor(
() =>
expect(fetchMock.called('/tag', { name: this.newTagName })).to.be
.true
)
await waitFor(
() =>
expect(
fetchMock.called(`/tag/${this.newTagId}/projects`, {
body: {
projectIds: [projectsData[0].id, projectsData[1].id],
},
})
).to.be.true
)
screen.getByRole('button', { name: `${this.newTagName} (2)` })
})
it('opens the tags dropdown and remove a tag from selected projects', async function () {
const deleteProjectsFromTagMock = fetchMock.delete(
`express:/tag/:id/projects`,
{
status: 204,
}
)
screen.getByRole('button', { name: `${this.tagName} (2)` })
const tagsDropdown = within(actionsToolbar).getByLabelText('Tags')
fireEvent.click(tagsDropdown)
screen.getByText('Add to folder')
const tagButton = screen.getByLabelText(
`Add or remove project from tag ${this.tagName}`
)
fireEvent.click(tagButton)
await waitFor(
() =>
expect(
deleteProjectsFromTagMock.called(
`/tag/${this.tagId}/projects`,
{
body: {
projectIds: [projectsData[0].id, projectsData[1].id],
},
}
)
).to.be.true
)
screen.getByRole('button', { name: `${this.tagName} (0)` })
})
it('select another project, opens the tags dropdown and add a tag only to the untagged project', async function () {
const addProjectsToTagMock = fetchMock.post(
`express:/tag/:id/projects`,
{
status: 204,
}
)
fireEvent.click(allCheckboxes[3])
screen.getByRole('button', { name: `${this.tagName} (2)` })
const tagsDropdown = within(actionsToolbar).getByLabelText('Tags')
fireEvent.click(tagsDropdown)
screen.getByText('Add to folder')
const tagButton = screen.getByLabelText(
`Add or remove project from tag ${this.tagName}`
)
fireEvent.click(tagButton)
await waitFor(
() =>
expect(
addProjectsToTagMock.called(`/tag/${this.tagId}/projects`, {
body: {
projectIds: [projectsData[2].id],
},
})
).to.be.true
)
screen.getByRole('button', { name: `${this.tagName} (3)` })
})
})
describe('project tools "More" dropdown', function () { describe('project tools "More" dropdown', function () {
beforeEach(async function () { beforeEach(async function () {
const filterButton = screen.getAllByText('All Projects')[0] const filterButton = screen.getAllByText('All Projects')[0]
@@ -374,9 +518,12 @@ describe('<ProjectListRoot />', function () {
}) })
it('opens the rename modal, and can rename the project, and view updated', async function () { it('opens the rename modal, and can rename the project, and view updated', async function () {
fetchMock.post(`express:/project/:id/rename`, { const renameProjectMock = fetchMock.post(
`express:/project/:id/rename`,
{
status: 200, status: 200,
}) }
)
await waitFor(() => { await waitFor(() => {
const moreDropdown = const moreDropdown =
@@ -384,7 +531,8 @@ describe('<ProjectListRoot />', function () {
fireEvent.click(moreDropdown) fireEvent.click(moreDropdown)
}) })
const renameButton = screen.getByText<HTMLInputElement>('Rename') const renameButton =
screen.getAllByText<HTMLInputElement>('Rename')[1] // first one is for the tag in the sidebar
fireEvent.click(renameButton) fireEvent.click(renameButton)
const modal = screen.getAllByRole('dialog')[0] const modal = screen.getAllByRole('dialog')[0]
@@ -419,8 +567,14 @@ describe('<ProjectListRoot />', function () {
expect(confirmButton.disabled).to.be.false expect(confirmButton.disabled).to.be.false
fireEvent.click(confirmButton) fireEvent.click(confirmButton)
await fetchMock.flush(true) await waitFor(
expect(fetchMock.done()).to.be.true () =>
expect(
renameProjectMock.called(
`/project/${projectsData[1].id}/rename`
)
).to.be.true
)
screen.findByText(newProjectName) screen.findByText(newProjectName)
expect(screen.queryByText(oldName)).to.be.null expect(screen.queryByText(oldName)).to.be.null
@@ -435,9 +589,11 @@ describe('<ProjectListRoot />', function () {
const tableRows = screen.getAllByRole('row') const tableRows = screen.getAllByRole('row')
const linkForProjectToCopy = within(tableRows[1]).getByRole('link') const linkForProjectToCopy = within(tableRows[1]).getByRole('link')
const projectNameToCopy = linkForProjectToCopy.textContent || '' // needed for type checking const projectNameToCopy = linkForProjectToCopy.textContent || '' // needed for type checking
screen.findByText(projectNameToCopy) // make sure not just empty string screen.getByText(projectNameToCopy) // make sure not just empty string
const copiedProjectName = `${projectNameToCopy} (Copy)` const copiedProjectName = `${projectNameToCopy} (Copy)`
fetchMock.post(`express:/project/:id/clone`, { const cloneProjectMock = fetchMock.post(
`express:/project/:id/clone`,
{
status: 200, status: 200,
body: { body: {
name: copiedProjectName, name: copiedProjectName,
@@ -452,7 +608,8 @@ describe('<ProjectListRoot />', function () {
trashed: false, trashed: false,
archived: false, archived: false,
}, },
}) }
)
await waitFor(() => { await waitFor(() => {
const moreDropdown = const moreDropdown =
@@ -470,13 +627,17 @@ describe('<ProjectListRoot />', function () {
) as HTMLElement ) as HTMLElement
fireEvent.click(copyConfirmButton) fireEvent.click(copyConfirmButton)
await fetchMock.flush(true) await waitFor(
expect(fetchMock.done()).to.be.true () =>
expect(
cloneProjectMock.called(`/project/${projectsData[1].id}/clone`)
).to.be.true
)
expect(sendSpy).to.be.calledOnce expect(sendSpy).to.be.calledOnce
expect(sendSpy).calledWith('project-list-page-interaction') expect(sendSpy).calledWith('project-list-page-interaction')
screen.findByText(copiedProjectName) screen.getByText(copiedProjectName)
}) })
}) })
}) })
@@ -27,6 +27,7 @@ describe('<TagsList />', function () {
name: 'New Tag', name: 'New Tag',
project_ids: [], project_ids: [],
}) })
fetchMock.post('express:/tag/:tagId/projects', 200)
fetchMock.post('express:/tag/:tagId/rename', 200) fetchMock.post('express:/tag/:tagId/rename', 200)
fetchMock.delete('express:/tag/:tagId', 200) fetchMock.delete('express:/tag/:tagId', 200)
@@ -145,7 +146,7 @@ describe('<TagsList />', function () {
await fireEvent.click(createButton) await fireEvent.click(createButton)
await waitFor(() => expect(fetchMock.called(`/tag`))) await waitFor(() => expect(fetchMock.called(`/tag`)).to.be.true)
expect(screen.queryByRole('dialog', { hidden: false })).to.be.null expect(screen.queryByRole('dialog', { hidden: false })).to.be.null
@@ -1,5 +1,5 @@
import { expect } from 'chai' import { expect } from 'chai'
import { fireEvent, screen } from '@testing-library/react' import { fireEvent, screen, waitFor } from '@testing-library/react'
import { ArchiveProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/archive-project-button' import { ArchiveProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/archive-project-button'
import { import {
archiveableProject, archiveableProject,
@@ -44,8 +44,8 @@ describe('<ArchiveProjectButton />', function () {
it('should archive the projects', async function () { it('should archive the projects', async function () {
const project = Object.assign({}, archiveableProject) const project = Object.assign({}, archiveableProject)
fetchMock.post( const archiveProjectMock = fetchMock.post(
`express:/project/${project.id}/archive`, `express:/project/:projectId/archive`,
{ {
status: 200, status: 200,
}, },
@@ -62,14 +62,11 @@ describe('<ArchiveProjectButton />', function () {
const confirmBtn = screen.getByText('Confirm') as HTMLButtonElement const confirmBtn = screen.getByText('Confirm') as HTMLButtonElement
fireEvent.click(confirmBtn) fireEvent.click(confirmBtn)
expect(confirmBtn.disabled).to.be.true expect(confirmBtn.disabled).to.be.true
// verify archived
await fetchMock.flush(true) await waitFor(
expect(fetchMock.done()).to.be.true () =>
const requests = fetchMock.calls() expect(archiveProjectMock.called(`/project/${project.id}/archive`)).to
// first mock call is to get list of projects in projectlistcontext .be.true
const [requestUrl, requestHeaders] = requests[1] )
expect(requestUrl).to.equal(`/project/${project.id}/archive`)
expect(requestHeaders?.method).to.equal('POST')
fetchMock.reset()
}) })
}) })
@@ -1,5 +1,5 @@
import { expect } from 'chai' import { expect } from 'chai'
import { fireEvent, screen } from '@testing-library/react' import { fireEvent, screen, waitFor } from '@testing-library/react'
import { CopyProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/copy-project-button' import { CopyProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/copy-project-button'
import { import {
archivedProject, archivedProject,
@@ -16,6 +16,7 @@ describe('<CopyProjectButton />', function () {
afterEach(function () { afterEach(function () {
resetProjectListContextFetch() resetProjectListContextFetch()
}) })
it('renders tooltip for button', function () { it('renders tooltip for button', function () {
renderWithProjectListContext( renderWithProjectListContext(
<CopyProjectButtonTooltip project={copyableProject} /> <CopyProjectButtonTooltip project={copyableProject} />
@@ -40,8 +41,8 @@ describe('<CopyProjectButton />', function () {
}) })
it('opens the modal and copies the project ', async function () { it('opens the modal and copies the project ', async function () {
fetchMock.post( const copyProjectMock = fetchMock.post(
`express:/project/${copyableProject.id}/clone`, `express:/project/:projectId/clone`,
{ {
status: 200, status: 200,
}, },
@@ -58,14 +59,11 @@ describe('<CopyProjectButton />', function () {
const copyBtn = screen.getByText('Copy') as HTMLButtonElement const copyBtn = screen.getByText('Copy') as HTMLButtonElement
fireEvent.click(copyBtn) fireEvent.click(copyBtn)
expect(copyBtn.disabled).to.be.true expect(copyBtn.disabled).to.be.true
// verify cloned
await fetchMock.flush(true) await waitFor(
expect(fetchMock.done()).to.be.true () =>
const requests = fetchMock.calls() expect(copyProjectMock.called(`/project/${copyableProject.id}/clone`))
// first mock call is to get list of projects in projectlistcontext .to.be.true
const [requestUrl, requestHeaders] = requests[1] )
expect(requestUrl).to.equal(`/project/${copyableProject.id}/clone`)
expect(requestHeaders?.method).to.equal('POST')
fetchMock.reset()
}) })
}) })
@@ -1,5 +1,5 @@
import { expect } from 'chai' import { expect } from 'chai'
import { fireEvent, screen } from '@testing-library/react' import { fireEvent, screen, waitFor } from '@testing-library/react'
import { DeleteProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/delete-project-button' import { DeleteProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/delete-project-button'
import { import {
archiveableProject, archiveableProject,
@@ -46,8 +46,8 @@ describe('<DeleteProjectButton />', function () {
it('opens the modal and deletes the project', async function () { it('opens the modal and deletes the project', async function () {
window.user_id = trashedProject?.owner?.id window.user_id = trashedProject?.owner?.id
const project = Object.assign({}, trashedProject) const project = Object.assign({}, trashedProject)
fetchMock.delete( const deleteProjectMock = fetchMock.delete(
`express:/project/${project.id}`, `express:/project/:projectId`,
{ {
status: 200, status: 200,
}, },
@@ -64,14 +64,10 @@ describe('<DeleteProjectButton />', function () {
const confirmBtn = screen.getByText('Confirm') as HTMLButtonElement const confirmBtn = screen.getByText('Confirm') as HTMLButtonElement
fireEvent.click(confirmBtn) fireEvent.click(confirmBtn)
expect(confirmBtn.disabled).to.be.true expect(confirmBtn.disabled).to.be.true
// verify trashed
await fetchMock.flush(true) await waitFor(
expect(fetchMock.done()).to.be.true () =>
const requests = fetchMock.calls() expect(deleteProjectMock.called(`/project/${project.id}`)).to.be.true
// first request is project list api in projectlistcontext )
const [requestUrl, requestHeaders] = requests[1]
expect(requestUrl).to.equal(`/project/${project.id}`)
expect(requestHeaders?.method).to.equal('DELETE')
fetchMock.reset()
}) })
}) })
@@ -1,5 +1,5 @@
import { expect } from 'chai' import { expect } from 'chai'
import { fireEvent, screen } from '@testing-library/react' import { fireEvent, screen, waitFor } from '@testing-library/react'
import { LeaveProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/leave-project-button' import { LeaveProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/leave-project-button'
import { import {
trashedProject, trashedProject,
@@ -17,6 +17,7 @@ describe('<LeaveProjectButtton />', function () {
afterEach(function () { afterEach(function () {
resetProjectListContextFetch() resetProjectListContextFetch()
}) })
it('renders tooltip for button', function () { it('renders tooltip for button', function () {
renderWithProjectListContext( renderWithProjectListContext(
<LeaveProjectButtonTooltip project={trashedAndNotOwnedProject} /> <LeaveProjectButtonTooltip project={trashedAndNotOwnedProject} />
@@ -51,7 +52,7 @@ describe('<LeaveProjectButtton />', function () {
it('opens the modal and leaves the project', async function () { it('opens the modal and leaves the project', async function () {
const project = Object.assign({}, trashedAndNotOwnedProject) const project = Object.assign({}, trashedAndNotOwnedProject)
fetchMock.post( const leaveProjectMock = fetchMock.post(
`express:/project/${project.id}/leave`, `express:/project/${project.id}/leave`,
{ {
status: 200, status: 200,
@@ -69,14 +70,11 @@ describe('<LeaveProjectButtton />', function () {
const confirmBtn = screen.getByText('Confirm') as HTMLButtonElement const confirmBtn = screen.getByText('Confirm') as HTMLButtonElement
fireEvent.click(confirmBtn) fireEvent.click(confirmBtn)
expect(confirmBtn.disabled).to.be.true expect(confirmBtn.disabled).to.be.true
// verify trashed
await fetchMock.flush(true) await waitFor(
expect(fetchMock.done()).to.be.true () =>
const requests = fetchMock.calls() expect(leaveProjectMock.called(`/project/${project.id}/leave`)).to.be
// first request is project list api in projectlistcontext .true
const [requestUrl, requestHeaders] = requests[1] )
expect(requestUrl).to.equal(`/project/${project.id}/leave`)
expect(requestHeaders?.method).to.equal('POST')
fetchMock.reset()
}) })
}) })
@@ -1,5 +1,5 @@
import { expect } from 'chai' import { expect } from 'chai'
import { fireEvent, screen } from '@testing-library/react' import { fireEvent, screen, waitFor } from '@testing-library/react'
import { TrashProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/trash-project-button' import { TrashProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/trash-project-button'
import { import {
archivedProject, archivedProject,
@@ -34,8 +34,8 @@ describe('<TrashProjectButton />', function () {
it('opens the modal and trashes the project', async function () { it('opens the modal and trashes the project', async function () {
const project = Object.assign({}, archivedProject) const project = Object.assign({}, archivedProject)
fetchMock.post( const trashProjectMock = fetchMock.post(
`express:/project/${project.id}/trash`, `express:/project/:projectId/trash`,
{ {
status: 200, status: 200,
}, },
@@ -52,14 +52,11 @@ describe('<TrashProjectButton />', function () {
const confirmBtn = screen.getByText('Confirm') as HTMLButtonElement const confirmBtn = screen.getByText('Confirm') as HTMLButtonElement
fireEvent.click(confirmBtn) fireEvent.click(confirmBtn)
expect(confirmBtn.disabled).to.be.true expect(confirmBtn.disabled).to.be.true
// verify trashed
await fetchMock.flush(true) await waitFor(
expect(fetchMock.done()).to.be.true () =>
const requests = fetchMock.calls() expect(trashProjectMock.called(`/project/${project.id}/trash`)).to.be
// first request is to get list of projects in projectlistcontext .true
const [requestUrl, requestHeaders] = requests[1] )
expect(requestUrl).to.equal(`/project/${project.id}/trash`)
expect(requestHeaders?.method).to.equal('POST')
fetchMock.reset()
}) })
}) })
@@ -1,5 +1,5 @@
import { expect } from 'chai' import { expect } from 'chai'
import { fireEvent, screen } from '@testing-library/react' import { fireEvent, screen, waitFor } from '@testing-library/react'
import { UnarchiveProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/unarchive-project-button' import { UnarchiveProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/unarchive-project-button'
import { import {
archiveableProject, archiveableProject,
@@ -42,8 +42,8 @@ describe('<UnarchiveProjectButton />', function () {
it('unarchive the project and updates the view data', async function () { it('unarchive the project and updates the view data', async function () {
const project = Object.assign({}, archivedProject) const project = Object.assign({}, archivedProject)
fetchMock.delete( const unarchiveProjectMock = fetchMock.delete(
`express:/project/${project.id}/archive`, `express:/project/:projectId/archive`,
{ {
status: 200, status: 200,
}, },
@@ -55,7 +55,10 @@ describe('<UnarchiveProjectButton />', function () {
const btn = screen.getByLabelText('Restore') const btn = screen.getByLabelText('Restore')
fireEvent.click(btn) fireEvent.click(btn)
await fetchMock.flush(true) await waitFor(
expect(fetchMock.done()).to.be.true () =>
expect(unarchiveProjectMock.called(`/project/${project.id}/archive`)).to
.be.true
)
}) })
}) })
@@ -1,5 +1,5 @@
import { expect } from 'chai' import { expect } from 'chai'
import { fireEvent, screen } from '@testing-library/react' import { fireEvent, screen, waitFor } from '@testing-library/react'
import fetchMock from 'fetch-mock' import fetchMock from 'fetch-mock'
import { UntrashProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/untrash-project-button' import { UntrashProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/untrash-project-button'
import { import {
@@ -12,10 +12,6 @@ import {
} from '../../../../helpers/render-with-context' } from '../../../../helpers/render-with-context'
describe('<UntrashProjectButton />', function () { describe('<UntrashProjectButton />', function () {
beforeEach(function () {
fetchMock.reset()
})
afterEach(function () { afterEach(function () {
resetProjectListContextFetch() resetProjectListContextFetch()
}) })
@@ -38,8 +34,8 @@ describe('<UntrashProjectButton />', function () {
it('untrashes the project and updates the view data', async function () { it('untrashes the project and updates the view data', async function () {
const project = Object.assign({}, trashedProject) const project = Object.assign({}, trashedProject)
fetchMock.delete( const untrashProjectMock = fetchMock.delete(
`express:/project/${project.id}/trash`, `express:/project/:projectId/trash`,
{ {
status: 200, status: 200,
}, },
@@ -51,7 +47,10 @@ describe('<UntrashProjectButton />', function () {
const btn = screen.getByLabelText('Restore') const btn = screen.getByLabelText('Restore')
fireEvent.click(btn) fireEvent.click(btn)
await fetchMock.flush(true) await waitFor(
expect(fetchMock.done()).to.be.true () =>
expect(untrashProjectMock.called(`/project/${project.id}/trash`)).to.be
.true
)
}) })
}) })
@@ -1,4 +1,4 @@
import { fireEvent, screen, within } from '@testing-library/react' import { fireEvent, screen, waitFor, within } from '@testing-library/react'
import { expect } from 'chai' import { expect } from 'chai'
import RenameProjectModal from '../../../../../../../frontend/js/features/project-list/components/modals/rename-project-modal' import RenameProjectModal from '../../../../../../../frontend/js/features/project-list/components/modals/rename-project-modal'
import { import {
@@ -9,14 +9,21 @@ import { currentProjects } from '../../../fixtures/projects-data'
import fetchMock from 'fetch-mock' import fetchMock from 'fetch-mock'
describe('<RenameProjectModal />', function () { describe('<RenameProjectModal />', function () {
beforeEach(function () {
resetProjectListContextFetch()
})
afterEach(function () { afterEach(function () {
resetProjectListContextFetch() resetProjectListContextFetch()
}) })
it('renders the modal and validates new name', async function () { it('renders the modal and validates new name', async function () {
fetchMock.post('express:/project/:projectId/rename', { const renameProjectMock = fetchMock.post(
'express:/project/:projectId/rename',
{
status: 200, status: 200,
}) }
)
renderWithProjectListContext( renderWithProjectListContext(
<RenameProjectModal <RenameProjectModal
handleCloseModal={() => {}} handleCloseModal={() => {}}
@@ -44,14 +51,21 @@ describe('<RenameProjectModal />', function () {
fireEvent.click(submitButton) fireEvent.click(submitButton)
expect(submitButton.disabled).to.be.true expect(submitButton.disabled).to.be.true
await fetchMock.flush(true) await waitFor(
expect(fetchMock.done()).to.be.true () =>
expect(
renameProjectMock.called(`/project/${currentProjects[0].id}/rename`)
).to.be.true
)
}) })
it('shows error message from API', async function () { it('shows error message from API', async function () {
fetchMock.post('express:/project/:projectId/rename', { const postRenameMock = fetchMock.post(
'express:/project/:projectId/rename',
{
status: 500, status: 500,
}) }
)
renderWithProjectListContext( renderWithProjectListContext(
<RenameProjectModal <RenameProjectModal
handleCloseModal={() => {}} handleCloseModal={() => {}}
@@ -70,8 +84,7 @@ describe('<RenameProjectModal />', function () {
const submitButton = within(modal).getByText('Rename') as HTMLButtonElement const submitButton = within(modal).getByText('Rename') as HTMLButtonElement
fireEvent.click(submitButton) fireEvent.click(submitButton)
await fetchMock.flush(true) await waitFor(() => expect(postRenameMock.called()).to.be.true)
expect(fetchMock.done()).to.be.true
screen.getByText('Something went wrong. Please try again.') screen.getByText('Something went wrong. Please try again.')
}) })
@@ -24,6 +24,11 @@ export function renderWithProjectListContext(
body: { projects, totalSize: projects.length }, body: { projects, totalSize: projects.length },
}) })
fetchMock.get('express:/system/messages', {
status: 200,
body: [],
})
const ProjectListProviderWrapper = ({ const ProjectListProviderWrapper = ({
children, children,
}: { }: {
@@ -14,7 +14,9 @@ describe('TagsController', function () {
this.TagsHandler = { this.TagsHandler = {
promises: { promises: {
addProjectToTag: sinon.stub().resolves(), addProjectToTag: sinon.stub().resolves(),
addProjectsToTag: sinon.stub().resolves(),
removeProjectFromTag: sinon.stub().resolves(), removeProjectFromTag: sinon.stub().resolves(),
removeProjectsFromTag: sinon.stub().resolves(),
deleteTag: sinon.stub().resolves(), deleteTag: sinon.stub().resolves(),
renameTag: sinon.stub().resolves(), renameTag: sinon.stub().resolves(),
createTag: sinon.stub().resolves(), createTag: sinon.stub().resolves(),
@@ -40,6 +42,7 @@ describe('TagsController', function () {
_id: userId, _id: userId,
}, },
}, },
body: {},
} }
this.res = {} this.res = {}
@@ -163,6 +166,30 @@ describe('TagsController', function () {
}) })
}) })
it('add projects to a tag', function (done) {
this.req.params.tagId = this.tagId = 'tag-id-123'
this.req.body.projectIds = this.projectIds = [
'project-id-123',
'project-id-234',
]
this.req.session.user._id = this.userId = 'user-id-123'
this.TagsController.addProjectsToTag(this.req, {
status: code => {
assert.equal(code, 204)
sinon.assert.calledWith(
this.TagsHandler.promises.addProjectsToTag,
this.userId,
this.tagId,
this.projectIds
)
done()
return {
end: () => {},
}
},
})
})
it('remove a project from a tag', function (done) { it('remove a project from a tag', function (done) {
this.req.params.tagId = this.tagId = 'tag-id-123' this.req.params.tagId = this.tagId = 'tag-id-123'
this.req.params.projectId = this.projectId = 'project-id-123' this.req.params.projectId = this.projectId = 'project-id-123'
@@ -183,4 +210,28 @@ describe('TagsController', function () {
}, },
}) })
}) })
it('remove projects from a tag', function (done) {
this.req.params.tagId = this.tagId = 'tag-id-123'
this.req.body.projectIds = this.projectIds = [
'project-id-123',
'project-id-234',
]
this.req.session.user._id = this.userId = 'user-id-123'
this.TagsController.removeProjectsFromTag(this.req, {
status: code => {
assert.equal(code, 204)
sinon.assert.calledWith(
this.TagsHandler.promises.removeProjectsFromTag,
this.userId,
this.tagId,
this.projectIds
)
done()
return {
end: () => {},
}
},
})
})
}) })