Commit 31977e5d authored by Enrique Alcantara's avatar Enrique Alcantara

Refactor eventHub in the Content Editor

Allow to share the eventHub object between
the Content Editor facade object and the
Content Editor extensions

The purpose of this refactoring is allowing
to trigger the content loading events from
the paste markdown extension
parent 188532e3
<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