Commit 036dd92a authored by Phil Hughes's avatar Phil Hughes

Merge branch 'refactor-event-bus-content-editor' into 'master'

Refactor triggering events in the Content Editor

See merge request gitlab-org/gitlab!81188
parents 119f5983 31977e5d
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import { EditorContent as TiptapEditorContent } from '@tiptap/vue-2';
import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } from '../constants';
import { createContentEditor } from '../services/create_content_editor';
import ContentEditorAlert from './content_editor_alert.vue';
import ContentEditorProvider from './content_editor_provider.vue';
......@@ -55,17 +54,12 @@ export default {
extensions,
serializerConfig,
});
this.contentEditor.on(LOADING_CONTENT_EVENT, this.displayLoadingIndicator);
this.contentEditor.on(LOADING_SUCCESS_EVENT, this.hideLoadingIndicator);
this.contentEditor.on(LOADING_ERROR_EVENT, this.hideLoadingIndicator);
},
mounted() {
this.$emit('initialized', this.contentEditor);
},
beforeDestroy() {
this.contentEditor.dispose();
this.contentEditor.off(LOADING_CONTENT_EVENT, this.displayLoadingIndicator);
this.contentEditor.off(LOADING_SUCCESS_EVENT, this.hideLoadingIndicator);
this.contentEditor.off(LOADING_ERROR_EVENT, this.hideLoadingIndicator);
},
methods: {
displayLoadingIndicator() {
......@@ -91,7 +85,14 @@ export default {
<template>
<content-editor-provider :content-editor="contentEditor">
<div>
<editor-state-observer @docUpdate="notifyChange" @focus="focus" @blur="blur" />
<editor-state-observer
@loading="displayLoadingIndicator"
@loadingSuccess="hideLoadingIndicator"
@loadingError="hideLoadingIndicator"
@docUpdate="notifyChange"
@focus="focus"
@blur="blur"
/>
<content-editor-alert />
<div
data-testid="content-editor"
......
......@@ -8,6 +8,7 @@ export default {
return {
contentEditor,
eventHub: contentEditor.eventHub,
tiptapEditor: contentEditor.tiptapEditor,
};
},
......
<script>
import { debounce } from 'lodash';
import {
LOADING_CONTENT_EVENT,
LOADING_SUCCESS_EVENT,
LOADING_ERROR_EVENT,
ALERT_EVENT,
} from '../constants';
export const tiptapToComponentMap = {
update: 'docUpdate',
......@@ -7,30 +13,48 @@ export const tiptapToComponentMap = {
transaction: 'transaction',
focus: 'focus',
blur: 'blur',
alert: 'alert',
};
export const eventHubEvents = [
ALERT_EVENT,
LOADING_CONTENT_EVENT,
LOADING_SUCCESS_EVENT,
LOADING_ERROR_EVENT,
];
const getComponentEventName = (tiptapEventName) => tiptapToComponentMap[tiptapEventName];
export default {
inject: ['tiptapEditor'],
inject: ['tiptapEditor', 'eventHub'],
created() {
this.disposables = [];
Object.keys(tiptapToComponentMap).forEach((tiptapEvent) => {
const eventHandler = debounce((params) => this.handleTipTapEvent(tiptapEvent, params), 100);
const eventHandler = debounce(
(params) => this.bubbleEvent(getComponentEventName(tiptapEvent), params),
100,
);
this.tiptapEditor?.on(tiptapEvent, eventHandler);
this.disposables.push(() => this.tiptapEditor?.off(tiptapEvent, eventHandler));
});
eventHubEvents.forEach((event) => {
const handler = (...params) => {
this.bubbleEvent(event, ...params);
};
this.eventHub.$on(event, handler);
this.disposables.push(() => this.eventHub?.$off(event, handler));
});
},
beforeDestroy() {
this.disposables.forEach((dispose) => dispose());
},
methods: {
handleTipTapEvent(tiptapEvent, params) {
this.$emit(getComponentEventName(tiptapEvent), params);
bubbleEvent(eventHubEvent, params) {
this.$emit(eventHubEvent, params);
},
},
render() {
......
......@@ -42,9 +42,10 @@ export const TEXT_STYLE_DROPDOWN_ITEMS = [
},
];
export const LOADING_CONTENT_EVENT = 'loadingContent';
export const LOADING_CONTENT_EVENT = 'loading';
export const LOADING_SUCCESS_EVENT = 'loadingSuccess';
export const LOADING_ERROR_EVENT = 'loadingError';
export const ALERT_EVENT = 'alert';
export const PARSE_HTML_PRIORITY_LOWEST = 1;
export const PARSE_HTML_PRIORITY_DEFAULT = 50;
......@@ -56,3 +57,4 @@ export const EXTENSION_PRIORITY_LOWER = 75;
* https://tiptap.dev/guide/custom-extensions/#priority
*/
export const EXTENSION_PRIORITY_DEFAULT = 100;
export const EXTENSION_PRIORITY_HIGHEST = 200;
......@@ -9,15 +9,22 @@ export default Extension.create({
return {
uploadsPath: null,
renderMarkdown: null,
eventHub: null,
};
},
addCommands() {
return {
uploadAttachment: ({ file }) => () => {
const { uploadsPath, renderMarkdown } = this.options;
const { uploadsPath, renderMarkdown, eventHub } = this.options;
return handleFileEvent({ file, uploadsPath, renderMarkdown, editor: this.editor });
return handleFileEvent({
file,
uploadsPath,
renderMarkdown,
editor: this.editor,
eventHub,
});
},
};
},
......@@ -29,23 +36,25 @@ export default Extension.create({
key: new PluginKey('attachment'),
props: {
handlePaste: (_, event) => {
const { uploadsPath, renderMarkdown } = this.options;
const { uploadsPath, renderMarkdown, eventHub } = this.options;
return handleFileEvent({
editor,
file: event.clipboardData.files[0],
uploadsPath,
renderMarkdown,
eventHub,
});
},
handleDrop: (_, event) => {
const { uploadsPath, renderMarkdown } = this.options;
const { uploadsPath, renderMarkdown, eventHub } = this.options;
return handleFileEvent({
editor,
file: event.dataTransfer.files[0],
uploadsPath,
renderMarkdown,
eventHub,
});
},
},
......
import eventHubFactory from '~/helpers/event_hub_factory';
import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } from '../constants';
/* eslint-disable no-underscore-dangle */
export class ContentEditor {
constructor({ tiptapEditor, serializer }) {
constructor({ tiptapEditor, serializer, eventHub }) {
this._tiptapEditor = tiptapEditor;
this._serializer = serializer;
this._eventHub = eventHubFactory();
this._eventHub = eventHub;
}
get tiptapEditor() {
return this._tiptapEditor;
}
get eventHub() {
return this._eventHub;
}
get empty() {
const doc = this.tiptapEditor?.state.doc;
......@@ -23,39 +26,23 @@ export class ContentEditor {
this.tiptapEditor.destroy();
}
once(type, handler) {
this._eventHub.$once(type, handler);
}
on(type, handler) {
this._eventHub.$on(type, handler);
}
emit(type, params = {}) {
this._eventHub.$emit(type, params);
}
off(type, handler) {
this._eventHub.$off(type, handler);
}
disposeAllEvents() {
this._eventHub.dispose();
}
async setSerializedContent(serializedContent) {
const { _tiptapEditor: editor, _serializer: serializer } = this;
const { _tiptapEditor: editor, _serializer: serializer, _eventHub: eventHub } = this;
try {
this._eventHub.$emit(LOADING_CONTENT_EVENT);
eventHub.$emit(LOADING_CONTENT_EVENT);
const document = await serializer.deserialize({
schema: editor.schema,
content: serializedContent,
});
editor.commands.setContent(document);
this._eventHub.$emit(LOADING_SUCCESS_EVENT);
eventHub.$emit(LOADING_SUCCESS_EVENT);
} catch (e) {
this._eventHub.$emit(LOADING_ERROR_EVENT, e);
eventHub.$emit(LOADING_ERROR_EVENT, e);
throw e;
}
}
......
import { Editor } from '@tiptap/vue-2';
import { isFunction } from 'lodash';
import eventHubFactory from '~/helpers/event_hub_factory';
import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants';
import Attachment from '../extensions/attachment';
import Audio from '../extensions/audio';
......@@ -78,8 +79,10 @@ export const createContentEditor = ({
throw new Error(PROVIDE_SERIALIZER_OR_RENDERER_ERROR);
}
const eventHub = eventHubFactory();
const builtInContentEditorExtensions = [
Attachment.configure({ uploadsPath, renderMarkdown }),
Attachment.configure({ uploadsPath, renderMarkdown, eventHub }),
Audio,
Blockquote,
Bold,
......@@ -137,5 +140,5 @@ export const createContentEditor = ({
const tiptapEditor = createTiptapEditor({ extensions: trackedExtensions, ...tiptapOptions });
const serializer = createMarkdownSerializer({ render: renderMarkdown, serializerConfig });
return new ContentEditor({ tiptapEditor, serializer });
return new ContentEditor({ tiptapEditor, serializer, eventHub });
};
......@@ -49,7 +49,7 @@ export const uploadFile = async ({ uploadsPath, renderMarkdown, file }) => {
return extractAttachmentLinkUrl(rendered);
};
const uploadImage = async ({ editor, file, uploadsPath, renderMarkdown }) => {
const uploadImage = async ({ editor, file, uploadsPath, renderMarkdown, eventHub }) => {
const encodedSrc = await readFileAsDataURL(file);
const { view } = editor;
......@@ -72,14 +72,14 @@ const uploadImage = async ({ editor, file, uploadsPath, renderMarkdown }) => {
);
} catch (e) {
editor.commands.deleteRange({ from: position, to: position + 1 });
editor.emit('alert', {
eventHub.$emit('alert', {
message: __('An error occurred while uploading the image. Please try again.'),
variant: 'danger',
});
}
};
const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown }) => {
const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown, eventHub }) => {
await Promise.resolve();
const { view } = editor;
......@@ -103,23 +103,23 @@ const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown }) =
);
} catch (e) {
editor.commands.deleteRange({ from, to: from + 1 });
editor.emit('alert', {
eventHub.$emit('alert', {
message: __('An error occurred while uploading the file. Please try again.'),
variant: 'danger',
});
}
};
export const handleFileEvent = ({ editor, file, uploadsPath, renderMarkdown }) => {
export const handleFileEvent = ({ editor, file, uploadsPath, renderMarkdown, eventHub }) => {
if (!file) return false;
if (acceptedMimes.image.includes(file?.type)) {
uploadImage({ editor, file, uploadsPath, renderMarkdown });
uploadImage({ editor, file, uploadsPath, renderMarkdown, eventHub });
return true;
}
uploadAttachment({ editor, file, uploadsPath, renderMarkdown });
uploadAttachment({ editor, file, uploadsPath, renderMarkdown, eventHub });
return true;
};
......@@ -3,20 +3,25 @@ import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ContentEditorAlert from '~/content_editor/components/content_editor_alert.vue';
import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue';
import { createTestEditor, emitEditorEvent } from '../test_utils';
import eventHubFactory from '~/helpers/event_hub_factory';
import { ALERT_EVENT } from '~/content_editor/constants';
import { createTestEditor } from '../test_utils';
describe('content_editor/components/content_editor_alert', () => {
let wrapper;
let tiptapEditor;
let eventHub;
const findErrorAlert = () => wrapper.findComponent(GlAlert);
const createWrapper = async () => {
tiptapEditor = createTestEditor();
eventHub = eventHubFactory();
wrapper = shallowMountExtended(ContentEditorAlert, {
provide: {
tiptapEditor,
eventHub,
},
stubs: {
EditorStateObserver,
......@@ -37,7 +42,9 @@ describe('content_editor/components/content_editor_alert', () => {
async ({ message, variant }) => {
createWrapper();
await emitEditorEvent({ tiptapEditor, event: 'alert', params: { message, variant } });
eventHub.$emit(ALERT_EVENT, { message, variant });
await nextTick();
expect(findErrorAlert().text()).toBe(message);
expect(findErrorAlert().attributes().variant).toBe(variant);
......@@ -48,11 +55,9 @@ describe('content_editor/components/content_editor_alert', () => {
const message = 'error message';
createWrapper();
await emitEditorEvent({ tiptapEditor, event: 'alert', params: { message } });
eventHub.$emit(ALERT_EVENT, { message });
await nextTick();
findErrorAlert().vm.$emit('dismiss');
await nextTick();
expect(findErrorAlert().exists()).toBe(false);
......
......@@ -121,7 +121,7 @@ describe('ContentEditor', () => {
beforeEach(async () => {
createWrapper();
contentEditor.emit(LOADING_CONTENT_EVENT);
contentEditor.eventHub.$emit(LOADING_CONTENT_EVENT);
await nextTick();
});
......@@ -143,9 +143,9 @@ describe('ContentEditor', () => {
beforeEach(async () => {
createWrapper();
contentEditor.emit(LOADING_CONTENT_EVENT);
contentEditor.eventHub.$emit(LOADING_CONTENT_EVENT);
await nextTick();
contentEditor.emit(LOADING_SUCCESS_EVENT);
contentEditor.eventHub.$emit(LOADING_SUCCESS_EVENT);
await nextTick();
});
......@@ -164,9 +164,9 @@ describe('ContentEditor', () => {
beforeEach(async () => {
createWrapper();
contentEditor.emit(LOADING_CONTENT_EVENT);
contentEditor.eventHub.$emit(LOADING_CONTENT_EVENT);
await nextTick();
contentEditor.emit(LOADING_ERROR_EVENT, error);
contentEditor.eventHub.$emit(LOADING_ERROR_EVENT, error);
await nextTick();
});
......
......@@ -3,6 +3,13 @@ import { each } from 'lodash';
import EditorStateObserver, {
tiptapToComponentMap,
} from '~/content_editor/components/editor_state_observer.vue';
import eventHubFactory from '~/helpers/event_hub_factory';
import {
LOADING_CONTENT_EVENT,
LOADING_SUCCESS_EVENT,
LOADING_ERROR_EVENT,
ALERT_EVENT,
} from '~/content_editor/constants';
import { createTestEditor } from '../test_utils';
describe('content_editor/components/editor_state_observer', () => {
......@@ -11,19 +18,29 @@ describe('content_editor/components/editor_state_observer', () => {
let onDocUpdateListener;
let onSelectionUpdateListener;
let onTransactionListener;
let onLoadingContentListener;
let onLoadingSuccessListener;
let onLoadingErrorListener;
let onAlertListener;
let eventHub;
const buildEditor = () => {
tiptapEditor = createTestEditor();
eventHub = eventHubFactory();
jest.spyOn(tiptapEditor, 'on');
};
const buildWrapper = () => {
wrapper = shallowMount(EditorStateObserver, {
provide: { tiptapEditor },
provide: { tiptapEditor, eventHub },
listeners: {
docUpdate: onDocUpdateListener,
selectionUpdate: onSelectionUpdateListener,
transaction: onTransactionListener,
[ALERT_EVENT]: onAlertListener,
[LOADING_CONTENT_EVENT]: onLoadingContentListener,
[LOADING_SUCCESS_EVENT]: onLoadingSuccessListener,
[LOADING_ERROR_EVENT]: onLoadingErrorListener,
},
});
};
......@@ -32,8 +49,11 @@ describe('content_editor/components/editor_state_observer', () => {
onDocUpdateListener = jest.fn();
onSelectionUpdateListener = jest.fn();
onTransactionListener = jest.fn();
onAlertListener = jest.fn();
onLoadingSuccessListener = jest.fn();
onLoadingContentListener = jest.fn();
onLoadingErrorListener = jest.fn();
buildEditor();
buildWrapper();
});
afterEach(() => {
......@@ -44,6 +64,8 @@ describe('content_editor/components/editor_state_observer', () => {
it('emits update, selectionUpdate, and transaction events', () => {
const content = '<p>My paragraph</p>';
buildWrapper();
tiptapEditor.commands.insertContent(content);
expect(onDocUpdateListener).toHaveBeenCalledWith(
......@@ -58,10 +80,27 @@ describe('content_editor/components/editor_state_observer', () => {
});
});
it.each`
event | listener
${ALERT_EVENT} | ${() => onAlertListener}
${LOADING_CONTENT_EVENT} | ${() => onLoadingContentListener}
${LOADING_SUCCESS_EVENT} | ${() => onLoadingSuccessListener}
${LOADING_ERROR_EVENT} | ${() => onLoadingErrorListener}
`('listens to $event event in the eventBus object', ({ event, listener }) => {
const args = {};
buildWrapper();
eventHub.$emit(event, args);
expect(listener()).toHaveBeenCalledWith(args);
});
describe('when component is destroyed', () => {
it('removes onTiptapDocUpdate and onTiptapSelectionUpdate hooks', () => {
jest.spyOn(tiptapEditor, 'off');
buildWrapper();
wrapper.destroy();
each(tiptapToComponentMap, (_, tiptapEvent) => {
......@@ -71,5 +110,25 @@ describe('content_editor/components/editor_state_observer', () => {
);
});
});
it.each`
event
${ALERT_EVENT}
${LOADING_CONTENT_EVENT}
${LOADING_SUCCESS_EVENT}
${LOADING_ERROR_EVENT}
`('removes $event event hook from eventHub', ({ event }) => {
jest.spyOn(eventHub, '$off');
jest.spyOn(eventHub, '$on');
buildWrapper();
wrapper.destroy();
expect(eventHub.$off).toHaveBeenCalledWith(
event,
eventHub.$on.mock.calls.find(([eventName]) => eventName === event)[1],
);
});
});
});
......@@ -2,6 +2,7 @@ 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 eventHubFactory from '~/helpers/event_hub_factory';
import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils';
describe('content_editor/components/toolbar_button', () => {
......@@ -25,6 +26,7 @@ describe('content_editor/components/toolbar_button', () => {
},
provide: {
tiptapEditor,
eventHub: eventHubFactory(),
},
propsData: {
contentType: CONTENT_TYPE,
......
import { GlDropdown, GlButton, GlFormInputGroup } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import ToolbarLinkButton from '~/content_editor/components/toolbar_link_button.vue';
import eventHubFactory from '~/helpers/event_hub_factory';
import Link from '~/content_editor/extensions/link';
import { hasSelection } from '~/content_editor/services/utils';
import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils';
......@@ -15,6 +16,7 @@ describe('content_editor/components/toolbar_link_button', () => {
wrapper = mountExtended(ToolbarLinkButton, {
provide: {
tiptapEditor: editor,
eventHub: eventHubFactory(),
},
});
};
......
......@@ -4,6 +4,7 @@ import EditorStateObserver from '~/content_editor/components/editor_state_observ
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 eventHubFactory from '~/helpers/event_hub_factory';
import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils';
describe('content_editor/components/toolbar_text_style_dropdown', () => {
......@@ -27,6 +28,7 @@ describe('content_editor/components/toolbar_text_style_dropdown', () => {
},
provide: {
tiptapEditor,
eventHub: eventHubFactory(),
},
propsData: {
...propsData,
......
......@@ -5,6 +5,7 @@ import Image from '~/content_editor/extensions/image';
import Link from '~/content_editor/extensions/link';
import Loading from '~/content_editor/extensions/loading';
import httpStatus from '~/lib/utils/http_status';
import eventHubFactory from '~/helpers/event_hub_factory';
import { createTestEditor, createDocBuilder } from '../test_utils';
const PROJECT_WIKI_ATTACHMENT_IMAGE_HTML = `<p data-sourcepos="1:1-1:27" dir="auto">
......@@ -25,6 +26,7 @@ describe('content_editor/extensions/attachment', () => {
let link;
let renderMarkdown;
let mock;
let eventHub;
const uploadsPath = '/uploads/';
const imageFile = new File(['foo'], 'test-file.png', { type: 'image/png' });
......@@ -50,9 +52,15 @@ describe('content_editor/extensions/attachment', () => {
beforeEach(() => {
renderMarkdown = jest.fn();
eventHub = eventHubFactory();
tiptapEditor = createTestEditor({
extensions: [Loading, Link, Image, Attachment.configure({ renderMarkdown, uploadsPath })],
extensions: [
Loading,
Link,
Image,
Attachment.configure({ renderMarkdown, uploadsPath, eventHub }),
],
});
({
......@@ -160,7 +168,7 @@ describe('content_editor/extensions/attachment', () => {
it('emits an alert event that includes an error message', (done) => {
tiptapEditor.commands.uploadAttachment({ file: imageFile });
tiptapEditor.on('alert', ({ message }) => {
eventHub.$on('alert', ({ message }) => {
expect(message).toBe('An error occurred while uploading the image. Please try again.');
done();
});
......@@ -236,7 +244,7 @@ describe('content_editor/extensions/attachment', () => {
it('emits an alert event that includes an error message', (done) => {
tiptapEditor.commands.uploadAttachment({ file: attachmentFile });
tiptapEditor.on('alert', ({ message }) => {
eventHub.$on('alert', ({ message }) => {
expect(message).toBe('An error occurred while uploading the file. Please try again.');
done();
});
......
......@@ -4,19 +4,21 @@ import {
LOADING_ERROR_EVENT,
} from '~/content_editor/constants';
import { ContentEditor } from '~/content_editor/services/content_editor';
import eventHubFactory from '~/helpers/event_hub_factory';
import { createTestEditor } from '../test_utils';
describe('content_editor/services/content_editor', () => {
let contentEditor;
let serializer;
let eventHub;
beforeEach(() => {
const tiptapEditor = createTestEditor();
jest.spyOn(tiptapEditor, 'destroy');
serializer = { deserialize: jest.fn() };
contentEditor = new ContentEditor({ tiptapEditor, serializer });
eventHub = eventHubFactory();
contentEditor = new ContentEditor({ tiptapEditor, serializer, eventHub });
});
describe('.dispose', () => {
......@@ -34,13 +36,13 @@ describe('content_editor/services/content_editor', () => {
serializer.deserialize.mockResolvedValueOnce('');
});
it('emits loadingContent and loadingSuccess event', () => {
it('emits loadingContent and loadingSuccess event in the eventHub', () => {
let loadingContentEmitted = false;
contentEditor.on(LOADING_CONTENT_EVENT, () => {
eventHub.$on(LOADING_CONTENT_EVENT, () => {
loadingContentEmitted = true;
});
contentEditor.on(LOADING_SUCCESS_EVENT, () => {
eventHub.$on(LOADING_SUCCESS_EVENT, () => {
expect(loadingContentEmitted).toBe(true);
});
......@@ -56,7 +58,7 @@ describe('content_editor/services/content_editor', () => {
});
it('emits loadingError event', async () => {
contentEditor.on(LOADING_ERROR_EVENT, (e) => {
eventHub.$on(LOADING_ERROR_EVENT, (e) => {
expect(e).toBe('error');
});
......
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