Commit a16d1a08 authored by Enrique Alcántara's avatar Enrique Alcántara Committed by Paul Slaughter

Initialize ContentEditor in its Vue component

Initialize the ContentEditor class in the
ContentEditor component. This change streamlines
the process of introducing the ContentEditor
in other features
parent 46982343
<script>
import { GlAlert } from '@gitlab/ui';
import { GlLoadingIcon } from '@gitlab/ui';
import { EditorContent as TiptapEditorContent } from '@tiptap/vue-2';
import { ContentEditor } from '../services/content_editor';
import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } from '../constants';
import { createContentEditor } from '../services/create_content_editor';
import ContentEditorError from './content_editor_error.vue';
import ContentEditorProvider from './content_editor_provider.vue';
import EditorStateObserver from './editor_state_observer.vue';
import FormattingBubbleMenu from './formatting_bubble_menu.vue';
import TopToolbar from './top_toolbar.vue';
export default {
components: {
GlAlert,
GlLoadingIcon,
ContentEditorError,
ContentEditorProvider,
TiptapEditorContent,
TopToolbar,
FormattingBubbleMenu,
},
provide() {
return {
tiptapEditor: this.contentEditor.tiptapEditor,
};
EditorStateObserver,
},
props: {
contentEditor: {
type: ContentEditor,
renderMarkdown: {
type: Function,
required: true,
},
uploadsPath: {
type: String,
required: true,
},
extensions: {
type: Array,
required: false,
default: () => [],
},
serializerConfig: {
type: Object,
required: false,
default: () => {},
},
},
data() {
return {
error: '',
isLoadingContent: false,
focused: false,
};
},
mounted() {
this.contentEditor.tiptapEditor.on('error', (error) => {
this.error = error;
created() {
const { renderMarkdown, uploadsPath, extensions, serializerConfig } = this;
// This is a non-reactive attribute intentionally since this is a complex object.
this.contentEditor = createContentEditor({
renderMarkdown,
uploadsPath,
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);
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() {
this.isLoadingContent = true;
},
hideLoadingIndicator() {
this.isLoadingContent = false;
},
focus() {
this.focused = true;
},
blur() {
this.focused = false;
},
notifyChange() {
this.$emit('change', {
empty: this.contentEditor.empty,
});
},
},
};
</script>
<template>
<content-editor-provider :content-editor="contentEditor">
<div>
<gl-alert v-if="error" class="gl-mb-6" variant="danger" @dismiss="error = ''">
{{ error }}
</gl-alert>
<div
data-testid="content-editor"
class="md-area"
:class="{ 'is-focused': contentEditor.tiptapEditor.isFocused }"
>
<editor-state-observer @docUpdate="notifyChange" @focus="focus" @blur="blur" />
<content-editor-error />
<div data-testid="content-editor" class="md-area" :class="{ 'is-focused': focused }">
<top-toolbar ref="toolbar" class="gl-mb-4" />
<formatting-bubble-menu />
<tiptap-editor-content class="md" :editor="contentEditor.tiptapEditor" />
<div v-if="isLoadingContent" class="gl-w-full gl-display-flex gl-justify-content-center">
<gl-loading-icon size="sm" />
</div>
<tiptap-editor-content v-else class="md" :editor="contentEditor.tiptapEditor" />
</div>
</div>
</content-editor-provider>
</template>
<script>
export default {
provide() {
// We can't use this.contentEditor due to bug in vue-apollo when
// provide is called in beforeCreate
// See https://github.com/vuejs/vue-apollo/pull/1153 for details
const { contentEditor } = this.$options.propsData;
return {
contentEditor,
tiptapEditor: contentEditor.tiptapEditor,
};
},
props: {
contentEditor: {
type: Object,
required: true,
},
},
render() {
return this.$slots.default;
},
};
</script>
......@@ -19,6 +19,10 @@ export class ContentEditor {
return doc.childCount === 0 || (doc.childCount === 1 && doc.child(0).childCount === 0);
}
dispose() {
this.tiptapEditor.destroy();
}
once(type, handler) {
this._eventHub.$once(type, handler);
}
......
......@@ -72,7 +72,9 @@ const uploadImage = async ({ editor, file, uploadsPath, renderMarkdown }) => {
);
} catch (e) {
editor.commands.deleteRange({ from: position, to: position + 1 });
editor.emit('error', __('An error occurred while uploading the image. Please try again.'));
editor.emit('error', {
error: __('An error occurred while uploading the image. Please try again.'),
});
}
};
......@@ -100,7 +102,9 @@ const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown }) =
);
} catch (e) {
editor.commands.deleteRange({ from, to: from + 1 });
editor.emit('error', __('An error occurred while uploading the file. Please try again.'));
editor.emit('error', {
error: __('An error occurred while uploading the file. Please try again.'),
});
}
};
......
......@@ -6,7 +6,6 @@ import {
GlButton,
GlSprintf,
GlAlert,
GlLoadingIcon,
GlModal,
GlModalDirective,
} from '@gitlab/ui';
......@@ -114,7 +113,6 @@ export default {
GlButton,
GlModal,
MarkdownField,
GlLoadingIcon,
ContentEditor: () =>
import(
/* webpackChunkName: 'content_editor' */ '~/content_editor/components/content_editor.vue'
......@@ -136,11 +134,12 @@ export default {
commitMessage: '',
isDirty: false,
contentEditorRenderFailed: false,
contentEditorEmpty: false,
};
},
computed: {
noContent() {
if (this.isContentEditorActive) return this.contentEditor?.empty;
if (this.isContentEditorActive) return this.contentEditorEmpty;
return !this.content.trim();
},
csrfToken() {
......@@ -205,7 +204,7 @@ export default {
window.removeEventListener('beforeunload', this.onPageUnload);
},
methods: {
getContentHTML(content) {
renderMarkdown(content) {
return axios
.post(this.pageInfo.markdownPreviewPath, { text: content })
.then(({ data }) => data.body);
......@@ -232,6 +231,32 @@ export default {
this.isDirty = true;
},
async loadInitialContent(contentEditor) {
this.contentEditor = contentEditor;
try {
await this.contentEditor.setSerializedContent(this.content);
this.trackContentEditorLoaded();
} catch (e) {
this.contentEditorRenderFailed = true;
}
},
async retryInitContentEditor() {
try {
this.contentEditorRenderFailed = false;
await this.contentEditor.setSerializedContent(this.content);
} catch (e) {
this.contentEditorRenderFailed = true;
}
},
handleContentEditorChange({ empty }) {
this.contentEditorEmpty = empty;
// TODO: Implement a precise mechanism to detect changes in the Content
this.isDirty = true;
},
onPageUnload(event) {
if (!this.isDirty) return undefined;
......@@ -252,36 +277,8 @@ export default {
this.commitMessage = newCommitMessage;
},
async initContentEditor() {
this.isContentEditorLoading = true;
initContentEditor() {
this.useContentEditor = true;
const { createContentEditor } = await import(
/* webpackChunkName: 'content_editor' */ '~/content_editor/services/create_content_editor'
);
this.contentEditor =
this.contentEditor ||
createContentEditor({
renderMarkdown: (markdown) => this.getContentHTML(markdown),
uploadsPath: this.pageInfo.uploadsPath,
tiptapOptions: {
onUpdate: () => this.handleContentChange(),
},
});
try {
await this.contentEditor.setSerializedContent(this.content);
this.isContentEditorLoading = false;
this.trackContentEditorLoaded();
} catch (e) {
this.contentEditorRenderFailed = true;
}
},
retryInitContentEditor() {
this.contentEditorRenderFailed = false;
this.initContentEditor();
},
switchToOldEditor() {
......@@ -475,12 +472,12 @@ export default {
>
</gl-sprintf>
</gl-alert>
<gl-loading-icon
v-if="isContentEditorLoading"
size="sm"
class="bordered-box gl-w-full gl-py-6"
<content-editor
:render-markdown="renderMarkdown"
:uploads-path="pageInfo.uploadsPath"
@initialized="loadInitialContent"
@change="handleContentEditorChange"
/>
<content-editor v-else :content-editor="contentEditor" />
<input id="wiki_content" v-model.trim="content" type="hidden" name="wiki[content]" />
</div>
......
import { GlAlert } from '@gitlab/ui';
import { GlLoadingIcon } from '@gitlab/ui';
import { EditorContent } from '@tiptap/vue-2';
import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ContentEditor from '~/content_editor/components/content_editor.vue';
import ContentEditorError from '~/content_editor/components/content_editor_error.vue';
import ContentEditorProvider from '~/content_editor/components/content_editor_provider.vue';
import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue';
import TopToolbar from '~/content_editor/components/top_toolbar.vue';
import { createContentEditor } from '~/content_editor/services/create_content_editor';
import {
LOADING_CONTENT_EVENT,
LOADING_SUCCESS_EVENT,
LOADING_ERROR_EVENT,
} from '~/content_editor/constants';
import { emitEditorEvent } from '../test_utils';
jest.mock('~/emoji');
describe('ContentEditor', () => {
let wrapper;
let editor;
let contentEditor;
let renderMarkdown;
const uploadsPath = '/uploads';
const findEditorElement = () => wrapper.findByTestId('content-editor');
const findErrorAlert = () => wrapper.findComponent(GlAlert);
const findEditorContent = () => wrapper.findComponent(EditorContent);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const createWrapper = (propsData = {}) => {
renderMarkdown = jest.fn();
const createWrapper = async (contentEditor) => {
wrapper = shallowMountExtended(ContentEditor, {
propsData: {
contentEditor,
renderMarkdown,
uploadsPath,
...propsData,
},
stubs: {
EditorStateObserver,
ContentEditorProvider,
},
listeners: {
initialized(editor) {
contentEditor = editor;
},
},
});
};
beforeEach(() => {
editor = createContentEditor({ renderMarkdown: () => true });
});
afterEach(() => {
wrapper.destroy();
});
it('renders editor content component and attaches editor instance', () => {
createWrapper(editor);
it('triggers initialized event and provides contentEditor instance as event data', () => {
createWrapper();
const editorContent = wrapper.findComponent(EditorContent);
expect(contentEditor).not.toBeFalsy();
});
it('renders EditorContent component and provides tiptapEditor instance', () => {
createWrapper();
const editorContent = findEditorContent();
expect(editorContent.props().editor).toBe(editor.tiptapEditor);
expect(editorContent.props().editor).toBe(contentEditor.tiptapEditor);
expect(editorContent.classes()).toContain('md');
});
it('renders ContentEditorProvider component', () => {
createWrapper();
expect(wrapper.findComponent(ContentEditorProvider).exists()).toBe(true);
});
it('renders top toolbar component', () => {
createWrapper(editor);
createWrapper();
expect(wrapper.findComponent(TopToolbar).exists()).toBe(true);
});
it.each`
isFocused | classes
${true} | ${['md-area', 'is-focused']}
${false} | ${['md-area']}
`(
'has $classes class selectors when tiptapEditor.isFocused = $isFocused',
({ isFocused, classes }) => {
editor.tiptapEditor.isFocused = isFocused;
createWrapper(editor);
it('adds is-focused class when focus event is emitted', async () => {
createWrapper();
expect(findEditorElement().classes()).toStrictEqual(classes);
},
);
it('adds isFocused class when tiptapEditor is focused', () => {
editor.tiptapEditor.isFocused = true;
createWrapper(editor);
await emitEditorEvent({ tiptapEditor: contentEditor.tiptapEditor, event: 'focus' });
expect(findEditorElement().classes()).toContain('is-focused');
});
describe('displaying error', () => {
const error = 'Content Editor error';
it('removes is-focused class when blur event is emitted', async () => {
createWrapper();
await emitEditorEvent({ tiptapEditor: contentEditor.tiptapEditor, event: 'focus' });
await emitEditorEvent({ tiptapEditor: contentEditor.tiptapEditor, event: 'blur' });
expect(findEditorElement().classes()).not.toContain('is-focused');
});
it('emits change event when document is updated', async () => {
createWrapper();
await emitEditorEvent({ tiptapEditor: contentEditor.tiptapEditor, event: 'update' });
expect(wrapper.emitted('change')).toEqual([
[
{
empty: contentEditor.empty,
},
],
]);
});
it('renders content_editor_error component', () => {
createWrapper();
expect(wrapper.findComponent(ContentEditorError).exists()).toBe(true);
});
describe('when loading content', () => {
beforeEach(async () => {
createWrapper(editor);
createWrapper();
contentEditor.emit(LOADING_CONTENT_EVENT);
await nextTick();
});
it('displays loading indicator', () => {
expect(findLoadingIcon().exists()).toBe(true);
});
it('hides EditorContent component', () => {
expect(findEditorContent().exists()).toBe(false);
});
});
editor.tiptapEditor.emit('error', error);
describe('when loading content succeeds', () => {
beforeEach(async () => {
createWrapper();
contentEditor.emit(LOADING_CONTENT_EVENT);
await nextTick();
contentEditor.emit(LOADING_SUCCESS_EVENT);
await nextTick();
});
it('displays error notifications from the tiptap editor', () => {
expect(findErrorAlert().text()).toBe(error);
it('hides loading indicator', () => {
expect(findLoadingIcon().exists()).toBe(false);
});
it('displays EditorContent component', () => {
expect(findEditorContent().exists()).toBe(true);
});
});
it('allows dismissing an error alert', async () => {
findErrorAlert().vm.$emit('dismiss');
describe('when loading content fails', () => {
const error = 'error';
beforeEach(async () => {
createWrapper();
contentEditor.emit(LOADING_CONTENT_EVENT);
await nextTick();
contentEditor.emit(LOADING_ERROR_EVENT, error);
await nextTick();
});
it('hides loading indicator', () => {
expect(findLoadingIcon().exists()).toBe(false);
});
expect(findErrorAlert().exists()).toBe(false);
it('displays EditorContent component', () => {
expect(findEditorContent().exists()).toBe(true);
});
});
});
......@@ -10,7 +10,7 @@ import httpStatus from '~/lib/utils/http_status';
import { loadMarkdownApiResult } from '../markdown_processing_examples';
import { createTestEditor, createDocBuilder } from '../test_utils';
describe('content_editor/extensions/image', () => {
describe('content_editor/extensions/attachment', () => {
let tiptapEditor;
let eq;
let doc;
......@@ -144,8 +144,8 @@ describe('content_editor/extensions/image', () => {
it('emits an error event that includes an error message', (done) => {
tiptapEditor.commands.uploadAttachment({ file: imageFile });
tiptapEditor.on('error', (message) => {
expect(message).toBe('An error occurred while uploading the image. Please try again.');
tiptapEditor.on('error', ({ error }) => {
expect(error).toBe('An error occurred while uploading the image. Please try again.');
done();
});
});
......@@ -224,8 +224,8 @@ describe('content_editor/extensions/image', () => {
it('emits an error event that includes an error message', (done) => {
tiptapEditor.commands.uploadAttachment({ file: attachmentFile });
tiptapEditor.on('error', (message) => {
expect(message).toBe('An error occurred while uploading the file. Please try again.');
tiptapEditor.on('error', ({ error }) => {
expect(error).toBe('An error occurred while uploading the file. Please try again.');
done();
});
});
......
......@@ -13,10 +13,22 @@ describe('content_editor/services/content_editor', () => {
beforeEach(() => {
const tiptapEditor = createTestEditor();
jest.spyOn(tiptapEditor, 'destroy');
serializer = { deserialize: jest.fn() };
contentEditor = new ContentEditor({ tiptapEditor, serializer });
});
describe('.dispose', () => {
it('destroys the tiptapEditor', () => {
expect(contentEditor.tiptapEditor.destroy).not.toHaveBeenCalled();
contentEditor.dispose();
expect(contentEditor.tiptapEditor.destroy).toHaveBeenCalled();
});
});
describe('when setSerializedContent succeeds', () => {
beforeEach(() => {
serializer.deserialize.mockResolvedValueOnce('');
......
......@@ -352,11 +352,6 @@ describe('WikiForm', () => {
await waitForPromises();
});
it('editor is shown in a perpetual loading state', () => {
expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
expect(wrapper.findComponent(ContentEditor).exists()).toBe(false);
});
it('disables the submit button', () => {
expect(findSubmitButton().props('disabled')).toBe(true);
});
......
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