first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -0,0 +1,322 @@
import { expect } from 'chai'
import sinon from 'sinon'
import { fireEvent, render, screen } from '@testing-library/react'
import {
CommonsPlanSubscription,
GroupPlanSubscription,
IndividualPlanSubscription,
} from '../../../../../types/project/dashboard/subscription'
import { DeepReadonly } from '../../../../../types/utils'
import * as eventTracking from '@/infrastructure/event-tracking'
import CurrentPlanWidget from '../../../../../frontend/js/features/project-list/components/current-plan-widget/current-plan-widget'
describe('<CurrentPlanWidget />', function () {
const freePlanTooltipMessage =
/click to find out how you could benefit from overleaf premium features/i
const paidPlanTooltipMessage =
/click to find out how to make the most of your overleaf premium features/i
const pausedTooltipMessage =
/click to unpause and reactivate your overleaf premium features/i
let sendMBSpy: sinon.SinonSpy
beforeEach(function () {
sendMBSpy = sinon.spy(eventTracking, 'sendMB')
})
afterEach(function () {
sendMBSpy.restore()
})
describe('paused', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-usersBestSubscription', {
type: 'individual',
subscription: {
recurlyStatus: {
state: 'paused',
},
},
})
render(<CurrentPlanWidget />)
})
it('shows text and tooltip on mouseover', async function () {
const link = screen.getByRole('link', {
name: /plan is paused/i,
})
fireEvent.mouseOver(link)
await screen.findByRole('tooltip', { name: pausedTooltipMessage })
})
})
describe('free plan', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-usersBestSubscription', {
type: 'free',
})
render(<CurrentPlanWidget />)
})
it('shows text and tooltip on mouseover', async function () {
const link = screen.getByRole('link', {
name: /youre on the free plan/i,
})
fireEvent.mouseOver(link)
await screen.findByRole('tooltip', { name: freePlanTooltipMessage })
})
it('clicks on upgrade button', function () {
const upgradeLink = screen.getByRole('button', { name: /upgrade/i })
fireEvent.click(upgradeLink)
expect(sendMBSpy).to.be.calledOnce
expect(sendMBSpy).calledWith('upgrade-button-click', {
source: 'dashboard-top',
page: '/',
'project-dashboard-react': 'enabled',
'is-dashboard-sidebar-hidden': false,
'is-screen-width-less-than-768px': false,
})
})
})
describe('paid plan', function () {
describe('trial', function () {
const subscription = {
type: 'individual',
plan: {
name: 'Abc',
},
subscription: {
name: 'Example Name',
},
remainingTrialDays: -1,
} as DeepReadonly<IndividualPlanSubscription>
beforeEach(function () {
window.metaAttributesCache.set('ol-usersBestSubscription', {
...subscription,
})
})
it('shows remaining days', function () {
const newSubscription: IndividualPlanSubscription = {
...subscription,
remainingTrialDays: 5,
}
window.metaAttributesCache.set(
'ol-usersBestSubscription',
newSubscription
)
render(<CurrentPlanWidget />)
screen.getByRole('link', {
name: new RegExp(
`${newSubscription.remainingTrialDays} more days on your overleaf premium trial`,
'i'
),
})
})
it('shows last day message', function () {
window.metaAttributesCache.set('ol-usersBestSubscription', {
...subscription,
remainingTrialDays: 1,
})
render(<CurrentPlanWidget />)
screen.getByRole('link', {
name: /this is the last day of your overleaf premium trial/i,
})
})
})
describe('individual', function () {
const subscription = {
type: 'individual',
plan: {
name: 'Abc',
},
subscription: {
teamName: 'Example Team',
name: 'Example Name',
},
remainingTrialDays: -1,
} as DeepReadonly<IndividualPlanSubscription>
beforeEach(function () {
window.metaAttributesCache.set('ol-usersBestSubscription', {
...subscription,
})
})
it('shows text and tooltip on mouseover', async function () {
render(<CurrentPlanWidget />)
const link = screen.getByRole('link', {
name: /youre using overleaf premium/i,
})
fireEvent.mouseOver(link)
await screen.findByRole('tooltip', {
name: new RegExp(`on the ${subscription.plan.name}`, 'i'),
})
await screen.findByRole('tooltip', { name: paidPlanTooltipMessage })
})
})
describe('group', function () {
const subscription = {
type: 'group',
plan: {
name: 'Abc',
},
subscription: {
name: 'Example Name',
},
remainingTrialDays: -1,
} as DeepReadonly<GroupPlanSubscription>
beforeEach(function () {
window.metaAttributesCache.set('ol-usersBestSubscription', {
...subscription,
})
})
it('shows text and tooltip on mouseover (without subscription team name)', async function () {
render(<CurrentPlanWidget />)
const link = screen.getByRole('link', {
name: /youre using overleaf premium/i,
})
fireEvent.mouseOver(link)
expect(subscription.subscription.teamName).to.be.undefined
await screen.findByRole('tooltip', {
name: new RegExp(
`on the ${subscription.plan.name} plan as a member of a group subscription`,
'i'
),
})
await screen.findByRole('tooltip', { name: paidPlanTooltipMessage })
})
it('shows text and tooltip on mouseover (with subscription team name)', async function () {
const newSubscription = {
...subscription,
subscription: {
teamName: 'Example Team',
},
}
window.metaAttributesCache.set(
'ol-usersBestSubscription',
newSubscription
)
render(<CurrentPlanWidget />)
const link = screen.getByRole('link', {
name: /youre using overleaf premium/i,
})
fireEvent.mouseOver(link)
await screen.findByRole('tooltip', {
name: new RegExp(
`on the ${newSubscription.plan.name} plan as a member of a group subscription, ${newSubscription.subscription.teamName}`,
'i'
),
})
await screen.findByRole('tooltip', { name: paidPlanTooltipMessage })
})
})
describe('commons', function () {
it('shows text and tooltip on mouseover', async function () {
const subscription = {
type: 'commons',
plan: {
name: 'Abc',
},
subscription: {
name: 'Example Name',
},
} as DeepReadonly<CommonsPlanSubscription>
window.metaAttributesCache.set('ol-usersBestSubscription', {
...subscription,
})
render(<CurrentPlanWidget />)
const link = screen.getByRole('link', {
name: /youre using overleaf premium/i,
})
fireEvent.mouseOver(link)
await screen.findByRole('tooltip', {
name: new RegExp(
`on the ${subscription.plan.name} plan because of your affiliation with ${subscription.subscription.name}`,
'i'
),
})
await screen.findByRole('tooltip', { name: paidPlanTooltipMessage })
})
})
})
describe('features page', function () {
const plans = [
{ type: 'free' },
{
type: 'individual',
plan: {
name: 'Abc',
},
},
{
type: 'group',
plan: {
name: 'Abc',
},
subscription: {
teamName: 'Example Team',
name: 'Example Name',
},
},
{
type: 'commons',
plan: {
name: 'Abc',
},
subscription: {
name: 'Example Name',
},
},
]
for (const plan of plans) {
it(`links to features page on ${plan.type} plan`, function () {
window.metaAttributesCache.set('ol-usersBestSubscription', {
...plan,
})
render(<CurrentPlanWidget />)
const links = screen.getAllByRole('link')
expect(links[0].getAttribute('href')).to.equal(
'/learn/how-to/Overleaf_premium_features'
)
fireEvent.click(links[0])
window.metaAttributesCache.delete('ol-usersBestSubscription')
})
}
})
})

View File

@@ -0,0 +1,84 @@
import INRBanner from '@/features/project-list/components/notifications/ads/inr-banner'
import customLocalStorage from '@/infrastructure/local-storage'
import { fireEvent, render, screen } from '@testing-library/react'
import { expect } from 'chai'
describe('<INRBanner />', function () {
beforeEach(function () {
customLocalStorage.clear()
})
it('renders correctly', async function () {
render(<INRBanner />)
await screen.findByRole('dialog')
await screen.findByText(
'70% off all Overleaf premium plans for users in India'
)
await screen.findByText(
'Get document history, track changes, additional collaborators, and more at Purchasing Power Parity prices.'
)
await screen.findByRole('button', { name: 'Maybe later' })
await screen.findByRole('button', { name: 'Get discounted plan' })
})
it('dismisses the modal when the "Maybe later" button is clicked', async function () {
render(<INRBanner />)
await screen.findByRole('dialog')
fireEvent.click(screen.getByRole('button', { name: 'Maybe later' }))
expect(screen.queryByRole('dialog')).to.be.null
const dismissedUntil = customLocalStorage.getItem(
'has_dismissed_inr_banner_until'
)
expect(dismissedUntil).to.not.be.null
const nowPlus2Days = new Date()
nowPlus2Days.setDate(nowPlus2Days.getDate() + 2)
// check if dismissal date is around 1 days after the dismissal via "Maybe later" button
expect(new Date(dismissedUntil)).to.be.greaterThan(new Date())
expect(new Date(dismissedUntil)).to.be.lessThan(nowPlus2Days)
})
it('dismisses the modal when close button is clicked', async function () {
render(<INRBanner />)
await screen.findByRole('dialog')
fireEvent.click(screen.getByRole('button', { name: /close/i }))
expect(screen.queryByRole('dialog')).to.be.null
const dismissedUntil = customLocalStorage.getItem(
'has_dismissed_inr_banner_until'
)
expect(dismissedUntil).to.not.be.null
const nowPlus29Days = new Date()
nowPlus29Days.setDate(nowPlus29Days.getDate() + 29)
const nowPlus31Days = new Date()
nowPlus31Days.setDate(nowPlus31Days.getDate() + 31)
// check if dismissal date is around 30 days after the dismissal via close button
expect(new Date(dismissedUntil)).to.be.greaterThan(nowPlus29Days)
expect(new Date(dismissedUntil)).to.be.lessThan(nowPlus31Days)
})
it('hides the modal when user visits while current date is less than local storage date', function () {
const until = new Date()
until.setDate(until.getDate() + 30) // 30 days
customLocalStorage.setItem('has_dismissed_inr_banner_until', until)
render(<INRBanner />)
expect(screen.queryByRole('dialog')).to.be.null
})
})

View File

@@ -0,0 +1,101 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { expect } from 'chai'
import fetchMock from 'fetch-mock'
import LoadMore from '../../../../../frontend/js/features/project-list/components/load-more'
import {
projectsData,
makeLongProjectList,
currentProjects,
} from '../fixtures/projects-data'
import { renderWithProjectListContext } from '../helpers/render-with-context'
describe('<LoadMore />', function () {
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
it('renders on a project list longer than 40', async function () {
const { fullList, currentList } = makeLongProjectList(55)
renderWithProjectListContext(<LoadMore />, {
projects: fullList,
})
await screen.findByRole('button', {
name: /Show 20 more projects/i,
})
await screen.findByText(`Showing 20 out of ${currentList.length} projects.`)
await screen.findByRole('button', {
name: /Show all projects/i,
})
})
it('renders on a project list longer than 20 and shorter than 40', async function () {
const { fullList, currentList } = makeLongProjectList(30)
renderWithProjectListContext(<LoadMore />, { projects: fullList })
await screen.findByRole('button', {
name: new RegExp(`Show ${currentList.length - 20} more projects`, 'i'),
})
await screen.findByText(`Showing 20 out of ${currentList.length} projects.`)
await screen.findByRole('button', {
name: /Show all projects/i,
})
})
it('renders on a project list shorter than 20', async function () {
renderWithProjectListContext(<LoadMore />, { projects: projectsData })
await waitFor(() => {
expect(screen.queryByRole('button', { name: 'Show all' })).to.not.exist
screen.getByText(
`Showing ${currentProjects.length} out of ${currentProjects.length} projects.`
)
})
})
it('change text when pressing the "Show 20 more" once for project list longer than 40', async function () {
const { fullList, currentList } = makeLongProjectList(55)
renderWithProjectListContext(<LoadMore />, { projects: fullList })
await waitFor(() => {
const showMoreBtn = screen.getByRole('button', {
name: /Show 20 more projects/i,
})
fireEvent.click(showMoreBtn)
})
await waitFor(() => {
screen.getByRole('button', {
name: `Show ${currentList.length - 20 - 20} more projects`,
})
screen.getByText(`Showing 40 out of ${currentList.length} projects.`)
})
})
it('change text when pressing the "Show 20 more" once for project list longer than 20 and shorter than 40', async function () {
const { fullList, currentList } = makeLongProjectList(30)
renderWithProjectListContext(<LoadMore />, { projects: fullList })
await waitFor(() => {
const showMoreBtn = screen.getByRole('button', {
name: /Show 7 more projects/i,
})
fireEvent.click(showMoreBtn)
})
await waitFor(() => {
expect(screen.queryByRole('button', { name: /Show/ })).to.not.exist
screen.getByText(
`Showing ${currentList.length} out of ${currentList.length} projects.`
)
})
})
})

View File

