mirror of
https://github.com/overleaf/overleaf.git
synced 2025-03-14 03:54:48 +00:00
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:
parent
48310f3715
commit
caeeedd764
19 changed files with 469 additions and 142 deletions
|
@ -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),
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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)}
|
||||
/>
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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]
|
||||
)
|
||||
|
|
|
@ -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`)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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.')
|
||||
})
|
||||
|
|
|
@ -24,6 +24,11 @@ export function renderWithProjectListContext(
|
|||
body: { projects, totalSize: projects.length },
|
||||
})
|
||||
|
||||
fetchMock.get('express:/system/messages', {
|
||||
status: 200,
|
||||
body: [],
|
||||
})
|
||||
|
||||
const ProjectListProviderWrapper = ({
|
||||
children,
|
||||
}: {
|
||||
|
|
|
@ -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: () => {},
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
Loading…
Reference in a new issue