Merge pull request #14499 from overleaf/mj-table-gen-tests

[visual] Add cypress tests for table generator

GitOrigin-RevId: 03bdd315221f8a5998fd37de4c955635dcc86148
This commit is contained in:
Mathias Jakobsen 2023-08-29 11:14:04 +01:00 committed by Copybot
parent a3ee0dcd39
commit 28d8c2b135
8 changed files with 464 additions and 19 deletions

View file

@ -10,6 +10,7 @@ import { interceptAsync } from './intercept-async'
import { interceptFileUpload } from './upload'
import { interceptProjectListing } from './project-list'
import { interceptLinkedFile } from './linked-file'
import { interceptMathJax } from './mathjax'
// eslint-disable-next-line no-unused-vars,@typescript-eslint/no-namespace
declare global {
@ -26,6 +27,7 @@ declare global {
interceptFileUpload: typeof interceptFileUpload
interceptProjectListing: typeof interceptProjectListing
interceptLinkedFile: typeof interceptLinkedFile
interceptMathJax: typeof interceptMathJax
}
}
}
@ -39,3 +41,4 @@ Cypress.Commands.add('interceptDeferredCompile', interceptDeferredCompile)
Cypress.Commands.add('interceptFileUpload', interceptFileUpload)
Cypress.Commands.add('interceptProjectListing', interceptProjectListing)
Cypress.Commands.add('interceptLinkedFile', interceptLinkedFile)
Cypress.Commands.add('interceptMathJax', interceptMathJax)

View file

@ -0,0 +1,23 @@
const MATHJAX_STUB = `
window.MathJax = {
startup: {
promise: Promise.resolve()
},
svgStylesheet: () => document.createElement("STYLE")
}
`
export const interceptMathJax = () => {
cy.window().then(win => {
win.metaAttributesCache.set(
'ol-mathJax3Path',
'https://unpkg.com/mathjax@3.2.2/es5/tex-svg-full.js'
)
})
cy.intercept('GET', '/js/libs/mathjax3/es5/tex-svg-full.js*', MATHJAX_STUB)
cy.intercept(
'GET',
'https://unpkg.com/mathjax@3.2.2/es5/tex-svg-full.js',
MATHJAX_STUB
)
}

View file

@ -40,7 +40,12 @@ export const loadMathJax = async () => {
}
const script = document.createElement('script')
script.src = getMeta('ol-mathJax3Path')
const path = getMeta('ol-mathJax3Path')
if (!path) {
reject(new Error('No MathJax path found'))
return
}
script.src = path
script.addEventListener('load', async () => {
await window.MathJax.startup.promise
document.head.appendChild(window.MathJax.svgStylesheet())

View file

@ -157,11 +157,13 @@ export const Cell: FC<{
renderDiv.current,
toDisplay.substring.bind(toDisplay)
)
loadMathJax().then(async MathJax => {
if (renderDiv.current) {
await MathJax.typesetPromise([renderDiv.current])
}
})
loadMathJax()
.then(async MathJax => {
if (renderDiv.current) {
await MathJax.typesetPromise([renderDiv.current])
}
})
.catch(() => {})
}
}, [cellData.content, editing])

View file