@@ -0,0 +1,134 @@
import { fireEvent, screen } from '@testing-library/react'
import { expect } from 'chai'
import fetchMock from 'fetch-mock'
import NewProjectButton from '../../../../../frontend/js/features/project-list/components/new-project-button'
import { renderWithProjectListContext } from '../helpers/render-with-context'
import getMeta from '@/utils/meta'
describe('<NewProjectButton />', function () {
beforeEach(function () {
fetchMock.removeRoutes().clearHistory()
})
describe('for every user (affiliated and non-affiliated)', function () {
beforeEach(function () {
Object.assign(getMeta('ol-ExposedSettings'), {
templateLinks: [
{
name: 'Journal articles',
url: '/gallery/tagged/academic-journal',
},
{
name: 'View All',
url: '/latex/templates',
},
],
})
renderWithProjectListContext(<NewProjectButton id="test" />)
const newProjectButton = screen.getByRole('button', {
name: 'New Project',
})
fireEvent.click(newProjectButton)
})
it('shows the correct dropdown menu', function () {
// static menu
screen.getByText('Blank Project')
screen.getByText('Example Project')
screen.getByText('Upload Project')
screen.getByText('Import from GitHub')
// static text
screen.getByText('Templates')
// dynamic menu based on templateLinks
screen.getByText('Journal articles')
screen.getByText('View All')
})
it('open new project modal when clicking at Blank Project', function () {
fireEvent.click(screen.getByRole('menuitem', { name: 'Blank Project' }))
screen.getByPlaceholderText('Project Name')
})
it('open new project modal when clicking at Example Project', function () {
fireEvent.click(screen.getByRole('menuitem', { name: 'Example Project' }))
screen.getByPlaceholderText('Project Name')
})
it('close the new project modal when clicking at the top right "x" button', function () {
fireEvent.click(screen.getByRole('menuitem', { name: 'Blank Project' }))
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(screen.queryByRole('dialog')).to.be.null
})
it('close the new project modal when clicking at the Cancel button', function () {
fireEvent.click(screen.getByRole('menuitem', { name: 'Blank Project' }))
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(screen.queryByRole('dialog')).to.be.null
})
})
describe('for affiliated user with custom templates', function () {
beforeEach(function () {
Object.assign(getMeta('ol-ExposedSettings'), {
templateLinks: [
{
name: 'Journal articles',
url: '/gallery/tagged/academic-journal',
},
{
name: 'View All',
url: '/latex/templates',
},
],
})
window.metaAttributesCache.set('ol-portalTemplates', [
{
name: 'Affiliation 1',
url: '/edu/test-new-template',
},
])
})
it('shows the correct dropdown menu', function () {
renderWithProjectListContext(<NewProjectButton id="test" />)
const newProjectButton = screen.getByRole('button', {
name: 'New Project',
})
fireEvent.click(newProjectButton)
// static menu
screen.getByText('Blank Project')
screen.getByText('Example Project')
screen.getByText('Upload Project')
screen.getByText('Import from GitHub')
// static text for institution templates
screen.getByText('Institution Templates')
// dynamic menu based on portalTemplates
const affiliationTemplate = screen.getByRole('menuitem', {
name: 'Affiliation 1 Template',
})
expect(affiliationTemplate.getAttribute('href')).to.equal(
'/edu/test-new-template#templates'
)
// static text
screen.getByText('Templates')
// dynamic menu based on templateLinks
screen.getByText('Journal articles')
screen.getByText('View All')
})
})
})

View File

@@ -0,0 +1,150 @@
import { render, fireEvent, screen, waitFor } from '@testing-library/react'
import { expect } from 'chai'
import fetchMock from 'fetch-mock'
import sinon from 'sinon'
import ModalContentNewProjectForm from '../../../../../../frontend/js/features/project-list/components/new-project-button/modal-content-new-project-form'
import * as useLocationModule from '../../../../../../frontend/js/shared/hooks/use-location'
describe('<ModalContentNewProjectForm />', function () {
let assignStub: sinon.SinonStub
beforeEach(function () {
assignStub = sinon.stub()
this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
assign: assignStub,
replace: sinon.stub(),
reload: sinon.stub(),
setHash: sinon.stub(),
})
})
afterEach(function () {
this.locationStub.restore()
fetchMock.removeRoutes().clearHistory()
})
it('submits form', async function () {
const projectId = 'ab123'
const newProjectMock = fetchMock.post('/project/new', {
status: 200,
body: {
project_id: projectId,
},
})
render(<ModalContentNewProjectForm onCancel={() => {}} />)
const createButton = screen.getByRole('button', {
name: 'Create',
})
expect(createButton.getAttribute('disabled')).to.exist
fireEvent.change(screen.getByPlaceholderText('Project Name'), {
target: { value: 'Test Name' },
})
expect(createButton.getAttribute('disabled')).to.be.null
fireEvent.click(createButton)
expect(newProjectMock.callHistory.called()).to.be.true
await waitFor(() => {
sinon.assert.calledOnce(assignStub)
sinon.assert.calledWith(assignStub, `/project/${projectId}`)
})
})
it('shows error when project name contains "/"', async function () {
const errorMessage = 'Project name cannot contain / characters'
const newProjectMock = fetchMock.post('/project/new', {
status: 400,
body: errorMessage,
})
render(<ModalContentNewProjectForm onCancel={() => {}} />)
fireEvent.change(screen.getByPlaceholderText('Project Name'), {
target: { value: '/' },
})
const createButton = screen.getByRole('button', {
name: 'Create',
})
fireEvent.click(createButton)
expect(newProjectMock.callHistory.called()).to.be.true
await waitFor(() => {
screen.getByText(errorMessage)
})
})
it('shows error when project name contains "\\" character', async function () {
const errorMessage = 'Project name cannot contain \\ characters'
const newProjectMock = fetchMock.post('/project/new', {
status: 400,
body: errorMessage,
})
render(<ModalContentNewProjectForm onCancel={() => {}} />)
fireEvent.change(screen.getByPlaceholderText('Project Name'), {
target: { value: '\\' },
})
const createButton = screen.getByRole('button', {
name: 'Create',
})
fireEvent.click(createButton)
expect(newProjectMock.callHistory.called()).to.be.true
await waitFor(() => {
screen.getByText(errorMessage)
})
})
it('shows error when project name is too long ', async function () {
const errorMessage = 'Project name is too long'
const newProjectMock = fetchMock.post('/project/new', {
status: 400,
body: errorMessage,
})
render(<ModalContentNewProjectForm onCancel={() => {}} />)
fireEvent.change(screen.getByPlaceholderText('Project Name'), {
target: {
value: `
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Arcu risus quis varius quam quisque id diam vel quam. Sit amet porttitor eget dolor morbi non arcu risus quis. In aliquam sem fringilla ut. Gravida cum sociis natoque penatibus. Semper risus in hendrerit gravida rutrum quisque non. Ut aliquam purus sit amet luctus venenatis. Neque ornare aenean euismod elementum nisi. Adipiscing bibendum est ultricies integer quis auctor elit. Nulla posuere sollicitudin aliquam ultrices sagittis. Nulla facilisi nullam vehicula ipsum a arcu cursus. Tristique senectus et netus et malesuada fames ac. Pulvinar pellentesque habitant morbi tristique senectus et netus et. Nisi scelerisque eu ultrices vitae auctor eu. Hendrerit gravida rutrum quisque non tellus orci. Volutpat blandit aliquam etiam erat velit scelerisque in dictum non. Donec enim diam vulputate ut pharetra sit amet aliquam id. Ullamcorper eget nulla facilisi etiam.
Enim praesent elementum facilisis leo vel fringilla est. Semper eget duis at tellus. Lacus luctus accumsan tortor posuere ac ut consequat semper viverra. Et malesuada fames ac turpis egestas maecenas pharetra convallis posuere. Ultrices in iaculis nunc sed augue lacus. Tellus orci ac auctor augue mauris augue. Velit scelerisque in dictum non consectetur a erat. Sed turpis tincidunt id aliquet risus. Felis eget velit aliquet sagittis id. Convallis tellus id interdum velit laoreet id.
Habitasse platea dictumst quisque sagittis. Massa sed elementum tempus egestas sed. Cursus eget nunc scelerisque viverra mauris in aliquam sem. Sociis natoque penatibus et magnis dis parturient montes nascetur. Mi in nulla posuere sollicitudin aliquam ultrices sagittis orci a. Fames ac turpis egestas sed tempus urna et pharetra. Pellentesque massa placerat duis ultricies lacus sed turpis tincidunt id. Erat pellentesque adipiscing commodo elit at imperdiet dui. Lectus magna fringilla urna porttitor rhoncus dolor purus non enim. Sagittis nisl rhoncus mattis rhoncus urna neque viverra. Nibh sed pulvinar proin gravida. Sed adipiscing diam donec adipiscing tristique risus nec feugiat in. Elit duis tristique sollicitudin nibh sit amet commodo. Vivamus arcu felis bibendum ut tristique et egestas. Tellus in hac habitasse platea dictumst vestibulum rhoncus est pellentesque. Vitae purus faucibus ornare suspendisse sed. Adipiscing elit duis tristique sollicitudin nibh sit amet commodo nulla.
Vitae congue mauris rhoncus aenean vel elit scelerisque mauris pellentesque. Erat imperdiet sed euismod nisi porta lorem mollis aliquam. Accumsan tortor posuere ac ut consequat semper viverra nam libero. Malesuada fames ac turpis egestas sed tempus urna et. Tellus mauris a diam maecenas sed enim ut sem viverra. Mauris in aliquam sem fringilla ut. Feugiat pretium nibh ipsum consequat. Nisl tincidunt eget nullam non nisi. Tortor consequat id porta nibh. Mattis rhoncus urna neque viverra justo nec ultrices dui sapien. Ac tincidunt vitae semper quis lectus nulla at. Risus quis varius quam quisque id diam. Nisl nunc mi ipsum faucibus vitae aliquet.
Fringilla phasellus faucibus scelerisque eleifend. Eget egestas purus viverra accumsan in nisl nisi scelerisque eu. Mauris commodo quis imperdiet massa tincidunt nunc. Nunc mi ipsum faucibus vitae aliquet nec ullamcorper sit. Elit duis tristique sollicitudin nibh sit amet commodo nulla facilisi. Phasellus egestas tellus rutrum tellus pellentesque eu tincidunt tortor aliquam. Mi sit amet mauris commodo quis imperdiet massa. Urna nec tincidunt praesent semper feugiat nibh sed pulvinar proin. Tempor nec feugiat nisl pretium fusce id velit ut. Morbi tristique senectus et netus et.
Accumsan in nisl nisi scelerisque eu ultrices vitae. Metus vulputate eu scelerisque felis imperdiet proin fermentum leo. Viverra tellus in hac habitasse platea dictumst vestibulum. Non arcu risus quis varius quam quisque id diam. Turpis cursus in hac habitasse platea dictumst. Erat imperdiet sed euismod nisi porta. Eu augue ut lectus arcu bibendum at varius vel pharetra. Aliquam ultrices sagittis orci a scelerisque. Amet consectetur adipiscing elit pellentesque habitant morbi tristique. Lobortis scelerisque fermentum dui faucibus in ornare quam. Commodo sed egestas egestas fringilla phasellus faucibus. Mauris augue neque gravida in fermentum. Ut eu sem integer vitae justo eget magna fermentum. Phasellus egestas tellus rutrum tellus pellentesque eu. Lorem ipsum dolor sit amet consectetur adipiscing. Nulla facilisi morbi tempus iaculis urna id. In egestas erat imperdiet sed euismod nisi porta lorem. Ipsum faucibus vitae aliquet nec ullamcorper sit amet risus.
Feugiat in fermentum posuere urna nec. Elementum eu facilisis sed odio morbi quis commodo. Vel fringilla est ullamcorper eget nulla facilisi. Nunc sed blandit libero volutpat sed cras ornare arcu dui. Tortor id aliquet lectus proin nibh nisl condimentum id venenatis. Sapien pellentesque habitant morbi tristique senectus et. Quam elementum pulvinar etiam non quam lacus suspendisse faucibus. Sem nulla pharetra diam sit amet nisl suscipit adipiscing bibendum. Porttitor leo a diam sollicitudin tempor id. In iaculis nunc sed augue.
Velit euismod in pellentesque massa placerat duis ultricies lacus sed. Dictum fusce ut placerat orci nulla pellentesque dignissim enim. Dui id ornare arcu odio. Dignissim cras tincidunt lobortis feugiat vivamus at augue. Non tellus orci ac auctor. Egestas fringilla phasellus faucibus scelerisque eleifend donec. Nisi vitae suscipit tellus mauris a diam maecenas. Orci dapibus ultrices in iaculis nunc sed. Facilisi morbi tempus iaculis urna id volutpat lacus laoreet non. Aliquam etiam erat velit scelerisque in dictum. Sed enim ut sem viverra. Eleifend donec pretium vulputate sapien nec sagittis. Quisque egestas diam in arcu cursus euismod quis. Faucibus a pellentesque sit amet porttitor eget dolor. Elementum facilisis leo vel fringilla. Pellentesque habitant morbi tristique senectus et netus. Viverra tellus in hac habitasse platea dictumst vestibulum. Tincidunt nunc pulvinar sapien et ligula ullamcorper malesuada proin. Sit amet porttitor eget dolor morbi non. Neque egestas congue quisque egestas.
Convallis posuere morbi leo urna molestie at. Posuere sollicitudin aliquam ultrices sagittis orci. Lacus vestibulum sed arcu non odio. Sit amet dictum sit amet. Nunc scelerisque viverra mauris in aliquam sem fringilla ut morbi. Vestibulum morbi blandit cursus risus at ultrices mi. Purus gravida quis blandit turpis cursus. Diam maecenas sed enim ut. Senectus et netus et malesuada fames ac turpis. Massa tempor nec feugiat nisl pretium fusce id velit. Mollis nunc sed id semper. Elit sed vulputate mi sit. Vitae et leo duis ut diam. Pellentesque sit amet porttitor eget dolor morbi non arcu risus.
Mi quis hendrerit dolor magna eget est lorem. Quam vulputate dignissim suspendisse in est ante in nibh. Nisi porta lorem mollis aliquam. Duis tristique sollicitudin nibh sit amet commodo nulla facilisi nullam. Euismod nisi porta lorem mollis aliquam ut porttitor leo a. Tempus imperdiet nulla malesuada pellentesque elit eget. Amet nisl purus in mollis nunc sed id. Id velit ut tortor pretium viverra suspendisse. Integer quis auctor elit sed. Tortor at risus viverra adipiscing. Ac auctor augue mauris augue neque gravida in. Lacus laoreet non curabitur gravida arcu ac tortor dignissim convallis. A diam sollicitudin tempor id eu nisl nunc mi. Tellus id interdum velit laoreet id donec. Lacus vestibulum sed arcu non odio euismod lacinia. Tellus at urna condimentum mattis.
`,
},
})
const createButton = screen.getByRole('button', {
name: 'Create',
})
fireEvent.click(createButton)
expect(newProjectMock.callHistory.called()).to.be.true
await waitFor(() => {
screen.getByText(errorMessage)
})
})
})

View File

