first commit

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

View File

@@ -0,0 +1,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
})
})
})

View File

@@ -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
})
})

View File

@@ -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
})
})

View File

@@ -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)
})
})
})

View 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
}

View File

@@ -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)
}

View File

@@ -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],
})
})
})
})

View File

@@ -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
})
})

View File

@@ -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')
})
})

View File

@@ -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')
})
})
})

View File

@@ -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)
})
})
})

View File

@@ -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
)
})
})

View File

@@ -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'
)
})
})
})

View File

@@ -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')
})
})

View File

@@ -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')
})
})

View File

@@ -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)
})
})

View File

@@ -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')
})
})

View File

@@ -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')
})
})

View File

@@ -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')
})
})

View File

@@ -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')
})
})

View File

@@ -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
})
})

View File

@@ -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')
})
})

View File

@@ -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)
}
})
})

View File

@@ -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'
)
})
})

View File

@@ -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)
}
})
})

View File

@@ -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)
}
})
})

View File

@@ -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')
})
})

View File

@@ -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')
})
})

View File

@@ -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')
})
})

View File

@@ -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
})
})
})

View File

@@ -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')
})
})

View File

@@ -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)
}
})
})

View File

@@ -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')
})
})

View File

@@ -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
})
})

View File

@@ -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)',
})
})
})
})

View File

@@ -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',
})
})
})
})

View File

@@ -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
})
})
})

View File

@@ -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
})
})
})

View File

@@ -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')
})
})
})

View File

@@ -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 doesnt 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 />
}

View File

@@ -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 })
})
})

View File

@@ -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 })
})
})
})

View File

@@ -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')
})
})

View File

@@ -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')
})
})
})

View File

@@ -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)
})
})
})

View File

@@ -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'
)
})
})
})

View File

@@ -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' })
})
})

View File

@@ -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')
})
})

View File

@@ -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-')
}
})

View File

@@ -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)
})
})
})

View File

@@ -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}')
}
})

View File

@@ -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>
)
}

View File

@@ -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')
})
})

View 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',
})
})
})
})

View File

@@ -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,
})
})
})

View File

@@ -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')
})
})

View File

@@ -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' })
})
})

View File

@@ -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')
})
})

View File

@@ -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 })
})
})

View File

@@ -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,
})
})
})
})

View File

@@ -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,
}

View File

