Commit ed0290e8 authored by Paul Slaughter's avatar Paul Slaughter

Merge branch 'refactor/337149-initialize-content-editor-vue-component' into 'master'

Initialize ContentEditor in its main Vue component

See merge request gitlab-org/gitlab!67372
parents e5f7ac00 a16d1a08
<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