@@ -0,0 +1,105 @@
import UploadProjectModal from '../../../../../../frontend/js/features/project-list/components/new-project-button/upload-project-modal'
describe('<UploadProjectModal />', function () {
const maxUploadSize = 10 * 1024 * 1024 // 10 MB
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-ExposedSettings', { maxUploadSize })
})
})
it('uploads a dropped file', function () {
cy.intercept('post', '/project/new/upload', {
body: { success: true, project_id: '123abc' },
}).as('uploadProject')
cy.mount(
<UploadProjectModal
onHide={cy.stub()}
openProject={cy.stub().as('openProject')}
/>
)
cy.findByRole('button', {
name: 'Select a .zip file',
}).trigger('drop', {
dataTransfer: {
files: [new File(['test'], 'test.zip', { type: 'application/zip' })],
},
})
cy.wait('@uploadProject')
cy.get('@openProject').should('have.been.calledOnceWith', '123abc')
})
it('shows error on file type other than zip', function () {
cy.mount(
<UploadProjectModal
onHide={cy.stub()}
openProject={cy.stub().as('openProject')}
/>
)
cy.findByRole('button', {
name: 'Select a .zip file',
}).trigger('drop', {
dataTransfer: {
files: [new File(['test'], 'test.png', { type: 'image/png' })],
},
})
cy.findByText('You can only upload: .zip')
cy.get('@openProject').should('not.have.been.called')
})
it('shows error for files bigger than maxUploadSize', function () {
cy.mount(
<UploadProjectModal
onHide={cy.stub()}
openProject={cy.stub().as('openProject')}
/>
)
const file = new File(['test'], 'test.zip', { type: 'application/zip' })
Object.defineProperty(file, 'size', { value: maxUploadSize + 1 })
cy.findByRole('button', {
name: 'Select a .zip file',
}).trigger('drop', {
dataTransfer: {
files: [file],
},
})
cy.findByText('test.zip exceeds maximum allowed size of 10 MB')
cy.get('@openProject').should('not.have.been.called')
})
it('handles server error', function () {
cy.intercept('post', '/project/new/upload', {
statusCode: 422,
body: { success: false },
}).as('uploadProject')
cy.mount(
<UploadProjectModal
onHide={cy.stub()}
openProject={cy.stub().as('openProject')}
/>
)
cy.findByRole('button', {
name: 'Select a .zip file',
}).trigger('drop', {
dataTransfer: {
files: [new File(['test'], 'test.zip', { type: 'application/zip' })],
},
})
cy.wait('@uploadProject')
cy.findByText('Upload failed')
cy.get('@openProject').should('not.have.been.called')
})
})

View File

@@ -0,0 +1,79 @@
import { render, screen } from '@testing-library/react'
import { Filter } from '../../../../../frontend/js/features/project-list/context/project-list-context'
import { Tag } from '../../../../../app/src/Features/Tags/types'
import ProjectListTitle from '../../../../../frontend/js/features/project-list/components/title/project-list-title'
describe('<ProjectListTitle />', function () {
type TestCase = {
filter: Filter
selectedTag: Tag | undefined
expectedText: string
selectedTagId: string | undefined
}
const testCases: Array<TestCase> = [
// Filter, without tag
{
filter: 'all',
selectedTag: undefined,
expectedText: 'all projects',
selectedTagId: undefined,
},
{
filter: 'owned',
selectedTag: undefined,
expectedText: 'your projects',
selectedTagId: undefined,
},
{
filter: 'shared',
selectedTag: undefined,
expectedText: 'shared with you',
selectedTagId: undefined,
},
{
filter: 'archived',
selectedTag: undefined,
expectedText: 'archived projects',
selectedTagId: undefined,
},
{
filter: 'trashed',
selectedTag: undefined,
expectedText: 'trashed projects',
selectedTagId: undefined,
},
// Tags
{
filter: 'all',
selectedTag: undefined,
expectedText: 'uncategorized',
selectedTagId: 'uncategorized',
},
{
filter: 'all',
selectedTag: { _id: '', user_id: '', name: 'sometag' },
expectedText: 'sometag',
selectedTagId: '',
},
{
filter: 'shared',
selectedTag: { _id: '', user_id: '', name: 'othertag' },
expectedText: 'othertag',
selectedTagId: '',
},
]
for (const testCase of testCases) {
it(`renders the title text for filter: ${testCase.filter}, tag: ${testCase?.selectedTag?.name}`, function () {
render(
<ProjectListTitle
filter={testCase.filter}
selectedTag={testCase.selectedTag}
selectedTagId={testCase.selectedTagId}
/>
)
screen.getByText(new RegExp(testCase.expectedText, 'i'))
})
}
})

View File

@@ -0,0 +1,155 @@
import sinon from 'sinon'
import { render, screen, fireEvent } from '@testing-library/react'
import { expect } from 'chai'
import SearchForm from '../../../../../frontend/js/features/project-list/components/search-form'
import * as eventTracking from '@/infrastructure/event-tracking'
import fetchMock from 'fetch-mock'
import { Filter } from '../../../../../frontend/js/features/project-list/context/project-list-context'
import { Tag } from '../../../../../app/src/Features/Tags/types'
describe('Project list search form', function () {
let sendMBSpy: sinon.SinonSpy
beforeEach(function () {
sendMBSpy = sinon.spy(eventTracking, 'sendMB')
fetchMock.removeRoutes().clearHistory()
})
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
sendMBSpy.restore()
})
it('renders the search form', function () {
const filter: Filter = 'all'
const selectedTag = undefined
render(
<SearchForm
inputValue=""
setInputValue={() => {}}
filter={filter}
selectedTag={selectedTag}
/>
)
screen.getByRole('search')
screen.getByRole('textbox', { name: /search in all projects/i })
})
it('calls clear text when clear button is clicked', function () {
const filter: Filter = 'all'
const selectedTag = undefined
const setInputValueMock = sinon.stub()
render(
<SearchForm
inputValue="abc"
setInputValue={setInputValueMock}
filter={filter}
selectedTag={selectedTag}
/>
)
const input = screen.getByRole<HTMLInputElement>('textbox', {
name: /search in all projects/i,
})
expect(input.value).to.equal('abc')
const clearBtn = screen.getByRole('button', { name: 'clear search' })
fireEvent.click(clearBtn)
expect(setInputValueMock).to.be.calledWith('')
})
it('changes text', function () {
const setInputValueMock = sinon.stub()
const filter: Filter = 'all'
const selectedTag = undefined
render(
<SearchForm
inputValue=""
setInputValue={setInputValueMock}
filter={filter}
selectedTag={selectedTag}
/>
)
const input = screen.getByRole('textbox', {
name: /search in all projects/i,
})
const value = 'abc'
fireEvent.change(input, { target: { value } })
expect(sendMBSpy).to.have.been.calledOnce
expect(sendMBSpy).to.have.been.calledWith('project-list-page-interaction', {
action: 'search',
page: '/',
isSmallDevice: true,
})
expect(setInputValueMock).to.be.calledWith(value)
})
type TestCase = {
filter: Filter
selectedTag: Tag | undefined
expectedText: string
}
const placeholderTestCases: Array<TestCase> = [
// Filter, without tag
{
filter: 'all',
selectedTag: undefined,
expectedText: 'search in all projects',
},
{
filter: 'owned',
selectedTag: undefined,
expectedText: 'search in your projects',
},
{
filter: 'shared',
selectedTag: undefined,
expectedText: 'search in projects shared with you',
},
{
filter: 'archived',
selectedTag: undefined,
expectedText: 'search in archived projects',
},
{
filter: 'trashed',
selectedTag: undefined,
expectedText: 'search in trashed projects',
},
// Tags
{
filter: 'all',
selectedTag: { _id: '', user_id: '', name: 'sometag' },
expectedText: 'search sometag',
},
{
filter: 'shared',
selectedTag: { _id: '', user_id: '', name: 'othertag' },
expectedText: 'search othertag',
},
]
for (const testCase of placeholderTestCases) {
it(`renders placeholder text for filter:${testCase.filter}, tag:${testCase?.selectedTag?.name}`, function () {
render(
<SearchForm
inputValue=""
setInputValue={() => {}}
filter={testCase.filter}
selectedTag={testCase.selectedTag}
/>
)
screen.getByRole('search')
screen.getByRole('textbox', {
name: new RegExp(testCase.expectedText, 'i'),
})
})
}
})

View File

@@ -0,0 +1,77 @@
import { screen, waitFor } from '@testing-library/react'
import { expect } from 'chai'
import fetchMock from 'fetch-mock'
import { renderWithProjectListContext } from '../../helpers/render-with-context'
import AddAffiliation from '../../../../../../frontend/js/features/project-list/components/add-affiliation'
import { Affiliation } from '../../../../../../types/affiliation'
import getMeta from '@/utils/meta'
describe('Add affiliation widget', function () {
const validateNonExistence = () => {
expect(screen.queryByText(/are you affiliated with an institution/i)).to.be
.null
expect(screen.queryByRole('link', { name: /add affiliation/i })).to.be.null
}
beforeEach(function () {
fetchMock.removeRoutes().clearHistory()
})
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
it('renders the component', async function () {
Object.assign(getMeta('ol-ExposedSettings'), { isOverleaf: true })
window.metaAttributesCache.set('ol-userAffiliations', [])
renderWithProjectListContext(<AddAffiliation />)
await fetchMock.callHistory.flush(true)
await waitFor(() => expect(fetchMock.callHistory.called('/api/project')))
screen.getByText(/are you affiliated with an institution/i)
const addAffiliationLink = screen.getByRole('button', {
name: /add affiliation/i,
})
expect(addAffiliationLink.getAttribute('href')).to.equal('/user/settings')
})
it('does not render when `isOverleaf` is `false`', async function () {
Object.assign(getMeta('ol-ExposedSettings'), { isOverleaf: false })
window.metaAttributesCache.set('ol-userAffiliations', [])
renderWithProjectListContext(<AddAffiliation />)
await fetchMock.callHistory.flush(true)
await waitFor(() => expect(fetchMock.callHistory.called('/api/project')))
validateNonExistence()
})
it('does not render when there no projects', async function () {
Object.assign(getMeta('ol-ExposedSettings'), { isOverleaf: true })
window.metaAttributesCache.set('ol-userAffiliations', [])
renderWithProjectListContext(<AddAffiliation />, {
projects: [],
})
await fetchMock.callHistory.flush(true)
await waitFor(() => expect(fetchMock.callHistory.called('/api/project')))
validateNonExistence()
})
it('does not render when there are affiliations', async function () {
Object.assign(getMeta('ol-ExposedSettings'), { isOverleaf: true })
window.metaAttributesCache.set('ol-userAffiliations', [{} as Affiliation])
renderWithProjectListContext(<AddAffiliation />)
await fetchMock.callHistory.flush(true)
await waitFor(() => expect(fetchMock.callHistory.called('/api/project')))
validateNonExistence()
})
})

View File

