first commit
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
import { expect } from 'chai'
|
||||
import {
|
||||
fireEvent,
|
||||
screen,
|
||||
waitForElementToBeRemoved,
|
||||
} from '@testing-library/react'
|
||||
import fetchMock from 'fetch-mock'
|
||||
|
||||
import ChatPane from '../../../../../frontend/js/features/chat/components/chat-pane'
|
||||
import {
|
||||
cleanUpContext,
|
||||
renderWithEditorContext,
|
||||
} from '../../../helpers/render-with-context'
|
||||
import { stubMathJax, tearDownMathJaxStubs } from './stubs'
|
||||
|
||||
describe('<ChatPane />', function () {
|
||||
const user = {
|
||||
id: 'fake_user',
|
||||
first_name: 'fake_user_first_name',
|
||||
email: 'fake@example.com',
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
window.metaAttributesCache.set('ol-user', user)
|
||||
window.metaAttributesCache.set('ol-chatEnabled', true)
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
const testMessages = [
|
||||
{
|
||||
id: 'msg_1',
|
||||
content: 'a message',
|
||||
user,
|
||||
timestamp: new Date().getTime(),
|
||||
},
|
||||
{
|
||||
id: 'msg_2',
|
||||
content: 'another message',
|
||||
user,
|
||||
timestamp: new Date().getTime(),
|
||||
},
|
||||
]
|
||||
|
||||
beforeEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
cleanUpContext()
|
||||
|
||||
stubMathJax()
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
tearDownMathJaxStubs()
|
||||
})
|
||||
|
||||
it('renders multiple messages', async function () {
|
||||
fetchMock.get(/messages/, testMessages)
|
||||
|
||||
renderWithEditorContext(<ChatPane />, { user })
|
||||
|
||||
await screen.findByText('a message')
|
||||
await screen.findByText('another message')
|
||||
})
|
||||
|
||||
it('provides error message with reload button on FetchError', async function () {
|
||||
fetchMock.get(/messages/, 500)
|
||||
|
||||
renderWithEditorContext(<ChatPane />, { user })
|
||||
|
||||
// should have hit a FetchError and will prompt user to reconnect
|
||||
await screen.findByText('Try again')
|
||||
|
||||
// bring chat back up
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
fetchMock.get(/messages/, [])
|
||||
|
||||
const reconnectButton = screen.getByRole('button', {
|
||||
name: 'Try again',
|
||||
})
|
||||
expect(reconnectButton).to.exist
|
||||
|
||||
// should now reconnect with placeholder message
|
||||
fireEvent.click(reconnectButton)
|
||||
await screen.findByText('Send your first message to your collaborators')
|
||||
})
|
||||
|
||||
it('a loading spinner is rendered while the messages are loading, then disappears', async function () {
|
||||
fetchMock.get(/messages/, [], { delay: 1000 })
|
||||
|
||||
renderWithEditorContext(<ChatPane />, { user })
|
||||
|
||||
// not displayed initially
|
||||
expect(screen.queryByText('Loading…')).to.not.exist
|
||||
|
||||
// eventually displayed
|
||||
await screen.findByText('Loading…')
|
||||
|
||||
// eventually removed when the fetch call returns
|
||||
await waitForElementToBeRemoved(() => screen.getByText('Loading…'))
|
||||
})
|
||||
|
||||
describe('"send your first message" placeholder', function () {
|
||||
it('is rendered when there are no messages ', async function () {
|
||||
fetchMock.get(/messages/, [])
|
||||
|
||||
renderWithEditorContext(<ChatPane />, { user })
|
||||
|
||||
await screen.findByText('Send your first message to your collaborators')
|
||||
})
|
||||
|
||||
it('is not rendered when messages are displayed', function () {
|
||||
fetchMock.get(/messages/, testMessages)
|
||||
|
||||
renderWithEditorContext(<ChatPane />, { user })
|
||||
|
||||
expect(
|
||||
screen.queryByText('Send your first message to your collaborators')
|
||||
).to.not.exist
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import { expect } from 'chai'
|
||||
import sinon from 'sinon'
|
||||
import { screen, render, fireEvent } from '@testing-library/react'
|
||||
|
||||
import MessageInput from '../../../../../frontend/js/features/chat/components/message-input'
|
||||
|
||||
describe('<MessageInput />', function () {
|
||||
let resetUnreadMessages, sendMessage
|
||||
|
||||
beforeEach(function () {
|
||||
resetUnreadMessages = sinon.stub()
|
||||
sendMessage = sinon.stub()
|
||||
})
|
||||
|
||||
it('renders successfully', function () {
|
||||
render(
|
||||
<MessageInput
|
||||
sendMessage={sendMessage}
|
||||
resetUnreadMessages={resetUnreadMessages}
|
||||
/>
|
||||
)
|
||||
|
||||
screen.getByLabelText('Send a message to your collaborators')
|
||||
})
|
||||
|
||||
it('sends a message after typing and hitting enter', function () {
|
||||
render(
|
||||
<MessageInput
|
||||
sendMessage={sendMessage}
|
||||
resetUnreadMessages={resetUnreadMessages}
|
||||
/>
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'hello world' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(sendMessage).to.be.calledOnce
|
||||
expect(sendMessage).to.be.calledWith('hello world')
|
||||
})
|
||||
|
||||
it('resets the number of unread messages after clicking on the input', function () {
|
||||
render(
|
||||
<MessageInput
|
||||
sendMessage={sendMessage}
|
||||
resetUnreadMessages={resetUnreadMessages}
|
||||
/>
|
||||
)
|
||||
|
||||
const input = screen.getByPlaceholderText(
|
||||
'Send a message to your collaborators…'
|
||||
)
|
||||
|
||||
fireEvent.click(input)
|
||||
expect(resetUnreadMessages).to.be.calledOnce
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
import sinon from 'sinon'
|
||||
import { expect } from 'chai'
|
||||
import { screen, render, fireEvent } from '@testing-library/react'
|
||||
|
||||
import MessageList from '../../../../../frontend/js/features/chat/components/message-list'
|
||||
import { stubMathJax, tearDownMathJaxStubs } from './stubs'
|
||||
import { UserProvider } from '@/shared/context/user-context'
|
||||
|
||||
describe('<MessageList />', function () {
|
||||
const currentUser = {
|
||||
id: 'fake_user',
|
||||
first_name: 'fake_user_first_name',
|
||||
email: 'fake@example.com',
|
||||
}
|
||||
|
||||
function createMessages() {
|
||||
return [
|
||||
{
|
||||
id: '1',
|
||||
contents: ['a message'],
|
||||
user: currentUser,
|
||||
timestamp: new Date().getTime(),
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
contents: ['another message'],
|
||||
user: currentUser,
|
||||
timestamp: new Date().getTime(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
before(function () {
|
||||
stubMathJax()
|
||||
})
|
||||
|
||||
after(function () {
|
||||
tearDownMathJaxStubs()
|
||||
})
|
||||
|
||||
let olUser
|
||||
beforeEach(function () {
|
||||
olUser = window.metaAttributesCache.get('ol-user')
|
||||
window.metaAttributesCache.set('ol-user', currentUser)
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
window.metaAttributesCache.set('ol-user', olUser)
|
||||
})
|
||||
|
||||
it('renders multiple messages', function () {
|
||||
render(
|
||||
<UserProvider>
|
||||
<MessageList
|
||||
userId={currentUser.id}
|
||||
messages={createMessages()}
|
||||
resetUnreadMessages={() => {}}
|
||||
/>
|
||||
</UserProvider>
|
||||
)
|
||||
|
||||
screen.getByText('a message')
|
||||
screen.getByText('another message')
|
||||
})
|
||||
|
||||
it('renders a single timestamp for all messages within 5 minutes', function () {
|
||||
const msgs = createMessages()
|
||||
msgs[0].timestamp = new Date(2019, 6, 3, 4, 23).getTime()
|
||||
msgs[1].timestamp = new Date(2019, 6, 3, 4, 27).getTime()
|
||||
|
||||
render(
|
||||
<UserProvider>
|
||||
<MessageList
|
||||
userId={currentUser.id}
|
||||
messages={msgs}
|
||||
resetUnreadMessages={() => {}}
|
||||
/>
|
||||
</UserProvider>
|
||||
)
|
||||
|
||||
screen.getByText('4:23 am Wed, 3rd Jul 19')
|
||||
expect(screen.queryByText('4:27 am Wed, 3rd Jul 19')).to.not.exist
|
||||
})
|
||||
|
||||
it('renders a timestamp for each messages separated for more than 5 minutes', function () {
|
||||
const msgs = createMessages()
|
||||
msgs[0].timestamp = new Date(2019, 6, 3, 4, 23).getTime()
|
||||
msgs[1].timestamp = new Date(2019, 6, 3, 4, 31).getTime()
|
||||
|
||||
render(
|
||||
<UserProvider>
|
||||
<MessageList
|
||||
userId={currentUser.id}
|
||||
messages={msgs}
|
||||
resetUnreadMessages={() => {}}
|
||||
/>
|
||||
</UserProvider>
|
||||
)
|
||||
|
||||
screen.getByText('4:23 am Wed, 3rd Jul 19')
|
||||
screen.getByText('4:31 am Wed, 3rd Jul 19')
|
||||
})
|
||||
|
||||
it('resets the number of unread messages after clicking on the input', function () {
|
||||
const resetUnreadMessages = sinon.stub()
|
||||
render(
|
||||
<UserProvider>
|
||||
<MessageList
|
||||
userId={currentUser.id}
|
||||
messages={createMessages()}
|
||||
resetUnreadMessages={resetUnreadMessages}
|
||||
/>
|
||||
</UserProvider>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('list'))
|
||||
expect(resetUnreadMessages).to.be.calledOnce
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
import { expect } from 'chai'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
|
||||
import Message from '../../../../../frontend/js/features/chat/components/message'
|
||||
import { stubMathJax, tearDownMathJaxStubs } from './stubs'
|
||||
|
||||
describe('<Message />', function () {
|
||||
const currentUser = {
|
||||
id: 'fake_user',
|
||||
first_name: 'fake_user_first_name',
|
||||
email: 'fake@example.com',
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
window.metaAttributesCache.set('ol-user', currentUser)
|
||||
stubMathJax()
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
tearDownMathJaxStubs()
|
||||
})
|
||||
|
||||
it('renders a basic message', function () {
|
||||
const message = {
|
||||
contents: ['a message'],
|
||||
user: currentUser,
|
||||
}
|
||||
|
||||
render(<Message message={message} fromSelf />)
|
||||
|
||||
screen.getByText('a message')
|
||||
})
|
||||
|
||||
it('renders a message with multiple contents', function () {
|
||||
const message = {
|
||||
contents: ['a message', 'another message'],
|
||||
user: currentUser,
|
||||
}
|
||||
|
||||
render(<Message message={message} fromSelf />)
|
||||
|
||||
screen.getByText('a message')
|
||||
screen.getByText('another message')
|
||||
})
|
||||
|
||||
it('renders HTML links within messages', function () {
|
||||
const message = {
|
||||
contents: [
|
||||
'a message with a <a href="https://overleaf.com">link to Overleaf</a>',
|
||||
],
|
||||
user: currentUser,
|
||||
}
|
||||
|
||||
render(<Message message={message} fromSelf />)
|
||||
|
||||
screen.getByRole('link', { name: 'https://overleaf.com' })
|
||||
})
|
||||
|
||||
describe('when the message is from the user themselves', function () {
|
||||
const message = {
|
||||
contents: ['a message'],
|
||||
user: currentUser,
|
||||
}
|
||||
|
||||
it('does not render the user name nor the email', function () {
|
||||
render(<Message message={message} fromSelf />)
|
||||
|
||||
expect(screen.queryByText(currentUser.first_name)).to.not.exist
|
||||
expect(screen.queryByText(currentUser.email)).to.not.exist
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the message is from other user', function () {
|
||||
const otherUser = {
|
||||
id: 'other_user',
|
||||
first_name: 'other_user_first_name',
|
||||
}
|
||||
|
||||
const message = {
|
||||
contents: ['a message'],
|
||||
user: otherUser,
|
||||
}
|
||||
|
||||
it('should render the other user name', function () {
|
||||
render(<Message message={message} />)
|
||||
|
||||
screen.getByText(otherUser.first_name)
|
||||
})
|
||||
|
||||
it('should render the other user email when their name is not available', function () {
|
||||
const msg = {
|
||||
contents: message.contents,
|
||||
user: {
|
||||
id: otherUser.id,
|
||||
email: 'other@example.com',
|
||||
},
|
||||
}
|
||||
|
||||
render(<Message message={msg} />)
|
||||
|
||||
expect(screen.queryByText(otherUser.first_name)).to.not.exist
|
||||
screen.getByText(msg.user.email)
|
||||
})
|
||||
})
|
||||
})
|
||||
14
services/web/test/frontend/features/chat/components/stubs.js
Normal file
14
services/web/test/frontend/features/chat/components/stubs.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import sinon from 'sinon'
|
||||
|
||||
export function stubMathJax() {
|
||||
window.MathJax = {
|
||||
Hub: {
|
||||
Queue: sinon.stub(),
|
||||
config: { tex2jax: { inlineMath: [['$', '$']] } },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function tearDownMathJaxStubs() {
|
||||
delete window.MathJax
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
// Disable prop type checks for test harnesses
|
||||
/* eslint-disable react/prop-types */
|
||||
|
||||
import { renderHook, act } from '@testing-library/react-hooks/dom'
|
||||
import { expect } from 'chai'
|
||||
import sinon from 'sinon'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import {
|
||||
useChatContext,
|
||||
chatClientIdGenerator,
|
||||
} from '@/features/chat/context/chat-context'
|
||||
import { cleanUpContext } from '../../../helpers/render-with-context'
|
||||
import { stubMathJax, tearDownMathJaxStubs } from '../components/stubs'
|
||||
import { SocketIOMock } from '@/ide/connection/SocketIoShim'
|
||||
import { EditorProviders } from '../../../helpers/editor-providers'
|
||||
|
||||
describe('ChatContext', function () {
|
||||
const user = {
|
||||
id: 'fake_user',
|
||||
first_name: 'fake_user_first_name',
|
||||
email: 'fake@example.com',
|
||||
}
|
||||
const uuidValue = '00000000-0000-0000-0000-000000000000'
|
||||
|
||||
beforeEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
cleanUpContext()
|
||||
|
||||
stubMathJax()
|
||||
|
||||
window.metaAttributesCache.set('ol-user', user)
|
||||
window.metaAttributesCache.set('ol-chatEnabled', true)
|
||||
|
||||
this.stub = sinon.stub(chatClientIdGenerator, 'generate').returns(uuidValue)
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
tearDownMathJaxStubs()
|
||||
|
||||
this.stub.restore()
|
||||
})
|
||||
|
||||
describe('socket connection', function () {
|
||||
beforeEach(function () {
|
||||
// Mock GET messages to return no messages
|
||||
// FIXME?
|
||||
// fetchMock.get('express:/project/:projectId/messages', [])
|
||||
|
||||
// Mock POST new message to return 200
|
||||
fetchMock.post('express:/project/:projectId/messages', 200)
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('subscribes when mounted', function () {
|
||||
const socket = new SocketIOMock()
|
||||
renderChatContextHook({ socket })
|
||||
expect(socket.countEventListeners('new-chat-message')).to.equal(1)
|
||||
})
|
||||
|
||||
it('unsubscribes when unmounted', function () {
|
||||
const socket = new SocketIOMock()
|
||||
const { unmount } = renderChatContextHook({ socket })
|
||||
|
||||
unmount()
|
||||
|
||||
expect(socket.countEventListeners('new-chat-message')).to.equal(0)
|
||||
})
|
||||
|
||||
it('adds received messages to the list', async function () {
|
||||
// Mock socket: we only need to emit events, not mock actual connections
|
||||
const socket = new SocketIOMock()
|
||||
const { result, waitForNextUpdate } = renderChatContextHook({
|
||||
socket,
|
||||
})
|
||||
|
||||
// Wait until initial messages have loaded
|
||||
result.current.loadInitialMessages()
|
||||
await waitForNextUpdate()
|
||||
|
||||
// No messages shown at first
|
||||
expect(result.current.messages).to.deep.equal([])
|
||||
|
||||
// Mock message being received from another user
|
||||
socket.emitToClient('new-chat-message', {
|
||||
id: 'msg_1',
|
||||
content: 'new message',
|
||||
timestamp: Date.now(),
|
||||
user: {
|
||||
id: 'another_fake_user',
|
||||
first_name: 'another_fake_user_first_name',
|
||||
email: 'another_fake@example.com',
|
||||
},
|
||||
})
|
||||
|
||||
const message = result.current.messages[0]
|
||||
expect(message.id).to.equal('msg_1')
|
||||
expect(message.contents).to.deep.equal(['new message'])
|
||||
})
|
||||
|
||||
it('deduplicate messages from preloading', async function () {
|
||||
// Mock socket: we only need to emit events, not mock actual connections
|
||||
const socket = new SocketIOMock()
|
||||
const { result, waitForNextUpdate } = renderChatContextHook({
|
||||
socket,
|
||||
})
|
||||
|
||||
fetchMock.get('express:/project/:projectId/messages', [
|
||||
{
|
||||
id: 'msg_1',
|
||||
content: 'new message',
|
||||
timestamp: Date.now(),
|
||||
user: {
|
||||
id: 'another_fake_user',
|
||||
first_name: 'another_fake_user_first_name',
|
||||
email: 'another_fake@example.com',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
// Mock message being received from another user
|
||||
socket.emitToClient('new-chat-message', {
|
||||
id: 'msg_1',
|
||||
content: 'new message',
|
||||
timestamp: Date.now(),
|
||||
user: {
|
||||
id: 'another_fake_user',
|
||||
first_name: 'another_fake_user_first_name',
|
||||
email: 'another_fake@example.com',
|
||||
},
|
||||
})
|
||||
|
||||
// Check if received the message ID
|
||||
expect(result.current.messages).to.have.length(1)
|
||||
|
||||
// Wait until initial messages have loaded
|
||||
result.current.loadInitialMessages()
|
||||
await waitForNextUpdate()
|
||||
|
||||
// Check if there are no message duplication
|
||||
expect(result.current.messages).to.have.length(1)
|
||||
|
||||
const message = result.current.messages[0]
|
||||
expect(message.id).to.equal('msg_1')
|
||||
expect(message.contents).to.deep.equal(['new message'])
|
||||
})
|
||||
|
||||
it('deduplicate messages from websocket', async function () {
|
||||
// Mock socket: we only need to emit events, not mock actual connections
|
||||
const socket = new SocketIOMock()
|
||||
const { result, waitForNextUpdate } = renderChatContextHook({
|
||||
socket,
|
||||
})
|
||||
|
||||
fetchMock.get('express:/project/:projectId/messages', [
|
||||
{
|
||||
id: 'msg_1',
|
||||
content: 'new message',
|
||||
timestamp: Date.now(),
|
||||
user: {
|
||||
id: 'another_fake_user',
|
||||
first_name: 'another_fake_user_first_name',
|
||||
email: 'another_fake@example.com',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
// Wait until initial messages have loaded
|
||||
result.current.loadInitialMessages()
|
||||
await waitForNextUpdate()
|
||||
|
||||
// Check if received the message ID
|
||||
expect(result.current.messages).to.have.length(1)
|
||||
|
||||
// Mock message being received from another user
|
||||
socket.emitToClient('new-chat-message', {
|
||||
id: 'msg_1',
|
||||
content: 'new message',
|
||||
timestamp: Date.now(),
|
||||
user: {
|
||||
id: 'another_fake_user',
|
||||
first_name: 'another_fake_user_first_name',
|
||||
email: 'another_fake@example.com',
|
||||
},
|
||||
})
|
||||
|
||||
// Check if there are no message duplication
|
||||
expect(result.current.messages).to.have.length(1)
|
||||
|
||||
const message = result.current.messages[0]
|
||||
expect(message.id).to.equal('msg_1')
|
||||
expect(message.contents).to.deep.equal(['new message'])
|
||||
})
|
||||
|
||||
it("doesn't add received messages from the current user if a message was just sent", async function () {
|
||||
const socket = new SocketIOMock()
|
||||
const { result, waitForNextUpdate } = renderChatContextHook({
|
||||
socket,
|
||||
})
|
||||
|
||||
// Wait until initial messages have loaded
|
||||
result.current.loadInitialMessages()
|
||||
await waitForNextUpdate()
|
||||
|
||||
// Send a message from the current user
|
||||
const sentMsg = 'sent message'
|
||||
result.current.sendMessage(sentMsg)
|
||||
|
||||
act(() => {
|
||||
// Receive a message from the current user
|
||||
socket.emitToClient('new-chat-message', {
|
||||
id: 'msg_1',
|
||||
content: 'received message',
|
||||
timestamp: Date.now(),
|
||||
user,
|
||||
clientId: uuidValue,
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current.messages).to.have.length(1)
|
||||
|
||||
const [message] = result.current.messages
|
||||
|
||||
expect(message.contents).to.deep.equal([sentMsg])
|
||||
})
|
||||
|
||||
it('adds the new message from the current user if another message was received after sending', async function () {
|
||||
const socket = new SocketIOMock()
|
||||
const { result, waitForNextUpdate } = renderChatContextHook({
|
||||
socket,
|
||||
})
|
||||
|
||||
// Wait until initial messages have loaded
|
||||
result.current.loadInitialMessages()
|
||||
await waitForNextUpdate()
|
||||
|
||||
// Send a message from the current user
|
||||
const sentMsg = 'sent message from current user'
|
||||
result.current.sendMessage(sentMsg)
|
||||
|
||||
const [sentMessageFromCurrentUser] = result.current.messages
|
||||
expect(sentMessageFromCurrentUser.contents).to.deep.equal([sentMsg])
|
||||
|
||||
const otherMsg = 'new message from other user'
|
||||
|
||||
act(() => {
|
||||
// Receive a message from another user.
|
||||
socket.emitToClient('new-chat-message', {
|
||||
id: 'msg_1',
|
||||
content: otherMsg,
|
||||
timestamp: Date.now(),
|
||||
user: {
|
||||
id: 'another_fake_user',
|
||||
first_name: 'another_fake_user_first_name',
|
||||
email: 'another_fake@example.com',
|
||||
},
|
||||
clientId: '123',
|
||||
})
|
||||
})
|
||||
|
||||
const [, messageFromOtherUser] = result.current.messages
|
||||
expect(messageFromOtherUser.contents).to.deep.equal([otherMsg])
|
||||
|
||||
act(() => {
|
||||
// Receive a message from the current user
|
||||
socket.emitToClient('new-chat-message', {
|
||||
id: 'msg_2',
|
||||
content: 'received message from current user',
|
||||
timestamp: Date.now(),
|
||||
user,
|
||||
clientId: uuidValue,
|
||||
})
|
||||
})
|
||||
|
||||
// Since the current user didn't just send a message, it is now shown
|
||||
expect(result.current.messages).to.deep.equal([
|
||||
sentMessageFromCurrentUser,
|
||||
messageFromOtherUser,
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadInitialMessages', function () {
|
||||
beforeEach(function () {
|
||||
fetchMock.get('express:/project/:projectId/messages', [
|
||||
{
|
||||
id: 'msg_1',
|
||||
content: 'a message',
|
||||
user,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('adds messages to the list', async function () {
|
||||
const { result, waitForNextUpdate } = renderChatContextHook({})
|
||||
|
||||
result.current.loadInitialMessages()
|
||||
await waitForNextUpdate()
|
||||
|
||||
expect(result.current.messages[0].contents).to.deep.equal(['a message'])
|
||||
})
|
||||
|
||||
it("won't load messages a second time", async function () {
|
||||
const { result, waitForNextUpdate } = renderChatContextHook({})
|
||||
|
||||
result.current.loadInitialMessages()
|
||||
await waitForNextUpdate()
|
||||
|
||||
expect(result.current.initialMessagesLoaded).to.equal(true)
|
||||
|
||||
// Calling a second time won't do anything
|
||||
result.current.loadInitialMessages()
|
||||
expect(
|
||||
fetchMock.callHistory.calls('express:/project/:projectId/messages')
|
||||
).to.have.lengthOf(1)
|
||||
})
|
||||
|
||||
it('provides an error on failure', async function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
fetchMock.get('express:/project/:projectId/messages', 500)
|
||||
const { result, waitForNextUpdate } = renderChatContextHook({})
|
||||
|
||||
result.current.loadInitialMessages()
|
||||
await waitForNextUpdate()
|
||||
|
||||
expect(result.current.error).to.exist
|
||||
expect(result.current.status).to.equal('error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadMoreMessages', function () {
|
||||
it('adds messages to the list', async function () {
|
||||
// Mock a GET request for an initial message
|
||||
fetchMock.getOnce('express:/project/:projectId/messages', [
|
||||
{
|
||||
id: 'msg_1',
|
||||
content: 'first message',
|
||||
user,
|
||||
timestamp: new Date('2021-03-04T10:00:00').getTime(),
|
||||
},
|
||||
])
|
||||
|
||||
const { result, waitForNextUpdate } = renderChatContextHook({})
|
||||
|
||||
result.current.loadMoreMessages()
|
||||
await waitForNextUpdate()
|
||||
|
||||
expect(result.current.messages[0].contents).to.deep.equal([
|
||||
'first message',
|
||||
])
|
||||
|
||||
// The before query param is not set
|
||||
expect(getLastFetchMockQueryParam('before')).to.be.null
|
||||
})
|
||||
|
||||
it('adds more messages if called a second time', async function () {
|
||||
// Mock 2 GET requests, with different content
|
||||
fetchMock
|
||||
.getOnce(
|
||||
'express:/project/:projectId/messages',
|
||||
// Resolve a full "page" of messages (50)
|
||||
createMessages(50, user, new Date('2021-03-04T10:00:00').getTime())
|
||||
)
|
||||
.getOnce('express:/project/:projectId/messages', [
|
||||
{
|
||||
id: 'msg_51',
|
||||
content: 'message from second page',
|
||||
user,
|
||||
timestamp: new Date('2021-03-04T11:00:00').getTime(),
|
||||
},
|
||||
])
|
||||
|
||||
const { result, waitForNextUpdate } = renderChatContextHook({})
|
||||
|
||||
result.current.loadMoreMessages()
|
||||
await waitForNextUpdate()
|
||||
|
||||
// Call a second time
|
||||
result.current.loadMoreMessages()
|
||||
await waitForNextUpdate()
|
||||
|
||||
// The second request is added to the list
|
||||
// Since both messages from the same user, they are collapsed into the
|
||||
// same "message"
|
||||
expect(result.current.messages[0].contents).to.include(
|
||||
'message from second page'
|
||||
)
|
||||
|
||||
// The before query param for the second request matches the timestamp
|
||||
// of the first message
|
||||
const beforeParam = parseInt(getLastFetchMockQueryParam('before'), 10)
|
||||
expect(beforeParam).to.equal(new Date('2021-03-04T10:00:00').getTime())
|
||||
})
|
||||
|
||||
it("won't load more messages if there are no more messages", async function () {
|
||||
// Mock a GET request for 49 messages. This is less the the full page size
|
||||
// (50 messages), meaning that there are no further messages to be loaded
|
||||
fetchMock.getOnce(
|
||||
'express:/project/:projectId/messages',
|
||||
createMessages(49, user)
|
||||
)
|
||||
|
||||
const { result, waitForNextUpdate } = renderChatContextHook({})
|
||||
|
||||
result.current.loadMoreMessages()
|
||||
await waitForNextUpdate()
|
||||
|
||||
expect(result.current.messages[0].contents).to.have.length(49)
|
||||
|
||||
result.current.loadMoreMessages()
|
||||
|
||||
expect(result.current.atEnd).to.be.true
|
||||
expect(
|
||||
fetchMock.callHistory.calls('express:/project/:projectId/messages')
|
||||
).to.have.lengthOf(1)
|
||||
})
|
||||
|
||||
it('handles socket messages while loading', async function () {
|
||||
// Mock GET messages so that we can control when the promise is resolved
|
||||
let resolveLoadingMessages
|
||||
fetchMock.get(
|
||||
'express:/project/:projectId/messages',
|
||||
new Promise(resolve => {
|
||||
resolveLoadingMessages = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const socket = new SocketIOMock()
|
||||
const { result, waitForNextUpdate } = renderChatContextHook({
|
||||
socket,
|
||||
})
|
||||
|
||||
// Start loading messages
|
||||
result.current.loadMoreMessages()
|
||||
|
||||
// Mock message being received from the socket while the request is in
|
||||
// flight
|
||||
socket.emitToClient('new-chat-message', {
|
||||
id: 'socket_msg',
|
||||
content: 'socket message',
|
||||
timestamp: Date.now(),
|
||||
user: {
|
||||
id: 'another_fake_user',
|
||||
first_name: 'another_fake_user_first_name',
|
||||
email: 'another_fake@example.com',
|
||||
},
|
||||
})
|
||||
|
||||
// Resolve messages being loaded
|
||||
resolveLoadingMessages([
|
||||
{
|
||||
id: 'fetched_msg',
|
||||
content: 'loaded message',
|
||||
user,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
])
|
||||
await waitForNextUpdate()
|
||||
|
||||
// Although the loaded message was resolved last, it appears first (since
|
||||
// requested messages must have come first)
|
||||
const messageContents = result.current.messages.map(
|
||||
({ contents }) => contents[0]
|
||||
)
|
||||
expect(messageContents).to.deep.equal([
|
||||
'loaded message',
|
||||
'socket message',
|
||||
])
|
||||
})
|
||||
|
||||
it('provides an error on failures', async function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
fetchMock.get('express:/project/:projectId/messages', 500)
|
||||
const { result, waitForNextUpdate } = renderChatContextHook({})
|
||||
|
||||
result.current.loadMoreMessages()
|
||||
await waitForNextUpdate()
|
||||
|
||||
expect(result.current.error).to.exist
|
||||
expect(result.current.status).to.equal('error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sendMessage', function () {
|
||||
beforeEach(function () {
|
||||
// Mock GET messages to return no messages and POST new message to be
|
||||
// successful
|
||||
fetchMock
|
||||
.get('express:/project/:projectId/messages', [])
|
||||
.postOnce('express:/project/:projectId/messages', 200)
|
||||
})
|
||||
|
||||
it('optimistically adds the message to the list', function () {
|
||||
const { result } = renderChatContextHook({})
|
||||
|
||||
result.current.sendMessage('sent message')
|
||||
|
||||
expect(result.current.messages[0].contents).to.deep.equal([
|
||||
'sent message',
|
||||
])
|
||||
})
|
||||
|
||||
it('POSTs the message to the backend', function () {
|
||||
const { result } = renderChatContextHook({})
|
||||
|
||||
result.current.sendMessage('sent message')
|
||||
|
||||
const {
|
||||
options: { body },
|
||||
} = fetchMock.callHistory
|
||||
.calls('express:/project/:projectId/messages', { method: 'POST' })
|
||||
.at(-1)
|
||||
expect(JSON.parse(body)).to.deep.include({ content: 'sent message' })
|
||||
})
|
||||
|
||||
it("doesn't send if the content is empty", function () {
|
||||
const { result } = renderChatContextHook({})
|
||||
|
||||
result.current.sendMessage('')
|
||||
|
||||
expect(result.current.messages).to.be.empty
|
||||
expect(
|
||||
fetchMock.callHistory.called('express:/project/:projectId/messages', {
|
||||
method: 'post',
|
||||
})
|
||||
).to.be.false
|
||||
})
|
||||
|
||||
it('provides an error on failure', async function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
fetchMock
|
||||
.get('express:/project/:projectId/messages', [])
|
||||
.postOnce('express:/project/:projectId/messages', 500)
|
||||
const { result, waitForNextUpdate } = renderChatContextHook({})
|
||||
|
||||
result.current.sendMessage('sent message')
|
||||
await waitForNextUpdate()
|
||||
|
||||
expect(result.current.error).to.exist
|
||||
expect(result.current.status).to.equal('error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('unread messages', function () {
|
||||
beforeEach(function () {
|
||||
// Mock GET messages to return no messages
|
||||
fetchMock.get('express:/project/:projectId/messages', [])
|
||||
})
|
||||
|
||||
it('increments unreadMessageCount when a new message is received', function () {
|
||||
const socket = new SocketIOMock()
|
||||
const { result } = renderChatContextHook({ socket })
|
||||
|
||||
// Receive a new message from the socket
|
||||
socket.emitToClient('new-chat-message', {
|
||||
id: 'msg_1',
|
||||
content: 'new message',
|
||||
timestamp: Date.now(),
|
||||
user,
|
||||
})
|
||||
|
||||
expect(result.current.unreadMessageCount).to.equal(1)
|
||||
})
|
||||
|
||||
it('resets unreadMessageCount when markMessagesAsRead is called', function () {
|
||||
const socket = new SocketIOMock()
|
||||
const { result } = renderChatContextHook({ socket })
|
||||
|
||||
// Receive a new message from the socket, incrementing unreadMessageCount
|
||||
// by 1
|
||||
socket.emitToClient('new-chat-message', {
|
||||
id: 'msg_1',
|
||||
content: 'new message',
|
||||
timestamp: Date.now(),
|
||||
user,
|
||||
})
|
||||
|
||||
result.current.markMessagesAsRead()
|
||||
|
||||
expect(result.current.unreadMessageCount).to.equal(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function renderChatContextHook(props) {
|
||||
return renderHook(() => useChatContext(), {
|
||||
// Wrap with ChatContext.Provider (and the other editor context providers)
|
||||
// eslint-disable-next-line react/display-name
|
||||
wrapper: ({ children }) => (
|
||||
<EditorProviders {...props}>{children}</EditorProviders>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
function createMessages(number, user, timestamp = Date.now()) {
|
||||
return Array.from({ length: number }, (_m, idx) => ({
|
||||
id: `msg_${idx + 1}`,
|
||||
content: `message ${idx + 1}`,
|
||||
user,
|
||||
timestamp,
|
||||
}))
|
||||
}
|
||||
|
||||
/*
|
||||
* Get query param by key from the last fetchMock response
|
||||
*/
|
||||
function getLastFetchMockQueryParam(key) {
|
||||
const { url } = fetchMock.callHistory.calls().at(-1)
|
||||
const { searchParams } = new URL(url, 'https://www.overleaf.com')
|
||||
return searchParams.get(key)
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import { expect } from 'chai'
|
||||
import {
|
||||
appendMessage,
|
||||
prependMessages,
|
||||
} from '../../../../../frontend/js/features/chat/utils/message-list-appender'
|
||||
|
||||
const testUser = {
|
||||
id: '123abc',
|
||||
}
|
||||
|
||||
const otherUser = {
|
||||
id: '234other',
|
||||
}
|
||||
|
||||
function createTestMessageList() {
|
||||
return [
|
||||
{
|
||||
id: 'msg_1',
|
||||
contents: ['hello', 'world'],
|
||||
timestamp: new Date().getTime(),
|
||||
user: otherUser,
|
||||
},
|
||||
{
|
||||
id: 'msg_2',
|
||||
contents: ['foo'],
|
||||
timestamp: new Date().getTime(),
|
||||
user: testUser,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
describe('prependMessages()', function () {
|
||||
function createTestMessages() {
|
||||
const message1 = {
|
||||
id: 'prepended_message',
|
||||
content: 'hello',
|
||||
timestamp: new Date().getTime(),
|
||||
user: testUser,
|
||||
}
|
||||
const message2 = { ...message1, id: 'prepended_message_2' }
|
||||
return [message1, message2]
|
||||
}
|
||||
|
||||
it('to an empty list', function () {
|
||||
const messages = createTestMessages()
|
||||
const uniqueMessageIds = []
|
||||
|
||||
expect(
|
||||
prependMessages([], messages, uniqueMessageIds).messages
|
||||
).to.deep.equal([
|
||||
{
|
||||
id: messages[0].id,
|
||||
timestamp: messages[0].timestamp,
|
||||
user: messages[0].user,
|
||||
contents: [messages[0].content, messages[1].content],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
describe('when the messages to prepend are from the same user', function () {
|
||||
let list, messages, uniqueMessageIds
|
||||
|
||||
beforeEach(function () {
|
||||
list = createTestMessageList()
|
||||
messages = createTestMessages()
|
||||
messages[0].user = testUser // makes all the messages have the same author
|
||||
uniqueMessageIds = []
|
||||
})
|
||||
|
||||
it('when the prepended messages are close in time, contents should be merged into the same message', function () {
|
||||
const result = prependMessages(
|
||||
createTestMessageList(),
|
||||
messages,
|
||||
uniqueMessageIds
|
||||
).messages
|
||||
expect(result.length).to.equal(list.length + 1)
|
||||
expect(result[0]).to.deep.equal({
|
||||
id: messages[0].id,
|
||||
timestamp: messages[0].timestamp,
|
||||
user: messages[0].user,
|
||||
contents: [messages[0].content, messages[1].content],
|
||||
})
|
||||
})
|
||||
|
||||
it('when the prepended messages are separated in time, each message is prepended', function () {
|
||||
messages[0].timestamp = messages[1].timestamp - 6 * 60 * 1000 // 6 minutes before the next message
|
||||
const result = prependMessages(
|
||||
createTestMessageList(),
|
||||
messages,
|
||||
uniqueMessageIds
|
||||
).messages
|
||||
expect(result.length).to.equal(list.length + 2)
|
||||
expect(result[0]).to.deep.equal({
|
||||
id: messages[0].id,
|
||||
timestamp: messages[0].timestamp,
|
||||
user: messages[0].user,
|
||||
contents: [messages[0].content],
|
||||
})
|
||||
expect(result[1]).to.deep.equal({
|
||||
id: messages[1].id,
|
||||
timestamp: messages[1].timestamp,
|
||||
user: messages[1].user,
|
||||
contents: [messages[1].content],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the messages to prepend are from different users', function () {
|
||||
let list, messages, uniqueMessageIds
|
||||
|
||||
beforeEach(function () {
|
||||
list = createTestMessageList()
|
||||
messages = createTestMessages()
|
||||
uniqueMessageIds = []
|
||||
})
|
||||
|
||||
it('should prepend separate messages to the list', function () {
|
||||
messages[0].user = otherUser
|
||||
const result = prependMessages(
|
||||
createTestMessageList(),
|
||||
messages,
|
||||
uniqueMessageIds
|
||||
).messages
|
||||
expect(result.length).to.equal(list.length + 2)
|
||||
expect(result[0]).to.deep.equal({
|
||||
id: messages[0].id,
|
||||
timestamp: messages[0].timestamp,
|
||||
user: messages[0].user,
|
||||
contents: [messages[0].content],
|
||||
})
|
||||
expect(result[1]).to.deep.equal({
|
||||
id: messages[1].id,
|
||||
timestamp: messages[1].timestamp,
|
||||
user: messages[1].user,
|
||||
contents: [messages[1].content],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should merge the prepended messages into the first existing one when user is same user and are close in time', function () {
|
||||
const list = createTestMessageList()
|
||||
const messages = createTestMessages()
|
||||
messages[0].user = messages[1].user = list[0].user
|
||||
const uniqueMessageIds = []
|
||||
|
||||
const result = prependMessages(
|
||||
createTestMessageList(),
|
||||
messages,
|
||||
uniqueMessageIds
|
||||
).messages
|
||||
expect(result.length).to.equal(list.length)
|
||||
expect(result[0]).to.deep.equal({
|
||||
id: messages[0].id,
|
||||
timestamp: messages[0].timestamp,
|
||||
user: messages[0].user,
|
||||
contents: [messages[0].content, messages[1].content, ...list[0].contents],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('appendMessage()', function () {
|
||||
function createTestMessage() {
|
||||
return {
|
||||
id: 'appended_message',
|
||||
content: 'hi!',
|
||||
timestamp: new Date().getTime(),
|
||||
user: testUser,
|
||||
}
|
||||
}
|
||||
|
||||
it('to an empty list', function () {
|
||||
const testMessage = createTestMessage()
|
||||
const uniqueMessageIds = []
|
||||
|
||||
expect(
|
||||
appendMessage([], testMessage, uniqueMessageIds).messages
|
||||
).to.deep.equal([
|
||||
{
|
||||
id: 'appended_message',
|
||||
timestamp: testMessage.timestamp,
|
||||
user: testMessage.user,
|
||||
contents: [testMessage.content],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
describe('messages appended shortly after the last message on the list', function () {
|
||||
let list, message, uniqueMessageIds
|
||||
|
||||
beforeEach(function () {
|
||||
list = createTestMessageList()
|
||||
message = createTestMessage()
|
||||
message.timestamp = list[1].timestamp + 6 * 1000 // 6 seconds after the last message in the list
|
||||
uniqueMessageIds = []
|
||||
})
|
||||
|
||||
describe('when the author is the same as the last message', function () {
|
||||
it('should append the content to the last message', function () {
|
||||
const result = appendMessage(list, message, uniqueMessageIds).messages
|
||||
expect(result.length).to.equal(list.length)
|
||||
expect(result[1].contents).to.deep.equal(
|
||||
list[1].contents.concat(message.content)
|
||||
)
|
||||
})
|
||||
|
||||
it('should update the last message timestamp', function () {
|
||||
const result = appendMessage(list, message, uniqueMessageIds).messages
|
||||
expect(result[1].timestamp).to.equal(message.timestamp)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the author is different than the last message', function () {
|
||||
beforeEach(function () {
|
||||
message.user = otherUser
|
||||
})
|
||||
|
||||
it('should append the new message to the list', function () {
|
||||
const result = appendMessage(list, message, uniqueMessageIds).messages
|
||||
expect(result.length).to.equal(list.length + 1)
|
||||
expect(result[2]).to.deep.equal({
|
||||
id: 'appended_message',
|
||||
timestamp: message.timestamp,
|
||||
user: message.user,
|
||||
contents: [message.content],
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('messages appended later after the last message on the list', function () {
|
||||
let list, message, uniqueMessageIds
|
||||
|
||||
beforeEach(function () {
|
||||
list = createTestMessageList()
|
||||
message = createTestMessage()
|
||||
message.timestamp = list[1].timestamp + 6 * 60 * 1000 // 6 minutes after the last message in the list
|
||||
uniqueMessageIds = []
|
||||
})
|
||||
|
||||
it('when the author is the same as the last message, should be appended as new message', function () {
|
||||
const result = appendMessage(list, message, uniqueMessageIds).messages
|
||||
expect(result.length).to.equal(3)
|
||||
expect(result[2]).to.deep.equal({
|
||||
id: 'appended_message',
|
||||
timestamp: message.timestamp,
|
||||
user: message.user,
|
||||
contents: [message.content],
|
||||
})
|
||||
})
|
||||
|
||||
it('when the author is the different than the last message, should be appended as new message', function () {
|
||||
message.user = otherUser
|
||||
|
||||
const result = appendMessage(list, message, uniqueMessageIds).messages
|
||||
expect(result.length).to.equal(3)
|
||||
expect(result[2]).to.deep.equal({
|
||||
id: 'appended_message',
|
||||
timestamp: message.timestamp,
|
||||
user: message.user,
|
||||
contents: [message.content],
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,182 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import sinon from 'sinon'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import EditorCloneProjectModalWrapper from '../../../../../frontend/js/features/clone-project-modal/components/editor-clone-project-modal-wrapper'
|
||||
import { renderWithEditorContext } from '../../../helpers/render-with-context'
|
||||
|
||||
describe('<EditorCloneProjectModalWrapper />', function () {
|
||||
beforeEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
after(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
const project = {
|
||||
_id: 'project-1',
|
||||
name: 'Test Project',
|
||||
}
|
||||
|
||||
it('renders the translated modal title', async function () {
|
||||
const handleHide = sinon.stub()
|
||||
const openProject = sinon.stub()
|
||||
|
||||
renderWithEditorContext(
|
||||
<EditorCloneProjectModalWrapper
|
||||
handleHide={handleHide}
|
||||
openProject={openProject}
|
||||
show
|
||||
/>,
|
||||
{ scope: { project } }
|
||||
)
|
||||
|
||||
await screen.findByText('Copy Project')
|
||||
})
|
||||
|
||||
it('posts the generated project name', async function () {
|
||||
fetchMock.post(
|
||||
'express:/project/:projectId/clone',
|
||||
{
|
||||
status: 200,
|
||||
body: { project_id: 'cloned-project' },
|
||||
},
|
||||
{ delay: 10 }
|
||||
)
|
||||
|
||||
const handleHide = sinon.stub()
|
||||
const openProject = sinon.stub()
|
||||
|
||||
renderWithEditorContext(
|
||||
<EditorCloneProjectModalWrapper
|
||||
handleHide={handleHide}
|
||||
openProject={openProject}
|
||||
show
|
||||
/>,
|
||||
{ scope: { project } }
|
||||
)
|
||||
|
||||
const cancelButton = await screen.findByRole('button', { name: 'Cancel' })
|
||||
expect(cancelButton.disabled).to.be.false
|
||||
|
||||
const submitButton = await screen.findByRole('button', { name: 'Copy' })
|
||||
expect(submitButton.disabled).to.be.false
|
||||
|
||||
const input = await screen.getByLabelText('New Name')
|
||||
|
||||
fireEvent.change(input, {
|
||||
target: { value: '' },
|
||||
})
|
||||
expect(submitButton.disabled).to.be.true
|
||||
|
||||
fireEvent.change(input, {
|
||||
target: { value: 'A Cloned Project' },
|
||||
})
|
||||
expect(submitButton.disabled).to.be.false
|
||||
|
||||
fireEvent.click(submitButton)
|
||||
expect(submitButton.disabled).to.be.true
|
||||
|
||||
await fetchMock.callHistory.flush(true)
|
||||
expect(fetchMock.callHistory.done()).to.be.true
|
||||
const { url, options } = fetchMock.callHistory
|
||||
.calls('express:/project/:projectId/clone')
|
||||
.at(-1)
|
||||
expect(url).to.equal(
|
||||
'https://www.test-overleaf.com/project/project-1/clone'
|
||||
)
|
||||
|
||||
expect(JSON.parse(options.body)).to.deep.equal({
|
||||
projectName: 'A Cloned Project',
|
||||
tags: [],
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(openProject).to.be.calledOnce
|
||||
})
|
||||
|
||||
const errorMessage = screen.queryByText('Sorry, something went wrong')
|
||||
expect(errorMessage).to.be.null
|
||||
|
||||
await waitFor(() => {
|
||||
expect(submitButton.disabled).to.be.false
|
||||
expect(cancelButton.disabled).to.be.false
|
||||
})
|
||||
})
|
||||
|
||||
it('handles a generic error response', async function () {
|
||||
const matcher = 'express:/project/:projectId/clone'
|
||||
|
||||
fetchMock.postOnce(matcher, {
|
||||
status: 500,
|
||||
body: 'There was an error!',
|
||||
})
|
||||
|
||||
const handleHide = sinon.stub()
|
||||
const openProject = sinon.stub()
|
||||
|
||||
renderWithEditorContext(
|
||||
<EditorCloneProjectModalWrapper
|
||||
handleHide={handleHide}
|
||||
openProject={openProject}
|
||||
show
|
||||
/>,
|
||||
{ scope: { project } }
|
||||
)
|
||||
|
||||
const button = await screen.findByRole('button', { name: 'Copy' })
|
||||
expect(button.disabled).to.be.false
|
||||
|
||||
const cancelButton = await screen.findByRole('button', { name: 'Cancel' })
|
||||
expect(cancelButton.disabled).to.be.false
|
||||
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(fetchMock.callHistory.done(matcher)).to.be.true
|
||||
expect(openProject).not.to.be.called
|
||||
|
||||
await screen.findByText('Sorry, something went wrong')
|
||||
|
||||
expect(button.disabled).to.be.false
|
||||
expect(cancelButton.disabled).to.be.false
|
||||
})
|
||||
|
||||
it('handles a specific error response', async function () {
|
||||
const matcher = 'express:/project/:projectId/clone'
|
||||
|
||||
fetchMock.postOnce(matcher, {
|
||||
status: 400,
|
||||
body: 'There was an error!',
|
||||
})
|
||||
|
||||
const handleHide = sinon.stub()
|
||||
const openProject = sinon.stub()
|
||||
|
||||
renderWithEditorContext(
|
||||
<EditorCloneProjectModalWrapper
|
||||
handleHide={handleHide}
|
||||
openProject={openProject}
|
||||
show
|
||||
/>,
|
||||
{ scope: { project } }
|
||||
)
|
||||
|
||||
const button = await screen.findByRole('button', { name: 'Copy' })
|
||||
expect(button.disabled).to.be.false
|
||||
|
||||
const cancelButton = await screen.findByRole('button', { name: 'Cancel' })
|
||||
expect(cancelButton.disabled).to.be.false
|
||||
|
||||
fireEvent.click(button)
|
||||
await fetchMock.callHistory.flush(true)
|
||||
|
||||
expect(fetchMock.callHistory.done(matcher)).to.be.true
|
||||
expect(openProject).not.to.be.called
|
||||
|
||||
await screen.findByText('There was an error!')
|
||||
|
||||
expect(button.disabled).to.be.false
|
||||
expect(cancelButton.disabled).to.be.false
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
import DictionaryModal from '@/features/dictionary/components/dictionary-modal'
|
||||
import { EditorProviders } from '../../../helpers/editor-providers'
|
||||
import { learnedWords } from '@/features/source-editor/extensions/spelling/learned-words'
|
||||
|
||||
describe('<DictionaryModalContent />', function () {
|
||||
let originalLearnedWords
|
||||
|
||||
beforeEach(function () {
|
||||
cy.then(() => {
|
||||
originalLearnedWords = learnedWords.global
|
||||
})
|
||||
cy.interceptCompile()
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
cy.then(() => {
|
||||
learnedWords.global = originalLearnedWords
|
||||
})
|
||||
})
|
||||
|
||||
it('list words', function () {
|
||||
cy.then(win => {
|
||||
learnedWords.global = new Set(['foo', 'bar'])
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<DictionaryModal show handleHide={cy.stub()} />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByText('foo')
|
||||
cy.findByText('bar')
|
||||
})
|
||||
|
||||
it('shows message when empty', function () {
|
||||
cy.then(win => {
|
||||
learnedWords.global = new Set([])
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<DictionaryModal show handleHide={cy.stub()} />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.contains('Your custom dictionary is empty.')
|
||||
})
|
||||
|
||||
it('removes words', function () {
|
||||
cy.intercept('/spelling/unlearn', { statusCode: 200 })
|
||||
|
||||
cy.then(win => {
|
||||
learnedWords.global = new Set(['Foo', 'bar'])
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<DictionaryModal show handleHide={cy.stub()} />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByText('Foo')
|
||||
cy.findByText('bar')
|
||||
|
||||
cy.findAllByRole('button', {
|
||||
name: 'Remove from dictionary',
|
||||
})
|
||||
.eq(0)
|
||||
.click()
|
||||
|
||||
cy.findByText('bar').should('not.exist')
|
||||
cy.findByText('Foo')
|
||||
})
|
||||
|
||||
it('handles errors', function () {
|
||||
cy.intercept('/spelling/unlearn', { statusCode: 500 }).as('unlearn')
|
||||
|
||||
cy.then(win => {
|
||||
learnedWords.global = new Set(['foo'])
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<DictionaryModal show handleHide={cy.stub()} />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByText('foo')
|
||||
|
||||
cy.findAllByRole('button', {
|
||||
name: 'Remove from dictionary',
|
||||
})
|
||||
.eq(0)
|
||||
.click()
|
||||
|
||||
cy.wait('@unlearn')
|
||||
|
||||
cy.findByText('Sorry, something went wrong')
|
||||
cy.findByText('foo')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import sinon from 'sinon'
|
||||
import { expect } from 'chai'
|
||||
import ActionsCopyProject from '../../../../../frontend/js/features/editor-left-menu/components/actions-copy-project'
|
||||
import { renderWithEditorContext } from '../../../helpers/render-with-context'
|
||||
import * as useLocationModule from '../../../../../frontend/js/shared/hooks/use-location'
|
||||
|
||||
describe('<ActionsCopyProject />', function () {
|
||||
let assignStub
|
||||
|
||||
beforeEach(function () {
|
||||
assignStub = sinon.stub()
|
||||
this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
|
||||
assign: assignStub,
|
||||
replace: sinon.stub(),
|
||||
reload: sinon.stub(),
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
this.locationStub.restore()
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('shows correct modal when clicked', async function () {
|
||||
renderWithEditorContext(<ActionsCopyProject />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copy Project' }))
|
||||
|
||||
screen.getByPlaceholderText('New Project Name')
|
||||
})
|
||||
|
||||
it('loads the project page when submitted', async function () {
|
||||
fetchMock.post('express:/project/:id/clone', {
|
||||
status: 200,
|
||||
body: {
|
||||
project_id: 'new-project',
|
||||
},
|
||||
})
|
||||
|
||||
renderWithEditorContext(<ActionsCopyProject />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copy Project' }))
|
||||
|
||||
const input = screen.getByPlaceholderText('New Project Name')
|
||||
fireEvent.change(input, { target: { value: 'New Project' } })
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Copy' })
|
||||
button.click()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(button.textContent).to.equal('Copying…')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(assignStub).to.have.been.calledOnceWith('/project/new-project')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import ActionsMenu from '../../../../../frontend/js/features/editor-left-menu/components/actions-menu'
|
||||
import { renderWithEditorContext } from '../../../helpers/render-with-context'
|
||||
|
||||
describe('<ActionsMenu />', function () {
|
||||
beforeEach(function () {
|
||||
fetchMock.post('express:/project/:projectId/compile', {
|
||||
status: 'success',
|
||||
pdfDownloadDomain: 'https://clsi.test-overleaf.com',
|
||||
outputFiles: [
|
||||
{
|
||||
path: 'output.pdf',
|
||||
build: 'build-123',
|
||||
url: '/build/build-123/output.pdf',
|
||||
type: 'pdf',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('shows correct menu for non-anonymous users', async function () {
|
||||
window.metaAttributesCache.set('ol-anonymous', false)
|
||||
|
||||
renderWithEditorContext(<ActionsMenu />, {
|
||||
projectId: '123abc',
|
||||
scope: {
|
||||
editor: {
|
||||
sharejs_doc: {
|
||||
doc_id: 'test-doc',
|
||||
getSnapshot: () => 'some doc content',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
screen.getByText('Actions')
|
||||
screen.getByRole('button', {
|
||||
name: 'Copy Project',
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
screen.getByRole('button', {
|
||||
name: 'Word Count',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('does not show anything for anonymous users', async function () {
|
||||
window.metaAttributesCache.set('ol-anonymous', true)
|
||||
|
||||
renderWithEditorContext(<ActionsMenu />, {
|
||||
projectId: '123abc',
|
||||
scope: {
|
||||
editor: {
|
||||
sharejs_doc: {
|
||||
doc_id: 'test-doc',
|
||||
getSnapshot: () => 'some doc content',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.queryByText('Actions')).to.equal(null)
|
||||
expect(
|
||||
screen.queryByRole('button', {
|
||||
name: 'Copy Project',
|
||||
})
|
||||
).to.equal(null)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole('button', {
|
||||
name: 'Word Count',
|
||||
})
|
||||
).to.equal(null)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import ActionsWordCount from '../../../../../frontend/js/features/editor-left-menu/components/actions-word-count'
|
||||
import { renderWithEditorContext } from '../../../helpers/render-with-context'
|
||||
|
||||
describe('<ActionsWordCount />', function () {
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('shows correct modal when clicked after document is compiled', async function () {
|
||||
const compileEndpoint = 'express:/project/:projectId/compile'
|
||||
const wordcountEndpoint = 'express:/project/:projectId/wordcount'
|
||||
|
||||
fetchMock.post(compileEndpoint, {
|
||||
status: 'success',
|
||||
pdfDownloadDomain: 'https://clsi.test-overleaf.com',
|
||||
outputFiles: [
|
||||
{
|
||||
path: 'output.pdf',
|
||||
build: 'build-123',
|
||||
url: '/build/build-123/output.pdf',
|
||||
type: 'pdf',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
fetchMock.get(wordcountEndpoint, {
|
||||
texcount: {
|
||||
encode: 'ascii',
|
||||
textWords: 0,
|
||||
headers: 0,
|
||||
mathInline: 0,
|
||||
mathDisplay: 0,
|
||||
},
|
||||
})
|
||||
|
||||
renderWithEditorContext(<ActionsWordCount />, {
|
||||
projectId: '123abc',
|
||||
scope: {
|
||||
editor: {
|
||||
sharejs_doc: {
|
||||
doc_id: 'test-doc',
|
||||
getSnapshot: () => 'some doc content',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// when loading, we don't render the "Word Count" as button yet
|
||||
expect(screen.queryByRole('button', { name: 'Word Count' })).to.equal(null)
|
||||
|
||||
await waitFor(
|
||||
() => expect(fetchMock.callHistory.called(compileEndpoint)).to.be.true
|
||||
)
|
||||
|
||||
const button = await screen.findByRole('button', { name: 'Word Count' })
|
||||
button.click()
|
||||
|
||||
await waitFor(
|
||||
() => expect(fetchMock.callHistory.called(wordcountEndpoint)).to.be.true
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import DownloadMenu from '../../../../../frontend/js/features/editor-left-menu/components/download-menu'
|
||||
import { renderWithEditorContext } from '../../../helpers/render-with-context'
|
||||
|
||||
describe('<DownloadMenu />', function () {
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('shows download links with correct url', async function () {
|
||||
fetchMock.post('express:/project/:projectId/compile', {
|
||||
clsiServerId: 'foo',
|
||||
compileGroup: 'priority',
|
||||
status: 'success',
|
||||
pdfDownloadDomain: 'https://clsi.test-overleaf.com',
|
||||
outputFiles: [
|
||||
{
|
||||
path: 'output.pdf',
|
||||
build: 'build-123',
|
||||
url: '/build/build-123/output.pdf',
|
||||
type: 'pdf',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
renderWithEditorContext(<DownloadMenu />, {
|
||||
projectId: '123abc',
|
||||
scope: {
|
||||
editor: {
|
||||
sharejs_doc: {
|
||||
doc_id: 'test-doc',
|
||||
getSnapshot: () => 'some doc content',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const sourceLink = screen.getByRole('link', {
|
||||
name: 'Source',
|
||||
})
|
||||
|
||||
expect(sourceLink.getAttribute('href')).to.equal(
|
||||
'/project/123abc/download/zip'
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
const pdfLink = screen.getByRole('link', {
|
||||
name: 'PDF',
|
||||
})
|
||||
|
||||
expect(pdfLink.getAttribute('href')).to.equal(
|
||||
'/download/project/123abc/build/build-123/output/output.pdf?compileGroup=priority&clsiserverid=foo&popupDownload=true'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import { expect } from 'chai'
|
||||
import { screen, fireEvent, within } from '@testing-library/react'
|
||||
import HelpContactUs from '../../../../../frontend/js/features/editor-left-menu/components/help-contact-us'
|
||||
import { renderWithEditorContext } from '../../../helpers/render-with-context'
|
||||
import fetchMock from 'fetch-mock'
|
||||
|
||||
describe('<HelpContactUs />', function () {
|
||||
beforeEach(function () {
|
||||
window.metaAttributesCache.set('ol-user', {
|
||||
email: 'sherlock@holmes.co.uk',
|
||||
first_name: 'Sherlock',
|
||||
last_name: 'Holmes',
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('open contact us modal when clicked', function () {
|
||||
renderWithEditorContext(<HelpContactUs />)
|
||||
|
||||
expect(screen.queryByRole('dialog')).to.equal(null)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Contact Us' }))
|
||||
const modal = screen.getAllByRole('dialog')[0]
|
||||
within(modal).getAllByText('Get in touch')
|
||||
within(modal).getByText('Subject')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { expect } from 'chai'
|
||||
import { screen, render } from '@testing-library/react'
|
||||
import HelpDocumentation from '../../../../../frontend/js/features/editor-left-menu/components/help-documentation'
|
||||
|
||||
describe('<HelpDocumentation />', function () {
|
||||
it('has correct href attribute', function () {
|
||||
render(<HelpDocumentation />)
|
||||
|
||||
const link = screen.getByRole('link', { name: 'Documentation' })
|
||||
expect(link.getAttribute('href')).to.equal('/learn')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { screen } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import HelpMenu from '../../../../../frontend/js/features/editor-left-menu/components/help-menu'
|
||||
import { renderWithEditorContext } from '../../../helpers/render-with-context'
|
||||
|
||||
describe('<HelpMenu />', function () {
|
||||
beforeEach(function () {
|
||||
window.metaAttributesCache.set('ol-user', {
|
||||
email: 'sherlock@holmes.co.uk',
|
||||
first_name: 'Sherlock',
|
||||
last_name: 'Holmes',
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('shows correct menu if `showSupport` is `true`', function () {
|
||||
window.metaAttributesCache.set('ol-showSupport', true)
|
||||
|
||||
renderWithEditorContext(<HelpMenu />)
|
||||
|
||||
screen.getByRole('button', { name: 'Show Hotkeys' })
|
||||
screen.getByRole('button', { name: 'Contact Us' })
|
||||
screen.getByRole('link', { name: 'Documentation' })
|
||||
})
|
||||
|
||||
it('shows correct menu if `showSupport` is `false`', function () {
|
||||
window.metaAttributesCache.set('ol-showSupport', false)
|
||||
|
||||
renderWithEditorContext(<HelpMenu />)
|
||||
|
||||
screen.getByRole('button', { name: 'Show Hotkeys' })
|
||||
expect(screen.queryByRole('button', { name: 'Contact Us' })).to.equal(null)
|
||||
expect(screen.queryByRole('link', { name: 'Documentation' })).to.equal(null)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { expect } from 'chai'
|
||||
import { screen, fireEvent, within } from '@testing-library/react'
|
||||
import HelpShowHotkeys from '../../../../../frontend/js/features/editor-left-menu/components/help-show-hotkeys'
|
||||
import { renderWithEditorContext } from '../../../helpers/render-with-context'
|
||||
import fetchMock from 'fetch-mock'
|
||||
|
||||
describe('<HelpShowHotkeys />', function () {
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('open hotkeys modal when clicked', function () {
|
||||
renderWithEditorContext(<HelpShowHotkeys />)
|
||||
|
||||
expect(screen.queryByRole('dialog')).to.equal(null)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show Hotkeys' }))
|
||||
const modal = screen.getAllByRole('dialog')[0]
|
||||
within(modal).getByText('Common')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { screen, within, render } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import SettingsAutoCloseBrackets from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-auto-close-brackets'
|
||||
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
|
||||
import { EditorProviders } from '../../../../helpers/editor-providers'
|
||||
|
||||
describe('<SettingsAutoCloseBrackets />', function () {
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('shows correct menu', async function () {
|
||||
render(
|
||||
<EditorProviders>
|
||||
<EditorLeftMenuProvider>
|
||||
<SettingsAutoCloseBrackets />
|
||||
</EditorLeftMenuProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
const select = screen.getByLabelText('Auto-close brackets')
|
||||
|
||||
const optionOn = within(select).getByText('On')
|
||||
expect(optionOn.getAttribute('value')).to.equal('true')
|
||||
|
||||
const optionOff = within(select).getByText('Off')
|
||||
expect(optionOff.getAttribute('value')).to.equal('false')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { screen, within, render } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import SettingsAutoComplete from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-auto-complete'
|
||||
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
|
||||
import { EditorProviders } from '../../../../helpers/editor-providers'
|
||||
|
||||
describe('<SettingsAutoComplete />', function () {
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('shows correct menu', async function () {
|
||||
render(
|
||||
<EditorProviders>
|
||||
<EditorLeftMenuProvider>
|
||||
<SettingsAutoComplete />
|
||||
</EditorLeftMenuProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
const select = screen.getByLabelText('Auto-complete')
|
||||
|
||||
const optionOn = within(select).getByText('On')
|
||||
expect(optionOn.getAttribute('value')).to.equal('true')
|
||||
|
||||
const optionOff = within(select).getByText('Off')
|
||||
expect(optionOff.getAttribute('value')).to.equal('false')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { screen, within, render } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import SettingsCompiler from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-compiler'
|
||||
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
|
||||
import { EditorProviders } from '../../../../helpers/editor-providers'
|
||||
|
||||
describe('<SettingsCompiler />', function () {
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('shows correct menu', async function () {
|
||||
render(
|
||||
<EditorProviders>
|
||||
<EditorLeftMenuProvider>
|
||||
<SettingsCompiler />
|
||||
</EditorLeftMenuProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
const select = screen.getByLabelText('Compiler')
|
||||
|
||||
const optionPdfLaTeX = within(select).getByText('pdfLaTeX')
|
||||
expect(optionPdfLaTeX.getAttribute('value')).to.equal('pdflatex')
|
||||
|
||||
const optionLaTeX = within(select).getByText('LaTeX')
|
||||
expect(optionLaTeX.getAttribute('value')).to.equal('latex')
|
||||
|
||||
const optionXeLaTeX = within(select).getByText('XeLaTeX')
|
||||
expect(optionXeLaTeX.getAttribute('value')).to.equal('xelatex')
|
||||
|
||||
const optionLuaLaTeX = within(select).getByText('LuaLaTeX')
|
||||
expect(optionLuaLaTeX.getAttribute('value')).to.equal('lualatex')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { fireEvent, screen, within, render } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import SettingsDictionary from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-dictionary'
|
||||
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
|
||||
import { EditorProviders } from '../../../../helpers/editor-providers'
|
||||
|
||||
describe('<SettingsDictionary />', function () {
|
||||
it('open dictionary modal', function () {
|
||||
render(
|
||||
<EditorProviders>
|
||||
<EditorLeftMenuProvider>
|
||||
<SettingsDictionary />
|
||||
</EditorLeftMenuProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
screen.getByText('Dictionary')
|
||||
|
||||
const button = screen.getByText('Edit')
|
||||
fireEvent.click(button)
|
||||
|
||||
const modal = screen.getByTestId('dictionary-modal')
|
||||
|
||||
within(modal).getByRole('heading', { name: 'Edit Dictionary' })
|
||||
within(modal).getByText('Your custom dictionary is empty.')
|
||||
|
||||
const [, closeButton] = within(modal).getAllByRole('button', {
|
||||
name: 'Close',
|
||||
})
|
||||
fireEvent.click(closeButton)
|
||||
expect(screen.getByTestId('dictionary-modal')).to.not.be.null
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import { screen, within, render } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import SettingsDocument from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-document'
|
||||
import { Folder } from '../../../../../../types/folder'
|
||||
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
|
||||
import { EditorProviders } from '../../../../helpers/editor-providers'
|
||||
|
||||
describe('<SettingsDocument />', function () {
|
||||
const rootFolder: Folder = {
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [
|
||||
{
|
||||
_id: '123abc',
|
||||
name: 'main.tex',
|
||||
},
|
||||
],
|
||||
fileRefs: [],
|
||||
folders: [],
|
||||
}
|
||||
|
||||
let originalSettings: typeof window.metaAttributesCache
|
||||
|
||||
beforeEach(function () {
|
||||
originalSettings = window.metaAttributesCache.get('ol-ExposedSettings')
|
||||
window.metaAttributesCache.set('ol-ExposedSettings', {
|
||||
validRootDocExtensions: ['tex'],
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
window.metaAttributesCache.set('ol-ExposedSettings', originalSettings)
|
||||
})
|
||||
|
||||
it('shows correct menu', async function () {
|
||||
render(
|
||||
<EditorProviders rootFolder={[rootFolder as any]}>
|
||||
<EditorLeftMenuProvider>
|
||||
<SettingsDocument />
|
||||
</EditorLeftMenuProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
const select = screen.getByLabelText('Main document')
|
||||
|
||||
const optionOn = within(select).getByText('main.tex')
|
||||
expect(optionOn.getAttribute('value')).to.equal('123abc')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { screen, within, render } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import SettingsEditorTheme from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-editor-theme'
|
||||
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
|
||||
import { EditorProviders } from '../../../../helpers/editor-providers'
|
||||
|
||||
describe('<SettingsEditorTheme />', function () {
|
||||
const editorThemes = ['editortheme-1', 'editortheme-2', 'editortheme-3']
|
||||
|
||||
const legacyEditorThemes = ['legacytheme-1', 'legacytheme-2', 'legacytheme-3']
|
||||
|
||||
beforeEach(function () {
|
||||
window.metaAttributesCache.set('ol-editorThemes', editorThemes)
|
||||
window.metaAttributesCache.set('ol-legacyEditorThemes', legacyEditorThemes)
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('shows correct menu', async function () {
|
||||
render(
|
||||
<EditorProviders>
|
||||
<EditorLeftMenuProvider>
|
||||
<SettingsEditorTheme />
|
||||
</EditorLeftMenuProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
const select = screen.getByLabelText('Editor theme')
|
||||
|
||||
for (const theme of editorThemes) {
|
||||
const option = within(select).getByText(theme.replace(/_/g, ' '))
|
||||
expect(option.getAttribute('value')).to.equal(theme)
|
||||
}
|
||||
|
||||
for (const theme of legacyEditorThemes) {
|
||||
const option = within(select).getByText(
|
||||
theme.replace(/_/g, ' ') + ' (Legacy)'
|
||||
)
|
||||
expect(option.getAttribute('value')).to.equal(theme)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { screen, within, render } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import SettingsFontFamily from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-font-family'
|
||||
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
|
||||
import { EditorProviders } from '../../../../helpers/editor-providers'
|
||||
|
||||
describe('<SettingsFontFamily />', function () {
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('shows correct menu', async function () {
|
||||
render(
|
||||
<EditorProviders>
|
||||
<EditorLeftMenuProvider>
|
||||
<SettingsFontFamily />
|
||||
</EditorLeftMenuProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
const select = screen.getByLabelText('Font Family')
|
||||
|
||||
const optionMonaco = within(select).getByText('Monaco / Menlo / Consolas')
|
||||
expect(optionMonaco.getAttribute('value')).to.equal('monaco')
|
||||
|
||||
const optionLucida = within(select).getByText('Lucida / Source Code Pro')
|
||||
expect(optionLucida.getAttribute('value')).to.equal('lucida')
|
||||
|
||||
const optionOpenDyslexicMono = within(select).getByText('OpenDyslexic Mono')
|
||||
expect(optionOpenDyslexicMono.getAttribute('value')).to.equal(
|
||||
'opendyslexicmono'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { screen, within, render } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import SettingsFontSize from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-font-size'
|
||||
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
|
||||
import { EditorProviders } from '../../../../helpers/editor-providers'
|
||||
|
||||
describe('<SettingsFontSize />', function () {
|
||||
const sizes = ['10', '11', '12', '13', '14', '16', '18', '20', '22', '24']
|
||||
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('shows correct menu', async function () {
|
||||
render(
|
||||
<EditorProviders>
|
||||
<EditorLeftMenuProvider>
|
||||
<SettingsFontSize />
|
||||
</EditorLeftMenuProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
const select = screen.getByLabelText('Font Size')
|
||||
|
||||
for (const size of sizes) {
|
||||
const option = within(select).getByText(`${size}px`)
|
||||
expect(option.getAttribute('value')).to.equal(size)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { screen, within, render } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import SettingsImageName from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-image-name'
|
||||
import type { AllowedImageName } from '../../../../../../types/project-settings'
|
||||
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
|
||||
import { EditorProviders } from '../../../../helpers/editor-providers'
|
||||
|
||||
describe('<SettingsImageName />', function () {
|
||||
const allowedImageNames: AllowedImageName[] = [
|
||||
{
|
||||
imageDesc: 'Image 1',
|
||||
imageName: 'img-1',
|
||||
},
|
||||
{
|
||||
imageDesc: 'Image 2',
|
||||
imageName: 'img-2',
|
||||
},
|
||||
]
|
||||
|
||||
beforeEach(function () {
|
||||
window.metaAttributesCache.set('ol-allowedImageNames', allowedImageNames)
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('shows correct menu', async function () {
|
||||
render(
|
||||
<EditorProviders>
|
||||
<EditorLeftMenuProvider>
|
||||
<SettingsImageName />
|
||||
</EditorLeftMenuProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
const select = screen.getByLabelText('TeX Live version')
|
||||
|
||||
for (const { imageName, imageDesc } of allowedImageNames) {
|
||||
const option = within(select).getByText(imageDesc)
|
||||
expect(option.getAttribute('value')).to.equal(imageName)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { screen, within, render } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import SettingsKeybindings from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-keybindings'
|
||||
import { EditorProviders } from '../../../../helpers/editor-providers'
|
||||
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
|
||||
|
||||
describe('<SettingsKeybindings />', function () {
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('shows correct menu', async function () {
|
||||
render(
|
||||
<EditorProviders>
|
||||
<EditorLeftMenuProvider>
|
||||
<SettingsKeybindings />
|
||||
</EditorLeftMenuProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
const select = screen.getByLabelText('Keybindings')
|
||||
|
||||
const optionNone = within(select).getByText('None')
|
||||
expect(optionNone.getAttribute('value')).to.equal('default')
|
||||
|
||||
const optionVim = within(select).getByText('Vim')
|
||||
expect(optionVim.getAttribute('value')).to.equal('vim')
|
||||
|
||||
const optionEmacs = within(select).getByText('Emacs')
|
||||
expect(optionEmacs.getAttribute('value')).to.equal('emacs')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { screen, within, render } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import SettingsLineHeight from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-line-height'
|
||||
import { EditorProviders } from '../../../../helpers/editor-providers'
|
||||
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
|
||||
|
||||
describe('<SettingsLineHeight />', function () {
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('shows correct menu', async function () {
|
||||
render(
|
||||
<EditorProviders>
|
||||
<EditorLeftMenuProvider>
|
||||
<SettingsLineHeight />
|
||||
</EditorLeftMenuProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
const select = screen.getByLabelText('Line Height')
|
||||
|
||||
const optionCompact = within(select).getByText('Compact')
|
||||
expect(optionCompact.getAttribute('value')).to.equal('compact')
|
||||
|
||||
const optionNormal = within(select).getByText('Normal')
|
||||
expect(optionNormal.getAttribute('value')).to.equal('normal')
|
||||
|
||||
const optionWide = within(select).getByText('Wide')
|
||||
expect(optionWide.getAttribute('value')).to.equal('wide')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { screen, within, render } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import SettingsMathPreview from '@/features/editor-left-menu/components/settings/settings-math-preview'
|
||||
import { EditorProviders } from '../../../../helpers/editor-providers'
|
||||
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
|
||||
|
||||
describe('<SettingsMathPreview />', function () {
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('shows correct menu', async function () {
|
||||
render(
|
||||
<EditorProviders>
|
||||
<EditorLeftMenuProvider>
|
||||
<SettingsMathPreview />
|
||||
</EditorLeftMenuProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
const select = screen.getByLabelText('Equation preview')
|
||||
|
||||
const optionOn = within(select).getByText('On')
|
||||
expect(optionOn.getAttribute('value')).to.equal('true')
|
||||
|
||||
const optionOff = within(select).getByText('Off')
|
||||
expect(optionOff.getAttribute('value')).to.equal('false')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
import { screen, within, render } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import SettingsOverallTheme from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-overall-theme'
|
||||
import type { OverallThemeMeta } from '../../../../../../types/project-settings'
|
||||
import getMeta from '@/utils/meta'
|
||||
import { EditorProviders } from '../../../../helpers/editor-providers'
|
||||
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
|
||||
|
||||
const IEEE_BRAND_ID = 1234
|
||||
const OTHER_BRAND_ID = 2234
|
||||
|
||||
describe('<SettingsOverallTheme />', function () {
|
||||
const overallThemes: OverallThemeMeta[] = [
|
||||
{
|
||||
name: 'Overall Theme 1',
|
||||
val: '',
|
||||
path: 'https://overleaf.com/overalltheme-1.css',
|
||||
},
|
||||
{
|
||||
name: 'Overall Theme 2',
|
||||
val: 'light-',
|
||||
path: 'https://overleaf.com/overalltheme-2.css',
|
||||
},
|
||||
]
|
||||
|
||||
beforeEach(function () {
|
||||
window.metaAttributesCache.set('ol-overallThemes', overallThemes)
|
||||
Object.assign(getMeta('ol-ExposedSettings'), {
|
||||
ieeeBrandId: IEEE_BRAND_ID,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('shows correct menu', async function () {
|
||||
render(
|
||||
<EditorProviders>
|
||||
<EditorLeftMenuProvider>
|
||||
<SettingsOverallTheme />
|
||||
</EditorLeftMenuProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
const select = screen.getByLabelText('Overall theme')
|
||||
|
||||
for (const theme of overallThemes) {
|
||||
const option = within(select).getByText(theme.name)
|
||||
expect(option.getAttribute('value')).to.equal(theme.val)
|
||||
}
|
||||
})
|
||||
describe('Branded Project', function () {
|
||||
it('should hide overall theme picker for IEEE branded projects', function () {
|
||||
window.metaAttributesCache.set('ol-brandVariation', {
|
||||
brand_id: IEEE_BRAND_ID,
|
||||
})
|
||||
render(
|
||||
<EditorProviders>
|
||||
<EditorLeftMenuProvider>
|
||||
<SettingsOverallTheme />
|
||||
</EditorLeftMenuProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
const select = screen.queryByText('Overall theme')
|
||||
expect(select).to.not.exist
|
||||
})
|
||||
|
||||
it('should show overall theme picker for branded projects that are not IEEE', function () {
|
||||
window.metaAttributesCache.set('ol-brandVariation', {
|
||||
brand_id: OTHER_BRAND_ID,
|
||||
})
|
||||
render(
|
||||
<EditorProviders>
|
||||
<EditorLeftMenuProvider>
|
||||
<SettingsOverallTheme />
|
||||
</EditorLeftMenuProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
const select = screen.getByLabelText('Overall theme')
|
||||
expect(select).to.exist
|
||||
})
|
||||
|
||||
it('should show overall theme picker for non branded projects', function () {
|
||||
window.metaAttributesCache.set('ol-brandVariation', undefined)
|
||||
render(
|
||||
<EditorProviders>
|
||||
<EditorLeftMenuProvider>
|
||||
<SettingsOverallTheme />
|
||||
</EditorLeftMenuProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
const select = screen.getByLabelText('Overall theme')
|
||||
expect(select).to.exist
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { screen, within, render } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import SettingsPdfViewer from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-pdf-viewer'
|
||||
import { EditorProviders } from '../../../../helpers/editor-providers'
|
||||
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
|
||||
|
||||
describe('<SettingsPdfViewer />', function () {
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('shows correct menu', async function () {
|
||||
render(
|
||||
<EditorProviders>
|
||||
<EditorLeftMenuProvider>
|
||||
<SettingsPdfViewer />
|
||||
</EditorLeftMenuProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
const select = screen.getByLabelText('PDF Viewer')
|
||||
|
||||
const optionOverleaf = within(select).getByText('Overleaf')
|
||||
expect(optionOverleaf.getAttribute('value')).to.equal('pdfjs')
|
||||
|
||||
const optionBrowser = within(select).getByText('Browser')
|
||||
expect(optionBrowser.getAttribute('value')).to.equal('native')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { screen, within, render } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import SettingsSpellCheckLanguage from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-spell-check-language'
|
||||
import type { SpellCheckLanguage } from '../../../../../../types/project-settings'
|
||||
import { EditorProviders } from '../../../../helpers/editor-providers'
|
||||
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
|
||||
|
||||
describe('<SettingsSpellCheckLanguage />', function () {
|
||||
const languages: SpellCheckLanguage[] = [
|
||||
{
|
||||
name: 'Lang 1',
|
||||
code: 'lang-1',
|
||||
dic: 'lang_1',
|
||||
},
|
||||
{
|
||||
name: 'Lang 2',
|
||||
code: 'lang-2',
|
||||
dic: 'lang_2',
|
||||
},
|
||||
]
|
||||
|
||||
beforeEach(function () {
|
||||
window.metaAttributesCache.set('ol-languages', languages)
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('shows correct menu', async function () {
|
||||
render(
|
||||
<EditorProviders>
|
||||
<EditorLeftMenuProvider>
|
||||
<SettingsSpellCheckLanguage />
|
||||
</EditorLeftMenuProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
const select = screen.getByLabelText('Spell check')
|
||||
|
||||
const optionEmpty = within(select).getByText('Off')
|
||||
expect(optionEmpty.getAttribute('value')).to.equal('')
|
||||
|
||||
for (const language of languages) {
|
||||
const option = within(select).getByText(language.name)
|
||||
expect(option.getAttribute('value')).to.equal(language.code)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { screen, within, render } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import SettingsSyntaxValidation from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-syntax-validation'
|
||||
import { EditorProviders } from '../../../../helpers/editor-providers'
|
||||
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
|
||||
|
||||
describe('<SettingsSyntaxValidation />', function () {
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('shows correct menu', async function () {
|
||||
render(
|
||||
<EditorProviders>
|
||||
<EditorLeftMenuProvider>
|
||||
<SettingsSyntaxValidation />
|
||||
</EditorLeftMenuProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
const select = screen.getByLabelText('Code check')
|
||||
|
||||
const optionOn = within(select).getByText('On')
|
||||
expect(optionOn.getAttribute('value')).to.equal('true')
|
||||
|
||||
const optionOff = within(select).getByText('Off')
|
||||
expect(optionOff.getAttribute('value')).to.equal('false')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { expect } from 'chai'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
|
||||
import ChatToggleButton from '../../../../../frontend/js/features/editor-navigation-toolbar/components/chat-toggle-button'
|
||||
|
||||
describe('<ChatToggleButton />', function () {
|
||||
const defaultProps = {
|
||||
chatIsOpen: false,
|
||||
unreadMessageCount: 0,
|
||||
onClick: () => {},
|
||||
}
|
||||
|
||||
it('displays the number of unread messages', function () {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
unreadMessageCount: 113,
|
||||
}
|
||||
render(<ChatToggleButton {...props} />)
|
||||
screen.getByText('113')
|
||||
})
|
||||
|
||||
it("doesn't display the unread messages badge when the number of unread messages is zero", function () {
|
||||
render(<ChatToggleButton {...defaultProps} />)
|
||||
expect(screen.queryByText('0')).to.not.exist
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,219 @@
|
||||
import sinon from 'sinon'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import { expect } from 'chai'
|
||||
import { screen } from '@testing-library/react'
|
||||
import LayoutDropdownButton from '../../../../../frontend/js/features/editor-navigation-toolbar/components/layout-dropdown-button'
|
||||
import { renderWithEditorContext } from '../../../helpers/render-with-context'
|
||||
import * as eventTracking from '@/infrastructure/event-tracking'
|
||||
|
||||
describe('<LayoutDropdownButton />', function () {
|
||||
let openStub
|
||||
let sendMBSpy
|
||||
const defaultUi = {
|
||||
pdfLayout: 'flat',
|
||||
view: 'pdf',
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
openStub = sinon.stub(window, 'open')
|
||||
sendMBSpy = sinon.spy(eventTracking, 'sendMB')
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
openStub.restore()
|
||||
sendMBSpy.restore()
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('should mark current layout option as selected', async function () {
|
||||
// Selected is aria-label, visually we show a checkmark
|
||||
renderWithEditorContext(<LayoutDropdownButton />, { ui: defaultUi })
|
||||
|
||||
screen.getByRole('button', { name: 'Layout' }).click()
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getByRole('menuitem', {
|
||||
name: 'Editor & PDF',
|
||||
})
|
||||
.getAttribute('aria-selected')
|
||||
).to.equal('false')
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getByRole('menuitem', {
|
||||
name: 'PDF only (hide editor)',
|
||||
})
|
||||
.getAttribute('aria-selected')
|
||||
).to.equal('true')
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getByRole('menuitem', {
|
||||
name: 'Editor only (hide PDF)',
|
||||
})
|
||||
.getAttribute('aria-selected')
|
||||
).to.equal('false')
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getByRole('menuitem', {
|
||||
name: 'PDF in separate tab',
|
||||
})
|
||||
.getAttribute('aria-selected')
|
||||
).to.equal('false')
|
||||
})
|
||||
|
||||
it('should not select any option in history view', function () {
|
||||
// Selected is aria-label, visually we show a checkmark
|
||||
renderWithEditorContext(<LayoutDropdownButton />, {
|
||||
ui: { ...defaultUi, view: 'history' },
|
||||
})
|
||||
|
||||
screen.getByRole('button', { name: 'Layout' }).click()
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getByRole('menuitem', {
|
||||
name: 'Editor & PDF',
|
||||
})
|
||||
.getAttribute('aria-selected')
|
||||
).to.equal('false')
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getByRole('menuitem', {
|
||||
name: 'PDF only (hide editor)',
|
||||
})
|
||||
.getAttribute('aria-selected')
|
||||
).to.equal('false')
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getByRole('menuitem', {
|
||||
name: 'Editor only (hide PDF)',
|
||||
})
|
||||
.getAttribute('aria-selected')
|
||||
).to.equal('false')
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getByRole('menuitem', {
|
||||
name: 'PDF in separate tab',
|
||||
})
|
||||
.getAttribute('aria-selected')
|
||||
).to.equal('false')
|
||||
})
|
||||
|
||||
it('should treat file and editor views the same way', function () {
|
||||
// Selected is aria-label, visually we show a checkmark
|
||||
renderWithEditorContext(<LayoutDropdownButton />, {
|
||||
ui: {
|
||||
pdfLayout: 'flat',
|
||||
view: 'file',
|
||||
},
|
||||
})
|
||||
|
||||
screen.getByRole('button', { name: 'Layout' }).click()
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getByRole('menuitem', {
|
||||
name: 'Editor & PDF',
|
||||
})
|
||||
.getAttribute('aria-selected')
|
||||
).to.equal('false')
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getByRole('menuitem', {
|
||||
name: 'PDF only (hide editor)',
|
||||
})
|
||||
.getAttribute('aria-selected')
|
||||
).to.equal('false')
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getByRole('menuitem', {
|
||||
name: 'Editor only (hide PDF)',
|
||||
})
|
||||
.getAttribute('aria-selected')
|
||||
).to.equal('true')
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getByRole('menuitem', {
|
||||
name: 'PDF in separate tab',
|
||||
})
|
||||
.getAttribute('aria-selected')
|
||||
).to.equal('false')
|
||||
})
|
||||
|
||||
describe('on detach', function () {
|
||||
let originalBroadcastChannel
|
||||
beforeEach(function () {
|
||||
window.BroadcastChannel = originalBroadcastChannel || true // ensure that window.BroadcastChannel is truthy
|
||||
|
||||
renderWithEditorContext(<LayoutDropdownButton />, {
|
||||
ui: { ...defaultUi, view: 'editor' },
|
||||
})
|
||||
|
||||
screen.getByRole('button', { name: 'Layout' }).click()
|
||||
|
||||
screen
|
||||
.getByRole('menuitem', {
|
||||
name: 'PDF in separate tab',
|
||||
})
|
||||
.click()
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
window.BroadcastChannel = originalBroadcastChannel
|
||||
})
|
||||
|
||||
it('should show processing', function () {
|
||||
screen.getByText('Layout processing')
|
||||
})
|
||||
|
||||
it('should record event', function () {
|
||||
sinon.assert.calledWith(sendMBSpy, 'project-layout-detach')
|
||||
})
|
||||
})
|
||||
|
||||
describe('on layout change / reattach', function () {
|
||||
beforeEach(function () {
|
||||
window.metaAttributesCache.set('ol-detachRole', 'detacher')
|
||||
renderWithEditorContext(<LayoutDropdownButton />, {
|
||||
ui: { ...defaultUi, view: 'editor' },
|
||||
})
|
||||
|
||||
screen.getByRole('button', { name: 'Layout' }).click()
|
||||
|
||||
screen
|
||||
.getByRole('menuitem', {
|
||||
name: 'Editor only (hide PDF)',
|
||||
})
|
||||
.click()
|
||||
})
|
||||
|
||||
it('should not show processing', function () {
|
||||
const processingText = screen.queryByText('Layout processing')
|
||||
expect(processingText).to.not.exist
|
||||
})
|
||||
|
||||
it('should record events', function () {
|
||||
sinon.assert.calledWith(sendMBSpy, 'project-layout-reattach')
|
||||
sinon.assert.calledWith(sendMBSpy, 'project-layout-change', {
|
||||
layout: 'flat',
|
||||
view: 'editor',
|
||||
page: '/detacher',
|
||||
})
|
||||
})
|
||||
|
||||
it('should select new menu item', function () {
|
||||
screen.getByRole('menuitem', {
|
||||
name: 'Editor only (hide PDF)',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
import { expect } from 'chai'
|
||||
import sinon from 'sinon'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
|
||||
import OnlineUsersWidget from '../../../../../frontend/js/features/editor-navigation-toolbar/components/online-users-widget'
|
||||
|
||||
describe('<OnlineUsersWidget />', function () {
|
||||
const defaultProps = {
|
||||
onlineUsers: [
|
||||
{
|
||||
user_id: 'test_user',
|
||||
name: 'test_user',
|
||||
},
|
||||
{
|
||||
user_id: 'another_test_user',
|
||||
name: 'another_test_user',
|
||||
},
|
||||
],
|
||||
goToUser: () => {},
|
||||
}
|
||||
|
||||
describe('with less than 4 users', function () {
|
||||
it('displays user initials', function () {
|
||||
render(<OnlineUsersWidget {...defaultProps} />)
|
||||
screen.getByText('t')
|
||||
screen.getByText('a')
|
||||
})
|
||||
|
||||
it('displays user name in a tooltip', async function () {
|
||||
render(<OnlineUsersWidget {...defaultProps} />)
|
||||
const icon = screen.getByText('t')
|
||||
fireEvent.mouseOver(icon)
|
||||
await screen.findByRole('tooltip', { name: 'test_user' })
|
||||
})
|
||||
|
||||
it('calls "goToUser" when the user initial is clicked', function () {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
goToUser: sinon.stub(),
|
||||
}
|
||||
render(<OnlineUsersWidget {...props} />)
|
||||
|
||||
const icon = screen.getByText('t')
|
||||
fireEvent.click(icon)
|
||||
|
||||
expect(props.goToUser).to.be.calledWith({
|
||||
name: 'test_user',
|
||||
user_id: 'test_user',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with 4 users and more', function () {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
onlineUsers: defaultProps.onlineUsers.concat([
|
||||
{
|
||||
user_id: 'user_3',
|
||||
name: 'user_3',
|
||||
},
|
||||
{
|
||||
user_id: 'user_4',
|
||||
name: 'user_4',
|
||||
},
|
||||
]),
|
||||
}
|
||||
|
||||
it('displays the count of users', function () {
|
||||
render(<OnlineUsersWidget {...props} />)
|
||||
screen.getByText('4')
|
||||
})
|
||||
|
||||
it('displays user names on hover', function () {
|
||||
render(<OnlineUsersWidget {...props} />)
|
||||
|
||||
const toggleButton = screen.getByRole('button')
|
||||
fireEvent.click(toggleButton)
|
||||
|
||||
screen.getByText('test_user')
|
||||
screen.getByText('another_test_user')
|
||||
screen.getByText('user_3')
|
||||
screen.getByText('user_4')
|
||||
})
|
||||
|
||||
it('calls "goToUser" when the user name is clicked', function () {
|
||||
const testProps = {
|
||||
...props,
|
||||
goToUser: sinon.stub(),
|
||||
}
|
||||
render(<OnlineUsersWidget {...testProps} />)
|
||||
|
||||
const toggleButton = screen.getByRole('button')
|
||||
fireEvent.click(toggleButton)
|
||||
|
||||
const icon = screen.getByText('user_3')
|
||||
fireEvent.click(icon)
|
||||
|
||||
expect(testProps.goToUser).to.be.calledWith({
|
||||
name: 'user_3',
|
||||
user_id: 'user_3',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { expect } from 'chai'
|
||||
import sinon from 'sinon'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
|
||||
import ProjectNameEditableLabel from '../../../../../frontend/js/features/editor-navigation-toolbar/components/project-name-editable-label'
|
||||
|
||||
describe('<ProjectNameEditableLabel />', function () {
|
||||
const defaultProps = { projectName: 'test-project', onChange: () => {} }
|
||||
|
||||
it('displays the project name', function () {
|
||||
render(<ProjectNameEditableLabel {...defaultProps} />)
|
||||
screen.getByText('test-project')
|
||||
})
|
||||
|
||||
describe('when the name is editable', function () {
|
||||
const editableProps = { ...defaultProps, hasRenamePermissions: true }
|
||||
|
||||
it('displays an editable input when the edit button is clicked', function () {
|
||||
render(<ProjectNameEditableLabel {...editableProps} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
screen.getByRole('textbox')
|
||||
})
|
||||
|
||||
it('displays an editable input when the project name is double clicked', function () {
|
||||
render(<ProjectNameEditableLabel {...editableProps} />)
|
||||
fireEvent.doubleClick(screen.getByText('test-project'))
|
||||
screen.getByRole('textbox')
|
||||
})
|
||||
|
||||
it('calls "onChange" when the project name is updated', function () {
|
||||
const props = {
|
||||
...editableProps,
|
||||
onChange: sinon.stub(),
|
||||
}
|
||||
render(<ProjectNameEditableLabel {...props} />)
|
||||
|
||||
fireEvent.doubleClick(screen.getByText('test-project'))
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'new project name' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
expect(props.onChange).to.be.calledWith('new project name')
|
||||
})
|
||||
|
||||
it('calls "onChange" when the input loses focus', function () {
|
||||
const props = {
|
||||
...editableProps,
|
||||
onChange: sinon.stub(),
|
||||
}
|
||||
render(<ProjectNameEditableLabel {...props} />)
|
||||
|
||||
fireEvent.doubleClick(screen.getByText('test-project'))
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'new project name' } })
|
||||
|
||||
fireEvent.blur(screen.getByRole('textbox'))
|
||||
|
||||
expect(props.onChange).to.be.calledWith('new project name')
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the name is not editable', function () {
|
||||
const nonEditableProps = { hasRenamePermissions: false, ...defaultProps }
|
||||
|
||||
it('the edit button is not displayed', function () {
|
||||
render(<ProjectNameEditableLabel {...nonEditableProps} />)
|
||||
expect(screen.queryByRole('button')).to.not.exist
|
||||
})
|
||||
|
||||
it('does not display an editable input when the project name is double clicked', function () {
|
||||
render(<ProjectNameEditableLabel {...nonEditableProps} />)
|
||||
fireEvent.doubleClick(screen.getByText('test-project'))
|
||||
expect(screen.queryByRole('textbox')).to.not.exist
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
import { expect } from 'chai'
|
||||
import { screen } from '@testing-library/react'
|
||||
|
||||
import ToolbarHeader from '../../../../../frontend/js/features/editor-navigation-toolbar/components/toolbar-header'
|
||||
import { renderWithEditorContext } from '../../../helpers/render-with-context'
|
||||
|
||||
describe('<ToolbarHeader />', function () {
|
||||
const defaultProps = {
|
||||
onShowLeftMenuClick: () => {},
|
||||
toggleChatOpen: () => {},
|
||||
toggleReviewPanelOpen: () => {},
|
||||
toggleHistoryOpen: () => {},
|
||||
unreadMessageCount: 0,
|
||||
onlineUsers: [],
|
||||
goToUser: () => {},
|
||||
projectName: 'test project',
|
||||
renameProject: () => {},
|
||||
openShareModal: () => {},
|
||||
hasPublishPermissions: true,
|
||||
chatVisible: true,
|
||||
trackChangesVisible: true,
|
||||
handleChangeLayout: () => {},
|
||||
pdfLayout: 'sideBySide',
|
||||
view: 'editor',
|
||||
reattach: () => {},
|
||||
detach: () => {},
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
window.metaAttributesCache.set('ol-chatEnabled', true)
|
||||
})
|
||||
|
||||
describe('cobranding logo', function () {
|
||||
it('is not displayed by default', function () {
|
||||
renderWithEditorContext(<ToolbarHeader {...defaultProps} />)
|
||||
expect(screen.queryByRole('link', { name: 'variation' })).to.not.exist
|
||||
})
|
||||
|
||||
it('is displayed when cobranding data is available', function () {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
cobranding: {
|
||||
brandVariationHomeUrl: 'http://cobranding',
|
||||
brandVariationName: 'variation',
|
||||
logoImgUrl: 'http://cobranding/logo',
|
||||
},
|
||||
}
|
||||
renderWithEditorContext(<ToolbarHeader {...props} />)
|
||||
screen.getByRole('link', { name: 'variation' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('track changes toggle button', function () {
|
||||
it('is displayed by default', function () {
|
||||
renderWithEditorContext(<ToolbarHeader {...defaultProps} />)
|
||||
screen.getByText('Review')
|
||||
})
|
||||
|
||||
it('is not displayed when "trackChangesVisible" prop is set to false', function () {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
trackChangesVisible: false,
|
||||
}
|
||||
renderWithEditorContext(<ToolbarHeader {...props} />)
|
||||
expect(screen.queryByText('Review')).to.not.exist
|
||||
})
|
||||
})
|
||||
|
||||
describe('History toggle button', function () {
|
||||
it('is displayed by default', function () {
|
||||
renderWithEditorContext(<ToolbarHeader {...defaultProps} />)
|
||||
screen.getByText('History')
|
||||
})
|
||||
|
||||
it('is not displayed when "isRestrictedTokenMember" prop is set to true', function () {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
isRestrictedTokenMember: true,
|
||||
}
|
||||
renderWithEditorContext(<ToolbarHeader {...props} />)
|
||||
expect(screen.queryByText('History')).to.not.exist
|
||||
})
|
||||
})
|
||||
|
||||
describe('Chat toggle button', function () {
|
||||
it('is displayed by default', function () {
|
||||
renderWithEditorContext(<ToolbarHeader {...defaultProps} />)
|
||||
screen.getByText('Chat')
|
||||
})
|
||||
|
||||
it('is not displayed when "chatVisible" prop is set to false', function () {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
chatVisible: false,
|
||||
}
|
||||
renderWithEditorContext(<ToolbarHeader {...props} />)
|
||||
expect(screen.queryByText('Chat')).to.not.exist
|
||||
})
|
||||
})
|
||||
|
||||
describe('Publish button', function () {
|
||||
it('is displayed by default', function () {
|
||||
renderWithEditorContext(<ToolbarHeader {...defaultProps} />)
|
||||
screen.getByText('Submit')
|
||||
})
|
||||
|
||||
it('is not displayed for users with no publish permissions', function () {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
hasPublishPermissions: false,
|
||||
}
|
||||
renderWithEditorContext(<ToolbarHeader {...props} />)
|
||||
expect(screen.queryByText('Submit')).to.not.exist
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
import FileTreeCreateNameInput from '../../../../../../frontend/js/features/file-tree/components/file-tree-create/file-tree-create-name-input'
|
||||
import FileTreeCreateNameProvider from '../../../../../../frontend/js/features/file-tree/contexts/file-tree-create-name'
|
||||
|
||||
describe('<FileTreeCreateNameInput/>', function () {
|
||||
it('renders an empty input', function () {
|
||||
cy.mount(
|
||||
<FileTreeCreateNameProvider>
|
||||
<FileTreeCreateNameInput inFlight={false} />
|
||||
</FileTreeCreateNameProvider>
|
||||
)
|
||||
|
||||
cy.findByLabelText('File Name')
|
||||
cy.findByPlaceholderText('File Name')
|
||||
})
|
||||
|
||||
it('renders a custom label and placeholder', function () {
|
||||
cy.mount(
|
||||
<FileTreeCreateNameProvider>
|
||||
<FileTreeCreateNameInput
|
||||
label="File name in this project"
|
||||
placeholder="Enter a file name…"
|
||||
inFlight={false}
|
||||
/>
|
||||
</FileTreeCreateNameProvider>
|
||||
)
|
||||
|
||||
cy.findByLabelText('File name in this project')
|
||||
cy.findByPlaceholderText('Enter a file name…')
|
||||
})
|
||||
|
||||
it('uses an initial name', function () {
|
||||
cy.mount(
|
||||
<FileTreeCreateNameProvider initialName="test.tex">
|
||||
<FileTreeCreateNameInput inFlight={false} />
|
||||
</FileTreeCreateNameProvider>
|
||||
)
|
||||
|
||||
cy.findByLabelText('File Name').should('have.value', 'test.tex')
|
||||
})
|
||||
|
||||
it('focuses the name', function () {
|
||||
cy.spy(window, 'requestAnimationFrame').as('requestAnimationFrame')
|
||||
|
||||
cy.mount(
|
||||
<FileTreeCreateNameProvider initialName="test.tex">
|
||||
<FileTreeCreateNameInput focusName inFlight={false} />
|
||||
</FileTreeCreateNameProvider>
|
||||
)
|
||||
|
||||
cy.findByLabelText('File Name').as('input')
|
||||
|
||||
cy.get('@input').should('have.value', 'test.tex')
|
||||
|
||||
cy.get('@requestAnimationFrame').should('have.been.calledOnce')
|
||||
|
||||
// https://github.com/jsdom/jsdom/issues/2995
|
||||
// "window.getSelection doesn't work with selection of <input> element"
|
||||
// const selection = window.getSelection().toString()
|
||||
// expect(selection).to.equal('test')
|
||||
|
||||
// wait for the selection to update
|
||||
// eslint-disable-next-line cypress/no-unnecessary-waiting
|
||||
cy.wait(100)
|
||||
|
||||
cy.get<HTMLInputElement>('@input').then(element => {
|
||||
expect(element.get(0).selectionStart).to.equal(0)
|
||||
expect(element.get(0).selectionEnd).to.equal(4)
|
||||
})
|
||||
})
|
||||
|
||||
it('disables the input when in flight', function () {
|
||||
cy.mount(
|
||||
<FileTreeCreateNameProvider initialName="test.tex">
|
||||
<FileTreeCreateNameInput inFlight={false} />
|
||||
</FileTreeCreateNameProvider>
|
||||
).then(({ rerender }) => {
|
||||
cy.findByLabelText('File Name').should('not.be.disabled')
|
||||
rerender(
|
||||
<FileTreeCreateNameProvider initialName="test.tex">
|
||||
<FileTreeCreateNameInput inFlight />
|
||||
</FileTreeCreateNameProvider>
|
||||
)
|
||||
cy.findByLabelText('File Name').should('be.disabled')
|
||||
rerender(
|
||||
<FileTreeCreateNameProvider initialName="test.tex">
|
||||
<FileTreeCreateNameInput inFlight={false} />
|
||||
</FileTreeCreateNameProvider>
|
||||
)
|
||||
cy.findByLabelText('File Name').should('not.be.disabled')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,497 @@
|
||||
import { useEffect } from 'react'
|
||||
import FileTreeModalCreateFile from '../../../../../../frontend/js/features/file-tree/components/modals/file-tree-modal-create-file'
|
||||
import { useFileTreeActionable } from '../../../../../../frontend/js/features/file-tree/contexts/file-tree-actionable'
|
||||
import { useFileTreeData } from '../../../../../../frontend/js/shared/context/file-tree-data-context'
|
||||
import { EditorProviders } from '../../../../helpers/editor-providers'
|
||||
import { FileTreeProvider } from '../../helpers/file-tree-provider'
|
||||
import getMeta from '@/utils/meta'
|
||||
|
||||
describe('<FileTreeModalCreateFile/>', function () {
|
||||
it('handles invalid file names', function () {
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<FileTreeProvider>
|
||||
<OpenWithMode mode="doc" />
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByLabelText('File Name').as('input')
|
||||
cy.findByRole('button', { name: 'Create' }).as('submit')
|
||||
|
||||
cy.get('@input').should('have.value', 'name.tex')
|
||||
cy.get('@submit').should('not.be.disabled')
|
||||
cy.findByRole('alert').should('not.exist')
|
||||
|
||||
cy.get('@input').clear()
|
||||
cy.get('@submit').should('be.disabled')
|
||||
cy.findByRole('alert').should('contain.text', 'File name is empty')
|
||||
|
||||
cy.get('@input').type('test.tex')
|
||||
cy.get('@submit').should('not.be.disabled')
|
||||
cy.findByRole('alert').should('not.exist')
|
||||
|
||||
cy.get('@input').type('oops/i/did/it/again')
|
||||
cy.get('@submit').should('be.disabled')
|
||||
cy.findByRole('alert').should('contain.text', 'contains invalid characters')
|
||||
})
|
||||
|
||||
it('displays an error when the file limit is reached', function () {
|
||||
getMeta('ol-ExposedSettings').maxEntitiesPerProject = 10
|
||||
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: Array.from({ length: 10 }, (_, index) => ({
|
||||
_id: `entity-${index}`,
|
||||
})),
|
||||
fileRefs: [],
|
||||
folders: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders rootFolder={rootFolder as any}>
|
||||
<FileTreeProvider>
|
||||
<OpenWithMode mode="doc" />
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('alert')
|
||||
.invoke('text')
|
||||
.should('match', /This project has reached the \d+ file limit/)
|
||||
})
|
||||
|
||||
it('displays a warning when the file limit is nearly reached', function () {
|
||||
getMeta('ol-ExposedSettings').maxEntitiesPerProject = 10
|
||||
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: Array.from({ length: 9 }, (_, index) => ({
|
||||
_id: `entity-${index}`,
|
||||
})),
|
||||
fileRefs: [],
|
||||
folders: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders rootFolder={rootFolder as any}>
|
||||
<FileTreeProvider>
|
||||
<OpenWithMode mode="doc" />
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByText(/This project is approaching the file limit \(\d+\/\d+\)/)
|
||||
})
|
||||
|
||||
it('counts files in nested folders', function () {
|
||||
getMeta('ol-ExposedSettings').maxEntitiesPerProject = 10
|
||||
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [{ _id: 'doc-1' }],
|
||||
fileRefs: [],
|
||||
folders: [
|
||||
{
|
||||
docs: [{ _id: 'doc-2' }],
|
||||
fileRefs: [],
|
||||
folders: [
|
||||
{
|
||||
docs: [
|
||||
{ _id: 'doc-3' },
|
||||
{ _id: 'doc-4' },
|
||||
{ _id: 'doc-5' },
|
||||
{ _id: 'doc-6' },
|
||||
{ _id: 'doc-7' },
|
||||
],
|
||||
fileRefs: [],
|
||||
folders: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders rootFolder={rootFolder as any}>
|
||||
<FileTreeProvider>
|
||||
<OpenWithMode mode="doc" />
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByText(/This project is approaching the file limit \(\d+\/\d+\)/)
|
||||
})
|
||||
|
||||
it('counts folders toward the limit', function () {
|
||||
getMeta('ol-ExposedSettings').maxEntitiesPerProject = 10
|
||||
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [{ _id: 'doc-1' }],
|
||||
fileRefs: [],
|
||||
folders: [
|
||||
{ docs: [], fileRefs: [], folders: [] },
|
||||
{ docs: [], fileRefs: [], folders: [] },
|
||||
{ docs: [], fileRefs: [], folders: [] },
|
||||
{ docs: [], fileRefs: [], folders: [] },
|
||||
{ docs: [], fileRefs: [], folders: [] },
|
||||
{ docs: [], fileRefs: [], folders: [] },
|
||||
{ docs: [], fileRefs: [], folders: [] },
|
||||
{ docs: [], fileRefs: [], folders: [] },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders rootFolder={rootFolder as any}>
|
||||
<FileTreeProvider>
|
||||
<OpenWithMode mode="doc" />
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByText(/This project is approaching the file limit \(\d+\/\d+\)/)
|
||||
})
|
||||
|
||||
it('creates a new file when the form is submitted', function () {
|
||||
cy.intercept('post', '/project/*/doc', {
|
||||
statusCode: 204,
|
||||
}).as('createDoc')
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<FileTreeProvider>
|
||||
<OpenWithMode mode="doc" />
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByLabelText('File Name').type('test')
|
||||
cy.findByRole('button', { name: 'Create' }).click()
|
||||
|
||||
cy.wait('@createDoc')
|
||||
|
||||
cy.get('@createDoc').its('request.body').should('deep.equal', {
|
||||
parent_folder_id: 'root-folder-id',
|
||||
name: 'test.tex',
|
||||
})
|
||||
})
|
||||
|
||||
it('imports a new file from a project', function () {
|
||||
getMeta('ol-ExposedSettings').hasLinkedProjectFileFeature = true
|
||||
getMeta('ol-ExposedSettings').hasLinkedProjectOutputFileFeature = true
|
||||
|
||||
cy.intercept('/user/projects', {
|
||||
body: {
|
||||
projects: [
|
||||
{
|
||||
_id: 'test-project',
|
||||
name: 'This Project',
|
||||
},
|
||||
{
|
||||
_id: 'project-1',
|
||||
name: 'Project One',
|
||||
},
|
||||
{
|
||||
_id: 'project-2',
|
||||
name: 'Project Two',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
cy.intercept('/project/*/entities', {
|
||||
body: {
|
||||
entities: [
|
||||
{
|
||||
path: '/foo.tex',
|
||||
},
|
||||
{
|
||||
path: '/bar.tex',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
cy.intercept('post', '/project/*/compile', {
|
||||
body: {
|
||||
status: 'success',
|
||||
outputFiles: [
|
||||
{
|
||||
build: 'test',
|
||||
path: 'baz.jpg',
|
||||
},
|
||||
{
|
||||
build: 'test',
|
||||
path: 'ball.jpg',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
cy.intercept('post', '/project/*/linked_file', {
|
||||
statusCode: 204,
|
||||
}).as('createLinkedFile')
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<FileTreeProvider>
|
||||
<OpenWithMode mode="project" />
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
// initial state, no project selected
|
||||
cy.findByLabelText('Select a Project').should('not.be.disabled')
|
||||
|
||||
// the submit button should be disabled
|
||||
cy.findByRole('button', { name: 'Create' }).should('be.disabled')
|
||||
|
||||
// the source file selector should be disabled
|
||||
cy.findByLabelText('Select a File').should('be.disabled')
|
||||
cy.findByLabelText('Select an Output File').should('not.exist')
|
||||
// TODO: check for options length, excluding current project
|
||||
|
||||
// select a project
|
||||
cy.findByLabelText('Select a Project').select('project-2')
|
||||
|
||||
// wait for the source file selector to be enabled
|
||||
cy.findByLabelText('Select a File').should('not.be.disabled')
|
||||
cy.findByLabelText('Select an Output File').should('not.exist')
|
||||
cy.findByRole('button', { name: 'Create' }).should('be.disabled')
|
||||
|
||||
// TODO: check for fileInput options length, excluding current project
|
||||
|
||||
// click on the button to toggle between source and output files
|
||||
cy.findByRole('button', {
|
||||
// NOTE: When changing the label, update the other tests with this label as well.
|
||||
name: 'select from output files',
|
||||
}).click()
|
||||
|
||||
// wait for the output file selector to be enabled
|
||||
cy.findByLabelText('Select an Output File').should('not.be.disabled')
|
||||
cy.findByLabelText('Select a File').should('not.exist')
|
||||
cy.findByRole('button', { name: 'Create' }).should('be.disabled')
|
||||
|
||||
// TODO: check for entityInput options length, excluding current project
|
||||
cy.findByLabelText('Select an Output File').select('ball.jpg')
|
||||
cy.findByRole('button', { name: 'Create' }).should('not.be.disabled')
|
||||
cy.findByRole('button', { name: 'Create' }).click()
|
||||
|
||||
cy.get('@createLinkedFile')
|
||||
.its('request.body')
|
||||
.should('deep.equal', {
|
||||
name: 'ball.jpg',
|
||||
provider: 'project_output_file',
|
||||
parent_folder_id: 'root-folder-id',
|
||||
data: {
|
||||
source_project_id: 'project-2',
|
||||
source_output_file_path: 'ball.jpg',
|
||||
build_id: 'test',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the output files feature is not available', function () {
|
||||
beforeEach(function () {
|
||||
getMeta('ol-ExposedSettings').hasLinkedProjectFileFeature = true
|
||||
getMeta('ol-ExposedSettings').hasLinkedProjectOutputFileFeature = false
|
||||
})
|
||||
|
||||
it('should not show the import from output file mode', function () {
|
||||
cy.intercept('/user/projects', {
|
||||
body: {
|
||||
projects: [
|
||||
{
|
||||
_id: 'test-project',
|
||||
name: 'This Project',
|
||||
},
|
||||
{
|
||||
_id: 'project-1',
|
||||
name: 'Project One',
|
||||
},
|
||||
{
|
||||
_id: 'project-2',
|
||||
name: 'Project Two',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<FileTreeProvider>
|
||||
<OpenWithMode mode="project" />
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByLabelText('Select a File')
|
||||
|
||||
cy.findByRole('button', {
|
||||
name: 'select from output files',
|
||||
}).should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
it('import from a URL when the form is submitted', function () {
|
||||
cy.intercept('/project/*/linked_file', {
|
||||
statusCode: 204,
|
||||
}).as('createLinkedFile')
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<FileTreeProvider>
|
||||
<OpenWithMode mode="url" />
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByLabelText('URL to fetch the file from').type(
|
||||
'https://example.com/example.tex'
|
||||
)
|
||||
cy.findByLabelText('File Name In This Project').should(
|
||||
'have.value',
|
||||
'example.tex'
|
||||
)
|
||||
|
||||
// check that the name can still be edited manually
|
||||
cy.findByLabelText('File Name In This Project').clear()
|
||||
cy.findByLabelText('File Name In This Project').type('test.tex')
|
||||
cy.findByLabelText('File Name In This Project').should(
|
||||
'have.value',
|
||||
'test.tex'
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Create' }).click()
|
||||
|
||||
cy.get('@createLinkedFile')
|
||||
.its('request.body')
|
||||
.should('deep.equal', {
|
||||
name: 'test.tex',
|
||||
provider: 'url',
|
||||
parent_folder_id: 'root-folder-id',
|
||||
data: { url: 'https://example.com/example.tex' },
|
||||
})
|
||||
})
|
||||
|
||||
it('uploads a dropped file', function () {
|
||||
cy.intercept('post', '/project/*/upload?folder_id=root-folder-id', {
|
||||
statusCode: 204,
|
||||
}).as('uploadFile')
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<FileTreeProvider>
|
||||
<OpenWithMode mode="upload" />
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
// the submit button should not be present
|
||||
cy.findByRole('button', { name: 'Create' }).should('not.exist')
|
||||
|
||||
cy.get('input[type=file]')
|
||||
.eq(0)
|
||||
.selectFile(
|
||||
{
|
||||
contents: Cypress.Buffer.from('test'),
|
||||
fileName: 'test.tex',
|
||||
mimeType: 'text/plain',
|
||||
lastModified: Date.now(),
|
||||
},
|
||||
{
|
||||
action: 'drag-drop',
|
||||
force: true, // invisible element
|
||||
}
|
||||
)
|
||||
|
||||
cy.wait('@uploadFile')
|
||||
})
|
||||
|
||||
it('uploads a pasted file', function () {
|
||||
cy.intercept('post', '/project/*/upload?folder_id=root-folder-id', {
|
||||
statusCode: 204,
|
||||
}).as('uploadFile')
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<FileTreeProvider>
|
||||
<OpenWithMode mode="upload" />
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
// the submit button should not be present
|
||||
cy.findByRole('button', { name: 'Create' }).should('not.exist')
|
||||
|
||||
cy.wrap(null).then(() => {
|
||||
const clipboardData = new DataTransfer()
|
||||
clipboardData.items.add(
|
||||
new File(['test'], 'test.tex', { type: 'text/plain' })
|
||||
)
|
||||
cy.findByLabelText('Uppy Dashboard').trigger('paste', { clipboardData })
|
||||
})
|
||||
|
||||
cy.wait('@uploadFile')
|
||||
})
|
||||
|
||||
it('displays upload errors', function () {
|
||||
cy.intercept('post', '/project/*/upload?folder_id=root-folder-id', {
|
||||
statusCode: 422,
|
||||
body: { success: false, error: 'invalid_filename' },
|
||||
}).as('uploadFile')
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<FileTreeProvider>
|
||||
<OpenWithMode mode="upload" />
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
// the submit button should not be present
|
||||
cy.findByRole('button', { name: 'Create' }).should('not.exist')
|
||||
|
||||
cy.wrap(null).then(() => {
|
||||
const clipboardData = new DataTransfer()
|
||||
clipboardData.items.add(
|
||||
new File(['test'], 'tes!t.tex', { type: 'text/plain' })
|
||||
)
|
||||
cy.findByLabelText('Uppy Dashboard').trigger('paste', { clipboardData })
|
||||
})
|
||||
|
||||
cy.wait('@uploadFile')
|
||||
|
||||
cy.findByText(
|
||||
`Upload failed: check that the file name doesn’t contain special characters, trailing/leading whitespace or more than 150 characters`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
function OpenWithMode({ mode }: { mode: string }) {
|
||||
const { newFileCreateMode, startCreatingFile } = useFileTreeActionable()
|
||||
|
||||
const { fileCount } = useFileTreeData()
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => startCreatingFile(mode), [])
|
||||
|
||||
if (!fileCount || !newFileCreateMode) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <FileTreeModalCreateFile />
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import FileTreeDoc from '../../../../../frontend/js/features/file-tree/components/file-tree-doc'
|
||||
import { EditorProviders } from '../../../helpers/editor-providers'
|
||||
import { FileTreeProvider } from '../helpers/file-tree-provider'
|
||||
|
||||
describe('<FileTreeDoc/>', function () {
|
||||
it('renders unselected', function () {
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<FileTreeProvider>
|
||||
<FileTreeDoc name="foo.tex" id="123abc" />
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('treeitem', { selected: false })
|
||||
cy.get('.linked-file-highlight').should('not.exist')
|
||||
})
|
||||
|
||||
it('renders selected', function () {
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [{ _id: '123abc' }],
|
||||
fileRefs: [],
|
||||
folders: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders rootFolder={rootFolder as any}>
|
||||
<FileTreeProvider>
|
||||
<FileTreeDoc name="foo.tex" id="123abc" />,
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('treeitem', { selected: false }).click()
|
||||
cy.findByRole('treeitem', { selected: true })
|
||||
})
|
||||
|
||||
it('renders as linked file', function () {
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<FileTreeProvider>
|
||||
<FileTreeDoc name="foo.tex" id="123abc" isLinkedFile />
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('treeitem')
|
||||
cy.get('.linked-file-highlight')
|
||||
})
|
||||
|
||||
it('multi-selects', function () {
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [{ _id: '123abc' }],
|
||||
fileRefs: [],
|
||||
folders: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders rootFolder={rootFolder as any}>
|
||||
<FileTreeProvider>
|
||||
<FileTreeDoc name="foo.tex" id="123abc" />,
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('treeitem').click({ ctrlKey: true, cmdKey: true })
|
||||
cy.findByRole('treeitem', { selected: true })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,197 @@
|
||||
import FileTreeFolderList from '../../../../../frontend/js/features/file-tree/components/file-tree-folder-list'
|
||||
import { EditorProviders } from '../../../helpers/editor-providers'
|
||||
import { FileTreeProvider } from '../helpers/file-tree-provider'
|
||||
|
||||
describe('<FileTreeFolderList/>', function () {
|
||||
it('renders empty', function () {
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<FileTreeProvider>
|
||||
<FileTreeFolderList folders={[]} docs={[]} files={[]} />
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('tree')
|
||||
cy.findByRole('treeitem').should('not.exist')
|
||||
})
|
||||
|
||||
it('renders docs, files and folders', function () {
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<FileTreeProvider>
|
||||
<FileTreeFolderList
|
||||
folders={[
|
||||
{
|
||||
_id: '456def',
|
||||
name: 'A Folder',
|
||||
folders: [],
|
||||
docs: [],
|
||||
fileRefs: [],
|
||||
},
|
||||
]}
|
||||
docs={[{ _id: '789ghi', name: 'doc.tex' }]}
|
||||
files={[
|
||||
{
|
||||
_id: '987jkl',
|
||||
name: 'file.bib',
|
||||
hash: 'some hash',
|
||||
linkedFileData: {},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('tree')
|
||||
cy.findByRole('treeitem', { name: 'A Folder' })
|
||||
cy.findByRole('treeitem', { name: 'doc.tex' })
|
||||
cy.findByRole('treeitem', { name: 'file.bib' })
|
||||
})
|
||||
|
||||
describe('selection and multi-selection', function () {
|
||||
it('without write permissions', function () {
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [{ _id: '1' }, { _id: '2' }],
|
||||
fileRefs: [],
|
||||
folders: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders
|
||||
rootFolder={rootFolder as any}
|
||||
permissionsLevel="readOnly"
|
||||
>
|
||||
<FileTreeProvider>
|
||||
<FileTreeFolderList
|
||||
folders={[]}
|
||||
docs={[
|
||||
{ _id: '1', name: '1.tex' },
|
||||
{ _id: '2', name: '2.tex' },
|
||||
]}
|
||||
files={[]}
|
||||
/>
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
// click on item 1: it gets selected
|
||||
cy.findByRole('treeitem', { name: '1.tex' }).click()
|
||||
cy.findByRole('treeitem', { name: '1.tex', selected: true })
|
||||
cy.findByRole('treeitem', { name: '2.tex', selected: false })
|
||||
|
||||
// meta-click on item 2: no changes
|
||||
cy.findByRole('treeitem', { name: '2.tex' }).click({
|
||||
ctrlKey: true,
|
||||
cmdKey: true,
|
||||
})
|
||||
cy.findByRole('treeitem', { name: '1.tex', selected: true })
|
||||
cy.findByRole('treeitem', { name: '2.tex', selected: false })
|
||||
})
|
||||
|
||||
it('with write permissions', function () {
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [{ _id: '1' }, { _id: '2' }, { _id: '3' }],
|
||||
fileRefs: [],
|
||||
folders: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders rootFolder={rootFolder as any}>
|
||||
<FileTreeProvider>
|
||||
<FileTreeFolderList
|
||||
folders={[]}
|
||||
docs={[
|
||||
{ _id: '1', name: '1.tex' },
|
||||
{ _id: '2', name: '2.tex' },
|
||||
{ _id: '3', name: '3.tex' },
|
||||
]}
|
||||
files={[]}
|
||||
/>
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
// click item 1: it gets selected
|
||||
cy.findByRole('treeitem', { name: '1.tex' }).click()
|
||||
cy.findByRole('treeitem', { name: '1.tex', selected: true })
|
||||
cy.findByRole('treeitem', { name: '2.tex', selected: false })
|
||||
cy.findByRole('treeitem', { name: '3.tex', selected: false })
|
||||
|
||||
// click on item 2: it gets selected and item 1 is not selected anymore
|
||||
cy.findByRole('treeitem', { name: '2.tex' }).click()
|
||||
cy.findByRole('treeitem', { name: '1.tex', selected: false })
|
||||
cy.findByRole('treeitem', { name: '2.tex', selected: true })
|
||||
cy.findByRole('treeitem', { name: '3.tex', selected: false })
|
||||
|
||||
// meta-click on item 3: it gets selected and item 2 as well
|
||||
cy.findByRole('treeitem', { name: '3.tex' }).click({
|
||||
ctrlKey: true,
|
||||
cmdKey: true,
|
||||
})
|
||||
cy.findByRole('treeitem', { name: '1.tex', selected: false })
|
||||
cy.findByRole('treeitem', { name: '2.tex', selected: true })
|
||||
cy.findByRole('treeitem', { name: '3.tex', selected: true })
|
||||
|
||||
// meta-click on item 1: add to selection
|
||||
cy.findByRole('treeitem', { name: '1.tex' }).click({
|
||||
ctrlKey: true,
|
||||
cmdKey: true,
|
||||
})
|
||||
cy.findByRole('treeitem', { name: '1.tex', selected: true })
|
||||
cy.findByRole('treeitem', { name: '2.tex', selected: true })
|
||||
cy.findByRole('treeitem', { name: '3.tex', selected: true })
|
||||
|
||||
// meta-click on item 1: remove from selection
|
||||
cy.findByRole('treeitem', { name: '1.tex' }).click({
|
||||
ctrlKey: true,
|
||||
cmdKey: true,
|
||||
})
|
||||
cy.findByRole('treeitem', { name: '1.tex', selected: false })
|
||||
cy.findByRole('treeitem', { name: '2.tex', selected: true })
|
||||
cy.findByRole('treeitem', { name: '3.tex', selected: true })
|
||||
|
||||
// meta-click on item 3: remove from selection
|
||||
cy.findByRole('treeitem', { name: '3.tex' }).click({
|
||||
ctrlKey: true,
|
||||
cmdKey: true,
|
||||
})
|
||||
cy.findByRole('treeitem', { name: '1.tex', selected: false })
|
||||
cy.findByRole('treeitem', { name: '2.tex', selected: true })
|
||||
cy.findByRole('treeitem', { name: '3.tex', selected: false })
|
||||
|
||||
// meta-click on item 2: cannot unselect
|
||||
cy.findByRole('treeitem', { name: '2.tex' }).click({
|
||||
ctrlKey: true,
|
||||
cmdKey: true,
|
||||
})
|
||||
cy.findByRole('treeitem', { name: '1.tex', selected: false })
|
||||
cy.findByRole('treeitem', { name: '2.tex', selected: true })
|
||||
cy.findByRole('treeitem', { name: '3.tex', selected: false })
|
||||
|
||||
// meta-click on item 3: add back to selection
|
||||
cy.findByRole('treeitem', { name: '3.tex' }).click({
|
||||
ctrlKey: true,
|
||||
cmdKey: true,
|
||||
})
|
||||
cy.findByRole('treeitem', { name: '1.tex', selected: false })
|
||||
cy.findByRole('treeitem', { name: '2.tex', selected: true })
|
||||
cy.findByRole('treeitem', { name: '3.tex', selected: true })
|
||||
|
||||
// click on item 3: unselect other items
|
||||
cy.findByRole('treeitem', { name: '3.tex' }).click()
|
||||
cy.findByRole('treeitem', { name: '1.tex', selected: false })
|
||||
cy.findByRole('treeitem', { name: '2.tex', selected: false })
|
||||
cy.findByRole('treeitem', { name: '3.tex', selected: true })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,134 @@
|
||||
import FileTreeFolder from '../../../../../frontend/js/features/file-tree/components/file-tree-folder'
|
||||
import { EditorProviders } from '../../../helpers/editor-providers'
|
||||
import { FileTreeProvider } from '../helpers/file-tree-provider'
|
||||
import { getContainerEl } from 'cypress/react'
|
||||
import ReactDom from 'react-dom'
|
||||
|
||||
describe('<FileTreeFolder/>', function () {
|
||||
it('renders unselected', function () {
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<FileTreeProvider>
|
||||
<FileTreeFolder
|
||||
name="foo"
|
||||
id="123abc"
|
||||
folders={[]}
|
||||
docs={[]}
|
||||
files={[]}
|
||||
/>
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('treeitem', { selected: false })
|
||||
cy.findByRole('tree').should('not.exist')
|
||||
})
|
||||
|
||||
it('renders selected', function () {
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [{ _id: '123abc' }],
|
||||
fileRefs: [],
|
||||
folders: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders rootFolder={rootFolder as any}>
|
||||
<FileTreeProvider>
|
||||
<FileTreeFolder
|
||||
name="foo"
|
||||
id="123abc"
|
||||
folders={[]}
|
||||
docs={[]}
|
||||
files={[]}
|
||||
/>
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('treeitem', { selected: false }).click()
|
||||
cy.findByRole('treeitem', { selected: true })
|
||||
cy.findByRole('tree').should('not.exist')
|
||||
})
|
||||
|
||||
it('expands', function () {
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [{ _id: '123abc' }],
|
||||
fileRefs: [],
|
||||
folders: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders rootFolder={rootFolder as any}>
|
||||
<FileTreeProvider>
|
||||
<FileTreeFolder
|
||||
name="foo"
|
||||
id="123abc"
|
||||
folders={[]}
|
||||
docs={[]}
|
||||
files={[]}
|
||||
/>
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('treeitem')
|
||||
cy.findByRole('button', { name: 'Expand' }).click()
|
||||
cy.findByRole('tree')
|
||||
})
|
||||
|
||||
it('saves the expanded state for the next render', function () {
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [{ _id: '123abc' }],
|
||||
fileRefs: [],
|
||||
folders: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders rootFolder={rootFolder as any}>
|
||||
<FileTreeProvider>
|
||||
<FileTreeFolder
|
||||
name="foo"
|
||||
id="123abc"
|
||||
folders={[]}
|
||||
docs={[]}
|
||||
files={[]}
|
||||
/>
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('tree').should('not.exist')
|
||||
cy.findByRole('button', { name: 'Expand' }).click()
|
||||
cy.findByRole('tree')
|
||||
|
||||
cy.then(() => ReactDom.unmountComponentAtNode(getContainerEl()))
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<FileTreeProvider>
|
||||
<FileTreeFolder
|
||||
name="foo"
|
||||
id="123abc"
|
||||
folders={[]}
|
||||
docs={[]}
|
||||
files={[]}
|
||||
/>
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('tree')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,124 @@
|
||||
import FileTreeitemInner from '../../../../../../frontend/js/features/file-tree/components/file-tree-item/file-tree-item-inner'
|
||||
import FileTreeContextMenu from '../../../../../../frontend/js/features/file-tree/components/file-tree-context-menu'
|
||||
import { EditorProviders } from '../../../../helpers/editor-providers'
|
||||
import { FileTreeProvider } from '../../helpers/file-tree-provider'
|
||||
|
||||
describe('<FileTreeitemInner />', function () {
|
||||
describe('menu', function () {
|
||||
it('does not display if file is not selected', function () {
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<FileTreeProvider>
|
||||
<FileTreeitemInner
|
||||
id="123abc"
|
||||
name="bar.tex"
|
||||
isSelected={false}
|
||||
type="doc"
|
||||
/>
|
||||
,
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('menu', { hidden: true }).should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('context menu', function () {
|
||||
it('does not display without write permissions', function () {
|
||||
cy.mount(
|
||||
<EditorProviders permissionsLevel="readOnly">
|
||||
<FileTreeProvider>
|
||||
<FileTreeitemInner
|
||||
id="123abc"
|
||||
name="bar.tex"
|
||||
isSelected
|
||||
type="doc"
|
||||
/>
|
||||
<FileTreeContextMenu />
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.get('div.entity').trigger('contextmenu')
|
||||
cy.findByRole('menu', { hidden: true }).should('not.exist')
|
||||
})
|
||||
|
||||
it('open / close', function () {
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<FileTreeProvider>
|
||||
<FileTreeitemInner
|
||||
id="123abc"
|
||||
name="bar.tex"
|
||||
isSelected
|
||||
type="doc"
|
||||
/>
|
||||
<FileTreeContextMenu />
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('menu', { hidden: true }).should('not.exist')
|
||||
|
||||
// open the context menu
|
||||
cy.get('div.entity').trigger('contextmenu')
|
||||
cy.findByRole('menu')
|
||||
|
||||
// close the context menu
|
||||
cy.get('div.entity').click()
|
||||
cy.findByRole('menu').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('name', function () {
|
||||
it('renders name', function () {
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<FileTreeProvider>
|
||||
<FileTreeitemInner
|
||||
id="123abc"
|
||||
name="bar.tex"
|
||||
isSelected
|
||||
type="doc"
|
||||
/>
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'bar.tex' })
|
||||
cy.findByRole('textbox').should('not.exist')
|
||||
})
|
||||
|
||||
it('starts rename on menu item click', function () {
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [{ _id: '123abc', name: 'bar.tex' }],
|
||||
folders: [],
|
||||
fileRefs: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders rootDocId="123abc" rootFolder={rootFolder as any}>
|
||||
<FileTreeProvider>
|
||||
<FileTreeitemInner
|
||||
id="123abc"
|
||||
name="bar.tex"
|
||||
isSelected
|
||||
type="doc"
|
||||
/>
|
||||
<FileTreeContextMenu />
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Open bar.tex action menu' }).click()
|
||||
cy.findByRole('menuitem', { name: 'Rename' }).click()
|
||||
cy.findByRole('button', { name: 'bar.tex' }).should('not.exist')
|
||||
cy.findByRole('textbox')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
import FileTreeItemName from '../../../../../../frontend/js/features/file-tree/components/file-tree-item/file-tree-item-name'
|
||||
import { EditorProviders } from '../../../../helpers/editor-providers'
|
||||
import { FileTreeProvider } from '../../helpers/file-tree-provider'
|
||||
|
||||
describe('<FileTreeItemName />', function () {
|
||||
it('renders name as button', function () {
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<FileTreeProvider>
|
||||
<FileTreeItemName
|
||||
name="foo.tex"
|
||||
isSelected
|
||||
setIsDraggable={cy.stub()}
|
||||
/>
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'foo.tex' })
|
||||
cy.findByRole('textbox').should('not.exist')
|
||||
})
|
||||
|
||||
it("doesn't start renaming on unselected component", function () {
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<FileTreeProvider>
|
||||
<FileTreeItemName
|
||||
name="foo.tex"
|
||||
isSelected={false}
|
||||
setIsDraggable={cy.stub()}
|
||||
/>
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button').click()
|
||||
cy.findByRole('button').click()
|
||||
cy.findByRole('button').dblclick()
|
||||
cy.findByRole('textbox').should('not.exist')
|
||||
})
|
||||
|
||||
it('start renaming on double-click', function () {
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<FileTreeProvider>
|
||||
<FileTreeItemName
|
||||
name="foo.tex"
|
||||
isSelected
|
||||
setIsDraggable={cy.stub().as('setIsDraggable')}
|
||||
/>
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button').click()
|
||||
cy.findByRole('button').click()
|
||||
cy.findByRole('button').dblclick()
|
||||
cy.findByRole('textbox')
|
||||
cy.findByRole('button').should('not.exist')
|
||||
cy.get('@setIsDraggable').should('have.been.calledWith', false)
|
||||
})
|
||||
|
||||
it('cannot start renaming in read-only', function () {
|
||||
cy.mount(
|
||||
<EditorProviders permissionsLevel="readOnly">
|
||||
<FileTreeProvider>
|
||||
<FileTreeItemName
|
||||
name="foo.tex"
|
||||
isSelected
|
||||
setIsDraggable={cy.stub()}
|
||||
/>
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button').click()
|
||||
cy.findByRole('button').click()
|
||||
cy.findByRole('button').dblclick()
|
||||
|
||||
cy.findByRole('textbox').should('not.exist')
|
||||
})
|
||||
|
||||
describe('stop renaming', function () {
|
||||
it('on Escape', function () {
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<FileTreeProvider>
|
||||
<FileTreeItemName
|
||||
name="foo.tex"
|
||||
isSelected
|
||||
setIsDraggable={cy.stub().as('setIsDraggable')}
|
||||
/>
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button').click()
|
||||
cy.findByRole('button').click()
|
||||
cy.findByRole('button').dblclick()
|
||||
|
||||
cy.findByRole('textbox').clear()
|
||||
cy.findByRole('textbox').type('bar.tex{esc}')
|
||||
|
||||
cy.findByRole('button', { name: 'foo.tex' })
|
||||
cy.get('@setIsDraggable').should('have.been.calledWith', true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,354 @@
|
||||
import FileTreeRoot from '../../../../../frontend/js/features/file-tree/components/file-tree-root'
|
||||
import { EditorProviders } from '../../../helpers/editor-providers'
|
||||
import { SocketIOMock } from '@/ide/connection/SocketIoShim'
|
||||
|
||||
describe('<FileTreeRoot/>', function () {
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-user', { id: 'user1' })
|
||||
})
|
||||
})
|
||||
|
||||
it('renders', function () {
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [{ _id: '456def', name: 'main.tex' }],
|
||||
folders: [],
|
||||
fileRefs: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders
|
||||
rootFolder={rootFolder as any}
|
||||
projectId="123abc"
|
||||
rootDocId="456def"
|
||||
features={{} as any}
|
||||
permissionsLevel="owner"
|
||||
>
|
||||
<FileTreeRoot
|
||||
refProviders={{}}
|
||||
setRefProviderEnabled={cy.stub()}
|
||||
setStartedFreeTrial={cy.stub()}
|
||||
onSelect={cy.stub()}
|
||||
onInit={cy.stub()}
|
||||
isConnected
|
||||
/>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('tree')
|
||||
cy.findByRole('treeitem')
|
||||
cy.findByRole('treeitem', { name: 'main.tex', selected: true })
|
||||
cy.get('.disconnected-overlay').should('not.exist')
|
||||
})
|
||||
|
||||
it('renders with invalid selected doc in local storage', function () {
|
||||
global.localStorage.setItem(
|
||||
'doc.open_id.123abc',
|
||||
JSON.stringify('not-a-valid-id')
|
||||
)
|
||||
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [{ _id: '456def', name: 'main.tex' }],
|
||||
folders: [],
|
||||
fileRefs: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<div style={{ width: 400 }}>
|
||||
<EditorProviders
|
||||
rootFolder={rootFolder as any}
|
||||
projectId="123abc"
|
||||
rootDocId="456def"
|
||||
features={{} as any}
|
||||
permissionsLevel="owner"
|
||||
>
|
||||
<FileTreeRoot
|
||||
refProviders={{}}
|
||||
setRefProviderEnabled={cy.stub()}
|
||||
setStartedFreeTrial={cy.stub()}
|
||||
onSelect={cy.stub()}
|
||||
onInit={cy.stub()}
|
||||
isConnected
|
||||
/>
|
||||
</EditorProviders>
|
||||
</div>
|
||||
)
|
||||
|
||||
// as a proxy to check that the invalid entity has not been select we start
|
||||
// a delete and ensure the modal is displayed (the cancel button can be
|
||||
// selected) This is needed to make sure the test fail.
|
||||
cy.findByRole('treeitem', { name: 'main.tex' }).click({
|
||||
ctrlKey: true,
|
||||
cmdKey: true,
|
||||
})
|
||||
cy.findByRole('button', { name: 'Open main.tex action menu' }).click()
|
||||
cy.findByRole('menuitem', { name: 'Delete' }).click()
|
||||
cy.findByRole('button', { name: 'Cancel' })
|
||||
})
|
||||
|
||||
it('renders disconnected overlay', function () {
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [{ _id: '456def', name: 'main.tex' }],
|
||||
folders: [],
|
||||
fileRefs: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders
|
||||
rootFolder={rootFolder as any}
|
||||
projectId="123abc"
|
||||
rootDocId="456def"
|
||||
features={{} as any}
|
||||
permissionsLevel="owner"
|
||||
>
|
||||
<FileTreeRoot
|
||||
refProviders={{}}
|
||||
setRefProviderEnabled={cy.stub()}
|
||||
setStartedFreeTrial={cy.stub()}
|
||||
onSelect={cy.stub()}
|
||||
onInit={cy.stub()}
|
||||
isConnected={false}
|
||||
/>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.get('.disconnected-overlay')
|
||||
})
|
||||
|
||||
it('fire onSelect', function () {
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [
|
||||
{ _id: '456def', name: 'main.tex' },
|
||||
{ _id: '789ghi', name: 'other.tex' },
|
||||
],
|
||||
folders: [],
|
||||
fileRefs: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders
|
||||
rootFolder={rootFolder as any}
|
||||
projectId="123abc"
|
||||
rootDocId="456def"
|
||||
features={{} as any}
|
||||
permissionsLevel="readOnly"
|
||||
>
|
||||
<FileTreeRoot
|
||||
refProviders={{}}
|
||||
setRefProviderEnabled={cy.stub()}
|
||||
setStartedFreeTrial={cy.stub()}
|
||||
onSelect={cy.stub().as('onSelect')}
|
||||
onInit={cy.stub()}
|
||||
isConnected
|
||||
/>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.get('@onSelect').should('have.been.calledOnceWith', [
|
||||
Cypress.sinon.match({
|
||||
entity: Cypress.sinon.match({ _id: '456def', name: 'main.tex' }),
|
||||
}),
|
||||
])
|
||||
cy.findByRole('tree')
|
||||
cy.findByRole('treeitem', { name: 'other.tex' }).click()
|
||||
cy.get('@onSelect').should('have.been.calledWith', [
|
||||
Cypress.sinon.match({
|
||||
entity: Cypress.sinon.match({ _id: '789ghi', name: 'other.tex' }),
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('only shows a menu button when a single item is selected', function () {
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [
|
||||
{ _id: '456def', name: 'main.tex' },
|
||||
{ _id: '789ghi', name: 'other.tex' },
|
||||
],
|
||||
folders: [],
|
||||
fileRefs: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders
|
||||
rootFolder={rootFolder as any}
|
||||
projectId="123abc"
|
||||
rootDocId="456def"
|
||||
features={{} as any}
|
||||
permissionsLevel="owner"
|
||||
>
|
||||
<FileTreeRoot
|
||||
refProviders={{}}
|
||||
setRefProviderEnabled={cy.stub()}
|
||||
setStartedFreeTrial={cy.stub()}
|
||||
onSelect={cy.stub()}
|
||||
onInit={cy.stub()}
|
||||
isConnected
|
||||
/>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('treeitem', { name: 'main.tex', selected: true })
|
||||
cy.findByRole('treeitem', { name: 'other.tex', selected: false })
|
||||
|
||||
// single item selected: menu button is visible
|
||||
cy.findAllByRole('button', { name: 'Open main.tex action menu' }).should(
|
||||
'have.length',
|
||||
1
|
||||
)
|
||||
|
||||
// select the other item
|
||||
cy.findByRole('treeitem', { name: 'other.tex' }).click()
|
||||
|
||||
cy.findByRole('treeitem', { name: 'main.tex', selected: false })
|
||||
cy.findByRole('treeitem', { name: 'other.tex', selected: true })
|
||||
|
||||
// single item selected: menu button is visible
|
||||
cy.findAllByRole('button', { name: 'Open other.tex action menu' }).should(
|
||||
'have.length',
|
||||
1
|
||||
)
|
||||
|
||||
// multi-select the main item
|
||||
cy.findByRole('treeitem', { name: 'main.tex' }).click({
|
||||
ctrlKey: true,
|
||||
cmdKey: true,
|
||||
})
|
||||
|
||||
cy.findByRole('treeitem', { name: 'main.tex', selected: true })
|
||||
cy.findByRole('treeitem', { name: 'other.tex', selected: true })
|
||||
|
||||
// multiple items selected: no menu button is visible
|
||||
cy.findAllByRole('button', { name: 'Open main.tex action menu' }).should(
|
||||
'have.length',
|
||||
0
|
||||
)
|
||||
})
|
||||
|
||||
describe('when deselecting files', function () {
|
||||
let socket: SocketIOMock
|
||||
beforeEach(function () {
|
||||
socket = new SocketIOMock()
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [{ _id: '123abc', name: 'main.tex' }],
|
||||
folders: [
|
||||
{
|
||||
_id: '789ghi',
|
||||
name: 'thefolder',
|
||||
docs: [{ _id: '456def', name: 'sub.tex' }],
|
||||
fileRefs: [],
|
||||
folders: [],
|
||||
},
|
||||
],
|
||||
fileRefs: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders
|
||||
rootFolder={rootFolder as any}
|
||||
projectId="123abc"
|
||||
rootDocId="456def"
|
||||
features={{} as any}
|
||||
permissionsLevel="owner"
|
||||
socket={socket}
|
||||
>
|
||||
<FileTreeRoot
|
||||
refProviders={{}}
|
||||
setRefProviderEnabled={cy.stub()}
|
||||
setStartedFreeTrial={cy.stub()}
|
||||
onSelect={cy.stub()}
|
||||
onInit={cy.stub()}
|
||||
isConnected
|
||||
/>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
// select the sub file
|
||||
cy.findByRole('treeitem', { name: 'sub.tex' }).click()
|
||||
cy.findByRole('treeitem', { name: 'sub.tex' }).should(
|
||||
'have.attr',
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
|
||||
// click on empty area (after giving it extra height below the tree)
|
||||
cy.findByTestId('file-tree-inner')
|
||||
.invoke('attr', 'style', 'height: 400px')
|
||||
.click()
|
||||
})
|
||||
|
||||
it('removes the selected indicator', function () {
|
||||
cy.findByRole('treeitem', { selected: true }).should('not.exist')
|
||||
})
|
||||
|
||||
it('disables the "rename" and "delete" buttons', function () {
|
||||
cy.findByRole('button', { name: 'Rename' }).should('not.exist')
|
||||
cy.findByRole('button', { name: 'Delete' }).should('not.exist')
|
||||
})
|
||||
|
||||
it('creates new file in the root folder', function () {
|
||||
cy.intercept('project/*/doc', { statusCode: 200 })
|
||||
|
||||
cy.findByRole('button', { name: /new file/i }).click()
|
||||
cy.findByRole('button', { name: /create/i }).click()
|
||||
|
||||
cy.then(() => {
|
||||
socket.emitToClient('reciveNewDoc', 'root-folder-id', {
|
||||
_id: '12345',
|
||||
name: 'abcdef.tex',
|
||||
docs: [],
|
||||
fileRefs: [],
|
||||
folders: [],
|
||||
})
|
||||
})
|
||||
|
||||
cy.findByRole('treeitem', { name: 'abcdef.tex' }).then($itemEl => {
|
||||
cy.findByTestId('file-tree-list-root').then($rootEl => {
|
||||
expect($itemEl.get(0).parentNode?.parentNode).to.equal($rootEl.get(0))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('starts a new selection', function () {
|
||||
cy.findByRole('treeitem', { name: 'sub.tex' }).should(
|
||||
'have.attr',
|
||||
'aria-selected',
|
||||
'false'
|
||||
)
|
||||
|
||||
cy.findByRole('treeitem', { name: 'main.tex' }).click({
|
||||
ctrlKey: true,
|
||||
cmdKey: true,
|
||||
})
|
||||
|
||||
cy.findByRole('treeitem', { name: 'main.tex' }).should(
|
||||
'have.attr',
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import FileTreeToolbar from '../../../../../frontend/js/features/file-tree/components/file-tree-toolbar'
|
||||
import { EditorProviders } from '../../../helpers/editor-providers'
|
||||
import { FileTreeProvider } from '../helpers/file-tree-provider'
|
||||
|
||||
describe('<FileTreeToolbar/>', function () {
|
||||
it('without selected files', function () {
|
||||
cy.mount(
|
||||
<EditorProviders rootDocId="">
|
||||
<FileTreeProvider>
|
||||
<FileTreeToolbar />
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findAllByRole('button', { name: 'New file' })
|
||||
cy.findAllByRole('button', { name: 'New folder' })
|
||||
cy.findAllByRole('button', { name: 'Upload' })
|
||||
cy.findAllByRole('button', { name: 'Rename' }).should('not.exist')
|
||||
cy.findAllByRole('button', { name: 'Delete' }).should('not.exist')
|
||||
})
|
||||
|
||||
it('read-only', function () {
|
||||
cy.mount(
|
||||
<EditorProviders permissionsLevel="readOnly">
|
||||
<FileTreeProvider>
|
||||
<FileTreeToolbar />
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findAllByRole('button').should('not.exist')
|
||||
})
|
||||
|
||||
it('with one selected file', function () {
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [{ _id: '456def', name: 'main.tex' }],
|
||||
folders: [],
|
||||
fileRefs: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders rootDocId="456def" rootFolder={rootFolder as any}>
|
||||
<FileTreeProvider>
|
||||
<FileTreeToolbar />
|
||||
</FileTreeProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findAllByRole('button', { name: 'New file' })
|
||||
cy.findAllByRole('button', { name: 'New folder' })
|
||||
cy.findAllByRole('button', { name: 'Upload' })
|
||||
cy.findAllByRole('button', { name: 'Rename' })
|
||||
cy.findAllByRole('button', { name: 'Delete' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
import FileTreeRoot from '../../../../../frontend/js/features/file-tree/components/file-tree-root'
|
||||
import { EditorProviders } from '../../../helpers/editor-providers'
|
||||
|
||||
describe('FileTree Context Menu Flow', function () {
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-user', { id: 'user1' })
|
||||
})
|
||||
})
|
||||
|
||||
it('opens on contextMenu event', function () {
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [{ _id: '456def', name: 'main.tex' }],
|
||||
folders: [],
|
||||
fileRefs: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders
|
||||
rootFolder={rootFolder as any}
|
||||
projectId="123abc"
|
||||
rootDocId="456def"
|
||||
>
|
||||
<FileTreeRoot
|
||||
refProviders={{}}
|
||||
setRefProviderEnabled={cy.stub()}
|
||||
setStartedFreeTrial={cy.stub()}
|
||||
onSelect={cy.stub()}
|
||||
onInit={cy.stub()}
|
||||
isConnected
|
||||
/>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('menu').should('not.exist')
|
||||
cy.findByRole('button', { name: 'main.tex' }).trigger('contextmenu')
|
||||
cy.findByRole('menu')
|
||||
})
|
||||
|
||||
it('closes when a new selection is started', function () {
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [
|
||||
{ _id: '456def', name: 'main.tex' },
|
||||
{ _id: '456def', name: 'foo.tex' },
|
||||
],
|
||||
folders: [],
|
||||
fileRefs: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders
|
||||
rootFolder={rootFolder as any}
|
||||
projectId="123abc"
|
||||
rootDocId="456def"
|
||||
>
|
||||
<FileTreeRoot
|
||||
refProviders={{}}
|
||||
setRefProviderEnabled={cy.stub()}
|
||||
setStartedFreeTrial={cy.stub()}
|
||||
onSelect={cy.stub()}
|
||||
onInit={cy.stub()}
|
||||
isConnected
|
||||
/>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('menu').should('not.exist')
|
||||
cy.findByRole('button', { name: 'main.tex' }).trigger('contextmenu')
|
||||
cy.findByRole('menu')
|
||||
cy.findAllByRole('button', { name: 'foo.tex' }).click()
|
||||
cy.findByRole('menu').should('not.exist')
|
||||
})
|
||||
|
||||
it("doesn't open in read only mode", function () {
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [{ _id: '456def', name: 'main.tex' }],
|
||||
folders: [],
|
||||
fileRefs: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders
|
||||
rootFolder={rootFolder as any}
|
||||
projectId="123abc"
|
||||
rootDocId="456def"
|
||||
permissionsLevel="readOnly"
|
||||
>
|
||||
<FileTreeRoot
|
||||
refProviders={{}}
|
||||
setRefProviderEnabled={cy.stub()}
|
||||
setStartedFreeTrial={cy.stub()}
|
||||
onSelect={cy.stub()}
|
||||
onInit={cy.stub()}
|
||||
isConnected
|
||||
/>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findAllByRole('button', { name: 'main.tex' }).trigger('contextmenu')
|
||||
cy.findByRole('menu').should('not.exist')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,281 @@
|
||||
import FileTreeRoot from '../../../../../frontend/js/features/file-tree/components/file-tree-root'
|
||||
import { EditorProviders } from '../../../helpers/editor-providers'
|
||||
import { SocketIOMock } from '@/ide/connection/SocketIoShim'
|
||||
|
||||
describe('FileTree Create Folder Flow', function () {
|
||||
let socket: SocketIOMock
|
||||
beforeEach(function () {
|
||||
socket = new SocketIOMock()
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-user', { id: 'user1' })
|
||||
})
|
||||
})
|
||||
|
||||
it('add to root when no files are selected', function () {
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [{ _id: '456def', name: 'main.tex' }],
|
||||
folders: [],
|
||||
fileRefs: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders
|
||||
rootFolder={rootFolder as any}
|
||||
projectId="123abc"
|
||||
socket={socket}
|
||||
>
|
||||
<FileTreeRoot
|
||||
refProviders={{}}
|
||||
setRefProviderEnabled={cy.stub()}
|
||||
setStartedFreeTrial={cy.stub()}
|
||||
onSelect={cy.stub()}
|
||||
onInit={cy.stub()}
|
||||
isConnected
|
||||
/>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
const name = 'Foo Bar In Root'
|
||||
|
||||
cy.intercept('post', '/project/*/folder', {
|
||||
body: {
|
||||
folders: [],
|
||||
fileRefs: [],
|
||||
docs: [],
|
||||
_id: fakeId(),
|
||||
name,
|
||||
},
|
||||
}).as('createFolder')
|
||||
|
||||
createFolder(name)
|
||||
|
||||
cy.get('@createFolder').its('request.body').should('deep.equal', {
|
||||
parent_folder_id: 'root-folder-id',
|
||||
name,
|
||||
})
|
||||
|
||||
cy.then(() => {
|
||||
socket.emitToClient('reciveNewFolder', 'root-folder-id', {
|
||||
_id: fakeId(),
|
||||
name,
|
||||
docs: [],
|
||||
fileRefs: [],
|
||||
folders: [],
|
||||
})
|
||||
})
|
||||
|
||||
cy.findByRole('treeitem', { name })
|
||||
})
|
||||
|
||||
it('add to folder from folder', function () {
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [],
|
||||
folders: [
|
||||
{
|
||||
_id: '789ghi',
|
||||
name: 'thefolder',
|
||||
docs: [],
|
||||
fileRefs: [],
|
||||
folders: [],
|
||||
},
|
||||
],
|
||||
fileRefs: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders
|
||||
rootFolder={rootFolder as any}
|
||||
projectId="123abc"
|
||||
rootDocId="789ghi"
|
||||
socket={socket}
|
||||
>
|
||||
<FileTreeRoot
|
||||
refProviders={{}}
|
||||
setRefProviderEnabled={cy.stub()}
|
||||
setStartedFreeTrial={cy.stub()}
|
||||
onSelect={cy.stub()}
|
||||
onInit={cy.stub()}
|
||||
isConnected
|
||||
/>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Expand' }).click()
|
||||
|
||||
const name = 'Foo Bar In thefolder'
|
||||
|
||||
cy.intercept('post', '/project/*/folder', {
|
||||
body: {
|
||||
folders: [],
|
||||
fileRefs: [],
|
||||
docs: [],
|
||||
_id: fakeId(),
|
||||
name,
|
||||
},
|
||||
}).as('createFolder')
|
||||
|
||||
createFolder(name)
|
||||
|
||||
cy.get('@createFolder').its('request.body').should('deep.equal', {
|
||||
parent_folder_id: '789ghi',
|
||||
name,
|
||||
})
|
||||
|
||||
cy.then(() => {
|
||||
socket.emitToClient('reciveNewFolder', '789ghi', {
|
||||
_id: fakeId(),
|
||||
name,
|
||||
docs: [],
|
||||
fileRefs: [],
|
||||
folders: [],
|
||||
})
|
||||
})
|
||||
|
||||
// find the created folder
|
||||
cy.findByRole('treeitem', { name })
|
||||
|
||||
// collapse the parent folder; created folder should not be rendered anymore
|
||||
cy.findByRole('button', { name: 'Collapse' }).click()
|
||||
cy.findByRole('treeitem', { name }).should('not.exist')
|
||||
})
|
||||
|
||||
it('add to folder from child', function () {
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [],
|
||||
folders: [
|
||||
{
|
||||
_id: '789ghi',
|
||||
name: 'thefolder',
|
||||
docs: [],
|
||||
fileRefs: [{ _id: '456def', name: 'sub.tex' }],
|
||||
folders: [],
|
||||
},
|
||||
],
|
||||
fileRefs: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders
|
||||
rootFolder={rootFolder as any}
|
||||
projectId="123abc"
|
||||
rootDocId="456def"
|
||||
socket={socket}
|
||||
>
|
||||
<FileTreeRoot
|
||||
refProviders={{}}
|
||||
setRefProviderEnabled={cy.stub()}
|
||||
setStartedFreeTrial={cy.stub()}
|
||||
onSelect={cy.stub()}
|
||||
onInit={cy.stub()}
|
||||
isConnected
|
||||
/>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
const name = 'Foo Bar In thefolder'
|
||||
|
||||
cy.intercept('post', '/project/*/folder', {
|
||||
body: {
|
||||
folders: [],
|
||||
fileRefs: [],
|
||||
docs: [],
|
||||
_id: fakeId(),
|
||||
name,
|
||||
},
|
||||
}).as('createFolder')
|
||||
|
||||
createFolder(name)
|
||||
|
||||
cy.get('@createFolder').its('request.body').should('deep.equal', {
|
||||
parent_folder_id: '789ghi',
|
||||
name,
|
||||
})
|
||||
|
||||
cy.then(() => {
|
||||
socket.emitToClient('reciveNewFolder', '789ghi', {
|
||||
_id: fakeId(),
|
||||
name,
|
||||
docs: [],
|
||||
fileRefs: [],
|
||||
folders: [],
|
||||
})
|
||||
})
|
||||
|
||||
// find the created folder
|
||||
cy.findByRole('treeitem', { name })
|
||||
|
||||
// collapse the parent folder; created folder should not be rendered anymore
|
||||
cy.findByRole('button', { name: 'Collapse' }).click()
|
||||
cy.findByRole('treeitem', { name }).should('not.exist')
|
||||
})
|
||||
|
||||
it('prevents adding duplicate or invalid names', function () {
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [{ _id: '456def', name: 'existingFile' }],
|
||||
folders: [],
|
||||
fileRefs: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders
|
||||
rootFolder={rootFolder as any}
|
||||
projectId="123abc"
|
||||
rootDocId="456def"
|
||||
socket={socket}
|
||||
>
|
||||
<FileTreeRoot
|
||||
refProviders={{}}
|
||||
setRefProviderEnabled={cy.stub()}
|
||||
setStartedFreeTrial={cy.stub()}
|
||||
onSelect={cy.stub()}
|
||||
onInit={cy.stub()}
|
||||
isConnected
|
||||
/>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
const name = 'existingFile'
|
||||
|
||||
cy.intercept('post', '/project/*/folder', cy.spy().as('createFolder'))
|
||||
|
||||
createFolder(name)
|
||||
|
||||
cy.get('@createFolder').should('not.have.been.called')
|
||||
|
||||
cy.findByRole('alert', {
|
||||
name: 'A file or folder with this name already exists',
|
||||
})
|
||||
|
||||
cy.findByRole('textbox').type('in/valid ')
|
||||
|
||||
cy.findByRole('alert', {
|
||||
name: 'File name is empty or contains invalid characters',
|
||||
})
|
||||
})
|
||||
|
||||
function createFolder(name: string) {
|
||||
cy.findByRole('button', { name: 'New folder' }).click()
|
||||
cy.findByRole('textbox').type(name)
|
||||
cy.findByRole('button', { name: 'Create' }).click()
|
||||
}
|
||||
|
||||
function fakeId() {
|
||||
return Math.random().toString(16).replace(/0\./, 'random-test-id-')
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,291 @@
|
||||
import FileTreeRoot from '../../../../../frontend/js/features/file-tree/components/file-tree-root'
|
||||
import { EditorProviders } from '../../../helpers/editor-providers'
|
||||
import { SocketIOMock } from '@/ide/connection/SocketIoShim'
|
||||
|
||||
describe('FileTree Delete Entity Flow', function () {
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-user', { id: 'user1' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('single entity', function () {
|
||||
let socket: SocketIOMock
|
||||
beforeEach(function () {
|
||||
socket = new SocketIOMock()
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [
|
||||
{ _id: '123abc', name: 'foo.tex' },
|
||||
{ _id: '456def', name: 'main.tex' },
|
||||
],
|
||||
folders: [],
|
||||
fileRefs: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<div style={{ width: 400 }}>
|
||||
<EditorProviders
|
||||
rootFolder={rootFolder as any}
|
||||
projectId="123abc"
|
||||
socket={socket}
|
||||
>
|
||||
<FileTreeRoot
|
||||
refProviders={{}}
|
||||
setRefProviderEnabled={cy.stub()}
|
||||
setStartedFreeTrial={cy.stub()}
|
||||
onSelect={cy.stub()}
|
||||
onInit={cy.stub()}
|
||||
isConnected
|
||||
/>
|
||||
</EditorProviders>
|
||||
</div>
|
||||
)
|
||||
|
||||
cy.findByRole('treeitem', { name: 'main.tex' }).click()
|
||||
cy.findByRole('button', { name: 'Open main.tex action menu' }).click()
|
||||
cy.findByRole('menuitem', { name: 'Delete' }).click()
|
||||
})
|
||||
|
||||
it('removes item', function () {
|
||||
cy.intercept('delete', '/project/*/doc/*', { statusCode: 204 }).as(
|
||||
'deleteDoc'
|
||||
)
|
||||
|
||||
cy.findByRole('dialog').within(() => {
|
||||
// check that the confirmation modal is open
|
||||
cy.findByText(
|
||||
'Are you sure you want to permanently delete the following files?'
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Delete' }).click()
|
||||
})
|
||||
|
||||
cy.wait('@deleteDoc')
|
||||
|
||||
cy.then(() => {
|
||||
socket.emitToClient('removeEntity', '456def')
|
||||
})
|
||||
|
||||
cy.findByRole('treeitem', {
|
||||
name: 'main.tex',
|
||||
hidden: true, // treeitem might be hidden behind the modal
|
||||
}).should('not.exist')
|
||||
|
||||
cy.findByRole('treeitem', {
|
||||
name: 'main.tex',
|
||||
}).should('not.exist')
|
||||
|
||||
// check that the confirmation modal is closed
|
||||
cy.findByText(
|
||||
'Are you sure you want to permanently delete the following files?'
|
||||
).should('not.exist')
|
||||
|
||||
cy.get('@deleteDoc.all').should('have.length', 1)
|
||||
})
|
||||
|
||||
it('continues delete on 404s', function () {
|
||||
cy.intercept('delete', '/project/*/doc/*', { statusCode: 404 }).as(
|
||||
'deleteDoc'
|
||||
)
|
||||
|
||||
cy.findByRole('dialog').within(() => {
|
||||
// check that the confirmation modal is open
|
||||
cy.findByText(
|
||||
'Are you sure you want to permanently delete the following files?'
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Delete' }).click()
|
||||
})
|
||||
|
||||
cy.then(() => {
|
||||
socket.emitToClient('removeEntity', '456def')
|
||||
})
|
||||
|
||||
cy.findByRole('treeitem', {
|
||||
name: 'main.tex',
|
||||
hidden: true, // treeitem might be hidden behind the modal
|
||||
}).should('not.exist')
|
||||
|
||||
cy.findByRole('treeitem', {
|
||||
name: 'main.tex',
|
||||
}).should('not.exist')
|
||||
|
||||
// check that the confirmation modal is closed
|
||||
// is not, the 404 probably triggered a bug
|
||||
cy.findByText(
|
||||
'Are you sure you want to permanently delete the following files?'
|
||||
).should('not.exist')
|
||||
})
|
||||
|
||||
it('aborts delete on error', function () {
|
||||
cy.intercept('delete', '/project/*/doc/*', { statusCode: 500 }).as(
|
||||
'deleteDoc'
|
||||
)
|
||||
|
||||
cy.findByRole('dialog').within(() => {
|
||||
cy.findByRole('button', { name: 'Delete' }).click()
|
||||
})
|
||||
|
||||
// The modal should still be open, but the file should not be deleted
|
||||
cy.findByRole('treeitem', { name: 'main.tex', hidden: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('folders', function () {
|
||||
let socket: SocketIOMock
|
||||
beforeEach(function () {
|
||||
socket = new SocketIOMock()
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [{ _id: '456def', name: 'main.tex' }],
|
||||
folders: [
|
||||
{
|
||||
_id: '123abc',
|
||||
name: 'folder',
|
||||
docs: [],
|
||||
folders: [],
|
||||
fileRefs: [{ _id: '789ghi', name: 'my.bib' }],
|
||||
},
|
||||
],
|
||||
fileRefs: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<div style={{ width: 400 }}>
|
||||
<EditorProviders
|
||||
rootFolder={rootFolder as any}
|
||||
projectId="123abc"
|
||||
socket={socket}
|
||||
>
|
||||
<FileTreeRoot
|
||||
refProviders={{}}
|
||||
setRefProviderEnabled={cy.stub()}
|
||||
setStartedFreeTrial={cy.stub()}
|
||||
onSelect={cy.stub()}
|
||||
onInit={cy.stub()}
|
||||
isConnected
|
||||
/>
|
||||
</EditorProviders>
|
||||
</div>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Expand' }).click()
|
||||
cy.findByRole('treeitem', { name: 'main.tex' }).click()
|
||||
cy.findByRole('treeitem', { name: 'my.bib' }).click({
|
||||
ctrlKey: true,
|
||||
cmdKey: true,
|
||||
})
|
||||
|
||||
cy.then(() => {
|
||||
socket.emitToClient('removeEntity', '123abc')
|
||||
})
|
||||
})
|
||||
|
||||
it('removes the folder', function () {
|
||||
cy.findByRole('treeitem', { name: 'folder' }).should('not.exist')
|
||||
})
|
||||
|
||||
it('leaves the main file selected', function () {
|
||||
cy.findByRole('treeitem', { name: 'main.tex', selected: true })
|
||||
})
|
||||
|
||||
it('unselect the child entity', function () {
|
||||
// as a proxy to check that the child entity has been unselect we start
|
||||
// a delete and ensure the modal is displayed (the cancel button can be
|
||||
// selected) This is needed to make sure the test fail.
|
||||
cy.findByRole('button', { name: 'Open main.tex action menu' }).click()
|
||||
cy.findByRole('menuitem', { name: 'Delete' }).click()
|
||||
cy.findByRole('button', { name: 'Cancel' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('multiple entities', function () {
|
||||
let socket: SocketIOMock
|
||||
beforeEach(function () {
|
||||
socket = new SocketIOMock()
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [{ _id: '456def', name: 'main.tex' }],
|
||||
folders: [],
|
||||
fileRefs: [{ _id: '789ghi', name: 'my.bib' }],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<div style={{ width: 400 }}>
|
||||
<EditorProviders
|
||||
rootFolder={rootFolder as any}
|
||||
projectId="123abc"
|
||||
socket={socket}
|
||||
>
|
||||
<FileTreeRoot
|
||||
refProviders={{}}
|
||||
setRefProviderEnabled={cy.stub()}
|
||||
setStartedFreeTrial={cy.stub()}
|
||||
onSelect={cy.stub()}
|
||||
onInit={cy.stub()}
|
||||
isConnected
|
||||
/>
|
||||
</EditorProviders>
|
||||
</div>
|
||||
)
|
||||
|
||||
// select two files
|
||||
cy.findByRole('treeitem', { name: 'main.tex' }).click()
|
||||
cy.findByRole('treeitem', { name: 'my.bib' }).click({
|
||||
ctrlKey: true,
|
||||
cmdKey: true,
|
||||
})
|
||||
|
||||
// open the context menu
|
||||
cy.findByRole('button', { name: 'my.bib' }).trigger('contextmenu')
|
||||
|
||||
// make sure the menu has opened, with only a "Delete" item (as multiple files are selected)
|
||||
cy.findByRole('menu')
|
||||
cy.findAllByRole('menuitem').should('have.length', 1)
|
||||
|
||||
// select the Delete menu item
|
||||
cy.findByRole('menuitem', { name: 'Delete' }).click()
|
||||
})
|
||||
|
||||
it('removes all items and reindexes references after deleting .bib file', function () {
|
||||
cy.intercept('delete', '/project/123abc/doc/456def', {
|
||||
statusCode: 204,
|
||||
}).as('deleteDoc')
|
||||
|
||||
cy.intercept('delete', '/project/123abc/file/789ghi', {
|
||||
statusCode: 204,
|
||||
}).as('deleteFile')
|
||||
|
||||
cy.findByRole('dialog').within(() => {
|
||||
cy.findByRole('button', { name: 'Delete' }).click()
|
||||
})
|
||||
|
||||
cy.then(() => {
|
||||
socket.emitToClient('removeEntity', '456def')
|
||||
socket.emitToClient('removeEntity', '789ghi')
|
||||
})
|
||||
|
||||
for (const name of ['main.tex', 'my.bib']) {
|
||||
for (const hidden of [true, false]) {
|
||||
cy.findByRole('treeitem', { name, hidden }).should('not.exist')
|
||||
}
|
||||
}
|
||||
|
||||
// check that the confirmation modal is closed
|
||||
cy.findByText('Are you sure').should('not.exist')
|
||||
|
||||
cy.get('@deleteDoc.all').should('have.length', 1)
|
||||
cy.get('@deleteFile.all').should('have.length', 1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,164 @@
|
||||
import FileTreeRoot from '../../../../../frontend/js/features/file-tree/components/file-tree-root'
|
||||
import { EditorProviders } from '../../../helpers/editor-providers'
|
||||
import { SocketIOMock } from '@/ide/connection/SocketIoShim'
|
||||
|
||||
describe('FileTree Rename Entity Flow', function () {
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-user', { id: 'user1' })
|
||||
})
|
||||
})
|
||||
|
||||
let socket: SocketIOMock
|
||||
beforeEach(function () {
|
||||
socket = new SocketIOMock()
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [{ _id: '456def', name: 'a.tex' }],
|
||||
folders: [
|
||||
{
|
||||
_id: '987jkl',
|
||||
name: 'folder',
|
||||
docs: [],
|
||||
fileRefs: [
|
||||
{ _id: '789ghi', name: 'c.tex' },
|
||||
{ _id: '981gkp', name: 'e.tex' },
|
||||
],
|
||||
folders: [],
|
||||
},
|
||||
],
|
||||
fileRefs: [],
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<div style={{ width: 400 }}>
|
||||
<EditorProviders
|
||||
rootFolder={rootFolder as any}
|
||||
projectId="123abc"
|
||||
socket={socket}
|
||||
>
|
||||
<FileTreeRoot
|
||||
refProviders={{}}
|
||||
setRefProviderEnabled={cy.stub()}
|
||||
setStartedFreeTrial={cy.stub()}
|
||||
onSelect={cy.stub().as('onSelect')}
|
||||
onInit={cy.stub()}
|
||||
isConnected
|
||||
/>
|
||||
</EditorProviders>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
it('renames doc', function () {
|
||||
cy.intercept('/project/*/doc/*/rename', { statusCode: 204 }).as('renameDoc')
|
||||
|
||||
renameItem('a.tex', 'b.tex')
|
||||
|
||||
cy.findByRole('treeitem', { name: 'b.tex' })
|
||||
|
||||
cy.get('@renameDoc').its('request.body').should('deep.equal', {
|
||||
name: 'b.tex',
|
||||
})
|
||||
})
|
||||
|
||||
it('renames folder', function () {
|
||||
cy.intercept('/project/*/folder/*/rename', { statusCode: 204 }).as(
|
||||
'renameFolder'
|
||||
)
|
||||
|
||||
renameItem('folder', 'new folder name')
|
||||
|
||||
cy.findByRole('treeitem', { name: 'new folder name' })
|
||||
|
||||
cy.get('@renameFolder').its('request.body').should('deep.equal', {
|
||||
name: 'new folder name',
|
||||
})
|
||||
})
|
||||
|
||||
it('renames file in subfolder', function () {
|
||||
cy.intercept('/project/*/file/*/rename', { statusCode: 204 }).as(
|
||||
'renameFile'
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Expand' }).click()
|
||||
|
||||
renameItem('c.tex', 'd.tex')
|
||||
|
||||
cy.findByRole('treeitem', { name: 'folder' })
|
||||
cy.findByRole('treeitem', { name: 'd.tex' })
|
||||
|
||||
cy.get('@renameFile').its('request.body').should('deep.equal', {
|
||||
name: 'd.tex',
|
||||
})
|
||||
})
|
||||
|
||||
it('reverts rename on error', function () {
|
||||
cy.intercept('/project/*/doc/*/rename', { statusCode: 500 })
|
||||
|
||||
renameItem('a.tex', 'b.tex')
|
||||
|
||||
cy.findByRole('treeitem', { name: 'a.tex' })
|
||||
})
|
||||
|
||||
it('shows error modal on invalid filename', function () {
|
||||
renameItem('a.tex', '///')
|
||||
|
||||
cy.findByRole('alert', {
|
||||
name: 'File name is empty or contains invalid characters',
|
||||
hidden: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error modal on duplicate filename', function () {
|
||||
renameItem('a.tex', 'folder')
|
||||
|
||||
cy.findByRole('alert', {
|
||||
name: 'A file or folder with this name already exists',
|
||||
hidden: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error modal on duplicate filename in subfolder', function () {
|
||||
cy.findByRole('button', { name: 'Expand' }).click()
|
||||
|
||||
renameItem('c.tex', 'e.tex')
|
||||
|
||||
cy.findByRole('alert', {
|
||||
name: 'A file or folder with this name already exists',
|
||||
hidden: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error modal on blocked filename', function () {
|
||||
renameItem('a.tex', 'prototype')
|
||||
|
||||
cy.findByRole('alert', {
|
||||
name: 'This file name is blocked.',
|
||||
hidden: true,
|
||||
})
|
||||
})
|
||||
|
||||
describe('via socket event', function () {
|
||||
it('renames doc', function () {
|
||||
cy.findByRole('treeitem', { name: 'a.tex' })
|
||||
|
||||
cy.then(() => {
|
||||
socket.emitToClient('reciveEntityRename', '456def', 'socket.tex')
|
||||
})
|
||||
|
||||
cy.findByRole('treeitem', { name: 'socket.tex' })
|
||||
})
|
||||
})
|
||||
|
||||
function renameItem(from: string, to: string) {
|
||||
cy.findByRole('treeitem', { name: from }).click()
|
||||
cy.findByRole('button', { name: `Open ${from} action menu` }).click()
|
||||
cy.findByRole('menuitem', { name: 'Rename' }).click()
|
||||
cy.findByRole('textbox').clear()
|
||||
cy.findByRole('textbox').type(to + '{enter}')
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ComponentProps, FC, useRef, useState } from 'react'
|
||||
import FileTreeContext from '@/features/file-tree/components/file-tree-context'
|
||||
|
||||
export const FileTreeProvider: FC<{
|
||||
refProviders?: Record<string, boolean>
|
||||
}> = ({ children, refProviders = {} }) => {
|
||||
const [fileTreeContainer, setFileTreeContainer] =
|
||||
useState<HTMLDivElement | null>(null)
|
||||
|
||||
const propsRef =
|
||||
useRef<Omit<ComponentProps<typeof FileTreeContext>, 'refProviders'>>()
|
||||
|
||||
if (propsRef.current === undefined) {
|
||||
propsRef.current = {
|
||||
setRefProviderEnabled: cy.stub().as('setRefProviderEnabled'),
|
||||
setStartedFreeTrial: cy.stub().as('setStartedFreeTrial'),
|
||||
onSelect: cy.stub(),
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={setFileTreeContainer}>
|
||||
{fileTreeContainer && (
|
||||
<FileTreeContext
|
||||
refProviders={refProviders}
|
||||
fileTreeContainer={fileTreeContainer}
|
||||
{...propsRef.current}
|
||||
>
|
||||
<>{children}</>
|
||||
</FileTreeContext>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { expect } from 'chai'
|
||||
|
||||
import iconTypeFromName from '../../../../../frontend/js/features/file-tree/util/icon-type-from-name'
|
||||
|
||||
describe('iconTypeFromName', function () {
|
||||
it('returns correct icon type', function () {
|
||||
expect(iconTypeFromName('main.tex')).to.equal('description')
|
||||
expect(iconTypeFromName('main.png')).to.equal('image')
|
||||
expect(iconTypeFromName('main.csv')).to.equal('table_chart')
|
||||
expect(iconTypeFromName('main.py')).to.equal('code')
|
||||
expect(iconTypeFromName('main.bib')).to.equal('menu_book')
|
||||
})
|
||||
|
||||
it('handles missing extensions', function () {
|
||||
expect(iconTypeFromName('main')).to.equal('description')
|
||||
})
|
||||
|
||||
it('lowercases extension', function () {
|
||||
expect(iconTypeFromName('ZOTERO.BIB')).to.equal('menu_book')
|
||||
})
|
||||
})
|
||||
162
services/web/test/frontend/features/file-tree/util/path.test.ts
Normal file
162
services/web/test/frontend/features/file-tree/util/path.test.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { expect } from 'chai'
|
||||
import { Folder } from '../../../../../types/folder'
|
||||
import { docId } from '../../source-editor/helpers/mock-doc'
|
||||
import {
|
||||
findEntityByPath,
|
||||
pathInFolder,
|
||||
previewByPath,
|
||||
} from '@/features/file-tree/util/path'
|
||||
|
||||
describe('Path utils', function () {
|
||||
let rootFolder: Folder
|
||||
|
||||
beforeEach(function () {
|
||||
rootFolder = {
|
||||
_id: 'root-folder-id',
|
||||
name: 'rootFolder',
|
||||
docs: [
|
||||
{
|
||||
_id: docId,
|
||||
name: 'main.tex',
|
||||
},
|
||||
],
|
||||
folders: [
|
||||
{
|
||||
_id: 'test-folder-id',
|
||||
name: 'test-folder',
|
||||
docs: [
|
||||
{
|
||||
_id: 'test-doc-in-folder',
|
||||
name: 'example.tex',
|
||||
},
|
||||
],
|
||||
fileRefs: [
|
||||
{
|
||||
_id: 'test-file-in-folder',
|
||||
name: 'example.png',
|
||||
hash: '42',
|
||||
},
|
||||
],
|
||||
folders: [
|
||||
{
|
||||
_id: 'test-subfolder-id',
|
||||
name: 'test-subfolder',
|
||||
docs: [
|
||||
{
|
||||
_id: 'test-doc-in-subfolder',
|
||||
name: 'nested-example.tex',
|
||||
},
|
||||
],
|
||||
fileRefs: [
|
||||
{
|
||||
_id: 'test-file-in-subfolder',
|
||||
name: 'nested-example.png',
|
||||
hash: '43',
|
||||
},
|
||||
],
|
||||
folders: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
fileRefs: [
|
||||
{
|
||||
_id: 'test-image-file',
|
||||
name: 'frog.jpg',
|
||||
hash: '21',
|
||||
},
|
||||
{
|
||||
_id: 'uppercase-extension-image-file',
|
||||
name: 'frog.JPG',
|
||||
hash: '22',
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
describe('pathInFolder', function () {
|
||||
it('gets null path for non-existent entity', function () {
|
||||
const retrieved = pathInFolder(rootFolder, 'non-existent.tex')
|
||||
expect(retrieved).to.be.null
|
||||
})
|
||||
|
||||
it('gets correct path for document in the root', function () {
|
||||
const retrieved = pathInFolder(rootFolder, docId)
|
||||
expect(retrieved).to.equal('main.tex')
|
||||
})
|
||||
|
||||
it('gets correct path for document in a folder', function () {
|
||||
const retrieved = pathInFolder(rootFolder, 'test-doc-in-folder')
|
||||
expect(retrieved).to.equal('test-folder/example.tex')
|
||||
})
|
||||
|
||||
it('gets correct path for document in a nested folder', function () {
|
||||
const retrieved = pathInFolder(rootFolder, 'test-doc-in-subfolder')
|
||||
expect(retrieved).to.equal(
|
||||
'test-folder/test-subfolder/nested-example.tex'
|
||||
)
|
||||
})
|
||||
|
||||
it('gets correct path for file in a nested folder', function () {
|
||||
const retrieved = pathInFolder(rootFolder, 'test-file-in-subfolder')
|
||||
expect(retrieved).to.equal(
|
||||
'test-folder/test-subfolder/nested-example.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('gets correct path for file in a nested folder relative to folder', function () {
|
||||
const retrieved = pathInFolder(
|
||||
rootFolder.folders[0],
|
||||
'test-file-in-subfolder'
|
||||
)
|
||||
expect(retrieved).to.equal('test-subfolder/nested-example.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('findEntityByPath', function () {
|
||||
it('returns null for a non-existent path', function () {
|
||||
const retrieved = findEntityByPath(rootFolder, 'not-a-real-document.tex')
|
||||
expect(retrieved).to.be.null
|
||||
})
|
||||
|
||||
it('finds a document in the root', function () {
|
||||
const retrieved = findEntityByPath(rootFolder, 'main.tex')
|
||||
expect(retrieved?.entity._id).to.equal(docId)
|
||||
})
|
||||
|
||||
it('finds a document in a folder', function () {
|
||||
const retrieved = findEntityByPath(rootFolder, 'test-folder/example.tex')
|
||||
expect(retrieved?.entity._id).to.equal('test-doc-in-folder')
|
||||
})
|
||||
|
||||
it('finds a document in a nested folder', function () {
|
||||
const retrieved = findEntityByPath(
|
||||
rootFolder,
|
||||
'test-folder/test-subfolder/nested-example.tex'
|
||||
)
|
||||
expect(retrieved?.entity._id).to.equal('test-doc-in-subfolder')
|
||||
})
|
||||
|
||||
it('finds a file in a nested folder', function () {
|
||||
const retrieved = findEntityByPath(
|
||||
rootFolder,
|
||||
'test-folder/test-subfolder/nested-example.png'
|
||||
)
|
||||
expect(retrieved?.entity._id).to.equal('test-file-in-subfolder')
|
||||
})
|
||||
})
|
||||
|
||||
describe('previewByPath', function () {
|
||||
it('returns extension without preceding dot', function () {
|
||||
const preview = previewByPath(
|
||||
rootFolder,
|
||||
'test-project-id',
|
||||
'test-folder/example.png'
|
||||
)
|
||||
expect(preview).to.deep.equal({
|
||||
url: '/project/test-project-id/blob/42?fallback=test-file-in-folder',
|
||||
extension: 'png',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
import { screen } from '@testing-library/react'
|
||||
import fetchMock from 'fetch-mock'
|
||||
|
||||
import { renderWithEditorContext } from '../../../helpers/render-with-context'
|
||||
import FileViewHeader from '../../../../../frontend/js/features/file-view/components/file-view-header'
|
||||
import { USER_ID } from '../../../helpers/editor-providers'
|
||||
import { fileViewFile } from '@/features/ide-react/util/file-view'
|
||||
|
||||
describe('<FileViewHeader/>', function () {
|
||||
const urlFile = {
|
||||
name: 'example.tex',
|
||||
linkedFileData: {
|
||||
url: 'https://overleaf.com',
|
||||
provider: 'url',
|
||||
},
|
||||
created: new Date(2021, 1, 17, 3, 24).toISOString(),
|
||||
}
|
||||
|
||||
const projectFile = {
|
||||
name: 'example.tex',
|
||||
linkedFileData: {
|
||||
v1_source_doc_id: 'v1-source-id',
|
||||
source_project_id: 'source-project-id',
|
||||
source_entity_path: '/source-entity-path.ext',
|
||||
provider: 'project_file',
|
||||
importer_id: USER_ID,
|
||||
},
|
||||
created: new Date(2021, 1, 17, 3, 24).toISOString(),
|
||||
}
|
||||
|
||||
const projectOutputFile = {
|
||||
name: 'example.pdf',
|
||||
linkedFileData: {
|
||||
v1_source_doc_id: 'v1-source-id',
|
||||
source_output_file_path: '/source-entity-path.ext',
|
||||
provider: 'project_output_file',
|
||||
},
|
||||
created: new Date(2021, 1, 17, 3, 24).toISOString(),
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
describe('header text', function () {
|
||||
it('Renders the correct text for a file with the url provider', function () {
|
||||
renderWithEditorContext(<FileViewHeader file={urlFile} />)
|
||||
screen.getByText('Imported from', { exact: false })
|
||||
screen.getByText('at 3:24 am Wed, 17th Feb 21', {
|
||||
exact: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('Renders the correct text for a file with the project_file provider', function () {
|
||||
renderWithEditorContext(<FileViewHeader file={projectFile} />)
|
||||
screen.getByText('Imported from', { exact: false })
|
||||
screen.getByText('Another project', { exact: false })
|
||||
screen.getByText('/source-entity-path.ext, at 3:24 am Wed, 17th Feb 21', {
|
||||
exact: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('Renders the correct text for a file with the project_output_file provider', function () {
|
||||
renderWithEditorContext(
|
||||
<FileViewHeader
|
||||
file={projectOutputFile}
|
||||
storeReferencesKeys={() => {}}
|
||||
/>
|
||||
)
|
||||
screen.getByText('Imported from the output of', { exact: false })
|
||||
screen.getByText('Another project', { exact: false })
|
||||
screen.getByText('/source-entity-path.ext, at 3:24 am Wed, 17th Feb 21', {
|
||||
exact: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('The download button', function () {
|
||||
it('exists', function () {
|
||||
renderWithEditorContext(<FileViewHeader file={urlFile} />)
|
||||
|
||||
screen.getByText('Download')
|
||||
})
|
||||
})
|
||||
|
||||
it('should use importedAt as timestamp when present in the linked file data', function () {
|
||||
const fileFromServer = {
|
||||
name: 'example.tex',
|
||||
linkedFileData: {
|
||||
v1_source_doc_id: 'v1-source-id',
|
||||
source_project_id: 'source-project-id',
|
||||
source_entity_path: '/source-entity-path.ext',
|
||||
provider: 'project_file',
|
||||
importer_id: USER_ID,
|
||||
importedAt: new Date(2024, 8, 16, 1, 30).getTime(),
|
||||
},
|
||||
created: new Date(2021, 1, 17, 3, 24).toISOString(),
|
||||
}
|
||||
// FIXME: This should be tested through the <EditorAndPdf /> component instead
|
||||
const fileShown = fileViewFile(fileFromServer)
|
||||
renderWithEditorContext(<FileViewHeader file={fileShown} />)
|
||||
screen.getByText('Imported from', { exact: false })
|
||||
screen.getByText('Another project', { exact: false })
|
||||
screen.getByText('/source-entity-path.ext, at 1:30 am Mon, 16th Sep 24', {
|
||||
exact: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { screen } from '@testing-library/react'
|
||||
|
||||
import { renderWithEditorContext } from '../../../helpers/render-with-context'
|
||||
import FileViewImage from '../../../../../frontend/js/features/file-view/components/file-view-image'
|
||||
|
||||
describe('<FileViewImage />', function () {
|
||||
const file = {
|
||||
id: '60097ca20454610027c442a8',
|
||||
name: 'file.jpg',
|
||||
hash: 'hash',
|
||||
linkedFileData: {
|
||||
source_entity_path: '/source-entity-path',
|
||||
provider: 'project_file',
|
||||
},
|
||||
}
|
||||
|
||||
it('renders an image', function () {
|
||||
renderWithEditorContext(
|
||||
<FileViewImage file={file} onError={() => {}} onLoad={() => {}} />
|
||||
)
|
||||
screen.getByRole('img')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
screen,
|
||||
fireEvent,
|
||||
waitForElementToBeRemoved,
|
||||
} from '@testing-library/react'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import sinon from 'sinon'
|
||||
import FileViewRefreshButton from '@/features/file-view/components/file-view-refresh-button'
|
||||
import { renderWithEditorContext } from '../../../helpers/render-with-context'
|
||||
import { USER_ID } from '../../../helpers/editor-providers'
|
||||
|
||||
describe('<FileViewRefreshButton />', function () {
|
||||
const projectFile = {
|
||||
name: 'example.tex',
|
||||
linkedFileData: {
|
||||
v1_source_doc_id: 'v1-source-id',
|
||||
source_project_id: 'source-project-id',
|
||||
source_entity_path: '/source-entity-path.ext',
|
||||
provider: 'project_file',
|
||||
importer_id: USER_ID,
|
||||
},
|
||||
created: new Date(2021, 1, 17, 3, 24).toISOString(),
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
// eslint-disable-next-line mocha/no-skipped-tests
|
||||
it.skip('Changes text when the file is refreshing', async function () {
|
||||
fetchMock.post(
|
||||
'express:/project/:project_id/linked_file/:file_id/refresh',
|
||||
{
|
||||
new_file_id: '5ff7418157b4e144321df5c4',
|
||||
}
|
||||
)
|
||||
|
||||
renderWithEditorContext(
|
||||
<FileViewRefreshButton
|
||||
file={projectFile}
|
||||
setRefreshError={sinon.stub()}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Refresh' }))
|
||||
|
||||
await waitForElementToBeRemoved(() =>
|
||||
screen.getByText('Refreshing', { exact: false })
|
||||
)
|
||||
|
||||
await screen.findByRole('button', { name: 'Refresh' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import FileViewRefreshError from '@/features/file-view/components/file-view-refresh-error'
|
||||
import type { BinaryFile } from '@/features/file-view/types/binary-file'
|
||||
|
||||
describe('<FileViewRefreshError />', function () {
|
||||
it('shows correct error message', function () {
|
||||
const anotherProjectFile: BinaryFile<'project_file'> = {
|
||||
id: '123abc',
|
||||
_id: '123abc',
|
||||
linkedFileData: {
|
||||
provider: 'project_file',
|
||||
source_project_id: 'some-id',
|
||||
source_entity_path: '/path/',
|
||||
},
|
||||
created: new Date(2023, 1, 17, 3, 24),
|
||||
name: 'frog.jpg',
|
||||
type: 'file',
|
||||
selected: true,
|
||||
hash: '42',
|
||||
}
|
||||
|
||||
render(
|
||||
<FileViewRefreshError
|
||||
file={anotherProjectFile}
|
||||
refreshError="An error message"
|
||||
/>
|
||||
)
|
||||
|
||||
screen.getByText('Access Denied: An error message')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { screen } from '@testing-library/react'
|
||||
import fetchMock from 'fetch-mock'
|
||||
|
||||
import { renderWithEditorContext } from '../../../helpers/render-with-context'
|
||||
import FileViewText from '../../../../../frontend/js/features/file-view/components/file-view-text'
|
||||
|
||||
describe('<FileViewText/>', function () {
|
||||
const file = {
|
||||
id: '123',
|
||||
hash: '1234',
|
||||
name: 'example.tex',
|
||||
linkedFileData: {
|
||||
v1_source_doc_id: 'v1-source-id',
|
||||
source_project_id: 'source-project-id',
|
||||
source_entity_path: '/source-entity-path.ext',
|
||||
provider: 'project_file',
|
||||
},
|
||||
created: new Date(2021, 1, 17, 3, 24).toISOString(),
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('renders a text view', async function () {
|
||||
fetchMock.head('express:/project/:project_id/blob/:hash', {
|
||||
status: 201,
|
||||
headers: { 'Content-Length': 10000 },
|
||||
})
|
||||
fetchMock.get(
|
||||
'express:/project/:project_id/blob/:hash',
|
||||
'Text file content'
|
||||
)
|
||||
|
||||
renderWithEditorContext(
|
||||
<FileViewText file={file} onError={() => {}} onLoad={() => {}} />
|
||||
)
|
||||
|
||||
await screen.findByText('Text file content', { exact: false })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
screen,
|
||||
waitForElementToBeRemoved,
|
||||
fireEvent,
|
||||
} from '@testing-library/react'
|
||||
import fetchMock from 'fetch-mock'
|
||||
|
||||
import { renderWithEditorContext } from '../../../helpers/render-with-context'
|
||||
import FileView from '../../../../../frontend/js/features/file-view/components/file-view'
|
||||
|
||||
describe('<FileView/>', function () {
|
||||
const textFile = {
|
||||
id: 'text-file',
|
||||
name: 'example.tex',
|
||||
linkedFileData: {
|
||||
v1_source_doc_id: 'v1-source-id',
|
||||
source_project_id: 'source-project-id',
|
||||
source_entity_path: '/source-entity-path.ext',
|
||||
provider: 'project_file',
|
||||
},
|
||||
hash: '012345678901234567890123',
|
||||
created: new Date(2021, 1, 17, 3, 24).toISOString(),
|
||||
}
|
||||
|
||||
const imageFile = {
|
||||
id: '60097ca20454610027c442a8',
|
||||
name: 'file.jpg',
|
||||
linkedFileData: {
|
||||
source_entity_path: '/source-entity-path',
|
||||
provider: 'project_file',
|
||||
},
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
describe('for a text file', function () {
|
||||
it('shows a loading indicator while the file is loading', async function () {
|
||||
fetchMock.head('express:/project/:project_id/blob/:hash', {
|
||||
status: 201,
|
||||
headers: { 'Content-Length': 10000 },
|
||||
})
|
||||
fetchMock.get(
|
||||
'express:/project/:project_id/blob/:hash',
|
||||
'Text file content'
|
||||
)
|
||||
|
||||
renderWithEditorContext(<FileView file={textFile} />)
|
||||
|
||||
await waitForElementToBeRemoved(() =>
|
||||
screen.getByTestId('loading-panel-file-view')
|
||||
)
|
||||
})
|
||||
|
||||
it('shows messaging if the text view could not be loaded', async function () {
|
||||
const unpreviewableTextFile = {
|
||||
...textFile,
|
||||
name: 'example.not-tex',
|
||||
}
|
||||
|
||||
renderWithEditorContext(<FileView file={unpreviewableTextFile} />)
|
||||
|
||||
await screen.findByText('Sorry, no preview is available', {
|
||||
exact: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('for an image file', function () {
|
||||
it('shows a loading indicator while the file is loading', async function () {
|
||||
renderWithEditorContext(<FileView file={imageFile} />)
|
||||
|
||||
screen.getByTestId('loading-panel-file-view')
|
||||
})
|
||||
|
||||
it('shows messaging if the image could not be loaded', async function () {
|
||||
renderWithEditorContext(<FileView file={imageFile} />)
|
||||
|
||||
// Fake the image request failing as the request is handled by the browser
|
||||
fireEvent.error(screen.getByRole('img'))
|
||||
|
||||
await screen.findByText('Sorry, no preview is available', {
|
||||
exact: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
import { EditorProviders } from '../../../helpers/editor-providers'
|
||||
import FullProjectSearch from '../../../../../modules/full-project-search/frontend/js/components/full-project-search'
|
||||
import {
|
||||
LayoutContext,
|
||||
LayoutContextValue,
|
||||
} from '@/shared/context/layout-context'
|
||||
import { FC, useState } from 'react'
|
||||
|
||||
describe('<FullProjectSearch/>', function () {
|
||||
beforeEach(function () {
|
||||
cy.interceptCompile()
|
||||
|
||||
cy.intercept('/project/*/flush', {
|
||||
statusCode: 204,
|
||||
}).as('project-history-flush')
|
||||
|
||||
cy.intercept('/project/*/changes?*', {
|
||||
body: [],
|
||||
}).as('project-history-changes')
|
||||
|
||||
cy.intercept('/project/*/latest/history', {
|
||||
body: { chunk: mockHistoryChunk },
|
||||
}).as('project-history-snapshot')
|
||||
|
||||
cy.intercept('get', '/project/*/blob/*', req => {
|
||||
const blobId = req.url.split('/').pop() as string
|
||||
|
||||
req.reply({
|
||||
fixture: `blobs/${blobId}`,
|
||||
})
|
||||
}).as('project-history-blob')
|
||||
})
|
||||
|
||||
it('displays the search form', function () {
|
||||
cy.mount(
|
||||
<EditorProviders providers={{ LayoutProvider }}>
|
||||
<FullProjectSearch />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Search' })
|
||||
})
|
||||
|
||||
it('displays a close button', function () {
|
||||
cy.mount(
|
||||
<EditorProviders providers={{ LayoutProvider }}>
|
||||
<FullProjectSearch />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Close' })
|
||||
})
|
||||
|
||||
it('displays matched content', function () {
|
||||
cy.mount(
|
||||
<EditorProviders providers={{ LayoutProvider }}>
|
||||
<FullProjectSearch />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('searchbox', { name: 'Search' }).type('and{enter}')
|
||||
|
||||
cy.findByRole('button', { name: 'main.tex 5' }) // TODO: remove count from name?
|
||||
|
||||
cy.get('.matched-file-hit').as('matches')
|
||||
cy.get('@matches').should('have.length', 5)
|
||||
|
||||
cy.get('@matches').first().click()
|
||||
cy.get('@matches').first().should('have.class', 'matched-file-hit-selected')
|
||||
})
|
||||
})
|
||||
|
||||
const createInitialValue = () =>
|
||||
({
|
||||
reattach: cy.stub(),
|
||||
detach: cy.stub(),
|
||||
detachIsLinked: false,
|
||||
detachRole: null,
|
||||
changeLayout: cy.stub(),
|
||||
view: 'editor',
|
||||
setView: cy.stub(),
|
||||
chatIsOpen: false,
|
||||
setChatIsOpen: cy.stub(),
|
||||
reviewPanelOpen: false,
|
||||
setReviewPanelOpen: cy.stub(),
|
||||
miniReviewPanelVisible: false,
|
||||
setMiniReviewPanelVisible: cy.stub(),
|
||||
leftMenuShown: false,
|
||||
setLeftMenuShown: cy.stub(),
|
||||
loadingStyleSheet: false,
|
||||
setLoadingStyleSheet: cy.stub(),
|
||||
pdfLayout: 'flat',
|
||||
pdfPreviewOpen: false,
|
||||
projectSearchIsOpen: true,
|
||||
setProjectSearchIsOpen: cy.stub(),
|
||||
}) satisfies LayoutContextValue
|
||||
|
||||
const LayoutProvider: FC = ({ children }) => {
|
||||
const [value] = useState(createInitialValue)
|
||||
|
||||
return (
|
||||
<LayoutContext.Provider value={value}>{children}</LayoutContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const mockHistoryChunk = {
|
||||
history: {
|
||||
snapshot: {
|
||||
files: {},
|
||||
},
|
||||
changes: [
|
||||
{
|
||||
operations: [
|
||||
{
|
||||
pathname: 'main.tex',
|
||||
file: {
|
||||
hash: '5199b66d9d1226551be436c66bad9d962cc05537',
|
||||
stringLength: 7066,
|
||||
},
|
||||
},
|
||||
],
|
||||
timestamp: '2025-01-03T10:10:40.840Z',
|
||||
authors: [],
|
||||
v2Authors: ['66e040e0da7136ec75ffe8a3'],
|
||||
projectVersion: '1.0',
|
||||
},
|
||||
{
|
||||
operations: [
|
||||
{
|
||||
pathname: 'sample.bib',
|
||||
file: {
|
||||
hash: 'a0e21c740cf81e868f158e30e88985b5ea1d6c19',
|
||||
stringLength: 244,
|
||||
},
|
||||
},
|
||||
],
|
||||
timestamp: '2025-01-03T10:10:40.856Z',
|
||||
authors: [],
|
||||
v2Authors: ['66e040e0da7136ec75ffe8a3'],
|
||||
projectVersion: '2.0',
|
||||
},
|
||||
{
|
||||
operations: [
|
||||
{
|
||||
pathname: 'frog.jpg',
|
||||
file: {
|
||||
hash: '5b889ef3cf71c83a4c027c4e4dc3d1a106b27809',
|
||||
byteLength: 97080,
|
||||
},
|
||||
},
|
||||
],
|
||||
timestamp: '2025-01-03T10:10:40.890Z',
|
||||
authors: [],
|
||||
v2Authors: ['66e040e0da7136ec75ffe8a3'],
|
||||
projectVersion: '3.0',
|
||||
},
|
||||
],
|
||||
},
|
||||
startVersion: 0,
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import AddSeats, {
|
||||
MAX_NUMBER_OF_USERS,
|
||||
} from '@/features/group-management/components/add-seats/add-seats'
|
||||
|
||||
describe('<AddSeats />', function () {
|
||||
beforeEach(function () {
|
||||
this.totalLicenses = 5
|
||||
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
|
||||
win.metaAttributesCache.set('ol-subscriptionId', '123')
|
||||
win.metaAttributesCache.set('ol-totalLicenses', this.totalLicenses)
|
||||
win.metaAttributesCache.set('ol-isProfessional', false)
|
||||
})
|
||||
|
||||
cy.mount(<AddSeats />)
|
||||
|
||||
cy.findByRole('button', { name: /buy licenses/i })
|
||||
cy.findByTestId('add-more-users-group-form')
|
||||
})
|
||||
|
||||
it('renders the back button', function () {
|
||||
cy.findByTestId('group-heading').within(() => {
|
||||
cy.findByRole('button', { name: /back to subscription/i }).should(
|
||||
'have.attr',
|
||||
'href',
|
||||
'/user/subscription'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the group name', function () {
|
||||
cy.findByTestId('group-heading').within(() => {
|
||||
cy.findByRole('heading', { name: 'My Awesome Team' })
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the "Buy more licenses" label', function () {
|
||||
cy.findByText(/buy more licenses/i)
|
||||
})
|
||||
|
||||
it('shows the maximum supported users', function () {
|
||||
cy.findByText(
|
||||
new RegExp(
|
||||
`your current plan supports up to ${this.totalLicenses} licenses`,
|
||||
'i'
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('shows instructions on how to reduce licenses on a plan', function () {
|
||||
cy.contains(
|
||||
/if you want to reduce the number of licenses on your plan, please contact customer support/i
|
||||
).within(() => {
|
||||
cy.findByRole('link', { name: /contact customer support/i }).should(
|
||||
'have.attr',
|
||||
'href',
|
||||
'/contact'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the cancel button', function () {
|
||||
cy.findByRole('button', { name: /cancel/i }).should(
|
||||
'have.attr',
|
||||
'href',
|
||||
'/user/subscription'
|
||||
)
|
||||
})
|
||||
|
||||
describe('"Upgrade my plan" link', function () {
|
||||
it('shows the link', function () {
|
||||
cy.findByRole('link', { name: /upgrade my plan/i }).should(
|
||||
'have.attr',
|
||||
'href',
|
||||
'/user/subscription/group/upgrade-subscription'
|
||||
)
|
||||
})
|
||||
|
||||
it('hides the link', function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-isProfessional', true)
|
||||
})
|
||||
|
||||
cy.mount(<AddSeats />)
|
||||
|
||||
cy.findByRole('link', { name: /upgrade my plan/i }).should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cost summary', function () {
|
||||
beforeEach(function () {
|
||||
cy.findByLabelText(/how many licenses do you want to buy/i).as('input')
|
||||
})
|
||||
|
||||
it('shows the title', function () {
|
||||
cy.findByTestId('cost-summary').within(() => {
|
||||
cy.findByText(/cost summary/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shows default content when', function () {
|
||||
afterEach(function () {
|
||||
cy.findByTestId('cost-summary').within(() => {
|
||||
cy.findByText(
|
||||
/enter the number of licenses you’d like to add to see the cost breakdown/i
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves input empty', function () {
|
||||
cy.get('@input').should('have.value', '')
|
||||
})
|
||||
|
||||
it('fills in a non-numeric value', function () {
|
||||
cy.get('@input').type('ab')
|
||||
cy.findByText(/value must be a number/i)
|
||||
})
|
||||
|
||||
it('fills in a decimal value', function () {
|
||||
cy.get('@input').type('1.5')
|
||||
cy.findByText(/value must be a whole number/i)
|
||||
})
|
||||
|
||||
it('fills in a "0" value', function () {
|
||||
cy.get('@input').type('0')
|
||||
cy.findByText(/value must be at least 1/i)
|
||||
})
|
||||
|
||||
it('fills in a value and clears the input', function () {
|
||||
cy.get('@input').type('a{backspace}')
|
||||
cy.get('@input').should('have.text', '')
|
||||
cy.findByText(/this field is required/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('entered more than the maximum allowed number of users', function () {
|
||||
beforeEach(function () {
|
||||
this.numberOfUsersExceedingMaxLimit = MAX_NUMBER_OF_USERS + 1
|
||||
|
||||
cy.get('@input').type(this.numberOfUsersExceedingMaxLimit.toString())
|
||||
cy.findByRole('button', { name: /buy licenses/i }).should('not.exist')
|
||||
cy.findByRole('button', { name: /send request/i }).as('sendRequestBtn')
|
||||
})
|
||||
|
||||
it('renders a notification', function () {
|
||||
cy.findByTestId('cost-summary').should('not.exist')
|
||||
cy.findByRole('alert').should(
|
||||
'contain.text',
|
||||
`If you want more than ${MAX_NUMBER_OF_USERS} licenses on your plan, we need to add them for you. Just click Send request below and we’ll be happy to help.`
|
||||
)
|
||||
})
|
||||
|
||||
describe('request', function () {
|
||||
afterEach(function () {
|
||||
cy.findByRole('button', { name: /go to subscriptions/i }).should(
|
||||
'have.attr',
|
||||
'href',
|
||||
'/user/subscription'
|
||||
)
|
||||
})
|
||||
|
||||
function makeRequest(statusCode: number, adding: string) {
|
||||
cy.intercept(
|
||||
'POST',
|
||||
'/user/subscription/group/add-users/sales-contact-form',
|
||||
{
|
||||
statusCode,
|
||||
}
|
||||
).as('addUsersRequest')
|
||||
cy.get('@sendRequestBtn').click()
|
||||
cy.get('@addUsersRequest').its('request.body').should('deep.equal', {
|
||||
adding,
|
||||
})
|
||||
cy.findByTestId('add-more-users-group-form').should('not.exist')
|
||||
}
|
||||
|
||||
it('sends a request that succeeds', function () {
|
||||
makeRequest(204, this.numberOfUsersExceedingMaxLimit.toString())
|
||||
cy.findByTestId('title').should(
|
||||
'contain.text',
|
||||
'We’ve got your request'
|
||||
)
|
||||
cy.findByText(/our team will get back to you shortly/i)
|
||||
})
|
||||
|
||||
it('sends a request that fails', function () {
|
||||
makeRequest(400, this.numberOfUsersExceedingMaxLimit.toString())
|
||||
cy.findByTestId('title').should(
|
||||
'contain.text',
|
||||
'Something went wrong'
|
||||
)
|
||||
cy.contains(
|
||||
/it looks like that didn’t work. You can try again or get in touch with our Support team for more help/i
|
||||
).within(() => {
|
||||
cy.findByRole('link', { name: /get in touch/i }).should(
|
||||
'have.attr',
|
||||
'href',
|
||||
'/contact'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('entered less than the maximum allowed number of users', function () {
|
||||
beforeEach(function () {
|
||||
this.adding = 1
|
||||
this.body = {
|
||||
change: {
|
||||
type: 'add-on-update',
|
||||
addOn: {
|
||||
code: 'additional-license',
|
||||
quantity: this.totalLicenses + this.adding,
|
||||
prevQuantity: this.totalLicenses,
|
||||
},
|
||||
},
|
||||
currency: 'USD',
|
||||
immediateCharge: {
|
||||
subtotal: 100,
|
||||
tax: 20,
|
||||
total: 120,
|
||||
discount: 0,
|
||||
},
|
||||
nextInvoice: {
|
||||
date: '2025-12-01T00:00:00.000Z',
|
||||
plan: {
|
||||
name: 'Overleaf Standard Group',
|
||||
amount: 0,
|
||||
},
|
||||
subtotal: 895,
|
||||
tax: {
|
||||
rate: 0.2,
|
||||
amount: 105,
|
||||
},
|
||||
total: 1000,
|
||||
},
|
||||
}
|
||||
|
||||
cy.findByRole('button', { name: /buy licenses/i }).as('addUsersBtn')
|
||||
cy.findByRole('button', { name: /send request/i }).should('not.exist')
|
||||
})
|
||||
|
||||
it('renders the preview data', function () {
|
||||
cy.intercept('POST', '/user/subscription/group/add-users/preview', {
|
||||
statusCode: 200,
|
||||
body: this.body,
|
||||
}).as('addUsersRequest')
|
||||
cy.get('@input').type(this.adding.toString())
|
||||
|
||||
cy.findByTestId('cost-summary').within(() => {
|
||||
cy.contains(
|
||||
new RegExp(
|
||||
`you’re adding ${this.adding} licenses to your plan giving you a total of ${this.body.change.addOn.quantity} licenses`,
|
||||
'i'
|
||||
)
|
||||
)
|
||||
|
||||
cy.findByTestId('plan').within(() => {
|
||||
cy.findByText(
|
||||
`${this.body.nextInvoice.plan.name} x ${this.adding} Licenses`
|
||||
)
|
||||
cy.findByTestId('price').should(
|
||||
'have.text',
|
||||
`$${this.body.immediateCharge.subtotal}.00`
|
||||
)
|
||||
})
|
||||
|
||||
cy.findByTestId('tax').within(() => {
|
||||
cy.findByText(
|
||||
new RegExp(`VAT · ${this.body.nextInvoice.tax.rate * 100}%`, 'i')
|
||||
)
|
||||
cy.findByTestId('price').should(
|
||||
'have.text',
|
||||
`$${this.body.immediateCharge.tax}.00`
|
||||
)
|
||||
})
|
||||
|
||||
cy.findByTestId('discount').should('not.exist')
|
||||
|
||||
cy.findByTestId('total').within(() => {
|
||||
cy.findByText(/total due today/i)
|
||||
cy.findByTestId('price').should(
|
||||
'have.text',
|
||||
`$${this.body.immediateCharge.total}.00`
|
||||
)
|
||||
})
|
||||
|
||||
cy.findByText(
|
||||
/we’ll charge you now for the cost of your additional licenses based on the remaining months of your current subscription/i
|
||||
)
|
||||
cy.findByText(
|
||||
/after that, we’ll bill you \$1,000\.00 \(\$895\.00 \+ \$105\.00 tax\) annually on December 1, unless you cancel/i
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the preview data with discount', function () {
|
||||
this.body.immediateCharge.discount = 50
|
||||
|
||||
cy.intercept('POST', '/user/subscription/group/add-users/preview', {
|
||||
statusCode: 200,
|
||||
body: this.body,
|
||||
}).as('addUsersRequest')
|
||||
cy.get('@input').type(this.adding.toString())
|
||||
|
||||
cy.findByTestId('cost-summary').within(() => {
|
||||
cy.findByTestId('discount').within(() => {
|
||||
cy.findByText(`($${this.body.immediateCharge.discount}.00)`)
|
||||
})
|
||||
|
||||
cy.findByText(
|
||||
/This does not include your current discounts, which will be applied automatically before your next payment/i
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('request', function () {
|
||||
afterEach(function () {
|
||||
cy.findByRole('button', { name: /go to subscriptions/i }).should(
|
||||
'have.attr',
|
||||
'href',
|
||||
'/user/subscription'
|
||||
)
|
||||
})
|
||||
|
||||
function makeRequest(statusCode: number, adding: string) {
|
||||
cy.intercept('POST', '/user/subscription/group/add-users/create', {
|
||||
statusCode,
|
||||
}).as('addUsersRequest')
|
||||
cy.get('@input').type(adding)
|
||||
cy.get('@addUsersBtn').click()
|
||||
cy.get('@addUsersRequest')
|
||||
.its('request.body')
|
||||
.should('deep.equal', {
|
||||
adding: Number(adding),
|
||||
})
|
||||
cy.findByTestId('add-more-users-group-form').should('not.exist')
|
||||
}
|
||||
|
||||
it('sends a request that succeeds', function () {
|
||||
makeRequest(204, this.adding.toString())
|
||||
cy.findByTestId('title').should(
|
||||
'contain.text',
|
||||
'You’ve added more license(s)'
|
||||
)
|
||||
cy.findByText(/you’ve added more license\(s\) to your subscription/i)
|
||||
cy.findByRole('link', { name: /invite people/i }).should(
|
||||
'have.attr',
|
||||
'href',
|
||||
'/manage/groups/123/members'
|
||||
)
|
||||
})
|
||||
|
||||
it('sends a request that fails', function () {
|
||||
makeRequest(400, this.adding.toString())
|
||||
cy.findByTestId('title').should(
|
||||
'contain.text',
|
||||
'Something went wrong'
|
||||
)
|
||||
cy.contains(
|
||||
/it looks like that didn’t work. You can try again or get in touch with our Support team for more help/i
|
||||
).within(() => {
|
||||
cy.findByRole('link', { name: /get in touch/i }).should(
|
||||
'have.attr',
|
||||
'href',
|
||||
'/contact'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,175 @@
|
||||
import GroupManagers from '@/features/group-management/components/group-managers'
|
||||
|
||||
const JOHN_DOE = {
|
||||
_id: 'abc123def456',
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
email: 'john.doe@test.com',
|
||||
last_active_at: new Date('2023-01-15'),
|
||||
invite: true,
|
||||
}
|
||||
const BOBBY_LAPOINTE = {
|
||||
_id: 'bcd234efa567',
|
||||
first_name: 'Bobby',
|
||||
last_name: 'Lapointe',
|
||||
email: 'bobby.lapointe@test.com',
|
||||
last_active_at: new Date('2023-01-02'),
|
||||
invite: false,
|
||||
}
|
||||
const GROUP_ID = '888fff888fff'
|
||||
const PATHS = {
|
||||
addMember: `/manage/groups/${GROUP_ID}/managers`,
|
||||
removeMember: `/manage/groups/${GROUP_ID}/managers`,
|
||||
}
|
||||
|
||||
describe('group managers', function () {
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [JOHN_DOE, BOBBY_LAPOINTE])
|
||||
win.metaAttributesCache.set('ol-groupId', GROUP_ID)
|
||||
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
|
||||
})
|
||||
|
||||
cy.mount(<GroupManagers />)
|
||||
})
|
||||
|
||||
it('renders the group management page', function () {
|
||||
cy.findByRole('heading', { name: /my awesome team/i, level: 1 })
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByText('john.doe@test.com')
|
||||
cy.findByText('John Doe')
|
||||
cy.findByText('15th Jan 2023')
|
||||
cy.findByText('Invite not yet accepted')
|
||||
})
|
||||
|
||||
cy.get('tr:nth-child(2)').within(() => {
|
||||
cy.findByText('bobby.lapointe@test.com')
|
||||
cy.findByText('Bobby Lapointe')
|
||||
cy.findByText('2nd Jan 2023')
|
||||
cy.findByText('Accepted invite')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('sends an invite', function () {
|
||||
cy.intercept('POST', PATHS.addMember, {
|
||||
statusCode: 201,
|
||||
body: {
|
||||
user: {
|
||||
email: 'someone.else@test.com',
|
||||
invite: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cy.findByTestId('add-members-form').within(() => {
|
||||
cy.findByRole('textbox').type('someone.else@test.com')
|
||||
cy.findByRole('button').click()
|
||||
})
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(3)').within(() => {
|
||||
cy.findByText('someone.else@test.com')
|
||||
cy.findByText('N/A')
|
||||
cy.findByText('Invite not yet accepted')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('tries to send an invite and displays the error', function () {
|
||||
cy.intercept('POST', PATHS.addMember, {
|
||||
statusCode: 500,
|
||||
body: {
|
||||
error: {
|
||||
message: 'User already added',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cy.findByTestId('add-members-form').within(() => {
|
||||
cy.findByRole('textbox').type('someone.else@test.com')
|
||||
cy.findByRole('button').click()
|
||||
})
|
||||
cy.findByRole('alert').should('contain.text', 'Error: User already added')
|
||||
})
|
||||
|
||||
it('checks the select all checkbox', function () {
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByLabelText(/select user/i).should('not.be.checked')
|
||||
})
|
||||
cy.get('tr:nth-child(2)').within(() => {
|
||||
cy.findByLabelText(/select user/i).should('not.be.checked')
|
||||
})
|
||||
})
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('thead')
|
||||
.within(() => {
|
||||
cy.findByLabelText(/select all/i).check()
|
||||
})
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByLabelText(/select user/i).should('be.checked')
|
||||
})
|
||||
cy.get('tr:nth-child(2)').within(() => {
|
||||
cy.findByLabelText(/select user/i).should('be.checked')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('remove a member', function () {
|
||||
cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
|
||||
statusCode: 200,
|
||||
})
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByLabelText(/select user/i).check()
|
||||
})
|
||||
})
|
||||
|
||||
cy.findByRole('button', { name: /remove manager/i }).click()
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByText('bobby.lapointe@test.com')
|
||||
cy.findByText('Bobby Lapointe')
|
||||
cy.findByText('2nd Jan 2023')
|
||||
cy.findByText('Accepted invite')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('tries to remove a manager and displays the error', function () {
|
||||
cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
|
||||
statusCode: 500,
|
||||
})
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByLabelText(/select user/i).check()
|
||||
})
|
||||
})
|
||||
cy.findByRole('button', { name: /remove manager/i }).click()
|
||||
|
||||
cy.findByRole('alert').should('contain.text', 'Sorry, something went wrong')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,577 @@
|
||||
import GroupMembers from '@/features/group-management/components/group-members'
|
||||
import { GroupMembersProvider } from '@/features/group-management/context/group-members-context'
|
||||
import { User } from '../../../../../types/group-management/user'
|
||||
|
||||
const GROUP_ID = '777fff777fff'
|
||||
const PATHS = {
|
||||
addMember: `/manage/groups/${GROUP_ID}/invites`,
|
||||
removeMember: `/manage/groups/${GROUP_ID}/user`,
|
||||
removeInvite: `/manage/groups/${GROUP_ID}/invites`,
|
||||
exportMembers: `/manage/groups/${GROUP_ID}/members/export`,
|
||||
}
|
||||
|
||||
describe('GroupMembers', function () {
|
||||
function mountGroupMembersProvider() {
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<GroupMembers />
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('with Managed Users and Group SSO disabled', function () {
|
||||
const JOHN_DOE = {
|
||||
_id: 'abc123def456',
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
email: 'john.doe@test.com',
|
||||
last_active_at: new Date('2023-01-15'),
|
||||
invite: true,
|
||||
}
|
||||
const BOBBY_LAPOINTE = {
|
||||
_id: 'bcd234efa567',
|
||||
first_name: 'Bobby',
|
||||
last_name: 'Lapointe',
|
||||
email: 'bobby.lapointe@test.com',
|
||||
last_active_at: new Date('2023-01-02'),
|
||||
invite: false,
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-groupId', GROUP_ID)
|
||||
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
|
||||
win.metaAttributesCache.set('ol-groupSize', 10)
|
||||
win.metaAttributesCache.set('ol-users', [JOHN_DOE, BOBBY_LAPOINTE])
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<GroupMembers />
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
})
|
||||
|
||||
it('renders the group members page', function () {
|
||||
cy.findByRole('heading', { name: /my awesome team/i, level: 1 })
|
||||
cy.findByTestId('page-header-members-details').contains(
|
||||
'You have added 2 of 10 available members'
|
||||
)
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.contains('john.doe@test.com')
|
||||
cy.contains('John Doe')
|
||||
cy.contains('15th Jan 2023')
|
||||
cy.findByTestId('badge-pending-invite').should(
|
||||
'have.text',
|
||||
'Pending invite'
|
||||
)
|
||||
})
|
||||
|
||||
cy.get('tr:nth-child(2)').within(() => {
|
||||
cy.contains('bobby.lapointe@test.com')
|
||||
cy.contains('Bobby Lapointe')
|
||||
cy.contains('2nd Jan 2023')
|
||||
cy.findByTestId('badge-pending-invite').should('not.exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('sends an invite', function () {
|
||||
cy.intercept('POST', PATHS.addMember, {
|
||||
statusCode: 201,
|
||||
body: {
|
||||
user: {
|
||||
email: 'someone.else@test.com',
|
||||
invite: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cy.get('.form-control').type('someone.else@test.com')
|
||||
cy.get('.add-more-members-form button').click()
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(3)').within(() => {
|
||||
cy.contains('someone.else@test.com')
|
||||
cy.contains('N/A')
|
||||
cy.findByTestId('badge-pending-invite').should(
|
||||
'have.text',
|
||||
'Pending invite'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('tries to send an invite and displays the error', function () {
|
||||
cy.intercept('POST', PATHS.addMember, {
|
||||
statusCode: 500,
|
||||
body: {
|
||||
error: {
|
||||
message: 'User already added',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cy.get('.form-control').type('someone.else@test.com')
|
||||
cy.get('.add-more-members-form button').click()
|
||||
cy.findByRole('alert').contains('Error: User already added')
|
||||
})
|
||||
|
||||
it('checks the select all checkbox', function () {
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByTestId('select-single-checkbox').should('not.be.checked')
|
||||
})
|
||||
cy.get('tr:nth-child(2)').within(() => {
|
||||
cy.findByTestId('select-single-checkbox').should('not.be.checked')
|
||||
})
|
||||
})
|
||||
|
||||
cy.findByTestId('select-all-checkbox').click()
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByTestId('select-single-checkbox').should('be.checked')
|
||||
})
|
||||
cy.get('tr:nth-child(2)').within(() => {
|
||||
cy.findByTestId('select-single-checkbox').should('be.checked')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('remove a member', function () {
|
||||
cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
|
||||
statusCode: 200,
|
||||
})
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByTestId('select-single-checkbox').check()
|
||||
})
|
||||
})
|
||||
|
||||
cy.get('button').contains('Remove from group').click()
|
||||
|
||||
cy.get('small').contains('You have added 1 of 10 available members')
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.contains('bobby.lapointe@test.com')
|
||||
cy.contains('Bobby Lapointe')
|
||||
cy.contains('2nd Jan 2023')
|
||||
cy.contains('Pending invite').should('not.exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('tries to remove a user and displays the error', function () {
|
||||
cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
|
||||
statusCode: 500,
|
||||
})
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByTestId('select-single-checkbox').check()
|
||||
})
|
||||
})
|
||||
cy.get('button').contains('Remove from group').click()
|
||||
|
||||
cy.findByRole('alert').contains('Sorry, something went wrong')
|
||||
})
|
||||
})
|
||||
|
||||
describe('with Managed Users enabled', function () {
|
||||
const JOHN_DOE: User = {
|
||||
_id: 'abc123def456',
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
email: 'john.doe@test.com',
|
||||
last_active_at: new Date('2023-01-15'),
|
||||
invite: true,
|
||||
}
|
||||
const BOBBY_LAPOINTE: User = {
|
||||
_id: 'bcd234efa567',
|
||||
first_name: 'Bobby',
|
||||
last_name: 'Lapointe',
|
||||
email: 'bobby.lapointe@test.com',
|
||||
last_active_at: new Date('2023-01-02'),
|
||||
invite: false,
|
||||
}
|
||||
const CLAIRE_JENNINGS: User = {
|
||||
_id: 'defabc231453',
|
||||
first_name: 'Claire',
|
||||
last_name: 'Jennings',
|
||||
email: 'claire.jennings@test.com',
|
||||
last_active_at: new Date('2023-01-03'),
|
||||
invite: false,
|
||||
enrollment: {
|
||||
managedBy: GROUP_ID,
|
||||
enrolledAt: new Date('2023-01-03'),
|
||||
sso: [
|
||||
{
|
||||
groupId: GROUP_ID,
|
||||
linkedAt: new Date(),
|
||||
primary: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [
|
||||
JOHN_DOE,
|
||||
BOBBY_LAPOINTE,
|
||||
CLAIRE_JENNINGS,
|
||||
])
|
||||
win.metaAttributesCache.set('ol-groupId', GROUP_ID)
|
||||
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
|
||||
win.metaAttributesCache.set('ol-groupSize', 10)
|
||||
win.metaAttributesCache.set('ol-managedUsersActive', true)
|
||||
})
|
||||
mountGroupMembersProvider()
|
||||
})
|
||||
|
||||
it('renders the group members page', function () {
|
||||
cy.get('h1').contains('My Awesome Team')
|
||||
cy.get('small').contains('You have added 3 of 10 available members')
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.contains('john.doe@test.com')
|
||||
cy.contains('John Doe')
|
||||
cy.contains('15th Jan 2023')
|
||||
cy.get('.visually-hidden').contains('Pending invite')
|
||||
cy.findByTestId('badge-pending-invite').should(
|
||||
'have.text',
|
||||
'Pending invite'
|
||||
)
|
||||
cy.get(`.security-state-invite-pending`).should('exist')
|
||||
})
|
||||
|
||||
cy.get('tr:nth-child(2)').within(() => {
|
||||
cy.contains('bobby.lapointe@test.com')
|
||||
cy.contains('Bobby Lapointe')
|
||||
cy.contains('2nd Jan 2023')
|
||||
cy.findByTestId('badge-pending-invite').should('not.exist')
|
||||
cy.get('.visually-hidden').contains('Not managed')
|
||||
})
|
||||
|
||||
cy.get('tr:nth-child(3)').within(() => {
|
||||
cy.contains('claire.jennings@test.com')
|
||||
cy.contains('Claire Jennings')
|
||||
cy.contains('3rd Jan 2023')
|
||||
cy.findByTestId('badge-pending-invite').should('not.exist')
|
||||
cy.get('.visually-hidden').contains('Managed')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('sends an invite', function () {
|
||||
cy.intercept('POST', PATHS.addMember, {
|
||||
statusCode: 201,
|
||||
body: {
|
||||
user: {
|
||||
email: 'someone.else@test.com',
|
||||
invite: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cy.get('.form-control').type('someone.else@test.com')
|
||||
cy.get('.add-more-members-form button').click()
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(4)').within(() => {
|
||||
cy.contains('someone.else@test.com')
|
||||
cy.contains('N/A')
|
||||
cy.get('.visually-hidden').contains('Pending invite')
|
||||
cy.findByTestId('badge-pending-invite').should(
|
||||
'have.text',
|
||||
'Pending invite'
|
||||
)
|
||||
cy.get(`.security-state-invite-pending`).should('exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('tries to send an invite and displays the error', function () {
|
||||
cy.intercept('POST', PATHS.addMember, {
|
||||
statusCode: 500,
|
||||
body: {
|
||||
error: {
|
||||
message: 'User already added',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cy.get('.form-control').type('someone.else@test.com')
|
||||
cy.get('.add-more-members-form button').click()
|
||||
cy.findByRole('alert').contains('Error: User already added')
|
||||
})
|
||||
|
||||
it('checks the select all checkbox', function () {
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByTestId('select-single-checkbox').should('not.be.checked')
|
||||
})
|
||||
cy.get('tr:nth-child(2)').within(() => {
|
||||
cy.findByTestId('select-single-checkbox').should('not.be.checked')
|
||||
})
|
||||
})
|
||||
|
||||
cy.findByTestId('select-all-checkbox').click()
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByTestId('select-single-checkbox').should('be.checked')
|
||||
})
|
||||
cy.get('tr:nth-child(2)').within(() => {
|
||||
cy.findByTestId('select-single-checkbox').should('be.checked')
|
||||
})
|
||||
})
|
||||
|
||||
cy.get('button').contains('Remove from group').click()
|
||||
})
|
||||
|
||||
it('remove a member', function () {
|
||||
cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
|
||||
statusCode: 200,
|
||||
})
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByTestId('select-single-checkbox').check()
|
||||
})
|
||||
})
|
||||
|
||||
cy.get('button').contains('Remove from group').click()
|
||||
|
||||
cy.get('small').contains('You have added 2 of 10 available members')
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.contains('bobby.lapointe@test.com')
|
||||
cy.contains('Bobby Lapointe')
|
||||
cy.contains('2nd Jan 2023')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('cannot remove a managed member', function () {
|
||||
cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
|
||||
statusCode: 200,
|
||||
})
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
// no checkbox should be shown for 'Claire Jennings', a managed user
|
||||
cy.get('tr:nth-child(3)').within(() => {
|
||||
cy.findByTestId('select-single-checkbox').should('not.exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('tries to remove a user and displays the error', function () {
|
||||
cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
|
||||
statusCode: 500,
|
||||
})
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByTestId('select-single-checkbox').check()
|
||||
})
|
||||
})
|
||||
cy.get('.page-header').within(() => {
|
||||
cy.get('button').contains('Remove from group').click()
|
||||
})
|
||||
|
||||
cy.findByRole('alert').contains('Sorry, something went wrong')
|
||||
})
|
||||
})
|
||||
|
||||
describe('with Group SSO enabled', function () {
|
||||
const JOHN_DOE: User = {
|
||||
_id: 'abc123def456',
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
email: 'john.doe@test.com',
|
||||
last_active_at: new Date('2023-01-15'),
|
||||
invite: true,
|
||||
}
|
||||
const BOBBY_LAPOINTE: User = {
|
||||
_id: 'bcd234efa567',
|
||||
first_name: 'Bobby',
|
||||
last_name: 'Lapointe',
|
||||
email: 'bobby.lapointe@test.com',
|
||||
last_active_at: new Date('2023-01-02'),
|
||||
invite: false,
|
||||
}
|
||||
const CLAIRE_JENNINGS: User = {
|
||||
_id: 'defabc231453',
|
||||
first_name: 'Claire',
|
||||
last_name: 'Jennings',
|
||||
email: 'claire.jennings@test.com',
|
||||
last_active_at: new Date('2023-01-03'),
|
||||
invite: false,
|
||||
enrollment: {
|
||||
managedBy: GROUP_ID,
|
||||
enrolledAt: new Date('2023-01-03'),
|
||||
sso: [
|
||||
{
|
||||
groupId: GROUP_ID,
|
||||
linkedAt: new Date(),
|
||||
primary: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [
|
||||
JOHN_DOE,
|
||||
BOBBY_LAPOINTE,
|
||||
CLAIRE_JENNINGS,
|
||||
])
|
||||
win.metaAttributesCache.set('ol-groupId', GROUP_ID)
|
||||
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
|
||||
win.metaAttributesCache.set('ol-groupSize', 10)
|
||||
win.metaAttributesCache.set('ol-managedUsersActive', false)
|
||||
win.metaAttributesCache.set('ol-groupSSOActive', true)
|
||||
})
|
||||
|
||||
mountGroupMembersProvider()
|
||||
})
|
||||
|
||||
it('should display the Security column', function () {
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(2)').within(() => {
|
||||
cy.contains('bobby.lapointe@test.com')
|
||||
cy.get('.visually-hidden').contains('SSO not active')
|
||||
})
|
||||
|
||||
cy.get('tr:nth-child(3)').within(() => {
|
||||
cy.contains('claire.jennings@test.com')
|
||||
cy.get('.visually-hidden').contains('SSO active')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with flexible group licensing enabled', function () {
|
||||
beforeEach(function () {
|
||||
this.JOHN_DOE = {
|
||||
_id: 'abc123def456',
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
email: 'john.doe@test.com',
|
||||
last_active_at: new Date('2023-01-15'),
|
||||
invite: false,
|
||||
}
|
||||
this.BOBBY_LAPOINTE = {
|
||||
_id: 'bcd234efa567',
|
||||
first_name: 'Bobby',
|
||||
last_name: 'Lapointe',
|
||||
email: 'bobby.lapointe@test.com',
|
||||
last_active_at: new Date('2023-01-02'),
|
||||
invite: false,
|
||||
}
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-groupId', GROUP_ID)
|
||||
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
|
||||
win.metaAttributesCache.set('ol-groupSize', 10)
|
||||
win.metaAttributesCache.set('ol-canUseFlexibleLicensing', true)
|
||||
win.metaAttributesCache.set('ol-canUseAddSeatsFeature', true)
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the group members page with the new text', function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [
|
||||
this.JOHN_DOE,
|
||||
this.BOBBY_LAPOINTE,
|
||||
])
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<GroupMembers />
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
|
||||
cy.findByTestId('group-size-details').contains(
|
||||
'You have 2 licenses and your plan supports up to 10. Buy more licenses.'
|
||||
)
|
||||
cy.findByTestId('add-more-members-form').within(() => {
|
||||
cy.contains('Invite more members')
|
||||
cy.get('button').contains('Invite')
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the group members page with new text when only has one group member', function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [this.JOHN_DOE])
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<GroupMembers />
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
|
||||
cy.findByTestId('group-size-details').contains(
|
||||
'You have 1 license and your plan supports up to 10. Buy more licenses.'
|
||||
)
|
||||
})
|
||||
|
||||
it('renders the group members page without "buy more licenses" link when not admin', function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [this.JOHN_DOE])
|
||||
win.metaAttributesCache.set('ol-canUseAddSeatsFeature', false)
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<GroupMembers />
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
|
||||
cy.findByTestId('group-size-details').within(() => {
|
||||
cy.findByText(/you have \d+ license and your plan supports up to \d+/i)
|
||||
cy.findByText(/buy more licenses/i).should('not.exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,175 @@
|
||||
import InstitutionManagers from '@/features/group-management/components/institution-managers'
|
||||
|
||||
const JOHN_DOE = {
|
||||
_id: 'abc123def456',
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
email: 'john.doe@test.com',
|
||||
last_active_at: new Date('2023-01-15'),
|
||||
invite: true,
|
||||
}
|
||||
const BOBBY_LAPOINTE = {
|
||||
_id: 'bcd234efa567',
|
||||
first_name: 'Bobby',
|
||||
last_name: 'Lapointe',
|
||||
email: 'bobby.lapointe@test.com',
|
||||
last_active_at: new Date('2023-01-02'),
|
||||
invite: false,
|
||||
}
|
||||
const GROUP_ID = '999fff999fff'
|
||||
const PATHS = {
|
||||
addMember: `/manage/institutions/${GROUP_ID}/managers`,
|
||||
removeMember: `/manage/institutions/${GROUP_ID}/managers`,
|
||||
}
|
||||
|
||||
describe('institution managers', function () {
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [JOHN_DOE, BOBBY_LAPOINTE])
|
||||
win.metaAttributesCache.set('ol-groupId', GROUP_ID)
|
||||
win.metaAttributesCache.set('ol-groupName', 'My Awesome Institution')
|
||||
})
|
||||
|
||||
cy.mount(<InstitutionManagers />)
|
||||
})
|
||||
|
||||
it('renders the institution management page', function () {
|
||||
cy.findByRole('heading', { name: /my awesome institution/i, level: 1 })
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByText('john.doe@test.com')
|
||||
cy.findByText('John Doe')
|
||||
cy.findByText('15th Jan 2023')
|
||||
cy.findByText('Invite not yet accepted')
|
||||
})
|
||||
|
||||
cy.get('tr:nth-child(2)').within(() => {
|
||||
cy.findByText('bobby.lapointe@test.com')
|
||||
cy.findByText('Bobby Lapointe')
|
||||
cy.findByText('2nd Jan 2023')
|
||||
cy.findByText('Accepted invite')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('sends an invite', function () {
|
||||
cy.intercept('POST', PATHS.addMember, {
|
||||
statusCode: 201,
|
||||
body: {
|
||||
user: {
|
||||
email: 'someone.else@test.com',
|
||||
invite: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cy.findByTestId('add-members-form').within(() => {
|
||||
cy.findByRole('textbox').type('someone.else@test.com')
|
||||
cy.findByRole('button').click()
|
||||
})
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(3)').within(() => {
|
||||
cy.findByText('someone.else@test.com')
|
||||
cy.findByText('N/A')
|
||||
cy.findByText('Invite not yet accepted')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('tries to send an invite and displays the error', function () {
|
||||
cy.intercept('POST', PATHS.addMember, {
|
||||
statusCode: 500,
|
||||
body: {
|
||||
error: {
|
||||
message: 'User already added',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cy.findByTestId('add-members-form').within(() => {
|
||||
cy.findByRole('textbox').type('someone.else@test.com')
|
||||
cy.findByRole('button').click()
|
||||
})
|
||||
cy.findByRole('alert').should('contain.text', 'Error: User already added')
|
||||
})
|
||||
|
||||
it('checks the select all checkbox', function () {
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByLabelText(/select user/i).should('not.be.checked')
|
||||
})
|
||||
cy.get('tr:nth-child(2)').within(() => {
|
||||
cy.findByLabelText(/select user/i).should('not.be.checked')
|
||||
})
|
||||
})
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('thead')
|
||||
.within(() => {
|
||||
cy.findByLabelText(/select all/i).check()
|
||||
})
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByLabelText(/select user/i).should('be.checked')
|
||||
})
|
||||
cy.get('tr:nth-child(2)').within(() => {
|
||||
cy.findByLabelText(/select user/i).should('be.checked')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('remove a member', function () {
|
||||
cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
|
||||
statusCode: 200,
|
||||
})
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByLabelText(/select user/i).check()
|
||||
})
|
||||
})
|
||||
|
||||
cy.findByRole('button', { name: /remove manager/i }).click()
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByText('bobby.lapointe@test.com')
|
||||
cy.findByText('Bobby Lapointe')
|
||||
cy.findByText('2nd Jan 2023')
|
||||
cy.findByText('Accepted invite')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('tries to remove a manager and displays the error', function () {
|
||||
cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
|
||||
statusCode: 500,
|
||||
})
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByLabelText(/select user/i).check()
|
||||
})
|
||||
})
|
||||
cy.findByRole('button', { name: /remove manager/i }).click()
|
||||
|
||||
cy.findByRole('alert').should('contain.text', 'Sorry, something went wrong')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,313 @@
|
||||
import GroupMembers from '@/features/group-management/components/group-members'
|
||||
import { GroupMembersProvider } from '@/features/group-management/context/group-members-context'
|
||||
import { User } from '../../../../../types/group-management/user'
|
||||
import { SplitTestProvider } from '@/shared/context/split-test-context'
|
||||
|
||||
const GROUP_ID = '777fff777fff'
|
||||
const JOHN_DOE: User = {
|
||||
_id: 'abc123def456',
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
email: 'john.doe@test.com',
|
||||
last_active_at: new Date('2023-01-15'),
|
||||
invite: true,
|
||||
}
|
||||
const BOBBY_LAPOINTE: User = {
|
||||
_id: 'bcd234efa567',
|
||||
first_name: 'Bobby',
|
||||
last_name: 'Lapointe',
|
||||
email: 'bobby.lapointe@test.com',
|
||||
last_active_at: new Date('2023-01-02'),
|
||||
invite: false,
|
||||
enrollment: {
|
||||
sso: [
|
||||
{
|
||||
groupId: 'another',
|
||||
linkedAt: new Date(),
|
||||
primary: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
const CLAIRE_JENNINGS: User = {
|
||||
_id: 'defabc231453',
|
||||
first_name: 'Claire',
|
||||
last_name: 'Jennings',
|
||||
email: 'claire.jennings@test.com',
|
||||
last_active_at: new Date('2023-01-03'),
|
||||
invite: false,
|
||||
enrollment: {
|
||||
managedBy: GROUP_ID,
|
||||
enrolledAt: new Date('2023-01-03'),
|
||||
sso: [
|
||||
{
|
||||
groupId: GROUP_ID,
|
||||
linkedAt: new Date(),
|
||||
primary: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
const PATHS = {
|
||||
addMember: `/manage/groups/${GROUP_ID}/invites`,
|
||||
removeMember: `/manage/groups/${GROUP_ID}/user`,
|
||||
removeInvite: `/manage/groups/${GROUP_ID}/invites`,
|
||||
exportMembers: `/manage/groups/${GROUP_ID}/members/export`,
|
||||
}
|
||||
|
||||
function mountGroupMembersProvider() {
|
||||
cy.mount(
|
||||
<SplitTestProvider>
|
||||
<GroupMembersProvider>
|
||||
<GroupMembers />
|
||||
</GroupMembersProvider>
|
||||
</SplitTestProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('group members, with managed users', function () {
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [
|
||||
JOHN_DOE,
|
||||
BOBBY_LAPOINTE,
|
||||
CLAIRE_JENNINGS,
|
||||
])
|
||||
win.metaAttributesCache.set('ol-groupId', GROUP_ID)
|
||||
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
|
||||
win.metaAttributesCache.set('ol-groupSize', 10)
|
||||
win.metaAttributesCache.set('ol-managedUsersActive', true)
|
||||
})
|
||||
mountGroupMembersProvider()
|
||||
})
|
||||
|
||||
it('renders the group members page', function () {
|
||||
cy.get('h1').contains('My Awesome Team')
|
||||
cy.get('small').contains('You have added 3 of 10 available members')
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.contains('john.doe@test.com')
|
||||
cy.contains('John Doe')
|
||||
cy.contains('15th Jan 2023')
|
||||
cy.get('.visually-hidden').contains('Pending invite')
|
||||
|
||||
cy.findByTestId('badge-pending-invite').should(
|
||||
'have.text',
|
||||
'Pending invite'
|
||||
)
|
||||
cy.get(`.security-state-invite-pending`).should('exist')
|
||||
})
|
||||
|
||||
cy.get('tr:nth-child(2)').within(() => {
|
||||
cy.contains('bobby.lapointe@test.com')
|
||||
cy.contains('Bobby Lapointe')
|
||||
cy.contains('2nd Jan 2023')
|
||||
cy.findByTestId('badge-pending-invite').should('not.exist')
|
||||
cy.get('.visually-hidden').contains('Not managed')
|
||||
})
|
||||
|
||||
cy.get('tr:nth-child(3)').within(() => {
|
||||
cy.contains('claire.jennings@test.com')
|
||||
cy.contains('Claire Jennings')
|
||||
cy.contains('3rd Jan 2023')
|
||||
cy.findByTestId('badge-pending-invite').should('not.exist')
|
||||
cy.get('.visually-hidden').contains('Managed')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('sends an invite', function () {
|
||||
cy.intercept('POST', PATHS.addMember, {
|
||||
statusCode: 201,
|
||||
body: {
|
||||
user: {
|
||||
email: 'someone.else@test.com',
|
||||
invite: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cy.get('.form-control').type('someone.else@test.com')
|
||||
cy.get('.add-more-members-form button').click()
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(4)').within(() => {
|
||||
cy.contains('someone.else@test.com')
|
||||
cy.contains('N/A')
|
||||
cy.get('.visually-hidden').contains('Pending invite')
|
||||
cy.findByTestId('badge-pending-invite').should(
|
||||
'have.text',
|
||||
'Pending invite'
|
||||
)
|
||||
cy.get(`.security-state-invite-pending`).should('exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('tries to send an invite and displays the error', function () {
|
||||
cy.intercept('POST', PATHS.addMember, {
|
||||
statusCode: 500,
|
||||
body: {
|
||||
error: {
|
||||
message: 'User already added',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cy.get('.form-control').type('someone.else@test.com')
|
||||
cy.get('.add-more-members-form button').click()
|
||||
cy.findByRole('alert').contains('Error: User already added')
|
||||
})
|
||||
|
||||
it('checks the select all checkbox', function () {
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByTestId('select-single-checkbox').should('not.be.checked')
|
||||
})
|
||||
cy.get('tr:nth-child(2)').within(() => {
|
||||
cy.findByTestId('select-single-checkbox').should('not.be.checked')
|
||||
})
|
||||
})
|
||||
|
||||
cy.findByTestId('select-all-checkbox').click()
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByTestId('select-single-checkbox').should('be.checked')
|
||||
})
|
||||
cy.get('tr:nth-child(2)').within(() => {
|
||||
cy.findByTestId('select-single-checkbox').should('be.checked')
|
||||
})
|
||||
})
|
||||
|
||||
cy.get('button').contains('Remove from group').click()
|
||||
})
|
||||
|
||||
it('remove a member', function () {
|
||||
cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
|
||||
statusCode: 200,
|
||||
})
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByTestId('select-single-checkbox').check()
|
||||
})
|
||||
})
|
||||
|
||||
cy.get('button').contains('Remove from group').click()
|
||||
|
||||
cy.get('small').contains('You have added 2 of 10 available members')
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.contains('bobby.lapointe@test.com')
|
||||
cy.contains('Bobby Lapointe')
|
||||
cy.contains('2nd Jan 2023')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('cannot remove a managed member', function () {
|
||||
cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
|
||||
statusCode: 200,
|
||||
})
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
// no checkbox should be shown for 'Claire Jennings', a managed user
|
||||
cy.get('tr:nth-child(3)').within(() => {
|
||||
cy.findByTestId('select-single-checkbox').should('not.exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('tries to remove a user and displays the error', function () {
|
||||
cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
|
||||
statusCode: 500,
|
||||
})
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByTestId('select-single-checkbox').check()
|
||||
})
|
||||
})
|
||||
cy.get('.page-header').within(() => {
|
||||
cy.get('button').contains('Remove from group').click()
|
||||
})
|
||||
|
||||
cy.findByRole('alert').contains('Sorry, something went wrong')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Group members when group SSO is enabled', function () {
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [
|
||||
JOHN_DOE,
|
||||
BOBBY_LAPOINTE,
|
||||
CLAIRE_JENNINGS,
|
||||
])
|
||||
win.metaAttributesCache.set('ol-groupId', GROUP_ID)
|
||||
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
|
||||
win.metaAttributesCache.set('ol-groupSize', 10)
|
||||
win.metaAttributesCache.set('ol-managedUsersActive', true)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not display SSO Column when group sso is not enabled', function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-groupSSOActive', false)
|
||||
})
|
||||
mountGroupMembersProvider()
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(2)').within(() => {
|
||||
cy.contains('bobby.lapointe@test.com')
|
||||
cy.get('.visually-hidden')
|
||||
.contains('SSO not active')
|
||||
.should('not.exist')
|
||||
})
|
||||
|
||||
cy.get('tr:nth-child(3)').within(() => {
|
||||
cy.contains('claire.jennings@test.com')
|
||||
cy.get('.visually-hidden').contains('SSO active').should('not.exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should display SSO Column when Group SSO is enabled', function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-groupSSOActive', true)
|
||||
})
|
||||
mountGroupMembersProvider()
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(2)').within(() => {
|
||||
cy.contains('bobby.lapointe@test.com')
|
||||
cy.get('.visually-hidden').contains('SSO not active')
|
||||
})
|
||||
|
||||
cy.get('tr:nth-child(3)').within(() => {
|
||||
cy.contains('claire.jennings@test.com')
|
||||
cy.get('.visually-hidden').contains('SSO active')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,802 @@
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import sinon from 'sinon'
|
||||
import DropdownButton from '@/features/group-management/components/members-table/dropdown-button'
|
||||
import { GroupMembersProvider } from '@/features/group-management/context/group-members-context'
|
||||
import { User } from '../../../../../../types/group-management/user'
|
||||
|
||||
function Wrapper({ children }: PropsWithChildren<Record<string, unknown>>) {
|
||||
return (
|
||||
<table className="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="managed-users-actions" style={{ textAlign: 'right' }}>
|
||||
<GroupMembersProvider>{children}</GroupMembersProvider>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
|
||||
function mountDropDownComponent(user: User, subscriptionId: string) {
|
||||
cy.mount(
|
||||
<Wrapper>
|
||||
<DropdownButton
|
||||
user={user}
|
||||
openOffboardingModalForUser={sinon.stub()}
|
||||
openUnlinkUserModal={sinon.stub()}
|
||||
groupId={subscriptionId}
|
||||
setGroupUserAlert={sinon.stub()}
|
||||
/>
|
||||
</Wrapper>
|
||||
)
|
||||
}
|
||||
|
||||
describe('DropdownButton', function () {
|
||||
const subscriptionId = '123abc123abc'
|
||||
|
||||
describe('with a standard group', function () {
|
||||
describe('for a pending user (has not joined group)', function () {
|
||||
const user: User = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: true,
|
||||
last_active_at: new Date(),
|
||||
enrollment: {},
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
mountDropDownComponent(user, subscriptionId)
|
||||
})
|
||||
|
||||
it('should render dropdown button', function () {
|
||||
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
|
||||
'exist'
|
||||
)
|
||||
cy.findByRole('button', { name: /actions/i })
|
||||
})
|
||||
|
||||
it('should show the correct menu when dropdown button is clicked', function () {
|
||||
cy.findByRole('button', { name: /actions/i }).click()
|
||||
cy.findByTestId('resend-group-invite-action').should('be.visible')
|
||||
cy.findByTestId('remove-user-action').should('be.visible')
|
||||
|
||||
cy.findByTestId('resend-managed-user-invite-action').should('not.exist')
|
||||
cy.findByTestId('resend-sso-link-invite-action').should('not.exist')
|
||||
cy.findByTestId('no-actions-available').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('for the group admin', function () {
|
||||
const user: User = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date(),
|
||||
enrollment: {},
|
||||
isEntityAdmin: true,
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
mountDropDownComponent(user, subscriptionId)
|
||||
})
|
||||
|
||||
it('should render dropdown button', function () {
|
||||
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
|
||||
'exist'
|
||||
)
|
||||
cy.findByRole('button', { name: /actions/i })
|
||||
})
|
||||
|
||||
it('should show the correct menu when dropdown button is clicked', function () {
|
||||
cy.findByRole('button', { name: /actions/i }).click()
|
||||
|
||||
cy.findByTestId('remove-user-action').should('be.visible')
|
||||
|
||||
cy.findByTestId('no-actions-available').should('not.exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with Managed Users enabled', function () {
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-managedUsersActive', true)
|
||||
win.metaAttributesCache.set('ol-groupSSOActive', false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('for a pending user (has not joined group)', function () {
|
||||
const user: User = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: true,
|
||||
last_active_at: new Date(),
|
||||
enrollment: {},
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
mountDropDownComponent(user, subscriptionId)
|
||||
})
|
||||
|
||||
it('should render dropdown button', function () {
|
||||
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
|
||||
'exist'
|
||||
)
|
||||
cy.findByRole('button', { name: /actions/i })
|
||||
})
|
||||
|
||||
it('should show the correct menu when dropdown button is clicked', function () {
|
||||
cy.findByRole('button', { name: /actions/i }).click()
|
||||
cy.findByTestId('resend-group-invite-action').should('be.visible')
|
||||
cy.findByTestId('remove-user-action').should('be.visible')
|
||||
|
||||
cy.findByTestId('resend-managed-user-invite-action').should('not.exist')
|
||||
cy.findByTestId('resend-sso-link-invite-action').should('not.exist')
|
||||
cy.findByTestId('no-actions-available').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('for a managed group member', function () {
|
||||
const user: User = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date(),
|
||||
enrollment: {
|
||||
managedBy: subscriptionId,
|
||||
enrolledAt: new Date(),
|
||||
},
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
mountDropDownComponent(user, subscriptionId)
|
||||
})
|
||||
|
||||
it('should render the dropdown button', function () {
|
||||
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
|
||||
'exist'
|
||||
)
|
||||
cy.findByRole('button', { name: /actions/i })
|
||||
})
|
||||
|
||||
it('should show the correct menu when dropdown button is clicked', function () {
|
||||
cy.findByRole('button', { name: /actions/i }).click()
|
||||
|
||||
cy.findByTestId('delete-user-action').should('be.visible')
|
||||
|
||||
cy.findByTestId('remove-user-action').should('not.exist')
|
||||
cy.findByTestId('resend-managed-user-invite-action').should('not.exist')
|
||||
cy.findByTestId('resend-sso-link-invite-action').should('not.exist')
|
||||
cy.findByTestId('no-actions-available').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('for a non-managed group member', function () {
|
||||
const user: User = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date(),
|
||||
enrollment: {},
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
mountDropDownComponent(user, subscriptionId)
|
||||
})
|
||||
|
||||
it('should render dropdown button', function () {
|
||||
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
|
||||
'exist'
|
||||
)
|
||||
cy.findByRole('button', { name: /actions/i })
|
||||
})
|
||||
|
||||
it('should show the correct menu when dropdown button is clicked', function () {
|
||||
cy.findByRole('button', { name: /actions/i }).click()
|
||||
|
||||
cy.findByTestId('resend-managed-user-invite-action').should(
|
||||
'be.visible'
|
||||
)
|
||||
cy.findByTestId('remove-user-action').should('be.visible')
|
||||
|
||||
cy.findByTestId('resend-sso-link-invite-action').should('not.exist')
|
||||
cy.findByTestId('no-actions-available').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('for a managed group admin user', function () {
|
||||
const user: User = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date(),
|
||||
enrollment: {
|
||||
managedBy: subscriptionId,
|
||||
enrolledAt: new Date(),
|
||||
},
|
||||
isEntityAdmin: true,
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
mountDropDownComponent(user, subscriptionId)
|
||||
})
|
||||
|
||||
it('should render the button', function () {
|
||||
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
|
||||
'exist'
|
||||
)
|
||||
cy.findByRole('button', { name: /actions/i })
|
||||
})
|
||||
|
||||
it('should show the (empty) menu when the button is clicked', function () {
|
||||
cy.findByRole('button', { name: /actions/i }).click()
|
||||
cy.findByTestId('no-actions-available').should('exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with Group SSO enabled', function () {
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-managedUsersActive', false)
|
||||
win.metaAttributesCache.set('ol-groupSSOActive', true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('for a pending user (has not joined group)', function () {
|
||||
const user: User = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: true,
|
||||
last_active_at: new Date(),
|
||||
enrollment: {},
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
mountDropDownComponent(user, subscriptionId)
|
||||
})
|
||||
|
||||
it('should render dropdown button', function () {
|
||||
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
|
||||
'exist'
|
||||
)
|
||||
cy.findByRole('button', { name: /actions/i })
|
||||
})
|
||||
|
||||
it('should show the correct menu when dropdown button is clicked', function () {
|
||||
cy.findByRole('button', { name: /actions/i }).click()
|
||||
cy.findByTestId('resend-group-invite-action').should('be.visible')
|
||||
cy.findByTestId('remove-user-action').should('be.visible')
|
||||
|
||||
cy.findByTestId('resend-managed-user-invite-action').should('not.exist')
|
||||
cy.findByTestId('resend-sso-link-invite-action').should('not.exist')
|
||||
cy.findByTestId('unlink-user-action').should('not.exist')
|
||||
cy.findByTestId('no-actions-available').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('for a group member not linked with SSO yet', function () {
|
||||
const user: User = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date(),
|
||||
enrollment: {},
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
})
|
||||
|
||||
it('should show resend invite when user is admin', function () {
|
||||
mountDropDownComponent({ ...user, isEntityAdmin: true }, '123abc')
|
||||
cy.findByRole('button', { name: /actions/i }).click()
|
||||
cy.findByTestId('resend-sso-link-invite-action').should('exist')
|
||||
})
|
||||
|
||||
it('should not show resend invite when SSO is disabled', function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-groupSSOActive', false)
|
||||
})
|
||||
mountDropDownComponent(user, '123abc')
|
||||
cy.findByRole('button', { name: /actions/i }).click()
|
||||
cy.findByTestId('resend-sso-link-invite-action').should('not.exist')
|
||||
})
|
||||
|
||||
it('should show the resend SSO invite option when dropdown button is clicked', function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-groupSSOActive', true)
|
||||
})
|
||||
mountDropDownComponent(user, '123abc')
|
||||
cy.findByRole('button', { name: /actions/i }).click()
|
||||
cy.findByTestId('resend-sso-link-invite-action').should('be.visible')
|
||||
})
|
||||
|
||||
it('should make the correct post request when resend SSO invite is clicked ', function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-groupSSOActive', true)
|
||||
})
|
||||
cy.intercept(
|
||||
'POST',
|
||||
'/manage/groups/123abc/resendSSOLinkInvite/some-user',
|
||||
{ success: true }
|
||||
).as('resendInviteRequest')
|
||||
mountDropDownComponent(user, '123abc')
|
||||
cy.findByRole('button', { name: /actions/i }).click()
|
||||
cy.findByTestId('resend-sso-link-invite-action')
|
||||
.should('exist')
|
||||
.as('resendInvite')
|
||||
cy.get('@resendInvite').click()
|
||||
cy.wait('@resendInviteRequest')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with Managed Users and Group SSO enabled', function () {
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-managedUsersActive', true)
|
||||
win.metaAttributesCache.set('ol-groupSSOActive', true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('for a pending user (has not joined group)', function () {
|
||||
const user: User = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: true,
|
||||
last_active_at: new Date(),
|
||||
enrollment: {},
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
mountDropDownComponent(user, subscriptionId)
|
||||
})
|
||||
|
||||
it('should render dropdown button', function () {
|
||||
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
|
||||
'exist'
|
||||
)
|
||||
cy.findByRole('button', { name: /actions/i })
|
||||
})
|
||||
|
||||
it('should show the correct menu when dropdown button is clicked', function () {
|
||||
cy.findByRole('button', { name: /actions/i }).click()
|
||||
cy.findByTestId('resend-group-invite-action').should('be.visible')
|
||||
cy.findByTestId('remove-user-action').should('be.visible')
|
||||
|
||||
cy.findByTestId('resend-managed-user-invite-action').should('not.exist')
|
||||
cy.findByTestId('resend-sso-link-invite-action').should('not.exist')
|
||||
cy.findByTestId('unlink-user-action').should('not.exist')
|
||||
cy.findByTestId('no-actions-available').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('for a non-managed group member with SSO linked', function () {
|
||||
const user: User = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date(),
|
||||
enrollment: {
|
||||
sso: [
|
||||
{
|
||||
groupId: subscriptionId,
|
||||
linkedAt: new Date(),
|
||||
primary: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
mountDropDownComponent(user, subscriptionId)
|
||||
})
|
||||
|
||||
it('should render dropdown button', function () {
|
||||
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
|
||||
'exist'
|
||||
)
|
||||
cy.findByRole('button', { name: /actions/i })
|
||||
})
|
||||
|
||||
it('should show the correct menu when dropdown button is clicked', function () {
|
||||
cy.findByRole('button', { name: /actions/i }).click()
|
||||
|
||||
cy.findByTestId('resend-managed-user-invite-action').should(
|
||||
'be.visible'
|
||||
)
|
||||
cy.findByTestId('remove-user-action').should('be.visible')
|
||||
cy.findByTestId('unlink-user-action').should('be.visible')
|
||||
|
||||
cy.findByTestId('resend-sso-link-invite-action').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('for a non-managed group member with SSO not linked', function () {
|
||||
const user: User = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date(),
|
||||
enrollment: {
|
||||
sso: [],
|
||||
},
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
mountDropDownComponent(user, subscriptionId)
|
||||
})
|
||||
|
||||
it('should render dropdown button', function () {
|
||||
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
|
||||
'exist'
|
||||
)
|
||||
cy.findByRole('button', { name: /actions/i })
|
||||
})
|
||||
|
||||
it('should show the correct menu when dropdown button is clicked', function () {
|
||||
cy.findByRole('button', { name: /actions/i }).click()
|
||||
|
||||
cy.findByTestId('resend-managed-user-invite-action').should(
|
||||
'be.visible'
|
||||
)
|
||||
cy.findByTestId('remove-user-action').should('be.visible')
|
||||
cy.findByTestId('resend-sso-link-invite-action').should('be.visible')
|
||||
|
||||
cy.findByTestId('no-actions-available').should('not.exist')
|
||||
cy.findByTestId('unlink-user-action').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('for a non-managed group admin with SSO linked', function () {
|
||||
const user: User = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date(),
|
||||
enrollment: {
|
||||
sso: [
|
||||
{
|
||||
groupId: subscriptionId,
|
||||
linkedAt: new Date(),
|
||||
primary: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
isEntityAdmin: true,
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
mountDropDownComponent(user, subscriptionId)
|
||||
})
|
||||
|
||||
it('should render dropdown button', function () {
|
||||
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
|
||||
'exist'
|
||||
)
|
||||
cy.findByRole('button', { name: /actions/i })
|
||||
})
|
||||
|
||||
it('should show the correct menu when dropdown button is clicked', function () {
|
||||
cy.findByRole('button', { name: /actions/i }).click()
|
||||
|
||||
cy.findByTestId('resend-managed-user-invite-action').should(
|
||||
'be.visible'
|
||||
)
|
||||
cy.findByTestId('remove-user-action').should('be.visible')
|
||||
cy.findByTestId('unlink-user-action').should('be.visible')
|
||||
|
||||
cy.findByTestId('delete-user-action').should('not.exist')
|
||||
cy.findByTestId('resend-sso-link-invite-action').should('not.exist')
|
||||
cy.findByTestId('no-actions-available').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('for a non-managed group admin with SSO not linked', function () {
|
||||
const user: User = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date(),
|
||||
enrollment: {
|
||||
sso: [],
|
||||
},
|
||||
isEntityAdmin: true,
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
mountDropDownComponent(user, subscriptionId)
|
||||
})
|
||||
|
||||
it('should render dropdown button', function () {
|
||||
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
|
||||
'exist'
|
||||
)
|
||||
cy.findByRole('button', { name: /actions/i })
|
||||
})
|
||||
|
||||
it('should show the correct menu when dropdown button is clicked', function () {
|
||||
cy.findByRole('button', { name: /actions/i }).click()
|
||||
|
||||
cy.findByTestId('resend-managed-user-invite-action').should(
|
||||
'be.visible'
|
||||
)
|
||||
cy.findByTestId('remove-user-action').should('be.visible')
|
||||
cy.findByTestId('delete-user-action').should('not.exist')
|
||||
cy.findByTestId('resend-sso-link-invite-action').should('exist')
|
||||
|
||||
cy.findByTestId('no-actions-available').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('for a managed group member with SSO not linked', function () {
|
||||
const user: User = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date(),
|
||||
enrollment: {
|
||||
managedBy: subscriptionId,
|
||||
enrolledAt: new Date(),
|
||||
sso: [],
|
||||
},
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
mountDropDownComponent(user, subscriptionId)
|
||||
})
|
||||
|
||||
it('should render the dropdown button', function () {
|
||||
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
|
||||
'exist'
|
||||
)
|
||||
cy.findByRole('button', { name: /actions/i })
|
||||
})
|
||||
|
||||
it('should show the correct menu when dropdown button is clicked', function () {
|
||||
cy.findByRole('button', { name: /actions/i }).click()
|
||||
|
||||
cy.findByTestId('delete-user-action').should('be.visible')
|
||||
|
||||
cy.findByTestId('remove-user-action').should('not.exist')
|
||||
cy.findByTestId('resend-managed-user-invite-action').should('not.exist')
|
||||
cy.findByTestId('resend-sso-link-invite-action').should('exist')
|
||||
|
||||
cy.findByTestId('no-actions-available').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('for a managed group member with SSO linked', function () {
|
||||
const user: User = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date(),
|
||||
enrollment: {
|
||||
managedBy: subscriptionId,
|
||||
enrolledAt: new Date(),
|
||||
sso: [
|
||||
{
|
||||
groupId: subscriptionId,
|
||||
linkedAt: new Date(),
|
||||
primary: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
mountDropDownComponent(user, subscriptionId)
|
||||
})
|
||||
|
||||
it('should render the dropdown button', function () {
|
||||
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
|
||||
'exist'
|
||||
)
|
||||
cy.findByRole('button', { name: /actions/i })
|
||||
})
|
||||
|
||||
it('should show the correct menu when dropdown button is clicked', function () {
|
||||
cy.findByRole('button', { name: /actions/i }).click()
|
||||
|
||||
cy.findByTestId('delete-user-action').should('be.visible')
|
||||
|
||||
cy.findByTestId('remove-user-action').should('not.exist')
|
||||
cy.findByTestId('resend-managed-user-invite-action').should('not.exist')
|
||||
cy.findByTestId('resend-sso-link-invite-action').should('not.exist')
|
||||
|
||||
cy.findByTestId('no-actions-available').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('for a managed group admin with SSO not linked', function () {
|
||||
const user: User = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date(),
|
||||
enrollment: {
|
||||
managedBy: subscriptionId,
|
||||
enrolledAt: new Date(),
|
||||
sso: [],
|
||||
},
|
||||
isEntityAdmin: true,
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
mountDropDownComponent(user, subscriptionId)
|
||||
})
|
||||
|
||||
it('should render the button', function () {
|
||||
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
|
||||
'exist'
|
||||
)
|
||||
cy.findByRole('button', { name: /actions/i })
|
||||
})
|
||||
|
||||
it('should show the correct menu when dropdown button is clicked', function () {
|
||||
cy.findByRole('button', { name: /actions/i }).click()
|
||||
|
||||
cy.findByTestId('resend-sso-link-invite-action').should('exist')
|
||||
|
||||
cy.findByTestId('resend-managed-user-invite-action').should('not.exist')
|
||||
cy.findByTestId('remove-user-action').should('not.exist')
|
||||
cy.findByTestId('delete-user-action').should('not.exist')
|
||||
cy.findByTestId('no-actions-available').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('for a managed group admin with SSO linked', function () {
|
||||
const user: User = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date(),
|
||||
enrollment: {
|
||||
managedBy: subscriptionId,
|
||||
enrolledAt: new Date(),
|
||||
sso: [
|
||||
{
|
||||
groupId: subscriptionId,
|
||||
linkedAt: new Date(),
|
||||
primary: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
isEntityAdmin: true,
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
mountDropDownComponent(user, subscriptionId)
|
||||
})
|
||||
|
||||
it('should render the button', function () {
|
||||
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
|
||||
'exist'
|
||||
)
|
||||
cy.findByRole('button', { name: /actions/i })
|
||||
})
|
||||
|
||||
it('should show no actions except to unlink when dropdown button is clicked', function () {
|
||||
cy.findByRole('button', { name: /actions/i }).click()
|
||||
|
||||
cy.findByTestId('unlink-user-action').should('exist')
|
||||
|
||||
cy.findByTestId('no-actions-available').should('not.exist')
|
||||
cy.findByTestId('delete-user-action').should('not.exist')
|
||||
cy.findByTestId('remove-user-action').should('not.exist')
|
||||
cy.findByTestId('resend-managed-user-invite-action').should('not.exist')
|
||||
cy.findByTestId('resend-sso-link-invite-action').should('not.exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import ManagedUserStatus from '@/features/group-management/components/members-table/managed-user-status'
|
||||
import { User } from '../../../../../../types/group-management/user'
|
||||
|
||||
describe('MemberStatus', function () {
|
||||
describe('with a pending invite', function () {
|
||||
const user: User = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: true,
|
||||
last_active_at: new Date(),
|
||||
enrollment: undefined,
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
beforeEach(function () {
|
||||
cy.mount(<ManagedUserStatus user={user} />)
|
||||
})
|
||||
|
||||
it('should render a pending state', function () {
|
||||
cy.get('.security-state-invite-pending').contains('Managed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a managed user', function () {
|
||||
const user: User = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date(),
|
||||
enrollment: { managedBy: 'some-group', enrolledAt: new Date() },
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
beforeEach(function () {
|
||||
cy.mount(<ManagedUserStatus user={user} />)
|
||||
})
|
||||
|
||||
it('should render a pending state', function () {
|
||||
cy.get('.security-state-managed').contains('Managed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('with an un-managed user', function () {
|
||||
const user: User = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date(),
|
||||
enrollment: undefined,
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
beforeEach(function () {
|
||||
cy.mount(<ManagedUserStatus user={user} />)
|
||||
})
|
||||
|
||||
it('should render an un-managed state', function () {
|
||||
cy.get('.security-state-not-managed').contains('Managed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('with the group admin', function () {
|
||||
const user: User = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date(),
|
||||
enrollment: undefined,
|
||||
isEntityAdmin: true,
|
||||
}
|
||||
beforeEach(function () {
|
||||
cy.mount(<ManagedUserStatus user={user} />)
|
||||
})
|
||||
|
||||
it('should render no state indicator', function () {
|
||||
cy.get('.security-state-group-admin')
|
||||
.contains('Managed')
|
||||
.should('not.exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,710 @@
|
||||
import sinon from 'sinon'
|
||||
import MemberRow from '@/features/group-management/components/members-table/member-row'
|
||||
import { GroupMembersProvider } from '@/features/group-management/context/group-members-context'
|
||||
import { User } from '../../../../../../types/group-management/user'
|
||||
|
||||
describe('MemberRow', function () {
|
||||
const subscriptionId = '123abc'
|
||||
|
||||
describe('default view', function () {
|
||||
describe('with an ordinary user', function () {
|
||||
let user: User
|
||||
|
||||
beforeEach(function () {
|
||||
user = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date('2070-11-21T03:00:00'),
|
||||
enrollment: undefined,
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<MemberRow
|
||||
user={user}
|
||||
openOffboardingModalForUser={sinon.stub()}
|
||||
openUnlinkUserModal={sinon.stub()}
|
||||
groupId={subscriptionId}
|
||||
setGroupUserAlert={sinon.stub()}
|
||||
/>
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
})
|
||||
|
||||
it('renders the row', function () {
|
||||
cy.get('tr')
|
||||
// Checkbox
|
||||
cy.findByTestId('select-single-checkbox').should('not.be.checked')
|
||||
// Email
|
||||
cy.get('tr').contains(user.email)
|
||||
// Name
|
||||
cy.get('tr').contains(user.first_name)
|
||||
cy.get('tr').contains(user.last_name)
|
||||
// Last active date
|
||||
cy.get('tr').contains('21st Nov 2070')
|
||||
// Dropdown button
|
||||
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
|
||||
'exist'
|
||||
)
|
||||
|
||||
cy.get('tr').contains('SSO').should('not.exist')
|
||||
cy.get('tr').contains('Managed').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a pending invite', function () {
|
||||
let user: User
|
||||
|
||||
beforeEach(function () {
|
||||
user = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: true,
|
||||
last_active_at: new Date('2070-11-21T03:00:00'),
|
||||
enrollment: undefined,
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<MemberRow
|
||||
user={user}
|
||||
openOffboardingModalForUser={sinon.stub()}
|
||||
openUnlinkUserModal={sinon.stub()}
|
||||
groupId={subscriptionId}
|
||||
setGroupUserAlert={sinon.stub()}
|
||||
/>
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
})
|
||||
|
||||
it('should render a "Pending invite" badge', function () {
|
||||
cy.findByTestId('badge-pending-invite').should(
|
||||
'have.text',
|
||||
'Pending invite'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a group admin', function () {
|
||||
let user: User
|
||||
|
||||
beforeEach(function () {
|
||||
user = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date('2070-11-21T03:00:00'),
|
||||
enrollment: undefined,
|
||||
isEntityAdmin: true,
|
||||
}
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<MemberRow
|
||||
user={user}
|
||||
openOffboardingModalForUser={sinon.stub()}
|
||||
openUnlinkUserModal={sinon.stub()}
|
||||
groupId={subscriptionId}
|
||||
setGroupUserAlert={sinon.stub()}
|
||||
/>
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
})
|
||||
|
||||
it('should render a "Group admin" symbol', function () {
|
||||
cy.findByTestId('group-admin-symbol').within(() => {
|
||||
cy.findByText(/group admin/i)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('selecting and unselecting user row', function () {
|
||||
let user: User
|
||||
|
||||
beforeEach(function () {
|
||||
user = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date('2070-11-21T03:00:00'),
|
||||
enrollment: undefined,
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<MemberRow
|
||||
user={user}
|
||||
openOffboardingModalForUser={sinon.stub()}
|
||||
openUnlinkUserModal={sinon.stub()}
|
||||
groupId={subscriptionId}
|
||||
setGroupUserAlert={sinon.stub()}
|
||||
/>
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
})
|
||||
|
||||
it('should select and unselect the user', function () {
|
||||
cy.findByTestId('select-single-checkbox').should('not.be.checked')
|
||||
cy.findByTestId('select-single-checkbox').click()
|
||||
cy.findByTestId('select-single-checkbox').should('be.checked')
|
||||
cy.findByTestId('select-single-checkbox').click()
|
||||
cy.findByTestId('select-single-checkbox').should('not.be.checked')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with Managed Users enabled', function () {
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-managedUsersActive', true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with an ordinary user', function () {
|
||||
let user: User
|
||||
|
||||
beforeEach(function () {
|
||||
user = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date('2070-11-21T03:00:00'),
|
||||
enrollment: undefined,
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<MemberRow
|
||||
user={user}
|
||||
openOffboardingModalForUser={sinon.stub()}
|
||||
openUnlinkUserModal={sinon.stub()}
|
||||
groupId={subscriptionId}
|
||||
setGroupUserAlert={sinon.stub()}
|
||||
/>
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
})
|
||||
|
||||
it('renders the row', function () {
|
||||
cy.get('tr').should('exist')
|
||||
// Checkbox
|
||||
cy.findByTestId('select-single-checkbox').should('not.be.checked')
|
||||
// Email
|
||||
cy.get('tr').contains(user.email)
|
||||
// Name
|
||||
cy.get('tr').contains(user.first_name)
|
||||
cy.get('tr').contains(user.last_name)
|
||||
// Last active date
|
||||
cy.get('tr').contains('21st Nov 2070')
|
||||
// Managed status
|
||||
cy.get('tr').contains('Managed')
|
||||
// Dropdown button
|
||||
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
|
||||
'exist'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a pending invite', function () {
|
||||
let user: User
|
||||
|
||||
beforeEach(function () {
|
||||
user = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: true,
|
||||
last_active_at: new Date('2070-11-21T03:00:00'),
|
||||
enrollment: undefined,
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<MemberRow
|
||||
user={user}
|
||||
openOffboardingModalForUser={sinon.stub()}
|
||||
openUnlinkUserModal={sinon.stub()}
|
||||
groupId={subscriptionId}
|
||||
setGroupUserAlert={sinon.stub()}
|
||||
/>
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
})
|
||||
|
||||
it('should render a "Pending invite" badge', function () {
|
||||
cy.findByTestId('badge-pending-invite').should(
|
||||
'have.text',
|
||||
'Pending invite'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a group admin', function () {
|
||||
let user: User
|
||||
|
||||
beforeEach(function () {
|
||||
user = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date('2070-11-21T03:00:00'),
|
||||
enrollment: undefined,
|
||||
isEntityAdmin: true,
|
||||
}
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<MemberRow
|
||||
user={user}
|
||||
openOffboardingModalForUser={sinon.stub()}
|
||||
openUnlinkUserModal={sinon.stub()}
|
||||
groupId={subscriptionId}
|
||||
setGroupUserAlert={sinon.stub()}
|
||||
/>
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
})
|
||||
|
||||
it('should render a "Group admin" symbol', function () {
|
||||
cy.findByTestId('group-admin-symbol').within(() => {
|
||||
cy.findByText(/group admin/i)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('selecting and unselecting user row', function () {
|
||||
let user: User
|
||||
|
||||
beforeEach(function () {
|
||||
user = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date('2070-11-21T03:00:00'),
|
||||
enrollment: undefined,
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<MemberRow
|
||||
user={user}
|
||||
openOffboardingModalForUser={sinon.stub()}
|
||||
openUnlinkUserModal={sinon.stub()}
|
||||
groupId={subscriptionId}
|
||||
setGroupUserAlert={sinon.stub()}
|
||||
/>
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
})
|
||||
|
||||
it('should select and unselect the user', function () {
|
||||
cy.findByTestId('select-single-checkbox').should('not.be.checked')
|
||||
cy.findByTestId('select-single-checkbox').click()
|
||||
cy.findByTestId('select-single-checkbox').should('be.checked')
|
||||
cy.findByTestId('select-single-checkbox').click()
|
||||
cy.findByTestId('select-single-checkbox').should('not.be.checked')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with Group SSO enabled', function () {
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-groupSSOActive', true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with an ordinary user', function () {
|
||||
let user: User
|
||||
|
||||
beforeEach(function () {
|
||||
user = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date('2070-11-21T03:00:00'),
|
||||
enrollment: undefined,
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<MemberRow
|
||||
user={user}
|
||||
openOffboardingModalForUser={sinon.stub()}
|
||||
openUnlinkUserModal={sinon.stub()}
|
||||
groupId={subscriptionId}
|
||||
setGroupUserAlert={sinon.stub()}
|
||||
/>
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
})
|
||||
|
||||
it('renders the row', function () {
|
||||
// Checkbox
|
||||
cy.findByTestId('select-single-checkbox').should('not.be.checked')
|
||||
// Email
|
||||
cy.get('tr').contains(user.email)
|
||||
// Name
|
||||
cy.get('tr').contains(user.first_name)
|
||||
cy.get('tr').contains(user.last_name)
|
||||
// Last active date
|
||||
cy.get('tr').contains('21st Nov 2070')
|
||||
// SSO status
|
||||
cy.get('tr').contains('SSO')
|
||||
// Dropdown button
|
||||
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
|
||||
'exist'
|
||||
)
|
||||
|
||||
cy.get('tr').contains('Managed').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a pending invite', function () {
|
||||
let user: User
|
||||
|
||||
beforeEach(function () {
|
||||
user = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: true,
|
||||
last_active_at: new Date('2070-11-21T03:00:00'),
|
||||
enrollment: undefined,
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<MemberRow
|
||||
user={user}
|
||||
openOffboardingModalForUser={sinon.stub()}
|
||||
openUnlinkUserModal={sinon.stub()}
|
||||
groupId={subscriptionId}
|
||||
setGroupUserAlert={sinon.stub()}
|
||||
/>
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
})
|
||||
|
||||
it('should render a "Pending invite" badge', function () {
|
||||
cy.findByTestId('badge-pending-invite').should(
|
||||
'have.text',
|
||||
'Pending invite'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a group admin', function () {
|
||||
let user: User
|
||||
|
||||
beforeEach(function () {
|
||||
user = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date('2070-11-21T03:00:00'),
|
||||
enrollment: undefined,
|
||||
isEntityAdmin: true,
|
||||
}
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<MemberRow
|
||||
user={user}
|
||||
openOffboardingModalForUser={sinon.stub()}
|
||||
openUnlinkUserModal={sinon.stub()}
|
||||
groupId={subscriptionId}
|
||||
setGroupUserAlert={sinon.stub()}
|
||||
/>
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
})
|
||||
|
||||
it('should render a "Group admin" symbol', function () {
|
||||
cy.findByTestId('group-admin-symbol').within(() => {
|
||||
cy.findByText(/group admin/i)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('selecting and unselecting user row', function () {
|
||||
let user: User
|
||||
|
||||
beforeEach(function () {
|
||||
user = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date('2070-11-21T03:00:00'),
|
||||
enrollment: undefined,
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<MemberRow
|
||||
user={user}
|
||||
openOffboardingModalForUser={sinon.stub()}
|
||||
openUnlinkUserModal={sinon.stub()}
|
||||
groupId={subscriptionId}
|
||||
setGroupUserAlert={sinon.stub()}
|
||||
/>
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
})
|
||||
|
||||
it('should select and unselect the user', function () {
|
||||
cy.findByTestId('select-single-checkbox').should('not.be.checked')
|
||||
cy.findByTestId('select-single-checkbox').click()
|
||||
cy.findByTestId('select-single-checkbox').should('be.checked')
|
||||
cy.findByTestId('select-single-checkbox').click()
|
||||
cy.findByTestId('select-single-checkbox').should('not.be.checked')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with Managed Users and Group SSO enabled', function () {
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-managedUsersActive', true)
|
||||
win.metaAttributesCache.set('ol-groupSSOActive', true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with an ordinary user', function () {
|
||||
let user: User
|
||||
|
||||
beforeEach(function () {
|
||||
user = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date('2070-11-21T03:00:00'),
|
||||
enrollment: undefined,
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<MemberRow
|
||||
user={user}
|
||||
openOffboardingModalForUser={sinon.stub()}
|
||||
openUnlinkUserModal={sinon.stub()}
|
||||
groupId={subscriptionId}
|
||||
setGroupUserAlert={sinon.stub()}
|
||||
/>
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
})
|
||||
|
||||
it('renders the row', function () {
|
||||
// Checkbox
|
||||
cy.findByTestId('select-single-checkbox').should('not.be.checked')
|
||||
// Email
|
||||
cy.get('tr').contains(user.email)
|
||||
// Name
|
||||
cy.get('tr').contains(user.first_name)
|
||||
cy.get('tr').contains(user.last_name)
|
||||
// Last active date
|
||||
cy.get('tr').contains('21st Nov 2070')
|
||||
// Managed status
|
||||
cy.get('tr').contains('Managed')
|
||||
// SSO status
|
||||
cy.get('tr').contains('SSO')
|
||||
// Dropdown button
|
||||
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
|
||||
'exist'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a pending invite', function () {
|
||||
let user: User
|
||||
|
||||
beforeEach(function () {
|
||||
user = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: true,
|
||||
last_active_at: new Date('2070-11-21T03:00:00'),
|
||||
enrollment: undefined,
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<MemberRow
|
||||
user={user}
|
||||
openOffboardingModalForUser={sinon.stub()}
|
||||
openUnlinkUserModal={sinon.stub()}
|
||||
groupId={subscriptionId}
|
||||
setGroupUserAlert={sinon.stub()}
|
||||
/>
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
})
|
||||
|
||||
it('should render a "Pending invite" badge', function () {
|
||||
cy.findByTestId('badge-pending-invite').should(
|
||||
'have.text',
|
||||
'Pending invite'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a group admin', function () {
|
||||
let user: User
|
||||
|
||||
beforeEach(function () {
|
||||
user = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date('2070-11-21T03:00:00'),
|
||||
enrollment: undefined,
|
||||
isEntityAdmin: true,
|
||||
}
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<MemberRow
|
||||
user={user}
|
||||
openOffboardingModalForUser={sinon.stub()}
|
||||
openUnlinkUserModal={sinon.stub()}
|
||||
groupId={subscriptionId}
|
||||
setGroupUserAlert={sinon.stub()}
|
||||
/>
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
})
|
||||
|
||||
it('should render a "Group admin" symbol', function () {
|
||||
cy.findByTestId('group-admin-symbol').within(() => {
|
||||
cy.findByText(/group admin/i)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('selecting and unselecting user row', function () {
|
||||
let user: User
|
||||
|
||||
beforeEach(function () {
|
||||
user = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date('2070-11-21T03:00:00'),
|
||||
enrollment: undefined,
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [user])
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<MemberRow
|
||||
user={user}
|
||||
openOffboardingModalForUser={sinon.stub()}
|
||||
openUnlinkUserModal={sinon.stub()}
|
||||
groupId={subscriptionId}
|
||||
setGroupUserAlert={sinon.stub()}
|
||||
/>
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
})
|
||||
|
||||
it('should select and unselect the user', function () {
|
||||
cy.findByTestId('select-single-checkbox').should('not.be.checked')
|
||||
cy.findByTestId('select-single-checkbox').click()
|
||||
cy.findByTestId('select-single-checkbox').should('be.checked')
|
||||
cy.findByTestId('select-single-checkbox').click()
|
||||
cy.findByTestId('select-single-checkbox').should('not.be.checked')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,332 @@
|
||||
import MembersList from '@/features/group-management/components/members-table/members-list'
|
||||
import { GroupMembersProvider } from '@/features/group-management/context/group-members-context'
|
||||
import { User } from '../../../../../../types/group-management/user'
|
||||
|
||||
const groupId = 'somegroup'
|
||||
|
||||
function mountManagedUsersList() {
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<MembersList groupId={groupId} />
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('MembersList', function () {
|
||||
describe('with users', function () {
|
||||
const users = [
|
||||
{
|
||||
_id: 'user-one',
|
||||
email: 'sarah.brennan@example.com',
|
||||
first_name: 'Sarah',
|
||||
last_name: 'Brennan',
|
||||
invite: false,
|
||||
last_active_at: new Date('2070-10-22T03:00:00'),
|
||||
enrollment: undefined,
|
||||
isEntityAdmin: undefined,
|
||||
},
|
||||
{
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date('2070-11-21T03:00:00'),
|
||||
enrollment: undefined,
|
||||
isEntityAdmin: undefined,
|
||||
},
|
||||
]
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', users)
|
||||
})
|
||||
mountManagedUsersList()
|
||||
})
|
||||
|
||||
it('should render the table headers but not SSO Column', function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-groupSSOActive', false)
|
||||
})
|
||||
mountManagedUsersList()
|
||||
|
||||
// Select-all checkbox
|
||||
cy.findByTestId('managed-entities-table').within(() => {
|
||||
cy.findByTestId('select-all-checkbox')
|
||||
})
|
||||
cy.findByTestId('managed-entities-table').should('contain.text', 'Email')
|
||||
cy.findByTestId('managed-entities-table').should('contain.text', 'Name')
|
||||
cy.findByTestId('managed-entities-table').should(
|
||||
'contain.text',
|
||||
'Last Active'
|
||||
)
|
||||
cy.findByTestId('managed-entities-table').should(
|
||||
'not.contain.text',
|
||||
'Security'
|
||||
)
|
||||
})
|
||||
it('should render the table headers with SSO Column', function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-groupSSOActive', true)
|
||||
})
|
||||
mountManagedUsersList()
|
||||
|
||||
// Select-all checkbox
|
||||
cy.findByTestId('managed-entities-table').within(() => {
|
||||
cy.findByTestId('select-all-checkbox')
|
||||
})
|
||||
|
||||
cy.findByTestId('managed-entities-table').should('contain.text', 'Email')
|
||||
cy.findByTestId('managed-entities-table').should('contain.text', 'Name')
|
||||
cy.findByTestId('managed-entities-table').should(
|
||||
'contain.text',
|
||||
'Last Active'
|
||||
)
|
||||
cy.findByTestId('managed-entities-table').should(
|
||||
'contain.text',
|
||||
'Security'
|
||||
)
|
||||
})
|
||||
|
||||
it('should render the list of users', function () {
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.findAllByRole('row').should('have.length', 2)
|
||||
})
|
||||
// First user
|
||||
cy.findByTestId('managed-entities-table').should(
|
||||
'contain.text',
|
||||
users[0].email
|
||||
)
|
||||
cy.findByTestId('managed-entities-table').should(
|
||||
'contain.text',
|
||||
users[0].first_name
|
||||
)
|
||||
cy.findByTestId('managed-entities-table').should(
|
||||
'contain.text',
|
||||
users[0].last_name
|
||||
)
|
||||
// Second user
|
||||
cy.findByTestId('managed-entities-table').should(
|
||||
'contain.text',
|
||||
users[1].email
|
||||
)
|
||||
cy.findByTestId('managed-entities-table').should(
|
||||
'contain.text',
|
||||
users[1].first_name
|
||||
)
|
||||
cy.findByTestId('managed-entities-table').should(
|
||||
'contain.text',
|
||||
users[1].last_name
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('empty user list', function () {
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [])
|
||||
})
|
||||
cy.mount(
|
||||
<GroupMembersProvider>
|
||||
<MembersList groupId={groupId} />
|
||||
</GroupMembersProvider>
|
||||
)
|
||||
})
|
||||
|
||||
it('should render the list, with a "no members" message', function () {
|
||||
cy.findByTestId('managed-entities-table').should(
|
||||
'contain.text',
|
||||
'No members'
|
||||
)
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.findAllByRole('row')
|
||||
.should('have.length', 1)
|
||||
.and('contain.text', 'No members')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('SSO unlinking', function () {
|
||||
const USER_PENDING_INVITE: User = {
|
||||
_id: 'abc123def456',
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
email: 'john.doe@test.com',
|
||||
last_active_at: new Date('2023-01-15'),
|
||||
invite: true,
|
||||
}
|
||||
const USER_NOT_LINKED: User = {
|
||||
_id: 'bcd234efa567',
|
||||
first_name: 'Bobby',
|
||||
last_name: 'Lapointe',
|
||||
email: 'bobby.lapointe@test.com',
|
||||
last_active_at: new Date('2023-01-02'),
|
||||
invite: false,
|
||||
}
|
||||
const USER_LINKED: User = {
|
||||
_id: 'defabc231453',
|
||||
first_name: 'Claire',
|
||||
last_name: 'Jennings',
|
||||
email: 'claire.jennings@test.com',
|
||||
last_active_at: new Date('2023-01-03'),
|
||||
invite: false,
|
||||
enrollment: {
|
||||
sso: [
|
||||
{
|
||||
groupId,
|
||||
linkedAt: new Date('2023-01-03'),
|
||||
primary: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
const USER_LINKED_AND_MANAGED: User = {
|
||||
_id: 'defabc231453',
|
||||
first_name: 'Jean-Luc',
|
||||
last_name: 'Picard',
|
||||
email: 'picard@test.com',
|
||||
last_active_at: new Date('2023-01-03'),
|
||||
invite: false,
|
||||
enrollment: {
|
||||
managedBy: groupId,
|
||||
enrolledAt: new Date('2023-01-03'),
|
||||
sso: [
|
||||
{
|
||||
groupId,
|
||||
linkedAt: new Date('2023-01-03'),
|
||||
primary: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
const users = [
|
||||
USER_PENDING_INVITE,
|
||||
USER_NOT_LINKED,
|
||||
USER_LINKED,
|
||||
USER_LINKED_AND_MANAGED,
|
||||
]
|
||||
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-groupId', groupId)
|
||||
win.metaAttributesCache.set('ol-users', users)
|
||||
win.metaAttributesCache.set('ol-groupSSOActive', true)
|
||||
})
|
||||
|
||||
cy.intercept('POST', `manage/groups/${groupId}/unlink-user/*`, {
|
||||
statusCode: 200,
|
||||
})
|
||||
})
|
||||
|
||||
describe('unlinking user', function () {
|
||||
beforeEach(function () {
|
||||
mountManagedUsersList()
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(3)').within(() => {
|
||||
cy.findByText('SSO active')
|
||||
cy.findByRole('button', { name: /actions/i }).click()
|
||||
cy.findByTestId('unlink-user-action').click()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should show successs notification and update the user row after unlinking', function () {
|
||||
cy.findByRole('dialog').within(() => {
|
||||
cy.findByRole('button', { name: /unlink user/i }).click()
|
||||
})
|
||||
cy.findByRole('alert').should(
|
||||
'contain.text',
|
||||
`SSO reauthentication request has been sent to ${USER_LINKED.email}`
|
||||
)
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(3)').within(() => {
|
||||
cy.findByText('SSO not active')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('managed users enabled', function () {
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-managedUsersActive', true)
|
||||
})
|
||||
mountManagedUsersList()
|
||||
})
|
||||
|
||||
describe('when user is not managed', function () {
|
||||
beforeEach(function () {
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(3)').within(() => {
|
||||
cy.findByText('SSO active')
|
||||
cy.findByText('Not managed')
|
||||
cy.findByRole('button', { name: /actions/i }).click()
|
||||
cy.findByTestId('unlink-user-action').click()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should show successs notification and update the user row after unlinking', function () {
|
||||
cy.findByRole('dialog').within(() => {
|
||||
cy.findByRole('button', { name: /unlink user/i }).click()
|
||||
})
|
||||
cy.findByRole('alert').should(
|
||||
'contain.text',
|
||||
`SSO reauthentication request has been sent to ${USER_LINKED.email}`
|
||||
)
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(3)').within(() => {
|
||||
cy.findByText('SSO not active')
|
||||
cy.findByText('Not managed')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('when user is managed', function () {
|
||||
beforeEach(function () {
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(4)').within(() => {
|
||||
cy.findByText('SSO active')
|
||||
cy.findAllByText('Managed')
|
||||
cy.findByRole('button', { name: /actions/i }).click()
|
||||
cy.findByTestId('unlink-user-action').click()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should show successs notification and update the user row after unlinking', function () {
|
||||
cy.findByRole('dialog').within(() => {
|
||||
cy.findByRole('button', { name: /unlink user/i }).click()
|
||||
})
|
||||
cy.findByRole('alert').should(
|
||||
'contain.text',
|
||||
`SSO reauthentication request has been sent to ${USER_LINKED_AND_MANAGED.email}`
|
||||
)
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(4)').within(() => {
|
||||
cy.findByText('SSO not active')
|
||||
cy.findAllByText('Managed')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
import OffboardManagedUserModal from '@/features/group-management/components/members-table/offboard-managed-user-modal'
|
||||
import sinon from 'sinon'
|
||||
|
||||
describe('OffboardManagedUserModal', function () {
|
||||
describe('happy path', function () {
|
||||
const groupId = 'some-group'
|
||||
const user = {
|
||||
_id: 'some-user',
|
||||
email: 'some.user@example.com',
|
||||
first_name: 'Some',
|
||||
last_name: 'User',
|
||||
invite: true,
|
||||
last_active_at: new Date(),
|
||||
enrollment: {
|
||||
managedBy: `${groupId}`,
|
||||
enrolledAt: new Date(),
|
||||
},
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
const otherUser = {
|
||||
_id: 'other-user',
|
||||
email: 'other.user@example.com',
|
||||
first_name: 'Other',
|
||||
last_name: 'User',
|
||||
invite: false,
|
||||
last_active_at: new Date(),
|
||||
enrollment: {
|
||||
managedBy: `${groupId}`,
|
||||
enrolledAt: new Date(),
|
||||
},
|
||||
isEntityAdmin: undefined,
|
||||
}
|
||||
const allMembers = [user, otherUser]
|
||||
|
||||
beforeEach(function () {
|
||||
cy.mount(
|
||||
<OffboardManagedUserModal
|
||||
user={user}
|
||||
allMembers={allMembers}
|
||||
groupId={groupId}
|
||||
onClose={sinon.stub()}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
it('should render the modal', function () {
|
||||
cy.get('#delete-user-form').should('exist')
|
||||
})
|
||||
|
||||
it('should disable the button if a recipient is not selected', function () {
|
||||
// Button should be disabled initially
|
||||
cy.get('button[type="submit"]').should('be.disabled')
|
||||
|
||||
// Not selecting a recipient...
|
||||
|
||||
// Fill in the email input
|
||||
cy.get('#supplied-email-input').type(user.email)
|
||||
|
||||
// Button still disabled
|
||||
cy.get('button[type="submit"]').should('be.disabled')
|
||||
})
|
||||
|
||||
it('should disable the button if the email is not filled in', function () {
|
||||
// Button should be disabled initially
|
||||
cy.get('button[type="submit"]').should('be.disabled')
|
||||
|
||||
// Select a recipient
|
||||
cy.get('#recipient-select-input').select('other.user@example.com')
|
||||
|
||||
// Not filling in the email...
|
||||
|
||||
// Button still disabled
|
||||
cy.get('button[type="submit"]').should('be.disabled')
|
||||
})
|
||||
|
||||
it('should disable the button if the email does not match the user', function () {
|
||||
// Button should be disabled initially
|
||||
cy.get('button[type="submit"]').should('be.disabled')
|
||||
|
||||
// Select a recipient
|
||||
cy.get('#recipient-select-input').select('other.user@example.com')
|
||||
|
||||
// Fill in the email input, with the wrong email address
|
||||
cy.get('#supplied-email-input').type('totally.wrong@example.com')
|
||||
|
||||
// Button still disabled
|
||||
cy.get('button[type="submit"]').should('be.disabled')
|
||||
})
|
||||
|
||||
it('should fill out the form, and enable the delete button', function () {
|
||||
// Button should be disabled initially
|
||||
cy.get('button[type="submit"]').should('be.disabled')
|
||||
|
||||
// Select a recipient
|
||||
cy.get('#recipient-select-input').select('other.user@example.com')
|
||||
|
||||
// Button still disabled
|
||||
cy.get('button[type="submit"]').should('be.disabled')
|
||||
|
||||
// Fill in the email input
|
||||
cy.get('#supplied-email-input').type(user.email)
|
||||
|
||||
// Button should be enabled now
|
||||
cy.get('button[type="submit"]').should('not.be.disabled')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { ReactElement } from 'react'
|
||||
import sinon from 'sinon'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import UnlinkUserModal from '@/features/group-management/components/members-table/unlink-user-modal'
|
||||
import { GroupMembersProvider } from '@/features/group-management/context/group-members-context'
|
||||
import { expect } from 'chai'
|
||||
|
||||
export function renderWithContext(component: ReactElement, props = {}) {
|
||||
const GroupMembersProviderWrapper = ({
|
||||
children,
|
||||
}: {
|
||||
children: ReactElement
|
||||
}) => <GroupMembersProvider {...props}>{children}</GroupMembersProvider>
|
||||
|
||||
return render(component, { wrapper: GroupMembersProviderWrapper })
|
||||
}
|
||||
|
||||
describe('<UnlinkUserModal />', function () {
|
||||
let defaultProps: any
|
||||
const groupId = 'group123'
|
||||
const userId = 'user123'
|
||||
|
||||
beforeEach(function () {
|
||||
defaultProps = {
|
||||
onClose: sinon.stub(),
|
||||
user: { _id: userId },
|
||||
setGroupUserAlert: sinon.stub(),
|
||||
}
|
||||
window.metaAttributesCache.set('ol-groupId', groupId)
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('displays the modal', async function () {
|
||||
renderWithContext(<UnlinkUserModal {...defaultProps} />)
|
||||
await screen.findByRole('heading', {
|
||||
name: 'Unlink user',
|
||||
})
|
||||
screen.getByText('You’re about to remove the SSO login option for', {
|
||||
exact: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('closes the modal on success', async function () {
|
||||
fetchMock.post(`/manage/groups/${groupId}/unlink-user/${userId}`, 200)
|
||||
|
||||
renderWithContext(<UnlinkUserModal {...defaultProps} />)
|
||||
await screen.findByRole('heading', {
|
||||
name: 'Unlink user',
|
||||
})
|
||||
|
||||
const confirmButton = screen.getByRole('button', { name: 'Unlink user' })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => expect(defaultProps.onClose).to.have.been.called)
|
||||
})
|
||||
|
||||
it('handles errors', async function () {
|
||||
fetchMock.post(`/manage/groups/${groupId}/unlink-user/${userId}`, 500)
|
||||
|
||||
renderWithContext(<UnlinkUserModal {...defaultProps} />)
|
||||
await screen.findByRole('heading', {
|
||||
name: 'Unlink user',
|
||||
})
|
||||
|
||||
const confirmButton = screen.getByRole('button', { name: 'Unlink user' })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => screen.findByText('Sorry, something went wrong'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { SplitTestProvider } from '@/shared/context/split-test-context'
|
||||
import MissingBillingInformation from '@/features/group-management/components/missing-billing-information'
|
||||
|
||||
describe('<MissingBillingInformation />', function () {
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<SplitTestProvider>
|
||||
<MissingBillingInformation />
|
||||
</SplitTestProvider>
|
||||
)
|
||||
})
|
||||
|
||||
it('shows missing payment details notification', function () {
|
||||
cy.findByRole('alert').within(() => {
|
||||
cy.findByText(/missing payment details/i)
|
||||
cy.findByText(
|
||||
/it looks like your payment details are missing\. Please.*, or.*with our Support team for more help/i
|
||||
).within(() => {
|
||||
cy.findByRole('link', {
|
||||
name: /update your billing information/i,
|
||||
}).should(
|
||||
'have.attr',
|
||||
'href',
|
||||
'/user/subscription/recurly/billing-details'
|
||||
)
|
||||
cy.findByRole('link', { name: /get in touch/i }).should(
|
||||
'have.attr',
|
||||
'href',
|
||||
'/contact'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,171 @@
|
||||
import PublisherManagers from '@/features/group-management/components/publisher-managers'
|
||||
|
||||
const JOHN_DOE = {
|
||||
_id: 'abc123def456',
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
email: 'john.doe@test.com',
|
||||
last_active_at: new Date('2023-01-15'),
|
||||
invite: true,
|
||||
}
|
||||
const BOBBY_LAPOINTE = {
|
||||
_id: 'bcd234efa567',
|
||||
first_name: 'Bobby',
|
||||
last_name: 'Lapointe',
|
||||
email: 'bobby.lapointe@test.com',
|
||||
last_active_at: new Date('2023-01-02'),
|
||||
invite: false,
|
||||
}
|
||||
const GROUP_ID = '000fff000fff'
|
||||
const PATHS = {
|
||||
addMember: `/manage/publishers/${GROUP_ID}/managers`,
|
||||
removeMember: `/manage/publishers/${GROUP_ID}/managers`,
|
||||
}
|
||||
|
||||
describe('publisher managers', function () {
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-users', [JOHN_DOE, BOBBY_LAPOINTE])
|
||||
win.metaAttributesCache.set('ol-groupId', GROUP_ID)
|
||||
win.metaAttributesCache.set('ol-groupName', 'My Awesome Publisher')
|
||||
})
|
||||
|
||||
cy.mount(<PublisherManagers />)
|
||||
})
|
||||
|
||||
it('renders the publisher management page', function () {
|
||||
cy.findByRole('heading', { name: /my awesome publisher/i, level: 1 })
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByText('john.doe@test.com')
|
||||
cy.findByText('John Doe')
|
||||
cy.findByText('15th Jan 2023')
|
||||
cy.findByText('Invite not yet accepted')
|
||||
})
|
||||
|
||||
cy.get('tr:nth-child(2)').within(() => {
|
||||
cy.findByText('bobby.lapointe@test.com')
|
||||
cy.findByText('Bobby Lapointe')
|
||||
cy.findByText('2nd Jan 2023')
|
||||
cy.findByText('Accepted invite')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('sends an invite', function () {
|
||||
cy.intercept('POST', PATHS.addMember, {
|
||||
statusCode: 201,
|
||||
body: {
|
||||
user: {
|
||||
email: 'someone.else@test.com',
|
||||
invite: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cy.findByTestId('add-members-form').within(() => {
|
||||
cy.findByRole('textbox').type('someone.else@test.com')
|
||||
cy.findByRole('button').click()
|
||||
})
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(3)').within(() => {
|
||||
cy.findByText('someone.else@test.com')
|
||||
cy.findByText('N/A')
|
||||
cy.findByText('Invite not yet accepted')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('tries to send an invite and displays the error', function () {
|
||||
cy.intercept('POST', PATHS.addMember, {
|
||||
statusCode: 500,
|
||||
body: {
|
||||
error: {
|
||||
message: 'User already added',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cy.findByTestId('add-members-form').within(() => {
|
||||
cy.findByRole('textbox').type('someone.else@test.com')
|
||||
cy.findByRole('button').click()
|
||||
})
|
||||
cy.findByRole('alert').should('contain.text', 'Error: User already added')
|
||||
})
|
||||
|
||||
it('checks the select all checkbox', function () {
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByLabelText(/select user/i).should('not.be.checked')
|
||||
})
|
||||
cy.get('tr:nth-child(2)').within(() => {
|
||||
cy.findByLabelText(/select user/i).should('not.be.checked')
|
||||
})
|
||||
})
|
||||
|
||||
cy.findByTestId('select-all-checkbox').click()
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByLabelText(/select user/i).should('be.checked')
|
||||
})
|
||||
cy.get('tr:nth-child(2)').within(() => {
|
||||
cy.findByLabelText(/select user/i).should('be.checked')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('remove a member', function () {
|
||||
cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
|
||||
statusCode: 200,
|
||||
})
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByLabelText(/select user/i).check()
|
||||
})
|
||||
})
|
||||
|
||||
cy.findByRole('button', { name: 'Remove manager' }).click()
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByText('bobby.lapointe@test.com')
|
||||
cy.findByText('Bobby Lapointe')
|
||||
cy.findByText('2nd Jan 2023')
|
||||
cy.findByText('Accepted invite')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('tries to remove a manager and displays the error', function () {
|
||||
cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
|
||||
statusCode: 500,
|
||||
})
|
||||
|
||||
cy.findByTestId('managed-entities-table')
|
||||
.find('tbody')
|
||||
.within(() => {
|
||||
cy.get('tr:nth-child(1)').within(() => {
|
||||
cy.findByLabelText(/select user/i).check()
|
||||
})
|
||||
})
|
||||
cy.findByRole('button', { name: /remove manager/i }).click()
|
||||
|
||||
cy.findByRole('alert').should('contain.text', 'Sorry, something went wrong')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import RequestStatus from '@/features/group-management/components/request-status'
|
||||
|
||||
describe('<RequestStatus />', function () {
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
|
||||
})
|
||||
cy.mount(
|
||||
<RequestStatus icon="email" title="Test title" content="Test content" />
|
||||
)
|
||||
})
|
||||
|
||||
it('renders the back button', function () {
|
||||
cy.findByTestId('group-heading').within(() => {
|
||||
cy.findByRole('button', { name: /back to subscription/i }).should(
|
||||
'have.attr',
|
||||
'href',
|
||||
'/user/subscription'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the group name', function () {
|
||||
cy.findByTestId('group-heading').within(() => {
|
||||
cy.findByRole('heading', { name: 'My Awesome Team' })
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the title', function () {
|
||||
cy.findByTestId('title').should('contain.text', 'Test title')
|
||||
})
|
||||
|
||||
it('shows the content', function () {
|
||||
cy.findByText('Test content')
|
||||
})
|
||||
|
||||
it('renders the link to subscriptions', function () {
|
||||
cy.findByRole('button', { name: /go to subscriptions/i }).should(
|
||||
'have.attr',
|
||||
'href',
|
||||
'/user/subscription'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import SubtotalLimitExceeded from '@/features/group-management/components/subtotal-limit-exceeded'
|
||||
|
||||
describe('<SubtotalLimitExceeded />', function () {
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
|
||||
})
|
||||
|
||||
cy.mount(<SubtotalLimitExceeded />)
|
||||
})
|
||||
|
||||
it('shows subtotal limit exceeded notification', function () {
|
||||
cy.findByRole('alert').within(() => {
|
||||
cy.findByText(
|
||||
/sorry, there was an issue upgrading your subscription\. Please.*for help/i
|
||||
).within(() => {
|
||||
cy.findByRole('link', {
|
||||
name: /contact our support team/i,
|
||||
}).should('have.attr', 'href', '/contact')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,150 @@
|
||||
import UpgradeSubscription from '@/features/group-management/components/upgrade-subscription/upgrade-subscription'
|
||||
import { SubscriptionChangePreview } from '../../../../../types/subscription/subscription-change-preview'
|
||||
|
||||
describe('<UpgradeSubscription />', function () {
|
||||
const resetPreviewAndRemount = (preview: SubscriptionChangePreview) => {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-subscriptionChangePreview', preview)
|
||||
})
|
||||
|
||||
cy.mount(<UpgradeSubscription />)
|
||||
}
|
||||
beforeEach(function () {
|
||||
this.totalLicenses = 2
|
||||
this.preview = {
|
||||
change: {
|
||||
type: 'group-plan-upgrade',
|
||||
prevPlan: { name: 'Overleaf Standard Group' },
|
||||
},
|
||||
currency: 'USD',
|
||||
immediateCharge: {
|
||||
subtotal: 353.99,
|
||||
tax: 70.8,
|
||||
total: 424.79,
|
||||
discount: 0,
|
||||
},
|
||||
paymentMethod: 'Visa **** 1111',
|
||||
nextPlan: { annual: true },
|
||||
nextInvoice: {
|
||||
date: '2025-11-05T11:35:32.000Z',
|
||||
plan: { name: 'Overleaf Professional Group', amount: 0 },
|
||||
addOns: [
|
||||
{
|
||||
code: 'additional-license',
|
||||
name: 'Seat',
|
||||
quantity: 2,
|
||||
unitAmount: 399,
|
||||
amount: 798,
|
||||
},
|
||||
],
|
||||
subtotal: 798,
|
||||
tax: { rate: 0.2, amount: 159.6 },
|
||||
total: 957.6,
|
||||
},
|
||||
}
|
||||
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
|
||||
win.metaAttributesCache.set('ol-totalLicenses', this.totalLicenses)
|
||||
})
|
||||
resetPreviewAndRemount(this.preview)
|
||||
})
|
||||
|
||||
it('shows the group name', function () {
|
||||
cy.findByTestId('group-heading').within(() => {
|
||||
cy.findByRole('heading', { name: 'My Awesome Team' })
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the "Add more licenses to my plan" label', function () {
|
||||
cy.findByText(/add more licenses to my plan/i).should(
|
||||
'have.attr',
|
||||
'href',
|
||||
'/user/subscription/group/add-users'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows the "Upgrade" and "Cancel" buttons', function () {
|
||||
cy.findByRole('button', { name: /upgrade/i })
|
||||
cy.findByRole('button', { name: /cancel/i }).should(
|
||||
'have.attr',
|
||||
'href',
|
||||
'/user/subscription'
|
||||
)
|
||||
})
|
||||
|
||||
describe('shows plan details', function () {
|
||||
it('shows per user price', function () {
|
||||
cy.findByTestId('per-user-price').within(() => {
|
||||
cy.findByText('$399')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows additional features', function () {
|
||||
cy.findByText(/unlimited collaborators per project/i)
|
||||
cy.findByText(/sso/i)
|
||||
cy.findByText(/managed user accounts/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shows upgrade summary', function () {
|
||||
it('shows subtotal, tax and total price', function () {
|
||||
cy.findByTestId('subtotal').within(() => {
|
||||
cy.findByText('$353.99')
|
||||
})
|
||||
cy.findByTestId('tax').within(() => {
|
||||
cy.findByText('$70.80')
|
||||
})
|
||||
cy.findByTestId('total').within(() => {
|
||||
cy.findByText('$424.79')
|
||||
})
|
||||
cy.findByTestId('discount').should('not.exist')
|
||||
})
|
||||
|
||||
it('shows subtotal, discount, tax and total price', function () {
|
||||
resetPreviewAndRemount({
|
||||
...this.preview,
|
||||
immediateCharge: {
|
||||
subtotal: 353.99,
|
||||
tax: 70.8,
|
||||
total: 424.79,
|
||||
discount: 50,
|
||||
},
|
||||
})
|
||||
cy.findByTestId('subtotal').within(() => {
|
||||
cy.findByText('$353.99')
|
||||
})
|
||||
cy.findByTestId('tax').within(() => {
|
||||
cy.findByText('$70.80')
|
||||
})
|
||||
cy.findByTestId('total').within(() => {
|
||||
cy.findByText('$424.79')
|
||||
})
|
||||
cy.findByTestId('discount').within(() => {
|
||||
cy.findByText('($50.00)')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows total users', function () {
|
||||
cy.findByText(/you have 2 licenses on your subscription./i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('submit upgrade request', function () {
|
||||
it('request succeeded', function () {
|
||||
cy.intercept('POST', '/user/subscription/group/upgrade-subscription', {
|
||||
statusCode: 200,
|
||||
}).as('upgradeRequest')
|
||||
cy.findByRole('button', { name: /upgrade/i }).click()
|
||||
cy.findByText(/you’ve upgraded your plan!/i)
|
||||
})
|
||||
|
||||
it('request failed', function () {
|
||||
cy.intercept('POST', '/user/subscription/group/upgrade-subscription', {
|
||||
statusCode: 400,
|
||||
}).as('upgradeRequest')
|
||||
cy.findByRole('button', { name: /upgrade/i }).click()
|
||||
cy.findByText(/something went wrong/i)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,680 @@
|
||||
import { useState } from 'react'
|
||||
import ToggleSwitch from '../../../../../frontend/js/features/history/components/change-list/toggle-switch'
|
||||
import ChangeList from '../../../../../frontend/js/features/history/components/change-list/change-list'
|
||||
import {
|
||||
EditorProviders,
|
||||
USER_EMAIL,
|
||||
USER_ID,
|
||||
} from '../../../helpers/editor-providers'
|
||||
import { HistoryProvider } from '../../../../../frontend/js/features/history/context/history-context'
|
||||
import { updates } from '../fixtures/updates'
|
||||
import { labels } from '../fixtures/labels'
|
||||
import { formatTime, relativeDate } from '@/features/utils/format-date'
|
||||
|
||||
const mountWithEditorProviders = (
|
||||
component: React.ReactNode,
|
||||
scope: Record<string, unknown> = {},
|
||||
props: Record<string, unknown> = {}
|
||||
) => {
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope} {...props}>
|
||||
<HistoryProvider>
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<div className="history-react">{component}</div>
|
||||
</div>
|
||||
</HistoryProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
}
|
||||
|
||||
describe('change list (Bootstrap 5)', function () {
|
||||
const scope = {
|
||||
ui: { view: 'history', pdfLayout: 'sideBySide', chatOpen: true },
|
||||
}
|
||||
|
||||
const waitForData = () => {
|
||||
cy.wait('@updates')
|
||||
cy.wait('@labels')
|
||||
cy.wait('@diff')
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.intercept('GET', '/project/*/updates*', {
|
||||
body: updates,
|
||||
}).as('updates')
|
||||
cy.intercept('GET', '/project/*/labels', {
|
||||
body: labels,
|
||||
}).as('labels')
|
||||
cy.intercept('GET', '/project/*/filetree/diff*', {
|
||||
body: { diff: [{ pathname: 'main.tex' }, { pathname: 'name.tex' }] },
|
||||
}).as('diff')
|
||||
window.metaAttributesCache.set('ol-inactiveTutorials', [
|
||||
'react-history-buttons-tutorial',
|
||||
])
|
||||
})
|
||||
|
||||
describe('toggle switch', function () {
|
||||
it('renders switch buttons', function () {
|
||||
mountWithEditorProviders(
|
||||
<ToggleSwitch labelsOnly={false} setLabelsOnly={() => {}} />
|
||||
)
|
||||
|
||||
cy.findByLabelText(/all history/i)
|
||||
cy.findByLabelText(/labels/i)
|
||||
})
|
||||
|
||||
it('toggles "all history" and "labels" buttons', function () {
|
||||
function ToggleSwitchWrapped({ labelsOnly }: { labelsOnly: boolean }) {
|
||||
const [labelsOnlyLocal, setLabelsOnlyLocal] = useState(labelsOnly)
|
||||
return (
|
||||
<ToggleSwitch
|
||||
labelsOnly={labelsOnlyLocal}
|
||||
setLabelsOnly={setLabelsOnlyLocal}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
mountWithEditorProviders(<ToggleSwitchWrapped labelsOnly={false} />)
|
||||
|
||||
cy.findByLabelText(/all history/i).as('all-history')
|
||||
cy.findByLabelText(/labels/i).as('labels')
|
||||
cy.get('@all-history').should('be.checked')
|
||||
cy.get('@labels').should('not.be.checked')
|
||||
cy.get('@labels').click({ force: true })
|
||||
cy.get('@all-history').should('not.be.checked')
|
||||
cy.get('@labels').should('be.checked')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tags', function () {
|
||||
it('renders tags', function () {
|
||||
mountWithEditorProviders(<ChangeList />, scope, {
|
||||
user: {
|
||||
id: USER_ID,
|
||||
email: USER_EMAIL,
|
||||
isAdmin: true,
|
||||
},
|
||||
})
|
||||
waitForData()
|
||||
|
||||
cy.findByLabelText(/all history/i).click({ force: true })
|
||||
cy.findAllByTestId('history-version-details').as('details')
|
||||
cy.get('@details').should('have.length', 5)
|
||||
// start with 2nd details entry, as first has no tags
|
||||
cy.get('@details')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.findAllByTestId('history-version-badge').as('tags')
|
||||
})
|
||||
cy.get('@tags').should('have.length', 2)
|
||||
cy.get('@tags').eq(0).should('contain.text', 'tag-2')
|
||||
cy.get('@tags').eq(1).should('contain.text', 'tag-1')
|
||||
// should have delete buttons
|
||||
cy.get('@tags').each(tag =>
|
||||
cy.wrap(tag).within(() => {
|
||||
cy.findByRole('button', { name: /delete/i })
|
||||
})
|
||||
)
|
||||
// 3rd details entry
|
||||
cy.get('@details')
|
||||
.eq(2)
|
||||
.within(() => {
|
||||
cy.findAllByTestId('history-version-badge').should('have.length', 0)
|
||||
})
|
||||
// 4th details entry
|
||||
cy.get('@details')
|
||||
.eq(3)
|
||||
.within(() => {
|
||||
cy.findAllByTestId('history-version-badge').as('tags')
|
||||
})
|
||||
cy.get('@tags').should('have.length', 2)
|
||||
cy.get('@tags').eq(0).should('contain.text', 'tag-4')
|
||||
cy.get('@tags').eq(1).should('contain.text', 'tag-3')
|
||||
// should not have delete buttons
|
||||
cy.get('@tags').each(tag =>
|
||||
cy.wrap(tag).within(() => {
|
||||
cy.findByRole('button', { name: /delete/i }).should('not.exist')
|
||||
})
|
||||
)
|
||||
cy.findByLabelText(/labels/i).click({ force: true })
|
||||
cy.findAllByTestId('history-version-details').as('details')
|
||||
// first details on labels is always "current version", start testing on second
|
||||
cy.get('@details').should('have.length', 3)
|
||||
cy.get('@details')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.findAllByTestId('history-version-badge').as('tags')
|
||||
})
|
||||
cy.get('@tags').should('have.length', 2)
|
||||
cy.get('@tags').eq(0).should('contain.text', 'tag-2')
|
||||
cy.get('@tags').eq(1).should('contain.text', 'tag-1')
|
||||
cy.get('@details')
|
||||
.eq(2)
|
||||
.within(() => {
|
||||
cy.findAllByTestId('history-version-badge').as('tags')
|
||||
})
|
||||
cy.get('@tags').should('have.length', 3)
|
||||
cy.get('@tags').eq(0).should('contain.text', 'tag-5')
|
||||
cy.get('@tags').eq(1).should('contain.text', 'tag-4')
|
||||
cy.get('@tags').eq(2).should('contain.text', 'tag-3')
|
||||
})
|
||||
|
||||
it('deletes tag', function () {
|
||||
mountWithEditorProviders(<ChangeList />, scope, {
|
||||
user: {
|
||||
id: USER_ID,
|
||||
email: USER_EMAIL,
|
||||
isAdmin: true,
|
||||
},
|
||||
})
|
||||
waitForData()
|
||||
|
||||
cy.findByLabelText(/all history/i).click({ force: true })
|
||||
|
||||
const labelToDelete = 'tag-2'
|
||||
cy.findAllByTestId('history-version-details').eq(1).as('details')
|
||||
cy.get('@details').within(() => {
|
||||
cy.findAllByTestId('history-version-badge').eq(0).as('tag')
|
||||
})
|
||||
cy.get('@tag').should('contain.text', labelToDelete)
|
||||
cy.get('@tag').within(() => {
|
||||
cy.findByRole('button', { name: /delete/i }).as('delete-btn')
|
||||
})
|
||||
cy.get('@delete-btn').click()
|
||||
cy.findByRole('dialog').as('modal')
|
||||
cy.get('@modal').within(() => {
|
||||
cy.findByRole('heading', { name: /delete label/i })
|
||||
})
|
||||
cy.get('@modal').contains(
|
||||
new RegExp(
|
||||
`are you sure you want to delete the following label "${labelToDelete}"?`,
|
||||
'i'
|
||||
)
|
||||
)
|
||||
cy.get('@modal').within(() => {
|
||||
cy.findByRole('button', { name: /cancel/i }).click()
|
||||
})
|
||||
cy.findByRole('dialog').should('not.exist')
|
||||
cy.get('@delete-btn').click()
|
||||
cy.findByRole('dialog').as('modal')
|
||||
cy.intercept('DELETE', '/project/*/labels/*', {
|
||||
statusCode: 500,
|
||||
}).as('delete')
|
||||
cy.get('@modal').within(() => {
|
||||
cy.findByRole('button', { name: /delete/i }).click()
|
||||
})
|
||||
cy.wait('@delete')
|
||||
cy.get('@modal').within(() => {
|
||||
cy.findByRole('alert').within(() => {
|
||||
cy.contains(/sorry, something went wrong/i)
|
||||
})
|
||||
})
|
||||
cy.findByText(labelToDelete).should('have.length', 1)
|
||||
|
||||
cy.intercept('DELETE', '/project/*/labels/*', {
|
||||
statusCode: 204,
|
||||
}).as('delete')
|
||||
cy.get('@modal').within(() => {
|
||||
cy.findByRole('button', { name: /delete/i }).click()
|
||||
})
|
||||
cy.wait('@delete')
|
||||
cy.findByText(labelToDelete).should('not.exist')
|
||||
})
|
||||
|
||||
it('verifies that selecting the same list item will not trigger a new diff', function () {
|
||||
mountWithEditorProviders(<ChangeList />, scope, {
|
||||
user: {
|
||||
id: USER_ID,
|
||||
email: USER_EMAIL,
|
||||
isAdmin: true,
|
||||
},
|
||||
})
|
||||
waitForData()
|
||||
|
||||
const stub = cy.stub().as('diffStub')
|
||||
cy.intercept('GET', '/project/*/filetree/diff*', stub).as('diff')
|
||||
|
||||
cy.findAllByTestId('history-version-details').eq(2).as('details')
|
||||
cy.get('@details').click() // 1st click
|
||||
cy.wait('@diff')
|
||||
cy.get('@details').click() // 2nd click
|
||||
cy.get('@diffStub').should('have.been.calledOnce')
|
||||
})
|
||||
})
|
||||
|
||||
describe('all history', function () {
|
||||
beforeEach(function () {
|
||||
mountWithEditorProviders(<ChangeList />, scope, {
|
||||
user: {
|
||||
id: USER_ID,
|
||||
email: USER_EMAIL,
|
||||
isAdmin: true,
|
||||
},
|
||||
})
|
||||
waitForData()
|
||||
})
|
||||
|
||||
it('shows grouped versions date', function () {
|
||||
cy.findByText(relativeDate(updates.updates[0].meta.end_ts))
|
||||
cy.findByText(relativeDate(updates.updates[1].meta.end_ts))
|
||||
})
|
||||
|
||||
it('shows the date of the version', function () {
|
||||
cy.findAllByTestId('history-version-details')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.findByTestId('history-version-metadata-time').should(
|
||||
'have.text',
|
||||
formatTime(updates.updates[0].meta.end_ts, 'Do MMMM, h:mm a')
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows change action', function () {
|
||||
cy.findAllByTestId('history-version-details')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.findByTestId('history-version-change-action').should(
|
||||
'have.text',
|
||||
'Created'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows changed document name', function () {
|
||||
cy.findAllByTestId('history-version-details')
|
||||
.eq(2)
|
||||
.within(() => {
|
||||
cy.findByTestId('history-version-change-doc').should(
|
||||
'have.text',
|
||||
updates.updates[2].pathnames[0]
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows users', function () {
|
||||
cy.findAllByTestId('history-version-details')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.findByTestId('history-version-metadata-users')
|
||||
.should('contain.text', 'You')
|
||||
.and('contain.text', updates.updates[1].meta.users[1].first_name)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('labels only', function () {
|
||||
beforeEach(function () {
|
||||
mountWithEditorProviders(<ChangeList />, scope, {
|
||||
user: {
|
||||
id: USER_ID,
|
||||
email: USER_EMAIL,
|
||||
isAdmin: true,
|
||||
},
|
||||
})
|
||||
waitForData()
|
||||
cy.findByLabelText(/labels/i).click({ force: true })
|
||||
})
|
||||
|
||||
it('shows the dropdown menu item for adding new labels', function () {
|
||||
cy.findAllByTestId('history-version-details')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.findByRole('button', { name: /more actions/i }).click()
|
||||
cy.findByRole('menu').within(() => {
|
||||
cy.findByRole('menuitem', {
|
||||
name: /label this version/i,
|
||||
}).should('exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('resets from compare to view mode when switching tabs', function () {
|
||||
cy.findAllByTestId('history-version-details')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.findByRole('button', {
|
||||
name: /Compare/i,
|
||||
}).click()
|
||||
})
|
||||
cy.findByLabelText(/all history/i).click({ force: true })
|
||||
cy.findAllByTestId('history-version-details').should($versions => {
|
||||
const [selected, ...rest] = Array.from($versions)
|
||||
expect(selected).to.have.attr('data-selected', 'selected')
|
||||
expect(
|
||||
rest.every(version => version.dataset.selected === 'belowSelected')
|
||||
).to.be.true
|
||||
})
|
||||
})
|
||||
it('opens the compare drop down and compares with selected version', function () {
|
||||
cy.findByLabelText(/all history/i).click({ force: true })
|
||||
cy.findAllByTestId('history-version-details')
|
||||
.eq(3)
|
||||
.within(() => {
|
||||
cy.findByRole('button', {
|
||||
name: /compare from this version/i,
|
||||
}).click()
|
||||
})
|
||||
|
||||
cy.findAllByTestId('history-version-details')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('[aria-label="Compare"]').click()
|
||||
cy.findByRole('menu').within(() => {
|
||||
cy.findByRole('menuitem', {
|
||||
name: /compare up to this version/i,
|
||||
}).click()
|
||||
})
|
||||
})
|
||||
|
||||
cy.findAllByTestId('history-version-details').should($versions => {
|
||||
const [
|
||||
aboveSelected,
|
||||
upperSelected,
|
||||
withinSelected,
|
||||
lowerSelected,
|
||||
belowSelected,
|
||||
] = Array.from($versions)
|
||||
expect(aboveSelected).to.have.attr('data-selected', 'aboveSelected')
|
||||
expect(upperSelected).to.have.attr('data-selected', 'upperSelected')
|
||||
expect(withinSelected).to.have.attr('data-selected', 'withinSelected')
|
||||
expect(lowerSelected).to.have.attr('data-selected', 'lowerSelected')
|
||||
expect(belowSelected).to.have.attr('data-selected', 'belowSelected')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('compare mode', function () {
|
||||
beforeEach(function () {
|
||||
mountWithEditorProviders(<ChangeList />, scope, {
|
||||
user: {
|
||||
id: USER_ID,
|
||||
email: USER_EMAIL,
|
||||
isAdmin: true,
|
||||
},
|
||||
})
|
||||
waitForData()
|
||||
})
|
||||
|
||||
it('compares versions', function () {
|
||||
cy.findAllByTestId('history-version-details').should($versions => {
|
||||
const [first, ...rest] = Array.from($versions)
|
||||
expect(first).to.have.attr('data-selected', 'selected')
|
||||
rest.forEach(version =>
|
||||
// Based on the fact that we are selecting first version as we load the page
|
||||
// Every other version will be belowSelected
|
||||
expect(version).to.have.attr('data-selected', 'belowSelected')
|
||||
)
|
||||
})
|
||||
|
||||
cy.intercept('GET', '/project/*/filetree/diff*', {
|
||||
body: { diff: [{ pathname: 'main.tex' }, { pathname: 'name.tex' }] },
|
||||
}).as('compareDiff')
|
||||
|
||||
cy.findAllByTestId('history-version-details')
|
||||
.last()
|
||||
.within(() => {
|
||||
cy.findByTestId('compare-icon-version').click()
|
||||
})
|
||||
cy.wait('@compareDiff')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dropdown', function () {
|
||||
beforeEach(function () {
|
||||
mountWithEditorProviders(<ChangeList />, scope, {
|
||||
user: {
|
||||
id: USER_ID,
|
||||
email: USER_EMAIL,
|
||||
isAdmin: true,
|
||||
},
|
||||
})
|
||||
waitForData()
|
||||
})
|
||||
|
||||
it('adds badge/label', function () {
|
||||
cy.findAllByTestId('history-version-details').eq(1).as('version')
|
||||
cy.get('@version').within(() => {
|
||||
cy.findByRole('button', { name: /more actions/i }).click()
|
||||
cy.findByRole('menu').within(() => {
|
||||
cy.findByRole('menuitem', {
|
||||
name: /label this version/i,
|
||||
}).click()
|
||||
})
|
||||
})
|
||||
cy.intercept('POST', '/project/*/labels', req => {
|
||||
req.reply(200, {
|
||||
id: '64633ee158e9ef7da614c000',
|
||||
comment: req.body.comment,
|
||||
version: req.body.version,
|
||||
user_id: USER_ID,
|
||||
created_at: '2023-05-16T08:29:21.250Z',
|
||||
user_display_name: 'john.doe',
|
||||
})
|
||||
}).as('addLabel')
|
||||
const newLabel = 'my new label'
|
||||
cy.findByRole('dialog').within(() => {
|
||||
cy.findByRole('heading', { name: /add label/i })
|
||||
cy.findByRole('button', { name: /cancel/i })
|
||||
cy.findByRole('button', { name: /add label/i }).should('be.disabled')
|
||||
cy.findByPlaceholderText(/new label name/i).as('input')
|
||||
cy.get('@input').type(newLabel)
|
||||
cy.findByRole('button', { name: /add label/i }).should('be.enabled')
|
||||
cy.get('@input').type('{enter}')
|
||||
})
|
||||
cy.wait('@addLabel')
|
||||
cy.get('@version').within(() => {
|
||||
cy.findAllByTestId('history-version-badge').should($badges => {
|
||||
const includes = Array.from($badges).some(badge =>
|
||||
badge.textContent?.includes(newLabel)
|
||||
)
|
||||
expect(includes).to.be.true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('downloads version', function () {
|
||||
cy.intercept('GET', '/project/*/version/*/zip', { statusCode: 200 }).as(
|
||||
'download'
|
||||
)
|
||||
cy.findAllByTestId('history-version-details')
|
||||
.eq(0)
|
||||
.within(() => {
|
||||
cy.findByRole('button', { name: /more actions/i }).click()
|
||||
cy.findByRole('menu').within(() => {
|
||||
cy.findByRole('menuitem', {
|
||||
name: /download this version/i,
|
||||
}).click()
|
||||
})
|
||||
})
|
||||
cy.wait('@download')
|
||||
})
|
||||
})
|
||||
|
||||
describe('paywall', function () {
|
||||
const now = Date.now()
|
||||
const oneMinuteAgo = now - 60 * 1000
|
||||
const justOverADayAgo = now - 25 * 60 * 60 * 1000
|
||||
const twoDaysAgo = now - 48 * 60 * 60 * 1000
|
||||
|
||||
const updates = {
|
||||
updates: [
|
||||
{
|
||||
fromV: 3,
|
||||
toV: 4,
|
||||
meta: {
|
||||
users: [
|
||||
{
|
||||
first_name: 'john.doe',
|
||||
last_name: '',
|
||||
email: 'john.doe@test.com',
|
||||
id: '1',
|
||||
},
|
||||
],
|
||||
start_ts: oneMinuteAgo,
|
||||
end_ts: oneMinuteAgo,
|
||||
},
|
||||
labels: [],
|
||||
pathnames: [],
|
||||
project_ops: [{ add: { pathname: 'name.tex' }, atV: 3 }],
|
||||
},
|
||||
{
|
||||
fromV: 1,
|
||||
toV: 3,
|
||||
meta: {
|
||||
users: [
|
||||
{
|
||||
first_name: 'bobby.lapointe',
|
||||
last_name: '',
|
||||
email: 'bobby.lapointe@test.com',
|
||||
id: '2',
|
||||
},
|
||||
],
|
||||
start_ts: justOverADayAgo,
|
||||
end_ts: justOverADayAgo - 10 * 1000,
|
||||
},
|
||||
labels: [],
|
||||
pathnames: ['main.tex'],
|
||||
project_ops: [],
|
||||
},
|
||||
{
|
||||
fromV: 0,
|
||||
toV: 1,
|
||||
meta: {
|
||||
users: [
|
||||
{
|
||||
first_name: 'john.doe',
|
||||
last_name: '',
|
||||
email: 'john.doe@test.com',
|
||||
id: '1',
|
||||
},
|
||||
],
|
||||
start_ts: twoDaysAgo,
|
||||
end_ts: twoDaysAgo,
|
||||
},
|
||||
labels: [
|
||||
{
|
||||
id: 'label1',
|
||||
comment: 'tag-1',
|
||||
version: 0,
|
||||
user_id: USER_ID,
|
||||
created_at: justOverADayAgo,
|
||||
},
|
||||
],
|
||||
pathnames: [],
|
||||
project_ops: [{ add: { pathname: 'main.tex' }, atV: 0 }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const labels = [
|
||||
{
|
||||
id: 'label1',
|
||||
comment: 'tag-1',
|
||||
version: 0,
|
||||
user_id: USER_ID,
|
||||
created_at: justOverADayAgo,
|
||||
user_display_name: 'john.doe',
|
||||
},
|
||||
]
|
||||
|
||||
const waitForData = () => {
|
||||
cy.wait('@updates')
|
||||
cy.wait('@labels')
|
||||
cy.wait('@diff')
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
cy.intercept('GET', '/project/*/updates*', {
|
||||
body: updates,
|
||||
}).as('updates')
|
||||
cy.intercept('GET', '/project/*/labels', {
|
||||
body: labels,
|
||||
}).as('labels')
|
||||
cy.intercept('GET', '/project/*/filetree/diff*', {
|
||||
body: { diff: [{ pathname: 'main.tex' }, { pathname: 'name.tex' }] },
|
||||
}).as('diff')
|
||||
})
|
||||
|
||||
it('shows non-owner paywall', function () {
|
||||
const scope = {
|
||||
ui: {
|
||||
view: 'history',
|
||||
pdfLayout: 'sideBySide',
|
||||
chatOpen: true,
|
||||
},
|
||||
}
|
||||
|
||||
mountWithEditorProviders(<ChangeList />, scope, {
|
||||
user: {
|
||||
id: USER_ID,
|
||||
email: USER_EMAIL,
|
||||
isAdmin: false,
|
||||
},
|
||||
})
|
||||
|
||||
waitForData()
|
||||
|
||||
cy.get('.history-paywall-prompt').should('have.length', 1)
|
||||
cy.findAllByTestId('history-version').should('have.length', 2)
|
||||
cy.get('.history-paywall-prompt button').should('not.exist')
|
||||
})
|
||||
|
||||
it('shows owner paywall', function () {
|
||||
const scope = {
|
||||
ui: {
|
||||
view: 'history',
|
||||
pdfLayout: 'sideBySide',
|
||||
chatOpen: true,
|
||||
},
|
||||
}
|
||||
|
||||
mountWithEditorProviders(<ChangeList />, scope, {
|
||||
user: {
|
||||
id: USER_ID,
|
||||
email: USER_EMAIL,
|
||||
isAdmin: false,
|
||||
},
|
||||
projectOwner: {
|
||||
_id: USER_ID,
|
||||
email: USER_EMAIL,
|
||||
},
|
||||
})
|
||||
|
||||
waitForData()
|
||||
|
||||
cy.get('.history-paywall-prompt').should('have.length', 1)
|
||||
cy.findAllByTestId('history-version').should('have.length', 2)
|
||||
cy.get('.history-paywall-prompt button').should('have.length', 1)
|
||||
})
|
||||
|
||||
it('shows all labels in free tier', function () {
|
||||
const scope = {
|
||||
ui: {
|
||||
view: 'history',
|
||||
pdfLayout: 'sideBySide',
|
||||
chatOpen: true,
|
||||
},
|
||||
}
|
||||
|
||||
mountWithEditorProviders(<ChangeList />, scope, {
|
||||
user: {
|
||||
id: USER_ID,
|
||||
email: USER_EMAIL,
|
||||
isAdmin: false,
|
||||
},
|
||||
projectOwner: {
|
||||
_id: USER_ID,
|
||||
email: USER_EMAIL,
|
||||
},
|
||||
})
|
||||
|
||||
waitForData()
|
||||
|
||||
cy.findByLabelText(/labels/i).click({ force: true })
|
||||
|
||||
// One pseudo-label for the current state, one for our label
|
||||
cy.get('.history-version-label').should('have.length', 2)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,256 @@
|
||||
import DocumentDiffViewer from '../../../../../frontend/js/features/history/components/diff-view/document-diff-viewer'
|
||||
import { Highlight } from '../../../../../frontend/js/features/history/services/types/doc'
|
||||
import { FC } from 'react'
|
||||
import { EditorProviders } from '../../../helpers/editor-providers'
|
||||
|
||||
const doc = `\\documentclass{article}
|
||||
|
||||
% Language setting
|
||||
% Replace \`english' with e.g. \`spanish' to change the document language
|
||||
\\usepackage[english]{babel}
|
||||
|
||||
% Set page size and margins
|
||||
% Replace \`letterpaper' with \`a4paper' for UK/EU standard size
|
||||
\\usepackage[letterpaper,top=2cm,bottom=2cm,left=3cm,right=3cm,marginparwidth=1.75cm]{geometry}
|
||||
|
||||
% Useful packages
|
||||
\\usepackage{amsmath}
|
||||
\\usepackage{graphicx}
|
||||
\\usepackage[colorlinks=true, allcolors=blue]{hyperref}
|
||||
|
||||
\\title{Your Paper}
|
||||
\\author{You}
|
||||
|
||||
\\begin{document}
|
||||
\\maketitle
|
||||
|
||||
\\begin{abstract}
|
||||
Your abstract.
|
||||
\\end{abstract}
|
||||
|
||||
\\section{Introduction}
|
||||
|
||||
Your introduction goes here! Simply start writing your document and use the Recompile button to view the updated PDF preview. Examples of commonly used commands and features are listed below, to help you get started.
|
||||
|
||||
Once you're familiar with the editor, you can find various project settings in the Overleaf menu, accessed via the button in the very top left of the editor. To view tutorials, user guides, and further documentation, please visit our \\href{https://www.overleaf.com/learn}{help library}, or head to our plans page to \\href{https://www.overleaf.com/user/subscription/plans}{choose your plan}.
|
||||
|
||||
${'\n'.repeat(200)}
|
||||
|
||||
\\end{document}`
|
||||
|
||||
const highlights: Highlight[] = [
|
||||
{
|
||||
type: 'addition',
|
||||
range: { from: 15, to: 22 },
|
||||
hue: 200,
|
||||
label: 'Added by Wombat on Monday',
|
||||
},
|
||||
{
|
||||
type: 'deletion',
|
||||
range: { from: 27, to: 35 },
|
||||
hue: 200,
|
||||
label: 'Deleted by Wombat on Tuesday',
|
||||
},
|
||||
{
|
||||
type: 'addition',
|
||||
range: { from: doc.length - 9, to: doc.length - 1 },
|
||||
hue: 200,
|
||||
label: 'Added by Wombat on Wednesday',
|
||||
},
|
||||
]
|
||||
|
||||
const Container: FC = ({ children }) => (
|
||||
<div style={{ width: 600, height: 400 }}>{children}</div>
|
||||
)
|
||||
|
||||
const mockScope = () => {
|
||||
return {
|
||||
settings: {
|
||||
fontSize: 12,
|
||||
fontFamily: 'monaco',
|
||||
lineHeight: 'normal',
|
||||
overallTheme: '',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('document diff viewer', function () {
|
||||
it('displays highlights with hover tooltips', function () {
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<Container>
|
||||
<EditorProviders scope={scope}>
|
||||
<DocumentDiffViewer doc={doc} highlights={highlights} />
|
||||
</EditorProviders>
|
||||
</Container>
|
||||
)
|
||||
|
||||
cy.get('.ol-cm-addition-marker').should('have.length', 1)
|
||||
cy.get('.ol-cm-addition-marker').first().as('addition')
|
||||
cy.get('@addition').should('have.text', 'article')
|
||||
|
||||
cy.get('.ol-cm-deletion-marker').should('have.length', 1)
|
||||
cy.get('.ol-cm-deletion-marker').first().as('deletion')
|
||||
cy.get('@deletion').should('have.text', 'Language')
|
||||
|
||||
// Check hover tooltips
|
||||
cy.get('@addition').trigger('mouseover')
|
||||
cy.get('.ol-cm-highlight-tooltip').should('have.length', 1)
|
||||
cy.get('.ol-cm-highlight-tooltip')
|
||||
.first()
|
||||
.should('have.text', 'Added by Wombat on Monday')
|
||||
|
||||
cy.get('@deletion').trigger('mouseover')
|
||||
cy.get('.ol-cm-highlight-tooltip').should('have.length', 1)
|
||||
cy.get('.ol-cm-highlight-tooltip')
|
||||
.first()
|
||||
.should('have.text', 'Deleted by Wombat on Tuesday')
|
||||
})
|
||||
|
||||
it('displays highlights with hover tooltips for empty lines', function () {
|
||||
const scope = mockScope()
|
||||
|
||||
const doc = `1
|
||||
Addition
|
||||
|
||||
|
||||
End
|
||||
2
|
||||
Deletion
|
||||
|
||||
End
|
||||
3`
|
||||
const highlights: Highlight[] = [
|
||||
{
|
||||
type: 'addition',
|
||||
range: { from: 2, to: 16 },
|
||||
hue: 200,
|
||||
label: 'Added by Wombat on Monday',
|
||||
},
|
||||
{
|
||||
type: 'deletion',
|
||||
range: { from: 19, to: 32 },
|
||||
hue: 200,
|
||||
label: 'Deleted by Wombat on Tuesday',
|
||||
},
|
||||
]
|
||||
|
||||
cy.mount(
|
||||
<Container>
|
||||
<EditorProviders scope={scope}>
|
||||
<DocumentDiffViewer doc={doc} highlights={highlights} />
|
||||
</EditorProviders>
|
||||
</Container>
|
||||
)
|
||||
|
||||
cy.get('.ol-cm-empty-line-addition-marker').should('have.length', 2)
|
||||
cy.get('.ol-cm-empty-line-deletion-marker').should('have.length', 1)
|
||||
|
||||
// For an empty line marker, we need to trigger mouseover on the containing
|
||||
// line beause the marker itself does not trigger mouseover
|
||||
cy.get('.ol-cm-empty-line-addition-marker')
|
||||
.first()
|
||||
.parent()
|
||||
.as('firstAdditionLine')
|
||||
cy.get('.ol-cm-empty-line-addition-marker')
|
||||
.first()
|
||||
.parent()
|
||||
.as('lastAdditionLine')
|
||||
cy.get('.ol-cm-empty-line-deletion-marker')
|
||||
.last()
|
||||
.parent()
|
||||
.as('deletionLine')
|
||||
|
||||
// Check hover tooltips
|
||||
cy.get('@lastAdditionLine').trigger('mouseover')
|
||||
cy.get('.ol-cm-highlight-tooltip').should('have.length', 1)
|
||||
cy.get('.ol-cm-highlight-tooltip')
|
||||
.first()
|
||||
.should('have.text', 'Added by Wombat on Monday')
|
||||
|
||||
cy.get('@lastAdditionLine').trigger('mouseleave')
|
||||
|
||||
cy.get('@firstAdditionLine').trigger('mouseover')
|
||||
cy.get('.ol-cm-highlight-tooltip').should('have.length', 1)
|
||||
cy.get('.ol-cm-highlight-tooltip')
|
||||
.first()
|
||||
.should('have.text', 'Added by Wombat on Monday')
|
||||
|
||||
cy.get('@deletionLine').trigger('mouseover')
|
||||
cy.get('.ol-cm-highlight-tooltip').should('have.length', 1)
|
||||
cy.get('.ol-cm-highlight-tooltip')
|
||||
.first()
|
||||
.should('have.text', 'Deleted by Wombat on Tuesday')
|
||||
})
|
||||
|
||||
it("renders 'More updates' buttons", function () {
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<Container>
|
||||
<EditorProviders scope={scope}>
|
||||
<DocumentDiffViewer doc={doc} highlights={highlights} />
|
||||
</EditorProviders>
|
||||
</Container>
|
||||
)
|
||||
|
||||
cy.get('.cm-scroller').first().as('scroller')
|
||||
|
||||
// Check the initial state, which should be a "More updates below" button
|
||||
// but no "More updates above", with the editor scrolled to the top
|
||||
cy.get('.ol-cm-addition-marker').should('have.length', 1)
|
||||
cy.get('.ol-cm-deletion-marker').should('have.length', 1)
|
||||
cy.get('.previous-highlight-button').should('have.length', 0)
|
||||
cy.get('.next-highlight-button').should('have.length', 1)
|
||||
cy.get('@scroller').invoke('scrollTop').should('equal', 0)
|
||||
|
||||
// Click the "More updates below" button, which should scroll the editor,
|
||||
// and check the new state
|
||||
cy.get('.next-highlight-button').first().click()
|
||||
|
||||
cy.get('@scroller').invoke('scrollTop').should('not.equal', 0)
|
||||
cy.get('.previous-highlight-button').should('have.length', 1)
|
||||
cy.get('.next-highlight-button').should('have.length', 0)
|
||||
|
||||
// Click the "More updates above" button, which should scroll the editor up
|
||||
// but not quite to the top, and check the new state
|
||||
cy.get('.previous-highlight-button').first().click()
|
||||
cy.get('@scroller').invoke('scrollTop').should('equal', 0)
|
||||
cy.get('.previous-highlight-button').should('not.exist')
|
||||
cy.get('.next-highlight-button').should('have.length', 1)
|
||||
})
|
||||
|
||||
it('scrolls to first change', function () {
|
||||
const scope = mockScope()
|
||||
const finalHighlightOnly = highlights.slice(-1)
|
||||
|
||||
cy.mount(
|
||||
<Container>
|
||||
<EditorProviders scope={scope}>
|
||||
<DocumentDiffViewer doc={doc} highlights={finalHighlightOnly} />
|
||||
</EditorProviders>
|
||||
</Container>
|
||||
)
|
||||
|
||||
cy.get('.cm-scroller').first().invoke('scrollTop').should('not.equal', 0)
|
||||
cy.get('.ol-cm-addition-marker')
|
||||
.first()
|
||||
.then($marker => {
|
||||
cy.get('.cm-content')
|
||||
.first()
|
||||
.then($content => {
|
||||
const contentRect = $content[0].getBoundingClientRect()
|
||||
const markerRect = $marker[0].getBoundingClientRect()
|
||||
expect(markerRect.top).to.be.within(
|
||||
contentRect.top,
|
||||
contentRect.bottom
|
||||
)
|
||||
expect(markerRect.bottom).to.be.within(
|
||||
contentRect.top,
|
||||
contentRect.bottom
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
149
services/web/test/frontend/features/history/fixtures/updates.ts
Normal file
149
services/web/test/frontend/features/history/fixtures/updates.ts
Normal 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 }],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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('?')
|
||||
})
|
||||
})
|
||||
@@ -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`
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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: /you’re 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: /you’re 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: /you’re 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: /you’re 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: /you’re 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')
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
@@ -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.`
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user