first commit

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

View File

@@ -0,0 +1,680 @@
import { useState } from 'react'
import ToggleSwitch from '../../../../../frontend/js/features/history/components/change-list/toggle-switch'
import ChangeList from '../../../../../frontend/js/features/history/components/change-list/change-list'
import {
EditorProviders,
USER_EMAIL,
USER_ID,
} from '../../../helpers/editor-providers'
import { HistoryProvider } from '../../../../../frontend/js/features/history/context/history-context'
import { updates } from '../fixtures/updates'
import { labels } from '../fixtures/labels'
import { formatTime, relativeDate } from '@/features/utils/format-date'
const mountWithEditorProviders = (
component: React.ReactNode,
scope: Record<string, unknown> = {},
props: Record<string, unknown> = {}
) => {
cy.mount(
<EditorProviders scope={scope} {...props}>
<HistoryProvider>
<div style={{ display: 'flex', justifyContent: 'center' }}>
<div className="history-react">{component}</div>
</div>
</HistoryProvider>
</EditorProviders>
)
}
describe('change list (Bootstrap 5)', function () {
const scope = {
ui: { view: 'history', pdfLayout: 'sideBySide', chatOpen: true },
}
const waitForData = () => {
cy.wait('@updates')
cy.wait('@labels')
cy.wait('@diff')
}
beforeEach(function () {
cy.intercept('GET', '/project/*/updates*', {
body: updates,
}).as('updates')
cy.intercept('GET', '/project/*/labels', {
body: labels,
}).as('labels')
cy.intercept('GET', '/project/*/filetree/diff*', {
body: { diff: [{ pathname: 'main.tex' }, { pathname: 'name.tex' }] },
}).as('diff')
window.metaAttributesCache.set('ol-inactiveTutorials', [
'react-history-buttons-tutorial',
])
})
describe('toggle switch', function () {
it('renders switch buttons', function () {
mountWithEditorProviders(
<ToggleSwitch labelsOnly={false} setLabelsOnly={() => {}} />
)
cy.findByLabelText(/all history/i)
cy.findByLabelText(/labels/i)
})
it('toggles "all history" and "labels" buttons', function () {
function ToggleSwitchWrapped({ labelsOnly }: { labelsOnly: boolean }) {
const [labelsOnlyLocal, setLabelsOnlyLocal] = useState(labelsOnly)
return (
<ToggleSwitch
labelsOnly={labelsOnlyLocal}
setLabelsOnly={setLabelsOnlyLocal}
/>
)
}
mountWithEditorProviders(<ToggleSwitchWrapped labelsOnly={false} />)
cy.findByLabelText(/all history/i).as('all-history')
cy.findByLabelText(/labels/i).as('labels')
cy.get('@all-history').should('be.checked')
cy.get('@labels').should('not.be.checked')
cy.get('@labels').click({ force: true })
cy.get('@all-history').should('not.be.checked')
cy.get('@labels').should('be.checked')
})
})
describe('tags', function () {
it('renders tags', function () {
mountWithEditorProviders(<ChangeList />, scope, {
user: {
id: USER_ID,
email: USER_EMAIL,
isAdmin: true,
},
})
waitForData()
cy.findByLabelText(/all history/i).click({ force: true })
cy.findAllByTestId('history-version-details').as('details')
cy.get('@details').should('have.length', 5)
// start with 2nd details entry, as first has no tags
cy.get('@details')
.eq(1)
.within(() => {
cy.findAllByTestId('history-version-badge').as('tags')
})
cy.get('@tags').should('have.length', 2)
cy.get('@tags').eq(0).should('contain.text', 'tag-2')
cy.get('@tags').eq(1).should('contain.text', 'tag-1')
// should have delete buttons
cy.get('@tags').each(tag =>
cy.wrap(tag).within(() => {
cy.findByRole('button', { name: /delete/i })
})
)
// 3rd details entry
cy.get('@details')
.eq(2)
.within(() => {
cy.findAllByTestId('history-version-badge').should('have.length', 0)
})
// 4th details entry
cy.get('@details')
.eq(3)
.within(() => {
cy.findAllByTestId('history-version-badge').as('tags')
})
cy.get('@tags').should('have.length', 2)
cy.get('@tags').eq(0).should('contain.text', 'tag-4')
cy.get('@tags').eq(1).should('contain.text', 'tag-3')
// should not have delete buttons
cy.get('@tags').each(tag =>
cy.wrap(tag).within(() => {
cy.findByRole('button', { name: /delete/i }).should('not.exist')
})
)
cy.findByLabelText(/labels/i).click({ force: true })
cy.findAllByTestId('history-version-details').as('details')
// first details on labels is always "current version", start testing on second
cy.get('@details').should('have.length', 3)
cy.get('@details')
.eq(1)
.within(() => {
cy.findAllByTestId('history-version-badge').as('tags')
})
cy.get('@tags').should('have.length', 2)
cy.get('@tags').eq(0).should('contain.text', 'tag-2')
cy.get('@tags').eq(1).should('contain.text', 'tag-1')
cy.get('@details')
.eq(2)
.within(() => {
cy.findAllByTestId('history-version-badge').as('tags')
})
cy.get('@tags').should('have.length', 3)
cy.get('@tags').eq(0).should('contain.text', 'tag-5')
cy.get('@tags').eq(1).should('contain.text', 'tag-4')
cy.get('@tags').eq(2).should('contain.text', 'tag-3')
})
it('deletes tag', function () {
mountWithEditorProviders(<ChangeList />, scope, {
user: {
id: USER_ID,
email: USER_EMAIL,
isAdmin: true,
},
})
waitForData()
cy.findByLabelText(/all history/i).click({ force: true })
const labelToDelete = 'tag-2'
cy.findAllByTestId('history-version-details').eq(1).as('details')
cy.get('@details').within(() => {
cy.findAllByTestId('history-version-badge').eq(0).as('tag')
})
cy.get('@tag').should('contain.text', labelToDelete)
cy.get('@tag').within(() => {
cy.findByRole('button', { name: /delete/i }).as('delete-btn')
})
cy.get('@delete-btn').click()
cy.findByRole('dialog').as('modal')
cy.get('@modal').within(() => {
cy.findByRole('heading', { name: /delete label/i })
})
cy.get('@modal').contains(
new RegExp(
`are you sure you want to delete the following label "${labelToDelete}"?`,
'i'
)
)
cy.get('@modal').within(() => {
cy.findByRole('button', { name: /cancel/i }).click()
})
cy.findByRole('dialog').should('not.exist')
cy.get('@delete-btn').click()
cy.findByRole('dialog').as('modal')
cy.intercept('DELETE', '/project/*/labels/*', {
statusCode: 500,
}).as('delete')
cy.get('@modal').within(() => {
cy.findByRole('button', { name: /delete/i }).click()
})
cy.wait('@delete')
cy.get('@modal').within(() => {
cy.findByRole('alert').within(() => {
cy.contains(/sorry, something went wrong/i)
})
})
cy.findByText(labelToDelete).should('have.length', 1)
cy.intercept('DELETE', '/project/*/labels/*', {
statusCode: 204,
}).as('delete')
cy.get('@modal').within(() => {
cy.findByRole('button', { name: /delete/i }).click()
})
cy.wait('@delete')
cy.findByText(labelToDelete).should('not.exist')
})
it('verifies that selecting the same list item will not trigger a new diff', function () {
mountWithEditorProviders(<ChangeList />, scope, {
user: {
id: USER_ID,
email: USER_EMAIL,
isAdmin: true,
},
})
waitForData()
const stub = cy.stub().as('diffStub')
cy.intercept('GET', '/project/*/filetree/diff*', stub).as('diff')
cy.findAllByTestId('history-version-details').eq(2).as('details')
cy.get('@details').click() // 1st click
cy.wait('@diff')
cy.get('@details').click() // 2nd click
cy.get('@diffStub').should('have.been.calledOnce')
})
})
describe('all history', function () {
beforeEach(function () {
mountWithEditorProviders(<ChangeList />, scope, {
user: {
id: USER_ID,
email: USER_EMAIL,
isAdmin: true,
},
})
waitForData()
})
it('shows grouped versions date', function () {
cy.findByText(relativeDate(updates.updates[0].meta.end_ts))
cy.findByText(relativeDate(updates.updates[1].meta.end_ts))
})
it('shows the date of the version', function () {
cy.findAllByTestId('history-version-details')
.eq(1)
.within(() => {
cy.findByTestId('history-version-metadata-time').should(
'have.text',
formatTime(updates.updates[0].meta.end_ts, 'Do MMMM, h:mm a')
)
})
})
it('shows change action', function () {
cy.findAllByTestId('history-version-details')
.eq(1)
.within(() => {
cy.findByTestId('history-version-change-action').should(
'have.text',
'Created'
)
})
})
it('shows changed document name', function () {
cy.findAllByTestId('history-version-details')
.eq(2)
.within(() => {
cy.findByTestId('history-version-change-doc').should(
'have.text',
updates.updates[2].pathnames[0]
)
})
})
it('shows users', function () {
cy.findAllByTestId('history-version-details')
.eq(1)
.within(() => {
cy.findByTestId('history-version-metadata-users')
.should('contain.text', 'You')
.and('contain.text', updates.updates[1].meta.users[1].first_name)
})
})
})
describe('labels only', function () {
beforeEach(function () {
mountWithEditorProviders(<ChangeList />, scope, {
user: {
id: USER_ID,
email: USER_EMAIL,
isAdmin: true,
},
})
waitForData()
cy.findByLabelText(/labels/i).click({ force: true })
})
it('shows the dropdown menu item for adding new labels', function () {
cy.findAllByTestId('history-version-details')
.eq(1)
.within(() => {
cy.findByRole('button', { name: /more actions/i }).click()
cy.findByRole('menu').within(() => {
cy.findByRole('menuitem', {
name: /label this version/i,
}).should('exist')
})
})
})
it('resets from compare to view mode when switching tabs', function () {
cy.findAllByTestId('history-version-details')
.eq(1)
.within(() => {
cy.findByRole('button', {
name: /Compare/i,
}).click()
})
cy.findByLabelText(/all history/i).click({ force: true })
cy.findAllByTestId('history-version-details').should($versions => {
const [selected, ...rest] = Array.from($versions)
expect(selected).to.have.attr('data-selected', 'selected')
expect(
rest.every(version => version.dataset.selected === 'belowSelected')
).to.be.true
})
})
it('opens the compare drop down and compares with selected version', function () {
cy.findByLabelText(/all history/i).click({ force: true })
cy.findAllByTestId('history-version-details')
.eq(3)
.within(() => {
cy.findByRole('button', {
name: /compare from this version/i,
}).click()
})
cy.findAllByTestId('history-version-details')
.eq(1)
.within(() => {
cy.get('[aria-label="Compare"]').click()
cy.findByRole('menu').within(() => {
cy.findByRole('menuitem', {
name: /compare up to this version/i,
}).click()
})
})
cy.findAllByTestId('history-version-details').should($versions => {
const [
aboveSelected,
upperSelected,
withinSelected,
lowerSelected,
belowSelected,
] = Array.from($versions)
expect(aboveSelected).to.have.attr('data-selected', 'aboveSelected')
expect(upperSelected).to.have.attr('data-selected', 'upperSelected')
expect(withinSelected).to.have.attr('data-selected', 'withinSelected')
expect(lowerSelected).to.have.attr('data-selected', 'lowerSelected')
expect(belowSelected).to.have.attr('data-selected', 'belowSelected')
})
})
})
describe('compare mode', function () {
beforeEach(function () {
mountWithEditorProviders(<ChangeList />, scope, {
user: {
id: USER_ID,
email: USER_EMAIL,
isAdmin: true,
},
})
waitForData()
})
it('compares versions', function () {
cy.findAllByTestId('history-version-details').should($versions => {
const [first, ...rest] = Array.from($versions)
expect(first).to.have.attr('data-selected', 'selected')
rest.forEach(version =>
// Based on the fact that we are selecting first version as we load the page
// Every other version will be belowSelected
expect(version).to.have.attr('data-selected', 'belowSelected')
)
})
cy.intercept('GET', '/project/*/filetree/diff*', {
body: { diff: [{ pathname: 'main.tex' }, { pathname: 'name.tex' }] },
}).as('compareDiff')
cy.findAllByTestId('history-version-details')
.last()
.within(() => {
cy.findByTestId('compare-icon-version').click()
})
cy.wait('@compareDiff')
})
})
describe('dropdown', function () {
beforeEach(function () {
mountWithEditorProviders(<ChangeList />, scope, {
user: {
id: USER_ID,
email: USER_EMAIL,
isAdmin: true,
},
})
waitForData()
})
it('adds badge/label', function () {
cy.findAllByTestId('history-version-details').eq(1).as('version')
cy.get('@version').within(() => {
cy.findByRole('button', { name: /more actions/i }).click()
cy.findByRole('menu').within(() => {
cy.findByRole('menuitem', {
name: /label this version/i,
}).click()
})
})
cy.intercept('POST', '/project/*/labels', req => {
req.reply(200, {
id: '64633ee158e9ef7da614c000',
comment: req.body.comment,
version: req.body.version,
user_id: USER_ID,
created_at: '2023-05-16T08:29:21.250Z',
user_display_name: 'john.doe',
})
}).as('addLabel')
const newLabel = 'my new label'
cy.findByRole('dialog').within(() => {
cy.findByRole('heading', { name: /add label/i })
cy.findByRole('button', { name: /cancel/i })
cy.findByRole('button', { name: /add label/i }).should('be.disabled')
cy.findByPlaceholderText(/new label name/i).as('input')
cy.get('@input').type(newLabel)
cy.findByRole('button', { name: /add label/i }).should('be.enabled')
cy.get('@input').type('{enter}')
})
cy.wait('@addLabel')
cy.get('@version').within(() => {
cy.findAllByTestId('history-version-badge').should($badges => {
const includes = Array.from($badges).some(badge =>
badge.textContent?.includes(newLabel)
)
expect(includes).to.be.true
})
})
})
it('downloads version', function () {
cy.intercept('GET', '/project/*/version/*/zip', { statusCode: 200 }).as(
'download'
)
cy.findAllByTestId('history-version-details')
.eq(0)
.within(() => {
cy.findByRole('button', { name: /more actions/i }).click()
cy.findByRole('menu').within(() => {
cy.findByRole('menuitem', {
name: /download this version/i,
}).click()
})
})
cy.wait('@download')
})
})
describe('paywall', function () {
const now = Date.now()
const oneMinuteAgo = now - 60 * 1000
const justOverADayAgo = now - 25 * 60 * 60 * 1000
const twoDaysAgo = now - 48 * 60 * 60 * 1000
const updates = {
updates: [
{
fromV: 3,
toV: 4,
meta: {
users: [
{
first_name: 'john.doe',
last_name: '',
email: 'john.doe@test.com',
id: '1',
},
],
start_ts: oneMinuteAgo,
end_ts: oneMinuteAgo,
},
labels: [],
pathnames: [],
project_ops: [{ add: { pathname: 'name.tex' }, atV: 3 }],
},
{
fromV: 1,
toV: 3,
meta: {
users: [
{
first_name: 'bobby.lapointe',
last_name: '',
email: 'bobby.lapointe@test.com',
id: '2',
},
],
start_ts: justOverADayAgo,
end_ts: justOverADayAgo - 10 * 1000,
},
labels: [],
pathnames: ['main.tex'],
project_ops: [],
},
{
fromV: 0,
toV: 1,
meta: {
users: [
{
first_name: 'john.doe',
last_name: '',
email: 'john.doe@test.com',
id: '1',
},
],
start_ts: twoDaysAgo,
end_ts: twoDaysAgo,
},
labels: [
{
id: 'label1',
comment: 'tag-1',
version: 0,
user_id: USER_ID,
created_at: justOverADayAgo,
},
],
pathnames: [],
project_ops: [{ add: { pathname: 'main.tex' }, atV: 0 }],
},
],
}
const labels = [
{
id: 'label1',
comment: 'tag-1',
version: 0,
user_id: USER_ID,
created_at: justOverADayAgo,
user_display_name: 'john.doe',
},
]
const waitForData = () => {
cy.wait('@updates')
cy.wait('@labels')
cy.wait('@diff')
}
beforeEach(function () {
cy.intercept('GET', '/project/*/updates*', {
body: updates,
}).as('updates')
cy.intercept('GET', '/project/*/labels', {
body: labels,
}).as('labels')
cy.intercept('GET', '/project/*/filetree/diff*', {
body: { diff: [{ pathname: 'main.tex' }, { pathname: 'name.tex' }] },
}).as('diff')
})
it('shows non-owner paywall', function () {
const scope = {
ui: {
view: 'history',
pdfLayout: 'sideBySide',
chatOpen: true,
},
}
mountWithEditorProviders(<ChangeList />, scope, {
user: {
id: USER_ID,
email: USER_EMAIL,
isAdmin: false,
},
})
waitForData()
cy.get('.history-paywall-prompt').should('have.length', 1)
cy.findAllByTestId('history-version').should('have.length', 2)
cy.get('.history-paywall-prompt button').should('not.exist')
})
it('shows owner paywall', function () {
const scope = {
ui: {
view: 'history',
pdfLayout: 'sideBySide',
chatOpen: true,
},
}
mountWithEditorProviders(<ChangeList />, scope, {
user: {
id: USER_ID,
email: USER_EMAIL,
isAdmin: false,
},
projectOwner: {
_id: USER_ID,
email: USER_EMAIL,
},
})
waitForData()
cy.get('.history-paywall-prompt').should('have.length', 1)
cy.findAllByTestId('history-version').should('have.length', 2)
cy.get('.history-paywall-prompt button').should('have.length', 1)
})
it('shows all labels in free tier', function () {
const scope = {
ui: {
view: 'history',
pdfLayout: 'sideBySide',
chatOpen: true,
},
}
mountWithEditorProviders(<ChangeList />, scope, {
user: {
id: USER_ID,
email: USER_EMAIL,
isAdmin: false,
},
projectOwner: {
_id: USER_ID,
email: USER_EMAIL,
},
})
waitForData()
cy.findByLabelText(/labels/i).click({ force: true })
// One pseudo-label for the current state, one for our label
cy.get('.history-version-label').should('have.length', 2)
})
})
})