@@ -0,0 +1,311 @@
import { fireEvent, screen, waitFor, within } from '@testing-library/react'
import { assert, expect } from 'chai'
import fetchMock from 'fetch-mock'
import TagsList from '../../../../../../frontend/js/features/project-list/components/sidebar/tags-list'
import { projectsData } from '../../fixtures/projects-data'
import { renderWithProjectListContext } from '../../helpers/render-with-context'
describe('<TagsList />', function () {
beforeEach(async function () {
global.localStorage.clear()
window.metaAttributesCache.set('ol-tags', [
{
_id: 'abc123def456',
name: 'Tag 1',
project_ids: [projectsData[0].id],
},
{
_id: 'bcd234efg567',
name: 'Another tag',
project_ids: [projectsData[0].id, projectsData[1].id],
},
])
window.metaAttributesCache.set('ol-ExposedSettings', { isOverleaf: true })
fetchMock.post('/tag', {
_id: 'eee888eee888',
name: 'New Tag',
project_ids: [],
})
fetchMock.post('express:/tag/:tagId/projects', 200)
fetchMock.post('express:/tag/:tagId/edit', 200)
fetchMock.delete('express:/tag/:tagId', 200, { name: 'delete tag' })
renderWithProjectListContext(<TagsList />)
await fetchMock.callHistory.flush(true)
await waitFor(() => expect(fetchMock.callHistory.called('/api/project')))
})
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
it('displays the tags list', function () {
const header = screen.getByTestId('organize-projects')
expect(header.textContent).to.equal('Organize Tags')
screen.getByRole('button', {
name: 'New Tag',
})
screen.getByRole('button', {
name: 'Tag 1 (1)',
})
screen.getByRole('button', {
name: 'Another tag (2)',
})
screen.getByRole('button', {
name: 'Uncategorized (3)',
})
})
it('selects the tag when clicked', async function () {
const tag1Button = screen.getByText('Tag 1')
assert.isFalse(tag1Button.closest('li')?.classList.contains('active'))
await fireEvent.click(tag1Button)
assert.isTrue(tag1Button.closest('li')?.classList.contains('active'))
})
it('selects uncategorized when clicked', function () {
const uncategorizedButton = screen.getByText('Uncategorized')
assert.isFalse(
uncategorizedButton.closest('li')?.classList.contains('active')
)
fireEvent.click(uncategorizedButton)
assert.isTrue(
uncategorizedButton.closest('li')?.classList.contains('active')
)
})
describe('Create modal', function () {
beforeEach(async function () {
const newTagButton = screen.getByRole('button', {
name: 'New Tag',
})
await fireEvent.click(newTagButton)
})
it('modal is open', async function () {
const modal = screen.getAllByRole('dialog', { hidden: false })[0]
within(modal).getByRole('heading', { name: 'Create new tag' })
})
it('click on cancel closes the modal', async function () {
const modal = screen.getAllByRole('dialog', { hidden: false })[0]
const cancelButton = within(modal).getByRole('button', { name: 'Cancel' })
await fireEvent.click(cancelButton)
expect(screen.queryByRole('dialog', { hidden: false })).to.be.null
})
it('Create button is disabled when input is empty', async function () {
const modal = screen.getAllByRole('dialog', { hidden: false })[0]
const createButton = within(modal).getByRole('button', { name: 'Create' })
expect(createButton.hasAttribute('disabled')).to.be.true
})
it('Create button is disabled with error message when tag name is too long', async function () {
const modal = screen.getAllByRole('dialog', { hidden: false })[0]
const input = within(modal).getByRole('textbox')
fireEvent.change(input, {
target: {
value: 'This is a very very very very very very long tag name',
},
})
const createButton = within(modal).getByRole('button', { name: 'Create' })
expect(createButton.hasAttribute('disabled')).to.be.true
screen.getByText('Tag name cannot exceed 50 characters')
})
it('Create button is disabled with error message when tag name is already used', async function () {
const modal = screen.getAllByRole('dialog', { hidden: false })[0]
const input = within(modal).getByRole('textbox')
fireEvent.change(input, {
target: {
value: 'Tag 1',
},
})
const createButton = within(modal).getByRole('button', { name: 'Create' })
expect(createButton.hasAttribute('disabled')).to.be.true
screen.getByText('Tag "Tag 1" already exists')
})
it('filling the input and clicking Create sends a request', async function () {
const modal = screen.getAllByRole('dialog', { hidden: false })[0]
const input = within(modal).getByRole('textbox')
fireEvent.change(input, { target: { value: 'New Tag' } })
const createButton = within(modal).getByRole('button', { name: 'Create' })
expect(createButton.hasAttribute('disabled')).to.be.false
await fireEvent.click(createButton)
await waitFor(
() => expect(fetchMock.callHistory.called(`/tag`)).to.be.true
)
expect(screen.queryByRole('dialog', { hidden: false })).to.be.null
screen.getByRole('button', {
name: 'New Tag (0)',
})
})
})
describe('Edit modal', function () {
beforeEach(async function () {
const tag1Button = screen.getByText('Tag 1')
const dropdownToggle = within(
tag1Button.closest('li') as HTMLElement
).getByTestId('tag-dropdown-toggle')
await fireEvent.click(dropdownToggle)
const editMenuItem = await screen.findByRole('menuitem', { name: 'Edit' })
await fireEvent.click(editMenuItem)
})
it('modal is open', async function () {
const modal = screen.getAllByRole('dialog', { hidden: false })[0]
within(modal).getByRole('heading', { name: 'Edit Tag' })
})
it('click on cancel closes the modal', async function () {
const modal = screen.getAllByRole('dialog', { hidden: false })[0]
const cancelButton = within(modal).getByRole('button', { name: 'Cancel' })
await fireEvent.click(cancelButton)
expect(screen.queryByRole('dialog', { hidden: false })).to.be.null
})
it('Save button is disabled when input is empty', async function () {
const modal = screen.getAllByRole('dialog', { hidden: false })[0]
const input = within(modal).getByRole('textbox')
fireEvent.change(input, {
target: {
value: '',
},
})
const saveButton = within(modal).getByRole('button', { name: 'Save' })
expect(saveButton.hasAttribute('disabled')).to.be.true
})
it('Save button is disabled with error message when tag name is too long', async function () {
const modal = screen.getAllByRole('dialog', { hidden: false })[0]
const input = within(modal).getByRole('textbox')
fireEvent.change(input, {
target: {
value: 'This is a very very very very very very long tag name',
},
})
const saveButton = within(modal).getByRole('button', { name: 'Save' })
expect(saveButton.hasAttribute('disabled')).to.be.true
screen.getByText('Tag name cannot exceed 50 characters')
})
it('Save button is disabled with no error message when tag name is unchanged', async function () {
const modal = screen.getAllByRole('dialog', { hidden: false })[0]
const saveButton = within(modal).getByRole('button', { name: 'Save' })
expect(saveButton.hasAttribute('disabled')).to.be.true
})
it('Save button is disabled with error message when tag name is already used', async function () {
const modal = screen.getAllByRole('dialog', { hidden: false })[0]
const input = within(modal).getByRole('textbox')
fireEvent.change(input, {
target: {
value: 'Another tag',
},
})
const saveButton = within(modal).getByRole('button', { name: 'Save' })
expect(saveButton.hasAttribute('disabled')).to.be.true
screen.getByText('Tag "Another tag" already exists')
})
it('filling the input and clicking Save sends a request', async function () {
const modal = screen.getAllByRole('dialog', { hidden: false })[0]
const input = within(modal).getByRole('textbox')
fireEvent.change(input, { target: { value: 'New Tag Name' } })
const saveButton = within(modal).getByRole('button', { name: 'Save' })
expect(saveButton.hasAttribute('disabled')).to.be.false
await fireEvent.click(saveButton)
await waitFor(() =>
expect(fetchMock.callHistory.called(`/tag/abc123def456/rename`))
)
expect(screen.queryByRole('dialog', { hidden: false })).to.be.null
screen.getByRole('button', {
name: 'New Tag Name (1)',
})
})
})
describe('Delete modal', function () {
beforeEach(async function () {
const tag1Button = screen.getByText('Tag 1')
const dropdownToggle = within(
tag1Button.closest('li') as HTMLElement
).getByTestId('tag-dropdown-toggle')
await fireEvent.click(dropdownToggle)
const deleteMenuItem = await screen.findByRole('menuitem', {
name: 'Delete',
})
await fireEvent.click(deleteMenuItem)
})
it('modal is open', async function () {
const modal = screen.getAllByRole('dialog', { hidden: false })[0]
within(modal).getByRole('heading', { name: 'Delete Tag' })
})
it('click on Cancel closes the modal', async function () {
const modal = screen.getAllByRole('dialog', { hidden: false })[0]
const cancelButton = within(modal).getByRole('button', { name: 'Cancel' })
await fireEvent.click(cancelButton)
expect(screen.queryByRole('dialog', { hidden: false })).to.be.null
})
it('clicking Delete sends a request', async function () {
const modal = screen.getAllByRole('dialog', { hidden: false })[0]
const deleteButton = within(modal).getByRole('button', { name: 'Delete' })
await fireEvent.click(deleteButton)
await waitFor(() =>
expect(fetchMock.callHistory.called(`/tag/bcd234efg567`))
)
expect(screen.queryByRole('dialog', { hidden: false })).to.be.null
expect(
screen.queryByRole('button', {
name: 'Another Tag (2)',
})
).to.be.null
})
it('a failed request displays an error message', async function () {
fetchMock.modifyRoute('delete tag', { response: { status: 500 } })
const modal = screen.getAllByRole('dialog', { hidden: false })[0]
const deleteButton = within(modal).getByRole('button', { name: 'Delete' })
await fireEvent.click(deleteButton)
await waitFor(() =>
expect(fetchMock.callHistory.called(`/tag/bcd234efg567`))
)
within(modal).getByText('Sorry, something went wrong')
})
})
})

View File

@@ -0,0 +1,105 @@
import { expect } from 'chai'
import { fireEvent, render, screen } from '@testing-library/react'
import { SurveyWidgetDsNav } from '../../../../../frontend/js/features/project-list/components/survey-widget-ds-nav'
import { SplitTestProvider } from '@/shared/context/split-test-context'
describe('<SurveyWidgetDsNav />', function () {
beforeEach(function () {
this.name = 'my-survey'
this.preText = 'To help shape the future of Overleaf'
this.linkText = 'Click here!'
this.url = 'https://example.com/my-survey'
localStorage.clear()
})
describe('survey widget is visible', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-survey', {
name: this.name,
preText: this.preText,
linkText: this.linkText,
url: this.url,
})
render(
<SplitTestProvider>
<SurveyWidgetDsNav />
</SplitTestProvider>
)
})
it('shows text and link', function () {
const dismissed = localStorage.getItem('dismissed-my-survey')
expect(dismissed).to.equal(null)
screen.getByText(this.preText)
screen.getByText(this.linkText)
const link = screen.getByRole('button', {
name: 'Take survey',
}) as HTMLAnchorElement
expect(link.href).to.equal(this.url)
})
it('it is dismissed on click on the dismiss button', function () {
const dismissButton = screen.getByRole('button', {
name: 'Close',
})
fireEvent.click(dismissButton)
const text = screen.queryByText(this.preText)
expect(text).to.be.null
const link = screen.queryByRole('button')
expect(link).to.be.null
const dismissed = localStorage.getItem('dismissed-my-survey')
expect(dismissed).to.equal('true')
})
})
describe('survey widget is not shown when already dismissed', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-survey', {
name: this.name,
preText: this.preText,
linkText: this.linkText,
url: this.url,
})
localStorage.setItem('dismissed-my-survey', 'true')
render(
<SplitTestProvider>
<SurveyWidgetDsNav />
</SplitTestProvider>
)
})
it('nothing is displayed', function () {
const text = screen.queryByText(this.preText)
expect(text).to.be.null
const link = screen.queryByRole('button')
expect(link).to.be.null
})
})
describe('survey widget is not shown when no survey is configured', function () {
beforeEach(function () {
render(
<SplitTestProvider>
<SurveyWidgetDsNav />
</SplitTestProvider>
)
})
it('nothing is displayed', function () {
const text = screen.queryByText(this.preText)
expect(text).to.be.null
const link = screen.queryByRole('button')
expect(link).to.be.null
})
})
})

View File

@@ -0,0 +1,84 @@
import { expect } from 'chai'
import { render, screen, fireEvent } from '@testing-library/react'
import fetchMock from 'fetch-mock'
import SystemMessages from '@/shared/components/system-messages'
describe('<SystemMessages />', function () {
beforeEach(function () {
fetchMock.removeRoutes().clearHistory()
localStorage.clear()
})
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
localStorage.clear()
})
it('renders non-dismissable system message', async function () {
const data = {
_id: 'protected',
content: 'Random content',
}
fetchMock.get(/\/system\/messages/, [data])
render(<SystemMessages />)
await fetchMock.callHistory.flush(true)
screen.getByText(data.content)
expect(screen.queryByRole('button', { name: /close/i })).to.be.null
})
it('renders and closes dismissable system message', async function () {
const data = {
_id: 1,
content: 'Random content',
}
fetchMock.get(/\/system\/messages/, [data])
render(<SystemMessages />)
await fetchMock.callHistory.flush(true)
screen.getByText(data.content)
const closeBtn = screen.getByRole('button', { name: /close/i })
fireEvent.click(closeBtn)
expect(screen.queryByText(data.content)).to.be.null
const dismissed = localStorage.getItem(`systemMessage.hide.${data._id}`)
expect(dismissed).to.equal('true')
})
it('renders and closes translation message', async function () {
const data = {
url: '/dev/null',
lngName: 'German',
imgUrl: 'https://flagcdn.com/w40/de.png',
}
const currentUrl = '/project'
fetchMock.get(/\/system\/messages/, [])
window.metaAttributesCache.set('ol-suggestedLanguage', data)
window.metaAttributesCache.set('ol-currentUrl', currentUrl)
render(<SystemMessages />)
await fetchMock.callHistory.flush(true)
const link = screen.getByRole('link', { name: /click here/i })
expect(link.getAttribute('href')).to.equal(`${data.url}${currentUrl}`)
const flag = screen.getByRole('img', { hidden: true })
expect(flag.getAttribute('src')).to.equal(data.imgUrl)
const closeBtn = screen.getByRole('button', { name: /close/i })
fireEvent.click(closeBtn)
expect(
screen.queryByRole('link', {
name: `Click here to use Overleaf in ${data.lngName}`,
})
).to.be.null
expect(screen.queryByRole('img')).to.be.null
const dismissed = localStorage.getItem('hide-i18n-notification')
expect(dismissed).to.equal('true')
})
})

View File

@@ -0,0 +1,77 @@
import { expect } from 'chai'
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,
archivedProject,
} from '../../../../fixtures/projects-data'
import fetchMock from 'fetch-mock'
import {
resetProjectListContextFetch,
renderWithProjectListContext,
} from '../../../../helpers/render-with-context'
describe('<ArchiveProjectButton />', function () {
afterEach(function () {
resetProjectListContextFetch()
})
it('renders tooltip for button', async function () {
renderWithProjectListContext(
<ArchiveProjectButtonTooltip project={archiveableProject} />
)
const btn = screen.getByRole('button', { name: 'Archive' })
fireEvent.mouseOver(btn)
await screen.findByRole('tooltip', { name: 'Archive' })
})
it('opens the modal when clicked', function () {
renderWithProjectListContext(
<ArchiveProjectButtonTooltip project={archiveableProject} />
)
const btn = screen.getByRole('button', { name: 'Archive' })
fireEvent.click(btn)
screen.getByText('Archive Projects')
screen.getByText(archiveableProject.name)
})
it('does not render the button when already archived', function () {
renderWithProjectListContext(
<ArchiveProjectButtonTooltip project={archivedProject} />
)
expect(screen.queryByRole('button', { name: 'Archive' })).to.be.null
})
it('should archive the projects', async function () {
const project = Object.assign({}, archiveableProject)
const archiveProjectMock = fetchMock.post(
`express:/project/:projectId/archive`,
{
status: 200,
},
{ delay: 0 }
)
renderWithProjectListContext(
<ArchiveProjectButtonTooltip project={project} />
)
const btn = screen.getByRole('button', { name: 'Archive' })
fireEvent.click(btn)
screen.getByText('Archive Projects')
screen.getByText('You are about to archive the following projects:')
screen.getByText('Archiving projects wont affect your collaborators.')
const confirmBtn = screen.getByRole('button', {
name: 'Confirm',
}) as HTMLButtonElement
fireEvent.click(confirmBtn)
expect(confirmBtn.disabled).to.be.true
await waitFor(
() =>
expect(
archiveProjectMock.callHistory.called(
`/project/${project.id}/archive`
)
).to.be.true
)
})
})

View File

