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-17 12:14:24 +02:00 committed by Copybot
parent 48310f3715
commit caeeedd764
19 changed files with 469 additions and 142 deletions

View file

@ -35,6 +35,14 @@ async function addProjectToTag(req, res) {
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) {
const userId = SessionManager.getLoggedInUserId(req.session)
const { tagId, projectId } = req.params
@ -42,6 +50,14 @@ async function removeProjectFromTag(req, res, next) {
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) {
const userId = SessionManager.getLoggedInUserId(req.session)
const { tagId } = req.params
@ -65,7 +81,9 @@ module.exports = {
getAllTags: expressify(getAllTags),
createTag: expressify(createTag),
addProjectToTag: expressify(addProjectToTag),
addProjectsToTag: expressify(addProjectsToTag),
removeProjectFromTag: expressify(removeProjectFromTag),
removeProjectsFromTag: expressify(removeProjectsFromTag),
deleteTag: expressify(deleteTag),
renameTag: expressify(renameTag),
}

View file

@ -79,6 +79,18 @@ function removeProjectFromTag(userId, tagId, projectId, 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) {
if (!callback) {
callback = function () {}
@ -91,6 +103,18 @@ function addProjectToTag(userId, tagId, projectId, 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) {
if (!callback) {
callback = function () {}
@ -115,8 +139,10 @@ const TagsHandler = {
renameTag,
deleteTag,
updateTagUserIds,
removeProjectFromTag,
addProjectToTag,
addProjectsToTag,
removeProjectFromTag,
removeProjectsFromTag,
addProjectToTagName,
removeProjectFromAllTags,
}

View file

@ -791,6 +791,11 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
maxRequests: 30,
timeInterval: 60,
}),
validate({
body: Joi.object({
name: Joi.string().required(),
}),
}),
TagsController.createTag
)
webRouter.post(
@ -801,6 +806,11 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
maxRequests: 30,
timeInterval: 60,
}),
validate({
body: Joi.object({
name: Joi.string().required(),
}),
}),
TagsController.renameTag
)
webRouter.delete(
@ -823,6 +833,21 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
}),
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(
'/tag/:tagId/project/:projectId',
AuthenticationController.requireLogin(),
@ -833,6 +858,21 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
}),
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(
'/notifications',

View file

@ -75,7 +75,7 @@ export default function CreateTagModal({
className="form-control"
type="text"
placeholder="New Tag Name"
name="new-tag-name"
name="new-tag-form-name"
required
onChange={e => setTagName(e.target.value)}
/>

View file

@ -6,7 +6,7 @@ import ControlledDropdown from '../../../../../../shared/components/controlled-d
import Icon from '../../../../../../shared/components/icon'
import { useProjectListContext } from '../../../../context/project-list-context'
import useTag from '../../../../hooks/use-tag'
import { addProjectToTag, removeProjectFromTag } from '../../../../util/api'
import { addProjectsToTag, removeProjectsFromTag } from '../../../../util/api'
function TagsDropdown() {
const {
@ -30,12 +30,14 @@ function TagsDropdown() {
(e, tagId) => {
e.preventDefault()
const tag = tags.find(tag => tag._id === tagId)
const projectIds = []
for (const selectedProject of selectedProjects) {
if (!tag?.project_ids?.includes(selectedProject.id)) {
addProjectToTagInView(tagId, selectedProject.id)
addProjectToTag(tagId, selectedProject.id)
projectIds.push(selectedProject.id)
}
}
addProjectsToTag(tagId, projectIds)
},
[tags, selectedProjects, addProjectToTagInView]
)
@ -45,8 +47,11 @@ function TagsDropdown() {
e.preventDefault()
for (const selectedProject of selectedProjects) {
removeProjectFromTagInView(tagId, selectedProject.id)
removeProjectFromTag(tagId, selectedProject.id)
}
removeProjectsFromTag(
tagId,
selectedProjects.map(project => project.id)
)
},
[selectedProjects, removeProjectFromTagInView]
)
@ -78,7 +83,11 @@ function TagsDropdown() {
return (
<>
<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" />
</Dropdown.Toggle>
<Dropdown.Menu className="dropdown-menu-right">

View file

@ -6,7 +6,7 @@ import RenameTagModal from '../components/modals/rename-tag-modal'
import DeleteTagModal from '../components/modals/delete-tag-modal'
import EditTagModal from '../components/modals/edit-tag-modal'
import { find } from 'lodash'
import { addProjectToTag } from '../util/api'
import { addProjectsToTag } from '../util/api'
function useTag() {
const {
@ -41,8 +41,11 @@ function useTag() {
addTag(tag)
for (const selectedProject of selectedProjects) {
addProjectToTagInView(tag._id, selectedProject.id)
addProjectToTag(tag._id, selectedProject.id)
}
addProjectsToTag(
tag._id,
selectedProjects.map(project => project.id)
)
},
[addTag, selectedProjects, addProjectToTagInView]
)

View file

@ -25,14 +25,26 @@ export function deleteTag(tagId: string) {
return deleteJSON(`/tag/${tagId}`)
}
export function addProjectToTag(tagId: string, projectId: string) {
return postJSON(`/tag/${tagId}/project/${projectId}`)
export function addProjectsToTag(tagId: string, projectIds: string[]) {
return postJSON(`/tag/${tagId}/projects`, {
body: {
projectIds,
},
})
}
export function removeProjectFromTag(tagId: string, projectId: string) {
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) {
return postJSON(`/project/${projectId}/archive`)
}

View file

@ -6,6 +6,7 @@ import ProjectListRoot from '../../../../../frontend/js/features/project-list/co
import { renderWithProjectListContext } from '../helpers/render-with-context'
import * as eventTracking from '../../../../../frontend/js/infrastructure/event-tracking'
import {
projectsData,
owner,
archivedProjects,
makeLongProjectList,
@ -23,7 +24,15 @@ describe('<ProjectListRoot />', function () {
global.localStorage.clear()
sendSpy = sinon.spy(eventTracking, 'send')
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', {
templateLinks: [],
})
@ -138,16 +147,16 @@ describe('<ProjectListRoot />', function () {
fireEvent.click(confirmBtn)
expect(confirmBtn.disabled).to.be.true
await fetchMock.flush(true)
expect(fetchMock.done()).to.be.true
const requests = fetchMock.calls()
const [projectRequest1Url, projectRequest1Headers] = requests[2]
expect(projectRequest1Url).to.equal(`/project/${project1Id}/archive`)
expect(projectRequest1Headers?.method).to.equal('POST')
const [projectRequest2Url, projectRequest2Headers] = requests[3]
expect(projectRequest2Url).to.equal(`/project/${project2Id}/archive`)
expect(projectRequest2Headers?.method).to.equal('POST')
await waitFor(
() =>
expect(fetchMock.called(`/project/${project1Id}/archive`)).to.be
.true
)
await waitFor(
() =>
expect(fetchMock.called(`/project/${project2Id}/archive`)).to.be
.true
)
})
it('opens trash modal for all selected projects and trashes all', async function () {
@ -173,16 +182,16 @@ describe('<ProjectListRoot />', function () {
fireEvent.click(confirmBtn)
expect(confirmBtn.disabled).to.be.true
await fetchMock.flush(true)
expect(fetchMock.done()).to.be.true
const requests = fetchMock.calls()
const [projectRequest1Url, projectRequest1Headers] = requests[2]
expect(projectRequest1Url).to.equal(`/project/${project1Id}/trash`)
expect(projectRequest1Headers?.method).to.equal('POST')
const [projectRequest2Url, projectRequest2Headers] = requests[3]
expect(projectRequest2Url).to.equal(`/project/${project2Id}/trash`)
expect(projectRequest2Headers?.method).to.equal('POST')
await waitFor(
() =>
expect(fetchMock.called(`/project/${project1Id}/trash`)).to.be
.true
)
await waitFor(
() =>
expect(fetchMock.called(`/project/${project2Id}/trash`)).to.be
.true
)
})
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 () {
beforeEach(async function () {
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 () {
fetchMock.post(`express:/project/:id/rename`, {
status: 200,
})
const renameProjectMock = fetchMock.post(
`express:/project/:id/rename`,
{
status: 200,
}
)
await waitFor(() => {
const moreDropdown =
@ -384,7 +531,8 @@ describe('<ProjectListRoot />', function () {
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)
const modal = screen.getAllByRole('dialog')[0]
@ -419,8 +567,14 @@ describe('<ProjectListRoot />', function () {
expect(confirmButton.disabled).to.be.false
fireEvent.click(confirmButton)
await fetchMock.flush(true)
expect(fetchMock.done()).to.be.true
await waitFor(
() =>
expect(
renameProjectMock.called(
`/project/${projectsData[1].id}/rename`
)
).to.be.true
)
screen.findByText(newProjectName)
expect(screen.queryByText(oldName)).to.be.null
@ -435,24 +589,27 @@ describe('<ProjectListRoot />', function () {
const tableRows = screen.getAllByRole('row')
const linkForProjectToCopy = within(tableRows[1]).getByRole('link')
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)`
fetchMock.post(`express:/project/:id/clone`, {
status: 200,
body: {
name: copiedProjectName,
lastUpdated: new Date(),
project_id: userId,
owner_ref: userId,
owner,
id: '6328e14abec0df019fce0be5',
lastUpdatedBy: owner,
accessLevel: 'owner',
source: 'owner',
trashed: false,
archived: false,
},
})
const cloneProjectMock = fetchMock.post(
`express:/project/:id/clone`,
{
status: 200,
body: {
name: copiedProjectName,
lastUpdated: new Date(),
project_id: userId,
owner_ref: userId,
owner,
id: '6328e14abec0df019fce0be5',
lastUpdatedBy: owner,
accessLevel: 'owner',
source: 'owner',
trashed: false,
archived: false,
},
}
)
await waitFor(() => {
const moreDropdown =
@ -470,13 +627,17 @@ describe('<ProjectListRoot />', function () {
) as HTMLElement
fireEvent.click(copyConfirmButton)
await fetchMock.flush(true)
expect(fetchMock.done()).to.be.true
await waitFor(
() =>
expect(
cloneProjectMock.called(`/project/${projectsData[1].id}/clone`)
).to.be.true
)
expect(sendSpy).to.be.calledOnce
expect(sendSpy).calledWith('project-list-page-interaction')
screen.findByText(copiedProjectName)
screen.getByText(copiedProjectName)
})
})
})

View file

@ -27,6 +27,7 @@ describe('<TagsList />', function () {
name: 'New Tag',
project_ids: [],
})
fetchMock.post('express:/tag/:tagId/projects', 200)
fetchMock.post('express:/tag/:tagId/rename', 200)
fetchMock.delete('express:/tag/:tagId', 200)
@ -145,7 +146,7 @@ describe('<TagsList />', function () {
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

View file

@ -1,5 +1,5 @@
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 {
archiveableProject,
@ -44,8 +44,8 @@ describe('<ArchiveProjectButton />', function () {
it('should archive the projects', async function () {
const project = Object.assign({}, archiveableProject)
fetchMock.post(
`express:/project/${project.id}/archive`,
const archiveProjectMock = fetchMock.post(
`express:/project/:projectId/archive`,
{
status: 200,
},
@ -62,14 +62,11 @@ describe('<ArchiveProjectButton />', function () {
const confirmBtn = screen.getByText('Confirm') as HTMLButtonElement
fireEvent.click(confirmBtn)
expect(confirmBtn.disabled).to.be.true
// verify archived
await fetchMock.flush(true)
expect(fetchMock.done()).to.be.true
const requests = fetchMock.calls()
// first mock call is to get list of projects in projectlistcontext
const [requestUrl, requestHeaders] = requests[1]
expect(requestUrl).to.equal(`/project/${project.id}/archive`)
expect(requestHeaders?.method).to.equal('POST')
fetchMock.reset()
await waitFor(
() =>
expect(archiveProjectMock.called(`/project/${project.id}/archive`)).to
.be.true
)
})
})

View file

@ -1,5 +1,5 @@
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 {
archivedProject,
@ -16,6 +16,7 @@ describe('<CopyProjectButton />', function () {
afterEach(function () {
resetProjectListContextFetch()
})
it('renders tooltip for button', function () {
renderWithProjectListContext(
<CopyProjectButtonTooltip project={copyableProject} />
@ -40,8 +41,8 @@ describe('<CopyProjectButton />', function () {
})
it('opens the modal and copies the project ', async function () {
fetchMock.post(
`express:/project/${copyableProject.id}/clone`,
const copyProjectMock = fetchMock.post(
`express:/project/:projectId/clone`,
{
status: 200,
},
@ -58,14 +59,11 @@ describe('<CopyProjectButton />', function () {
const copyBtn = screen.getByText('Copy') as HTMLButtonElement
fireEvent.click(copyBtn)
expect(copyBtn.disabled).to.be.true
// verify cloned
await fetchMock.flush(true)
expect(fetchMock.done()).to.be.true
const requests = fetchMock.calls()
// first mock call is to get list of projects in projectlistcontext
const [requestUrl, requestHeaders] = requests[1]
expect(requestUrl).to.equal(`/project/${copyableProject.id}/clone`)
expect(requestHeaders?.method).to.equal('POST')
fetchMock.reset()
await waitFor(
() =>
expect(copyProjectMock.called(`/project/${copyableProject.id}/clone`))
.to.be.true
)
})
})

View file

@ -1,5 +1,5 @@
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 {
archiveableProject,
@ -46,8 +46,8 @@ describe('<DeleteProjectButton />', function () {
it('opens the modal and deletes the project', async function () {
window.user_id = trashedProject?.owner?.id
const project = Object.assign({}, trashedProject)
fetchMock.delete(
`express:/project/${project.id}`,
const deleteProjectMock = fetchMock.delete(
`express:/project/:projectId`,
{
status: 200,
},
@ -64,14 +64,10 @@ describe('<DeleteProjectButton />', function () {
const confirmBtn = screen.getByText('Confirm') as HTMLButtonElement
fireEvent.click(confirmBtn)
expect(confirmBtn.disabled).to.be.true
// verify trashed
await fetchMock.flush(true)
expect(fetchMock.done()).to.be.true
const requests = fetchMock.calls()
// 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()
await waitFor(
() =>
expect(deleteProjectMock.called(`/project/${project.id}`)).to.be.true
)
})
})

View file

@ -1,5 +1,5 @@
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 {
trashedProject,
@ -17,6 +17,7 @@ describe('<LeaveProjectButtton />', function () {
afterEach(function () {
resetProjectListContextFetch()
})
it('renders tooltip for button', function () {
renderWithProjectListContext(
<LeaveProjectButtonTooltip project={trashedAndNotOwnedProject} />
@ -51,7 +52,7 @@ describe('<LeaveProjectButtton />', function () {
it('opens the modal and leaves the project', async function () {
const project = Object.assign({}, trashedAndNotOwnedProject)
fetchMock.post(
const leaveProjectMock = fetchMock.post(
`express:/project/${project.id}/leave`,
{
status: 200,
@ -69,14 +70,11 @@ describe('<LeaveProjectButtton />', function () {
const confirmBtn = screen.getByText('Confirm') as HTMLButtonElement
fireEvent.click(confirmBtn)
expect(confirmBtn.disabled).to.be.true
// verify trashed
await fetchMock.flush(true)
expect(fetchMock.done()).to.be.true
const requests = fetchMock.calls()
// first request is project list api in projectlistcontext
const [requestUrl, requestHeaders] = requests[1]
expect(requestUrl).to.equal(`/project/${project.id}/leave`)
expect(requestHeaders?.method).to.equal('POST')
fetchMock.reset()
await waitFor(
() =>
expect(leaveProjectMock.called(`/project/${project.id}/leave`)).to.be
.true
)
})
})

View file

@ -1,5 +1,5 @@
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 {
archivedProject,
@ -34,8 +34,8 @@ describe('<TrashProjectButton />', function () {
it('opens the modal and trashes the project', async function () {
const project = Object.assign({}, archivedProject)
fetchMock.post(
`express:/project/${project.id}/trash`,
const trashProjectMock = fetchMock.post(
`express:/project/:projectId/trash`,
{
status: 200,
},
@ -52,14 +52,11 @@ describe('<TrashProjectButton />', function () {
const confirmBtn = screen.getByText('Confirm') as HTMLButtonElement
fireEvent.click(confirmBtn)
expect(confirmBtn.disabled).to.be.true
// verify trashed
await fetchMock.flush(true)
expect(fetchMock.done()).to.be.true
const requests = fetchMock.calls()
// first request is to get list of projects in projectlistcontext
const [requestUrl, requestHeaders] = requests[1]
expect(requestUrl).to.equal(`/project/${project.id}/trash`)
expect(requestHeaders?.method).to.equal('POST')
fetchMock.reset()
await waitFor(
() =>
expect(trashProjectMock.called(`/project/${project.id}/trash`)).to.be
.true
)
})
})

View file

@ -1,5 +1,5 @@
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 {
archiveableProject,
@ -42,8 +42,8 @@ describe('<UnarchiveProjectButton />', function () {
it('unarchive the project and updates the view data', async function () {
const project = Object.assign({}, archivedProject)
fetchMock.delete(
`express:/project/${project.id}/archive`,
const unarchiveProjectMock = fetchMock.delete(
`express:/project/:projectId/archive`,
{
status: 200,
},
@ -55,7 +55,10 @@ describe('<UnarchiveProjectButton />', function () {
const btn = screen.getByLabelText('Restore')
fireEvent.click(btn)
await fetchMock.flush(true)
expect(fetchMock.done()).to.be.true
await waitFor(
() =>
expect(unarchiveProjectMock.called(`/project/${project.id}/archive`)).to
.be.true
)
})
})

View file

@ -1,5 +1,5 @@
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 { UntrashProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/untrash-project-button'
import {
@ -12,10 +12,6 @@ import {
} from '../../../../helpers/render-with-context'
describe('<UntrashProjectButton />', function () {
beforeEach(function () {
fetchMock.reset()
})
afterEach(function () {
resetProjectListContextFetch()
})
@ -38,8 +34,8 @@ describe('<UntrashProjectButton />', function () {
it('untrashes the project and updates the view data', async function () {
const project = Object.assign({}, trashedProject)
fetchMock.delete(
`express:/project/${project.id}/trash`,
const untrashProjectMock = fetchMock.delete(
`express:/project/:projectId/trash`,
{
status: 200,
},
@ -51,7 +47,10 @@ describe('<UntrashProjectButton />', function () {
const btn = screen.getByLabelText('Restore')
fireEvent.click(btn)
await fetchMock.flush(true)
expect(fetchMock.done()).to.be.true
await waitFor(
() =>
expect(untrashProjectMock.called(`/project/${project.id}/trash`)).to.be
.true
)
})
})

View file

@ -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 RenameProjectModal from '../../../../../../../frontend/js/features/project-list/components/modals/rename-project-modal'
import {
@ -9,14 +9,21 @@ import { currentProjects } from '../../../fixtures/projects-data'
import fetchMock from 'fetch-mock'
describe('<RenameProjectModal />', function () {
beforeEach(function () {
resetProjectListContextFetch()
})
afterEach(function () {
resetProjectListContextFetch()
})
it('renders the modal and validates new name', async function () {
fetchMock.post('express:/project/:projectId/rename', {
status: 200,
})
const renameProjectMock = fetchMock.post(
'express:/project/:projectId/rename',
{
status: 200,
}
)
renderWithProjectListContext(
<RenameProjectModal
handleCloseModal={() => {}}
@ -44,14 +51,21 @@ describe('<RenameProjectModal />', function () {
fireEvent.click(submitButton)
expect(submitButton.disabled).to.be.true
await fetchMock.flush(true)
expect(fetchMock.done()).to.be.true
await waitFor(
() =>
expect(
renameProjectMock.called(`/project/${currentProjects[0].id}/rename`)
).to.be.true
)
})
it('shows error message from API', async function () {
fetchMock.post('express:/project/:projectId/rename', {
status: 500,
})
const postRenameMock = fetchMock.post(
'express:/project/:projectId/rename',
{
status: 500,
}
)
renderWithProjectListContext(
<RenameProjectModal
handleCloseModal={() => {}}
@ -70,8 +84,7 @@ describe('<RenameProjectModal />', function () {
const submitButton = within(modal).getByText('Rename') as HTMLButtonElement
fireEvent.click(submitButton)
await fetchMock.flush(true)
expect(fetchMock.done()).to.be.true
await waitFor(() => expect(postRenameMock.called()).to.be.true)
screen.getByText('Something went wrong. Please try again.')
})

View file

@ -24,6 +24,11 @@ export function renderWithProjectListContext(
body: { projects, totalSize: projects.length },
})
fetchMock.get('express:/system/messages', {
status: 200,
body: [],
})
const ProjectListProviderWrapper = ({
children,
}: {

View file

@ -14,7 +14,9 @@ describe('TagsController', function () {
this.TagsHandler = {
promises: {
addProjectToTag: sinon.stub().resolves(),
addProjectsToTag: sinon.stub().resolves(),
removeProjectFromTag: sinon.stub().resolves(),
removeProjectsFromTag: sinon.stub().resolves(),
deleteTag: sinon.stub().resolves(),
renameTag: sinon.stub().resolves(),
createTag: sinon.stub().resolves(),
@ -40,6 +42,7 @@ describe('TagsController', function () {
_id: userId,
},
},
body: {},
}
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) {
this.req.params.tagId = this.tagId = 'tag-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: () => {},
}
},
})
})
})