Commit 2520e560 authored by Paul Slaughter's avatar Paul Slaughter

Merge branch 'refactor/inject-tiptap-editor-instance' into 'master'

Refactor: Do not include Tiptap Editor class in Vue reactivity

See merge request gitlab-org/gitlab!66540
parents 99f91ccc a1713d26
......@@ -10,6 +10,11 @@ export default {
TiptapEditorContent,
TopToolbar,
},
provide() {
return {
tiptapEditor: this.contentEditor.tiptapEditor,
};
},
props: {
contentEditor: {
type: ContentEditor,
......@@ -38,7 +43,7 @@ export default {
class="md-area"
:class="{ 'is-focused': contentEditor.tiptapEditor.isFocused }"
>
<top-toolbar ref="toolbar" class="gl-mb-4" :content-editor="contentEditor" />
<top-toolbar ref="toolbar" class="gl-mb-4" />
<tiptap-editor-content class="md" :editor="contentEditor.tiptapEditor" />
</div>
</div>
......
<script>
import { debounce } from 'lodash';
export const tiptapToComponentMap = {
update: 'docUpdate',
selectionUpdate: 'selectionUpdate',
transaction: 'transaction',
};
const getComponentEventName = (tiptapEventName) => tiptapToComponentMap[tiptapEventName];
export default {
inject: ['tiptapEditor'],
created() {
this.disposables = [];
Object.keys(tiptapToComponentMap).forEach((tiptapEvent) => {
const eventHandler = debounce((params) => this.handleTipTapEvent(tiptapEvent, params), 100);
this.tiptapEditor?.on(tiptapEvent, eventHandler);
this.disposables.push(() => this.tiptapEditor?.off(tiptapEvent, eventHandler));
});
},
beforeDestroy() {
this.disposables.forEach((dispose) => dispose());
},
methods: {
handleTipTapEvent(tiptapEvent, params) {
this.$emit(getComponentEventName(tiptapEvent), params);
},
},
render() {
return this.$slots.default;
},
};
</script>
<script>
import { GlButton, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
import { Editor as TiptapEditor } from '@tiptap/vue-2';
import EditorStateObserver from './editor_state_observer.vue';
export default {
components: {
GlButton,
EditorStateObserver,
},
directives: {
GlTooltip,
},
inject: ['tiptapEditor'],
props: {
iconName: {
type: String,
required: true,
},
tiptapEditor: {
type: TiptapEditor,
required: true,
},
contentType: {
type: String,
required: true,
......@@ -32,12 +30,15 @@ export default {
default: '',
},
},
computed: {
isActive() {
return this.tiptapEditor.isActive(this.contentType) && this.tiptapEditor.isFocused;
},
data() {
return {
isActive: null,
};
},
methods: {
updateActive({ editor }) {
this.isActive = editor.isActive(this.contentType) && editor.isFocused;
},
execute() {
const { contentType } = this;
......@@ -51,6 +52,7 @@ export default {
};
</script>
<template>
<editor-state-observer @transaction="updateActive">
<gl-button
v-gl-tooltip
category="tertiary"
......@@ -62,4 +64,5 @@ export default {
:icon="iconName"
@click="execute"
/>
</editor-state-observer>
</template>
......@@ -8,7 +8,6 @@ import {
GlDropdownItem,
GlTooltipDirective as GlTooltip,
} from '@gitlab/ui';
import { Editor as TiptapEditor } from '@tiptap/vue-2';
import { acceptedMimes } from '../extensions/image';
import { getImageAlt } from '../services/utils';
......@@ -24,12 +23,7 @@ export default {
directives: {
GlTooltip,
},
props: {
tiptapEditor: {
type: TiptapEditor,
required: true,
},
},
inject: ['tiptapEditor'],
data() {
return {
imgSrc: '',
......
......@@ -8,9 +8,9 @@ import {
GlDropdownItem,
GlTooltipDirective as GlTooltip,
} from '@gitlab/ui';
import { Editor as TiptapEditor } from '@tiptap/vue-2';
import Link from '../extensions/link';
import { hasSelection } from '../services/utils';
import EditorStateObserver from './editor_state_observer.vue';
export default {
components: {
......@@ -20,34 +20,25 @@ export default {
GlDropdownDivider,
GlDropdownItem,
GlButton,
EditorStateObserver,
},
directives: {
GlTooltip,
},
props: {
tiptapEditor: {
type: TiptapEditor,
required: true,
},
},
inject: ['tiptapEditor'],
data() {
return {
linkHref: '',
isActive: false,
};
},
computed: {
isActive() {
return this.tiptapEditor.isActive(Link.name);
},
},
mounted() {
this.tiptapEditor.on('selectionUpdate', ({ editor }) => {
methods: {
updateLinkState({ editor }) {
const { canonicalSrc, href } = editor.getAttributes(Link.name);
this.isActive = editor.isActive(Link.name);
this.linkHref = canonicalSrc || href;
});
},
methods: {
updateLink() {
this.tiptapEditor
.chain()
......@@ -78,6 +69,7 @@ export default {
};
</script>
<template>
<editor-state-observer @transaction="updateLinkState">
<gl-dropdown
v-gl-tooltip
:aria-label="__('Insert link')"
......@@ -100,4 +92,5 @@ export default {
{{ __('Remove link') }}
</gl-dropdown-item>
</gl-dropdown>
</editor-state-observer>
</template>
<script>
import { GlDropdown, GlDropdownDivider, GlDropdownForm, GlButton } from '@gitlab/ui';
import { Editor as TiptapEditor } from '@tiptap/vue-2';
import { __, sprintf } from '~/locale';
import { clamp } from '../services/utils';
......@@ -18,12 +17,7 @@ export default {
GlDropdownForm,
GlButton,
},
props: {
tiptapEditor: {
type: TiptapEditor,
required: true,
},
},
inject: ['tiptapEditor'],
data() {
return {
maxRows: MIN_ROWS,
......
<script>
import { GlDropdown, GlDropdownItem, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
import { Editor as TiptapEditor } from '@tiptap/vue-2';
import { __ } from '~/locale';
import { TEXT_STYLE_DROPDOWN_ITEMS } from '../constants';
import EditorStateObserver from './editor_state_observer.vue';
export default {
components: {
GlDropdown,
GlDropdownItem,
EditorStateObserver,
},
directives: {
GlTooltip,
},
props: {
tiptapEditor: {
type: TiptapEditor,
required: true,
},
inject: ['tiptapEditor'],
data() {
return {
activeItem: null,
};
},
computed: {
activeItem() {
return TEXT_STYLE_DROPDOWN_ITEMS.find((item) =>
this.tiptapEditor.isActive(item.contentType, item.commandParams),
);
},
activeItemLabel() {
const { activeItem } = this;
......@@ -31,6 +27,11 @@ export default {
},
},
methods: {
updateActiveItem({ editor }) {
this.activeItem = TEXT_STYLE_DROPDOWN_ITEMS.find((item) =>
editor.isActive(item.contentType, item.commandParams),
);
},
execute(item) {
const { editorCommand, contentType, commandParams } = item;
const value = commandParams?.level;
......@@ -38,8 +39,8 @@ export default {
if (editorCommand) {
this.tiptapEditor
.chain()
.focus()
[editorCommand](commandParams || {})
.focus()
.run();
}
......@@ -56,6 +57,7 @@ export default {
};
</script>
<template>
<editor-state-observer @transaction="updateActiveItem">
<gl-dropdown
v-gl-tooltip="$options.i18n.placeholder"
size="small"
......@@ -72,4 +74,5 @@ export default {
{{ item.label }}
</gl-dropdown-item>
</gl-dropdown>
</editor-state-observer>
</template>
<script>
import Tracking from '~/tracking';
import { CONTENT_EDITOR_TRACKING_LABEL, TOOLBAR_CONTROL_TRACKING_ACTION } from '../constants';
import { ContentEditor } from '../services/content_editor';
import Divider from './divider.vue';
import ToolbarButton from './toolbar_button.vue';
import ToolbarImageButton from './toolbar_image_button.vue';
......@@ -23,12 +22,6 @@ export default {
Divider,
},
mixins: [trackingMixin],
props: {
contentEditor: {
type: ContentEditor,
required: true,
},
},
methods: {
trackToolbarControlExecution({ contentType: property, value }) {
this.track(TOOLBAR_CONTROL_TRACKING_ACTION, {
......@@ -45,7 +38,6 @@ export default {
>
<toolbar-text-style-dropdown
data-testid="text-styles"
:tiptap-editor="contentEditor.tiptapEditor"
@execute="trackToolbarControlExecution"
/>
<divider />
......@@ -55,7 +47,6 @@ export default {
icon-name="bold"
editor-command="toggleBold"
:label="__('Bold text')"
:tiptap-editor="contentEditor.tiptapEditor"
@execute="trackToolbarControlExecution"
/>
<toolbar-button
......@@ -64,7 +55,6 @@ export default {
icon-name="italic"
editor-command="toggleItalic"
:label="__('Italic text')"
:tiptap-editor="contentEditor.tiptapEditor"
@execute="trackToolbarControlExecution"
/>
<toolbar-button
......@@ -73,7 +63,6 @@ export default {
icon-name="strikethrough"
editor-command="toggleStrike"
:label="__('Strikethrough')"
:tiptap-editor="contentEditor.tiptapEditor"
@execute="trackToolbarControlExecution"
/>
<toolbar-button
......@@ -82,19 +71,13 @@ export default {
icon-name="code"
editor-command="toggleCode"
:label="__('Code')"
:tiptap-editor="contentEditor.tiptapEditor"
@execute="trackToolbarControlExecution"
/>
<toolbar-link-button
data-testid="link"
:tiptap-editor="contentEditor.tiptapEditor"
@execute="trackToolbarControlExecution"
/>
<toolbar-link-button data-testid="link" @execute="trackToolbarControlExecution" />
<divider />
<toolbar-image-button
ref="imageButton"
data-testid="image"
:tiptap-editor="contentEditor.tiptapEditor"
@execute="trackToolbarControlExecution"
/>
<toolbar-button
......@@ -103,7 +86,6 @@ export default {
icon-name="quote"
editor-command="toggleBlockquote"
:label="__('Insert a quote')"
:tiptap-editor="contentEditor.tiptapEditor"
@execute="trackToolbarControlExecution"
/>
<toolbar-button
......@@ -112,7 +94,6 @@ export default {
icon-name="doc-code"
editor-command="toggleCodeBlock"
:label="__('Insert a code block')"
:tiptap-editor="contentEditor.tiptapEditor"
@execute="trackToolbarControlExecution"
/>
<toolbar-button
......@@ -121,7 +102,6 @@ export default {
icon-name="list-bulleted"
editor-command="toggleBulletList"
:label="__('Add a bullet list')"
:tiptap-editor="contentEditor.tiptapEditor"
@execute="trackToolbarControlExecution"
/>
<toolbar-button
......@@ -130,7 +110,6 @@ export default {
icon-name="list-numbered"
editor-command="toggleOrderedList"
:label="__('Add a numbered list')"
:tiptap-editor="contentEditor.tiptapEditor"
@execute="trackToolbarControlExecution"
/>
<toolbar-button
......@@ -139,13 +118,9 @@ export default {
icon-name="dash"
editor-command="setHorizontalRule"
:label="__('Add a horizontal rule')"
:tiptap-editor="contentEditor.tiptapEditor"
@execute="trackToolbarControlExecution"
/>
<toolbar-table-button
:tiptap-editor="contentEditor.tiptapEditor"
@execute="trackToolbarControlExecution"
/>
<toolbar-table-button @execute="trackToolbarControlExecution" />
</div>
</template>
<style>
......
......@@ -134,7 +134,6 @@ export default {
isContentEditorLoading: true,
useContentEditor: false,
commitMessage: '',
contentEditor: null,
isDirty: false,
contentEditorRenderFailed: false,
};
......
......@@ -38,10 +38,10 @@ describe('ContentEditor', () => {
expect(editorContent.classes()).toContain('md');
});
it('renders top toolbar component and attaches editor instance', () => {
it('renders top toolbar component', () => {
createWrapper(editor);
expect(wrapper.findComponent(TopToolbar).props().contentEditor).toBe(editor);
expect(wrapper.findComponent(TopToolbar).exists()).toBe(true);
});
it.each`
......
import { shallowMount } from '@vue/test-utils';
import { each } from 'lodash';
import EditorStateObserver, {
tiptapToComponentMap,
} from '~/content_editor/components/editor_state_observer.vue';
import { createTestEditor } from '../test_utils';
describe('content_editor/components/editor_state_observer', () => {
let tiptapEditor;
let wrapper;
let onDocUpdateListener;
let onSelectionUpdateListener;
let onTransactionListener;
const buildEditor = () => {
tiptapEditor = createTestEditor();
jest.spyOn(tiptapEditor, 'on');
};
const buildWrapper = () => {
wrapper = shallowMount(EditorStateObserver, {
provide: { tiptapEditor },
listeners: {
docUpdate: onDocUpdateListener,
selectionUpdate: onSelectionUpdateListener,
transaction: onTransactionListener,
},
});
};
beforeEach(() => {
onDocUpdateListener = jest.fn();
onSelectionUpdateListener = jest.fn();
onTransactionListener = jest.fn();
buildEditor();
buildWrapper();
});
afterEach(() => {
wrapper.destroy();
});
describe('when editor content changes', () => {
it('emits update, selectionUpdate, and transaction events', () => {
const content = '<p>My paragraph</p>';
tiptapEditor.commands.insertContent(content);
expect(onDocUpdateListener).toHaveBeenCalledWith(
expect.objectContaining({ editor: tiptapEditor }),
);
expect(onSelectionUpdateListener).toHaveBeenCalledWith(
expect.objectContaining({ editor: tiptapEditor }),
);
expect(onSelectionUpdateListener).toHaveBeenCalledWith(
expect.objectContaining({ editor: tiptapEditor }),
);
});
});
describe('when component is destroyed', () => {
it('removes onTiptapDocUpdate and onTiptapSelectionUpdate hooks', () => {
jest.spyOn(tiptapEditor, 'off');
wrapper.destroy();
each(tiptapToComponentMap, (_, tiptapEvent) => {
expect(tiptapEditor.off).toHaveBeenCalledWith(
tiptapEvent,
tiptapEditor.on.mock.calls.find(([eventName]) => eventName === tiptapEvent)[1],
);
});
});
});
});
import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue';
import ToolbarButton from '~/content_editor/components/toolbar_button.vue';
import { createTestEditor, mockChainedCommands } from '../test_utils';
import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils';
describe('content_editor/components/toolbar_button', () => {
let wrapper;
......@@ -20,9 +21,12 @@ describe('content_editor/components/toolbar_button', () => {
wrapper = shallowMount(ToolbarButton, {
stubs: {
GlButton,
EditorStateObserver,
},
propsData: {
provide: {
tiptapEditor,
},
propsData: {
contentType: CONTENT_TYPE,
iconName: ICON_NAME,
label: LABEL,
......@@ -51,14 +55,20 @@ describe('content_editor/components/toolbar_button', () => {
${{ isActive: true, isFocused: true }} | ${'button is active'} | ${true}
${{ isActive: false, isFocused: true }} | ${'button is not active'} | ${false}
${{ isActive: true, isFocused: false }} | ${'button is not active '} | ${false}
`('$outcomeDescription when when editor state is $editorState', ({ editorState, outcome }) => {
`(
'$outcomeDescription when when editor state is $editorState',
async ({ editorState, outcome }) => {
tiptapEditor.isActive.mockReturnValueOnce(editorState.isActive);
tiptapEditor.isFocused = editorState.isFocused;
buildWrapper();
await emitEditorEvent({ event: 'transaction', tiptapEditor });
expect(findButton().classes().includes('active')).toBe(outcome);
expect(tiptapEditor.isActive).toHaveBeenCalledWith(CONTENT_TYPE);
});
},
);
describe('when button is clicked', () => {
it('executes the content type command when executeCommand = true', async () => {
......
......@@ -10,7 +10,7 @@ describe('content_editor/components/toolbar_image_button', () => {
const buildWrapper = () => {
wrapper = mountExtended(ToolbarImageButton, {
propsData: {
provide: {
tiptapEditor: editor,
},
});
......
......@@ -3,7 +3,7 @@ import { mountExtended } from 'helpers/vue_test_utils_helper';
import ToolbarLinkButton from '~/content_editor/components/toolbar_link_button.vue';
import Link from '~/content_editor/extensions/link';
import { hasSelection } from '~/content_editor/services/utils';
import { createTestEditor, mockChainedCommands } from '../test_utils';
import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils';
jest.mock('~/content_editor/services/utils');
......@@ -13,7 +13,7 @@ describe('content_editor/components/toolbar_link_button', () => {
const buildWrapper = () => {
wrapper = mountExtended(ToolbarLinkButton, {
propsData: {
provide: {
tiptapEditor: editor,
},
});
......@@ -43,6 +43,8 @@ describe('content_editor/components/toolbar_link_button', () => {
beforeEach(async () => {
jest.spyOn(editor, 'isActive').mockReturnValueOnce(true);
buildWrapper();
await emitEditorEvent({ event: 'transaction', tiptapEditor: editor });
});
it('sets dropdown as active when link extension is active', () => {
......@@ -88,7 +90,7 @@ describe('content_editor/components/toolbar_link_button', () => {
href: '/username/my-project/uploads/abcdefgh133535/my-file.zip',
});
await editor.emit('selectionUpdate', { editor });
await emitEditorEvent({ event: 'transaction', tiptapEditor: editor });
expect(findLinkURLInput().element.value).toEqual('uploads/my-file.zip');
});
......@@ -98,7 +100,7 @@ describe('content_editor/components/toolbar_link_button', () => {
href: 'https://gitlab.com',
});
await editor.emit('selectionUpdate', { editor });
await emitEditorEvent({ event: 'transaction', tiptapEditor: editor });
expect(findLinkURLInput().element.value).toEqual('https://gitlab.com');
});
......
......@@ -9,7 +9,7 @@ describe('content_editor/components/toolbar_table_button', () => {
const buildWrapper = () => {
wrapper = mountExtended(ToolbarTableButton, {
propsData: {
provide: {
tiptapEditor: editor,
},
});
......
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue';
import ToolbarTextStyleDropdown from '~/content_editor/components/toolbar_text_style_dropdown.vue';
import { TEXT_STYLE_DROPDOWN_ITEMS } from '~/content_editor/constants';
import Heading from '~/content_editor/extensions/heading';
import { createTestEditor, mockChainedCommands } from '../test_utils';
import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils';
describe('content_editor/components/toolbar_headings_dropdown', () => {
describe('content_editor/components/toolbar_text_style_dropdown', () => {
let wrapper;
let tiptapEditor;
......@@ -22,9 +23,12 @@ describe('content_editor/components/toolbar_headings_dropdown', () => {
stubs: {
GlDropdown,
GlDropdownItem,
EditorStateObserver,
},
propsData: {
provide: {
tiptapEditor,
},
propsData: {
...propsData,
},
});
......@@ -50,7 +54,7 @@ describe('content_editor/components/toolbar_headings_dropdown', () => {
describe('when there is an active item ', () => {
let activeTextStyle;
beforeEach(() => {
beforeEach(async () => {
[, activeTextStyle] = TEXT_STYLE_DROPDOWN_ITEMS;
tiptapEditor.isActive.mockImplementation(
......@@ -59,6 +63,7 @@ describe('content_editor/components/toolbar_headings_dropdown', () => {
);
buildWrapper();
await emitEditorEvent({ event: 'transaction', tiptapEditor });
});
it('displays the active text style label as the dropdown toggle text ', () => {
......@@ -79,9 +84,10 @@ describe('content_editor/components/toolbar_headings_dropdown', () => {
});
describe('when there isn’t an active item', () => {
beforeEach(() => {
beforeEach(async () => {
tiptapEditor.isActive.mockReturnValue(false);
buildWrapper();
await emitEditorEvent({ event: 'transaction', tiptapEditor });
});
it('sets dropdown as disabled', () => {
......
......@@ -6,34 +6,19 @@ import {
TOOLBAR_CONTROL_TRACKING_ACTION,
CONTENT_EDITOR_TRACKING_LABEL,
} from '~/content_editor/constants';
import { createContentEditor } from '~/content_editor/services/create_content_editor';
describe('content_editor/components/top_toolbar', () => {
let wrapper;
let contentEditor;
let trackingSpy;
const buildEditor = () => {
contentEditor = createContentEditor({ renderMarkdown: () => true });
};
const buildWrapper = () => {
wrapper = extendedWrapper(
shallowMount(TopToolbar, {
propsData: {
contentEditor,
},
}),
);
wrapper = extendedWrapper(shallowMount(TopToolbar));
};
beforeEach(() => {
trackingSpy = mockTracking(undefined, null, jest.spyOn);
});
beforeEach(() => {
buildEditor();
});
afterEach(() => {
wrapper.destroy();
});
......@@ -60,7 +45,6 @@ describe('content_editor/components/top_toolbar', () => {
it('renders the toolbar control with the provided properties', () => {
expect(wrapper.findByTestId(testId).props()).toEqual({
...controlProps,
tiptapEditor: contentEditor.tiptapEditor,
});
});
......
......@@ -4,6 +4,7 @@ import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import { Editor } from '@tiptap/vue-2';
import { builders, eq } from 'prosemirror-test-builder';
import { nextTick } from 'vue';
export const createDocBuilder = ({ tiptapEditor, names = {} }) => {
const docBuilders = builders(tiptapEditor.schema, {
......@@ -14,6 +15,12 @@ export const createDocBuilder = ({ tiptapEditor, names = {} }) => {
return { eq, builders: docBuilders };
};
export const emitEditorEvent = ({ tiptapEditor, event, params = {} }) => {
tiptapEditor.emit(event, { editor: tiptapEditor, ...params });
return nextTick();
};
/**
* Creates an instance of the Tiptap Editor class
* with a minimal configuration 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