@@ -0,0 +1,93 @@
import { expect } from 'chai'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import sinon from 'sinon'
import { projectsData } from '../../../../fixtures/projects-data'
import * as useLocationModule from '../../../../../../../../frontend/js/shared/hooks/use-location'
import { CompileAndDownloadProjectPDFButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/compile-and-download-project-pdf-button'
import fetchMock from 'fetch-mock'
import * as eventTracking from '@/infrastructure/event-tracking'
describe('<CompileAndDownloadProjectPDFButton />', function () {
let assignStub: sinon.SinonStub
let locationStub: sinon.SinonStub
let sendMBSpy: sinon.SinonSpy
beforeEach(function () {
sendMBSpy = sinon.spy(eventTracking, 'sendMB')
assignStub = sinon.stub()
locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
assign: assignStub,
replace: sinon.stub(),
reload: sinon.stub(),
setHash: sinon.stub(),
})
render(
<CompileAndDownloadProjectPDFButtonTooltip project={projectsData[0]} />
)
})
afterEach(function () {
locationStub.restore()
fetchMock.removeRoutes().clearHistory()
sendMBSpy.restore()
})
it('renders tooltip for button', async function () {
const btn = screen.getByRole('button', { name: 'Download PDF' })
fireEvent.mouseOver(btn)
await screen.findByRole('tooltip', { name: 'Download PDF' })
})
it('downloads the project PDF when clicked', async function () {
fetchMock.post(
`/project/${projectsData[0].id}/compile`,
{
status: 'success',
compileGroup: 'standard',
clsiServerId: 'server-1',
outputFiles: [{ path: 'output.pdf', build: '123-321' }],
},
{ delay: 10 }
)
const btn = screen.getByRole('button', { name: 'Download PDF' })
fireEvent.click(btn)
await waitFor(() => {
screen.getByRole('button', { name: 'Compiling…' })
})
await waitFor(() => {
expect(assignStub).to.have.been.called
})
expect(assignStub).to.have.been.calledOnce
expect(assignStub).to.have.been.calledWith(
`/download/project/${projectsData[0].id}/build/123-321/output/output.pdf?compileGroup=standard&popupDownload=true&clsiserverid=server-1`
)
expect(sendMBSpy).to.have.been.calledOnce
expect(sendMBSpy).to.have.been.calledWith('project-list-page-interaction', {
action: 'downloadPDF',
page: '/',
projectId: projectsData[0].id,
isSmallDevice: true,
})
})
it('displays a modal when the compile failed', async function () {
fetchMock.post(`/project/${projectsData[0].id}/compile`, {
status: 'failure',
})
const btn = screen.getByRole('button', {
name: 'Download PDF',
}) as HTMLButtonElement
fireEvent.click(btn)
await waitFor(() => {
screen.getByText(`${projectsData[0].name}: PDF unavailable for download`)
})
expect(assignStub).to.have.not.been.called
})
})

View File

@@ -0,0 +1,76 @@
import { expect } from 'chai'
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,
copyableProject,
trashedProject,
} from '../../../../fixtures/projects-data'
import {
renderWithProjectListContext,
resetProjectListContextFetch,
} from '../../../../helpers/render-with-context'
import fetchMock from 'fetch-mock'
describe('<CopyProjectButton />', function () {
afterEach(function () {
resetProjectListContextFetch()
})
it('renders tooltip for button', async function () {
renderWithProjectListContext(
<CopyProjectButtonTooltip project={copyableProject} />
)
const btn = screen.getByRole('button', { name: 'Copy' })
fireEvent.mouseOver(btn)
await screen.findByRole('tooltip', { name: 'Copy' })
})
it('does not render the button when project is archived', function () {
renderWithProjectListContext(
<CopyProjectButtonTooltip project={archivedProject} />
)
expect(screen.queryByRole('button', { name: 'Copy' })).to.be.null
})
it('does not render the button when project is trashed', function () {
renderWithProjectListContext(
<CopyProjectButtonTooltip project={trashedProject} />
)
expect(screen.queryByRole('button', { name: 'Copy' })).to.be.null
})
it('opens the modal and copies the project', async function () {
const copyProjectMock = fetchMock.post(
`express:/project/:projectId/clone`,
{
status: 200,
},
{ delay: 0 }
)
renderWithProjectListContext(
<CopyProjectButtonTooltip project={copyableProject} />
)
const btn = screen.getByRole('button', { name: 'Copy' })
fireEvent.click(btn)
screen.getByText('Copy Project')
screen.getByLabelText('New Name')
screen.getByDisplayValue(`${copyableProject.name} (Copy)`)
const copyBtn = screen.getAllByRole<HTMLButtonElement>('button', {
name: 'Copy',
})[1]
fireEvent.click(copyBtn)
expect(copyBtn.disabled).to.be.true
await waitFor(
() =>
expect(
copyProjectMock.callHistory.called(
`/project/${copyableProject.id}/clone`
)
).to.be.true
)
})
})

View File

@@ -0,0 +1,76 @@
import { expect } from 'chai'
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,
trashedAndNotOwnedProject,
trashedProject,
} from '../../../../fixtures/projects-data'
import fetchMock from 'fetch-mock'
import {
renderWithProjectListContext,
resetProjectListContextFetch,
} from '../../../../helpers/render-with-context'
describe('<DeleteProjectButton />', function () {
afterEach(function () {
resetProjectListContextFetch()
})
it('renders tooltip for button', async function () {
window.metaAttributesCache.set('ol-user_id', trashedProject.owner?.id)
renderWithProjectListContext(
<DeleteProjectButtonTooltip project={trashedProject} />
)
const btn = screen.getByRole('button', { name: 'Delete' })
fireEvent.mouseOver(btn)
await screen.findByRole('tooltip', { name: 'Delete' })
})
it('does not render button when trashed and not owner', function () {
window.metaAttributesCache.set('ol-user_id', '123abc')
renderWithProjectListContext(
<DeleteProjectButtonTooltip project={trashedAndNotOwnedProject} />
)
const btn = screen.queryByRole('button', { name: 'Delete' })
expect(btn).to.be.null
})
it('does not render the button when project is current', function () {
renderWithProjectListContext(
<DeleteProjectButtonTooltip project={archiveableProject} />
)
expect(screen.queryByRole('button', { name: 'Delete' })).to.be.null
})
it('opens the modal and deletes the project', async function () {
window.metaAttributesCache.set('ol-user_id', trashedProject.owner?.id)
const project = Object.assign({}, trashedProject)
const deleteProjectMock = fetchMock.delete(
`express:/project/:projectId`,
{
status: 200,
},
{ delay: 0 }
)
renderWithProjectListContext(
<DeleteProjectButtonTooltip project={project} />
)
const btn = screen.getByRole('button', { name: 'Delete' })
fireEvent.click(btn)
screen.getByText('Delete Projects')
screen.getByText('You are about to delete the following projects:')
screen.getByText('This action cannot be undone.')
const confirmBtn = screen.getByRole('button', {
name: 'Confirm',
}) as HTMLButtonElement
fireEvent.click(confirmBtn)
expect(confirmBtn.disabled).to.be.true
await waitFor(
() =>
expect(deleteProjectMock.callHistory.called(`/project/${project.id}`))
.to.be.true
)
})
})

View File

@@ -0,0 +1,49 @@
import { expect } from 'chai'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import sinon from 'sinon'
import { DownloadProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/download-project-button'
import { projectsData } from '../../../../fixtures/projects-data'
import * as useLocationModule from '../../../../../../../../frontend/js/shared/hooks/use-location'
describe('<DownloadProjectButton />', function () {
let assignStub: sinon.SinonStub
beforeEach(function () {
assignStub = sinon.stub()
this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
assign: assignStub,
replace: sinon.stub(),
reload: sinon.stub(),
setHash: sinon.stub(),
})
render(<DownloadProjectButtonTooltip project={projectsData[0]} />)
})
afterEach(function () {
this.locationStub.restore()
})
it('renders tooltip for button', async function () {
const btn = screen.getByRole('button', { name: 'Download .zip file' })
fireEvent.mouseOver(btn)
await screen.findByRole('tooltip', { name: 'Download .zip file' })
})
it('downloads the project when clicked', async function () {
const btn = screen.getByRole('button', {
name: 'Download .zip file',
}) as HTMLButtonElement
fireEvent.click(btn)
await waitFor(() => {
expect(assignStub).to.have.been.called
})
sinon.assert.calledOnce(assignStub)
sinon.assert.calledWithMatch(
assignStub,
`/project/${projectsData[0].id}/download/zip`
)
})
})

View File

@@ -0,0 +1,83 @@
import { expect } from 'chai'
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,
trashedAndNotOwnedProject,
archivedProject,
archiveableProject,
} from '../../../../fixtures/projects-data'
import fetchMock from 'fetch-mock'
import {
renderWithProjectListContext,
resetProjectListContextFetch,
} from '../../../../helpers/render-with-context'
describe('<LeaveProjectButtton />', function () {
afterEach(function () {
resetProjectListContextFetch()
})
it('renders tooltip for button', async function () {
renderWithProjectListContext(
<LeaveProjectButtonTooltip project={trashedAndNotOwnedProject} />
)
const btn = screen.getByRole('button', { name: 'Leave' })
fireEvent.mouseOver(btn)
await screen.findByRole('tooltip', { name: 'Leave' })
})
it('does not render button when owner', function () {
window.metaAttributesCache.set('ol-user_id', trashedProject.owner?.id)
renderWithProjectListContext(
<LeaveProjectButtonTooltip project={trashedProject} />
)
const btn = screen.queryByRole('button', { name: 'Leave' })
expect(btn).to.be.null
})
it('does not render the button when project is archived', function () {
renderWithProjectListContext(
<LeaveProjectButtonTooltip project={archivedProject} />
)
expect(screen.queryByRole('button', { name: 'Leave' })).to.be.null
})
it('does not render the button when project is current', function () {
renderWithProjectListContext(
<LeaveProjectButtonTooltip project={archiveableProject} />
)
expect(screen.queryByRole('button', { name: 'Leave' })).to.be.null
})
it('opens the modal and leaves the project', async function () {
const project = Object.assign({}, trashedAndNotOwnedProject)
const leaveProjectMock = fetchMock.post(
`express:/project/${project.id}/leave`,
{
status: 200,
},
{ delay: 0 }
)
renderWithProjectListContext(
<LeaveProjectButtonTooltip project={project} />
)
const btn = screen.getByRole('button', { name: 'Leave' })
fireEvent.click(btn)
screen.getByText('Leave Projects')
screen.getByText('You are about to leave the following projects:')
screen.getByText('This action cannot be undone.')
const confirmBtn = screen.getByRole('button', {
name: 'Confirm',
}) as HTMLButtonElement
fireEvent.click(confirmBtn)
expect(confirmBtn.disabled).to.be.true
await waitFor(
() =>
expect(
leaveProjectMock.callHistory.called(`/project/${project.id}/leave`)
).to.be.true
)
})
})

View File

@@ -0,0 +1,71 @@
import RenameProjectButton from '@/features/project-list/components/table/cells/action-buttons/rename-project-button'
import {
renderWithProjectListContext,
resetProjectListContextFetch,
} from '../../../../helpers/render-with-context'
import { ownedProject, sharedProject } from '../../../../fixtures/projects-data'
import { Project } from '../../../../../../../../types/project/dashboard/api'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import fetchMock from 'fetch-mock'
import { expect } from 'chai'
// Little test jig for rendering the button
function renderWithProject(project: Project) {
renderWithProjectListContext(
<RenameProjectButton project={project}>
{(text, onClick) => {
return <button onClick={onClick}>Rename Project Button</button>
}}
</RenameProjectButton>
)
}
describe('<RenameProjectButton />', function () {
afterEach(function () {
resetProjectListContextFetch()
})
it('opens the modal when clicked', function () {
renderWithProject(ownedProject)
const btn = screen.getByRole('button')
fireEvent.click(btn)
screen.getByText('Rename Project')
screen.getByDisplayValue(ownedProject.name)
})
it('does not render the button when already archived', function () {
renderWithProject(sharedProject)
expect(screen.queryByRole('button')).to.be.null
})
it('should rename the project', async function () {
const project = Object.assign({}, ownedProject)
const renameProjectMock = fetchMock.post(
`express:/project/:projectId/rename`,
{
status: 200,
},
{ delay: 0 }
)
renderWithProject(ownedProject)
const btn = screen.getByRole('button')
fireEvent.click(btn)
screen.getByText('Rename Project')
const confirmBtn = screen.getByRole('button', {
name: 'Rename',
}) as HTMLButtonElement
expect(confirmBtn.disabled).to.be.true
const nameInput = screen.getByDisplayValue(ownedProject.name)
fireEvent.change(nameInput, { target: { value: 'new name' } })
expect(confirmBtn.disabled).to.be.false
fireEvent.click(confirmBtn)
expect(confirmBtn.disabled).to.be.true
await waitFor(
() =>
expect(
renameProjectMock.callHistory.called(`/project/${project.id}/rename`)
).to.be.true
)
})
})

View File

@@ -0,0 +1,65 @@
import { expect } from 'chai'
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,
trashedProject,
} from '../../../../fixtures/projects-data'
import fetchMock from 'fetch-mock'
import {
renderWithProjectListContext,
resetProjectListContextFetch,
} from '../../../../helpers/render-with-context'
describe('<TrashProjectButton />', function () {
afterEach(function () {
resetProjectListContextFetch()
})
it('renders tooltip for button', async function () {
renderWithProjectListContext(
<TrashProjectButtonTooltip project={archivedProject} />
)
const btn = screen.getByRole('button', { name: 'Trash' })
fireEvent.mouseOver(btn)
await screen.findByRole('tooltip', { name: 'Trash' })
})
it('does not render the button when project is trashed', function () {
renderWithProjectListContext(
<TrashProjectButtonTooltip project={trashedProject} />
)
expect(screen.queryByRole('button', { name: 'Trash' })).to.be.null
})
it('opens the modal and trashes the project', async function () {
const project = Object.assign({}, archivedProject)
const trashProjectMock = fetchMock.post(
`express:/project/:projectId/trash`,
{
status: 200,
},
{ delay: 0 }
)
renderWithProjectListContext(
<TrashProjectButtonTooltip project={project} />
)
const btn = screen.getByRole('button', { name: 'Trash' })
fireEvent.click(btn)
screen.getByText('Trash Projects')
screen.getByText('You are about to trash the following projects:')
screen.getByText('Trashing projects wont affect your collaborators.')
const confirmBtn = screen.getByRole('button', {
name: 'Confirm',
}) as HTMLButtonElement
fireEvent.click(confirmBtn)
expect(confirmBtn.disabled).to.be.true
await waitFor(
() =>
expect(
trashProjectMock.callHistory.called(`/project/${project.id}/trash`)
).to.be.true
)
})
})

View File

