Commit ce2b5a1f authored by Enrique Alcántara's avatar Enrique Alcántara

Merge branch '328757-content-editor-tables' into 'master'

Add support for rendering tables in content editor

See merge request gitlab-org/gitlab!63945
parents eed7cdd3 51035cfa
<script>
import { GlDropdown, GlDropdownDivider, GlDropdownForm, GlButton } from '@gitlab/ui';
import { Editor as TiptapEditor } from '@tiptap/vue-2';
import { __, sprintf } from '~/locale';
import { clamp } from '../services/utils';
export const tableContentType = 'table';
const MIN_ROWS = 3;
const MIN_COLS = 3;
const MAX_ROWS = 8;
const MAX_COLS = 8;
export default {
components: {
GlDropdown,
GlDropdownDivider,
GlDropdownForm,
GlButton,
},
props: {
tiptapEditor: {
type: TiptapEditor,
required: true,
},
},
data() {
return {
maxRows: MIN_ROWS,
maxCols: MIN_COLS,
rows: 1,
cols: 1,
};
},
methods: {
list(n) {
return new Array(n).fill().map((_, i) => i + 1);
},
setRowsAndCols(rows, cols) {
this.rows = rows;
this.cols = cols;
this.maxRows = clamp(rows + 1, MIN_ROWS, MAX_ROWS);
this.maxCols = clamp(cols + 1, MIN_COLS, MAX_COLS);
},
resetState() {
this.rows = 1;
this.cols = 1;
},
insertTable() {
this.tiptapEditor
.chain()
.focus()
.insertTable({
rows: this.rows,
cols: this.cols,
withHeaderRow: true,
})
.run();
this.resetState();
this.$emit('execute', { contentType: 'table' });
},
getButtonLabel(rows, cols) {
return sprintf(__('Insert a %{rows}x%{cols} table.'), { rows, cols });
},
},
};
</script>
<template>
<gl-dropdown size="small" category="tertiary" icon="table">
<gl-dropdown-form class="gl-px-3! gl-w-auto!">
<div class="gl-w-auto!">
<div v-for="c of list(maxCols)" :key="c" class="gl-display-flex">
<gl-button
v-for="r of list(maxRows)"
:key="r"
:data-testid="`table-${r}-${c}`"
:class="{ 'gl-bg-blue-50!': r <= rows && c <= cols }"
:aria-label="getButtonLabel(r, c)"
class="gl-display-inline! gl-px-0! gl-w-5! gl-h-5! gl-rounded-0!"
@mouseover="setRowsAndCols(r, c)"
@click="insertTable()"
/>
</div>
<gl-dropdown-divider />
{{ getButtonLabel(rows, cols) }}
</div>
</gl-dropdown-form>
</gl-dropdown>
</template>
......@@ -5,6 +5,7 @@ import { ContentEditor } from '../services/content_editor';
import Divider from './divider.vue';
import ToolbarButton from './toolbar_button.vue';
import ToolbarLinkButton from './toolbar_link_button.vue';
import ToolbarTableButton from './toolbar_table_button.vue';
import ToolbarTextStyleDropdown from './toolbar_text_style_dropdown.vue';
const trackingMixin = Tracking.mixin({
......@@ -16,6 +17,7 @@ export default {
ToolbarButton,
ToolbarTextStyleDropdown,
ToolbarLinkButton,
ToolbarTableButton,
Divider,
},
mixins: [trackingMixin],
......@@ -132,5 +134,9 @@ export default {
:tiptap-editor="contentEditor.tiptapEditor"
@execute="trackToolbarControlExecution"
/>
<toolbar-table-button
:tiptap-editor="contentEditor.tiptapEditor"
@execute="trackToolbarControlExecution"
/>
</div>
</template>
import { Table } from '@tiptap/extension-table';
export const tiptapExtension = Table;
export function serializer(state, node) {
state.renderContent(node);
}
import { TableCell } from '@tiptap/extension-table-cell';
export const tiptapExtension = TableCell.extend({
content: 'inline*',
});
export function serializer(state, node) {
state.renderInline(node);
}
import { TableHeader } from '@tiptap/extension-table-header';
export const tiptapExtension = TableHeader.extend({
content: 'inline*',
});
export function serializer(state, node) {
state.renderInline(node);
}
import { TableRow } from '@tiptap/extension-table-row';
export const tiptapExtension = TableRow.extend({
allowGapCursor: false,
});
export function serializer(state, node) {
const isHeaderRow = node.child(0).type.name === 'tableHeader';
const renderRow = () => {
const cellWidths = [];
state.flushClose(1);
state.write('| ');
node.forEach((cell, _, i) => {
if (i) state.write(' | ');
const { length } = state.out;
state.render(cell, node, i);
cellWidths.push(state.out.length - length);
});
state.write(' |');
state.closeBlock(node);
return cellWidths;
};
const renderHeaderRow = (cellWidths) => {
state.flushClose(1);
state.write('|');
node.forEach((cell, _, i) => {
if (i) state.write('|');
state.write(cell.attrs.align === 'center' ? ':' : '-');
state.write(state.repeat('-', cellWidths[i]));
state.write(cell.attrs.align === 'center' || cell.attrs.align === 'right' ? ':' : '-');
});
state.write('|');
state.closeBlock(node);
};
if (isHeaderRow) {
renderHeaderRow(renderRow());
} else {
renderRow();
}
}
......@@ -20,6 +20,10 @@ import * as ListItem from '../extensions/list_item';
import * as OrderedList from '../extensions/ordered_list';
import * as Paragraph from '../extensions/paragraph';
import * as Strike from '../extensions/strike';
import * as Table from '../extensions/table';
import * as TableCell from '../extensions/table_cell';
import * as TableHeader from '../extensions/table_header';
import * as TableRow from '../extensions/table_row';
import * as Text from '../extensions/text';
import buildSerializerConfig from './build_serializer_config';
import { ContentEditor } from './content_editor';
......@@ -70,6 +74,10 @@ export const createContentEditor = ({
OrderedList,
Paragraph,
Strike,
TableCell,
TableHeader,
TableRow,
Table,
Text,
];
......
......@@ -15,3 +15,5 @@ export const readFileAsDataURL = (file) => {
reader.readAsDataURL(file);
});
};
export const clamp = (n, min, max) => Math.max(Math.min(n, max), min);
......@@ -17501,6 +17501,9 @@ msgstr ""
msgid "Input the remote repository URL"
msgstr ""
msgid "Insert a %{rows}x%{cols} table."
msgstr ""
msgid "Insert a code block"
msgstr ""
......
import { GlDropdown, GlButton } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import ToolbarTableButton from '~/content_editor/components/toolbar_table_button.vue';
import { tiptapExtension as Table } from '~/content_editor/extensions/table';
import { tiptapExtension as TableCell } from '~/content_editor/extensions/table_cell';
import { tiptapExtension as TableHeader } from '~/content_editor/extensions/table_header';
import { tiptapExtension as TableRow } from '~/content_editor/extensions/table_row';
import { createTestEditor, mockChainedCommands } from '../test_utils';
describe('content_editor/components/toolbar_table_button', () => {
let wrapper;
let editor;
const buildWrapper = () => {
wrapper = mountExtended(ToolbarTableButton, {
propsData: {
tiptapEditor: editor,
},
});
};
const findDropdown = () => wrapper.findComponent(GlDropdown);
const getNumButtons = () => findDropdown().findAllComponents(GlButton).length;
beforeEach(() => {
editor = createTestEditor({
extensions: [Table, TableCell, TableRow, TableHeader],
});
buildWrapper();
});
afterEach(() => {
editor.destroy();
wrapper.destroy();
});
it('renders a grid of 3x3 buttons to create a table', () => {
expect(getNumButtons()).toBe(9); // 3 x 3
});
describe.each`
row | col | numButtons | tableSize
${1} | ${2} | ${9} | ${'1x2'}
${2} | ${2} | ${9} | ${'2x2'}
${2} | ${3} | ${12} | ${'2x3'}
${3} | ${2} | ${12} | ${'3x2'}
${3} | ${3} | ${16} | ${'3x3'}
`('button($row, $col) in the table creator grid', ({ row, col, numButtons, tableSize }) => {
describe('on mouse over', () => {
beforeEach(async () => {
const button = wrapper.findByTestId(`table-${row}-${col}`);
await button.trigger('mouseover');
});
it('marks all rows and cols before it as active', () => {
const prevRow = Math.max(1, row - 1);
const prevCol = Math.max(1, col - 1);
expect(wrapper.findByTestId(`table-${prevRow}-${prevCol}`).element).toHaveClass(
'gl-bg-blue-50!',
);
});
it('shows a help text indicating the size of the table being inserted', () => {
expect(findDropdown().element).toHaveText(`Insert a ${tableSize} table.`);
});
it('adds another row and col of buttons to create a bigger table', () => {
expect(getNumButtons()).toBe(numButtons);
});
});
describe('on click', () => {
let commands;
beforeEach(async () => {
commands = mockChainedCommands(editor, ['focus', 'insertTable', 'run']);
const button = wrapper.findByTestId(`table-${row}-${col}`);
await button.trigger('mouseover');
await button.trigger('click');
});
it('inserts a table with $tableSize rows and cols', () => {
expect(commands.focus).toHaveBeenCalled();
expect(commands.insertTable).toHaveBeenCalledWith({
rows: row,
cols: col,
withHeaderRow: true,
});
expect(commands.run).toHaveBeenCalled();
expect(wrapper.emitted().execute).toHaveLength(1);
});
});
});
it('does not create more buttons than a 8x8 grid', async () => {
for (let i = 3; i < 8; i += 1) {
expect(getNumButtons()).toBe(i * i);
// eslint-disable-next-line no-await-in-loop
await wrapper.findByTestId(`table-${i}-${i}`).trigger('mouseover');
expect(findDropdown().element).toHaveText(`Insert a ${i}x${i} table.`);
}
expect(getNumButtons()).toBe(64); // 8x8 (and not 9x9)
});
});
......@@ -74,3 +74,16 @@
markdown: |-
This is a line after a\
hard break
- name: table
markdown: |-
| header | header |
|--------|--------|
| cell | cell |
| cell | cell |
- name: table_with_alignment
markdown: |-
| header | : header : | header : |
|--------|------------|----------|
| cell | cell | cell |
| cell | cell | cell |
......@@ -1475,6 +1475,29 @@
resolved "https://registry.yarnpkg.com/@tiptap/extension-strike/-/extension-strike-2.0.0-beta.15.tgz#c274ae85b1067f80d45a1cb30d0cad24733c9be7"
integrity sha512-8R6L4jVxeGabItZ2a4B8lvcy60yhD95nRkO4ruH4iBQ5qlyGwShRbvuJQQGT/2j2RY7W793nXZ/Uohcd/5gJCw==
"@tiptap/extension-table-cell@^2.0.0-beta.13":
version "2.0.0-beta.13"
resolved "https://registry.yarnpkg.com/@tiptap/extension-table-cell/-/extension-table-cell-2.0.0-beta.13.tgz#c01eada4859d5ea487d61e68cc7fab7ed2e4842a"
integrity sha512-dnPMsBySCbOLG9irQt+cle44y6RxNVwEdknpVocjME6wAgyLpyiShHpS4Tq1j6jAhTYcXR29lNCVMcsIwzSK0A==
"@tiptap/extension-table-header@^2.0.0-beta.15":
version "2.0.0-beta.15"
resolved "https://registry.yarnpkg.com/@tiptap/extension-table-header/-/extension-table-header-2.0.0-beta.15.tgz#884d16f104671ee672f1f629f4e4fef0b096bfbb"
integrity sha512-8K0YXFG7bcM1iMZ1pAVcshXdchDQv17a1jGjKXC1+e1NjH1eb/Ya8eyFWlyxC0ZjAFNzQF4r3mGzmGTbWoI6cA==
"@tiptap/extension-table-row@^2.0.0-beta.13":
version "2.0.0-beta.13"
resolved "https://registry.yarnpkg.com/@tiptap/extension-table-row/-/extension-table-row-2.0.0-beta.13.tgz#3f9a61112afcde750228f4437ae3cd7b82d02f74"
integrity sha512-tGE3/ADBaVgpBYXgdx5YkAs7waYLKDRormUXKNnTpR+4qVHKUmXrDUTdJ2urXaANaB95F8R5Lj146h+EYiLxgw==
"@tiptap/extension-table@^2.0.0-beta.23":
version "2.0.0-beta.23"
resolved "https://registry.yarnpkg.com/@tiptap/extension-table/-/extension-table-2.0.0-beta.23.tgz#12b4b586654874b86f8ceb93b0536e846744a04e"
integrity sha512-o8V7MTCQuf0iXoKpF7q6HWTjCRu1HnpDJoKzyJN6AB1ecVDPNOce72L2EjceRgziYJy0QDBHK1xm/+C4t8h44Q==
dependencies:
prosemirror-tables "^1.1.1"
prosemirror-view "^1.18.7"
"@tiptap/extension-text@^2.0.0-beta.12":
version "2.0.0-beta.12"
resolved "https://registry.yarnpkg.com/@tiptap/extension-text/-/extension-text-2.0.0-beta.12.tgz#b857f36dda5e8cedd350f9bad7115e4060f8d9c0"
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment