diff --git a/cypress.config.ts b/cypress.config.ts new file mode 100644 index 000000000..058efddb8 --- /dev/null +++ b/cypress.config.ts @@ -0,0 +1,46 @@ +import { defineConfig } from 'cypress'; + +const task = { + log(message: string) { + //eslint-disable-next-line no-console + console.log(message); + + return null; + }, + table(message: string) { + //eslint-disable-next-line no-console + console.table(message); + + return null; + }, +}; + +export default defineConfig({ + retries: { + runMode: 2, + openMode: 0, + }, + numTestsKeptInMemory: 1, + e2e: { + // We've imported your old cypress plugins here. + // You may want to clean this up later by importing these. + setupNodeEvents(_on, config) { + return config; + }, + baseUrl: 'http://localhost:9000', + specPattern: 'cypress/e2e/**/*.spec.*', + excludeSpecPattern: ['**/__snapshots__/*', '**/__image_snapshots__/*'], + }, + component: { + setupNodeEvents(on, config) { + on('task', task); + return config; + }, + devServer: { + framework: 'create-react-app', + bundler: 'webpack', + }, + specPattern: 'cypress/component/**/*.spec.{js,ts,jsx,tsx}', + excludeSpecPattern: ['**/__snapshots__/*', '**/__image_snapshots__/*'], + }, +}); diff --git a/cypress/e2e/DateEditor.spec.ts b/cypress/e2e/DateEditor.spec.ts new file mode 100644 index 000000000..866e80694 --- /dev/null +++ b/cypress/e2e/DateEditor.spec.ts @@ -0,0 +1,351 @@ +describe('Date Editor', () => { + // February 15, 2019 timestamp + const now = new Date(2019, 1, 15); + + const selectors = { + getDateInput: () => { + return cy.findByTestId('cf-ui-datepicker-input'); + }, + getCalendarTrigger: () => { + return cy.findByTestId('cf-ui-datepicker-button'); + }, + getTimeInput: () => { + return cy.findByTestId('time-input'); + }, + getTimezoneInput: () => { + return cy.findByTestId('timezone-input'); + }, + getClearBtn: () => { + return cy.findByTestId('date-clear'); + }, + getCalendar: () => { + return cy.get('.rdp'); + }, + getCalendarMonth: () => { + return cy.get('.rdp select[aria-label="Month: "]'); + }, + getCalendarYear: () => { + return cy.get('.rdp select[aria-label="Year: "]'); + }, + getCalendarTodayDate: () => { + return cy.get('.rdp .rdp-day_today'); + }, + getCalendarSelectedDate: () => { + return cy.get('.rdp .rdp-day_selected'); + }, + }; + + const getToday = () => { + const month = now.getMonth(); + const year = now.getFullYear(); + const date = now.getDate(); + return { + month, + year, + date, + }; + }; + + const openPage = () => { + cy.visit('/date'); + cy.findByTestId('date-editor-integration-test').should('be.visible'); + }; + + beforeEach(() => { + cy.clock(now.getTime()); + }); + + describe('disabled state', () => { + it('all fields should be disabled', () => { + cy.setInitialDisabled(true); + openPage(); + + selectors.getDateInput().should('be.disabled'); + selectors.getTimeInput().should('be.disabled'); + selectors.getTimezoneInput().should('be.disabled'); + selectors.getClearBtn().should('not.exist'); + }); + }); + + describe('default configuration', () => { + it('should read initial value', () => { + cy.setInitialValue('2018-01-03T05:53+03:00'); + openPage(); + + selectors.getDateInput().should('have.value', '03 Jan 2018'); + selectors.getTimeInput().should('have.value', '05:53'); + selectors.getTimezoneInput().should('have.value', '+03:00'); + }); + + it('should render date, time (24 format) and timezone inputs by default', () => { + openPage(); + + selectors.getDateInput().should('be.visible').should('have.value', ''); + selectors + .getTimeInput() + .should('be.visible') + .should('have.attr', 'date-time-type', '24') + .should('have.attr', 'placeholder', '00:00') + .should('have.value', '00:00'); + // @TODO: is this value affected by daytime savings? + selectors.getTimezoneInput().should('be.visible').should('have.value', '+01:00'); + }); + + it('calendar should show current year, month and date', () => { + openPage(); + const { year, month, date } = getToday(); + + selectors.getCalendarTrigger().click(); + + selectors.getCalendar().should('be.visible'); + selectors.getCalendarYear().should('have.value', year.toString()); + selectors.getCalendarMonth().should('have.value', month.toString()); + selectors.getCalendarTodayDate().should('have.text', date.toString()); + selectors.getCalendarSelectedDate().should('not.exist'); + }); + + it('correct actions are called when user interacts with editor', () => { + openPage(); + selectors.getTimezoneInput().select('+08:00').blur().should('have.value', '+08:00'); + + selectors.getCalendarTrigger().click(); + selectors.getCalendarTodayDate().click(); + selectors.getDateInput().should('have.value', '15 Feb 2019'); + + selectors.getTimeInput().focus().type('15:00').blur().should('have.value', '15:00'); + + selectors.getClearBtn().click(); + + // it is necessary cause we mock cypress.clock + cy.tick(1000); + + selectors.getDateInput().should('have.value', ''); + selectors.getTimeInput().should('have.value', '00:00'); + selectors.getTimezoneInput().should('have.value', '+00:00'); + selectors.getClearBtn().should('not.exist'); + + cy.editorEvents().should('deep.equal', [ + { id: 6, type: 'onValueChanged', value: undefined }, + { id: 5, type: 'removeValue', value: undefined }, + { id: 4, type: 'onValueChanged', value: '2019-02-15T15:00+08:00' }, + { id: 3, type: 'setValue', value: '2019-02-15T15:00+08:00' }, + { id: 2, type: 'onValueChanged', value: '2019-02-15T00:00+08:00' }, + { id: 1, type: 'setValue', value: '2019-02-15T00:00+08:00' }, + ]); + }); + + it('should reset field state on external change', () => { + cy.setInitialValue('1990-01-03T22:53+03:00'); + + openPage(); + + selectors.getDateInput().should('have.value', '03 Jan 1990'); + selectors.getTimeInput().should('have.value', '22:53'); + selectors.getTimezoneInput().should('have.value', '+03:00'); + + cy.setValueExternal('1992-01-03T21:40+05:00'); + + cy.wait(500); + + selectors.getDateInput().should('have.value', '03 Jan 1992'); + selectors.getTimeInput().should('have.value', '21:40'); + selectors.getTimezoneInput().should('have.value', '+05:00'); + }); + + it('should parse values in time input', () => { + openPage(); + selectors.getTimeInput().should('have.value', '00:00'); + + const pairs = [ + ['3 PM', '15:00'], + ['1:01', '01:01'], + ['5', '05:00'], + ['99', '00:00'], + ['asdasd', '00:00'], + ['9:43 AM', '09:43'], + ]; + + pairs.forEach((pair) => { + selectors.getTimeInput().type(pair[0]).blur().should('have.value', pair[1]); + }); + }); + + it('should show the correct date regardless of the time and timezone #1', () => { + cy.setInitialValue('2022-11-01T00:00+02:00'); + openPage(); + + selectors.getDateInput().should('have.value', '01 Nov 2022'); + selectors.getTimeInput().should('have.value', '00:00'); + selectors.getTimezoneInput().should('have.value', '+02:00'); + + selectors.getCalendarTrigger().click(); + + selectors.getCalendar().should('be.visible'); + selectors.getCalendarYear().should('have.value', '2022'); + selectors.getCalendarMonth().should('have.value', '10'); + selectors.getCalendarSelectedDate().should('have.text', '1'); + }); + + it('should show the correct date regardless of the time and timezone #2', () => { + cy.setInitialValue('2022-11-01T00:00+12:00'); + openPage(); + + selectors.getDateInput().should('have.value', '01 Nov 2022'); + selectors.getTimeInput().should('have.value', '00:00'); + selectors.getTimezoneInput().should('have.value', '+12:00'); + + selectors.getCalendarTrigger().click(); + + selectors.getCalendar().should('be.visible'); + selectors.getCalendarYear().should('have.value', '2022'); + selectors.getCalendarMonth().should('have.value', '10'); + selectors.getCalendarSelectedDate().should('have.text', '1'); + }); + }); + + describe('without timezone and with AM/PM', () => { + beforeEach(() => { + cy.setInstanceParams({ + format: 'time', + ampm: '12', + }); + }); + + it('should read initial value', () => { + cy.setInitialValue('1990-01-03T22:53+03:00'); + openPage(); + + selectors.getDateInput().should('have.value', '03 Jan 1990'); + selectors.getTimeInput().should('have.value', '10:53 PM'); + selectors.getTimezoneInput().should('not.exist'); + + selectors.getCalendarTrigger().click(); + + selectors.getCalendar().should('be.visible'); + selectors.getCalendarYear().should('have.value', '1990'); + selectors.getCalendarMonth().should('have.value', '0'); + selectors.getCalendarSelectedDate().should('have.text', '3'); + }); + + it('should parse values in time input', () => { + openPage(); + selectors.getTimeInput().should('have.value', '12:00 AM'); + + const pairs = [ + ['3 PM', '03:00 PM'], + ['1:01', '01:01 AM'], + ['5', '05:00 AM'], + ['99', '12:00 AM'], + ['asdasd', '12:00 AM'], + ['9:43', '09:43 AM'], + ]; + + pairs.forEach((pair) => { + selectors.getTimeInput().type(pair[0]).blur().should('have.value', pair[1]); + }); + }); + + it('correct actions are called when user interacts with editor', () => { + openPage(); + + selectors.getCalendarTrigger().click(); + selectors.getCalendarTodayDate().click(); + selectors.getDateInput().should('have.value', '15 Feb 2019'); + + selectors.getTimeInput().focus().type('3:00 PM').blur().should('have.value', '03:00 PM'); + + selectors.getClearBtn().click(); + + // it is necessary cause we mock cypress.clock + cy.tick(1000); + + selectors.getDateInput().should('have.value', ''); + selectors.getTimeInput().should('have.value', '12:00 AM'); + selectors.getClearBtn().should('not.exist'); + + cy.editorEvents().should('deep.equal', [ + { id: 6, type: 'onValueChanged', value: undefined }, + { id: 5, type: 'removeValue', value: undefined }, + { id: 4, type: 'onValueChanged', value: '2019-02-15T15:00' }, + { id: 3, type: 'setValue', value: '2019-02-15T15:00' }, + { id: 2, type: 'onValueChanged', value: '2019-02-15T00:00' }, + { id: 1, type: 'setValue', value: '2019-02-15T00:00' }, + ]); + }); + + it('should reset field state on external change', () => { + cy.setInitialValue('1990-01-03T22:53'); + + openPage(); + + selectors.getDateInput().should('have.value', '03 Jan 1990'); + selectors.getTimeInput().should('have.value', '10:53 PM'); + + cy.setValueExternal('1992-01-03T21:40'); + + cy.wait(500); + + selectors.getDateInput().should('have.value', '03 Jan 1992'); + selectors.getTimeInput().should('have.value', '09:40 PM'); + }); + }); + + describe('without timezone and time', () => { + beforeEach(() => { + cy.setInstanceParams({ + format: 'dateonly', + }); + }); + + it('should read initial value', () => { + cy.setInitialValue('1990-01-03T22:53'); + openPage(); + + selectors.getDateInput().should('have.value', '03 Jan 1990'); + selectors.getTimeInput().should('not.exist'); + selectors.getTimezoneInput().should('not.exist'); + + selectors.getCalendarTrigger().click(); + + selectors.getCalendar().should('be.visible'); + selectors.getCalendarYear().should('have.value', '1990'); + selectors.getCalendarMonth().should('have.value', '0'); + selectors.getCalendarSelectedDate().should('have.text', '3'); + }); + + it('correct actions are called when user interacts with editor', () => { + openPage(); + + selectors.getCalendarTrigger().click(); + selectors.getCalendarTodayDate().click(); + selectors.getDateInput().should('have.value', '15 Feb 2019'); + + selectors.getClearBtn().click(); + + // it is necessary cause we mock cypress.clock + cy.tick(1000); + + selectors.getDateInput().should('have.value', ''); + selectors.getClearBtn().should('not.exist'); + + cy.editorEvents().should('deep.equal', [ + { id: 4, type: 'onValueChanged', value: undefined }, + { id: 3, type: 'removeValue', value: undefined }, + { id: 2, type: 'onValueChanged', value: '2019-02-15' }, + { id: 1, type: 'setValue', value: '2019-02-15' }, + ]); + }); + + it('should reset field state on external change', () => { + cy.setInitialValue('1990-01-03'); + + openPage(); + + selectors.getDateInput().should('have.value', '03 Jan 1990'); + + cy.setValueExternal('1992-01-03'); + + selectors.getDateInput().should('have.value', '03 Jan 1992'); + }); + }); +}); diff --git a/cypress/e2e/JsonEditor.spec.ts b/cypress/e2e/JsonEditor.spec.ts new file mode 100644 index 000000000..c9ebcd701 --- /dev/null +++ b/cypress/e2e/JsonEditor.spec.ts @@ -0,0 +1,129 @@ +describe('JSON Editor', () => { + const selectors = { + getInput: () => { + return cy.get('[data-test-id="json-editor-code-mirror"] .cm-content'); + }, + getCode: () => { + return cy.get('[data-test-id="json-editor-code-mirror"] .cm-editor'); + }, + getRedoButton: () => { + return cy.findByTestId('json-editor-redo'); + }, + getUndoButton: () => { + return cy.findByTestId('json-editor-undo'); + }, + getValidationError: () => { + return cy.findByTestId('json-editor.invalid-json'); + }, + }; + + const type = (value) => { + selectors.getInput().type(value, { force: true }); + cy.wait(500); + }; + + const checkCode = (value) => { + selectors.getCode().should(($div) => { + expect($div.get(0).innerText).to.eq(value); + }); + }; + + beforeEach(() => { + cy.visit('/json'); + cy.findByTestId('json-editor-integration-test').should('be.visible'); + }); + + it('should set and clear values properly', () => { + cy.editorEvents().should('deep.equal', []); + + selectors.getInput().should('have.value', ''); + + type('{}'); + + cy.editorEvents().should('deep.equal', [ + { id: 2, type: 'onValueChanged', value: {} }, + { id: 1, type: 'setValue', value: {} }, + ]); + + type('{backspace}{backspace}'); + + cy.editorEvents().should('deep.equal', [ + { id: 4, type: 'onValueChanged', value: undefined }, + { id: 3, type: 'removeValue', value: undefined }, + { id: 2, type: 'onValueChanged', value: {} }, + { id: 1, type: 'setValue', value: {} }, + ]); + }); + + it('should undo and redo properly', () => { + selectors.getUndoButton().should('be.disabled'); + selectors.getRedoButton().should('be.disabled'); + + type('{ "foo": '); + type('"bar" }'); + + selectors.getUndoButton().should('not.be.disabled'); + selectors.getRedoButton().should('be.disabled'); + + selectors.getUndoButton().click(); + selectors.getRedoButton().should('not.be.disabled'); + + checkCode('{ "foo": "bar" '); + + selectors.getUndoButton().click(); + + checkCode('{ "foo": '); + + selectors.getRedoButton().click().click(); + + selectors.getRedoButton().should('be.disabled'); + + checkCode('{ "foo": "bar" }'); + cy.wait(500); + + cy.editorEvents().should('deep.equal', [ + { id: 4, type: 'onValueChanged', value: { foo: 'bar' } }, + { id: 3, type: 'setValue', value: { foo: 'bar' } }, + { id: 2, type: 'onValueChanged', value: { foo: 'bar' } }, + { id: 1, type: 'setValue', value: { foo: 'bar' } }, + ]); + }); + + it('should reset field state on external change', () => { + type('{"foo": {'); + type('"bar": "xyz" }}'); + + cy.editorEvents().should('deep.equal', [ + { id: 2, type: 'onValueChanged', value: { foo: { bar: 'xyz' } } }, + { id: 1, type: 'setValue', value: { foo: { bar: 'xyz' } } }, + ]); + + cy.setValueExternal({ something: 'new' }); + cy.wait(500); + + cy.editorEvents().should('deep.equal', [ + { id: 3, type: 'onValueChanged', value: { something: 'new' } }, + { id: 2, type: 'onValueChanged', value: { foo: { bar: 'xyz' } } }, + { id: 1, type: 'setValue', value: { foo: { bar: 'xyz' } } }, + ]); + + selectors.getCode().should(($div) => { + expect($div.get(0).innerText).to.include('something'); + }); + + selectors.getRedoButton().should('be.disabled'); + selectors.getUndoButton().should('be.disabled'); + }); + + it('should show validation warning if object is invalid', () => { + selectors.getValidationError().should('not.exist'); + + type('{ "foo": '); + + selectors.getValidationError().should('exist').should('have.text', 'This is not valid JSON'); + + type('"bar" }'); + + selectors.getValidationError().should('not.exist'); + }); +}); diff --git a/cypress/e2e/LocationEditor.spec.ts b/cypress/e2e/LocationEditor.spec.ts new file mode 100644 index 000000000..51c1956c7 --- /dev/null +++ b/cypress/e2e/LocationEditor.spec.ts @@ -0,0 +1,139 @@ +describe('Location Editor', () => { + const LOCATION = { + address: 'Max-Urich-Straße 1, 13355 Berlin, Germany', + value: { lon: 13.38381, lat: 52.53885 }, + }; + + const selectors = { + getAddressRadio: () => { + return cy.findByTestId('location-editor-address-radio').find('input'); + }, + getCoordinatesRadio: () => { + return cy.findByTestId('location-editor-coordinates-radio').find('input'); + }, + getSearchInput: () => { + return cy.findByTestId('location-editor-search'); + }, + getClearBtn: () => { + return cy.findByTestId('location-editor-clear'); + }, + getLatitudeInput: () => { + return cy.findByTestId('location-editor-latitude'); + }, + getLongitudeInput: () => { + return cy.findByTestId('location-editor-longitude'); + }, + getLocationSuggestion: () => { + return cy.findByTestId('location-editor-suggestion'); + }, + getValidationError: () => { + return cy.findByTestId('location-editor-not-found'); + }, + }; + + beforeEach(() => { + cy.setGoogleMapsKey(); + cy.visit('/location'); + cy.findByTestId('location-editor-integration-test').should('be.visible'); + }); + + afterEach(() => { + cy.setInitialValue(undefined); + cy.setInitialDisabled(undefined); + }); + + it('should have a proper default state', () => { + cy.editorEvents().should('deep.equal', []); + + selectors.getAddressRadio().should('be.checked'); + selectors.getCoordinatesRadio().should('not.be.checked'); + selectors.getSearchInput().should('be.empty'); + selectors.getClearBtn().should('exist'); + + selectors.getCoordinatesRadio().click(); + + selectors.getLongitudeInput().should('be.empty'); + selectors.getLatitudeInput().should('be.empty'); + selectors.getClearBtn().should('exist'); + + cy.editorEvents().should('deep.equal', []); + }); + + it('should set value after latitude and longitude change', () => { + cy.mockGoogleMapsResponse(require('../fixtures/maps-by-coordinates.json')); + cy.editorEvents().should('deep.equal', []); + + selectors.getCoordinatesRadio().click(); + + selectors.getLatitudeInput().type(LOCATION.value.lat.toString(), { delay: 0 }); + cy.wait(500); + + cy.editorEvents().should('deep.equal', [ + { id: 2, type: 'onValueChanged', value: { lon: 0, lat: LOCATION.value.lat } }, + { id: 1, type: 'setValue', value: { lon: 0, lat: LOCATION.value.lat } }, + ]); + + selectors.getLongitudeInput().type(LOCATION.value.lon.toString(), { delay: 0 }); + + selectors.getAddressRadio().click(); + cy.wait(500); + + selectors.getSearchInput().should('have.value', LOCATION.address); + + cy.editorEvents().should('have.length', 4); + cy.editorEvents(2).should('deep.equal', [ + { id: 4, type: 'onValueChanged', value: LOCATION.value }, + { id: 3, type: 'setValue', value: LOCATION.value }, + ]); + + selectors.getSearchInput().clear(); + + cy.wait(500); + + cy.editorEvents().should('have.length', 6); + cy.editorEvents(2).should('deep.equal', [ + { id: 6, type: 'onValueChanged', value: undefined }, + { id: 5, type: 'removeValue', value: undefined }, + ]); + }); + + it('should set value after using search input', () => { + cy.mockGoogleMapsResponse(require('../fixtures/maps-by-address.json')); + cy.editorEvents().should('deep.equal', []); + + selectors.getSearchInput().type(LOCATION.address); + cy.wait(1000); + selectors.getLocationSuggestion().click(); + cy.wait(500); + + selectors.getCoordinatesRadio().click(); + + selectors.getLatitudeInput().should('have.value', LOCATION.value.lat.toString()); + selectors.getLongitudeInput().should('have.value', LOCATION.value.lon.toString()); + + cy.editorEvents().should('deep.equal', [ + { id: 2, type: 'onValueChanged', value: LOCATION.value }, + { id: 1, type: 'setValue', value: LOCATION.value }, + ]); + + selectors.getAddressRadio().click(); + selectors.getClearBtn().click(); + cy.wait(500); + + cy.editorEvents().should('have.length', 4); + cy.editorEvents(2).should('deep.equal', [ + { id: 4, type: 'onValueChanged', value: undefined }, + { id: 3, type: 'removeValue', value: undefined }, + ]); + }); + + it('should disable all elements if isDisabled is true', () => { + cy.setInitialDisabled(true); + cy.reload(); + + selectors.getSearchInput().should('be.disabled'); + selectors.getAddressRadio().should('be.disabled'); + selectors.getCoordinatesRadio().should('be.disabled'); + selectors.getClearBtn().should('be.disabled'); + }); +}); diff --git a/cypress/e2e/MarkdownEditorCheatsheet.spec.ts b/cypress/e2e/MarkdownEditorCheatsheet.spec.ts new file mode 100644 index 000000000..63a977b39 --- /dev/null +++ b/cypress/e2e/MarkdownEditorCheatsheet.spec.ts @@ -0,0 +1,25 @@ +describe('Markdown Editor / Cheatsheet Dialog', () => { + const selectors = { + getDialogTitle() { + return cy.findByTestId('dialog-title').find('h2'); + }, + getOpenCheatsheetButton() { + return cy.findByTestId('open-markdown-cheatsheet-button'); + }, + getCheatsheetContent() { + return cy.findByTestId('markdown-cheatsheet-dialog-content'); + }, + }; + + beforeEach(() => { + cy.visit('/markdown'); + cy.wait(500); + cy.findByTestId('markdown-editor').should('be.visible'); + }); + + it('should be visible after user clicks on help button', () => { + selectors.getOpenCheatsheetButton().click(); + selectors.getCheatsheetContent().should('exist'); + selectors.getDialogTitle().should('have.text', 'Markdown formatting help'); + }); +}); diff --git a/cypress/e2e/MarkdownEditorHistory.spec.ts b/cypress/e2e/MarkdownEditorHistory.spec.ts new file mode 100644 index 000000000..dc112ac15 --- /dev/null +++ b/cypress/e2e/MarkdownEditorHistory.spec.ts @@ -0,0 +1,52 @@ +describe('Markdown Editor / History', () => { + const selectors = { + getInput: () => { + return cy.get('[data-test-id="markdown-textarea"] [contenteditable]'); + }, + getToggleAdditionalActionsButton: () => { + return cy.findByTestId('markdown-action-button-toggle-additional'); + }, + getRedoButton() { + return cy.findByTestId('markdown-action-button-redo'); + }, + getUndoButton() { + return cy.findByTestId('markdown-action-button-undo'); + }, + }; + + const type = (value) => { + return selectors.getInput().focus().type(value, { force: true }); + }; + + const checkValue = (value) => { + cy.getMarkdownInstance().then((markdown) => { + expect(markdown.getContent()).eq(value); + }); + }; + + beforeEach(() => { + cy.visit('/markdown'); + cy.wait(500); + cy.findByTestId('markdown-editor').should('be.visible'); + selectors.getToggleAdditionalActionsButton().click(); + }); + + it('should redo and undo properly', () => { + checkValue(''); + type('Hello!{enter}'); + cy.wait(1500); + type('This is new sentence!'); + + checkValue('Hello!\nThis is new sentence!'); + + selectors.getUndoButton().click(); + checkValue('Hello!\n'); + selectors.getUndoButton().click(); + checkValue(''); + + selectors.getRedoButton().click(); + checkValue('Hello!\n'); + selectors.getRedoButton().click(); + checkValue('Hello!\nThis is new sentence!'); + }); +}); diff --git a/cypress/e2e/MarkdownEditorInsertAssets.spec.ts b/cypress/e2e/MarkdownEditorInsertAssets.spec.ts new file mode 100644 index 000000000..20b9954d7 --- /dev/null +++ b/cypress/e2e/MarkdownEditorInsertAssets.spec.ts @@ -0,0 +1,44 @@ +describe('Markdown Editor / Insert Assets', () => { + const selectors = { + getInput: () => { + return cy.get('[data-test-id="markdown-textarea"] [contenteditable]'); + }, + getInsertMediaDropdown: () => { + return cy.findByTestId('markdownEditor.insertMediaDropdownTrigger'); + }, + getInsertNewAssetButton: () => { + return cy.findByTestId('markdownEditor.uploadAssetsAndLink'); + }, + getInsertExistingAssetButton: () => { + return cy.findByTestId('markdownEditor.linkExistingAssets'); + }, + }; + + const checkValue = (value) => { + cy.getMarkdownInstance().then((markdown) => { + expect(markdown.getContent()).eq(value); + }); + }; + + beforeEach(() => { + cy.visit('/markdown'); + cy.wait(500); + cy.findByTestId('markdown-editor').should('be.visible'); + }); + + it('should insert a new asset', () => { + selectors.getInsertMediaDropdown().click(); + selectors.getInsertNewAssetButton().click(); + checkValue( + '![dog](//images.ctfassets.net/b04hhmxrptgr/6oYURL50Ddai6jRCboSB7u/b1a3768d6d987f3f6110a41175f4d7d3/dog.jpg)' + ); + }); + + it('should insert an existing asset', () => { + selectors.getInsertMediaDropdown().click(); + selectors.getInsertExistingAssetButton().click(); + checkValue( + '![test](//images.ctfassets.net/5uld3crqmsuo/12XMPLSTs2vmmjw6xTlCDg/a7099dad14319e0f2908e99c9a2d6c62/Terrier_mixed-breed_dog.jpg)' + ); + }); +}); diff --git a/cypress/e2e/MarkdownEditorInsertLink.spec.ts b/cypress/e2e/MarkdownEditorInsertLink.spec.ts new file mode 100644 index 000000000..5d02b5da3 --- /dev/null +++ b/cypress/e2e/MarkdownEditorInsertLink.spec.ts @@ -0,0 +1,219 @@ +describe('Markdown Editor / Insert Link Dialog', () => { + const selectors = { + getInput: () => { + return cy.get('[data-test-id="markdown-textarea"] [contenteditable]'); + }, + getDialogTitle() { + return cy.findByTestId('dialog-title').find('h2'); + }, + getInsertDialogButton() { + return cy.findByTestId('markdown-action-button-link'); + }, + getModalContent() { + return cy.findByTestId('insert-link-modal'); + }, + inputs: { + getLinkTextInput() { + return cy.findByTestId('link-text-field'); + }, + getTargetUrlInput() { + return cy.findByTestId('target-url-field'); + }, + getLinkTitle() { + return cy.findByTestId('link-title-field'); + }, + }, + getConfirmButton() { + return cy.findByTestId('insert-link-confirm'); + }, + getCancelButton() { + return cy.findByTestId('insert-link-cancel'); + }, + getInvalidMessage() { + return cy.findByText('Invalid URL'); + }, + }; + + const type = (value) => { + return selectors.getInput().focus().type(value, { force: true }); + }; + + const clearAll = () => { + cy.getMarkdownInstance().then((markdown) => { + markdown.clear(); + }); + }; + + const checkValue = (value) => { + cy.getMarkdownInstance().then((markdown) => { + expect(markdown.getContent()).eq(value); + }); + }; + + const selectBackwards = (skip, len) => { + cy.getMarkdownInstance().then((markdown) => { + markdown.selectBackwards(skip, len); + }); + }; + + beforeEach(() => { + cy.visit('/markdown'); + cy.viewport('macbook-16'); + cy.wait(500); + cy.findByTestId('markdown-editor').should('be.visible'); + }); + + it('should have correct title', () => { + selectors.getInsertDialogButton().click(); + selectors.getDialogTitle().should('have.text', 'Insert link'); + }); + + it('should insert nothing if click on cancel button or close window with ESC', () => { + checkValue(''); + + // close with button + selectors.getInsertDialogButton().click(); + selectors.getCancelButton().click(); + selectors.getModalContent().should('not.exist'); + checkValue(''); + + // close with esc + selectors.getInsertDialogButton().click(); + selectors.inputs.getTargetUrlInput().type('{esc}'); + selectors.getModalContent().should('not.exist'); + checkValue(''); + }); + + it('should show validation error when url is incorrect', () => { + checkValue(''); + + selectors.getInsertDialogButton().click(); + + // type correct value + + const correctValues = ['https://contentful.com', 'http://google.com', 'ftp://somefile']; + + correctValues.forEach((value) => { + selectors.inputs.getTargetUrlInput().clear().type(value); + selectors.getInvalidMessage().should('not.exist'); + selectors.getConfirmButton().should('not.be.disabled'); + }); + + // clear and type incorrect value + + const incorrectValues = [ + 'does not look like an url, bro', + 'htp://contentful.com', + 'http:/oops.com', + ]; + + incorrectValues.forEach((value) => { + selectors.inputs.getTargetUrlInput().clear().type(value); + selectors.getInvalidMessage().should('be.visible'); + selectors.getConfirmButton().should('be.disabled'); + }); + }); + + describe('when there is no text selected', () => { + it('should have correct default state', () => { + checkValue(''); + + selectors.getInsertDialogButton().click(); + + selectors.inputs + .getLinkTextInput() + .should('be.visible') + .should('have.value', '') + .should('not.be.disabled'); + selectors.inputs.getTargetUrlInput().should('be.visible').should('have.value', ''); + + selectors.inputs.getLinkTitle().should('be.visible').should('have.value', ''); + + selectors.getConfirmButton().should('be.disabled'); + selectors.getInvalidMessage().should('not.exist'); + }); + + it('should paste link in a correct format', () => { + checkValue(''); + + // with all fields provided + selectors.getInsertDialogButton().click(); + selectors.inputs.getLinkTextInput().type('best headless CMS ever'); + selectors.inputs.getTargetUrlInput().clear().type('https://contentful.com'); + selectors.inputs.getLinkTitle().clear().type('Contentful'); + + selectors.getConfirmButton().click(); + selectors.getModalContent().should('not.exist'); + checkValue('[best headless CMS ever](https://contentful.com "Contentful")'); + + // without title field + clearAll(); + checkValue(''); + selectors.getInsertDialogButton().click(); + selectors.inputs.getLinkTextInput().type('best headless CMS ever'); + selectors.inputs.getTargetUrlInput().clear().type('https://contentful.com'); + selectors.getConfirmButton().click(); + selectors.getModalContent().should('not.exist'); + checkValue('[best headless CMS ever](https://contentful.com)'); + + // only with url + clearAll(); + checkValue(''); + selectors.getInsertDialogButton().click(); + selectors.inputs.getLinkTextInput().clear(); + selectors.inputs.getTargetUrlInput().clear().type('https://contentful.com'); + selectors.getConfirmButton().click(); + selectors.getModalContent().should('not.exist'); + checkValue(''); + }); + }); + + describe('when there is text selected', () => { + it('should have correct default state', () => { + type('check out Contentful'); + selectBackwards(0, 10); + selectors.getInsertDialogButton().click(); + + selectors.inputs + .getLinkTextInput() + .should('be.visible') + .should('have.value', 'Contentful') + .should('be.disabled'); + selectors.inputs + .getTargetUrlInput() + .should('be.visible') + .should('have.value', '') + .should('have.focus'); + selectors.inputs.getLinkTitle().should('be.visible').should('have.value', ''); + + selectors.getConfirmButton().should('be.disabled'); + selectors.getInvalidMessage().should('not.exist'); + }); + + it('should paste link in a correct format', () => { + type('check out Contentful'); + selectBackwards(0, 10); + + // with all fields provided + + selectors.getInsertDialogButton().click(); + selectors.inputs.getTargetUrlInput().clear().type('https://contentful.com'); + selectors.inputs.getLinkTitle().clear().type('The best headless CMS ever'); + + selectors.getConfirmButton().click(); + selectors.getModalContent().should('not.exist'); + checkValue('check out [Contentful](https://contentful.com "The best headless CMS ever")'); + + // without title field + + clearAll(); + type('check out Contentful'); + selectBackwards(0, 10); + selectors.getInsertDialogButton().click(); + selectors.inputs.getTargetUrlInput().clear().type('https://contentful.com'); + selectors.getConfirmButton().click(); + selectors.getModalContent().should('not.exist'); + checkValue('check out [Contentful](https://contentful.com)'); + }); + }); +}); diff --git a/cypress/e2e/MarkdownEditorInsertTable.spec.ts b/cypress/e2e/MarkdownEditorInsertTable.spec.ts new file mode 100644 index 000000000..11a117ede --- /dev/null +++ b/cypress/e2e/MarkdownEditorInsertTable.spec.ts @@ -0,0 +1,120 @@ +describe('Markdown Editor / Insert Table Dialog', () => { + const selectors = { + getInput: () => { + return cy.get('[data-test-id="markdown-textarea"] [contenteditable]'); + }, + getDialogTitle() { + return cy.findByTestId('dialog-title').find('h2'); + }, + getToggleAdditionalActionsButton: () => { + return cy.findByTestId('markdown-action-button-toggle-additional'); + }, + getInsertTableButton() { + return cy.findByTestId('markdown-action-button-table'); + }, + getModalContent() { + return cy.findByTestId('insert-table-modal'); + }, + inputs: { + getRowsInput() { + return cy.findByTestId('insert-table-rows-number-field'); + }, + getColsInput() { + return cy.findByTestId('insert-table-columns-number-field'); + }, + }, + getConfirmButton() { + return cy.findByTestId('insert-table-confirm'); + }, + getCancelButton() { + return cy.findByTestId('insert-table-cancel'); + }, + }; + + const clearAll = () => { + cy.getMarkdownInstance().then((markdown) => { + markdown.clear(); + }); + }; + + const checkValue = (value) => { + cy.getMarkdownInstance().then((markdown) => { + expect(markdown.getContent()).eq(value); + }); + }; + + beforeEach(() => { + cy.visit('/markdown'); + cy.wait(500); + cy.findByTestId('markdown-editor').should('be.visible'); + }); + + it('should have correct title', () => { + selectors.getToggleAdditionalActionsButton().click(); + selectors.getInsertTableButton().click({ force: true }); + selectors.getDialogTitle().should('have.text', 'Insert table'); + }); + + it('should insert nothing if click on cancel button or close window with ESC', () => { + checkValue(''); + selectors.getToggleAdditionalActionsButton().click(); + + // close with button + selectors.getInsertTableButton().click({ force: true }); + selectors.getCancelButton().click(); + selectors.getModalContent().should('not.exist'); + checkValue(''); + + // close with esc + selectors.getInsertTableButton().click(); + selectors.inputs.getRowsInput().type('{esc}'); + selectors.getModalContent().should('not.exist'); + checkValue(''); + }); + + it('should have a correct default state', () => { + checkValue(''); + selectors.getToggleAdditionalActionsButton().click(); + selectors.getInsertTableButton().click({ force: true }); + + selectors.inputs.getRowsInput().should('have.value', '2'); + selectors.inputs.getColsInput().should('have.value', '1'); + + selectors.getConfirmButton().should('not.be.disabled'); + }); + + it('should validate incorrect values', () => { + checkValue(''); + selectors.getToggleAdditionalActionsButton().click(); + selectors.getInsertTableButton().click({ force: true }); + + selectors.inputs.getRowsInput().focus().type('{selectall}').type('1'); + + cy.findByText('Should be between 2 and 100').should('be.visible'); + + selectors.inputs.getColsInput().focus().type('{selectall}').type('100'); + + cy.findByText('Should be between 1 and 100').should('be.visible'); + + selectors.getConfirmButton().should('be.disabled'); + }); + + it('should insert table with correct number rows and cols', () => { + checkValue(''); + selectors.getToggleAdditionalActionsButton().click(); + + selectors.getInsertTableButton().click({ force: true }); + selectors.getConfirmButton().click(); + checkValue('\n| Header |\n| ---------- |\n| Cell |\n| Cell |\n\n'); + + clearAll(); + + selectors.getInsertTableButton().click(); + selectors.inputs.getRowsInput().focus().type('{selectall}').type('3'); + selectors.inputs.getColsInput().focus().type('{selectall}').type('2'); + selectors.getConfirmButton().click(); + checkValue( + '\n| Header | Header |\n| ---------- | ---------- |\n| Cell | Cell |\n| Cell | Cell |\n| Cell | Cell |\n\n' + ); + }); +}); diff --git a/cypress/e2e/MarkdownEditorSimpleActions.spec.ts b/cypress/e2e/MarkdownEditorSimpleActions.spec.ts new file mode 100644 index 000000000..953f22764 --- /dev/null +++ b/cypress/e2e/MarkdownEditorSimpleActions.spec.ts @@ -0,0 +1,435 @@ +describe('Markdown Editor / Simple Actions', () => { + const selectors = { + getInput: () => { + return cy.get('[data-test-id="markdown-textarea"] [contenteditable]'); + }, + getHeadingsSelectorButton: () => { + return cy.findByTestId('markdown-action-button-heading'); + }, + getHeadingButton: (type) => { + return cy.findByTestId('markdown-action-button-heading-' + type); + }, + getBoldButton: () => { + return cy.findByTestId('markdown-action-button-bold'); + }, + getItalicButton: () => { + return cy.findByTestId('markdown-action-button-italic'); + }, + getQuoteButton: () => { + return cy.findByTestId('markdown-action-button-quote'); + }, + getUnorderedListButton: () => { + return cy.findByTestId('markdown-action-button-ul'); + }, + getOrderedListButton: () => { + return cy.findByTestId('markdown-action-button-ol'); + }, + getToggleAdditionalActionsButton: () => { + return cy.findByTestId('markdown-action-button-toggle-additional'); + }, + getStrikeButton: () => { + return cy.findByTestId('markdown-action-button-strike'); + }, + getCodeButton: () => { + return cy.findByTestId('markdown-action-button-code'); + }, + getHorizontalLineButton: () => { + return cy.findByTestId('markdown-action-button-hr'); + }, + getIndentButton: () => { + return cy.findByTestId('markdown-action-button-indent'); + }, + getDedentButton: () => { + return cy.findByTestId('markdown-action-button-dedent'); + }, + }; + + const examples = { + long: 'This course helps you understand the basics behind Contentful. It contains modules that introduce you to core concepts and how your app consumes content from Contentful. This content is pulled from Contentful APIs using a Contentful SDK.', + code: 'console.log("This is Javascript code!");', + }; + + const type = (value) => { + return selectors.getInput().focus().type(value, { force: true }); + }; + + const unveilAdditionalButtonsRow = () => { + selectors.getToggleAdditionalActionsButton().click(); + }; + + const clearAll = () => { + cy.getMarkdownInstance().then((markdown) => { + markdown.clear(); + }); + }; + + const selectAll = () => { + cy.getMarkdownInstance().then((markdown) => { + markdown.selectAll(); + }); + }; + + const checkValue = (value) => { + cy.getMarkdownInstance().then((markdown) => { + expect(markdown.getContent()).eq(value); + }); + }; + + const selectBackwards = (skip, len) => { + cy.getMarkdownInstance().then((markdown) => { + markdown.selectBackwards(skip, len); + }); + }; + + beforeEach(() => { + cy.visit('/markdown'); + cy.wait(500); + cy.findByTestId('markdown-editor').should('be.visible'); + }); + + describe('headings', () => { + const clickHeading = (value) => { + selectors.getHeadingsSelectorButton().click({ force: true }); + selectors.getHeadingButton(value).click(); + }; + + it('should work properly', () => { + checkValue(''); + + clickHeading('h1'); + checkValue('# '); + cy.get('.cm-header-1').should('have.text', '# '); + + clickHeading('h2'); + checkValue('## '); + cy.get('.cm-header-2').should('have.text', '## '); + + clickHeading('h3'); + checkValue('### '); + cy.get('.cm-header-3').should('have.text', '### '); + + type('Heading 3{enter}'); + + cy.get('.cm-header-3').should('have.text', '### Heading 3'); + + type('Future heading 2'); + clickHeading('h2'); + checkValue('### Heading 3\n## Future heading 2'); + + clickHeading('h2'); + checkValue('### Heading 3\nFuture heading 2'); + + type('{enter}{enter}'); + type(examples.long); + + clickHeading('h3'); + checkValue(`### Heading 3\nFuture heading 2\n\n### ${examples.long}`); + }); + }); + + describe('bold', () => { + const clickBold = () => { + selectors.getBoldButton().click(); + }; + + it('should work properly', () => { + checkValue(''); + clickBold(); + checkValue('__text in bold__'); + + type('bold text'); + checkValue('__bold text__'); + + type('{rightarrow}{rightarrow}{enter}'); + + type('Sentence a bold word.'); + selectBackwards(1, 9); // select 'bold word' + clickBold(); + type(' and not a bold word'); + checkValue('__bold text__\nSentence a __bold word__ and not a bold word.'); + }); + + it('should remove boldness to already applied', () => { + checkValue(''); + type('text'); + selectBackwards(0, 4); + clickBold(); + checkValue('__text__'); + selectBackwards(0, 8); + clickBold(); + checkValue('text'); + }); + }); + + describe('italic', () => { + const clickItalic = () => { + selectors.getItalicButton().click(); + }; + + it('should work properly', () => { + checkValue(''); + clickItalic(); + checkValue('*text in italic*'); + + type('italic text'); + checkValue('*italic text*'); + + type('{rightarrow}{rightarrow}{enter}'); + + type('Sentence an italic word.'); + selectBackwards(1, 11); // select 'italic word' + clickItalic(); + type(' and not an italic word'); + checkValue('*italic text*\nSentence an *italic word* and not an italic word.'); + }); + + it('should remove italicness to already applied', () => { + checkValue(''); + type('text'); + selectBackwards(0, 4); + clickItalic(); + checkValue('*text*'); + selectBackwards(0, 6); + clickItalic(); + checkValue('text'); + }); + }); + + describe('quote', () => { + const clickQuote = () => { + selectors.getQuoteButton().click(); + }; + + it('should work properly', () => { + checkValue(''); + clickQuote(); + checkValue('> '); + type('some really smart wisdom'); + type('{enter}'); + type('by some really smart person'); + checkValue('> some really smart wisdom\n> by some really smart person'); + + clearAll(); + checkValue(''); + + type(examples.long); + clickQuote(); + checkValue(`> ${examples.long}`); + clickQuote(); + checkValue(examples.long); + }); + }); + + describe('code', () => { + const clickCode = () => { + selectors.getCodeButton().click(); + }; + + it('should work properly', () => { + checkValue(''); + unveilAdditionalButtonsRow(); + clickCode(); + checkValue(' '); + type('var i = 0;'); + type('{enter}'); + type('i++;'); + checkValue(' var i = 0;\n i++;'); + + clearAll(); + checkValue(''); + + type(examples.code); + clickCode(); + checkValue(` ${examples.code}`); + clickCode(); + checkValue(examples.code); + }); + }); + + describe('strike', () => { + const clickStrike = () => { + selectors.getStrikeButton().click(); + }; + + it('should work properly', () => { + checkValue(''); + unveilAdditionalButtonsRow(); + clickStrike(); + checkValue('~~striked out~~'); + + type('striked text'); + checkValue('~~striked text~~'); + + type('{rightarrow}{rightarrow}{enter}'); + + type('Sentence a striked out word.'); + selectBackwards(1, 16); // select 'striked word' + clickStrike(); + type(' and not a striked out word'); + checkValue('~~striked text~~\nSentence a ~~striked out word~~ and not a striked out word.'); + }); + + it('should remove strike to already applied', () => { + checkValue(''); + unveilAdditionalButtonsRow(); + type('text'); + selectBackwards(0, 4); + clickStrike(); + checkValue('~~text~~'); + selectBackwards(0, 8); + clickStrike(); + checkValue('text'); + }); + }); + + describe('unordered list', () => { + const clickUnorderedList = () => { + selectors.getUnorderedListButton().click(); + }; + + it('should work properly', () => { + checkValue(''); + clickUnorderedList(); + type('first item'); + type('{enter}'); + type('second item'); + type('{enter}{enter}'); + checkValue('- first item\n- second item\n\n\n'); + + clearAll(); + checkValue(''); + + type('sentence at the very beginning.'); + clickUnorderedList(); + type('first item{enter}second item'); + checkValue('sentence at the very beginning.\n\n- first item\n- second item\n'); + + clearAll(); + checkValue(''); + + type('- first item'); + clickUnorderedList(); + checkValue('first item'); + + selectBackwards(0, 4); + clickUnorderedList(); + checkValue('- first item'); + type('{enter}'); + checkValue('- first item\n- '); + clickUnorderedList(); + checkValue('- first item\n'); + }); + + it('should work properly with selection', () => { + checkValue(''); + type('first item{enter}'); + type('second item{enter}'); + type('third item'); + + selectAll(); + clickUnorderedList(); + + checkValue('- first item\n- second item\n- third item'); + }); + }); + + describe('ordered list', () => { + const clickOrderedList = () => { + selectors.getOrderedListButton().click(); + }; + + it('should work properly', () => { + checkValue(''); + clickOrderedList(); + type('first item'); + type('{enter}'); + type('second item'); + type('{enter}{enter}'); + checkValue('1. first item\n2. second item\n\n\n'); + + clearAll(); + checkValue(''); + + type('sentence at the very beginning.'); + clickOrderedList(); + type('first item{enter}second item'); + checkValue('sentence at the very beginning.\n\n1. first item\n2. second item\n'); + + clearAll(); + checkValue(''); + + type('1. first item'); + clickOrderedList(); + checkValue('first item'); + + selectBackwards(0, 4); + clickOrderedList(); + checkValue('1. first item'); + type('{enter}'); + checkValue('1. first item\n2. '); + clickOrderedList(); + checkValue('1. first item\n'); + }); + + it('should work properly with selection', () => { + checkValue(''); + type('first item{enter}'); + type('second item{enter}'); + type('third item'); + + selectAll(); + clickOrderedList(); + + checkValue('1. first item\n2. second item\n3. third item'); + }); + }); + + describe('horizontal line', () => { + const clickHorizontalButton = () => { + selectors.getHorizontalLineButton().click(); + }; + + it('should work properly', () => { + checkValue(''); + unveilAdditionalButtonsRow(); + clickHorizontalButton(); + checkValue('\n---\n\n'); + clickHorizontalButton(); + checkValue('\n---\n\n\n---\n\n'); + + clearAll(); + + type('something'); + clickHorizontalButton(); + checkValue('something\n\n---\n\n'); + }); + }); + + describe('indent and dedent', () => { + const clickIndentButton = () => { + selectors.getIndentButton().click(); + }; + + const clickDedentButton = () => { + selectors.getDedentButton().click(); + }; + + it('should work properly', () => { + checkValue(''); + unveilAdditionalButtonsRow(); + type('something'); + clickIndentButton(); + checkValue(' something'); + + type('{enter}'); + clickIndentButton(); + type('line two{enter}'); + clickDedentButton(); + type('line three{enter}'); + clickDedentButton(); + type('final line'); + + checkValue(' something\n line two\n line three\nfinal line'); + }); + }); +}); diff --git a/cypress/e2e/MarkdownEditorSpecialCharacter.spec.ts b/cypress/e2e/MarkdownEditorSpecialCharacter.spec.ts new file mode 100644 index 000000000..a7c4fc23c --- /dev/null +++ b/cypress/e2e/MarkdownEditorSpecialCharacter.spec.ts @@ -0,0 +1,84 @@ +describe('Markdown Editor / Insert Special Character Dialog', () => { + const selectors = { + getInput: () => { + return cy.get('[data-test-id="markdown-textarea"] [contenteditable]'); + }, + getDialogTitle() { + return cy.findByTestId('dialog-title').find('h2'); + }, + getToggleAdditionalActionsButton: () => { + return cy.findByTestId('markdown-action-button-toggle-additional'); + }, + getModalContent() { + return cy.findByTestId('insert-special-character-modal'); + }, + getInsertCharacterButton() { + return cy.findByTestId('markdown-action-button-special'); + }, + getConfirmButton() { + return cy.findByTestId('insert-character-confirm'); + }, + getCancelButton() { + return cy.findByTestId('insert-character-cancel'); + }, + getSpecialCharacterButtons() { + return cy.findAllByTestId('special-character-button'); + }, + getCharButton(char: string) { + return cy.findByText(char); + }, + }; + + const checkValue = (value) => { + cy.getMarkdownInstance().then((markdown) => { + expect(markdown.getContent()).eq(value); + }); + }; + + beforeEach(() => { + cy.visit('/markdown'); + cy.wait(500); + cy.findByTestId('markdown-editor').should('be.visible'); + selectors.getToggleAdditionalActionsButton().click(); + }); + + function openDialog() { + // we need to force the click here as a tooltip covers it + selectors.getInsertCharacterButton().click({ force: true }); + } + + function insertSpecialCharacter(char: string) { + selectors.getCharButton(char).click(); + selectors.getConfirmButton().click(); + } + + it('should have correct title', () => { + openDialog(); + selectors.getDialogTitle().should('have.text', 'Insert special character'); + }); + + it('should insert first charter by default', () => { + checkValue(''); + openDialog(); + selectors.getConfirmButton().click(); + checkValue('´'); + }); + + it('should include any selected character', () => { + checkValue(''); + openDialog(); + selectors.getSpecialCharacterButtons().should('have.length', 54); + insertSpecialCharacter('¼'); + + openDialog(); + insertSpecialCharacter('€'); + checkValue('¼€'); + }); + + it('should include nothing if dialog was just closed', () => { + checkValue(''); + openDialog(); + selectors.getCancelButton().click(); + checkValue(''); + }); +}); diff --git a/cypress/e2e/MarkdownEditorVoidElements.spec.ts b/cypress/e2e/MarkdownEditorVoidElements.spec.ts new file mode 100644 index 000000000..cab08c506 --- /dev/null +++ b/cypress/e2e/MarkdownEditorVoidElements.spec.ts @@ -0,0 +1,38 @@ +describe('Markdown Editor / Void elements', () => { + const selectors = { + getInput() { + return cy.get('[data-test-id="markdown-textarea"] [contenteditable]'); + }, + getPreviewButton() { + return cy.findByTestId('markdown-tab-preview'); + }, + getVoidElementsWarning() { + return cy.findByTestId('markdown-void-elements-warning'); + }, + getPreview() { + return cy.findByTestId('markdown-preview'); + }, + }; + + const type = (value) => { + return selectors.getInput().focus().type(value, { force: true }); + }; + + beforeEach(() => { + cy.visit('/markdown'); + cy.wait(500); + cy.findByTestId('markdown-editor').should('be.visible'); + }); + + it('renders even with invalid use of void elements', () => { + type(` +
br
+ link + + `); + + selectors.getPreviewButton().click(); + selectors.getPreview().should('contain.text', 'br'); + selectors.getPreview().should('contain.text', 'link'); + }); +}); diff --git a/cypress/e2e/MarkdownEmbedExternal.spec.ts b/cypress/e2e/MarkdownEmbedExternal.spec.ts new file mode 100644 index 000000000..5cf5ca81b --- /dev/null +++ b/cypress/e2e/MarkdownEmbedExternal.spec.ts @@ -0,0 +1,101 @@ +describe('Markdown Editor / Embed External Dialog', () => { + const selectors = { + getInput: () => { + return cy.get('[data-test-id="markdown-textarea"] [contenteditable]'); + }, + getDialogTitle() { + return cy.findByTestId('dialog-title').find('h2'); + }, + getToggleAdditionalActionsButton: () => { + return cy.findByTestId('markdown-action-button-toggle-additional'); + }, + getModalContent() { + return cy.findByTestId('embed-external-dialog'); + }, + getEmbedExternalContentButton() { + return cy.findByTestId('markdown-action-button-embed'); + }, + getConfirmButton() { + return cy.findByTestId('embed-external-confirm'); + }, + getCancelButton() { + return cy.findByTestId('embed-external-cancel'); + }, + inputs: { + getUrlInput() { + return cy.findByTestId('external-link-url-field'); + }, + getWidthInput() { + return cy.findByTestId('embedded-content-width'); + }, + getPercentRadio() { + return cy.findByLabelText('percent'); + }, + getPixelRadio() { + return cy.findByLabelText('pixels'); + }, + }, + }; + + const checkValue = (value) => { + cy.getMarkdownInstance().then((markdown) => { + expect(markdown.getContent()).eq(value); + }); + }; + + const clearAll = () => { + cy.getMarkdownInstance().then((markdown) => { + markdown.clear(); + }); + }; + + beforeEach(() => { + cy.visit('/markdown'); + cy.wait(500); + cy.findByTestId('markdown-editor').should('be.visible'); + selectors.getToggleAdditionalActionsButton().click(); + }); + + function openDialog() { + selectors.getEmbedExternalContentButton().click(); + } + + it('should have correct title', () => { + openDialog(); + selectors.getDialogTitle().should('have.text', 'Embed external content'); + }); + + it('should have correct default state', () => { + openDialog(); + + selectors.inputs.getUrlInput().should('have.value', 'https://'); + + selectors.inputs.getWidthInput().should('have.value', '100'); + selectors.inputs.getPercentRadio().should('be.checked'); + selectors.inputs.getPixelRadio().should('not.be.checked'); + }); + + it('should insert a correct embedly script', () => { + checkValue(''); + + openDialog(); + selectors.inputs.getUrlInput().clear().type('https://contentful.com'); + selectors.getConfirmButton().click(); + selectors.getModalContent().should('not.exist'); + checkValue( + `Embedded content: https://contentful.com` + ); + + clearAll(); + + openDialog(); + selectors.inputs.getUrlInput().clear().type('https://contentful.com'); + selectors.inputs.getPixelRadio().click(); + selectors.inputs.getWidthInput().clear().type('500'); + selectors.getConfirmButton().click(); + selectors.getModalContent().should('not.exist'); + checkValue( + `Embedded content: https://contentful.com` + ); + }); +}); diff --git a/cypress/e2e/MarkdownOrganizeLinks.spec.ts b/cypress/e2e/MarkdownOrganizeLinks.spec.ts new file mode 100644 index 000000000..08f0e914d --- /dev/null +++ b/cypress/e2e/MarkdownOrganizeLinks.spec.ts @@ -0,0 +1,47 @@ +describe('Markdown Editor / Organize Links', () => { + const selectors = { + getInput: () => { + return cy.get('[data-test-id="markdown-textarea"] [contenteditable]'); + }, + getToggleAdditionalActionsButton: () => { + return cy.findByTestId('markdown-action-button-toggle-additional'); + }, + getOrganizeLinksButton() { + return cy.findByTestId('markdown-action-button-organizeLinks'); + }, + getSuccessNotification() { + return cy.get('[data-test-id="cf-ui-notification"][data-intent="success"]'); + }, + }; + + const type = (value) => { + return selectors.getInput().focus().type(value, { force: true }); + }; + + const checkValue = (value) => { + cy.getMarkdownInstance().then((markdown) => { + expect(markdown.getContent()).eq(value); + }); + }; + + beforeEach(() => { + cy.visit('/markdown'); + cy.wait(500); + cy.findByTestId('markdown-editor').should('be.visible'); + selectors.getToggleAdditionalActionsButton().click(); + }); + + it('should organize links properly', () => { + const initialText = `Content editors use [Contentful](https://contentful.com "CMS that you will love") to make ongoing improvements and updates to their websites without relying on engineering, while developers focus their talents on building software without the distraction of [CMS](https://en.wikipedia.org/wiki/Content_management_system) complexities or hard-coding content. [Contentful](https://contentful.com "CMS that you will love") is your choice.`; + const expectedText = `Content editors use [Contentful][1] to make ongoing improvements and updates to their websites without relying on engineering, while developers focus their talents on building software without the distraction of [CMS][2] complexities or hard-coding content. [Contentful][1] is your choice.\n\n\n[1]: https://contentful.com "CMS that you will love"\n[2]: https://en.wikipedia.org/wiki/Content_management_system`; + + checkValue(''); + type(initialText); + selectors.getOrganizeLinksButton().click(); + + checkValue(expectedText); + selectors + .getSuccessNotification() + .should('include.text', 'All your links are now references at the bottom of your document'); + }); +}); diff --git a/cypress/e2e/rich-text/README.md b/cypress/e2e/rich-text/README.md new file mode 100644 index 000000000..53b033d8c --- /dev/null +++ b/cypress/e2e/rich-text/README.md @@ -0,0 +1,26 @@ +# Rich Text integration tests + +## How to add tests for pasting + +### Grab the clipboard data + +Because pasting (or drag & drop) is a matter of working with the [DataTransfer object](https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer) of the HTML5 Clipboard API +you need get your hands on the underlying data. To make this easy, there is a page in Docz to help you with that. Simply: + +1. Execute `yarn docz:dev` locally +2. Navigate to http://127.0.0.1:9000/clipboard-capture. +3. Paste what you have into the textarea +4. Click on the copy button next to the "Your pasted data" heading, all the data from the table will be copied as JSON to your clipboard + +![Grab data from clipboard](getting-clipboard-data.gif) + +### Pasting the necessary data into your test + +You are all set now, last things to do: + +1. Go to your Cypress test +2. Select an element, focus it and run the paste command, e.g. `editor().click().paste()` +3. Paste the JSON from your clipboard as argument of the `.paste()` command +4. Cleanup and anonymize the data if necessary + +![Pasting into your test](pasting-into-test.gif) diff --git a/cypress/e2e/rich-text/RichTextEditor.Pasting.spec.ts b/cypress/e2e/rich-text/RichTextEditor.Pasting.spec.ts new file mode 100644 index 000000000..52f66f9da --- /dev/null +++ b/cypress/e2e/rich-text/RichTextEditor.Pasting.spec.ts @@ -0,0 +1,454 @@ +import tableAndTextFromMsWord from './fixtures/msWordOnline'; +import { RichTextPage } from './RichTextPage'; + +// the sticky toolbar gets in the way of some of the tests, therefore +// we increase the viewport height to fit the whole page on the screen +describe( + 'Rich Text Editor', + { + viewportHeight: 2000, + retries: 0, + }, + () => { + let richText: RichTextPage; + + beforeEach(() => { + richText = new RichTextPage(); + richText.visit(); + }); + + it('removes style tags', () => { + richText.editor.click().paste({ + 'text/html': ` + +

paste only this

`, + }); + + richText.expectSnapshotValue(); + }); + + describe('text', () => { + it('supports pasting of links within text', () => { + richText.editor.click().paste({ + 'text/html': + 'Some text with link and some more text and another link following.', + }); + + richText.expectSnapshotValue(); + }); + }); + + describe('Lists', () => { + it('supports pasting of a simple list', () => { + richText.editor.click().paste({ + 'text/html': '', + }); + + richText.expectSnapshotValue(); + }); + + it('MS Word - does not remove space around link in list surrounded by text with background color', () => { + richText.editor.click().paste({ + 'text/plain': 'One list item \n\nA list item with a background colors ', + 'text/html': + '', + }); + richText.expectSnapshotValue(); + }); + + it('pastes texts inside lists', () => { + richText.editor.click(); + richText.toolbar.ul.click(); + + richText.editor.type('Hello '); + + richText.editor.paste({ + 'text/plain': 'world!', + }); + + richText.expectSnapshotValue(); + }); + + it('pastes elements inside links', () => { + richText.editor.click(); + richText.toolbar.ul.click(); + + richText.editor.paste({ + 'text/html': + 'This is a link and an inline entry:
Example Content Type The best article ever
', + }); + + richText.expectSnapshotValue(); + }); + + it('pastes list items as new lists inside lists', () => { + richText.editor.click(); + richText.toolbar.ul.click(); + + richText.editor.type('Hello'); + + richText.editor.paste({ + 'text/html': + '
  • sub
  • list
  • ', + }); + + richText.expectSnapshotValue(); + }); + + it('confers the parent list type upon list items pasted within lists', () => { + richText.editor.click(); + richText.toolbar.ol.click(); + + richText.editor.type('Hello'); + + richText.editor.paste({ + 'text/html': + '
  • sub
  • list
  • ', + }); + + richText.expectSnapshotValue(); + }); + + it('pastes orphaned list items as unordered lists', () => { + richText.editor.click(); + + richText.editor.paste({ + 'text/html': + '
  • Hello
  • \n
    ', + }); + + richText.expectSnapshotValue(); + }); + + // TODO: test related to https://contentful.atlassian.net/browse/SHE-752 + // when a table is copied its structure is kept if pasted at a list entry level + // only its text should be pasted instead. + // Note that, in real scenarions, tables fragments contain the attribute "data-slate-fragment" differently from this test case, + // which determines the table to keep its formatting. Therefore ensure the example html has that attribute + it('pastes only the text content of other blocks', () => { + richText.editor.click(); + richText.toolbar.ul.click(); + + richText.editor.type('Item #1'); + + richText.editor.paste({ + 'text/html': + '
    Header 1
    Header 2 (with link)
    Cell 1
    Cell 2
    ', + }); + + richText.expectSnapshotValue(); + }); + + it('pastes table & its inline elements correctly', () => { + richText.editor.click(); + + // What can I do with tables + // __________________________________________________________________________________ + // | Property | Supported | + // |--------------------------------------|-----------------------------------------| + // | Adding and removing rows and columns | Yes | + // | Table header | Yes, for rows and columns | + // | Formatting options | Bold,italics,underline,code | + // | Hyperlinks | URL, asset and entry | + // | Embed entries | Only inline entries [inline entry] | + // | Copy & paste from other documents | Yes, Eg. Google Docs, Jira, Confluence | + // --------------------------------------|----------------------------------------- + // + // + richText.editor.paste({ + 'application/x-slate-fragment': + 'JTVCJTdCJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmNoaWxkcmVuJTIyJTNBJTVCJTdCJTIydGV4dCUyMiUzQSUyMldoYXQlMjBjYW4lMjBJJTIwZG8lMjB3aXRoJTIwdGFibGVzJTIyJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCU3RCU1RCUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCU3RCUyQyU3QiUyMnR5cGUlMjIlM0ElMjJ0YWJsZSUyMiUyQyUyMmNoaWxkcmVuJTIyJTNBJTVCJTdCJTIydHlwZSUyMiUzQSUyMnRhYmxlLXJvdyUyMiUyQyUyMmNoaWxkcmVuJTIyJTNBJTVCJTdCJTIydHlwZSUyMiUzQSUyMnRhYmxlLWhlYWRlci1jZWxsJTIyJTJDJTIyY2hpbGRyZW4lMjIlM0ElNUIlN0IlMjJ0eXBlJTIyJTNBJTIycGFyYWdyYXBoJTIyJTJDJTIyY2hpbGRyZW4lMjIlM0ElNUIlN0IlMjJ0ZXh0JTIyJTNBJTIyUHJvcGVydHklMjIlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJ0eXBlJTIyJTNBJTIydGFibGUtaGVhZGVyLWNlbGwlMjIlMkMlMjJjaGlsZHJlbiUyMiUzQSU1QiU3QiUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJjaGlsZHJlbiUyMiUzQSU1QiU3QiUyMnRleHQlMjIlM0ElMjJTdXBwb3J0ZWQlMjIlN0QlNUQlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJ0eXBlJTIyJTNBJTIydGFibGUtcm93JTIyJTJDJTIyY2hpbGRyZW4lMjIlM0ElNUIlN0IlMjJ0eXBlJTIyJTNBJTIydGFibGUtY2VsbCUyMiUyQyUyMmNoaWxkcmVuJTIyJTNBJTVCJTdCJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmNoaWxkcmVuJTIyJTNBJTVCJTdCJTIydGV4dCUyMiUzQSUyMkFkZGluZyUyMGFuZCUyMHJlbW92aW5nJTIwcm93cyUyMGFuZCUyMGNvbHVtbnMlMjIlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJ0eXBlJTIyJTNBJTIydGFibGUtY2VsbCUyMiUyQyUyMmNoaWxkcmVuJTIyJTNBJTVCJTdCJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmNoaWxkcmVuJTIyJTNBJTVCJTdCJTIydGV4dCUyMiUzQSUyMlllcyUyMiU3RCU1RCU3RCU1RCU3RCU1RCU3RCUyQyU3QiUyMnR5cGUlMjIlM0ElMjJ0YWJsZS1yb3clMjIlMkMlMjJjaGlsZHJlbiUyMiUzQSU1QiU3QiUyMnR5cGUlMjIlM0ElMjJ0YWJsZS1jZWxsJTIyJTJDJTIyY2hpbGRyZW4lMjIlM0ElNUIlN0IlMjJ0eXBlJTIyJTNBJTIycGFyYWdyYXBoJTIyJTJDJTIyY2hpbGRyZW4lMjIlM0ElNUIlN0IlMjJ0ZXh0JTIyJTNBJTIyVGFibGUlMjBoZWFkZXIlMjIlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJ0eXBlJTIyJTNBJTIydGFibGUtY2VsbCUyMiUyQyUyMmNoaWxkcmVuJTIyJTNBJTVCJTdCJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmNoaWxkcmVuJTIyJTNBJTVCJTdCJTIydGV4dCUyMiUzQSUyMlllcyUyQyUyMGZvciUyMHJvd3MlMjBhbmQlMjBjb2x1bW5zJTIyJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIydHlwZSUyMiUzQSUyMnRhYmxlLXJvdyUyMiUyQyUyMmNoaWxkcmVuJTIyJTNBJTVCJTdCJTIydHlwZSUyMiUzQSUyMnRhYmxlLWNlbGwlMjIlMkMlMjJjaGlsZHJlbiUyMiUzQSU1QiU3QiUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJjaGlsZHJlbiUyMiUzQSU1QiU3QiUyMnRleHQlMjIlM0ElMjJGb3JtYXR0aW5nJTIwb3B0aW9ucyUyMiU3RCU1RCU3RCU1RCU3RCUyQyU3QiUyMnR5cGUlMjIlM0ElMjJ0YWJsZS1jZWxsJTIyJTJDJTIyY2hpbGRyZW4lMjIlM0ElNUIlN0IlMjJ0eXBlJTIyJTNBJTIycGFyYWdyYXBoJTIyJTJDJTIyY2hpbGRyZW4lMjIlM0ElNUIlN0IlMjJ0ZXh0JTIyJTNBJTIyQm9sZCUyMiUyQyUyMmJvbGQlMjIlM0F0cnVlJTdEJTJDJTdCJTIydGV4dCUyMiUzQSUyMiUyQyUyMiU3RCUyQyU3QiUyMnRleHQlMjIlM0ElMjJpdGFsaWNzJTIyJTJDJTIyaXRhbGljJTIyJTNBdHJ1ZSU3RCUyQyU3QiUyMnRleHQlMjIlM0ElMjIlMkMlMjIlN0QlMkMlN0IlMjJ0ZXh0JTIyJTNBJTIydW5kZXJsaW5lJTIyJTJDJTIydW5kZXJsaW5lJTIyJTNBdHJ1ZSU3RCUyQyU3QiUyMnRleHQlMjIlM0ElMjIlMkMlMjIlN0QlMkMlN0IlMjJ0ZXh0JTIyJTNBJTIyY29kZSUyMiUyQyUyMmNvZGUlMjIlM0F0cnVlJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIydHlwZSUyMiUzQSUyMnRhYmxlLXJvdyUyMiUyQyUyMmNoaWxkcmVuJTIyJTNBJTVCJTdCJTIydHlwZSUyMiUzQSUyMnRhYmxlLWNlbGwlMjIlMkMlMjJjaGlsZHJlbiUyMiUzQSU1QiU3QiUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJjaGlsZHJlbiUyMiUzQSU1QiU3QiUyMnRleHQlMjIlM0ElMjJIeXBlcmxpbmtzJTIyJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIydHlwZSUyMiUzQSUyMnRhYmxlLWNlbGwlMjIlMkMlMjJjaGlsZHJlbiUyMiUzQSU1QiU3QiUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJjaGlsZHJlbiUyMiUzQSU1QiU3QiUyMnRleHQlMjIlM0ElMjIlMjIlN0QlMkMlN0IlMjJ0eXBlJTIyJTNBJTIyaHlwZXJsaW5rJTIyJTJDJTIyZGF0YSUyMiUzQSU3QiUyMnVyaSUyMiUzQSUyMmh0dHBzJTNBJTJGJTJGZ29vZ2xlLmNvbSUyMiU3RCUyQyUyMmNoaWxkcmVuJTIyJTNBJTVCJTdCJTIydGV4dCUyMiUzQSUyMlVSTCUyMiU3RCU1RCU3RCUyQyU3QiUyMnRleHQlMjIlM0ElMjIlMkMlMjAlMjIlN0QlMkMlN0IlMjJ0eXBlJTIyJTNBJTIyYXNzZXQtaHlwZXJsaW5rJTIyJTJDJTIyZGF0YSUyMiUzQSU3QiUyMnRhcmdldCUyMiUzQSU3QiUyMnN5cyUyMiUzQSU3QiUyMmlkJTIyJTNBJTIyZXhhbXBsZS1lbnRpdHktaWQlMjIlMkMlMjJ0eXBlJTIyJTNBJTIyTGluayUyMiUyQyUyMmxpbmtUeXBlJTIyJTNBJTIyQXNzZXQlMjIlN0QlN0QlN0QlMkMlMjJjaGlsZHJlbiUyMiUzQSU1QiU3QiUyMnRleHQlMjIlM0ElMjJhc3NldCUyMiU3RCU1RCU3RCUyQyU3QiUyMnRleHQlMjIlM0ElMjIlMjBhbmQlMjAlMjIlN0QlMkMlN0IlMjJ0eXBlJTIyJTNBJTIyZW50cnktaHlwZXJsaW5rJTIyJTJDJTIyZGF0YSUyMiUzQSU3QiUyMnRhcmdldCUyMiUzQSU3QiUyMnN5cyUyMiUzQSU3QiUyMmlkJTIyJTNBJTIyZXhhbXBsZS1lbnRpdHktaWQlMjIlMkMlMjJ0eXBlJTIyJTNBJTIyTGluayUyMiUyQyUyMmxpbmtUeXBlJTIyJTNBJTIyRW50cnklMjIlN0QlN0QlN0QlMkMlMjJjaGlsZHJlbiUyMiUzQSU1QiU3QiUyMnRleHQlMjIlM0ElMjJlbnRyeSUyMiU3RCU1RCU3RCUyQyU3QiUyMnRleHQlMjIlM0ElMjIlMjIlN0QlNUQlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJ0eXBlJTIyJTNBJTIydGFibGUtcm93JTIyJTJDJTIyY2hpbGRyZW4lMjIlM0ElNUIlN0IlMjJ0eXBlJTIyJTNBJTIydGFibGUtY2VsbCUyMiUyQyUyMmNoaWxkcmVuJTIyJTNBJTVCJTdCJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmNoaWxkcmVuJTIyJTNBJTVCJTdCJTIydGV4dCUyMiUzQSUyMkVtYmVkJTIwZW50cmllcyUyMiU3RCU1RCU3RCU1RCU3RCUyQyU3QiUyMnR5cGUlMjIlM0ElMjJ0YWJsZS1jZWxsJTIyJTJDJTIyY2hpbGRyZW4lMjIlM0ElNUIlN0IlMjJ0eXBlJTIyJTNBJTIycGFyYWdyYXBoJTIyJTJDJTIyY2hpbGRyZW4lMjIlM0ElNUIlN0IlMjJ0ZXh0JTIyJTNBJTIyT25seSUyMGlubGluZSUyMGVudHJpZXMlMjAlMjIlN0QlMkMlN0IlMjJ0eXBlJTIyJTNBJTIyZW1iZWRkZWQtZW50cnktaW5saW5lJTIyJTJDJTIyY2hpbGRyZW4lMjIlM0ElNUIlN0IlMjJ0ZXh0JTIyJTNBJTIyJTIyJTdEJTVEJTJDJTIyZGF0YSUyMiUzQSU3QiUyMnRhcmdldCUyMiUzQSU3QiUyMnN5cyUyMiUzQSU3QiUyMmlkJTIyJTNBJTIyZXhhbXBsZS1lbnRpdHktaWQlMjIlMkMlMjJ0eXBlJTIyJTNBJTIyTGluayUyMiUyQyUyMmxpbmtUeXBlJTIyJTNBJTIyRW50cnklMjIlN0QlN0QlN0QlN0QlMkMlN0IlMjJ0ZXh0JTIyJTNBJTIyJTIyJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIydHlwZSUyMiUzQSUyMnRhYmxlLXJvdyUyMiUyQyUyMmNoaWxkcmVuJTIyJTNBJTVCJTdCJTIydHlwZSUyMiUzQSUyMnRhYmxlLWNlbGwlMjIlMkMlMjJjaGlsZHJlbiUyMiUzQSU1QiU3QiUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJjaGlsZHJlbiUyMiUzQSU1QiU3QiUyMnRleHQlMjIlM0ElMjJDb3B5JTIwJTI2JTIwcGFzdGUlMjBmcm9tJTIwb3RoZXIlMjBkb2N1bWVudHMlMjIlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJ0eXBlJTIyJTNBJTIydGFibGUtY2VsbCUyMiUyQyUyMmNoaWxkcmVuJTIyJTNBJTVCJTdCJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmNoaWxkcmVuJTIyJTNBJTVCJTdCJTIydGV4dCUyMiUzQSUyMlllcy4lMjBFZy4lMjBHb29nbGUlMjBEb2NzJTJDJTIwSmlyYSUyQyUyMENvbmZsdWVuY2UlMjIlN0QlNUQlN0QlNUQlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJ0eXBlJTIyJTNBJTIycGFyYWdyYXBoJTIyJTJDJTIyY2hpbGRyZW4lMjIlM0ElNUIlN0IlMjJ0ZXh0JTIyJTNBJTIyJTIyJTdEJTVEJTdEJTVE', + }); + + richText.expectSnapshotValue(); + }); + + it('normalizes paragraphs in table cells correctly', () => { + richText.editor.click(); + richText.editor.paste({ + 'text/html': + '

    Field

    Type

    Description

    ', + }); + richText.expectSnapshotValue(); + }); + }); + + describe('HR', () => { + it('should paste from internal copying', () => { + richText.editor.click().paste({ + 'text/html': `
    `, + }); + + richText.expectSnapshotValue(); + }); + + it('should paste from external resources', () => { + richText.editor.click().paste({ + 'text/html': `

    `, + }); + + richText.expectSnapshotValue(); + }); + }); + + describe('Tables', () => { + it('Google Docs', () => { + richText.editor.click().paste({ + 'text/html': + '

    Field

    Type

    Description

    sys

    Sys

    Common system properties

    system common 

    properties.

    fields.title

    Text

    Title of the asset.

    Title 

    Of the

    asset

    fields.description

    Text

    Description of the asset.

    fields.file

    File

    File(s) of the asset.

    fields.file.fileName

    Symbol

    Original filename of the file.

    fields.file.contentType

    Symbol

    Content type of the file.

    fields.file.url

    Symbol

    URL of the file.

    fields.file.details

    Object

    Details of the file, depending on its MIME type.

    fields.file.details.size

    Number

    Size (in bytes) of the file.




    ', + }); + + richText.expectSnapshotValue(); + }); + + it('Google Docs - around
    ', () => { + richText.editor.click().paste({ + 'text/html': `

    Cell 1

    Cell 2

    Cell 3

    Cell 4

    `, + }); + + richText.expectSnapshotValue(); + }); + + it('removes table wrappers when pasting a single cell', () => { + richText.editor.click().paste({ + 'application/x-slate-fragment': + 'JTVCJTdCJTIydHlwZSUyMiUzQSUyMnRhYmxlJTIyJTJDJTIyY2hpbGRyZW4lMjIlM0ElNUIlN0IlMjJ0eXBlJTIyJTNBJTIydGFibGUtcm93JTIyJTJDJTIyY2hpbGRyZW4lMjIlM0ElNUIlN0IlMjJ0eXBlJTIyJTNBJTIydGFibGUtY2VsbCUyMiUyQyUyMmNoaWxkcmVuJTIyJTNBJTVCJTdCJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmNoaWxkcmVuJTIyJTNBJTVCJTdCJTIydGV4dCUyMiUzQSUyMmNlbGwlMjBjb250ZW50JTIwd2l0aCUyMGElMjBsaW5rJTIwYW5kJTIwaW5saW5lJTIwZW50cnklMjIlN0QlMkMlN0IlMjJ0eXBlJTIyJTNBJTIyZW1iZWRkZWQtZW50cnktaW5saW5lJTIyJTJDJTIyY2hpbGRyZW4lMjIlM0ElNUIlN0IlMjJ0ZXh0JTIyJTNBJTIyJTIyJTdEJTVEJTJDJTIyZGF0YSUyMiUzQSU3QiUyMnRhcmdldCUyMiUzQSU3QiUyMnN5cyUyMiUzQSU3QiUyMmlkJTIyJTNBJTIyZXhhbXBsZS1lbnRpdHktaWQlMjIlMkMlMjJ0eXBlJTIyJTNBJTIyTGluayUyMiUyQyUyMmxpbmtUeXBlJTIyJTNBJTIyRW50cnklMjIlN0QlN0QlN0QlN0QlMkMlN0IlMjJ0ZXh0JTIyJTNBJTIyLiUyMiU3RCU1RCU3RCU1RCU3RCU1RCU3RCU1RCU3RCU1RA==', + }); + + richText.expectSnapshotValue(); + }); + }); + + describe('spreadsheets', () => { + describe('removes empty columns/rows', () => { + it('Google Sheets', () => { + richText.editor.click().paste({ + 'text/html': + '
    Cell 1Cell 2
    ', + }); + + richText.expectSnapshotValue(); + }); + + it('MS Excel', () => { + richText.editor.click().paste({ + 'text/html': + "
    \r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n\r\n
    Cell 1Cell 2
    \r\n\r\n\r\n\r\n\r\n
    ", + }); + + richText.expectSnapshotValue(); + }); + }); + }); + + describe('Microsoft Word (.docx) deserialization', () => { + it('paragraphs, marks and links', () => { + richText.editor.click().paste({ + 'text/html': + 'This is a paragraph with some marks and links ', + }); + + richText.expectSnapshotValue(); + }); + + it('paragraphs with formattings', () => { + richText.editor.click().paste({ + 'text/html': ` + + + +

    What is Lorem Ipsum?

    + +

    Lorem Ipsum is simply dummy + text of the printing and typesetting industry. Lorem Ipsum has been the + industry's standard dummy text ever since the 1500s, when an unknown printer + took a galley of type and scrambled it to make a type specimen book. It has + survived not only five centuries, but also the leap into electronic + typesetting, remaining essentially unchanged. It was popularised in the + 1960s with the release of Letraset sheets containing Lorem Ipsum passages, + and more recently with desktop publishing software like Aldus PageMaker + including versions of Lorem Ipsum.

    + +

     

    + + + + + `, + }); + + richText.expectSnapshotValue(); + }); + + it('unordered list', () => { + richText.editor.click().paste({ + 'text/html': + '
    • This

    • Is

    • A list

    ', + }); + + richText.expectSnapshotValue(); + }); + + it('ordered list', () => { + richText.editor.click().paste({ + 'text/html': + '
    1. This is

    1. An ordered list

    ', + }); + + richText.expectSnapshotValue(); + }); + + it('tables', () => { + richText.editor.click().paste({ + 'text/html': + '

    This is some

    Content on tables

    ', + }); + + richText.expectSnapshotValue(); + }); + + it('text and tables from ms word online', () => { + richText.editor.click().paste({ + 'text/html': tableAndTextFromMsWord, + }); + + richText.expectSnapshotValue(); + }); + }); + + describe('Basic marks', () => { + it('works when pasting from another RT editor', () => { + // A simple "hello world" text with marks: bold, italic, underline + // and code. Copied from the RT editor + richText.editor.click().paste({ + 'text/html': + 'hello world', + 'text/plain': 'hello world', + 'application/x-slate-fragment': + 'JTVCJTdCJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmNoaWxkcmVuJTIyJTNBJTVCJTdCJTIydGV4dCUyMiUzQSUyMmhlbGxvJTIwd29ybGQlMjIlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIyYm9sZCUyMiUzQXRydWUlMkMlMjJpdGFsaWMlMjIlM0F0cnVlJTJDJTIydW5kZXJsaW5lJTIyJTNBdHJ1ZSUyQyUyMmNvZGUlMjIlM0F0cnVlJTdEJTVEJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTdEJTVE', + }); + + richText.expectSnapshotValue(); + }); + }); + + describe('Superscript and subscript marks', () => { + it('works when pasting subscript and superscript from a google doc', () => { + // A simple "hello world" text with marks: superscript and subscript. + // Copied from a google doc + richText.editor.click().paste({ + 'text/plain': 'HelloWorld', + 'text/html': + 'HelloWorld', + 'application/x-vnd.google-docs-document-slice-clip+wrapped': + '{"dih":3014089275,"data":"{\\"resolved\\":{\\"dsl_spacers\\":\\"HelloWorld\\",\\"dsl_styleslices\\":[{\\"stsl_type\\":\\"autogen\\",\\"stsl_styles\\":[]},{\\"stsl_type\\":\\"cell\\",\\"stsl_styles\\":[]},{\\"stsl_type\\":\\"code_snippet\\",\\"stsl_styles\\":[]},{\\"stsl_type\\":\\"collapsed_heading\\",\\"stsl_styles\\":[]},{\\"stsl_type\\":\\"column_sector\\",\\"stsl_leading\\":{\\"css_cols\\":{\\"cv\\":{\\"op\\":\\"set\\",\\"opValue\\":[]}},\\"css_lb\\":false,\\"css_ltr\\":true,\\"css_st\\":\\"continuous\\",\\"css_mb\\":null,\\"css_mh\\":null,\\"css_mf\\":null,\\"css_ml\\":null,\\"css_mr\\":null,\\"css_mt\\":null,\\"css_fi\\":null,\\"css_hi\\":null,\\"css_epfi\\":null,\\"css_ephi\\":null,\\"css_fpfi\\":null,\\"css_fphi\\":null,\\"css_ufphf\\":null,\\"css_pnsi\\":null,\\"css_fpo\\":null},\\"stsl_leadingType\\":\\"column_sector\\",\\"stsl_trailing\\":{\\"css_cols\\":{\\"cv\\":{\\"op\\":\\"set\\",\\"opValue\\":[]}},\\"css_lb\\":false,\\"css_ltr\\":true,\\"css_st\\":\\"continuous\\",\\"css_mb\\":null,\\"css_mh\\":null,\\"css_mf\\":null,\\"css_ml\\":null,\\"css_mr\\":null,\\"css_mt\\":null,\\"css_fi\\":null,\\"css_hi\\":null,\\"css_epfi\\":null,\\"css_ephi\\":null,\\"css_fpfi\\":null,\\"css_fphi\\":null,\\"css_ufphf\\":null,\\"css_pnsi\\":null,\\"css_fpo\\":null},\\"stsl_trailingType\\":\\"column_sector\\",\\"stsl_styles\\":[]},{\\"stsl_type\\":\\"comment\\",\\"stsl_styles\\":[{\\"cs_cids\\":{\\"cv\\":{\\"op\\":\\"set\\",\\"opValue\\":[]}}}]},{\\"stsl_type\\":\\"doco_anchor\\",\\"stsl_styles\\":[{\\"das_a\\":{\\"cv\\":{\\"op\\":\\"set\\",\\"opValue\\":[]}}}]},{\\"stsl_type\\":\\"document\\",\\"stsl_leading\\":{\\"ds_b\\":{\\"bg_c2\\":{\\"clr_type\\":0,\\"hclr_color\\":null}},\\"ds_df\\":{\\"df_dm\\":0},\\"ds_fi\\":null,\\"ds_hi\\":null,\\"ds_epfi\\":null,\\"ds_ephi\\":null,\\"ds_uephf\\":false,\\"ds_fpfi\\":null,\\"ds_fphi\\":null,\\"ds_ufphf\\":false,\\"ds_pnsi\\":1,\\"ds_mb\\":72,\\"ds_ml\\":72,\\"ds_mr\\":72,\\"ds_mt\\":72,\\"ds_ph\\":792,\\"ds_pw\\":612,\\"ds_rtd\\":\\"\\",\\"ds_mh\\":36,\\"ds_mf\\":36,\\"ds_ulhfl\\":false,\\"ds_lhs\\":1,\\"ds_fpo\\":false},\\"stsl_leadingType\\":\\"document\\",\\"stsl_styles\\":[]},{\\"stsl_type\\":\\"equation\\",\\"stsl_styles\\":[]},{\\"stsl_type\\":\\"equation_function\\",\\"stsl_styles\\":[]},{\\"stsl_type\\":\\"field\\",\\"stsl_styles\\":[]},{\\"stsl_type\\":\\"footnote\\",\\"stsl_styles\\":[]},{\\"stsl_type\\":\\"headings\\",\\"stsl_styles\\":[]},{\\"stsl_type\\":\\"horizontal_rule\\",\\"stsl_styles\\":[]},{\\"stsl_type\\":\\"ignore_spellcheck\\",\\"stsl_styles\\":[{\\"isc_osh\\":null}]},{\\"stsl_type\\":\\"import_warnings\\",\\"stsl_styles\\":[{\\"iws_iwids\\":{\\"cv\\":{\\"op\\":\\"set\\",\\"opValue\\":[]}}}]},{\\"stsl_type\\":\\"language\\",\\"stsl_trailing\\":{\\"lgs_l\\":\\"en\\"},\\"stsl_trailingType\\":\\"language\\",\\"stsl_styles\\":[]},{\\"stsl_type\\":\\"link\\",\\"stsl_styles\\":[{\\"lnks_link\\":null}]},{\\"stsl_type\\":\\"list\\",\\"stsl_trailing\\":{\\"ls_nest\\":0,\\"ls_id\\":null,\\"ls_c\\":null,\\"ls_ts\\":{\\"ts_bd\\":false,\\"ts_fs\\":11,\\"ts_ff\\":\\"Arial\\",\\"ts_it\\":false,\\"ts_sc\\":false,\\"ts_st\\":false,\\"ts_tw\\":400,\\"ts_un\\":false,\\"ts_va\\":\\"nor\\",\\"ts_bgc2\\":{\\"clr_type\\":0,\\"hclr_color\\":null},\\"ts_fgc2\\":{\\"clr_type\\":0,\\"hclr_color\\":\\"#000000\\"},\\"ts_bd_i\\":false,\\"ts_fs_i\\":false,\\"ts_ff_i\\":false,\\"ts_it_i\\":false,\\"ts_sc_i\\":false,\\"ts_st_i\\":false,\\"ts_un_i\\":false,\\"ts_va_i\\":false,\\"ts_bgc2_i\\":false,\\"ts_fgc2_i\\":false}},\\"stsl_trailingType\\":\\"list\\",\\"stsl_styles\\":[]},{\\"stsl_type\\":\\"named_range\\",\\"stsl_styles\\":[{\\"nrs_ei\\":{\\"cv\\":{\\"op\\":\\"set\\",\\"opValue\\":[]}}}]},{\\"stsl_type\\":\\"paragraph\\",\\"stsl_trailing\\":{\\"ps_al\\":0,\\"ps_awao\\":true,\\"ps_sd\\":null,\\"ps_bbtw\\":null,\\"ps_bb\\":null,\\"ps_bl\\":null,\\"ps_br\\":null,\\"ps_bt\\":null,\\"ps_hd\\":0,\\"ps_hdid\\":\\"\\",\\"ps_ifl\\":0,\\"ps_il\\":0,\\"ps_ir\\":0,\\"ps_klt\\":false,\\"ps_kwn\\":false,\\"ps_ltr\\":true,\\"ps_ls\\":1.15,\\"ps_lslm\\":1,\\"ps_pbb\\":false,\\"ps_sm\\":0,\\"ps_sa\\":0,\\"ps_sb\\":0,\\"ps_al_i\\":false,\\"ps_awao_i\\":false,\\"ps_sd_i\\":false,\\"ps_bbtw_i\\":false,\\"ps_bb_i\\":false,\\"ps_bl_i\\":false,\\"ps_br_i\\":false,\\"ps_bt_i\\":false,\\"ps_ifl_i\\":false,\\"ps_il_i\\":false,\\"ps_ir_i\\":false,\\"ps_klt_i\\":false,\\"ps_kwn_i\\":false,\\"ps_ls_i\\":false,\\"ps_lslm_i\\":false,\\"ps_pbb_i\\":false,\\"ps_rd\\":\\"\\",\\"ps_sm_i\\":false,\\"ps_sa_i\\":false,\\"ps_sb_i\\":false,\\"ps_shd\\":false,\\"ps_ts\\":{\\"cv\\":{\\"op\\":\\"set\\",\\"opValue\\":[]}}},\\"stsl_trailingType\\":\\"paragraph\\",\\"stsl_styles\\":[]},{\\"stsl_type\\":\\"row\\",\\"stsl_styles\\":[]},{\\"stsl_type\\":\\"suppress_feature\\",\\"stsl_styles\\":[{\\"sfs_sst\\":false}]},{\\"stsl_type\\":\\"tbl\\",\\"stsl_styles\\":[]},{\\"stsl_type\\":\\"text\\",\\"stsl_styles\\":[{\\"ts_bd\\":false,\\"ts_fs\\":11,\\"ts_ff\\":\\"Arial\\",\\"ts_it\\":false,\\"ts_sc\\":false,\\"ts_st\\":false,\\"ts_tw\\":400,\\"ts_un\\":false,\\"ts_va\\":\\"sup\\",\\"ts_bgc2\\":{\\"clr_type\\":0,\\"hclr_color\\":null},\\"ts_fgc2\\":{\\"clr_type\\":0,\\"hclr_color\\":\\"#000000\\"},\\"ts_bd_i\\":false,\\"ts_fs_i\\":false,\\"ts_ff_i\\":false,\\"ts_it_i\\":false,\\"ts_sc_i\\":false,\\"ts_st_i\\":false,\\"ts_un_i\\":false,\\"ts_va_i\\":false,\\"ts_bgc2_i\\":false,\\"ts_fgc2_i\\":false},null,null,null,null,{\\"ts_bd\\":false,\\"ts_fs\\":11,\\"ts_ff\\":\\"Arial\\",\\"ts_it\\":false,\\"ts_sc\\":false,\\"ts_st\\":false,\\"ts_tw\\":400,\\"ts_un\\":false,\\"ts_va\\":\\"sub\\",\\"ts_bgc2\\":{\\"clr_type\\":0,\\"hclr_color\\":null},\\"ts_fgc2\\":{\\"clr_type\\":0,\\"hclr_color\\":\\"#000000\\"},\\"ts_bd_i\\":false,\\"ts_fs_i\\":false,\\"ts_ff_i\\":false,\\"ts_it_i\\":false,\\"ts_sc_i\\":false,\\"ts_st_i\\":false,\\"ts_un_i\\":false,\\"ts_va_i\\":false,\\"ts_bgc2_i\\":false,\\"ts_fgc2_i\\":false}]}],\\"dsl_metastyleslices\\":[{\\"stsl_type\\":\\"autocorrect\\",\\"stsl_styles\\":[{\\"ac_ot\\":null,\\"ac_ct\\":null,\\"ac_type\\":null,\\"ac_sm\\":{\\"asm_s\\":0,\\"asm_rl\\":0,\\"asm_l\\":\\"\\"},\\"ac_id\\":\\"\\"}]},{\\"stsl_type\\":\\"collapsed_content\\",\\"stsl_styles\\":[{\\"colc_icc\\":false}]},{\\"stsl_type\\":\\"composing_decoration\\",\\"stsl_styles\\":[{\\"cd_u\\":false,\\"cd_bgc\\":{\\"clr_type\\":0,\\"hclr_color\\":null}}]},{\\"stsl_type\\":\\"composing_region\\",\\"stsl_styles\\":[{\\"cr_c\\":false}]},{\\"stsl_type\\":\\"draft_comment\\",\\"stsl_styles\\":[{\\"dcs_cids\\":{\\"cv\\":{\\"op\\":\\"set\\",\\"opValue\\":[]}}}]},{\\"stsl_type\\":\\"ignore_word\\",\\"stsl_styles\\":[{\\"iwos_i\\":false}]},{\\"stsl_type\\":\\"revision_diff\\",\\"stsl_styles\\":[{\\"revdiff_dt\\":0,\\"revdiff_a\\":\\"\\",\\"revdiff_aid\\":\\"\\"}]},{\\"stsl_type\\":\\"smart_todo\\",\\"stsl_styles\\":[{\\"sts_cid\\":null,\\"sts_ot\\":null,\\"sts_ac\\":null,\\"sts_hi\\":{\\"cv\\":{\\"op\\":\\"set\\",\\"opValue\\":[]}},\\"sts_pa\\":{\\"cv\\":{\\"op\\":\\"set\\",\\"opValue\\":[]}},\\"sts_dm\\":{\\"cv\\":{\\"op\\":\\"set\\",\\"opValue\\":[]}}}]},{\\"stsl_type\\":\\"spellcheck\\",\\"stsl_styles\\":[{\\"sc_id\\":\\"\\",\\"sc_ow\\":null,\\"sc_sl\\":null,\\"sc_sugg\\":{\\"cv\\":{\\"op\\":\\"set\\",\\"opValue\\":[]}},\\"sc_sm\\":{\\"cv\\":{\\"op\\":\\"set\\",\\"opValue\\":[]}}}]},{\\"stsl_type\\":\\"voice_corrections\\",\\"stsl_styles\\":[{\\"vcs_c\\":{\\"cv\\":{\\"op\\":\\"set\\",\\"opValue\\":[]}},\\"vcs_id\\":\\"\\"}]},{\\"stsl_type\\":\\"voice_dotted_span\\",\\"stsl_styles\\":[{\\"vdss_p\\":null,\\"vdss_id\\":\\"\\"}]}],\\"dsl_suggestedinsertions\\":{\\"sgsl_sugg\\":[]},\\"dsl_suggesteddeletions\\":{\\"sgsl_sugg\\":[]},\\"dsl_entitypositionmap\\":{},\\"dsl_entitymap\\":{},\\"dsl_entitytypemap\\":{},\\"dsl_drawingrevisionaccesstokenmap\\":{},\\"dsl_relateddocslices\\":{},\\"dsl_nestedmodelmap\\":{}},\\"autotext_content\\":{}}","edi":"QT9bASY3_GwFN8ZN119g2Ja2EHnrx_nq6yjODnrlfjrbkZEpqMl5NY_AGJtzSbcFsVk9XklA8IoHTqo123xXoO8cLKmVgnTCRWC6udfsvDpa","edrk":"xKYajWttYyLMt_u511ACUFI3qewboAf7dCubtDmoDhoCHwlXkw..","dct":"kix","ds":false,"cses":false}', + }); + + richText.expectSnapshotValue(); + }); + }); + + describe('copy from safari (no href in anchors)', () => { + it('recognizes entry hyperlink', () => { + richText.editor.click().paste({ + 'text/html': + 'a b', + 'text/plain': 'a b', + }); + + richText.expectSnapshotValue(); + }); + + it('recognizes asset hyperlink', () => { + richText.editor.click().paste({ + 'text/html': + 'a b', + 'text/plain': 'a b', + }); + + richText.expectSnapshotValue(); + }); + }); + + describe('blockquotes', () => { + it('breaks a paragraph when pasting a blockquote in the middle', () => { + richText.editor.type('A paragraph{leftarrow}').paste({ + 'text/html': + 'a quote', + 'text/plain': 'a blockquote', + }); + + richText.expectSnapshotValue(); + }); + + it("removes the paragraph if it's empty", () => { + richText.editor.click().paste({ + 'text/html': + '
    a quote
    ', + 'text/plain': 'a blockquote', + }); + + richText.expectSnapshotValue(); + }); + + it("removes the paragraph if it's fully selected", () => { + richText.editor.click().type('abc').type('{selectall}'); + + cy.window().then((win) => { + const selection = win.getSelection(); + cy.wrap(selection).its('focusNode.data').should('equal', 'abc'); + // slate throttles the handling of selection changes + // so the editor might be unaware of the new selection at the time we paste + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(200); + }); + richText.editor.paste({ + 'text/html': + '
    a quote
    ', + 'text/plain': 'a blockquote', + }); + + richText.expectSnapshotValue(); + }); + }); + + describe('missing application/x-slate-fragment [safari]', () => { + it('render slate fragment if attribute "data-slate-fragment" exists', () => { + richText.editor.click().paste({ + 'text/html': + 'quote', + 'text/plain': 'quote', + }); + richText.expectSnapshotValue(); + }); + }); + + describe('removing restricted marks', () => { + it('works when pasting subscript and superscript from a google doc', () => { + cy.setRestrictedMarks(['superscript', 'subscript']); + cy.reload(); + // A simple "hello world" text with marks: superscript and subscript. + // Copied from a google doc + richText.editor.click().paste({ + 'text/plain': 'HelloWorld', + 'text/html': + 'HelloWorld', + 'application/x-vnd.google-docs-document-slice-clip+wrapped': + '{"dih":3014089275,"data":"{\\"resolved\\":{\\"dsl_spacers\\":\\"HelloWorld\\",\\"dsl_styleslices\\":[{\\"stsl_type\\":\\"autogen\\",\\"stsl_styles\\":[]},{\\"stsl_type\\":\\"cell\\",\\"stsl_styles\\":[]},{\\"stsl_type\\":\\"code_snippet\\",\\"stsl_styles\\":[]},{\\"stsl_type\\":\\"collapsed_heading\\",\\"stsl_styles\\":[]},{\\"stsl_type\\":\\"column_sector\\",\\"stsl_leading\\":{\\"css_cols\\":{\\"cv\\":{\\"op\\":\\"set\\",\\"opValue\\":[]}},\\"css_lb\\":false,\\"css_ltr\\":true,\\"css_st\\":\\"continuous\\",\\"css_mb\\":null,\\"css_mh\\":null,\\"css_mf\\":null,\\"css_ml\\":null,\\"css_mr\\":null,\\"css_mt\\":null,\\"css_fi\\":null,\\"css_hi\\":null,\\"css_epfi\\":null,\\"css_ephi\\":null,\\"css_fpfi\\":null,\\"css_fphi\\":null,\\"css_ufphf\\":null,\\"css_pnsi\\":null,\\"css_fpo\\":null},\\"stsl_leadingType\\":\\"column_sector\\",\\"stsl_trailing\\":{\\"css_cols\\":{\\"cv\\":{\\"op\\":\\"set\\",\\"opValue\\":[]}},\\"css_lb\\":false,\\"css_ltr\\":true,\\"css_st\\":\\"continuous\\",\\"css_mb\\":null,\\"css_mh\\":null,\\"css_mf\\":null,\\"css_ml\\":null,\\"css_mr\\":null,\\"css_mt\\":null,\\"css_fi\\":null,\\"css_hi\\":null,\\"css_epfi\\":null,\\"css_ephi\\":null,\\"css_fpfi\\":null,\\"css_fphi\\":null,\\"css_ufphf\\":null,\\"css_pnsi\\":null,\\"css_fpo\\":null},\\"stsl_trailingType\\":\\"column_sector\\",\\"stsl_styles\\":[]},{\\"stsl_type\\":\\"comment\\",\\"stsl_styles\\":[{\\"cs_cids\\":{\\"cv\\":{\\"op\\":\\"set\\",\\"opValue\\":[]}}}]},{\\"stsl_type\\":\\"doco_anchor\\",\\"stsl_styles\\":[{\\"das_a\\":{\\"cv\\":{\\"op\\":\\"set\\",\\"opValue\\":[]}}}]},{\\"stsl_type\\":\\"document\\",\\"stsl_leading\\":{\\"ds_b\\":{\\"bg_c2\\":{\\"clr_type\\":0,\\"hclr_color\\":null}},\\"ds_df\\":{\\"df_dm\\":0},\\"ds_fi\\":null,\\"ds_hi\\":null,\\"ds_epfi\\":null,\\"ds_ephi\\":null,\\"ds_uephf\\":false,\\"ds_fpfi\\":null,\\"ds_fphi\\":null,\\"ds_ufphf\\":false,\\"ds_pnsi\\":1,\\"ds_mb\\":72,\\"ds_ml\\":72,\\"ds_mr\\":72,\\"ds_mt\\":72,\\"ds_ph\\":792,\\"ds_pw\\":612,\\"ds_rtd\\":\\"\\",\\"ds_mh\\":36,\\"ds_mf\\":36,\\"ds_ulhfl\\":false,\\"ds_lhs\\":1,\\"ds_fpo\\":false},\\"stsl_leadingType\\":\\"document\\",\\"stsl_styles\\":[]},{\\"stsl_type\\":\\"equation\\",\\"stsl_styles\\":[]},{\\"stsl_type\\":\\"equation_function\\",\\"stsl_styles\\":[]},{\\"stsl_type\\":\\"field\\",\\"stsl_styles\\":[]},{\\"stsl_type\\":\\"footnote\\",\\"stsl_styles\\":[]},{\\"stsl_type\\":\\"headings\\",\\"stsl_styles\\":[]},{\\"stsl_type\\":\\"horizontal_rule\\",\\"stsl_styles\\":[]},{\\"stsl_type\\":\\"ignore_spellcheck\\",\\"stsl_styles\\":[{\\"isc_osh\\":null}]},{\\"stsl_type\\":\\"import_warnings\\",\\"stsl_styles\\":[{\\"iws_iwids\\":{\\"cv\\":{\\"op\\":\\"set\\",\\"opValue\\":[]}}}]},{\\"stsl_type\\":\\"language\\",\\"stsl_trailing\\":{\\"lgs_l\\":\\"en\\"},\\"stsl_trailingType\\":\\"language\\",\\"stsl_styles\\":[]},{\\"stsl_type\\":\\"link\\",\\"stsl_styles\\":[{\\"lnks_link\\":null}]},{\\"stsl_type\\":\\"list\\",\\"stsl_trailing\\":{\\"ls_nest\\":0,\\"ls_id\\":null,\\"ls_c\\":null,\\"ls_ts\\":{\\"ts_bd\\":false,\\"ts_fs\\":11,\\"ts_ff\\":\\"Arial\\",\\"ts_it\\":false,\\"ts_sc\\":false,\\"ts_st\\":false,\\"ts_tw\\":400,\\"ts_un\\":false,\\"ts_va\\":\\"nor\\",\\"ts_bgc2\\":{\\"clr_type\\":0,\\"hclr_color\\":null},\\"ts_fgc2\\":{\\"clr_type\\":0,\\"hclr_color\\":\\"#000000\\"},\\"ts_bd_i\\":false,\\"ts_fs_i\\":false,\\"ts_ff_i\\":false,\\"ts_it_i\\":false,\\"ts_sc_i\\":false,\\"ts_st_i\\":false,\\"ts_un_i\\":false,\\"ts_va_i\\":false,\\"ts_bgc2_i\\":false,\\"ts_fgc2_i\\":false}},\\"stsl_trailingType\\":\\"list\\",\\"stsl_styles\\":[]},{\\"stsl_type\\":\\"named_range\\",\\"stsl_styles\\":[{\\"nrs_ei\\":{\\"cv\\":{\\"op\\":\\"set\\",\\"opValue\\":[]}}}]},{\\"stsl_type\\":\\"paragraph\\",\\"stsl_trailing\\":{\\"ps_al\\":0,\\"ps_awao\\":true,\\"ps_sd\\":null,\\"ps_bbtw\\":null,\\"ps_bb\\":null,\\"ps_bl\\":null,\\"ps_br\\":null,\\"ps_bt\\":null,\\"ps_hd\\":0,\\"ps_hdid\\":\\"\\",\\"ps_ifl\\":0,\\"ps_il\\":0,\\"ps_ir\\":0,\\"ps_klt\\":false,\\"ps_kwn\\":false,\\"ps_ltr\\":true,\\"ps_ls\\":1.15,\\"ps_lslm\\":1,\\"ps_pbb\\":false,\\"ps_sm\\":0,\\"ps_sa\\":0,\\"ps_sb\\":0,\\"ps_al_i\\":false,\\"ps_awao_i\\":false,\\"ps_sd_i\\":false,\\"ps_bbtw_i\\":false,\\"ps_bb_i\\":false,\\"ps_bl_i\\":false,\\"ps_br_i\\":false,\\"ps_bt_i\\":false,\\"ps_ifl_i\\":false,\\"ps_il_i\\":false,\\"ps_ir_i\\":false,\\"ps_klt_i\\":false,\\"ps_kwn_i\\":false,\\"ps_ls_i\\":false,\\"ps_lslm_i\\":false,\\"ps_pbb_i\\":false,\\"ps_rd\\":\\"\\",\\"ps_sm_i\\":false,\\"ps_sa_i\\":false,\\"ps_sb_i\\":false,\\"ps_shd\\":false,\\"ps_ts\\":{\\"cv\\":{\\"op\\":\\"set\\",\\"opValue\\":[]}}},\\"stsl_trailingType\\":\\"paragraph\\",\\"stsl_styles\\":[]},{\\"stsl_type\\":\\"row\\",\\"stsl_styles\\":[]},{\\"stsl_type\\":\\"suppress_feature\\",\\"stsl_styles\\":[{\\"sfs_sst\\":false}]},{\\"stsl_type\\":\\"tbl\\",\\"stsl_styles\\":[]},{\\"stsl_type\\":\\"text\\",\\"stsl_styles\\":[{\\"ts_bd\\":false,\\"ts_fs\\":11,\\"ts_ff\\":\\"Arial\\",\\"ts_it\\":false,\\"ts_sc\\":false,\\"ts_st\\":false,\\"ts_tw\\":400,\\"ts_un\\":false,\\"ts_va\\":\\"sup\\",\\"ts_bgc2\\":{\\"clr_type\\":0,\\"hclr_color\\":null},\\"ts_fgc2\\":{\\"clr_type\\":0,\\"hclr_color\\":\\"#000000\\"},\\"ts_bd_i\\":false,\\"ts_fs_i\\":false,\\"ts_ff_i\\":false,\\"ts_it_i\\":false,\\"ts_sc_i\\":false,\\"ts_st_i\\":false,\\"ts_un_i\\":false,\\"ts_va_i\\":false,\\"ts_bgc2_i\\":false,\\"ts_fgc2_i\\":false},null,null,null,null,{\\"ts_bd\\":false,\\"ts_fs\\":11,\\"ts_ff\\":\\"Arial\\",\\"ts_it\\":false,\\"ts_sc\\":false,\\"ts_st\\":false,\\"ts_tw\\":400,\\"ts_un\\":false,\\"ts_va\\":\\"sub\\",\\"ts_bgc2\\":{\\"clr_type\\":0,\\"hclr_color\\":null},\\"ts_fgc2\\":{\\"clr_type\\":0,\\"hclr_color\\":\\"#000000\\"},\\"ts_bd_i\\":false,\\"ts_fs_i\\":false,\\"ts_ff_i\\":false,\\"ts_it_i\\":false,\\"ts_sc_i\\":false,\\"ts_st_i\\":false,\\"ts_un_i\\":false,\\"ts_va_i\\":false,\\"ts_bgc2_i\\":false,\\"ts_fgc2_i\\":false}]}],\\"dsl_metastyleslices\\":[{\\"stsl_type\\":\\"autocorrect\\",\\"stsl_styles\\":[{\\"ac_ot\\":null,\\"ac_ct\\":null,\\"ac_type\\":null,\\"ac_sm\\":{\\"asm_s\\":0,\\"asm_rl\\":0,\\"asm_l\\":\\"\\"},\\"ac_id\\":\\"\\"}]},{\\"stsl_type\\":\\"collapsed_content\\",\\"stsl_styles\\":[{\\"colc_icc\\":false}]},{\\"stsl_type\\":\\"composing_decoration\\",\\"stsl_styles\\":[{\\"cd_u\\":false,\\"cd_bgc\\":{\\"clr_type\\":0,\\"hclr_color\\":null}}]},{\\"stsl_type\\":\\"composing_region\\",\\"stsl_styles\\":[{\\"cr_c\\":false}]},{\\"stsl_type\\":\\"draft_comment\\",\\"stsl_styles\\":[{\\"dcs_cids\\":{\\"cv\\":{\\"op\\":\\"set\\",\\"opValue\\":[]}}}]},{\\"stsl_type\\":\\"ignore_word\\",\\"stsl_styles\\":[{\\"iwos_i\\":false}]},{\\"stsl_type\\":\\"revision_diff\\",\\"stsl_styles\\":[{\\"revdiff_dt\\":0,\\"revdiff_a\\":\\"\\",\\"revdiff_aid\\":\\"\\"}]},{\\"stsl_type\\":\\"smart_todo\\",\\"stsl_styles\\":[{\\"sts_cid\\":null,\\"sts_ot\\":null,\\"sts_ac\\":null,\\"sts_hi\\":{\\"cv\\":{\\"op\\":\\"set\\",\\"opValue\\":[]}},\\"sts_pa\\":{\\"cv\\":{\\"op\\":\\"set\\",\\"opValue\\":[]}},\\"sts_dm\\":{\\"cv\\":{\\"op\\":\\"set\\",\\"opValue\\":[]}}}]},{\\"stsl_type\\":\\"spellcheck\\",\\"stsl_styles\\":[{\\"sc_id\\":\\"\\",\\"sc_ow\\":null,\\"sc_sl\\":null,\\"sc_sugg\\":{\\"cv\\":{\\"op\\":\\"set\\",\\"opValue\\":[]}},\\"sc_sm\\":{\\"cv\\":{\\"op\\":\\"set\\",\\"opValue\\":[]}}}]},{\\"stsl_type\\":\\"voice_corrections\\",\\"stsl_styles\\":[{\\"vcs_c\\":{\\"cv\\":{\\"op\\":\\"set\\",\\"opValue\\":[]}},\\"vcs_id\\":\\"\\"}]},{\\"stsl_type\\":\\"voice_dotted_span\\",\\"stsl_styles\\":[{\\"vdss_p\\":null,\\"vdss_id\\":\\"\\"}]}],\\"dsl_suggestedinsertions\\":{\\"sgsl_sugg\\":[]},\\"dsl_suggesteddeletions\\":{\\"sgsl_sugg\\":[]},\\"dsl_entitypositionmap\\":{},\\"dsl_entitymap\\":{},\\"dsl_entitytypemap\\":{},\\"dsl_drawingrevisionaccesstokenmap\\":{},\\"dsl_relateddocslices\\":{},\\"dsl_nestedmodelmap\\":{}},\\"autotext_content\\":{}}","edi":"QT9bASY3_GwFN8ZN119g2Ja2EHnrx_nq6yjODnrlfjrbkZEpqMl5NY_AGJtzSbcFsVk9XklA8IoHTqo123xXoO8cLKmVgnTCRWC6udfsvDpa","edrk":"xKYajWttYyLMt_u511ACUFI3qewboAf7dCubtDmoDhoCHwlXkw..","dct":"kix","ds":false,"cses":false}', + }); + + richText.expectSnapshotValue(); + }); + }); + } +); diff --git a/cypress/e2e/rich-text/RichTextEditor.Tracking.spec.ts b/cypress/e2e/rich-text/RichTextEditor.Tracking.spec.ts new file mode 100644 index 000000000..756dc39db --- /dev/null +++ b/cypress/e2e/rich-text/RichTextEditor.Tracking.spec.ts @@ -0,0 +1,824 @@ +/* eslint-disable mocha/no-setup-in-describe */ + +import { MARKS, BLOCKS, INLINES } from '@contentful/rich-text-types'; + +import { RichTextPage } from './RichTextPage'; + +// the sticky toolbar gets in the way of some of the tests, therefore +// we increase the viewport height to fit the whole page on the screen + +describe('Rich Text Editor - Tracking', { viewportHeight: 2000 }, () => { + let richText: RichTextPage; + + // copied from the 'is-hotkey' library we use for RichText shortcuts + const IS_MAC = + typeof window != 'undefined' && /Mac|iPod|iPhone|iPad/.test(window.navigator.platform); + const mod = IS_MAC ? 'meta' : 'control'; + + const action = (action, origin, payload = {}) => [ + action, + { + origin, + ...payload, + }, + ]; + + const linkRendered = () => action('linkRendered', 'viewport-interaction'); + + const openCreateEmbedDialog = (origin, nodeType) => + action('openCreateEmbedDialog', origin, { nodeType }); + + const insert = (origin, payload) => action('insert', origin, payload); + const remove = (origin, payload) => action('remove', origin, payload); + const edit = (origin, payload) => action('edit', origin, payload); + + const cancelEmbeddedDialog = (origin, nodeType) => + action('cancelCreateEmbedDialog', origin, { nodeType }); + + const openCommandPalette = () => action('openRichTextCommandPalette', 'command-palette'); + + const cancelCommandPalette = () => action('cancelRichTextCommandPalette', 'command-palette'); + + beforeEach(() => { + richText = new RichTextPage(); + richText.visit(); + }); + + describe('Text Pasting', () => { + it('tracks text pasting', () => { + richText.editor.click().paste({ 'text/plain': 'Hello World!' }); + + richText.expectTrackingValue([ + action('paste', 'shortcut-or-viewport', { + characterCountAfter: 12, + characterCountBefore: 0, + characterCountSelection: 0, + source: 'Unknown', + }), + ]); + + richText.editor.click().type('{enter}').paste({ 'text/plain': 'Hello World!' }); + + richText.expectTrackingValue([ + action('paste', 'shortcut-or-viewport', { + characterCountAfter: 12, + characterCountBefore: 0, + characterCountSelection: 0, + source: 'Unknown', + }), + action('paste', 'shortcut-or-viewport', { + characterCountAfter: 25, + characterCountBefore: 13, + characterCountSelection: 0, + source: 'Unknown', + }), + ]); + }); + + it('tracks google docs source', () => { + richText.editor.click().paste({ + 'text/html': `Hello`, + }); + + richText.expectTrackingValue([ + action('paste', 'shortcut-or-viewport', { + characterCountAfter: 5, + characterCountBefore: 0, + characterCountSelection: 0, + source: 'Google Docs', + }), + ]); + }); + + it('tracks google spreadsheets source', () => { + richText.editor.click().paste({ + 'text/html': `Example response for issues that go into the sprint (we don't close the Zendesk ticket):`, + }); + + richText.expectTrackingValue([ + action('paste', 'shortcut-or-viewport', { + characterCountAfter: 88, + characterCountBefore: 0, + characterCountSelection: 0, + source: 'Google Spreadsheets', + }), + ]); + }); + + it('tracks slack source', () => { + richText.editor.click().paste({ + 'text/html': `Hey everyone`, + }); + + richText.expectTrackingValue([ + action('paste', 'shortcut-or-viewport', { + characterCountAfter: 12, + characterCountBefore: 0, + characterCountSelection: 0, + source: 'Slack', + }), + ]); + }); + + it('tracks apple notes source', () => { + richText.editor.click().paste({ + 'text/html': `

    Hello world

    `, + }); + + richText.expectTrackingValue([ + action('paste', 'shortcut-or-viewport', { + characterCountAfter: 14, + characterCountBefore: 0, + characterCountSelection: 0, + source: 'Apple Notes', + }), + ]); + }); + + it('tracks microsoft word source', () => { + richText.editor.click().paste({ + 'text/html': `

    Hello

    `, + }); + + richText.expectTrackingValue([ + action('paste', 'shortcut-or-viewport', { + characterCountAfter: 5, + characterCountBefore: 0, + characterCountSelection: 0, + source: 'Microsoft Word', + }), + ]); + }); + + it('tracks microsoft excel source', () => { + richText.editor.click().paste({ + 'text/html': `

    Hello

    `, + }); + + richText.expectTrackingValue([ + action('paste', 'shortcut-or-viewport', { + characterCountAfter: 5, + characterCountBefore: 0, + characterCountSelection: 0, + source: 'Microsoft Excel', + }), + ]); + }); + }); + + describe('Marks', () => { + [ + [MARKS.BOLD, `{${mod}}b`], + [MARKS.ITALIC, `{${mod}}i`], + [MARKS.UNDERLINE, `{${mod}}u`], + [MARKS.CODE, `{${mod}}/`], + ].forEach(([mark, shortcut]) => { + const toggleMarkViaToolbar = (mark: MARKS) => { + if (mark === 'code' || mark === 'superscript' || mark === 'subscript') { + cy.findByTestId('dropdown-toolbar-button').click(); + cy.findByTestId(`${mark}-toolbar-button`).click(); + } else { + cy.findByTestId(`${mark}-toolbar-button`).click(); + } + }; + it(`tracks ${mark} mark via toolbar`, () => { + richText.editor.click(); + + toggleMarkViaToolbar(mark); + toggleMarkViaToolbar(mark); + + richText.expectTrackingValue([ + action('mark', 'toolbar-icon', { markType: mark }), + action('unmark', 'toolbar-icon', { markType: mark }), + ]); + }); + + it(`tracks ${mark} mark via shortcut`, () => { + richText.editor.click().type(shortcut).type(shortcut); + + richText.expectTrackingValue([ + action('mark', 'shortcut', { markType: mark }), + action('unmark', 'shortcut', { markType: mark }), + ]); + }); + }); + }); + + describe('Headings', () => { + const headings = [ + [BLOCKS.HEADING_1, 'Heading 1', `{${mod}+alt+1}`], + [BLOCKS.HEADING_2, 'Heading 2', `{${mod}+alt+2}`], + [BLOCKS.HEADING_3, 'Heading 3', `{${mod}+alt+3}`], + [BLOCKS.HEADING_4, 'Heading 4', `{${mod}+alt+4}`], + [BLOCKS.HEADING_5, 'Heading 5', `{${mod}+alt+5}`], + [BLOCKS.HEADING_6, 'Heading 6', `{${mod}+alt+6}`], + ]; + + headings.forEach(([type, label, shortcut]) => { + it(`tracks ${label} (${type}) via toolbar`, () => { + richText.editor.click(); + + richText.toolbar.toggleHeading(type); + richText.toolbar.toggleHeading(type); + + richText.expectTrackingValue([ + insert('toolbar-icon', { nodeType: type }), + remove('toolbar-icon', { nodeType: type }), + ]); + }); + + it(`tracks ${label} (${type}) via hotkeys ${shortcut}`, () => { + richText.editor.click().type(shortcut).type(shortcut); + + richText.expectTrackingValue([ + insert('shortcut', { nodeType: type }), + remove('shortcut', { nodeType: type }), + ]); + }); + }); + }); + + describe('Quote', () => { + const methods: [string, string, () => void][] = [ + [ + 'using the toolbar', + 'toolbar-icon', + () => { + richText.toolbar.quote.click(); + }, + ], + [ + 'using hotkey (mod+shift+1)', + 'shortcut', + () => { + richText.editor.type(`{${mod}}{shift}1`); + }, + ], + ]; + + for (const [scenario, origin, toggleQuote] of methods) { + it(`tracks blockquote ${scenario}`, () => { + richText.editor.click(); + + toggleQuote(); + toggleQuote(); + + richText.expectTrackingValue([ + insert(origin, { nodeType: BLOCKS.QUOTE }), + remove(origin, { nodeType: BLOCKS.QUOTE }), + ]); + }); + } + }); + + describe('Tables', () => { + const insertTable = () => { + richText.editor.click(); + richText.toolbar.table.click(); + return richText.editor; + }; + const insertTableWithExampleData = () => { + insertTable() + .type('foo') + .type('{rightarrow}') + .type('bar') + .type('{rightarrow}') + .type('baz') + .type('{rightarrow}') + .type('quux'); + }; + + const insertTableAction = () => action('insertTable', 'toolbar-icon'); + const removeTableAction = (payload) => action('removeTable', 'viewport-interaction', payload); + const insertTableRowAction = (payload) => + action('insertTableRow', 'viewport-interaction', payload); + const insertTableColumnAction = (payload) => + action('insertTableColumn', 'viewport-interaction', payload); + const removeTableColumnAction = (payload) => + action('removeTableColumn', 'viewport-interaction', payload); + const removeTableRowAction = (payload) => + action('removeTableRow', 'viewport-interaction', payload); + + it('tracks insert table', () => { + insertTable(); + + richText.expectTrackingValue([insertTableAction()]); + }); + + describe('Table Actions', () => { + const findAction = (action: string) => { + cy.findByTestId('cf-table-actions-button').click(); + return cy.findByText(action); + }; + + const doAction = (action: string) => { + findAction(action).click({ force: true }); + }; + + beforeEach(() => { + insertTableWithExampleData(); + }); + + it('adds row above', () => { + doAction('Add row above'); + + richText.expectTrackingValue([ + insertTableAction(), + insertTableRowAction({ + tableSize: { + numColumns: 2, + numRows: 2, + }, + }), + ]); + }); + + it('adds row below', () => { + doAction('Add row below'); + + richText.expectTrackingValue([ + insertTableAction(), + insertTableRowAction({ + tableSize: { + numColumns: 2, + numRows: 2, + }, + }), + ]); + }); + + it('adds column left', () => { + doAction('Add column left'); + + richText.expectTrackingValue([ + insertTableAction(), + insertTableColumnAction({ + tableSize: { + numColumns: 2, + numRows: 2, + }, + }), + ]); + }); + + it('adds column right', () => { + doAction('Add column right'); + + richText.expectTrackingValue([ + insertTableAction(), + insertTableColumnAction({ + tableSize: { + numColumns: 2, + numRows: 2, + }, + }), + ]); + }); + + it('deletes row', () => { + doAction('Delete row'); + + richText.expectTrackingValue([ + insertTableAction(), + removeTableRowAction({ + tableSize: { + numColumns: 2, + numRows: 2, + }, + }), + ]); + }); + + it('deletes column', () => { + doAction('Delete column'); + + richText.expectTrackingValue([ + insertTableAction(), + removeTableColumnAction({ + tableSize: { + numColumns: 2, + numRows: 2, + }, + }), + ]); + }); + + it('deletes table', () => { + doAction('Delete table'); + + richText.expectTrackingValue([ + insertTableAction(), + removeTableAction({ + tableSize: { + numColumns: 2, + numRows: 2, + }, + }), + ]); + }); + }); + }); + + describe('Links', () => { + const openCreateModal = (origin) => [ + 'openCreateHyperlinkDialog', + { + origin, + }, + ]; + + const closeModal = (origin) => [ + 'cancelCreateHyperlinkDialog', + { + origin, + }, + ]; + + const openEditModal = () => [ + 'openEditHyperlinkDialog', + { + origin: 'viewport-interaction', + }, + ]; + + const insertHyperlink = (origin) => insert(origin, { linkType: 'uri', nodeType: 'hyperlink' }); + const insertAssetHyperlink = (origin) => + insert(origin, { linkType: 'Asset', nodeType: 'asset-hyperlink' }); + const insertEntryHyperlink = (origin) => + insert(origin, { linkType: 'Entry', nodeType: 'entry-hyperlink' }); + + const editHyperlink = () => + edit('viewport-interaction', { linkType: 'uri', nodeType: 'hyperlink' }); + const editAssetHyperlink = () => + edit('viewport-interaction', { linkType: 'Asset', nodeType: 'asset-hyperlink' }); + const editEntryHyperlink = () => + edit('viewport-interaction', { linkType: 'Entry', nodeType: 'entry-hyperlink' }); + + const unlink = (origin) => [ + 'unlinkHyperlinks', + { + origin, + }, + ]; + + const methods: [string, string, () => void][] = [ + [ + 'using the link toolbar button', + 'toolbar-icon', + () => { + richText.toolbar.hyperlink.click(); + }, + ], + [ + 'using the link keyboard shortcut', + 'shortcut', + () => { + richText.editor.type(`{${mod}}k`); + }, + ], + ]; + + for (const [triggerMethod, origin, triggerLinkModal] of methods) { + describe(triggerMethod, () => { + it('opens the hyperlink modal but cancels without adding a link', () => { + richText.editor.type('The quick brown fox jumps over the lazy '); + + triggerLinkModal(); + + const form = richText.forms.hyperlink; + + form.cancel.click(); + + richText.expectTrackingValue([openCreateModal(origin), closeModal(origin)]); + }); + + it('tracks adds and removes hyperlinks', () => { + richText.editor.type('The quick brown fox jumps over the lazy '); + + triggerLinkModal(); + + const form = richText.forms.hyperlink; + + form.linkText.type('dog'); + form.linkTarget.type('https://zombo.com'); + form.submit.click(); + + richText.expectTrackingValue([openCreateModal(origin), insertHyperlink(origin)]); + + richText.editor.click().type('{selectall}'); + cy.findByTestId('hyperlink-toolbar-button').click(); + + richText.expectTrackingValue([ + openCreateModal(origin), + insertHyperlink(origin), + unlink('toolbar-icon'), + ]); + }); + + it('tracks when converting text to URL hyperlink', () => { + richText.editor.type('My cool website{selectall}'); + + triggerLinkModal(); + const form = richText.forms.hyperlink; + + form.linkTarget.type('https://zombo.com'); + + form.submit.click(); + + richText.expectTrackingValue([openCreateModal(origin), insertHyperlink(origin)]); + }); + + it('tracks when converting text to entry hyperlink', () => { + richText.editor.type('My cool entry{selectall}'); + triggerLinkModal(); + const form = richText.forms.hyperlink; + + form.linkType.select('entry-hyperlink'); + + form.linkEntityTarget.click(); + + form.submit.click(); + + richText.expectTrackingValue([ + openCreateModal(origin), + insertEntryHyperlink(origin), + linkRendered(), + ]); + }); + + it('tracks when converting text to asset hyperlink', () => { + richText.editor.type('My cool asset{selectall}'); + + triggerLinkModal(); + + const form = richText.forms.hyperlink; + + form.linkType.select('asset-hyperlink'); + form.linkEntityTarget.click(); + form.submit.click(); + + richText.expectTrackingValue([ + openCreateModal(origin), + insertAssetHyperlink(origin), + linkRendered(), + ]); + }); + + it('tracks when editing hyperlinks', () => { + richText.editor.type('My cool website{selectall}'); + + triggerLinkModal(); + + // Part 1: + // Create a hyperlink + const form = richText.forms.hyperlink; + + form.linkTarget.type('https://zombo.com'); + form.submit.click(); + + richText.expectTrackingValue([openCreateModal(origin), insertHyperlink(origin)]); + + // Part 2: + // Update hyperlink to entry link + + richText.editor.findByTestId('cf-ui-text-link').click({ force: true }); + + form.linkType.select('entry-hyperlink'); + form.linkEntityTarget.click(); + form.submit.click(); + + richText.expectTrackingValue([ + openCreateModal(origin), + insertHyperlink(origin), + openEditModal(), + editEntryHyperlink(), + linkRendered(), + ]); + + // Part 3: + // Update entry link to asset link + + richText.editor.findByTestId('cf-ui-text-link').click({ force: true }); + + form.linkType.select('asset-hyperlink'); + form.linkEntityTarget.click(); + form.submit.click(); + + richText.expectTrackingValue([ + openCreateModal(origin), + insertHyperlink(origin), + openEditModal(), + editEntryHyperlink(), + linkRendered(), + openEditModal(), + editAssetHyperlink(), + linkRendered(), + ]); + + // Part 3: + // Update asset link to hyperlink + + richText.editor.findByTestId('cf-ui-text-link').click({ force: true }); + + form.linkType.select('hyperlink'); + form.linkTarget.type('https://zombo.com'); + form.submit.click(); + + richText.expectTrackingValue([ + openCreateModal(origin), + insertHyperlink(origin), + openEditModal(), + editEntryHyperlink(), + linkRendered(), + openEditModal(), + editAssetHyperlink(), + linkRendered(), + openEditModal(), + editHyperlink(), + ]); + }); + }); + } + }); + + describe('Embedded Entry Blocks', () => { + const methods: [string, string, () => void][] = [ + [ + 'using the toolbar button', + 'toolbar-icon', + () => { + richText.toolbar.embed('entry-block'); + }, + ], + [ + 'using the keyboard shortcut', + 'shortcut', + () => { + richText.editor.type(`{${mod}+shift+e}`); + }, + ], + ]; + + for (const [triggerMethod, origin, triggerEmbeddedEntry] of methods) { + describe(triggerMethod, () => { + it('tracks when inserting embedded entry block', () => { + richText.editor.click().then(triggerEmbeddedEntry); + + richText.expectTrackingValue([ + openCreateEmbedDialog(origin, BLOCKS.EMBEDDED_ENTRY), + insert(origin, { nodeType: BLOCKS.EMBEDDED_ENTRY }), + linkRendered(), + ]); + }); + + it('cancels without adding the entry block', () => { + cy.on('window:confirm', () => false); + + richText.editor.click().then(triggerEmbeddedEntry); + + richText.expectTrackingValue([ + openCreateEmbedDialog(origin, BLOCKS.EMBEDDED_ENTRY), + cancelEmbeddedDialog(origin, BLOCKS.EMBEDDED_ENTRY), + ]); + }); + }); + } + }); + + describe('Embedded Asset Blocks', () => { + const methods: [string, string, () => void][] = [ + [ + 'using the toolbar button', + 'toolbar-icon', + () => { + richText.toolbar.embed('asset-block'); + }, + ], + [ + 'using the keyboard shortcut', + 'shortcut', + () => { + richText.editor.type(`{${mod}+shift+a}`); + }, + ], + ]; + + for (const [triggerMethod, origin, triggerEmbeddedAsset] of methods) { + describe(triggerMethod, () => { + it('tracks when inserting embedded asset block', () => { + richText.editor.click().then(triggerEmbeddedAsset); + + richText.expectTrackingValue([ + openCreateEmbedDialog(origin, BLOCKS.EMBEDDED_ASSET), + insert(origin, { nodeType: BLOCKS.EMBEDDED_ASSET }), + linkRendered(), + ]); + }); + + it('cancels without adding the entry asset', () => { + cy.on('window:confirm', () => false); + + richText.editor.click().then(triggerEmbeddedAsset); + + richText.expectTrackingValue([ + openCreateEmbedDialog(origin, BLOCKS.EMBEDDED_ASSET), + cancelEmbeddedDialog(origin, BLOCKS.EMBEDDED_ASSET), + ]); + }); + }); + } + }); + + describe('Embedded Entry Inlines', () => { + const methods: [string, string, () => void][] = [ + [ + 'using the toolbar button', + 'toolbar-icon', + () => { + richText.toolbar.embed('entry-inline'); + }, + ], + [ + 'using the keyboard shortcut', + 'shortcut', + () => { + richText.editor.type(`{${mod}+shift+2}`); + }, + ], + ]; + + for (const [triggerMethod, origin, triggerEmbeddedInline] of methods) { + describe(triggerMethod, () => { + it('tracks when inserting embedded asset block', () => { + richText.editor.click().then(triggerEmbeddedInline); + + richText.expectTrackingValue([ + openCreateEmbedDialog(origin, INLINES.EMBEDDED_ENTRY), + insert(origin, { nodeType: INLINES.EMBEDDED_ENTRY }), + linkRendered(), + ]); + }); + + it('cancels without adding the entry asset', () => { + cy.on('window:confirm', () => false); + + richText.editor.click().then(triggerEmbeddedInline); + + richText.expectTrackingValue([ + openCreateEmbedDialog(origin, INLINES.EMBEDDED_ENTRY), + cancelEmbeddedDialog(origin, INLINES.EMBEDDED_ENTRY), + ]); + }); + }); + } + }); + + describe('Commands', () => { + const origin = 'command-palette'; + const getCommandList = () => cy.findByTestId('rich-text-commands-list'); + + beforeEach(() => { + richText.editor.click().type('/'); + }); + it('tracks opening the command palette', () => { + richText.expectTrackingValue([openCommandPalette()]); + }); + + it('tracks cancelling the command palette on pressing esc', () => { + richText.editor.type('{esc}'); + richText.expectTrackingValue([openCommandPalette(), cancelCommandPalette()]); + }); + + it('tracks embedding an entry block', () => { + getCommandList().findByText('Embed Example Content Type').click(); + getCommandList().findByText('Hello world').click(); + richText.expectTrackingValue([ + openCommandPalette(), + insert(origin, { nodeType: BLOCKS.EMBEDDED_ENTRY }), + linkRendered(), + ]); + }); + + it('tracks embedding an inline entry', () => { + getCommandList().findByText('Embed Example Content Type - Inline').click(); + getCommandList().findByText('Hello world').click(); + + richText.expectTrackingValue([ + openCommandPalette(), + insert(origin, { nodeType: INLINES.EMBEDDED_ENTRY }), + linkRendered(), + ]); + }); + + it('tracks embedding an asset block', () => { + getCommandList().findByText('Embed Asset').click(); + getCommandList().findByText('test').click(); + + richText.expectTrackingValue([ + openCommandPalette(), + insert(origin, { nodeType: BLOCKS.EMBEDDED_ASSET }), + linkRendered(), + ]); + }); + }); +}); diff --git a/cypress/e2e/rich-text/RichTextEditor.spec.ts b/cypress/e2e/rich-text/RichTextEditor.spec.ts new file mode 100644 index 000000000..d3d87025c --- /dev/null +++ b/cypress/e2e/rich-text/RichTextEditor.spec.ts @@ -0,0 +1,2147 @@ +/* eslint-disable mocha/no-setup-in-describe */ + +import { MARKS, BLOCKS, INLINES } from '@contentful/rich-text-types'; + +import { + document as doc, + block, + inline, + text, +} from '../../../packages/rich-text/src/helpers/nodeFactory'; +import documentWithLinks from './document-mocks/documentWithLinks'; +import invalidDocumentNormalizable from './document-mocks/invalidDocumentNormalizable'; +import { EmbedType, RichTextPage } from './RichTextPage'; + +// the sticky toolbar gets in the way of some of the tests, therefore +// we increase the viewport height to fit the whole page on the screen + +describe('Rich Text Editor', { viewportHeight: 2000 }, () => { + let richText: RichTextPage; + + // copied from the 'is-hotkey' library we use for RichText shortcuts + const IS_MAC = + typeof window != 'undefined' && /Mac|iPod|iPhone|iPad/.test(window.navigator.platform); + const mod = IS_MAC ? 'meta' : 'control'; + const buildHelper = + (type) => + (...children) => + block(type, {}, ...children); + const paragraph = buildHelper(BLOCKS.PARAGRAPH); + const paragraphWithText = (t) => paragraph(text(t, [])); + const emptyParagraph = () => paragraphWithText(''); + const expectDocumentToBeEmpty = () => richText.expectValue(undefined); + const entryBlock = () => + block(BLOCKS.EMBEDDED_ENTRY, { + target: { + sys: { + id: 'example-entity-id', + type: 'Link', + linkType: 'Entry', + }, + }, + }); + const assetBlock = () => + block(BLOCKS.EMBEDDED_ASSET, { + target: { + sys: { + id: 'example-entity-id', + type: 'Link', + linkType: 'Asset', + }, + }, + }); + + const keys = { + enter: { keyCode: 13, which: 13, key: 'Enter' }, + backspace: { keyCode: 8, which: 8, key: 'Backspace' }, + }; + + const headings = [ + [BLOCKS.PARAGRAPH, 'Normal text'], + [BLOCKS.HEADING_1, 'Heading 1', `{${mod}+alt+1}`], + [BLOCKS.HEADING_2, 'Heading 2', `{${mod}+alt+2}`], + [BLOCKS.HEADING_3, 'Heading 3', `{${mod}+alt+3}`], + [BLOCKS.HEADING_4, 'Heading 4', `{${mod}+alt+4}`], + [BLOCKS.HEADING_5, 'Heading 5', `{${mod}+alt+5}`], + [BLOCKS.HEADING_6, 'Heading 6', `{${mod}+alt+6}`], + ]; + + function pressEnter() { + richText.editor.trigger('keydown', keys.enter); + } + + function getDropdownList() { + return cy.findByTestId('dropdown-heading-list'); + } + + function getDropdownItem(type: string) { + return cy.findByTestId(`dropdown-option-${type}`); + } + + function addBlockquote(content = '') { + richText.editor.click().type(content); + + richText.toolbar.quote.click(); + + const expectedValue = doc( + block(BLOCKS.QUOTE, {}, block(BLOCKS.PARAGRAPH, {}, text(content, []))), + block(BLOCKS.PARAGRAPH, {}, text('', [])) + ); + + richText.expectValue(expectedValue); + + return expectedValue; + } + + beforeEach(() => { + richText = new RichTextPage(); + richText.visit(); + }); + + it('is empty by default', () => { + cy.editorEvents().should('deep.equal', []); + }); + + it('disable all editor actions on readonly mode', () => { + cy.setInitialValue( + doc( + paragraphWithText('text'), + block( + BLOCKS.TABLE, + {}, + block( + BLOCKS.TABLE_ROW, + {}, + block(BLOCKS.TABLE_HEADER_CELL, {}, paragraphWithText('heading 1')), + block(BLOCKS.TABLE_HEADER_CELL, {}, paragraphWithText('heading 2')) + ), + block( + BLOCKS.TABLE_ROW, + {}, + block(BLOCKS.TABLE_CELL, {}, paragraphWithText('cell 1')), + block(BLOCKS.TABLE_CELL, {}, paragraphWithText('cell 2')) + ) + ), + emptyParagraph() + ) + ); + + cy.setInitialDisabled(true); + + // Necessary for reading the correct LocalStorage values as we do + // the initial page load on the beforeEach hook + cy.reload(); + + richText.toolbar.bold.should('be.disabled'); + richText.toolbar.headingsDropdown.should('be.disabled'); + richText.toolbar.hr.should('be.disabled'); + richText.toolbar.hyperlink.should('be.disabled'); + richText.toolbar.italic.should('be.disabled'); + richText.toolbar.ol.should('be.disabled'); + richText.toolbar.quote.should('be.disabled'); + richText.toolbar.table.should('be.disabled'); + richText.toolbar.ul.should('be.disabled'); + richText.toolbar.underline.should('be.disabled'); + richText.toolbar.embedDropdown.should('be.disabled'); + }); + + it('allows typing', () => { + richText.editor.click().type('some text').click(); + + const expectedValue = doc(block(BLOCKS.PARAGRAPH, {}, text('some text'))); + + richText.expectValue(expectedValue); + }); + + it('has correct keyboard navigation', () => { + richText.editor.focus(); + richText.editor.tab({ shift: true }); + richText.toolbar.embedDropdown.should('have.focus'); + richText.editor.tab(); + richText.editor.tab(); + richText.editor.should('not.have.focus'); + }); + + describe('history', () => { + it('supports undo and redo', () => { + const expectedValue = doc(block(BLOCKS.PARAGRAPH, {}, text('some text.'))); + + // type + richText.editor.click().type('some text.').click(); + + richText.expectValue(expectedValue); + + // undo + richText.editor.click().type(`{${mod}}z`).click(); + richText.expectValue(undefined); + + // redo + richText.editor.click().type(`{${mod}}{shift}z`).click(); + richText.expectValue(expectedValue); + }); + + it('correctly undoes after drag&drop', () => { + const paragraph = block(BLOCKS.PARAGRAPH, {}, text('some text.')); + const docBeforeDragAndDrop = doc(paragraph, entryBlock(), emptyParagraph()); + + // type text, insert entry block + richText.editor.click().type('some text.').click(); + richText.toolbar.embed('entry-block'); + richText.expectValue(docBeforeDragAndDrop); + + // drag & drop + cy.findByTestId('cf-ui-entry-card') + .parent() + .parent() + .dragTo(() => richText.editor.findByText('some text.')); + if (Cypress.browser.name === 'firefox') { + richText.expectValue(doc(entryBlock(), paragraph, emptyParagraph())); + } else { + richText.expectValue( + doc( + block(BLOCKS.PARAGRAPH, {}, text('some')), + entryBlock(), + block(BLOCKS.PARAGRAPH, {}, text(' text.')), + emptyParagraph() + ) + ); + } + + // undo + // Ensures that drag&drop was recorded in a separate history batch, + // undoing should not delete the entry block. + // See the Slate bug report: https://github.com/ianstormtaylor/slate/issues/4694 + richText.editor.click().type(`{${mod}}z`).click(); + richText.expectValue(docBeforeDragAndDrop); + }); + }); + + describe('Marks', () => { + const findMarkViaToolbar = (mark: string) => { + if (mark === 'code' || mark === 'superscript' || mark === 'subscript') { + cy.findByTestId('dropdown-toolbar-button').click(); + return cy.findByTestId(`${mark}-toolbar-button`); + } else { + return cy.findByTestId(`${mark}-toolbar-button`); + } + }; + + const toggleMarkViaToolbar = (mark: string) => { + if (mark === 'code' || mark === 'superscript' || mark === 'subscript') { + cy.findByTestId('dropdown-toolbar-button').click(); + cy.findByTestId(`${mark}-toolbar-button`).click(); + } else { + cy.findByTestId(`${mark}-toolbar-button`).click(); + } + }; + + it(`shows ${MARKS.BOLD}, ${MARKS.ITALIC}, ${MARKS.UNDERLINE}, ${MARKS.CODE} if not explicitly allowed`, () => { + cy.setFieldValidations([]); + cy.reload(); + findMarkViaToolbar(MARKS.BOLD).should('be.visible'); + findMarkViaToolbar(MARKS.ITALIC).should('be.visible'); + findMarkViaToolbar(MARKS.UNDERLINE).should('be.visible'); + findMarkViaToolbar(MARKS.CODE).should('be.visible'); + }); + + [ + [MARKS.BOLD, `{${mod}}b`], + [MARKS.ITALIC, `{${mod}}i`], + [MARKS.UNDERLINE, `{${mod}}u`], + [MARKS.CODE, `{${mod}}/`], + [MARKS.SUPERSCRIPT], + [MARKS.SUBSCRIPT], + ].forEach(([mark, shortcut]) => { + describe(`${mark} mark toggle via toolbar`, () => { + it('allows writing marked text', () => { + richText.editor.click(); + + toggleMarkViaToolbar(mark); + + richText.editor.type('some text'); + + const expectedValue = doc( + block(BLOCKS.PARAGRAPH, {}, text('some text', [{ type: mark }])) + ); + + richText.expectValue(expectedValue); + }); + + it('allows writing marked text by selecting text', () => { + richText.editor.click().type('some text{selectall}'); + + toggleMarkViaToolbar(mark); + + const expectedValue = doc( + block(BLOCKS.PARAGRAPH, {}, text('some text', [{ type: mark }])) + ); + + richText.expectValue(expectedValue); + }); + + it('allows writing unmarked text', () => { + richText.editor.click(); + + toggleMarkViaToolbar(mark); + toggleMarkViaToolbar(mark); + + richText.editor.type('some text'); + + const expectedValue = doc(block(BLOCKS.PARAGRAPH, {}, text('some text', []))); + + richText.expectValue(expectedValue); + }); + + it('allows writing unmarked text by selecting text', () => { + richText.editor.click().type('some text{selectall}'); + + toggleMarkViaToolbar(mark); + + // Wait until the mark is applied + richText.expectValue( + doc(block(BLOCKS.PARAGRAPH, {}, text('some text', [{ type: mark }]))) + ); + + richText.editor.click().type('{selectall}'); + + toggleMarkViaToolbar(mark); + + const expectedValue = doc(block(BLOCKS.PARAGRAPH, {}, text('some text', []))); + + richText.expectValue(expectedValue); + }); + }); + + if (shortcut) { + describe(`${mark} mark toggle via shortcut`, () => { + it('allows writing marked text', () => { + richText.editor.click().type(shortcut).type('some text'); + + const expectedValue = doc( + block(BLOCKS.PARAGRAPH, {}, text('some text', [{ type: mark }])) + ); + + richText.expectValue(expectedValue); + }); + + it('allows writing marked text by selecting text', () => { + richText.editor.click().type('some text'); + + cy.wait(100); + + richText.editor.type('{selectall}').type(shortcut); + + const expectedValue = doc( + block(BLOCKS.PARAGRAPH, {}, text('some text', [{ type: mark }])) + ); + + richText.expectValue(expectedValue); + }); + + it('allows writing unmarked text', () => { + richText.editor.click().type(shortcut).type(shortcut).type('some text'); + + const expectedValue = doc(block(BLOCKS.PARAGRAPH, {}, text('some text', []))); + + richText.expectValue(expectedValue); + }); + + it('allows writing unmarked text by selecting text', () => { + richText.editor.click().type('some text'); + + cy.wait(100); + + richText.editor.type('{selectall}').type(shortcut).type('{selectall}').type(shortcut); + + const expectedValue = doc(block(BLOCKS.PARAGRAPH, {}, text('some text', []))); + + richText.expectValue(expectedValue); + }); + }); + } + }); + + it('should remove subscript when superscript mark is selected', () => { + richText.editor.click().type('should only be superscript{selectall}'); + toggleMarkViaToolbar(MARKS.SUBSCRIPT); + richText.editor.click().type('{selectall}'); + toggleMarkViaToolbar(MARKS.SUPERSCRIPT); + + const expectedValue = doc( + block( + BLOCKS.PARAGRAPH, + {}, + text('should only be superscript', [{ type: MARKS.SUPERSCRIPT }]) + ) + ); + richText.expectValue(expectedValue); + }); + + it('should remove superscript when subscript mark is selected', () => { + richText.editor.click().type('should only be subscript{selectall}'); + toggleMarkViaToolbar(MARKS.SUPERSCRIPT); + richText.editor.click().type('{selectall}'); + toggleMarkViaToolbar(MARKS.SUBSCRIPT); + + const expectedValue = doc( + block(BLOCKS.PARAGRAPH, {}, text('should only be subscript', [{ type: MARKS.SUBSCRIPT }])) + ); + richText.expectValue(expectedValue); + }); + }); + + describe('Commands', () => { + describe('Palette', () => { + const getPalette = () => cy.findByTestId('rich-text-commands'); + const getCommandList = () => cy.findByTestId('rich-text-commands-list'); + + it('should be visible', () => { + richText.editor.click().type('/'); + getPalette().should('be.visible'); + }); + + it('should close on pressing esc', () => { + richText.editor.click().type('/'); + getPalette().should('be.visible'); + richText.editor.type('{esc}'); + getPalette().should('not.exist'); + richText.expectValue(doc(block(BLOCKS.PARAGRAPH, {}, text('/')))); + }); + + it('should be searchable', () => { + richText.editor.click().type('/asset'); + getCommandList() + .children() + .each((child) => { + cy.wrap(child).should('include.text', 'Asset'); + }); + }); + + it('should delete search text after navigating', () => { + richText.editor.click().type('/asset'); + getCommandList().findByText('Embed Asset').click(); + richText.expectValue(doc(block(BLOCKS.PARAGRAPH, {}, text('/')))); + }); + + it('should navigate on category enter', () => { + richText.editor.click().type('/'); + getCommandList().findByText('Embed Example Content Type').click(); + getCommandList().should('be.visible'); + getCommandList().findByText('Embed Example Content Type').should('not.exist'); + }); + + it('should embed entry', () => { + richText.editor.click().type('/'); + getCommandList().findByText('Embed Example Content Type').click(); + getCommandList().findByText('Hello world').click(); + + //this is used instead of snapshot value because we have randomized entry IDs + richText.getValue().should((doc) => { + expect( + doc.content.filter((node) => node.nodeType === BLOCKS.EMBEDDED_ENTRY) + ).to.have.length(1); + }); + }); + + it('should embed inline', () => { + richText.editor.click().type('/'); + getCommandList().findByText('Embed Example Content Type - Inline').click(); + getCommandList().findByText('Hello world').click(); + + richText.expectSnapshotValue(); + }); + + it('should embed asset', () => { + richText.editor.click().type('/'); + getCommandList().findByText('Embed Asset').click(); + getCommandList().findByText('test').click(); + + richText.expectSnapshotValue(); + }); + + it('should delete command after embedding', () => { + richText.editor.click().type('/'); + getCommandList().findByText('Embed Example Content Type').click(); + getCommandList().findByText('Hello world').click(); + + richText.editor.children().contains('/').should('not.exist'); + }); + + it('should navigate then embed on pressing enter', () => { + richText.editor.click().type('/'); + getCommandList().findByText('Embed Example Content Type').should('exist'); + richText.editor.type('{enter}'); + getCommandList().findByText('Embed Example Content Type').should('not.exist'); + richText.editor.type('{enter}'); + + //this is used instead of snapshot value because we have randomized entry IDs + richText.getValue().should((doc) => { + expect( + doc.content.filter((node) => node.nodeType === BLOCKS.EMBEDDED_ENTRY) + ).to.have.length(1); + }); + }); + + it('should select next item on down arrow press', () => { + richText.editor.click().type('/{downarrow}{enter}{enter}'); + + richText.editor.findByTestId('embedded-entry-inline').should('exist'); + richText.expectSnapshotValue(); + }); + + it('should select previous item on up arrow press', () => { + richText.editor.click().type('/{downarrow}{uparrow}{enter}{enter}'); + + //this is used instead of snapshot value because we have randomized entry IDs + richText.getValue().should((doc) => { + expect( + doc.content.filter((node) => node.nodeType === BLOCKS.EMBEDDED_ENTRY) + ).to.have.length(1); + }); + }); + + it('should not delete adjacent text', () => { + richText.editor.click().type('test/{downarrow}{enter}{enter}'); + richText.expectSnapshotValue(); + }); + + it('should work inside headings', () => { + richText.editor.click().type('Heading 1'); + richText.toolbar.toggleHeading(BLOCKS.HEADING_1); + richText.editor.click().type('/{enter}{enter}'); + + //this is used instead of snapshot value because we have randomized entry IDs + richText.getValue().should((doc) => { + expect( + doc.content.filter((node) => { + return node.nodeType === BLOCKS.EMBEDDED_ENTRY || node.nodeType === BLOCKS.HEADING_1; + }) + ).to.have.length(2); + }); + }); + + it('should be disabled without any action item', () => { + // disable embedded entries/assets + cy.setFieldValidations([ + { + enabledNodeTypes: ['heading-1'], + }, + ]); + cy.reload(); + + // try to open command prompt + richText.editor.click().type('/'); + getPalette().should('not.exist'); + + // try to press enter and type content, which would not work with the open palette + richText.editor.click().type('{enter}Hello'); + richText.expectValue({ + nodeType: 'document', + data: {}, + content: [ + { + nodeType: 'paragraph', + data: {}, + content: [ + { + nodeType: 'text', + value: '/', + marks: [], + data: {}, + }, + ], + }, + { + nodeType: 'paragraph', + data: {}, + content: [ + { + nodeType: 'text', + value: 'Hello', + marks: [], + data: {}, + }, + ], + }, + ], + }); + // Clear validations after the test + cy.setFieldValidations([]); + }); + }); + }); + + describe('HR', () => { + describe('toolbar button', () => { + it('should be visible', () => { + richText.toolbar.hr.should('be.visible'); + }); + + it('should add a new line when clicking', () => { + richText.editor.click().type('some text'); + + richText.toolbar.hr.click(); + + const expectedValue = doc( + block(BLOCKS.PARAGRAPH, {}, text('some text', [])), + block(BLOCKS.HR, {}), + block(BLOCKS.PARAGRAPH, {}, text('', [])) + ); + + richText.expectValue(expectedValue); + }); + + it('should end with an empty paragraph', () => { + richText.editor.click().type('some text'); + + richText.toolbar.hr.click(); + richText.toolbar.hr.click(); + richText.toolbar.hr.click(); + + const expectedValue = doc( + block(BLOCKS.PARAGRAPH, {}, text('some text', [])), + block(BLOCKS.HR, {}), + block(BLOCKS.HR, {}), + block(BLOCKS.HR, {}), + block(BLOCKS.PARAGRAPH, {}, text('', [])) + ); + + richText.expectValue(expectedValue); + }); + + it('should split blockquote', () => { + addBlockquote('some text'); + + richText.editor.type('{enter}some text{uparrow}'); + + richText.toolbar.hr.click(); + + const expectedValue = doc( + block(BLOCKS.QUOTE, {}, block(BLOCKS.PARAGRAPH, {}, text('some text', []))), + block(BLOCKS.HR, {}), + block(BLOCKS.QUOTE, {}, block(BLOCKS.PARAGRAPH, {}, text('some text', []))), + block(BLOCKS.PARAGRAPH, {}, text('', [])) + ); + + richText.expectValue(expectedValue); + }); + + // TODO: Seems to be failing on CI + // eslint-disable-next-line mocha/no-skipped-tests + it.skip('should add line if HR is the first void block', () => { + richText.editor.click(); + + richText.toolbar.hr.click(); + + // Not necessary for the test but here to "force" waiting until + // we have the expected document structure + richText.expectValue(doc(block(BLOCKS.HR, {}), block(BLOCKS.PARAGRAPH, {}, text('', [])))); + + // Move arrow up to select the HR then press ENTER + richText.editor.click().type('{uparrow}{enter}'); + + const expectedValue = doc( + block(BLOCKS.PARAGRAPH, {}, text('', [])), + block(BLOCKS.HR, {}), + block(BLOCKS.PARAGRAPH, {}, text('', [])) + ); + + richText.expectValue(expectedValue); + }); + + it('should select all and delete if HR is the first void block', () => { + richText.editor.click(); + + richText.toolbar.hr.click(); + + richText.editor.click().type('hey').type('{selectall}{del}'); + + // editor is empty + richText.expectValue(undefined); + }); + + it('should be selected on backspace', () => { + richText.editor.click(); + + richText.toolbar.hr.click(); + richText.editor.type('X'); + + richText.expectValue(doc(block(BLOCKS.HR, {}), paragraphWithText('X'))); + + richText.editor.type('{backspace}{backspace}'); + + richText.expectValue(doc(block(BLOCKS.HR, {}), emptyParagraph())); + + richText.editor.type('{backspace}'); + + expectDocumentToBeEmpty(); + }); + }); + }); + + describe('Headings', () => { + headings.forEach(([type, label, shortcut]) => { + describe(label, () => { + it(`allows typing ${label} (${type})`, () => { + richText.editor.click().type('some text'); + + richText.toolbar.toggleHeading(type); + + // TODO: We should somehow assert that the editor is focused after this. + + // Account for trailing paragraph + const expectedValue = + type === BLOCKS.PARAGRAPH + ? doc(block(type, {}, text('some text', []))) + : doc(block(type, {}, text('some text', [])), emptyParagraph()); + + richText.expectValue(expectedValue); + }); + + if (shortcut) { + it(`allows writing ${label} (${type}) via hotkeys ${shortcut}`, () => { + richText.editor.click().type(shortcut).type('some text'); + + const expectedValue = doc(block(type, {}, text('some text', [])), emptyParagraph()); + + richText.expectValue(expectedValue); + }); + } + + it(`should set the dropdown label to ${label}`, () => { + richText.editor.click().type('some text'); + + richText.toolbar.toggleHeading(type); + + richText.toolbar.headingsDropdown.should('have.text', label); + }); + + // TODO: Move this test to either a single test with multiple assertions or for only one heading type due to performance + if (type !== BLOCKS.PARAGRAPH) { + it('should unwrap blockquote', () => { + addBlockquote('some text'); + + richText.toolbar.toggleHeading(type); + + const expectedHeadingValue = doc( + block(type, {}, text('some text', [])), + block(BLOCKS.PARAGRAPH, {}, text('', [])) + ); + + richText.expectValue(expectedHeadingValue); + }); + } else { + it('should not unwrap blockquote', () => { + const expectedQuoteValue = addBlockquote('some text'); + + richText.toolbar.toggleHeading(type); + + richText.expectValue(expectedQuoteValue); + }); + } + + it('should be deleted if empty when pressing delete', () => { + richText.editor.click(); // to set an initial editor.location + + richText.toolbar.toggleHeading(type); + + richText.editor.type('x{enter}'); + + richText.toolbar.embed('entry-block'); + + // To make sure paragraph/heading is present + richText.expectValue(doc(block(type, {}, text('x')), entryBlock(), emptyParagraph())); + + richText.editor + .click('bottom') + // Using `delay` to avoid flakiness, cypress triggers a keypress every 10ms and the editor was not responding correctly + .type('{uparrow}{uparrow}{uparrow}{del}{del}', { delay: 100 }); + + richText.expectValue(doc(entryBlock(), emptyParagraph())); + }); + + it('should delete next block if not empty when pressing delete', () => { + const value = 'some text'; + richText.editor.click().type(value); + + richText.toolbar.toggleHeading(type); + + richText.toolbar.embed('entry-block'); + + // Using `delay` to avoid flakiness, cypress triggers a keypress every 10ms and the editor was not responding correcrly + richText.editor.type('{leftarrow}{del}', { delay: 100 }); + + richText.expectValue(doc(block(type, {}, text(value)), emptyParagraph())); + }); + }); + }); + + describe('Toolbar', () => { + it('should be visible', () => { + richText.toolbar.headingsDropdown.should('be.visible'); + + richText.toolbar.headingsDropdown.click(); + getDropdownList().should('be.visible'); + }); + + it(`should have ${headings.length} items`, () => { + richText.toolbar.headingsDropdown.click(); + getDropdownList().children().should('have.length', headings.length); + + headings.forEach(([, label], index) => { + getDropdownList().children().eq(index).should('have.text', label); + }); + }); + }); + }); + + describe('Quote', () => { + const methods: [string, () => void][] = [ + [ + 'using the toolbar', + () => { + richText.toolbar.quote.click(); + }, + ], + [ + 'using hotkey (mod+shift+1)', + () => { + richText.editor.type(`{${mod}}{shift}1`); + }, + ], + ]; + + for (const [scenario, toggleQuote] of methods) { + describe(scenario, () => { + it('the toolbar button should be visible', () => { + richText.toolbar.quote.should('be.visible'); + }); + + it('should toggle off empty quotes on backspace', () => { + richText.editor.click(); + + toggleQuote(); + + richText.editor.type('{backspace}'); + + richText.expectSnapshotValue(); + }); + + it('should add a block quote when clicking followed by a trailing empty paragraph', () => { + richText.editor.click(); + + toggleQuote(); + + const expectedValue = doc( + block(BLOCKS.QUOTE, {}, block(BLOCKS.PARAGRAPH, {}, text('', []))), + block(BLOCKS.PARAGRAPH, {}, text('', [])) + ); + + richText.expectValue(expectedValue); + }); + + it('should convert existing paragraph into a block quote', () => { + richText.editor.click().type('some text'); + + toggleQuote(); + + const expectedValue = doc( + block(BLOCKS.QUOTE, {}, block(BLOCKS.PARAGRAPH, {}, text('some text', []))), + block(BLOCKS.PARAGRAPH, {}, text('', [])) + ); + + richText.expectValue(expectedValue); + }); + + it('should convert block quote back to paragraph', () => { + richText.editor.click().type('some text'); + + toggleQuote(); + toggleQuote(); + + const expectedValue = doc( + block(BLOCKS.PARAGRAPH, {}, text('some text', [])), + block(BLOCKS.PARAGRAPH, {}, text('', [])) + ); + + richText.expectValue(expectedValue); + }); + + it('should add multi-paragraph block quotes', () => { + richText.editor.click().type('paragraph 1'); + + toggleQuote(); + + richText.editor.type('{enter}').type('paragraph 2'); + + const expectedValue = doc( + block( + BLOCKS.QUOTE, + {}, + block(BLOCKS.PARAGRAPH, {}, text('paragraph 1', [])), + block(BLOCKS.PARAGRAPH, {}, text('paragraph 2', [])) + ), + block(BLOCKS.PARAGRAPH, {}, text('', [])) + ); + + richText.expectValue(expectedValue); + }); + + it('should preserve marks & inline elements', () => { + richText.editor.click(); + // bold underline italic code [link] [inline-entry] more text + richText.editor.paste({ + 'application/x-slate-fragment': + 'JTVCJTdCJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmNoaWxkcmVuJTIyJTNBJTVCJTdCJTIydGV4dCUyMiUzQSUyMmJvbGQlMjIlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIyYm9sZCUyMiUzQXRydWUlN0QlMkMlN0IlMjJ0ZXh0JTIyJTNBJTIyJTIwJTIyJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCU3RCUyQyU3QiUyMnRleHQlMjIlM0ElMjJpdGFsaWMlMjIlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIyaXRhbGljJTIyJTNBdHJ1ZSU3RCUyQyU3QiUyMnRleHQlMjIlM0ElMjIlMjAlMjIlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTdEJTJDJTdCJTIydGV4dCUyMiUzQSUyMnVuZGVybGluZSUyMiUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJ1bmRlcmxpbmUlMjIlM0F0cnVlJTdEJTJDJTdCJTIydGV4dCUyMiUzQSUyMiUyMCUyMiUyQyUyMmRhdGElMjIlM0ElN0IlN0QlN0QlMkMlN0IlMjJ0ZXh0JTIyJTNBJTIyY29kZSUyMiUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJjb2RlJTIyJTNBdHJ1ZSU3RCUyQyU3QiUyMnRleHQlMjIlM0ElMjIlMjAlMjIlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTdEJTJDJTdCJTIydHlwZSUyMiUzQSUyMmh5cGVybGluayUyMiUyQyUyMmRhdGElMjIlM0ElN0IlMjJ1cmklMjIlM0ElMjJodHRwcyUzQSUyRiUyRmV4YW1wbGUuY29tJTIyJTdEJTJDJTIyY2hpbGRyZW4lMjIlM0ElNUIlN0IlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIydGV4dCUyMiUzQSUyMmxpbmslMjIlN0QlNUQlN0QlMkMlN0IlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIydGV4dCUyMiUzQSUyMiUyMCUyMiU3RCUyQyU3QiUyMnR5cGUlMjIlM0ElMjJlbWJlZGRlZC1lbnRyeS1pbmxpbmUlMjIlMkMlMjJjaGlsZHJlbiUyMiUzQSU1QiU3QiUyMnRleHQlMjIlM0ElMjIlMjIlN0QlNUQlMkMlMjJkYXRhJTIyJTNBJTdCJTIydGFyZ2V0JTIyJTNBJTdCJTIyc3lzJTIyJTNBJTdCJTIyaWQlMjIlM0ElMjJleGFtcGxlLWVudGl0eS1pZCUyMiUyQyUyMnR5cGUlMjIlM0ElMjJMaW5rJTIyJTJDJTIybGlua1R5cGUlMjIlM0ElMjJFbnRyeSUyMiU3RCU3RCU3RCU3RCUyQyU3QiUyMnRleHQlMjIlM0ElMjIlMjBtb3JlJTIwdGV4dCUyMiU3RCU1RCUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCU3RCU1RA==', + }); + + toggleQuote(); + + richText.expectSnapshotValue(); + }); + }); + } + }); + + describe('New Line', () => { + it('should add a new line on a paragraph', () => { + richText.editor + .click() + .type('some text 1') + .type('{shift+enter}') + .type('some text 2') + .type('{shift+enter}') + .type('some text 3'); + + const expectedValue = doc( + block(BLOCKS.PARAGRAPH, {}, text('some text 1\nsome text 2\nsome text 3')) + ); + + richText.expectValue(expectedValue); + }); + + it('should add a new line on a heading', () => { + richText.editor.click(); + + richText.toolbar.toggleHeading(BLOCKS.HEADING_1); + + richText.editor + .type('some text 1') + .type('{shift+enter}') + .type('some text 2') + .type('{shift+enter}') + .type('some text 3'); + + const expectedValue = doc( + block(BLOCKS.HEADING_1, {}, text('some text 1\nsome text 2\nsome text 3')), + emptyParagraph() + ); + + richText.expectValue(expectedValue); + }); + + describe('in a list', () => { + it('should add a new line', () => { + richText.editor.click(); + + richText.toolbar.ul.click(); + + richText.editor + .type('some text 1') + .type('{shift+enter}') + .type('some text 2') + .type('{shift+enter}') + .type('some text 3'); + + const expectedValue = doc( + block( + BLOCKS.UL_LIST, + {}, + block( + BLOCKS.LIST_ITEM, + {}, + block(BLOCKS.PARAGRAPH, {}, text('some text 1\nsome text 2\nsome text 3', [])) + ) + ), + emptyParagraph() + ); + + richText.expectValue(expectedValue); + }); + + it('should add a new line after entity block in same list item', () => { + richText.editor.click(); + + richText.toolbar.ul.click(); + + richText.editor + .type('some text 1') + .type('{enter}') + .type(`{${mod}+shift+e}`) + .type('{enter}') + .type('some more text') + .type(`{${mod}+shift+e}`) + .type('{enter}'); + + richText.expectSnapshotValue(); + }); + }); + }); + + describe('Tables', () => { + const table = buildHelper(BLOCKS.TABLE); + const row = buildHelper(BLOCKS.TABLE_ROW); + const cell = buildHelper(BLOCKS.TABLE_CELL); + const header = buildHelper(BLOCKS.TABLE_HEADER_CELL); + const emptyCell = () => cell(emptyParagraph()); + const emptyHeader = () => header(emptyParagraph()); + const cellWithText = (t) => cell(paragraphWithText(t)); + const headerWithText = (t) => header(paragraphWithText(t)); + const insertTable = () => { + richText.editor.click(); + richText.toolbar.table.click(); + return richText.editor; + }; + const insertTableWithExampleData = () => { + insertTable() + .type('foo') + .type('{rightarrow}') + .type('bar') + .type('{rightarrow}') + .type('baz') + .type('{rightarrow}') + .type('quux'); + }; + const expectDocumentStructure = (...elements) => { + richText.expectValue(doc(...elements, emptyParagraph())); + }; + const expectTable = (...tableElements) => expectDocumentStructure(table(...tableElements)); + + // We know this feature doesn't work on firefox, we skip it + if (Cypress.browser.family !== 'firefox') { + it('inserts new line before table', () => { + // prevent expected error `Cannot resolve a Slate point from DOM point` from failing this test + Cypress.on('uncaught:exception', (err) => { + if ( + err.message.includes( + 'Cannot resolve a Slate point from DOM point: [object HTMLDivElement],0' + ) + ) { + return false; + } + + // we still want the test to fail in case there's any other error + return true; + }); + + insertTable(); + + richText.editor.type('{uparrow}{enter}'); + + richText.expectValue( + doc( + emptyParagraph(), + + table(row(emptyHeader(), emptyHeader()), row(emptyCell(), emptyCell())), + emptyParagraph() + ) + ); + }); + } + + it('can delete embedded inline entries inside table', () => { + insertTable(); + + richText.editor.type('hey'); + richText.toolbar.embed('entry-inline'); + richText.editor.type('{backspace}{backspace}'); // one selects, the secodnd deletes it + + richText.expectValue( + doc( + table( + row(header(paragraphWithText('hey')), emptyHeader()), + row(emptyCell(), emptyCell()) + ), + emptyParagraph() + ) + ); + }); + + it('does not delete table header cells when selecting the whole table', () => { + insertTable(); + + richText.editor.type(`hey{${mod}}a{backspace}`); + + richText.expectValue( + doc( + table(row(emptyHeader(), emptyHeader()), row(emptyCell(), emptyCell())), + emptyParagraph() + ) + ); + }); + + it('delete multiple lines inside cells', () => { + insertTable(); + + richText.editor.type('hey{enter}{backspace}'); + + richText.expectValue( + doc( + table( + row(header(paragraphWithText('hey')), emptyHeader()), + row(emptyCell(), emptyCell()) + ), + emptyParagraph() + ) + ); + }); + + it('disables block element toolbar buttons when selected', () => { + insertTable(); + + const blockElements = ['quote', 'ul', 'ol', 'hr', 'table']; + + blockElements.forEach((el) => { + richText.toolbar[el].should('be.disabled'); + }); + + richText.toolbar.headingsDropdown.should('be.disabled'); + + // select outside the table + richText.editor.click().type('{downarrow}').wait(100); + + blockElements.forEach((el) => { + cy.findByTestId(`${el}-toolbar-button`).should('not.be.disabled'); + }); + + richText.toolbar.headingsDropdown.click(); + [ + BLOCKS.PARAGRAPH, + BLOCKS.HEADING_1, + BLOCKS.HEADING_2, + BLOCKS.HEADING_3, + BLOCKS.HEADING_4, + BLOCKS.HEADING_5, + BLOCKS.HEADING_6, + ].map((type) => getDropdownItem(type).get('button').should('not.be.disabled')); + }); + + describe('Inserting Tables', () => { + it('replaces empty paragraphs with tables', () => { + insertTable(); + + richText.expectValue( + doc( + table(row(emptyHeader(), emptyHeader()), row(emptyCell(), emptyCell())), + emptyParagraph() + ) + ); + }); + + it('inserts new table below if paragraph is not empty', () => { + richText.editor.type('foo'); + + insertTable(); + + richText.expectValue( + doc( + paragraphWithText('foo'), + table(row(emptyHeader(), emptyHeader()), row(emptyCell(), emptyCell())), + emptyParagraph() + ) + ); + }); + + describe('Toolbar', () => { + const buttonsToDisableTable = ['ul', 'ol', 'quote']; + + it(`should disable table button if ${buttonsToDisableTable.join( + ', ' + )} elements are focused`, () => { + buttonsToDisableTable.forEach((button) => { + richText.editor.click(); + + cy.findByTestId(`${button}-toolbar-button`).click(); + + richText.toolbar.table.should('be.disabled'); + }); + }); + }); + }); + + describe('Deleting text', () => { + describe('Backward deletion', () => { + it('removes the text, not the cell', () => { + insertTableWithExampleData(); + richText.editor.find('table > tbody > tr:last-child > td:last-child').click(); + richText.editor + .type('{backspace}{backspace}{backspace}{backspace}{backspace}') + // .type('{backspace}') does not work on non-typable elements.(contentEditable=false) + .trigger('keydown', { keyCode: 8, which: 8, key: 'Backspace' }); // 8 = delete/backspace + + expectTable( + row(headerWithText('foo'), headerWithText('bar')), + row(cellWithText('baz'), emptyCell()) + ); + + // make sure it works for table header cells, too + richText.editor.find('table > tbody > tr:first-child > th:first-child').click(); + richText.editor + .type('{backspace}{backspace}{backspace}{backspace}{backspace}') + // .type('{backspace}') does not work on non-typable elements.(contentEditable=false) + .trigger('keydown', { keyCode: 8, which: 8, key: 'Backspace' }); // 8 = delete/backspace + + expectTable( + row(emptyHeader(), headerWithText('bar')), + row(cellWithText('baz'), emptyCell()) + ); + }); + }); + + describe('Forward deletion', () => { + it('removes the text, not the cell', () => { + insertTableWithExampleData(); + richText.editor.find('table > tbody > tr:first-child > th:first-child').click(); + richText.editor + .type('{leftarrow}{leftarrow}{leftarrow}{del}{del}{del}{del}') + // .type('{backspace}') does not work on non-typable elements.(contentEditable=false) + .trigger('keydown', { keyCode: 8, which: 8, key: 'Delete' }) // 8 = delete/backspace + // try forward-deleting from outside the table for good measure + .type('{leftarrow}{del}') + .trigger('keydown', { keyCode: 8, which: 8, key: 'Delete' }); + expectTable( + row(headerWithText(''), headerWithText('bar')), + row(cellWithText('baz'), cellWithText('quux')) + ); + }); + }); + }); + + describe('Table Actions', () => { + const findAction = (action: string) => { + cy.findByTestId('cf-table-actions-button').click(); + return cy.findByText(action); + }; + + const doAction = (action: string) => { + findAction(action).click({ force: true }); + }; + + beforeEach(() => { + insertTableWithExampleData(); + }); + + describe('adds row above', () => { + it('with table header cell', () => { + // Delete the table that was added in the beforeEach clause + // because we need the focus to be on the first row + doAction('Delete table'); + + // Insert an empty table (focus is on first row by default) + insertTable(); + + findAction('Add row above').should('be.disabled'); + }); + + it('with table cell', () => { + doAction('Add row above'); + + expectTable( + row(headerWithText('foo'), headerWithText('bar')), + row(emptyCell(), emptyCell()), + row(cellWithText('baz'), cellWithText('quux')) + ); + }); + }); + + describe('adds row below', () => { + it('with dropdown', () => { + doAction('Add row below'); + + expectTable( + row(headerWithText('foo'), headerWithText('bar')), + row(cellWithText('baz'), cellWithText('quux')), + row(emptyCell(), emptyCell()) + ); + }); + + it('with Tab key at the end', () => { + richText.editor.tab(); + + expectTable( + row(headerWithText('foo'), headerWithText('bar')), + row(cellWithText('baz'), cellWithText('quux')), + row(emptyCell(), emptyCell()) + ); + }); + }); + + it('adds column left', () => { + doAction('Add column left'); + + expectTable( + row(headerWithText('foo'), emptyHeader(), headerWithText('bar')), + row(cellWithText('baz'), emptyCell(), cellWithText('quux')) + ); + }); + + it('adds column right', () => { + doAction('Add column right'); + + expectTable( + row(headerWithText('foo'), headerWithText('bar'), emptyHeader()), + row(cellWithText('baz'), cellWithText('quux'), emptyCell()) + ); + }); + + it('enables/disables table header', () => { + doAction('Disable table header'); + + expectTable( + row(cellWithText('foo'), cellWithText('bar')), + row(cellWithText('baz'), cellWithText('quux')) + ); + + doAction('Enable table header'); + + expectTable( + row(headerWithText('foo'), headerWithText('bar')), + row(cellWithText('baz'), cellWithText('quux')) + ); + }); + + it('deletes row', () => { + doAction('Delete row'); + + expectTable(row(headerWithText('foo'), headerWithText('bar'))); + }); + + it('deletes column', () => { + doAction('Delete column'); + + expectTable(row(headerWithText('foo')), row(cellWithText('baz'))); + }); + + it('deletes table', () => { + doAction('Delete table'); + + expectDocumentToBeEmpty(); + }); + }); + }); + + describe('Links', () => { + const expectDocumentStructure = (...nodes) => { + richText.expectValue( + doc( + block( + BLOCKS.PARAGRAPH, + {}, + ...nodes.map(([nodeType, ...content]) => { + if (nodeType === 'text') return text(...content); + const [data, textContent] = content; + return inline(nodeType, data, text(textContent)); + }) + ) + ) + ); + }; + + // Type and wait for the text to be persisted + const safelyType = (text: string) => { + richText.editor.type(text); + + expectDocumentStructure(['text', text.replace('{selectall}', '')]); + }; + + const methods: [string, () => void][] = [ + [ + 'using the link toolbar button', + () => { + richText.toolbar.hyperlink.click(); + }, + ], + [ + 'using the link keyboard shortcut', + () => { + richText.editor.type(`{${mod}}k`); + }, + ], + ]; + + for (const [triggerMethod, triggerLinkModal] of methods) { + describe(triggerMethod, () => { + it('adds and removes hyperlinks', () => { + safelyType('The quick brown fox jumps over the lazy '); + + triggerLinkModal(); + + const form = richText.forms.hyperlink; + form.submit.should('be.disabled'); + + form.linkText.type('dog'); + form.submit.should('be.disabled'); + + form.linkTarget.type('https://zombo.com'); + form.submit.should('not.be.disabled'); + + form.submit.click(); + + expectDocumentStructure( + ['text', 'The quick brown fox jumps over the lazy '], + [INLINES.HYPERLINK, { uri: 'https://zombo.com' }, 'dog'], + ['text', ''] + ); + + richText.editor.click().type('{selectall}'); + // TODO: This should just be + // ``` + // triggerLinkModal(); + // `` + // but with the keyboard shortcut, this causes an error in Cypress I + // haven't been able to replicate in the editor. As it's not + // replicable in "normal" usage we use the toolbar button both places + // in this test. + cy.findByTestId('hyperlink-toolbar-button').click(); + + expectDocumentStructure( + // TODO: the editor should normalize this + ['text', 'The quick brown fox jumps over the lazy '], + ['text', 'dog'] + ); + }); + + it('converts text to URL hyperlink', () => { + safelyType('My cool website{selectall}'); + + triggerLinkModal(); + const form = richText.forms.hyperlink; + + form.linkText.should('have.value', 'My cool website'); + form.linkType.should('have.value', 'hyperlink'); + form.submit.should('be.disabled'); + + form.linkTarget.type('https://zombo.com'); + form.submit.should('not.be.disabled'); + + form.submit.click(); + + expectDocumentStructure( + ['text', ''], + [INLINES.HYPERLINK, { uri: 'https://zombo.com' }, 'My cool website'], + ['text', ''] + ); + }); + + it('converts text to entry hyperlink', () => { + safelyType('My cool entry{selectall}'); + triggerLinkModal(); + const form = richText.forms.hyperlink; + + form.linkText.should('have.value', 'My cool entry'); + form.submit.should('be.disabled'); + + form.linkType.should('have.value', 'hyperlink').select('entry-hyperlink'); + form.submit.should('be.disabled'); + + cy.findByTestId('cf-ui-entry-card').should('not.exist'); + form.linkEntityTarget.should('have.text', 'Select entry').click(); + cy.findByTestId('cf-ui-entry-card').should('exist'); + + form.linkEntityTarget.should('have.text', 'Remove selection').click(); + cy.findByTestId('cf-ui-entry-card').should('not.exist'); + + form.linkEntityTarget.should('have.text', 'Select entry').click(); + cy.findByTestId('cf-ui-entry-card').should('exist'); + + form.submit.click(); + + expectDocumentStructure( + ['text', ''], + [ + INLINES.ENTRY_HYPERLINK, + { target: { sys: { id: 'example-entity-id', type: 'Link', linkType: 'Entry' } } }, + 'My cool entry', + ], + ['text', ''] + ); + }); + + it('converts text to asset hyperlink', () => { + safelyType('My cool asset{selectall}'); + + triggerLinkModal(); + + const form = richText.forms.hyperlink; + + form.linkText.should('have.value', 'My cool asset'); + form.submit.should('be.disabled'); + + form.linkType.should('have.value', 'hyperlink').select('asset-hyperlink'); + form.submit.should('be.disabled'); + + cy.findByTestId('cf-ui-asset-card').should('not.exist'); + form.linkEntityTarget.should('have.text', 'Select asset').click(); + cy.findByTestId('cf-ui-asset-card').should('exist'); + + form.linkEntityTarget.should('have.text', 'Remove selection').click(); + cy.findByTestId('cf-ui-asset-card').should('not.exist'); + + form.linkEntityTarget.should('have.text', 'Select asset').click(); + cy.findByTestId('cf-ui-asset-card').should('exist'); + + form.submit.click(); + + expectDocumentStructure( + ['text', ''], + [ + INLINES.ASSET_HYPERLINK, + { target: { sys: { id: 'example-entity-id', type: 'Link', linkType: 'Asset' } } }, + 'My cool asset', + ], + ['text', ''] + ); + }); + + it('edits hyperlinks', () => { + safelyType('My cool website{selectall}'); + + triggerLinkModal(); + + // Part 1: + // Create a hyperlink + const form = richText.forms.hyperlink; + + form.linkText.should('have.value', 'My cool website'); + form.linkTarget.type('https://zombo.com'); + form.submit.click(); + + expectDocumentStructure( + ['text', ''], + [INLINES.HYPERLINK, { uri: 'https://zombo.com' }, 'My cool website'], + ['text', ''] + ); + + // Part 2: + // Update hyperlink to entry link + + richText.editor + .findByTestId('cf-ui-text-link') + .should('have.text', 'My cool website') + .click({ force: true }); + + form.linkText.should('not.exist'); + form.linkType.should('have.value', 'hyperlink').select('entry-hyperlink'); + form.linkEntityTarget.should('have.text', 'Select entry').click(); + form.submit.click(); + + expectDocumentStructure( + ['text', ''], + [ + INLINES.ENTRY_HYPERLINK, + { target: { sys: { id: 'example-entity-id', type: 'Link', linkType: 'Entry' } } }, + 'My cool website', + ], + ['text', ''] + ); + + // Part 3: + // Update entry link to asset link + + richText.editor + .findByTestId('cf-ui-text-link') + .should('have.text', 'My cool website') + .click({ force: true }); + + form.linkText.should('not.exist'); + form.linkType.should('have.value', 'entry-hyperlink').select('asset-hyperlink'); + form.linkEntityTarget.should('have.text', 'Select asset').click(); + form.submit.click(); + + expectDocumentStructure( + ['text', ''], + [ + INLINES.ASSET_HYPERLINK, + { target: { sys: { id: 'example-entity-id', type: 'Link', linkType: 'Asset' } } }, + 'My cool website', + ], + ['text', ''] + ); + + // Part 3: + // Update asset link to hyperlink + + richText.editor + .findByTestId('cf-ui-text-link') + .should('have.text', 'My cool website') + .click({ force: true }); + + form.linkText.should('not.exist'); + form.linkType.should('have.value', 'asset-hyperlink').select('hyperlink'); + form.linkTarget.type('https://zombo.com'); + form.submit.click(); + + expectDocumentStructure( + ['text', ''], + [INLINES.HYPERLINK, { uri: 'https://zombo.com' }, 'My cool website'], + ['text', ''] + ); + }); + + it('is removed from the document structure when empty', () => { + richText.editor.click(); + + triggerLinkModal(); + + const form = richText.forms.hyperlink; + + form.linkText.type('Link'); + form.linkTarget.type('https://link.com'); + form.submit.click(); + + expectDocumentStructure( + ['text', ''], + [INLINES.HYPERLINK, { uri: 'https://link.com' }, 'Link'], + ['text', ''] + ); + + richText.editor + .click() + .type('{backspace}{backspace}{backspace}{backspace}', { delay: 100 }); + + richText.expectValue(undefined); + }); + }); + } + }); + + describe('Embedded Entry Blocks', () => { + const methods: [string, () => void][] = [ + [ + 'using the toolbar button', + () => { + richText.toolbar.embed('entry-block'); + }, + ], + [ + 'using the keyboard shortcut', + () => { + richText.editor.type(`{${mod}+shift+e}`); + }, + ], + ]; + + for (const [triggerMethod, triggerEmbeddedEntry] of methods) { + describe(triggerMethod, () => { + it('adds paragraph before the block when pressing enter if the block is first document node', () => { + richText.editor.click().then(triggerEmbeddedEntry); + + richText.editor.find('[data-entity-id="example-entity-id"]').click(); + + richText.editor.trigger('keydown', keys.enter); + + richText.expectValue(doc(emptyParagraph(), entryBlock(), emptyParagraph())); + }); + + it('adds paragraph between two blocks when pressing enter', () => { + function addEmbeddedEntry() { + richText.editor.click('bottom').then(triggerEmbeddedEntry); + richText.editor.click('bottom'); + } + + addEmbeddedEntry(); + addEmbeddedEntry(); + + // Inserts paragraph before embed because it's in the first line. + richText.editor.get('[data-entity-id="example-entity-id"]').first().click(); + pressEnter(); + + // inserts paragraph in-between embeds. + richText.editor.get('[data-entity-id="example-entity-id"]').first().click(); + pressEnter(); + + richText.expectValue( + doc(emptyParagraph(), entryBlock(), emptyParagraph(), entryBlock(), emptyParagraph()) + ); + }); + + it('adds and removes embedded entries', () => { + richText.editor.click().then(triggerEmbeddedEntry); + + richText.expectValue(doc(entryBlock(), emptyParagraph())); + + cy.findByTestId('cf-ui-card-actions').click(); + cy.findByTestId('delete').click(); + + richText.expectValue(undefined); + }); + + it('adds and removes embedded entries by selecting and pressing `backspace`', () => { + richText.editor.click().then(triggerEmbeddedEntry); + + richText.expectValue(doc(entryBlock(), emptyParagraph())); + + cy.findByTestId('cf-ui-entry-card').click(); + // .type('{backspace}') does not work on non-typable elements.(contentEditable=false) + richText.editor.trigger('keydown', keys.backspace); + + richText.expectValue(undefined); + }); + + it('adds embedded entries between words', () => { + richText.editor + .click() + .type('foobar{leftarrow}{leftarrow}{leftarrow}') + .then(triggerEmbeddedEntry); + + richText.expectValue( + doc( + block(BLOCKS.PARAGRAPH, {}, text('foo')), + entryBlock(), + block(BLOCKS.PARAGRAPH, {}, text('bar')) + ) + ); + }); + + it('should be selected on backspace', () => { + richText.editor.click(); + triggerEmbeddedEntry(); + + richText.editor.type('{downarrow}X'); + + richText.expectValue(doc(entryBlock(), paragraphWithText('X'))); + + richText.editor.type('{backspace}{backspace}'); + + richText.expectValue(doc(entryBlock(), emptyParagraph())); + + richText.editor.type('{backspace}'); + + expectDocumentToBeEmpty(); + }); + }); + } + }); + + describe('Embedded Asset Blocks', () => { + const methods: [string, () => void][] = [ + [ + 'using the toolbar button', + () => { + richText.toolbar.embed('asset-block'); + }, + ], + [ + 'using the keyboard shortcut', + () => { + richText.editor.type(`{${mod}+shift+a}`); + }, + ], + ]; + + for (const [triggerMethod, triggerEmbeddedAsset] of methods) { + describe(triggerMethod, () => { + it('adds paragraph before the block when pressing enter if the block is first document node', () => { + richText.editor.click(); + triggerEmbeddedAsset(); + + richText.editor.find('[data-entity-id="example-entity-id"]').click(); + + richText.editor.trigger('keydown', keys.enter); + + richText.expectValue(doc(emptyParagraph(), assetBlock(), emptyParagraph())); + }); + + it('adds paragraph between two blocks when pressing enter', () => { + function addEmbeddedAsset() { + richText.editor.click('bottom').then(triggerEmbeddedAsset); + richText.editor.click('bottom'); + } + + addEmbeddedAsset(); + addEmbeddedAsset(); + + // Press enter on the first asset block + richText.editor.click().get('[data-entity-id="example-entity-id"]').first().click(); + pressEnter(); + + // Press enter on the second asset block + richText.editor.click().get('[data-entity-id="example-entity-id"]').first().click(); + pressEnter(); + + richText.expectValue( + doc(emptyParagraph(), assetBlock(), emptyParagraph(), assetBlock(), emptyParagraph()) + ); + }); + + it('adds and removes embedded assets', () => { + richText.editor.click().then(triggerEmbeddedAsset); + + richText.expectValue(doc(assetBlock(), emptyParagraph())); + + cy.findByTestId('cf-ui-card-actions').click(); + cy.findByTestId('card-action-remove').click(); + + richText.expectValue(undefined); + }); + + it('adds and removes embedded assets by selecting and pressing `backspace`', () => { + richText.editor.click().then(triggerEmbeddedAsset); + + richText.expectValue(doc(assetBlock(), emptyParagraph())); + + cy.findByTestId('cf-ui-asset-card').click(); + // .type('{backspace}') does not work on non-typable elements.(contentEditable=false) + richText.editor.trigger('keydown', keys.backspace); + + richText.expectValue(undefined); + }); + + it('adds embedded assets between words', () => { + richText.editor + .click() + .type('foobar{leftarrow}{leftarrow}{leftarrow}') + .then(triggerEmbeddedAsset); + + richText.expectValue( + doc( + block(BLOCKS.PARAGRAPH, {}, text('foo')), + assetBlock(), + block(BLOCKS.PARAGRAPH, {}, text('bar')) + ) + ); + }); + + it('should be selected on backspace', () => { + richText.editor.click(); + triggerEmbeddedAsset(); + + richText.editor.type('{downarrow}X'); + + richText.expectValue(doc(assetBlock(), paragraphWithText('X'))); + + richText.editor.type('{backspace}{backspace}'); + + richText.expectValue(doc(assetBlock(), emptyParagraph())); + + richText.editor.type('{backspace}'); + + expectDocumentToBeEmpty(); + }); + }); + } + }); + + describe('Embedded Entry Inlines', () => { + const methods: [string, () => void][] = [ + [ + 'using the toolbar button', + () => { + richText.toolbar.embed('entry-inline'); + }, + ], + [ + 'using the keyboard shortcut', + () => { + richText.editor.type(`{${mod}+shift+2}`); + }, + ], + ]; + + for (const [triggerMethod, triggerEmbeddedAsset] of methods) { + describe(triggerMethod, () => { + it('adds and removes embedded entries', () => { + richText.editor + .click() + .type('hello') + .then(triggerEmbeddedAsset) + .then(() => { + richText.editor.click().type('world'); + }); + + richText.expectValue( + doc( + block( + BLOCKS.PARAGRAPH, + {}, + text('hello'), + inline(INLINES.EMBEDDED_ENTRY, { + target: { + sys: { + id: 'example-entity-id', + type: 'Link', + linkType: 'Entry', + }, + }, + }), + text('world') + ) + ) + ); + + cy.findByTestId('cf-ui-card-actions').click({ force: true }); + cy.findByTestId('delete').click({ force: true }); + + richText.expectValue(doc(block(BLOCKS.PARAGRAPH, {}, text('hello'), text('world')))); + + // TODO: we should also test deletion via {backspace}, + // but this breaks in cypress even though it works in the editor + }); + }); + } + }); + + describe('on action callback', () => { + it('is invoked callback when rendering links', () => { + cy.setInitialValue(documentWithLinks); + cy.editorActions().should('be.empty'); + // Necessary for reading the correct LocalStorage values as we do + // the initial page load on the beforeEach hook + cy.reload(); + richText.expectValue(documentWithLinks); + cy.editorActions().should( + 'deep.equal', + new Array(5).fill([ + 'linkRendered', + { + origin: 'viewport-interaction', + }, + ]) + ); + }); + }); + + describe('invalid document structure', () => { + it('accepts document with no content', () => { + const docWithoutContent = { + nodeType: 'document', + data: {}, + content: [], + }; + + cy.setInitialValue(docWithoutContent); + + cy.reload(); + + // The field value in this case will still be untouched (i.e. un-normalized) + // since we won't trigger onChange. + richText.expectValue(docWithoutContent); + + // Initial normalization should not invoke onChange + cy.editorEvents() + .then((events) => events.filter((e) => e.type === 'onValueChanged')) + .should('deep.equal', []); + + // We can adjust the content + richText.editor.type('it works'); + richText.expectValue(doc(paragraphWithText('it works'))); + }); + + it('does not crash when an empty link is followed by a list', () => { + const exampleDoc = { + data: {}, + content: [ + { + data: {}, + content: [ + { + data: { + uri: 'https://example.com', + }, + content: [], + nodeType: 'hyperlink', + }, + ], + nodeType: 'paragraph', + }, + { + data: {}, + content: [ + { + data: {}, + content: [ + { + data: {}, + content: [ + { + data: {}, + marks: [], + value: 'some text', + nodeType: 'text', + }, + { + data: {}, + marks: [], + value: ' more text', + nodeType: 'text', + }, + ], + nodeType: 'paragraph', + }, + ], + nodeType: 'list-item', + }, + ], + nodeType: 'unordered-list', + }, + ], + nodeType: 'document', + }; + + cy.setInitialValue(exampleDoc); + + cy.reload(); + + // The field value in this case will still be untouched (i.e. un-normalized) + // since we won't trigger onChange. + richText.expectValue(exampleDoc); + + // Initial normalization should not invoke onChange + cy.editorEvents() + .then((events) => events.filter((e) => e.type === 'onValueChanged')) + .should('deep.equal', []); + + cy.findByText('some text more text'); + }); + + it('runs initial normalization without triggering a value change', () => { + cy.setInitialValue(invalidDocumentNormalizable); + + cy.reload(); + + // Should render normalized content + richText.editor.should('contain.text', 'This is a hyperlink'); + richText.editor.should('contain.text', 'This is a paragraph'); + richText.editor.should('contain.text', 'Text with custom marks'); + + richText.editor.should('contain.text', 'paragraph inside list item'); + richText.editor.should('contain.text', 'paragraph inside a nested list'); + richText.editor.should('contain.text', 'blockquote inside list item'); + + richText.editor.should('contain.text', 'cell #1'); + richText.editor.should('contain.text', 'cell #2'); + richText.editor.should('contain.text', 'cell #3'); + richText.editor.should('contain.text', 'cell #4'); + richText.editor.should('contain.text', 'cell #5'); + richText.editor.should('contain.text', 'cell #6'); + + // The field value in this case will still be untouched (i.e. un-normalized) + // since we won't trigger onChange. + richText.expectValue(invalidDocumentNormalizable); + + // Initial normalization should not invoke onChange + cy.editorEvents() + .then((events) => events.filter((e) => e.type === 'onValueChanged')) + .should('deep.equal', []); + + // Trigger normalization by changing the editor content + richText.editor.type('end'); + + richText.expectSnapshotValue(); + }); + }); + + describe('Toggling', () => { + const blocks: [string, EmbedType][] = [ + ['From Entry Block to Headings/Paragraph', 'entry-block'], + ['From Asset Block to Headings/Paragraph', 'asset-block'], + ]; + + blocks.forEach(([title, blockType]) => { + describe(title, () => { + headings.forEach(([type]) => { + it(`should not carry over the "data" property from ${blockType} to ${type}`, () => { + richText.editor.click(); + + richText.toolbar.embed(blockType); + + richText.editor.find('[data-entity-id="example-entity-id"]').click(); + + richText.toolbar.toggleHeading(type); + + richText.expectValue(doc(block(type, {}, text('')), emptyParagraph())); + }); + }); + }); + }); + }); + + describe('external updates', () => { + // FIXME: test is broken. The result shows correctly in Cypress but the + // assertion is not working. To be fixed in a follow up + // eslint-disable-next-line + it.skip('renders the new value', () => { + const firstString = 'Hello, World'; + richText.editor.type(firstString); + const oldDoc = doc(block(BLOCKS.PARAGRAPH, {}, text(firstString, []))); + richText.expectValue(oldDoc); + + // simulate a remote value change + const newDoc = doc( + block(BLOCKS.PARAGRAPH, {}, text(firstString, [])), + block(BLOCKS.EMBEDDED_ENTRY, { + target: { + sys: { + id: 'example-entity-id', + type: 'Link', + linkType: 'Entry', + }, + }, + }), + block(BLOCKS.PARAGRAPH, {}, text('', [])) + ); + + cy.getRichTextField().then((field) => { + const setValueSpy = cy.spy(field, 'setValue'); + cy.getRichTextField().setValueExternal(newDoc); + richText.expectValue(newDoc); + + // Ensure the value change hasn't triggered an editor change callback + // That scenario would cause a loop of updates + expect(setValueSpy).to.not.be.called; + // type something else to trigger the editor change callback + // the new value must contain the external value plus the typed text + const secondString = 'Bye, world'; + richText.editor.type('{enter}'); + richText.editor.type(secondString); + richText.expectValue({ + ...newDoc, + content: [...newDoc.content, block(BLOCKS.PARAGRAPH, {}, text(secondString, []))], + }); + }); + }); + }); + + describe('deleting paragraph between voids', () => { + it('can delete paragraph between entry blocks', () => { + richText.editor.click(); + richText.toolbar.embed('entry-block'); + richText.editor.type('hey'); + richText.toolbar.embed('entry-block'); + richText.editor.type('{leftarrow}{leftarrow}{backspace}{backspace}{backspace}{backspace}'); + + richText.expectValue(doc(entryBlock(), entryBlock(), emptyParagraph())); + }); + + it('can delete paragraph between asset blocks', () => { + richText.editor.click(); + richText.toolbar.embed('asset-block'); + richText.editor.type('hey'); + richText.toolbar.embed('asset-block'); + richText.editor.type('{leftarrow}{leftarrow}{backspace}{backspace}{backspace}{backspace}'); + + richText.expectValue(doc(assetBlock(), assetBlock(), emptyParagraph())); + }); + + it('can delete paragraph between HRs', () => { + richText.editor.click(); + richText.toolbar.hr.click(); + richText.editor.type('hey'); + richText.toolbar.hr.click(); + richText.editor.type('{leftarrow}{leftarrow}{backspace}{backspace}{backspace}{backspace}'); + + const hr = block(BLOCKS.HR, {}); + richText.expectValue(doc(hr, hr, emptyParagraph())); + }); + }); +}); diff --git a/cypress/e2e/rich-text/RichTextEscapeInlines.spec.ts b/cypress/e2e/rich-text/RichTextEscapeInlines.spec.ts new file mode 100644 index 000000000..9a676f6f2 --- /dev/null +++ b/cypress/e2e/rich-text/RichTextEscapeInlines.spec.ts @@ -0,0 +1,27 @@ +/* eslint-disable mocha/no-setup-in-describe */ +import { RichTextPage } from './RichTextPage'; + +describe('Rich Text Lists', () => { + let richText: RichTextPage; + + // eslint-disable-next-line mocha/no-hooks-for-single-case + beforeEach(() => { + richText = new RichTextPage(); + richText.visit(); + }); + + it('escapes hyperlink when typing at the end', () => { + richText.editor.click(); + richText.toolbar.hyperlink.click(); + + const form = richText.forms.hyperlink; + + form.linkText.type('link'); + form.linkTarget.type('https://example.com'); + form.submit.click(); + + richText.editor.click().type('outside the link'); + + richText.expectSnapshotValue(); + }); +}); diff --git a/cypress/e2e/rich-text/RichTextLists.spec.ts b/cypress/e2e/rich-text/RichTextLists.spec.ts new file mode 100644 index 000000000..5c33828b5 --- /dev/null +++ b/cypress/e2e/rich-text/RichTextLists.spec.ts @@ -0,0 +1,324 @@ +/* eslint-disable mocha/no-setup-in-describe */ +import { BLOCKS } from '@contentful/rich-text-types'; + +import { document as doc, block, text } from '../../../packages/rich-text/src/helpers/nodeFactory'; +import { RichTextPage } from './RichTextPage'; + +describe('Rich Text Lists', () => { + let richText: RichTextPage; + + const buildHelper = + (type) => + (...children) => + block(type, {}, ...children); + const paragraph = buildHelper(BLOCKS.PARAGRAPH); + const paragraphWithText = (t) => paragraph(text(t, [])); + const emptyParagraph = () => paragraphWithText(''); + + const entryBlock = () => + block(BLOCKS.EMBEDDED_ENTRY, { + target: { + sys: { + id: 'example-entity-id', + type: 'Link', + linkType: 'Entry', + }, + }, + }); + + const assetBlock = () => + block(BLOCKS.EMBEDDED_ASSET, { + target: { + sys: { + id: 'example-entity-id', + type: 'Link', + linkType: 'Asset', + }, + }, + }); + + const keys = { + tab: { keyCode: 9, which: 9, key: 'Tab' }, + }; + + function addBlockquote(content = '') { + richText.editor.click().type(content); + + richText.toolbar.quote.click(); + + const expectedValue = doc( + block(BLOCKS.QUOTE, {}, block(BLOCKS.PARAGRAPH, {}, text(content, []))), + block(BLOCKS.PARAGRAPH, {}, text('', [])) + ); + + richText.expectValue(expectedValue); + + return expectedValue; + } + + beforeEach(() => { + richText = new RichTextPage(); + richText.visit(); + }); + + const lists = [ + { + getList: () => richText.toolbar.ul, + listType: BLOCKS.UL_LIST, + label: 'Unordered List (UL)', + }, + { + getList: () => richText.toolbar.ol, + listType: BLOCKS.OL_LIST, + label: 'Ordered List (OL)', + }, + ]; + + it('does not remove entity cards when toggling off a list', () => { + const { toolbar, editor } = richText; + editor.click(); + // Toggle on an UL list + toolbar.ul.click(); + + toolbar.embed('entry-block'); + + // toggle off + toolbar.ul.click(); + + richText.expectSnapshotValue(); + }); + + lists.forEach((test) => { + describe(test.label, () => { + it('should be visible', () => { + test.getList().should('be.visible'); + }); + + it('should add a new list', () => { + richText.editor.click(); + + test.getList().click(); + // TODO: Find a way to test deeper lists + /* + Having issues with `.type('{enter})` to break lines. + The error is: + Cannot resolve a Slate node from DOM node: [object HTMLSpanElement] + */ + richText.editor.type('item 1'); + + const expectedValue = doc( + block( + test.listType, + {}, + block(BLOCKS.LIST_ITEM, {}, block(BLOCKS.PARAGRAPH, {}, text('item 1', []))) + ), + emptyParagraph() + ); + + richText.expectValue(expectedValue); + }); + + it('backspace on empty li at the beginning of doc should work', () => { + const { editor } = richText; + editor.click(); + + test.getList().click(); + + editor.click().type('{backspace}'); + + richText.expectSnapshotValue(); + }); + + it('backspace at the start of li should reset the item', () => { + const { editor } = richText; + editor.click(); + + test.getList().click(); + editor.type('abc'); + + editor.type('{leftArrow}{leftArrow}{leftArrow}'); + editor.type('{backspace}'); + + richText.expectSnapshotValue(); + }); + + it('should untoggle the list', () => { + richText.editor.click(); + + test.getList().click(); + + richText.editor.type('some text'); + + test.getList().click(); + + const expectedValue = doc( + block(BLOCKS.PARAGRAPH, {}, text('some text', [])), + emptyParagraph() + ); + + richText.expectValue(expectedValue); + }); + + it('should unwrap blockquote', () => { + addBlockquote('some text'); + + test.getList().click(); + + const expectedValue = doc( + block( + test.listType, + {}, + block(BLOCKS.LIST_ITEM, {}, block(BLOCKS.PARAGRAPH, {}, text('some text', []))) + ), + emptyParagraph() + ); + + richText.expectValue(expectedValue); + }); + + it('should allow heading as direct child of
  • ', () => { + richText.editor.click(); + test.getList().click(); + + richText.toolbar.toggleHeading(BLOCKS.HEADING_1); + richText.editor.type('heading'); + + const expectedValue = doc( + block( + test.listType, + {}, + block(BLOCKS.LIST_ITEM, {}, block(BLOCKS.HEADING_1, {}, text('heading'))) + ), + emptyParagraph() + ); + + richText.expectValue(expectedValue); + }); + + it('should allow HR as direct child of
  • ', () => { + richText.editor.click(); + test.getList().click(); + + richText.toolbar.hr.click(); + + const expectedValue = doc( + block(test.listType, {}, block(BLOCKS.LIST_ITEM, {}, block(BLOCKS.HR, {}))), + emptyParagraph() + ); + + richText.expectValue(expectedValue); + }); + + it('should allow embedded entry as direct child of
  • ', () => { + richText.editor.click(); + test.getList().click(); + + richText.toolbar.embed('entry-block'); + + const expectedValue = doc( + block(test.listType, {}, block(BLOCKS.LIST_ITEM, {}, entryBlock(), emptyParagraph())), + emptyParagraph() + ); + + richText.expectValue(expectedValue); + }); + + it('should allow embedded asset as direct child of
  • ', () => { + richText.editor.click(); + test.getList().click(); + + richText.toolbar.embed('asset-block'); + + const expectedValue = doc( + block(test.listType, {}, block(BLOCKS.LIST_ITEM, {}, assetBlock(), emptyParagraph())), + emptyParagraph() + ); + + richText.expectValue(expectedValue); + }); + + it('should allow block quotes as direct child of
  • ', () => { + richText.editor.click(); + test.getList().click(); + + richText.toolbar.quote.click(); + richText.editor.type('quote'); + + const expectedValue = doc( + block( + test.listType, + {}, + block(BLOCKS.LIST_ITEM, {}, block(BLOCKS.QUOTE, {}, paragraphWithText('quote'))) + ), + emptyParagraph() + ); + + richText.expectValue(expectedValue); + }); + + it('should preserve current marks when inserting a new li', () => { + richText.editor.click(); + test.getList().click(); + + // should only be applied to the word "bold " + richText.toolbar.bold.click(); + richText.editor.type('bold '); + richText.toolbar.bold.click(); + + // should be applied to the rest of line AND next li + richText.toolbar.italic.click(); + richText.editor.type('italic'); + + richText.editor.type('{enter}'); + richText.editor.type('more italic text'); + + richText.expectSnapshotValue(); + }); + + it('should move nested list items when parent is invalid', () => { + richText.editor.click(); + test.getList().click(); + + richText.editor + .type('1{enter}2{enter}3{enter}4') + .trigger('keydown', keys.tab) + .type('{uparrow}{uparrow}') + .trigger('keydown', keys.tab) + .type('{downarrow}{backspace}{backspace}'); + + richText.expectSnapshotValue(); + }); + + describe('switching off the list', () => { + it('it raises the list item entirely', () => { + richText.editor.click(); + test.getList().click(); + richText.editor.type('A paragraph'); + richText.toolbar.embed('entry-block'); + richText.editor.type('{uparrow}'); + + // switch the list off + test.getList().click(); + + richText.expectSnapshotValue(); + }); + + it('it raises the non-first list item entirely', () => { + richText.editor.click(); + test.getList().click(); + richText.editor.type('A paragraph'); + richText.toolbar.embed('entry-block'); + richText.editor.type('{enter}Another paragraph'); + richText.toolbar.embed('entry-block'); + richText.editor.type('{enter}Another paragraph again'); + richText.editor.type('{uparrow}{uparrow}'); + + // switch the list off + test.getList().click(); + + richText.expectSnapshotValue(); + }); + }); + }); + }); +}); diff --git a/cypress/e2e/rich-text/RichTextPage.ts b/cypress/e2e/rich-text/RichTextPage.ts new file mode 100644 index 000000000..db53331e8 --- /dev/null +++ b/cypress/e2e/rich-text/RichTextPage.ts @@ -0,0 +1,175 @@ +import { INLINES } from '@contentful/rich-text-types'; + +const isValidationEvent = ({ type }) => type === 'onSchemaErrorsChanged'; + +export type EmbedType = 'entry-block' | 'asset-block' | 'entry-inline'; + +export class RichTextPage { + visit() { + cy.visit('/rich-text'); + + this.editor.should('be.visible'); + } + + get editor() { + return cy.findByTestId('rich-text-editor').find('[data-slate-editor=true]'); + } + + get toolbar() { + return { + get headingsDropdown() { + return cy.findByTestId('toolbar-heading-toggle'); + }, + + toggleHeading(type: string) { + this.headingsDropdown.click(); + cy.findByTestId(`dropdown-option-${type}`).click({ force: true }); + }, + + get bold() { + return cy.findByTestId('bold-toolbar-button'); + }, + + get italic() { + return cy.findByTestId('italic-toolbar-button'); + }, + + get underline() { + return cy.findByTestId('underline-toolbar-button'); + }, + + get code() { + return cy.findByTestId('code-toolbar-button'); + }, + + get ul() { + return cy.findByTestId('ul-toolbar-button'); + }, + + get ol() { + return cy.findByTestId('ol-toolbar-button'); + }, + + get quote() { + return cy.findByTestId('quote-toolbar-button'); + }, + + get hr() { + return cy.findByTestId('hr-toolbar-button'); + }, + + get hyperlink() { + return cy.findByTestId('hyperlink-toolbar-button'); + }, + + get table() { + return cy.findByTestId('table-toolbar-button'); + }, + + get embedDropdown() { + return cy.findByTestId('toolbar-entity-dropdown-toggle'); + }, + + embed(type: EmbedType) { + this.embedDropdown.click(); + cy.findByTestId(`toolbar-toggle-embedded-${type}`).click(); + }, + }; + } + + get forms() { + return { + get hyperlink() { + return new HyperLinkModal(); + }, + }; + } + + getValue() { + cy.wait(500); + + return cy.getRichTextField().then((field) => { + return field.getValue(); + }); + } + + expectValue(expectedValue: any) { + // we want to make sure any kind of debounced behavior + // is already triggered before we go on and assert the + // content of the field in any test. Using cy.clock() + // doesn't work for some reason + // eslint-disable-next-line + cy.wait(500); + + cy.getRichTextField().should((field) => { + expect(field.getValue()).to.deep.equal(expectedValue); + }); + + // There can't be any validation error + this.expectNoValidationErrors(); + } + + expectSnapshotValue() { + // we want to make sure any kind of debounced behavior + // is already triggered before we go on and assert the + // content of the field in any test. Using cy.clock() + // doesn't work for some reason + // eslint-disable-next-line + cy.wait(500); + + cy.getRichTextField().should((field) => { + //@ts-expect-error cypress-plugin-snapshots doesn't have type definitions + cy.wrap(field.getValue()).toMatchSnapshot(); + }); + + // There can't be any validation error + this.expectNoValidationErrors(); + } + + expectNoValidationErrors() { + cy.editorEvents() + .then((events) => { + return events.filter((ev) => isValidationEvent(ev) && ev.value.length > 0); + }) + .should('be.empty') + .as('validationErrors'); + } + + expectTrackingValue(expectedValue: any) { + cy.window() + .should((win) => { + expect(win.actions).to.deep.equal(expectedValue); + }) + .as('trackingValue'); + } +} + +class HyperLinkModal { + get linkText() { + return cy.findByTestId('link-text-input'); + } + + get linkType() { + return cy.findByTestId('link-type-input'); + } + + setLinkType = (type: INLINES.HYPERLINK | INLINES.ENTRY_HYPERLINK | INLINES.ASSET_HYPERLINK) => { + this.linkType.select(type); + }; + + get linkTarget() { + return cy.findByTestId('link-target-input'); + } + + get linkEntityTarget() { + return cy.findByTestId('entity-selection-link'); + } + + get submit() { + return cy.findByTestId('confirm-cta'); + } + + get cancel() { + return cy.findByTestId('cancel-cta'); + } +} diff --git a/cypress/e2e/rich-text/__snapshots__/RichTextEditor.Pasting.spec.ts.snap b/cypress/e2e/rich-text/__snapshots__/RichTextEditor.Pasting.spec.ts.snap new file mode 100644 index 000000000..26e43819b --- /dev/null +++ b/cypress/e2e/rich-text/__snapshots__/RichTextEditor.Pasting.spec.ts.snap @@ -0,0 +1,4151 @@ +exports[`Rich Text Editor > Lists > pastes orphaned list items as unordered lists #0`] = +{ + "content": [ + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Hello" + } + ], + "data": {}, + "nodeType": "paragraph" + }, + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [ + { + "type": "bold" + } + ], + "nodeType": "text", + "value": "world!" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "list-item" + } + ], + "data": {}, + "nodeType": "unordered-list" + } + ], + "data": {}, + "nodeType": "list-item" + } + ], + "data": {}, + "nodeType": "unordered-list" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Editor > Lists > supports pasting of a simple list #0`] = +{ + "content": [ + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "item #1" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "list-item" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "item #2" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "list-item" + } + ], + "data": {}, + "nodeType": "unordered-list" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Editor > Lists > pastes texts inside lists #0`] = +{ + "content": [ + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Hello world!" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "list-item" + } + ], + "data": {}, + "nodeType": "unordered-list" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Editor > Lists > pastes elements inside links #0`] = +{ + "content": [ + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "This is a " + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "link" + } + ], + "data": { + "uri": "https://example.com" + }, + "nodeType": "hyperlink" + }, + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " and an inline entry: " + }, + { + "content": [], + "data": { + "target": { + "sys": { + "id": "example-entity-id", + "linkType": "Entry", + "type": "Link" + } + } + }, + "nodeType": "embedded-entry-inline" + }, + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "list-item" + } + ], + "data": {}, + "nodeType": "unordered-list" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Editor > Lists > pastes list items as new lists inside lists #0`] = +{ + "content": [ + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Hello" + } + ], + "data": {}, + "nodeType": "paragraph" + }, + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "sub" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "list-item" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "list" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "list-item" + } + ], + "data": {}, + "nodeType": "unordered-list" + } + ], + "data": {}, + "nodeType": "list-item" + } + ], + "data": {}, + "nodeType": "unordered-list" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Editor > Lists > confers the parent list type upon list items pasted within lists #0`] = +{ + "content": [ + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Hello" + } + ], + "data": {}, + "nodeType": "paragraph" + }, + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "sub" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "list-item" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "list" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "list-item" + } + ], + "data": {}, + "nodeType": "ordered-list" + } + ], + "data": {}, + "nodeType": "list-item" + } + ], + "data": {}, + "nodeType": "ordered-list" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Editor > Lists > pastes only the text content of other blocks #0`] = +{ + "content": [ + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Item #1" + }, + { + "data": {}, + "marks": [ + { + "type": "bold" + } + ], + "nodeType": "text", + "value": "Header 1" + } + ], + "data": {}, + "nodeType": "paragraph" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Header 2 (" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "with link" + } + ], + "data": { + "uri": "https://example.com" + }, + "nodeType": "hyperlink" + }, + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": ")" + } + ], + "data": {}, + "nodeType": "paragraph" + }, + { + "content": [ + { + "data": {}, + "marks": [ + { + "type": "bold" + } + ], + "nodeType": "text", + "value": "Cell 1" + } + ], + "data": {}, + "nodeType": "paragraph" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Cell 2" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "list-item" + } + ], + "data": {}, + "nodeType": "unordered-list" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Editor > Lists > pastes table & its inline elements correctly #0`] = +{ + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "What can I do with tables" + } + ], + "data": {}, + "nodeType": "paragraph" + }, + { + "content": [ + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Property" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-header-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Supported" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-header-cell" + } + ], + "data": {}, + "nodeType": "table-row" + }, + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Adding and removing rows and columns" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Yes" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + } + ], + "data": {}, + "nodeType": "table-row" + }, + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Table header" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Yes, for rows and columns" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + } + ], + "data": {}, + "nodeType": "table-row" + }, + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Formatting options" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [ + { + "type": "bold" + } + ], + "nodeType": "text", + "value": "Bold" + }, + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "," + }, + { + "data": {}, + "marks": [ + { + "type": "italic" + } + ], + "nodeType": "text", + "value": "italics" + }, + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "," + }, + { + "data": {}, + "marks": [ + { + "type": "underline" + } + ], + "nodeType": "text", + "value": "underline" + }, + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "," + }, + { + "data": {}, + "marks": [ + { + "type": "code" + } + ], + "nodeType": "text", + "value": "code" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + } + ], + "data": {}, + "nodeType": "table-row" + }, + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Hyperlinks" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "URL" + } + ], + "data": { + "uri": "https://google.com" + }, + "nodeType": "hyperlink" + }, + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": ", " + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "asset" + } + ], + "data": { + "target": { + "sys": { + "id": "example-entity-id", + "linkType": "Asset", + "type": "Link" + } + } + }, + "nodeType": "asset-hyperlink" + }, + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " and " + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "entry" + } + ], + "data": { + "target": { + "sys": { + "id": "example-entity-id", + "linkType": "Entry", + "type": "Link" + } + } + }, + "nodeType": "entry-hyperlink" + }, + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + } + ], + "data": {}, + "nodeType": "table-row" + }, + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Embed entries" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Only inline entries " + }, + { + "content": [], + "data": { + "target": { + "sys": { + "id": "example-entity-id", + "linkType": "Entry", + "type": "Link" + } + } + }, + "nodeType": "embedded-entry-inline" + }, + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + } + ], + "data": {}, + "nodeType": "table-row" + }, + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Copy & paste from other documents" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Yes. Eg. Google Docs, Jira, Confluence" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + } + ], + "data": {}, + "nodeType": "table-row" + } + ], + "data": {}, + "nodeType": "table" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Editor > HR > should paste from internal copying #0`] = +{ + "content": [ + { + "content": [], + "data": {}, + "nodeType": "hr" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Editor > HR > should paste from external resources #0`] = +{ + "content": [ + { + "content": [], + "data": {}, + "nodeType": "hr" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Editor > removes style tags #0`] = +{ + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "paste only this" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Editor > Basic marks > works when pasting from another RT editor #0`] = +{ + "content": [ + { + "content": [ + { + "data": {}, + "marks": [ + { + "type": "bold" + }, + { + "type": "italic" + }, + { + "type": "underline" + }, + { + "type": "code" + } + ], + "nodeType": "text", + "value": "hello world" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Editor > spreadsheets > removes empty columns/rows > Google Sheets #0`] = +{ + "content": [ + { + "content": [ + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Cell 1" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Cell 2" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + } + ], + "data": {}, + "nodeType": "table-row" + } + ], + "data": {}, + "nodeType": "table" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Editor > spreadsheets > removes empty columns/rows > MS Excel #0`] = +{ + "content": [ + { + "content": [ + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Cell 1" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Cell 2" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + } + ], + "data": {}, + "nodeType": "table-row" + } + ], + "data": {}, + "nodeType": "table" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Editor > Microsoft Word (.docx) deserialization > paragraphs, marks and links #0`] = +{ + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "This is a " + }, + { + "data": {}, + "marks": [ + { + "type": "bold" + } + ], + "nodeType": "text", + "value": "paragraph " + }, + { + "data": {}, + "marks": [ + { + "type": "italic" + } + ], + "nodeType": "text", + "value": "with " + }, + { + "data": {}, + "marks": [ + { + "type": "underline" + } + ], + "nodeType": "text", + "value": "some" + }, + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " marks and " + }, + { + "content": [ + { + "data": {}, + "marks": [ + { + "type": "underline" + } + ], + "nodeType": "text", + "value": "links" + } + ], + "data": { + "uri": "https://contentful.com/" + }, + "nodeType": "hyperlink" + }, + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " " + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Editor > Microsoft Word (.docx) deserialization > unordered list #0`] = +{ + "content": [ + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "This" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "list-item" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Is" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "list-item" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "A list" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "list-item" + } + ], + "data": {}, + "nodeType": "unordered-list" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Editor > Microsoft Word (.docx) deserialization > ordered list #0`] = +{ + "content": [ + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "This is" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "list-item" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "An ordered list" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "list-item" + } + ], + "data": {}, + "nodeType": "ordered-list" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Editor > Microsoft Word (.docx) deserialization > tables #0`] = +{ + "content": [ + { + "content": [ + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [ + { + "type": "italic" + } + ], + "nodeType": "text", + "value": "This is some" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [ + { + "type": "underline" + }, + { + "type": "bold" + } + ], + "nodeType": "text", + "value": "Content on tables" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + } + ], + "data": {}, + "nodeType": "table-row" + } + ], + "data": {}, + "nodeType": "table" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Editor > copy from safari (no href in anchors) > recognizes entry hyperlink #0`] = +{ + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "a" + } + ], + "data": { + "target": { + "sys": { + "id": "example-entity-id", + "linkType": "Entry", + "type": "Link" + } + } + }, + "nodeType": "entry-hyperlink" + }, + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " b" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Editor > copy from safari (no href in anchors) > recognizes asset hyperlink #0`] = +{ + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "a" + } + ], + "data": { + "target": { + "sys": { + "id": "example-entity-id", + "linkType": "Asset", + "type": "Link" + } + } + }, + "nodeType": "asset-hyperlink" + }, + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " b" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Editor > Lists > normalizes table cells correctly #0`] = +{ + "content": [ + { + "content": [ + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Field" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Type" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Description" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + } + ], + "data": {}, + "nodeType": "table-row" + } + ], + "data": {}, + "nodeType": "table" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Editor > Lists > normalizes paragraphs in table cells correctly #0`] = +{ + "content": [ + { + "content": [ + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Field" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Type" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Description" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + } + ], + "data": {}, + "nodeType": "table-row" + } + ], + "data": {}, + "nodeType": "table" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Editor > blockquotes > breaks a paragraph when pasting a blockquote in the middle #0`] = +{ + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "A paragrap" + } + ], + "data": {}, + "nodeType": "paragraph" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "a quote" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "blockquote" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "h" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Editor > blockquotes > remove the paragraph if it's empty #0`] = +{ + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "a quote" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "blockquote" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Editor > blockquotes > removes the paragraph if it's empty #0`] = +{ + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "a quote" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "blockquote" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Editor > blockquotes > removes the paragraph if it's fully selected #0`] = +{ + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "a quote" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "blockquote" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Editor > missing application/x-slate-fragment [safari] > render slate fragment if attribute "data-slate-fragment" exists #0`] = +{ + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "quote" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "blockquote" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Editor > Tables > Google Docs - around
    #0`] = +{ + "content": [ + { + "content": [ + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Cell 1" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Cell 2" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + } + ], + "data": {}, + "nodeType": "table-row" + }, + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Cell 3" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Cell 4" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + } + ], + "data": {}, + "nodeType": "table-row" + } + ], + "data": {}, + "nodeType": "table" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Editor > Tables > removes table wrappers when pasting a single cell #0`] = +{ + "nodeType": "document", + "data": {}, + "content": [ + { + "nodeType": "paragraph", + "data": {}, + "content": [ + { + "nodeType": "text", + "value": "cell content with a link and inline entry", + "marks": [], + "data": {} + }, + { + "nodeType": "embedded-entry-inline", + "data": { + "target": { + "sys": { + "id": "example-entity-id", + "type": "Link", + "linkType": "Entry" + } + } + }, + "content": [] + }, + { + "nodeType": "text", + "value": ".", + "marks": [], + "data": {} + } + ] + } + ] +}; + +exports[`Rich Text Editor > works when pasting from another RT editor > works when pasting subscript and superscript from a google doc #0`] = +{ + "content": [ + { + "content": [ + { + "data": {}, + "marks": [ + { + "type": "bold" + }, + { + "type": "italic" + }, + { + "type": "underline" + }, + { + "type": "code" + } + ], + "nodeType": "text", + "value": "hello world" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Editor > Superscript and subscript marks > works when pasting subscript and superscript from a google doc #0`] = +{ + "content": [ + { + "content": [ + { + "data": {}, + "marks": [ + { + "type": "superscript" + } + ], + "nodeType": "text", + "value": "Hello" + }, + { + "data": {}, + "marks": [ + { + "type": "subscript" + } + ], + "nodeType": "text", + "value": "World" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Editor > removing restricted marks > works when pasting subscript and superscript from a google doc #0`] = +{ + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "HelloWorld" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Editor > text > supports pasting of links within text #0`] = +{ + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Some text " + }, + { + "content": [ + { + "data": {}, + "marks": [ + { + "type": "underline" + } + ], + "nodeType": "text", + "value": "with link" + } + ], + "data": { + "uri": "https://be.contentful.com" + }, + "nodeType": "hyperlink" + }, + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " and some more text" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " " + }, + { + "data": {}, + "marks": [ + { + "type": "underline" + } + ], + "nodeType": "text", + "value": "and another link" + } + ], + "data": { + "uri": "https://be.contentful.com" + }, + "nodeType": "hyperlink" + }, + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " following." + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Editor > Lists > MS Word - does not remove space around link in list surrounded by text with background color #0`] = +{ + "content": [ + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "One list item" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "list-item" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "A list " + }, + { + "content": [ + { + "data": {}, + "marks": [ + { + "type": "underline" + } + ], + "nodeType": "text", + "value": "item" + } + ], + "data": { + "uri": "https://www.google.com/" + }, + "nodeType": "hyperlink" + }, + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " with a background colors" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "list-item" + } + ], + "data": {}, + "nodeType": "unordered-list" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Editor > Tables > Google Docs #0`] = +{ + "content": [ + { + "content": [ + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [ + { + "type": "bold" + } + ], + "nodeType": "text", + "value": "Field" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [ + { + "type": "bold" + } + ], + "nodeType": "text", + "value": "Type" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [ + { + "type": "bold" + } + ], + "nodeType": "text", + "value": "Description" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + } + ], + "data": {}, + "nodeType": "table-row" + }, + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "sys" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Sys" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Common " + }, + { + "data": {}, + "marks": [ + { + "type": "bold" + } + ], + "nodeType": "text", + "value": "system" + }, + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " properties" + } + ], + "data": {}, + "nodeType": "paragraph" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "system common " + } + ], + "data": {}, + "nodeType": "paragraph" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "properties." + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + } + ], + "data": {}, + "nodeType": "table-row" + }, + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "fields.title" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Text" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [ + { + "type": "bold" + } + ], + "nodeType": "text", + "value": "Title" + }, + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " of the asset." + } + ], + "data": {}, + "nodeType": "paragraph" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Title " + } + ], + "data": {}, + "nodeType": "paragraph" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Of the" + } + ], + "data": {}, + "nodeType": "paragraph" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "asset" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + } + ], + "data": {}, + "nodeType": "table-row" + }, + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "fields.description" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Text" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [ + { + "type": "italic" + } + ], + "nodeType": "text", + "value": "Description" + }, + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " of the asset." + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + } + ], + "data": {}, + "nodeType": "table-row" + }, + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "fields.file" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "File" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [ + { + "type": "underline" + } + ], + "nodeType": "text", + "value": "File" + }, + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "(s) of the asset." + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + } + ], + "data": {}, + "nodeType": "table-row" + }, + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "fields.file.fileName" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Symbol" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [ + { + "type": "underline" + }, + { + "type": "italic" + }, + { + "type": "bold" + } + ], + "nodeType": "text", + "value": "Original" + }, + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " filename of the file." + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + } + ], + "data": {}, + "nodeType": "table-row" + }, + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "fields.file.contentType" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Symbol" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Content type of the file." + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + } + ], + "data": {}, + "nodeType": "table-row" + }, + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "fields.file.url" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Symbol" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "URL of the file." + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + } + ], + "data": {}, + "nodeType": "table-row" + }, + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "fields.file.details" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Object" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Details of the file, depending on its MIME type." + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + } + ], + "data": {}, + "nodeType": "table-row" + }, + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "fields.file.details.size" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Number" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Size (in bytes) of the file." + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + } + ], + "data": {}, + "nodeType": "table-row" + } + ], + "data": {}, + "nodeType": "table" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Editor > Microsoft Word (.docx) deserialization > paragraphs with formattings #0`] = +{ + "content": [ + { + "content": [ + { + "data": {}, + "marks": [ + { + "type": "bold" + } + ], + "nodeType": "text", + "value": "What is Lorem Ipsum?" + } + ], + "data": {}, + "nodeType": "paragraph" + }, + { + "content": [ + { + "data": {}, + "marks": [ + { + "type": "bold" + } + ], + "nodeType": "text", + "value": "Lorem Ipsum" + }, + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. " + }, + { + "data": {}, + "marks": [ + { + "type": "italic" + } + ], + "nodeType": "text", + "value": "It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum" + }, + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " " + }, + { + "data": {}, + "marks": [ + { + "type": "bold" + } + ], + "nodeType": "text", + "value": "passages, and more recently with desktop publishing software like Aldus PageMaker including " + }, + { + "data": {}, + "marks": [ + { + "type": "underline" + } + ], + "nodeType": "text", + "value": "versions of Lorem Ipsum." + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Editor > Microsoft Word (.docx) deserialization > text and tables from ms word online #0`] = +{ + "content": [ + { + "content": [ + { + "data": {}, + "marks": [ + { + "type": "bold" + } + ], + "nodeType": "text", + "value": "What is Lorem Ipsum?" + }, + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " " + } + ], + "data": {}, + "nodeType": "paragraph" + }, + { + "content": [ + { + "data": {}, + "marks": [ + { + "type": "bold" + } + ], + "nodeType": "text", + "value": "Lorem Ipsum" + }, + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. " + }, + { + "data": {}, + "marks": [ + { + "type": "italic" + } + ], + "nodeType": "text", + "value": "It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum" + }, + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " " + }, + { + "data": {}, + "marks": [ + { + "type": "bold" + } + ], + "nodeType": "text", + "value": "passages, and more recently with desktop publishing software like Aldus PageMaker including " + }, + { + "data": {}, + "marks": [ + { + "type": "underline" + } + ], + "nodeType": "text", + "value": "versions of Lorem Ipsum." + }, + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " " + } + ], + "data": {}, + "nodeType": "paragraph" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " " + } + ], + "data": {}, + "nodeType": "paragraph" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " " + } + ], + "data": {}, + "nodeType": "paragraph" + }, + { + "content": [ + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "1 " + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "2 " + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "3 " + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "4 " + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "5 " + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "6 " + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "7 " + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "8 " + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + } + ], + "data": {}, + "nodeType": "table-row" + }, + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "a " + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "b " + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "c " + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "d " + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "e " + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "f " + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "g " + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "h " + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + } + ], + "data": {}, + "nodeType": "table-row" + }, + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "a1 " + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " " + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "c3 " + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " " + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "e5 " + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " " + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "e7 " + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "h8 " + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + } + ], + "data": {}, + "nodeType": "table-row" + }, + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " " + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " " + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " " + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " " + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " " + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " " + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " " + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " " + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + } + ], + "data": {}, + "nodeType": "table-row" + }, + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " " + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " " + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " " + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " " + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " " + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " " + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " " + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " " + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + } + ], + "data": {}, + "nodeType": "table-row" + } + ], + "data": {}, + "nodeType": "table" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " " + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; diff --git a/cypress/e2e/rich-text/__snapshots__/RichTextEditor.spec.ts.snap b/cypress/e2e/rich-text/__snapshots__/RichTextEditor.spec.ts.snap new file mode 100644 index 000000000..d534b3672 --- /dev/null +++ b/cypress/e2e/rich-text/__snapshots__/RichTextEditor.spec.ts.snap @@ -0,0 +1,1023 @@ +exports[`Rich Text Editor > Quote > using the toolbar > should preserve marks & inline elements #0`] = +{ + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [ + { + "type": "bold" + } + ], + "nodeType": "text", + "value": "bold" + }, + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " " + }, + { + "data": {}, + "marks": [ + { + "type": "italic" + } + ], + "nodeType": "text", + "value": "italic" + }, + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " " + }, + { + "data": {}, + "marks": [ + { + "type": "underline" + } + ], + "nodeType": "text", + "value": "underline" + }, + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " " + }, + { + "data": {}, + "marks": [ + { + "type": "code" + } + ], + "nodeType": "text", + "value": "code" + }, + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " " + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "link" + } + ], + "data": { + "uri": "https://example.com" + }, + "nodeType": "hyperlink" + }, + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " " + }, + { + "content": [], + "data": { + "target": { + "sys": { + "id": "example-entity-id", + "linkType": "Entry", + "type": "Link" + } + } + }, + "nodeType": "embedded-entry-inline" + }, + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " more text" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "blockquote" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Editor > Quote > using hotkey (mod+shift+1) > should preserve marks & inline elements #0`] = +{ + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [ + { + "type": "bold" + } + ], + "nodeType": "text", + "value": "bold" + }, + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " " + }, + { + "data": {}, + "marks": [ + { + "type": "italic" + } + ], + "nodeType": "text", + "value": "italic" + }, + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " " + }, + { + "data": {}, + "marks": [ + { + "type": "underline" + } + ], + "nodeType": "text", + "value": "underline" + }, + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " " + }, + { + "data": {}, + "marks": [ + { + "type": "code" + } + ], + "nodeType": "text", + "value": "code" + }, + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " " + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "link" + } + ], + "data": { + "uri": "https://example.com" + }, + "nodeType": "hyperlink" + }, + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " " + }, + { + "content": [], + "data": { + "target": { + "sys": { + "id": "example-entity-id", + "linkType": "Entry", + "type": "Link" + } + } + }, + "nodeType": "embedded-entry-inline" + }, + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": " more text" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "blockquote" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Editor > New Line > in a list > should add a new line after entity block in same list item #0`] = +{ + "content": [ + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "some text 1" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "list-item" + }, + { + "content": [ + { + "content": [], + "data": { + "target": { + "sys": { + "id": "example-entity-id", + "linkType": "Entry", + "type": "Link" + } + } + }, + "nodeType": "embedded-entry-block" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "some more text" + } + ], + "data": {}, + "nodeType": "paragraph" + }, + { + "content": [], + "data": { + "target": { + "sys": { + "id": "example-entity-id", + "linkType": "Entry", + "type": "Link" + } + } + }, + "nodeType": "embedded-entry-block" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "list-item" + } + ], + "data": {}, + "nodeType": "unordered-list" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Editor > invalid document structure > runs initial normalization without triggering a value change #0`] = +{ + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "This is a hyperlink" + } + ], + "data": { + "uri": "https://exmaple.com" + }, + "nodeType": "hyperlink" + }, + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "heading-1" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "This is a paragraph" + } + ], + "data": {}, + "nodeType": "paragraph" + }, + { + "content": [ + { + "data": {}, + "marks": [ + { + "type": "bold" + }, + { + "type": "superscript" + }, + { + "type": "banana" + } + ], + "nodeType": "text", + "value": "Text with custom marks" + } + ], + "data": {}, + "nodeType": "paragraph" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "blockquote" + }, + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "list-item" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "paragraph inside list item" + } + ], + "data": {}, + "nodeType": "paragraph" + }, + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "paragraph inside a nested list" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "list-item" + } + ], + "data": {}, + "nodeType": "unordered-list" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "blockquote inside list item" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "blockquote" + } + ], + "data": {}, + "nodeType": "list-item" + } + ], + "data": {}, + "nodeType": "unordered-list" + }, + { + "content": [ + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + } + ], + "data": {}, + "nodeType": "table-row" + } + ], + "data": {}, + "nodeType": "table" + }, + { + "content": [ + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "cell #1" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + } + ], + "data": {}, + "nodeType": "table-row" + }, + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "cell #2" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "cell #3" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "cell #4" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + } + ], + "data": {}, + "nodeType": "table-row" + }, + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "cell #5" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "cell #6" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "table-cell" + } + ], + "data": {}, + "nodeType": "table-row" + } + ], + "data": {}, + "nodeType": "table" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "end" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Editor > Quote > using the toolbar > should toggle off empty quotes on backspace #0`] = +{ + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Editor > Quote > using hotkey (mod+shift+1) > should toggle off empty quotes on backspace #0`] = +{ + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Editor > Commands > Palette > should embed inline #0`] = +{ + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + }, + { + "content": [], + "data": { + "target": { + "sys": { + "id": "exampleCT", + "linkType": "Entry", + "type": "Link" + } + } + }, + "nodeType": "embedded-entry-inline" + }, + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Editor > Commands > Palette > should embed asset #0`] = +{ + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + }, + { + "content": [], + "data": { + "target": { + "sys": { + "id": "published_asset", + "linkType": "Asset", + "type": "Link" + } + } + }, + "nodeType": "embedded-asset-block" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Editor > Commands > Palette > should select next item on down arrow press #0`] = +{ + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + }, + { + "content": [], + "data": { + "target": { + "sys": { + "id": "exampleCT", + "linkType": "Entry", + "type": "Link" + } + } + }, + "nodeType": "embedded-entry-inline" + }, + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Editor > Commands > Palette > should not delete adjacent text #0`] = +{ + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "test" + }, + { + "content": [], + "data": { + "target": { + "sys": { + "id": "exampleCT", + "linkType": "Entry", + "type": "Link" + } + } + }, + "nodeType": "embedded-entry-inline" + }, + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; diff --git a/cypress/e2e/rich-text/__snapshots__/RichTextEscapeInlines.spec.ts.snap b/cypress/e2e/rich-text/__snapshots__/RichTextEscapeInlines.spec.ts.snap new file mode 100644 index 000000000..170f62933 --- /dev/null +++ b/cypress/e2e/rich-text/__snapshots__/RichTextEscapeInlines.spec.ts.snap @@ -0,0 +1,39 @@ +exports[`Rich Text Lists > escapes hyperlink when typing at the end #0`] = +{ + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "link" + } + ], + "data": { + "uri": "https://example.com" + }, + "nodeType": "hyperlink" + }, + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "outside the link" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; diff --git a/cypress/e2e/rich-text/__snapshots__/RichTextLists.spec.ts.snap b/cypress/e2e/rich-text/__snapshots__/RichTextLists.spec.ts.snap new file mode 100644 index 000000000..ed07d5740 --- /dev/null +++ b/cypress/e2e/rich-text/__snapshots__/RichTextLists.spec.ts.snap @@ -0,0 +1,785 @@ +exports[`Rich Text Lists > Unordered List (UL) > should preserve current marks when inserting a new li #0`] = +{ + "content": [ + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [ + { + "type": "bold" + } + ], + "nodeType": "text", + "value": "bold " + }, + { + "data": {}, + "marks": [ + { + "type": "italic" + } + ], + "nodeType": "text", + "value": "italic" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "list-item" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [ + { + "type": "italic" + } + ], + "nodeType": "text", + "value": "more italic text" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "list-item" + } + ], + "data": {}, + "nodeType": "unordered-list" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Lists > Ordered List (OL) > should preserve current marks when inserting a new li #0`] = +{ + "content": [ + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [ + { + "type": "bold" + } + ], + "nodeType": "text", + "value": "bold " + }, + { + "data": {}, + "marks": [ + { + "type": "italic" + } + ], + "nodeType": "text", + "value": "italic" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "list-item" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [ + { + "type": "italic" + } + ], + "nodeType": "text", + "value": "more italic text" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "list-item" + } + ], + "data": {}, + "nodeType": "ordered-list" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Lists > Unordered List (UL) > should move nested list items when parent is invalid #0`] = +{ + "content": [ + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "1" + } + ], + "data": {}, + "nodeType": "paragraph" + }, + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "2" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "list-item" + } + ], + "data": {}, + "nodeType": "unordered-list" + } + ], + "data": {}, + "nodeType": "list-item" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "4" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "list-item" + } + ], + "data": {}, + "nodeType": "unordered-list" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Lists > Ordered List (OL) > should move nested list items when parent is invalid #0`] = +{ + "content": [ + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "1" + } + ], + "data": {}, + "nodeType": "paragraph" + }, + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "2" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "list-item" + } + ], + "data": {}, + "nodeType": "ordered-list" + } + ], + "data": {}, + "nodeType": "list-item" + }, + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "4" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "list-item" + } + ], + "data": {}, + "nodeType": "ordered-list" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Lists > does not remove entity cards when toggling off a list #0`] = +{ + "content": [ + { + "content": [], + "data": { + "target": { + "sys": { + "id": "example-entity-id", + "linkType": "Entry", + "type": "Link" + } + } + }, + "nodeType": "embedded-entry-block" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Lists > Unordered List (UL) > backspace at the start of li should reset the item #0`] = +{ + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "abc" + } + ], + "data": {}, + "nodeType": "paragraph" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Lists > Ordered List (OL) > backspace at the start of li should reset the item #0`] = +{ + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "abc" + } + ], + "data": {}, + "nodeType": "paragraph" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Lists > Unordered List (UL) > backspace on empty li at the beginning of doc should work #0`] = +{ + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Lists > Ordered List (OL) > backspace on empty li at the beginning of doc should work #0`] = +{ + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Lists > Unordered List (UL) > switching off the list > it raises the non-first list item entirely #0`] = +{ + "content": [ + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "A paragraph" + } + ], + "data": {}, + "nodeType": "paragraph" + }, + { + "content": [], + "data": { + "target": { + "sys": { + "id": "example-entity-id", + "linkType": "Entry", + "type": "Link" + } + } + }, + "nodeType": "embedded-entry-block" + } + ], + "data": {}, + "nodeType": "list-item" + } + ], + "data": {}, + "nodeType": "unordered-list" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Another paragraph" + } + ], + "data": {}, + "nodeType": "paragraph" + }, + { + "content": [], + "data": { + "target": { + "sys": { + "id": "example-entity-id", + "linkType": "Entry", + "type": "Link" + } + } + }, + "nodeType": "embedded-entry-block" + }, + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Another paragraph again" + } + ], + "data": {}, + "nodeType": "paragraph" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "list-item" + } + ], + "data": {}, + "nodeType": "unordered-list" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Lists > Ordered List (OL) > switching off the list > it raises the list item entirely #0`] = +{ + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "A paragraph" + } + ], + "data": {}, + "nodeType": "paragraph" + }, + { + "content": [], + "data": { + "target": { + "sys": { + "id": "example-entity-id", + "linkType": "Entry", + "type": "Link" + } + } + }, + "nodeType": "embedded-entry-block" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; + +exports[`Rich Text Lists > Ordered List (OL) > switching off the list > it raises the non-first list item entirely #0`] = +{ + "content": [ + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "A paragraph" + } + ], + "data": {}, + "nodeType": "paragraph" + }, + { + "content": [], + "data": { + "target": { + "sys": { + "id": "example-entity-id", + "linkType": "Entry", + "type": "Link" + } + } + }, + "nodeType": "embedded-entry-block" + } + ], + "data": {}, + "nodeType": "list-item" + } + ], + "data": {}, + "nodeType": "ordered-list" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Another paragraph" + } + ], + "data": {}, + "nodeType": "paragraph" + }, + { + "content": [], + "data": { + "target": { + "sys": { + "id": "example-entity-id", + "linkType": "Entry", + "type": "Link" + } + } + }, + "nodeType": "embedded-entry-block" + }, + { + "content": [ + { + "content": [ + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "Another paragraph again" + } + ], + "data": {}, + "nodeType": "paragraph" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "list-item" + } + ], + "data": {}, + "nodeType": "ordered-list" + }, + { + "content": [ + { + "data": {}, + "marks": [], + "nodeType": "text", + "value": "" + } + ], + "data": {}, + "nodeType": "paragraph" + } + ], + "data": {}, + "nodeType": "document" +}; diff --git a/cypress/e2e/rich-text/document-mocks/documentWithLinks.js b/cypress/e2e/rich-text/document-mocks/documentWithLinks.js new file mode 100644 index 000000000..05a625f8a --- /dev/null +++ b/cypress/e2e/rich-text/document-mocks/documentWithLinks.js @@ -0,0 +1,139 @@ +export default { + nodeType: 'document', + data: {}, + content: [ + { + nodeType: 'embedded-entry-block', + data: { + target: { + sys: { + id: 'example-entity-id', + type: 'Link', + linkType: 'Entry', + }, + }, + }, + content: [], + }, + { + nodeType: 'paragraph', + data: {}, + content: [ + { + nodeType: 'text', + value: '', + marks: [], + data: {}, + }, + { + nodeType: 'embedded-entry-inline', + data: { + target: { + sys: { + id: 'example-entity-id', + type: 'Link', + linkType: 'Entry', + }, + }, + }, + content: [], + }, + { + nodeType: 'text', + value: '', + marks: [], + data: {}, + }, + ], + }, + { + nodeType: 'embedded-asset-block', + data: { + target: { + sys: { + id: 'example-entity-id', + type: 'Link', + linkType: 'Asset', + }, + }, + }, + content: [], + }, + { + nodeType: 'paragraph', + data: {}, + content: [ + { + nodeType: 'text', + value: '', + marks: [], + data: {}, + }, + { + nodeType: 'asset-hyperlink', + data: { + target: { + sys: { + id: 'example-entity-id', + type: 'Link', + linkType: 'Asset', + }, + }, + }, + content: [ + { + nodeType: 'text', + value: 'an asset', + marks: [], + data: {}, + }, + ], + }, + { + nodeType: 'text', + value: '', + marks: [], + data: {}, + }, + ], + }, + { + nodeType: 'paragraph', + data: {}, + content: [ + { + nodeType: 'text', + value: '', + marks: [], + data: {}, + }, + { + nodeType: 'entry-hyperlink', + data: { + target: { + sys: { + id: 'example-entity-id', + type: 'Link', + linkType: 'Entry', + }, + }, + }, + content: [ + { + nodeType: 'text', + value: 'an entry', + marks: [], + data: {}, + }, + ], + }, + { + nodeType: 'text', + value: '', + marks: [], + data: {}, + }, + ], + }, + ], +}; diff --git a/cypress/e2e/rich-text/document-mocks/invalidDocumentNormalizable.js b/cypress/e2e/rich-text/document-mocks/invalidDocumentNormalizable.js new file mode 100644 index 000000000..7b0c16f88 --- /dev/null +++ b/cypress/e2e/rich-text/document-mocks/invalidDocumentNormalizable.js @@ -0,0 +1,410 @@ +// This document contains errors that must be solved with normalization: +export default { + nodeType: 'document', + data: {}, + content: [ + { + nodeType: 'paragraph', + data: {}, + content: [ + { + nodeType: 'text', + value: '', + marks: [], + data: {}, + }, + + // hyperlink with an empty text node + { + nodeType: 'hyperlink', + data: { + uri: 'https://exmaple.com', + }, + content: [ + { + nodeType: 'text', + value: '', + marks: [], + data: {}, + }, + ], + }, + + // Hyperlink without a text node + { + nodeType: 'hyperlink', + data: { + uri: 'https://exmaple.com', + }, + content: [], + }, + + // Hyperlink with multiple text nodes without marks + { + nodeType: 'hyperlink', + data: { + uri: 'https://exmaple.com', + }, + content: [ + { + nodeType: 'text', + value: 'This ', + marks: [], + data: {}, + }, + { + nodeType: 'text', + value: 'is a ', + marks: [], + data: {}, + }, + { + nodeType: 'text', + value: 'hyperlink', + marks: [], + data: {}, + }, + ], + }, + + { + nodeType: 'text', + value: '', + marks: [], + data: {}, + }, + ], + }, + + // paragraphs/headings without text nodes + { + nodeType: 'paragraph', + data: {}, + content: [], + }, + { + nodeType: 'heading-1', + data: {}, + content: [], + }, + + // Paragraph with multiple text nodes without marks + { + nodeType: 'paragraph', + data: {}, + content: [ + { + nodeType: 'text', + value: 'This is ', + marks: [], + data: {}, + }, + { + nodeType: 'text', + value: '', + marks: [], + data: {}, + }, + { + nodeType: 'text', + value: 'a paragraph', + marks: [], + data: {}, + }, + ], + }, + + // Custom marks + { + nodeType: 'paragraph', + data: {}, + content: [ + { + nodeType: 'text', + value: 'Text with custom marks', + marks: [ + { + type: 'bold', + }, + { + type: 'superscript', + }, + { + type: 'banana', + }, + ], + data: {}, + }, + ], + }, + + // blockquote without paragraphs + { + nodeType: 'blockquote', + data: {}, + content: [], + }, + + // ol/ul with empty array of list items + { + nodeType: 'unordered-list', + data: {}, + content: [], + }, + { + nodeType: 'ordered-list', + data: {}, + content: [], + }, + + // list items with empty array of child nodes + { + nodeType: 'unordered-list', + data: {}, + content: [ + { + nodeType: 'list-item', + data: {}, + content: [], + }, + ], + }, + + // list > li with the following children: + // 1. p + // 2. ul + // 3. blockquote + { + nodeType: 'unordered-list', + data: {}, + content: [ + { + nodeType: 'list-item', + data: {}, + content: [ + { + nodeType: 'paragraph', + data: {}, + content: [ + { + nodeType: 'text', + value: 'paragraph inside list item', + marks: [], + data: {}, + }, + ], + }, + + { + nodeType: 'unordered-list', + data: {}, + content: [ + { + nodeType: 'list-item', + data: {}, + content: [ + { + nodeType: 'paragraph', + data: {}, + content: [ + { + nodeType: 'text', + value: 'paragraph inside a nested list', + marks: [], + data: {}, + }, + ], + }, + ], + }, + ], + }, + + { + nodeType: 'blockquote', + data: {}, + content: [ + { + nodeType: 'paragraph', + data: {}, + content: [ + { + nodeType: 'text', + value: 'blockquote inside list item', + marks: [], + data: {}, + }, + ], + }, + ], + }, + ], + }, + ], + }, + + // Table cell with paragraph without text node + { + nodeType: 'table', + data: {}, + content: [ + { + nodeType: 'table-row', + data: {}, + content: [ + { + nodeType: 'table-cell', + data: {}, + content: [ + { + nodeType: 'paragraph', + data: {}, + content: [], + }, + ], + }, + ], + }, + ], + }, + + // Table with variable size rows + { + nodeType: 'table', + data: {}, + content: [ + // 1 cell in a row + { + nodeType: 'table-row', + data: {}, + content: [ + { + nodeType: 'table-cell', + data: {}, + content: [ + { + nodeType: 'paragraph', + data: {}, + content: [ + { + nodeType: 'text', + value: 'cell #1', + marks: [], + data: {}, + }, + ], + }, + ], + }, + ], + }, + + // 3 cells in a row + { + nodeType: 'table-row', + data: {}, + content: [ + { + nodeType: 'table-cell', + data: {}, + content: [ + { + nodeType: 'paragraph', + data: {}, + content: [ + { + nodeType: 'text', + value: 'cell #2', + marks: [], + data: {}, + }, + ], + }, + ], + }, + { + nodeType: 'table-cell', + data: {}, + content: [ + { + nodeType: 'paragraph', + data: {}, + content: [ + { + nodeType: 'text', + value: 'cell #3', + marks: [], + data: {}, + }, + ], + }, + ], + }, + { + nodeType: 'table-cell', + data: {}, + content: [ + { + nodeType: 'paragraph', + data: {}, + content: [ + { + nodeType: 'text', + value: 'cell #4', + marks: [], + data: {}, + }, + ], + }, + ], + }, + ], + }, + + // 2 cells in a row + { + nodeType: 'table-row', + data: {}, + content: [ + { + nodeType: 'table-cell', + data: {}, + content: [ + { + nodeType: 'paragraph', + data: {}, + content: [ + { + nodeType: 'text', + value: 'cell #5', + marks: [], + data: {}, + }, + ], + }, + ], + }, + { + nodeType: 'table-cell', + data: {}, + content: [ + { + nodeType: 'paragraph', + data: {}, + content: [ + { + nodeType: 'text', + value: 'cell #6', + marks: [], + data: {}, + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], +}; diff --git a/cypress/e2e/rich-text/fixtures/msWordOnline.js b/cypress/e2e/rich-text/fixtures/msWordOnline.js new file mode 100644 index 000000000..3e9d502c1 --- /dev/null +++ b/cypress/e2e/rich-text/fixtures/msWordOnline.js @@ -0,0 +1,3 @@ +/* eslint-disable */ +// prettier-ignore +export default `

    What is Lorem Ipsum? 

    Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum. 

     

     

    1 

    2 

    3 

    4 

    5 

    6 

    7 

    8 

    a 

    b 

    c 

    d 

    e 

    f 

    g 

    h 

    a1 

     

    c3 

     

    e5 

     

    e7 

    h8 

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

    ` diff --git a/cypress/e2e/rich-text/getting-clipboard-data.gif b/cypress/e2e/rich-text/getting-clipboard-data.gif new file mode 100644 index 000000000..afe3dd740 Binary files /dev/null and b/cypress/e2e/rich-text/getting-clipboard-data.gif differ diff --git a/cypress/e2e/rich-text/pasting-into-test.gif b/cypress/e2e/rich-text/pasting-into-test.gif new file mode 100644 index 000000000..b54512ae6 Binary files /dev/null and b/cypress/e2e/rich-text/pasting-into-test.gif differ diff --git a/cypress/plugins/index.ts b/cypress/plugins/index.ts new file mode 100644 index 000000000..14a7db9a7 --- /dev/null +++ b/cypress/plugins/index.ts @@ -0,0 +1,19 @@ +import webpack from '@cypress/webpack-preprocessor'; +import { register } from '@cypress/snapshot'; +import path from 'path'; + +const webpackFilename = path.join(__dirname, 'webpack.config.js'); + +export const plugin = (on: Cypress.PluginEvents, config: Cypress.PluginConfigOptions) => { + if (config.testingType === 'e2e') { + on('file:preprocessor', webpack({ webpackOptions: require(webpackFilename) })); + + register(); + } + + if (config.testingType === 'component') { + require('@cypress/react/plugins/load-webpack')(on, config, { webpackFilename }); + } + + return config; +}; diff --git a/cypress/support/e2e.js b/cypress/support/e2e.js new file mode 100644 index 000000000..44fc6abfe --- /dev/null +++ b/cypress/support/e2e.js @@ -0,0 +1,21 @@ +// *********************************************************** +// This example support/index.js is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** +require('cypress-plugin-tab'); +import './commands'; + +if (Cypress.testingType === 'e2e') { + require('@cypress/snapshot'); + require('cypress-plugin-tab'); +}