diff --git a/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue b/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue index 5624bae34c270cd2cfdda3f5db15ef5378b2d3d4..5abacf44cf3f9829f9e41edc14ac91280dce7ffd 100644 --- a/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue +++ b/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue @@ -1,22 +1,71 @@ <script> -import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui'; +import { GlDisclosureDropdown } from '@gitlab/ui'; import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2'; import { selectedRect as getSelectedRect } from '@tiptap/pm/tables'; -import { __ } from '~/locale'; +import { __, n__ } from '~/locale'; const TABLE_CELL_HEADER = 'th'; const TABLE_CELL_BODY = 'td'; +function getDropdownItems({ selectedRect, cellType, rowspan = 1, colspan = 1 }) { + const totalRows = selectedRect?.map.height; + const totalCols = selectedRect?.map.width; + const isTableBodyCell = cellType === TABLE_CELL_BODY; + const selectedRows = selectedRect ? selectedRect.bottom - selectedRect.top : 0; + const selectedCols = selectedRect ? selectedRect.right - selectedRect.left : 0; + const showSplitCellOption = + selectedRows === rowspan && selectedCols === colspan && (rowspan > 1 || colspan > 1); + const showMergeCellsOption = selectedRows !== rowspan || selectedCols !== colspan; + const numCellsToMerge = (selectedRows - rowspan + 1) * (selectedCols - colspan + 1); + const showDeleteRowOption = totalRows > selectedRows + 1 && isTableBodyCell; + const showDeleteColumnOption = totalCols > selectedCols; + + return [ + { + items: [ + { text: __('Insert column before'), value: 'addColumnBefore' }, + { text: __('Insert column after'), value: 'addColumnAfter' }, + isTableBodyCell && { text: __('Insert row before'), value: 'addRowBefore' }, + { text: __('Insert row after'), value: 'addRowAfter' }, + ].filter(Boolean), + }, + { + items: [ + showSplitCellOption && { text: __('Split cell'), value: 'splitCell' }, + showMergeCellsOption && { + text: n__('Merge %d cell', 'Merge %d cells', numCellsToMerge), + value: 'mergeCells', + }, + ].filter(Boolean), + }, + { + items: [ + showDeleteRowOption && { + text: n__('Delete row', 'Delete %d rows', selectedRows), + value: 'deleteRow', + }, + showDeleteColumnOption && { + text: n__('Delete column', 'Delete %d columns', selectedCols), + value: 'deleteColumn', + }, + { text: __('Delete table'), value: 'deleteTable' }, + ].filter(Boolean), + }, + ].filter(({ items }) => items.length); +} + export default { name: 'TableCellBaseWrapper', components: { NodeViewWrapper, NodeViewContent, - GlDropdown, - GlDropdownItem, - GlDropdownDivider, + GlDisclosureDropdown, }, props: { + getPos: { + type: Function, + required: true, + }, cellType: { type: String, validator: (type) => [TABLE_CELL_HEADER, TABLE_CELL_BODY].includes(type), @@ -34,19 +83,17 @@ export default { data() { return { displayActionsDropdown: false, - preventHide: true, selectedRect: null, }; }, computed: { - totalRows() { - return this.selectedRect?.map.height; - }, - totalCols() { - return this.selectedRect?.map.width; - }, - isTableBodyCell() { - return this.cellType === TABLE_CELL_BODY; + dropdownItems() { + return getDropdownItems({ + selectedRect: this.selectedRect, + cellType: this.cellType, + rowspan: this.node.attrs.rowspan, + colspan: this.node.attrs.colspan, + }); }, }, mounted() { @@ -61,6 +108,8 @@ export default { const { state } = this.editor; const { $cursor } = state.selection; + this.selectedRect = getSelectedRect(state); + if (!$cursor) return; this.displayActionsDropdown = false; @@ -71,54 +120,34 @@ export default { break; } } - - if (this.displayActionsDropdown) { - this.selectedRect = getSelectedRect(state); - } }, - runCommand(command) { - this.editor.chain()[command]().run(); + + runCommand({ value: command }) { this.hideDropdown(); + this.editor.chain()[command]().run(); }, - handleHide($event) { - if (this.preventHide) { - $event.preventDefault(); - } - this.preventHide = true; - }, + hideDropdown() { - this.preventHide = false; - this.$refs.dropdown?.hide(); + this.$refs.dropdown?.close(); }, }, - i18n: { - insertColumnBefore: __('Insert column before'), - insertColumnAfter: __('Insert column after'), - insertRowBefore: __('Insert row before'), - insertRowAfter: __('Insert row after'), - deleteRow: __('Delete row'), - deleteColumn: __('Delete column'), - deleteTable: __('Delete table'), - editTableActions: __('Edit table'), - }, - dropdownPopperOpts: { - positionFixed: true, - }, }; </script> <template> <node-view-wrapper - class="gl-relative gl-padding-5 gl-min-w-10" :as="cellType" + :rowspan="node.attrs.rowspan || 1" + :colspan="node.attrs.colspan || 1" dir="auto" + class="gl-m-0! gl-p-0! gl-relative" @click="hideDropdown" > <span v-if="displayActionsDropdown" contenteditable="false" - class="gl-absolute gl-right-0 gl-top-0" + class="gl-absolute gl-right-0 gl-top-0 gl-pr-1 gl-pt-1" > - <gl-dropdown + <gl-disclosure-dropdown ref="dropdown" dropup icon="chevron-down" @@ -127,34 +156,12 @@ export default { boundary="viewport" no-caret text-sr-only - :text="$options.i18n.editTableActions" - :popper-opts="$options.dropdownPopperOpts" - @hide="handleHide($event)" - > - <gl-dropdown-item @click="runCommand('addColumnBefore')"> - {{ $options.i18n.insertColumnBefore }} - </gl-dropdown-item> - <gl-dropdown-item @click="runCommand('addColumnAfter')"> - {{ $options.i18n.insertColumnAfter }} - </gl-dropdown-item> - <gl-dropdown-item v-if="isTableBodyCell" @click="runCommand('addRowBefore')"> - {{ $options.i18n.insertRowBefore }} - </gl-dropdown-item> - <gl-dropdown-item @click="runCommand('addRowAfter')"> - {{ $options.i18n.insertRowAfter }} - </gl-dropdown-item> - <gl-dropdown-divider /> - <gl-dropdown-item v-if="totalRows > 2 && isTableBodyCell" @click="runCommand('deleteRow')"> - {{ $options.i18n.deleteRow }} - </gl-dropdown-item> - <gl-dropdown-item v-if="totalCols > 1" @click="runCommand('deleteColumn')"> - {{ $options.i18n.deleteColumn }} - </gl-dropdown-item> - <gl-dropdown-item @click="runCommand('deleteTable')"> - {{ $options.i18n.deleteTable }} - </gl-dropdown-item> - </gl-dropdown> + :items="dropdownItems" + :toggle-text="__('Edit table')" + positioning-strategy="fixed" + @action="runCommand" + /> </span> - <node-view-content /> + <node-view-content as="div" class="gl-p-5 gl-min-w-10" /> </node-view-wrapper> </template> diff --git a/app/assets/stylesheets/components/content_editor.scss b/app/assets/stylesheets/components/content_editor.scss index 7f66d335f41a0e3f8d84777fe4f2fe826410727d..680b24be442f7d2cf39374642a4d0cf71f685b76 100644 --- a/app/assets/stylesheets/components/content_editor.scss +++ b/app/assets/stylesheets/components/content_editor.scss @@ -33,6 +33,17 @@ outline-offset: -3px; } + .selectedCell::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba($blue-200, 0.25); + pointer-events: none; + } + video { max-width: 400px; } @@ -115,6 +126,15 @@ display: inherit; } } + + .gl-new-dropdown-inner li { + margin-left: 0 !important; + + &.gl-new-dropdown-item { + padding-left: $gl-spacing-scale-2; + padding-right: $gl-spacing-scale-2; + } + } } .table-creator-grid-item { diff --git a/locale/gitlab.pot b/locale/gitlab.pot index d71636d3580e79fbe9e117050c60cd730b40e03c..313e37cb81a91bc14ac53e861c997afc3d3a10d0 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -14742,7 +14742,9 @@ msgid "Delete code block" msgstr "" msgid "Delete column" -msgstr "" +msgid_plural "Delete %d columns" +msgstr[0] "" +msgstr[1] "" msgid "Delete comment" msgstr "" @@ -14802,7 +14804,9 @@ msgid "Delete release %{release}?" msgstr "" msgid "Delete row" -msgstr "" +msgid_plural "Delete %d rows" +msgstr[0] "" +msgstr[1] "" msgid "Delete selected" msgstr "" @@ -28282,6 +28286,11 @@ msgstr "" msgid "Merge" msgstr "" +msgid "Merge %d cell" +msgid_plural "Merge %d cells" +msgstr[0] "" +msgstr[1] "" + msgid "Merge Conflicts" msgstr "" @@ -43667,6 +43676,9 @@ msgstr "" msgid "Spent at can't be a future date and time." msgstr "" +msgid "Split cell" +msgstr "" + msgid "Squash commit message" msgstr "" diff --git a/spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js b/spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js index 0d56280d63039f542419431f01172a0e3f76df64..275f48ea857c2ed0d1d64abe51f41ea0a0f18efd 100644 --- a/spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js +++ b/spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js @@ -1,8 +1,8 @@ -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; -import { NodeViewWrapper } from '@tiptap/vue-2'; +import { GlDisclosureDropdown } from '@gitlab/ui'; +import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2'; import { selectedRect as getSelectedRect } from '@tiptap/pm/tables'; import { nextTick } from 'vue'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import { stubComponent } from 'helpers/stub_component'; import TableCellBaseWrapper from '~/content_editor/components/wrappers/table_cell_base.vue'; import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../../test_utils'; @@ -15,32 +15,21 @@ describe('content/components/wrappers/table_cell_base', () => { let node; const createWrapper = (propsData = { cellType: 'td' }) => { - wrapper = shallowMountExtended(TableCellBaseWrapper, { + wrapper = mountExtended(TableCellBaseWrapper, { propsData: { editor, node, + getPos: () => 0, ...propsData, }, stubs: { - GlDropdown: stubComponent(GlDropdown, { - methods: { - hide: jest.fn(), - }, - }), + NodeViewWrapper: stubComponent(NodeViewWrapper), + NodeViewContent: stubComponent(NodeViewContent), }, }); }; - const findDropdown = () => wrapper.findComponent(GlDropdown); - const findDropdownItemWithLabel = (name) => - wrapper - .findAllComponents(GlDropdownItem) - .filter((dropdownItem) => dropdownItem.text().includes(name)) - .at(0); - const findDropdownItemWithLabelExists = (name) => - wrapper - .findAllComponents(GlDropdownItem) - .filter((dropdownItem) => dropdownItem.text().includes(name)).length > 0; + const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown); const setCurrentPositionInCell = () => { const { $cursor } = editor.state.selection; @@ -48,7 +37,9 @@ describe('content/components/wrappers/table_cell_base', () => { }; beforeEach(() => { - node = {}; + node = { + attrs: {}, + }; editor = createTestEditor({}); }); @@ -68,11 +59,10 @@ describe('content/components/wrappers/table_cell_base', () => { category: 'tertiary', icon: 'chevron-down', size: 'small', - split: false, + noCaret: true, }); expect(findDropdown().attributes()).toMatchObject({ boundary: 'viewport', - 'no-caret': '', }); }); @@ -88,6 +78,10 @@ describe('content/components/wrappers/table_cell_base', () => { beforeEach(async () => { setCurrentPositionInCell(); getSelectedRect.mockReturnValue({ + top: 0, + left: 0, + bottom: 1, + right: 1, map: { height: 1, width: 1, @@ -107,81 +101,176 @@ describe('content/components/wrappers/table_cell_base', () => { ${'Delete table'} | ${'deleteTable'} `( 'executes $commandName when $dropdownItemLabel button is clicked', - ({ commandName, dropdownItemLabel }) => { + async ({ dropdownItemLabel, commandName }) => { const mocks = mockChainedCommands(editor, [commandName, 'run']); - findDropdownItemWithLabel(dropdownItemLabel).vm.$emit('click'); + await wrapper.findByRole('button', { name: dropdownItemLabel }).trigger('click'); expect(mocks[commandName]).toHaveBeenCalled(); }, ); - it('does not allow deleting rows and columns', () => { - expect(findDropdownItemWithLabelExists('Delete row')).toBe(false); - expect(findDropdownItemWithLabelExists('Delete column')).toBe(false); + it.each` + dropdownItemLabel + ${'Delete row'} + ${'Delete column'} + ${'Split cell'} + ${'Merge'} + `('does not have option $dropdownItemLabel available', ({ dropdownItemLabel }) => { + expect(findDropdown().text()).not.toContain(dropdownItemLabel); }); - it('allows deleting rows when there are more than 2 rows in the table', async () => { - const mocks = mockChainedCommands(editor, ['deleteRow', 'run']); + it.each` + dropdownItemLabel | commandName + ${'Delete row'} | ${'deleteRow'} + ${'Delete column'} | ${'deleteColumn'} + `( + 'allows $dropdownItemLabel operation when there are more than 2 rows and 1 column in the table', + async ({ dropdownItemLabel, commandName }) => { + const mocks = mockChainedCommands(editor, [commandName, 'run']); - getSelectedRect.mockReturnValue({ - map: { - height: 3, - }, - }); + getSelectedRect.mockReturnValue({ + top: 0, + left: 0, + bottom: 1, + right: 1, + map: { + height: 3, + width: 2, + }, + }); - emitEditorEvent({ tiptapEditor: editor, event: 'selectionUpdate' }); + emitEditorEvent({ tiptapEditor: editor, event: 'selectionUpdate' }); - await nextTick(); + await nextTick(); + await wrapper.findByRole('button', { name: dropdownItemLabel }).trigger('click'); - findDropdownItemWithLabel('Delete row').vm.$emit('click'); + expect(mocks[commandName]).toHaveBeenCalled(); + }, + ); - expect(mocks.deleteRow).toHaveBeenCalled(); - }); + describe("when current row is the table's header", () => { + beforeEach(async () => { + // Remove 2 rows condition + getSelectedRect.mockReturnValue({ + map: { + height: 3, + }, + }); - it('allows deleting columns when there are more than 1 column in the table', async () => { - const mocks = mockChainedCommands(editor, ['deleteColumn', 'run']); + createWrapper({ cellType: 'th' }); - getSelectedRect.mockReturnValue({ - map: { - width: 2, - }, + await nextTick(); }); - emitEditorEvent({ tiptapEditor: editor, event: 'selectionUpdate' }); + it('does not allow adding a row before the header', () => { + expect(findDropdown().text()).not.toContain('Insert row before'); + }); - await nextTick(); + it('does not allow removing the header row', async () => { + createWrapper({ cellType: 'th' }); - findDropdownItemWithLabel('Delete column').vm.$emit('click'); + await nextTick(); - expect(mocks.deleteColumn).toHaveBeenCalled(); + expect(findDropdown().text()).not.toContain('Delete row'); + }); }); - describe('when current row is the table’s header', () => { - beforeEach(async () => { - // Remove 2 rows condition + describe.each` + attrs | rect + ${{ rowspan: 2 }} | ${{ top: 0, left: 0, bottom: 2, right: 1 }} + ${{ colspan: 2 }} | ${{ top: 0, left: 0, bottom: 1, right: 2 }} + `('when selected cell has $attrs', ({ attrs, rect }) => { + beforeEach(() => { + node = { attrs }; + getSelectedRect.mockReturnValue({ + ...rect, map: { height: 3, + width: 2, }, }); - createWrapper({ cellType: 'th' }); + setCurrentPositionInCell(); + }); + + it('allows splitting the cell', async () => { + const mocks = mockChainedCommands(editor, ['splitCell', 'run']); + + createWrapper(); await nextTick(); + await wrapper.findByRole('button', { name: 'Split cell' }).trigger('click'); + + expect(mocks.splitCell).toHaveBeenCalled(); }); + }); - it('does not allow adding a row before the header', () => { - expect(findDropdownItemWithLabelExists('Insert row before')).toBe(false); + describe('when selected cell has rowspan=2 and colspan=2', () => { + beforeEach(() => { + node = { attrs: { rowspan: 2, colspan: 2 } }; + const rect = { top: 1, left: 1, bottom: 3, right: 3 }; + + getSelectedRect.mockReturnValue({ + ...rect, + map: { height: 5, width: 5 }, + }); + + setCurrentPositionInCell(); }); - it('does not allow removing the header row', async () => { - createWrapper({ cellType: 'th' }); + it.each` + type | dropdownItemLabel | commandName + ${'rows'} | ${'Delete 2 rows'} | ${'deleteRow'} + ${'columns'} | ${'Delete 2 columns'} | ${'deleteColumn'} + `('shows correct label for deleting $type', async ({ dropdownItemLabel, commandName }) => { + const mocks = mockChainedCommands(editor, [commandName, 'run']); + + createWrapper(); await nextTick(); + await wrapper.findByRole('button', { name: dropdownItemLabel }).trigger('click'); - expect(findDropdownItemWithLabelExists('Delete row')).toBe(false); + expect(mocks[commandName]).toHaveBeenCalled(); }); }); + + describe.each` + rows | cols | product + ${2} | ${1} | ${2} + ${1} | ${2} | ${2} + ${2} | ${2} | ${4} + `('when $rows x $cols ($product) cells are selected', ({ rows, cols, product }) => { + it.each` + dropdownItemLabel | commandName + ${`Merge ${product} cells`} | ${'mergeCells'} + ${rows === 1 ? 'Delete row' : `Delete ${rows} rows`} | ${'deleteRow'} + ${cols === 1 ? 'Delete column' : `Delete ${cols} columns`} | ${'deleteColumn'} + `( + 'executes $commandName when $dropdownItemLabel is clicked', + async ({ dropdownItemLabel, commandName }) => { + const mocks = mockChainedCommands(editor, [commandName, 'run']); + + getSelectedRect.mockReturnValue({ + top: 0, + left: 0, + bottom: rows, + right: cols, + map: { + height: 4, + width: 4, + }, + }); + + emitEditorEvent({ tiptapEditor: editor, event: 'selectionUpdate' }); + + await nextTick(); + await wrapper.findByRole('button', { name: dropdownItemLabel }).trigger('click'); + + expect(mocks[commandName]).toHaveBeenCalled(); + }, + ); + }); }); });