@@ -0,0 +1,67 @@
import { expect } from 'chai'
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,
archivedProject,
trashedProject,
} from '../../../../fixtures/projects-data'
import fetchMock from 'fetch-mock'
import {
resetProjectListContextFetch,
renderWithProjectListContext,
} from '../../../../helpers/render-with-context'
describe('<UnarchiveProjectButton />', function () {
afterEach(function () {
resetProjectListContextFetch()
})
it('renders tooltip for button', async function () {
renderWithProjectListContext(
<UnarchiveProjectButtonTooltip project={archivedProject} />
)
const btn = screen.getByRole('button', { name: 'Restore' })
fireEvent.mouseOver(btn)
await screen.findByRole('tooltip', { name: 'Restore' })
})
it('does not render the button when project is trashed', function () {
renderWithProjectListContext(
<UnarchiveProjectButtonTooltip project={trashedProject} />
)
expect(screen.queryByRole('button', { name: 'Restore' })).to.be.null
})
it('does not render the button when project is current', function () {
renderWithProjectListContext(
<UnarchiveProjectButtonTooltip project={archiveableProject} />
)
expect(screen.queryByRole('button', { name: 'Restore' })).to.be.null
})
it('unarchive the project and updates the view data', async function () {
const project = Object.assign({}, archivedProject)
const unarchiveProjectMock = fetchMock.delete(
`express:/project/:projectId/archive`,
{
status: 200,
},
{ delay: 0 }
)
renderWithProjectListContext(
<UnarchiveProjectButtonTooltip project={project} />
)
const btn = screen.getByRole('button', { name: 'Restore' })
fireEvent.click(btn)
await waitFor(
() =>
expect(
unarchiveProjectMock.callHistory.called(
`/project/${project.id}/archive`
)
).to.be.true
)
})
})

View File

@@ -0,0 +1,57 @@
import { expect } from 'chai'
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 {
archiveableProject,
trashedProject,
} from '../../../../fixtures/projects-data'
import {
renderWithProjectListContext,
resetProjectListContextFetch,
} from '../../../../helpers/render-with-context'
describe('<UntrashProjectButton />', function () {
afterEach(function () {
resetProjectListContextFetch()
})
it('renders tooltip for button', async function () {
renderWithProjectListContext(
<UntrashProjectButtonTooltip project={trashedProject} />
)
const btn = screen.getByRole('button', { name: 'Restore' })
fireEvent.mouseOver(btn)
await screen.findByRole('tooltip', { name: 'Restore' })
})
it('does not render the button when project is current', function () {
renderWithProjectListContext(
<UntrashProjectButtonTooltip project={archiveableProject} />
)
expect(screen.queryByRole('button', { name: 'Restore' })).to.be.null
})
it('untrashes the project and updates the view data', async function () {
const project = Object.assign({}, trashedProject)
const untrashProjectMock = fetchMock.delete(
`express:/project/:projectId/trash`,
{
status: 200,
},
{ delay: 0 }
)
renderWithProjectListContext(
<UntrashProjectButtonTooltip project={project} />
)
const btn = screen.getByRole('button', { name: 'Restore' })
fireEvent.click(btn)
await waitFor(
() =>
expect(
untrashProjectMock.callHistory.called(`/project/${project.id}/trash`)
).to.be.true
)
})
})

View File

@@ -0,0 +1,74 @@
import { expect } from 'chai'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import fetchMock from 'fetch-mock'
import {
resetProjectListContextFetch,
renderWithProjectListContext,
} from '../../../helpers/render-with-context'
import InlineTags from '../../../../../../../frontend/js/features/project-list/components/table/cells/inline-tags'
import {
archivedProject,
copyableProject,
} from '../../../fixtures/projects-data'
describe('<InlineTags />', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-tags', [
{
_id: '789fff789fff',
name: 'My Test Tag',
project_ids: [copyableProject.id, archivedProject.id],
},
{
_id: '555eee555eee',
name: 'Tag 2',
project_ids: [copyableProject.id],
},
{
_id: '444ddd444ddd',
name: 'Tag 3',
project_ids: [archivedProject.id],
},
])
this.projectId = copyableProject.id
})
afterEach(function () {
resetProjectListContextFetch()
})
it('renders tags list for a project', function () {
renderWithProjectListContext(<InlineTags projectId={this.projectId} />)
screen.getByText('My Test Tag')
screen.getByText('Tag 2')
expect(screen.queryByText('Tag 3')).to.not.exist
})
it('handles removing a project from a tag', async function () {
fetchMock.delete(
`express:/tag/789fff789fff/project/${copyableProject.id}`,
{
status: 204,
},
{ delay: 0 }
)
renderWithProjectListContext(<InlineTags projectId={this.projectId} />)
const removeButton = screen.getByRole('button', {
name: 'Remove tag My Test Tag',
})
await fireEvent.click(removeButton)
await waitFor(() =>
expect(
fetchMock.callHistory.called(
`/tag/789fff789fff/project/${copyableProject.id}`,
{
method: 'DELETE',
}
)
)
)
expect(screen.queryByText('My Test Tag')).to.not.exist
screen.getByText('Tag 2')
})
})

View File

@@ -0,0 +1,183 @@
import { screen, within, fireEvent } from '@testing-library/react'
import { expect } from 'chai'
import ProjectListTable from '../../../../../../frontend/js/features/project-list/components/table/project-list-table'
import { currentProjects } from '../../fixtures/projects-data'
import fetchMock from 'fetch-mock'
import { renderWithProjectListContext } from '../../helpers/render-with-context'
const userId = '624333f147cfd8002622a1d3'
describe('<ProjectListTable />', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-tags', [])
window.metaAttributesCache.set('ol-user_id', userId)
fetchMock.removeRoutes().clearHistory()
})
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
it('renders the table', function () {
renderWithProjectListContext(<ProjectListTable />)
screen.getByRole('table')
})
it('sets aria-sort on column header currently sorted', function () {
renderWithProjectListContext(<ProjectListTable />)
let foundSortedColumn = false
const columns = screen.getAllByRole('columnheader')
columns.forEach(col => {
if (col.getAttribute('aria-label') === 'Last Modified') {
expect(col.getAttribute('aria-sort')).to.equal('descending')
foundSortedColumn = true
} else {
expect(col.getAttribute('aria-sort')).to.be.null
}
})
expect(foundSortedColumn).to.be.true
})
it('keeps the order type when selecting different column for sorting', function () {
renderWithProjectListContext(<ProjectListTable />)
const lastModifiedBtn = screen.getByRole('button', {
name: /last modified/i,
})
const lastModifiedCol = lastModifiedBtn.closest('th')
expect(lastModifiedCol?.getAttribute('aria-sort')).to.equal('descending')
const ownerBtn = screen.getByRole('button', { name: /owner/i })
const ownerCol = ownerBtn.closest('th')
expect(ownerCol?.getAttribute('aria-sort')).to.be.null
fireEvent.click(ownerBtn)
expect(ownerCol?.getAttribute('aria-sort')).to.equal('descending')
fireEvent.click(ownerBtn)
expect(ownerCol?.getAttribute('aria-sort')).to.equal('ascending')
})
it('renders buttons for sorting all sortable columns', function () {
renderWithProjectListContext(<ProjectListTable />)
screen.getByRole('button', { name: 'Sort by Title' })
screen.getByRole('button', { name: 'Sort by Owner' })
screen.getByRole('button', { name: 'Reverse Last Modified sort order' }) // currently sorted
})
it('renders project title, owner, last modified, and action buttons', async function () {
this.timeout(10000)
renderWithProjectListContext(<ProjectListTable />)
await fetchMock.callHistory.flush(true)
const rows = screen.getAllByRole('row')
rows.shift() // remove first row since it's the header
expect(rows.length).to.equal(currentProjects.length)
// Project name cell
currentProjects.forEach(project => {
screen.getByText(project.name)
})
// Owner Column and Last Modified Column
const row1 = screen
.getByRole('cell', { name: currentProjects[0].name })
.closest('tr')!
within(row1).getByText('You')
within(row1).getAllByText('a day ago by Jean-Luc Picard', { exact: false })
const row2 = screen
.getByRole('cell', { name: currentProjects[1].name })
.closest('tr')!
within(row2).getByText('Jean-Luc Picard')
within(row2).getAllByText('7 days ago by Jean-Luc Picard')
const row3 = screen
.getByRole('cell', { name: currentProjects[2].name })
.closest('tr')!
within(row3).getByText('worf@overleaf.com')
within(row3).getAllByText('a month ago by worf@overleaf.com')
// link sharing project
const row4 = screen
.getByRole('cell', { name: currentProjects[3].name })
.closest('tr')!
within(row4).getByText('La Forge')
within(row4).getByText('Link sharing')
within(row4).getAllByText('2 months ago by La Forge')
// link sharing read only, so it will not show an owner
const row5 = screen
.getByRole('cell', { name: currentProjects[4].name })
.closest('tr')!
within(row5).getByText('Link sharing')
within(row5).getAllByText('2 years ago')
// Action Column
// temporary count tests until we add filtering for archived/trashed
const copyButtons = screen.getAllByRole('button', {
name: 'Copy',
})
expect(copyButtons.length).to.equal(currentProjects.length)
const downloadButtons = screen.getAllByRole('button', {
name: 'Download .zip file',
})
expect(downloadButtons.length).to.equal(currentProjects.length)
const downloadPDFButtons = screen.getAllByRole('button', {
name: 'Download PDF',
})
expect(downloadPDFButtons.length).to.equal(currentProjects.length)
const archiveButtons = screen.getAllByRole('button', {
name: 'Archive',
})
expect(archiveButtons.length).to.equal(currentProjects.length)
const trashButtons = screen.getAllByRole('button', {
name: 'Trash',
})
expect(trashButtons.length).to.equal(currentProjects.length)
// TODO to be implemented when the component renders trashed & archived projects
// const restoreButtons = screen.getAllByLabelText('Restore')
// expect(restoreButtons.length).to.equal(2)
// const deleteButtons = screen.getAllByLabelText('Delete')
// expect(deleteButtons.length).to.equal(1)
})
it('selects all projects when header checkbox checked', async function () {
renderWithProjectListContext(<ProjectListTable />)
await fetchMock.callHistory.flush(true)
const checkbox = screen.getByLabelText('Select all projects')
fireEvent.click(checkbox)
const allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
// + 1 because of select all checkbox
expect(allCheckboxesChecked.length).to.equal(currentProjects.length + 1)
})
it('unselects all projects when select all checkbox uchecked', async function () {
renderWithProjectListContext(<ProjectListTable />)
await fetchMock.callHistory.flush(true)
const checkbox = screen.getByLabelText('Select all projects')
fireEvent.click(checkbox)
fireEvent.click(checkbox)
const allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
expect(allCheckboxesChecked.length).to.equal(0)
})
it('unselects select all projects checkbox when one project is unchecked', async function () {
renderWithProjectListContext(<ProjectListTable />)
await fetchMock.callHistory.flush(true)
const checkbox = screen.getByLabelText('Select all projects')
fireEvent.click(checkbox)
let allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
expect(allCheckboxes[1].getAttribute('data-project-id')).to.exist // make sure we are unchecking a project checkbox
fireEvent.click(allCheckboxes[1])
allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
expect(allCheckboxesChecked.length).to.equal(currentProjects.length - 1)
})
it('only checks the checked project', async function () {
renderWithProjectListContext(<ProjectListTable />)
await fetchMock.callHistory.flush(true)
const checkbox = screen.getByLabelText(`Select ${currentProjects[0].name}`)
fireEvent.click(checkbox)
const allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
expect(allCheckboxesChecked.length).to.equal(1)
})
})

View File

@@ -0,0 +1,26 @@
import { fireEvent, screen } from '@testing-library/react'
import ArchiveProjectsButton from '../../../../../../../../frontend/js/features/project-list/components/table/project-tools/buttons/archive-projects-button'
import {
resetProjectListContextFetch,
renderWithProjectListContext,
} from '../../../../helpers/render-with-context'
describe('<ArchiveProjectsButton />', function () {
afterEach(function () {
resetProjectListContextFetch()
})
it('renders tooltip for button', async function () {
renderWithProjectListContext(<ArchiveProjectsButton />)
const btn = screen.getByRole('button', { name: 'Archive' })
fireEvent.mouseOver(btn)
await screen.findByRole('tooltip', { name: 'Archive' })
})
it('opens the modal when clicked', function () {
renderWithProjectListContext(<ArchiveProjectsButton />)
const btn = screen.getByRole('button', { name: 'Archive' })
fireEvent.click(btn)
screen.getByText('Archive Projects')
})
})

View File

@@ -0,0 +1,29 @@
import { fireEvent, screen, render } from '@testing-library/react'
import DeleteLeaveProjectsButton from '../../../../../../../../frontend/js/features/project-list/components/table/project-tools/buttons/delete-leave-projects-button'
import { makeLongProjectList } from '../../../../fixtures/projects-data'
import {
ProjectListContext,
ProjectListContextValue,
} from '../../../../../../../../frontend/js/features/project-list/context/project-list-context'
const { deletableList, leavableList } = makeLongProjectList(40)
describe('<DeleteLeaveProjectsButton />', function () {
it('opens the modal when clicked', function () {
const value = {
selectedProjects: [...deletableList, ...leavableList],
hasDeletableProjectsSelected: true,
hasLeavableProjectsSelected: true,
} as ProjectListContextValue
render(
<ProjectListContext.Provider value={value}>
<DeleteLeaveProjectsButton />
</ProjectListContext.Provider>
)
const btn = screen.getByRole('button', { name: /delete \/ leave/i })
fireEvent.click(btn)
screen.getByRole('heading', { name: /delete and leave projects/i })
})
})

View File

@@ -0,0 +1,29 @@
import { fireEvent, screen, render } from '@testing-library/react'
import DeleteProjectsButton from '../../../../../../../../frontend/js/features/project-list/components/table/project-tools/buttons/delete-projects-button'
import { makeLongProjectList } from '../../../../fixtures/projects-data'
import {
ProjectListContext,
ProjectListContextValue,
} from '../../../../../../../../frontend/js/features/project-list/context/project-list-context'
const { deletableList } = makeLongProjectList(40)
describe('<DeleteProjectsButton />', function () {
it('opens the modal when clicked', function () {
const value = {
selectedProjects: deletableList,
hasDeletableProjectsSelected: true,
hasLeavableProjectsSelected: false,
} as ProjectListContextValue
render(
<ProjectListContext.Provider value={value}>
<DeleteProjectsButton />
</ProjectListContext.Provider>
)
const btn = screen.getByRole('button', { name: /delete/i })
fireEvent.click(btn)
screen.getByRole('heading', { name: /delete projects/i })
})
})

