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> <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