Commit 06cd45af authored by Enrique Alcantara's avatar Enrique Alcantara

Allow editing the structure of tables

Provide visual controls to edit the structure
of tables in the Content Editor

Changelog: added
parent 0b5df713
<script>
import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui';
import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2';
import { selectedRect as getSelectedRect } from 'prosemirror-tables';
export default {
name: 'TableCellWrapper',
components: {
NodeViewWrapper,
NodeViewContent,
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
},
props: {
editor: {
type: Object,
required: true,
},
getPos: {
type: Function,
required: true,
},
},
data() {
return {
displayActionsDropdown: false,
preventHide: true,
selectedRect: null,
};
},
computed: {
totalRows() {
return this.selectedRect?.map.height;
},
totalCols() {
return this.selectedRect?.map.width;
},
},
mounted() {
this.editor.on('selectionUpdate', this.handleSelectionUpdate);
this.handleSelectionUpdate();
},
beforeDestroy() {
this.editor.off('selectionUpdate', this.handleSelectionUpdate);
},
methods: {
handleSelectionUpdate() {
const { state } = this.editor;
const { $cursor } = state.selection;
this.displayActionsDropdown = $cursor?.pos - $cursor?.parentOffset - 1 === this.getPos();
if (this.displayActionsDropdown) {
this.selectedRect = getSelectedRect(state);
}
},
runCommand(command) {
this.editor.chain()[command]().run();
this.hideDropdown();
},
handleHide($event) {
if (this.preventHide) {
$event.preventDefault();
}
this.preventHide = true;
},
hideDropdown() {
this.preventHide = false;
this.$refs.dropdown?.hide();
},
},
};
</script>
<template>
<node-view-wrapper class="gl-relative gl-padding-5" as="td" @click="hideDropdown">
<span v-if="displayActionsDropdown" class="gl-absolute gl-right-0 gl-top-0">
<gl-dropdown
ref="dropdown"
dropup
icon="chevron-down"
size="small"
category="tertiary"
boundary="viewport"
no-caret
:popper-opts="{ positionFixed: true }"
@hide="handleHide($event)"
>
<gl-dropdown-item @click="runCommand('addColumnBefore')">
{{ __('Insert column before') }}
</gl-dropdown-item>
<gl-dropdown-item @click="runCommand('addColumnAfter')">
{{ __('Insert column after') }}
</gl-dropdown-item>
<gl-dropdown-item @click="runCommand('addRowBefore')">
{{ __('Insert row before') }}
</gl-dropdown-item>
<gl-dropdown-item @click="runCommand('addRowAfter')">
{{ __('Insert row after') }}
</gl-dropdown-item>
<gl-dropdown-divider />
<gl-dropdown-item v-if="totalRows > 2" @click="runCommand('deleteRow')">
{{ __('Delete row') }}
</gl-dropdown-item>
<gl-dropdown-item v-if="totalCols > 1" @click="runCommand('deleteColumn')">
{{ __('Delete column') }}
</gl-dropdown-item>
<gl-dropdown-item @click="runCommand('deleteTable')">
{{ __('Delete table') }}
</gl-dropdown-item>
</gl-dropdown>
</span>
<node-view-content />
</node-view-wrapper>
</template>
import { TableCell } from '@tiptap/extension-table-cell';
import { VueNodeViewRenderer } from '@tiptap/vue-2';
import TableCellWrapper from '../components/wrappers/table_cell.vue';
import { isBlockTablesFeatureEnabled } from '../services/feature_flags';
export default TableCell.extend({
content: isBlockTablesFeatureEnabled() ? 'block+' : 'inline*',
addNodeView() {
return VueNodeViewRenderer(TableCellWrapper);
},
});
......@@ -549,17 +549,12 @@
margin: 0;
font-size: $gl-font-size-small;
}
}
ul.dropdown-menu {
margin-top: 4px;
margin-bottom: 24px;
padding: 8px 0;
li {
margin: 0;
padding: 0 1px;
}
}
.gl-new-dropdown-item {
margin: 0;
padding: 0;
line-height: 1rem;
}
/* AsciiDoc(tor) built-in alignment roles */
......
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { NodeViewWrapper } from '@tiptap/vue-2';
import { selectedRect as getSelectedRect } from 'prosemirror-tables';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import TableCellWrapper from '~/content_editor/components/wrappers/table_cell.vue';
import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../../test_utils';
jest.mock('prosemirror-tables');
describe('content/components/wrappers/table_cell', () => {
let wrapper;
let editor;
let getPos;
const createWrapper = async () => {
wrapper = shallowMountExtended(TableCellWrapper, {
propsData: {
editor,
getPos,
},
});
};
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 setCurrentPositionInCell = () => {
const { $cursor } = editor.state.selection;
getPos.mockReturnValue($cursor.pos - $cursor.parentOffset - 1);
};
const mockDropdownHide = () => {
/*
* TODO: Replace this method with using the scoped hide function
* provided by BootstrapVue https://bootstrap-vue.org/docs/components/dropdown.
* GitLab UI is not exposing it in the default scope
*/
findDropdown().vm.hide = jest.fn();
};
beforeEach(() => {
getPos = jest.fn();
editor = createTestEditor({});
});
afterEach(() => {
wrapper.destroy();
});
it('renders a td node-view-wrapper with relative position', () => {
createWrapper();
expect(wrapper.findComponent(NodeViewWrapper).classes()).toEqual(['gl-relative']);
expect(wrapper.findComponent(NodeViewWrapper).props().as).toBe('td');
});
it('displays dropdown when selection cursor is on the cell', async () => {
setCurrentPositionInCell();
createWrapper();
await wrapper.vm.$nextTick();
expect(findDropdown().props()).toMatchObject({
category: 'tertiary',
icon: 'chevron-down',
size: 'small',
split: false,
});
expect(findDropdown().attributes()).toMatchObject({
boundary: 'viewport',
'no-caret': '',
});
});
it('does not display dropdown when selection cursor is not on the cell', async () => {
createWrapper();
await wrapper.vm.$nextTick();
expect(findDropdown().exists()).toBe(false);
});
describe('when dropdown is visible', () => {
beforeEach(async () => {
setCurrentPositionInCell();
getSelectedRect.mockReturnValue({
map: {
height: 1,
width: 1,
},
});
createWrapper();
await wrapper.vm.$nextTick();
mockDropdownHide();
});
it.each`
dropdownItemLabel | commandName
${'Insert column before'} | ${'addColumnBefore'}
${'Insert column after'} | ${'addColumnAfter'}
${'Insert row before'} | ${'addRowBefore'}
${'Insert row after'} | ${'addRowAfter'}
${'Delete table'} | ${'deleteTable'}
`(
'executes $commandName when $dropdownItemLabel button is clicked',
({ commandName, dropdownItemLabel }) => {
const mocks = mockChainedCommands(editor, [commandName, 'run']);
findDropdownItemWithLabel(dropdownItemLabel).vm.$emit('click');
expect(mocks[commandName]).toHaveBeenCalled();
},
);
it('does not allow deleting rows and columns', async () => {
expect(findDropdownItemWithLabelExists('Delete row')).toBe(false);
expect(findDropdownItemWithLabelExists('Delete column')).toBe(false);
});
it('allows deleting rows when there are more than 2 rows in the table', async () => {
const mocks = mockChainedCommands(editor, ['deleteRow', 'run']);
getSelectedRect.mockReturnValue({
map: {
height: 3,
},
});
emitEditorEvent({ tiptapEditor: editor, event: 'selectionUpdate' });
await wrapper.vm.$nextTick();
findDropdownItemWithLabel('Delete row').vm.$emit('click');
expect(mocks.deleteRow).toHaveBeenCalled();
});
it('allows deleting columns when there are more than 1 column in the table', async () => {
const mocks = mockChainedCommands(editor, ['deleteColumn', 'run']);
getSelectedRect.mockReturnValue({
map: {
width: 2,
},
});
emitEditorEvent({ tiptapEditor: editor, event: 'selectionUpdate' });
await wrapper.vm.$nextTick();
findDropdownItemWithLabel('Delete column').vm.$emit('click');
expect(mocks.deleteColumn).toHaveBeenCalled();
});
});
});
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