@@ -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 youd 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 well 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',
'Weve 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 didnt 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(
`youre 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(
/well charge you now for the cost of your additional licenses based on the remaining months of your current subscription/i
)
cy.findByText(
/after that, well 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',
'Youve added more license(s)'
)
cy.findByText(/youve 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 didnt 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'
)
})
})
})
})
})
})

View File

@@ -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')
})
})

View File

@@ -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')
})
})
})
})

View File

@@ -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')
})
})

View File

@@ -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')
})
})
})
})

View File

@@ -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')
})
})
})
})

View File

@@ -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')
})
})
})

View File

@@ -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')
})
})
})
})

View File

@@ -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')
})
})
})
})
})
})
})

View File

@@ -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')
})
})
})

View File

@@ -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('Youre 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'))
})
})

View File

@@ -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'
)
})
})
})
})

View File

@@ -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')
})
})

View File

@@ -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'
)
})
})

View File

@@ -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')
})
})
})
})

View File

@@ -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(/youve 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)
})
})
})

View File

@@ -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)
})
})
})

View File

@@ -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
)
})
})
})
})

View File

@@ -0,0 +1,126 @@
import Toolbar from '../../../../../frontend/js/features/history/components/diff-view/toolbar/toolbar'
import { HistoryProvider } from '../../../../../frontend/js/features/history/context/history-context'
import { HistoryContextValue } from '../../../../../frontend/js/features/history/context/types/history-context-value'
import { Diff } from '../../../../../frontend/js/features/history/services/types/doc'
import { EditorProviders } from '../../../helpers/editor-providers'
describe('history toolbar', function () {
const editorProvidersScope = {
ui: { view: 'history', pdfLayout: 'sideBySide', chatOpen: true },
}
const diff: Diff = {
binary: false,
docDiff: {
highlights: [
{
range: {
from: 0,
to: 3,
},
hue: 1,
type: 'addition',
label: 'label',
},
],
doc: 'doc',
},
}
it('renders viewing mode', function () {
const selection: HistoryContextValue['selection'] = {
updateRange: {
fromV: 3,
toV: 6,
fromVTimestamp: 1681413775958,
toVTimestamp: 1681413775958,
},
comparing: false,
files: [
{
pathname: 'main.tex',
operation: 'edited',
},
{
pathname: 'sample.bib',
editable: true,
},
{
pathname: 'frog.jpg',
editable: false,
},
],
selectedFile: {
pathname: 'main.tex',
editable: true,
},
previouslySelectedPathname: null,
}
cy.mount(
<EditorProviders scope={editorProvidersScope}>
<HistoryProvider>
<div className="history-react">
<Toolbar diff={diff} selection={selection} />
</div>
</HistoryProvider>
</EditorProviders>
)
cy.get('.history-react-toolbar').within(() => {
cy.get('div:first-child').contains('Viewing 13th April')
})
cy.get('.history-react-toolbar-file-info').contains('1 change in main.tex')
})
it('renders comparing mode', function () {
const selection: HistoryContextValue['selection'] = {
updateRange: {
fromV: 0,
toV: 6,
fromVTimestamp: 1681313775958,
toVTimestamp: 1681413775958,
},
comparing: true,
files: [
{
pathname: 'main.tex',
operation: 'added',
editable: true,
},
{
pathname: 'sample.bib',
operation: 'added',
editable: true,
},
{
pathname: 'frog.jpg',
operation: 'added',
editable: false,
},
],
selectedFile: {
pathname: 'main.tex',
editable: true,
},
previouslySelectedPathname: null,
}
cy.mount(
<EditorProviders scope={editorProvidersScope}>
<HistoryProvider>
<div className="history-react">
<Toolbar diff={diff} selection={selection} />
</div>
</HistoryProvider>
</EditorProviders>
)
cy.get('.history-react-toolbar').within(() => {
cy.get('div:first-child').contains('Comparing from 12th April')
cy.get('div:first-child').contains('to 13th April')
})
})
})

View File

@@ -0,0 +1,44 @@
import { USER_ID } from '../../../helpers/editor-providers'
export const labels = [
{
id: '643561cdfa2b2beac88f0024',
comment: 'tag-1',
version: 4,
user_id: USER_ID,
created_at: '2023-04-11T13:34:05.856Z',
user_display_name: 'john.doe',
},
{
id: '643561d1fa2b2beac88f0025',
comment: 'tag-2',
version: 4,
user_id: USER_ID,
created_at: '2023-04-11T13:34:09.280Z',
user_display_name: 'john.doe',
},
{
id: '6436bcf630293cb49e7f13a4',
comment: 'tag-3',
version: 1,
user_id: '631710ab1094c5002647184e',
created_at: '2023-04-12T14:15:18.892Z',
user_display_name: 'bobby.lapointe',
},
{
id: '6436bcf830293cb49e7f13a5',
comment: 'tag-4',
version: 1,
user_id: '631710ab1094c5002647184e',
created_at: '2023-04-12T14:15:20.814Z',
user_display_name: 'bobby.lapointe',
},
{
id: '6436bcfb30293cb49e7f13a6',
comment: 'tag-5',
version: 1,
user_id: '631710ab1094c5002647184e',
created_at: '2023-04-12T14:15:23.481Z',
user_display_name: 'bobby.lapointe',
},
]

View File

@@ -0,0 +1,149 @@
import { USER_ID, USER_EMAIL } from '../../../helpers/editor-providers'
export const updates = {
updates: [
{
fromV: 5,
toV: 6,
meta: {
users: [
{
first_name: 'testuser',
last_name: '',
email: USER_EMAIL,
id: USER_ID,
},
{
first_name: 'john.doe',
last_name: '',
email: 'john.doe@test.com',
id: '631710ab1094c5002647184e',
},
],
start_ts: 1681220036519,
end_ts: 1681220036619,
},
labels: [],
pathnames: ['name.tex'],
project_ops: [],
},
{
fromV: 4,
toV: 5,
meta: {
users: [
{
first_name: 'testuser',
last_name: '',
email: USER_EMAIL,
id: USER_ID,
},
{
first_name: 'john.doe',
last_name: '',
email: 'john.doe@test.com',
id: '631710ab1094c5002647184e',
},
],
start_ts: 1681220036419,
end_ts: 1681220036419,
},
labels: [
{
id: '643561cdfa2b2beac88f0024',
comment: 'tag-1',
version: 4,
user_id: USER_ID,
created_at: '2023-04-11T13:34:05.856Z',
},
{
id: '643561d1fa2b2beac88f0025',
comment: 'tag-2',
version: 4,
user_id: USER_ID,
created_at: '2023-04-11T13:34:09.280Z',
},
],
pathnames: [],
project_ops: [{ add: { pathname: 'name.tex' }, atV: 3 }],
},
{
fromV: 2,
toV: 4,
meta: {
users: [
{
first_name: 'bobby.lapointe',
last_name: '',
email: 'bobby.lapointe@test.com',
id: '631710ab1094c5002647184e',
},
],
start_ts: 1681220029569,
end_ts: 1681220031589,
},
labels: [],
pathnames: ['main.tex'],
project_ops: [],
},
{
fromV: 1,
toV: 2,
meta: {
users: [
{
first_name: 'john.doe',
last_name: '',
email: 'john.doe@test.com',
id: '631710ab1094c5002647184e',
},
],
start_ts: 1669218226672,
end_ts: 1669218226672,
},
labels: [
{
id: '6436bcf630293cb49e7f13a4',
comment: 'tag-3',
version: 3,
user_id: '631710ab1094c5002647184e',
created_at: '2023-04-12T14:15:18.892Z',
},
{
id: '6436bcf830293cb49e7f13a5',
comment: 'tag-4',
version: 3,
user_id: '631710ab1094c5002647184e',
created_at: '2023-04-12T14:15:20.814Z',
},
],
pathnames: ['main.tex'],
project_ops: [],
},
{
fromV: 0,
toV: 1,
meta: {
users: [
{
first_name: 'testuser',
last_name: '',
email: USER_EMAIL,
id: USER_ID,
},
{
first_name: 'john.doe',
last_name: '',
email: 'john.doe@test.com',
id: '631710ab1094c5002647184e',
},
],
start_ts: 1669218226500,
end_ts: 1669218226600,
},
labels: [],
pathnames: [],
project_ops: [{ add: { pathname: 'main.tex' }, atV: 3 }],
},
],
}

View File

@@ -0,0 +1,899 @@
import { expect } from 'chai'
import type { FileDiff } from '../../../../../frontend/js/features/history/services/types/file'
import { autoSelectFile } from '../../../../../frontend/js/features/history/utils/auto-select-file'
import type { User } from '../../../../../frontend/js/features/history/services/types/shared'
import { LoadedUpdate } from '../../../../../frontend/js/features/history/services/types/update'
import { fileFinalPathname } from '../../../../../frontend/js/features/history/utils/file-diff'
import { getUpdateForVersion } from '../../../../../frontend/js/features/history/utils/history-details'
describe('autoSelectFile', function () {
const historyUsers: User[] = [
{
first_name: 'first_name',
last_name: 'last_name',
email: 'email@overleaf.com',
id: '6266xb6b7a366460a66186xx',
},
]
describe('for comparing version with previous', function () {
const comparing = false
it('return the file with `edited` as the last operation', function () {
const files: FileDiff[] = [
{
pathname: 'main.tex',
editable: true,
},
{
pathname: 'sample.bib',
editable: true,
},
{
pathname: 'frog.jpg',
editable: false,
},
{
pathname: 'newfile5.tex',
editable: true,
},
{
pathname: 'newfolder1/newfolder2/newfile2.tex',
editable: true,
},
{
pathname: 'newfolder1/newfile10.tex',
operation: 'edited',
},
]
const updates: LoadedUpdate[] = [
{
fromV: 25,
toV: 26,
meta: {
users: historyUsers,
start_ts: 1680888731881,
end_ts: 1680888731881,
},
labels: [],
pathnames: ['newfolder1/newfile10.tex'],
project_ops: [],
},
{
fromV: 23,
toV: 25,
meta: {
users: historyUsers,
start_ts: 1680888725098,
end_ts: 1680888729123,
},
labels: [],
pathnames: [],
project_ops: [
{
rename: {
pathname: 'newfolder1/newfile3.tex',
newPathname: 'newfolder1/newfile10.tex',
},
atV: 24,
},
{
rename: {
pathname: 'newfile3.tex',
newPathname: 'newfolder1/newfile3.tex',
},
atV: 23,
},
],
},
{
fromV: 22,
toV: 23,
meta: {
users: historyUsers,
start_ts: 1680888721015,
end_ts: 1680888721015,
},
labels: [],
pathnames: ['newfile3.tex'],
project_ops: [],
},
{
fromV: 19,
toV: 22,
meta: {
users: historyUsers,
start_ts: 1680888715364,
end_ts: 1680888718726,
},
labels: [],
pathnames: [],
project_ops: [
{
rename: {
pathname: 'newfolder1/newfolder2/newfile3.tex',
newPathname: 'newfile3.tex',
},
atV: 21,
},
{
rename: {
pathname: 'newfolder1/newfile2.tex',
newPathname: 'newfolder1/newfolder2/newfile2.tex',
},
atV: 20,
},
{
rename: {
pathname: 'newfolder1/newfile5.tex',
newPathname: 'newfile5.tex',
},
atV: 19,
},
],
},
{
fromV: 16,
toV: 19,
meta: {
users: historyUsers,
start_ts: 1680888705042,
end_ts: 1680888712662,
},
labels: [],
pathnames: [
'main.tex',
'newfolder1/newfile2.tex',
'newfolder1/newfile5.tex',
],
project_ops: [],
},
{
fromV: 0,
toV: 16,
meta: {
users: historyUsers,
start_ts: 1680888456499,
end_ts: 1680888640774,
},
labels: [],
pathnames: [],
project_ops: [
{
add: {
pathname: 'newfolder1/newfile2.tex',
},
atV: 15,
},
{
remove: {
pathname: 'newfile2.tex',
},
atV: 14,
},
{
rename: {
pathname: 'newfolder1/frog.jpg',
newPathname: 'frog.jpg',
},
atV: 13,
},
{
rename: {
pathname: 'newfolder1/newfile2.tex',
newPathname: 'newfile2.tex',
},
atV: 12,
},
{
rename: {
pathname: 'newfile5.tex',
newPathname: 'newfolder1/newfile5.tex',
},
atV: 11,
},
{
rename: {
pathname: 'newfile4.tex',
newPathname: 'newfile5.tex',
},
atV: 10,
},
{
add: {
pathname: 'newfile4.tex',
},
atV: 9,
},
{
remove: {
pathname: 'newfolder1/newfolder2/newfile1.tex',
},
atV: 8,
},
{
rename: {
pathname: 'frog.jpg',
newPathname: 'newfolder1/frog.jpg',
},
atV: 7,
},
{
add: {
pathname: 'newfolder1/newfolder2/newfile3.tex',
},
atV: 6,
},
{
add: {
pathname: 'newfolder1/newfile2.tex',
},
atV: 5,
},
{
rename: {
pathname: 'newfolder1/newfile1.tex',
newPathname: 'newfolder1/newfolder2/newfile1.tex',
},
atV: 4,
},
{
add: {
pathname: 'newfolder1/newfile1.tex',
},
atV: 3,
},
{
add: {
pathname: 'frog.jpg',
},
atV: 2,
},
{
add: {
pathname: 'sample.bib',
},
atV: 1,
},
{
add: {
pathname: 'main.tex',
},
atV: 0,
},
],
},
]
const { pathname } = autoSelectFile(
files,
updates[0].toV,
comparing,
getUpdateForVersion(updates[0].toV, updates),
null
)
expect(pathname).to.equal('newfolder1/newfile10.tex')
})
it('return file with `added` operation on highest `atV` value if no other operation is available on the latest `updates` entry', function () {
const files: FileDiff[] = [
{
pathname: 'main.tex',
operation: 'added',
editable: true,
},
{
pathname: 'sample.bib',
operation: 'added',
editable: true,
},
{
pathname: 'frog.jpg',
operation: 'added',
editable: false,
},
{
pathname: 'newfile1.tex',
operation: 'added',
editable: true,
},
]
const updates: LoadedUpdate[] = [
{
fromV: 0,
toV: 4,
meta: {
users: historyUsers,
start_ts: 1680861468999,
end_ts: 1680861491861,
},
labels: [],
pathnames: [],
project_ops: [
{
add: {
pathname: 'newfile1.tex',
},
atV: 3,
},
{
add: {
pathname: 'frog.jpg',
},
atV: 2,
},
{
add: {
pathname: 'sample.bib',
},
atV: 1,
},
{
add: {
pathname: 'main.tex',
},
atV: 0,
},
],
},
]
const { pathname } = autoSelectFile(
files,
updates[0].toV,
comparing,
getUpdateForVersion(updates[0].toV, updates),
null
)
expect(pathname).to.equal('newfile1.tex')
})
it('return the last non-`removed` operation with the highest `atV` value', function () {
const files: FileDiff[] = [
{
pathname: 'main.tex',
operation: 'removed',
deletedAtV: 6,
editable: true,
},
{
pathname: 'sample.bib',
editable: true,
},
{
pathname: 'main2.tex',
operation: 'added',
editable: true,
},
{
pathname: 'main3.tex',
operation: 'added',
editable: true,
},
]
const updates: LoadedUpdate[] = [
{
fromV: 4,
toV: 7,
meta: {
users: historyUsers,
start_ts: 1680874742389,
end_ts: 1680874755552,
},
labels: [],
pathnames: [],
project_ops: [
{
remove: {
pathname: 'main.tex',
},
atV: 6,
},
{
add: {
pathname: 'main3.tex',
},
atV: 5,
},
{
add: {
pathname: 'main2.tex',
},
atV: 4,
},
],
},
{
fromV: 0,
toV: 4,
meta: {
users: historyUsers,
start_ts: 1680861975947,
end_ts: 1680861988442,
},
labels: [],
pathnames: [],
project_ops: [
{
remove: {
pathname: 'frog.jpg',
},
atV: 3,
},
{
add: {
pathname: 'frog.jpg',
},
atV: 2,
},
{
add: {
pathname: 'sample.bib',
},
atV: 1,
},
{
add: {
pathname: 'main.tex',
},
atV: 0,
},
],
},
]
const { pathname } = autoSelectFile(
files,
updates[0].toV,
comparing,
getUpdateForVersion(updates[0].toV, updates),
null
)
expect(pathname).to.equal('main3.tex')
})
it('if `removed` is the last operation, and no other operation is available on the latest `updates` entry, with `main.tex` available as a file name somewhere in the file tree, return `main.tex`', function () {
const files: FileDiff[] = [
{
pathname: 'main.tex',
editable: true,
},
{
pathname: 'sample.bib',
editable: true,
},
{
pathname: 'frog.jpg',
editable: false,
},
{
pathname: 'newfolder/maybewillbedeleted.tex',
newPathname: 'newfolder2/maybewillbedeleted.tex',
operation: 'removed',
deletedAtV: 10,
editable: true,
},
]
const updates: LoadedUpdate[] = [
{
fromV: 9,
toV: 11,
meta: {
users: historyUsers,
start_ts: 1680904414419,
end_ts: 1680904417538,
},
labels: [],
pathnames: [],
project_ops: [
{
remove: {
pathname: 'newfolder2/maybewillbedeleted.tex',
},
atV: 10,
},
{
rename: {
pathname: 'newfolder/maybewillbedeleted.tex',
newPathname: 'newfolder2/maybewillbedeleted.tex',
},
atV: 9,
},
],
},
{
fromV: 8,
toV: 9,
meta: {
users: historyUsers,
start_ts: 1680904410333,
end_ts: 1680904410333,
},
labels: [],
pathnames: ['newfolder/maybewillbedeleted.tex'],
project_ops: [],
},
{
fromV: 7,
toV: 8,
meta: {
users: historyUsers,
start_ts: 1680904407448,
end_ts: 1680904407448,
},
labels: [],
pathnames: [],
project_ops: [
{
rename: {
pathname: 'newfolder/tobedeleted.tex',
newPathname: 'newfolder/maybewillbedeleted.tex',
},
atV: 7,
},
],
},
{
fromV: 6,
toV: 7,
meta: {
users: historyUsers,
start_ts: 1680904400839,
end_ts: 1680904400839,
},
labels: [],
pathnames: ['newfolder/tobedeleted.tex'],
project_ops: [],
},
{
fromV: 5,
toV: 6,
meta: {
users: historyUsers,
start_ts: 1680904398544,
end_ts: 1680904398544,
},
labels: [],
pathnames: [],
project_ops: [
{
rename: {
pathname: 'tobedeleted.tex',
newPathname: 'newfolder/tobedeleted.tex',
},
atV: 5,
},
],
},
{
fromV: 4,
toV: 5,
meta: {
users: historyUsers,
start_ts: 1680904389891,
end_ts: 1680904389891,
},
labels: [],
pathnames: ['tobedeleted.tex'],
project_ops: [],
},
{
fromV: 0,
toV: 4,
meta: {
users: historyUsers,
start_ts: 1680904363778,
end_ts: 1680904385308,
},
labels: [],
pathnames: [],
project_ops: [
{
add: {
pathname: 'tobedeleted.tex',
},
atV: 3,
},
{
add: {
pathname: 'frog.jpg',
},
atV: 2,
},
{
add: {
pathname: 'sample.bib',
},
atV: 1,
},
{
add: {
pathname: 'main.tex',
},
atV: 0,
},
],
},
]
const { pathname } = autoSelectFile(
files,
updates[0].toV,
comparing,
getUpdateForVersion(updates[0].toV, updates),
null
)
expect(pathname).to.equal('main.tex')
})
it('if `removed` is the last operation, and no other operation is available on the latest `updates` entry, with `main.tex` is not available as a file name somewhere in the file tree, return any tex file based on ascending alphabetical order', function () {
const files: FileDiff[] = [
{
pathname: 'certainly_not_main.tex',
editable: true,
},
{
pathname: 'newfile.tex',
editable: true,
},
{
pathname: 'file2.tex',
editable: true,
},
]
const updates: LoadedUpdate[] = [
{
fromV: 7,
toV: 8,
meta: {
users: historyUsers,
start_ts: 1680905536168,
end_ts: 1680905536168,
},
labels: [],
pathnames: [],
project_ops: [
{
remove: {
pathname: 'newfolder/tobedeleted.txt',
},
atV: 7,
},
],
},
{
fromV: 6,
toV: 7,
meta: {
users: historyUsers,
start_ts: 1680905531816,
end_ts: 1680905531816,
},
labels: [],
pathnames: ['newfolder/tobedeleted.txt'],
project_ops: [],
},
{
fromV: 0,
toV: 6,
meta: {
users: historyUsers,
start_ts: 1680905492130,
end_ts: 1680905529186,
},
labels: [],
pathnames: [],
project_ops: [
{
rename: {
pathname: 'tobedeleted.txt',
newPathname: 'newfolder/tobedeleted.txt',
},
atV: 5,
},
{
add: {
pathname: 'file2.tex',
},
atV: 4,
},
{
add: {
pathname: 'newfile.tex',
},
atV: 3,
},
{
add: {
pathname: 'tobedeleted.txt',
},
atV: 2,
},
{
rename: {
pathname: 'main.tex',
newPathname: 'certainly_not_main.tex',
},
atV: 1,
},
{
add: {
pathname: 'main.tex',
},
atV: 0,
},
],
},
]
const { pathname } = autoSelectFile(
files,
updates[0].toV,
comparing,
getUpdateForVersion(updates[0].toV, updates),
null
)
expect(pathname).to.equal('certainly_not_main.tex')
})
it('selects renamed file', function () {
const files: FileDiff[] = [
{
pathname: 'main.tex',
editable: true,
},
{
pathname: 'original.bib',
newPathname: 'new.bib',
operation: 'renamed',
editable: true,
},
]
const updates: LoadedUpdate[] = [
{
fromV: 4,
toV: 7,
meta: {
users: historyUsers,
start_ts: 1680874742389,
end_ts: 1680874755552,
},
labels: [],
pathnames: [],
project_ops: [
{
rename: {
pathname: 'original.bib',
newPathname: 'new.bib',
},
atV: 5,
},
],
},
{
fromV: 0,
toV: 4,
meta: {
users: historyUsers,
start_ts: 1680861975947,
end_ts: 1680861988442,
},
labels: [],
pathnames: [],
project_ops: [
{
add: {
pathname: 'original.bib',
},
atV: 1,
},
{
add: {
pathname: 'main.tex',
},
atV: 0,
},
],
},
]
const pathname = fileFinalPathname(
autoSelectFile(
files,
updates[0].toV,
comparing,
getUpdateForVersion(updates[0].toV, updates),
null
)
)
expect(pathname).to.equal('new.bib')
})
it('ignores binary file', function () {
const files: FileDiff[] = [
{
pathname: 'frog.jpg',
editable: false,
operation: 'added',
},
{
pathname: 'main.tex',
editable: true,
},
{
pathname: 'sample.bib',
editable: true,
},
]
const updates: LoadedUpdate[] = [
{
fromV: 4,
toV: 7,
meta: {
users: historyUsers,
start_ts: 1680874742389,
end_ts: 1680874755552,
},
labels: [],
pathnames: [],
project_ops: [
{
add: {
pathname: 'frog.jpg',
},
atV: 5,
},
],
},
{
fromV: 0,
toV: 4,
meta: {
users: historyUsers,
start_ts: 1680861975947,
end_ts: 1680861988442,
},
labels: [],
pathnames: [],
project_ops: [
{
add: {
pathname: 'sample.bib',
},
atV: 1,
},
{
add: {
pathname: 'main.tex',
},
atV: 0,
},
],
},
]
const { pathname } = autoSelectFile(
files,
updates[0].toV,
comparing,
getUpdateForVersion(updates[0].toV, updates),
null
)
expect(pathname).to.equal('main.tex')
})
})
})

View File

@@ -0,0 +1,68 @@
import { expect } from 'chai'
import displayNameForUser from '@/features/history/utils/display-name-for-user'
describe('displayNameForUser', function () {
const currentUsersId = 'user-a'
beforeEach(function () {
window.metaAttributesCache.set('ol-user', { id: currentUsersId })
})
it("should return 'Anonymous' with no user", function () {
return expect(displayNameForUser(null)).to.equal('Anonymous')
})
it("should return 'you' when the user has the same id as the window", function () {
return expect(
displayNameForUser({
id: currentUsersId,
email: 'james.allen@overleaf.com',
first_name: 'James',
last_name: 'Allen',
})
).to.equal('you')
})
it('should return the first_name and last_name when present', function () {
return expect(
displayNameForUser({
id: currentUsersId + 1,
email: 'james.allen@overleaf.com',
first_name: 'James',
last_name: 'Allen',
})
).to.equal('James Allen')
})
it('should return only the first_name if no last_name', function () {
return expect(
displayNameForUser({
id: currentUsersId + 1,
email: 'james.allen@overleaf.com',
first_name: 'James',
last_name: '',
})
).to.equal('James')
})
it('should return the email username if there are no names', function () {
return expect(
displayNameForUser({
id: currentUsersId + 1,
email: 'james.allen@overleaf.com',
first_name: '',
last_name: '',
})
).to.equal('james.allen')
})
it("should return the '?' if it has nothing", function () {
return expect(
displayNameForUser({
id: currentUsersId + 1,
email: '',
first_name: '',
last_name: '',
})
).to.equal('?')
})
})

View File

@@ -0,0 +1,21 @@
import { render, screen } from '@testing-library/react'
import { expect } from 'chai'
import HotkeysModalBottomText from '../../../../../frontend/js/features/hotkeys-modal/components/hotkeys-modal-bottom-text'
describe('<HotkeysModalBottomText />', function () {
it('renders the correct text', function () {
render(<HotkeysModalBottomText />)
screen.getByText(
/A more comprehensive list of keyboard shortcuts can be found in/
)
const link = screen.getByRole('link', {
name: /this Overleaf project template/,
})
expect(link.getAttribute('href')).to.equal(
`/articles/overleaf-keyboard-shortcuts/qykqfvmxdnjf`
)
})
})

View File

@@ -0,0 +1,61 @@
import { render, screen } from '@testing-library/react'
import HotkeysModal from '../../../../../frontend/js/features/hotkeys-modal/components/hotkeys-modal'
import { expect } from 'chai'
import sinon from 'sinon'
const modalProps = {
show: true,
handleHide: sinon.stub(),
trackChangesVisible: false,
}
describe('<HotkeysModal />', function () {
it('renders the translated modal title on cm6', async function () {
const { baseElement } = render(<HotkeysModal {...modalProps} />)
expect(baseElement.querySelector('.modal-title').textContent).to.equal(
'Hotkeys'
)
})
it('renders translated heading with embedded code', function () {
const { baseElement } = render(<HotkeysModal {...modalProps} />)
const results = baseElement.querySelectorAll('h3 code')
expect(results).to.have.length(1)
})
it('renders the hotkey descriptions', function () {
const { baseElement } = render(<HotkeysModal {...modalProps} />)
const hotkeys = baseElement.querySelectorAll(
'[data-test-selector="hotkey"]'
)
expect(hotkeys).to.have.length(19)
})
it('adds extra hotkey descriptions when Track Changes is enabled', function () {
const { baseElement } = render(
<HotkeysModal {...modalProps} trackChangesVisible />
)
const hotkeys = baseElement.querySelectorAll(
'[data-test-selector="hotkey"]'
)
expect(hotkeys).to.have.length(22)
})
it('uses Ctrl for non-macOS', function () {
render(<HotkeysModal {...modalProps} />)
expect(screen.getAllByText(/Ctrl/)).to.have.length(16)
expect(screen.queryByText(/Cmd/)).to.not.exist
})
it('uses Cmd for macOS', function () {
render(<HotkeysModal {...modalProps} isMac />)
expect(screen.getAllByText(/Cmd/)).to.have.length(12)
expect(screen.getAllByText(/Ctrl/)).to.have.length(4)
})
})

View File

@@ -0,0 +1,328 @@
import { expect } from 'chai'
import { ReactScopeValueStore } from '@/features/ide-react/scope-value-store/react-scope-value-store'
import sinon from 'sinon'
import customLocalStorage from '@/infrastructure/local-storage'
function waitForWatchers(callback: () => void) {
return new Promise(resolve => {
callback()
window.setTimeout(resolve, 1)
})
}
describe('ReactScopeValueStore', function () {
it('can set and retrieve a value', function () {
const store = new ReactScopeValueStore()
store.set('test', 'wombat')
const retrieved = store.get('test')
expect(retrieved).to.equal('wombat')
})
it('can overwrite a value', function () {
const store = new ReactScopeValueStore()
store.set('test', 'wombat')
store.set('test', 'not a wombat')
const retrieved = store.get('test')
expect(retrieved).to.equal('not a wombat')
})
it('can overwrite a nested value', function () {
const store = new ReactScopeValueStore()
store.set('test', { prop: 'wombat' })
store.set('test.prop', 'not a wombat')
const retrieved = store.get('test.prop')
expect(retrieved).to.equal('not a wombat')
})
it('throws an error when retrieving an unknown value', function () {
const store = new ReactScopeValueStore()
expect(() => store.get('test')).to.throw
})
it('can watch a value', async function () {
const store = new ReactScopeValueStore()
store.set('changing', 'one')
store.set('fixed', 'one')
const changingItemWatcher = sinon.stub()
const fixedItemWatcher = sinon.stub()
await waitForWatchers(() => {
store.watch('changing', changingItemWatcher)
store.watch('fixed', fixedItemWatcher)
})
expect(changingItemWatcher).to.have.been.calledWith('one')
expect(fixedItemWatcher).to.have.been.calledWith('one')
changingItemWatcher.reset()
fixedItemWatcher.reset()
await waitForWatchers(() => {
store.set('changing', 'two')
})
expect(changingItemWatcher).to.have.been.calledWith('two')
expect(fixedItemWatcher).not.to.have.been.called
})
it('allows synchronous watcher updates', function () {
const store = new ReactScopeValueStore()
store.set('test', 'wombat')
const watcher = sinon.stub()
store.watch('test', watcher)
store.set('test', 'not a wombat')
expect(watcher).not.to.have.been.called
store.flushUpdates()
expect(watcher).to.have.been.calledWith('not a wombat')
})
it('removes a watcher', async function () {
const store = new ReactScopeValueStore()
store.set('test', 'wombat')
const watcher = sinon.stub()
const removeWatcher = store.watch('test', watcher)
store.flushUpdates()
watcher.reset()
removeWatcher()
store.set('test', 'not a wombat')
store.flushUpdates()
expect(watcher).not.to.have.been.called
})
it('does not call a watcher removed between observing change and being called', async function () {
const store = new ReactScopeValueStore()
store.set('test', 'wombat')
const watcher = sinon.stub()
const removeWatcher = store.watch('test', watcher)
store.flushUpdates()
watcher.reset()
store.set('test', 'not a wombat')
removeWatcher()
store.flushUpdates()
expect(watcher).not.to.have.been.called
})
it('does not trigger watcher on setting to an identical value', async function () {
const store = new ReactScopeValueStore()
store.set('test', 'wombat')
const watcher = sinon.stub()
await waitForWatchers(() => {
store.watch('test', watcher)
})
expect(watcher).to.have.been.calledWith('wombat')
watcher.reset()
await waitForWatchers(() => {
store.set('test', 'wombat')
})
expect(watcher).not.to.have.been.called
})
it('can watch a value before it has been set', async function () {
const store = new ReactScopeValueStore()
const watcher = sinon.stub()
store.watch('test', watcher)
await waitForWatchers(() => {
store.set('test', 'wombat')
})
expect(watcher).to.have.been.calledWith('wombat')
})
it('handles multiple watchers for the same path added at the same time before the value is set', async function () {
const store = new ReactScopeValueStore()
const watcherOne = sinon.stub()
const watcherTwo = sinon.stub()
store.watch('test', watcherOne)
store.watch('test', watcherTwo)
await waitForWatchers(() => {
store.set('test', 'wombat')
})
expect(watcherOne).to.have.been.calledWith('wombat')
expect(watcherTwo).to.have.been.calledWith('wombat')
})
it('handles multiple watchers for the same path added at the same time after the value is set', async function () {
const store = new ReactScopeValueStore()
store.set('test', 'wombat')
const watcherOne = sinon.stub()
const watcherTwo = sinon.stub()
store.watch('test', watcherOne)
store.watch('test', watcherTwo)
store.flushUpdates()
expect(watcherOne).to.have.been.calledWith('wombat')
expect(watcherTwo).to.have.been.calledWith('wombat')
})
it('throws an error when watching an unknown value', function () {
const store = new ReactScopeValueStore()
expect(() => store.watch('test', () => {})).to.throw
})
it('sets nested value if watched', function () {
const store = new ReactScopeValueStore()
store.set('test', { nested: 'one' })
const watcher = sinon.stub()
store.watch('test.nested', watcher)
const retrieved = store.get('test.nested')
expect(retrieved).to.equal('one')
})
it('does not set nested value if not watched', function () {
const store = new ReactScopeValueStore()
store.set('test', { nested: 'one' })
expect(() => store.get('test.nested')).to.throw
})
it('can watch a nested value', async function () {
const store = new ReactScopeValueStore()
store.set('test', { nested: 'one' })
const watcher = sinon.stub()
store.watch('test.nested', watcher)
await waitForWatchers(() => {
store.set('test', { nested: 'two' })
})
expect(watcher).to.have.been.calledWith('two')
})
it('can watch a deeply nested value', async function () {
const store = new ReactScopeValueStore()
store.set('test', { levelOne: { levelTwo: { levelThree: 'one' } } })
const watcher = sinon.stub()
store.watch('test.levelOne.levelTwo.levelThree', watcher)
await waitForWatchers(() => {
store.set('test', { levelOne: { levelTwo: { levelThree: 'two' } } })
})
expect(watcher).to.have.been.calledWith('two')
})
it('does not inform nested value watcher when nested value does not change', async function () {
const store = new ReactScopeValueStore()
store.set('test', { nestedOne: 'one', nestedTwo: 'one' })
const nestedOneWatcher = sinon.stub()
const nestedTwoWatcher = sinon.stub()
await waitForWatchers(() => {
store.watch('test.nestedOne', nestedOneWatcher)
store.watch('test.nestedTwo', nestedTwoWatcher)
})
nestedOneWatcher.reset()
nestedTwoWatcher.reset()
await waitForWatchers(() => {
store.set('test', { nestedOne: 'two', nestedTwo: 'one' })
})
expect(nestedOneWatcher).to.have.been.calledWith('two')
expect(nestedTwoWatcher).not.to.have.been.called
})
it('deletes nested values that no longer exist', function () {
const store = new ReactScopeValueStore()
store.set('test', { levelOne: { levelTwo: { levelThree: 'one' } } })
store.set('test', { levelOne: { different: 'wombat' } })
const retrieved = store.get('test.levelOne.different')
expect(retrieved).to.equal('wombat')
expect(() => store.get('test.levelOne.levelTwo')).to.throw
expect(() => store.get('test.levelOne.levelTwo.levelThree')).to.throw
})
it('does not throw for allowed non-existent path', function () {
const store = new ReactScopeValueStore()
store.allowNonExistentPath('wombat')
store.set('test', { levelOne: { levelTwo: { levelThree: 'one' } } })
store.set('test', { levelOne: { different: 'wombat' } })
expect(() => store.get('test')).not.to.throw
expect(store.get('wombat')).to.equal(undefined)
})
it('does not throw for deep allowed non-existent path', function () {
const store = new ReactScopeValueStore()
store.allowNonExistentPath('wombat', true)
expect(() => store.get('wombat')).not.to.throw
expect(() => store.get('wombat.nested')).not.to.throw
expect(() => store.get('wombat.really.very.nested')).not.to.throw
})
it('throws for nested value in non-deep allowed non-existent path', function () {
const store = new ReactScopeValueStore()
store.allowNonExistentPath('wombat', false)
expect(() => store.get('wombat.nested')).to.throw
})
it('throws for ancestor of allowed non-existent path', function () {
const store = new ReactScopeValueStore()
store.allowNonExistentPath('wombat.nested', true)
expect(() => store.get('wombat.really.very.nested')).not.to.throw
expect(() => store.get('wombat')).to.throw
})
it('updates ancestors', async function () {
const store = new ReactScopeValueStore()
const testValue = {
prop1: {
subProp: 'wombat',
},
prop2: {
subProp: 'wombat',
},
}
store.set('test', testValue)
const rootWatcher = sinon.stub()
const prop1Watcher = sinon.stub()
const subPropWatcher = sinon.stub()
const prop2Watcher = sinon.stub()
await waitForWatchers(() => {
store.watch('test', rootWatcher)
store.watch('test.prop1', prop1Watcher)
store.watch('test.prop1.subProp', subPropWatcher)
store.watch('test.prop2', prop2Watcher)
})
rootWatcher.reset()
prop1Watcher.reset()
subPropWatcher.reset()
prop2Watcher.reset()
await waitForWatchers(() => {
store.set('test.prop1.subProp', 'picard')
})
expect(store.get('test')).to.deep.equal({
prop1: {
subProp: 'picard',
},
prop2: {
subProp: 'wombat',
},
})
expect(store.get('test.prop2')).to.equal(testValue.prop2)
expect(rootWatcher).to.have.been.called
expect(prop1Watcher).to.have.been.called
expect(subPropWatcher).to.have.been.called
expect(prop2Watcher).not.to.have.been.called
})
describe('persistence', function () {
beforeEach(function () {
customLocalStorage.clear()
})
it('persists string to local storage', function () {
const store = new ReactScopeValueStore()
store.persisted('test-path', 'fallback value', 'test-storage-key')
expect(store.get('test-path')).to.equal('fallback value')
store.set('test-path', 'new value')
expect(customLocalStorage.getItem('test-storage-key')).to.equal(
'new value'
)
})
it("doesn't persist string to local storage until set() is called", function () {
const store = new ReactScopeValueStore()
store.persisted('test-path', 'fallback value', 'test-storage-key')
expect(customLocalStorage.getItem('test-storage-key')).to.equal(null)
})
it('converts persisted value', function () {
const store = new ReactScopeValueStore()
store.persisted('test-path', false, 'test-storage-key', {
toPersisted: value => (value ? 'on' : 'off'),
fromPersisted: persistedValue => persistedValue === 'on',
})
store.set('test-path', true)
expect(customLocalStorage.getItem('test-storage-key')).to.equal('on')
})
})
})

View File

@@ -0,0 +1,38 @@
import { EditorProviders } from '../../../helpers/editor-providers'
import SwitchToEditorButton from '@/features/pdf-preview/components/switch-to-editor-button'
describe('<SwitchToEditorButton />', function () {
it('shows button in full screen pdf layout', function () {
cy.mount(
<EditorProviders ui={{ view: 'pdf', pdfLayout: 'flat', chatOpen: false }}>
<SwitchToEditorButton />
</EditorProviders>
)
cy.findByRole('button', { name: 'Switch to editor' })
})
it('does not show button in split screen layout', function () {
cy.mount(
<EditorProviders
ui={{ view: 'pdf', pdfLayout: 'sideBySide', chatOpen: false }}
>
<SwitchToEditorButton />
</EditorProviders>
)
cy.findByRole('button', { name: 'Switch to editor' }).should('not.exist')
})
it('does not show button when detached', function () {
window.metaAttributesCache.set('ol-detachRole', 'detacher')
cy.mount(
<EditorProviders ui={{ view: 'pdf', pdfLayout: 'flat', chatOpen: false }}>
<SwitchToEditorButton />
</EditorProviders>
)
cy.findByRole('button', { name: 'Switch to editor' }).should('not.exist')
})
})

View File

@@ -0,0 +1,42 @@
import SwitchToPDFButton from '@/features/source-editor/components/switch-to-pdf-button'
import { EditorProviders } from '../../../helpers/editor-providers'
describe('<SwitchToPDFButton />', function () {
it('shows button in full screen editor layout', function () {
cy.mount(
<EditorProviders
ui={{ view: 'editor', pdfLayout: 'flat', chatOpen: false }}
>
<SwitchToPDFButton />
</EditorProviders>
)
cy.findByRole('button', { name: 'Switch to PDF' })
})
it('does not show button in split screen layout', function () {
cy.mount(
<EditorProviders
ui={{ view: 'editor', pdfLayout: 'sideBySide', chatOpen: false }}
>
<SwitchToPDFButton />
</EditorProviders>
)
cy.findByRole('button', { name: 'Switch to PDF' }).should('not.exist')
})
it('does not show button when detached', function () {
window.metaAttributesCache.set('ol-detachRole', 'detacher')
cy.mount(
<EditorProviders
ui={{ view: 'editor', pdfLayout: 'flat', chatOpen: false }}
>
<SwitchToPDFButton />
</EditorProviders>
)
cy.findByRole('button', { name: 'Switch to PDF' }).should('not.exist')
})
})

View File

@@ -0,0 +1,97 @@
import OutlineItem from '../../../../../frontend/js/features/outline/components/outline-item'
describe('<OutlineItem />', function () {
it('renders basic item', function () {
cy.mount(
<OutlineItem
// @ts-ignore
outlineItem={{
title: 'Test Title',
line: 1,
}}
jumpToLine={cy.stub()}
/>
)
cy.findByRole('treeitem', { current: false })
cy.findByRole('button', { name: 'Test Title' })
cy.findByRole('button', { name: 'Collapse' }).should('not.exist')
})
it('collapses and expands', function () {
cy.mount(
<OutlineItem
// @ts-ignore
outlineItem={{
title: 'Parent',
line: 1,
children: [{ title: 'Child', line: 2 }],
}}
jumpToLine={cy.stub()}
/>
)
cy.findByRole('button', { name: 'Child' })
cy.findByRole('button', { name: 'Collapse' }).click()
cy.findByRole('button', { name: 'Expand' })
cy.findByRole('button', { name: 'Child' }).should('not.exist')
})
it('highlights', function () {
cy.mount(
<OutlineItem
// @ts-ignore
outlineItem={{
title: 'Parent',
line: 1,
}}
jumpToLine={cy.stub()}
highlightedLine={1}
matchesHighlightedLine
/>
)
cy.findByRole('treeitem', { current: true })
})
it('highlights when has collapsed highlighted child', function () {
cy.mount(
<OutlineItem
// @ts-ignore
outlineItem={{
title: 'Parent',
line: 1,
children: [{ title: 'Child', line: 2 }],
}}
jumpToLine={cy.stub()}
highlightedLine={2}
containsHighlightedLine
/>
)
cy.findByRole('treeitem', { name: 'Parent', current: false })
cy.findByRole('treeitem', { name: 'Child', current: true })
cy.findByRole('button', { name: 'Collapse' }).click()
cy.findByRole('treeitem', { name: 'Parent', current: true })
})
it('click and double-click jump to location', function () {
cy.mount(
<OutlineItem
// @ts-ignore
outlineItem={{
title: 'Parent',
line: 1,
}}
jumpToLine={cy.stub().as('jumpToLine')}
/>
)
cy.findByRole('button', { name: 'Parent' }).click()
cy.get('@jumpToLine').should('have.been.calledOnceWith', 1, false)
cy.findByRole('button', { name: 'Parent' }).dblclick()
cy.get('@jumpToLine').should('have.been.calledThrice')
cy.get('@jumpToLine').should('have.been.calledWith', 1, true)
})
})

View File

@@ -0,0 +1,45 @@
import OutlineList from '../../../../../frontend/js/features/outline/components/outline-list'
describe('<OutlineList />', function () {
it('renders items', function () {
cy.mount(
<OutlineList
// @ts-ignore
outline={[
{ title: 'Section 1', line: 1, level: 10 },
{ title: 'Section 2', line: 2, level: 10 },
]}
isRoot
jumpToLine={cy.stub()}
/>
)
cy.findByRole('treeitem', { name: 'Section 1' })
cy.findByRole('treeitem', { name: 'Section 2' })
})
it('renders as root', function () {
cy.mount(
<OutlineList
// @ts-ignore
outline={[{ title: 'Section', line: 1, level: 10 }]}
isRoot
jumpToLine={cy.stub()}
/>
)
cy.findByRole('tree')
})
it('renders as non-root', function () {
cy.mount(
<OutlineList
// @ts-ignore
outline={[{ title: 'Section', line: 1, level: 10 }]}
jumpToLine={cy.stub()}
/>
)
cy.findByRole('group')
})
})

View File

@@ -0,0 +1,119 @@
import OutlinePane from '@/features/outline/components/outline-pane'
import { EditorProviders, PROJECT_ID } from '../../../helpers/editor-providers'
import { useState } from 'react'
import customLocalStorage from '@/infrastructure/local-storage'
describe('<OutlinePane />', function () {
it('renders expanded outline', function () {
cy.mount(
<EditorProviders>
<OutlinePane
isTexFile
outline={[{ title: 'Section', line: 1, level: 10 }]}
jumpToLine={cy.stub()}
onToggle={cy.stub()}
expanded
toggleExpanded={cy.stub()}
/>
</EditorProviders>
)
cy.findByRole('tree')
})
it('renders disabled outline', function () {
cy.mount(
<EditorProviders>
<OutlinePane
isTexFile
outline={[]}
jumpToLine={cy.stub()}
onToggle={cy.stub()}
expanded
toggleExpanded={cy.stub()}
/>
</EditorProviders>
)
cy.findByRole('tree').should('not.exist')
})
it('expand outline and use local storage', function () {
customLocalStorage.setItem(`file_outline.expanded.${PROJECT_ID}`, false)
const onToggle = cy.stub()
const Container = () => {
const [expanded, setExpanded] = useState(false)
return (
<OutlinePane
isTexFile
outline={[{ title: 'Section', line: 1, level: 10 }]}
jumpToLine={cy.stub()}
onToggle={onToggle}
expanded={expanded}
toggleExpanded={() => {
customLocalStorage.setItem(
`file_outline.expanded.${PROJECT_ID}`,
!expanded
)
setExpanded(!expanded)
}}
/>
)
}
cy.mount(
<EditorProviders>
<Container />
</EditorProviders>
)
cy.findByRole('tree').should('not.exist')
cy.findByRole('button', {
name: 'Show File outline',
}).click()
cy.findByRole('tree').then(() => {
expect(onToggle).to.be.calledTwice
expect(
customLocalStorage.getItem(`file_outline.expanded.${PROJECT_ID}`)
).to.equal(true)
})
})
it('shows warning on partial result', function () {
cy.mount(
<EditorProviders>
<OutlinePane
isTexFile
outline={[]}
jumpToLine={cy.stub()}
onToggle={cy.stub()}
toggleExpanded={cy.stub()}
isPartial
/>
</EditorProviders>
)
cy.findByRole('status')
})
it('shows no warning on non-partial result', function () {
cy.mount(
<EditorProviders>
<OutlinePane
isTexFile
outline={[]}
jumpToLine={cy.stub()}
onToggle={cy.stub()}
toggleExpanded={cy.stub()}
/>
</EditorProviders>
)
cy.findByRole('status').should('not.exist')
})
})

View File

@@ -0,0 +1,22 @@
import OutlineRoot from '../../../../../frontend/js/features/outline/components/outline-root'
describe('<OutlineRoot />', function () {
it('renders outline', function () {
cy.mount(
<OutlineRoot
outline={[{ title: 'Section', line: 1, level: 10 }]}
jumpToLine={cy.stub()}
/>
)
cy.findByRole('tree')
cy.findByRole('link').should('not.exist')
})
it('renders placeholder', function () {
cy.mount(<OutlineRoot outline={[]} jumpToLine={cy.stub()} />)
cy.findByRole('tree').should('not.exist')
cy.findByRole('link')
})
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More