first commit
This commit is contained in:
106
services/web/test/frontend/bootstrap.js
vendored
Normal file
106
services/web/test/frontend/bootstrap.js
vendored
Normal file
@@ -0,0 +1,106 @@
|
||||
// Run babel on tests to allow support for import/export statements in Node
|
||||
require('@babel/register')({
|
||||
extensions: ['.ts', '.tsx', '.js', '.jsx', '.mjs'],
|
||||
plugins: [['module-resolver', { alias: { '^@/(.+)': './frontend/js/\\1' } }]],
|
||||
})
|
||||
|
||||
// Load JSDOM to mock the DOM in Node
|
||||
// Set pretendToBeVisual to enable requestAnimationFrame
|
||||
require('jsdom-global')(undefined, {
|
||||
pretendToBeVisual: true,
|
||||
url: 'https://www.test-overleaf.com/',
|
||||
})
|
||||
|
||||
const path = require('path')
|
||||
process.env.OVERLEAF_CONFIG = path.resolve(
|
||||
__dirname,
|
||||
'../../config/settings.webpack.js'
|
||||
)
|
||||
|
||||
// Load sinon-chai assertions so expect(stubFn).to.have.been.calledWith('abc')
|
||||
// has a nicer failure messages
|
||||
const chai = require('chai')
|
||||
chai.use(require('sinon-chai'))
|
||||
chai.use(require('chai-as-promised'))
|
||||
|
||||
// Populate meta for top-level access in modules on import
|
||||
const { resetMeta } = require('./helpers/reset-meta')
|
||||
resetMeta()
|
||||
// i18n requires access to 'ol-i18n' as defined above
|
||||
require('../../frontend/js/i18n')
|
||||
|
||||
const moment = require('moment')
|
||||
moment.updateLocale('en', {
|
||||
calendar: {
|
||||
lastDay: '[Yesterday]',
|
||||
sameDay: '[Today]',
|
||||
nextDay: '[Tomorrow]',
|
||||
lastWeek: 'ddd, Do MMM YY',
|
||||
nextWeek: 'ddd, Do MMM YY',
|
||||
sameElse: 'ddd, Do MMM YY',
|
||||
},
|
||||
})
|
||||
|
||||
// workaround for missing keys in jsdom-global's keys.js
|
||||
globalThis.AbortController = global.AbortController = window.AbortController
|
||||
globalThis.MutationObserver = global.MutationObserver = window.MutationObserver
|
||||
globalThis.StorageEvent = global.StorageEvent = window.StorageEvent
|
||||
globalThis.SVGElement = global.SVGElement = window.SVGElement
|
||||
globalThis.localStorage = global.localStorage = window.localStorage
|
||||
globalThis.performance = global.performance = window.performance
|
||||
globalThis.cancelAnimationFrame = global.cancelAnimationFrame =
|
||||
window.cancelAnimationFrame
|
||||
globalThis.requestAnimationFrame = global.requestAnimationFrame =
|
||||
window.requestAnimationFrame
|
||||
globalThis.sessionStorage = global.sessionStorage = window.sessionStorage
|
||||
|
||||
// add polyfill for ResizeObserver
|
||||
globalThis.ResizeObserver =
|
||||
global.ResizeObserver =
|
||||
window.ResizeObserver =
|
||||
require('@juggle/resize-observer').ResizeObserver
|
||||
|
||||
// add stub for BroadcastChannel (unused in these tests)
|
||||
globalThis.BroadcastChannel =
|
||||
global.BroadcastChannel =
|
||||
window.BroadcastChannel =
|
||||
class BroadcastChannel {
|
||||
addEventListener(type, listener) {}
|
||||
removeEventListener(type, listener) {}
|
||||
postMessage(message) {}
|
||||
}
|
||||
|
||||
// add stub for WebSocket state enum
|
||||
globalThis.WebSocket = class WebSocket {
|
||||
static CONNECTING = 0
|
||||
static OPEN = 1
|
||||
static CLOSING = 2
|
||||
static CLOSED = 3
|
||||
}
|
||||
|
||||
// node-fetch doesn't accept relative URL's: https://github.com/node-fetch/node-fetch/blob/master/docs/v2-LIMITS.md#known-differences
|
||||
const fetch = require('node-fetch')
|
||||
globalThis.fetch =
|
||||
global.fetch =
|
||||
window.fetch =
|
||||
(url, ...options) => fetch(new URL(url, 'http://127.0.0.1'), ...options)
|
||||
|
||||
// ignore style/image files
|
||||
const { addHook } = require('pirates')
|
||||
addHook(() => '', {
|
||||
exts: ['.css', '.less', '.svg', '.png', '.gif', '.mp4'],
|
||||
ignoreNodeModules: false,
|
||||
})
|
||||
|
||||
globalThis.HTMLElement.prototype.scrollIntoView = () => {}
|
||||
|
||||
globalThis.DOMParser = window.DOMParser
|
||||
|
||||
// Polyfill for IndexedDB
|
||||
require('fake-indexeddb/auto')
|
||||
|
||||
const fetchMock = require('fetch-mock').default
|
||||
|
||||
fetchMock.spyGlobal()
|
||||
fetchMock.config.fetch = global.fetch
|
||||
fetchMock.config.Response = fetch.Response
|
||||
@@ -0,0 +1,905 @@
|
||||
import EditorLeftMenu from '../../../../frontend/js/features/editor-left-menu/components/editor-left-menu'
|
||||
import {
|
||||
AllowedImageName,
|
||||
OverallThemeMeta,
|
||||
SpellCheckLanguage,
|
||||
} from '../../../../types/project-settings'
|
||||
import { EditorProviders } from '../../helpers/editor-providers'
|
||||
import { mockScope } from './scope'
|
||||
import { Folder } from '../../../../types/folder'
|
||||
import { docsInFolder } from '@/features/file-tree/util/docs-in-folder'
|
||||
import getMeta from '@/utils/meta'
|
||||
|
||||
describe('<EditorLeftMenu />', function () {
|
||||
beforeEach(function () {
|
||||
cy.viewport(800, 800)
|
||||
cy.interceptCompile()
|
||||
})
|
||||
|
||||
describe('for non-anonymous users', function () {
|
||||
const overallThemes: OverallThemeMeta[] = [
|
||||
{
|
||||
name: 'Overall Theme 1',
|
||||
val: '',
|
||||
path: 'https://overleaf.com/overalltheme-1.css',
|
||||
},
|
||||
{
|
||||
name: 'Overall Theme 2',
|
||||
val: 'light-',
|
||||
path: 'https://overleaf.com/overalltheme-2.css',
|
||||
},
|
||||
]
|
||||
|
||||
const allowedImageNames: AllowedImageName[] = [
|
||||
{
|
||||
imageDesc: 'Image 1',
|
||||
imageName: 'img-1',
|
||||
},
|
||||
{
|
||||
imageDesc: 'Image 2',
|
||||
imageName: 'img-2',
|
||||
},
|
||||
]
|
||||
|
||||
beforeEach(function () {
|
||||
window.metaAttributesCache.set('ol-overallThemes', overallThemes)
|
||||
window.metaAttributesCache.set('ol-allowedImageNames', allowedImageNames)
|
||||
window.metaAttributesCache.set('ol-anonymous', false)
|
||||
window.metaAttributesCache.set('ol-gitBridgeEnabled', true)
|
||||
window.metaAttributesCache.set('ol-showSupport', true)
|
||||
Object.assign(getMeta('ol-ExposedSettings'), { ieeeBrandId: 123 })
|
||||
window.metaAttributesCache.set('ol-user', {
|
||||
email: 'sherlock@holmes.co.uk',
|
||||
first_name: 'Sherlock',
|
||||
last_name: 'Holmes',
|
||||
})
|
||||
})
|
||||
|
||||
it('render full menu', function () {
|
||||
const scope = mockScope({
|
||||
ui: {
|
||||
leftMenuShown: true,
|
||||
},
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<EditorLeftMenu />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
// Download Menu
|
||||
cy.findByRole('heading', { name: 'Download' })
|
||||
cy.findByRole('link', { name: 'Source' })
|
||||
cy.findByRole('link', { name: 'PDF' })
|
||||
|
||||
// Actions Menu
|
||||
cy.findByRole('heading', { name: 'Actions' })
|
||||
cy.findByRole('button', { name: 'Copy Project' })
|
||||
cy.findByRole('button', { name: 'Word Count' })
|
||||
|
||||
// Sync Menu
|
||||
cy.findByRole('heading', { name: 'Sync' })
|
||||
cy.findByRole('button', { name: 'Dropbox' })
|
||||
cy.findByRole('button', { name: 'Git' })
|
||||
cy.findByRole('button', { name: 'GitHub' })
|
||||
|
||||
// Settings Menu
|
||||
cy.findByRole('heading', { name: 'Settings' })
|
||||
cy.findByLabelText('Compiler')
|
||||
cy.findByLabelText('TeX Live version')
|
||||
cy.findByLabelText('Main document')
|
||||
cy.findByLabelText('Spell check')
|
||||
cy.findByLabelText('Auto-complete')
|
||||
cy.findByLabelText('Auto-close brackets')
|
||||
cy.findByLabelText('Code check')
|
||||
cy.findByLabelText('Editor theme')
|
||||
cy.findByLabelText('Overall theme')
|
||||
cy.findByLabelText('Keybindings')
|
||||
cy.findByLabelText('Font Size')
|
||||
cy.findByLabelText('Font Family')
|
||||
cy.findByLabelText('Line Height')
|
||||
cy.findByLabelText('PDF Viewer')
|
||||
|
||||
// Help Menu
|
||||
cy.findByRole('heading', { name: 'Help' })
|
||||
cy.findByRole('button', { name: 'Show Hotkeys' })
|
||||
cy.findByRole('link', { name: 'Documentation' })
|
||||
cy.findByRole('button', { name: 'Contact Us' })
|
||||
})
|
||||
|
||||
describe('download menu', function () {
|
||||
it('have a correct source & pdf download url', function () {
|
||||
const scope = mockScope({
|
||||
ui: {
|
||||
leftMenuShown: true,
|
||||
},
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<EditorLeftMenu />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('link', { name: 'Source' }).should(
|
||||
'have.attr',
|
||||
'href',
|
||||
'/project/project123/download/zip'
|
||||
)
|
||||
|
||||
cy.findByRole('link', { name: 'PDF' })
|
||||
.should('have.attr', 'href')
|
||||
.and('match', /\/download\/project\/project123\/build/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('actions menu', function () {
|
||||
it('shows copy project modal correctly', function () {
|
||||
cy.intercept('POST', '/project/*/clone', {
|
||||
body: {
|
||||
project_id: 'new_project_id',
|
||||
},
|
||||
})
|
||||
|
||||
const scope = mockScope({
|
||||
ui: {
|
||||
leftMenuShown: true,
|
||||
},
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<EditorLeftMenu />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Copy Project' }).click()
|
||||
cy.findByRole('heading', { name: 'Copy Project' })
|
||||
|
||||
// try closing & re-opening the modal with different methods
|
||||
cy.findByRole('button', { name: 'Close' }).click()
|
||||
cy.findByRole('button', { name: 'Copy Project' }).click()
|
||||
cy.findByRole('button', { name: 'Cancel' }).click()
|
||||
cy.findByRole('button', { name: 'Copy Project' }).click()
|
||||
|
||||
cy.findByLabelText('New Name').focus()
|
||||
cy.findByLabelText('New Name').clear()
|
||||
cy.findByLabelText('New Name').type('Project Renamed')
|
||||
cy.get('#clone-project-form-name[value="Project Renamed"')
|
||||
})
|
||||
|
||||
it('shows word count modal correctly', function () {
|
||||
cy.intercept('GET', '/project/*/wordcount*', {
|
||||
texcount: {
|
||||
encode: 'ascii',
|
||||
textWords: 781,
|
||||
headWords: 66,
|
||||
outside: 11,
|
||||
headers: 41,
|
||||
elements: 2,
|
||||
mathInline: 6,
|
||||
mathDisplay: 1,
|
||||
errors: 0,
|
||||
},
|
||||
}).as('wordCount')
|
||||
|
||||
const scope = mockScope({
|
||||
ui: {
|
||||
leftMenuShown: true,
|
||||
},
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<EditorLeftMenu />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Word Count' }).click()
|
||||
|
||||
cy.wait('@wordCount')
|
||||
cy.findByText('Total Words:')
|
||||
cy.findByText('781')
|
||||
cy.findByText('Headers:')
|
||||
cy.findByText('41')
|
||||
cy.findByText('Math Inline:')
|
||||
cy.findByText('6')
|
||||
cy.findByText('Math Display:')
|
||||
cy.findByText('1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sync menu', function () {
|
||||
it('shows dropbox modal correctly', function () {
|
||||
cy.intercept('GET', '/dropbox/status', {
|
||||
registered: true,
|
||||
})
|
||||
|
||||
const scope = mockScope({
|
||||
ui: {
|
||||
leftMenuShown: true,
|
||||
},
|
||||
project: {
|
||||
members: [],
|
||||
owner: {
|
||||
_id: '123',
|
||||
},
|
||||
},
|
||||
user: {
|
||||
features: {
|
||||
dropbox: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<EditorLeftMenu />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Dropbox' }).click()
|
||||
cy.findByText('Dropbox Sync')
|
||||
})
|
||||
|
||||
it('shows git modal correctly', function () {
|
||||
const scope = mockScope({
|
||||
ui: {
|
||||
leftMenuShown: true,
|
||||
},
|
||||
project: {
|
||||
owner: {
|
||||
_id: '123',
|
||||
},
|
||||
features: {
|
||||
gitBridge: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<EditorLeftMenu />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Git' }).click()
|
||||
cy.findByText('Clone with Git')
|
||||
cy.findByText(/clone your project by using the link below/)
|
||||
})
|
||||
|
||||
it('shows git modal paywall correctly', function () {
|
||||
const scope = mockScope({
|
||||
ui: {
|
||||
leftMenuShown: true,
|
||||
},
|
||||
project: {
|
||||
owner: {
|
||||
_id: '123',
|
||||
},
|
||||
features: {
|
||||
gitBridge: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<EditorLeftMenu />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Git' }).click()
|
||||
cy.findByText('Collaborate online and offline, using your own workflow')
|
||||
})
|
||||
|
||||
it('shows github modal correctly', function () {
|
||||
cy.intercept('GET', '/user/github-sync/status', {
|
||||
available: false,
|
||||
enabled: false,
|
||||
}).as('user-status')
|
||||
|
||||
cy.intercept('GET', '/project/*/github-sync/status', {
|
||||
enabled: false,
|
||||
}).as('project-status')
|
||||
|
||||
const scope = mockScope({
|
||||
ui: {
|
||||
leftMenuShown: true,
|
||||
},
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<EditorLeftMenu />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.wait('@compile')
|
||||
cy.findByRole('button', { name: 'GitHub' }).click()
|
||||
cy.findByText('GitHub Sync')
|
||||
|
||||
cy.wait(['@user-status', '@project-status'])
|
||||
cy.findByText('Push to GitHub, pull to Overleaf')
|
||||
})
|
||||
|
||||
it('hides the entire sync section when git bridge is disabled', function () {
|
||||
window.metaAttributesCache.set('ol-gitBridgeEnabled', false)
|
||||
|
||||
cy.findByRole('button', { name: 'Dropbox' }).should('not.exist')
|
||||
cy.findByRole('button', { name: 'Git' }).should('not.exist')
|
||||
cy.findByRole('button', { name: 'GitHub' }).should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('settings menu', function () {
|
||||
it('shows compiler menu correctly', function () {
|
||||
const scope = mockScope({
|
||||
ui: {
|
||||
leftMenuShown: true,
|
||||
},
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<EditorLeftMenu />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.get<HTMLOptionElement>('#settings-menu-compiler option').then(
|
||||
options => {
|
||||
const values = [...options].map(o => o.value)
|
||||
expect(values).to.deep.eq([
|
||||
'pdflatex',
|
||||
'latex',
|
||||
'xelatex',
|
||||
'lualatex',
|
||||
])
|
||||
|
||||
const texts = [...options].map(o => o.text)
|
||||
expect(texts).to.deep.eq([
|
||||
'pdfLaTeX',
|
||||
'LaTeX',
|
||||
'XeLaTeX',
|
||||
'LuaLaTeX',
|
||||
])
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('shows texlive version menu correctly', function () {
|
||||
const scope = mockScope({
|
||||
ui: {
|
||||
leftMenuShown: true,
|
||||
},
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<EditorLeftMenu />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.get<HTMLOptionElement>('#settings-menu-imageName option').then(
|
||||
options => {
|
||||
const values = [...options].map(o => o.value)
|
||||
expect(values).to.deep.eq(['img-1', 'img-2'])
|
||||
|
||||
const texts = [...options].map(o => o.text)
|
||||
expect(texts).to.deep.eq(['Image 1', 'Image 2'])
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('shows document menu correctly', function () {
|
||||
const rootFolder: Folder = {
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [
|
||||
{
|
||||
_id: 'id1',
|
||||
name: 'main.tex',
|
||||
},
|
||||
{
|
||||
_id: 'id2',
|
||||
name: 'main2.tex',
|
||||
},
|
||||
],
|
||||
fileRefs: [],
|
||||
folders: [],
|
||||
}
|
||||
|
||||
const scope = mockScope({
|
||||
ui: {
|
||||
leftMenuShown: true,
|
||||
},
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope} rootFolder={[rootFolder as any]}>
|
||||
<EditorLeftMenu />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
const docs = docsInFolder(rootFolder)
|
||||
|
||||
cy.get<HTMLOptionElement>('#settings-menu-rootDocId option').then(
|
||||
options => {
|
||||
const values = [...options].map(o => o.value)
|
||||
expect(values).to.deep.eq(docs.map(doc => doc.doc.id))
|
||||
|
||||
const texts = [...options].map(o => o.text)
|
||||
expect(texts).to.deep.eq(docs.map(doc => doc.path))
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('shows spellcheck menu correctly', function () {
|
||||
const languages: SpellCheckLanguage[] = [
|
||||
{
|
||||
name: 'Lang 1',
|
||||
code: 'lang-1',
|
||||
dic: 'lang_1',
|
||||
},
|
||||
{
|
||||
name: 'Lang 2',
|
||||
code: 'lang-2',
|
||||
dic: 'lang_2',
|
||||
},
|
||||
]
|
||||
|
||||
window.metaAttributesCache.set('ol-languages', languages)
|
||||
|
||||
const scope = mockScope({
|
||||
ui: {
|
||||
leftMenuShown: true,
|
||||
},
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<EditorLeftMenu />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.get<HTMLOptionElement>(
|
||||
'#settings-menu-spellCheckLanguage option'
|
||||
).then(options => {
|
||||
const values = [...options].map(o => o.value)
|
||||
expect(values).to.deep.eq(['', 'lang-1', 'lang-2'])
|
||||
|
||||
const texts = [...options].map(o => o.text)
|
||||
expect(texts).to.deep.eq(['Off', 'Lang 1', 'Lang 2'])
|
||||
})
|
||||
})
|
||||
|
||||
it('shows dictionary modal correctly', function () {
|
||||
const scope = mockScope({
|
||||
ui: {
|
||||
leftMenuShown: true,
|
||||
},
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<EditorLeftMenu />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.get('label[for="dictionary-settings"] ~ button').click()
|
||||
cy.findByText('Edit Dictionary')
|
||||
cy.findByText('Your custom dictionary is empty.')
|
||||
})
|
||||
|
||||
it('shows auto-complete menu correctly', function () {
|
||||
const scope = mockScope({
|
||||
ui: {
|
||||
leftMenuShown: true,
|
||||
},
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<EditorLeftMenu />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.get<HTMLOptionElement>('#settings-menu-autoComplete option').then(
|
||||
options => {
|
||||
const values = [...options].map(o => o.value)
|
||||
expect(values).to.deep.eq(['true', 'false'])
|
||||
|
||||
const texts = [...options].map(o => o.text)
|
||||
expect(texts).to.deep.eq(['On', 'Off'])
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('shows auto-close brackets menu correctly', function () {
|
||||
const scope = mockScope({
|
||||
ui: {
|
||||
leftMenuShown: true,
|
||||
},
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<EditorLeftMenu />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.get<HTMLOptionElement>(
|
||||
'#settings-menu-autoPairDelimiters option'
|
||||
).then(options => {
|
||||
const values = [...options].map(o => o.value)
|
||||
expect(values).to.deep.eq(['true', 'false'])
|
||||
|
||||
const texts = [...options].map(o => o.text)
|
||||
expect(texts).to.deep.eq(['On', 'Off'])
|
||||
})
|
||||
})
|
||||
|
||||
it('shows code check menu correctly', function () {
|
||||
const scope = mockScope({
|
||||
ui: {
|
||||
leftMenuShown: true,
|
||||
},
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<EditorLeftMenu />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.get<HTMLOptionElement>(
|
||||
'#settings-menu-syntaxValidation option'
|
||||
).then(options => {
|
||||
const values = [...options].map(o => o.value)
|
||||
expect(values).to.deep.eq(['true', 'false'])
|
||||
|
||||
const texts = [...options].map(o => o.text)
|
||||
expect(texts).to.deep.eq(['On', 'Off'])
|
||||
})
|
||||
})
|
||||
|
||||
it('shows editor theme menu correctly', function () {
|
||||
const editorThemes = ['editortheme-1', 'editortheme-2', 'editortheme-3']
|
||||
|
||||
const legacyEditorThemes = [
|
||||
'legacytheme-1',
|
||||
'legacytheme-2',
|
||||
'legacytheme-3',
|
||||
]
|
||||
|
||||
window.metaAttributesCache.set('ol-editorThemes', editorThemes)
|
||||
window.metaAttributesCache.set(
|
||||
'ol-legacyEditorThemes',
|
||||
legacyEditorThemes
|
||||
)
|
||||
|
||||
const scope = mockScope({
|
||||
ui: {
|
||||
leftMenuShown: true,
|
||||
},
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<EditorLeftMenu />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.get<HTMLOptionElement>('#settings-menu-editorTheme option').then(
|
||||
options => {
|
||||
const values = [...options].map(o => o.value)
|
||||
expect(values).to.deep.eq([
|
||||
'editortheme-1',
|
||||
'editortheme-2',
|
||||
'editortheme-3',
|
||||
'-',
|
||||
'legacytheme-1',
|
||||
'legacytheme-2',
|
||||
'legacytheme-3',
|
||||
])
|
||||
|
||||
const texts = [...options].map(o => o.text)
|
||||
expect(texts).to.deep.eq([
|
||||
'editortheme-1',
|
||||
'editortheme-2',
|
||||
'editortheme-3',
|
||||
'—————————————————',
|
||||
'legacytheme-1 (Legacy)',
|
||||
'legacytheme-2 (Legacy)',
|
||||
'legacytheme-3 (Legacy)',
|
||||
])
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('shows overall theme menu correctly', function () {
|
||||
const scope = mockScope({
|
||||
ui: {
|
||||
leftMenuShown: true,
|
||||
},
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<EditorLeftMenu />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.get<HTMLOptionElement>('#settings-menu-overallTheme option').then(
|
||||
options => {
|
||||
const values = [...options].map(o => o.value)
|
||||
expect(values).to.deep.eq(['', 'light-'])
|
||||
|
||||
const texts = [...options].map(o => o.text)
|
||||
expect(texts).to.deep.eq(['Overall Theme 1', 'Overall Theme 2'])
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('shows keybindings menu correctly', function () {
|
||||
const scope = mockScope({
|
||||
ui: {
|
||||
leftMenuShown: true,
|
||||
},
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<EditorLeftMenu />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.get<HTMLOptionElement>('#settings-menu-mode option').then(
|
||||
options => {
|
||||
const values = [...options].map(o => o.value)
|
||||
expect(values).to.deep.eq(['default', 'vim', 'emacs'])
|
||||
|
||||
const texts = [...options].map(o => o.text)
|
||||
expect(texts).to.deep.eq(['None', 'Vim', 'Emacs'])
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('shows font size menu correctly', function () {
|
||||
const scope = mockScope({
|
||||
ui: {
|
||||
leftMenuShown: true,
|
||||
},
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<EditorLeftMenu />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.get<HTMLOptionElement>('#settings-menu-fontSize option').then(
|
||||
options => {
|
||||
const values = [...options].map(o => o.value)
|
||||
expect(values).to.deep.eq([
|
||||
'10',
|
||||
'11',
|
||||
'12',
|
||||
'13',
|
||||
'14',
|
||||
'16',
|
||||
'18',
|
||||
'20',
|
||||
'22',
|
||||
'24',
|
||||
])
|
||||
|
||||
const texts = [...options].map(o => o.text)
|
||||
expect(texts).to.deep.eq([
|
||||
'10px',
|
||||
'11px',
|
||||
'12px',
|
||||
'13px',
|
||||
'14px',
|
||||
'16px',
|
||||
'18px',
|
||||
'20px',
|
||||
'22px',
|
||||
'24px',
|
||||
])
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('shows font family menu correctly', function () {
|
||||
const scope = mockScope({
|
||||
ui: {
|
||||
leftMenuShown: true,
|
||||
},
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<EditorLeftMenu />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.get<HTMLOptionElement>('#settings-menu-fontFamily option').then(
|
||||
options => {
|
||||
const values = [...options].map(o => o.value)
|
||||
expect(values).to.deep.eq(['monaco', 'lucida', 'opendyslexicmono'])
|
||||
|
||||
const texts = [...options].map(o => o.text)
|
||||
expect(texts).to.deep.eq([
|
||||
'Monaco / Menlo / Consolas',
|
||||
'Lucida / Source Code Pro',
|
||||
'OpenDyslexic Mono',
|
||||
])
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('shows line height menu correctly', function () {
|
||||
const scope = mockScope({
|
||||
ui: {
|
||||
leftMenuShown: true,
|
||||
},
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<EditorLeftMenu />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.get<HTMLOptionElement>('#settings-menu-lineHeight option').then(
|
||||
options => {
|
||||
const values = [...options].map(o => o.value)
|
||||
expect(values).to.deep.eq(['compact', 'normal', 'wide'])
|
||||
|
||||
const texts = [...options].map(o => o.text)
|
||||
expect(texts).to.deep.eq(['Compact', 'Normal', 'Wide'])
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('shows pdf viewer menu correctly', function () {
|
||||
const scope = mockScope({
|
||||
ui: {
|
||||
leftMenuShown: true,
|
||||
},
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<EditorLeftMenu />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.get<HTMLOptionElement>('#settings-menu-pdfViewer option').then(
|
||||
options => {
|
||||
const values = [...options].map(o => o.value)
|
||||
expect(values).to.deep.eq(['pdfjs', 'native'])
|
||||
|
||||
const texts = [...options].map(o => o.text)
|
||||
expect(texts).to.deep.eq(['Overleaf', 'Browser'])
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('help menu', function () {
|
||||
it('shows hotkeys modal correctly', function () {
|
||||
const scope = mockScope({
|
||||
ui: {
|
||||
leftMenuShown: true,
|
||||
},
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<EditorLeftMenu />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Show Hotkeys' }).click()
|
||||
cy.findByText('Hotkeys')
|
||||
})
|
||||
|
||||
it('shows correct url for documentation', function () {
|
||||
const scope = mockScope({
|
||||
ui: {
|
||||
leftMenuShown: true,
|
||||
},
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<EditorLeftMenu />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('link', { name: 'Documentation' }).should(
|
||||
'have.attr',
|
||||
'href',
|
||||
'/learn'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows correct contact us modal', function () {
|
||||
const scope = mockScope({
|
||||
ui: {
|
||||
leftMenuShown: true,
|
||||
},
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<EditorLeftMenu />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Contact Us' }).click()
|
||||
cy.findByText('Affected project URL (Optional)')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('for anonymous users', function () {
|
||||
it('render minimal menu', function () {
|
||||
const scope = mockScope({
|
||||
ui: {
|
||||
leftMenuShown: true,
|
||||
},
|
||||
})
|
||||
window.metaAttributesCache.set('ol-anonymous', true)
|
||||
Object.assign(getMeta('ol-ExposedSettings'), { ieeeBrandId: 123 })
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<EditorLeftMenu />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
// Download Menu
|
||||
cy.findByRole('heading', { name: 'Download' })
|
||||
cy.findByRole('link', { name: 'Source' })
|
||||
cy.findByRole('link', { name: 'PDF' })
|
||||
|
||||
// Actions Menu
|
||||
cy.findByRole('heading', { name: 'Actions' }).should('not.exist')
|
||||
cy.findByRole('button', { name: 'Copy Project' }).should('not.exist')
|
||||
cy.findByRole('button', { name: 'Word Count' }).should('not.exist')
|
||||
|
||||
// Sync Menu
|
||||
cy.findByRole('heading', { name: 'Sync' }).should('not.exist')
|
||||
cy.findByRole('button', { name: 'Dropbox' }).should('not.exist')
|
||||
cy.findByRole('button', { name: 'Git' }).should('not.exist')
|
||||
cy.findByRole('button', { name: 'GitHub' }).should('not.exist')
|
||||
|
||||
// Settings Menu
|
||||
cy.findByRole('heading', { name: 'Settings' }).should('not.exist')
|
||||
cy.findByLabelText('Compiler').should('not.exist')
|
||||
cy.findByLabelText('TeX Live version').should('not.exist')
|
||||
cy.findByLabelText('Main document').should('not.exist')
|
||||
cy.findByLabelText('Spell check').should('not.exist')
|
||||
cy.findByLabelText('Auto-complete').should('not.exist')
|
||||
cy.findByLabelText('Auto-close brackets').should('not.exist')
|
||||
cy.findByLabelText('Code check').should('not.exist')
|
||||
cy.findByLabelText('Editor theme').should('not.exist')
|
||||
cy.findByLabelText('Overall theme').should('not.exist')
|
||||
cy.findByLabelText('Keybindings').should('not.exist')
|
||||
cy.findByLabelText('Font Size').should('not.exist')
|
||||
cy.findByLabelText('Font Family').should('not.exist')
|
||||
cy.findByLabelText('Line Height').should('not.exist')
|
||||
cy.findByLabelText('PDF Viewer').should('not.exist')
|
||||
|
||||
// Help Menu
|
||||
cy.findByRole('heading', { name: 'Help' })
|
||||
cy.findByRole('button', { name: 'Show Hotkeys' })
|
||||
cy.findByRole('button', { name: 'Documentation' }).should('not.exist')
|
||||
cy.findByRole('link', { name: 'Contact Us' }).should('not.exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { MainDocument } from '../../../../types/project-settings'
|
||||
import { PdfViewer } from '../../../../types/user-settings'
|
||||
|
||||
type Scope = {
|
||||
settings?: {
|
||||
syntaxValidation?: boolean
|
||||
pdfViewer?: PdfViewer
|
||||
}
|
||||
editor?: {
|
||||
sharejs_doc?: {
|
||||
doc_id?: string
|
||||
getSnapshot?: () => string
|
||||
}
|
||||
}
|
||||
hasLintingError?: boolean
|
||||
ui?: {
|
||||
view?: 'editor' | 'history' | 'file' | 'pdf'
|
||||
pdfLayout?: 'flat' | 'sideBySide' | 'split'
|
||||
leftMenuShown?: boolean
|
||||
}
|
||||
project?: {
|
||||
members?: any[]
|
||||
owner: {
|
||||
_id: string
|
||||
}
|
||||
features?: {
|
||||
gitBridge?: boolean
|
||||
}
|
||||
}
|
||||
user?: {
|
||||
features?: {
|
||||
dropbox: boolean
|
||||
}
|
||||
}
|
||||
docs?: MainDocument[]
|
||||
}
|
||||
|
||||
export const mockScope = (scope?: Scope) => ({
|
||||
settings: {
|
||||
syntaxValidation: false,
|
||||
pdfViewer: 'pdfjs',
|
||||
},
|
||||
editor: {
|
||||
sharejs_doc: {
|
||||
doc_id: 'test-doc',
|
||||
getSnapshot: () => 'some doc content',
|
||||
},
|
||||
},
|
||||
hasLintingError: false,
|
||||
ui: {
|
||||
view: 'editor',
|
||||
pdfLayout: 'sideBySide',
|
||||
leftMenuShown: false,
|
||||
},
|
||||
...scope,
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import { EditorProviders } from '../../helpers/editor-providers'
|
||||
import DetachCompileButtonWrapper from '../../../../frontend/js/features/pdf-preview/components/detach-compile-button-wrapper'
|
||||
import { mockScope } from './scope'
|
||||
import { testDetachChannel } from '../../helpers/detach-channel'
|
||||
|
||||
describe('<DetachCompileButtonWrapper />', function () {
|
||||
beforeEach(function () {
|
||||
cy.interceptEvents()
|
||||
})
|
||||
|
||||
it('detacher mode and not linked: does not show button ', function () {
|
||||
cy.interceptCompile()
|
||||
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-detachRole', 'detacher')
|
||||
})
|
||||
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<DetachCompileButtonWrapper />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.waitForCompile()
|
||||
|
||||
cy.findByRole('button', { name: 'Recompile' }).should('not.exist')
|
||||
})
|
||||
|
||||
it('detacher mode and linked: show button', function () {
|
||||
cy.interceptCompile()
|
||||
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-detachRole', 'detacher')
|
||||
})
|
||||
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<DetachCompileButtonWrapper />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.waitForCompile()
|
||||
|
||||
cy.wrap(null).then(() => {
|
||||
testDetachChannel.postMessage({
|
||||
role: 'detached',
|
||||
event: 'connected',
|
||||
})
|
||||
})
|
||||
|
||||
cy.findByRole('button', { name: 'Recompile' })
|
||||
})
|
||||
|
||||
it('not detacher mode and linked: does not show button ', function () {
|
||||
cy.interceptCompile()
|
||||
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-detachRole', 'detached')
|
||||
})
|
||||
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<DetachCompileButtonWrapper />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.waitForCompile()
|
||||
|
||||
cy.wrap(null).then(() => {
|
||||
testDetachChannel.postMessage({
|
||||
role: 'detacher',
|
||||
event: 'connected',
|
||||
})
|
||||
})
|
||||
|
||||
cy.findByRole('button', { name: 'Recompile' }).should('not.exist')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import { EditorProviders } from '../../helpers/editor-providers'
|
||||
import PdfJsViewer from '../../../../frontend/js/features/pdf-preview/components/pdf-js-viewer'
|
||||
import { mockScope } from './scope'
|
||||
import { getContainerEl } from 'cypress/react'
|
||||
import { unmountComponentAtNode } from 'react-dom'
|
||||
import { PdfPreviewProvider } from '../../../../frontend/js/features/pdf-preview/components/pdf-preview-provider'
|
||||
|
||||
// Unicode directional isolates, added around placeables by @fluent/bundle/esm/resolver
|
||||
const FSI = '\u2068'
|
||||
const PDI = '\u2069'
|
||||
|
||||
describe('<PdfJSViewer/>', function () {
|
||||
beforeEach(function () {
|
||||
cy.interceptEvents()
|
||||
})
|
||||
|
||||
it('loads all PDF pages', function () {
|
||||
cy.interceptCompile()
|
||||
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<PdfPreviewProvider>
|
||||
<div className="pdf-viewer">
|
||||
<PdfJsViewer url="/build/123/output.pdf?clsiserverid=foo" />
|
||||
</div>
|
||||
</PdfPreviewProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.waitForCompile({ pdf: true })
|
||||
|
||||
cy.findByRole('region', { name: `Page ${FSI}1${PDI}` })
|
||||
cy.findByRole('region', { name: `Page ${FSI}2${PDI}` })
|
||||
cy.findByRole('region', { name: `Page ${FSI}3${PDI}` })
|
||||
cy.findByRole('region', { name: `Page ${FSI}4${PDI}` }).should('not.exist')
|
||||
|
||||
cy.contains('Your Paper')
|
||||
})
|
||||
|
||||
it('renders pages in a "loading" state', function () {
|
||||
cy.interceptCompile()
|
||||
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<PdfPreviewProvider>
|
||||
<div className="pdf-viewer">
|
||||
<PdfJsViewer url="/build/123/output.pdf" />
|
||||
</div>
|
||||
</PdfPreviewProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.waitForCompile()
|
||||
|
||||
cy.get('.page.loading')
|
||||
})
|
||||
|
||||
it('can be unmounted while loading a document', function () {
|
||||
cy.interceptCompile()
|
||||
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<PdfPreviewProvider>
|
||||
<div className="pdf-viewer">
|
||||
<PdfJsViewer url="/build/123/output.pdf?clsiserverid=foo" />
|
||||
</div>
|
||||
</PdfPreviewProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.waitForCompile()
|
||||
|
||||
cy.then(() => unmountComponentAtNode(getContainerEl()))
|
||||
})
|
||||
|
||||
it('can be unmounted after loading a document', function () {
|
||||
cy.interceptCompile()
|
||||
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<PdfPreviewProvider>
|
||||
<div className="pdf-viewer">
|
||||
<PdfJsViewer url="/build/123/output.pdf?clsiserverid=foo" />
|
||||
</div>
|
||||
</PdfPreviewProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.waitForCompile({ pdf: true })
|
||||
|
||||
cy.findByRole('region', { name: `Page ${FSI}1${PDI}` })
|
||||
|
||||
cy.then(() => unmountComponentAtNode(getContainerEl()))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,186 @@
|
||||
import { EditorProviders } from '../../helpers/editor-providers'
|
||||
import PdfLogsEntries from '../../../../frontend/js/features/pdf-preview/components/pdf-logs-entries'
|
||||
import { detachChannel, testDetachChannel } from '../../helpers/detach-channel'
|
||||
import { FileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
|
||||
import { FindResult } from '@/features/file-tree/util/path'
|
||||
import { FC } from 'react'
|
||||
import {
|
||||
EditorManager,
|
||||
EditorManagerContext,
|
||||
} from '@/features/ide-react/context/editor-manager-context'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { OpenDocuments } from '@/features/ide-react/editor/open-documents'
|
||||
import { LogEntry } from '@/features/pdf-preview/util/types'
|
||||
|
||||
describe('<PdfLogsEntries/>', function () {
|
||||
const fakeFindEntityResult: FindResult = {
|
||||
type: 'doc',
|
||||
entity: { _id: '123', name: '123 Doc' },
|
||||
}
|
||||
|
||||
const FileTreePathProvider: FC = ({ children }) => (
|
||||
<FileTreePathContext.Provider
|
||||
value={{
|
||||
dirname: cy.stub(),
|
||||
findEntityByPath: cy
|
||||
.stub()
|
||||
.as('findEntityByPath')
|
||||
.returns(fakeFindEntityResult),
|
||||
pathInFolder: cy.stub(),
|
||||
previewByPath: cy.stub(),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</FileTreePathContext.Provider>
|
||||
)
|
||||
|
||||
const EditorManagerProvider: FC = ({ children }) => {
|
||||
const value = {
|
||||
openDocWithId: cy.spy().as('openDocWithId'),
|
||||
// @ts-ignore
|
||||
openDocs: new OpenDocuments(),
|
||||
} as unknown as EditorManager
|
||||
|
||||
return (
|
||||
<EditorManagerContext.Provider value={value}>
|
||||
{children}
|
||||
</EditorManagerContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const logEntries: LogEntry[] = [
|
||||
{
|
||||
file: 'main.tex',
|
||||
line: 9,
|
||||
column: 8,
|
||||
level: 'error',
|
||||
message: 'LaTeX Error',
|
||||
content: 'See the LaTeX manual',
|
||||
raw: '',
|
||||
ruleId: 'hint_misplaced_alignment_tab_character',
|
||||
key: '',
|
||||
},
|
||||
]
|
||||
|
||||
const scope = {
|
||||
'editor.view': new EditorView({ doc: '\\documentclass{article}' }),
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.interceptCompile()
|
||||
cy.interceptEvents()
|
||||
})
|
||||
|
||||
it('displays human readable hint', function () {
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<PdfLogsEntries entries={logEntries} />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.contains('You have placed an alignment tab character')
|
||||
})
|
||||
|
||||
it('opens doc on click', function () {
|
||||
cy.mount(
|
||||
<EditorProviders
|
||||
scope={scope}
|
||||
providers={{ EditorManagerProvider, FileTreePathProvider }}
|
||||
>
|
||||
<PdfLogsEntries entries={logEntries} />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button', {
|
||||
name: 'Navigate to log position in source code: main.tex, 9',
|
||||
}).click()
|
||||
|
||||
cy.get('@findEntityByPath').should('have.been.calledOnceWith', 'main.tex')
|
||||
cy.get('@openDocWithId').should(
|
||||
'have.been.calledOnceWith',
|
||||
fakeFindEntityResult.entity._id,
|
||||
{
|
||||
gotoLine: 9,
|
||||
gotoColumn: 8,
|
||||
keepCurrentView: false,
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('opens doc via detached action', function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-detachRole', 'detacher')
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders
|
||||
scope={scope}
|
||||
providers={{ EditorManagerProvider, FileTreePathProvider }}
|
||||
>
|
||||
<PdfLogsEntries entries={logEntries} />
|
||||
</EditorProviders>
|
||||
).then(() => {
|
||||
testDetachChannel.postMessage({
|
||||
role: 'detached',
|
||||
event: 'action-sync-to-entry',
|
||||
data: {
|
||||
args: [
|
||||
{
|
||||
file: 'main.tex',
|
||||
line: 7,
|
||||
column: 6,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
cy.get('@findEntityByPath').should('have.been.calledOnce')
|
||||
cy.get('@openDocWithId').should(
|
||||
'have.been.calledOnceWith',
|
||||
fakeFindEntityResult.entity._id,
|
||||
{
|
||||
gotoLine: 7,
|
||||
gotoColumn: 6,
|
||||
keepCurrentView: false,
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('sends open doc clicks via detached action', function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-detachRole', 'detached')
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders
|
||||
scope={scope}
|
||||
providers={{ EditorManagerProvider, FileTreePathProvider }}
|
||||
>
|
||||
<PdfLogsEntries entries={logEntries} />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.spy(detachChannel, 'postMessage').as('postDetachMessage')
|
||||
|
||||
cy.findByRole('button', {
|
||||
name: 'Navigate to log position in source code: main.tex, 9',
|
||||
}).click()
|
||||
|
||||
cy.get('@findEntityByPath').should('not.have.been.called')
|
||||
cy.get('@openDocWithId').should('not.have.been.called')
|
||||
cy.get('@postDetachMessage').should('have.been.calledWith', {
|
||||
role: 'detached',
|
||||
event: 'action-sync-to-entry',
|
||||
data: {
|
||||
args: [
|
||||
{
|
||||
file: 'main.tex',
|
||||
line: 9,
|
||||
column: 8,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import PdfPreviewDetachedRoot from '../../../../frontend/js/features/pdf-preview/components/pdf-preview-detached-root'
|
||||
import { detachChannel, testDetachChannel } from '../../helpers/detach-channel'
|
||||
|
||||
describe('<PdfPreviewDetachedRoot/>', function () {
|
||||
beforeEach(function () {
|
||||
window.metaAttributesCache.set('ol-user', { id: 'user1' })
|
||||
window.metaAttributesCache.set('ol-project_id', 'project1')
|
||||
window.metaAttributesCache.set('ol-detachRole', 'detached')
|
||||
window.metaAttributesCache.set('ol-projectName', 'Project Name')
|
||||
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
|
||||
|
||||
cy.interceptEvents()
|
||||
})
|
||||
|
||||
it('syncs compiling state', function () {
|
||||
cy.interceptCompile()
|
||||
|
||||
cy.mount(<PdfPreviewDetachedRoot />)
|
||||
|
||||
cy.wrap(null).then(() => {
|
||||
testDetachChannel.postMessage({
|
||||
role: 'detacher',
|
||||
event: 'connected',
|
||||
})
|
||||
|
||||
testDetachChannel.postMessage({
|
||||
role: 'detacher',
|
||||
event: 'state-compiling',
|
||||
data: { value: true },
|
||||
})
|
||||
})
|
||||
|
||||
cy.findByRole('button', { name: 'Compiling…' })
|
||||
cy.findByRole('button', { name: 'Recompile' }).should('not.exist')
|
||||
cy.wrap(null).then(() => {
|
||||
testDetachChannel.postMessage({
|
||||
role: 'detacher',
|
||||
event: 'state-compiling',
|
||||
data: { value: false },
|
||||
})
|
||||
})
|
||||
cy.findByRole('button', { name: 'Recompile' })
|
||||
cy.findByRole('button', { name: 'Compiling…' }).should('not.exist')
|
||||
})
|
||||
|
||||
it('sends a clear cache request when the button is pressed', function () {
|
||||
cy.interceptCompile()
|
||||
|
||||
cy.mount(<PdfPreviewDetachedRoot />)
|
||||
|
||||
cy.wrap(null).then(() => {
|
||||
testDetachChannel.postMessage({
|
||||
role: 'detacher',
|
||||
event: 'state-showLogs',
|
||||
data: { value: true },
|
||||
})
|
||||
})
|
||||
|
||||
cy.spy(detachChannel, 'postMessage').as('postDetachMessage')
|
||||
|
||||
cy.findByRole('button', { name: 'Clear cached files' })
|
||||
.should('not.be.disabled')
|
||||
.click()
|
||||
|
||||
cy.get('@postDetachMessage').should('have.been.calledWith', {
|
||||
role: 'detached',
|
||||
event: 'action-clearCache',
|
||||
data: {
|
||||
args: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import { EditorProviders } from '../../helpers/editor-providers'
|
||||
import PdfPreviewHybridToolbar from '../../../../frontend/js/features/pdf-preview/components/pdf-preview-hybrid-toolbar'
|
||||
import { testDetachChannel } from '../../helpers/detach-channel'
|
||||
|
||||
describe('<PdfPreviewHybridToolbar/>', function () {
|
||||
beforeEach(function () {
|
||||
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
|
||||
cy.interceptEvents()
|
||||
})
|
||||
|
||||
it('shows normal mode', function () {
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<PdfPreviewHybridToolbar />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Recompile' })
|
||||
})
|
||||
|
||||
describe('orphan mode', function () {
|
||||
it('shows connecting message on load', function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-detachRole', 'detached')
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<PdfPreviewHybridToolbar />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.contains('Connecting with the editor')
|
||||
})
|
||||
|
||||
it('shows compile UI when connected', function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-detachRole', 'detached')
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<PdfPreviewHybridToolbar />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.wrap(null).then(() => {
|
||||
testDetachChannel.postMessage({
|
||||
role: 'detacher',
|
||||
event: 'connected',
|
||||
})
|
||||
})
|
||||
|
||||
cy.findByRole('button', { name: 'Recompile' })
|
||||
})
|
||||
|
||||
it('shows connecting message when disconnected', function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-detachRole', 'detached')
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<PdfPreviewHybridToolbar />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.wrap(null).then(() => {
|
||||
testDetachChannel.postMessage({
|
||||
role: 'detacher',
|
||||
event: 'connected',
|
||||
})
|
||||
testDetachChannel.postMessage({
|
||||
role: 'detacher',
|
||||
event: 'closed',
|
||||
})
|
||||
})
|
||||
|
||||
cy.contains('Connecting with the editor')
|
||||
})
|
||||
|
||||
it('shows redirect button after timeout', function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-detachRole', 'detached')
|
||||
})
|
||||
|
||||
cy.clock()
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<PdfPreviewHybridToolbar />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.tick(6000)
|
||||
|
||||
cy.findByRole('button', { name: 'Redirect to editor' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,872 @@
|
||||
import localStorage from '@/infrastructure/local-storage'
|
||||
import PdfPreview from '../../../../frontend/js/features/pdf-preview/components/pdf-preview'
|
||||
import { EditorProviders } from '../../helpers/editor-providers'
|
||||
import { mockScope } from './scope'
|
||||
import {
|
||||
IdeLayout,
|
||||
IdeView,
|
||||
useLayoutContext,
|
||||
} from '../../../../frontend/js/shared/context/layout-context'
|
||||
import { FC, useEffect } from 'react'
|
||||
|
||||
const storeAndFireEvent = (win: typeof window, key: string, value: unknown) => {
|
||||
localStorage.setItem(key, value)
|
||||
win.dispatchEvent(new StorageEvent('storage', { key }))
|
||||
}
|
||||
|
||||
const Layout: FC<{ layout: IdeLayout; view?: IdeView }> = ({
|
||||
layout,
|
||||
view,
|
||||
}) => {
|
||||
const { changeLayout } = useLayoutContext()
|
||||
|
||||
useEffect(() => {
|
||||
changeLayout(layout, view)
|
||||
}, [changeLayout, layout, view])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
describe('<PdfPreview/>', function () {
|
||||
let projectId: string
|
||||
beforeEach(function () {
|
||||
/**
|
||||
* There are time sensitive tests in this test suite. They need to wait for a Promise before resolving a request.
|
||||
*
|
||||
* Using a promise across the test-env (browser) vs stub-env (server) causes additional latency.
|
||||
*
|
||||
* This latency seems to stack up when adding more intercepts for the same path. Using static responses for some of these intercepts does not help.
|
||||
*
|
||||
* All of that seems like a bug in Cypress. For now just work around it by using a unique projectId for each intercept.
|
||||
*/
|
||||
projectId = Math.random().toString().slice(2)
|
||||
|
||||
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
|
||||
window.metaAttributesCache.set(
|
||||
'ol-compilesUserContentDomain',
|
||||
'https://compiles-user.dev-overleaf.com'
|
||||
)
|
||||
window.metaAttributesCache.set('ol-splitTestVariants', {
|
||||
'initial-compile-from-clsi-cache': 'enabled',
|
||||
})
|
||||
cy.interceptEvents()
|
||||
})
|
||||
|
||||
it('renders the PDF preview', function () {
|
||||
window.metaAttributesCache.set('ol-preventCompileOnLoad', false)
|
||||
cy.interceptCompile()
|
||||
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<div className="pdf-viewer">
|
||||
<PdfPreview />
|
||||
</div>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
// wait for "compile on load" to finish
|
||||
cy.waitForCompile({ pdf: true })
|
||||
|
||||
cy.findByRole('button', { name: 'Recompile' })
|
||||
})
|
||||
|
||||
it('uses the cache when available', function () {
|
||||
cy.interceptCompile({
|
||||
prefix: 'compile',
|
||||
times: 1,
|
||||
cached: true,
|
||||
regular: false,
|
||||
})
|
||||
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<div className="pdf-viewer">
|
||||
<PdfPreview />
|
||||
</div>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
// wait for "compile from cache on load" to finish
|
||||
cy.waitForCompile({ pdf: true, cached: true, regular: false })
|
||||
|
||||
cy.contains('Your Paper')
|
||||
})
|
||||
|
||||
it('uses the cache when available then compiles', function () {
|
||||
cy.interceptCompile({
|
||||
prefix: 'compile',
|
||||
times: 1,
|
||||
cached: true,
|
||||
regular: false,
|
||||
})
|
||||
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<div className="pdf-viewer">
|
||||
<PdfPreview />
|
||||
</div>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
// wait for "compile from cache on load" to finish
|
||||
cy.waitForCompile({ pdf: true, cached: true, regular: false })
|
||||
cy.contains('Your Paper')
|
||||
|
||||
// Then trigger a new compile
|
||||
cy.interceptCompile({
|
||||
prefix: 'recompile',
|
||||
times: 1,
|
||||
cached: false,
|
||||
outputPDFFixture: 'output-2.pdf',
|
||||
})
|
||||
|
||||
// press the Recompile button => compile
|
||||
cy.findByRole('button', { name: 'Recompile' }).click()
|
||||
|
||||
// wait for compile to finish
|
||||
cy.waitForCompile({ prefix: 'recompile', pdf: true })
|
||||
cy.contains('Modern Authoring Tools for Science')
|
||||
})
|
||||
|
||||
describe('racing compile from cache and regular compile trigger', function () {
|
||||
for (const [timing] of ['before rendering', 'after rendering']) {
|
||||
it(`replaces the compile from cache with a regular compile - ${timing}`, function () {
|
||||
const requestedOnce = new Set()
|
||||
;['log', 'pdf', 'blg'].forEach(ext => {
|
||||
cy.intercept({ pathname: `/build/*/output.${ext}` }, req => {
|
||||
if (requestedOnce.has(ext)) {
|
||||
throw new Error(
|
||||
`compile from cache triggered extra ${ext} request: ${req.url}`
|
||||
)
|
||||
}
|
||||
requestedOnce.add(ext)
|
||||
req.reply({ fixture: `build/output.${ext},null` })
|
||||
}).as(`compile-${ext}`)
|
||||
})
|
||||
const { promise, resolve } = Promise.withResolvers<void>()
|
||||
cy.interceptCompileFromCacheRequest({
|
||||
promise,
|
||||
times: 1,
|
||||
}).as('cached-compile')
|
||||
cy.interceptCompileRequest().as('compile')
|
||||
|
||||
const scope = mockScope()
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope} projectId={projectId}>
|
||||
<div className="pdf-viewer">
|
||||
<PdfPreview />
|
||||
</div>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
// press the Recompile button => compile
|
||||
cy.findByRole('button', { name: 'Recompile' }).click()
|
||||
|
||||
if (timing === 'before rendering') {
|
||||
cy.then(() => resolve())
|
||||
cy.wait('@cached-compile')
|
||||
}
|
||||
|
||||
// wait for rendering to finish
|
||||
cy.waitForCompile({ pdf: true, cached: false })
|
||||
|
||||
if (timing === 'after rendering') {
|
||||
cy.then(() => resolve())
|
||||
cy.wait('@cached-compile')
|
||||
}
|
||||
|
||||
cy.contains('Your Paper')
|
||||
cy.then(() => Array.from(requestedOnce).sort().join(',')).should(
|
||||
'equal',
|
||||
'blg,log,pdf'
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('clsi-cache project settings validation', function () {
|
||||
const cases = {
|
||||
// Flaky, skip for now
|
||||
'uses compile from cache when nothing changed': {
|
||||
cached: true,
|
||||
setup: () => {},
|
||||
props: {},
|
||||
},
|
||||
'ignores the compile from cache when imageName changed': {
|
||||
cached: false,
|
||||
setup: () => {},
|
||||
props: {
|
||||
imageName: 'texlive-full:2025.1',
|
||||
},
|
||||
},
|
||||
'ignores the compile from cache when compiler changed': {
|
||||
cached: false,
|
||||
setup: () => {},
|
||||
props: {
|
||||
compiler: 'lualatex',
|
||||
},
|
||||
},
|
||||
'ignores the compile from cache when draft mode changed': {
|
||||
cached: false,
|
||||
setup: () => {
|
||||
cy.window().then(w =>
|
||||
w.localStorage.setItem(`draft:${projectId}`, 'true')
|
||||
)
|
||||
},
|
||||
props: {},
|
||||
},
|
||||
'ignores the compile from cache when stopOnFirstError mode changed': {
|
||||
cached: false,
|
||||
setup: () => {
|
||||
cy.window().then(w =>
|
||||
w.localStorage.setItem(`stop_on_first_error:${projectId}`, 'true')
|
||||
)
|
||||
},
|
||||
props: {},
|
||||
},
|
||||
'ignores the compile from cache when rootDoc changed': {
|
||||
cached: false,
|
||||
setup: () => {},
|
||||
props: {
|
||||
rootDocId: 'new-root-doc-id',
|
||||
rootFolder: [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [
|
||||
{
|
||||
_id: '_root_doc_id',
|
||||
name: 'main.tex',
|
||||
},
|
||||
{
|
||||
_id: 'new-root-doc-id',
|
||||
name: 'new-main.tex',
|
||||
},
|
||||
],
|
||||
folders: [],
|
||||
fileRefs: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
Object.entries(cases).forEach(([name, { cached, setup, props }]) => {
|
||||
it(name, function () {
|
||||
cy.interceptCompile({
|
||||
cached: true,
|
||||
regular: !cached,
|
||||
})
|
||||
|
||||
const scope = mockScope()
|
||||
window.metaAttributesCache.set('ol-preventCompileOnLoad', false)
|
||||
setup()
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope} projectId={projectId} {...props}>
|
||||
<div className="pdf-viewer">
|
||||
<PdfPreview />
|
||||
</div>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
// wait for compile to finish
|
||||
cy.waitForCompile({ pdf: true, cached, regular: !cached })
|
||||
cy.contains('Your Paper')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('runs a compile when the Recompile button is pressed', function () {
|
||||
cy.interceptCompile()
|
||||
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<div className="pdf-viewer">
|
||||
<PdfPreview />
|
||||
</div>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
// press the Recompile button => compile
|
||||
cy.findByRole('button', { name: 'Recompile' }).click()
|
||||
|
||||
// wait for compile to finish
|
||||
cy.waitForCompile({ pdf: true })
|
||||
|
||||
cy.contains('Your Paper')
|
||||
})
|
||||
|
||||
it('runs a compile on `pdf:recompile` event', function () {
|
||||
cy.interceptCompile()
|
||||
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<div className="pdf-viewer">
|
||||
<PdfPreview />
|
||||
</div>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.window().then(win => {
|
||||
win.dispatchEvent(new CustomEvent('pdf:recompile'))
|
||||
})
|
||||
|
||||
// wait for compile to finish
|
||||
cy.waitForCompile({ pdf: true })
|
||||
|
||||
cy.contains('Your Paper')
|
||||
})
|
||||
|
||||
it('does not compile while compiling', function () {
|
||||
let counter = 0
|
||||
cy.interceptDeferredCompile(() => counter++).then(
|
||||
resolveDeferredCompile => {
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<div className="pdf-viewer">
|
||||
<PdfPreview />
|
||||
</div>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
// start compiling
|
||||
cy.findByRole('button', { name: 'Recompile' }).click()
|
||||
|
||||
cy.findByRole('button', { name: 'Compiling…' }).then(() => {
|
||||
// trigger a recompile
|
||||
cy.window().then(win => {
|
||||
win.dispatchEvent(new CustomEvent('pdf:recompile'))
|
||||
})
|
||||
|
||||
// finish the original compile
|
||||
resolveDeferredCompile()
|
||||
|
||||
// wait for the original compile to finish
|
||||
cy.waitForCompile().then(() => {
|
||||
// NOTE: difficult to assert that a second request won't be sent, at some point
|
||||
expect(counter).to.equal(1)
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('disables compile button while compile is running', function () {
|
||||
cy.interceptDeferredCompile().then(resolveDeferredCompile => {
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<div className="pdf-viewer">
|
||||
<PdfPreview />
|
||||
</div>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Recompile' }).click()
|
||||
cy.findByRole('button', { name: 'Compiling…' })
|
||||
.should('be.disabled')
|
||||
.then(() => resolveDeferredCompile())
|
||||
|
||||
cy.waitForCompile()
|
||||
cy.findByRole('button', { name: 'Recompile' }).should('not.be.disabled')
|
||||
})
|
||||
})
|
||||
|
||||
it('runs a compile on doc change if autocompile is enabled', function () {
|
||||
cy.interceptCompile()
|
||||
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<div className="pdf-viewer">
|
||||
<PdfPreview />
|
||||
</div>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.window().then(win => {
|
||||
cy.clock()
|
||||
|
||||
// switch on auto compile
|
||||
storeAndFireEvent(win, 'autocompile_enabled:project123', true)
|
||||
|
||||
// fire a doc:changed event => compile
|
||||
win.dispatchEvent(new CustomEvent('doc:changed'))
|
||||
|
||||
// wait enough time for the compile to start
|
||||
cy.tick(6000) // > AUTO_COMPILE_DEBOUNCE
|
||||
|
||||
cy.clock().invoke('restore')
|
||||
})
|
||||
|
||||
// wait for compile to finish
|
||||
cy.waitForCompile({ pdf: true })
|
||||
|
||||
cy.findByRole('button', { name: 'Recompile' })
|
||||
})
|
||||
|
||||
it('does not run a compile on doc change if autocompile is disabled', function () {
|
||||
cy.interceptCompile()
|
||||
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<div className="pdf-viewer">
|
||||
<PdfPreview />
|
||||
</div>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.window().then(win => {
|
||||
cy.clock()
|
||||
|
||||
// make sure auto compile is switched off
|
||||
storeAndFireEvent(win, 'autocompile_enabled:project123', false)
|
||||
|
||||
// fire a doc:changed event => no compile
|
||||
win.dispatchEvent(new CustomEvent('doc:changed'))
|
||||
|
||||
// wait enough time for the compile to start
|
||||
cy.tick(6000) // AUTO_COMPILE_DEBOUNCE
|
||||
|
||||
cy.clock().invoke('restore')
|
||||
})
|
||||
|
||||
// NOTE: difficult to assert that a request hasn't been sent
|
||||
cy.findByRole('button', { name: 'Recompile' })
|
||||
})
|
||||
|
||||
it('does not run a compile on doc change if autocompile is blocked by syntax check', function () {
|
||||
cy.interceptCompile()
|
||||
|
||||
const scope = mockScope()
|
||||
// enable linting in the editor
|
||||
const userSettings = { syntaxValidation: true }
|
||||
// mock a linting error
|
||||
scope.hasLintingError = true
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope} userSettings={userSettings}>
|
||||
<div className="pdf-viewer">
|
||||
<PdfPreview />
|
||||
</div>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.window().then(win => {
|
||||
cy.clock()
|
||||
|
||||
// switch on auto compile
|
||||
storeAndFireEvent(win, 'autocompile_enabled:project123', true)
|
||||
|
||||
// switch on syntax checking
|
||||
storeAndFireEvent(win, 'stop_on_validation_error', true)
|
||||
|
||||
// fire a doc:changed event => no compile
|
||||
win.dispatchEvent(new CustomEvent('doc:changed'))
|
||||
|
||||
// wait enough time for the compile to start
|
||||
cy.tick(6000) // AUTO_COMPILE_DEBOUNCE
|
||||
|
||||
cy.clock().invoke('restore')
|
||||
})
|
||||
|
||||
// NOTE: difficult to assert that a request hasn't been sent
|
||||
cy.findByRole('button', { name: 'Recompile' })
|
||||
|
||||
cy.findByText('Code check failed')
|
||||
})
|
||||
|
||||
it('does not run a compile on doc change if the PDF preview is not open', function () {
|
||||
cy.interceptCompile()
|
||||
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<Layout layout="flat" view="editor" />
|
||||
<div className="pdf-viewer">
|
||||
<PdfPreview />
|
||||
</div>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.window().then(win => {
|
||||
cy.clock()
|
||||
|
||||
// switch on auto compile
|
||||
storeAndFireEvent(win, 'autocompile_enabled:project123', true)
|
||||
|
||||
// fire a doc:changed event => compile
|
||||
win.dispatchEvent(new CustomEvent('doc:changed'))
|
||||
|
||||
// wait enough time for the compile to start
|
||||
cy.tick(6000) // > AUTO_COMPILE_DEBOUNCE
|
||||
|
||||
cy.clock().invoke('restore')
|
||||
})
|
||||
|
||||
// NOTE: difficult to assert that a request hasn't been sent
|
||||
cy.findByRole('button', { name: 'Recompile' })
|
||||
})
|
||||
|
||||
describe('error messages', function () {
|
||||
const compileErrorStatuses = {
|
||||
'clear-cache':
|
||||
'Sorry, something went wrong and your project could not be compiled. Please try again in a few moments.',
|
||||
'clsi-maintenance':
|
||||
'The compile servers are down for maintenance, and will be back shortly.',
|
||||
'compile-in-progress':
|
||||
'A previous compile is still running. Please wait a minute and try compiling again.',
|
||||
exited: 'Server Error',
|
||||
failure: 'No PDF',
|
||||
generic: 'Server Error',
|
||||
'project-too-large': 'Project too large',
|
||||
'rate-limited': 'Compile rate limit hit',
|
||||
terminated: 'Compilation cancelled',
|
||||
timedout: 'Timed out',
|
||||
'too-recently-compiled':
|
||||
'This project was compiled very recently, so this compile has been skipped.',
|
||||
unavailable:
|
||||
'Sorry, the compile server for your project was temporarily unavailable. Please try again in a few moments.',
|
||||
foo: 'Sorry, something went wrong and your project could not be compiled. Please try again in a few moments.',
|
||||
}
|
||||
|
||||
for (const [status, message] of Object.entries(compileErrorStatuses)) {
|
||||
it(`displays error message for '${status}' status`, function () {
|
||||
cy.intercept('POST', '/project/*/compile*', {
|
||||
body: {
|
||||
status,
|
||||
clsiServerId: 'foo',
|
||||
compileGroup: 'priority',
|
||||
},
|
||||
}).as('compile')
|
||||
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<div className="pdf-viewer">
|
||||
<PdfPreview />
|
||||
</div>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Recompile' }).click()
|
||||
cy.wait('@compile')
|
||||
cy.findByText(message)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('displays expandable raw logs', function () {
|
||||
cy.interceptCompile()
|
||||
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<div className="pdf-viewer">
|
||||
<PdfPreview />
|
||||
</div>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Recompile' }).click()
|
||||
cy.waitForCompile({ pdf: true })
|
||||
|
||||
cy.findByRole('button', { name: 'View logs' }).click()
|
||||
cy.findByRole('button', { name: 'View PDF' })
|
||||
|
||||
cy.findByRole('button', { name: 'Expand' }).click()
|
||||
cy.findByRole('button', { name: 'Collapse' }).click()
|
||||
})
|
||||
|
||||
it('displays error messages if there were validation problems', function () {
|
||||
const validationProblems = {
|
||||
sizeCheck: {
|
||||
resources: [
|
||||
{ path: 'foo/bar', kbSize: 76221 },
|
||||
{ path: 'bar/baz', kbSize: 2342 },
|
||||
],
|
||||
},
|
||||
mainFile: true,
|
||||
conflictedPaths: [
|
||||
{
|
||||
path: 'foo/bar',
|
||||
},
|
||||
{
|
||||
path: 'foo/baz',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
cy.intercept('POST', '/project/*/compile*', {
|
||||
body: {
|
||||
status: 'validation-problems',
|
||||
validationProblems,
|
||||
clsiServerId: 'foo',
|
||||
compileGroup: 'priority',
|
||||
},
|
||||
}).as('compile')
|
||||
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<div className="pdf-viewer">
|
||||
<PdfPreview />
|
||||
</div>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Recompile' }).click()
|
||||
cy.wait('@compile')
|
||||
|
||||
cy.findByText('Project too large')
|
||||
cy.findByText('Unknown main document')
|
||||
cy.findByText('Conflicting Paths Found')
|
||||
})
|
||||
|
||||
describe('clear cache', function () {
|
||||
it('sends a clear cache request when the button is pressed', function () {
|
||||
cy.interceptCompile()
|
||||
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<div className="pdf-viewer">
|
||||
<PdfPreview />
|
||||
</div>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Recompile' }).click()
|
||||
cy.waitForCompile({ pdf: true })
|
||||
|
||||
cy.findByRole('button', { name: 'View logs' }).click()
|
||||
cy.findByRole('button', { name: 'Clear cached files' }).should(
|
||||
'not.be.disabled'
|
||||
)
|
||||
|
||||
const { promise, resolve } = Promise.withResolvers<void>()
|
||||
|
||||
cy.intercept('DELETE', '/project/*/output*', req => {
|
||||
return promise
|
||||
.then(() => Cypress.Promise.delay(100))
|
||||
.then(() => {
|
||||
req.reply({ statusCode: 204 })
|
||||
})
|
||||
}).as('clear-cache')
|
||||
|
||||
// click the button
|
||||
cy.findByRole('button', { name: 'Clear cached files' }).click()
|
||||
cy.findByRole('button', { name: 'Clear cached files' }).should(
|
||||
'be.disabled'
|
||||
)
|
||||
cy.then(() => {
|
||||
resolve()
|
||||
})
|
||||
cy.wait('@clear-cache')
|
||||
cy.findByRole('button', { name: 'Clear cached files' }).should(
|
||||
'not.be.disabled'
|
||||
)
|
||||
})
|
||||
|
||||
it('handle "recompile from scratch"', function () {
|
||||
cy.interceptCompile()
|
||||
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<div className="pdf-viewer">
|
||||
<PdfPreview />
|
||||
</div>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Recompile' }).click()
|
||||
cy.waitForCompile({ pdf: true })
|
||||
cy.interceptCompile({ prefix: 'recompile' })
|
||||
|
||||
cy.intercept('DELETE', '/project/*/output*', {
|
||||
statusCode: 204,
|
||||
delay: 100,
|
||||
}).as('clear-cache')
|
||||
|
||||
// show the logs UI
|
||||
cy.findByRole('button', { name: 'View logs' }).click()
|
||||
|
||||
cy.findByRole('button', { name: 'Clear cached files' }).should(
|
||||
'not.be.disabled'
|
||||
)
|
||||
|
||||
cy.interceptDeferredCompile().then(resolveDeferredCompile => {
|
||||
cy.findByRole('button', { name: 'Toggle compile options menu' }).click()
|
||||
|
||||
cy.findByRole('menuitem', {
|
||||
name: 'Recompile from scratch',
|
||||
}).trigger('click')
|
||||
|
||||
cy.findByRole('button', { name: 'Clear cached files' }).should(
|
||||
'be.disabled'
|
||||
)
|
||||
|
||||
cy.wait('@clear-cache')
|
||||
|
||||
cy.findByRole('button', { name: 'Compiling…' }).then(() =>
|
||||
resolveDeferredCompile()
|
||||
)
|
||||
|
||||
// wait for recompile from scratch to finish
|
||||
cy.waitForCompile({ pdf: true })
|
||||
|
||||
cy.findByRole('button', { name: 'Recompile' })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('invalid URLs and broken PDFs', function () {
|
||||
it('shows an error for an invalid URL', function () {
|
||||
cy.interceptCompile()
|
||||
|
||||
cy.intercept('/build/*/output.pdf*', {
|
||||
statusCode: 500,
|
||||
body: {
|
||||
message: 'something awful happened',
|
||||
code: 'AWFUL_ERROR',
|
||||
},
|
||||
}).as('compile-pdf-error')
|
||||
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<div className="pdf-viewer">
|
||||
<PdfPreview />
|
||||
</div>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Recompile' }).click()
|
||||
cy.waitForCompile()
|
||||
cy.wait('@compile-pdf-error')
|
||||
|
||||
cy.contains('Something went wrong while rendering this PDF.')
|
||||
cy.contains(
|
||||
'Please try recompiling the project from scratch, and if that doesn’t help, follow our troubleshooting guide.'
|
||||
)
|
||||
cy.findByLabelText('Page 1').should('not.exist')
|
||||
})
|
||||
|
||||
it('shows an error for a corrupt PDF', function () {
|
||||
cy.interceptCompile()
|
||||
|
||||
cy.intercept('/build/*/output.pdf*', {
|
||||
fixture: 'build/output-corrupt.pdf,null',
|
||||
}).as('compile-pdf-corrupt')
|
||||
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<div className="pdf-viewer">
|
||||
<PdfPreview />
|
||||
</div>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Recompile' }).click()
|
||||
cy.waitForCompile()
|
||||
cy.wait('@compile-pdf-corrupt')
|
||||
|
||||
cy.contains('Something went wrong while rendering this PDF.')
|
||||
cy.contains(
|
||||
'Please try recompiling the project from scratch, and if that doesn’t help, follow our troubleshooting guide.'
|
||||
)
|
||||
cy.findByLabelText('Page 1').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('human readable logs', function () {
|
||||
it('shows human readable hint for undefined reference errors', function () {
|
||||
cy.interceptCompile()
|
||||
|
||||
cy.intercept('/build/*/output.log*', {
|
||||
fixture: 'build/output-human-readable.log',
|
||||
}).as('compile-log')
|
||||
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<div className="pdf-viewer">
|
||||
<PdfPreview />
|
||||
</div>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Recompile' }).click()
|
||||
cy.waitForCompile()
|
||||
cy.findByRole('button', { name: 'View logs' }).click()
|
||||
|
||||
cy.findByText(
|
||||
"Reference `intorduction' on page 1 undefined on input line 11."
|
||||
)
|
||||
cy.findByText(
|
||||
"Reference `section1' on page 1 undefined on input line 13."
|
||||
)
|
||||
cy.findByText('There were undefined references.')
|
||||
|
||||
cy.findAllByText(
|
||||
/You have referenced something which has not yet been labelled/
|
||||
).should('have.length', 3)
|
||||
})
|
||||
|
||||
it('does not show human readable hint when no undefined reference errors', function () {
|
||||
cy.interceptCompile()
|
||||
cy.intercept('/build/*/output.log?*', {
|
||||
fixture: 'build/output-undefined-references.log',
|
||||
}).as('compile-log')
|
||||
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<div className="pdf-viewer">
|
||||
<PdfPreview />
|
||||
</div>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Recompile' }).click()
|
||||
cy.waitForCompile()
|
||||
cy.findByRole('button', { name: 'View logs' }).click()
|
||||
|
||||
cy.findByText(
|
||||
"Package rerunfilecheck Warning: File `output.brf' has changed. Rerun to get bibliographical references right."
|
||||
)
|
||||
|
||||
cy.findByText(
|
||||
/You have referenced something which has not yet been labelled/
|
||||
).should('not.exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,439 @@
|
||||
import PdfSynctexControls from '../../../../frontend/js/features/pdf-preview/components/pdf-synctex-controls'
|
||||
import { cloneDeep } from 'lodash'
|
||||
import { useDetachCompileContext as useCompileContext } from '../../../../frontend/js/shared/context/detach-compile-context'
|
||||
import { useFileTreeData } from '../../../../frontend/js/shared/context/file-tree-data-context'
|
||||
import { useEffect } from 'react'
|
||||
import { EditorProviders } from '../../helpers/editor-providers'
|
||||
import { mockScope } from './scope'
|
||||
import { detachChannel, testDetachChannel } from '../../helpers/detach-channel'
|
||||
import { FindResult } from '@/features/file-tree/util/path'
|
||||
|
||||
const mockHighlights = [
|
||||
{
|
||||
page: 1,
|
||||
h: 85.03936,
|
||||
v: 509.999878,
|
||||
width: 441.921265,
|
||||
height: 8.855677,
|
||||
},
|
||||
{
|
||||
page: 1,
|
||||
h: 85.03936,
|
||||
v: 486.089539,
|
||||
width: 441.921265,
|
||||
height: 8.855677,
|
||||
},
|
||||
]
|
||||
|
||||
type Position = {
|
||||
page: number
|
||||
offset: { top: number; left: number }
|
||||
pageSize: { height: number; width: number }
|
||||
}
|
||||
|
||||
const mockPosition: Position = {
|
||||
page: 1,
|
||||
offset: { top: 10, left: 10 },
|
||||
pageSize: { height: 500, width: 500 },
|
||||
}
|
||||
|
||||
const mockSelectedEntities = [{ type: 'doc' }] as FindResult[]
|
||||
|
||||
const WithPosition = ({ mockPosition }: { mockPosition: Position }) => {
|
||||
const { setPosition } = useCompileContext()
|
||||
|
||||
// mock PDF scroll position update
|
||||
useEffect(() => {
|
||||
setPosition(mockPosition)
|
||||
}, [mockPosition, setPosition])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// mock PDF scroll position update
|
||||
const setDetachedPosition = (mockPosition: Position) => {
|
||||
testDetachChannel.postMessage({
|
||||
role: 'detacher',
|
||||
event: 'state-position',
|
||||
data: { value: mockPosition },
|
||||
})
|
||||
}
|
||||
|
||||
const WithSelectedEntities = ({
|
||||
mockSelectedEntities = [],
|
||||
}: {
|
||||
mockSelectedEntities: FindResult[]
|
||||
}) => {
|
||||
const { setSelectedEntities } = useFileTreeData()
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedEntities(mockSelectedEntities)
|
||||
}, [mockSelectedEntities, setSelectedEntities])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
describe('<PdfSynctexControls/>', function () {
|
||||
beforeEach(function () {
|
||||
window.metaAttributesCache.set('ol-project_id', 'test-project')
|
||||
window.metaAttributesCache.set('ol-preventCompileOnLoad', false)
|
||||
cy.interceptEvents()
|
||||
})
|
||||
|
||||
it('handles clicks on sync buttons', function () {
|
||||
cy.interceptCompile()
|
||||
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<WithPosition mockPosition={mockPosition} />
|
||||
<WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
|
||||
<PdfSynctexControls />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.waitForCompile()
|
||||
|
||||
cy.get('.synctex-control-icon').should('have.length', 2)
|
||||
|
||||
// mock editor cursor position update
|
||||
cy.window().then(win => {
|
||||
win.dispatchEvent(
|
||||
new CustomEvent('cursor:editor:update', {
|
||||
detail: { row: 100, column: 10 },
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
cy.wrap(null).then(() => {
|
||||
setDetachedPosition(mockPosition)
|
||||
})
|
||||
|
||||
cy.interceptAsync({ pathname: '/project/*/sync/code' }, 'sync-code').then(
|
||||
syncCodeResponse => {
|
||||
cy.findByRole('button', { name: 'Go to code location in PDF' }).click()
|
||||
cy.findByRole('button', { name: 'Go to code location in PDF' })
|
||||
.should('be.disabled')
|
||||
.then(() => {
|
||||
syncCodeResponse.resolve({
|
||||
body: { pdf: cloneDeep(mockHighlights) },
|
||||
})
|
||||
})
|
||||
|
||||
cy.wait('@sync-code')
|
||||
}
|
||||
)
|
||||
|
||||
cy.interceptAsync({ pathname: '/project/*/sync/pdf' }, 'sync-pdf').then(
|
||||
syncPdfResponse => {
|
||||
cy.findByRole('button', { name: /^Go to PDF location in code/ }).click()
|
||||
cy.findByRole('button', { name: /^Go to PDF location in code/ })
|
||||
.should('be.disabled')
|
||||
.then(() => {
|
||||
syncPdfResponse.resolve({
|
||||
body: { code: [{ file: 'main.tex', line: 100 }] },
|
||||
})
|
||||
})
|
||||
|
||||
cy.wait('@sync-pdf')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('disables button when multiple entities are selected', function () {
|
||||
cy.interceptCompile()
|
||||
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<WithPosition mockPosition={mockPosition} />
|
||||
<WithSelectedEntities
|
||||
mockSelectedEntities={
|
||||
[{ type: 'doc' }, { type: 'doc' }] as FindResult[]
|
||||
}
|
||||
/>
|
||||
<PdfSynctexControls />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.waitForCompile()
|
||||
|
||||
cy.findByRole('button', { name: 'Go to code location in PDF' }).should(
|
||||
'be.disabled'
|
||||
)
|
||||
})
|
||||
|
||||
it('disables button when a file is selected', function () {
|
||||
cy.interceptCompile()
|
||||
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<WithPosition mockPosition={mockPosition} />
|
||||
<WithSelectedEntities
|
||||
mockSelectedEntities={[{ type: 'fileRef' }] as FindResult[]}
|
||||
/>
|
||||
<PdfSynctexControls />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.waitForCompile()
|
||||
|
||||
cy.findByRole('button', { name: 'Go to code location in PDF' }).should(
|
||||
'be.disabled'
|
||||
)
|
||||
})
|
||||
|
||||
describe('with detacher role', function () {
|
||||
beforeEach(function () {
|
||||
window.metaAttributesCache.set('ol-detachRole', 'detacher')
|
||||
})
|
||||
|
||||
it('does not have go to PDF location button nor arrow icon', function () {
|
||||
cy.interceptCompile()
|
||||
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<WithPosition mockPosition={mockPosition} />
|
||||
<WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
|
||||
<PdfSynctexControls />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.waitForCompile()
|
||||
|
||||
cy.findByRole('button', { name: /^Go to PDF location in code/ }).should(
|
||||
'not.exist'
|
||||
)
|
||||
|
||||
cy.get('.synctex-control-icon').should('not.exist')
|
||||
})
|
||||
|
||||
it('send set highlights action', function () {
|
||||
cy.interceptCompile()
|
||||
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<WithPosition mockPosition={mockPosition} />
|
||||
<WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
|
||||
<PdfSynctexControls />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.waitForCompile()
|
||||
|
||||
// mock editor cursor position update
|
||||
cy.window().then(win => {
|
||||
win.dispatchEvent(
|
||||
new CustomEvent('cursor:editor:update', {
|
||||
detail: { row: 100, column: 10 },
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
cy.spy(detachChannel, 'postMessage').as('postDetachMessage')
|
||||
|
||||
cy.interceptAsync({ pathname: '/project/*/sync/code' }, 'sync-code').then(
|
||||
syncCodeResponse => {
|
||||
cy.findByRole('button', {
|
||||
name: 'Go to code location in PDF',
|
||||
})
|
||||
.should('not.be.disabled')
|
||||
.click()
|
||||
|
||||
cy.findByRole('button', {
|
||||
name: 'Go to code location in PDF',
|
||||
})
|
||||
.should('be.disabled')
|
||||
.then(() => {
|
||||
syncCodeResponse.resolve({
|
||||
body: { pdf: cloneDeep(mockHighlights) },
|
||||
})
|
||||
})
|
||||
|
||||
cy.wait('@sync-code')
|
||||
}
|
||||
)
|
||||
|
||||
cy.findByRole('button', {
|
||||
name: 'Go to code location in PDF',
|
||||
}).should('not.be.disabled')
|
||||
|
||||
// synctex is called locally and the result are broadcast for the detached tab
|
||||
// NOTE: can't use `.to.deep.include({…})` as it doesn't match the nested array
|
||||
cy.get('@postDetachMessage').should('have.been.calledWith', {
|
||||
role: 'detacher',
|
||||
event: 'action-setHighlights',
|
||||
data: { args: [mockHighlights] },
|
||||
})
|
||||
})
|
||||
|
||||
it('reacts to sync to code action', function () {
|
||||
cy.interceptCompile()
|
||||
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<WithPosition mockPosition={mockPosition} />
|
||||
<WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
|
||||
<PdfSynctexControls />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.waitForCompile()
|
||||
|
||||
cy.interceptAsync({ pathname: '/project/*/sync/pdf' }, 'sync-pdf')
|
||||
.then(syncPdfResponse => {
|
||||
syncPdfResponse.resolve({
|
||||
body: { code: [{ file: 'main.tex', line: 100 }] },
|
||||
})
|
||||
|
||||
testDetachChannel.postMessage({
|
||||
role: 'detached',
|
||||
event: 'action-sync-to-code',
|
||||
data: {
|
||||
args: [mockPosition],
|
||||
},
|
||||
})
|
||||
})
|
||||
.wait('@sync-pdf')
|
||||
})
|
||||
})
|
||||
|
||||
describe('with detached role', function () {
|
||||
beforeEach(function () {
|
||||
window.metaAttributesCache.set('ol-detachRole', 'detached')
|
||||
})
|
||||
|
||||
it('does not have go to code location button nor arrow icon', function () {
|
||||
cy.interceptCompile()
|
||||
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<WithPosition mockPosition={mockPosition} />
|
||||
<PdfSynctexControls />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.waitForCompile()
|
||||
|
||||
cy.findByRole('button', {
|
||||
name: 'Go to code location in PDF',
|
||||
}).should('not.exist')
|
||||
|
||||
cy.get('.synctex-control-icon').should('not.exist')
|
||||
})
|
||||
|
||||
it('send go to code line action', function () {
|
||||
cy.interceptCompile()
|
||||
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<PdfSynctexControls />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.waitForCompile().then(() => {
|
||||
testDetachChannel.postMessage({
|
||||
role: 'detacher',
|
||||
event: `state-position`,
|
||||
data: { value: mockPosition },
|
||||
})
|
||||
})
|
||||
|
||||
cy.findByRole('button', {
|
||||
name: /^Go to PDF location in code/,
|
||||
})
|
||||
|
||||
cy.findByRole('button', { name: /^Go to PDF location in code/ }).should(
|
||||
'not.be.disabled'
|
||||
)
|
||||
|
||||
cy.spy(detachChannel, 'postMessage').as('postDetachMessage')
|
||||
|
||||
cy.findByRole('button', { name: /^Go to PDF location in code/ }).click()
|
||||
|
||||
// the button is only disabled when the state is updated
|
||||
cy.findByRole('button', { name: /^Go to PDF location in code/ }).should(
|
||||
'not.be.disabled'
|
||||
)
|
||||
|
||||
cy.get('.synctex-spin-icon').should('not.exist')
|
||||
|
||||
cy.get('@postDetachMessage').should('have.been.calledWith', {
|
||||
role: 'detached',
|
||||
event: 'action-sync-to-code',
|
||||
data: {
|
||||
args: [{ visualOffset: 72 }],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('update inflight state', function () {
|
||||
cy.interceptCompile()
|
||||
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<WithPosition mockPosition={mockPosition} />
|
||||
<PdfSynctexControls />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.waitForCompile().then(() => {
|
||||
testDetachChannel.postMessage({
|
||||
role: 'detacher',
|
||||
event: `state-position`,
|
||||
data: { value: mockPosition },
|
||||
})
|
||||
})
|
||||
|
||||
cy.findByRole('button', { name: /^Go to PDF location in code/ }).should(
|
||||
'not.be.disabled'
|
||||
)
|
||||
|
||||
cy.findByRole('status', { hidden: true }).should('not.exist')
|
||||
|
||||
cy.wrap(null).then(() => {
|
||||
testDetachChannel.postMessage({
|
||||
role: 'detacher',
|
||||
event: 'state-sync-to-code-inflight',
|
||||
data: { value: true },
|
||||
})
|
||||
})
|
||||
|
||||
cy.findByRole('button', { name: /^Go to PDF location in code/ }).should(
|
||||
'be.disabled'
|
||||
)
|
||||
|
||||
cy.findByRole('status', { hidden: true }).should('have.length', 1)
|
||||
|
||||
cy.wrap(null).then(() => {
|
||||
testDetachChannel.postMessage({
|
||||
role: 'detacher',
|
||||
event: 'state-sync-to-code-inflight',
|
||||
data: { value: false },
|
||||
})
|
||||
})
|
||||
|
||||
cy.findByRole('button', { name: /^Go to PDF location in code/ }).should(
|
||||
'not.be.disabled'
|
||||
)
|
||||
|
||||
cy.get('.synctex-spin-icon').should('not.exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
24
services/web/test/frontend/components/pdf-preview/scope.tsx
Normal file
24
services/web/test/frontend/components/pdf-preview/scope.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { EditorView } from '@codemirror/view'
|
||||
|
||||
export const mockScope = () => ({
|
||||
settings: {
|
||||
syntaxValidation: false,
|
||||
pdfViewer: 'pdfjs',
|
||||
},
|
||||
editor: {
|
||||
open_doc_name: 'main.tex',
|
||||
sharejs_doc: {
|
||||
doc_id: 'test-doc',
|
||||
getSnapshot: () => 'some doc content',
|
||||
hasBufferedOps: () => false,
|
||||
},
|
||||
view: new EditorView({
|
||||
doc: '\\documentclass{article}',
|
||||
}),
|
||||
},
|
||||
hasLintingError: false,
|
||||
ui: {
|
||||
view: 'editor',
|
||||
pdfLayout: 'sideBySide',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,133 @@
|
||||
import GroupInvitationNotification from '@/features/project-list/components/notifications/groups/group-invitation/group-invitation'
|
||||
import { NotificationGroupInvitation } from '../../../../../types/project/dashboard/notification'
|
||||
|
||||
type Props = {
|
||||
notification: NotificationGroupInvitation
|
||||
}
|
||||
|
||||
function GroupInvitation({ notification }: Props) {
|
||||
return (
|
||||
<div className="user-notifications">
|
||||
<ul className="list-unstyled">
|
||||
<GroupInvitationNotification notification={notification} />
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
describe('<GroupInvitationNotification />', function () {
|
||||
const notification: NotificationGroupInvitation = {
|
||||
_id: 1,
|
||||
templateKey: 'notification_group_invitation',
|
||||
messageOpts: {
|
||||
inviterName: 'inviter@overleaf.com',
|
||||
token: '123abc',
|
||||
managedUsersEnabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.intercept(
|
||||
'PUT',
|
||||
`/subscription/invites/${notification.messageOpts.token}`,
|
||||
{
|
||||
statusCode: 204,
|
||||
}
|
||||
).as('acceptInvite')
|
||||
})
|
||||
|
||||
describe('user without existing personal subscription', function () {
|
||||
it('is able to join group successfully', function () {
|
||||
cy.mount(<GroupInvitation notification={notification} />)
|
||||
|
||||
cy.findByRole('alert')
|
||||
|
||||
cy.contains(
|
||||
'inviter@overleaf.com has invited you to join a group subscription on Overleaf'
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Join now' }).click()
|
||||
|
||||
cy.wait('@acceptInvite')
|
||||
|
||||
cy.findByText(
|
||||
'Congratulations! You‘ve successfully joined the group subscription.'
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: /close/i }).click()
|
||||
|
||||
cy.findByRole('alert').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('user with existing personal subscription', function () {
|
||||
beforeEach(function () {
|
||||
window.metaAttributesCache.set(
|
||||
'ol-hasIndividualRecurlySubscription',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('is able to join group successfully without cancelling personal subscription', function () {
|
||||
cy.mount(<GroupInvitation notification={notification} />)
|
||||
|
||||
cy.findByRole('alert')
|
||||
|
||||
cy.contains(
|
||||
'inviter@overleaf.com has invited you to join a group Overleaf subscription. If you join this group, you may not need your individual subscription. Would you like to cancel it?'
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Not now' }).click()
|
||||
|
||||
cy.contains(
|
||||
'inviter@overleaf.com has invited you to join a group subscription on Overleaf'
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Join now' }).click()
|
||||
|
||||
cy.wait('@acceptInvite')
|
||||
|
||||
cy.findByText(
|
||||
'Congratulations! You‘ve successfully joined the group subscription.'
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: /close/i }).click()
|
||||
|
||||
cy.findByRole('alert').should('not.exist')
|
||||
})
|
||||
|
||||
it('is able to join group successfully after cancelling personal subscription', function () {
|
||||
cy.intercept('POST', '/user/subscription/cancel', {
|
||||
statusCode: 204,
|
||||
}).as('cancelPersonalSubscription')
|
||||
|
||||
cy.mount(<GroupInvitation notification={notification} />)
|
||||
|
||||
cy.findByRole('alert')
|
||||
|
||||
cy.contains(
|
||||
'inviter@overleaf.com has invited you to join a group Overleaf subscription. If you join this group, you may not need your individual subscription. Would you like to cancel it?'
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Cancel my subscription' }).click()
|
||||
|
||||
cy.wait('@cancelPersonalSubscription')
|
||||
|
||||
cy.contains(
|
||||
'inviter@overleaf.com has invited you to join a group subscription on Overleaf'
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Join now' }).click()
|
||||
|
||||
cy.wait('@acceptInvite')
|
||||
|
||||
cy.findByText(
|
||||
'Congratulations! You‘ve successfully joined the group subscription.'
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: /close/i }).click()
|
||||
|
||||
cy.findByRole('alert').should('not.exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import BetaBadge from '../../../../frontend/js/shared/components/beta-badge'
|
||||
|
||||
describe('beta badge', function () {
|
||||
it('renders the url and tooltip text', function () {
|
||||
cy.mount(
|
||||
<BetaBadge
|
||||
link={{ href: '/foo' }}
|
||||
tooltip={{
|
||||
id: 'test-tooltip',
|
||||
text: 'This is a test',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
cy.get('a[href="/foo"]').contains('This is a test')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from 'react'
|
||||
import LanguagePicker from '../../../../frontend/js/features/ui/components/bootstrap-5/language-picker'
|
||||
import getMeta from '@/utils/meta'
|
||||
import exposedSettings from '../../../../modules/admin-panel/test/frontend/js/features/user/data/exposedSettings'
|
||||
|
||||
describe('LanguagePicker', function () {
|
||||
beforeEach(function () {
|
||||
window.metaAttributesCache.set('ol-i18n', {
|
||||
currentLangCode: 'en',
|
||||
})
|
||||
window.metaAttributesCache.set('ol-footer', {
|
||||
showThinFooter: false,
|
||||
translatedLanguages: {
|
||||
en: 'English',
|
||||
fr: 'Français',
|
||||
es: 'Español',
|
||||
},
|
||||
subdomainLang: {
|
||||
en: { lngCode: 'en', url: 'overleaf.com' },
|
||||
fr: { lngCode: 'fr', url: 'fr.overleaf.com' },
|
||||
es: { lngCode: 'es', url: 'es.overleaf.com' },
|
||||
},
|
||||
})
|
||||
|
||||
Object.assign(getMeta('ol-ExposedSettings'), exposedSettings)
|
||||
})
|
||||
|
||||
it('renders the language picker with the current language', function () {
|
||||
cy.mount(<LanguagePicker showHeader />)
|
||||
cy.get('#language-picker-toggle').should('contain', 'English')
|
||||
})
|
||||
|
||||
it('opens the dropdown and lists available languages', function () {
|
||||
cy.mount(<LanguagePicker showHeader />)
|
||||
cy.get('#language-picker-toggle').click()
|
||||
|
||||
cy.get('.dropdown-menu').within(() => {
|
||||
cy.contains('English').should('exist')
|
||||
cy.contains('Français').should('exist')
|
||||
cy.contains('Español').should('exist')
|
||||
})
|
||||
})
|
||||
|
||||
it('changes the language and updates the URL when a language is selected', function () {
|
||||
cy.mount(<LanguagePicker showHeader />)
|
||||
cy.get('#language-picker-toggle').should('exist').click()
|
||||
cy.contains('Français').click()
|
||||
cy.url().should('include', 'fr.overleaf.com')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import unfilledIconTypes from '../../../../frontend/fonts/material-symbols/unfilled-symbols.mjs'
|
||||
|
||||
const FONT_SIZE = 40
|
||||
|
||||
describe('MaterialIcon', function () {
|
||||
describe('Filled', function () {
|
||||
it('contains symbols', function () {
|
||||
cy.mount(<MaterialIcon type="home" style={{ fontSize: FONT_SIZE }} />)
|
||||
cy.get('.material-symbols').as('icon')
|
||||
cy.get('@icon')
|
||||
.invoke('width')
|
||||
.should('be.within', FONT_SIZE - 1, FONT_SIZE + 1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Unfilled', function () {
|
||||
it('Contain all unfilled symbol', function () {
|
||||
for (const type of unfilledIconTypes) {
|
||||
cy.mount(
|
||||
<MaterialIcon type={type} unfilled style={{ fontSize: FONT_SIZE }} />
|
||||
)
|
||||
cy.get('.material-symbols').as('icon')
|
||||
cy.get('@icon')
|
||||
.invoke('width')
|
||||
.should('be.within', FONT_SIZE - 1, FONT_SIZE + 1)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
345
services/web/test/frontend/components/shared/select.spec.tsx
Normal file
345
services/web/test/frontend/components/shared/select.spec.tsx
Normal file
@@ -0,0 +1,345 @@
|
||||
import { useCallback, FormEvent } from 'react'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
import OLForm from '@/features/ui/components/ol/ol-form'
|
||||
import OLFormControl from '@/features/ui/components/ol/ol-form-control'
|
||||
import {
|
||||
Select,
|
||||
SelectProps,
|
||||
} from '../../../../frontend/js/shared/components/select'
|
||||
|
||||
const testData = [1, 2, 3].map(index => ({
|
||||
key: index,
|
||||
value: `Demo item ${index}`,
|
||||
sub: `Subtitle ${index}`,
|
||||
}))
|
||||
|
||||
type RenderProps = Partial<SelectProps<(typeof testData)[number]>> & {
|
||||
onSubmit?: (formData: object) => void
|
||||
}
|
||||
|
||||
function render(props: RenderProps) {
|
||||
const submitHandler = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
if (props.onSubmit) {
|
||||
const formData = new FormData(event.target as HTMLFormElement)
|
||||
// a plain object is more convenient to work later with assertions
|
||||
props.onSubmit(Object.fromEntries(formData.entries()))
|
||||
}
|
||||
}
|
||||
cy.mount(
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
}}
|
||||
>
|
||||
<form onSubmit={submitHandler}>
|
||||
<Select
|
||||
items={testData}
|
||||
itemToString={x => String(x?.value)}
|
||||
label={props.label}
|
||||
name="select_control"
|
||||
defaultText={props.defaultText}
|
||||
defaultItem={props.defaultItem}
|
||||
itemToSubtitle={props.itemToSubtitle}
|
||||
itemToKey={x => String(x.key)}
|
||||
onSelectedItemChanged={props.onSelectedItemChanged}
|
||||
selected={props.selected}
|
||||
disabled={props.disabled}
|
||||
itemToDisabled={props.itemToDisabled}
|
||||
optionalLabel={props.optionalLabel}
|
||||
loading={props.loading}
|
||||
selectedIcon={props.selectedIcon}
|
||||
/>
|
||||
<button type="submit">submit</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
describe('<Select />', function () {
|
||||
describe('initial rendering', function () {
|
||||
it('renders default text', function () {
|
||||
render({ defaultText: 'Choose an item' })
|
||||
cy.findByTestId('spinner').should('not.exist')
|
||||
cy.findByRole('textbox', { name: 'Choose an item' })
|
||||
})
|
||||
|
||||
it('renders default item', function () {
|
||||
render({ defaultItem: testData[2] })
|
||||
cy.findByRole('textbox', { name: 'Demo item 3' })
|
||||
})
|
||||
|
||||
it('default item takes precedence over default text', function () {
|
||||
render({ defaultText: 'Choose an item', defaultItem: testData[2] })
|
||||
cy.findByRole('textbox', { name: 'Demo item 3' })
|
||||
})
|
||||
|
||||
it('renders label', function () {
|
||||
render({
|
||||
defaultText: 'Choose an item',
|
||||
label: 'test label',
|
||||
optionalLabel: false,
|
||||
})
|
||||
cy.findByRole('textbox', { name: 'test label' })
|
||||
cy.findByRole('textbox', { name: '(Optional)' }).should('not.exist')
|
||||
})
|
||||
|
||||
it('renders optional label', function () {
|
||||
render({
|
||||
defaultText: 'Choose an item',
|
||||
label: 'test label',
|
||||
optionalLabel: true,
|
||||
})
|
||||
cy.findByRole('textbox', { name: 'test label (Optional)' })
|
||||
})
|
||||
|
||||
it('renders a spinner while loading when there is a label', function () {
|
||||
render({
|
||||
defaultText: 'Choose an item',
|
||||
label: 'test label',
|
||||
loading: true,
|
||||
})
|
||||
cy.findByTestId('spinner')
|
||||
})
|
||||
|
||||
it('does not render a spinner while loading if there is no label', function () {
|
||||
render({
|
||||
defaultText: 'Choose an item',
|
||||
loading: true,
|
||||
})
|
||||
cy.findByTestId('spinner').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('items rendering', function () {
|
||||
it('renders all items', function () {
|
||||
render({ defaultText: 'Choose an item' })
|
||||
cy.findByRole('textbox', { name: 'Choose an item' }).click()
|
||||
|
||||
cy.findByRole('option', { name: 'Demo item 1' })
|
||||
cy.findByRole('option', { name: 'Demo item 2' })
|
||||
cy.findByRole('option', { name: 'Demo item 3' })
|
||||
})
|
||||
|
||||
it('renders subtitles', function () {
|
||||
render({
|
||||
defaultText: 'Choose an item',
|
||||
itemToSubtitle: x => String(x?.sub),
|
||||
})
|
||||
cy.findByRole('textbox', { name: 'Choose an item' }).click()
|
||||
|
||||
cy.findByRole('option', { name: 'Demo item 1 Subtitle 1' })
|
||||
cy.findByRole('option', { name: 'Demo item 2 Subtitle 2' })
|
||||
cy.findByRole('option', { name: 'Demo item 3 Subtitle 3' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('item selection', function () {
|
||||
it('cannot select an item when disabled', function () {
|
||||
render({ defaultText: 'Choose an item', disabled: true })
|
||||
cy.findByRole('textbox', { name: 'Choose an item' }).click({
|
||||
force: true,
|
||||
})
|
||||
cy.findByRole('option', { name: 'Demo item 1' }).should('not.exist')
|
||||
cy.findByRole('option', { name: 'Demo item 2' }).should('not.exist')
|
||||
cy.findByRole('option', { name: 'Demo item 3' }).should('not.exist')
|
||||
cy.findByRole('textbox', { name: 'Choose an item' })
|
||||
})
|
||||
|
||||
it('renders only the selected item after selection', function () {
|
||||
render({ defaultText: 'Choose an item' })
|
||||
cy.findByRole('textbox', { name: 'Choose an item' }).click()
|
||||
|
||||
cy.findByRole('option', { name: 'Demo item 1' })
|
||||
cy.findByRole('option', { name: 'Demo item 2' })
|
||||
cy.findByRole('option', { name: 'Demo item 3' }).click()
|
||||
|
||||
cy.findByRole('textbox', { name: 'Choose an item' }).should('not.exist')
|
||||
cy.findByRole('option', { name: 'Demo item 1' }).should('not.exist')
|
||||
cy.findByRole('option', { name: 'Demo item 2' }).should('not.exist')
|
||||
cy.findByRole('textbox', { name: 'Demo item 3' })
|
||||
})
|
||||
|
||||
it('invokes callback after selection', function () {
|
||||
const selectionHandler = cy.stub().as('selectionHandler')
|
||||
|
||||
render({
|
||||
defaultText: 'Choose an item',
|
||||
onSelectedItemChanged: selectionHandler,
|
||||
})
|
||||
cy.findByRole('textbox', { name: 'Choose an item' }).click()
|
||||
cy.findByRole('option', { name: 'Demo item 2' }).click()
|
||||
|
||||
cy.get('@selectionHandler').should(
|
||||
'have.been.calledOnceWith',
|
||||
testData[1]
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the form is submitted', function () {
|
||||
it('populates FormData with the default selected item', function () {
|
||||
const submitHandler = cy.stub().as('submitHandler')
|
||||
render({ defaultItem: testData[1], onSubmit: submitHandler })
|
||||
|
||||
cy.findByText('submit').click()
|
||||
cy.get('@submitHandler').should('have.been.calledOnceWith', {
|
||||
select_control: 'Demo item 2',
|
||||
})
|
||||
})
|
||||
|
||||
it('populates FormData with the selected item', function () {
|
||||
const submitHandler = cy.stub().as('submitHandler')
|
||||
render({ defaultItem: testData[1], onSubmit: submitHandler })
|
||||
|
||||
cy.findByRole('textbox', { name: 'Demo item 2' }).click() // open dropdown
|
||||
cy.findByText('Demo item 3').click() // choose a different item
|
||||
|
||||
cy.findByText('submit').click()
|
||||
cy.get('@submitHandler').should('have.been.calledOnceWith', {
|
||||
select_control: 'Demo item 3',
|
||||
})
|
||||
})
|
||||
|
||||
it('does not populate FormData when no item is selected', function () {
|
||||
const submitHandler = cy.stub().as('submitHandler')
|
||||
render({ defaultText: 'Choose an item', onSubmit: submitHandler })
|
||||
|
||||
cy.findByText('submit').click()
|
||||
cy.get('@submitHandler').should('have.been.calledOnceWith', {})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with react-bootstrap forms', function () {
|
||||
type FormWithSelectProps = {
|
||||
onSubmit: (formData: object) => void
|
||||
}
|
||||
|
||||
const FormWithSelect = ({ onSubmit }: FormWithSelectProps) => {
|
||||
const selectComponent = useCallback(
|
||||
() => (
|
||||
<Select
|
||||
name="select_control"
|
||||
items={testData}
|
||||
defaultItem={testData[0]}
|
||||
itemToString={x => String(x?.value)}
|
||||
itemToKey={x => String(x.key)}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
)
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
const formData = new FormData(event.target as HTMLFormElement)
|
||||
// a plain object is more convenient to work later with assertions
|
||||
onSubmit(Object.fromEntries(formData.entries()))
|
||||
}
|
||||
|
||||
return (
|
||||
<OLForm onSubmit={handleSubmit}>
|
||||
<OLFormControl as={selectComponent} />
|
||||
<OLButton type="submit">submit</OLButton>
|
||||
</OLForm>
|
||||
)
|
||||
}
|
||||
|
||||
it('populates FormData with the selected item when the form is submitted', function () {
|
||||
const submitHandler = cy.stub().as('submitHandler')
|
||||
cy.mount(
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
}}
|
||||
>
|
||||
<FormWithSelect onSubmit={submitHandler} />
|
||||
</div>
|
||||
)
|
||||
|
||||
cy.findByRole('textbox', { name: 'Demo item 1' }).click() // open dropdown
|
||||
cy.findByRole('option', { name: 'Demo item 3' }).click() // choose a different item
|
||||
|
||||
cy.findByText('submit').click()
|
||||
cy.get('@submitHandler').should('have.been.calledOnceWith', {
|
||||
select_control: 'Demo item 3',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('keyboard navigation', function () {
|
||||
it('can select an item using the keyboard', function () {
|
||||
render({ defaultText: 'Choose an item' })
|
||||
cy.findByRole('textbox', { name: 'Choose an item' }).type(
|
||||
'{Enter}{downArrow}{Enter}',
|
||||
{ force: true }
|
||||
)
|
||||
cy.findByRole('textbox', { name: 'Demo item 1' }).should('exist')
|
||||
cy.findByRole('option', { name: 'Demo item 2' }).should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectedIcon', function () {
|
||||
it('renders a selected icon if the prop is set', function () {
|
||||
render({
|
||||
defaultText: 'Choose an item',
|
||||
selectedIcon: true,
|
||||
})
|
||||
cy.findByRole('textbox', { name: 'Choose an item' }).click()
|
||||
cy.findByRole('option', { name: 'Demo item 1' }).click()
|
||||
cy.findByRole('textbox', { name: 'Demo item 1' }).click()
|
||||
|
||||
cy.findByText('check').should('exist')
|
||||
})
|
||||
it('renders no selected icon if the prop is not set', function () {
|
||||
render({
|
||||
defaultText: 'Choose an item',
|
||||
selectedIcon: false,
|
||||
})
|
||||
cy.findByRole('textbox', { name: 'Choose an item' }).click()
|
||||
cy.findByRole('option', { name: 'Demo item 1' }).click()
|
||||
cy.findByRole('textbox', { name: 'Demo item 1' }).click()
|
||||
|
||||
cy.findByText('check').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('itemToDisabled', function () {
|
||||
it('prevents selecting a disabled item', function () {
|
||||
render({
|
||||
defaultText: 'Choose an item',
|
||||
itemToDisabled: x => x?.key === 2,
|
||||
})
|
||||
cy.findByRole('textbox', { name: 'Choose an item' }).click()
|
||||
cy.findByRole('option', { name: 'Demo item 2' }).click({ force: true })
|
||||
// still showing other list items
|
||||
cy.findByRole('option', { name: 'Demo item 3' }).should('exist')
|
||||
cy.findByRole('option', { name: 'Demo item 1' }).click()
|
||||
// clicking an enabled item dismisses the list
|
||||
cy.findByRole('option', { name: 'Demo item 3' }).should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('selected', function () {
|
||||
it('shows the item provided in the selected prop', function () {
|
||||
render({
|
||||
defaultText: 'Choose an item',
|
||||
selected: testData[1],
|
||||
})
|
||||
cy.findByRole('textbox', { name: 'Demo item 2' }).should('exist')
|
||||
})
|
||||
|
||||
it('should show default text when selected is null', function () {
|
||||
render({
|
||||
selected: null,
|
||||
defaultText: 'Choose an item',
|
||||
})
|
||||
cy.findByRole('textbox', { name: 'Choose an item' }).should('exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,228 @@
|
||||
import SplitTestBadge from '../../../../frontend/js/shared/components/split-test-badge'
|
||||
import { EditorProviders } from '../../helpers/editor-providers'
|
||||
|
||||
describe('split test badge', function () {
|
||||
it('renders an alpha badge with the url and tooltip text', function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-splitTestVariants', {
|
||||
'cypress-test': 'active',
|
||||
})
|
||||
win.metaAttributesCache.set('ol-splitTestInfo', {
|
||||
'cypress-test': {
|
||||
phase: 'alpha',
|
||||
badgeInfo: {
|
||||
url: '/alpha/participate',
|
||||
tooltipText: 'This is an alpha feature',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<SplitTestBadge
|
||||
splitTestName="cypress-test"
|
||||
displayOnVariants={['active']}
|
||||
/>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('link', { name: /this is an alpha feature/i })
|
||||
.should('have.attr', 'href', '/alpha/participate')
|
||||
.find('.badge')
|
||||
.contains('α')
|
||||
})
|
||||
|
||||
it('does not render the alpha badge when user is not assigned to the variant', function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-splitTestVariants', {
|
||||
'cypress-test': 'default',
|
||||
})
|
||||
win.metaAttributesCache.set('ol-splitTestInfo', {
|
||||
'cypress-test': {
|
||||
phase: 'alpha',
|
||||
badgeInfo: {
|
||||
url: '/alpha/participate',
|
||||
tooltipText: 'This is an alpha feature',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<SplitTestBadge
|
||||
splitTestName="cypress-test"
|
||||
displayOnVariants={['active']}
|
||||
/>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.get('.badge').should('not.exist')
|
||||
})
|
||||
|
||||
it('renders a beta badge with the url and tooltip text', function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-splitTestVariants', {
|
||||
'cypress-test': 'active',
|
||||
})
|
||||
win.metaAttributesCache.set('ol-splitTestInfo', {
|
||||
'cypress-test': {
|
||||
phase: 'beta',
|
||||
badgeInfo: {
|
||||
url: '/beta/participate',
|
||||
tooltipText: 'This is a beta feature',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<SplitTestBadge
|
||||
splitTestName="cypress-test"
|
||||
displayOnVariants={['active']}
|
||||
/>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('link', { name: /this is a beta feature/i })
|
||||
.should('have.attr', 'href', '/beta/participate')
|
||||
.find('.badge')
|
||||
.contains('β')
|
||||
})
|
||||
|
||||
it('does not render the beta badge when user is not assigned to the variant', function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-splitTestVariants', {
|
||||
'cypress-test': 'default',
|
||||
})
|
||||
win.metaAttributesCache.set('ol-splitTestInfo', {
|
||||
'cypress-test': {
|
||||
phase: 'beta',
|
||||
badgeInfo: {
|
||||
url: '/beta/participate',
|
||||
tooltipText: 'This is a beta feature',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<SplitTestBadge
|
||||
splitTestName="cypress-test"
|
||||
displayOnVariants={['active']}
|
||||
/>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.get('.badge').should('not.exist')
|
||||
})
|
||||
|
||||
it('renders an info badge with the url and tooltip text', function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-splitTestVariants', {
|
||||
'cypress-test': 'active',
|
||||
})
|
||||
win.metaAttributesCache.set('ol-splitTestInfo', {
|
||||
'cypress-test': {
|
||||
phase: 'release',
|
||||
badgeInfo: {
|
||||
url: '/feedback/form',
|
||||
tooltipText: 'This is a new feature',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<SplitTestBadge
|
||||
splitTestName="cypress-test"
|
||||
displayOnVariants={['active']}
|
||||
/>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('link', { name: /this is a new feature/i })
|
||||
.should('have.attr', 'href', '/feedback/form')
|
||||
.find('.info-badge')
|
||||
})
|
||||
|
||||
it('does not render the info badge when user is not assigned to the variant', function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-splitTestVariants', {
|
||||
'cypress-test': 'default',
|
||||
})
|
||||
win.metaAttributesCache.set('ol-splitTestInfo', {
|
||||
'cypress-test': {
|
||||
phase: 'release',
|
||||
badgeInfo: {
|
||||
url: '/feedback/form',
|
||||
tooltipText: 'This is a new feature',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<SplitTestBadge
|
||||
splitTestName="cypress-test"
|
||||
displayOnVariants={['active']}
|
||||
/>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.get('.badge').should('not.exist')
|
||||
})
|
||||
|
||||
it('does not render the badge when no split test info is available', function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-splitTestVariants', {
|
||||
'cypress-test': 'active',
|
||||
})
|
||||
win.metaAttributesCache.set('ol-splitTestInfo', {})
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<SplitTestBadge
|
||||
splitTestName="cypress-test"
|
||||
displayOnVariants={['active']}
|
||||
/>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.get('.badge').should('not.exist')
|
||||
})
|
||||
|
||||
it('default badge url and text are used when not provided', function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-splitTestVariants', {
|
||||
'cypress-test': 'active',
|
||||
})
|
||||
win.metaAttributesCache.set('ol-splitTestInfo', {
|
||||
'cypress-test': {
|
||||
phase: 'release',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<SplitTestBadge
|
||||
splitTestName="cypress-test"
|
||||
displayOnVariants={['active']}
|
||||
/>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('link', {
|
||||
name: /we are testing this new feature.*click to give feedback/i,
|
||||
})
|
||||
.should('have.attr', 'href', '/beta/participate')
|
||||
.find('.info-badge')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import StartFreeTrialButton from '../../../../frontend/js/shared/components/start-free-trial-button'
|
||||
import getMeta from '@/utils/meta'
|
||||
|
||||
describe('start free trial button', function () {
|
||||
beforeEach(function () {
|
||||
cy.intercept('POST', '/event/paywall-prompt', {
|
||||
statusCode: 204,
|
||||
}).as('event-paywall-prompt')
|
||||
cy.intercept('POST', '/event/paywall-click', {
|
||||
statusCode: 204,
|
||||
}).as('event-paywall-click')
|
||||
|
||||
getMeta('ol-ExposedSettings').isOverleaf = true
|
||||
})
|
||||
|
||||
it('renders the button with default text', function () {
|
||||
cy.mount(<StartFreeTrialButton source="cypress-test" />)
|
||||
|
||||
cy.wait('@event-paywall-prompt')
|
||||
.its('request.body.paywall-type')
|
||||
.should('eq', 'cypress-test')
|
||||
|
||||
cy.get('button').contains('Start Free Trial!')
|
||||
})
|
||||
|
||||
it('renders the button with custom text', function () {
|
||||
cy.mount(
|
||||
<StartFreeTrialButton source="cypress-test">
|
||||
Some Custom Text
|
||||
</StartFreeTrialButton>
|
||||
)
|
||||
|
||||
cy.wait('@event-paywall-prompt')
|
||||
.its('request.body.paywall-type')
|
||||
.should('eq', 'cypress-test')
|
||||
|
||||
cy.get('button').contains('Some Custom Text')
|
||||
})
|
||||
|
||||
it('renders the button with styled button', function () {
|
||||
cy.mount(
|
||||
<StartFreeTrialButton
|
||||
source="cypress-test"
|
||||
buttonProps={{
|
||||
variant: 'danger',
|
||||
size: 'lg',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
cy.wait('@event-paywall-prompt')
|
||||
|
||||
cy.get('button.btn.btn-danger.btn-lg').contains('Start Free Trial!')
|
||||
})
|
||||
|
||||
it('renders the button with custom class', function () {
|
||||
cy.mount(
|
||||
<StartFreeTrialButton
|
||||
source="cypress-test"
|
||||
buttonProps={{ className: 'ct-test-class' }}
|
||||
/>
|
||||
)
|
||||
|
||||
cy.wait('@event-paywall-prompt')
|
||||
.its('request.body.paywall-type')
|
||||
.should('eq', 'cypress-test')
|
||||
|
||||
cy.get('.ct-test-class').contains('Start Free Trial!')
|
||||
})
|
||||
|
||||
it('calls onClick callback and opens a new tab to the subscription page on click', function () {
|
||||
const onClickStub = cy.stub()
|
||||
cy.mount(
|
||||
<StartFreeTrialButton source="cypress-test" handleClick={onClickStub} />
|
||||
)
|
||||
|
||||
cy.wait('@event-paywall-prompt')
|
||||
|
||||
cy.window().then(win => {
|
||||
cy.stub(win, 'open').as('Open')
|
||||
})
|
||||
cy.get('button.btn').contains('Start Free Trial!').click()
|
||||
|
||||
cy.wrap(null).then(() => {
|
||||
cy.wait('@event-paywall-click')
|
||||
.its('request.body.paywall-type')
|
||||
.should('eq', 'cypress-test')
|
||||
cy.get('@Open').should(
|
||||
'have.been.calledOnceWithExactly',
|
||||
'/user/subscription/choose-your-plan?itm_campaign=cypress-test'
|
||||
)
|
||||
expect(onClickStub).to.be.called
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
|
||||
|
||||
describe('<OLTooltip />', function () {
|
||||
it('calls the bound handler and blur then hides text on click', function () {
|
||||
const clickHandler = cy.stub().as('clickHandler')
|
||||
const blurHandler = cy.stub().as('blurHandler')
|
||||
const description = 'foo'
|
||||
const btnText = 'Click me!'
|
||||
|
||||
cy.mount(
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
}}
|
||||
>
|
||||
<OLTooltip id="abc" description={description}>
|
||||
<button onClick={clickHandler} onBlur={blurHandler}>
|
||||
{btnText}
|
||||
</button>
|
||||
</OLTooltip>
|
||||
</div>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: btnText }).as('button')
|
||||
cy.get('@button').trigger('mouseover')
|
||||
cy.findByText(description)
|
||||
cy.get('@button').click()
|
||||
cy.get('@clickHandler').should('have.been.calledOnce')
|
||||
cy.get('@blurHandler').should('have.been.calledOnce')
|
||||
cy.findByText(description).should('not.exist')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,164 @@
|
||||
import TokenAccessPage from '@/features/token-access/components/token-access-root'
|
||||
import { location } from '@/shared/components/location'
|
||||
|
||||
describe('<TokenAccessPage/>', function () {
|
||||
// this is a URL for a read-only token, but the process is the same for read-write tokens
|
||||
const url = '/read/123/grant'
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-postUrl', url)
|
||||
win.metaAttributesCache.set('ol-user', { email: 'test@example.com' })
|
||||
})
|
||||
})
|
||||
|
||||
it('handles a successful token access request', function () {
|
||||
cy.intercept(
|
||||
{ method: 'post', url, times: 1 },
|
||||
{
|
||||
body: {
|
||||
requireAccept: { projectName: 'Test Project' },
|
||||
},
|
||||
}
|
||||
).as('grantRequest')
|
||||
|
||||
cy.mount(<TokenAccessPage />)
|
||||
|
||||
cy.wait('@grantRequest').then(interception => {
|
||||
expect(interception.request.body.confirmedByUser).to.be.false
|
||||
})
|
||||
|
||||
cy.get('.link-sharing-invite-header').should(
|
||||
'have.text',
|
||||
['You’re joining', 'Test Project', 'as test@example.com'].join('')
|
||||
)
|
||||
|
||||
cy.intercept(
|
||||
{ method: 'post', url, times: 1 },
|
||||
{
|
||||
body: {
|
||||
redirect: '/project/123',
|
||||
},
|
||||
}
|
||||
).as('confirmedGrantRequest')
|
||||
|
||||
cy.stub(location, 'replace').as('replaceLocation')
|
||||
|
||||
cy.findByRole('button', { name: 'OK, join project' }).click()
|
||||
|
||||
cy.wait('@confirmedGrantRequest').then(interception => {
|
||||
expect(interception.request.body.confirmedByUser).to.be.true
|
||||
})
|
||||
|
||||
cy.get('@replaceLocation').should(
|
||||
'have.been.calledOnceWith',
|
||||
'/project/123'
|
||||
)
|
||||
})
|
||||
|
||||
it('handles a project not found response', function () {
|
||||
cy.intercept({ method: 'post', url, times: 1 }, { statusCode: 404 }).as(
|
||||
'grantRequest'
|
||||
)
|
||||
|
||||
cy.mount(<TokenAccessPage />)
|
||||
|
||||
cy.wait('@grantRequest')
|
||||
|
||||
cy.get('h3').should('have.text', 'Join Project')
|
||||
cy.get('h4').should('have.text', 'Project not found')
|
||||
|
||||
cy.findByRole('button', { name: 'Join Project' }).should('not.exist')
|
||||
})
|
||||
|
||||
it('handles a redirect response', function () {
|
||||
cy.intercept(
|
||||
{ method: 'post', url, times: 1 },
|
||||
{
|
||||
body: {
|
||||
redirect: '/restricted',
|
||||
},
|
||||
}
|
||||
).as('grantRequest')
|
||||
|
||||
cy.stub(location, 'replace').as('replaceLocation')
|
||||
|
||||
cy.mount(<TokenAccessPage />)
|
||||
|
||||
cy.wait('@grantRequest')
|
||||
|
||||
cy.get('@replaceLocation').should('have.been.calledOnceWith', '/restricted')
|
||||
})
|
||||
|
||||
it('handles a v1 "must login" response', function () {
|
||||
cy.intercept(
|
||||
{ method: 'post', url, times: 1 },
|
||||
{
|
||||
body: {
|
||||
v1Import: { status: 'mustLogin' },
|
||||
},
|
||||
}
|
||||
).as('grantRequest')
|
||||
|
||||
cy.stub(location, 'replace').as('replaceLocation')
|
||||
|
||||
cy.mount(<TokenAccessPage />)
|
||||
|
||||
cy.wait('@grantRequest')
|
||||
|
||||
cy.get('h1').should('have.text', 'Please log in')
|
||||
|
||||
cy.findByRole('link', { name: 'Log in to access project' })
|
||||
.should('have.attr', 'href')
|
||||
.and('match', /^\/login\?redir=/)
|
||||
})
|
||||
|
||||
it('handles a v1 "cannot import" response', function () {
|
||||
cy.intercept(
|
||||
{ method: 'post', url, times: 1 },
|
||||
{
|
||||
body: {
|
||||
v1Import: { status: 'cannotImport' },
|
||||
},
|
||||
}
|
||||
).as('grantRequest')
|
||||
|
||||
cy.stub(location, 'replace').as('replaceLocation')
|
||||
|
||||
cy.mount(<TokenAccessPage />)
|
||||
|
||||
cy.wait('@grantRequest')
|
||||
|
||||
cy.get('h1').should('have.text', 'Overleaf v1 Project')
|
||||
cy.get('h2').should('have.text', 'Cannot Access Overleaf v1 Project')
|
||||
})
|
||||
|
||||
it('handles a v1 "can download zip" response', function () {
|
||||
cy.intercept(
|
||||
{ method: 'post', url, times: 1 },
|
||||
{
|
||||
body: {
|
||||
v1Import: {
|
||||
status: 'canDownloadZip',
|
||||
projectId: '123',
|
||||
name: 'Test Project',
|
||||
},
|
||||
},
|
||||
}
|
||||
).as('grantRequest')
|
||||
|
||||
cy.stub(location, 'replace').as('replaceLocation')
|
||||
|
||||
cy.mount(<TokenAccessPage />)
|
||||
|
||||
cy.wait('@grantRequest')
|
||||
|
||||
cy.get('h1').should('have.text', 'Overleaf v1 Project')
|
||||
|
||||
cy.findByRole('link', { name: 'Download project zip file' }).should(
|
||||
'have.attr',
|
||||
'href',
|
||||
'/overleaf/project/123/download/zip'
|
||||
)
|
||||
})
|
||||
})
|
||||
10
services/web/test/frontend/cut-log-noise.js
Normal file
10
services/web/test/frontend/cut-log-noise.js
Normal file
@@ -0,0 +1,10 @@
|
||||
/* eslint-disable no-console */
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
|
||||
if (process.env.VERBOSE_LOGGING === 'true') {
|
||||
debugConsole.debug = console.debug
|
||||
debugConsole.log = console.log
|
||||
} else {
|
||||
debugConsole.warn = () => {}
|
||||
debugConsole.error = () => {}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { expect } from 'chai'
|
||||
import {
|
||||
fireEvent,
|
||||
screen,
|
||||
waitForElementToBeRemoved,
|
||||
} from '@testing-library/react'
|
||||
import fetchMock from 'fetch-mock'
|
||||
|
||||
import ChatPane from '../../../../../frontend/js/features/chat/components/chat-pane'
|
||||
import {
|
||||
cleanUpContext,
|
||||
renderWithEditorContext,
|
||||
} from '../../../helpers/render-with-context'
|
||||
import { stubMathJax, tearDownMathJaxStubs } from './stubs'
|
||||
|
||||
describe('<ChatPane />', function () {
|
||||
const user = {
|
||||
id: 'fake_user',
|
||||
first_name: 'fake_user_first_name',
|
||||
email: 'fake@example.com',
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
window.metaAttributesCache.set('ol-user', user)
|
||||
window.metaAttributesCache.set('ol-chatEnabled', true)
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
const testMessages = [
|
||||
{
|
||||
id: 'msg_1',
|
||||
content: 'a message',
|
||||
user,
|
||||
timestamp: new Date().getTime(),
|
||||
},
|
||||
{
|
||||
id: 'msg_2',
|
||||
content: 'another message',
|
||||
user,
|
||||
timestamp: new Date().getTime(),
|
||||
},
|
||||
]
|
||||
|
||||
beforeEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
cleanUpContext()
|
||||
|
||||
stubMathJax()
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
tearDownMathJaxStubs()
|
||||
})
|
||||
|
||||
it('renders multiple messages', async function () {
|
||||
fetchMock.get(/messages/, testMessages)
|
||||
|
||||
renderWithEditorContext(<ChatPane />, { user })
|
||||
|
||||
await screen.findByText('a message')
|
||||
await screen.findByText('another message')
|
||||
})
|
||||
|
||||
it('provides error message with reload button on FetchError', async function () {
|
||||
fetchMock.get(/messages/, 500)
|
||||
|
||||
renderWithEditorContext(<ChatPane />, { user })
|
||||
|
||||
// should have hit a FetchError and will prompt user to reconnect
|
||||
await screen.findByText('Try again')
|
||||
|
||||
// bring chat back up
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
fetchMock.get(/messages/, [])
|
||||
|
||||
const reconnectButton = screen.getByRole('button', {
|
||||
name: 'Try again',
|
||||
})
|
||||
expect(reconnectButton).to.exist
|
||||
|
||||
// should now reconnect with placeholder message
|
||||
fireEvent.click(reconnectButton)
|
||||
await screen.findByText('Send your first message to your collaborators')
|
||||
})
|
||||
|
||||
it('a loading spinner is rendered while the messages are loading, then disappears', async function () {
|
||||
fetchMock.get(/messages/, [], { delay: 1000 })
|
||||
|
||||
renderWithEditorContext(<ChatPane />, { user })
|
||||
|
||||
// not displayed initially
|
||||
expect(screen.queryByText('Loading…')).to.not.exist
|
||||
|
||||
// eventually displayed
|
||||
await screen.findByText('Loading…')
|
||||
|
||||
// eventually removed when the fetch call returns
|
||||
await waitForElementToBeRemoved(() => screen.getByText('Loading…'))
|
||||
})
|
||||
|
||||
describe('"send your first message" placeholder', function () {
|
||||
it('is rendered when there are no messages ', async function () {
|
||||
fetchMock.get(/messages/, [])
|
||||
|
||||
renderWithEditorContext(<ChatPane />, { user })
|
||||
|
||||
await screen.findByText('Send your first message to your collaborators')
|
||||
})
|
||||
|
||||
it('is not rendered when messages are displayed', function () {
|
||||
fetchMock.get(/messages/, testMessages)
|
||||
|
||||
renderWithEditorContext(<ChatPane />, { user })
|
||||
|
||||
expect(
|
||||
screen.queryByText('Send your first message to your collaborators')
|
||||
).to.not.exist
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import { expect } from 'chai'
|
||||
import sinon from 'sinon'
|
||||
import { screen, render, fireEvent } from '@testing-library/react'
|
||||
|
||||
import MessageInput from '../../../../../frontend/js/features/chat/components/message-input'
|
||||
|
||||
describe('<MessageInput />', function () {
|
||||
let resetUnreadMessages, sendMessage
|
||||
|
||||
beforeEach(function () {
|
||||
resetUnreadMessages = sinon.stub()
|
||||
sendMessage = sinon.stub()
|
||||
})
|
||||
|
||||
it('renders successfully', function () {
|
||||
render(
|
||||
<MessageInput
|
||||
sendMessage={sendMessage}
|
||||
resetUnreadMessages={resetUnreadMessages}
|
||||
/>
|
||||
)
|
||||
|
||||
screen.getByLabelText('Send a message to your collaborators')
|
||||
})
|
||||
|
||||
it('sends a message after typing and hitting enter', function () {
|
||||
render(
|
||||
<MessageInput
|
||||
sendMessage={sendMessage}
|
||||
resetUnreadMessages={resetUnreadMessages}
|
||||
/>
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'hello world' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(sendMessage).to.be.calledOnce
|
||||
expect(sendMessage).to.be.calledWith('hello world')
|
||||
})
|
||||
|
||||
it('resets the number of unread messages after clicking on the input', function () {
|
||||
render(
|
||||
<MessageInput
|
||||
sendMessage={sendMessage}
|
||||
resetUnreadMessages={resetUnreadMessages}
|
||||
/>
|
||||
)
|
||||
|
||||
const input = screen.getByPlaceholderText(
|
||||
'Send a message to your collaborators…'
|
||||
)
|
||||
|
||||
fireEvent.click(input)
|
||||
expect(resetUnreadMessages).to.be.calledOnce
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
import sinon from 'sinon'
|
||||
import { expect } from 'chai'
|
||||
import { screen, render, fireEvent } from '@testing-library/react'
|
||||
|
||||
import MessageList from '../../../../../frontend/js/features/chat/components/message-list'
|
||||
import { stubMathJax, tearDownMathJaxStubs } from './stubs'
|
||||
import { UserProvider } from '@/shared/context/user-context'
|
||||
|
||||
describe('<MessageList />', function () {
|
||||
const currentUser = {
|
||||
id: 'fake_user',
|
||||
first_name: 'fake_user_first_name',
|
||||
email: 'fake@example.com',
|
||||
}
|
||||
|
||||
function createMessages() {
|
||||
return [
|
||||
{
|
||||
id: '1',
|
||||
contents: ['a message'],
|
||||
user: currentUser,
|
||||
timestamp: new Date().getTime(),
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
contents: ['another message'],
|
||||
user: currentUser,
|
||||
timestamp: new Date().getTime(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
before(function () {
|
||||
stubMathJax()
|
||||
})
|
||||
|
||||
after(function () {
|
||||
tearDownMathJaxStubs()
|
||||
})
|
||||
|
||||
let olUser
|
||||
beforeEach(function () {
|
||||
olUser = window.metaAttributesCache.get('ol-user')
|
||||
window.metaAttributesCache.set('ol-user', currentUser)
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
window.metaAttributesCache.set('ol-user', olUser)
|
||||
})
|
||||
|
||||
it('renders multiple messages', function () {
|
||||
render(
|
||||
<UserProvider>
|
||||
<MessageList
|
||||
userId={currentUser.id}
|
||||
messages={createMessages()}
|
||||
resetUnreadMessages={() => {}}
|
||||
/>
|
||||
</UserProvider>
|
||||
)
|
||||
|
||||
screen.getByText('a message')
|
||||
screen.getByText('another message')
|
||||
})
|
||||
|
||||
it('renders a single timestamp for all messages within 5 minutes', function () {
|
||||
const msgs = createMessages()
|
||||
msgs[0].timestamp = new Date(2019, 6, 3, 4, 23).getTime()
|
||||
msgs[1].timestamp = new Date(2019, 6, 3, 4, 27).getTime()
|
||||
|
||||
render(
|
||||
<UserProvider>
|
||||
<MessageList
|
||||
userId={currentUser.id}
|
||||
messages={msgs}
|
||||
resetUnreadMessages={() => {}}
|
||||
/>
|
||||
</UserProvider>
|
||||
)
|
||||
|
||||
screen.getByText('4:23 am Wed, 3rd Jul 19')
|
||||
expect(screen.queryByText('4:27 am Wed, 3rd Jul 19')).to.not.exist
|
||||
})
|
||||
|
||||
it('renders a timestamp for each messages separated for more than 5 minutes', function () {
|
||||
const msgs = createMessages()
|
||||
msgs[0].timestamp = new Date(2019, 6, 3, 4, 23).getTime()
|
||||
msgs[1].timestamp = new Date(2019, 6, 3, 4, 31).getTime()
|
||||
|
||||
render(
|
||||
<UserProvider>
|
||||
<MessageList
|
||||
userId={currentUser.id}
|
||||
messages={msgs}
|
||||
resetUnreadMessages={() => {}}
|
||||
/>
|
||||
</UserProvider>
|
||||
)
|
||||
|
||||
screen.getByText('4:23 am Wed, 3rd Jul 19')
|
||||
screen.getByText('4:31 am Wed, 3rd Jul 19')
|
||||
})
|
||||
|
||||
it('resets the number of unread messages after clicking on the input', function () {
|
||||
const resetUnreadMessages = sinon.stub()
|
||||
render(
|
||||
<UserProvider>
|
||||
<MessageList
|
||||
userId={currentUser.id}
|
||||
messages={createMessages()}
|
||||
resetUnreadMessages={resetUnreadMessages}
|
||||
/>
|
||||
</UserProvider>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('list'))
|
||||
expect(resetUnreadMessages).to.be.calledOnce
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
import { expect } from 'chai'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
|
||||
import Message from '../../../../../frontend/js/features/chat/components/message'
|
||||
import { stubMathJax, tearDownMathJaxStubs } from './stubs'
|
||||
|
||||
describe('<Message />', function () {
|
||||
const currentUser = {
|
||||
id: 'fake_user',
|
||||
first_name: 'fake_user_first_name',
|
||||
email: 'fake@example.com',
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
window.metaAttributesCache.set('ol-user', currentUser)
|
||||
stubMathJax()
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
tearDownMathJaxStubs()
|
||||
})
|
||||
|
||||
it('renders a basic message', function () {
|
||||
const message = {
|
||||
contents: ['a message'],
|
||||
user: currentUser,
|
||||
}
|
||||
|
||||
render(<Message message={message} fromSelf />)
|
||||
|
||||
screen.getByText('a message')
|
||||
})
|
||||
|
||||
it('renders a message with multiple contents', function () {
|
||||
const message = {
|
||||
contents: ['a message', 'another message'],
|
||||
user: currentUser,
|
||||
}
|
||||
|
||||
render(<Message message={message} fromSelf />)
|
||||
|
||||
screen.getByText('a message')
|
||||
screen.getByText('another message')
|
||||
})
|
||||
|
||||
it('renders HTML links within messages', function () {
|
||||
const message = {
|
||||
contents: [
|
||||
'a message with a <a href="https://overleaf.com">link to Overleaf</a>',
|
||||
],
|
||||
user: currentUser,
|
||||
}
|
||||
|
||||
render(<Message message={message} fromSelf />)
|
||||
|
||||
screen.getByRole('link', { name: 'https://overleaf.com' })
|
||||
})
|
||||
|
||||
describe('when the message is from the user themselves', function () {
|
||||
const message = {
|
||||
contents: ['a message'],
|
||||
user: currentUser,
|
||||
}
|
||||
|
||||
it('does not render the user name nor the email', function () {
|
||||
render(<Message message={message} fromSelf />)
|
||||
|
||||
expect(screen.queryByText(currentUser.first_name)).to.not.exist
|
||||
expect(screen.queryByText(currentUser.email)).to.not.exist
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the message is from other user', function () {
|
||||
const otherUser = {
|
||||
id: 'other_user',
|
||||
first_name: 'other_user_first_name',
|
||||
}
|
||||
|
||||
const message = {
|
||||
contents: ['a message'],
|
||||
user: otherUser,
|
||||
}
|
||||
|
||||
it('should render the other user name', function () {
|
||||
render(<Message message={message} />)
|
||||
|
||||
screen.getByText(otherUser.first_name)
|
||||
})
|
||||
|
||||
it('should render the other user email when their name is not available', function () {
|
||||
const msg = {
|
||||
contents: message.contents,
|
||||
user: {
|
||||
id: otherUser.id,
|
||||
email: 'other@example.com',
|
||||
},
|
||||
}
|
||||
|
||||
render(<Message message={msg} />)
|
||||
|
||||
expect(screen.queryByText(otherUser.first_name)).to.not.exist
|
||||
screen.getByText(msg.user.email)
|
||||
})
|
||||
})
|
||||
})
|
||||
14
services/web/test/frontend/features/chat/components/stubs.js
Normal file
14
services/web/test/frontend/features/chat/components/stubs.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import sinon from 'sinon'
|
||||
|
||||
export function stubMathJax() {
|
||||
window.MathJax = {
|
||||
Hub: {
|
||||
Queue: sinon.stub(),
|
||||
config: { tex2jax: { inlineMath: [['$', '$']] } },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function tearDownMathJaxStubs() {
|
||||
delete window.MathJax
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
// Disable prop type checks for test harnesses
|
||||
/* eslint-disable react/prop-types */
|
||||
|
||||
import { renderHook, act } from '@testing-library/react-hooks/dom'
|
||||
import { expect } from 'chai'
|
||||
import sinon from 'sinon'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import {
|
||||
useChatContext,
|
||||
chatClientIdGenerator,
|
||||
} from '@/features/chat/context/chat-context'
|
||||
import { cleanUpContext } from '../../../helpers/render-with-context'
|
||||
import { stubMathJax, tearDownMathJaxStubs } from '../components/stubs'
|
||||
import { SocketIOMock } from '@/ide/connection/SocketIoShim'
|
||||
import { EditorProviders } from '../../../helpers/editor-providers'
|
||||
|
||||
describe('ChatContext', function () {
|
||||
const user = {
|
||||
id: 'fake_user',
|
||||
first_name: 'fake_user_first_name',
|
||||
email: 'fake@example.com',
|
||||
}
|
||||
const uuidValue = '00000000-0000-0000-0000-000000000000'
|
||||
|
||||
beforeEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
cleanUpContext()
|
||||
|
||||
stubMathJax()
|
||||
|
||||
window.metaAttributesCache.set('ol-user', user)
|
||||
window.metaAttributesCache.set('ol-chatEnabled', true)
|
||||
|
||||
this.stub = sinon.stub(chatClientIdGenerator, 'generate').returns(uuidValue)
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
tearDownMathJaxStubs()
|
||||
|
||||
this.stub.restore()
|
||||
})
|
||||
|
||||
describe('socket connection', function () {
|
||||
beforeEach(function () {
|
||||
// Mock GET messages to return no messages
|
||||
// FIXME?
|
||||
// fetchMock.get('express:/project/:projectId/messages', [])
|
||||
|
||||
// Mock POST new message to return 200
|
||||
fetchMock.post('express:/project/:projectId/messages', 200)
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('subscribes when mounted', function () {
|
||||
const socket = new SocketIOMock()
|
||||
renderChatContextHook({ socket })
|
||||
expect(socket.countEventListeners('new-chat-message')).to.equal(1)
|
||||
})
|
||||
|
||||
it('unsubscribes when unmounted', function () {
|
||||
const socket = new SocketIOMock()
|
||||
const { unmount } = renderChatContextHook({ socket })
|
||||
|
||||
unmount()
|
||||
|
||||
expect(socket.countEventListeners('new-chat-message')).to.equal(0)
|
||||
})
|
||||
|
||||
it('adds received messages to the list', async function () {
|
||||
// Mock socket: we only need to emit events, not mock actual connections
|
||||
const socket = new SocketIOMock()
|
||||
const { result, waitForNextUpdate } = renderChatContextHook({
|
||||
socket,
|
||||
})
|
||||
|
||||
// Wait until initial messages have loaded
|
||||
result.current.loadInitialMessages()
|
||||
await waitForNextUpdate()
|
||||
|
||||
// No messages shown at first
|
||||
expect(result.current.messages).to.deep.equal([])
|
||||
|
||||
// Mock message being received from another user
|
||||
socket.emitToClient('new-chat-message', {
|
||||
id: 'msg_1',
|
||||
content: 'new message',
|
||||
timestamp: Date.now(),
|
||||
user: {
|
||||
id: 'another_fake_user',
|
||||
first_name: 'another_fake_user_first_name',
|
||||
email: 'another_fake@example.com',
|
||||
},
|
||||
})
|
||||
|
||||
const message = result.current.messages[0]
|
||||
expect(message.id).to.equal('msg_1')
|
||||
expect(message.contents).to.deep.equal(['new message'])
|
||||
})
|
||||
|
||||
it('deduplicate messages from preloading', async function () {
|
||||
// Mock socket: we only need to emit events, not mock actual connections
|
||||
const socket = new SocketIOMock()
|
||||
const { result, waitForNextUpdate } = renderChatContextHook({
|
||||
socket,
|
||||
})
|
||||
|
||||
fetchMock.get('express:/project/:projectId/messages', [
|
||||
{
|
||||
id: 'msg_1',
|
||||
content: 'new message',
|
||||
timestamp: Date.now(),
|
||||
user: {
|
||||
id: 'another_fake_user',
|
||||
first_name: 'another_fake_user_first_name',
|
||||
email: 'another_fake@example.com',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
// Mock message being received from another user
|
||||
socket.emitToClient('new-chat-message', {
|
||||
id: 'msg_1',
|
||||
content: 'new message',
|
||||
timestamp: Date.now(),
|
||||
user: {
|
||||
id: 'another_fake_user',
|
||||
first_name: 'another_fake_user_first_name',
|
||||
email: 'another_fake@example.com',
|
||||
},
|
||||
})
|
||||
|
||||
// Check if received the message ID
|
||||
expect(result.current.messages).to.have.length(1)
|
||||
|
||||
// Wait until initial messages have loaded
|
||||
result.current.loadInitialMessages()
|
||||
await waitForNextUpdate()
|
||||
|
||||
// Check if there are no message duplication
|
||||
expect(result.current.messages).to.have.length(1)
|
||||
|
||||
const message = result.current.messages[0]
|
||||
expect(message.id).to.equal('msg_1')
|
||||
expect(message.contents).to.deep.equal(['new message'])
|
||||
})
|
||||
|
||||
it('deduplicate messages from websocket', async function () {
|
||||
// Mock socket: we only need to emit events, not mock actual connections
|
||||
const socket = new SocketIOMock()
|
||||
const { result, waitForNextUpdate } = renderChatContextHook({
|
||||
socket,
|
||||
})
|
||||
|
||||
fetchMock.get('express:/project/:projectId/messages', [
|
||||
{
|
||||
id: 'msg_1',
|
||||
content: 'new message',
|
||||
timestamp: Date.now(),
|
||||
user: {
|
||||
id: 'another_fake_user',
|
||||
first_name: 'another_fake_user_first_name',
|
||||
email: 'another_fake@example.com',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
// Wait until initial messages have loaded
|
||||
result.current.loadInitialMessages()
|
||||
await waitForNextUpdate()
|
||||
|
||||
// Check if received the message ID
|
||||
expect(result.current.messages).to.have.length(1)
|
||||
|
||||
// Mock message being received from another user
|
||||
socket.emitToClient('new-chat-message', {
|
||||
id: 'msg_1',
|
||||
content: 'new message',
|
||||
timestamp: Date.now(),
|
||||
user: {
|
||||
id: 'another_fake_user',
|
||||
first_name: 'another_fake_user_first_name',
|
||||
email: 'another_fake@example.com',
|
||||
},
|
||||
})
|
||||
|
||||
// Check if there are no message duplication
|
||||
expect(result.current.messages).to.have.length(1)
|
||||
|
||||
const message = result.current.messages[0]
|
||||
expect(message.id).to.equal('msg_1')
|
||||
expect(message.contents).to.deep.equal(['new message'])
|
||||
})
|
||||
|
||||
it("doesn't add received messages from the current user if a message was just sent", async function () {
|
||||
const socket = new SocketIOMock()
|
||||
const { result, waitForNextUpdate } = renderChatContextHook({
|
||||
socket,
|
||||
})
|
||||
|
||||
// Wait until initial messages have loaded
|
||||
result.current.loadInitialMessages()
|
||||
await waitForNextUpdate()
|
||||
|
||||
// Send a message from the current user
|
||||
const sentMsg = 'sent message'
|
||||
result.current.sendMessage(sentMsg)
|
||||
|
||||
act(() => {
|
||||
// Receive a message from the current user
|
||||
socket.emitToClient('new-chat-message', {
|
||||
id: 'msg_1',
|
||||
content: 'received message',
|
||||
timestamp: Date.now(),
|
||||
user,
|
||||
clientId: uuidValue,
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current.messages).to.have.length(1)
|
||||
|
||||
const [message] = result.current.messages
|
||||
|
||||
expect(message.contents).to.deep.equal([sentMsg])
|
||||
})
|
||||
|
||||
it('adds the new message from the current user if another message was received after sending', async function () {
|
||||
const socket = new SocketIOMock()
|
||||
const { result, waitForNextUpdate } = renderChatContextHook({
|
||||
socket,
|
||||
})
|
||||
|
||||
// Wait until initial messages have loaded
|
||||
result.current.loadInitialMessages()
|
||||
await waitForNextUpdate()
|
||||
|
||||
// Send a message from the current user
|
||||
const sentMsg = 'sent message from current user'
|
||||
result.current.sendMessage(sentMsg)
|
||||
|
||||
const [sentMessageFromCurrentUser] = result.current.messages
|
||||
expect(sentMessageFromCurrentUser.contents).to.deep.equal([sentMsg])
|
||||
|
||||
const otherMsg = 'new message from other user'
|
||||
|
||||
act(() => {
|
||||
// Receive a message from another user.
|
||||
socket.emitToClient('new-chat-message', {
|
||||
id: 'msg_1',
|
||||
content: otherMsg,
|
||||
timestamp: Date.now(),
|
||||
user: {
|
||||
id: 'another_fake_user',
|
||||
first_name: 'another_fake_user_first_name',
|
||||
email: 'another_fake@example.com',
|
||||
},
|
||||
clientId: '123',
|
||||
})
|
||||
})
|
||||
|
||||
const [, messageFromOtherUser] = result.current.messages
|
||||
expect(messageFromOtherUser.contents).to.deep.equal([otherMsg])
|
||||
|
||||
act(() => {
|
||||
// Receive a message from the current user
|
||||
socket.emitToClient('new-chat-message', {
|
||||
id: 'msg_2',
|
||||
content: 'received message from current user',
|
||||
timestamp: Date.now(),
|
||||
user,
|
||||
clientId: uuidValue,
|
||||
})
|
||||
})
|
||||
|
||||
// Since the current user didn't just send a message, it is now shown
|
||||
expect(result.current.messages).to.deep.equal([
|
||||
sentMessageFromCurrentUser,
|
||||
messageFromOtherUser,
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadInitialMessages', function () {
|
||||
beforeEach(function () {
|
||||
fetchMock.get('express:/project/:projectId/messages', [
|
||||
{
|
||||
id: 'msg_1',
|
||||
content: 'a message',
|
||||
user,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('adds messages to the list', async function () {
|
||||
const { result, waitForNextUpdate } = renderChatContextHook({})
|
||||
|
||||
result.current.loadInitialMessages()
|
||||
await waitForNextUpdate()
|
||||
|
||||
expect(result.current.messages[0].contents).to.deep.equal(['a message'])
|
||||
})
|
||||
|
||||
it("won't load messages a second time", async function () {
|
||||
const { result, waitForNextUpdate } = renderChatContextHook({})
|
||||
|
||||
result.current.loadInitialMessages()
|
||||
await waitForNextUpdate()
|
||||
|
||||
expect(result.current.initialMessagesLoaded).to.equal(true)
|
||||
|
||||
// Calling a second time won't do anything
|
||||
result.current.loadInitialMessages()
|
||||
expect(
|
||||
fetchMock.callHistory.calls('express:/project/:projectId/messages')
|
||||
).to.have.lengthOf(1)
|
||||
})
|
||||
|
||||
it('provides an error on failure', async function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
fetchMock.get('express:/project/:projectId/messages', 500)
|
||||
const { result, waitForNextUpdate } = renderChatContextHook({})
|
||||
|
||||
result.current.loadInitialMessages()
|
||||
await waitForNextUpdate()
|
||||
|
||||
expect(result.current.error).to.exist
|
||||
expect(result.current.status).to.equal('error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadMoreMessages', function () {
|
||||
it('adds messages to the list', async function () {
|
||||
// Mock a GET request for an initial message
|
||||
fetchMock.getOnce('express:/project/:projectId/messages', [
|
||||
{
|
||||
id: 'msg_1',
|
||||
content: 'first message',
|
||||
user,
|
||||
timestamp: new Date('2021-03-04T10:00:00').getTime(),
|
||||
},
|
||||
])
|
||||
|
||||
const { result, waitForNextUpdate } = renderChatContextHook({})
|
||||
|
||||
result.current.loadMoreMessages()
|
||||
await waitForNextUpdate()
|
||||
|
||||
expect(result.current.messages[0].contents).to.deep.equal([
|
||||
'first message',
|
||||
])
|
||||
|
||||
// The before query param is not set
|
||||
expect(getLastFetchMockQueryParam('before')).to.be.null
|
||||
})
|
||||
|
||||
it('adds more messages if called a second time', async function () {
|
||||
// Mock 2 GET requests, with different content
|
||||
fetchMock
|
||||
.getOnce(
|
||||
'express:/project/:projectId/messages',
|
||||
// Resolve a full "page" of messages (50)
|
||||
createMessages(50, user, new Date('2021-03-04T10:00:00').getTime())
|
||||
)
|
||||
.getOnce('express:/project/:projectId/messages', [
|
||||
{
|
||||
id: 'msg_51',
|
||||
content: 'message from second page',
|
||||
user,
|
||||
timestamp: new Date('2021-03-04T11:00:00').getTime(),
|
||||
},
|
||||
])
|
||||
|
||||
const { result, waitForNextUpdate } = renderChatContextHook({})
|
||||
|
||||
result.current.loadMoreMessages()
|
||||
await waitForNextUpdate()
|
||||
|
||||
// Call a second time
|
||||
result.current.loadMoreMessages()
|
||||
await waitForNextUpdate()
|
||||
|
||||
// The second request is added to the list
|
||||
// Since both messages from the same user, they are collapsed into the
|
||||
// same "message"
|
||||
expect(result.current.messages[0].contents).to.include(
|
||||
'message from second page'
|
||||
)
|
||||
|
||||
// The before query param for the second request matches the timestamp
|
||||
// of the first message
|
||||
const beforeParam = parseInt(getLastFetchMockQueryParam('before'), 10)
|
||||
expect(beforeParam).to.equal(new Date('2021-03-04T10:00:00').getTime())
|
||||
})
|
||||
|
||||
it("won't load more messages if there are no more messages", async function () {
|
||||
// Mock a GET request for 49 messages. This is less the the full page size
|
||||
// (50 messages), meaning that there are no further messages to be loaded
|
||||
fetchMock.getOnce(
|
||||
'express:/project/:projectId/messages',
|
||||
createMessages(49, user)
|
||||
)
|
||||
|
||||
const { result, waitForNextUpdate } = renderChatContextHook({})
|
||||
|
||||
result.current.loadMoreMessages()
|
||||
await waitForNextUpdate()
|
||||
|
||||
expect(result.current.messages[0].contents).to.have.length(49)
|
||||
|
||||
result.current.loadMoreMessages()
|
||||
|
||||
expect(result.current.atEnd).to.be.true
|
||||
expect(
|
||||
fetchMock.callHistory.calls('express:/project/:projectId/messages')
|
||||
).to.have.lengthOf(1)
|
||||
})
|
||||
|
||||
it('handles socket messages while loading', async function () {
|
||||
// Mock GET messages so that we can control when the promise is resolved
|
||||
let resolveLoadingMessages
|
||||
fetchMock.get(
|
||||
'express:/project/:projectId/messages',
|
||||
new Promise(resolve => {
|
||||
resolveLoadingMessages = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const socket = new SocketIOMock()
|
||||
const { result, waitForNextUpdate } = renderChatContextHook({
|
||||
socket,
|
||||
})
|
||||
|
||||
// Start loading messages
|
||||
result.current.loadMoreMessages()
|
||||
|
||||
// Mock message being received from the socket while the request is in
|
||||
// flight
|
||||
socket.emitToClient('new-chat-message', {
|
||||
id: 'socket_msg',
|
||||
content: 'socket message',
|
||||
timestamp: Date.now(),
|
||||
user: {
|
||||
id: 'another_fake_user',
|
||||
first_name: 'another_fake_user_first_name',
|
||||
email: 'another_fake@example.com',
|
||||
},
|
||||
})
|
||||
|
||||
// Resolve messages being loaded
|
||||
resolveLoadingMessages([
|
||||
{
|
||||
id: 'fetched_msg',
|
||||
content: 'loaded message',
|
||||
user,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
])
|
||||
await waitForNextUpdate()
|
||||
|
||||
// Although the loaded message was resolved last, it appears first (since
|
||||
// requested messages must have come first)
|
||||
const messageContents = result.current.messages.map(
|
||||
({ contents }) => contents[0]
|
||||
)
|
||||
expect(messageContents).to.deep.equal([
|
||||
'loaded message',
|
||||
'socket message',
|
||||
])
|
||||
})
|
||||
|
||||
it('provides an error on failures', async function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
fetchMock.get('express:/project/:projectId/messages', 500)
|
||||
const { result, waitForNextUpdate } = renderChatContextHook({})
|
||||
|
||||
result.current.loadMoreMessages()
|
||||
await waitForNextUpdate()
|
||||
|
||||
expect(result.current.error).to.exist
|
||||
expect(result.current.status).to.equal('error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sendMessage', function () {
|
||||
beforeEach(function () {
|
||||
// Mock GET messages to return no messages and POST new message to be
|
||||
// successful
|
||||
fetchMock
|
||||
.get('express:/project/:projectId/messages', [])
|
||||
.postOnce('express:/project/:projectId/messages', 200)
|
||||
})
|
||||
|
||||
it('optimistically adds the message to the list', function () {
|
||||
const { result } = renderChatContextHook({})
|
||||
|
||||
result.current.sendMessage('sent message')
|
||||
|
||||
expect(result.current.messages[0].contents).to.deep.equal([
|
||||
'sent message',
|
||||
])
|
||||
})
|
||||
|
||||
it('POSTs the message to the backend', function () {
|
||||
const { result } = renderChatContextHook({})
|
||||
|
||||
result.current.sendMessage('sent message')
|
||||
|
||||
const {
|
||||
options: { body },
|
||||
} = fetchMock.callHistory
|
||||
.calls('express:/project/:projectId/messages', { method: 'POST' })
|
||||
.at(-1)
|
||||
expect(JSON.parse(body)).to.deep.include({ content: 'sent message' })
|
||||
})
|
||||
|
||||
it("doesn't send if the content is empty", function () {
|
||||
const { result } = renderChatContextHook({})
|
||||
|
||||
result.current.sendMessage('')
|
||||
|
||||
expect(result.current.messages).to.be.empty
|
||||
expect(
|
||||
fetchMock.callHistory.called('express:/project/:projectId/messages', {
|
||||
method: 'post',
|
||||
})
|
||||
).to.be.false
|
||||
})
|
||||
|
||||
it('provides an error on failure', async function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
fetchMock
|
||||
.get('express:/project/:projectId/messages', [])
|
||||
.postOnce('express:/project/:projectId/messages', 500)
|
||||
const { result, waitForNextUpdate } = renderChatContextHook({})
|
||||
|
||||
result.current.sendMessage('sent message')
|
||||
await waitForNextUpdate()
|
||||
|
||||
expect(result.current.error).to.exist
|
||||
expect(result.current.status).to.equal('error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('unread messages', function () {
|
||||
beforeEach(function () {
|
||||
// Mock GET messages to return no messages
|
||||
fetchMock.get('express:/project/:projectId/messages', [])
|
||||
})
|
||||
|
||||
it('increments unreadMessageCount when a new message is received', function () {
|
||||
const socket = new SocketIOMock()
|
||||
const { result } = renderChatContextHook({ socket })
|
||||
|
||||
// Receive a new message from the socket
|
||||
socket.emitToClient('new-chat-message', {
|
||||
id: 'msg_1',
|
||||
content: 'new message',
|
||||
timestamp: Date.now(),
|
||||
user,
|
||||
})
|
||||
|
||||
expect(result.current.unreadMessageCount).to.equal(1)
|
||||
})
|
||||
|
||||
it('resets unreadMessageCount when markMessagesAsRead is called', function () {
|
||||
const socket = new SocketIOMock()
|
||||
const { result } = renderChatContextHook({ socket })
|
||||
|
||||
// Receive a new message from the socket, incrementing unreadMessageCount
|
||||
// by 1
|
||||
socket.emitToClient('new-chat-message', {
|
||||
id: 'msg_1',
|
||||
content: 'new message',
|
||||
timestamp: Date.now(),
|
||||
user,
|
||||
})
|
||||
|
||||
result.current.markMessagesAsRead()
|
||||
|
||||
expect(result.current.unreadMessageCount).to.equal(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function renderChatContextHook(props) {
|
||||
return renderHook(() => useChatContext(), {
|
||||
// Wrap with ChatContext.Provider (and the other editor context providers)
|
||||
// eslint-disable-next-line react/display-name
|
||||
wrapper: ({ children }) => (
|
||||
<EditorProviders {...props}>{children}</EditorProviders>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
function createMessages(number, user, timestamp = Date.now()) {
|
||||
return Array.from({ length: number }, (_m, idx) => ({
|
||||
id: `msg_${idx + 1}`,
|
||||
content: `message ${idx + 1}`,
|
||||
user,
|
||||
timestamp,
|
||||
}))
|
||||
}
|
||||
|
||||
/*
|
||||
* Get query param by key from the last fetchMock response
|
||||
*/
|
||||
function getLastFetchMockQueryParam(key) {
|
||||
const { url } = fetchMock.callHistory.calls().at(-1)
|
||||
const { searchParams } = new URL(url, 'https://www.overleaf.com')
|
||||
return searchParams.get(key)
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import { expect } from 'chai'
|
||||
import {
|
||||
appendMessage,
|
||||
prependMessages,
|
||||
} from '../../../../../frontend/js/features/chat/utils/message-list-appender'
|
||||
|
||||
const testUser = {
|
||||
id: '123abc',
|
||||
}
|
||||
|
||||
const otherUser = {
|
||||
id: '234other',
|
||||
}
|
||||
|
||||
function createTestMessageList() {
|
||||
return [
|
||||
{
|
||||
id: 'msg_1',
|
||||
contents: ['hello', 'world'],
|
||||
timestamp: new Date().getTime(),
|
||||
user: otherUser,
|
||||
},
|
||||
{
|
||||
id: 'msg_2',
|
||||
contents: ['foo'],
|
||||
timestamp: new Date().getTime(),
|
||||
user: testUser,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
describe('prependMessages()', function () {
|
||||
function createTestMessages() {
|
||||
const message1 = {
|
||||
id: 'prepended_message',
|
||||
content: 'hello',
|
||||
timestamp: new Date().getTime(),
|
||||
user: testUser,
|
||||
}
|
||||
const message2 = { ...message1, id: 'prepended_message_2' }
|
||||
return [message1, message2]
|
||||
}
|
||||
|
||||
it('to an empty list', function () {
|
||||
const messages = createTestMessages()
|
||||
const uniqueMessageIds = []
|
||||
|
||||
expect(
|
||||
prependMessages([], messages, uniqueMessageIds).messages
|
||||
).to.deep.equal([
|
||||
{
|
||||
id: messages[0].id,
|
||||
timestamp: messages[0].timestamp,
|
||||
user: messages[0].user,
|
||||
contents: [messages[0].content, messages[1].content],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
describe('when the messages to prepend are from the same user', function () {
|
||||
let list, messages, uniqueMessageIds
|
||||
|
||||
beforeEach(function () {
|
||||
list = createTestMessageList()
|
||||
messages = createTestMessages()
|
||||
messages[0].user = testUser // makes all the messages have the same author
|
||||
uniqueMessageIds = []
|
||||
})
|
||||
|
||||
it('when the prepended messages are close in time, contents should be merged into the same message', function () {
|
||||
const result = prependMessages(
|
||||
createTestMessageList(),
|
||||
messages,
|
||||
uniqueMessageIds
|
||||
).messages
|
||||
expect(result.length).to.equal(list.length + 1)
|
||||
expect(result[0]).to.deep.equal({
|
||||
id: messages[0].id,
|
||||
timestamp: messages[0].timestamp,
|
||||
user: messages[0].user,
|
||||
contents: [messages[0].content, messages[1].content],
|
||||
})
|
||||
})
|
||||
|
||||
it('when the prepended messages are separated in time, each message is prepended', function () {
|
||||
messages[0].timestamp = messages[1].timestamp - 6 * 60 * 1000 // 6 minutes before the next message
|
||||
const result = prependMessages(
|
||||
createTestMessageList(),
|
||||
messages,
|
||||
uniqueMessageIds
|
||||
).messages
|
||||
expect(result.length).to.equal(list.length + 2)
|
||||
expect(result[0]).to.deep.equal({
|
||||
id: messages[0].id,
|
||||
timestamp: messages[0].timestamp,
|
||||
user: messages[0].user,
|
||||
contents: [messages[0].content],
|
||||
})
|
||||
expect(result[1]).to.deep.equal({
|
||||
id: messages[1].id,
|
||||
timestamp: messages[1].timestamp,
|
||||
user: messages[1].user,
|
||||
contents: [messages[1].content],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the messages to prepend are from different users', function () {
|
||||
let list, messages, uniqueMessageIds
|
||||
|
||||
beforeEach(function () {
|
||||
list = createTestMessageList()
|
||||
messages = createTestMessages()
|
||||
uniqueMessageIds = []
|
||||
})
|
||||
|
||||
it('should prepend separate messages to the list', function () {
|
||||
messages[0].user = otherUser
|
||||
const result = prependMessages(
|
||||
createTestMessageList(),
|
||||
messages,
|
||||
uniqueMessageIds
|
||||
).messages
|
||||
expect(result.length).to.equal(list.length + 2)
|
||||
expect(result[0]).to.deep.equal({
|
||||
id: messages[0].id,
|
||||
timestamp: messages[0].timestamp,
|
||||
user: messages[0].user,
|
||||
contents: [messages[0].content],
|
||||
})
|
||||
expect(result[1]).to.deep.equal({
|
||||
id: messages[1].id,
|
||||
timestamp: messages[1].timestamp,
|
||||
user: messages[1].user,
|
||||
contents: [messages[1].content],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should merge the prepended messages into the first existing one when user is same user and are close in time', function () {
|
||||
const list = createTestMessageList()
|
||||
const messages = createTestMessages()
|
||||
messages[0].user = messages[1].user = list[0].user
|
||||
const uniqueMessageIds = []
|
||||
|
||||
const result = prependMessages(
|
||||
createTestMessageList(),
|
||||
messages,
|
||||
uniqueMessageIds
|
||||
).messages
|
||||
expect(result.length).to.equal(list.length)
|
||||
expect(result[0]).to.deep.equal({
|
||||
id: messages[0].id,
|
||||
timestamp: messages[0].timestamp,
|
||||
user: messages[0].user,
|
||||
contents: [messages[0].content, messages[1].content, ...list[0].contents],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('appendMessage()', function () {
|
||||
function createTestMessage() {
|
||||
return {
|
||||
id: 'appended_message',
|
||||
content: 'hi!',
|
||||
timestamp: new Date().getTime(),
|
||||
user: testUser,
|
||||
}
|
||||
}
|
||||
|
||||
it('to an empty list', function () {
|
||||
const testMessage = createTestMessage()
|
||||
const uniqueMessageIds = []
|
||||
|
||||
expect(
|
||||
appendMessage([], testMessage, uniqueMessageIds).messages
|
||||
).to.deep.equal([
|
||||
{
|
||||
id: 'appended_message',
|
||||
timestamp: testMessage.timestamp,
|
||||
user: testMessage.user,
|
||||
contents: [testMessage.content],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
describe('messages appended shortly after the last message on the list', function () {
|
||||
let list, message, uniqueMessageIds
|
||||
|
||||
beforeEach(function () {
|
||||
list = createTestMessageList()
|
||||
message = createTestMessage()
|
||||
message.timestamp = list[1].timestamp + 6 * 1000 // 6 seconds after the last message in the list
|
||||
uniqueMessageIds = []
|
||||
})
|
||||
|
||||
describe('when the author is the same as the last message', function () {
|
||||
it('should append the content to the last message', function () {
|
||||
const result = appendMessage(list, message, uniqueMessageIds).messages
|
||||
expect(result.length).to.equal(list.length)
|
||||
expect(result[1].contents).to.deep.equal(
|
||||
list[1].contents.concat(message.content)
|
||||
)
|
||||
})
|
||||
|
||||
it('should update the last message timestamp', function () {
|
||||
const result = appendMessage(list, message, uniqueMessageIds).messages
|
||||
expect(result[1].timestamp).to.equal(message.timestamp)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the author is different than the last message', function () {
|
||||
beforeEach(function () {
|
||||
message.user = otherUser
|
||||
})
|
||||
|
||||
it('should append the new message to the list', function () {
|
||||
const result = appendMessage(list, message, uniqueMessageIds).messages
|
||||
expect(result.length).to.equal(list.length + 1)
|
||||
expect(result[2]).to.deep.equal({
|
||||
id: 'appended_message',
|
||||
timestamp: message.timestamp,
|
||||
user: message.user,
|
||||
contents: [message.content],
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('messages appended later after the last message on the list', function () {
|
||||
let list, message, uniqueMessageIds
|
||||
|
||||
beforeEach(function () {
|
||||
list = createTestMessageList()
|
||||
message = createTestMessage()
|
||||
message.timestamp = list[1].timestamp + 6 * 60 * 1000 // 6 minutes after the last message in the list
|
||||
uniqueMessageIds = []
|
||||
})
|
||||
|
||||
it('when the author is the same as the last message, should be appended as new message', function () {
|
||||
const result = appendMessage(list, message, uniqueMessageIds).messages
|
||||
expect(result.length).to.equal(3)
|
||||
expect(result[2]).to.deep.equal({
|
||||
id: 'appended_message',
|
||||
timestamp: message.timestamp,
|
||||
user: message.user,
|
||||
contents: [message.content],
|
||||
})
|
||||
})
|
||||
|
||||
it('when the author is the different than the last message, should be appended as new message', function () {
|
||||
message.user = otherUser
|
||||
|
||||
const result = appendMessage(list, message, uniqueMessageIds).messages
|
||||
expect(result.length).to.equal(3)
|
||||
expect(result[2]).to.deep.equal({
|
||||
id: 'appended_message',
|
||||
timestamp: message.timestamp,
|
||||
user: message.user,
|
||||
contents: [message.content],
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,182 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import sinon from 'sinon'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import EditorCloneProjectModalWrapper from '../../../../../frontend/js/features/clone-project-modal/components/editor-clone-project-modal-wrapper'
|
||||
import { renderWithEditorContext } from '../../../helpers/render-with-context'
|
||||
|
||||
describe('<EditorCloneProjectModalWrapper />', function () {
|
||||
beforeEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
after(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
const project = {
|
||||
_id: 'project-1',
|
||||
name: 'Test Project',
|
||||
}
|
||||
|
||||
it('renders the translated modal title', async function () {
|
||||
const handleHide = sinon.stub()
|
||||
const openProject = sinon.stub()
|
||||
|
||||
renderWithEditorContext(
|
||||
<EditorCloneProjectModalWrapper
|
||||
handleHide={handleHide}
|
||||
openProject={openProject}
|
||||
show
|
||||
/>,
|
||||
{ scope: { project } }
|
||||
)
|
||||
|
||||
await screen.findByText('Copy Project')
|
||||
})
|
||||
|
||||
it('posts the generated project name', async function () {
|
||||
fetchMock.post(
|
||||
'express:/project/:projectId/clone',
|
||||
{
|
||||
status: 200,
|
||||
body: { project_id: 'cloned-project' },
|
||||
},
|
||||
{ delay: 10 }
|
||||
)
|
||||
|
||||
const handleHide = sinon.stub()
|
||||
const openProject = sinon.stub()
|
||||
|
||||
renderWithEditorContext(
|
||||
<EditorCloneProjectModalWrapper
|
||||
handleHide={handleHide}
|
||||
openProject={openProject}
|
||||
show
|
||||
/>,
|
||||
{ scope: { project } }
|
||||
)
|
||||
|
||||
const cancelButton = await screen.findByRole('button', { name: 'Cancel' })
|
||||
expect(cancelButton.disabled).to.be.false
|
||||
|
||||
const submitButton = await screen.findByRole('button', { name: 'Copy' })
|
||||
expect(submitButton.disabled).to.be.false
|
||||
|
||||
const input = await screen.getByLabelText('New Name')
|
||||
|
||||
fireEvent.change(input, {
|
||||
target: { value: '' },
|
||||
})
|
||||
expect(submitButton.disabled).to.be.true
|
||||
|
||||
fireEvent.change(input, {
|
||||
target: { value: 'A Cloned Project' },
|
||||
})
|
||||
expect(submitButton.disabled).to.be.false
|
||||
|
||||
fireEvent.click(submitButton)
|
||||
expect(submitButton.disabled).to.be.true
|
||||
|
||||
await fetchMock.callHistory.flush(true)
|
||||
expect(fetchMock.callHistory.done()).to.be.true
|
||||
const { url, options } = fetchMock.callHistory
|
||||
.calls('express:/project/:projectId/clone')
|
||||
.at(-1)
|
||||
expect(url).to.equal(
|
||||
'https://www.test-overleaf.com/project/project-1/clone'
|
||||
)
|
||||
|
||||
expect(JSON.parse(options.body)).to.deep.equal({
|
||||
projectName: 'A Cloned Project',
|
||||
tags: [],
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(openProject).to.be.calledOnce
|
||||
})
|
||||
|
||||
const errorMessage = screen.queryByText('Sorry, something went wrong')
|
||||
expect(errorMessage).to.be.null
|
||||
|
||||
await waitFor(() => {
|
||||
expect(submitButton.disabled).to.be.false
|
||||
expect(cancelButton.disabled).to.be.false
|
||||
})
|
||||
})
|
||||
|
||||
it('handles a generic error response', async function () {
|
||||
const matcher = 'express:/project/:projectId/clone'
|
||||
|
||||
fetchMock.postOnce(matcher, {
|
||||
status: 500,
|
||||
body: 'There was an error!',
|
||||
})
|
||||
|
||||
const handleHide = sinon.stub()
|
||||
const openProject = sinon.stub()
|
||||
|
||||
renderWithEditorContext(
|
||||
<EditorCloneProjectModalWrapper
|
||||
handleHide={handleHide}
|
||||
openProject={openProject}
|
||||
show
|
||||
/>,
|
||||
{ scope: { project } }
|
||||
)
|
||||
|
||||
const button = await screen.findByRole('button', { name: 'Copy' })
|
||||
expect(button.disabled).to.be.false
|
||||
|
||||
const cancelButton = await screen.findByRole('button', { name: 'Cancel' })
|
||||
expect(cancelButton.disabled).to.be.false
|
||||
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(fetchMock.callHistory.done(matcher)).to.be.true
|
||||
expect(openProject).not.to.be.called
|
||||
|
||||
await screen.findByText('Sorry, something went wrong')
|
||||
|
||||
expect(button.disabled).to.be.false
|
||||
expect(cancelButton.disabled).to.be.false
|
||||
})
|
||||
|
||||
it('handles a specific error response', async function () {
|
||||
const matcher = 'express:/project/:projectId/clone'
|
||||
|
||||
fetchMock.postOnce(matcher, {
|
||||
status: 400,
|
||||
body: 'There was an error!',
|
||||
})
|
||||
|
||||
const handleHide = sinon.stub()
|
||||
const openProject = sinon.stub()
|
||||
|
||||
renderWithEditorContext(
|
||||
<EditorCloneProjectModalWrapper
|
||||
handleHide={handleHide}
|
||||
openProject={openProject}
|
||||
show
|
||||
/>,
|
||||
{ scope: { project } }
|
||||
)
|
||||
|
||||
const button = await screen.findByRole('button', { name: 'Copy' })
|
||||
expect(button.disabled).to.be.false
|
||||
|
||||
const cancelButton = await screen.findByRole('button', { name: 'Cancel' })
|
||||
expect(cancelButton.disabled).to.be.false
|
||||
|
||||
fireEvent.click(button)
|
||||
await fetchMock.callHistory.flush(true)
|
||||
|
||||
expect(fetchMock.callHistory.done(matcher)).to.be.true
|
||||
expect(openProject).not.to.be.called
|
||||
|
||||
await screen.findByText('There was an error!')
|
||||
|
||||
expect(button.disabled).to.be.false
|
||||
expect(cancelButton.disabled).to.be.false
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
import DictionaryModal from '@/features/dictionary/components/dictionary-modal'
|
||||
import { EditorProviders } from '../../../helpers/editor-providers'
|
||||
import { learnedWords } from '@/features/source-editor/extensions/spelling/learned-words'
|
||||
|
||||
describe('<DictionaryModalContent />', function () {
|
||||
let originalLearnedWords
|
||||
|
||||
beforeEach(function () {
|
||||
cy.then(() => {
|
||||
originalLearnedWords = learnedWords.global
|
||||
})
|
||||
cy.interceptCompile()
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
cy.then(() => {
|
||||
learnedWords.global = originalLearnedWords
|
||||
})
|
||||
})
|
||||
|
||||
it('list words', function () {
|
||||
cy.then(win => {
|
||||
learnedWords.global = new Set(['foo', 'bar'])
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<DictionaryModal show handleHide={cy.stub()} />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByText('foo')
|
||||
cy.findByText('bar')
|
||||
})
|
||||
|
||||
it('shows message when empty', function () {
|
||||
cy.then(win => {
|
||||
learnedWords.global = new Set([])
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<DictionaryModal show handleHide={cy.stub()} />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.contains('Your custom dictionary is empty.')
|
||||
})
|
||||
|
||||
it('removes words', function () {
|
||||
cy.intercept('/spelling/unlearn', { statusCode: 200 })
|
||||
|
||||
cy.then(win => {
|
||||
learnedWords.global = new Set(['Foo', 'bar'])
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<DictionaryModal show handleHide={cy.stub()} />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByText('Foo')
|
||||
cy.findByText('bar')
|
||||
|
||||
cy.findAllByRole('button', {
|
||||
name: 'Remove from dictionary',
|
||||
})
|
||||
.eq(0)
|
||||
.click()
|
||||
|
||||
cy.findByText('bar').should('not.exist')
|
||||
cy.findByText('Foo')
|
||||
})
|
||||
|
||||
it('handles errors', function () {
|
||||
cy.intercept('/spelling/unlearn', { statusCode: 500 }).as('unlearn')
|
||||
|
||||
cy.then(win => {
|
||||
learnedWords.global = new Set(['foo'])
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<DictionaryModal show handleHide={cy.stub()} />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByText('foo')
|
||||
|
||||
cy.findAllByRole('button', {
|
||||
name: 'Remove from dictionary',
|
||||
})
|
||||
.eq(0)
|
||||
.click()
|
||||
|
||||
cy.wait('@unlearn')
|
||||
|
||||
cy.findByText('Sorry, something went wrong')
|
||||
cy.findByText('foo')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import sinon from 'sinon'
|
||||
import { expect } from 'chai'
|
||||
import ActionsCopyProject from '../../../../../frontend/js/features/editor-left-menu/components/actions-copy-project'
|
||||
import { renderWithEditorContext } from '../../../helpers/render-with-context'
|
||||
import * as useLocationModule from '../../../../../frontend/js/shared/hooks/use-location'
|
||||
|
||||
describe('<ActionsCopyProject />', function () {
|
||||
let assignStub
|
||||
|
||||
beforeEach(function () {
|
||||
assignStub = sinon.stub()
|
||||
this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
|
||||
assign: assignStub,
|
||||
replace: sinon.stub(),
|
||||
reload: sinon.stub(),
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
this.locationStub.restore()
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('shows correct modal when clicked', async function () {
|
||||
renderWithEditorContext(<ActionsCopyProject />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copy Project' }))
|
||||
|
||||
screen.getByPlaceholderText('New Project Name')
|
||||
})
|
||||
|
||||
it('loads the project page when submitted', async function () {
|
||||
fetchMock.post('express:/project/:id/clone', {
|
||||
status: 200,
|
||||
body: {
|
||||
project_id: 'new-project',
|
||||
},
|
||||
})
|
||||
|
||||
renderWithEditorContext(<ActionsCopyProject />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copy Project' }))
|
||||
|
||||
const input = screen.getByPlaceholderText('New Project Name')
|
||||
fireEvent.change(input, { target: { value: 'New Project' } })
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Copy' })
|
||||
button.click()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(button.textContent).to.equal('Copying…')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(assignStub).to.have.been.calledOnceWith('/project/new-project')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import ActionsMenu from '../../../../../frontend/js/features/editor-left-menu/components/actions-menu'
|
||||
import { renderWithEditorContext } from '../../../helpers/render-with-context'
|
||||
|
||||
describe('<ActionsMenu />', function () {
|
||||
beforeEach(function () {
|
||||
fetchMock.post('express:/project/:projectId/compile', {
|
||||
status: 'success',
|
||||
pdfDownloadDomain: 'https://clsi.test-overleaf.com',
|
||||
outputFiles: [
|
||||
{
|
||||
path: 'output.pdf',
|
||||
build: 'build-123',
|
||||
url: '/build/build-123/output.pdf',
|
||||
type: 'pdf',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('shows correct menu for non-anonymous users', async function () {
|
||||
window.metaAttributesCache.set('ol-anonymous', false)
|
||||
|
||||
renderWithEditorContext(<ActionsMenu />, {
|
||||
projectId: '123abc',
|
||||
scope: {
|
||||
editor: {
|
||||
sharejs_doc: {
|
||||
doc_id: 'test-doc',
|
||||
getSnapshot: () => 'some doc content',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
screen.getByText('Actions')
|
||||
screen.getByRole('button', {
|
||||
name: 'Copy Project',
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
screen.getByRole('button', {
|
||||
name: 'Word Count',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('does not show anything for anonymous users', async function () {
|
||||
window.metaAttributesCache.set('ol-anonymous', true)
|
||||
|
||||
renderWithEditorContext(<ActionsMenu />, {
|
||||
projectId: '123abc',
|
||||
scope: {
|
||||
editor: {
|
||||
sharejs_doc: {
|
||||
doc_id: 'test-doc',
|
||||
getSnapshot: () => 'some doc content',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.queryByText('Actions')).to.equal(null)
|
||||
expect(
|
||||
screen.queryByRole('button', {
|
||||
name: 'Copy Project',
|
||||
})
|
||||
).to.equal(null)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole('button', {
|
||||
name: 'Word Count',
|
||||
})
|
||||
).to.equal(null)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import ActionsWordCount from '../../../../../frontend/js/features/editor-left-menu/components/actions-word-count'
|
||||
import { renderWithEditorContext } from '../../../helpers/render-with-context'
|
||||
|
||||
describe('<ActionsWordCount />', function () {
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('shows correct modal when clicked after document is compiled', async function () {
|
||||
const compileEndpoint = 'express:/project/:projectId/compile'
|
||||
const wordcountEndpoint = 'express:/project/:projectId/wordcount'
|
||||
|
||||
fetchMock.post(compileEndpoint, {
|
||||
status: 'success',
|
||||
pdfDownloadDomain: 'https://clsi.test-overleaf.com',
|
||||
outputFiles: [
|
||||
{
|
||||
path: 'output.pdf',
|
||||
build: 'build-123',
|
||||
url: '/build/build-123/output.pdf',
|
||||
type: 'pdf',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
fetchMock.get(wordcountEndpoint, {
|
||||
texcount: {
|
||||
encode: 'ascii',
|
||||
textWords: 0,
|
||||
headers: 0,
|
||||
mathInline: 0,
|
||||
mathDisplay: 0,
|
||||
},
|
||||
})
|
||||
|
||||
renderWithEditorContext(<ActionsWordCount />, {
|
||||
projectId: '123abc',
|
||||
scope: {
|
||||
editor: {
|
||||
sharejs_doc: {
|
||||
doc_id: 'test-doc',
|
||||
getSnapshot: () => 'some doc content',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// when loading, we don't render the "Word Count" as button yet
|
||||
expect(screen.queryByRole('button', { name: 'Word Count' })).to.equal(null)
|
||||
|
||||
await waitFor(
|
||||
() => expect(fetchMock.callHistory.called(compileEndpoint)).to.be.true
|
||||
)
|
||||
|
||||
const button = await screen.findByRole('button', { name: 'Word Count' })
|
||||
button.click()
|
||||
|
||||
await waitFor(
|
||||
() => expect(fetchMock.callHistory.called(wordcountEndpoint)).to.be.true
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import DownloadMenu from '../../../../../frontend/js/features/editor-left-menu/components/download-menu'
|
||||
import { renderWithEditorContext } from '../../../helpers/render-with-context'
|
||||
|
||||
describe('<DownloadMenu />', function () {
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('shows download links with correct url', async function () {
|
||||
fetchMock.post('express:/project/:projectId/compile', {
|
||||
clsiServerId: 'foo',
|
||||
compileGroup: 'priority',
|
||||
status: 'success',
|
||||
pdfDownloadDomain: 'https://clsi.test-overleaf.com',
|
||||
outputFiles: [
|
||||
{
|
||||
path: 'output.pdf',
|
||||
build: 'build-123',
|
||||
url: '/build/build-123/output.pdf',
|
||||
type: 'pdf',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
renderWithEditorContext(<DownloadMenu />, {
|
||||
projectId: '123abc',
|
||||
scope: {
|
||||
editor: {
|
||||
sharejs_doc: {
|
||||
doc_id: 'test-doc',
|
||||
getSnapshot: () => 'some doc content',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const sourceLink = screen.getByRole('link', {
|
||||
name: 'Source',
|
||||
})
|
||||
|
||||
expect(sourceLink.getAttribute('href')).to.equal(
|
||||
'/project/123abc/download/zip'
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
const pdfLink = screen.getByRole('link', {
|
||||
name: 'PDF',
|
||||
})
|
||||
|
||||
expect(pdfLink.getAttribute('href')).to.equal(
|
||||
'/download/project/123abc/build/build-123/output/output.pdf?compileGroup=priority&clsiserverid=foo&popupDownload=true'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import { expect } from 'chai'
|
||||
import { screen, fireEvent, within } from '@testing-library/react'
|
||||
import HelpContactUs from '../../../../../frontend/js/features/editor-left-menu/components/help-contact-us'
|
||||
import { renderWithEditorContext } from '../../../helpers/render-with-context'
|
||||
import fetchMock from 'fetch-mock'
|
||||
|
||||
describe('<HelpContactUs />', function () {
|
||||
beforeEach(function () {
|
||||
window.metaAttributesCache.set('ol-user', {
|
||||
email: 'sherlock@holmes.co.uk',
|
||||
first_name: 'Sherlock',
|
||||
last_name: 'Holmes',
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('open contact us modal when clicked', function () {
|
||||
renderWithEditorContext(<HelpContactUs />)
|
||||
|
||||
expect(screen.queryByRole('dialog')).to.equal(null)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Contact Us' }))
|
||||
const modal = screen.getAllByRole('dialog')[0]
|
||||
within(modal).getAllByText('Get in touch')
|
||||
within(modal).getByText('Subject')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { expect } from 'chai'
|
||||
import { screen, render } from '@testing-library/react'
|
||||
import HelpDocumentation from '../../../../../frontend/js/features/editor-left-menu/components/help-documentation'
|
||||
|
||||
describe('<HelpDocumentation />', function () {
|
||||
it('has correct href attribute', function () {
|
||||
render(<HelpDocumentation />)
|
||||
|
||||
const link = screen.getByRole('link', { name: 'Documentation' })
|
||||
expect(link.getAttribute('href')).to.equal('/learn')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { screen } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import HelpMenu from '../../../../../frontend/js/features/editor-left-menu/components/help-menu'
|
||||
import { renderWithEditorContext } from '../../../helpers/render-with-context'
|
||||
|
||||
describe('<HelpMenu />', function () {
|
||||
beforeEach(function () {
|
||||
window.metaAttributesCache.set('ol-user', {
|
||||
email: 'sherlock@holmes.co.uk',
|
||||
first_name: 'Sherlock',
|
||||
last_name: 'Holmes',
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('shows correct menu if `showSupport` is `true`', function () {
|
||||
window.metaAttributesCache.set('ol-showSupport', true)
|
||||
|
||||
renderWithEditorContext(<HelpMenu />)
|
||||
|
||||
screen.getByRole('button', { name: 'Show Hotkeys' })
|
||||
screen.getByRole('button', { name: 'Contact Us' })
|
||||
screen.getByRole('link', { name: 'Documentation' })
|
||||
})
|
||||
|
||||
it('shows correct menu if `showSupport` is `false`', function () {
|
||||
window.metaAttributesCache.set('ol-showSupport', false)
|
||||
|
||||
renderWithEditorContext(<HelpMenu />)
|
||||
|
||||
screen.getByRole('button', { name: 'Show Hotkeys' })
|
||||
expect(screen.queryByRole('button', { name: 'Contact Us' })).to.equal(null)
|
||||
expect(screen.queryByRole('link', { name: 'Documentation' })).to.equal(null)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { expect } from 'chai'
|
||||
import { screen, fireEvent, within } from '@testing-library/react'
|
||||
import HelpShowHotkeys from '../../../../../frontend/js/features/editor-left-menu/components/help-show-hotkeys'
|
||||
import { renderWithEditorContext } from '../../../helpers/render-with-context'
|
||||
import fetchMock from 'fetch-mock'
|
||||
|
||||
describe('<HelpShowHotkeys />', function () {
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('open hotkeys modal when clicked', function () {
|
||||
renderWithEditorContext(<HelpShowHotkeys />)
|
||||
|
||||
expect(screen.queryByRole('dialog')).to.equal(null)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show Hotkeys' }))
|
||||
const modal = screen.getAllByRole('dialog')[0]
|
||||
within(modal).getByText('Common')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { screen, within, render } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import SettingsAutoCloseBrackets from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-auto-close-brackets'
|
||||
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
|
||||
import { EditorProviders } from '../../../../helpers/editor-providers'
|
||||
|
||||
describe('<SettingsAutoCloseBrackets />', function () {
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('shows correct menu', async function () {
|
||||
render(
|
||||
<EditorProviders>
|
||||
<EditorLeftMenuProvider>
|
||||
<SettingsAutoCloseBrackets />
|
||||
</EditorLeftMenuProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
const select = screen.getByLabelText('Auto-close brackets')
|
||||
|
||||
const optionOn = within(select).getByText('On')
|
||||
expect(optionOn.getAttribute('value')).to.equal('true')
|
||||
|
||||
const optionOff = within(select).getByText('Off')
|
||||
expect(optionOff.getAttribute('value')).to.equal('false')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { screen, within, render } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import SettingsAutoComplete from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-auto-complete'
|
||||
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
|
||||
import { EditorProviders } from '../../../../helpers/editor-providers'
|
||||
|
||||
describe('<SettingsAutoComplete />', function () {
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('shows correct menu', async function () {
|
||||
render(
|
||||
<EditorProviders>
|
||||
<EditorLeftMenuProvider>
|
||||
<SettingsAutoComplete />
|
||||
</EditorLeftMenuProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
const select = screen.getByLabelText('Auto-complete')
|
||||
|
||||
const optionOn = within(select).getByText('On')
|
||||
expect(optionOn.getAttribute('value')).to.equal('true')
|
||||
|
||||
const optionOff = within(select).getByText('Off')
|
||||
expect(optionOff.getAttribute('value')).to.equal('false')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { screen, within, render } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import SettingsCompiler from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-compiler'
|
||||
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
|
||||
import { EditorProviders } from '../../../../helpers/editor-providers'
|
||||
|
||||
describe('<SettingsCompiler />', function () {
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('shows correct menu', async function () {
|
||||
render(
|
||||
<EditorProviders>
|
||||
<EditorLeftMenuProvider>
|
||||
<SettingsCompiler />
|
||||
</EditorLeftMenuProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
const select = screen.getByLabelText('Compiler')
|
||||
|
||||
const optionPdfLaTeX = within(select).getByText('pdfLaTeX')
|
||||
expect(optionPdfLaTeX.getAttribute('value')).to.equal('pdflatex')
|
||||
|
||||
const optionLaTeX = within(select).getByText('LaTeX')
|
||||
expect(optionLaTeX.getAttribute('value')).to.equal('latex')
|
||||
|
||||
const optionXeLaTeX = within(select).getByText('XeLaTeX')
|
||||
expect(optionXeLaTeX.getAttribute('value')).to.equal('xelatex')
|
||||
|
||||
const optionLuaLaTeX = within(select).getByText('LuaLaTeX')
|
||||
expect(optionLuaLaTeX.getAttribute('value')).to.equal('lualatex')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { fireEvent, screen, within, render } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import SettingsDictionary from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-dictionary'
|
||||
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
|
||||
import { EditorProviders } from '../../../../helpers/editor-providers'
|
||||
|
||||
describe('<SettingsDictionary />', function () {
|
||||
it('open dictionary modal', function () {
|
||||
render(
|
||||
<EditorProviders>
|
||||
<EditorLeftMenuProvider>
|
||||
<SettingsDictionary />
|
||||
</EditorLeftMenuProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
screen.getByText('Dictionary')
|
||||
|
||||
const button = screen.getByText('Edit')
|
||||
fireEvent.click(button)
|
||||
|
||||
const modal = screen.getByTestId('dictionary-modal')
|
||||
|
||||
within(modal).getByRole('heading', { name: 'Edit Dictionary' })
|
||||
within(modal).getByText('Your custom dictionary is empty.')
|
||||
|
||||
const [, closeButton] = within(modal).getAllByRole('button', {
|
||||
name: 'Close',
|
||||
})
|
||||
fireEvent.click(closeButton)
|
||||
expect(screen.getByTestId('dictionary-modal')).to.not.be.null
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import { screen, within, render } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import SettingsDocument from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-document'
|
||||
import { Folder } from '../../../../../../types/folder'
|
||||
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
|
||||
import { EditorProviders } from '../../../../helpers/editor-providers'
|
||||
|
||||
describe('<SettingsDocument />', function () {
|
||||
const rootFolder: Folder = {
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [
|
||||
{
|
||||
_id: '123abc',
|
||||
name: 'main.tex',
|
||||
},
|
||||
],
|
||||
fileRefs: [],
|
||||
folders: [],
|
||||
}
|
||||
|
||||
let originalSettings: typeof window.metaAttributesCache
|
||||
|
||||
beforeEach(function () {
|
||||
originalSettings = window.metaAttributesCache.get('ol-ExposedSettings')
|
||||
window.metaAttributesCache.set('ol-ExposedSettings', {
|
||||
validRootDocExtensions: ['tex'],
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
window.metaAttributesCache.set('ol-ExposedSettings', originalSettings)
|
||||
})
|
||||
|
||||
it('shows correct menu', async function () {
|
||||
render(
|
||||
<EditorProviders rootFolder={[rootFolder as any]}>
|
||||
<EditorLeftMenuProvider>
|
||||
<SettingsDocument />
|
||||
</EditorLeftMenuProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
const select = screen.getByLabelText('Main document')
|
||||
|
||||
const optionOn = within(select).getByText('main.tex')
|
||||
expect(optionOn.getAttribute('value')).to.equal('123abc')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { screen, within, render } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import SettingsEditorTheme from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-editor-theme'
|
||||
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
|
||||
import { EditorProviders } from '../../../../helpers/editor-providers'
|
||||
|
||||
describe('<SettingsEditorTheme />', function () {
|
||||
const editorThemes = ['editortheme-1', 'editortheme-2', 'editortheme-3']
|
||||
|
||||
const legacyEditorThemes = ['legacytheme-1', 'legacytheme-2', 'legacytheme-3']
|
||||
|
||||
beforeEach(function () {
|
||||
window.metaAttributesCache.set('ol-editorThemes', editorThemes)
|
||||
window.metaAttributesCache.set('ol-legacyEditorThemes', legacyEditorThemes)
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('shows correct menu', async function () {
|
||||
render(
|
||||
<EditorProviders>
|
||||
<EditorLeftMenuProvider>
|
||||
<SettingsEditorTheme />
|
||||
</EditorLeftMenuProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
const select = screen.getByLabelText('Editor theme')
|
||||
|
||||
for (const theme of editorThemes) {
|
||||
const option = within(select).getByText(theme.replace(/_/g, ' '))
|
||||
expect(option.getAttribute('value')).to.equal(theme)
|
||||
}
|
||||
|
||||
for (const theme of legacyEditorThemes) {
|
||||
const option = within(select).getByText(
|
||||
theme.replace(/_/g, ' ') + ' (Legacy)'
|
||||
)
|
||||
expect(option.getAttribute('value')).to.equal(theme)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { screen, within, render } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import SettingsFontFamily from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-font-family'
|
||||
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
|
||||
import { EditorProviders } from '../../../../helpers/editor-providers'
|
||||
|
||||
describe('<SettingsFontFamily />', function () {
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('shows correct menu', async function () {
|
||||
render(
|
||||
<EditorProviders>
|
||||
<EditorLeftMenuProvider>
|
||||
<SettingsFontFamily />
|
||||
</EditorLeftMenuProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
const select = screen.getByLabelText('Font Family')
|
||||
|
||||
const optionMonaco = within(select).getByText('Monaco / Menlo / Consolas')
|
||||
expect(optionMonaco.getAttribute('value')).to.equal('monaco')
|
||||
|
||||
const optionLucida = within(select).getByText('Lucida / Source Code Pro')
|
||||
expect(optionLucida.getAttribute('value')).to.equal('lucida')
|
||||
|
||||
const optionOpenDyslexicMono = within(select).getByText('OpenDyslexic Mono')
|
||||
expect(optionOpenDyslexicMono.getAttribute('value')).to.equal(
|
||||
'opendyslexicmono'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { screen, within, render } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import SettingsFontSize from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-font-size'
|
||||
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
|
||||
import { EditorProviders } from '../../../../helpers/editor-providers'
|
||||
|
||||
describe('<SettingsFontSize />', function () {
|
||||
const sizes = ['10', '11', '12', '13', '14', '16', '18', '20', '22', '24']
|
||||
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('shows correct menu', async function () {
|
||||
render(
|
||||
<EditorProviders>
|
||||
<EditorLeftMenuProvider>
|
||||
<SettingsFontSize />
|
||||
</EditorLeftMenuProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
const select = screen.getByLabelText('Font Size')
|
||||
|
||||
for (const size of sizes) {
|
||||
const option = within(select).getByText(`${size}px`)
|
||||
expect(option.getAttribute('value')).to.equal(size)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { screen, within, render } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import SettingsImageName from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-image-name'
|
||||
import type { AllowedImageName } from '../../../../../../types/project-settings'
|
||||
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
|
||||
import { EditorProviders } from '../../../../helpers/editor-providers'
|
||||
|
||||
describe('<SettingsImageName />', function () {
|
||||
const allowedImageNames: AllowedImageName[] = [
|
||||
{
|
||||
imageDesc: 'Image 1',
|
||||
imageName: 'img-1',
|
||||
},
|
||||
{
|
||||
imageDesc: 'Image 2',
|
||||
imageName: 'img-2',
|
||||
},
|
||||
]
|
||||
|
||||
beforeEach(function () {
|
||||
window.metaAttributesCache.set('ol-allowedImageNames', allowedImageNames)
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('shows correct menu', async function () {
|
||||
render(
|
||||
<EditorProviders>
|
||||
<EditorLeftMenuProvider>
|
||||
<SettingsImageName />
|
||||
</EditorLeftMenuProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
const select = screen.getByLabelText('TeX Live version')
|
||||
|
||||
for (const { imageName, imageDesc } of allowedImageNames) {
|
||||
const option = within(select).getByText(imageDesc)
|
||||
expect(option.getAttribute('value')).to.equal(imageName)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { screen, within, render } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import SettingsKeybindings from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-keybindings'
|
||||
import { EditorProviders } from '../../../../helpers/editor-providers'
|
||||
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
|
||||
|
||||
describe('<SettingsKeybindings />', function () {
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('shows correct menu', async function () {
|
||||
render(
|
||||
<EditorProviders>
|
||||
<EditorLeftMenuProvider>
|
||||
<SettingsKeybindings />
|
||||
</EditorLeftMenuProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
const select = screen.getByLabelText('Keybindings')
|
||||
|
||||
const optionNone = within(select).getByText('None')
|
||||
expect(optionNone.getAttribute('value')).to.equal('default')
|
||||
|
||||
const optionVim = within(select).getByText('Vim')
|
||||
expect(optionVim.getAttribute('value')).to.equal('vim')
|
||||
|
||||
const optionEmacs = within(select).getByText('Emacs')
|
||||
expect(optionEmacs.getAttribute('value')).to.equal('emacs')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { screen, within, render } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import SettingsLineHeight from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-line-height'
|
||||
import { EditorProviders } from '../../../../helpers/editor-providers'
|
||||
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
|
||||
|
||||
describe('<SettingsLineHeight />', function () {
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('shows correct menu', async function () {
|
||||
render(
|
||||
<EditorProviders>
|
||||
<EditorLeftMenuProvider>
|
||||
<SettingsLineHeight />
|
||||
</EditorLeftMenuProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
const select = screen.getByLabelText('Line Height')
|
||||
|
||||
const optionCompact = within(select).getByText('Compact')
|
||||
expect(optionCompact.getAttribute('value')).to.equal('compact')
|
||||
|
||||
const optionNormal = within(select).getByText('Normal')
|
||||
expect(optionNormal.getAttribute('value')).to.equal('normal')
|
||||
|
||||
const optionWide = within(select).getByText('Wide')
|
||||
expect(optionWide.getAttribute('value')).to.equal('wide')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { screen, within, render } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import SettingsMathPreview from '@/features/editor-left-menu/components/settings/settings-math-preview'
|
||||
import { EditorProviders } from '../../../../helpers/editor-providers'
|
||||
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
|
||||
|
||||
describe('<SettingsMathPreview />', function () {
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('shows correct menu', async function () {
|
||||
render(
|
||||
<EditorProviders>
|
||||
<EditorLeftMenuProvider>
|
||||
<SettingsMathPreview />
|
||||
</EditorLeftMenuProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
const select = screen.getByLabelText('Equation preview')
|
||||
|
||||
const optionOn = within(select).getByText('On')
|
||||
expect(optionOn.getAttribute('value')).to.equal('true')
|
||||
|
||||
const optionOff = within(select).getByText('Off')
|
||||
expect(optionOff.getAttribute('value')).to.equal('false')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
import { screen, within, render } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import SettingsOverallTheme from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-overall-theme'
|
||||
import type { OverallThemeMeta } from '../../../../../../types/project-settings'
|
||||
import getMeta from '@/utils/meta'
|
||||
import { EditorProviders } from '../../../../helpers/editor-providers'
|
||||
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
|
||||
|
||||
const IEEE_BRAND_ID = 1234
|
||||
const OTHER_BRAND_ID = 2234
|
||||
|
||||
describe('<SettingsOverallTheme />', function () {
|
||||
const overallThemes: OverallThemeMeta[] = [
|
||||
{
|
||||
name: 'Overall Theme 1',
|
||||
val: '',
|
||||
path: 'https://overleaf.com/overalltheme-1.css',
|
||||
},
|
||||
{
|
||||
name: 'Overall Theme 2',
|
||||
val: 'light-',
|
||||
path: 'https://overleaf.com/overalltheme-2.css',
|
||||
},
|
||||
]
|
||||
|
||||
beforeEach(function () {
|
||||
window.metaAttributesCache.set('ol-overallThemes', overallThemes)
|
||||
Object.assign(getMeta('ol-ExposedSettings'), {
|
||||
ieeeBrandId: IEEE_BRAND_ID,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('shows correct menu', async function () {
|
||||
render(
|
||||
<EditorProviders>
|
||||
<EditorLeftMenuProvider>
|
||||
<SettingsOverallTheme />
|
||||
</EditorLeftMenuProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
const select = screen.getByLabelText('Overall theme')
|
||||
|
||||
for (const theme of overallThemes) {
|
||||
const option = within(select).getByText(theme.name)
|
||||
expect(option.getAttribute('value')).to.equal(theme.val)
|
||||
}
|
||||
})
|
||||
describe('Branded Project', function () {
|
||||
it('should hide overall theme picker for IEEE branded projects', function () {
|
||||
window.metaAttributesCache.set('ol-brandVariation', {
|
||||
brand_id: IEEE_BRAND_ID,
|
||||
})
|
||||
render(
|
||||
<EditorProviders>
|
||||
<EditorLeftMenuProvider>
|
||||
<SettingsOverallTheme />
|
||||
</EditorLeftMenuProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
const select = screen.queryByText('Overall theme')
|
||||
expect(select).to.not.exist
|
||||
})
|
||||
|
||||
it('should show overall theme picker for branded projects that are not IEEE', function () {
|
||||
window.metaAttributesCache.set('ol-brandVariation', {
|
||||
brand_id: OTHER_BRAND_ID,
|
||||
})
|
||||
render(
|
||||
<EditorProviders>
|
||||
<EditorLeftMenuProvider>
|
||||
<SettingsOverallTheme />
|
||||
</EditorLeftMenuProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
const select = screen.getByLabelText('Overall theme')
|
||||
expect(select).to.exist
|
||||
})
|
||||
|
||||
it('should show overall theme picker for non branded projects', function () {
|
||||
window.metaAttributesCache.set('ol-brandVariation', undefined)
|
||||
render(
|
||||
<EditorProviders>
|
||||
<EditorLeftMenuProvider>
|
||||
<SettingsOverallTheme />
|
||||
</EditorLeftMenuProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
const select = screen.getByLabelText('Overall theme')
|
||||
expect(select).to.exist
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { screen, within, render } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import SettingsPdfViewer from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-pdf-viewer'
|
||||
import { EditorProviders } from '../../../../helpers/editor-providers'
|
||||
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
|
||||
|
||||
describe('<SettingsPdfViewer />', function () {
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('shows correct menu', async function () {
|
||||
render(
|
||||
<EditorProviders>
|
||||
<EditorLeftMenuProvider>
|
||||
<SettingsPdfViewer />
|
||||
</EditorLeftMenuProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
const select = screen.getByLabelText('PDF Viewer')
|
||||
|
||||
const optionOverleaf = within(select).getByText('Overleaf')
|
||||
expect(optionOverleaf.getAttribute('value')).to.equal('pdfjs')
|
||||
|
||||
const optionBrowser = within(select).getByText('Browser')
|
||||
expect(optionBrowser.getAttribute('value')).to.equal('native')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { screen, within, render } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import SettingsSpellCheckLanguage from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-spell-check-language'
|
||||
import type { SpellCheckLanguage } from '../../../../../../types/project-settings'
|
||||
import { EditorProviders } from '../../../../helpers/editor-providers'
|
||||
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
|
||||
|
||||
describe('<SettingsSpellCheckLanguage />', function () {
|
||||
const languages: SpellCheckLanguage[] = [
|
||||
{
|
||||
name: 'Lang 1',
|
||||
code: 'lang-1',
|
||||
dic: 'lang_1',
|
||||
},
|
||||
{
|
||||
name: 'Lang 2',
|
||||
code: 'lang-2',
|
||||
dic: 'lang_2',
|
||||
},
|
||||
]
|
||||
|
||||
beforeEach(function () {
|
||||
window.metaAttributesCache.set('ol-languages', languages)
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('shows correct menu', async function () {
|
||||
render(
|
||||
<EditorProviders>
|
||||
<EditorLeftMenuProvider>
|
||||
<SettingsSpellCheckLanguage />
|
||||
</EditorLeftMenuProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
const select = screen.getByLabelText('Spell check')
|
||||
|
||||
const optionEmpty = within(select).getByText('Off')
|
||||
expect(optionEmpty.getAttribute('value')).to.equal('')
|
||||
|
||||
for (const language of languages) {
|
||||
const option = within(select).getByText(language.name)
|
||||
expect(option.getAttribute('value')).to.equal(language.code)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { screen, within, render } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import SettingsSyntaxValidation from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-syntax-validation'
|
||||
import { EditorProviders } from '../../../../helpers/editor-providers'
|
||||
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
|
||||
|
||||
describe('<SettingsSyntaxValidation />', function () {
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('shows correct menu', async function () {
|
||||
render(
|
||||
<EditorProviders>
|
||||
<EditorLeftMenuProvider>
|
||||
<SettingsSyntaxValidation />
|
||||
</EditorLeftMenuProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
const select = screen.getByLabelText('Code check')
|
||||
|
||||
const optionOn = within(select).getByText('On')
|
||||
expect(optionOn.getAttribute('value')).to.equal('true')
|
||||
|
||||
const optionOff = within(select).getByText('Off')
|
||||
expect(optionOff.getAttribute('value')).to.equal('false')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { expect } from 'chai'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
|
||||
import ChatToggleButton from '../../../../../frontend/js/features/editor-navigation-toolbar/components/chat-toggle-button'
|
||||
|
||||
describe('<ChatToggleButton />', function () {
|
||||
const defaultProps = {
|
||||
chatIsOpen: false,
|
||||
unreadMessageCount: 0,
|
||||
onClick: () => {},
|
||||
}
|
||||
|
||||
it('displays the number of unread messages', function () {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
unreadMessageCount: 113,
|
||||
}
|
||||
render(<ChatToggleButton {...props} />)
|
||||
screen.getByText('113')
|
||||
})
|
||||
|
||||
it("doesn't display the unread messages badge when the number of unread messages is zero", function () {
|
||||
render(<ChatToggleButton {...defaultProps} />)
|
||||
expect(screen.queryByText('0')).to.not.exist
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,219 @@
|
||||
import sinon from 'sinon'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import { expect } from 'chai'
|
||||
import { screen } from '@testing-library/react'
|
||||
import LayoutDropdownButton from '../../../../../frontend/js/features/editor-navigation-toolbar/components/layout-dropdown-button'
|
||||
import { renderWithEditorContext } from '../../../helpers/render-with-context'
|
||||
import * as eventTracking from '@/infrastructure/event-tracking'
|
||||
|
||||
describe('<LayoutDropdownButton />', function () {
|
||||
let openStub
|
||||
let sendMBSpy
|
||||
const defaultUi = {
|
||||
pdfLayout: 'flat',
|
||||
view: 'pdf',
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
openStub = sinon.stub(window, 'open')
|
||||
sendMBSpy = sinon.spy(eventTracking, 'sendMB')
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
openStub.restore()
|
||||
sendMBSpy.restore()
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('should mark current layout option as selected', async function () {
|
||||
// Selected is aria-label, visually we show a checkmark
|
||||
renderWithEditorContext(<LayoutDropdownButton />, { ui: defaultUi })
|
||||
|
||||
screen.getByRole('button', { name: 'Layout' }).click()
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getByRole('menuitem', {
|
||||
name: 'Editor & PDF',
|
||||
})
|
||||
.getAttribute('aria-selected')
|
||||
).to.equal('false')
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getByRole('menuitem', {
|
||||
name: 'PDF only (hide editor)',
|
||||
})
|
||||
.getAttribute('aria-selected')
|
||||
).to.equal('true')
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getByRole('menuitem', {
|
||||
name: 'Editor only (hide PDF)',
|
||||
})
|
||||
.getAttribute('aria-selected')
|
||||
).to.equal('false')
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getByRole('menuitem', {
|
||||
name: 'PDF in separate tab',
|
||||
})
|
||||
.getAttribute('aria-selected')
|
||||
).to.equal('false')
|
||||
})
|
||||
|
||||
it('should not select any option in history view', function () {
|
||||
// Selected is aria-label, visually we show a checkmark
|
||||
renderWithEditorContext(<LayoutDropdownButton />, {
|
||||
ui: { ...defaultUi, view: 'history' },
|
||||
})
|
||||
|
||||
screen.getByRole('button', { name: 'Layout' }).click()
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getByRole('menuitem', {
|
||||
name: 'Editor & PDF',
|
||||
})
|
||||
.getAttribute('aria-selected')
|
||||
).to.equal('false')
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getByRole('menuitem', {
|
||||
name: 'PDF only (hide editor)',
|
||||
})
|
||||
.getAttribute('aria-selected')
|
||||
).to.equal('false')
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getByRole('menuitem', {
|
||||
name: 'Editor only (hide PDF)',
|
||||
})
|
||||
.getAttribute('aria-selected')
|
||||
).to.equal('false')
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getByRole('menuitem', {
|
||||
name: 'PDF in separate tab',
|
||||
})
|
||||
.getAttribute('aria-selected')
|
||||
).to.equal('false')
|
||||
})
|
||||
|
||||
it('should treat file and editor views the same way', function () {
|
||||
// Selected is aria-label, visually we show a checkmark
|
||||
renderWithEditorContext(<LayoutDropdownButton />, {
|
||||
ui: {
|
||||
pdfLayout: 'flat',
|
||||
view: 'file',
|
||||
},
|
||||
})
|
||||
|
||||
screen.getByRole('button', { name: 'Layout' }).click()
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getByRole('menuitem', {
|
||||
name: 'Editor & PDF',
|
||||
})
|
||||
.getAttribute('aria-selected')
|
||||
).to.equal('false')
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getByRole('menuitem', {
|
||||
name: 'PDF only (hide editor)',
|
||||
})
|
||||
.getAttribute('aria-selected')
|
||||
).to.equal('false')
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getByRole('menuitem', {
|
||||
name: 'Editor only (hide PDF)',
|
||||
})
|
||||
.getAttribute('aria-selected')
|
||||
).to.equal('true')
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getByRole('menuitem', {
|
||||
name: 'PDF in separate tab',
|
||||
})
|
||||
.getAttribute('aria-selected')
|
||||
).to.equal('false')
|
||||
})
|
||||
|
||||
describe('on detach', function () {
|
||||
let originalBroadcastChannel
|
||||
beforeEach(function () {
|
||||
window.BroadcastChannel = originalBroadcastChannel || true // ensure that window.BroadcastChannel is truthy
|
||||
|
||||
renderWithEditorContext(<LayoutDropdownButton />, {
|
||||
ui: { ...defaultUi, view: 'editor' },
|
||||
})
|
||||
|
||||
screen.getByRole('button', { name: 'Layout' }).click()
|
||||
|
||||
screen
|
||||
.getByRole('menuitem', {
|
||||
name: 'PDF in separate tab',
|
||||
})
|
||||
.click()
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
window.BroadcastChannel = originalBroadcastChannel
|
||||
})
|
||||
|
||||
it('should show processing', function () {
|
||||
screen.getByText('Layout processing')
|
||||
})
|
||||
|
||||
it('should record event', function () {
|
||||
sinon.assert.calledWith(sendMBSpy, 'project-layout-detach')
|
||||
})
|
||||
})
|
||||
|
||||
describe('on layout change / reattach', function () {
|
||||
beforeEach(function () {
|
||||
window.metaAttributesCache.set('ol-detachRole', 'detacher')
|
||||
renderWithEditorContext(<LayoutDropdownButton />, {
|
||||
ui: { ...defaultUi, view: 'editor' },
|
||||
})
|
||||
|
||||
screen.getByRole('button', { name: 'Layout' }).click()
|
||||
|
||||
screen
|
||||
.getByRole('menuitem', {
|
||||
name: 'Editor only (hide PDF)',
|
||||
})
|
||||
.click()
|
||||
})
|
||||
|
||||
it('should not show processing', function () {
|
||||
const processingText = screen.queryByText('Layout processing')
|
||||
expect(processingText).to.not.exist
|
||||
})
|
||||
|
||||
it('should record events', function () {
|
||||
sinon.assert.calledWith(sendMBSpy, 'project-layout-reattach')
|
||||
sinon.assert.calledWith(sendMBSpy, 'project-layout-change', {
|
||||
layout: 'flat',
|
||||
view: 'editor',
|
||||
page: '/detacher',
|
||||
})
|
||||
})
|
||||
|
||||
it('should select new menu item', function () {
|
||||
screen.getByRole('menuitem', {
|
||||
name: 'Editor only (hide PDF)',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
import { expect } from 'chai'
|
||||
import sinon from 'sinon'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
|
||||
import OnlineUsersWidget from '../../../../../frontend/js/features/editor-navigation-toolbar/components/online-users-widget'
|
||||
|
||||
describe('<OnlineUsersWidget />', function () {
|
||||
const defaultProps = {
|
||||
onlineUsers: [
|
||||
{
|
||||
user_id: 'test_user',
|
||||
name: 'test_user',
|
||||
},
|
||||
{
|
||||
user_id: 'another_test_user',
|
||||
name: 'another_test_user',
|
||||
},
|
||||
],
|
||||
goToUser: () => {},
|
||||
}
|
||||
|
||||
describe('with less than 4 users', function () {
|
||||
it('displays user initials', function () {
|
||||
render(<OnlineUsersWidget {...defaultProps} />)
|
||||
screen.getByText('t')
|
||||
screen.getByText('a')
|
||||
})
|
||||
|
||||
it('displays user name in a tooltip', async function () {
|
||||
render(<OnlineUsersWidget {...defaultProps} />)
|
||||
const icon = screen.getByText('t')
|
||||
fireEvent.mouseOver(icon)
|
||||
await screen.findByRole('tooltip', { name: 'test_user' })
|
||||
})
|
||||
|
||||
it('calls "goToUser" when the user initial is clicked', function () {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
goToUser: sinon.stub(),
|
||||
}
|
||||
render(<OnlineUsersWidget {...props} />)
|
||||
|
||||
const icon = screen.getByText('t')
|
||||
fireEvent.click(icon)
|
||||
|
||||
expect(props.goToUser).to.be.calledWith({
|
||||
name: 'test_user',
|
||||
user_id: 'test_user',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with 4 users and more', function () {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
onlineUsers: defaultProps.onlineUsers.concat([
|
||||
{
|
||||
user_id: 'user_3',
|
||||
name: 'user_3',
|
||||
},
|
||||
{
|
||||
user_id: 'user_4',
|
||||
name: 'user_4',
|
||||
},
|
||||
]),
|
||||
}
|
||||
|
||||
it('displays the count of users', function () {
|
||||
render(<OnlineUsersWidget {...props} />)
|
||||
screen.getByText('4')
|
||||
})
|
||||
|
||||
it('displays user names on hover', function () {
|
||||
render(<OnlineUsersWidget {...props} />)
|
||||
|
||||
const toggleButton = screen.getByRole('button')
|
||||
fireEvent.click(toggleButton)
|
||||
|
||||
screen.getByText('test_user')
|
||||
screen.getByText('another_test_user')
|
||||
screen.getByText('user_3')
|
||||
screen.getByText('user_4')
|
||||
})
|
||||
|
||||
it('calls "goToUser" when the user name is clicked', function () {
|
||||
const testProps = {
|
||||
...props,
|
||||
goToUser: sinon.stub(),
|
||||
}
|
||||
render(<OnlineUsersWidget {...testProps} />)
|
||||
|
||||
const toggleButton = screen.getByRole('button')
|
||||
fireEvent.click(toggleButton)
|
||||
|
||||
const icon = screen.getByText('user_3')
|
||||
fireEvent.click(icon)
|
||||
|
||||
expect(testProps.goToUser).to.be.calledWith({
|
||||
name: 'user_3',
|
||||
user_id: 'user_3',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { expect } from 'chai'
|
||||
import sinon from 'sinon'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
|
||||
import ProjectNameEditableLabel from '../../../../../frontend/js/features/editor-navigation-toolbar/components/project-name-editable-label'
|
||||
|
||||
describe('<ProjectNameEditableLabel />', function () {
|
||||
const defaultProps = { projectName: 'test-project', onChange: () => {} }
|
||||
|
||||
it('displays the project name', function () {
|
||||
render(<ProjectNameEditableLabel {...defaultProps} />)
|
||||
screen.getByText('test-project')
|
||||
})
|
||||
|
||||
describe('when the name is editable', function () {
|
||||
const editableProps = { ...defaultProps, hasRenamePermissions: true }
|
||||
|
||||
it('displays an editable input when the edit button is clicked', function () {
|
||||
render(<ProjectNameEditableLabel {...editableProps} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
screen.getByRole('textbox')
|
||||
})
|
||||
|
||||
it('displays an editable input when the project name is double clicked', function () {
|
||||
render(<ProjectNameEditableLabel {...editableProps} />)
|
||||
fireEvent.doubleClick(screen.getByText('test-project'))
|
||||
screen.getByRole('textbox')
|
||||
})
|
||||
|
||||
it('calls "onChange" when the project name is updated', function () {
|
||||
const props = {
|
||||
...editableProps,
|
||||
onChange: sinon.stub(),
|
||||
}
|
||||
render(<ProjectNameEditableLabel {...props} />)
|
||||
|
||||
fireEvent.doubleClick(screen.getByText('test-project'))
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'new project name' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
expect(props.onChange).to.be.calledWith('new project name')
|
||||
})
|
||||
|
||||
it('calls "onChange" when the input loses focus', function () {
|
||||
const props = {
|
||||
...editableProps,
|
||||
onChange: sinon.stub(),
|
||||
}
|
||||
render(<ProjectNameEditableLabel {...props} />)
|
||||
|
||||
fireEvent.doubleClick(screen.getByText('test-project'))
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'new project name' } })
|
||||
|
||||
fireEvent.blur(screen.getByRole('textbox'))
|
||||
|
||||
expect(props.onChange).to.be.calledWith('new project name')
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the name is not editable', function () {
|
||||
const nonEditableProps = { hasRenamePermissions: false, ...defaultProps }
|
||||
|
||||
it('the edit button is not displayed', function () {
|
||||
render(<ProjectNameEditableLabel {...nonEditableProps} />)
|
||||
expect(screen.queryByRole('button')).to.not.exist
|
||||
})
|
||||
|
||||
it('does not display an editable input when the project name is double clicked', function () {
|
||||
render(<ProjectNameEditableLabel {...nonEditableProps} />)
|
||||
fireEvent.doubleClick(screen.getByText('test-project'))
|
||||
expect(screen.queryByRole('textbox')).to.not.exist
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
import { expect } from 'chai'
|
||||
import { screen } from '@testing-library/react'
|
||||
|
||||
import ToolbarHeader from '../../../../../frontend/js/features/editor-navigation-toolbar/components/toolbar-header'
|
||||
import { renderWithEditorContext } from '../../../helpers/render-with-context'
|
||||
|
||||
describe('<ToolbarHeader />', function () {
|
||||
const defaultProps = {
|
||||
onShowLeftMenuClick: () => {},
|
||||
toggleChatOpen: () => {},
|
||||
toggleReviewPanelOpen: () => {},
|
||||
toggleHistoryOpen: () => {},
|
||||
unreadMessageCount: 0,
|
||||
onlineUsers: [],
|
||||
goToUser: () => {},
|
||||
projectName: 'test project',
|
||||
renameProject: () => {},
|
||||
openShareModal: () => {},
|
||||
hasPublishPermissions: true,
|
||||
chatVisible: true,
|
||||
trackChangesVisible: true,
|
||||
handleChangeLayout: () => {},
|
||||
pdfLayout: 'sideBySide',
|
||||
view: 'editor',
|
||||
reattach: () => {},
|
||||
detach: () => {},
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
window.metaAttributesCache.set('ol-chatEnabled', true)
|
||||
})
|
||||
|
||||
describe('cobranding logo', function () {
|
||||
it('is not displayed by default', function () {
|
||||
renderWithEditorContext(<ToolbarHeader {...defaultProps} />)
|
||||
expect(screen.queryByRole('link', { name: 'variation' })).to.not.exist
|
||||
})
|
||||
|
||||
it('is displayed when cobranding data is available', function () {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
cobranding: {
|
||||
brandVariationHomeUrl: 'http://cobranding',
|
||||
brandVariationName: 'variation',
|
||||
logoImgUrl: 'http://cobranding/logo',
|
||||
},
|
||||
}
|
||||
renderWithEditorContext(<ToolbarHeader {...props} />)
|
||||
screen.getByRole('link', { name: 'variation' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('track changes toggle button', function () {
|
||||
it('is displayed by default', function () {
|
||||
renderWithEditorContext(<ToolbarHeader {...defaultProps} />)
|
||||
screen.getByText('Review')
|
||||
})
|
||||
|
||||
it('is not displayed when "trackChangesVisible" prop is set to false', function () {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
trackChangesVisible: false,
|
||||
}
|
||||
renderWithEditorContext(<ToolbarHeader {...props} />)
|
||||
expect(screen.queryByText('Review')).to.not.exist
|
||||
})
|
||||
})
|
||||
|
||||
describe('History toggle button', function () {
|
||||
it('is displayed by default', function () {
|
||||
renderWithEditorContext(<ToolbarHeader {...defaultProps} />)
|
||||
screen.getByText('History')
|
||||
})
|
||||
|
||||
it('is not displayed when "isRestrictedTokenMember" prop is set to true', function () {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
isRestrictedTokenMember: true,
|
||||
}
|
||||
renderWithEditorContext(<ToolbarHeader {...props} />)
|
||||
expect(screen.queryByText('History')).to.not.exist
|
||||
})
|
||||
})
|
||||
|
||||
describe('Chat toggle button', function () {
|
||||
it('is displayed by default', function () {
|
||||
renderWithEditorContext(<ToolbarHeader {...defaultProps} />)
|
||||
screen.getByText('Chat')
|
||||
})
|
||||
|
||||
it('is not displayed when "chatVisible" prop is set to false', function () {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
chatVisible: false,
|
||||
}
|
||||
renderWithEditorContext(<ToolbarHeader {...props} />)
|
||||
expect(screen.queryByText('Chat')).to.not.exist
|
||||
})
|
||||
})
|
||||
|
||||
describe('Publish button', function () {
|
||||
it('is displayed by default', function () {
|
||||
renderWithEditorContext(<ToolbarHeader {...defaultProps} />)
|
||||
screen.getByText('Submit')
|
||||
})
|
||||
|
||||
it('is not displayed for users with no publish permissions', function () {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
hasPublishPermissions: false,
|
||||
}
|
||||
renderWithEditorContext(<ToolbarHeader {...props} />)
|
||||
expect(screen.queryByText('Submit')).to.not.exist
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
import FileTreeCreateNameInput from '../../../../../../frontend/js/features/file-tree/components/file-tree-create/file-tree-create-name-input'
|
||||
import FileTreeCreateNameProvider from '../../../../../../frontend/js/features/file-tree/contexts/file-tree-create-name'
|
||||
|
||||
describe('<FileTreeCreateNameInput/>', function () {
|
||||
it('renders an empty input', function () {
|
||||
cy.mount(
|
||||
<FileTreeCreateNameProvider>
|
||||
<FileTreeCreateNameInput inFlight={false} />
|
||||
</FileTreeCreateNameProvider>
|
||||
)
|
||||
|
||||
cy.findByLabelText('File Name')
|
||||
cy.findByPlaceholderText('File Name')
|
||||
})
|
||||
|
||||
it('renders a custom label and placeholder', function () {
|
||||
cy.mount(
|
||||
<FileTreeCreateNameProvider>
|
||||
<FileTreeCreateNameInput
|
||||
label="File name in this project"
|
||||
placeholder="Enter a file name…"
|
||||
inFlight={false}
|
||||
/>
|
||||
</FileTreeCreateNameProvider>
|
||||
)
|
||||
|
||||
cy.findByLabelText('File name in this project')
|
||||
cy.findByPlaceholderText('Enter a file name…')
|
||||
})
|
||||
|
||||
it('uses an initial name', function () {
|
||||
cy.mount(
|
||||
<FileTreeCreateNameProvider initialName="test.tex">
|
||||
<FileTreeCreateNameInput inFlight={false} />
|
||||
</FileTreeCreateNameProvider>
|
||||
)
|
||||
|
||||
cy.findByLabelText('File Name').should('have.value', 'test.tex')
|
||||
})
|
||||
|
||||
it('focuses the name', function () {
|
||||
cy.spy(window, 'requestAnimationFrame').as('requestAnimationFrame')
|
||||
|
||||
cy.mount(
|
||||
<FileTreeCreateNameProvider initialName="test.tex">
|
||||
<FileTreeCreateNameInput focusName inFlight={false} />
|
||||
</FileTreeCreateNameProvider>
|
||||
)
|
||||
|
||||
cy.findByLabelText('File Name').as('input')
|
||||
|
||||
cy.get('@input').should('have.value', 'test.tex')
|
||||
|
||||
cy.get('@requestAnimationFrame').should('have.been.calledOnce')
|
||||
|
||||
// https://github.com/jsdom/jsdom/issues/2995
|
||||
// "window.getSelection doesn't work with selection of <input> element"
|
||||
// const selection = window.getSelection().toString()
|
||||
// expect(selection).to.equal('test')
|
||||
|
||||
// wait for the selection to update
|
||||
// eslint-disable-next-line cypress/no-unnecessary-waiting
|
||||
cy.wait(100)
|
||||
|
||||
cy.get<HTMLInputElement>('@input').then(element => {
|
||||
expect(element.get(0).selectionStart).to.equal(0)
|
||||
expect(element.get(0).selectionEnd).to.equal(4)
|
||||
})
|
||||
})
|
||||
|
||||
it('disables the input when in flight', function () {
|
||||
cy.mount(
|
||||
<FileTreeCreateNameProvider initialName="test.tex">
|
||||
<FileTreeCreateNameInput inFlight={false} />
|
||||
</FileTreeCreateNameProvider>
|
||||
).then(({ rerender }) => {
|
||||
cy.findByLabelText('File Name').should('not.be.disabled')
|
||||
rerender(
|
||||
<FileTreeCreateNameProvider initialName="test.tex">
|
||||
<FileTreeCreateNameInput inFlight />
|
||||
</FileTreeCreateNameProvider>
|
||||
)
|
||||
cy.findByLabelText('File Name').should('be.disabled')
|
||||
rerender(
|
||||
<FileTreeCreateNameProvider initialName="test.tex">
|
||||
<FileTreeCreateNameInput inFlight={false} />
|
||||
</FileTreeCreateNameProvider>
|
||||
)
|
||||
cy.findByLabelText('File Name').should('not.be.disabled')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,497 @@
|
||||
import { useEffect } from 'react'
|
||||
import FileTreeModalCreateFile from '../../../../../../frontend/js/features/file-tree/components/modals/file-tree-modal-create-file'
|
||||
import { useFileTreeActionable } from '../../../../../../frontend/js/features/file-tree/contexts/file-tree-actionable'
|
||||
import { useFileTreeData } from '../../../../../../frontend/js/shared/context/file-tree-data-context'
|
||||
import { EditorProviders } from '../../../../helpers/editor-providers'
|
||||
import { FileTreeProvider } from '../../helpers/file-tree-provider'
|
||||
import getMeta from '@/utils/meta'
|
||||
|
||||
describe('<FileTreeModalCreateFile/>', function () {
|
||||
it('handles invalid file names', function () {
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<FileTreeProvider>
|
||||
<OpenWithMode mode="doc" />
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByLabelText('File Name').as('input')
|
||||
cy.findByRole('button', { name: 'Create' }).as('submit')
|
||||
|
||||
cy.get('@input').should('have.value', 'name.tex')
|
||||
cy.get('@submit').should('not.be.disabled')
|
||||
cy.findByRole('alert').should('not.exist')
|
||||
|
||||
cy.get('@input').clear()
|
||||
cy.get('@submit').should('be.disabled')
|
||||
cy.findByRole('alert').should('contain.text', 'File name is empty')
|
||||
|
||||
cy.get('@input').type('test.tex')
|
||||
cy.get('@submit').should('not.be.disabled')
|
||||
cy.findByRole('alert').should('not.exist')
|
||||
|
||||
cy.get('@input').type('oops/i/did/it/again')
|
||||
cy.get('@submit').should('be.disabled')
|
||||
cy.findByRole('alert').should('contain.text', 'contains invalid characters')
|
||||
})
|
||||
|
||||
it('displays an error when the file limit is reached', function () {
|
||||
getMeta('ol-ExposedSettings').maxEntitiesPerProject = 10
|
||||
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: Array.from({ length: 10 }, (_, index) => ({
|
||||
_id: `entity-${index}`,
|
||||
})),
|
||||
fileRefs: [],
|
||||
folders: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders rootFolder={rootFolder as any}>
|
||||
<FileTreeProvider>
|
||||
<OpenWithMode mode="doc" />
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('alert')
|
||||
.invoke('text')
|
||||
.should('match', /This project has reached the \d+ file limit/)
|
||||
})
|
||||
|
||||
it('displays a warning when the file limit is nearly reached', function () {
|
||||
getMeta('ol-ExposedSettings').maxEntitiesPerProject = 10
|
||||
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: Array.from({ length: 9 }, (_, index) => ({
|
||||
_id: `entity-${index}`,
|
||||
})),
|
||||
fileRefs: [],
|
||||
folders: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders rootFolder={rootFolder as any}>
|
||||
<FileTreeProvider>
|
||||
<OpenWithMode mode="doc" />
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByText(/This project is approaching the file limit \(\d+\/\d+\)/)
|
||||
})
|
||||
|
||||
it('counts files in nested folders', function () {
|
||||
getMeta('ol-ExposedSettings').maxEntitiesPerProject = 10
|
||||
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [{ _id: 'doc-1' }],
|
||||
fileRefs: [],
|
||||
folders: [
|
||||
{
|
||||
docs: [{ _id: 'doc-2' }],
|
||||
fileRefs: [],
|
||||
folders: [
|
||||
{
|
||||
docs: [
|
||||
{ _id: 'doc-3' },
|
||||
{ _id: 'doc-4' },
|
||||
{ _id: 'doc-5' },
|
||||
{ _id: 'doc-6' },
|
||||
{ _id: 'doc-7' },
|
||||
],
|
||||
fileRefs: [],
|
||||
folders: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders rootFolder={rootFolder as any}>
|
||||
<FileTreeProvider>
|
||||
<OpenWithMode mode="doc" />
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByText(/This project is approaching the file limit \(\d+\/\d+\)/)
|
||||
})
|
||||
|
||||
it('counts folders toward the limit', function () {
|
||||
getMeta('ol-ExposedSettings').maxEntitiesPerProject = 10
|
||||
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [{ _id: 'doc-1' }],
|
||||
fileRefs: [],
|
||||
folders: [
|
||||
{ docs: [], fileRefs: [], folders: [] },
|
||||
{ docs: [], fileRefs: [], folders: [] },
|
||||
{ docs: [], fileRefs: [], folders: [] },
|
||||
{ docs: [], fileRefs: [], folders: [] },
|
||||
{ docs: [], fileRefs: [], folders: [] },
|
||||
{ docs: [], fileRefs: [], folders: [] },
|
||||
{ docs: [], fileRefs: [], folders: [] },
|
||||
{ docs: [], fileRefs: [], folders: [] },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders rootFolder={rootFolder as any}>
|
||||
<FileTreeProvider>
|
||||
<OpenWithMode mode="doc" />
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByText(/This project is approaching the file limit \(\d+\/\d+\)/)
|
||||
})
|
||||
|
||||
it('creates a new file when the form is submitted', function () {
|
||||
cy.intercept('post', '/project/*/doc', {
|
||||
statusCode: 204,
|
||||
}).as('createDoc')
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<FileTreeProvider>
|
||||
<OpenWithMode mode="doc" />
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByLabelText('File Name').type('test')
|
||||
cy.findByRole('button', { name: 'Create' }).click()
|
||||
|
||||
cy.wait('@createDoc')
|
||||
|
||||
cy.get('@createDoc').its('request.body').should('deep.equal', {
|
||||
parent_folder_id: 'root-folder-id',
|
||||
name: 'test.tex',
|
||||
})
|
||||
})
|
||||
|
||||
it('imports a new file from a project', function () {
|
||||
getMeta('ol-ExposedSettings').hasLinkedProjectFileFeature = true
|
||||
getMeta('ol-ExposedSettings').hasLinkedProjectOutputFileFeature = true
|
||||
|
||||
cy.intercept('/user/projects', {
|
||||
body: {
|
||||
projects: [
|
||||
{
|
||||
_id: 'test-project',
|
||||
name: 'This Project',
|
||||
},
|
||||
{
|
||||
_id: 'project-1',
|
||||
name: 'Project One',
|
||||
},
|
||||
{
|
||||
_id: 'project-2',
|
||||
name: 'Project Two',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
cy.intercept('/project/*/entities', {
|
||||
body: {
|
||||
entities: [
|
||||
{
|
||||
path: '/foo.tex',
|
||||
},
|
||||
{
|
||||
path: '/bar.tex',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
cy.intercept('post', '/project/*/compile', {
|
||||
body: {
|
||||
status: 'success',
|
||||
outputFiles: [
|
||||
{
|
||||
build: 'test',
|
||||
path: 'baz.jpg',
|
||||
},
|
||||
{
|
||||
build: 'test',
|
||||
path: 'ball.jpg',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
cy.intercept('post', '/project/*/linked_file', {
|
||||
statusCode: 204,
|
||||
}).as('createLinkedFile')
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<FileTreeProvider>
|
||||
<OpenWithMode mode="project" />
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
// initial state, no project selected
|
||||
cy.findByLabelText('Select a Project').should('not.be.disabled')
|
||||
|
||||
// the submit button should be disabled
|
||||
cy.findByRole('button', { name: 'Create' }).should('be.disabled')
|
||||
|
||||
// the source file selector should be disabled
|
||||
cy.findByLabelText('Select a File').should('be.disabled')
|
||||
cy.findByLabelText('Select an Output File').should('not.exist')
|
||||
// TODO: check for options length, excluding current project
|
||||
|
||||
// select a project
|
||||
cy.findByLabelText('Select a Project').select('project-2')
|
||||
|
||||
// wait for the source file selector to be enabled
|
||||
cy.findByLabelText('Select a File').should('not.be.disabled')
|
||||
cy.findByLabelText('Select an Output File').should('not.exist')
|
||||
cy.findByRole('button', { name: 'Create' }).should('be.disabled')
|
||||
|
||||
// TODO: check for fileInput options length, excluding current project
|
||||
|
||||
// click on the button to toggle between source and output files
|
||||
cy.findByRole('button', {
|
||||
// NOTE: When changing the label, update the other tests with this label as well.
|
||||
name: 'select from output files',
|
||||
}).click()
|
||||
|
||||
// wait for the output file selector to be enabled
|
||||
cy.findByLabelText('Select an Output File').should('not.be.disabled')
|
||||
cy.findByLabelText('Select a File').should('not.exist')
|
||||
cy.findByRole('button', { name: 'Create' }).should('be.disabled')
|
||||
|
||||
// TODO: check for entityInput options length, excluding current project
|
||||
cy.findByLabelText('Select an Output File').select('ball.jpg')
|
||||
cy.findByRole('button', { name: 'Create' }).should('not.be.disabled')
|
||||
cy.findByRole('button', { name: 'Create' }).click()
|
||||
|
||||
cy.get('@createLinkedFile')
|
||||
.its('request.body')
|
||||
.should('deep.equal', {
|
||||
name: 'ball.jpg',
|
||||
provider: 'project_output_file',
|
||||
parent_folder_id: 'root-folder-id',
|
||||
data: {
|
||||
source_project_id: 'project-2',
|
||||
source_output_file_path: 'ball.jpg',
|
||||
build_id: 'test',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the output files feature is not available', function () {
|
||||
beforeEach(function () {
|
||||
getMeta('ol-ExposedSettings').hasLinkedProjectFileFeature = true
|
||||
getMeta('ol-ExposedSettings').hasLinkedProjectOutputFileFeature = false
|
||||
})
|
||||
|
||||
it('should not show the import from output file mode', function () {
|
||||
cy.intercept('/user/projects', {
|
||||
body: {
|
||||
projects: [
|
||||
{
|
||||
_id: 'test-project',
|
||||
name: 'This Project',
|
||||
},
|
||||
{
|
||||
_id: 'project-1',
|
||||
name: 'Project One',
|
||||
},
|
||||
{
|
||||
_id: 'project-2',
|
||||
name: 'Project Two',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<FileTreeProvider>
|
||||
<OpenWithMode mode="project" />
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByLabelText('Select a File')
|
||||
|
||||
cy.findByRole('button', {
|
||||
name: 'select from output files',
|
||||
}).should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
it('import from a URL when the form is submitted', function () {
|
||||
cy.intercept('/project/*/linked_file', {
|
||||
statusCode: 204,
|
||||
}).as('createLinkedFile')
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<FileTreeProvider>
|
||||
<OpenWithMode mode="url" />
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByLabelText('URL to fetch the file from').type(
|
||||
'https://example.com/example.tex'
|
||||
)
|
||||
cy.findByLabelText('File Name In This Project').should(
|
||||
'have.value',
|
||||
'example.tex'
|
||||
)
|
||||
|
||||
// check that the name can still be edited manually
|
||||
cy.findByLabelText('File Name In This Project').clear()
|
||||
cy.findByLabelText('File Name In This Project').type('test.tex')
|
||||
cy.findByLabelText('File Name In This Project').should(
|
||||
'have.value',
|
||||
'test.tex'
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Create' }).click()
|
||||
|
||||
cy.get('@createLinkedFile')
|
||||
.its('request.body')
|
||||
.should('deep.equal', {
|
||||
name: 'test.tex',
|
||||
provider: 'url',
|
||||
parent_folder_id: 'root-folder-id',
|
||||
data: { url: 'https://example.com/example.tex' },
|
||||
})
|
||||
})
|
||||
|
||||
it('uploads a dropped file', function () {
|
||||
cy.intercept('post', '/project/*/upload?folder_id=root-folder-id', {
|
||||
statusCode: 204,
|
||||
}).as('uploadFile')
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<FileTreeProvider>
|
||||
<OpenWithMode mode="upload" />
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
// the submit button should not be present
|
||||
cy.findByRole('button', { name: 'Create' }).should('not.exist')
|
||||
|
||||
cy.get('input[type=file]')
|
||||
.eq(0)
|
||||
.selectFile(
|
||||
{
|
||||
contents: Cypress.Buffer.from('test'),
|
||||
fileName: 'test.tex',
|
||||
mimeType: 'text/plain',
|
||||
lastModified: Date.now(),
|
||||
},
|
||||
{
|
||||
action: 'drag-drop',
|
||||
force: true, // invisible element
|
||||
}
|
||||
)
|
||||
|
||||
cy.wait('@uploadFile')
|
||||
})
|
||||
|
||||
it('uploads a pasted file', function () {
|
||||
cy.intercept('post', '/project/*/upload?folder_id=root-folder-id', {
|
||||
statusCode: 204,
|
||||
}).as('uploadFile')
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<FileTreeProvider>
|
||||
<OpenWithMode mode="upload" />
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
// the submit button should not be present
|
||||
cy.findByRole('button', { name: 'Create' }).should('not.exist')
|
||||
|
||||
cy.wrap(null).then(() => {
|
||||
const clipboardData = new DataTransfer()
|
||||
clipboardData.items.add(
|
||||
new File(['test'], 'test.tex', { type: 'text/plain' })
|
||||
)
|
||||
cy.findByLabelText('Uppy Dashboard').trigger('paste', { clipboardData })
|
||||
})
|
||||
|
||||
cy.wait('@uploadFile')
|
||||
})
|
||||
|
||||
it('displays upload errors', function () {
|
||||
cy.intercept('post', '/project/*/upload?folder_id=root-folder-id', {
|
||||
statusCode: 422,
|
||||
body: { success: false, error: 'invalid_filename' },
|
||||
}).as('uploadFile')
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<FileTreeProvider>
|
||||
<OpenWithMode mode="upload" />
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
// the submit button should not be present
|
||||
cy.findByRole('button', { name: 'Create' }).should('not.exist')
|
||||
|
||||
cy.wrap(null).then(() => {
|
||||
const clipboardData = new DataTransfer()
|
||||
clipboardData.items.add(
|
||||
new File(['test'], 'tes!t.tex', { type: 'text/plain' })
|
||||
)
|
||||
cy.findByLabelText('Uppy Dashboard').trigger('paste', { clipboardData })
|
||||
})
|
||||
|
||||
cy.wait('@uploadFile')
|
||||
|
||||
cy.findByText(
|
||||
`Upload failed: check that the file name doesn’t contain special characters, trailing/leading whitespace or more than 150 characters`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
function OpenWithMode({ mode }: { mode: string }) {
|
||||
const { newFileCreateMode, startCreatingFile } = useFileTreeActionable()
|
||||
|
||||
const { fileCount } = useFileTreeData()
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => startCreatingFile(mode), [])
|
||||
|
||||
if (!fileCount || !newFileCreateMode) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <FileTreeModalCreateFile />
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import FileTreeDoc from '../../../../../frontend/js/features/file-tree/components/file-tree-doc'
|
||||
import { EditorProviders } from '../../../helpers/editor-providers'
|
||||
import { FileTreeProvider } from '../helpers/file-tree-provider'
|
||||
|
||||
describe('<FileTreeDoc/>', function () {
|
||||
it('renders unselected', function () {
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<FileTreeProvider>
|
||||
<FileTreeDoc name="foo.tex" id="123abc" />
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('treeitem', { selected: false })
|
||||
cy.get('.linked-file-highlight').should('not.exist')
|
||||
})
|
||||
|
||||
it('renders selected', function () {
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [{ _id: '123abc' }],
|
||||
fileRefs: [],
|
||||
folders: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders rootFolder={rootFolder as any}>
|
||||
<FileTreeProvider>
|
||||
<FileTreeDoc name="foo.tex" id="123abc" />,
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('treeitem', { selected: false }).click()
|
||||
cy.findByRole('treeitem', { selected: true })
|
||||
})
|
||||
|
||||
it('renders as linked file', function () {
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<FileTreeProvider>
|
||||
<FileTreeDoc name="foo.tex" id="123abc" isLinkedFile />
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('treeitem')
|
||||
cy.get('.linked-file-highlight')
|
||||
})
|
||||
|
||||
it('multi-selects', function () {
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [{ _id: '123abc' }],
|
||||
fileRefs: [],
|
||||
folders: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders rootFolder={rootFolder as any}>
|
||||
<FileTreeProvider>
|
||||
<FileTreeDoc name="foo.tex" id="123abc" />,
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('treeitem').click({ ctrlKey: true, cmdKey: true })
|
||||
cy.findByRole('treeitem', { selected: true })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,197 @@
|
||||
import FileTreeFolderList from '../../../../../frontend/js/features/file-tree/components/file-tree-folder-list'
|
||||
import { EditorProviders } from '../../../helpers/editor-providers'
|
||||
import { FileTreeProvider } from '../helpers/file-tree-provider'
|
||||
|
||||
describe('<FileTreeFolderList/>', function () {
|
||||
it('renders empty', function () {
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<FileTreeProvider>
|
||||
<FileTreeFolderList folders={[]} docs={[]} files={[]} />
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('tree')
|
||||
cy.findByRole('treeitem').should('not.exist')
|
||||
})
|
||||
|
||||
it('renders docs, files and folders', function () {
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<FileTreeProvider>
|
||||
<FileTreeFolderList
|
||||
folders={[
|
||||
{
|
||||
_id: '456def',
|
||||
name: 'A Folder',
|
||||
folders: [],
|
||||
docs: [],
|
||||
fileRefs: [],
|
||||
},
|
||||
]}
|
||||
docs={[{ _id: '789ghi', name: 'doc.tex' }]}
|
||||
files={[
|
||||
{
|
||||
_id: '987jkl',
|
||||
name: 'file.bib',
|
||||
hash: 'some hash',
|
||||
linkedFileData: {},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('tree')
|
||||
cy.findByRole('treeitem', { name: 'A Folder' })
|
||||
cy.findByRole('treeitem', { name: 'doc.tex' })
|
||||
cy.findByRole('treeitem', { name: 'file.bib' })
|
||||
})
|
||||
|
||||
describe('selection and multi-selection', function () {
|
||||
it('without write permissions', function () {
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [{ _id: '1' }, { _id: '2' }],
|
||||
fileRefs: [],
|
||||
folders: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders
|
||||
rootFolder={rootFolder as any}
|
||||
permissionsLevel="readOnly"
|
||||
>
|
||||
<FileTreeProvider>
|
||||
<FileTreeFolderList
|
||||
folders={[]}
|
||||
docs={[
|
||||
{ _id: '1', name: '1.tex' },
|
||||
{ _id: '2', name: '2.tex' },
|
||||
]}
|
||||
files={[]}
|
||||
/>
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
// click on item 1: it gets selected
|
||||
cy.findByRole('treeitem', { name: '1.tex' }).click()
|
||||
cy.findByRole('treeitem', { name: '1.tex', selected: true })
|
||||
cy.findByRole('treeitem', { name: '2.tex', selected: false })
|
||||
|
||||
// meta-click on item 2: no changes
|
||||
cy.findByRole('treeitem', { name: '2.tex' }).click({
|
||||
ctrlKey: true,
|
||||
cmdKey: true,
|
||||
})
|
||||
cy.findByRole('treeitem', { name: '1.tex', selected: true })
|
||||
cy.findByRole('treeitem', { name: '2.tex', selected: false })
|
||||
})
|
||||
|
||||
it('with write permissions', function () {
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [{ _id: '1' }, { _id: '2' }, { _id: '3' }],
|
||||
fileRefs: [],
|
||||
folders: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders rootFolder={rootFolder as any}>
|
||||
<FileTreeProvider>
|
||||
<FileTreeFolderList
|
||||
folders={[]}
|
||||
docs={[
|
||||
{ _id: '1', name: '1.tex' },
|
||||
{ _id: '2', name: '2.tex' },
|
||||
{ _id: '3', name: '3.tex' },
|
||||
]}
|
||||
files={[]}
|
||||
/>
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
// click item 1: it gets selected
|
||||
cy.findByRole('treeitem', { name: '1.tex' }).click()
|
||||
cy.findByRole('treeitem', { name: '1.tex', selected: true })
|
||||
cy.findByRole('treeitem', { name: '2.tex', selected: false })
|
||||
cy.findByRole('treeitem', { name: '3.tex', selected: false })
|
||||
|
||||
// click on item 2: it gets selected and item 1 is not selected anymore
|
||||
cy.findByRole('treeitem', { name: '2.tex' }).click()
|
||||
cy.findByRole('treeitem', { name: '1.tex', selected: false })
|
||||
cy.findByRole('treeitem', { name: '2.tex', selected: true })
|
||||
cy.findByRole('treeitem', { name: '3.tex', selected: false })
|
||||
|
||||
// meta-click on item 3: it gets selected and item 2 as well
|
||||
cy.findByRole('treeitem', { name: '3.tex' }).click({
|
||||
ctrlKey: true,
|
||||
cmdKey: true,
|
||||
})
|
||||
cy.findByRole('treeitem', { name: '1.tex', selected: false })
|
||||
cy.findByRole('treeitem', { name: '2.tex', selected: true })
|
||||
cy.findByRole('treeitem', { name: '3.tex', selected: true })
|
||||
|
||||
// meta-click on item 1: add to selection
|
||||
cy.findByRole('treeitem', { name: '1.tex' }).click({
|
||||
ctrlKey: true,
|
||||
cmdKey: true,
|
||||
})
|
||||
cy.findByRole('treeitem', { name: '1.tex', selected: true })
|
||||
cy.findByRole('treeitem', { name: '2.tex', selected: true })
|
||||
cy.findByRole('treeitem', { name: '3.tex', selected: true })
|
||||
|
||||
// meta-click on item 1: remove from selection
|
||||
cy.findByRole('treeitem', { name: '1.tex' }).click({
|
||||
ctrlKey: true,
|
||||
cmdKey: true,
|
||||
})
|
||||
cy.findByRole('treeitem', { name: '1.tex', selected: false })
|
||||
cy.findByRole('treeitem', { name: '2.tex', selected: true })
|
||||
cy.findByRole('treeitem', { name: '3.tex', selected: true })
|
||||
|
||||
// meta-click on item 3: remove from selection
|
||||
cy.findByRole('treeitem', { name: '3.tex' }).click({
|
||||
ctrlKey: true,
|
||||
cmdKey: true,
|
||||
})
|
||||
cy.findByRole('treeitem', { name: '1.tex', selected: false })
|
||||
cy.findByRole('treeitem', { name: '2.tex', selected: true })
|
||||
cy.findByRole('treeitem', { name: '3.tex', selected: false })
|
||||
|
||||
// meta-click on item 2: cannot unselect
|
||||
cy.findByRole('treeitem', { name: '2.tex' }).click({
|
||||
ctrlKey: true,
|
||||
cmdKey: true,
|
||||
})
|
||||
cy.findByRole('treeitem', { name: '1.tex', selected: false })
|
||||
cy.findByRole('treeitem', { name: '2.tex', selected: true })
|
||||
cy.findByRole('treeitem', { name: '3.tex', selected: false })
|
||||
|
||||
// meta-click on item 3: add back to selection
|
||||
cy.findByRole('treeitem', { name: '3.tex' }).click({
|
||||
ctrlKey: true,
|
||||
cmdKey: true,
|
||||
})
|
||||
cy.findByRole('treeitem', { name: '1.tex', selected: false })
|
||||
cy.findByRole('treeitem', { name: '2.tex', selected: true })
|
||||
cy.findByRole('treeitem', { name: '3.tex', selected: true })
|
||||
|
||||
// click on item 3: unselect other items
|
||||
cy.findByRole('treeitem', { name: '3.tex' }).click()
|
||||
cy.findByRole('treeitem', { name: '1.tex', selected: false })
|
||||
cy.findByRole('treeitem', { name: '2.tex', selected: false })
|
||||
cy.findByRole('treeitem', { name: '3.tex', selected: true })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,134 @@
|
||||
import FileTreeFolder from '../../../../../frontend/js/features/file-tree/components/file-tree-folder'
|
||||
import { EditorProviders } from '../../../helpers/editor-providers'
|
||||
import { FileTreeProvider } from '../helpers/file-tree-provider'
|
||||
import { getContainerEl } from 'cypress/react'
|
||||
import ReactDom from 'react-dom'
|
||||
|
||||
describe('<FileTreeFolder/>', function () {
|
||||
it('renders unselected', function () {
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<FileTreeProvider>
|
||||
<FileTreeFolder
|
||||
name="foo"
|
||||
id="123abc"
|
||||
folders={[]}
|
||||
docs={[]}
|
||||
files={[]}
|
||||
/>
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('treeitem', { selected: false })
|
||||
cy.findByRole('tree').should('not.exist')
|
||||
})
|
||||
|
||||
it('renders selected', function () {
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [{ _id: '123abc' }],
|
||||
fileRefs: [],
|
||||
folders: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders rootFolder={rootFolder as any}>
|
||||
<FileTreeProvider>
|
||||
<FileTreeFolder
|
||||
name="foo"
|
||||
id="123abc"
|
||||
folders={[]}
|
||||
docs={[]}
|
||||
files={[]}
|
||||
/>
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('treeitem', { selected: false }).click()
|
||||
cy.findByRole('treeitem', { selected: true })
|
||||
cy.findByRole('tree').should('not.exist')
|
||||
})
|
||||
|
||||
it('expands', function () {
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [{ _id: '123abc' }],
|
||||
fileRefs: [],
|
||||
folders: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders rootFolder={rootFolder as any}>
|
||||
<FileTreeProvider>
|
||||
<FileTreeFolder
|
||||
name="foo"
|
||||
id="123abc"
|
||||
folders={[]}
|
||||
docs={[]}
|
||||
files={[]}
|
||||
/>
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('treeitem')
|
||||
cy.findByRole('button', { name: 'Expand' }).click()
|
||||
cy.findByRole('tree')
|
||||
})
|
||||
|
||||
it('saves the expanded state for the next render', function () {
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [{ _id: '123abc' }],
|
||||
fileRefs: [],
|
||||
folders: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders rootFolder={rootFolder as any}>
|
||||
<FileTreeProvider>
|
||||
<FileTreeFolder
|
||||
name="foo"
|
||||
id="123abc"
|
||||
folders={[]}
|
||||
docs={[]}
|
||||
files={[]}
|
||||
/>
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('tree').should('not.exist')
|
||||
cy.findByRole('button', { name: 'Expand' }).click()
|
||||
cy.findByRole('tree')
|
||||
|
||||
cy.then(() => ReactDom.unmountComponentAtNode(getContainerEl()))
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<FileTreeProvider>
|
||||
<FileTreeFolder
|
||||
name="foo"
|
||||
id="123abc"
|
||||
folders={[]}
|
||||
docs={[]}
|
||||
files={[]}
|
||||
/>
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('tree')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,124 @@
|
||||
import FileTreeitemInner from '../../../../../../frontend/js/features/file-tree/components/file-tree-item/file-tree-item-inner'
|
||||
import FileTreeContextMenu from '../../../../../../frontend/js/features/file-tree/components/file-tree-context-menu'
|
||||
import { EditorProviders } from '../../../../helpers/editor-providers'
|
||||
import { FileTreeProvider } from '../../helpers/file-tree-provider'
|
||||
|
||||
describe('<FileTreeitemInner />', function () {
|
||||
describe('menu', function () {
|
||||
it('does not display if file is not selected', function () {
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<FileTreeProvider>
|
||||
<FileTreeitemInner
|
||||
id="123abc"
|
||||
name="bar.tex"
|
||||
isSelected={false}
|
||||
type="doc"
|
||||
/>
|
||||
,
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('menu', { hidden: true }).should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('context menu', function () {
|
||||
it('does not display without write permissions', function () {
|
||||
cy.mount(
|
||||
<EditorProviders permissionsLevel="readOnly">
|
||||
<FileTreeProvider>
|
||||
<FileTreeitemInner
|
||||
id="123abc"
|
||||
name="bar.tex"
|
||||
isSelected
|
||||
type="doc"
|
||||
/>
|
||||
<FileTreeContextMenu />
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.get('div.entity').trigger('contextmenu')
|
||||
cy.findByRole('menu', { hidden: true }).should('not.exist')
|
||||
})
|
||||
|
||||
it('open / close', function () {
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<FileTreeProvider>
|
||||
<FileTreeitemInner
|
||||
id="123abc"
|
||||
name="bar.tex"
|
||||
isSelected
|
||||
type="doc"
|
||||
/>
|
||||
<FileTreeContextMenu />
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('menu', { hidden: true }).should('not.exist')
|
||||
|
||||
// open the context menu
|
||||
cy.get('div.entity').trigger('contextmenu')
|
||||
cy.findByRole('menu')
|
||||
|
||||
// close the context menu
|
||||
cy.get('div.entity').click()
|
||||
cy.findByRole('menu').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('name', function () {
|
||||
it('renders name', function () {
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<FileTreeProvider>
|
||||
<FileTreeitemInner
|
||||
id="123abc"
|
||||
name="bar.tex"
|
||||
isSelected
|
||||
type="doc"
|
||||
/>
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'bar.tex' })
|
||||
cy.findByRole('textbox').should('not.exist')
|
||||
})
|
||||
|
||||
it('starts rename on menu item click', function () {
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [{ _id: '123abc', name: 'bar.tex' }],
|
||||
folders: [],
|
||||
fileRefs: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders rootDocId="123abc" rootFolder={rootFolder as any}>
|
||||
<FileTreeProvider>
|
||||
<FileTreeitemInner
|
||||
id="123abc"
|
||||
name="bar.tex"
|
||||
isSelected
|
||||
type="doc"
|
||||
/>
|
||||
<FileTreeContextMenu />
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Open bar.tex action menu' }).click()
|
||||
cy.findByRole('menuitem', { name: 'Rename' }).click()
|
||||
cy.findByRole('button', { name: 'bar.tex' }).should('not.exist')
|
||||
cy.findByRole('textbox')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
import FileTreeItemName from '../../../../../../frontend/js/features/file-tree/components/file-tree-item/file-tree-item-name'
|
||||
import { EditorProviders } from '../../../../helpers/editor-providers'
|
||||
import { FileTreeProvider } from '../../helpers/file-tree-provider'
|
||||
|
||||
describe('<FileTreeItemName />', function () {
|
||||
it('renders name as button', function () {
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<FileTreeProvider>
|
||||
<FileTreeItemName
|
||||
name="foo.tex"
|
||||
isSelected
|
||||
setIsDraggable={cy.stub()}
|
||||
/>
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'foo.tex' })
|
||||
cy.findByRole('textbox').should('not.exist')
|
||||
})
|
||||
|
||||
it("doesn't start renaming on unselected component", function () {
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<FileTreeProvider>
|
||||
<FileTreeItemName
|
||||
name="foo.tex"
|
||||
isSelected={false}
|
||||
setIsDraggable={cy.stub()}
|
||||
/>
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button').click()
|
||||
cy.findByRole('button').click()
|
||||
cy.findByRole('button').dblclick()
|
||||
cy.findByRole('textbox').should('not.exist')
|
||||
})
|
||||
|
||||
it('start renaming on double-click', function () {
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<FileTreeProvider>
|
||||
<FileTreeItemName
|
||||
name="foo.tex"
|
||||
isSelected
|
||||
setIsDraggable={cy.stub().as('setIsDraggable')}
|
||||
/>
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button').click()
|
||||
cy.findByRole('button').click()
|
||||
cy.findByRole('button').dblclick()
|
||||
cy.findByRole('textbox')
|
||||
cy.findByRole('button').should('not.exist')
|
||||
cy.get('@setIsDraggable').should('have.been.calledWith', false)
|
||||
})
|
||||
|
||||
it('cannot start renaming in read-only', function () {
|
||||
cy.mount(
|
||||
<EditorProviders permissionsLevel="readOnly">
|
||||
<FileTreeProvider>
|
||||
<FileTreeItemName
|
||||
name="foo.tex"
|
||||
isSelected
|
||||
setIsDraggable={cy.stub()}
|
||||
/>
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button').click()
|
||||
cy.findByRole('button').click()
|
||||
cy.findByRole('button').dblclick()
|
||||
|
||||
cy.findByRole('textbox').should('not.exist')
|
||||
})
|
||||
|
||||
describe('stop renaming', function () {
|
||||
it('on Escape', function () {
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<FileTreeProvider>
|
||||
<FileTreeItemName
|
||||
name="foo.tex"
|
||||
isSelected
|
||||
setIsDraggable={cy.stub().as('setIsDraggable')}
|
||||
/>
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button').click()
|
||||
cy.findByRole('button').click()
|
||||
cy.findByRole('button').dblclick()
|
||||
|
||||
cy.findByRole('textbox').clear()
|
||||
cy.findByRole('textbox').type('bar.tex{esc}')
|
||||
|
||||
cy.findByRole('button', { name: 'foo.tex' })
|
||||
cy.get('@setIsDraggable').should('have.been.calledWith', true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,354 @@
|
||||
import FileTreeRoot from '../../../../../frontend/js/features/file-tree/components/file-tree-root'
|
||||
import { EditorProviders } from '../../../helpers/editor-providers'
|
||||
import { SocketIOMock } from '@/ide/connection/SocketIoShim'
|
||||
|
||||
describe('<FileTreeRoot/>', function () {
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-user', { id: 'user1' })
|
||||
})
|
||||
})
|
||||
|
||||
it('renders', function () {
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [{ _id: '456def', name: 'main.tex' }],
|
||||
folders: [],
|
||||
fileRefs: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders
|
||||
rootFolder={rootFolder as any}
|
||||
projectId="123abc"
|
||||
rootDocId="456def"
|
||||
features={{} as any}
|
||||
permissionsLevel="owner"
|
||||
>
|
||||
<FileTreeRoot
|
||||
refProviders={{}}
|
||||
setRefProviderEnabled={cy.stub()}
|
||||
setStartedFreeTrial={cy.stub()}
|
||||
onSelect={cy.stub()}
|
||||
onInit={cy.stub()}
|
||||
isConnected
|
||||
/>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('tree')
|
||||
cy.findByRole('treeitem')
|
||||
cy.findByRole('treeitem', { name: 'main.tex', selected: true })
|
||||
cy.get('.disconnected-overlay').should('not.exist')
|
||||
})
|
||||
|
||||
it('renders with invalid selected doc in local storage', function () {
|
||||
global.localStorage.setItem(
|
||||
'doc.open_id.123abc',
|
||||
JSON.stringify('not-a-valid-id')
|
||||
)
|
||||
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [{ _id: '456def', name: 'main.tex' }],
|
||||
folders: [],
|
||||
fileRefs: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<div style={{ width: 400 }}>
|
||||
<EditorProviders
|
||||
rootFolder={rootFolder as any}
|
||||
projectId="123abc"
|
||||
rootDocId="456def"
|
||||
features={{} as any}
|
||||
permissionsLevel="owner"
|
||||
>
|
||||
<FileTreeRoot
|
||||
refProviders={{}}
|
||||
setRefProviderEnabled={cy.stub()}
|
||||
setStartedFreeTrial={cy.stub()}
|
||||
onSelect={cy.stub()}
|
||||
onInit={cy.stub()}
|
||||
isConnected
|
||||
/>
|
||||
</EditorProviders>
|
||||
</div>
|
||||
)
|
||||
|
||||
// as a proxy to check that the invalid entity has not been select we start
|
||||
// a delete and ensure the modal is displayed (the cancel button can be
|
||||
// selected) This is needed to make sure the test fail.
|
||||
cy.findByRole('treeitem', { name: 'main.tex' }).click({
|
||||
ctrlKey: true,
|
||||
cmdKey: true,
|
||||
})
|
||||
cy.findByRole('button', { name: 'Open main.tex action menu' }).click()
|
||||
cy.findByRole('menuitem', { name: 'Delete' }).click()
|
||||
cy.findByRole('button', { name: 'Cancel' })
|
||||
})
|
||||
|
||||
it('renders disconnected overlay', function () {
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [{ _id: '456def', name: 'main.tex' }],
|
||||
folders: [],
|
||||
fileRefs: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders
|
||||
rootFolder={rootFolder as any}
|
||||
projectId="123abc"
|
||||
rootDocId="456def"
|
||||
features={{} as any}
|
||||
permissionsLevel="owner"
|
||||
>
|
||||
<FileTreeRoot
|
||||
refProviders={{}}
|
||||
setRefProviderEnabled={cy.stub()}
|
||||
setStartedFreeTrial={cy.stub()}
|
||||
onSelect={cy.stub()}
|
||||
onInit={cy.stub()}
|
||||
isConnected={false}
|
||||
/>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.get('.disconnected-overlay')
|
||||
})
|
||||
|
||||
it('fire onSelect', function () {
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [
|
||||
{ _id: '456def', name: 'main.tex' },
|
||||
{ _id: '789ghi', name: 'other.tex' },
|
||||
],
|
||||
folders: [],
|
||||
fileRefs: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders
|
||||
rootFolder={rootFolder as any}
|
||||
projectId="123abc"
|
||||
rootDocId="456def"
|
||||
features={{} as any}
|
||||
permissionsLevel="readOnly"
|
||||
>
|
||||
<FileTreeRoot
|
||||
refProviders={{}}
|
||||
setRefProviderEnabled={cy.stub()}
|
||||
setStartedFreeTrial={cy.stub()}
|
||||
onSelect={cy.stub().as('onSelect')}
|
||||
onInit={cy.stub()}
|
||||
isConnected
|
||||
/>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.get('@onSelect').should('have.been.calledOnceWith', [
|
||||
Cypress.sinon.match({
|
||||
entity: Cypress.sinon.match({ _id: '456def', name: 'main.tex' }),
|
||||
}),
|
||||
])
|
||||
cy.findByRole('tree')
|
||||
cy.findByRole('treeitem', { name: 'other.tex' }).click()
|
||||
cy.get('@onSelect').should('have.been.calledWith', [
|
||||
Cypress.sinon.match({
|
||||
entity: Cypress.sinon.match({ _id: '789ghi', name: 'other.tex' }),
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('only shows a menu button when a single item is selected', function () {
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [
|
||||
{ _id: '456def', name: 'main.tex' },
|
||||
{ _id: '789ghi', name: 'other.tex' },
|
||||
],
|
||||
folders: [],
|
||||
fileRefs: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders
|
||||
rootFolder={rootFolder as any}
|
||||
projectId="123abc"
|
||||
rootDocId="456def"
|
||||
features={{} as any}
|
||||
permissionsLevel="owner"
|
||||
>
|
||||
<FileTreeRoot
|
||||
refProviders={{}}
|
||||
setRefProviderEnabled={cy.stub()}
|
||||
setStartedFreeTrial={cy.stub()}
|
||||
onSelect={cy.stub()}
|
||||
onInit={cy.stub()}
|
||||
isConnected
|
||||
/>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('treeitem', { name: 'main.tex', selected: true })
|
||||
cy.findByRole('treeitem', { name: 'other.tex', selected: false })
|
||||
|
||||
// single item selected: menu button is visible
|
||||
cy.findAllByRole('button', { name: 'Open main.tex action menu' }).should(
|
||||
'have.length',
|
||||
1
|
||||
)
|
||||
|
||||
// select the other item
|
||||
cy.findByRole('treeitem', { name: 'other.tex' }).click()
|
||||
|
||||
cy.findByRole('treeitem', { name: 'main.tex', selected: false })
|
||||
cy.findByRole('treeitem', { name: 'other.tex', selected: true })
|
||||
|
||||
// single item selected: menu button is visible
|
||||
cy.findAllByRole('button', { name: 'Open other.tex action menu' }).should(
|
||||
'have.length',
|
||||
1
|
||||
)
|
||||
|
||||
// multi-select the main item
|
||||
cy.findByRole('treeitem', { name: 'main.tex' }).click({
|
||||
ctrlKey: true,
|
||||
cmdKey: true,
|
||||
})
|
||||
|
||||
cy.findByRole('treeitem', { name: 'main.tex', selected: true })
|
||||
cy.findByRole('treeitem', { name: 'other.tex', selected: true })
|
||||
|
||||
// multiple items selected: no menu button is visible
|
||||
cy.findAllByRole('button', { name: 'Open main.tex action menu' }).should(
|
||||
'have.length',
|
||||
0
|
||||
)
|
||||
})
|
||||
|
||||
describe('when deselecting files', function () {
|
||||
let socket: SocketIOMock
|
||||
beforeEach(function () {
|
||||
socket = new SocketIOMock()
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [{ _id: '123abc', name: 'main.tex' }],
|
||||
folders: [
|
||||
{
|
||||
_id: '789ghi',
|
||||
name: 'thefolder',
|
||||
docs: [{ _id: '456def', name: 'sub.tex' }],
|
||||
fileRefs: [],
|
||||
folders: [],
|
||||
},
|
||||
],
|
||||
fileRefs: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders
|
||||
rootFolder={rootFolder as any}
|
||||
projectId="123abc"
|
||||
rootDocId="456def"
|
||||
features={{} as any}
|
||||
permissionsLevel="owner"
|
||||
socket={socket}
|
||||
>
|
||||
<FileTreeRoot
|
||||
refProviders={{}}
|
||||
setRefProviderEnabled={cy.stub()}
|
||||
setStartedFreeTrial={cy.stub()}
|
||||
onSelect={cy.stub()}
|
||||
onInit={cy.stub()}
|
||||
isConnected
|
||||
/>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
// select the sub file
|
||||
cy.findByRole('treeitem', { name: 'sub.tex' }).click()
|
||||
cy.findByRole('treeitem', { name: 'sub.tex' }).should(
|
||||
'have.attr',
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
|
||||
// click on empty area (after giving it extra height below the tree)
|
||||
cy.findByTestId('file-tree-inner')
|
||||
.invoke('attr', 'style', 'height: 400px')
|
||||
.click()
|
||||
})
|
||||
|
||||
it('removes the selected indicator', function () {
|
||||
cy.findByRole('treeitem', { selected: true }).should('not.exist')
|
||||
})
|
||||
|
||||
it('disables the "rename" and "delete" buttons', function () {
|
||||
cy.findByRole('button', { name: 'Rename' }).should('not.exist')
|
||||
cy.findByRole('button', { name: 'Delete' }).should('not.exist')
|
||||
})
|
||||
|
||||
it('creates new file in the root folder', function () {
|
||||
cy.intercept('project/*/doc', { statusCode: 200 })
|
||||
|
||||
cy.findByRole('button', { name: /new file/i }).click()
|
||||
cy.findByRole('button', { name: /create/i }).click()
|
||||
|
||||
cy.then(() => {
|
||||
socket.emitToClient('reciveNewDoc', 'root-folder-id', {
|
||||
_id: '12345',
|
||||
name: 'abcdef.tex',
|
||||
docs: [],
|
||||
fileRefs: [],
|
||||
folders: [],
|
||||
})
|
||||
})
|
||||
|
||||
cy.findByRole('treeitem', { name: 'abcdef.tex' }).then($itemEl => {
|
||||
cy.findByTestId('file-tree-list-root').then($rootEl => {
|
||||
expect($itemEl.get(0).parentNode?.parentNode).to.equal($rootEl.get(0))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('starts a new selection', function () {
|
||||
cy.findByRole('treeitem', { name: 'sub.tex' }).should(
|
||||
'have.attr',
|
||||
'aria-selected',
|
||||
'false'
|
||||
)
|
||||
|
||||
cy.findByRole('treeitem', { name: 'main.tex' }).click({
|
||||
ctrlKey: true,
|
||||
cmdKey: true,
|
||||
})
|
||||
|
||||
cy.findByRole('treeitem', { name: 'main.tex' }).should(
|
||||
'have.attr',
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import FileTreeToolbar from '../../../../../frontend/js/features/file-tree/components/file-tree-toolbar'
|
||||
import { EditorProviders } from '../../../helpers/editor-providers'
|
||||
import { FileTreeProvider } from '../helpers/file-tree-provider'
|
||||
|
||||
describe('<FileTreeToolbar/>', function () {
|
||||
it('without selected files', function () {
|
||||
cy.mount(
|
||||
<EditorProviders rootDocId="">
|
||||
<FileTreeProvider>
|
||||
<FileTreeToolbar />
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findAllByRole('button', { name: 'New file' })
|
||||
cy.findAllByRole('button', { name: 'New folder' })
|
||||
cy.findAllByRole('button', { name: 'Upload' })
|
||||
cy.findAllByRole('button', { name: 'Rename' }).should('not.exist')
|
||||
cy.findAllByRole('button', { name: 'Delete' }).should('not.exist')
|
||||
})
|
||||
|
||||
it('read-only', function () {
|
||||
cy.mount(
|
||||
<EditorProviders permissionsLevel="readOnly">
|
||||
<FileTreeProvider>
|
||||
<FileTreeToolbar />
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findAllByRole('button').should('not.exist')
|
||||
})
|
||||
|
||||
it('with one selected file', function () {
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [{ _id: '456def', name: 'main.tex' }],
|
||||
folders: [],
|
||||
fileRefs: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders rootDocId="456def" rootFolder={rootFolder as any}>
|
||||
<FileTreeProvider>
|
||||
<FileTreeToolbar />
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findAllByRole('button', { name: 'New file' })
|
||||
cy.findAllByRole('button', { name: 'New folder' })
|
||||
cy.findAllByRole('button', { name: 'Upload' })
|
||||
cy.findAllByRole('button', { name: 'Rename' })
|
||||
cy.findAllByRole('button', { name: 'Delete' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
import FileTreeRoot from '../../../../../frontend/js/features/file-tree/components/file-tree-root'
|
||||
import { EditorProviders } from '../../../helpers/editor-providers'
|
||||
|
||||
describe('FileTree Context Menu Flow', function () {
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-user', { id: 'user1' })
|
||||
})
|
||||
})
|
||||
|
||||
it('opens on contextMenu event', function () {
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [{ _id: '456def', name: 'main.tex' }],
|
||||
folders: [],
|
||||
fileRefs: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders
|
||||
rootFolder={rootFolder as any}
|
||||
projectId="123abc"
|
||||
rootDocId="456def"
|
||||
>
|
||||
<FileTreeRoot
|
||||
refProviders={{}}
|
||||
setRefProviderEnabled={cy.stub()}
|
||||
setStartedFreeTrial={cy.stub()}
|
||||
onSelect={cy.stub()}
|
||||
onInit={cy.stub()}
|
||||
isConnected
|
||||
/>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('menu').should('not.exist')
|
||||
cy.findByRole('button', { name: 'main.tex' }).trigger('contextmenu')
|
||||
cy.findByRole('menu')
|
||||
})
|
||||
|
||||
it('closes when a new selection is started', function () {
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [
|
||||
{ _id: '456def', name: 'main.tex' },
|
||||
{ _id: '456def', name: 'foo.tex' },
|
||||
],
|
||||
folders: [],
|
||||
fileRefs: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders
|
||||
rootFolder={rootFolder as any}
|
||||
projectId="123abc"
|
||||
rootDocId="456def"
|
||||
>
|
||||
<FileTreeRoot
|
||||
refProviders={{}}
|
||||
setRefProviderEnabled={cy.stub()}
|
||||
setStartedFreeTrial={cy.stub()}
|
||||
onSelect={cy.stub()}
|
||||
onInit={cy.stub()}
|
||||
isConnected
|
||||
/>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('menu').should('not.exist')
|
||||
cy.findByRole('button', { name: 'main.tex' }).trigger('contextmenu')
|
||||
cy.findByRole('menu')
|
||||
cy.findAllByRole('button', { name: 'foo.tex' }).click()
|
||||
cy.findByRole('menu').should('not.exist')
|
||||
})
|
||||
|
||||
it("doesn't open in read only mode", function () {
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [{ _id: '456def', name: 'main.tex' }],
|
||||
folders: [],
|
||||
fileRefs: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders
|
||||
rootFolder={rootFolder as any}
|
||||
projectId="123abc"
|
||||
rootDocId="456def"
|
||||
permissionsLevel="readOnly"
|
||||
>
|
||||
<FileTreeRoot
|
||||
refProviders={{}}
|
||||
setRefProviderEnabled={cy.stub()}
|
||||
setStartedFreeTrial={cy.stub()}
|
||||
onSelect={cy.stub()}
|
||||
onInit={cy.stub()}
|
||||
isConnected
|
||||
/>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findAllByRole('button', { name: 'main.tex' }).trigger('contextmenu')
|
||||
cy.findByRole('menu').should('not.exist')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,281 @@
|
||||
import FileTreeRoot from '../../../../../frontend/js/features/file-tree/components/file-tree-root'
|
||||
import { EditorProviders } from '../../../helpers/editor-providers'
|
||||
import { SocketIOMock } from '@/ide/connection/SocketIoShim'
|
||||
|
||||
describe('FileTree Create Folder Flow', function () {
|
||||
let socket: SocketIOMock
|
||||
beforeEach(function () {
|
||||
socket = new SocketIOMock()
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-user', { id: 'user1' })
|
||||
})
|
||||
})
|
||||
|
||||
it('add to root when no files are selected', function () {
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [{ _id: '456def', name: 'main.tex' }],
|
||||
folders: [],
|
||||
fileRefs: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders
|
||||
rootFolder={rootFolder as any}
|
||||
projectId="123abc"
|
||||
socket={socket}
|
||||
>
|
||||
<FileTreeRoot
|
||||
refProviders={{}}
|
||||
setRefProviderEnabled={cy.stub()}
|
||||
setStartedFreeTrial={cy.stub()}
|
||||
onSelect={cy.stub()}
|
||||
onInit={cy.stub()}
|
||||
isConnected
|
||||
/>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
const name = 'Foo Bar In Root'
|
||||
|
||||
cy.intercept('post', '/project/*/folder', {
|
||||
body: {
|
||||
folders: [],
|
||||
fileRefs: [],
|
||||
docs: [],
|
||||
_id: fakeId(),
|
||||
name,
|
||||
},
|
||||
}).as('createFolder')
|
||||
|
||||
createFolder(name)
|
||||
|
||||
cy.get('@createFolder').its('request.body').should('deep.equal', {
|
||||
parent_folder_id: 'root-folder-id',
|
||||
name,
|
||||
})
|
||||
|
||||
cy.then(() => {
|
||||
socket.emitToClient('reciveNewFolder', 'root-folder-id', {
|
||||
_id: fakeId(),
|
||||
name,
|
||||
docs: [],
|
||||
fileRefs: [],
|
||||
folders: [],
|
||||
})
|
||||
})
|
||||
|
||||
cy.findByRole('treeitem', { name })
|
||||
})
|
||||
|
||||
it('add to folder from folder', function () {
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [],
|
||||
folders: [
|
||||
{
|
||||
_id: '789ghi',
|
||||
name: 'thefolder',
|
||||
docs: [],
|
||||
fileRefs: [],
|
||||
folders: [],
|
||||
},
|
||||
],
|
||||
fileRefs: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders
|
||||
rootFolder={rootFolder as any}
|
||||
projectId="123abc"
|
||||
rootDocId="789ghi"
|
||||
socket={socket}
|
||||
>
|
||||
<FileTreeRoot
|
||||
refProviders={{}}
|
||||
setRefProviderEnabled={cy.stub()}
|
||||
setStartedFreeTrial={cy.stub()}
|
||||
onSelect={cy.stub()}
|
||||
onInit={cy.stub()}
|
||||
isConnected
|
||||
/>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Expand' }).click()
|
||||
|
||||
const name = 'Foo Bar In thefolder'
|
||||
|
||||
cy.intercept('post', '/project/*/folder', {
|
||||
body: {
|
||||
folders: [],
|
||||
fileRefs: [],
|
||||
docs: [],
|
||||
_id: fakeId(),
|
||||
name,
|
||||
},
|
||||
}).as('createFolder')
|
||||
|
||||
createFolder(name)
|
||||
|
||||
cy.get('@createFolder').its('request.body').should('deep.equal', {
|
||||
parent_folder_id: '789ghi',
|
||||
name,
|
||||
})
|
||||
|
||||
cy.then(() => {
|
||||
socket.emitToClient('reciveNewFolder', '789ghi', {
|
||||
_id: fakeId(),
|
||||
name,
|
||||
docs: [],
|
||||
fileRefs: [],
|
||||
folders: [],
|
||||
})
|
||||
})
|
||||
|
||||
// find the created folder
|
||||
cy.findByRole('treeitem', { name })
|
||||
|
||||
// collapse the parent folder; created folder should not be rendered anymore
|
||||
cy.findByRole('button', { name: 'Collapse' }).click()
|
||||
cy.findByRole('treeitem', { name }).should('not.exist')
|
||||
})
|
||||
|
||||
it('add to folder from child', function () {
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [],
|
||||
folders: [
|
||||
{
|
||||
_id: '789ghi',
|
||||
name: 'thefolder',
|
||||
docs: [],
|
||||
fileRefs: [{ _id: '456def', name: 'sub.tex' }],
|
||||
folders: [],
|
||||
},
|
||||
],
|
||||
fileRefs: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders
|
||||
rootFolder={rootFolder as any}
|
||||
projectId="123abc"
|
||||
rootDocId="456def"
|
||||
socket={socket}
|
||||
>
|
||||
<FileTreeRoot
|
||||
refProviders={{}}
|
||||
setRefProviderEnabled={cy.stub()}
|
||||
setStartedFreeTrial={cy.stub()}
|
||||
onSelect={cy.stub()}
|
||||
onInit={cy.stub()}
|
||||
isConnected
|
||||
/>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
const name = 'Foo Bar In thefolder'
|
||||
|
||||
cy.intercept('post', '/project/*/folder', {
|
||||
body: {
|
||||
folders: [],
|
||||
fileRefs: [],
|
||||
docs: [],
|
||||
_id: fakeId(),
|
||||
name,
|
||||
},
|
||||
}).as('createFolder')
|
||||
|
||||
createFolder(name)
|
||||
|
||||
cy.get('@createFolder').its('request.body').should('deep.equal', {
|
||||
parent_folder_id: '789ghi',
|
||||
name,
|
||||
})
|
||||
|
||||
cy.then(() => {
|
||||
socket.emitToClient('reciveNewFolder', '789ghi', {
|
||||
_id: fakeId(),
|
||||
name,
|
||||
docs: [],
|
||||
fileRefs: [],
|
||||
folders: [],
|
||||
})
|
||||
})
|
||||
|
||||
// find the created folder
|
||||
cy.findByRole('treeitem', { name })
|
||||
|
||||
// collapse the parent folder; created folder should not be rendered anymore
|
||||
cy.findByRole('button', { name: 'Collapse' }).click()
|
||||
cy.findByRole('treeitem', { name }).should('not.exist')
|
||||
})
|
||||
|
||||
it('prevents adding duplicate or invalid names', function () {
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [{ _id: '456def', name: 'existingFile' }],
|
||||
folders: [],
|
||||
fileRefs: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders
|
||||
rootFolder={rootFolder as any}
|
||||
projectId="123abc"
|
||||
rootDocId="456def"
|
||||
socket={socket}
|
||||
>
|
||||
<FileTreeRoot
|
||||
refProviders={{}}
|
||||
setRefProviderEnabled={cy.stub()}
|
||||
setStartedFreeTrial={cy.stub()}
|
||||
onSelect={cy.stub()}
|
||||
onInit={cy.stub()}
|
||||
isConnected
|
||||
/>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
const name = 'existingFile'
|
||||
|
||||
cy.intercept('post', '/project/*/folder', cy.spy().as('createFolder'))
|
||||
|
||||
createFolder(name)
|
||||
|
||||
cy.get('@createFolder').should('not.have.been.called')
|
||||
|
||||
cy.findByRole('alert', {
|
||||
name: 'A file or folder with this name already exists',
|
||||
})
|
||||
|
||||
cy.findByRole('textbox').type('in/valid ')
|
||||
|
||||
cy.findByRole('alert', {
|
||||
name: 'File name is empty or contains invalid characters',
|
||||
})
|
||||
})
|
||||
|
||||
function createFolder(name: string) {
|
||||
cy.findByRole('button', { name: 'New folder' }).click()
|
||||
cy.findByRole('textbox').type(name)
|
||||
cy.findByRole('button', { name: 'Create' }).click()
|
||||
}
|
||||
|
||||
function fakeId() {
|
||||
return Math.random().toString(16).replace(/0\./, 'random-test-id-')
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,291 @@
|
||||
import FileTreeRoot from '../../../../../frontend/js/features/file-tree/components/file-tree-root'
|
||||
import { EditorProviders } from '../../../helpers/editor-providers'
|
||||
import { SocketIOMock } from '@/ide/connection/SocketIoShim'
|
||||
|
||||
describe('FileTree Delete Entity Flow', function () {
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-user', { id: 'user1' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('single entity', function () {
|
||||
let socket: SocketIOMock
|
||||
beforeEach(function () {
|
||||
socket = new SocketIOMock()
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [
|
||||
{ _id: '123abc', name: 'foo.tex' },
|
||||
{ _id: '456def', name: 'main.tex' },
|
||||
],
|
||||
folders: [],
|
||||
fileRefs: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<div style={{ width: 400 }}>
|
||||
<EditorProviders
|
||||
rootFolder={rootFolder as any}
|
||||
projectId="123abc"
|
||||
socket={socket}
|
||||
>
|
||||
<FileTreeRoot
|
||||
refProviders={{}}
|
||||
setRefProviderEnabled={cy.stub()}
|
||||
setStartedFreeTrial={cy.stub()}
|
||||
onSelect={cy.stub()}
|
||||
onInit={cy.stub()}
|
||||
isConnected
|
||||
/>
|
||||
</EditorProviders>
|
||||
</div>
|
||||
)
|
||||
|
||||
cy.findByRole('treeitem', { name: 'main.tex' }).click()
|
||||
cy.findByRole('button', { name: 'Open main.tex action menu' }).click()
|
||||
cy.findByRole('menuitem', { name: 'Delete' }).click()
|
||||
})
|
||||
|
||||
it('removes item', function () {
|
||||
cy.intercept('delete', '/project/*/doc/*', { statusCode: 204 }).as(
|
||||
'deleteDoc'
|
||||
)
|
||||
|
||||
cy.findByRole('dialog').within(() => {
|
||||
// check that the confirmation modal is open
|
||||
cy.findByText(
|
||||
'Are you sure you want to permanently delete the following files?'
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Delete' }).click()
|
||||
})
|
||||
|
||||
cy.wait('@deleteDoc')
|
||||
|
||||
cy.then(() => {
|
||||
socket.emitToClient('removeEntity', '456def')
|
||||
})
|
||||
|
||||
cy.findByRole('treeitem', {
|
||||
name: 'main.tex',
|
||||
hidden: true, // treeitem might be hidden behind the modal
|
||||
}).should('not.exist')
|
||||
|
||||
cy.findByRole('treeitem', {
|
||||
name: 'main.tex',
|
||||
}).should('not.exist')
|
||||
|
||||
// check that the confirmation modal is closed
|
||||
cy.findByText(
|
||||
'Are you sure you want to permanently delete the following files?'
|
||||
).should('not.exist')
|
||||
|
||||
cy.get('@deleteDoc.all').should('have.length', 1)
|
||||
})
|
||||
|
||||
it('continues delete on 404s', function () {
|
||||
cy.intercept('delete', '/project/*/doc/*', { statusCode: 404 }).as(
|
||||
'deleteDoc'
|
||||
)
|
||||
|
||||
cy.findByRole('dialog').within(() => {
|
||||
// check that the confirmation modal is open
|
||||
cy.findByText(
|
||||
'Are you sure you want to permanently delete the following files?'
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Delete' }).click()
|
||||
})
|
||||
|
||||
cy.then(() => {
|
||||
socket.emitToClient('removeEntity', '456def')
|
||||
})
|
||||
|
||||
cy.findByRole('treeitem', {
|
||||
name: 'main.tex',
|
||||
hidden: true, // treeitem might be hidden behind the modal
|
||||
}).should('not.exist')
|
||||
|
||||
cy.findByRole('treeitem', {
|
||||
name: 'main.tex',
|
||||
}).should('not.exist')
|
||||
|
||||
// check that the confirmation modal is closed
|
||||
// is not, the 404 probably triggered a bug
|
||||
cy.findByText(
|
||||
'Are you sure you want to permanently delete the following files?'
|
||||
).should('not.exist')
|
||||
})
|
||||
|
||||
it('aborts delete on error', function () {
|
||||
cy.intercept('delete', '/project/*/doc/*', { statusCode: 500 }).as(
|
||||
'deleteDoc'
|
||||
)
|
||||
|
||||
cy.findByRole('dialog').within(() => {
|
||||
cy.findByRole('button', { name: 'Delete' }).click()
|
||||
})
|
||||
|
||||
// The modal should still be open, but the file should not be deleted
|
||||
cy.findByRole('treeitem', { name: 'main.tex', hidden: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('folders', function () {
|
||||
let socket: SocketIOMock
|
||||
beforeEach(function () {
|
||||
socket = new SocketIOMock()
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [{ _id: '456def', name: 'main.tex' }],
|
||||
folders: [
|
||||
{
|
||||
_id: '123abc',
|
||||
name: 'folder',
|
||||
docs: [],
|
||||
folders: [],
|
||||
fileRefs: [{ _id: '789ghi', name: 'my.bib' }],
|
||||
},
|
||||
],
|
||||
fileRefs: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<div style={{ width: 400 }}>
|
||||
<EditorProviders
|
||||
rootFolder={rootFolder as any}
|
||||
projectId="123abc"
|
||||
socket={socket}
|
||||
>
|
||||
<FileTreeRoot
|
||||
refProviders={{}}
|
||||
setRefProviderEnabled={cy.stub()}
|
||||
setStartedFreeTrial={cy.stub()}
|
||||
onSelect={cy.stub()}
|
||||
onInit={cy.stub()}
|
||||
isConnected
|
||||
/>
|
||||
</EditorProviders>
|
||||
</div>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Expand' }).click()
|
||||
cy.findByRole('treeitem', { name: 'main.tex' }).click()
|
||||
cy.findByRole('treeitem', { name: 'my.bib' }).click({
|
||||
ctrlKey: true,
|
||||
cmdKey: true,
|
||||
})
|
||||
|
||||
cy.then(() => {
|
||||
socket.emitToClient('removeEntity', '123abc')
|
||||
})
|
||||
})
|
||||
|
||||
it('removes the folder', function () {
|
||||
cy.findByRole('treeitem', { name: 'folder' }).should('not.exist')
|
||||
})
|
||||
|
||||
it('leaves the main file selected', function () {
|
||||
cy.findByRole('treeitem', { name: 'main.tex', selected: true })
|
||||
})
|
||||
|
||||
it('unselect the child entity', function () {
|
||||
// as a proxy to check that the child entity has been unselect we start
|
||||
// a delete and ensure the modal is displayed (the cancel button can be
|
||||
// selected) This is needed to make sure the test fail.
|
||||
cy.findByRole('button', { name: 'Open main.tex action menu' }).click()
|
||||
cy.findByRole('menuitem', { name: 'Delete' }).click()
|
||||
cy.findByRole('button', { name: 'Cancel' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('multiple entities', function () {
|
||||
let socket: SocketIOMock
|
||||
beforeEach(function () {
|
||||
socket = new SocketIOMock()
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [{ _id: '456def', name: 'main.tex' }],
|
||||
folders: [],
|
||||
fileRefs: [{ _id: '789ghi', name: 'my.bib' }],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<div style={{ width: 400 }}>
|
||||
<EditorProviders
|
||||
rootFolder={rootFolder as any}
|
||||
projectId="123abc"
|
||||
socket={socket}
|
||||
>
|
||||
<FileTreeRoot
|
||||
refProviders={{}}
|
||||
setRefProviderEnabled={cy.stub()}
|
||||
setStartedFreeTrial={cy.stub()}
|
||||
onSelect={cy.stub()}
|
||||
onInit={cy.stub()}
|
||||
isConnected
|
||||
/>
|
||||
</EditorProviders>
|
||||
</div>
|
||||
)
|
||||
|
||||
// select two files
|
||||
cy.findByRole('treeitem', { name: 'main.tex' }).click()
|
||||
cy.findByRole('treeitem', { name: 'my.bib' }).click({
|
||||
ctrlKey: true,
|
||||
cmdKey: true,
|
||||
})
|
||||
|
||||
// open the context menu
|
||||
cy.findByRole('button', { name: 'my.bib' }).trigger('contextmenu')
|
||||
|
||||
// make sure the menu has opened, with only a "Delete" item (as multiple files are selected)
|
||||
cy.findByRole('menu')
|
||||
cy.findAllByRole('menuitem').should('have.length', 1)
|
||||
|
||||
// select the Delete menu item
|
||||
cy.findByRole('menuitem', { name: 'Delete' }).click()
|
||||
})
|
||||
|
||||
it('removes all items and reindexes references after deleting .bib file', function () {
|
||||
cy.intercept('delete', '/project/123abc/doc/456def', {
|
||||
statusCode: 204,
|
||||
}).as('deleteDoc')
|
||||
|
||||
cy.intercept('delete', '/project/123abc/file/789ghi', {
|
||||
statusCode: 204,
|
||||
}).as('deleteFile')
|
||||
|
||||
cy.findByRole('dialog').within(() => {
|
||||
cy.findByRole('button', { name: 'Delete' }).click()
|
||||
})
|
||||
|
||||
cy.then(() => {
|
||||
socket.emitToClient('removeEntity', '456def')
|
||||
socket.emitToClient('removeEntity', '789ghi')
|
||||
})
|
||||
|
||||
for (const name of ['main.tex', 'my.bib']) {
|
||||
for (const hidden of [true, false]) {
|
||||
cy.findByRole('treeitem', { name, hidden }).should('not.exist')
|
||||
}
|
||||
}
|
||||
|
||||
// check that the confirmation modal is closed
|
||||
cy.findByText('Are you sure').should('not.exist')
|
||||
|
||||
cy.get('@deleteDoc.all').should('have.length', 1)
|
||||
cy.get('@deleteFile.all').should('have.length', 1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,164 @@
|
||||
import FileTreeRoot from '../../../../../frontend/js/features/file-tree/components/file-tree-root'
|
||||
import { EditorProviders } from '../../../helpers/editor-providers'
|
||||
import { SocketIOMock } from '@/ide/connection/SocketIoShim'
|
||||
|
||||
describe('FileTree Rename Entity Flow', function () {
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-user', { id: 'user1' })
|
||||
})
|
||||
})
|
||||
|
||||
let socket: SocketIOMock
|
||||
beforeEach(function () {
|
||||
socket = new SocketIOMock()
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [{ _id: '456def', name: 'a.tex' }],
|
||||
folders: [
|
||||
{
|
||||
_id: '987jkl',
|
||||
name: 'folder',
|
||||
docs: [],
|
||||
fileRefs: [
|
||||
{ _id: '789ghi', name: 'c.tex' },
|
||||
{ _id: '981gkp', name: 'e.tex' },
|
||||
],
|
||||
folders: [],
|
||||
},
|
||||
],
|
||||
fileRefs: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<div style={{ width: 400 }}>
|
||||
<EditorProviders
|
||||
rootFolder={rootFolder as any}
|
||||
projectId="123abc"
|
||||
socket={socket}
|
||||
>
|
||||
<FileTreeRoot
|
||||
refProviders={{}}
|
||||
setRefProviderEnabled={cy.stub()}
|
||||
setStartedFreeTrial={cy.stub()}
|
||||
onSelect={cy.stub().as('onSelect')}
|
||||
onInit={cy.stub()}
|
||||
isConnected
|
||||
/>
|
||||
</EditorProviders>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
it('renames doc', function () {
|
||||
cy.intercept('/project/*/doc/*/rename', { statusCode: 204 }).as('renameDoc')
|
||||
|
||||
renameItem('a.tex', 'b.tex')
|
||||
|
||||
cy.findByRole('treeitem', { name: 'b.tex' })
|
||||
|
||||
cy.get('@renameDoc').its('request.body').should('deep.equal', {
|
||||
name: 'b.tex',
|
||||
})
|
||||
})
|
||||
|
||||
it('renames folder', function () {
|
||||
cy.intercept('/project/*/folder/*/rename', { statusCode: 204 }).as(
|
||||
'renameFolder'
|
||||
)
|
||||
|
||||
renameItem('folder', 'new folder name')
|
||||
|
||||
cy.findByRole('treeitem', { name: 'new folder name' })
|
||||
|
||||
cy.get('@renameFolder').its('request.body').should('deep.equal', {
|
||||
name: 'new folder name',
|
||||
})
|
||||
})
|
||||
|
||||
it('renames file in subfolder', function () {
|
||||
cy.intercept('/project/*/file/*/rename', { statusCode: 204 }).as(
|
||||
'renameFile'
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Expand' }).click()
|
||||
|
||||
renameItem('c.tex', 'd.tex')
|
||||
|
||||
cy.findByRole('treeitem', { name: 'folder' })
|
||||
cy.findByRole('treeitem', { name: 'd.tex' })
|
||||
|
||||
cy.get('@renameFile').its('request.body').should('deep.equal', {
|
||||
name: 'd.tex',
|
||||
})
|
||||
})
|
||||
|
||||
it('reverts rename on error', function () {
|
||||
cy.intercept('/project/*/doc/*/rename', { statusCode: 500 })
|
||||
|
||||
renameItem('a.tex', 'b.tex')
|
||||
|
||||
cy.findByRole('treeitem', { name: 'a.tex' })
|
||||
})
|
||||
|
||||
it('shows error modal on invalid filename', function () {
|
||||
renameItem('a.tex', '///')
|
||||
|
||||
cy.findByRole('alert', {
|
||||
name: 'File name is empty or contains invalid characters',
|
||||
hidden: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error modal on duplicate filename', function () {
|
||||
renameItem('a.tex', 'folder')
|
||||
|
||||
cy.findByRole('alert', {
|
||||
name: 'A file or folder with this name already exists',
|
||||
hidden: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error modal on duplicate filename in subfolder', function () {
|
||||
cy.findByRole('button', { name: 'Expand' }).click()
|
||||
|
||||
renameItem('c.tex', 'e.tex')
|
||||
|
||||
cy.findByRole('alert', {
|
||||
name: 'A file or folder with this name already exists',
|
||||
hidden: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error modal on blocked filename', function () {
|
||||
renameItem('a.tex', 'prototype')
|
||||
|
||||
cy.findByRole('alert', {
|
||||
name: 'This file name is blocked.',
|
||||
hidden: true,
|
||||
})
|
||||
})
|
||||
|
||||
describe('via socket event', function () {
|
||||
it('renames doc', function () {
|
||||
cy.findByRole('treeitem', { name: 'a.tex' })
|
||||
|
||||
cy.then(() => {
|
||||
socket.emitToClient('reciveEntityRename', '456def', 'socket.tex')
|
||||
})
|
||||
|
||||
cy.findByRole('treeitem', { name: 'socket.tex' })
|
||||
})
|
||||
})
|
||||
|
||||
function renameItem(from: string, to: string) {
|
||||
cy.findByRole('treeitem', { name: from }).click()
|
||||
cy.findByRole('button', { name: `Open ${from} action menu` }).click()
|
||||
cy.findByRole('menuitem', { name: 'Rename' }).click()
|
||||
cy.findByRole('textbox').clear()
|
||||
cy.findByRole('textbox').type(to + '{enter}')
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ComponentProps, FC, useRef, useState } from 'react'
|
||||
import FileTreeContext from '@/features/file-tree/components/file-tree-context'
|
||||
|
||||
export const FileTreeProvider: FC<{
|
||||
refProviders?: Record<string, boolean>
|
||||
}> = ({ children, refProviders = {} }) => {
|
||||
const [fileTreeContainer, setFileTreeContainer] =
|
||||
useState<HTMLDivElement | null>(null)
|
||||
|
||||
const propsRef =
|
||||
useRef<Omit<ComponentProps<typeof FileTreeContext>, 'refProviders'>>()
|
||||
|
||||
if (propsRef.current === undefined) {
|
||||
propsRef.current = {
|
||||
setRefProviderEnabled: cy.stub().as('setRefProviderEnabled'),
|
||||
setStartedFreeTrial: cy.stub().as('setStartedFreeTrial'),
|
||||
onSelect: cy.stub(),
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={setFileTreeContainer}>
|
||||
{fileTreeContainer && (
|
||||
<FileTreeContext
|
||||
refProviders={refProviders}
|
||||
fileTreeContainer={fileTreeContainer}
|
||||
{...propsRef.current}
|
||||
>
|
||||
<>{children}</>
|
||||
</FileTreeContext>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { expect } from 'chai'
|
||||
|
||||
import iconTypeFromName from '../../../../../frontend/js/features/file-tree/util/icon-type-from-name'
|
||||
|
||||
describe('iconTypeFromName', function () {
|
||||
it('returns correct icon type', function () {
|
||||
expect(iconTypeFromName('main.tex')).to.equal('description')
|
||||
expect(iconTypeFromName('main.png')).to.equal('image')
|
||||
expect(iconTypeFromName('main.csv')).to.equal('table_chart')
|
||||
expect(iconTypeFromName('main.py')).to.equal('code')
|
||||
expect(iconTypeFromName('main.bib')).to.equal('menu_book')
|
||||
})
|
||||
|
||||
it('handles missing extensions', function () {
|
||||
expect(iconTypeFromName('main')).to.equal('description')
|
||||
})
|
||||
|
||||
it('lowercases extension', function () {
|
||||
expect(iconTypeFromName('ZOTERO.BIB')).to.equal('menu_book')
|
||||
})
|
||||
})
|
||||
162
services/web/test/frontend/features/file-tree/util/path.test.ts
Normal file
162
services/web/test/frontend/features/file-tree/util/path.test.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { expect } from 'chai'
|
||||
import { Folder } from '../../../../../types/folder'
|
||||
import { docId } from '../../source-editor/helpers/mock-doc'
|
||||
import {
|
||||
findEntityByPath,
|
||||
pathInFolder,
|
||||
previewByPath,
|
||||
} from '@/features/file-tree/util/path'
|
||||
|
||||
describe('Path utils', function () {
|
||||
let rootFolder: Folder
|
||||
|
||||
beforeEach(function () {
|
||||
rootFolder = {
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [
|
||||
{
|
||||
_id: docId,
|
||||
name: 'main.tex',
|
||||
},
|
||||
],
|
||||
folders: [
|
||||
{
|
||||
_id: 'test-folder-id',
|
||||
name: 'test-folder',
|
||||
docs: [
|
||||
{
|
||||
_id: 'test-doc-in-folder',
|
||||
name: 'example.tex',
|
||||
},
|
||||
],
|
||||
fileRefs: [
|
||||
{
|
||||
_id: 'test-file-in-folder',
|
||||
name: 'example.png',
|
||||
hash: '42',
|
||||
},
|
||||
],
|
||||
folders: [
|
||||
{
|
||||
_id: 'test-subfolder-id',
|
||||
name: 'test-subfolder',
|
||||
docs: [
|
||||
{
|
||||
_id: 'test-doc-in-subfolder',
|
||||
name: 'nested-example.tex',
|
||||
},
|
||||
],
|
||||
fileRefs: [
|
||||
{
|
||||
_id: 'test-file-in-subfolder',
|
||||
name: 'nested-example.png',
|
||||
hash: '43',
|
||||
},
|
||||
],
|
||||
folders: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
fileRefs: [
|
||||
{
|
||||
_id: 'test-image-file',
|
||||
name: 'frog.jpg',
|
||||
hash: '21',
|
||||
},
|
||||
{
|
||||
_id: 'uppercase-extension-image-file',
|
||||
name: 'frog.JPG',
|
||||
hash: '22',
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
describe('pathInFolder', function () {
|
||||
it('gets null path for non-existent entity', function () {
|
||||
const retrieved = pathInFolder(rootFolder, 'non-existent.tex')
|
||||
expect(retrieved).to.be.null
|
||||
})
|
||||
|
||||
it('gets correct path for document in the root', function () {
|
||||
const retrieved = pathInFolder(rootFolder, docId)
|
||||
expect(retrieved).to.equal('main.tex')
|
||||
})
|
||||
|
||||
it('gets correct path for document in a folder', function () {
|
||||
const retrieved = pathInFolder(rootFolder, 'test-doc-in-folder')
|
||||
expect(retrieved).to.equal('test-folder/example.tex')
|
||||
})
|
||||
|
||||
it('gets correct path for document in a nested folder', function () {
|
||||
const retrieved = pathInFolder(rootFolder, 'test-doc-in-subfolder')
|
||||
expect(retrieved).to.equal(
|
||||
'test-folder/test-subfolder/nested-example.tex'
|
||||
)
|
||||
})
|
||||
|
||||
it('gets correct path for file in a nested folder', function () {
|
||||
const retrieved = pathInFolder(rootFolder, 'test-file-in-subfolder')
|
||||
expect(retrieved).to.equal(
|
||||
'test-folder/test-subfolder/nested-example.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('gets correct path for file in a nested folder relative to folder', function () {
|
||||
const retrieved = pathInFolder(
|
||||
rootFolder.folders[0],
|
||||
'test-file-in-subfolder'
|
||||
)
|
||||
expect(retrieved).to.equal('test-subfolder/nested-example.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('findEntityByPath', function () {
|
||||
it('returns null for a non-existent path', function () {
|
||||
const retrieved = findEntityByPath(rootFolder, 'not-a-real-document.tex')
|
||||
expect(retrieved).to.be.null
|
||||
})
|
||||
|
||||
it('finds a document in the root', function () {
|
||||
const retrieved = findEntityByPath(rootFolder, 'main.tex')
|
||||
expect(retrieved?.entity._id).to.equal(docId)
|
||||
})
|
||||
|
||||
it('finds a document in a folder', function () {
|
||||
const retrieved = findEntityByPath(rootFolder, 'test-folder/example.tex')
|
||||
expect(retrieved?.entity._id).to.equal('test-doc-in-folder')
|
||||
})
|
||||
|
||||
it('finds a document in a nested folder', function () {
|
||||
const retrieved = findEntityByPath(
|
||||
rootFolder,
|
||||
'test-folder/test-subfolder/nested-example.tex'
|
||||
)
|
||||
expect(retrieved?.entity._id).to.equal('test-doc-in-subfolder')
|
||||
})
|
||||
|
||||
it('finds a file in a nested folder', function () {
|
||||
const retrieved = findEntityByPath(
|
||||
rootFolder,
|
||||
'test-folder/test-subfolder/nested-example.png'
|
||||
)
|
||||
expect(retrieved?.entity._id).to.equal('test-file-in-subfolder')
|
||||
})
|
||||
})
|
||||
|
||||
describe('previewByPath', function () {
|
||||
it('returns extension without preceding dot', function () {
|
||||
const preview = previewByPath(
|
||||
rootFolder,
|
||||
'test-project-id',
|
||||
'test-folder/example.png'
|
||||
)
|
||||
expect(preview).to.deep.equal({
|
||||
url: '/project/test-project-id/blob/42?fallback=test-file-in-folder',
|
||||
extension: 'png',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
import { screen } from '@testing-library/react'
|
||||
import fetchMock from 'fetch-mock'
|
||||
|
||||
import { renderWithEditorContext } from '../../../helpers/render-with-context'
|
||||
import FileViewHeader from '../../../../../frontend/js/features/file-view/components/file-view-header'
|
||||
import { USER_ID } from '../../../helpers/editor-providers'
|
||||
import { fileViewFile } from '@/features/ide-react/util/file-view'
|
||||
|
||||
describe('<FileViewHeader/>', function () {
|
||||
const urlFile = {
|
||||
name: 'example.tex',
|
||||
linkedFileData: {
|
||||
url: 'https://overleaf.com',
|
||||
provider: 'url',
|
||||
},
|
||||
created: new Date(2021, 1, 17, 3, 24).toISOString(),
|
||||
}
|
||||
|
||||
const projectFile = {
|
||||
name: 'example.tex',
|
||||
linkedFileData: {
|
||||
v1_source_doc_id: 'v1-source-id',
|
||||
source_project_id: 'source-project-id',
|
||||
source_entity_path: '/source-entity-path.ext',
|
||||
provider: 'project_file',
|
||||
importer_id: USER_ID,
|
||||
},
|
||||
created: new Date(2021, 1, 17, 3, 24).toISOString(),
|
||||
}
|
||||
|
||||
const projectOutputFile = {
|
||||
name: 'example.pdf',
|
||||
linkedFileData: {
|
||||
v1_source_doc_id: 'v1-source-id',
|
||||
source_output_file_path: '/source-entity-path.ext',
|
||||
provider: 'project_output_file',
|
||||
},
|
||||
created: new Date(2021, 1, 17, 3, 24).toISOString(),
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
describe('header text', function () {
|
||||
it('Renders the correct text for a file with the url provider', function () {
|
||||
renderWithEditorContext(<FileViewHeader file={urlFile} />)
|
||||
screen.getByText('Imported from', { exact: false })
|
||||
screen.getByText('at 3:24 am Wed, 17th Feb 21', {
|
||||
exact: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('Renders the correct text for a file with the project_file provider', function () {
|
||||
renderWithEditorContext(<FileViewHeader file={projectFile} />)
|
||||
screen.getByText('Imported from', { exact: false })
|
||||
screen.getByText('Another project', { exact: false })
|
||||
screen.getByText('/source-entity-path.ext, at 3:24 am Wed, 17th Feb 21', {
|
||||
exact: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('Renders the correct text for a file with the project_output_file provider', function () {
|
||||
renderWithEditorContext(
|
||||
<FileViewHeader
|
||||
file={projectOutputFile}
|
||||
storeReferencesKeys={() => {}}
|
||||
/>
|
||||
)
|
||||
screen.getByText('Imported from the output of', { exact: false })
|
||||
screen.getByText('Another project', { exact: false })
|
||||
screen.getByText('/source-entity-path.ext, at 3:24 am Wed, 17th Feb 21', {
|
||||
exact: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('The download button', function () {
|
||||
it('exists', function () {
|
||||
renderWithEditorContext(<FileViewHeader file={urlFile} />)
|
||||
|
||||
screen.getByText('Download')
|
||||
})
|
||||
})
|
||||
|
||||
it('should use importedAt as timestamp when present in the linked file data', function () {
|
||||
const fileFromServer = {
|
||||
name: 'example.tex',
|
||||
linkedFileData: {
|
||||
v1_source_doc_id: 'v1-source-id',
|
||||
source_project_id: 'source-project-id',
|
||||
source_entity_path: '/source-entity-path.ext',
|
||||
provider: 'project_file',
|
||||
importer_id: USER_ID,
|
||||
importedAt: new Date(2024, 8, 16, 1, 30).getTime(),
|
||||
},
|
||||
created: new Date(2021, 1, 17, 3, 24).toISOString(),
|
||||
}
|
||||
// FIXME: This should be tested through the <EditorAndPdf /> component instead
|
||||
const fileShown = fileViewFile(fileFromServer)
|
||||
renderWithEditorContext(<FileViewHeader file={fileShown} />)
|
||||
screen.getByText('Imported from', { exact: false })
|
||||
screen.getByText('Another project', { exact: false })
|
||||
screen.getByText('/source-entity-path.ext, at 1:30 am Mon, 16th Sep 24', {
|
||||
exact: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { screen } from '@testing-library/react'
|
||||
|
||||
import { renderWithEditorContext } from '../../../helpers/render-with-context'
|
||||
import FileViewImage from '../../../../../frontend/js/features/file-view/components/file-view-image'
|
||||
|
||||
describe('<FileViewImage />', function () {
|
||||
const file = {
|
||||
id: '60097ca20454610027c442a8',
|
||||
name: 'file.jpg',
|
||||
hash: 'hash',
|
||||
linkedFileData: {
|
||||
source_entity_path: '/source-entity-path',
|
||||
provider: 'project_file',
|
||||
},
|
||||
}
|
||||
|
||||
it('renders an image', function () {
|
||||
renderWithEditorContext(
|
||||
<FileViewImage file={file} onError={() => {}} onLoad={() => {}} />
|
||||
)
|
||||
screen.getByRole('img')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
screen,
|
||||
fireEvent,
|
||||
waitForElementToBeRemoved,
|
||||
} from '@testing-library/react'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import sinon from 'sinon'
|
||||
import FileViewRefreshButton from '@/features/file-view/components/file-view-refresh-button'
|
||||
import { renderWithEditorContext } from '../../../helpers/render-with-context'
|
||||
import { USER_ID } from '../../../helpers/editor-providers'
|
||||
|
||||
describe('<FileViewRefreshButton />', function () {
|
||||
const projectFile = {
|
||||
name: 'example.tex',
|
||||
linkedFileData: {
|
||||
v1_source_doc_id: 'v1-source-id',
|
||||
source_project_id: 'source-project-id',
|
||||
source_entity_path: '/source-entity-path.ext',
|
||||
provider: 'project_file',
|
||||
importer_id: USER_ID,
|
||||
},
|
||||
created: new Date(2021, 1, 17, 3, 24).toISOString(),
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
// eslint-disable-next-line mocha/no-skipped-tests
|
||||
it.skip('Changes text when the file is refreshing', async function () {
|
||||
fetchMock.post(
|
||||
'express:/project/:project_id/linked_file/:file_id/refresh',
|
||||
{
|
||||
new_file_id: '5ff7418157b4e144321df5c4',
|
||||
}
|
||||
)
|
||||
|
||||
renderWithEditorContext(
|
||||
<FileViewRefreshButton
|
||||
file={projectFile}
|
||||
setRefreshError={sinon.stub()}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Refresh' }))
|
||||
|
||||
await waitForElementToBeRemoved(() =>
|
||||
screen.getByText('Refreshing', { exact: false })
|
||||
)
|
||||
|
||||
await screen.findByRole('button', { name: 'Refresh' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import FileViewRefreshError from '@/features/file-view/components/file-view-refresh-error'
|
||||
import type { BinaryFile } from '@/features/file-view/types/binary-file'
|
||||
|
||||
describe('<FileViewRefreshError />', function () {
|
||||
it('shows correct error message', function () {
|
||||
const anotherProjectFile: BinaryFile<'project_file'> = {
|
||||
id: '123abc',
|
||||
_id: '123abc',
|
||||
linkedFileData: {
|
||||
provider: 'project_file',
|
||||
source_project_id: 'some-id',
|
||||
source_entity_path: '/path/',
|
||||
},
|
||||
created: new Date(2023, 1, 17, 3, 24),
|
||||
name: 'frog.jpg',
|
||||
type: 'file',
|
||||
selected: true,
|
||||
hash: '42',
|
||||
}
|
||||
|
||||
render(
|
||||
<FileViewRefreshError
|
||||
file={anotherProjectFile}
|
||||
refreshError="An error message"
|
||||
/>
|
||||
)
|
||||
|
||||
screen.getByText('Access Denied: An error message')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { screen } from '@testing-library/react'
|
||||
import fetchMock from 'fetch-mock'
|
||||
|
||||
import { renderWithEditorContext } from '../../../helpers/render-with-context'
|
||||
import FileViewText from '../../../../../frontend/js/features/file-view/components/file-view-text'
|
||||
|
||||
describe('<FileViewText/>', function () {
|
||||
const file = {
|
||||
id: '123',
|
||||
hash: '1234',
|
||||
name: 'example.tex',
|
||||
linkedFileData: {
|
||||
v1_source_doc_id: 'v1-source-id',
|
||||
source_project_id: 'source-project-id',
|
||||
source_entity_path: '/source-entity-path.ext',
|
||||
provider: 'project_file',
|
||||
},
|
||||
created: new Date(2021, 1, 17, 3, 24).toISOString(),
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('renders a text view', async function () {
|
||||
fetchMock.head('express:/project/:project_id/blob/:hash', {
|
||||
status: 201,
|
||||
headers: { 'Content-Length': 10000 },
|
||||
})
|
||||
fetchMock.get(
|
||||
'express:/project/:project_id/blob/:hash',
|
||||
'Text file content'
|
||||
)
|
||||
|
||||
renderWithEditorContext(
|
||||
<FileViewText file={file} onError={() => {}} onLoad={() => {}} />
|
||||
)
|
||||
|
||||
await screen.findByText('Text file content', { exact: false })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
screen,
|
||||
waitForElementToBeRemoved,
|
||||
fireEvent,
|
||||
} from '@testing-library/react'
|
||||
import fetchMock from 'fetch-mock'
|
||||
|
||||
import { renderWithEditorContext } from '../../../helpers/render-with-context'
|
||||
import FileView from '../../../../../frontend/js/features/file-view/components/file-view'
|
||||
|
||||
describe('<FileView/>', function () {
|
||||
const textFile = {
|
||||
id: 'text-file',
|
||||
name: 'example.tex',
|
||||
linkedFileData: {
|
||||
v1_source_doc_id: 'v1-source-id',
|
||||
source_project_id: 'source-project-id',
|
||||
source_entity_path: '/source-entity-path.ext',
|
||||
provider: 'project_file',
|
||||
},
|
||||
hash: '012345678901234567890123',
|
||||
created: new Date(2021, 1, 17, 3, 24).toISOString(),
|
||||
}
|
||||
|
||||
const imageFile = {
|
||||
id: '60097ca20454610027c442a8',
|
||||
name: 'file.jpg',
|
||||
linkedFileData: {
|
||||
source_entity_path: '/source-entity-path',
|
||||
provider: 'project_file',
|
||||
},
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
describe('for a text file', function () {
|
||||
it('shows a loading indicator while the file is loading', async function () {
|
||||
fetchMock.head('express:/project/:project_id/blob/:hash', {
|
||||
status: 201,
|
||||
headers: { 'Content-Length': 10000 },
|
||||
})
|
||||
fetchMock.get(
|
||||
'express:/project/:project_id/blob/:hash',
|
||||
'Text file content'
|
||||
)
|
||||
|
||||
renderWithEditorContext(<FileView file={textFile} />)
|
||||
|
||||
await waitForElementToBeRemoved(() =>
|
||||
screen.getByTestId('loading-panel-file-view')
|
||||
)
|
||||
})
|
||||
|
||||
it('shows messaging if the text view could not be loaded', async function () {
|
||||
const unpreviewableTextFile = {
|
||||
...textFile,
|
||||
name: 'example.not-tex',
|
||||
}
|
||||
|
||||
renderWithEditorContext(<FileView file={unpreviewableTextFile} />)
|
||||
|
||||
await screen.findByText('Sorry, no preview is available', {
|
||||
exact: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('for an image file', function () {
|
||||
it('shows a loading indicator while the file is loading', async function () {
|
||||
renderWithEditorContext(<FileView file={imageFile} />)
|
||||
|
||||
screen.getByTestId('loading-panel-file-view')
|
||||
})
|
||||
|
||||
it('shows messaging if the image could not be loaded', async function () {
|
||||
renderWithEditorContext(<FileView file={imageFile} />)
|
||||
|
||||
// Fake the image request failing as the request is handled by the browser
|
||||
fireEvent.error(screen.getByRole('img'))
|
||||
|
||||
await screen.findByText('Sorry, no preview is available', {
|
||||
exact: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
import { EditorProviders } from '../../../helpers/editor-providers'
|
||||
import FullProjectSearch from '../../../../../modules/full-project-search/frontend/js/components/full-project-search'
|
||||
import {
|
||||
LayoutContext,
|
||||
LayoutContextValue,
|
||||
} from '@/shared/context/layout-context'
|
||||
import { FC, useState } from 'react'
|
||||
|
||||
describe('<FullProjectSearch/>', function () {
|
||||
beforeEach(function () {
|
||||
cy.interceptCompile()
|
||||
|
||||
cy.intercept('/project/*/flush', {
|
||||
statusCode: 204,
|
||||
}).as('project-history-flush')
|
||||
|
||||
cy.intercept('/project/*/changes?*', {
|
||||
body: [],
|
||||
}).as('project-history-changes')
|
||||
|
||||
cy.intercept('/project/*/latest/history', {
|
||||
body: { chunk: mockHistoryChunk },
|
||||
}).as('project-history-snapshot')
|
||||
|
||||
cy.intercept('get', '/project/*/blob/*', req => {
|
||||
const blobId = req.url.split('/').pop() as string
|
||||
|
||||
req.reply({
|
||||
fixture: `blobs/${blobId}`,
|
||||
})
|
||||
}).as('project-history-blob')
|
||||
})
|
||||
|
||||
it('displays the search form', function () {
|
||||
cy.mount(
|
||||
<EditorProviders providers={{ LayoutProvider }}>
|
||||
<FullProjectSearch />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Search' })
|
||||
})
|
||||
|
||||
it('displays a close button', function () {
|
||||
cy.mount(
|
||||
<EditorProviders providers={{ LayoutProvider }}>
|
||||
<FullProjectSearch />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Close' })
|
||||
})
|
||||
|
||||
it('displays matched content', function () {
|
||||
cy.mount(
|
||||
<EditorProviders providers={{ LayoutProvider }}>
|
||||
<FullProjectSearch />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('searchbox', { name: 'Search' }).type('and{enter}')
|
||||
|
||||
cy.findByRole('button', { name: 'main.tex 5' }) // TODO: remove count from name?
|
||||
|
||||
cy.get('.matched-file-hit').as('matches')
|
||||
cy.get('@matches').should('have.length', 5)
|
||||
|
||||
cy.get('@matches').first().click()
|
||||
cy.get('@matches').first().should('have.class', 'matched-file-hit-selected')
|
||||
})
|
||||
})
|
||||
|
||||
const createInitialValue = () =>
|
||||
({
|
||||
reattach: cy.stub(),
|
||||
detach: cy.stub(),
|
||||
detachIsLinked: false,
|
||||
detachRole: null,
|
||||
changeLayout: cy.stub(),
|
||||
view: 'editor',
|
||||
setView: cy.stub(),
|
||||
chatIsOpen: false,
|
||||
setChatIsOpen: cy.stub(),
|
||||
reviewPanelOpen: false,
|
||||
setReviewPanelOpen: cy.stub(),
|
||||
miniReviewPanelVisible: false,
|
||||
setMiniReviewPanelVisible: cy.stub(),
|
||||
leftMenuShown: false,
|
||||
setLeftMenuShown: cy.stub(),
|
||||
loadingStyleSheet: false,
|
||||
setLoadingStyleSheet: cy.stub(),
|
||||
pdfLayout: 'flat',
|
||||
pdfPreviewOpen: false,
|
||||
projectSearchIsOpen: true,
|
||||
setProjectSearchIsOpen: cy.stub(),
|
||||
}) satisfies LayoutContextValue
|
||||
|
||||
const LayoutProvider: FC = ({ children }) => {
|
||||
const [value] = useState(createInitialValue)
|
||||
|
||||
return (
|
||||
<LayoutContext.Provider value={value}>{children}</LayoutContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const mockHistoryChunk = {
|
||||
history: {
|
||||
snapshot: {
|
||||
files: {},
|
||||
},
|
||||
changes: [
|
||||
{
|
||||
operations: [
|
||||
{
|
||||
pathname: 'main.tex',
|
||||
file: {
|
||||
hash: '5199b66d9d1226551be436c66bad9d962cc05537',
|
||||
stringLength: 7066,
|
||||
},
|
||||
},
|
||||
],
|
||||
timestamp: '2025-01-03T10:10:40.840Z',
|
||||
authors: [],
|
||||
v2Authors: ['66e040e0da7136ec75ffe8a3'],
|
||||
projectVersion: '1.0',
|
||||
},
|
||||
{
|
||||
operations: [
|
||||
{
|
||||
pathname: 'sample.bib',
|
||||
file: {
|
||||
hash: 'a0e21c740cf81e868f158e30e88985b5ea1d6c19',
|
||||
stringLength: 244,
|
||||
},
|
||||
},
|
||||
],
|
||||
timestamp: '2025-01-03T10:10:40.856Z',
|
||||
authors: [],
|
||||
v2Authors: ['66e040e0da7136ec75ffe8a3'],
|
||||
projectVersion: '2.0',
|
||||
},
|
||||
{
|
||||
operations: [
|
||||
{
|
||||
pathname: 'frog.jpg',
|
||||
file: {
|
||||
hash: '5b889ef3cf71c83a4c027c4e4dc3d1a106b27809',
|
||||
byteLength: 97080,
|
||||
},
|
||||
},
|
||||
],
|
||||
timestamp: '2025-01-03T10:10:40.890Z',
|
||||
authors: [],
|
||||
v2Authors: ['66e040e0da7136ec75ffe8a3'],
|
||||
projectVersion: '3.0',
|
||||
},
|
||||
],
|
||||
},
|
||||
startVersion: 0,
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import AddSeats, {
|
||||
MAX_NUMBER_OF_USERS,
|
||||
} from '@/features/group-management/components/add-seats/add-seats'
|
||||
|
||||
describe('<AddSeats />', function () {
|
||||
beforeEach(function () {
|
||||
this.totalLicenses = 5
|
||||
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
|
||||
win.metaAttributesCache.set('ol-subscriptionId', '123')
|
||||
win.metaAttributesCache.set('ol-totalLicenses', this.totalLicenses)
|
||||
win.metaAttributesCache.set('ol-isProfessional', false)
|
||||
})
|
||||
|
||||
cy.mount(<AddSeats />)
|
||||
|
||||
cy.findByRole('button', { name: /buy licenses/i })
|
||||
cy.findByTestId('add-more-users-group-form')
|
||||
})
|
||||
|
||||
it('renders the back button', function () {
|
||||
cy.findByTestId('group-heading').within(() => {
|
||||
cy.findByRole('button', { name: /back to subscription/i }).should(
|
||||
'have.attr',
|
||||
'href',
|
||||
'/user/subscription'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the group name', function () {
|
||||
cy.findByTestId('group-heading').within(() => {
|
||||
cy.findByRole('heading', { name: 'My Awesome Team' })
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the "Buy more licenses" label', function () {
|
||||
cy.findByText(/buy more licenses/i)
|
||||
})
|
||||
|
||||
it('shows the maximum supported users', function () {
|
||||
cy.findByText(
|
||||
new RegExp(
|
||||
`your current plan supports up to ${this.totalLicenses} licenses`,
|
||||
'i'
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('shows instructions on how to reduce licenses on a plan', function () {
|
||||
cy.contains(
|
||||
/if you want to reduce the number of licenses on your plan, please contact customer support/i
|
||||
).within(() => {
|
||||
cy.findByRole('link', { name: /contact customer support/i }).should(
|
||||
'have.attr',
|
||||
'href',
|
||||
'/contact'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the cancel button', function () {
|
||||
cy.findByRole('button', { name: /cancel/i }).should(
|
||||
'have.attr',
|
||||
'href',
|
||||
'/user/subscription'
|
||||
)
|
||||
})
|
||||
|
||||
describe('"Upgrade my plan" link', function () {
|
||||
it('shows the link', function () {
|
||||
cy.findByRole('link', { name: /upgrade my plan/i }).should(
|
||||
'have.attr',
|
||||
'href',
|
||||
'/user/subscription/group/upgrade-subscription'
|
||||
)
|
||||
})
|
||||
|
||||
it('hides the link', function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-isProfessional', true)
|
||||
})
|
||||
|
||||
cy.mount(<AddSeats />)
|
||||
|
||||
cy.findByRole('link', { name: /upgrade my plan/i }).should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cost summary', function () {
|
||||
beforeEach(function () {
|
||||
cy.findByLabelText(/how many licenses do you want to buy/i).as('input')
|
||||
})
|
||||
|
||||
it('shows the title', function () {
|
||||
cy.findByTestId('cost-summary').within(() => {
|
||||
cy.findByText(/cost summary/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shows default content when', function () {
|
||||
afterEach(function () {
|
||||
cy.findByTestId('cost-summary').within(() => {
|
||||
cy.findByText(
|
||||
/enter the number of licenses you’d like to add to see the cost breakdown/i
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves input empty', function () {
|
||||
cy.get('@input').should('have.value', '')
|
||||
})
|
||||
|
||||
it('fills in a non-numeric value', function () {
|
||||
cy.get('@input').type('ab')
|
||||
cy.findByText(/value must be a number/i)
|
||||
})
|
||||
|
||||
it('fills in a decimal value', function () {
|
||||
cy.get('@input').type('1.5')
|
||||
cy.findByText(/value must be a whole number/i)
|
||||
})
|
||||
|
||||
it('fills in a "0" value', function () {
|
||||
cy.get('@input').type('0')
|
||||
cy.findByText(/value must be at least 1/i)
|
||||
})
|
||||
|
||||
it('fills in a value and clears the input', function () {
|
||||
cy.get('@input').type('a{backspace}')
|
||||
cy.get('@input').should('have.text', '')
|
||||
cy.findByText(/this field is required/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('entered more than the maximum allowed number of users', function () {
|
||||
beforeEach(function () {
|
||||
this.numberOfUsersExceedingMaxLimit = MAX_NUMBER_OF_USERS + 1
|
||||
|
||||
cy.get('@input').type(this.numberOfUsersExceedingMaxLimit.toString())
|
||||
cy.findByRole('button', { name: /buy licenses/i }).should('not.exist')
|
||||
cy.findByRole('button', { name: /send request/i }).as('sendRequestBtn')
|
||||
})
|
||||
|
||||
it('renders a notification', function () {
|
||||
cy.findByTestId('cost-summary').should('not.exist')
|
||||
cy.findByRole('alert').should(
|
||||
'contain.text',
|
||||
`If you want more than ${MAX_NUMBER_OF_USERS} licenses on your plan, we need to add them for you. Just click Send request below and we’ll be happy to help.`
|
||||
)
|
||||
})
|
||||
|
||||
describe('request', function () {
|
||||
afterEach(function () {
|
||||
cy.findByRole('button', { name: /go to subscriptions/i }).should(
|
||||
'have.attr',
|
||||
'href',
|
||||
'/user/subscription'
|
||||
)
|
||||
})
|
||||
|
||||
function makeRequest(statusCode: number, adding: string) {
|
||||
cy.intercept(
|
||||
'POST',
|
||||
'/user/subscription/group/add-users/sales-contact-form',
|
||||
{
|
||||
statusCode,
|
||||
}
|
||||
).as('addUsersRequest')
|
||||
cy.get('@sendRequestBtn').click()
|
||||
cy.get('@addUsersRequest').its('request.body').should('deep.equal', {
|
||||
adding,
|
||||
})
|
||||
cy.findByTestId('add-more-users-group-form').should('not.exist')
|
||||
}
|
||||
|
||||
it('sends a request that succeeds', function () {
|
||||
makeRequest(204, this.numberOfUsersExceedingMaxLimit.toString())
|
||||
cy.findByTestId('title').should(
|
||||
'contain.text',
|
||||
'We’ve got your request'
|
||||
)
|
||||
cy.findByText(/our team will get back to you shortly/i)
|
||||
})
|
||||
|
||||
it('sends a request that fails', function () {
|
||||
makeRequest(400, this.numberOfUsersExceedingMaxLimit.toString())
|
||||
cy.findByTestId('title').should(
|
||||
'contain.text',
|
||||
'Something went wrong'
|
||||
)
|
||||
cy.contains(
|
||||
/it looks like that didn’t work. You can try again or get in touch with our Support team for more help/i
|
||||
).within(() => {
|
||||
cy.findByRole('link', { name: /get in touch/i }).should(
|
||||
'have.attr',
|
||||
'href',
|
||||
'/contact'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('entered less than the maximum allowed number of users', function () {
|
||||
beforeEach(function () {
|
||||
this.adding = 1
|
||||
this.body = {
|
||||
change: {
|
||||
type: 'add-on-update',
|
||||
addOn: {
|
||||
code: 'additional-license',
|
||||
quantity: this.totalLicenses + this.adding,
|
||||
prevQuantity: this.totalLicenses,
|
||||
},
|
||||
},
|
||||
currency: 'USD',
|
||||
immediateCharge: {
|
||||
subtotal: 100,
|
||||
tax: 20,
|
||||
total: 120,
|
||||
discount: 0,
|
||||
},
|
||||
nextInvoice: {
|
||||
date: '2025-12-01T00:00:00.000Z',
|
||||
plan: {
|
||||
name: 'Overleaf Standard Group',
|
||||
amount: 0,
|
||||
},
|
||||
subtotal: 895,
|
||||
tax: {
|
||||
rate: 0.2,
|
||||
amount: 105,
|
||||
},
|
||||
total: 1000,
|
||||
},
|
||||
}
|
||||
|
||||
cy.findByRole('button', { name: /buy licenses/i }).as('addUsersBtn')
|
||||
cy.findByRole('button', { name: /send request/i }).should('not.exist')
|
||||
})
|
||||
|
||||
it('renders the preview data', function () {
|
||||
cy.intercept('POST', '/user/subscription/group/add-users/preview', {
|
||||
statusCode: 200,
|
||||
body: this.body,
|
||||
}).as('addUsersRequest')
|
||||
cy.get('@input').type(this.adding.toString())
|
||||
|
||||
cy.findByTestId('cost-summary').within(() => {
|
||||
cy.contains(
|
||||
new RegExp(
|
||||
`you’re adding ${this.adding} licenses to your plan giving you a total of ${this.body.change.addOn.quantity} licenses`,
|
||||
'i'
|
||||
)
|
||||
)
|
||||
|
||||
cy.findByTestId('plan').within(() => {
|
||||
cy.findByText(
|
||||
`${this.body.nextInvoice.plan.name} x ${this.adding} Licenses`
|
||||
)
|
||||
cy.findByTestId('price').should(
|
||||
'have.text',
|
||||
`$${this.body.immediateCharge.subtotal}.00`
|
||||
)
|
||||
})
|
||||
|
||||
cy.findByTestId('tax').within(() => {
|
||||
cy.findByText(
|
||||
new RegExp(`VAT · ${this.body.nextInvoice.tax.rate * 100}%`, 'i')
|
||||
)
|
||||
cy.findByTestId('price').should(
|
||||
'have.text',
|
||||
`$${this.body.immediateCharge.tax}.00`
|
||||
)
|
||||
})
|
||||
|
||||
cy.findByTestId('discount').should('not.exist')
|
||||
|
||||
cy.findByTestId('total').within(() => {
|
||||
cy.findByText(/total due today/i)
|
||||
cy.findByTestId('price').should(
|
||||
'have.text',
|
||||
`$${this.body.immediateCharge.total}.00`
|
||||
)
|
||||
})
|
||||
|
||||
cy.findByText(
|
||||
/we’ll charge you now for the cost of your additional licenses based on the remaining months of your current subscription/i
|
||||
)
|
||||
cy.findByText(
|
||||
/after that, we’ll bill you \$1,000\.00 \(\$895\.00 \+ \$105\.00 tax\) annually on December 1, unless you cancel/i
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the preview data with discount', function () {
|
||||
this.body.immediateCharge.discount = 50
|
||||
|
||||
cy.intercept('POST', '/user/subscription/group/add-users/preview', {
|
||||
statusCode: 200,
|
||||
body: this.body,
|
||||
}).as('addUsersRequest')
|
||||
cy.get('@input').type(this.adding.toString())
|
||||
|
||||
cy.findByTestId('cost-summary').within(() => {
|
||||
cy.findByTestId('discount').within(() => {
|
||||
cy.findByText(`($${this.body.immediateCharge.discount}.00)`)
|
||||
})
|
||||
|
||||
cy.findByText(
|
||||
/This does not include your current discounts, which will be applied automatically before your next payment/i
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('request', function () {
|
||||
afterEach(function () {
|
||||
cy.findByRole('button', { name: /go to subscriptions/i }).should(
|
||||
'have.attr',
|
||||
'href',
|
||||
'/user/subscription'
|
||||
)
|
||||
})
|
||||
|
||||
function makeRequest(statusCode: number, adding: string) {
|
||||
cy.intercept('POST', '/user/subscription/group/add-users/create', {
|
||||
statusCode,
|
||||
}).as('addUsersRequest')
|
||||
cy.get('@input').type(adding)
|
||||
cy.get('@addUsersBtn').click()
|
||||
cy.get('@addUsersRequest')
|
||||
.its('request.body')
|
||||
.should('deep.equal', {
|
||||
adding: Number(adding),
|
||||
})
|
||||
cy.findByTestId('add-more-users-group-form').should('not.exist')
|
||||
}
|
||||
|
||||
it('sends a request that succeeds', function () {
|
||||
makeRequest(204, this.adding.toString())
|
||||
cy.findByTestId('title').should(
|
||||
'contain.text',
|
||||
'You’ve added more license(s)'
|
||||
)
|
||||
cy.findByText(/you’ve added more license\(s\) to your subscription/i)
|
||||
cy.findByRole('link', { name: /invite people/i }).should(
|
||||
'have.attr',
|
||||
'href',
|
||||
'/manage/groups/123/members'
|
||||
)
|
||||
})
|
||||
|
||||
it('sends a request that fails', function () {
|
||||
makeRequest(400, this.adding.toString())
|
||||
cy.findByTestId('title').should(
|
||||
'contain.text',
|
||||
'Something went wrong'
|
||||
)
|
||||
cy.contains(
|
||||
/it looks like that didn’t work. You can try again or get in touch with our Support team for more help/i
|
||||
).within(() => {
|
||||
cy.findByRole('link', { name: /get in touch/i }).should(
|
||||
'have.attr',
|
||||
'href',
|
||||
'/contact'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,175 @@
|
||||
import GroupManagers from '@/features/group-management/components/group-managers'
|
||||
|
||||
const JOHN_DOE = {
|
||||
_id: 'abc123def456',
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
email: 'john.doe@test.com',
|
||||
last_active_at: new Date('2023-01-15'),
|
||||
invite: true,
|
||||
}
|
||||
const BOBBY_LAPOINTE = {
|
||||
_id: 'bcd234efa567',
|
||||
first_name: 'Bobby',
|
||||
last_name: 'Lapointe',
|
||||
email: 'bobby.lapointe@test.com',
|
||||
last_active_at: new Date('2023-01-02'),
|
||||
invite: false,
|
||||
}
|
||||
const GROUP_ID = '888fff888fff'
|
||||
const PATHS = {
|
||||
addMember: `/manage/groups/${GROUP_ID}/managers`,
|
||||
removeMember: `/manage/groups/${GROUP_ID}/managers`,
|
||||
}
|
||||
|
||||
describe('group managers', function () {
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [JOHN_DOE, BOBBY_LAPOINTE])
|
||||
win.metaAttributesCache.set('ol-groupId', GROUP_ID)
|
||||
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
|
||||
})
|
||||
|
||||
cy.mount(<GroupManagers />)
|
||||
})
|
||||
|
||||
it('renders the group management page', function () {
|
||||
cy.findByRole('heading', { name: /my awesome team/i, level: 1 })
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByText('john.doe@test.com')
|
||||
cy.findByText('John Doe')
|
||||
cy.findByText('15th Jan 2023')
|
||||
cy.findByText('Invite not yet accepted')
|
||||
})
|
||||
|
||||
cy.get('tr:nth-child(2)').within(() => {
|
||||
cy.findByText('bobby.lapointe@test.com')
|
||||
cy.findByText('Bobby Lapointe')
|
||||
cy.findByText('2nd Jan 2023')
|
||||
cy.findByText('Accepted invite')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('sends an invite', function () {
|
||||
cy.intercept('POST', PATHS.addMember, {
|
||||
statusCode: 201,
|
||||
body: {
|
||||
user: {
|
||||
email: 'someone.else@test.com',
|
||||
invite: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cy.findByTestId('add-members-form').within(() => {
|
||||
cy.findByRole('textbox').type('someone.else@test.com')
|
||||
cy.findByRole('button').click()
|
||||
})
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(3)').within(() => {
|
||||
cy.findByText('someone.else@test.com')
|
||||
cy.findByText('N/A')
|
||||
cy.findByText('Invite not yet accepted')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('tries to send an invite and displays the error', function () {
|
||||
cy.intercept('POST', PATHS.addMember, {
|
||||
statusCode: 500,
|
||||
body: {
|
||||
error: {
|
||||
message: 'User already added',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cy.findByTestId('add-members-form').within(() => {
|
||||
cy.findByRole('textbox').type('someone.else@test.com')
|
||||
cy.findByRole('button').click()
|
||||
})
|
||||
cy.findByRole('alert').should('contain.text', 'Error: User already added')
|
||||
})
|
||||
|
||||
it('checks the select all checkbox', function () {
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByLabelText(/select user/i).should('not.be.checked')
|
||||
})
|
||||
cy.get('tr:nth-child(2)').within(() => {
|
||||
cy.findByLabelText(/select user/i).should('not.be.checked')
|
||||
})
|
||||
})
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('thead')
|
||||
.within(() => {
|
||||
cy.findByLabelText(/select all/i).check()
|
||||
})
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByLabelText(/select user/i).should('be.checked')
|
||||
})
|
||||
cy.get('tr:nth-child(2)').within(() => {
|
||||
cy.findByLabelText(/select user/i).should('be.checked')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('remove a member', function () {
|
||||
cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
|
||||
statusCode: 200,
|
||||
})
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByLabelText(/select user/i).check()
|
||||
})
|
||||
})
|
||||
|
||||
cy.findByRole('button', { name: /remove manager/i }).click()
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByText('bobby.lapointe@test.com')
|
||||
cy.findByText('Bobby Lapointe')
|
||||
cy.findByText('2nd Jan 2023')
|
||||
cy.findByText('Accepted invite')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('tries to remove a manager and displays the error', function () {
|
||||
cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
|
||||
statusCode: 500,
|
||||
})
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByLabelText(/select user/i).check()
|
||||
})
|
||||
})
|
||||
cy.findByRole('button', { name: /remove manager/i }).click()
|
||||
|
||||
cy.findByRole('alert').should('contain.text', 'Sorry, something went wrong')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,577 @@
|
||||
import GroupMembers from '@/features/group-management/components/group-members'
|
||||
import { GroupMembersProvider } from '@/features/group-management/context/group-members-context'
|
||||
import { User } from '../../../../../types/group-management/user'
|
||||
|
||||
const GROUP_ID = '777fff777fff'
|
||||
const PATHS = {
|
||||
addMember: `/manage/groups/${GROUP_ID}/invites`,
|
||||
removeMember: `/manage/groups/${GROUP_ID}/user`,
|
||||
removeInvite: `/manage/groups/${GROUP_ID}/invites`,
|
||||
exportMembers: `/manage/groups/${GROUP_ID}/members/export`,
|
||||
}
|
||||
|
||||
describe('GroupMembers', function () {
|
||||
function mountGroupMembersProvider() {
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<GroupMembers />
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('with Managed Users and Group SSO disabled', function () {
|
||||
const JOHN_DOE = {
|
||||
_id: 'abc123def456',
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
email: 'john.doe@test.com',
|
||||
last_active_at: new Date('2023-01-15'),
|
||||
invite: true,
|
||||
}
|
||||
const BOBBY_LAPOINTE = {
|
||||
_id: 'bcd234efa567',
|
||||
first_name: 'Bobby',
|
||||
last_name: 'Lapointe',
|
||||
email: 'bobby.lapointe@test.com',
|
||||
last_active_at: new Date('2023-01-02'),
|
||||
invite: false,
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-groupId', GROUP_ID)
|
||||
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
|
||||
win.metaAttributesCache.set('ol-groupSize', 10)
|
||||
win.metaAttributesCache.set('ol-users', [JOHN_DOE, BOBBY_LAPOINTE])
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<GroupMembers />
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
})
|
||||
|
||||
it('renders the group members page', function () {
|
||||
cy.findByRole('heading', { name: /my awesome team/i, level: 1 })
|
||||
cy.findByTestId('page-header-members-details').contains(
|
||||
'You have added 2 of 10 available members'
|
||||
)
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.contains('john.doe@test.com')
|
||||
cy.contains('John Doe')
|
||||
cy.contains('15th Jan 2023')
|
||||
cy.findByTestId('badge-pending-invite').should(
|
||||
'have.text',
|
||||
'Pending invite'
|
||||
)
|
||||
})
|
||||
|
||||
cy.get('tr:nth-child(2)').within(() => {
|
||||
cy.contains('bobby.lapointe@test.com')
|
||||
cy.contains('Bobby Lapointe')
|
||||
cy.contains('2nd Jan 2023')
|
||||
cy.findByTestId('badge-pending-invite').should('not.exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('sends an invite', function () {
|
||||
cy.intercept('POST', PATHS.addMember, {
|
||||
statusCode: 201,
|
||||
body: {
|
||||
user: {
|
||||
email: 'someone.else@test.com',
|
||||
invite: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cy.get('.form-control').type('someone.else@test.com')
|
||||
cy.get('.add-more-members-form button').click()
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(3)').within(() => {
|
||||
cy.contains('someone.else@test.com')
|
||||
cy.contains('N/A')
|
||||
cy.findByTestId('badge-pending-invite').should(
|
||||
'have.text',
|
||||
'Pending invite'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('tries to send an invite and displays the error', function () {
|
||||
cy.intercept('POST', PATHS.addMember, {
|
||||
statusCode: 500,
|
||||
body: {
|
||||
error: {
|
||||
message: 'User already added',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cy.get('.form-control').type('someone.else@test.com')
|
||||
cy.get('.add-more-members-form button').click()
|
||||
cy.findByRole('alert').contains('Error: User already added')
|
||||
})
|
||||
|
||||
it('checks the select all checkbox', function () {
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByTestId('select-single-checkbox').should('not.be.checked')
|
||||
})
|
||||
cy.get('tr:nth-child(2)').within(() => {
|
||||
cy.findByTestId('select-single-checkbox').should('not.be.checked')
|
||||
})
|
||||
})
|
||||
|
||||
cy.findByTestId('select-all-checkbox').click()
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByTestId('select-single-checkbox').should('be.checked')
|
||||
})
|
||||
cy.get('tr:nth-child(2)').within(() => {
|
||||
cy.findByTestId('select-single-checkbox').should('be.checked')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('remove a member', function () {
|
||||
cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
|
||||
statusCode: 200,
|
||||
})
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByTestId('select-single-checkbox').check()
|
||||
})
|
||||
})
|
||||
|
||||
cy.get('button').contains('Remove from group').click()
|
||||
|
||||
cy.get('small').contains('You have added 1 of 10 available members')
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.contains('bobby.lapointe@test.com')
|
||||
cy.contains('Bobby Lapointe')
|
||||
cy.contains('2nd Jan 2023')
|
||||
cy.contains('Pending invite').should('not.exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('tries to remove a user and displays the error', function () {
|
||||
cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
|
||||
statusCode: 500,
|
||||
})
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByTestId('select-single-checkbox').check()
|
||||
})
|
||||
})
|
||||
cy.get('button').contains('Remove from group').click()
|
||||
|
||||
cy.findByRole('alert').contains('Sorry, something went wrong')
|
||||
})
|
||||
})
|
||||
|
||||
describe('with Managed Users enabled', function () {
|
||||
const JOHN_DOE: User = {
|
||||
_id: 'abc123def456',
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
email: 'john.doe@test.com',
|
||||
last_active_at: new Date('2023-01-15'),
|
||||
invite: true,
|
||||
}
|
||||
const BOBBY_LAPOINTE: User = {
|
||||
_id: 'bcd234efa567',
|
||||
first_name: 'Bobby',
|
||||
last_name: 'Lapointe',
|
||||
email: 'bobby.lapointe@test.com',
|
||||
last_active_at: new Date('2023-01-02'),
|
||||
invite: false,
|
||||
}
|
||||
const CLAIRE_JENNINGS: User = {
|
||||
_id: 'defabc231453',
|
||||
first_name: 'Claire',
|
||||
last_name: 'Jennings',
|
||||
email: 'claire.jennings@test.com',
|
||||
last_active_at: new Date('2023-01-03'),
|
||||
invite: false,
|
||||
enrollment: {
|
||||
managedBy: GROUP_ID,
|
||||
enrolledAt: new Date('2023-01-03'),
|
||||
sso: [
|
||||
{
|
||||
groupId: GROUP_ID,
|
||||
linkedAt: new Date(),
|
||||
primary: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [
|
||||
JOHN_DOE,
|
||||
BOBBY_LAPOINTE,
|
||||
CLAIRE_JENNINGS,
|
||||
])
|
||||
win.metaAttributesCache.set('ol-groupId', GROUP_ID)
|
||||
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
|
||||
win.metaAttributesCache.set('ol-groupSize', 10)
|
||||
win.metaAttributesCache.set('ol-managedUsersActive', true)
|
||||
})
|
||||
mountGroupMembersProvider()
|
||||
})
|
||||
|
||||
it('renders the group members page', function () {
|
||||
cy.get('h1').contains('My Awesome Team')
|
||||
cy.get('small').contains('You have added 3 of 10 available members')
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.contains('john.doe@test.com')
|
||||
cy.contains('John Doe')
|
||||
cy.contains('15th Jan 2023')
|
||||
cy.get('.visually-hidden').contains('Pending invite')
|
||||
cy.findByTestId('badge-pending-invite').should(
|
||||
'have.text',
|
||||
'Pending invite'
|
||||
)
|
||||
cy.get(`.security-state-invite-pending`).should('exist')
|
||||
})
|
||||
|
||||
cy.get('tr:nth-child(2)').within(() => {
|
||||
cy.contains('bobby.lapointe@test.com')
|
||||
cy.contains('Bobby Lapointe')
|
||||
cy.contains('2nd Jan 2023')
|
||||
cy.findByTestId('badge-pending-invite').should('not.exist')
|
||||
cy.get('.visually-hidden').contains('Not managed')
|
||||
})
|
||||
|
||||
cy.get('tr:nth-child(3)').within(() => {
|
||||
cy.contains('claire.jennings@test.com')
|
||||
cy.contains('Claire Jennings')
|
||||
cy.contains('3rd Jan 2023')
|
||||
cy.findByTestId('badge-pending-invite').should('not.exist')
|
||||
cy.get('.visually-hidden').contains('Managed')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('sends an invite', function () {
|
||||
cy.intercept('POST', PATHS.addMember, {
|
||||
statusCode: 201,
|
||||
body: {
|
||||
user: {
|
||||
email: 'someone.else@test.com',
|
||||
invite: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cy.get('.form-control').type('someone.else@test.com')
|
||||
cy.get('.add-more-members-form button').click()
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(4)').within(() => {
|
||||
cy.contains('someone.else@test.com')
|
||||
cy.contains('N/A')
|
||||
cy.get('.visually-hidden').contains('Pending invite')
|
||||
cy.findByTestId('badge-pending-invite').should(
|
||||
'have.text',
|
||||
'Pending invite'
|
||||
)
|
||||
cy.get(`.security-state-invite-pending`).should('exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('tries to send an invite and displays the error', function () {
|
||||
cy.intercept('POST', PATHS.addMember, {
|
||||
statusCode: 500,
|
||||
body: {
|
||||
error: {
|
||||
message: 'User already added',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cy.get('.form-control').type('someone.else@test.com')
|
||||
cy.get('.add-more-members-form button').click()
|
||||
cy.findByRole('alert').contains('Error: User already added')
|
||||
})
|
||||
|
||||
it('checks the select all checkbox', function () {
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByTestId('select-single-checkbox').should('not.be.checked')
|
||||
})
|
||||
cy.get('tr:nth-child(2)').within(() => {
|
||||
cy.findByTestId('select-single-checkbox').should('not.be.checked')
|
||||
})
|
||||
})
|
||||
|
||||
cy.findByTestId('select-all-checkbox').click()
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByTestId('select-single-checkbox').should('be.checked')
|
||||
})
|
||||
cy.get('tr:nth-child(2)').within(() => {
|
||||
cy.findByTestId('select-single-checkbox').should('be.checked')
|
||||
})
|
||||
})
|
||||
|
||||
cy.get('button').contains('Remove from group').click()
|
||||
})
|
||||
|
||||
it('remove a member', function () {
|
||||
cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
|
||||
statusCode: 200,
|
||||
})
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByTestId('select-single-checkbox').check()
|
||||
})
|
||||
})
|
||||
|
||||
cy.get('button').contains('Remove from group').click()
|
||||
|
||||
cy.get('small').contains('You have added 2 of 10 available members')
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.contains('bobby.lapointe@test.com')
|
||||
cy.contains('Bobby Lapointe')
|
||||
cy.contains('2nd Jan 2023')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('cannot remove a managed member', function () {
|
||||
cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
|
||||
statusCode: 200,
|
||||
})
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
// no checkbox should be shown for 'Claire Jennings', a managed user
|
||||
cy.get('tr:nth-child(3)').within(() => {
|
||||
cy.findByTestId('select-single-checkbox').should('not.exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('tries to remove a user and displays the error', function () {
|
||||
cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
|
||||
statusCode: 500,
|
||||
})
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByTestId('select-single-checkbox').check()
|
||||
})
|
||||
})
|
||||
cy.get('.page-header').within(() => {
|
||||
cy.get('button').contains('Remove from group').click()
|
||||
})
|
||||
|
||||
cy.findByRole('alert').contains('Sorry, something went wrong')
|
||||
})
|
||||
})
|
||||
|
||||
describe('with Group SSO enabled', function () {
|
||||
const JOHN_DOE: User = {
|
||||
_id: 'abc123def456',
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
email: 'john.doe@test.com',
|
||||
last_active_at: new Date('2023-01-15'),
|
||||
invite: true,
|
||||
}
|
||||
const BOBBY_LAPOINTE: User = {
|
||||
_id: 'bcd234efa567',
|
||||
first_name: 'Bobby',
|
||||
last_name: 'Lapointe',
|
||||
email: 'bobby.lapointe@test.com',
|
||||
last_active_at: new Date('2023-01-02'),
|
||||
invite: false,
|
||||
}
|
||||
const CLAIRE_JENNINGS: User = {
|
||||
_id: 'defabc231453',
|
||||
first_name: 'Claire',
|
||||
last_name: 'Jennings',
|
||||
email: 'claire.jennings@test.com',
|
||||
last_active_at: new Date('2023-01-03'),
|
||||
invite: false,
|
||||
enrollment: {
|
||||
managedBy: GROUP_ID,
|
||||
enrolledAt: new Date('2023-01-03'),
|
||||
sso: [
|
||||
{
|
||||
groupId: GROUP_ID,
|
||||
linkedAt: new Date(),
|
||||
primary: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [
|
||||
JOHN_DOE,
|
||||
BOBBY_LAPOINTE,
|
||||
CLAIRE_JENNINGS,
|
||||
])
|
||||
win.metaAttributesCache.set('ol-groupId', GROUP_ID)
|
||||
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
|
||||
win.metaAttributesCache.set('ol-groupSize', 10)
|
||||
win.metaAttributesCache.set('ol-managedUsersActive', false)
|
||||
win.metaAttributesCache.set('ol-groupSSOActive', true)
|
||||
})
|
||||
|
||||
mountGroupMembersProvider()
|
||||
})
|
||||
|
||||
it('should display the Security column', function () {
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(2)').within(() => {
|
||||
cy.contains('bobby.lapointe@test.com')
|
||||
cy.get('.visually-hidden').contains('SSO not active')
|
||||
})
|
||||
|
||||
cy.get('tr:nth-child(3)').within(() => {
|
||||
cy.contains('claire.jennings@test.com')
|
||||
cy.get('.visually-hidden').contains('SSO active')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with flexible group licensing enabled', function () {
|
||||
beforeEach(function () {
|
||||
this.JOHN_DOE = {
|
||||
_id: 'abc123def456',
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
email: 'john.doe@test.com',
|
||||
last_active_at: new Date('2023-01-15'),
|
||||
invite: false,
|
||||
}
|
||||
this.BOBBY_LAPOINTE = {
|
||||
_id: 'bcd234efa567',
|
||||
first_name: 'Bobby',
|
||||
last_name: 'Lapointe',
|
||||
email: 'bobby.lapointe@test.com',
|
||||
last_active_at: new Date('2023-01-02'),
|
||||
invite: false,
|
||||
}
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-groupId', GROUP_ID)
|
||||
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
|
||||
win.metaAttributesCache.set('ol-groupSize', 10)
|
||||
win.metaAttributesCache.set('ol-canUseFlexibleLicensing', true)
|
||||
win.metaAttributesCache.set('ol-canUseAddSeatsFeature', true)
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the group members page with the new text', function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [
|
||||
this.JOHN_DOE,
|
||||
this.BOBBY_LAPOINTE,
|
||||
])
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<GroupMembers />
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
|
||||
cy.findByTestId('group-size-details').contains(
|
||||
'You have 2 licenses and your plan supports up to 10. Buy more licenses.'
|
||||
)
|
||||
cy.findByTestId('add-more-members-form').within(() => {
|
||||
cy.contains('Invite more members')
|
||||
cy.get('button').contains('Invite')
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the group members page with new text when only has one group member', function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [this.JOHN_DOE])
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<GroupMembers />
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
|
||||
cy.findByTestId('group-size-details').contains(
|
||||
'You have 1 license and your plan supports up to 10. Buy more licenses.'
|
||||
)
|
||||
})
|
||||
|
||||
it('renders the group members page without "buy more licenses" link when not admin', function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [this.JOHN_DOE])
|
||||
win.metaAttributesCache.set('ol-canUseAddSeatsFeature', false)
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<GroupMembers />
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
|
||||
cy.findByTestId('group-size-details').within(() => {
|
||||
cy.findByText(/you have \d+ license and your plan supports up to \d+/i)
|
||||
cy.findByText(/buy more licenses/i).should('not.exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,175 @@
|
||||
import InstitutionManagers from '@/features/group-management/components/institution-managers'
|
||||
|
||||
const JOHN_DOE = {
|
||||
_id: 'abc123def456',
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
email: 'john.doe@test.com',
|
||||
last_active_at: new Date('2023-01-15'),
|
||||
invite: true,
|
||||
}
|
||||
const BOBBY_LAPOINTE = {
|
||||
_id: 'bcd234efa567',
|
||||
first_name: 'Bobby',
|
||||
last_name: 'Lapointe',
|
||||
email: 'bobby.lapointe@test.com',
|
||||
last_active_at: new Date('2023-01-02'),
|
||||
invite: false,
|
||||
}
|
||||
const GROUP_ID = '999fff999fff'
|
||||
const PATHS = {
|
||||
addMember: `/manage/institutions/${GROUP_ID}/managers`,
|
||||
removeMember: `/manage/institutions/${GROUP_ID}/managers`,
|
||||
}
|
||||
|
||||
describe('institution managers', function () {
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [JOHN_DOE, BOBBY_LAPOINTE])
|
||||
win.metaAttributesCache.set('ol-groupId', GROUP_ID)
|
||||
win.metaAttributesCache.set('ol-groupName', 'My Awesome Institution')
|
||||
})
|
||||
|
||||
cy.mount(<InstitutionManagers />)
|
||||
})
|
||||
|
||||
it('renders the institution management page', function () {
|
||||
cy.findByRole('heading', { name: /my awesome institution/i, level: 1 })
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByText('john.doe@test.com')
|
||||
cy.findByText('John Doe')
|
||||
cy.findByText('15th Jan 2023')
|
||||
cy.findByText('Invite not yet accepted')
|
||||
})
|
||||
|
||||
cy.get('tr:nth-child(2)').within(() => {
|
||||
cy.findByText('bobby.lapointe@test.com')
|
||||
cy.findByText('Bobby Lapointe')
|
||||
cy.findByText('2nd Jan 2023')
|
||||
cy.findByText('Accepted invite')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('sends an invite', function () {
|
||||
cy.intercept('POST', PATHS.addMember, {
|
||||
statusCode: 201,
|
||||
body: {
|
||||
user: {
|
||||
email: 'someone.else@test.com',
|
||||
invite: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cy.findByTestId('add-members-form').within(() => {
|
||||
cy.findByRole('textbox').type('someone.else@test.com')
|
||||
cy.findByRole('button').click()
|
||||
})
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(3)').within(() => {
|
||||
cy.findByText('someone.else@test.com')
|
||||
cy.findByText('N/A')
|
||||
cy.findByText('Invite not yet accepted')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('tries to send an invite and displays the error', function () {
|
||||
cy.intercept('POST', PATHS.addMember, {
|
||||
statusCode: 500,
|
||||
body: {
|
||||
error: {
|
||||
message: 'User already added',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cy.findByTestId('add-members-form').within(() => {
|
||||
cy.findByRole('textbox').type('someone.else@test.com')
|
||||
cy.findByRole('button').click()
|
||||
})
|
||||
cy.findByRole('alert').should('contain.text', 'Error: User already added')
|
||||
})
|
||||
|
||||
it('checks the select all checkbox', function () {
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByLabelText(/select user/i).should('not.be.checked')
|
||||
})
|
||||
cy.get('tr:nth-child(2)').within(() => {
|
||||
cy.findByLabelText(/select user/i).should('not.be.checked')
|
||||
})
|
||||
})
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('thead')
|
||||
.within(() => {
|
||||
cy.findByLabelText(/select all/i).check()
|
||||
})
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByLabelText(/select user/i).should('be.checked')
|
||||
})
|
||||
cy.get('tr:nth-child(2)').within(() => {
|
||||
cy.findByLabelText(/select user/i).should('be.checked')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('remove a member', function () {
|
||||
cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
|
||||
statusCode: 200,
|
||||
})
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByLabelText(/select user/i).check()
|
||||
})
|
||||
})
|
||||
|
||||
cy.findByRole('button', { name: /remove manager/i }).click()
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByText('bobby.lapointe@test.com')
|
||||
cy.findByText('Bobby Lapointe')
|
||||
cy.findByText('2nd Jan 2023')
|
||||
cy.findByText('Accepted invite')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('tries to remove a manager and displays the error', function () {
|
||||
cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
|
||||
statusCode: 500,
|
||||
})
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByLabelText(/select user/i).check()
|
||||
})
|
||||
})
|
||||
cy.findByRole('button', { name: /remove manager/i }).click()
|
||||
|
||||
cy.findByRole('alert').should('contain.text', 'Sorry, something went wrong')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,313 @@
|
||||
import GroupMembers from '@/features/group-management/components/group-members'
|
||||
import { GroupMembersProvider } from '@/features/group-management/context/group-members-context'
|
||||
import { User } from '../../../../../types/group-management/user'
|
||||
import { SplitTestProvider } from '@/shared/context/split-test-context'
|
||||
|
||||
const GROUP_ID = '777fff777fff'
|
||||
const JOHN_DOE: User = {
|
||||
_id: 'abc123def456',
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
email: 'john.doe@test.com',
|
||||
last_active_at: new Date('2023-01-15'),
|
||||
invite: true,
|
||||
}
|
||||
const BOBBY_LAPOINTE: User = {
|
||||
_id: 'bcd234efa567',
|
||||
first_name: 'Bobby',
|
||||
last_name: 'Lapointe',
|
||||
email: 'bobby.lapointe@test.com',
|
||||
last_active_at: new Date('2023-01-02'),
|
||||
invite: false,
|
||||
enrollment: {
|
||||
sso: [
|
||||
{
|
||||
groupId: 'another',
|
||||
linkedAt: new Date(),
|
||||
primary: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
const CLAIRE_JENNINGS: User = {
|
||||
_id: 'defabc231453',
|
||||
first_name: 'Claire',
|
||||
last_name: 'Jennings',
|
||||
email: 'claire.jennings@test.com',
|
||||
last_active_at: new Date('2023-01-03'),
|
||||
invite: false,
|
||||
enrollment: {
|
||||
managedBy: GROUP_ID,
|
||||
enrolledAt: new Date('2023-01-03'),
|
||||
sso: [
|
||||
{
|
||||
groupId: GROUP_ID,
|
||||
linkedAt: new Date(),
|
||||
primary: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
const PATHS = {
|
||||
addMember: `/manage/groups/${GROUP_ID}/invites`,
|
||||
removeMember: `/manage/groups/${GROUP_ID}/user`,
|
||||
removeInvite: `/manage/groups/${GROUP_ID}/invites`,
|
||||
exportMembers: `/manage/groups/${GROUP_ID}/members/export`,
|
||||
}
|
||||
|
||||
function mountGroupMembersProvider() {
|
||||
cy.mount(
|
||||
<SplitTestProvider>
|
||||
<GroupMembersProvider>
|
||||
<GroupMembers />
|
||||
</GroupMembersProvider>
|
||||
</SplitTestProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('group members, with managed users', function () {
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [
|
||||
JOHN_DOE,
|
||||
BOBBY_LAPOINTE,
|
||||
CLAIRE_JENNINGS,
|
||||
])
|
||||
win.metaAttributesCache.set('ol-groupId', GROUP_ID)
|
||||
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
|
||||
win.metaAttributesCache.set('ol-groupSize', 10)
|
||||
win.metaAttributesCache.set('ol-managedUsersActive', true)
|
||||
})
|
||||
mountGroupMembersProvider()
|
||||
})
|
||||
|
||||
it('renders the group members page', function () {
|
||||
cy.get('h1').contains('My Awesome Team')
|
||||
cy.get('small').contains('You have added 3 of 10 available members')
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.contains('john.doe@test.com')
|
||||
cy.contains('John Doe')
|
||||
cy.contains('15th Jan 2023')
|
||||
cy.get('.visually-hidden').contains('Pending invite')
|
||||
|
||||
cy.findByTestId('badge-pending-invite').should(
|
||||
'have.text',
|
||||
'Pending invite'
|
||||
)
|
||||
cy.get(`.security-state-invite-pending`).should('exist')
|
||||
})
|
||||
|
||||
cy.get('tr:nth-child(2)').within(() => {
|
||||
cy.contains('bobby.lapointe@test.com')
|
||||
cy.contains('Bobby Lapointe')
|
||||
cy.contains('2nd Jan 2023')
|
||||
cy.findByTestId('badge-pending-invite').should('not.exist')
|
||||
cy.get('.visually-hidden').contains('Not managed')
|
||||
})
|
||||
|
||||
cy.get('tr:nth-child(3)').within(() => {
|
||||
cy.contains('claire.jennings@test.com')
|
||||
cy.contains('Claire Jennings')
|
||||
cy.contains('3rd Jan 2023')
|
||||
cy.findByTestId('badge-pending-invite').should('not.exist')
|
||||
cy.get('.visually-hidden').contains('Managed')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('sends an invite', function () {
|
||||
cy.intercept('POST', PATHS.addMember, {
|
||||
statusCode: 201,
|
||||
body: {
|
||||
user: {
|
||||
email: 'someone.else@test.com',
|
||||
invite: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cy.get('.form-control').type('someone.else@test.com')
|
||||
cy.get('.add-more-members-form button').click()
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(4)').within(() => {
|
||||
cy.contains('someone.else@test.com')
|
||||
cy.contains('N/A')
|
||||
cy.get('.visually-hidden').contains('Pending invite')
|
||||
cy.findByTestId('badge-pending-invite').should(
|
||||
'have.text',
|
||||
'Pending invite'
|
||||
)
|
||||
cy.get(`.security-state-invite-pending`).should('exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('tries to send an invite and displays the error', function () {
|
||||
cy.intercept('POST', PATHS.addMember, {
|
||||
statusCode: 500,
|
||||
body: {
|
||||
error: {
|
||||
message: 'User already added',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cy.get('.form-control').type('someone.else@test.com')
|
||||
cy.get('.add-more-members-form button').click()
|
||||
cy.findByRole('alert').contains('Error: User already added')
|
||||
})
|
||||
|
||||
it('checks the select all checkbox', function () {
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByTestId('select-single-checkbox').should('not.be.checked')
|
||||
})
|
||||
cy.get('tr:nth-child(2)').within(() => {
|
||||
cy.findByTestId('select-single-checkbox').should('not.be.checked')
|
||||
})
|
||||
})
|
||||
|
||||
cy.findByTestId('select-all-checkbox').click()
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByTestId('select-single-checkbox').should('be.checked')
|
||||
})
|
||||
cy.get('tr:nth-child(2)').within(() => {
|
||||
cy.findByTestId('select-single-checkbox').should('be.checked')
|
||||
})
|
||||
})
|
||||
|
||||
cy.get('button').contains('Remove from group').click()
|
||||
})
|
||||
|
||||
it('remove a member', function () {
|
||||
cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
|
||||
statusCode: 200,
|
||||
})
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByTestId('select-single-checkbox').check()
|
||||
})
|
||||
})
|
||||
|
||||
cy.get('button').contains('Remove from group').click()
|
||||
|
||||
cy.get('small').contains('You have added 2 of 10 available members')
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.contains('bobby.lapointe@test.com')
|
||||
cy.contains('Bobby Lapointe')
|
||||
cy.contains('2nd Jan 2023')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('cannot remove a managed member', function () {
|
||||
cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
|
||||
statusCode: 200,
|
||||
})
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
// no checkbox should be shown for 'Claire Jennings', a managed user
|
||||
cy.get('tr:nth-child(3)').within(() => {
|
||||
cy.findByTestId('select-single-checkbox').should('not.exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('tries to remove a user and displays the error', function () {
|
||||
cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
|
||||
statusCode: 500,
|
||||
})
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByTestId('select-single-checkbox').check()
|
||||
})
|
||||
})
|
||||
cy.get('.page-header').within(() => {
|
||||
cy.get('button').contains('Remove from group').click()
|
||||
})
|
||||
|
||||
cy.findByRole('alert').contains('Sorry, something went wrong')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Group members when group SSO is enabled', function () {
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [
|
||||
JOHN_DOE,
|
||||
BOBBY_LAPOINTE,
|
||||
CLAIRE_JENNINGS,
|
||||
])
|
||||
win.metaAttributesCache.set('ol-groupId', GROUP_ID)
|
||||
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
|
||||
win.metaAttributesCache.set('ol-groupSize', 10)
|
||||
win.metaAttributesCache.set('ol-managedUsersActive', true)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not display SSO Column when group sso is not enabled', function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-groupSSOActive', false)
|
||||
})
|
||||
mountGroupMembersProvider()
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(2)').within(() => {
|
||||
cy.contains('bobby.lapointe@test.com')
|
||||
cy.get('.visually-hidden')
|
||||
.contains('SSO not active')
|
||||
.should('not.exist')
|
||||
})
|
||||
|
||||
cy.get('tr:nth-child(3)').within(() => {
|
||||
cy.contains('claire.jennings@test.com')
|
||||
cy.get('.visually-hidden').contains('SSO active').should('not.exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should display SSO Column when Group SSO is enabled', function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-groupSSOActive', true)
|
||||
})
|
||||
mountGroupMembersProvider()
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(2)').within(() => {
|
||||
cy.contains('bobby.lapointe@test.com')
|
||||
cy.get('.visually-hidden').contains('SSO not active')
|
||||
})
|
||||
|
||||
cy.get('tr:nth-child(3)').within(() => {
|
||||
cy.contains('claire.jennings@test.com')
|
||||
cy.get('.visually-hidden').contains('SSO active')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,802 @@
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import sinon from 'sinon'
|
||||
import DropdownButton from '@/features/group-management/components/members-table/dropdown-button'
|
||||
import { GroupMembersProvider } from '@/features/group-management/context/group-members-context'
|
||||
import { User } from '../../../../../../types/group-management/user'
|
||||
|
||||
function Wrapper({ children }: PropsWithChildren<Record<string, unknown>>) {
|
||||
return (
|
||||
<table className="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="managed-users-actions" style={{ textAlign: 'right' }}>
|
||||
<GroupMembersProvider>{children}</GroupMembersProvider>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
|
||||
function mountDropDownComponent(user: User, subscriptionId: string) {
|
||||
cy.mount(
|
||||
<Wrapper>
|
||||
<DropdownButton
|
||||
user={user}
|
||||
openOffboardingModalForUser={sinon.stub()}
|
||||
openUnlinkUserModal={sinon.stub()}
|
||||
groupId={subscriptionId}
|
||||
setGroupUserAlert={sinon.stub()}
|
||||
/>
|
||||
</Wrapper>
|
||||
)
|
||||
}
|
||||
|
||||
describe('DropdownButton', function () {
|
||||
const subscriptionId = '123abc123abc'
|
||||
|
||||
describe('with a standard group', function () {
|
||||
describe('for a pending user (has not joined group)', function () {
|
||||
const user: User = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: true,
|
||||
last_active_at: new Date(),
|
||||
enrollment: {},
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
mountDropDownComponent(user, subscriptionId)
|
||||
})
|
||||
|
||||
it('should render dropdown button', function () {
|
||||
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
|
||||
'exist'
|
||||
)
|
||||
cy.findByRole('button', { name: /actions/i })
|
||||
})
|
||||
|
||||
it('should show the correct menu when dropdown button is clicked', function () {
|
||||
cy.findByRole('button', { name: /actions/i }).click()
|
||||
cy.findByTestId('resend-group-invite-action').should('be.visible')
|
||||
cy.findByTestId('remove-user-action').should('be.visible')
|
||||
|
||||
cy.findByTestId('resend-managed-user-invite-action').should('not.exist')
|
||||
cy.findByTestId('resend-sso-link-invite-action').should('not.exist')
|
||||
cy.findByTestId('no-actions-available').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('for the group admin', function () {
|
||||
const user: User = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date(),
|
||||
enrollment: {},
|
||||
isEntityAdmin: true,
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
mountDropDownComponent(user, subscriptionId)
|
||||
})
|
||||
|
||||
it('should render dropdown button', function () {
|
||||
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
|
||||
'exist'
|
||||
)
|
||||
cy.findByRole('button', { name: /actions/i })
|
||||
})
|
||||
|
||||
it('should show the correct menu when dropdown button is clicked', function () {
|
||||
cy.findByRole('button', { name: /actions/i }).click()
|
||||
|
||||
cy.findByTestId('remove-user-action').should('be.visible')
|
||||
|
||||
cy.findByTestId('no-actions-available').should('not.exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with Managed Users enabled', function () {
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-managedUsersActive', true)
|
||||
win.metaAttributesCache.set('ol-groupSSOActive', false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('for a pending user (has not joined group)', function () {
|
||||
const user: User = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: true,
|
||||
last_active_at: new Date(),
|
||||
enrollment: {},
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
mountDropDownComponent(user, subscriptionId)
|
||||
})
|
||||
|
||||
it('should render dropdown button', function () {
|
||||
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
|
||||
'exist'
|
||||
)
|
||||
cy.findByRole('button', { name: /actions/i })
|
||||
})
|
||||
|
||||
it('should show the correct menu when dropdown button is clicked', function () {
|
||||
cy.findByRole('button', { name: /actions/i }).click()
|
||||
cy.findByTestId('resend-group-invite-action').should('be.visible')
|
||||
cy.findByTestId('remove-user-action').should('be.visible')
|
||||
|
||||
cy.findByTestId('resend-managed-user-invite-action').should('not.exist')
|
||||
cy.findByTestId('resend-sso-link-invite-action').should('not.exist')
|
||||
cy.findByTestId('no-actions-available').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('for a managed group member', function () {
|
||||
const user: User = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date(),
|
||||
enrollment: {
|
||||
managedBy: subscriptionId,
|
||||
enrolledAt: new Date(),
|
||||
},
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
mountDropDownComponent(user, subscriptionId)
|
||||
})
|
||||
|
||||
it('should render the dropdown button', function () {
|
||||
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
|
||||
'exist'
|
||||
)
|
||||
cy.findByRole('button', { name: /actions/i })
|
||||
})
|
||||
|
||||
it('should show the correct menu when dropdown button is clicked', function () {
|
||||
cy.findByRole('button', { name: /actions/i }).click()
|
||||
|
||||
cy.findByTestId('delete-user-action').should('be.visible')
|
||||
|
||||
cy.findByTestId('remove-user-action').should('not.exist')
|
||||
cy.findByTestId('resend-managed-user-invite-action').should('not.exist')
|
||||
cy.findByTestId('resend-sso-link-invite-action').should('not.exist')
|
||||
cy.findByTestId('no-actions-available').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('for a non-managed group member', function () {
|
||||
const user: User = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date(),
|
||||
enrollment: {},
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
mountDropDownComponent(user, subscriptionId)
|
||||
})
|
||||
|
||||
it('should render dropdown button', function () {
|
||||
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
|
||||
'exist'
|
||||
)
|
||||
cy.findByRole('button', { name: /actions/i })
|
||||
})
|
||||
|
||||
it('should show the correct menu when dropdown button is clicked', function () {
|
||||
cy.findByRole('button', { name: /actions/i }).click()
|
||||
|
||||
cy.findByTestId('resend-managed-user-invite-action').should(
|
||||
'be.visible'
|
||||
)
|
||||
cy.findByTestId('remove-user-action').should('be.visible')
|
||||
|
||||
cy.findByTestId('resend-sso-link-invite-action').should('not.exist')
|
||||
cy.findByTestId('no-actions-available').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('for a managed group admin user', function () {
|
||||
const user: User = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date(),
|
||||
enrollment: {
|
||||
managedBy: subscriptionId,
|
||||
enrolledAt: new Date(),
|
||||
},
|
||||
isEntityAdmin: true,
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
mountDropDownComponent(user, subscriptionId)
|
||||
})
|
||||
|
||||
it('should render the button', function () {
|
||||
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
|
||||
'exist'
|
||||
)
|
||||
cy.findByRole('button', { name: /actions/i })
|
||||
})
|
||||
|
||||
it('should show the (empty) menu when the button is clicked', function () {
|
||||
cy.findByRole('button', { name: /actions/i }).click()
|
||||
cy.findByTestId('no-actions-available').should('exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with Group SSO enabled', function () {
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-managedUsersActive', false)
|
||||
win.metaAttributesCache.set('ol-groupSSOActive', true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('for a pending user (has not joined group)', function () {
|
||||
const user: User = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: true,
|
||||
last_active_at: new Date(),
|
||||
enrollment: {},
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
mountDropDownComponent(user, subscriptionId)
|
||||
})
|
||||
|
||||
it('should render dropdown button', function () {
|
||||
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
|
||||
'exist'
|
||||
)
|
||||
cy.findByRole('button', { name: /actions/i })
|
||||
})
|
||||
|
||||
it('should show the correct menu when dropdown button is clicked', function () {
|
||||
cy.findByRole('button', { name: /actions/i }).click()
|
||||
cy.findByTestId('resend-group-invite-action').should('be.visible')
|
||||
cy.findByTestId('remove-user-action').should('be.visible')
|
||||
|
||||
cy.findByTestId('resend-managed-user-invite-action').should('not.exist')
|
||||
cy.findByTestId('resend-sso-link-invite-action').should('not.exist')
|
||||
cy.findByTestId('unlink-user-action').should('not.exist')
|
||||
cy.findByTestId('no-actions-available').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('for a group member not linked with SSO yet', function () {
|
||||
const user: User = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date(),
|
||||
enrollment: {},
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
})
|
||||
|
||||
it('should show resend invite when user is admin', function () {
|
||||
mountDropDownComponent({ ...user, isEntityAdmin: true }, '123abc')
|
||||
cy.findByRole('button', { name: /actions/i }).click()
|
||||
cy.findByTestId('resend-sso-link-invite-action').should('exist')
|
||||
})
|
||||
|
||||
it('should not show resend invite when SSO is disabled', function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-groupSSOActive', false)
|
||||
})
|
||||
mountDropDownComponent(user, '123abc')
|
||||
cy.findByRole('button', { name: /actions/i }).click()
|
||||
cy.findByTestId('resend-sso-link-invite-action').should('not.exist')
|
||||
})
|
||||
|
||||
it('should show the resend SSO invite option when dropdown button is clicked', function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-groupSSOActive', true)
|
||||
})
|
||||
mountDropDownComponent(user, '123abc')
|
||||
cy.findByRole('button', { name: /actions/i }).click()
|
||||
cy.findByTestId('resend-sso-link-invite-action').should('be.visible')
|
||||
})
|
||||
|
||||
it('should make the correct post request when resend SSO invite is clicked ', function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-groupSSOActive', true)
|
||||
})
|
||||
cy.intercept(
|
||||
'POST',
|
||||
'/manage/groups/123abc/resendSSOLinkInvite/some-user',
|
||||
{ success: true }
|
||||
).as('resendInviteRequest')
|
||||
mountDropDownComponent(user, '123abc')
|
||||
cy.findByRole('button', { name: /actions/i }).click()
|
||||
cy.findByTestId('resend-sso-link-invite-action')
|
||||
.should('exist')
|
||||
.as('resendInvite')
|
||||
cy.get('@resendInvite').click()
|
||||
cy.wait('@resendInviteRequest')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with Managed Users and Group SSO enabled', function () {
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-managedUsersActive', true)
|
||||
win.metaAttributesCache.set('ol-groupSSOActive', true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('for a pending user (has not joined group)', function () {
|
||||
const user: User = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: true,
|
||||
last_active_at: new Date(),
|
||||
enrollment: {},
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
mountDropDownComponent(user, subscriptionId)
|
||||
})
|
||||
|
||||
it('should render dropdown button', function () {
|
||||
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
|
||||
'exist'
|
||||
)
|
||||
cy.findByRole('button', { name: /actions/i })
|
||||
})
|
||||
|
||||
it('should show the correct menu when dropdown button is clicked', function () {
|
||||
cy.findByRole('button', { name: /actions/i }).click()
|
||||
cy.findByTestId('resend-group-invite-action').should('be.visible')
|
||||
cy.findByTestId('remove-user-action').should('be.visible')
|
||||
|
||||
cy.findByTestId('resend-managed-user-invite-action').should('not.exist')
|
||||
cy.findByTestId('resend-sso-link-invite-action').should('not.exist')
|
||||
cy.findByTestId('unlink-user-action').should('not.exist')
|
||||
cy.findByTestId('no-actions-available').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('for a non-managed group member with SSO linked', function () {
|
||||
const user: User = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date(),
|
||||
enrollment: {
|
||||
sso: [
|
||||
{
|
||||
groupId: subscriptionId,
|
||||
linkedAt: new Date(),
|
||||
primary: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
mountDropDownComponent(user, subscriptionId)
|
||||
})
|
||||
|
||||
it('should render dropdown button', function () {
|
||||
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
|
||||
'exist'
|
||||
)
|
||||
cy.findByRole('button', { name: /actions/i })
|
||||
})
|
||||
|
||||
it('should show the correct menu when dropdown button is clicked', function () {
|
||||
cy.findByRole('button', { name: /actions/i }).click()
|
||||
|
||||
cy.findByTestId('resend-managed-user-invite-action').should(
|
||||
'be.visible'
|
||||
)
|
||||
cy.findByTestId('remove-user-action').should('be.visible')
|
||||
cy.findByTestId('unlink-user-action').should('be.visible')
|
||||
|
||||
cy.findByTestId('resend-sso-link-invite-action').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('for a non-managed group member with SSO not linked', function () {
|
||||
const user: User = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date(),
|
||||
enrollment: {
|
||||
sso: [],
|
||||
},
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
mountDropDownComponent(user, subscriptionId)
|
||||
})
|
||||
|
||||
it('should render dropdown button', function () {
|
||||
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
|
||||
'exist'
|
||||
)
|
||||
cy.findByRole('button', { name: /actions/i })
|
||||
})
|
||||
|
||||
it('should show the correct menu when dropdown button is clicked', function () {
|
||||
cy.findByRole('button', { name: /actions/i }).click()
|
||||
|
||||
cy.findByTestId('resend-managed-user-invite-action').should(
|
||||
'be.visible'
|
||||
)
|
||||
cy.findByTestId('remove-user-action').should('be.visible')
|
||||
cy.findByTestId('resend-sso-link-invite-action').should('be.visible')
|
||||
|
||||
cy.findByTestId('no-actions-available').should('not.exist')
|
||||
cy.findByTestId('unlink-user-action').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('for a non-managed group admin with SSO linked', function () {
|
||||
const user: User = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date(),
|
||||
enrollment: {
|
||||
sso: [
|
||||
{
|
||||
groupId: subscriptionId,
|
||||
linkedAt: new Date(),
|
||||
primary: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
isEntityAdmin: true,
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
mountDropDownComponent(user, subscriptionId)
|
||||
})
|
||||
|
||||
it('should render dropdown button', function () {
|
||||
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
|
||||
'exist'
|
||||
)
|
||||
cy.findByRole('button', { name: /actions/i })
|
||||
})
|
||||
|
||||
it('should show the correct menu when dropdown button is clicked', function () {
|
||||
cy.findByRole('button', { name: /actions/i }).click()
|
||||
|
||||
cy.findByTestId('resend-managed-user-invite-action').should(
|
||||
'be.visible'
|
||||
)
|
||||
cy.findByTestId('remove-user-action').should('be.visible')
|
||||
cy.findByTestId('unlink-user-action').should('be.visible')
|
||||
|
||||
cy.findByTestId('delete-user-action').should('not.exist')
|
||||
cy.findByTestId('resend-sso-link-invite-action').should('not.exist')
|
||||
cy.findByTestId('no-actions-available').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('for a non-managed group admin with SSO not linked', function () {
|
||||
const user: User = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date(),
|
||||
enrollment: {
|
||||
sso: [],
|
||||
},
|
||||
isEntityAdmin: true,
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
mountDropDownComponent(user, subscriptionId)
|
||||
})
|
||||
|
||||
it('should render dropdown button', function () {
|
||||
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
|
||||
'exist'
|
||||
)
|
||||
cy.findByRole('button', { name: /actions/i })
|
||||
})
|
||||
|
||||
it('should show the correct menu when dropdown button is clicked', function () {
|
||||
cy.findByRole('button', { name: /actions/i }).click()
|
||||
|
||||
cy.findByTestId('resend-managed-user-invite-action').should(
|
||||
'be.visible'
|
||||
)
|
||||
cy.findByTestId('remove-user-action').should('be.visible')
|
||||
cy.findByTestId('delete-user-action').should('not.exist')
|
||||
cy.findByTestId('resend-sso-link-invite-action').should('exist')
|
||||
|
||||
cy.findByTestId('no-actions-available').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('for a managed group member with SSO not linked', function () {
|
||||
const user: User = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date(),
|
||||
enrollment: {
|
||||
managedBy: subscriptionId,
|
||||
enrolledAt: new Date(),
|
||||
sso: [],
|
||||
},
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
mountDropDownComponent(user, subscriptionId)
|
||||
})
|
||||
|
||||
it('should render the dropdown button', function () {
|
||||
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
|
||||
'exist'
|
||||
)
|
||||
cy.findByRole('button', { name: /actions/i })
|
||||
})
|
||||
|
||||
it('should show the correct menu when dropdown button is clicked', function () {
|
||||
cy.findByRole('button', { name: /actions/i }).click()
|
||||
|
||||
cy.findByTestId('delete-user-action').should('be.visible')
|
||||
|
||||
cy.findByTestId('remove-user-action').should('not.exist')
|
||||
cy.findByTestId('resend-managed-user-invite-action').should('not.exist')
|
||||
cy.findByTestId('resend-sso-link-invite-action').should('exist')
|
||||
|
||||
cy.findByTestId('no-actions-available').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('for a managed group member with SSO linked', function () {
|
||||
const user: User = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date(),
|
||||
enrollment: {
|
||||
managedBy: subscriptionId,
|
||||
enrolledAt: new Date(),
|
||||
sso: [
|
||||
{
|
||||
groupId: subscriptionId,
|
||||
linkedAt: new Date(),
|
||||
primary: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
mountDropDownComponent(user, subscriptionId)
|
||||
})
|
||||
|
||||
it('should render the dropdown button', function () {
|
||||
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
|
||||
'exist'
|
||||
)
|
||||
cy.findByRole('button', { name: /actions/i })
|
||||
})
|
||||
|
||||
it('should show the correct menu when dropdown button is clicked', function () {
|
||||
cy.findByRole('button', { name: /actions/i }).click()
|
||||
|
||||
cy.findByTestId('delete-user-action').should('be.visible')
|
||||
|
||||
cy.findByTestId('remove-user-action').should('not.exist')
|
||||
cy.findByTestId('resend-managed-user-invite-action').should('not.exist')
|
||||
cy.findByTestId('resend-sso-link-invite-action').should('not.exist')
|
||||
|
||||
cy.findByTestId('no-actions-available').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('for a managed group admin with SSO not linked', function () {
|
||||
const user: User = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date(),
|
||||
enrollment: {
|
||||
managedBy: subscriptionId,
|
||||
enrolledAt: new Date(),
|
||||
sso: [],
|
||||
},
|
||||
isEntityAdmin: true,
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
mountDropDownComponent(user, subscriptionId)
|
||||
})
|
||||
|
||||
it('should render the button', function () {
|
||||
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
|
||||
'exist'
|
||||
)
|
||||
cy.findByRole('button', { name: /actions/i })
|
||||
})
|
||||
|
||||
it('should show the correct menu when dropdown button is clicked', function () {
|
||||
cy.findByRole('button', { name: /actions/i }).click()
|
||||
|
||||
cy.findByTestId('resend-sso-link-invite-action').should('exist')
|
||||
|
||||
cy.findByTestId('resend-managed-user-invite-action').should('not.exist')
|
||||
cy.findByTestId('remove-user-action').should('not.exist')
|
||||
cy.findByTestId('delete-user-action').should('not.exist')
|
||||
cy.findByTestId('no-actions-available').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('for a managed group admin with SSO linked', function () {
|
||||
const user: User = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date(),
|
||||
enrollment: {
|
||||
managedBy: subscriptionId,
|
||||
enrolledAt: new Date(),
|
||||
sso: [
|
||||
{
|
||||
groupId: subscriptionId,
|
||||
linkedAt: new Date(),
|
||||
primary: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
isEntityAdmin: true,
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
mountDropDownComponent(user, subscriptionId)
|
||||
})
|
||||
|
||||
it('should render the button', function () {
|
||||
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
|
||||
'exist'
|
||||
)
|
||||
cy.findByRole('button', { name: /actions/i })
|
||||
})
|
||||
|
||||
it('should show no actions except to unlink when dropdown button is clicked', function () {
|
||||
cy.findByRole('button', { name: /actions/i }).click()
|
||||
|
||||
cy.findByTestId('unlink-user-action').should('exist')
|
||||
|
||||
cy.findByTestId('no-actions-available').should('not.exist')
|
||||
cy.findByTestId('delete-user-action').should('not.exist')
|
||||
cy.findByTestId('remove-user-action').should('not.exist')
|
||||
cy.findByTestId('resend-managed-user-invite-action').should('not.exist')
|
||||
cy.findByTestId('resend-sso-link-invite-action').should('not.exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import ManagedUserStatus from '@/features/group-management/components/members-table/managed-user-status'
|
||||
import { User } from '../../../../../../types/group-management/user'
|
||||
|
||||
describe('MemberStatus', function () {
|
||||
describe('with a pending invite', function () {
|
||||
const user: User = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: true,
|
||||
last_active_at: new Date(),
|
||||
enrollment: undefined,
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
beforeEach(function () {
|
||||
cy.mount(<ManagedUserStatus user={user} />)
|
||||
})
|
||||
|
||||
it('should render a pending state', function () {
|
||||
cy.get('.security-state-invite-pending').contains('Managed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a managed user', function () {
|
||||
const user: User = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date(),
|
||||
enrollment: { managedBy: 'some-group', enrolledAt: new Date() },
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
beforeEach(function () {
|
||||
cy.mount(<ManagedUserStatus user={user} />)
|
||||
})
|
||||
|
||||
it('should render a pending state', function () {
|
||||
cy.get('.security-state-managed').contains('Managed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('with an un-managed user', function () {
|
||||
const user: User = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date(),
|
||||
enrollment: undefined,
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
beforeEach(function () {
|
||||
cy.mount(<ManagedUserStatus user={user} />)
|
||||
})
|
||||
|
||||
it('should render an un-managed state', function () {
|
||||
cy.get('.security-state-not-managed').contains('Managed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('with the group admin', function () {
|
||||
const user: User = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date(),
|
||||
enrollment: undefined,
|
||||
isEntityAdmin: true,
|
||||
}
|
||||
beforeEach(function () {
|
||||
cy.mount(<ManagedUserStatus user={user} />)
|
||||
})
|
||||
|
||||
it('should render no state indicator', function () {
|
||||
cy.get('.security-state-group-admin')
|
||||
.contains('Managed')
|
||||
.should('not.exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,710 @@
|
||||
import sinon from 'sinon'
|
||||
import MemberRow from '@/features/group-management/components/members-table/member-row'
|
||||
import { GroupMembersProvider } from '@/features/group-management/context/group-members-context'
|
||||
import { User } from '../../../../../../types/group-management/user'
|
||||
|
||||
describe('MemberRow', function () {
|
||||
const subscriptionId = '123abc'
|
||||
|
||||
describe('default view', function () {
|
||||
describe('with an ordinary user', function () {
|
||||
let user: User
|
||||
|
||||
beforeEach(function () {
|
||||
user = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date('2070-11-21T03:00:00'),
|
||||
enrollment: undefined,
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<MemberRow
|
||||
user={user}
|
||||
openOffboardingModalForUser={sinon.stub()}
|
||||
openUnlinkUserModal={sinon.stub()}
|
||||
groupId={subscriptionId}
|
||||
setGroupUserAlert={sinon.stub()}
|
||||
/>
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
})
|
||||
|
||||
it('renders the row', function () {
|
||||
cy.get('tr')
|
||||
// Checkbox
|
||||
cy.findByTestId('select-single-checkbox').should('not.be.checked')
|
||||
// Email
|
||||
cy.get('tr').contains(user.email)
|
||||
// Name
|
||||
cy.get('tr').contains(user.first_name)
|
||||
cy.get('tr').contains(user.last_name)
|
||||
// Last active date
|
||||
cy.get('tr').contains('21st Nov 2070')
|
||||
// Dropdown button
|
||||
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
|
||||
'exist'
|
||||
)
|
||||
|
||||
cy.get('tr').contains('SSO').should('not.exist')
|
||||
cy.get('tr').contains('Managed').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a pending invite', function () {
|
||||
let user: User
|
||||
|
||||
beforeEach(function () {
|
||||
user = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: true,
|
||||
last_active_at: new Date('2070-11-21T03:00:00'),
|
||||
enrollment: undefined,
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<MemberRow
|
||||
user={user}
|
||||
openOffboardingModalForUser={sinon.stub()}
|
||||
openUnlinkUserModal={sinon.stub()}
|
||||
groupId={subscriptionId}
|
||||
setGroupUserAlert={sinon.stub()}
|
||||
/>
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
})
|
||||
|
||||
it('should render a "Pending invite" badge', function () {
|
||||
cy.findByTestId('badge-pending-invite').should(
|
||||
'have.text',
|
||||
'Pending invite'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a group admin', function () {
|
||||
let user: User
|
||||
|
||||
beforeEach(function () {
|
||||
user = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date('2070-11-21T03:00:00'),
|
||||
enrollment: undefined,
|
||||
isEntityAdmin: true,
|
||||
}
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<MemberRow
|
||||
user={user}
|
||||
openOffboardingModalForUser={sinon.stub()}
|
||||
openUnlinkUserModal={sinon.stub()}
|
||||
groupId={subscriptionId}
|
||||
setGroupUserAlert={sinon.stub()}
|
||||
/>
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
})
|
||||
|
||||
it('should render a "Group admin" symbol', function () {
|
||||
cy.findByTestId('group-admin-symbol').within(() => {
|
||||
cy.findByText(/group admin/i)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('selecting and unselecting user row', function () {
|
||||
let user: User
|
||||
|
||||
beforeEach(function () {
|
||||
user = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date('2070-11-21T03:00:00'),
|
||||
enrollment: undefined,
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<MemberRow
|
||||
user={user}
|
||||
openOffboardingModalForUser={sinon.stub()}
|
||||
openUnlinkUserModal={sinon.stub()}
|
||||
groupId={subscriptionId}
|
||||
setGroupUserAlert={sinon.stub()}
|
||||
/>
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
})
|
||||
|
||||
it('should select and unselect the user', function () {
|
||||
cy.findByTestId('select-single-checkbox').should('not.be.checked')
|
||||
cy.findByTestId('select-single-checkbox').click()
|
||||
cy.findByTestId('select-single-checkbox').should('be.checked')
|
||||
cy.findByTestId('select-single-checkbox').click()
|
||||
cy.findByTestId('select-single-checkbox').should('not.be.checked')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with Managed Users enabled', function () {
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-managedUsersActive', true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with an ordinary user', function () {
|
||||
let user: User
|
||||
|
||||
beforeEach(function () {
|
||||
user = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date('2070-11-21T03:00:00'),
|
||||
enrollment: undefined,
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<MemberRow
|
||||
user={user}
|
||||
openOffboardingModalForUser={sinon.stub()}
|
||||
openUnlinkUserModal={sinon.stub()}
|
||||
groupId={subscriptionId}
|
||||
setGroupUserAlert={sinon.stub()}
|
||||
/>
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
})
|
||||
|
||||
it('renders the row', function () {
|
||||
cy.get('tr').should('exist')
|
||||
// Checkbox
|
||||
cy.findByTestId('select-single-checkbox').should('not.be.checked')
|
||||
// Email
|
||||
cy.get('tr').contains(user.email)
|
||||
// Name
|
||||
cy.get('tr').contains(user.first_name)
|
||||
cy.get('tr').contains(user.last_name)
|
||||
// Last active date
|
||||
cy.get('tr').contains('21st Nov 2070')
|
||||
// Managed status
|
||||
cy.get('tr').contains('Managed')
|
||||
// Dropdown button
|
||||
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
|
||||
'exist'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a pending invite', function () {
|
||||
let user: User
|
||||
|
||||
beforeEach(function () {
|
||||
user = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: true,
|
||||
last_active_at: new Date('2070-11-21T03:00:00'),
|
||||
enrollment: undefined,
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<MemberRow
|
||||
user={user}
|
||||
openOffboardingModalForUser={sinon.stub()}
|
||||
openUnlinkUserModal={sinon.stub()}
|
||||
groupId={subscriptionId}
|
||||
setGroupUserAlert={sinon.stub()}
|
||||
/>
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
})
|
||||
|
||||
it('should render a "Pending invite" badge', function () {
|
||||
cy.findByTestId('badge-pending-invite').should(
|
||||
'have.text',
|
||||
'Pending invite'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a group admin', function () {
|
||||
let user: User
|
||||
|
||||
beforeEach(function () {
|
||||
user = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date('2070-11-21T03:00:00'),
|
||||
enrollment: undefined,
|
||||
isEntityAdmin: true,
|
||||
}
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<MemberRow
|
||||
user={user}
|
||||
openOffboardingModalForUser={sinon.stub()}
|
||||
openUnlinkUserModal={sinon.stub()}
|
||||
groupId={subscriptionId}
|
||||
setGroupUserAlert={sinon.stub()}
|
||||
/>
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
})
|
||||
|
||||
it('should render a "Group admin" symbol', function () {
|
||||
cy.findByTestId('group-admin-symbol').within(() => {
|
||||
cy.findByText(/group admin/i)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('selecting and unselecting user row', function () {
|
||||
let user: User
|
||||
|
||||
beforeEach(function () {
|
||||
user = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date('2070-11-21T03:00:00'),
|
||||
enrollment: undefined,
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<MemberRow
|
||||
user={user}
|
||||
openOffboardingModalForUser={sinon.stub()}
|
||||
openUnlinkUserModal={sinon.stub()}
|
||||
groupId={subscriptionId}
|
||||
setGroupUserAlert={sinon.stub()}
|
||||
/>
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
})
|
||||
|
||||
it('should select and unselect the user', function () {
|
||||
cy.findByTestId('select-single-checkbox').should('not.be.checked')
|
||||
cy.findByTestId('select-single-checkbox').click()
|
||||
cy.findByTestId('select-single-checkbox').should('be.checked')
|
||||
cy.findByTestId('select-single-checkbox').click()
|
||||
cy.findByTestId('select-single-checkbox').should('not.be.checked')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with Group SSO enabled', function () {
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-groupSSOActive', true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with an ordinary user', function () {
|
||||
let user: User
|
||||
|
||||
beforeEach(function () {
|
||||
user = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date('2070-11-21T03:00:00'),
|
||||
enrollment: undefined,
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<MemberRow
|
||||
user={user}
|
||||
openOffboardingModalForUser={sinon.stub()}
|
||||
openUnlinkUserModal={sinon.stub()}
|
||||
groupId={subscriptionId}
|
||||
setGroupUserAlert={sinon.stub()}
|
||||
/>
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
})
|
||||
|
||||
it('renders the row', function () {
|
||||
// Checkbox
|
||||
cy.findByTestId('select-single-checkbox').should('not.be.checked')
|
||||
// Email
|
||||
cy.get('tr').contains(user.email)
|
||||
// Name
|
||||
cy.get('tr').contains(user.first_name)
|
||||
cy.get('tr').contains(user.last_name)
|
||||
// Last active date
|
||||
cy.get('tr').contains('21st Nov 2070')
|
||||
// SSO status
|
||||
cy.get('tr').contains('SSO')
|
||||
// Dropdown button
|
||||
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
|
||||
'exist'
|
||||
)
|
||||
|
||||
cy.get('tr').contains('Managed').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a pending invite', function () {
|
||||
let user: User
|
||||
|
||||
beforeEach(function () {
|
||||
user = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: true,
|
||||
last_active_at: new Date('2070-11-21T03:00:00'),
|
||||
enrollment: undefined,
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<MemberRow
|
||||
user={user}
|
||||
openOffboardingModalForUser={sinon.stub()}
|
||||
openUnlinkUserModal={sinon.stub()}
|
||||
groupId={subscriptionId}
|
||||
setGroupUserAlert={sinon.stub()}
|
||||
/>
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
})
|
||||
|
||||
it('should render a "Pending invite" badge', function () {
|
||||
cy.findByTestId('badge-pending-invite').should(
|
||||
'have.text',
|
||||
'Pending invite'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a group admin', function () {
|
||||
let user: User
|
||||
|
||||
beforeEach(function () {
|
||||
user = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date('2070-11-21T03:00:00'),
|
||||
enrollment: undefined,
|
||||
isEntityAdmin: true,
|
||||
}
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<MemberRow
|
||||
user={user}
|
||||
openOffboardingModalForUser={sinon.stub()}
|
||||
openUnlinkUserModal={sinon.stub()}
|
||||
groupId={subscriptionId}
|
||||
setGroupUserAlert={sinon.stub()}
|
||||
/>
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
})
|
||||
|
||||
it('should render a "Group admin" symbol', function () {
|
||||
cy.findByTestId('group-admin-symbol').within(() => {
|
||||
cy.findByText(/group admin/i)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('selecting and unselecting user row', function () {
|
||||
let user: User
|
||||
|
||||
beforeEach(function () {
|
||||
user = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date('2070-11-21T03:00:00'),
|
||||
enrollment: undefined,
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<MemberRow
|
||||
user={user}
|
||||
openOffboardingModalForUser={sinon.stub()}
|
||||
openUnlinkUserModal={sinon.stub()}
|
||||
groupId={subscriptionId}
|
||||
setGroupUserAlert={sinon.stub()}
|
||||
/>
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
})
|
||||
|
||||
it('should select and unselect the user', function () {
|
||||
cy.findByTestId('select-single-checkbox').should('not.be.checked')
|
||||
cy.findByTestId('select-single-checkbox').click()
|
||||
cy.findByTestId('select-single-checkbox').should('be.checked')
|
||||
cy.findByTestId('select-single-checkbox').click()
|
||||
cy.findByTestId('select-single-checkbox').should('not.be.checked')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with Managed Users and Group SSO enabled', function () {
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-managedUsersActive', true)
|
||||
win.metaAttributesCache.set('ol-groupSSOActive', true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with an ordinary user', function () {
|
||||
let user: User
|
||||
|
||||
beforeEach(function () {
|
||||
user = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date('2070-11-21T03:00:00'),
|
||||
enrollment: undefined,
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<MemberRow
|
||||
user={user}
|
||||
openOffboardingModalForUser={sinon.stub()}
|
||||
openUnlinkUserModal={sinon.stub()}
|
||||
groupId={subscriptionId}
|
||||
setGroupUserAlert={sinon.stub()}
|
||||
/>
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
})
|
||||
|
||||
it('renders the row', function () {
|
||||
// Checkbox
|
||||
cy.findByTestId('select-single-checkbox').should('not.be.checked')
|
||||
// Email
|
||||
cy.get('tr').contains(user.email)
|
||||
// Name
|
||||
cy.get('tr').contains(user.first_name)
|
||||
cy.get('tr').contains(user.last_name)
|
||||
// Last active date
|
||||
cy.get('tr').contains('21st Nov 2070')
|
||||
// Managed status
|
||||
cy.get('tr').contains('Managed')
|
||||
// SSO status
|
||||
cy.get('tr').contains('SSO')
|
||||
// Dropdown button
|
||||
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
|
||||
'exist'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a pending invite', function () {
|
||||
let user: User
|
||||
|
||||
beforeEach(function () {
|
||||
user = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: true,
|
||||
last_active_at: new Date('2070-11-21T03:00:00'),
|
||||
enrollment: undefined,
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<MemberRow
|
||||
user={user}
|
||||
openOffboardingModalForUser={sinon.stub()}
|
||||
openUnlinkUserModal={sinon.stub()}
|
||||
groupId={subscriptionId}
|
||||
setGroupUserAlert={sinon.stub()}
|
||||
/>
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
})
|
||||
|
||||
it('should render a "Pending invite" badge', function () {
|
||||
cy.findByTestId('badge-pending-invite').should(
|
||||
'have.text',
|
||||
'Pending invite'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a group admin', function () {
|
||||
let user: User
|
||||
|
||||
beforeEach(function () {
|
||||
user = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date('2070-11-21T03:00:00'),
|
||||
enrollment: undefined,
|
||||
isEntityAdmin: true,
|
||||
}
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<MemberRow
|
||||
user={user}
|
||||
openOffboardingModalForUser={sinon.stub()}
|
||||
openUnlinkUserModal={sinon.stub()}
|
||||
groupId={subscriptionId}
|
||||
setGroupUserAlert={sinon.stub()}
|
||||
/>
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
})
|
||||
|
||||
it('should render a "Group admin" symbol', function () {
|
||||
cy.findByTestId('group-admin-symbol').within(() => {
|
||||
cy.findByText(/group admin/i)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('selecting and unselecting user row', function () {
|
||||
let user: User
|
||||
|
||||
beforeEach(function () {
|
||||
user = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date('2070-11-21T03:00:00'),
|
||||
enrollment: undefined,
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<MemberRow
|
||||
user={user}
|
||||
openOffboardingModalForUser={sinon.stub()}
|
||||
openUnlinkUserModal={sinon.stub()}
|
||||
groupId={subscriptionId}
|
||||
setGroupUserAlert={sinon.stub()}
|
||||
/>
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
})
|
||||
|
||||
it('should select and unselect the user', function () {
|
||||
cy.findByTestId('select-single-checkbox').should('not.be.checked')
|
||||
cy.findByTestId('select-single-checkbox').click()
|
||||
cy.findByTestId('select-single-checkbox').should('be.checked')
|
||||
cy.findByTestId('select-single-checkbox').click()
|
||||
cy.findByTestId('select-single-checkbox').should('not.be.checked')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,332 @@
|
||||
import MembersList from '@/features/group-management/components/members-table/members-list'
|
||||
import { GroupMembersProvider } from '@/features/group-management/context/group-members-context'
|
||||
import { User } from '../../../../../../types/group-management/user'
|
||||
|
||||
const groupId = 'somegroup'
|
||||
|
||||
function mountManagedUsersList() {
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<MembersList groupId={groupId} />
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('MembersList', function () {
|
||||
describe('with users', function () {
|
||||
const users = [
|
||||
{
|
||||
_id: 'user-one',
|
||||
email: 'sarah.brennan@example.com',
|
||||
first_name: 'Sarah',
|
||||
last_name: 'Brennan',
|
||||
invite: false,
|
||||
last_active_at: new Date('2070-10-22T03:00:00'),
|
||||
enrollment: undefined,
|
||||
isEntityAdmin: undefined,
|
||||
},
|
||||
{
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date('2070-11-21T03:00:00'),
|
||||
enrollment: undefined,
|
||||
isEntityAdmin: undefined,
|
||||
},
|
||||
]
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', users)
|
||||
})
|
||||
mountManagedUsersList()
|
||||
})
|
||||
|
||||
it('should render the table headers but not SSO Column', function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-groupSSOActive', false)
|
||||
})
|
||||
mountManagedUsersList()
|
||||
|
||||
// Select-all checkbox
|
||||
cy.findByTestId('managed-entities-table').within(() => {
|
||||
cy.findByTestId('select-all-checkbox')
|
||||
})
|
||||
cy.findByTestId('managed-entities-table').should('contain.text', 'Email')
|
||||
cy.findByTestId('managed-entities-table').should('contain.text', 'Name')
|
||||
cy.findByTestId('managed-entities-table').should(
|
||||
'contain.text',
|
||||
'Last Active'
|
||||
)
|
||||
cy.findByTestId('managed-entities-table').should(
|
||||
'not.contain.text',
|
||||
'Security'
|
||||
)
|
||||
})
|
||||
it('should render the table headers with SSO Column', function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-groupSSOActive', true)
|
||||
})
|
||||
mountManagedUsersList()
|
||||
|
||||
// Select-all checkbox
|
||||
cy.findByTestId('managed-entities-table').within(() => {
|
||||
cy.findByTestId('select-all-checkbox')
|
||||
})
|
||||
|
||||
cy.findByTestId('managed-entities-table').should('contain.text', 'Email')
|
||||
cy.findByTestId('managed-entities-table').should('contain.text', 'Name')
|
||||
cy.findByTestId('managed-entities-table').should(
|
||||
'contain.text',
|
||||
'Last Active'
|
||||
)
|
||||
cy.findByTestId('managed-entities-table').should(
|
||||
'contain.text',
|
||||
'Security'
|
||||
)
|
||||
})
|
||||
|
||||
it('should render the list of users', function () {
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.findAllByRole('row').should('have.length', 2)
|
||||
})
|
||||
// First user
|
||||
cy.findByTestId('managed-entities-table').should(
|
||||
'contain.text',
|
||||
users[0].email
|
||||
)
|
||||
cy.findByTestId('managed-entities-table').should(
|
||||
'contain.text',
|
||||
users[0].first_name
|
||||
)
|
||||
cy.findByTestId('managed-entities-table').should(
|
||||
'contain.text',
|
||||
users[0].last_name
|
||||
)
|
||||
// Second user
|
||||
cy.findByTestId('managed-entities-table').should(
|
||||
'contain.text',
|
||||
users[1].email
|
||||
)
|
||||
cy.findByTestId('managed-entities-table').should(
|
||||
'contain.text',
|
||||
users[1].first_name
|
||||
)
|
||||
cy.findByTestId('managed-entities-table').should(
|
||||
'contain.text',
|
||||
users[1].last_name
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('empty user list', function () {
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [])
|
||||
})
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<MembersList groupId={groupId} />
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
})
|
||||
|
||||
it('should render the list, with a "no members" message', function () {
|
||||
cy.findByTestId('managed-entities-table').should(
|
||||
'contain.text',
|
||||
'No members'
|
||||
)
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.findAllByRole('row')
|
||||
.should('have.length', 1)
|
||||
.and('contain.text', 'No members')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('SSO unlinking', function () {
|
||||
const USER_PENDING_INVITE: User = {
|
||||
_id: 'abc123def456',
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
email: 'john.doe@test.com',
|
||||
last_active_at: new Date('2023-01-15'),
|
||||
invite: true,
|
||||
}
|
||||
const USER_NOT_LINKED: User = {
|
||||
_id: 'bcd234efa567',
|
||||
first_name: 'Bobby',
|
||||
last_name: 'Lapointe',
|
||||
email: 'bobby.lapointe@test.com',
|
||||
last_active_at: new Date('2023-01-02'),
|
||||
invite: false,
|
||||
}
|
||||
const USER_LINKED: User = {
|
||||
_id: 'defabc231453',
|
||||
first_name: 'Claire',
|
||||
last_name: 'Jennings',
|
||||
email: 'claire.jennings@test.com',
|
||||
last_active_at: new Date('2023-01-03'),
|
||||
invite: false,
|
||||
enrollment: {
|
||||
sso: [
|
||||
{
|
||||
groupId,
|
||||
linkedAt: new Date('2023-01-03'),
|
||||
primary: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
const USER_LINKED_AND_MANAGED: User = {
|
||||
_id: 'defabc231453',
|
||||
first_name: 'Jean-Luc',
|
||||
last_name: 'Picard',
|
||||
email: 'picard@test.com',
|
||||
last_active_at: new Date('2023-01-03'),
|
||||
invite: false,
|
||||
enrollment: {
|
||||
managedBy: groupId,
|
||||
enrolledAt: new Date('2023-01-03'),
|
||||
sso: [
|
||||
{
|
||||
groupId,
|
||||
linkedAt: new Date('2023-01-03'),
|
||||
primary: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
const users = [
|
||||
USER_PENDING_INVITE,
|
||||
USER_NOT_LINKED,
|
||||
USER_LINKED,
|
||||
USER_LINKED_AND_MANAGED,
|
||||
]
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-groupId', groupId)
|
||||
win.metaAttributesCache.set('ol-users', users)
|
||||
win.metaAttributesCache.set('ol-groupSSOActive', true)
|
||||
})
|
||||
|
||||
cy.intercept('POST', `manage/groups/${groupId}/unlink-user/*`, {
|
||||
statusCode: 200,
|
||||
})
|
||||
})
|
||||
|
||||
describe('unlinking user', function () {
|
||||
beforeEach(function () {
|
||||
mountManagedUsersList()
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(3)').within(() => {
|
||||
cy.findByText('SSO active')
|
||||
cy.findByRole('button', { name: /actions/i }).click()
|
||||
cy.findByTestId('unlink-user-action').click()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should show successs notification and update the user row after unlinking', function () {
|
||||
cy.findByRole('dialog').within(() => {
|
||||
cy.findByRole('button', { name: /unlink user/i }).click()
|
||||
})
|
||||
cy.findByRole('alert').should(
|
||||
'contain.text',
|
||||
`SSO reauthentication request has been sent to ${USER_LINKED.email}`
|
||||
)
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(3)').within(() => {
|
||||
cy.findByText('SSO not active')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('managed users enabled', function () {
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-managedUsersActive', true)
|
||||
})
|
||||
mountManagedUsersList()
|
||||
})
|
||||
|
||||
describe('when user is not managed', function () {
|
||||
beforeEach(function () {
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(3)').within(() => {
|
||||
cy.findByText('SSO active')
|
||||
cy.findByText('Not managed')
|
||||
cy.findByRole('button', { name: /actions/i }).click()
|
||||
cy.findByTestId('unlink-user-action').click()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should show successs notification and update the user row after unlinking', function () {
|
||||
cy.findByRole('dialog').within(() => {
|
||||
cy.findByRole('button', { name: /unlink user/i }).click()
|
||||
})
|
||||
cy.findByRole('alert').should(
|
||||
'contain.text',
|
||||
`SSO reauthentication request has been sent to ${USER_LINKED.email}`
|
||||
)
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(3)').within(() => {
|
||||
cy.findByText('SSO not active')
|
||||
cy.findByText('Not managed')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('when user is managed', function () {
|
||||
beforeEach(function () {
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(4)').within(() => {
|
||||
cy.findByText('SSO active')
|
||||
cy.findAllByText('Managed')
|
||||
cy.findByRole('button', { name: /actions/i }).click()
|
||||
cy.findByTestId('unlink-user-action').click()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should show successs notification and update the user row after unlinking', function () {
|
||||
cy.findByRole('dialog').within(() => {
|
||||
cy.findByRole('button', { name: /unlink user/i }).click()
|
||||
})
|
||||
cy.findByRole('alert').should(
|
||||
'contain.text',
|
||||
`SSO reauthentication request has been sent to ${USER_LINKED_AND_MANAGED.email}`
|
||||
)
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(4)').within(() => {
|
||||
cy.findByText('SSO not active')
|
||||
cy.findAllByText('Managed')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
import OffboardManagedUserModal from '@/features/group-management/components/members-table/offboard-managed-user-modal'
|
||||
import sinon from 'sinon'
|
||||
|
||||
describe('OffboardManagedUserModal', function () {
|
||||
describe('happy path', function () {
|
||||
const groupId = 'some-group'
|
||||
const user = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: true,
|
||||
last_active_at: new Date(),
|
||||
enrollment: {
|
||||
managedBy: `${groupId}`,
|
||||
enrolledAt: new Date(),
|
||||
},
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
const otherUser = {
|
||||
_id: 'other-user',
|
||||
email: 'other.user@example.com',
|
||||
first_name: 'Other',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date(),
|
||||
enrollment: {
|
||||
managedBy: `${groupId}`,
|
||||
enrolledAt: new Date(),
|
||||
},
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
const allMembers = [user, otherUser]
|
||||
|
||||
beforeEach(function () {
|
||||
cy.mount(
|
||||
<OffboardManagedUserModal
|
||||
user={user}
|
||||
allMembers={allMembers}
|
||||
groupId={groupId}
|
||||
onClose={sinon.stub()}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
it('should render the modal', function () {
|
||||
cy.get('#delete-user-form').should('exist')
|
||||
})
|
||||
|
||||
it('should disable the button if a recipient is not selected', function () {
|
||||
// Button should be disabled initially
|
||||
cy.get('button[type="submit"]').should('be.disabled')
|
||||
|
||||
// Not selecting a recipient...
|
||||
|
||||
// Fill in the email input
|
||||
cy.get('#supplied-email-input').type(user.email)
|
||||
|
||||
// Button still disabled
|
||||
cy.get('button[type="submit"]').should('be.disabled')
|
||||
})
|
||||
|
||||
it('should disable the button if the email is not filled in', function () {
|
||||
// Button should be disabled initially
|
||||
cy.get('button[type="submit"]').should('be.disabled')
|
||||
|
||||
// Select a recipient
|
||||
cy.get('#recipient-select-input').select('other.user@example.com')
|
||||
|
||||
// Not filling in the email...
|
||||
|
||||
// Button still disabled
|
||||
cy.get('button[type="submit"]').should('be.disabled')
|
||||
})
|
||||
|
||||
it('should disable the button if the email does not match the user', function () {
|
||||
// Button should be disabled initially
|
||||
cy.get('button[type="submit"]').should('be.disabled')
|
||||
|
||||
// Select a recipient
|
||||
cy.get('#recipient-select-input').select('other.user@example.com')
|
||||
|
||||
// Fill in the email input, with the wrong email address
|
||||
cy.get('#supplied-email-input').type('totally.wrong@example.com')
|
||||
|
||||
// Button still disabled
|
||||
cy.get('button[type="submit"]').should('be.disabled')
|
||||
})
|
||||
|
||||
it('should fill out the form, and enable the delete button', function () {
|
||||
// Button should be disabled initially
|
||||
cy.get('button[type="submit"]').should('be.disabled')
|
||||
|
||||
// Select a recipient
|
||||
cy.get('#recipient-select-input').select('other.user@example.com')
|
||||
|
||||
// Button still disabled
|
||||
cy.get('button[type="submit"]').should('be.disabled')
|
||||
|
||||
// Fill in the email input
|
||||
cy.get('#supplied-email-input').type(user.email)
|
||||
|
||||
// Button should be enabled now
|
||||
cy.get('button[type="submit"]').should('not.be.disabled')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { ReactElement } from 'react'
|
||||
import sinon from 'sinon'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import UnlinkUserModal from '@/features/group-management/components/members-table/unlink-user-modal'
|
||||
import { GroupMembersProvider } from '@/features/group-management/context/group-members-context'
|
||||
import { expect } from 'chai'
|
||||
|
||||
export function renderWithContext(component: ReactElement, props = {}) {
|
||||
const GroupMembersProviderWrapper = ({
|
||||
children,
|
||||
}: {
|
||||
children: ReactElement
|
||||
}) => <GroupMembersProvider {...props}>{children}</GroupMembersProvider>
|
||||
|
||||
return render(component, { wrapper: GroupMembersProviderWrapper })
|
||||
}
|
||||
|
||||
describe('<UnlinkUserModal />', function () {
|
||||
let defaultProps: any
|
||||
const groupId = 'group123'
|
||||
const userId = 'user123'
|
||||
|
||||
beforeEach(function () {
|
||||
defaultProps = {
|
||||
onClose: sinon.stub(),
|
||||
user: { _id: userId },
|
||||
setGroupUserAlert: sinon.stub(),
|
||||
}
|
||||
window.metaAttributesCache.set('ol-groupId', groupId)
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('displays the modal', async function () {
|
||||
renderWithContext(<UnlinkUserModal {...defaultProps} />)
|
||||
await screen.findByRole('heading', {
|
||||
name: 'Unlink user',
|
||||
})
|
||||
screen.getByText('You’re about to remove the SSO login option for', {
|
||||
exact: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('closes the modal on success', async function () {
|
||||
fetchMock.post(`/manage/groups/${groupId}/unlink-user/${userId}`, 200)
|
||||
|
||||
renderWithContext(<UnlinkUserModal {...defaultProps} />)
|
||||
await screen.findByRole('heading', {
|
||||
name: 'Unlink user',
|
||||
})
|
||||
|
||||
const confirmButton = screen.getByRole('button', { name: 'Unlink user' })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => expect(defaultProps.onClose).to.have.been.called)
|
||||
})
|
||||
|
||||
it('handles errors', async function () {
|
||||
fetchMock.post(`/manage/groups/${groupId}/unlink-user/${userId}`, 500)
|
||||
|
||||
renderWithContext(<UnlinkUserModal {...defaultProps} />)
|
||||
await screen.findByRole('heading', {
|
||||
name: 'Unlink user',
|
||||
})
|
||||
|
||||
const confirmButton = screen.getByRole('button', { name: 'Unlink user' })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => screen.findByText('Sorry, something went wrong'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { SplitTestProvider } from '@/shared/context/split-test-context'
|
||||
import MissingBillingInformation from '@/features/group-management/components/missing-billing-information'
|
||||
|
||||
describe('<MissingBillingInformation />', function () {
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<SplitTestProvider>
|
||||
<MissingBillingInformation />
|
||||
</SplitTestProvider>
|
||||
)
|
||||
})
|
||||
|
||||
it('shows missing payment details notification', function () {
|
||||
cy.findByRole('alert').within(() => {
|
||||
cy.findByText(/missing payment details/i)
|
||||
cy.findByText(
|
||||
/it looks like your payment details are missing\. Please.*, or.*with our Support team for more help/i
|
||||
).within(() => {
|
||||
cy.findByRole('link', {
|
||||
name: /update your billing information/i,
|
||||
}).should(
|
||||
'have.attr',
|
||||
'href',
|
||||
'/user/subscription/recurly/billing-details'
|
||||
)
|
||||
cy.findByRole('link', { name: /get in touch/i }).should(
|
||||
'have.attr',
|
||||
'href',
|
||||
'/contact'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,171 @@
|
||||
import PublisherManagers from '@/features/group-management/components/publisher-managers'
|
||||
|
||||
const JOHN_DOE = {
|
||||
_id: 'abc123def456',
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
email: 'john.doe@test.com',
|
||||
last_active_at: new Date('2023-01-15'),
|
||||
invite: true,
|
||||
}
|
||||
const BOBBY_LAPOINTE = {
|
||||
_id: 'bcd234efa567',
|
||||
first_name: 'Bobby',
|
||||
last_name: 'Lapointe',
|
||||
email: 'bobby.lapointe@test.com',
|
||||
last_active_at: new Date('2023-01-02'),
|
||||
invite: false,
|
||||
}
|
||||
const GROUP_ID = '000fff000fff'
|
||||
const PATHS = {
|
||||
addMember: `/manage/publishers/${GROUP_ID}/managers`,
|
||||
removeMember: `/manage/publishers/${GROUP_ID}/managers`,
|
||||
}
|
||||
|
||||
describe('publisher managers', function () {
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [JOHN_DOE, BOBBY_LAPOINTE])
|
||||
win.metaAttributesCache.set('ol-groupId', GROUP_ID)
|
||||
win.metaAttributesCache.set('ol-groupName', 'My Awesome Publisher')
|
||||
})
|
||||
|
||||
cy.mount(<PublisherManagers />)
|
||||
})
|
||||
|
||||
it('renders the publisher management page', function () {
|
||||
cy.findByRole('heading', { name: /my awesome publisher/i, level: 1 })
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByText('john.doe@test.com')
|
||||
cy.findByText('John Doe')
|
||||
cy.findByText('15th Jan 2023')
|
||||
cy.findByText('Invite not yet accepted')
|
||||
})
|
||||
|
||||
cy.get('tr:nth-child(2)').within(() => {
|
||||
cy.findByText('bobby.lapointe@test.com')
|
||||
cy.findByText('Bobby Lapointe')
|
||||
cy.findByText('2nd Jan 2023')
|
||||
cy.findByText('Accepted invite')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('sends an invite', function () {
|
||||
cy.intercept('POST', PATHS.addMember, {
|
||||
statusCode: 201,
|
||||
body: {
|
||||
user: {
|
||||
email: 'someone.else@test.com',
|
||||
invite: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cy.findByTestId('add-members-form').within(() => {
|
||||
cy.findByRole('textbox').type('someone.else@test.com')
|
||||
cy.findByRole('button').click()
|
||||
})
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(3)').within(() => {
|
||||
cy.findByText('someone.else@test.com')
|
||||
cy.findByText('N/A')
|
||||
cy.findByText('Invite not yet accepted')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('tries to send an invite and displays the error', function () {
|
||||
cy.intercept('POST', PATHS.addMember, {
|
||||
statusCode: 500,
|
||||
body: {
|
||||
error: {
|
||||
message: 'User already added',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cy.findByTestId('add-members-form').within(() => {
|
||||
cy.findByRole('textbox').type('someone.else@test.com')
|
||||
cy.findByRole('button').click()
|
||||
})
|
||||
cy.findByRole('alert').should('contain.text', 'Error: User already added')
|
||||
})
|
||||
|
||||
it('checks the select all checkbox', function () {
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByLabelText(/select user/i).should('not.be.checked')
|
||||
})
|
||||
cy.get('tr:nth-child(2)').within(() => {
|
||||
cy.findByLabelText(/select user/i).should('not.be.checked')
|
||||
})
|
||||
})
|
||||
|
||||
cy.findByTestId('select-all-checkbox').click()
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByLabelText(/select user/i).should('be.checked')
|
||||
})
|
||||
cy.get('tr:nth-child(2)').within(() => {
|
||||
cy.findByLabelText(/select user/i).should('be.checked')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('remove a member', function () {
|
||||
cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
|
||||
statusCode: 200,
|
||||
})
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByLabelText(/select user/i).check()
|
||||
})
|
||||
})
|
||||
|
||||
cy.findByRole('button', { name: 'Remove manager' }).click()
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByText('bobby.lapointe@test.com')
|
||||
cy.findByText('Bobby Lapointe')
|
||||
cy.findByText('2nd Jan 2023')
|
||||
cy.findByText('Accepted invite')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('tries to remove a manager and displays the error', function () {
|
||||
cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
|
||||
statusCode: 500,
|
||||
})
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByLabelText(/select user/i).check()
|
||||
})
|
||||
})
|
||||
cy.findByRole('button', { name: /remove manager/i }).click()
|
||||
|
||||
cy.findByRole('alert').should('contain.text', 'Sorry, something went wrong')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import RequestStatus from '@/features/group-management/components/request-status'
|
||||
|
||||
describe('<RequestStatus />', function () {
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
|
||||
})
|
||||
cy.mount(
|
||||
<RequestStatus icon="email" title="Test title" content="Test content" />
|
||||
)
|
||||
})
|
||||
|
||||
it('renders the back button', function () {
|
||||
cy.findByTestId('group-heading').within(() => {
|
||||
cy.findByRole('button', { name: /back to subscription/i }).should(
|
||||
'have.attr',
|
||||
'href',
|
||||
'/user/subscription'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the group name', function () {
|
||||
cy.findByTestId('group-heading').within(() => {
|
||||
cy.findByRole('heading', { name: 'My Awesome Team' })
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the title', function () {
|
||||
cy.findByTestId('title').should('contain.text', 'Test title')
|
||||
})
|
||||
|
||||
it('shows the content', function () {
|
||||
cy.findByText('Test content')
|
||||
})
|
||||
|
||||
it('renders the link to subscriptions', function () {
|
||||
cy.findByRole('button', { name: /go to subscriptions/i }).should(
|
||||
'have.attr',
|
||||
'href',
|
||||
'/user/subscription'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import SubtotalLimitExceeded from '@/features/group-management/components/subtotal-limit-exceeded'
|
||||
|
||||
describe('<SubtotalLimitExceeded />', function () {
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
|
||||
})
|
||||
|
||||
cy.mount(<SubtotalLimitExceeded />)
|
||||
})
|
||||
|
||||
it('shows subtotal limit exceeded notification', function () {
|
||||
cy.findByRole('alert').within(() => {
|
||||
cy.findByText(
|
||||
/sorry, there was an issue upgrading your subscription\. Please.*for help/i
|
||||
).within(() => {
|
||||
cy.findByRole('link', {
|
||||
name: /contact our support team/i,
|
||||
}).should('have.attr', 'href', '/contact')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,150 @@
|
||||
import UpgradeSubscription from '@/features/group-management/components/upgrade-subscription/upgrade-subscription'
|
||||
import { SubscriptionChangePreview } from '../../../../../types/subscription/subscription-change-preview'
|
||||
|
||||
describe('<UpgradeSubscription />', function () {
|
||||
const resetPreviewAndRemount = (preview: SubscriptionChangePreview) => {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-subscriptionChangePreview', preview)
|
||||
})
|
||||
|
||||
cy.mount(<UpgradeSubscription />)
|
||||
}
|
||||
beforeEach(function () {
|
||||
this.totalLicenses = 2
|
||||
this.preview = {
|
||||
change: {
|
||||
type: 'group-plan-upgrade',
|
||||
prevPlan: { name: 'Overleaf Standard Group' },
|
||||
},
|
||||
currency: 'USD',
|
||||
immediateCharge: {
|
||||
subtotal: 353.99,
|
||||
tax: 70.8,
|
||||
total: 424.79,
|
||||
discount: 0,
|
||||
},
|
||||
paymentMethod: 'Visa **** 1111',
|
||||
nextPlan: { annual: true },
|
||||
nextInvoice: {
|
||||
date: '2025-11-05T11:35:32.000Z',
|
||||
plan: { name: 'Overleaf Professional Group', amount: 0 },
|
||||
addOns: [
|
||||
{
|
||||
code: 'additional-license',
|
||||
name: 'Seat',
|
||||
quantity: 2,
|
||||
unitAmount: 399,
|
||||
amount: 798,
|
||||
},
|
||||
],
|
||||
subtotal: 798,
|
||||
tax: { rate: 0.2, amount: 159.6 },
|
||||
total: 957.6,
|
||||
},
|
||||
}
|
||||
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
|
||||
win.metaAttributesCache.set('ol-totalLicenses', this.totalLicenses)
|
||||
})
|
||||
resetPreviewAndRemount(this.preview)
|
||||
})
|
||||
|
||||
it('shows the group name', function () {
|
||||
cy.findByTestId('group-heading').within(() => {
|
||||
cy.findByRole('heading', { name: 'My Awesome Team' })
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the "Add more licenses to my plan" label', function () {
|
||||
cy.findByText(/add more licenses to my plan/i).should(
|
||||
'have.attr',
|
||||
'href',
|
||||
'/user/subscription/group/add-users'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows the "Upgrade" and "Cancel" buttons', function () {
|
||||
cy.findByRole('button', { name: /upgrade/i })
|
||||
cy.findByRole('button', { name: /cancel/i }).should(
|
||||
'have.attr',
|
||||
'href',
|
||||
'/user/subscription'
|
||||
)
|
||||
})
|
||||
|
||||
describe('shows plan details', function () {
|
||||
it('shows per user price', function () {
|
||||
cy.findByTestId('per-user-price').within(() => {
|
||||
cy.findByText('$399')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows additional features', function () {
|
||||
cy.findByText(/unlimited collaborators per project/i)
|
||||
cy.findByText(/sso/i)
|
||||
cy.findByText(/managed user accounts/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shows upgrade summary', function () {
|
||||
it('shows subtotal, tax and total price', function () {
|
||||
cy.findByTestId('subtotal').within(() => {
|
||||
cy.findByText('$353.99')
|
||||
})
|
||||
cy.findByTestId('tax').within(() => {
|
||||
cy.findByText('$70.80')
|
||||
})
|
||||
cy.findByTestId('total').within(() => {
|
||||
cy.findByText('$424.79')
|
||||
})
|
||||
cy.findByTestId('discount').should('not.exist')
|
||||
})
|
||||
|
||||
it('shows subtotal, discount, tax and total price', function () {
|
||||
resetPreviewAndRemount({
|
||||
...this.preview,
|
||||
immediateCharge: {
|
||||
subtotal: 353.99,
|
||||
tax: 70.8,
|
||||
total: 424.79,
|
||||
discount: 50,
|
||||
},
|
||||
})
|
||||
cy.findByTestId('subtotal').within(() => {
|
||||
cy.findByText('$353.99')
|
||||
})
|
||||
cy.findByTestId('tax').within(() => {
|
||||
cy.findByText('$70.80')
|
||||
})
|
||||
cy.findByTestId('total').within(() => {
|
||||
cy.findByText('$424.79')
|
||||
})
|
||||
cy.findByTestId('discount').within(() => {
|
||||
cy.findByText('($50.00)')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows total users', function () {
|
||||
cy.findByText(/you have 2 licenses on your subscription./i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('submit upgrade request', function () {
|
||||
it('request succeeded', function () {
|
||||
cy.intercept('POST', '/user/subscription/group/upgrade-subscription', {
|
||||
statusCode: 200,
|
||||
}).as('upgradeRequest')
|
||||
cy.findByRole('button', { name: /upgrade/i }).click()
|
||||
cy.findByText(/you’ve upgraded your plan!/i)
|
||||
})
|
||||
|
||||
it('request failed', function () {
|
||||
cy.intercept('POST', '/user/subscription/group/upgrade-subscription', {
|
||||
statusCode: 400,
|
||||
}).as('upgradeRequest')
|
||||
cy.findByRole('button', { name: /upgrade/i }).click()
|
||||
cy.findByText(/something went wrong/i)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,680 @@
|
||||
import { useState } from 'react'
|
||||
import ToggleSwitch from '../../../../../frontend/js/features/history/components/change-list/toggle-switch'
|
||||
import ChangeList from '../../../../../frontend/js/features/history/components/change-list/change-list'
|
||||
import {
|
||||
EditorProviders,
|
||||
USER_EMAIL,
|
||||
USER_ID,
|
||||
} from '../../../helpers/editor-providers'
|
||||
import { HistoryProvider } from '../../../../../frontend/js/features/history/context/history-context'
|
||||
import { updates } from '../fixtures/updates'
|
||||
import { labels } from '../fixtures/labels'
|
||||
import { formatTime, relativeDate } from '@/features/utils/format-date'
|
||||
|
||||
const mountWithEditorProviders = (
|
||||
component: React.ReactNode,
|
||||
scope: Record<string, unknown> = {},
|
||||
props: Record<string, unknown> = {}
|
||||
) => {
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope} {...props}>
|
||||
<HistoryProvider>
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<div className="history-react">{component}</div>
|
||||
</div>
|
||||
</HistoryProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
}
|
||||
|
||||
describe('change list (Bootstrap 5)', function () {
|
||||
const scope = {
|
||||
ui: { view: 'history', pdfLayout: 'sideBySide', chatOpen: true },
|
||||
}
|
||||
|
||||
const waitForData = () => {
|
||||
cy.wait('@updates')
|
||||
cy.wait('@labels')
|
||||
cy.wait('@diff')
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.intercept('GET', '/project/*/updates*', {
|
||||
body: updates,
|
||||
}).as('updates')
|
||||
cy.intercept('GET', '/project/*/labels', {
|
||||
body: labels,
|
||||
}).as('labels')
|
||||
cy.intercept('GET', '/project/*/filetree/diff*', {
|
||||
body: { diff: [{ pathname: 'main.tex' }, { pathname: 'name.tex' }] },
|
||||
}).as('diff')
|
||||
window.metaAttributesCache.set('ol-inactiveTutorials', [
|
||||
'react-history-buttons-tutorial',
|
||||
])
|
||||
})
|
||||
|
||||
describe('toggle switch', function () {
|
||||
it('renders switch buttons', function () {
|
||||
mountWithEditorProviders(
|
||||
<ToggleSwitch labelsOnly={false} setLabelsOnly={() => {}} />
|
||||
)
|
||||
|
||||
cy.findByLabelText(/all history/i)
|
||||
cy.findByLabelText(/labels/i)
|
||||
})
|
||||
|
||||
it('toggles "all history" and "labels" buttons', function () {
|
||||
function ToggleSwitchWrapped({ labelsOnly }: { labelsOnly: boolean }) {
|
||||
const [labelsOnlyLocal, setLabelsOnlyLocal] = useState(labelsOnly)
|
||||
return (
|
||||
<ToggleSwitch
|
||||
labelsOnly={labelsOnlyLocal}
|
||||
setLabelsOnly={setLabelsOnlyLocal}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
mountWithEditorProviders(<ToggleSwitchWrapped labelsOnly={false} />)
|
||||
|
||||
cy.findByLabelText(/all history/i).as('all-history')
|
||||
cy.findByLabelText(/labels/i).as('labels')
|
||||
cy.get('@all-history').should('be.checked')
|
||||
cy.get('@labels').should('not.be.checked')
|
||||
cy.get('@labels').click({ force: true })
|
||||
cy.get('@all-history').should('not.be.checked')
|
||||
cy.get('@labels').should('be.checked')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tags', function () {
|
||||
it('renders tags', function () {
|
||||
mountWithEditorProviders(<ChangeList />, scope, {
|
||||
user: {
|
||||
id: USER_ID,
|
||||
email: USER_EMAIL,
|
||||
isAdmin: true,
|
||||
},
|
||||
})
|
||||
waitForData()
|
||||
|
||||
cy.findByLabelText(/all history/i).click({ force: true })
|
||||
cy.findAllByTestId('history-version-details').as('details')
|
||||
cy.get('@details').should('have.length', 5)
|
||||
// start with 2nd details entry, as first has no tags
|
||||
cy.get('@details')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.findAllByTestId('history-version-badge').as('tags')
|
||||
})
|
||||
cy.get('@tags').should('have.length', 2)
|
||||
cy.get('@tags').eq(0).should('contain.text', 'tag-2')
|
||||
cy.get('@tags').eq(1).should('contain.text', 'tag-1')
|
||||
// should have delete buttons
|
||||
cy.get('@tags').each(tag =>
|
||||
cy.wrap(tag).within(() => {
|
||||
cy.findByRole('button', { name: /delete/i })
|
||||
})
|
||||
)
|
||||
// 3rd details entry
|
||||
cy.get('@details')
|
||||
.eq(2)
|
||||
.within(() => {
|
||||
cy.findAllByTestId('history-version-badge').should('have.length', 0)
|
||||
})
|
||||
// 4th details entry
|
||||
cy.get('@details')
|
||||
.eq(3)
|
||||
.within(() => {
|
||||
cy.findAllByTestId('history-version-badge').as('tags')
|
||||
})
|
||||
cy.get('@tags').should('have.length', 2)
|
||||
cy.get('@tags').eq(0).should('contain.text', 'tag-4')
|
||||
cy.get('@tags').eq(1).should('contain.text', 'tag-3')
|
||||
// should not have delete buttons
|
||||
cy.get('@tags').each(tag =>
|
||||
cy.wrap(tag).within(() => {
|
||||
cy.findByRole('button', { name: /delete/i }).should('not.exist')
|
||||
})
|
||||
)
|
||||
cy.findByLabelText(/labels/i).click({ force: true })
|
||||
cy.findAllByTestId('history-version-details').as('details')
|
||||
// first details on labels is always "current version", start testing on second
|
||||
cy.get('@details').should('have.length', 3)
|
||||
cy.get('@details')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.findAllByTestId('history-version-badge').as('tags')
|
||||
})
|
||||
cy.get('@tags').should('have.length', 2)
|
||||
cy.get('@tags').eq(0).should('contain.text', 'tag-2')
|
||||
cy.get('@tags').eq(1).should('contain.text', 'tag-1')
|
||||
cy.get('@details')
|
||||
.eq(2)
|
||||
.within(() => {
|
||||
cy.findAllByTestId('history-version-badge').as('tags')
|
||||
})
|
||||
cy.get('@tags').should('have.length', 3)
|
||||
cy.get('@tags').eq(0).should('contain.text', 'tag-5')
|
||||
cy.get('@tags').eq(1).should('contain.text', 'tag-4')
|
||||
cy.get('@tags').eq(2).should('contain.text', 'tag-3')
|
||||
})
|
||||
|
||||
it('deletes tag', function () {
|
||||
mountWithEditorProviders(<ChangeList />, scope, {
|
||||
user: {
|
||||
id: USER_ID,
|
||||
email: USER_EMAIL,
|
||||
isAdmin: true,
|
||||
},
|
||||
})
|
||||
waitForData()
|
||||
|
||||
cy.findByLabelText(/all history/i).click({ force: true })
|
||||
|
||||
const labelToDelete = 'tag-2'
|
||||
cy.findAllByTestId('history-version-details').eq(1).as('details')
|
||||
cy.get('@details').within(() => {
|
||||
cy.findAllByTestId('history-version-badge').eq(0).as('tag')
|
||||
})
|
||||
cy.get('@tag').should('contain.text', labelToDelete)
|
||||
cy.get('@tag').within(() => {
|
||||
cy.findByRole('button', { name: /delete/i }).as('delete-btn')
|
||||
})
|
||||
cy.get('@delete-btn').click()
|
||||
cy.findByRole('dialog').as('modal')
|
||||
cy.get('@modal').within(() => {
|
||||
cy.findByRole('heading', { name: /delete label/i })
|
||||
})
|
||||
cy.get('@modal').contains(
|
||||
new RegExp(
|
||||
`are you sure you want to delete the following label "${labelToDelete}"?`,
|
||||
'i'
|
||||
)
|
||||
)
|
||||
cy.get('@modal').within(() => {
|
||||
cy.findByRole('button', { name: /cancel/i }).click()
|
||||
})
|
||||
cy.findByRole('dialog').should('not.exist')
|
||||
cy.get('@delete-btn').click()
|
||||
cy.findByRole('dialog').as('modal')
|
||||
cy.intercept('DELETE', '/project/*/labels/*', {
|
||||
statusCode: 500,
|
||||
}).as('delete')
|
||||
cy.get('@modal').within(() => {
|
||||
cy.findByRole('button', { name: /delete/i }).click()
|
||||
})
|
||||
cy.wait('@delete')
|
||||
cy.get('@modal').within(() => {
|
||||
cy.findByRole('alert').within(() => {
|
||||
cy.contains(/sorry, something went wrong/i)
|
||||
})
|
||||
})
|
||||
cy.findByText(labelToDelete).should('have.length', 1)
|
||||
|
||||
cy.intercept('DELETE', '/project/*/labels/*', {
|
||||
statusCode: 204,
|
||||
}).as('delete')
|
||||
cy.get('@modal').within(() => {
|
||||
cy.findByRole('button', { name: /delete/i }).click()
|
||||
})
|
||||
cy.wait('@delete')
|
||||
cy.findByText(labelToDelete).should('not.exist')
|
||||
})
|
||||
|
||||
it('verifies that selecting the same list item will not trigger a new diff', function () {
|
||||
mountWithEditorProviders(<ChangeList />, scope, {
|
||||
user: {
|
||||
id: USER_ID,
|
||||
email: USER_EMAIL,
|
||||
isAdmin: true,
|
||||
},
|
||||
})
|
||||
waitForData()
|
||||
|
||||
const stub = cy.stub().as('diffStub')
|
||||
cy.intercept('GET', '/project/*/filetree/diff*', stub).as('diff')
|
||||
|
||||
cy.findAllByTestId('history-version-details').eq(2).as('details')
|
||||
cy.get('@details').click() // 1st click
|
||||
cy.wait('@diff')
|
||||
cy.get('@details').click() // 2nd click
|
||||
cy.get('@diffStub').should('have.been.calledOnce')
|
||||
})
|
||||
})
|
||||
|
||||
describe('all history', function () {
|
||||
beforeEach(function () {
|
||||
mountWithEditorProviders(<ChangeList />, scope, {
|
||||
user: {
|
||||
id: USER_ID,
|
||||
email: USER_EMAIL,
|
||||
isAdmin: true,
|
||||
},
|
||||
})
|
||||
waitForData()
|
||||
})
|
||||
|
||||
it('shows grouped versions date', function () {
|
||||
cy.findByText(relativeDate(updates.updates[0].meta.end_ts))
|
||||
cy.findByText(relativeDate(updates.updates[1].meta.end_ts))
|
||||
})
|
||||
|
||||
it('shows the date of the version', function () {
|
||||
cy.findAllByTestId('history-version-details')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.findByTestId('history-version-metadata-time').should(
|
||||
'have.text',
|
||||
formatTime(updates.updates[0].meta.end_ts, 'Do MMMM, h:mm a')
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows change action', function () {
|
||||
cy.findAllByTestId('history-version-details')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.findByTestId('history-version-change-action').should(
|
||||
'have.text',
|
||||
'Created'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows changed document name', function () {
|
||||
cy.findAllByTestId('history-version-details')
|
||||
.eq(2)
|
||||
.within(() => {
|
||||
cy.findByTestId('history-version-change-doc').should(
|
||||
'have.text',
|
||||
updates.updates[2].pathnames[0]
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows users', function () {
|
||||
cy.findAllByTestId('history-version-details')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.findByTestId('history-version-metadata-users')
|
||||
.should('contain.text', 'You')
|
||||
.and('contain.text', updates.updates[1].meta.users[1].first_name)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('labels only', function () {
|
||||
beforeEach(function () {
|
||||
mountWithEditorProviders(<ChangeList />, scope, {
|
||||
user: {
|
||||
id: USER_ID,
|
||||
email: USER_EMAIL,
|
||||
isAdmin: true,
|
||||
},
|
||||
})
|
||||
waitForData()
|
||||
cy.findByLabelText(/labels/i).click({ force: true })
|
||||
})
|
||||
|
||||
it('shows the dropdown menu item for adding new labels', function () {
|
||||
cy.findAllByTestId('history-version-details')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.findByRole('button', { name: /more actions/i }).click()
|
||||
cy.findByRole('menu').within(() => {
|
||||
cy.findByRole('menuitem', {
|
||||
name: /label this version/i,
|
||||
}).should('exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('resets from compare to view mode when switching tabs', function () {
|
||||
cy.findAllByTestId('history-version-details')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.findByRole('button', {
|
||||
name: /Compare/i,
|
||||
}).click()
|
||||
})
|
||||
cy.findByLabelText(/all history/i).click({ force: true })
|
||||
cy.findAllByTestId('history-version-details').should($versions => {
|
||||
const [selected, ...rest] = Array.from($versions)
|
||||
expect(selected).to.have.attr('data-selected', 'selected')
|
||||
expect(
|
||||
rest.every(version => version.dataset.selected === 'belowSelected')
|
||||
).to.be.true
|
||||
})
|
||||
})
|
||||
it('opens the compare drop down and compares with selected version', function () {
|
||||
cy.findByLabelText(/all history/i).click({ force: true })
|
||||
cy.findAllByTestId('history-version-details')
|
||||
.eq(3)
|
||||
.within(() => {
|
||||
cy.findByRole('button', {
|
||||
name: /compare from this version/i,
|
||||
}).click()
|
||||
})
|
||||
|
||||
cy.findAllByTestId('history-version-details')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('[aria-label="Compare"]').click()
|
||||
cy.findByRole('menu').within(() => {
|
||||
cy.findByRole('menuitem', {
|
||||
name: /compare up to this version/i,
|
||||
}).click()
|
||||
})
|
||||
})
|
||||
|
||||
cy.findAllByTestId('history-version-details').should($versions => {
|
||||
const [
|
||||
aboveSelected,
|
||||
upperSelected,
|
||||
withinSelected,
|
||||
lowerSelected,
|
||||
belowSelected,
|
||||
] = Array.from($versions)
|
||||
expect(aboveSelected).to.have.attr('data-selected', 'aboveSelected')
|
||||
expect(upperSelected).to.have.attr('data-selected', 'upperSelected')
|
||||
expect(withinSelected).to.have.attr('data-selected', 'withinSelected')
|
||||
expect(lowerSelected).to.have.attr('data-selected', 'lowerSelected')
|
||||
expect(belowSelected).to.have.attr('data-selected', 'belowSelected')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('compare mode', function () {
|
||||
beforeEach(function () {
|
||||
mountWithEditorProviders(<ChangeList />, scope, {
|
||||
user: {
|
||||
id: USER_ID,
|
||||
email: USER_EMAIL,
|
||||
isAdmin: true,
|
||||
},
|
||||
})
|
||||
waitForData()
|
||||
})
|
||||
|
||||
it('compares versions', function () {
|
||||
cy.findAllByTestId('history-version-details').should($versions => {
|
||||
const [first, ...rest] = Array.from($versions)
|
||||
expect(first).to.have.attr('data-selected', 'selected')
|
||||
rest.forEach(version =>
|
||||
// Based on the fact that we are selecting first version as we load the page
|
||||
// Every other version will be belowSelected
|
||||
expect(version).to.have.attr('data-selected', 'belowSelected')
|
||||
)
|
||||
})
|
||||
|
||||
cy.intercept('GET', '/project/*/filetree/diff*', {
|
||||
body: { diff: [{ pathname: 'main.tex' }, { pathname: 'name.tex' }] },
|
||||
}).as('compareDiff')
|
||||
|
||||
cy.findAllByTestId('history-version-details')
|
||||
.last()
|
||||
.within(() => {
|
||||
cy.findByTestId('compare-icon-version').click()
|
||||
})
|
||||
cy.wait('@compareDiff')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dropdown', function () {
|
||||
beforeEach(function () {
|
||||
mountWithEditorProviders(<ChangeList />, scope, {
|
||||
user: {
|
||||
id: USER_ID,
|
||||
email: USER_EMAIL,
|
||||
isAdmin: true,
|
||||
},
|
||||
})
|
||||
waitForData()
|
||||
})
|
||||
|
||||
it('adds badge/label', function () {
|
||||
cy.findAllByTestId('history-version-details').eq(1).as('version')
|
||||
cy.get('@version').within(() => {
|
||||
cy.findByRole('button', { name: /more actions/i }).click()
|
||||
cy.findByRole('menu').within(() => {
|
||||
cy.findByRole('menuitem', {
|
||||
name: /label this version/i,
|
||||
}).click()
|
||||
})
|
||||
})
|
||||
cy.intercept('POST', '/project/*/labels', req => {
|
||||
req.reply(200, {
|
||||
id: '64633ee158e9ef7da614c000',
|
||||
comment: req.body.comment,
|
||||
version: req.body.version,
|
||||
user_id: USER_ID,
|
||||
created_at: '2023-05-16T08:29:21.250Z',
|
||||
user_display_name: 'john.doe',
|
||||
})
|
||||
}).as('addLabel')
|
||||
const newLabel = 'my new label'
|
||||
cy.findByRole('dialog').within(() => {
|
||||
cy.findByRole('heading', { name: /add label/i })
|
||||
cy.findByRole('button', { name: /cancel/i })
|
||||
cy.findByRole('button', { name: /add label/i }).should('be.disabled')
|
||||
cy.findByPlaceholderText(/new label name/i).as('input')
|
||||
cy.get('@input').type(newLabel)
|
||||
cy.findByRole('button', { name: /add label/i }).should('be.enabled')
|
||||
cy.get('@input').type('{enter}')
|
||||
})
|
||||
cy.wait('@addLabel')
|
||||
cy.get('@version').within(() => {
|
||||
cy.findAllByTestId('history-version-badge').should($badges => {
|
||||
const includes = Array.from($badges).some(badge =>
|
||||
badge.textContent?.includes(newLabel)
|
||||
)
|
||||
expect(includes).to.be.true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('downloads version', function () {
|
||||
cy.intercept('GET', '/project/*/version/*/zip', { statusCode: 200 }).as(
|
||||
'download'
|
||||
)
|
||||
cy.findAllByTestId('history-version-details')
|
||||
.eq(0)
|
||||
.within(() => {
|
||||
cy.findByRole('button', { name: /more actions/i }).click()
|
||||
cy.findByRole('menu').within(() => {
|
||||
cy.findByRole('menuitem', {
|
||||
name: /download this version/i,
|
||||
}).click()
|
||||
})
|
||||
})
|
||||
cy.wait('@download')
|
||||
})
|
||||
})
|
||||
|
||||
describe('paywall', function () {
|
||||
const now = Date.now()
|
||||
const oneMinuteAgo = now - 60 * 1000
|
||||
const justOverADayAgo = now - 25 * 60 * 60 * 1000
|
||||
const twoDaysAgo = now - 48 * 60 * 60 * 1000
|
||||
|
||||
const updates = {
|
||||
updates: [
|
||||
{
|
||||
fromV: 3,
|
||||
toV: 4,
|
||||
meta: {
|
||||
users: [
|
||||
{
|
||||
first_name: 'john.doe',
|
||||
last_name: '',
|
||||
email: 'john.doe@test.com',
|
||||
id: '1',
|
||||
},
|
||||
],
|
||||
start_ts: oneMinuteAgo,
|
||||
end_ts: oneMinuteAgo,
|
||||
},
|
||||
labels: [],
|
||||
pathnames: [],
|
||||
project_ops: [{ add: { pathname: 'name.tex' }, atV: 3 }],
|
||||
},
|
||||
{
|
||||
fromV: 1,
|
||||
toV: 3,
|
||||
meta: {
|
||||
users: [
|
||||
{
|
||||
first_name: 'bobby.lapointe',
|
||||
last_name: '',
|
||||
email: 'bobby.lapointe@test.com',
|
||||
id: '2',
|
||||
},
|
||||
],
|
||||
start_ts: justOverADayAgo,
|
||||
end_ts: justOverADayAgo - 10 * 1000,
|
||||
},
|
||||
labels: [],
|
||||
pathnames: ['main.tex'],
|
||||
project_ops: [],
|
||||
},
|
||||
{
|
||||
fromV: 0,
|
||||
toV: 1,
|
||||
meta: {
|
||||
users: [
|
||||
{
|
||||
first_name: 'john.doe',
|
||||
last_name: '',
|
||||
email: 'john.doe@test.com',
|
||||
id: '1',
|
||||
},
|
||||
],
|
||||
start_ts: twoDaysAgo,
|
||||
end_ts: twoDaysAgo,
|
||||
},
|
||||
labels: [
|
||||
{
|
||||
id: 'label1',
|
||||
comment: 'tag-1',
|
||||
version: 0,
|
||||
user_id: USER_ID,
|
||||
created_at: justOverADayAgo,
|
||||
},
|
||||
],
|
||||
pathnames: [],
|
||||
project_ops: [{ add: { pathname: 'main.tex' }, atV: 0 }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const labels = [
|
||||
{
|
||||
id: 'label1',
|
||||
comment: 'tag-1',
|
||||
version: 0,
|
||||
user_id: USER_ID,
|
||||
created_at: justOverADayAgo,
|
||||
user_display_name: 'john.doe',
|
||||
},
|
||||
]
|
||||
|
||||
const waitForData = () => {
|
||||
cy.wait('@updates')
|
||||
cy.wait('@labels')
|
||||
cy.wait('@diff')
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.intercept('GET', '/project/*/updates*', {
|
||||
body: updates,
|
||||
}).as('updates')
|
||||
cy.intercept('GET', '/project/*/labels', {
|
||||
body: labels,
|
||||
}).as('labels')
|
||||
cy.intercept('GET', '/project/*/filetree/diff*', {
|
||||
body: { diff: [{ pathname: 'main.tex' }, { pathname: 'name.tex' }] },
|
||||
}).as('diff')
|
||||
})
|
||||
|
||||
it('shows non-owner paywall', function () {
|
||||
const scope = {
|
||||
ui: {
|
||||
view: 'history',
|
||||
pdfLayout: 'sideBySide',
|
||||
chatOpen: true,
|
||||
},
|
||||
}
|
||||
|
||||
mountWithEditorProviders(<ChangeList />, scope, {
|
||||
user: {
|
||||
id: USER_ID,
|
||||
email: USER_EMAIL,
|
||||
isAdmin: false,
|
||||
},
|
||||
})
|
||||
|
||||
waitForData()
|
||||
|
||||
cy.get('.history-paywall-prompt').should('have.length', 1)
|
||||
cy.findAllByTestId('history-version').should('have.length', 2)
|
||||
cy.get('.history-paywall-prompt button').should('not.exist')
|
||||
})
|
||||
|
||||
it('shows owner paywall', function () {
|
||||
const scope = {
|
||||
ui: {
|
||||
view: 'history',
|
||||
pdfLayout: 'sideBySide',
|
||||
chatOpen: true,
|
||||
},
|
||||
}
|
||||
|
||||
mountWithEditorProviders(<ChangeList />, scope, {
|
||||
user: {
|
||||
id: USER_ID,
|
||||
email: USER_EMAIL,
|
||||
isAdmin: false,
|
||||
},
|
||||
projectOwner: {
|
||||
_id: USER_ID,
|
||||
email: USER_EMAIL,
|
||||
},
|
||||
})
|
||||
|
||||
waitForData()
|
||||
|
||||
cy.get('.history-paywall-prompt').should('have.length', 1)
|
||||
cy.findAllByTestId('history-version').should('have.length', 2)
|
||||
cy.get('.history-paywall-prompt button').should('have.length', 1)
|
||||
})
|
||||
|
||||
it('shows all labels in free tier', function () {
|
||||
const scope = {
|
||||
ui: {
|
||||
view: 'history',
|
||||
pdfLayout: 'sideBySide',
|
||||
chatOpen: true,
|
||||
},
|
||||
}
|
||||
|
||||
mountWithEditorProviders(<ChangeList />, scope, {
|
||||
user: {
|
||||
id: USER_ID,
|
||||
email: USER_EMAIL,
|
||||
isAdmin: false,
|
||||
},
|
||||
projectOwner: {
|
||||
_id: USER_ID,
|
||||
email: USER_EMAIL,
|
||||
},
|
||||
})
|
||||
|
||||
waitForData()
|
||||
|
||||
cy.findByLabelText(/labels/i).click({ force: true })
|
||||
|
||||
// One pseudo-label for the current state, one for our label
|
||||
cy.get('.history-version-label').should('have.length', 2)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,256 @@
|
||||
import DocumentDiffViewer from '../../../../../frontend/js/features/history/components/diff-view/document-diff-viewer'
|
||||
import { Highlight } from '../../../../../frontend/js/features/history/services/types/doc'
|
||||
import { FC } from 'react'
|
||||
import { EditorProviders } from '../../../helpers/editor-providers'
|
||||
|
||||
const doc = `\\documentclass{article}
|
||||
|
||||
% Language setting
|
||||
% Replace \`english' with e.g. \`spanish' to change the document language
|
||||
\\usepackage[english]{babel}
|
||||
|
||||
% Set page size and margins
|
||||
% Replace \`letterpaper' with \`a4paper' for UK/EU standard size
|
||||
\\usepackage[letterpaper,top=2cm,bottom=2cm,left=3cm,right=3cm,marginparwidth=1.75cm]{geometry}
|
||||
|
||||
% Useful packages
|
||||
\\usepackage{amsmath}
|
||||
\\usepackage{graphicx}
|
||||
\\usepackage[colorlinks=true, allcolors=blue]{hyperref}
|
||||
|
||||
\\title{Your Paper}
|
||||
\\author{You}
|
||||
|
||||
\\begin{document}
|
||||
\\maketitle
|
||||
|
||||
\\begin{abstract}
|
||||
Your abstract.
|
||||
\\end{abstract}
|
||||
|
||||
\\section{Introduction}
|
||||
|
||||
Your introduction goes here! Simply start writing your document and use the Recompile button to view the updated PDF preview. Examples of commonly used commands and features are listed below, to help you get started.
|
||||
|
||||
Once you're familiar with the editor, you can find various project settings in the Overleaf menu, accessed via the button in the very top left of the editor. To view tutorials, user guides, and further documentation, please visit our \\href{https://www.overleaf.com/learn}{help library}, or head to our plans page to \\href{https://www.overleaf.com/user/subscription/plans}{choose your plan}.
|
||||
|
||||
${'\n'.repeat(200)}
|
||||
|
||||
\\end{document}`
|
||||
|
||||
const highlights: Highlight[] = [
|
||||
{
|
||||
type: 'addition',
|
||||
range: { from: 15, to: 22 },
|
||||
hue: 200,
|
||||
label: 'Added by Wombat on Monday',
|
||||
},
|
||||
{
|
||||
type: 'deletion',
|
||||
range: { from: 27, to: 35 },
|
||||
hue: 200,
|
||||
label: 'Deleted by Wombat on Tuesday',
|
||||
},
|
||||
{
|
||||
type: 'addition',
|
||||
range: { from: doc.length - 9, to: doc.length - 1 },
|
||||
hue: 200,
|
||||
label: 'Added by Wombat on Wednesday',
|
||||
},
|
||||
]
|
||||
|
||||
const Container: FC = ({ children }) => (
|
||||
<div style={{ width: 600, height: 400 }}>{children}</div>
|
||||
)
|
||||
|
||||
const mockScope = () => {
|
||||
return {
|
||||
settings: {
|
||||
fontSize: 12,
|
||||
fontFamily: 'monaco',
|
||||
lineHeight: 'normal',
|
||||
overallTheme: '',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('document diff viewer', function () {
|
||||
it('displays highlights with hover tooltips', function () {
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<Container>
|
||||
<EditorProviders scope={scope}>
|
||||
<DocumentDiffViewer doc={doc} highlights={highlights} />
|
||||
</EditorProviders>
|
||||
</Container>
|
||||
)
|
||||
|
||||
cy.get('.ol-cm-addition-marker').should('have.length', 1)
|
||||
cy.get('.ol-cm-addition-marker').first().as('addition')
|
||||
cy.get('@addition').should('have.text', 'article')
|
||||
|
||||
cy.get('.ol-cm-deletion-marker').should('have.length', 1)
|
||||
cy.get('.ol-cm-deletion-marker').first().as('deletion')
|
||||
cy.get('@deletion').should('have.text', 'Language')
|
||||
|
||||
// Check hover tooltips
|
||||
cy.get('@addition').trigger('mouseover')
|
||||
cy.get('.ol-cm-highlight-tooltip').should('have.length', 1)
|
||||
cy.get('.ol-cm-highlight-tooltip')
|
||||
.first()
|
||||
.should('have.text', 'Added by Wombat on Monday')
|
||||
|
||||
cy.get('@deletion').trigger('mouseover')
|
||||
cy.get('.ol-cm-highlight-tooltip').should('have.length', 1)
|
||||
cy.get('.ol-cm-highlight-tooltip')
|
||||
.first()
|
||||
.should('have.text', 'Deleted by Wombat on Tuesday')
|
||||
})
|
||||
|
||||
it('displays highlights with hover tooltips for empty lines', function () {
|
||||
const scope = mockScope()
|
||||
|
||||
const doc = `1
|
||||
Addition
|
||||
|
||||
|
||||
End
|
||||
2
|
||||
Deletion
|
||||
|
||||
End
|
||||
3`
|
||||
const highlights: Highlight[] = [
|
||||
{
|
||||
type: 'addition',
|
||||
range: { from: 2, to: 16 },
|
||||
hue: 200,
|
||||
label: 'Added by Wombat on Monday',
|
||||
},
|
||||
{
|
||||
type: 'deletion',
|
||||
range: { from: 19, to: 32 },
|
||||
hue: 200,
|
||||
label: 'Deleted by Wombat on Tuesday',
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<Container>
|
||||
<EditorProviders scope={scope}>
|
||||
<DocumentDiffViewer doc={doc} highlights={highlights} />
|
||||
</EditorProviders>
|
||||
</Container>
|
||||
)
|
||||
|
||||
cy.get('.ol-cm-empty-line-addition-marker').should('have.length', 2)
|
||||
cy.get('.ol-cm-empty-line-deletion-marker').should('have.length', 1)
|
||||
|
||||
// For an empty line marker, we need to trigger mouseover on the containing
|
||||
// line beause the marker itself does not trigger mouseover
|
||||
cy.get('.ol-cm-empty-line-addition-marker')
|
||||
.first()
|
||||
.parent()
|
||||
.as('firstAdditionLine')
|
||||
cy.get('.ol-cm-empty-line-addition-marker')
|
||||
.first()
|
||||
.parent()
|
||||
.as('lastAdditionLine')
|
||||
cy.get('.ol-cm-empty-line-deletion-marker')
|
||||
.last()
|
||||
.parent()
|
||||
.as('deletionLine')
|
||||
|
||||
// Check hover tooltips
|
||||
cy.get('@lastAdditionLine').trigger('mouseover')
|
||||
cy.get('.ol-cm-highlight-tooltip').should('have.length', 1)
|
||||
cy.get('.ol-cm-highlight-tooltip')
|
||||
.first()
|
||||
.should('have.text', 'Added by Wombat on Monday')
|
||||
|
||||
cy.get('@lastAdditionLine').trigger('mouseleave')
|
||||
|
||||
cy.get('@firstAdditionLine').trigger('mouseover')
|
||||
cy.get('.ol-cm-highlight-tooltip').should('have.length', 1)
|
||||
cy.get('.ol-cm-highlight-tooltip')
|
||||
.first()
|
||||
.should('have.text', 'Added by Wombat on Monday')
|
||||
|
||||
cy.get('@deletionLine').trigger('mouseover')
|
||||
cy.get('.ol-cm-highlight-tooltip').should('have.length', 1)
|
||||
cy.get('.ol-cm-highlight-tooltip')
|
||||
.first()
|
||||
.should('have.text', 'Deleted by Wombat on Tuesday')
|
||||
})
|
||||
|
||||
it("renders 'More updates' buttons", function () {
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<Container>
|
||||
<EditorProviders scope={scope}>
|
||||
<DocumentDiffViewer doc={doc} highlights={highlights} />
|
||||
</EditorProviders>
|
||||
</Container>
|
||||
)
|
||||
|
||||
cy.get('.cm-scroller').first().as('scroller')
|
||||
|
||||
// Check the initial state, which should be a "More updates below" button
|
||||
// but no "More updates above", with the editor scrolled to the top
|
||||
cy.get('.ol-cm-addition-marker').should('have.length', 1)
|
||||
cy.get('.ol-cm-deletion-marker').should('have.length', 1)
|
||||
cy.get('.previous-highlight-button').should('have.length', 0)
|
||||
cy.get('.next-highlight-button').should('have.length', 1)
|
||||
cy.get('@scroller').invoke('scrollTop').should('equal', 0)
|
||||
|
||||
// Click the "More updates below" button, which should scroll the editor,
|
||||
// and check the new state
|
||||
cy.get('.next-highlight-button').first().click()
|
||||
|
||||
cy.get('@scroller').invoke('scrollTop').should('not.equal', 0)
|
||||
cy.get('.previous-highlight-button').should('have.length', 1)
|
||||
cy.get('.next-highlight-button').should('have.length', 0)
|
||||
|
||||
// Click the "More updates above" button, which should scroll the editor up
|
||||
// but not quite to the top, and check the new state
|
||||
cy.get('.previous-highlight-button').first().click()
|
||||
cy.get('@scroller').invoke('scrollTop').should('equal', 0)
|
||||
cy.get('.previous-highlight-button').should('not.exist')
|
||||
cy.get('.next-highlight-button').should('have.length', 1)
|
||||
})
|
||||
|
||||
it('scrolls to first change', function () {
|
||||
const scope = mockScope()
|
||||
const finalHighlightOnly = highlights.slice(-1)
|
||||
|
||||
cy.mount(
|
||||
<Container>
|
||||
<EditorProviders scope={scope}>
|
||||
<DocumentDiffViewer doc={doc} highlights={finalHighlightOnly} />
|
||||
</EditorProviders>
|
||||
</Container>
|
||||
)
|
||||
|
||||
cy.get('.cm-scroller').first().invoke('scrollTop').should('not.equal', 0)
|
||||
cy.get('.ol-cm-addition-marker')
|
||||
.first()
|
||||
.then($marker => {
|
||||
cy.get('.cm-content')
|
||||
.first()
|
||||
.then($content => {
|
||||
const contentRect = $content[0].getBoundingClientRect()
|
||||
const markerRect = $marker[0].getBoundingClientRect()
|
||||
expect(markerRect.top).to.be.within(
|
||||
contentRect.top,
|
||||
contentRect.bottom
|
||||
)
|
||||
expect(markerRect.bottom).to.be.within(
|
||||
contentRect.top,
|
||||
contentRect.bottom
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user