Commit 00b69f21 authored by Andrew Fontaine's avatar Andrew Fontaine

Merge branch 'insert-and-edit-links-content-editor' into 'master'

Insert and edit links in the Content Editor

See merge request gitlab-org/gitlab!62125
parents 0f190487 3243e275
<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();
this.$emit('execute', { contentType: linkContentType });
},
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();
this.$emit('execute', { contentType: linkContentType });
},
},
};
</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;
};
......@@ -19817,6 +19817,9 @@ msgstr ""
msgid "Link Sentry to GitLab to discover and view the errors your application generates."
msgstr ""
msgid "Link URL"
msgstr ""
msgid "Link an external wiki from the project's sidebar. %{docs_link}"
msgstr ""
......@@ -27363,6 +27366,9 @@ msgstr ""
msgid "Remove limit"
msgstr ""
msgid "Remove link"
msgstr ""
msgid "Remove list"
msgstr ""
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`content_editor/components/toolbar_link_button renders dropdown component 1`] = `
"<div class=\\"dropdown b-dropdown gl-new-dropdown btn-group\\">
<!----><button aria-haspopup=\\"true\\" aria-expanded=\\"false\\" type=\\"button\\" class=\\"btn dropdown-toggle btn-default btn-sm gl-button gl-dropdown-toggle btn-default-tertiary dropdown-icon-only\\">
<!----> <svg data-testid=\\"link-icon\\" role=\\"img\\" aria-hidden=\\"true\\" class=\\"dropdown-icon gl-icon s16\\">
<use href=\\"#link\\"></use>
</svg> <span class=\\"gl-new-dropdown-button-text\\"></span> <svg data-testid=\\"chevron-down-icon\\" role=\\"img\\" aria-hidden=\\"true\\" class=\\"gl-button-icon dropdown-chevron gl-icon s16\\">
<use href=\\"#chevron-down\\"></use>
</svg></button>
<ul role=\\"menu\\" tabindex=\\"-1\\" class=\\"dropdown-menu\\">
<div class=\\"gl-new-dropdown-inner\\">
<!---->
<div class=\\"gl-new-dropdown-contents\\">
<li role=\\"presentation\\" class=\\"gl-px-3!\\">
<form tabindex=\\"-1\\" class=\\"b-dropdown-form gl-p-0\\">
<div placeholder=\\"Link URL\\">
<div role=\\"group\\" class=\\"input-group\\">
<!---->
<!----> <input type=\\"text\\" placeholder=\\"Link URL\\" class=\\"gl-form-input form-control\\">
<div class=\\"input-group-append\\"><button type=\\"button\\" class=\\"btn btn-confirm btn-md gl-button\\">
<!---->
<!----> <span class=\\"gl-button-text\\">Apply</span></button></div>
<!---->
</div>
</div>
</form>
</li>
<!---->
<!---->
</div>
<!---->
</div>
</ul>
</div>"
`;
import { GlDropdown, GlDropdownDivider, GlFormInputGroup, GlButton } from '@gitlab/ui';
import { mountExtended } 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 = mountExtended(ToolbarLinkButton, {
propsData: {
tiptapEditor: editor,
},
stubs: {
GlFormInputGroup,
},
});
};
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findDropdownDivider = () => wrapper.findComponent(GlDropdownDivider);
const findLinkURLInput = () => wrapper.findComponent(GlFormInputGroup).find('input[type="text"]');
const findApplyLinkButton = () => wrapper.findComponent(GlButton);
const findRemoveLinkButton = () => wrapper.findByText('Remove link');
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', async () => {
const commands = mockChainedCommands(editor, ['focus', 'unsetLink', 'run']);
await findRemoveLinkButton().trigger('click');
expect(commands.unsetLink).toHaveBeenCalled();
expect(commands.focus).toHaveBeenCalled();
expect(commands.run).toHaveBeenCalled();
});
it('updates the link with a new link when "Apply" button is clicked', async () => {
const commands = mockChainedCommands(editor, ['focus', 'unsetLink', 'setLink', 'run']);
await findLinkURLInput().setValue('https://example');
await findApplyLinkButton().trigger('click');
expect(commands.focus).toHaveBeenCalled();
expect(commands.unsetLink).toHaveBeenCalled();
expect(commands.setLink).toHaveBeenCalledWith({ href: 'https://example' });
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);
});
it('sets the link to the value in the URL input when "Apply" button is clicked', async () => {
const commands = mockChainedCommands(editor, ['focus', 'unsetLink', 'setLink', 'run']);
await findLinkURLInput().setValue('https://example');
await findApplyLinkButton().trigger('click');
expect(commands.focus).toHaveBeenCalled();
expect(commands.setLink).toHaveBeenCalledWith({ href: 'https://example' });
expect(commands.run).toHaveBeenCalled();
});
});
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