Commit 0eecc2c9 authored by Enrique Alcantara's avatar Enrique Alcantara Committed by Enrique Alcántara

Insert and edit links in the Content Editor

parent 27e6117f
<script>
import {
GlDropdown,
GlDropdownForm,
GlButton,
GlFormInputGroup,
GlDropdownDivider,
GlDropdownItem,
} from '@gitlab/ui';
import { Editor as TiptapEditor } from '@tiptap/vue-2';
import { hasSelection } from '../services/utils';
export const linkContentType = 'link';
export default {
components: {
GlDropdown,
GlDropdownForm,
GlFormInputGroup,
GlDropdownDivider,
GlDropdownItem,
GlButton,
},
props: {
tiptapEditor: {
type: TiptapEditor,
required: true,
},
},
data() {
return {
linkHref: '',
};
},
computed: {
isActive() {
return this.tiptapEditor.isActive(linkContentType);
},
},
mounted() {
this.tiptapEditor.on('selectionUpdate', ({ editor }) => {
const { href } = editor.getAttributes(linkContentType);
this.linkHref = href;
});
},
methods: {
updateLink() {
this.tiptapEditor.chain().focus().unsetLink().setLink({ href: this.linkHref }).run();
},
selectLink() {
const { tiptapEditor } = this;
// a selection has already been made by the user, so do nothing
if (!hasSelection(tiptapEditor)) {
tiptapEditor.chain().focus().extendMarkRange(linkContentType).run();
}
},
removeLink() {
this.tiptapEditor.chain().focus().unsetLink().run();
},
},
};
</script>
<template>
<gl-dropdown
:toggle-class="{ active: isActive }"
size="small"
category="tertiary"
icon="link"
@show="selectLink()"
>
<gl-dropdown-form class="gl-px-3!">
<gl-form-input-group v-model="linkHref" :placeholder="__('Link URL')">
<template #append>
<gl-button variant="confirm" @click="updateLink">{{ __('Apply') }}</gl-button>
</template>
</gl-form-input-group>
</gl-dropdown-form>
<gl-dropdown-divider v-if="isActive" />
<gl-dropdown-item v-if="isActive" @click="removeLink()">
{{ __('Remove link') }}
</gl-dropdown-item>
</gl-dropdown>
</template>
......@@ -4,6 +4,7 @@ import { CONTENT_EDITOR_TRACKING_LABEL, TOOLBAR_CONTROL_TRACKING_ACTION } from '
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 ToolbarTextStyleDropdown from './toolbar_text_style_dropdown.vue';
const trackingMixin = Tracking.mixin({
......@@ -14,6 +15,7 @@ export default {
components: {
ToolbarButton,
ToolbarTextStyleDropdown,
ToolbarLinkButton,
Divider,
},
mixins: [trackingMixin],
......@@ -70,6 +72,7 @@ export default {
:tiptap-editor="contentEditor.tiptapEditor"
@execute="trackToolbarControlExecution"
/>
<toolbar-link-button :tiptap-editor="contentEditor.tiptapEditor" />
<divider />
<toolbar-button
data-testid="blockquote"
......
import { Link } from '@tiptap/extension-link';
import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
export const tiptapExtension = Link;
export const tiptapExtension = Link.configure({
openOnClick: false,
});
export const serializer = defaultMarkdownSerializer.marks.link;
export const hasSelection = (tiptapEditor) => {
const { from, to } = tiptapEditor.state.selection;
return from < to;
};
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`content_editor/components/toolbar_link_button renders dropdown component 1`] = `
"<gl-dropdown-stub headertext=\\"\\" hideheaderborder=\\"true\\" text=\\"\\" category=\\"tertiary\\" variant=\\"default\\" size=\\"small\\" icon=\\"link\\" toggleclass=\\"[object Object]\\">
<gl-dropdown-form-stub class=\\"gl-px-3!\\">
<div placeholder=\\"Link URL\\">
<b-input-group-stub tag=\\"div\\">
<!---->
<b-form-input-stub value=\\"\\" placeholder=\\"Link URL\\" debounce=\\"0\\" type=\\"text\\" class=\\"gl-form-input\\"></b-form-input-stub>
<b-input-group-append-stub tag=\\"div\\">
<gl-button-stub category=\\"primary\\" variant=\\"confirm\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\">Apply</gl-button-stub>
</b-input-group-append-stub>
</b-input-group-stub>
</div>
</gl-dropdown-form-stub>
<!---->
<!---->
</gl-dropdown-stub>"
`;
import { GlDropdown, GlDropdownDivider, GlDropdownItem, GlFormInputGroup } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ToolbarLinkButton from '~/content_editor/components/toolbar_link_button.vue';
import { tiptapExtension as Link } from '~/content_editor/extensions/link';
import { hasSelection } from '~/content_editor/services/utils';
import { createTestEditor, mockChainedCommands } from '../test_utils';
jest.mock('~/content_editor/services/utils');
describe('content_editor/components/toolbar_link_button', () => {
let wrapper;
let editor;
const buildWrapper = () => {
wrapper = shallowMountExtended(ToolbarLinkButton, {
propsData: {
tiptapEditor: editor,
},
stubs: {
GlFormInputGroup,
},
});
};
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findDropdownDivider = () => wrapper.findComponent(GlDropdownDivider);
const findDropdownItem = () => wrapper.findComponent(GlDropdownItem);
beforeEach(() => {
editor = createTestEditor({
extensions: [Link],
});
});
afterEach(() => {
editor.destroy();
wrapper.destroy();
});
it('renders dropdown component', () => {
buildWrapper();
expect(findDropdown().html()).toMatchSnapshot();
});
describe('when there is an active link', () => {
beforeEach(() => {
jest.spyOn(editor, 'isActive');
editor.isActive.mockReturnValueOnce(true);
buildWrapper();
});
it('sets dropdown as active when link extension is active', () => {
expect(findDropdown().props('toggleClass')).toEqual({ active: true });
});
it('displays a remove link dropdown option', () => {
expect(findDropdownDivider().exists()).toBe(true);
expect(wrapper.findByText('Remove link').exists()).toBe(true);
});
it('executes removeLink command when the remove link option is clicked', () => {
const commands = mockChainedCommands(editor, ['focus', 'unsetLink', 'run']);
findDropdownItem().vm.$emit('click');
expect(commands.unsetLink).toHaveBeenCalled();
expect(commands.focus).toHaveBeenCalled();
expect(commands.run).toHaveBeenCalled();
});
});
describe('when there is not an active link', () => {
beforeEach(() => {
jest.spyOn(editor, 'isActive');
editor.isActive.mockReturnValueOnce(false);
buildWrapper();
});
it('does not set dropdown as active', () => {
expect(findDropdown().props('toggleClass')).toEqual({ active: false });
});
it('does not display a remove link dropdown option', () => {
expect(findDropdownDivider().exists()).toBe(false);
expect(wrapper.findByText('Remove link').exists()).toBe(false);
});
});
describe('when the user displays the dropdown', () => {
let commands;
beforeEach(() => {
commands = mockChainedCommands(editor, ['focus', 'extendMarkRange', 'run']);
});
describe('given the user has not selected text', () => {
beforeEach(() => {
hasSelection.mockReturnValueOnce(false);
});
it('the editor selection is extended to the current mark extent', () => {
buildWrapper();
findDropdown().vm.$emit('show');
expect(commands.extendMarkRange).toHaveBeenCalledWith(Link.name);
expect(commands.focus).toHaveBeenCalled();
expect(commands.run).toHaveBeenCalled();
});
});
describe('given the user has selected text', () => {
beforeEach(() => {
hasSelection.mockReturnValueOnce(true);
});
it('the editor does not modify the current selection', () => {
buildWrapper();
findDropdown().vm.$emit('show');
expect(commands.extendMarkRange).not.toHaveBeenCalled();
expect(commands.focus).not.toHaveBeenCalled();
expect(commands.run).not.toHaveBeenCalled();
});
});
});
});
......@@ -21,6 +21,24 @@ export const createTestEditor = ({ extensions = [] }) => {
});
};
export const mockChainedCommands = (editor, commandNames = []) => {
const commandMocks = commandNames.reduce(
(accum, commandName) => ({
...accum,
[commandName]: jest.fn(),
}),
{},
);
Object.keys(commandMocks).forEach((commandName) => {
commandMocks[commandName].mockReturnValue(commandMocks);
});
jest.spyOn(editor, 'chain').mockImplementation(() => commandMocks);
return commandMocks;
};
/**
* Creates a Content Editor extension for testing
* purposes.
......
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