View File

@@ -0,0 +1,256 @@
import DocumentDiffViewer from '../../../../../frontend/js/features/history/components/diff-view/document-diff-viewer'
import { Highlight } from '../../../../../frontend/js/features/history/services/types/doc'
import { FC } from 'react'
import { EditorProviders } from '../../../helpers/editor-providers'
const doc = `\\documentclass{article}
% Language setting
% Replace \`english' with e.g. \`spanish' to change the document language
\\usepackage[english]{babel}
% Set page size and margins
% Replace \`letterpaper' with \`a4paper' for UK/EU standard size
\\usepackage[letterpaper,top=2cm,bottom=2cm,left=3cm,right=3cm,marginparwidth=1.75cm]{geometry}
% Useful packages
\\usepackage{amsmath}
\\usepackage{graphicx}
\\usepackage[colorlinks=true, allcolors=blue]{hyperref}
\\title{Your Paper}
\\author{You}
\\begin{document}
\\maketitle
\\begin{abstract}
Your abstract.
\\end{abstract}
\\section{Introduction}
Your introduction goes here! Simply start writing your document and use the Recompile button to view the updated PDF preview. Examples of commonly used commands and features are listed below, to help you get started.
Once you're familiar with the editor, you can find various project settings in the Overleaf menu, accessed via the button in the very top left of the editor. To view tutorials, user guides, and further documentation, please visit our \\href{https://www.overleaf.com/learn}{help library}, or head to our plans page to \\href{https://www.overleaf.com/user/subscription/plans}{choose your plan}.
${'\n'.repeat(200)}
\\end{document}`
const highlights: Highlight[] = [
{
type: 'addition',
range: { from: 15, to: 22 },
hue: 200,
label: 'Added by Wombat on Monday',
},
{
type: 'deletion',
range: { from: 27, to: 35 },
hue: 200,
label: 'Deleted by Wombat on Tuesday',
},
{
type: 'addition',
range: { from: doc.length - 9, to: doc.length - 1 },
hue: 200,
label: 'Added by Wombat on Wednesday',
},
]
const Container: FC = ({ children }) => (
<div style={{ width: 600, height: 400 }}>{children}</div>
)
const mockScope = () => {
return {
settings: {
fontSize: 12,
fontFamily: 'monaco',
lineHeight: 'normal',
overallTheme: '',
},
}
}
describe('document diff viewer', function () {
it('displays highlights with hover tooltips', function () {
const scope = mockScope()
cy.mount(
<Container>
<EditorProviders scope={scope}>
<DocumentDiffViewer doc={doc} highlights={highlights} />
</EditorProviders>
</Container>
)
cy.get('.ol-cm-addition-marker').should('have.length', 1)
cy.get('.ol-cm-addition-marker').first().as('addition')
cy.get('@addition').should('have.text', 'article')
cy.get('.ol-cm-deletion-marker').should('have.length', 1)
cy.get('.ol-cm-deletion-marker').first().as('deletion')
cy.get('@deletion').should('have.text', 'Language')
// Check hover tooltips
cy.get('@addition').trigger('mouseover')
cy.get('.ol-cm-highlight-tooltip').should('have.length', 1)
cy.get('.ol-cm-highlight-tooltip')
.first()
.should('have.text', 'Added by Wombat on Monday')
cy.get('@deletion').trigger('mouseover')
cy.get('.ol-cm-highlight-tooltip').should('have.length', 1)
cy.get('.ol-cm-highlight-tooltip')
.first()
.should('have.text', 'Deleted by Wombat on Tuesday')
})
it('displays highlights with hover tooltips for empty lines', function () {
const scope = mockScope()
const doc = `1
Addition
End
2
Deletion
End
3`
const highlights: Highlight[] = [
{
type: 'addition',
range: { from: 2, to: 16 },
hue: 200,
label: 'Added by Wombat on Monday',
},
{
type: 'deletion',
range: { from: 19, to: 32 },
hue: 200,
label: 'Deleted by Wombat on Tuesday',
},
]
cy.mount(
<Container>
<EditorProviders scope={scope}>
<DocumentDiffViewer doc={doc} highlights={highlights} />
</EditorProviders>
</Container>
)
cy.get('.ol-cm-empty-line-addition-marker').should('have.length', 2)
cy.get('.ol-cm-empty-line-deletion-marker').should('have.length', 1)
// For an empty line marker, we need to trigger mouseover on the containing
// line beause the marker itself does not trigger mouseover
cy.get('.ol-cm-empty-line-addition-marker')
.first()
.parent()
.as('firstAdditionLine')
cy.get('.ol-cm-empty-line-addition-marker')
.first()
.parent()
.as('lastAdditionLine')
cy.get('.ol-cm-empty-line-deletion-marker')
.last()
.parent()
.as('deletionLine')
// Check hover tooltips
cy.get('@lastAdditionLine').trigger('mouseover')
cy.get('.ol-cm-highlight-tooltip').should('have.length', 1)
cy.get('.ol-cm-highlight-tooltip')
.first()
.should('have.text', 'Added by Wombat on Monday')
cy.get('@lastAdditionLine').trigger('mouseleave')
cy.get('@firstAdditionLine').trigger('mouseover')
cy.get('.ol-cm-highlight-tooltip').should('have.length', 1)
cy.get('.ol-cm-highlight-tooltip')
.first()
.should('have.text', 'Added by Wombat on Monday')
cy.get('@deletionLine').trigger('mouseover')
cy.get('.ol-cm-highlight-tooltip').should('have.length', 1)
cy.get('.ol-cm-highlight-tooltip')
.first()
.should('have.text', 'Deleted by Wombat on Tuesday')
})
it("renders 'More updates' buttons", function () {
const scope = mockScope()
cy.mount(
<Container>
<EditorProviders scope={scope}>
<DocumentDiffViewer doc={doc} highlights={highlights} />
</EditorProviders>
</Container>
)
cy.get('.cm-scroller').first().as('scroller')
// Check the initial state, which should be a "More updates below" button
// but no "More updates above", with the editor scrolled to the top
cy.get('.ol-cm-addition-marker').should('have.length', 1)
cy.get('.ol-cm-deletion-marker').should('have.length', 1)
cy.get('.previous-highlight-button').should('have.length', 0)
cy.get('.next-highlight-button').should('have.length', 1)
cy.get('@scroller').invoke('scrollTop').should('equal', 0)
// Click the "More updates below" button, which should scroll the editor,
// and check the new state
cy.get('.next-highlight-button').first().click()
cy.get('@scroller').invoke('scrollTop').should('not.equal', 0)
cy.get('.previous-highlight-button').should('have.length', 1)
cy.get('.next-highlight-button').should('have.length', 0)
// Click the "More updates above" button, which should scroll the editor up
// but not quite to the top, and check the new state
cy.get('.previous-highlight-button').first().click()
cy.get('@scroller').invoke('scrollTop').should('equal', 0)
cy.get('.previous-highlight-button').should('not.exist')
cy.get('.next-highlight-button').should('have.length', 1)
})
it('scrolls to first change', function () {
const scope = mockScope()
const finalHighlightOnly = highlights.slice(-1)
cy.mount(
<Container>
<EditorProviders scope={scope}>
<DocumentDiffViewer doc={doc} highlights={finalHighlightOnly} />
</EditorProviders>
</Container>
)
cy.get('.cm-scroller').first().invoke('scrollTop').should('not.equal', 0)
cy.get('.ol-cm-addition-marker')
.first()
.then($marker => {
cy.get('.cm-content')
.first()
.then($content => {
const contentRect = $content[0].getBoundingClientRect()
const markerRect = $marker[0].getBoundingClientRect()
expect(markerRect.top).to.be.within(
contentRect.top,
contentRect.bottom
)
expect(markerRect.bottom).to.be.within(
contentRect.top,
contentRect.bottom
)
})
})
})
})

View File

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

View File

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

View File

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

View File

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

View File

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