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 { 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'; import { isBlockTablesFeatureEnabled } from '../services/feature_flags';
export default TableCell.extend({ export default TableCell.extend({
content: isBlockTablesFeatureEnabled() ? 'block+' : 'inline*', content: isBlockTablesFeatureEnabled() ? 'block+' : 'inline*',
addNodeView() {
return VueNodeViewRenderer(TableCellWrapper);
},
}); });
...@@ -549,17 +549,12 @@ ...@@ -549,17 +549,12 @@
margin: 0; margin: 0;
font-size: $gl-font-size-small; font-size: $gl-font-size-small;
} }
}
ul.dropdown-menu { .gl-new-dropdown-item {
margin-top: 4px; margin: 0;
margin-bottom: 24px; padding: 0;
padding: 8px 0; line-height: 1rem;
li {
margin: 0;
padding: 0 1px;
}
}
} }
/* AsciiDoc(tor) built-in alignment roles */ /* AsciiDoc(tor) built-in alignment roles */
......
...@@ -245,11 +245,16 @@ $gl-line-height-42: px-to-rem(42px); ...@@ -245,11 +245,16 @@ $gl-line-height-42: px-to-rem(42px);
width: $grid-size * 28; 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 { .gl-min-w-8 {
min-width: $gl-spacing-scale-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 // Will both be moved to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1526
.gl-opacity-6 { .gl-opacity-6 {
opacity: 0.6; opacity: 0.6;
......
...@@ -10780,6 +10780,9 @@ msgstr "" ...@@ -10780,6 +10780,9 @@ msgstr ""
msgid "Delete badge" msgid "Delete badge"
msgstr "" msgstr ""
msgid "Delete column"
msgstr ""
msgid "Delete comment" msgid "Delete comment"
msgstr "" msgstr ""
...@@ -10810,6 +10813,9 @@ msgstr "" ...@@ -10810,6 +10813,9 @@ msgstr ""
msgid "Delete project. Are you ABSOLUTELY SURE?" msgid "Delete project. Are you ABSOLUTELY SURE?"
msgstr "" msgstr ""
msgid "Delete row"
msgstr ""
msgid "Delete self monitoring project" msgid "Delete self monitoring project"
msgstr "" msgstr ""
...@@ -10828,6 +10834,9 @@ msgstr "" ...@@ -10828,6 +10834,9 @@ msgstr ""
msgid "Delete subscription" msgid "Delete subscription"
msgstr "" msgstr ""
msgid "Delete table"
msgstr ""
msgid "Delete this attachment" msgid "Delete this attachment"
msgstr "" msgstr ""
...@@ -12128,6 +12137,9 @@ msgstr "" ...@@ -12128,6 +12137,9 @@ msgstr ""
msgid "Edit sidebar" msgid "Edit sidebar"
msgstr "" msgstr ""
msgid "Edit table"
msgstr ""
msgid "Edit this file only." msgid "Edit this file only."
msgstr "" msgstr ""
...@@ -17811,6 +17823,12 @@ msgstr "" ...@@ -17811,6 +17823,12 @@ msgstr ""
msgid "Insert code" msgid "Insert code"
msgstr "" msgstr ""
msgid "Insert column after"
msgstr ""
msgid "Insert column before"
msgstr ""
msgid "Insert image" msgid "Insert image"
msgstr "" msgstr ""
...@@ -17820,6 +17838,12 @@ msgstr "" ...@@ -17820,6 +17838,12 @@ msgstr ""
msgid "Insert link" msgid "Insert link"
msgstr "" msgstr ""
msgid "Insert row after"
msgstr ""
msgid "Insert row before"
msgstr ""
msgid "Insert suggestion" msgid "Insert suggestion"
msgstr "" 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