Commit f1fdf339 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch 'edit-table-structure-poc' into 'master'

Edit a table’s structure in the Content Editor

See merge request gitlab-org/gitlab!68473
parents da6da940 41443430
<script>
import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui';
import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2';
import { selectedRect as getSelectedRect } from 'prosemirror-tables';
import { __ } from '~/locale';
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();
},
},
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'),
},
};
</script>
<template>
<node-view-wrapper class="gl-relative gl-padding-5 gl-min-w-10" 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
text-sr-only
:text="$options.i18n.editTableActions"
:popper-opts="{ positionFixed: true }"
@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 @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" @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>
</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 */
......
......@@ -245,11 +245,16 @@ $gl-line-height-42: px-to-rem(42px);
width: $grid-size * 28;
}
// Will be moved to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1491
// Will be removed after https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/2347 is merged
.gl-min-w-8 {
min-width: $gl-spacing-scale-8;
}
// Will be removed after https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/2347 is merged
.gl-min-w-10 {
min-width: $gl-spacing-scale-10;
}
// Will both be moved to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1526
.gl-opacity-6 {
opacity: 0.6;
......
......@@ -10780,6 +10780,9 @@ msgstr ""
msgid "Delete badge"
msgstr ""
msgid "Delete column"
msgstr ""
msgid "Delete comment"
msgstr ""
......@@ -10810,6 +10813,9 @@ msgstr ""
msgid "Delete project. Are you ABSOLUTELY SURE?"
msgstr ""
msgid "Delete row"
msgstr ""
msgid "Delete self monitoring project"
msgstr ""
......@@ -10828,6 +10834,9 @@ msgstr ""
msgid "Delete subscription"
msgstr ""
msgid "Delete table"
msgstr ""
msgid "Delete this attachment"
msgstr ""
......@@ -12128,6 +12137,9 @@ msgstr ""
msgid "Edit sidebar"
msgstr ""
msgid "Edit table"
msgstr ""
msgid "Edit this file only."
msgstr ""
......@@ -17811,6 +17823,12 @@ msgstr ""
msgid "Insert code"
msgstr ""
msgid "Insert column after"
msgstr ""
msgid "Insert column before"
msgstr ""
msgid "Insert image"
msgstr ""
......@@ -17820,6 +17838,12 @@ msgstr ""
msgid "Insert link"
msgstr ""
msgid "Insert row after"
msgstr ""
msgid "Insert row before"
msgstr ""
msgid "Insert suggestion"
msgstr ""
......
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()).toContain('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