@ -172,6 +172,15 @@ export const Table: FC = () => {
ref={tableRef}
>
<thead>
{/* A workaround for a chrome bug where it will not respect colspan
unless there is a row filled with cells without colspan */}
<tr className="table-generator-filler-row">
{/* A td for the row selector */}
<td />
{tableData.columns.map((_, columnIndex) => (
<td key={columnIndex} />
))}
</tr>
<tr>
<td />
{tableData.columns.map((_, columnIndex) => (
@ -188,15 +197,6 @@ export const Table: FC = () => {
columnSpecifications={tableData.columns}
/>
))}
{/* A workaround for a chrome bug where it will not respect colspan
unless there is a row filled with cells without colspan */}
<tr className="table-generator-filler-row">
{/* A td for the row selector */}
<td />
{tableData.columns.map((_, columnIndex) => (
<td key={columnIndex} />
))}
</tr>
</tbody>
</table>
)

View file

@ -340,6 +340,7 @@ export const insertRow = (
selection: TableSelection,
positions: Positions,
below: boolean,
rowSeparators: RowSeparator[],
table: TableData
) => {
const { maxY, minY } = selection.normalized()
@ -350,11 +351,13 @@ export const insertRow = (
const numberOfColumns = table.columns.length
const borderTheme = table.getBorderTheme()
const border = borderTheme === BorderTheme.FULLY_BORDERED ? '\\hline' : ''
const initialRowSeparator =
below && rowSeparators.length === table.rows.length - 1 ? '\\\\' : ''
const initialHline =
borderTheme === BorderTheme.FULLY_BORDERED && !below && minY === 0
? '\\hline'
: ''
const insert = `${initialHline}\n${' &'.repeat(
const insert = `${initialRowSeparator}${initialHline}\n${' &'.repeat(
numberOfColumns - 1
)}\\\\${border}`.repeat(rowsToInsert)
view.dispatch({ changes: { from, to: from, insert } })
@ -599,7 +602,7 @@ export const mergeCells = (
}
const cellContent = []
for (let i = minX; i <= maxX; i++) {
cellContent.push(table.getCell(minY, i).content)
cellContent.push(table.getCell(minY, i).content.trim())
}
const content = cellContent.join(' ').trim()
const border =

View file

@ -266,7 +266,16 @@ export const Toolbar = memo(function Toolbar() {
role="menuitem"
type="button"
onClick={() => {
setSelection(insertRow(view, selection, positions, false, table))
setSelection(
insertRow(
view,
selection,
positions,
false,
rowSeparators,
table
)
)
}}
>
<span className="table-generator-button-label">
@ -280,7 +289,16 @@ export const Toolbar = memo(function Toolbar() {
role="menuitem"
type="button"
onClick={() => {
setSelection(insertRow(view, selection, positions, true, table))
setSelection(
insertRow(
view,
selection,
positions,
true,
rowSeparators,
table
)
)
}}
>
<span className="table-generator-button-label">

View file

@ -0,0 +1,391 @@
import { FC } from 'react'
import { EditorProviders } from '../../../helpers/editor-providers'
import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
import { mockScope } from '../helpers/mock-scope'
const Container: FC = ({ children }) => (
<div style={{ width: 785, height: 785 }}>{children}</div>
)
const mountEditor = (content: string | string[]) => {
if (Array.isArray(content)) {
content = content.join('\n')
}
if (!content.startsWith('\n')) {
content = '\n' + content
}
const scope = mockScope(content)
scope.editor.showVisual = true
cy.mount(
<Container>
<EditorProviders scope={scope}>
<CodemirrorEditor />
</EditorProviders>
</Container>
)
// wait for the content to be parsed and revealed
cy.get('.cm-content').should('have.css', 'opacity', '1')
cy.get('.cm-line').first().click()
}
function checkTable(
expected: (string | { text: string; colspan: number })[][]
) {
cy.get('.table-generator').as('table').should('exist')
cy.get('@table')
.find('tbody')
.as('body')
.find('tr')
.should('have.length', expected.length)
cy.get('@body')
.find('tr')
.each((row, rowIndex) => {
// Add one to the expected length to account for the row selector
cy.wrap(row)
.find('.table-generator-cell')
.as('cells')
.should('have.length', expected[rowIndex].length)
cy.get('@cells').each((cell, cellIndex) => {
const expectation = expected[rowIndex][cellIndex]
const cellText =
typeof expectation === 'string' ? expectation : expectation.text
const colspan =
typeof expectation === 'string' ? undefined : expectation.colspan
cy.wrap(cell).should('contain.text', cellText)
if (colspan) {
cy.wrap(cell).should('have.attr', 'colspan', colspan.toString())
}
})
})
}
function checkBordersWithNoMultiColumn(
verticalBorderIndices: boolean[],
horizontalBorderIndices: boolean[]
) {
cy.get('.table-generator').as('table').should('have.length', 1)
cy.get('@table')
.find('tbody')
.as('body')
.find('tr')
.should('have.length', verticalBorderIndices.length - 1)
.each((row, rowIndex) => {
cy.wrap(row)
.find('.table-generator-cell')
.should('have.length', horizontalBorderIndices.length - 1)
.each((cell, cellIndex) => {
if (cellIndex === 0) {
cy.wrap(cell).should(
horizontalBorderIndices[0] ? 'have.class' : 'not.have.class',
'table-generator-cell-border-left'
)
}
cy.wrap(cell).should(
horizontalBorderIndices[cellIndex + 1]
? 'have.class'
: 'not.have.class',
'table-generator-cell-border-right'
)
cy.wrap(cell).should(
verticalBorderIndices[rowIndex] ? 'have.class' : 'not.have.class',
'table-generator-row-border-top'
)
if (rowIndex === verticalBorderIndices.length - 2) {
cy.wrap(cell).should(
verticalBorderIndices[rowIndex + 1]
? 'have.class'
: 'not.have.class',
'table-generator-row-border-bottom'
)
}
})
})
}
describe('<CodeMirrorEditor/> Table editor', function () {
beforeEach(function () {
cy.interceptEvents()
cy.interceptSpelling()
cy.interceptMathJax()
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
window.metaAttributesCache.set('ol-splitTestVariants', {
'table-generator': 'enabled',
})
})
describe('Table rendering', function () {
it('Renders a simple table', function () {
mountEditor(`
\\begin{tabular}{ccc}
cell 1 & cell 2 & cell 3 \\\\
cell 4 & cell 5 & cell 6 \\\\
\\end{tabular}`)
// Find cell in table
checkTable([
['cell 1', 'cell 2', 'cell 3'],
['cell 4', 'cell 5', 'cell 6'],
])
})
it('Renders a table with \\multicolumn', function () {
mountEditor(`
\\begin{tabular}{ccc}
\\multicolumn{2}{c}{cell 1 and cell 2} & cell 3 \\\\
cell 4 & cell 5 & cell 6 \\\\
\\end{tabular}`)
// Find cell in table
checkTable([
[{ text: 'cell 1 and cell 2', colspan: 2 }, 'cell 3'],
['cell 4', 'cell 5', 'cell 6'],
])
})
it('Renders borders', function () {
mountEditor(`
\\begin{tabular}{c|c}
cell 1 & cell 2 \\\\
\\hline
cell 3 & cell 4 \\\\
\\end{tabular}`)
checkBordersWithNoMultiColumn([false, true, false], [false, true, false])
})
})
describe('The toolbar', function () {
it('Renders the toolbar when the table is selected', function () {
mountEditor(`
\\begin{tabular}{c}
cell
\\end{tabular}
`)
cy.get('.table-generator-floating-toolbar').should('not.exist')
cy.get('.table-generator-cell').click()
cy.get('.table-generator-floating-toolbar').should('exist')
// The element is partially covered, but we can still click it
cy.get('.cm-line').first().click({ force: true })
cy.get('.table-generator-floating-toolbar').should('not.exist')
})
it('Adds and removes borders when theme is changed', function () {
mountEditor(`
\\begin{tabular}{c|c}
cell 1 & cell 2 \\\\
cell 3 & cell 4 \\\\
\\end{tabular}
`)
checkBordersWithNoMultiColumn([false, false, false], [false, true, false])
cy.get('.table-generator-floating-toolbar').should('not.exist')
cy.get('.table-generator-cell').first().click()
cy.get('.table-generator-floating-toolbar').as('toolbar').should('exist')
cy.get('@toolbar').findByText('Custom borders').click()
cy.get('.table-generator').findByText('All borders').click()
// The element is partially covered, but we can still click it
cy.get('.cm-line').first().click({ force: true })
// Table should be unchanged
checkTable([
['cell 1', 'cell 2'],
['cell 3', 'cell 4'],
])
checkBordersWithNoMultiColumn([true, true, true], [true, true, true])
cy.get('.table-generator-cell').first().click()
cy.get('@toolbar').findByText('All borders').click()
cy.get('.table-generator').findByText('No borders').click()
// The element is partially covered, but we can still click it
cy.get('.cm-line').first().click({ force: true })
// Table should be unchanged
checkTable([
['cell 1', 'cell 2'],
['cell 3', 'cell 4'],
])
checkBordersWithNoMultiColumn(
[false, false, false],
[false, false, false]
)
})
it('Changes the column alignment with dropdown buttons', function () {
mountEditor(`
\\begin{tabular}{cc}
cell 1 & cell 2 \\\\
cell 3 & cell 4 \\\\
\\end{tabular}
`)
cy.get('.table-generator-cell')
.should('have.class', 'alignment-center')
.first()
.click()
cy.get('.table-generator-floating-toolbar').as('toolbar').should('exist')
cy.get('@toolbar').findByLabelText('Alignment').should('be.disabled')
cy.get('.column-selector').first().click()
cy.get('@toolbar')
.findByLabelText('Alignment')
.should('not.be.disabled')
.click()
cy.get('.table-generator').findByLabelText('Left').click()
// The element is partially covered, but we can still click it
cy.get('.cm-line').first().click({ force: true })
// Table contents shouldn't have changed
checkTable([
['cell 1', 'cell 2'],
['cell 3', 'cell 4'],
])
cy.get('.table-generator-cell')
.eq(0)
.should('have.class', 'alignment-left')
cy.get('.table-generator-cell')
.eq(1)
.should('have.class', 'alignment-center')
cy.get('.table-generator-cell')
.eq(2)
.should('have.class', 'alignment-left')
cy.get('.table-generator-cell')
.eq(3)
.should('have.class', 'alignment-center')
})
it('Removes rows and columns', function () {
mountEditor(`
\\begin{tabular}{ccc}
cell 1 & cell 2 & cell 3 \\\\
cell 4 & cell 5 & cell 6 \\\\
cell 7 & cell 8 & cell 9 \\\\
\\end{tabular}
`)
checkTable([
['cell 1', 'cell 2', 'cell 3'],
['cell 4', 'cell 5', 'cell 6'],
['cell 7', 'cell 8', 'cell 9'],
])
cy.get('.table-generator-cell').first().click()
cy.get('.table-generator-floating-toolbar').as('toolbar').should('exist')
cy.get('@toolbar')
.findByLabelText('Delete row or column')
.should('be.disabled')
cy.get('.column-selector').eq(1).click()
cy.get('@toolbar').findByLabelText('Delete row or column').click()
checkTable([
['cell 1', 'cell 3'],
['cell 4', 'cell 6'],
['cell 7', 'cell 9'],
])
cy.get('.row-selector').eq(1).click()
cy.get('@toolbar').findByLabelText('Delete row or column').click()
checkTable([
['cell 1', 'cell 3'],
['cell 7', 'cell 9'],
])
})
it('Merges and unmerged cells', function () {
mountEditor(`
\\begin{tabular}{ccc}
cell 1 & cell 2 & cell 3 \\\\
cell 4 & cell 5 & cell 6 \\\\
\\end{tabular}
`)
cy.get('.table-generator-cell').first().click()
cy.get('.table-generator-cell').first().type('{shift}{rightarrow}')
cy.get('.table-generator-floating-toolbar').as('toolbar').should('exist')
cy.get('@toolbar').findByLabelText('Merge cells').click()
checkTable([
[{ text: 'cell 1 cell 2', colspan: 2 }, 'cell 3'],
['cell 4', 'cell 5', 'cell 6'],
])
cy.get('@toolbar').findByLabelText('Unmerge cells').click()
checkTable([
['cell 1 cell 2', '', 'cell 3'],
['cell 4', 'cell 5', 'cell 6'],
])
})
it('Adds rows and columns', function () {
mountEditor(`
\\begin{tabular}{c}
cell 1
\\end{tabular}
`)
cy.get('.table-generator').findByText('cell 1').click()
cy.get('.table-generator-floating-toolbar').as('toolbar').should('exist')
cy.get('.table-generator').findByText('cell 1').click()
cy.get('@toolbar').findByLabelText('Insert').click()
cy.get('.table-generator').findByText('Insert column left').click()
checkTable([['', 'cell 1']])
cy.get('.table-generator').findByText('cell 1').click()
cy.get('@toolbar').findByLabelText('Insert').click()
cy.get('.table-generator').findByText('Insert column right').click()
checkTable([['', 'cell 1', '']])
cy.get('.table-generator').findByText('cell 1').click()
cy.get('@toolbar').findByLabelText('Insert').click()
cy.get('.table-generator').findByText('Insert row above').click()
checkTable([
['', '', ''],
['', 'cell 1', ''],
])
cy.get('.table-generator').findByText('cell 1').click()
cy.get('@toolbar').findByLabelText('Insert').click()
cy.get('.table-generator').findByText('Insert row below').click()
checkTable([
['', '', ''],
['', 'cell 1', ''],
['', '', ''],
])
})
it('Removes the table on toolbar button click', function () {
mountEditor(`
\\begin{tabular}{c}
cell 1
\\end{tabular}`)
cy.get('.table-generator').findByText('cell 1').click()
cy.get('.table-generator-floating-toolbar').as('toolbar').should('exist')
cy.get('@toolbar').findByLabelText('Delete table').click()
cy.get('.table-generator').should('not.exist')
})
it('Moves the caption when using dropdown', function () {
mountEditor(`
\\begin{table}
\\caption{Table caption}
\\label{tab:table}
\\begin{tabular}{c}
cell 1
\\end{tabular}
\\end{table}`)
cy.get('.table-generator').findByText('cell 1').click()
cy.get('.table-generator-floating-toolbar').as('toolbar').should('exist')
cy.get('@toolbar').findByText('Caption above').click()
cy.get('.table-generator-toolbar-dropdown-menu')
.findByText('Caption below')
.click()
// Check that caption is below table
cy.get('.ol-cm-command-caption').then(([caption]) => {
const { top: captionYPosition } = caption.getBoundingClientRect()
cy.get('.table-generator').then(([table]) => {
const { top: tableYPosition } = table.getBoundingClientRect()
cy.wrap(captionYPosition).should('be.greaterThan', tableYPosition)
})
})
// Removes caption when clicking "No caption"
cy.get('@toolbar').findByText('Caption below').click()
cy.get('.table-generator-toolbar-dropdown-menu')
.findByText('No caption')
.click()
cy.get('@toolbar').findByText('No caption').should('exist')
cy.get('.ol-cm-command-caption').should('not.exist')
cy.get('.ol-cm-command-label').should('not.exist')
})
})
})