View File

@@ -0,0 +1,19 @@
import { fireEvent, screen } from '@testing-library/react'
import DownloadProjectsButton from '../../../../../../../../frontend/js/features/project-list/components/table/project-tools/buttons/download-projects-button'
import {
resetProjectListContextFetch,
renderWithProjectListContext,
} from '../../../../helpers/render-with-context'
describe('<DownloadProjectsButton />', function () {
afterEach(function () {
resetProjectListContextFetch()
})
it('renders tooltip for button', async function () {
renderWithProjectListContext(<DownloadProjectsButton />)
const btn = screen.getByRole('button', { name: 'Download' })
fireEvent.mouseOver(btn)
await screen.findByRole('tooltip', { name: 'Download' })
})
})

View File

@@ -0,0 +1,28 @@
import { fireEvent, screen, render } from '@testing-library/react'
import LeaveProjectsButton from '../../../../../../../../frontend/js/features/project-list/components/table/project-tools/buttons/leave-projects-button'
import { makeLongProjectList } from '../../../../fixtures/projects-data'
import {
ProjectListContext,
ProjectListContextValue,
} from '../../../../../../../../frontend/js/features/project-list/context/project-list-context'
const { leavableList } = makeLongProjectList(40)
describe('<LeaveProjectsButton />', function () {
it('opens the modal when clicked', function () {
const value = {
selectedProjects: leavableList,
hasDeletableProjectsSelected: false,
hasLeavableProjectsSelected: true,
} as ProjectListContextValue
render(
<ProjectListContext.Provider value={value}>
<LeaveProjectsButton />
</ProjectListContext.Provider>
)
const btn = screen.getByRole('button', { name: /leave/i })
fireEvent.click(btn)
screen.getByRole('heading', { name: /leave projects/i })
})
})

View File

@@ -0,0 +1,26 @@
import { fireEvent, screen } from '@testing-library/react'
import TrashProjectsButton from '../../../../../../../../frontend/js/features/project-list/components/table/project-tools/buttons/trash-projects-button'
import {
resetProjectListContextFetch,
renderWithProjectListContext,
} from '../../../../helpers/render-with-context'
describe('<TrashProjectsButton />', function () {
afterEach(function () {
resetProjectListContextFetch()
})
it('renders tooltip for button', async function () {
renderWithProjectListContext(<TrashProjectsButton />)
const btn = screen.getByRole('button', { name: 'Trash' })
fireEvent.mouseOver(btn)
await screen.findByRole('tooltip', { name: 'Trash' })
})
it('opens the modal when clicked', function () {
renderWithProjectListContext(<TrashProjectsButton />)
const btn = screen.getByRole('button', { name: 'Trash' })
fireEvent.click(btn)
screen.getByText('Trash Projects')
})
})

View File

@@ -0,0 +1,91 @@
import { render, screen, within } from '@testing-library/react'
import { expect } from 'chai'
import moment from 'moment/moment'
import fetchMock from 'fetch-mock'
import { Project } from '../../../../../../../types/project/dashboard/api'
import { ProjectListRootInner } from '@/features/project-list/components/project-list-root'
const users = {
picard: {
id: '62d6d0b4c5c5030a4d696c7a',
email: 'picard@overleaf.com',
firstName: 'Jean-Luc',
lastName: 'Picard',
},
riker: {
id: '624333f147cfd8002622a1d3',
email: 'riker@overleaf.com',
firstName: 'William',
lastName: 'Riker',
},
}
const projects: Project[] = [
{
id: '62f17f594641b405ca2b3264',
name: 'Starfleet Report (owner)',
lastUpdated: moment().subtract(1, 'day').toISOString(),
lastUpdatedBy: users.riker,
accessLevel: 'owner',
source: 'owner',
archived: false,
trashed: false,
owner: users.picard,
},
{
id: '62f17f594641b405ca2b3265',
name: 'Starfleet Report (readAndWrite)',
lastUpdated: moment().subtract(1, 'day').toISOString(),
lastUpdatedBy: users.picard,
accessLevel: 'readAndWrite',
source: 'owner',
archived: false,
trashed: false,
owner: users.riker,
},
]
describe('<ProjectTools />', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-user', {})
window.metaAttributesCache.set('ol-prefetchedProjectsBlob', {
projects,
totalSize: 100,
})
window.metaAttributesCache.set('ol-footer', {
showThinFooter: false,
translatedLanguages: { en: 'English' },
subdomainLang: { en: { lngCode: 'en', url: 'overleaf.com' } },
})
window.metaAttributesCache.set('ol-navbar', {
items: [],
})
fetchMock.get('/system/messages', [])
})
afterEach(function () {
window.metaAttributesCache.clear()
fetchMock.removeRoutes().clearHistory()
})
it('does not show the Rename option for a project owned by a different user', function () {
render(<ProjectListRootInner />)
screen.getByLabelText('Select Starfleet Report (readAndWrite)').click()
screen.getByRole('button', { name: 'More' }).click()
expect(
within(
screen.getByTestId('project-tools-more-dropdown-menu')
).queryByRole('menuitem', { name: 'Rename' })
).to.be.null
})
it('displays the Rename option for a project owned by the current user', function () {
render(<ProjectListRootInner />)
screen.getByLabelText('Select Starfleet Report (owner)').click()
screen.getByRole('button', { name: 'More' }).click()
within(screen.getByTestId('project-tools-more-dropdown-menu'))
.getByRole('menuitem', { name: 'Rename' })
.click()
within(screen.getByRole('dialog')).getByText('Rename Project')
})
})

View File

@@ -0,0 +1,32 @@
import { screen } from '@testing-library/react'
import { expect } from 'chai'
import ProjectTools from '../../../../../../../frontend/js/features/project-list/components/table/project-tools/project-tools'
import {
renderWithProjectListContext,
resetProjectListContextFetch,
} from '../../../helpers/render-with-context'
describe('<ProjectTools />', function () {
afterEach(function () {
resetProjectListContextFetch()
})
it('renders the project tools for the all projects filter', async function () {
renderWithProjectListContext(<ProjectTools />)
const initialButtons = screen.getAllByRole('button')
expect(initialButtons).to.have.length(4)
expect(screen.getByLabelText('Download')).to.exist
expect(screen.getByLabelText('Archive')).to.exist
expect(screen.getByLabelText('Trash')).to.exist
expect(screen.getByLabelText('Tags')).to.exist
expect(screen.queryByText('Create new tag')).to.not.exist
screen.getByLabelText('Tags').click()
const createTagButton = await screen.findByText('Create new tag')
expect(createTagButton).to.exist
})
})

View File

@@ -0,0 +1,97 @@
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 {
renderWithProjectListContext,
resetProjectListContextFetch,
} from '../../../helpers/render-with-context'
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 () {
const renameProjectMock = fetchMock.post(
'express:/project/:projectId/rename',
{
status: 200,
}
)
renderWithProjectListContext(
<RenameProjectModal
handleCloseModal={() => {}}
showModal
project={currentProjects[0]}
/>
)
screen.getByText('Rename Project')
const input = screen.getByRole('textbox', {
name: 'New Name',
}) as HTMLInputElement
expect(input.value).to.equal(currentProjects[0].name)
const submitButton = screen.getByRole('button', {
name: 'Rename',
}) as HTMLButtonElement
expect(submitButton.disabled).to.be.true
fireEvent.change(input, {
target: { value: '' },
})
expect(submitButton.disabled).to.be.true
fireEvent.change(input, {
target: { value: 'A new name' },
})
expect(submitButton.disabled).to.be.false
fireEvent.click(submitButton)
expect(submitButton.disabled).to.be.true
await waitFor(
() =>
expect(
renameProjectMock.callHistory.called(
`/project/${currentProjects[0].id}/rename`
)
).to.be.true
)
})
it('shows error message from API', async function () {
const postRenameMock = fetchMock.post(
'express:/project/:projectId/rename',
{
status: 500,
}
)
renderWithProjectListContext(
<RenameProjectModal
handleCloseModal={() => {}}
showModal
project={currentProjects[0]}
/>
)
screen.getByText('Rename Project')
const input = screen.getByLabelText('New Name') as HTMLButtonElement
expect(input.value).to.equal(currentProjects[0].name)
fireEvent.change(input, {
target: { value: 'A new name' },
})
const modal = screen.getAllByRole('dialog')[0]
const submitButton = within(modal).getByText('Rename') as HTMLButtonElement
fireEvent.click(submitButton)
await waitFor(() => expect(postRenameMock.callHistory.called()).to.be.true)
screen.getByText('Something went wrong. Please try again.')
})
})

View File

@@ -0,0 +1,99 @@
import { expect } from 'chai'
import sinon from 'sinon'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import ProjectsActionModal from '../../../../../../frontend/js/features/project-list/components/modals/projects-action-modal'
import { projectsData } from '../../fixtures/projects-data'
import {
resetProjectListContextFetch,
renderWithProjectListContext,
} from '../../helpers/render-with-context'
import * as eventTracking from '@/infrastructure/event-tracking'
describe('<ProjectsActionModal />', function () {
const actionHandler = sinon.stub().resolves({})
let sendMBSpy: sinon.SinonSpy
beforeEach(function () {
sendMBSpy = sinon.spy(eventTracking, 'sendMB')
})
afterEach(function () {
sendMBSpy.restore()
resetProjectListContextFetch()
})
it('should handle the action passed', async function () {
renderWithProjectListContext(
<ProjectsActionModal
action="archive"
actionHandler={actionHandler}
projects={[projectsData[0], projectsData[1]]}
handleCloseModal={() => {}}
showModal
/>
)
const confirmBtn = screen.getByRole('button', {
name: 'Confirm',
}) as HTMLButtonElement
fireEvent.click(confirmBtn)
expect(confirmBtn.disabled).to.be.true
// verify action handled
await waitFor(() => expect(actionHandler.callCount).to.equal(2))
})
it('should show an error message for all actions that fail', async function () {
actionHandler
.withArgs(projectsData[2])
.rejects(new Error('Something went wrong. Please try again.'))
actionHandler
.withArgs(projectsData[3])
.rejects(new Error('Something went wrong. Please try again.'))
renderWithProjectListContext(
<ProjectsActionModal
action="archive"
actionHandler={actionHandler}
projects={[
projectsData[0],
projectsData[1],
projectsData[2],
projectsData[3],
]}
handleCloseModal={() => {}}
showModal
/>
)
const confirmBtn = screen.getByText('Confirm') as HTMLButtonElement
fireEvent.click(confirmBtn)
await waitFor(() => {
const alerts = screen.getAllByRole('alert')
expect(alerts.length).to.equal(2)
expect(alerts[0].textContent).to.contain(
`${projectsData[2].name}Something went wrong. Please try again.`
)
expect(alerts[1].textContent).to.contain(
`${projectsData[3].name}Something went wrong. Please try again.`
)
})
})
it('should send an analytics event when opened', function () {
renderWithProjectListContext(
<ProjectsActionModal
action="archive"
actionHandler={actionHandler}
projects={[projectsData[0], projectsData[1]]}
handleCloseModal={() => {}}
showModal
/>
)
expect(sendMBSpy).to.have.been.calledOnce
expect(sendMBSpy).to.have.been.calledWith('project-list-page-interaction', {
action: 'archive',
page: '/',
isSmallDevice: true,
})
})
})

View File

@@ -0,0 +1,153 @@
import { fireEvent, render, screen } from '@testing-library/react'
import WelcomeMessage from '../../../../../frontend/js/features/project-list/components/welcome-message'
import { expect } from 'chai'
import getMeta from '@/utils/meta'
describe('<WelcomeMessage />', function () {
beforeEach(function () {
Object.assign(getMeta('ol-ExposedSettings'), {
isOverleaf: true,
wikiEnabled: true,
templatesEnabled: true,
})
})
it('renders welcome page correctly', function () {
render(<WelcomeMessage />)
screen.getByText('Welcome to Overleaf')
screen.getByText('Create a new project')
screen.getByText('Learn LaTeX with a tutorial')
screen.getByText('Browse templates')
})
it('shows correct dropdown when clicking create a new project', function () {
render(<WelcomeMessage />)
const button = screen.getByRole('button', {
name: 'Create a new project',
})
fireEvent.click(button)
screen.getByText('Blank Project')
screen.getByText('Example Project')
screen.getByText('Upload Project')
screen.getByText('Import from GitHub')
})
it('show the correct dropdown menu for affiliated users', function () {
window.metaAttributesCache.set('ol-portalTemplates', [
{
name: 'Affiliation 1',
url: '/edu/test-new-template',
},
])
render(<WelcomeMessage />)
const button = screen.getByRole('button', {
name: 'Create a new project',
})
fireEvent.click(button)
// static menu
screen.getByText('Blank Project')
screen.getByText('Example Project')
screen.getByText('Upload Project')
screen.getByText('Import from GitHub')
// static text for institution templates
screen.getByText('Institution Templates')
// dynamic menu based on portalTemplates
const affiliationTemplate = screen.getByRole('menuitem', {
name: 'Affiliation 1',
})
expect(affiliationTemplate.getAttribute('href')).to.equal(
'/edu/test-new-template#templates'
)
})
it('shows correct dropdown when clicking create a new project with a portal template', function () {
render(<WelcomeMessage />)
const button = screen.getByRole('button', {
name: 'Create a new project',
})
fireEvent.click(button)
screen.getByText('Blank Project')
screen.getByText('Example Project')
screen.getByText('Upload Project')
screen.getByText('Import from GitHub')
})
it('shows correct link for latex tutorial menu', function () {
render(<WelcomeMessage />)
const link = screen.getByRole('link', {
name: 'Learn LaTeX with a tutorial',
})
expect(link.getAttribute('href')).to.equal(
'/learn/latex/Learn_LaTeX_in_30_minutes'
)
})
it('shows correct link for browse templates menu', function () {
render(<WelcomeMessage />)
const link = screen.getByRole('link', {
name: 'Browse templates',
})
expect(link.getAttribute('href')).to.equal('/templates')
})
describe('when not in SaaS', function () {
beforeEach(function () {
getMeta('ol-ExposedSettings').isOverleaf = false
})
it('renders welcome page correctly', function () {
render(<WelcomeMessage />)
screen.getByText('Welcome to Overleaf')
screen.getByText('Create a new project')
screen.getByText('Learn LaTeX with a tutorial')
screen.getByText('Browse templates')
})
it("doesn't display github in the dropdown when clicking create a new project", function () {
render(<WelcomeMessage />)
const button = screen.getByRole('button', {
name: 'Create a new project',
})
fireEvent.click(button)
screen.getByText('Blank Project')
screen.getByText('Example Project')
screen.getByText('Upload Project')
expect(screen.queryByText('Import from GitHub')).to.not.exist
})
it('does not render the tutorial link when the learn wiki is not configured', function () {
getMeta('ol-ExposedSettings').wikiEnabled = false
render(<WelcomeMessage />)
expect(screen.queryByText('Learn LaTeX with a tutorial')).to.not.exist
})
it('does not render the templates link when templates are not configured', function () {
getMeta('ol-ExposedSettings').templatesEnabled = false
render(<WelcomeMessage />)
expect(screen.queryByText('Browse templates')).to.not.exist
})
})
})

