فهرست منبع

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

[visual] Add cypress tests for table generator

GitOrigin-RevId: 03bdd315221f8a5998fd37de4c955635dcc86148
Mathias Jakobsen 3 سال پیش
والد
کامیت
28d8c2b135

+ 3 - 0
services/web/cypress/support/shared/commands/index.ts

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

+ 23 - 0
services/web/cypress/support/shared/commands/mathjax.ts

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

+ 6 - 1
services/web/frontend/js/features/mathjax/load-mathjax.ts

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

+ 7 - 5
services/web/frontend/js/features/source-editor/components/table-generator/cell.tsx

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

+ 9 - 9
services/web/frontend/js/features/source-editor/components/table-generator/table.tsx

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

+ 5 - 2
services/web/frontend/js/features/source-editor/components/table-generator/toolbar/commands.ts

@@ -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 =

+ 20 - 2
services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar.tsx

@@ -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">

+ 391 - 0
services/web/test/frontend/features/source-editor/components/codemirror-editor-table-generator.spec.tsx

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