From 12681394bad852e7530e68c2fba6853bcd92efb8 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Wed, 4 Dec 2024 16:04:17 +0100 Subject: [PATCH] Add info table content element REDMINE-20878 --- .../config/locales/new/info_table.de.yml | 50 + .../config/locales/new/info_table.en.yml | 50 + .../doc/creating_themes/custom_typography.md | 3 + .../scrolled/lib/pageflow_scrolled/plugin.rb | 1 + .../spec/frontend/EditableTable-spec.js | 149 +++ .../inlineEditing/EditableTable-spec.js | 161 +++ .../EditableTable/withFixedColumns-spec.js | 949 ++++++++++++++++++ .../package/src/contentElements/editor.js | 1 + .../package/src/contentElements/frontend.js | 1 + .../contentElements/infoTable/InfoTable.js | 30 + .../infoTable/InfoTable.module.css | 38 + .../src/contentElements/infoTable/editor.js | 27 + .../src/contentElements/infoTable/frontend.js | 6 + .../contentElements/infoTable/pictogram.svg | 3 + .../src/contentElements/infoTable/stories.js | 53 + .../package/src/frontend/EditableTable.js | 99 ++ .../package/src/frontend/EditableText.js | 2 +- .../scrolled/package/src/frontend/Text.js | 2 + .../package/src/frontend/Text.module.css | 13 + .../scrolled/package/src/frontend/index.js | 1 + .../inlineEditing/EditableTable/index.js | 120 +++ .../EditableTable/placeholders.js | 30 + .../EditableTable/placeholders.module.css | 13 + .../EditableTable/withFixedColumns.js | 302 ++++++ .../inlineEditing/EditableText/index.js | 17 + .../inlineEditing/EditableText/withLinks.js | 26 +- .../src/frontend/inlineEditing/components.js | 1 + 27 files changed, 2136 insertions(+), 12 deletions(-) create mode 100644 entry_types/scrolled/config/locales/new/info_table.de.yml create mode 100644 entry_types/scrolled/config/locales/new/info_table.en.yml create mode 100644 entry_types/scrolled/package/spec/frontend/EditableTable-spec.js create mode 100644 entry_types/scrolled/package/spec/frontend/inlineEditing/EditableTable-spec.js create mode 100644 entry_types/scrolled/package/spec/frontend/inlineEditing/EditableTable/withFixedColumns-spec.js create mode 100644 entry_types/scrolled/package/src/contentElements/infoTable/InfoTable.js create mode 100644 entry_types/scrolled/package/src/contentElements/infoTable/InfoTable.module.css create mode 100644 entry_types/scrolled/package/src/contentElements/infoTable/editor.js create mode 100644 entry_types/scrolled/package/src/contentElements/infoTable/frontend.js create mode 100644 entry_types/scrolled/package/src/contentElements/infoTable/pictogram.svg create mode 100644 entry_types/scrolled/package/src/contentElements/infoTable/stories.js create mode 100644 entry_types/scrolled/package/src/frontend/EditableTable.js create mode 100644 entry_types/scrolled/package/src/frontend/inlineEditing/EditableTable/index.js create mode 100644 entry_types/scrolled/package/src/frontend/inlineEditing/EditableTable/placeholders.js create mode 100644 entry_types/scrolled/package/src/frontend/inlineEditing/EditableTable/placeholders.module.css create mode 100644 entry_types/scrolled/package/src/frontend/inlineEditing/EditableTable/withFixedColumns.js diff --git a/entry_types/scrolled/config/locales/new/info_table.de.yml b/entry_types/scrolled/config/locales/new/info_table.de.yml new file mode 100644 index 0000000000..0a35732123 --- /dev/null +++ b/entry_types/scrolled/config/locales/new/info_table.de.yml @@ -0,0 +1,50 @@ +de: + pageflow: + info_table_content_element: + feature_name: Info-Tabelle Element + pageflow_scrolled: + editor: + content_elements: + infoTable: + description: Zweispaltige Tabelle mit Name/Wert-Paaren. + name: Info-Tabelle + tabs: + general: Info-Tabelle + help_texts: + shortcuts: |- +
+
+ + Enter + + + Füge eine neue Tabellenzeile hinzu. + +
+
+ Tabellenzeile einfügen +
+
+ + Shift + Enter + + + Füge eine neue Zeile innerhalb einer Tabellenzelle ein. + +
+
+ Zeilenumbruch einfügen +
+
+ + Alt + Enter + + + Füge potentielle Zeilenumbruchspunkte in lange Worte ein, + um Silbentrennung zu ermöglichen. + +
+
+ Bedingten Trennstrich einfügen +
+
diff --git a/entry_types/scrolled/config/locales/new/info_table.en.yml b/entry_types/scrolled/config/locales/new/info_table.en.yml new file mode 100644 index 0000000000..6a7d805c13 --- /dev/null +++ b/entry_types/scrolled/config/locales/new/info_table.en.yml @@ -0,0 +1,50 @@ +en: + pageflow: + info_table_content_element: + feature_name: Info table element + pageflow_scrolled: + editor: + content_elements: + infoTable: + description: Two-column table for label value pairs. + name: Info Table + tabs: + general: Info Table + help_texts: + shortcuts: |- +
+
+ + Enter + + + Add a new row to the table.. + +
+
+ Insert table row +
+
+ + Shift + Enter + + + Insert a new line without beginning a new paragraph. + +
+
+ Insert soft break/new line +
+
+ + Alt + Enter + + + Specify potential line break points within long words + to control hyphenation. + +
+
+ Insert soft hyphen +
+
diff --git a/entry_types/scrolled/doc/creating_themes/custom_typography.md b/entry_types/scrolled/doc/creating_themes/custom_typography.md index 1b0b6297bd..a67a61d134 100644 --- a/entry_types/scrolled/doc/creating_themes/custom_typography.md +++ b/entry_types/scrolled/doc/creating_themes/custom_typography.md @@ -88,6 +88,9 @@ The following rule names are supported: | `hotspot_tooltip_title` | Applies to the title in hotspot tooltips. | | `hotspot_tooltip_description` | Applies to the description text in hotspot tooltips. | | `hotspot_tooltip_link` | Applies to link buttons in hotspot tooltips. | +| `info_table_label` | Applies to first column of info tables. | +| `info_table_value` | Applies to second column of info tables. | + ### Responsive Breakpoints diff --git a/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb b/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb index 908cd0a20d..4f333d2a56 100644 --- a/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb +++ b/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb @@ -41,6 +41,7 @@ def configure(config) c.features.register('scrolled_entry_fragment_caching') c.features.register('backdrop_content_elements') c.features.register('external_links_options') + c.features.register('info_table_content_element') c.additional_frontend_seed_data.register( 'frontendVersion', diff --git a/entry_types/scrolled/package/spec/frontend/EditableTable-spec.js b/entry_types/scrolled/package/spec/frontend/EditableTable-spec.js new file mode 100644 index 0000000000..04f8cc8528 --- /dev/null +++ b/entry_types/scrolled/package/spec/frontend/EditableTable-spec.js @@ -0,0 +1,149 @@ +import React from 'react'; + +import {EditableTable} from 'frontend'; + +import {render, screen} from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect' + +describe('EditableTable', () => { + it('renders class name on table', () => { + render(); + + expect(screen.getByRole('table')).toHaveClass('some-class'); + }); + + it('renders table with label and value typography classes on cells', () => { + const value = [ + { + type: 'row', + children: [ + { + type: 'label', + children: [ + {text: 'Name'} + ] + }, + { + type: 'value', + children: [ + {text: 'Jane'} + ] + } + ] + } + ]; + + render(); + + expect( + screen.getByRole('cell', {name: 'Name'}).querySelector('.typography-infoTableLabel') + ).toBeInTheDocument(); + expect( + screen.getByRole('cell', {name: 'Jane'}).querySelector('.typography-infoTableValue') + ).toBeInTheDocument(); + }); + + it('renders links', () => { + const value = [{ + type: 'row', + children: [ + { + type: 'label', + children: [ + {text: ''} + ] + }, + { + type: 'value', + children: [ + {text: 'Find more '}, + { + type: 'link', + href: 'https://example.com', + children: [ + {text: 'here'} + ] + }, + {text: '.'} + ] + } + ] + }]; + + const {getByRole} = render(); + + expect(getByRole('link')).toHaveTextContent('here') + expect(getByRole('link')).toHaveAttribute('href', 'https://example.com') + expect(getByRole('link')).toHaveClass('typography-contentLink') + expect(getByRole('link')).not.toHaveAttribute('target') + expect(getByRole('link')).not.toHaveAttribute('rel') + }); + + it('supports rendering links with target blank', () => { + const value = [{ + type: 'row', + children: [ + { + type: 'label', + children: [ + {text: ''} + ] + }, + { + type: 'value', + children: [ + {text: 'Find more '}, + { + type: 'link', + href: 'https://example.com', + openInNewTab: true, + children: [ + {text: 'here'} + ] + }, + {text: '.'} + ] + } + ] + }]; + + const {getByRole} = render(); + + expect(getByRole('link')).toHaveTextContent('here') + expect(getByRole('link')).toHaveAttribute('target', '_blank') + expect(getByRole('link')).toHaveAttribute('rel', 'noopener noreferrer') + }); + + it('supports text formatting', () => { + const value = [{ + type: 'row', + children: [ + { + type: 'label', + children: [ + {text: ''} + ] + }, + { + type: 'value', + children: [ + {text: 'x', bold: true}, + {text: '3', sup: true}, + {text: ' and '}, + {text: 'CO'}, + {text: '2', sub: true} + ] + } + ] + }]; + + const {container} = render(); + + expect(container.querySelector('strong')).toHaveTextContent('x') + expect(container.querySelector('sup')).toHaveTextContent('3') + expect(container.querySelector('sub')).toHaveTextContent('2') + }); +}); diff --git a/entry_types/scrolled/package/spec/frontend/inlineEditing/EditableTable-spec.js b/entry_types/scrolled/package/spec/frontend/inlineEditing/EditableTable-spec.js new file mode 100644 index 0000000000..27ae215c62 --- /dev/null +++ b/entry_types/scrolled/package/spec/frontend/inlineEditing/EditableTable-spec.js @@ -0,0 +1,161 @@ +import React from 'react'; + +import {EditableTable} from 'frontend'; +import {loadInlineEditingComponents} from 'frontend/inlineEditing'; + +import {render, screen} from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect' + +describe('EditableText', () => { + beforeAll(loadInlineEditingComponents); + + it('renders class name on table', () => { + render(); + + expect(screen.getByRole('table')).toHaveClass('some-class'); + }); + + it('renders table with label and value typography classes on cells', () => { + const value = [ + { + type: 'row', + children: [ + { + type: 'label', + children: [ + {text: 'Name'} + ] + }, + { + type: 'value', + children: [ + {text: 'Jane'} + ] + } + ] + } + ]; + + render(); + + expect( + screen.getByRole('cell', {name: 'Name'}).querySelector('.typography-infoTableLabel') + ).toBeInTheDocument(); + expect( + screen.getByRole('cell', {name: 'Jane'}).querySelector('.typography-infoTableValue') + ).toBeInTheDocument(); + }); + + it('renders placeholder for empty table with single row', () => { + const value = [ + { + type: 'row', + children: [ + { + type: 'label', + children: [ + {text: ''} + ] + }, + { + type: 'value', + children: [ + {text: ''} + ] + } + ] + } + ]; + + render(); + + expect( + screen.getAllByRole('cell')[0].querySelector('[contenteditable=false]') + ).toHaveAttribute('data-text', 'Enter Label'); + expect( + screen.getAllByRole('cell')[1].querySelector('[contenteditable=false]') + ).toHaveAttribute('data-text', 'Enter Value'); + }); + + it('does not render placeholders for table with multiple rows ', () => { + const value = [ + { + type: 'row', + children: [ + { + type: 'label', + children: [ + {text: ''} + ] + }, + { + type: 'value', + children: [ + {text: ''} + ] + } + ] + }, + { + type: 'row', + children: [ + { + type: 'label', + children: [ + {text: ''} + ] + }, + { + type: 'value', + children: [ + {text: ''} + ] + } + ] + } + ]; + + render(); + + expect( + screen.getAllByRole('cell')[0].querySelector('[contenteditable=false]') + ).toBeNull(); + }); + + it('does not render placeholder in non-empty cell', () => { + const value = [ + { + type: 'row', + children: [ + { + type: 'label', + children: [ + {text: 'Some text'} + ] + }, + { + type: 'value', + children: [ + {text: ''} + ] + } + ] + } + ]; + + render(); + + expect( + screen.getAllByRole('cell')[0].querySelector('[contenteditable=false]') + ).toBeNull(); + }); +}); diff --git a/entry_types/scrolled/package/spec/frontend/inlineEditing/EditableTable/withFixedColumns-spec.js b/entry_types/scrolled/package/spec/frontend/inlineEditing/EditableTable/withFixedColumns-spec.js new file mode 100644 index 0000000000..24a3b7600b --- /dev/null +++ b/entry_types/scrolled/package/spec/frontend/inlineEditing/EditableTable/withFixedColumns-spec.js @@ -0,0 +1,949 @@ +/** @jsx jsx */ +import { + handleTableNavigation, + withFixedColumns +} from 'frontend/inlineEditing/EditableTable/withFixedColumns'; + +import {createHyperscript} from 'slate-hyperscript'; + +export const h = createHyperscript({ + elements: { + row: {type: 'row'}, + label: {type: 'label'}, + value: {type: 'value'}, + inline: { inline: true } + }, +}); + +// Strip meta tags to make deep equality checks work +const jsx = (tagName, attributes, ...children) => { + delete attributes.__self; + delete attributes.__source; + return h(tagName, attributes, ...children); +} + +describe('withFixedColumns', () => { + describe('insertBreak', () => { + it('adds new row when inserting break', () => { + const editor = withFixedColumns( + + + + + Jane Doe + + + + ); + + editor.insertBreak(); + + expect(editor.children).toEqual(( + + + + + Jane Doe + + + + + + + + + ).children + ); + expect(editor.selection).toEqual({ + anchor: {path: [1, 0, 0], offset: 0}, + focus: {path: [1, 0, 0], offset: 0}, + }); + }); + + it('splits content of first cell when inserting break inside cell text', () => { + const editor = withFixedColumns( + + + + + Jane Doe + + + + ); + + editor.insertBreak(); + + expect(editor.children).toEqual(( + + + + + + + + + + + Jane Doe + + + ).children + ); + expect(editor.selection).toEqual({ + anchor: {path: [1, 0, 0], offset: 0}, + focus: {path: [1, 0, 0], offset: 0}, + }); + }); + + it('splits content of second cell when inserting break inside cell text', () => { + const editor = withFixedColumns( + + + + + Jane DoeJoe Shmoe + + + + ); + + editor.insertBreak(); + + expect(editor.children).toEqual(( + + + + + Jane Doe + + + + + + Joe Shmoe + + + ).children + ); + expect(editor.selection).toEqual({ + anchor: {path: [1, 1, 0], offset: 0}, + focus: {path: [1, 1, 0], offset: 0}, + }); + }); + }); + + describe('deleteBackwards', () => { + it('can delete backward in first column', () => { + const editor = withFixedColumns( + + + + + Jane Doe + + + + ); + + editor.deleteBackward(); + + expect(editor.children).toEqual(( + + + + + Jane Doe + + + ).children + ); + }); + + it('can delete backward in second column', () => { + const editor = withFixedColumns( + + + + + Jane Doe + + + + ); + + editor.deleteBackward(); + + expect(editor.children).toEqual(( + + + + + Jan Doe + + + ).children + ); + }); + + it('moves cursor to first column when deleting backward from beginning of second column', () => { + const editor = withFixedColumns( + + + + + Jane Doe + + + + ); + + editor.deleteBackward(); + + expect(editor.children).toEqual(( + + + + + Jane Doe + + + ).children + ); + expect(editor.selection).toEqual({ + anchor: {path: [0, 0, 0], offset: 4}, + focus: {path: [0, 0, 0], offset: 4}, + }); + }); + + it('moves cursor to end of previous row when deleting backwards from first cell', () => { + const editor = withFixedColumns( + + + + + B + + + + + + D + + + + ); + + editor.deleteBackward(); + + expect(editor.children).toEqual(( + + + + + B + + + + + + D + + + ).children + ); + expect(editor.selection).toEqual({ + anchor: {path: [0, 1, 0], offset: 1}, + focus: {path: [0, 1, 0], offset: 1}, + }); + }); + + it('removes empty previous row when deleting backwards from first cell', () => { + const editor = withFixedColumns( + + + + + + + + + + + B + + + + ); + + editor.deleteBackward(); + + expect(editor.children).toEqual(( + + + + + B + + + ).children + ); + expect(editor.selection).toEqual({ + anchor: {path: [0, 0, 0], offset: 0}, + focus: {path: [0, 0, 0], offset: 0}, + }); + }); + + it('removes empty row when deleting backward from first cell', () => { + const editor = withFixedColumns( + + + + + b + + + + + + + + + + + + B + + + + ); + + editor.deleteBackward(); + + expect(editor.children).toEqual(( + + + + + b + + + + + + B + + + ).children + ); + expect(editor.selection).toEqual({ + anchor: {path: [0, 1, 0], offset: 1}, + focus: {path: [0, 1, 0], offset: 1}, + }); + }); + + it('makes deleting backward from first cell no-op', () => { + const editor = withFixedColumns( + + + + + B + + + + ); + + editor.deleteBackward(); + + expect(editor.children).toEqual(( + + + + + B + + + ).children + ); + expect(editor.selection).toEqual({ + anchor: {path: [0, 0, 0], offset: 0}, + focus: {path: [0, 0, 0], offset: 0}, + }); + }); + }); + + describe('deleteForward', () => { + it('can delete forward in first column', () => { + const editor = withFixedColumns( + + + + + Jane Doe + + + + ); + + editor.deleteForward(); + + expect(editor.children).toEqual(( + + + + + Jane Doe + + + ).children + ); + }); + + it('can delete forward in at start of first column', () => { + const editor = withFixedColumns( + + + + + Jane Doe + + + + ); + + editor.deleteForward(); + + expect(editor.children).toEqual(( + + + + + Jane Doe + + + ).children + ); + }); + + it('can delete forward in second column', () => { + const editor = withFixedColumns( + + + + + Jane Doe + + + + ); + + editor.deleteForward(); + + expect(editor.children).toEqual(( + + + + + JaneDoe + + + ).children + ); + }); + + it('moves cursor to second column when deleting forward from end of first column', () => { + const editor = withFixedColumns( + + + + + Jane Doe + + + + ); + + editor.deleteForward(); + + expect(editor.children).toEqual(( + + + + + Jane Doe + + + ).children + ); + expect(editor.selection).toEqual({ + anchor: {path: [0, 1, 0], offset: 0}, + focus: {path: [0, 1, 0], offset: 0}, + }); + }); + + it('moves cursor to start of next row when deleting forward from second cell', () => { + const editor = withFixedColumns( + + + + + B + + + + + + D + + + + ); + + editor.deleteForward(); + + expect(editor.children).toEqual(( + + + + + B + + + + + + D + + + ).children + ); + expect(editor.selection).toEqual({ + anchor: {path: [1, 0, 0], offset: 0}, + focus: {path: [1, 0, 0], offset: 0}, + }); + }); + + it('removes empty next row when deleting forward from last cell', () => { + const editor = withFixedColumns( + + + + + B + + + + + + + + + + ); + + editor.deleteForward(); + + expect(editor.children).toEqual(( + + + + + B + + + ).children + ); + expect(editor.selection).toEqual({ + anchor: {path: [0, 1, 0], offset: 1}, + focus: {path: [0, 1, 0], offset: 1}, + }); + }); + + it('removes empty row when deleting forward from first cell', () => { + const editor = withFixedColumns( + + + + + b + + + + + + + + + + + + B + + + + ); + + editor.deleteForward(); + + expect(editor.children).toEqual(( + + + + + b + + + + + + B + + + ).children + ); + expect(editor.selection).toEqual({ + anchor: {path: [1, 0, 0], offset: 0}, + focus: {path: [1, 0, 0], offset: 0}, + }); + }); + + it('removes empty row when deleting forward from first cell of last line', () => { + const editor = withFixedColumns( + + + + + b + + + + + + + + + + ); + + editor.deleteForward(); + + expect(editor.children).toEqual(( + + + + + b + + + ).children + ); + expect(editor.selection).toEqual({ + anchor: {path: [0, 0, 0], offset: 0}, + focus: {path: [0, 0, 0], offset: 0}, + }); + }); + + it('makes deleting forward from first cell of last remaining line no-op', () => { + const editor = withFixedColumns( + + + + + + + + + ); + + editor.deleteForward(); + + expect(editor.children).toEqual(( + + + + + + + + ).children + ); + expect(editor.selection).toEqual({ + anchor: {path: [0, 0, 0], offset: 0}, + focus: {path: [0, 0, 0], offset: 0}, + }); + }); + + it('makes deleting forward from last cell no-op', () => { + const editor = withFixedColumns( + + + + + B + + + + ); + + editor.deleteForward(); + + expect(editor.children).toEqual(( + + + + + B + + + ).children + ); + expect(editor.selection).toEqual({ + anchor: {path: [0, 1, 0], offset: 1}, + focus: {path: [0, 1, 0], offset: 1}, + }); + }); + }); + + describe('deleteFragment', () => { + it('does not remove cells when deleting selection across cells', () => { + const editor = withFixedColumns( + + + + + Jane Doe + + + + ); + + editor.deleteFragment(); + + expect(editor.children).toEqual(( + + + + + Doe + + + ).children + ); + }); + + it('does not remove cells when deleting selection across rows', () => { + const editor = withFixedColumns( + + + + + Jane Doe + + + + + + B + + + + + + Foo Content + + + + ); + + editor.deleteFragment(); + + expect(editor.children).toEqual(( + + + + + Content + + + ).children + ); + }); + }); +}); + + describe('handleTableNavigation', () => { + it('moves the cursor to the cell above when pressing ArrowUp', () => { + const editor = withFixedColumns( + + + + Row 1, Col 2 + + + + + Row 2, Col 2 + + + + ); + + const event = new KeyboardEvent('keydown', {key: 'ArrowUp'}); + handleTableNavigation(editor, event); + + expect(editor.selection).toEqual({ + anchor: {path: [0, 1, 0], offset: 0}, + focus: {path: [0, 1, 0], offset: 0} + }); + }); + + it('moves the cursor to the cell below when pressing ArrowDown', () => { + const editor = withFixedColumns( + + + + + Row 1, Col 2 + + + + + Row 2, Col 2 + + + ); + + const event = new KeyboardEvent('keydown', {key: 'ArrowDown'}); + handleTableNavigation(editor, event); + + expect(editor.selection).toEqual({ + anchor: {path: [1, 1, 0], offset: 0}, + focus: {path: [1, 1, 0], offset: 0} + }); + }); +}); diff --git a/entry_types/scrolled/package/src/contentElements/editor.js b/entry_types/scrolled/package/src/contentElements/editor.js index ff58b04319..57fafefefd 100644 --- a/entry_types/scrolled/package/src/contentElements/editor.js +++ b/entry_types/scrolled/package/src/contentElements/editor.js @@ -17,3 +17,4 @@ import './tikTokEmbed/editor' import './question/editor' import './counter/editor' import './quote/editor' +import './infoTable/editor' diff --git a/entry_types/scrolled/package/src/contentElements/frontend.js b/entry_types/scrolled/package/src/contentElements/frontend.js index 019596e3c7..996fea57e8 100644 --- a/entry_types/scrolled/package/src/contentElements/frontend.js +++ b/entry_types/scrolled/package/src/contentElements/frontend.js @@ -15,3 +15,4 @@ import './question/frontend' import './counter/frontend' import './quote/frontend' import './imageGallery/frontend' +import './infoTable/frontend' diff --git a/entry_types/scrolled/package/src/contentElements/infoTable/InfoTable.js b/entry_types/scrolled/package/src/contentElements/infoTable/InfoTable.js new file mode 100644 index 0000000000..46c9cbdab5 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/infoTable/InfoTable.js @@ -0,0 +1,30 @@ +import React from 'react'; +import classNames from 'classnames'; + +import { + EditableTable, + useContentElementConfigurationUpdate, + useContentElementEditorState, + useI18n +} from 'pageflow-scrolled/frontend'; + +import styles from './InfoTable.module.css'; + +export function InfoTable({configuration, sectionProps}) { + const {isSelected} = useContentElementEditorState(); + const updateConfiguration = useContentElementConfigurationUpdate(); + + const {t} = useI18n({locale: 'ui'}); + + return ( + updateConfiguration({value})} /> + ); +} diff --git a/entry_types/scrolled/package/src/contentElements/infoTable/InfoTable.module.css b/entry_types/scrolled/package/src/contentElements/infoTable/InfoTable.module.css new file mode 100644 index 0000000000..6d7f754c3d --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/infoTable/InfoTable.module.css @@ -0,0 +1,38 @@ +.table { + width: 100%; + border-collapse: collapse; + border-spacing: 0; + --table-border-color: transparent; +} + +.table td { + min-width: 50px; + vertical-align: top; +} + +.table td:first-child { + border-right: solid 1px var(--table-border-color); + padding: 0.5rem 0.5rem 0.5rem 0; + white-space: pre; +} + +.table td:last-child { + width: 100%; + padding: 0.5rem 0 0.5rem 0.5rem; +} + +.center td { + width: 50%; +} + +.center td:first-child { + text-align: right; +} + +.table tr:nth-child(n + 2) td { + border-top: solid 1px var(--table-border-color); +} + +.selected { + --table-border-color: color-mix(in srgb, transparent, currentColor); +} diff --git a/entry_types/scrolled/package/src/contentElements/infoTable/editor.js b/entry_types/scrolled/package/src/contentElements/infoTable/editor.js new file mode 100644 index 0000000000..46ad506de4 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/infoTable/editor.js @@ -0,0 +1,27 @@ +import I18n from 'i18n-js'; +import {editor} from 'pageflow-scrolled/editor'; +import {InfoBoxView} from 'pageflow/editor'; +import {SeparatorView} from 'pageflow/ui' + +import pictogram from './pictogram.svg'; + +editor.contentElementTypes.register('infoTable', { + featureName: 'info_table_content_element', + pictogram, + supportedPositions: ['inline', 'sticky', 'standAlone', 'left', 'right'], + supportedWidthRange: ['s', 'xl'], + + configurationEditor({entry, contentElement}) { + this.tab('general', function() { + this.group('ContentElementPosition'); + + this.view(SeparatorView); + + this.view(InfoBoxView, { + text: I18n.t( + 'pageflow_scrolled.editor.content_elements.infoTable.help_texts.shortcuts' + ), + }); + }); + } +}); diff --git a/entry_types/scrolled/package/src/contentElements/infoTable/frontend.js b/entry_types/scrolled/package/src/contentElements/infoTable/frontend.js new file mode 100644 index 0000000000..1ded35c4a2 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/infoTable/frontend.js @@ -0,0 +1,6 @@ +import {frontend} from 'pageflow-scrolled/frontend'; +import {InfoTable} from './InfoTable'; + +frontend.contentElementTypes.register('infoTable', { + component: InfoTable +}); diff --git a/entry_types/scrolled/package/src/contentElements/infoTable/pictogram.svg b/entry_types/scrolled/package/src/contentElements/infoTable/pictogram.svg new file mode 100644 index 0000000000..76b2423718 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/infoTable/pictogram.svg @@ -0,0 +1,3 @@ + + + diff --git a/entry_types/scrolled/package/src/contentElements/infoTable/stories.js b/entry_types/scrolled/package/src/contentElements/infoTable/stories.js new file mode 100644 index 0000000000..9e12e09dd9 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/infoTable/stories.js @@ -0,0 +1,53 @@ +import '../frontend'; +import {storiesOfContentElement} from 'pageflow-scrolled/spec/support/stories'; + +storiesOfContentElement(module, { + typeName: 'infoTable', + baseConfiguration: { + value: [ + { + type: 'row', + children: [ + { + type: 'label', + children: [ + {text: 'Name'} + ] + }, + { + type: 'value', + children: [ + {text: 'Jane '}, + {text: 'Doe', italic: true} + ] + } + ] + }, + { + type: 'row', + children: [ + { + type: 'label', + children: [ + {text: 'Website'} + ] + }, + { + type: 'value', + children: [ + {text: 'Find more '}, + { + type: 'link', + href: 'https://example.com', + openInNewTab: true, + children: [ + {text: 'here'} + ] + } + ] + } + ] + } + ] + } +}); diff --git a/entry_types/scrolled/package/src/frontend/EditableTable.js b/entry_types/scrolled/package/src/frontend/EditableTable.js new file mode 100644 index 0000000000..39d50109be --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/EditableTable.js @@ -0,0 +1,99 @@ +import React from 'react'; +import classNames from 'classnames'; + +import {withInlineEditingAlternative} from './inlineEditing'; +import {Text} from './Text'; + +import { + renderLink, + renderLeaf +} from './EditableText'; + +const defaultValue = [{ + type: 'row', + children: [ + { + type: 'label', + children: [ + {text: ''} + ] + }, + { + type: 'value', + children: [ + {text: ''} + ] + } + ], +}]; + +export const EditableTable = withInlineEditingAlternative('EditableTable', function EditableTable({ + value, className, + labelScaleCategory = 'body', + valueScaleCategory = 'body' +}) { + return ( + + + {render(value || defaultValue, { + labelScaleCategory, + valueScaleCategory + })} + +
+ ); +}); + +function render(children, options) { + return children.map((element, index) => { + if (element.type) { + return createRenderElement(options)({ + attributes: {key: index}, + element, + children: render(element.children, options), + }); + } + else { + return renderLeaf({ + attributes: {key: index}, + leaf: element, + children: children.length === 1 && + element.text.trim() === '' ? '\uFEFF' : element.text + }); + } + }); +} + +export function createRenderElement({labelScaleCategory, valueScaleCategory}) { + return function renderElement({ + attributes, children, element + }) { + switch (element.type) { + case 'row': + return ( + + {children} + + ); + case 'link': + return renderLink({attributes, children, element}); + case 'label': + return ( + + + {children} + + + ); + default: + return ( + + + {children} + + + + ); + } + } +} diff --git a/entry_types/scrolled/package/src/frontend/EditableText.js b/entry_types/scrolled/package/src/frontend/EditableText.js index 683f1cb1f3..ad765b91a4 100644 --- a/entry_types/scrolled/package/src/frontend/EditableText.js +++ b/entry_types/scrolled/package/src/frontend/EditableText.js @@ -120,7 +120,7 @@ function Heading({attributes, variantClassName, styles: inlineStyles, children}) ); } -function renderLink({attributes, children, element}) { +export function renderLink({attributes, children, element}) { const {key, ...otherAttributes} = attributes; return ( diff --git a/entry_types/scrolled/package/src/frontend/Text.js b/entry_types/scrolled/package/src/frontend/Text.js index 1b6b127f0c..ecaf45734d 100644 --- a/entry_types/scrolled/package/src/frontend/Text.js +++ b/entry_types/scrolled/package/src/frontend/Text.js @@ -15,6 +15,7 @@ import styles from './Text.module.css'; * `'quoteText-lg'`, `'quoteText-md'`, `'quoteText-sm'`, `'quoteText-xs'`, `'quoteAttribution'`, * `'counterNumber-lg'`, `'counterNumber-md'`, `'counterNumber-sm'`, * `'counterNumber-xs'`, `'counterDescription`'. + * `'infoTableLabel'`, `'infoTableValue`'. * `'hotspotsTooltipTitle'`, `'hotspotsTooltipDescription`', `'hotspotsTooltipLink`'. * @param {string} [props.inline] - Render a span instread of a div. * @param {string} props.children - Nodes to render with specified typography. @@ -37,6 +38,7 @@ Text.propTypes = { 'hotspotsTooltipTitle', 'hotspotsTooltipDescription', 'hotspotsTooltipLink', 'counterNumber-lg', 'counterNumber-md', 'counterNumber-sm', 'counterNumber-xs', 'counterDescription', + 'infoTableLabel', 'infoTableValue', 'body', 'caption', 'question' ]), } diff --git a/entry_types/scrolled/package/src/frontend/Text.module.css b/entry_types/scrolled/package/src/frontend/Text.module.css index 7a1c15ab2d..746cdca2e3 100644 --- a/entry_types/scrolled/package/src/frontend/Text.module.css +++ b/entry_types/scrolled/package/src/frontend/Text.module.css @@ -207,6 +207,19 @@ font-weight: bold; } +.infoTableLabel { + composes: typography-infoTableLabel from global; + font-size: text-s; + line-height: 1.4; + font-weight: 600; +} + +.infoTableValue { + composes: typography-infoTableValue from global; + font-size: text-s; + line-height: 1.4; +} + @media (max-width: 600px) { .heading-lg { font-size: text-xl; diff --git a/entry_types/scrolled/package/src/frontend/index.js b/entry_types/scrolled/package/src/frontend/index.js index 01ff97a788..6ce57e8f86 100644 --- a/entry_types/scrolled/package/src/frontend/index.js +++ b/entry_types/scrolled/package/src/frontend/index.js @@ -99,6 +99,7 @@ export {useScrollPosition} from './useScrollPosition'; export {usePhonePlatform} from './usePhonePlatform'; export {useIsomorphicLayoutEffect} from './useIsomorphicLayoutEffect'; +export {EditableTable} from './EditableTable'; export {EditableText} from './EditableText'; export {EditableInlineText} from './EditableInlineText'; export {EditableLink} from './EditableLink'; diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/EditableTable/index.js b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableTable/index.js new file mode 100644 index 0000000000..8bc1d921d9 --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableTable/index.js @@ -0,0 +1,120 @@ +import React, {useCallback, useMemo} from 'react'; +import classNames from 'classnames'; +import {createEditor, Range} from 'slate'; +import {Slate, Editable, withReact} from 'slate-react'; +import {withHistory} from 'slate-history'; + +import {useCachedValue} from '../useCachedValue'; +import {useContentElementEditorState} from '../../useContentElementEditorState'; + +import { + decorateLineBreaks, + renderLeafWithLineBreakDecoration, + selectedClassName, + useLineBreakHandler, + useShortcutHandler, + withLineBreakNormalization, + withLinks, + wrapRenderElementWithLinkPreview, + HoveringToolbar +} from '../EditableText'; + +import { + handleTableNavigation, + withFixedColumns +} from './withFixedColumns'; + +import { + createRenderElementWithPlaceholder +} from './placeholders'; + +import {LinkTooltipProvider} from '../LinkTooltip'; + +export const EditableTable = React.memo(function EditableTable({ + value, onChange, className, + labelScaleCategory = 'body', + valueScaleCategory = 'body', + labelPlaceholder, valuePlaceholder, + floatingControlsPosition = 'below' +}) { + const editor = useMemo( + () => withFixedColumns( + withLinks( + withLineBreakNormalization( + withReact( + withHistory( + createEditor() + ) + ) + ) + ) + ), + [] + ); + + const {isSelected} = useContentElementEditorState(); + + const handleLineBreaks = useLineBreakHandler(editor); + const handleShortcuts = useShortcutHandler(editor); + + const handleKeyDown = useCallback(event => { + handleLineBreaks(event); + handleShortcuts(event); + handleTableNavigation(editor, event); + }, [editor, handleLineBreaks, handleShortcuts]); + + const [cachedValue, setCachedValue] = useCachedValue(value, { + defaultValue: [{ + type: 'row', + children: [ + { + type: 'label', + children: [ + {text: ''} + ] + }, + { + type: 'value', + children: [ + {text: ''} + ] + } + ], + }], + onDebouncedChange: onChange + }); + + const showPlaceholders = cachedValue.length === 1; + + const renderElement = useMemo( + () => wrapRenderElementWithLinkPreview( + createRenderElementWithPlaceholder({ + labelScaleCategory, valueScaleCategory, + labelPlaceholder, valuePlaceholder, + showPlaceholders + }) + ), + [ + labelScaleCategory, valueScaleCategory, + labelPlaceholder, valuePlaceholder, + showPlaceholders + ] + ); + + return ( + + + + + +
+
+
+ ); +}); diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/EditableTable/placeholders.js b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableTable/placeholders.js new file mode 100644 index 0000000000..b3610b1c8b --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableTable/placeholders.js @@ -0,0 +1,30 @@ +import React from 'react'; +import { Node} from 'slate'; +import {createRenderElement} from '../../EditableTable'; + +import styles from './placeholders.module.css'; + +export function createRenderElementWithPlaceholder(options) { + const renderElement = createRenderElement(options); + + return function({attributes, children, element}) { + if ((element.type === 'label' || element.type === 'value') && + options.showPlaceholders && + Node.string(element) === '') { + children = <> + {children} + + ; + } + + return renderElement({ + attributes, + children, + element + }); + }; +} diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/EditableTable/placeholders.module.css b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableTable/placeholders.module.css new file mode 100644 index 0000000000..a5490f7e16 --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableTable/placeholders.module.css @@ -0,0 +1,13 @@ +.placeholder { + pointer-events: none; + opacity: 0.5; + font-weight: normal; +} + +.placeholder::before { + content: attr(data-text); + display: block; + height: 0; + position: relative; + top: -1lh; +} diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/EditableTable/withFixedColumns.js b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableTable/withFixedColumns.js new file mode 100644 index 0000000000..ff785a6208 --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableTable/withFixedColumns.js @@ -0,0 +1,302 @@ +import {Editor, Node, Path, Point, Range, Transforms} from 'slate'; + +export function withFixedColumns(editor) { + const {insertBreak, deleteBackward, deleteForward, deleteFragment} = editor; + + editor.insertBreak = () => { + const cellMatch = matchCurrentCell(editor); + + if (cellMatch) { + const [cellNode, cellPath] = cellMatch; + + const [rowMatch] = Editor.nodes(editor, { + match: (n) => n.type === 'row', + }); + + if (rowMatch) { + const [, rowPath] = rowMatch; + + const columnIndex = cellPath[cellPath.length - 1]; + + const cursorOffset = editor.selection.anchor.offset; + const text = Node.string(cellNode); + const beforeText = text.slice(0, cursorOffset); + const afterText = text.slice(cursorOffset); + + const newRowPath = Path.next(rowPath); + + if (columnIndex === 0) { + Transforms.insertText(editor, afterText, {at: cellPath }); + + const newRow = { + type: 'row', + children: [ + { type: 'label', children: [{text: beforeText}] }, + { type: 'value', children: [{text: ''}]} + ] + }; + + Transforms.insertNodes(editor, newRow, { at: rowPath}); + } + else { + Transforms.insertText(editor, beforeText, { at: cellPath }); + + const newRow = { + type: 'row', + children: [ + { type: 'label', children: [{text: ''}] }, + { type: 'value', children: [{text: afterText}]} + ] + }; + + Transforms.insertNodes(editor, newRow, { at: newRowPath }); + } + + const cursor = { + path: [...newRowPath, afterText.length ? columnIndex : 0, 0], + offset: 0 + }; + + Transforms.select(editor, { + anchor: cursor, + focus: cursor, + }); + + return; + } + }; + + insertBreak(); + }; + + editor.deleteBackward = function() { + const {selection} = editor; + + if (selection && Range.isCollapsed(selection)) { + const cellMatch = matchCurrentCell(editor); + + if (cellMatch) { + const [, cellPath] = cellMatch; + const start = Editor.start(editor, cellPath); + + if (Point.equals(selection.anchor, start)) { + const columnIndex = cellPath[cellPath.length - 1]; + + if (columnIndex === 0) { + const rowMatch = matchCurrentRow(editor); + const previousRowMatch = matchPreviousRow(editor); + + if (previousRowMatch) { + const [row, rowPath] = rowMatch; + const [previousRow, previousRowPath] = previousRowMatch; + + if (Node.string(previousRow) === '') { + Transforms.delete(editor, {at: previousRowPath}); + } + else if (Node.string(row) === '') { + Transforms.delete(editor, {at: rowPath}); + } + else { + Transforms.select(editor, Editor.end(editor, previousRowPath)); + } + } + } + else { + Transforms.select(editor, Editor.end(editor, Path.previous(cellPath))); + } + + return; + } + } + } + + deleteBackward.apply(this, arguments); + }; + + editor.deleteForward = () => { + const {selection} = editor; + + if (selection && Range.isCollapsed(selection)) { + const cellMatch = matchCurrentCell(editor); + + if (cellMatch) { + const [, cellPath] = cellMatch; + const columnIndex = cellPath[cellPath.length - 1]; + + if (Point.equals(selection.anchor, Editor.end(editor, cellPath))) { + if (columnIndex === 0) { + const rowMatch = matchCurrentRow(editor); + + if (rowMatch) { + const [row, rowPath] = rowMatch; + + if (Node.string(row) === '') { + const previousRowMatch = matchPreviousRow(editor); + const nextRowMatch = matchNextRow(editor); + + if (previousRowMatch || nextRowMatch) { + Transforms.delete(editor, {at: rowPath}); + + if (Node.has(editor, rowPath)) { + Transforms.select(editor, Editor.start(editor, rowPath)); + } + else { + const [, previousRowPath] = previousRowMatch; + Transforms.select(editor, Editor.start(editor, previousRowPath)); + } + } + } + else { + Transforms.select(editor, Editor.start(editor, Path.next(cellPath))); + } + + return; + } + } + + if (columnIndex === 1) { + const nextRowMatch = matchNextRow(editor); + + if (nextRowMatch) { + const [nextRow, nextRowPath] = nextRowMatch; + + if (Node.string(nextRow) === '') { + Transforms.delete(editor, {at: nextRowPath}); + } + else { + Transforms.select(editor, Editor.start(editor, nextRowPath)); + } + } + } + + return; + } + } + } + + deleteForward(); + }; + + editor.deleteFragment = () => { + const { selection } = editor; + + if (selection && Range.isExpanded(selection)) { + const [startCellMatch] = Editor.nodes(editor, { + match: (n) => n.type === 'label' || n.type === 'value', + at: selection.anchor.path, + }); + + const [endCellMatch] = Editor.nodes(editor, { + match: (n) => n.type === 'label' || n.type === 'value', + at: selection.focus.path, + }); + + if (startCellMatch && endCellMatch) { + const [, startCellPath] = startCellMatch; + const [, startRowSecondCellPath] = Editor.next(editor, {at: startCellPath}); + const [, endCellPath] = endCellMatch; + + // Collect all rows in the selection range + const rows = Array.from(Editor.nodes(editor, { + match: (n) => n.type === 'row', + at: { anchor: selection.anchor, focus: selection.focus }, + })); + + // Delete text in the end cell from the start of the cell to the selection focus + const endCellText = Editor.string(editor, { + focus: selection.focus, + anchor: Editor.end(editor, endCellPath), + }); + Transforms.insertText(editor, endCellText, {at: startRowSecondCellPath}) + + // Delete text in the start cell from the selection anchor to the end of the cell + Transforms.delete(editor, { + at: { + anchor: selection.anchor, + focus: Editor.end(editor, startCellPath), + }, + }); + + // Remove all middle rows between start and end rows + const middleRows = rows.slice(1); // Exclude the first and last rows + middleRows.reverse().forEach(([_, rowPath]) => { + Transforms.removeNodes(editor, { at: rowPath }); + }); + + return; + } + } + + // Default delete behavior for non-table cases + deleteFragment(); + }; + + return editor; +} + +export function handleTableNavigation(editor, event) { + const {selection} = editor; + + if (selection && Range.isCollapsed(selection)) { + const cellMatch = matchCurrentCell(editor); + + if (cellMatch) { + const [, cellPath] = cellMatch; + const rowPath = cellPath.slice(0, -1); + + if (event.key === 'ArrowUp') { + event.preventDefault(); + + if (rowPath[rowPath.length - 1] > 0) { + const previousRowPath = Path.previous(rowPath); + const targetPath = [...previousRowPath, cellPath[cellPath.length - 1]]; + + Transforms.select(editor, Editor.start(editor, targetPath)); + } + } else if (event.key === 'ArrowDown') { + event.preventDefault(); + + const nextRowPath = Path.next(rowPath); + const targetPath = [...nextRowPath, cellPath[cellPath.length - 1]]; + + if (Node.has(editor, targetPath)) { + Transforms.select(editor, Editor.start(editor, targetPath)); + } + } + } + } +} + +function matchCurrentCell(editor) { + const [cellMatch] = Editor.nodes(editor, { + match: n => n.type === 'label' || n.type === 'value' + }); + + return cellMatch; +} + +function matchCurrentRow(editor) { + const [rowMatch] = Editor.nodes(editor, { + match: (n) => n.type === 'row', + }); + + return rowMatch; +} + +function matchPreviousRow(editor) { + const rowMatch = matchCurrentRow(editor); + + if (rowMatch) { + const [, rowPath] = rowMatch; + return Editor.previous(editor, {at: rowPath}); + } +} + +function matchNextRow(editor) { + const rowMatch = matchCurrentRow(editor); + + if (rowMatch) { + const [, rowPath] = rowMatch; + return Editor.next(editor, {at: rowPath}); + } +} diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/index.js b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/index.js index d8f6d6170a..000e56588b 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/index.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/index.js @@ -38,6 +38,23 @@ import {useShortcutHandler} from './shortcuts'; import styles from './index.module.css'; +export { + wrapRenderElementWithLinkPreview +} from './withLinks'; + +export { + decorateLineBreaks, + useLineBreakHandler, + useShortcutHandler, + renderLeafWithLineBreakDecoration, + withLinks, + withLineBreakNormalization, + LinkTooltipProvider, + HoveringToolbar +}; + +export const selectedClassName = styles.selected; + export const EditableText = React.memo(function EditableText({ value, contentElementId, placeholder, onChange, selectionRect, className, placeholderClassName, scaleCategory = 'body', autoFocus, diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/withLinks.js b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/withLinks.js index 9f7d56adf8..bd815d3e15 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/withLinks.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/withLinks.js @@ -13,17 +13,21 @@ export function withLinks(editor) { return editor } -export function renderElementWithLinkPreview(options) { - if (options.element.type === 'link') { - return ( +export const renderElementWithLinkPreview = + wrapRenderElementWithLinkPreview(renderElement); - - {renderElement(options)} - - ) - } - else { - return renderElement(options); +export function wrapRenderElementWithLinkPreview(renderElement) { + return function(options) { + if (options.element.type === 'link') { + return ( + + {renderElement(options)} + + ) + } + else { + return renderElement(options); + } } } diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/components.js b/entry_types/scrolled/package/src/frontend/inlineEditing/components.js index 89ba8be5b0..ba09d4908b 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/components.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/components.js @@ -6,6 +6,7 @@ export {LayoutWithPlaceholder} from './LayoutWithPlaceholder'; export {EditableText} from './EditableText'; export {EditableInlineText} from './EditableInlineText'; +export {EditableTable} from './EditableTable'; export {EditableLink} from './EditableLink'; export {ActionButton} from './ActionButton';