View File

@@ -0,0 +1,63 @@
import { DeepReadonly } from '../../../../../types/utils'
import {
Institution,
NotificationDropboxDuplicateProjectNames,
NotificationGroupInvitation,
NotificationIPMatchedAffiliation,
NotificationProjectInvite,
NotificationTPDSFileLimit,
} from '../../../../../types/project/dashboard/notification'
export const notificationsInstitution = {
email: 'email@example.com',
institutionEmail: 'institution@example.com',
institutionId: 123,
institutionName: 'Abc Institution',
requestedEmail: 'requested@example.com',
} as DeepReadonly<Institution>
export const notificationProjectInvite = {
messageOpts: {
projectId: '123',
projectName: 'Abc Project',
userName: 'fakeUser',
token: 'abcdef',
},
} as DeepReadonly<NotificationProjectInvite>
export const notificationIPMatchedAffiliation = {
messageOpts: {
university_name: 'Abc University',
ssoEnabled: false,
institutionId: '456',
},
} as DeepReadonly<NotificationIPMatchedAffiliation>
export const notificationTPDSFileLimit = {
messageOpts: {
projectName: 'Abc Project',
projectId: '123',
},
} as DeepReadonly<NotificationTPDSFileLimit>
export const notificationDropboxDuplicateProjectNames = {
messageOpts: {
projectName: 'Abc Project',
},
} as DeepReadonly<NotificationDropboxDuplicateProjectNames>
export const notificationGroupInviteDefault = {
messageOpts: {
token: '123abc',
inviterName: 'inviter@overleaf.com',
managedUsersEnabled: false,
},
} as DeepReadonly<NotificationGroupInvitation>
export const notificationGroupInviteManagedUsers = {
messageOpts: {
token: '123abc',
inviterName: 'inviter@overleaf.com',
managedUsersEnabled: true,
},
} as DeepReadonly<NotificationGroupInvitation>

View File

@@ -0,0 +1,170 @@
import { Project } from '../../../../../types/project/dashboard/api'
import {
isDeletableProject,
isLeavableProject,
} from '../../../../../frontend/js/features/project-list/util/project'
import moment from 'moment'
export const owner = {
id: '624333f147cfd8002622a1d3',
email: 'riker@overleaf.com',
firstName: 'William',
lastName: 'Riker',
}
const users = {
laforge: {
id: '624371e98a21dd0026a5bfef',
email: 'laforge@overleaf.com',
firstName: '',
lastName: 'La Forge',
},
picard: {
id: '62d6d0b4c5c5030a4d696c7a',
email: 'picard@overleaf.com',
firstName: 'Jean-Luc',
lastName: 'Picard',
},
riker: owner,
worf: {
id: '624371708a21dd0026a5bf86',
email: 'worf@overleaf.com',
firstName: '',
lastName: '',
},
}
export const copyableProject = <Project>{
id: '62f17f594641b405ca2b3264',
name: 'Starfleet Report (owner)',
lastUpdated: moment().subtract(1, 'day').toISOString(),
lastUpdatedBy: users.picard,
accessLevel: 'owner',
source: 'owner',
archived: false,
trashed: false,
owner: users.riker,
}
export const archiveableProject = <Project>{
id: '62d6d3721357e20a682110d5',
name: "Captain's logs (Invite & Read Only)",
lastUpdated: moment().subtract(1, 'week').toISOString(),
lastUpdatedBy: users.picard,
accessLevel: 'readOnly',
source: 'invite',
archived: false,
trashed: false,
owner: users.picard,
}
export const trashedProject = <Project>{
id: '42f17f594641b405ca2b3265',
name: 'Starfleet Report draft (owner & trashed)',
lastUpdated: moment().subtract(2, 'year').toISOString(),
lastUpdatedBy: users.picard,
accessLevel: 'owner',
source: 'owner',
archived: false,
trashed: true,
owner: users.riker,
}
export const archivedProject = <Project>{
id: '52f17f594641b405ca2b3266',
name: 'Starfleet Report old (owner & archive)',
lastUpdated: moment().subtract(1, 'year').toISOString(),
lastUpdatedBy: users.picard,
accessLevel: 'owner',
source: 'owner',
archived: true,
trashed: false,
owner: users.riker,
}
export const trashedAndNotOwnedProject = <Project>{
id: '63d6d3721357e20a682110d5',
name: "Captain's logs very old (Trashed & Read Only & Not Owned)",
lastUpdated: moment().subtract(11, 'year').toISOString(),
lastUpdatedBy: users.picard,
accessLevel: 'readOnly',
source: 'invite',
archived: false,
trashed: true,
owner: users.picard,
}
export const sharedProject = archiveableProject
export const ownedProject = copyableProject
export const projectsData: Array<Project> = [
copyableProject,
archiveableProject,
{
id: '62b5cdf85212090c2244161c',
name: 'Enterprise Security Analysis | Deflector Shields, Sensors, Tractor Beams, and Cloaking Devices (Invite & Edit)',
lastUpdated: moment().subtract(1, 'month').toISOString(),
lastUpdatedBy: users.worf,
accessLevel: 'readWrite',
source: 'invite',
archived: false,
trashed: false,
owner: users.worf,
},
{
id: '624380431c2e40006c59b922',
name: 'VISOR Sensors (Link Sharing & Edit)',
lastUpdated: moment().subtract(2, 'months').toISOString(),
lastUpdatedBy: users.laforge,
accessLevel: 'readAndWrite',
source: 'token',
archived: false,
trashed: false,
owner: users.laforge,
},
{
id: '62f51b31f6f4c60027e8935f',
name: 'United Federation of Planets (Link Sharing & View Only)',
lastUpdated: moment().subtract(2, 'year').toISOString(),
lastUpdatedBy: null,
accessLevel: 'readOnly',
source: 'token',
archived: false,
trashed: false,
},
archivedProject,
trashedProject,
trashedAndNotOwnedProject,
]
export const archivedProjects = projectsData.filter(({ archived }) => archived)
export const currentProjects = projectsData.filter(
({ archived, trashed }) => !archived && !trashed
)
export const trashedProjects = projectsData.filter(({ trashed }) => trashed)
export const makeLongProjectList = (listLength: number) => {
const longList = [...projectsData]
while (longList.length < listLength) {
longList.push(
Object.assign({}, copyableProject, {
name: `Report (${longList.length})`,
id: `newProjectId${longList.length}`,
})
)
}
return {
fullList: longList,
currentList: longList.filter(
({ archived, trashed }) => !archived && !trashed
),
trashedList: longList.filter(({ trashed }) => trashed),
archivedList: longList.filter(({ archived }) => archived),
leavableList: longList.filter(isLeavableProject),
deletableList: longList.filter(isDeletableProject),
}
}

View File

@@ -0,0 +1,18 @@
import { Tag } from '../../../../../app/src/Features/Tags/types'
export const tags: Tag[] = [
{
_id: 'tag-1',
name: 'foo',
color: '#f00',
user_id: '624333f147cfd8002622a1d3',
project_ids: ['62f17f594641b405ca2b3264'],
},
{
_id: 'tag-2',
name: 'bar',
color: '#0f0',
user_id: '624333f147cfd8002622a1d3',
project_ids: ['62f17f594641b405ca2b3264'],
},
]

View File

@@ -0,0 +1,41 @@
import {
CommonsPlanSubscription,
FreePlanSubscription,
GroupPlanSubscription,
IndividualPlanSubscription,
} from '../../../../../types/project/dashboard/subscription'
export const freeSubscription: FreePlanSubscription = {
type: 'free',
featuresPageURL: '/features',
}
export const individualSubscription: IndividualPlanSubscription = {
type: 'individual',
plan: { name: 'professional' },
featuresPageURL: '/features',
remainingTrialDays: -1,
subscription: {
name: 'professional',
},
}
export const commonsSubscription: CommonsPlanSubscription = {
type: 'commons',
plan: { name: 'professional' },
featuresPageURL: '/features',
subscription: {
name: 'professional',
},
}
export const groupSubscription: GroupPlanSubscription = {
type: 'group',
plan: { name: 'professional' },
featuresPageURL: '/features',
remainingTrialDays: -1,
subscription: {
name: 'professional',
teamName: 'My group',
},
}

View File

@@ -0,0 +1,62 @@
import { render } from '@testing-library/react'
import fetchMock from 'fetch-mock'
import React from 'react'
import { ColorPickerProvider } from '../../../../../frontend/js/features/project-list/context/color-picker-context'
import { ProjectListProvider } from '../../../../../frontend/js/features/project-list/context/project-list-context'
import { Project } from '../../../../../types/project/dashboard/api'
import { projectsData } from '../fixtures/projects-data'
import { SplitTestProvider } from '@/shared/context/split-test-context'
import { UserProvider } from '@/shared/context/user-context'
type Options = {
projects?: Project[]
}
export function renderWithProjectListContext(
component: React.ReactElement,
options: Options = {}
) {
let { projects } = options
if (!projects) {
projects = projectsData
}
fetchMock.post('express:/api/project', {
status: 200,
body: { projects, totalSize: projects.length },
})
fetchMock.get('express:/system/messages', {
status: 200,
body: [],
})
window.metaAttributesCache.set('ol-user', {
id: 'fake_user',
first_name: 'fake_user_first_name',
email: 'fake@example.com',
})
const ProjectListProviderWrapper = ({
children,
}: {
children: React.ReactNode
}) => (
<UserProvider>
<ProjectListProvider>
<SplitTestProvider>
<ColorPickerProvider>{children}</ColorPickerProvider>
</SplitTestProvider>
</ProjectListProvider>
</UserProvider>
)
return render(component, {
wrapper: ProjectListProviderWrapper,
})
}
export function resetProjectListContextFetch() {
fetchMock.removeRoutes().clearHistory()
}

View File

@@ -0,0 +1,133 @@
import { expect } from 'chai'
import {
ownerNameComparator,
defaultComparator,
} from '../../../../../frontend/js/features/project-list/util/sort-projects'
import { Project } from '../../../../../types/project/dashboard/api'
const now = new Date()
const dateAddDays = (days = 0) => {
return new Date(new Date().setDate(now.getDate() + days)).toISOString()
}
describe('sort comparators', function () {
describe('default comparator', function () {
it('sorts by `name`', function () {
const projectsData = [
{ name: '#2' },
{ name: '#1' },
{ name: '#3' },
] as Project[]
const result = [...projectsData].sort((v1, v2) => {
return defaultComparator(v1, v2, 'name')
})
expect(result[0]).to.include(projectsData[1])
expect(result[1]).to.include(projectsData[0])
expect(result[2]).to.include(projectsData[2])
})
it('sorts by `lastUpdated`', function () {
const projectsData = [
{ lastUpdated: dateAddDays(0) },
{ lastUpdated: dateAddDays(2) },
{ lastUpdated: dateAddDays(1) },
] as Project[]
const result = [...projectsData].sort((v1, v2) => {
return defaultComparator(v1, v2, 'lastUpdated')
})
expect(result[0]).to.include(projectsData[0])
expect(result[1]).to.include(projectsData[2])
expect(result[2]).to.include(projectsData[1])
})
})
describe('owner comparator', function () {
const owner = {
id: '62d6d0b4c5c5030a4d696c7a',
email: 'picard@overleaf.com',
firstName: 'Jean-Luc',
lastName: 'Picard',
}
const projectsData = [
{
lastUpdated: dateAddDays(0),
accessLevel: 'readOnly',
source: 'invite',
owner,
},
{
lastUpdated: dateAddDays(2),
accessLevel: 'owner',
source: 'owner',
owner,
},
{
lastUpdated: dateAddDays(1),
accessLevel: 'readWrite',
source: 'invite',
owner,
},
{
lastUpdated: dateAddDays(3),
accessLevel: 'owner',
source: 'owner',
owner,
},
{
lastUpdated: dateAddDays(8),
accessLevel: 'readAndWrite',
source: 'token',
owner,
},
{
lastUpdated: dateAddDays(1),
accessLevel: 'owner',
source: 'owner',
owner,
},
{
lastUpdated: dateAddDays(4),
source: 'token',
accessLevel: 'readOnly',
},
{
lastUpdated: dateAddDays(1),
source: 'token',
accessLevel: 'readAndWrite',
owner,
},
{
lastUpdated: dateAddDays(3),
source: 'token',
accessLevel: 'readOnly',
},
{
lastUpdated: dateAddDays(1),
source: 'token',
accessLevel: 'readAndWrite',
owner,
},
] as Project[]
it('sorts by owner name', function () {
const result = [...projectsData].sort((v1, v2) => {
return ownerNameComparator(v1, v2)
})
expect(result[0]).to.include(projectsData[8])
expect(result[1]).to.include(projectsData[6])
expect(result[2]).to.include(projectsData[9])
expect(result[3]).to.include(projectsData[7])
expect(result[4]).to.include(projectsData[4])
expect(result[5]).to.include(projectsData[0])
expect(result[6]).to.include(projectsData[2])
expect(result[7]).to.include(projectsData[5])
expect(result[8]).to.include(projectsData[1])
expect(result[9]).to.include(projectsData[3])
})
})
})