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