From c2b77bb05df487b4bc9f087fa506c9148b5eda69 Mon Sep 17 00:00:00 2001
From: Himanshu Kapoor <hkapoor@gitlab.com>
Date: Thu, 30 Jun 2022 21:55:27 +0200
Subject: [PATCH] Refactor method to pass markdown to the Content Editor

Refactor the content_editor vue component so it
accepts Markdown via a Vue component property
and outputs updated Markdown via Vue events.

The purpose of this refactoring is simplifying
the API to integrate the Content Editor in other
parts of the application
---
 .../components/content_editor.vue             |  70 +++++-
 .../components/content_editor_alert.vue       |  17 +-
 .../components/editor_state_observer.vue      |  14 +-
 .../components/loading_indicator.vue          |  34 +--
 .../content_editor/services/content_editor.js |  41 ++--
 .../shared/wikis/components/wiki_form.vue     |  73 +------
 locale/gitlab.pot                             |   9 +-
 .../components/content_editor_alert_spec.js   |  25 +++
 .../components/content_editor_spec.js         | 206 ++++++++++++------
 .../components/editor_state_observer_spec.js  |  26 +--
 .../components/loading_indicator_spec.js      |  46 +---
 .../services/content_editor_spec.js           |  95 +++++---
 .../shared/wikis/components/wiki_form_spec.js |  48 +---
 .../content_editor_integration_spec.js        |  71 +++---
 14 files changed, 397 insertions(+), 378 deletions(-)

diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue
index 05d6f468d2389..659c447e86191 100644
--- a/app/assets/javascripts/content_editor/components/content_editor.vue
+++ b/app/assets/javascripts/content_editor/components/content_editor.vue
@@ -1,6 +1,9 @@
 <script>
 import { EditorContent as TiptapEditorContent } from '@tiptap/vue-2';
+import { __ } from '~/locale';
+import { VARIANT_DANGER } from '~/flash';
 import { createContentEditor } from '../services/create_content_editor';
+import { ALERT_EVENT } from '../constants';
 import ContentEditorAlert from './content_editor_alert.vue';
 import ContentEditorProvider from './content_editor_provider.vue';
 import EditorStateObserver from './editor_state_observer.vue';
@@ -43,12 +46,26 @@ export default {
       required: false,
       default: () => {},
     },
+    markdown: {
+      type: String,
+      required: false,
+      default: '',
+    },
   },
   data() {
     return {
       focused: false,
+      isLoading: false,
+      latestMarkdown: null,
     };
   },
+  watch: {
+    markdown(markdown) {
+      if (markdown !== this.latestMarkdown) {
+        this.setSerializedContent(markdown);
+      }
+    },
+  },
   created() {
     const { renderMarkdown, uploadsPath, extensions, serializerConfig } = this;
 
@@ -61,21 +78,61 @@ export default {
     });
   },
   mounted() {
-    this.$emit('initialized', this.contentEditor);
+    this.$emit('initialized');
+    this.setSerializedContent(this.markdown);
   },
   beforeDestroy() {
     this.contentEditor.dispose();
   },
   methods: {
+    async setSerializedContent(markdown) {
+      this.notifyLoading();
+
+      try {
+        await this.contentEditor.setSerializedContent(markdown);
+        this.contentEditor.setEditable(true);
+        this.notifyLoadingSuccess();
+        this.latestMarkdown = markdown;
+      } catch {
+        this.contentEditor.eventHub.$emit(ALERT_EVENT, {
+          message: __(
+            'An error occurred while trying to render the content editor. Please try again.',
+          ),
+          variant: VARIANT_DANGER,
+          actionLabel: __('Retry'),
+          action: () => {
+            this.setSerializedContent(markdown);
+          },
+        });
+        this.contentEditor.setEditable(false);
+        this.notifyLoadingError();
+      }
+    },
     focus() {
       this.focused = true;
     },
     blur() {
       this.focused = false;
     },
+    notifyLoading() {
+      this.isLoading = true;
+      this.$emit('loading');
+    },
+    notifyLoadingSuccess() {
+      this.isLoading = false;
+      this.$emit('loadingSuccess');
+    },
+    notifyLoadingError(error) {
+      this.isLoading = false;
+      this.$emit('loadingError', error);
+    },
     notifyChange() {
+      this.latestMarkdown = this.contentEditor.getSerializedContent();
+
       this.$emit('change', {
         empty: this.contentEditor.empty,
+        changed: this.contentEditor.changed,
+        markdown: this.latestMarkdown,
       });
     },
   },
@@ -84,14 +141,7 @@ export default {
 <template>
   <content-editor-provider :content-editor="contentEditor">
     <div>
-      <editor-state-observer
-        @docUpdate="notifyChange"
-        @focus="focus"
-        @blur="blur"
-        @loading="$emit('loading')"
-        @loadingSuccess="$emit('loadingSuccess')"
-        @loadingError="$emit('loadingError')"
-      />
+      <editor-state-observer @docUpdate="notifyChange" @focus="focus" @blur="blur" />
       <content-editor-alert />
       <div
         data-testid="content-editor"
@@ -110,7 +160,7 @@ export default {
             data-testid="content_editor_editablebox"
             :editor="contentEditor.tiptapEditor"
           />
-          <loading-indicator />
+          <loading-indicator v-if="isLoading" />
         </div>
       </div>
     </div>
diff --git a/app/assets/javascripts/content_editor/components/content_editor_alert.vue b/app/assets/javascripts/content_editor/components/content_editor_alert.vue
index c6737da1d7735..87eff2451ecce 100644
--- a/app/assets/javascripts/content_editor/components/content_editor_alert.vue
+++ b/app/assets/javascripts/content_editor/components/content_editor_alert.vue
@@ -14,19 +14,32 @@ export default {
     };
   },
   methods: {
-    displayAlert({ message, variant }) {
+    displayAlert({ message, variant, action, actionLabel }) {
       this.message = message;
       this.variant = variant;
+      this.action = action;
+      this.actionLabel = actionLabel;
     },
     dismissAlert() {
       this.message = null;
     },
+    primaryAction() {
+      this.dismissAlert();
+      this.action?.();
+    },
   },
 };
 </script>
 <template>
   <editor-state-observer @alert="displayAlert">
-    <gl-alert v-if="message" class="gl-mb-6" :variant="variant" @dismiss="dismissAlert">
+    <gl-alert
+      v-if="message"
+      class="gl-mb-6"
+      :variant="variant"
+      :primary-button-text="actionLabel"
+      @dismiss="dismissAlert"
+      @primaryAction="primaryAction"
+    >
       {{ message }}
     </gl-alert>
   </editor-state-observer>
diff --git a/app/assets/javascripts/content_editor/components/editor_state_observer.vue b/app/assets/javascripts/content_editor/components/editor_state_observer.vue
index 252f69f7a5dd5..41c3771bf4169 100644
--- a/app/assets/javascripts/content_editor/components/editor_state_observer.vue
+++ b/app/assets/javascripts/content_editor/components/editor_state_observer.vue
@@ -1,11 +1,6 @@
 <script>
 import { debounce } from 'lodash';
-import {
-  LOADING_CONTENT_EVENT,
-  LOADING_SUCCESS_EVENT,
-  LOADING_ERROR_EVENT,
-  ALERT_EVENT,
-} from '../constants';
+import { ALERT_EVENT } from '../constants';
 
 export const tiptapToComponentMap = {
   update: 'docUpdate',
@@ -15,12 +10,7 @@ export const tiptapToComponentMap = {
   blur: 'blur',
 };
 
-export const eventHubEvents = [
-  ALERT_EVENT,
-  LOADING_CONTENT_EVENT,
-  LOADING_SUCCESS_EVENT,
-  LOADING_ERROR_EVENT,
-];
+export const eventHubEvents = [ALERT_EVENT];
 
 const getComponentEventName = (tiptapEventName) => tiptapToComponentMap[tiptapEventName];
 
diff --git a/app/assets/javascripts/content_editor/components/loading_indicator.vue b/app/assets/javascripts/content_editor/components/loading_indicator.vue
index 7bc953e0dc345..e2af6cabddb2b 100644
--- a/app/assets/javascripts/content_editor/components/loading_indicator.vue
+++ b/app/assets/javascripts/content_editor/components/loading_indicator.vue
@@ -1,40 +1,18 @@
 <script>
 import { GlLoadingIcon } from '@gitlab/ui';
-import EditorStateObserver from './editor_state_observer.vue';
 
 export default {
   components: {
     GlLoadingIcon,
-    EditorStateObserver,
-  },
-  data() {
-    return {
-      isLoading: false,
-    };
-  },
-  methods: {
-    displayLoadingIndicator() {
-      this.isLoading = true;
-    },
-    hideLoadingIndicator() {
-      this.isLoading = false;
-    },
   },
 };
 </script>
 <template>
-  <editor-state-observer
-    @loading="displayLoadingIndicator"
-    @loadingSuccess="hideLoadingIndicator"
-    @loadingError="hideLoadingIndicator"
+  <div
+    data-testid="content-editor-loading-indicator"
+    class="gl-w-full gl-display-flex gl-justify-content-center gl-align-items-center gl-absolute gl-top-0 gl-bottom-0"
   >
-    <div
-      v-if="isLoading"
-      data-testid="content-editor-loading-indicator"
-      class="gl-w-full gl-display-flex gl-justify-content-center gl-align-items-center gl-absolute gl-top-0 gl-bottom-0"
-    >
-      <div class="gl-bg-white gl-absolute gl-w-full gl-h-full gl-opacity-3"></div>
-      <gl-loading-icon size="lg" />
-    </div>
-  </editor-state-observer>
+    <div class="gl-bg-white gl-absolute gl-w-full gl-h-full gl-opacity-3"></div>
+    <gl-loading-icon size="lg" />
+  </div>
 </template>
diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js
index 75d8581890fff..514ab9699bc73 100644
--- a/app/assets/javascripts/content_editor/services/content_editor.js
+++ b/app/assets/javascripts/content_editor/services/content_editor.js
@@ -1,5 +1,3 @@
-import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } from '../constants';
-
 /* eslint-disable no-underscore-dangle */
 export class ContentEditor {
   constructor({ tiptapEditor, serializer, deserializer, assetResolver, eventHub }) {
@@ -20,14 +18,19 @@ export class ContentEditor {
   }
 
   get changed() {
-    return this._pristineDoc?.eq(this.tiptapEditor.state.doc);
+    if (!this._pristineDoc) {
+      return !this.empty;
+    }
+
+    return !this._pristineDoc.eq(this.tiptapEditor.state.doc);
   }
 
   get empty() {
-    const doc = this.tiptapEditor?.state.doc;
+    return this.tiptapEditor.isEmpty;
+  }
 
-    // Makes sure the document has more than one empty paragraph
-    return doc.childCount === 0 || (doc.childCount === 1 && doc.child(0).childCount === 0);
+  get editable() {
+    return this.tiptapEditor.isEditable;
   }
 
   dispose() {
@@ -55,24 +58,22 @@ export class ContentEditor {
     return this._assetResolver.renderDiagram(code, language);
   }
 
+  setEditable(editable = true) {
+    this._tiptapEditor.setOptions({
+      editable,
+    });
+  }
+
   async setSerializedContent(serializedContent) {
-    const { _tiptapEditor: editor, _eventHub: eventHub } = this;
+    const { _tiptapEditor: editor } = this;
     const { doc, tr } = editor.state;
 
-    try {
-      eventHub.$emit(LOADING_CONTENT_EVENT);
-      const { document } = await this.deserialize(serializedContent);
-
-      if (document) {
-        this._pristineDoc = document;
-        tr.replaceWith(0, doc.content.size, document).setMeta('preventUpdate', true);
-        editor.view.dispatch(tr);
-      }
+    const { document } = await this.deserialize(serializedContent);
 
-      eventHub.$emit(LOADING_SUCCESS_EVENT);
-    } catch (e) {
-      eventHub.$emit(LOADING_ERROR_EVENT, e);
-      throw e;
+    if (document) {
+      this._pristineDoc = document;
+      tr.replaceWith(0, doc.content.size, document).setMeta('preventUpdate', true);
+      editor.view.dispatch(tr);
     }
   }
 
diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
index 9d7d9e376cf65..3f4ab7319a038 100644
--- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
+++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
@@ -5,7 +5,6 @@ import {
   GlLink,
   GlButton,
   GlSprintf,
-  GlAlert,
   GlFormGroup,
   GlFormInput,
   GlFormSelect,
@@ -59,14 +58,6 @@ export default {
       label: s__('WikiPage|Content'),
       placeholder: s__('WikiPage|Write your content or drag files here…'),
     },
-    contentEditor: {
-      renderFailed: {
-        message: s__(
-          'WikiPage|An error occurred while trying to render the content editor. Please try again later.',
-        ),
-        primaryAction: s__('WikiPage|Retry'),
-      },
-    },
     linksHelpText: s__(
       'WikiPage|To link to a (new) page, simply type %{linkExample}. More examples are in the %{linkStart}documentation%{linkEnd}.',
     ),
@@ -88,7 +79,6 @@ export default {
     { text: s__('Wiki Page|Rich text'), value: 'richText' },
   ],
   components: {
-    GlAlert,
     GlIcon,
     GlForm,
     GlFormGroup,
@@ -115,14 +105,12 @@ export default {
       content: this.pageInfo.content || '',
       commitMessage: '',
       isDirty: false,
-      contentEditorRenderFailed: false,
       contentEditorEmpty: false,
       switchEditingControlDisabled: false,
     };
   },
   computed: {
     noContent() {
-      if (this.isContentEditorActive) return this.contentEditorEmpty;
       return !this.content.trim();
     },
     csrfToken() {
@@ -145,11 +133,6 @@ export default {
     linkExample() {
       return MARKDOWN_LINK_TEXT[this.format];
     },
-    toggleEditingModeButtonText() {
-      return this.isContentEditorActive
-        ? this.$options.i18n.editSourceButtonText
-        : this.$options.i18n.editRichTextButtonText;
-    },
     submitButtonText() {
       return this.pageInfo.persisted
         ? this.$options.i18n.submitButton.existingPage
@@ -177,7 +160,7 @@ export default {
       return !this.isContentEditorActive;
     },
     disableSubmitButton() {
-      return this.noContent || !this.title || this.contentEditorRenderFailed;
+      return this.noContent || !this.title;
     },
     isContentEditorActive() {
       return this.isMarkdownFormat && this.useContentEditor;
@@ -201,23 +184,14 @@ export default {
         .then(({ data }) => data.body);
     },
 
-    toggleEditingMode(editingMode) {
+    setEditingMode(editingMode) {
       this.editingMode = editingMode;
-      if (!this.useContentEditor && this.contentEditor) {
-        this.content = this.contentEditor.getSerializedContent();
-      }
-    },
-
-    setEditingMode(value) {
-      this.editingMode = value;
     },
 
     async handleFormSubmit(e) {
       e.preventDefault();
 
       if (this.useContentEditor) {
-        this.content = this.contentEditor.getSerializedContent();
-
         this.trackFormSubmit();
       }
 
@@ -235,30 +209,10 @@ 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 }) {
+    handleContentEditorChange({ empty, markdown, changed }) {
       this.contentEditorEmpty = empty;
-      // TODO: Implement a precise mechanism to detect changes in the Content
-      this.isDirty = true;
+      this.isDirty = changed;
+      this.content = markdown;
     },
 
     onPageUnload(event) {
@@ -320,17 +274,6 @@ export default {
     class="wiki-form common-note-form gl-mt-3 js-quick-submit"
     @submit="handleFormSubmit"
   >
-    <gl-alert
-      v-if="isContentEditorActive && contentEditorRenderFailed"
-      class="gl-mb-6"
-      :dismissible="false"
-      variant="danger"
-      :primary-button-text="$options.i18n.contentEditor.renderFailed.primaryAction"
-      @primaryAction="retryInitContentEditor"
-    >
-      {{ $options.i18n.contentEditor.renderFailed.message }}
-    </gl-alert>
-
     <input :value="csrfToken" type="hidden" name="authenticity_token" />
     <input v-if="pageInfo.persisted" type="hidden" name="_method" value="put" />
     <input
@@ -350,7 +293,6 @@ export default {
               {{ $options.i18n.title.helpText.learnMore }}
             </gl-link>
           </template>
-
           <gl-form-input
             id="wiki_title"
             v-model="title"
@@ -395,7 +337,7 @@ export default {
               :checked="editingMode"
               :options="$options.switchEditingControlOptions"
               :disabled="switchEditingControlDisabled"
-              @input="toggleEditingMode"
+              @input="setEditingMode"
             />
           </div>
           <local-storage-sync
@@ -436,7 +378,8 @@ export default {
             <content-editor
               :render-markdown="renderMarkdown"
               :uploads-path="pageInfo.uploadsPath"
-              @initialized="loadInitialContent"
+              :markdown="content"
+              @initialized="trackContentEditorLoaded"
               @change="handleContentEditorChange"
               @loading="disableSwitchEditingControl"
               @loadingSuccess="enableSwitchEditingControl"
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 621500f4d87f3..7f45c5dc6c8de 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -4354,6 +4354,9 @@ msgstr ""
 msgid "An error occurred while trying to generate the report. Please try again later."
 msgstr ""
 
+msgid "An error occurred while trying to render the content editor. Please try again."
+msgstr ""
+
 msgid "An error occurred while trying to run a new pipeline for this merge request."
 msgstr ""
 
@@ -44476,9 +44479,6 @@ msgstr ""
 msgid "WikiPageConflictMessage|Someone edited the page the same time you did. Please check out %{wikiLinkStart}the page%{wikiLinkEnd} and make sure your changes will not unintentionally remove theirs."
 msgstr ""
 
-msgid "WikiPage|An error occurred while trying to render the content editor. Please try again later."
-msgstr ""
-
 msgid "WikiPage|Cancel"
 msgstr ""
 
@@ -44503,9 +44503,6 @@ msgstr ""
 msgid "WikiPage|Page title"
 msgstr ""
 
-msgid "WikiPage|Retry"
-msgstr ""
-
 msgid "WikiPage|Save changes"
 msgstr ""
 
diff --git a/spec/frontend/content_editor/components/content_editor_alert_spec.js b/spec/frontend/content_editor/components/content_editor_alert_spec.js
index 12484cb13c631..ee9ead8f8a7e4 100644
--- a/spec/frontend/content_editor/components/content_editor_alert_spec.js
+++ b/spec/frontend/content_editor/components/content_editor_alert_spec.js
@@ -51,6 +51,16 @@ describe('content_editor/components/content_editor_alert', () => {
     },
   );
 
+  it('does not show primary action by default', async () => {
+    const message = 'error message';
+
+    createWrapper();
+    eventHub.$emit(ALERT_EVENT, { message });
+    await nextTick();
+
+    expect(findErrorAlert().attributes().primaryButtonText).toBeUndefined();
+  });
+
   it('allows dismissing the error', async () => {
     const message = 'error message';
 
@@ -62,4 +72,19 @@ describe('content_editor/components/content_editor_alert', () => {
 
     expect(findErrorAlert().exists()).toBe(false);
   });
+
+  it('allows dismissing the error with a primary action button', async () => {
+    const message = 'error message';
+    const actionLabel = 'Retry';
+    const action = jest.fn();
+
+    createWrapper();
+    eventHub.$emit(ALERT_EVENT, { message, action, actionLabel });
+    await nextTick();
+    findErrorAlert().vm.$emit('primaryAction');
+    await nextTick();
+
+    expect(action).toHaveBeenCalled();
+    expect(findErrorAlert().exists()).toBe(false);
+  });
 });
diff --git a/spec/frontend/content_editor/components/content_editor_spec.js b/spec/frontend/content_editor/components/content_editor_spec.js
index 4c87ccca85b6e..bb4d80c94ee0f 100644
--- a/spec/frontend/content_editor/components/content_editor_spec.js
+++ b/spec/frontend/content_editor/components/content_editor_spec.js
@@ -1,4 +1,6 @@
-import { EditorContent } from '@tiptap/vue-2';
+import { GlAlert } from '@gitlab/ui';
+import { EditorContent, Editor } 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 ContentEditorAlert from '~/content_editor/components/content_editor_alert.vue';
@@ -10,112 +12,205 @@ import LinkBubbleMenu from '~/content_editor/components/bubble_menus/link_bubble
 import MediaBubbleMenu from '~/content_editor/components/bubble_menus/media_bubble_menu.vue';
 import TopToolbar from '~/content_editor/components/top_toolbar.vue';
 import LoadingIndicator from '~/content_editor/components/loading_indicator.vue';
-import { emitEditorEvent } from '../test_utils';
+import waitForPromises from 'helpers/wait_for_promises';
 
 jest.mock('~/emoji');
 
 describe('ContentEditor', () => {
   let wrapper;
-  let contentEditor;
   let renderMarkdown;
   const uploadsPath = '/uploads';
 
   const findEditorElement = () => wrapper.findByTestId('content-editor');
   const findEditorContent = () => wrapper.findComponent(EditorContent);
   const findEditorStateObserver = () => wrapper.findComponent(EditorStateObserver);
-  const createWrapper = (propsData = {}) => {
-    renderMarkdown = jest.fn();
-
+  const findLoadingIndicator = () => wrapper.findComponent(LoadingIndicator);
+  const findContentEditorAlert = () => wrapper.findComponent(ContentEditorAlert);
+  const createWrapper = ({ markdown } = {}) => {
     wrapper = shallowMountExtended(ContentEditor, {
       propsData: {
         renderMarkdown,
         uploadsPath,
-        ...propsData,
+        markdown,
       },
       stubs: {
         EditorStateObserver,
         ContentEditorProvider,
-      },
-      listeners: {
-        initialized(editor) {
-          contentEditor = editor;
-        },
+        ContentEditorAlert,
       },
     });
   };
 
+  beforeEach(() => {
+    renderMarkdown = jest.fn();
+  });
+
   afterEach(() => {
     wrapper.destroy();
   });
 
-  it('triggers initialized event and provides contentEditor instance as event data', () => {
-    createWrapper();
+  it('triggers initialized event', async () => {
+    await createWrapper();
 
-    expect(contentEditor).not.toBe(false);
+    expect(wrapper.emitted('initialized')).toHaveLength(1);
   });
 
-  it('renders EditorContent component and provides tiptapEditor instance', () => {
-    createWrapper();
+  it('renders EditorContent component and provides tiptapEditor instance', async () => {
+    const markdown = 'hello world';
+
+    createWrapper({ markdown });
+
+    renderMarkdown.mockResolvedValueOnce(markdown);
+
+    await nextTick();
 
     const editorContent = findEditorContent();
 
-    expect(editorContent.props().editor).toBe(contentEditor.tiptapEditor);
+    expect(editorContent.props().editor).toBeInstanceOf(Editor);
     expect(editorContent.classes()).toContain('md');
   });
 
-  it('renders ContentEditorProvider component', () => {
-    createWrapper();
+  it('renders ContentEditorProvider component', async () => {
+    await createWrapper();
 
     expect(wrapper.findComponent(ContentEditorProvider).exists()).toBe(true);
   });
 
-  it('renders top toolbar component', () => {
-    createWrapper();
+  it('renders top toolbar component', async () => {
+    await createWrapper();
 
     expect(wrapper.findComponent(TopToolbar).exists()).toBe(true);
   });
 
-  it('adds is-focused class when focus event is emitted', async () => {
-    createWrapper();
+  describe('when setting initial content', () => {
+    it('displays loading indicator', async () => {
+      createWrapper();
 
-    await emitEditorEvent({ tiptapEditor: contentEditor.tiptapEditor, event: 'focus' });
+      await nextTick();
 
-    expect(findEditorElement().classes()).toContain('is-focused');
-  });
+      expect(findLoadingIndicator().exists()).toBe(true);
+    });
 
-  it('removes is-focused class when blur event is emitted', async () => {
-    createWrapper();
+    it('emits loading event', async () => {
+      createWrapper();
 
-    await emitEditorEvent({ tiptapEditor: contentEditor.tiptapEditor, event: 'focus' });
-    await emitEditorEvent({ tiptapEditor: contentEditor.tiptapEditor, event: 'blur' });
+      await nextTick();
 
-    expect(findEditorElement().classes()).not.toContain('is-focused');
-  });
+      expect(wrapper.emitted('loading')).toHaveLength(1);
+    });
 
-  it('emits change event when document is updated', async () => {
-    createWrapper();
+    describe('succeeds', () => {
+      beforeEach(async () => {
+        renderMarkdown.mockResolvedValueOnce('hello world');
+
+        createWrapper({ markddown: 'hello world' });
+        await nextTick();
+      });
+
+      it('hides loading indicator', async () => {
+        await nextTick();
+        expect(findLoadingIndicator().exists()).toBe(false);
+      });
+
+      it('emits loadingSuccess event', () => {
+        expect(wrapper.emitted('loadingSuccess')).toHaveLength(1);
+      });
+    });
+
+    describe('fails', () => {
+      beforeEach(async () => {
+        renderMarkdown.mockRejectedValueOnce(new Error());
+
+        createWrapper({ markddown: 'hello world' });
+        await nextTick();
+      });
+
+      it('sets the content editor as read only when loading content fails', async () => {
+        await nextTick();
+
+        expect(findEditorContent().props().editor.isEditable).toBe(false);
+      });
 
-    await emitEditorEvent({ tiptapEditor: contentEditor.tiptapEditor, event: 'update' });
+      it('hides loading indicator', async () => {
+        await nextTick();
 
-    expect(wrapper.emitted('change')).toEqual([
-      [
-        {
-          empty: contentEditor.empty,
-        },
-      ],
-    ]);
+        expect(findLoadingIndicator().exists()).toBe(false);
+      });
+
+      it('emits loadingError event', () => {
+        expect(wrapper.emitted('loadingError')).toHaveLength(1);
+      });
+
+      it('displays error alert indicating that the content editor failed to load', () => {
+        expect(findContentEditorAlert().text()).toContain(
+          'An error occurred while trying to render the content editor. Please try again.',
+        );
+      });
+
+      describe('when clicking the retry button in the loading error alert and loading succeeds', () => {
+        beforeEach(async () => {
+          renderMarkdown.mockResolvedValueOnce('hello markdown');
+          await wrapper.findComponent(GlAlert).vm.$emit('primaryAction');
+        });
+
+        it('hides the loading error alert', () => {
+          expect(findContentEditorAlert().text()).toBe('');
+        });
+
+        it('sets the content editor as writable', async () => {
+          await nextTick();
+
+          expect(findEditorContent().props().editor.isEditable).toBe(true);
+        });
+      });
+    });
   });
 
-  it('renders content_editor_alert component', () => {
-    createWrapper();
+  describe('when focused event is emitted', () => {
+    beforeEach(async () => {
+      createWrapper();
+
+      findEditorStateObserver().vm.$emit('focus');
+
+      await nextTick();
+    });
+
+    it('adds is-focused class when focus event is emitted', () => {
+      expect(findEditorElement().classes()).toContain('is-focused');
+    });
+
+    it('removes is-focused class when blur event is emitted', async () => {
+      findEditorStateObserver().vm.$emit('blur');
 
-    expect(wrapper.findComponent(ContentEditorAlert).exists()).toBe(true);
+      await nextTick();
+
+      expect(findEditorElement().classes()).not.toContain('is-focused');
+    });
   });
 
-  it('renders loading indicator component', () => {
-    createWrapper();
+  describe('when editorStateObserver emits docUpdate event', () => {
+    it('emits change event with the latest markdown', async () => {
+      const markdown = 'Loaded content';
+
+      renderMarkdown.mockResolvedValueOnce(markdown);
+
+      createWrapper({ markdown: 'initial content' });
+
+      await nextTick();
+      await waitForPromises();
+
+      findEditorStateObserver().vm.$emit('docUpdate');
 
-    expect(wrapper.findComponent(LoadingIndicator).exists()).toBe(true);
+      expect(wrapper.emitted('change')).toEqual([
+        [
+          {
+            markdown,
+            changed: false,
+            empty: false,
+          },
+        ],
+      ]);
+    });
   });
 
   it.each`
@@ -129,17 +224,4 @@ describe('ContentEditor', () => {
 
     expect(wrapper.findComponent(component).exists()).toBe(true);
   });
-
-  it.each`
-    event
-    ${'loading'}
-    ${'loadingSuccess'}
-    ${'loadingError'}
-  `('broadcasts $event event triggered by editor-state-observer component', ({ event }) => {
-    createWrapper();
-
-    findEditorStateObserver().vm.$emit(event);
-
-    expect(wrapper.emitted(event)).toHaveLength(1);
-  });
 });
diff --git a/spec/frontend/content_editor/components/editor_state_observer_spec.js b/spec/frontend/content_editor/components/editor_state_observer_spec.js
index 51a594a606b83..e8c2d8c8793f1 100644
--- a/spec/frontend/content_editor/components/editor_state_observer_spec.js
+++ b/spec/frontend/content_editor/components/editor_state_observer_spec.js
@@ -4,12 +4,7 @@ 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 { ALERT_EVENT } from '~/content_editor/constants';
 import { createTestEditor } from '../test_utils';
 
 describe('content_editor/components/editor_state_observer', () => {
@@ -18,9 +13,6 @@ describe('content_editor/components/editor_state_observer', () => {
   let onDocUpdateListener;
   let onSelectionUpdateListener;
   let onTransactionListener;
-  let onLoadingContentListener;
-  let onLoadingSuccessListener;
-  let onLoadingErrorListener;
   let onAlertListener;
   let eventHub;
 
@@ -38,9 +30,6 @@ describe('content_editor/components/editor_state_observer', () => {
         selectionUpdate: onSelectionUpdateListener,
         transaction: onTransactionListener,
         [ALERT_EVENT]: onAlertListener,
-        [LOADING_CONTENT_EVENT]: onLoadingContentListener,
-        [LOADING_SUCCESS_EVENT]: onLoadingSuccessListener,
-        [LOADING_ERROR_EVENT]: onLoadingErrorListener,
       },
     });
   };
@@ -50,9 +39,6 @@ describe('content_editor/components/editor_state_observer', () => {
     onSelectionUpdateListener = jest.fn();
     onTransactionListener = jest.fn();
     onAlertListener = jest.fn();
-    onLoadingSuccessListener = jest.fn();
-    onLoadingContentListener = jest.fn();
-    onLoadingErrorListener = jest.fn();
     buildEditor();
   });
 
@@ -81,11 +67,8 @@ 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}
+    event          | listener
+    ${ALERT_EVENT} | ${() => onAlertListener}
   `('listens to $event event in the eventBus object', ({ event, listener }) => {
     const args = {};
 
@@ -114,9 +97,6 @@ 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');
diff --git a/spec/frontend/content_editor/components/loading_indicator_spec.js b/spec/frontend/content_editor/components/loading_indicator_spec.js
index e4fb09b70a4fa..0065103d01bf8 100644
--- a/spec/frontend/content_editor/components/loading_indicator_spec.js
+++ b/spec/frontend/content_editor/components/loading_indicator_spec.js
@@ -1,18 +1,10 @@
 import { GlLoadingIcon } from '@gitlab/ui';
-import { nextTick } from 'vue';
 import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
 import LoadingIndicator from '~/content_editor/components/loading_indicator.vue';
-import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue';
-import {
-  LOADING_CONTENT_EVENT,
-  LOADING_SUCCESS_EVENT,
-  LOADING_ERROR_EVENT,
-} from '~/content_editor/constants';
 
 describe('content_editor/components/loading_indicator', () => {
   let wrapper;
 
-  const findEditorStateObserver = () => wrapper.findComponent(EditorStateObserver);
   const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
 
   const createWrapper = () => {
@@ -24,48 +16,12 @@ describe('content_editor/components/loading_indicator', () => {
   });
 
   describe('when loading content', () => {
-    beforeEach(async () => {
+    beforeEach(() => {
       createWrapper();
-
-      findEditorStateObserver().vm.$emit(LOADING_CONTENT_EVENT);
-
-      await nextTick();
     });
 
     it('displays loading indicator', () => {
       expect(findLoadingIcon().exists()).toBe(true);
     });
   });
-
-  describe('when loading content succeeds', () => {
-    beforeEach(async () => {
-      createWrapper();
-
-      findEditorStateObserver().vm.$emit(LOADING_CONTENT_EVENT);
-      await nextTick();
-      findEditorStateObserver().vm.$emit(LOADING_SUCCESS_EVENT);
-      await nextTick();
-    });
-
-    it('hides loading indicator', () => {
-      expect(findLoadingIcon().exists()).toBe(false);
-    });
-  });
-
-  describe('when loading content fails', () => {
-    const error = 'error';
-
-    beforeEach(async () => {
-      createWrapper();
-
-      findEditorStateObserver().vm.$emit(LOADING_CONTENT_EVENT);
-      await nextTick();
-      findEditorStateObserver().vm.$emit(LOADING_ERROR_EVENT, error);
-      await nextTick();
-    });
-
-    it('hides loading indicator', () => {
-      expect(findLoadingIcon().exists()).toBe(false);
-    });
-  });
 });
diff --git a/spec/frontend/content_editor/services/content_editor_spec.js b/spec/frontend/content_editor/services/content_editor_spec.js
index a3553e612ca98..6175cbdd3d4ba 100644
--- a/spec/frontend/content_editor/services/content_editor_spec.js
+++ b/spec/frontend/content_editor/services/content_editor_spec.js
@@ -1,8 +1,3 @@
-import {
-  LOADING_CONTENT_EVENT,
-  LOADING_SUCCESS_EVENT,
-  LOADING_ERROR_EVENT,
-} from '~/content_editor/constants';
 import { ContentEditor } from '~/content_editor/services/content_editor';
 import eventHubFactory from '~/helpers/event_hub_factory';
 import { createTestEditor, createDocBuilder } from '../test_utils';
@@ -14,6 +9,7 @@ describe('content_editor/services/content_editor', () => {
   let eventHub;
   let doc;
   let p;
+  const testMarkdown = '**bold text**';
 
   beforeEach(() => {
     const tiptapEditor = createTestEditor();
@@ -36,6 +32,9 @@ describe('content_editor/services/content_editor', () => {
     });
   });
 
+  const testDoc = () => doc(p('document'));
+  const testEmptyDoc = () => doc();
+
   describe('.dispose', () => {
     it('destroys the tiptapEditor', () => {
       expect(contentEditor.tiptapEditor.destroy).not.toHaveBeenCalled();
@@ -46,51 +45,77 @@ describe('content_editor/services/content_editor', () => {
     });
   });
 
-  describe('when setSerializedContent succeeds', () => {
-    let document;
-    const languages = ['javascript'];
-    const testMarkdown = '**bold text**';
+  describe('empty', () => {
+    it('returns true when tiptapEditor is empty', async () => {
+      deserializer.deserialize.mockResolvedValueOnce({ document: testEmptyDoc() });
+
+      await contentEditor.setSerializedContent(testMarkdown);
 
-    beforeEach(() => {
-      document = doc(p('document'));
-      deserializer.deserialize.mockResolvedValueOnce({ document, languages });
+      expect(contentEditor.empty).toBe(true);
     });
 
-    it('emits loadingContent and loadingSuccess event in the eventHub', () => {
-      let loadingContentEmitted = false;
+    it('returns false when tiptapEditor is not empty', async () => {
+      deserializer.deserialize.mockResolvedValueOnce({ document: testDoc() });
 
-      eventHub.$on(LOADING_CONTENT_EVENT, () => {
-        loadingContentEmitted = true;
-      });
-      eventHub.$on(LOADING_SUCCESS_EVENT, () => {
-        expect(loadingContentEmitted).toBe(true);
-      });
+      await contentEditor.setSerializedContent(testMarkdown);
 
-      contentEditor.setSerializedContent(testMarkdown);
+      expect(contentEditor.empty).toBe(false);
     });
+  });
 
-    it('sets the deserialized document in the tiptap editor object', async () => {
-      await contentEditor.setSerializedContent(testMarkdown);
+  describe('editable', () => {
+    it('returns true when tiptapEditor is editable', async () => {
+      contentEditor.setEditable(true);
 
-      expect(contentEditor.tiptapEditor.state.doc.toJSON()).toEqual(document.toJSON());
+      expect(contentEditor.editable).toBe(true);
+    });
+
+    it('returns false when tiptapEditor is readonly', async () => {
+      contentEditor.setEditable(false);
+
+      expect(contentEditor.editable).toBe(false);
     });
   });
 
-  describe('when setSerializedContent fails', () => {
-    const error = 'error';
+  describe('changed', () => {
+    it('returns true when the initial document changes', async () => {
+      deserializer.deserialize.mockResolvedValueOnce({ document: testDoc() });
+
+      await contentEditor.setSerializedContent(testMarkdown);
+
+      contentEditor.tiptapEditor.commands.insertContent(' new content');
+
+      expect(contentEditor.changed).toBe(true);
+    });
+
+    it('returns false when the initial document hasn’t changed', async () => {
+      deserializer.deserialize.mockResolvedValueOnce({ document: testDoc() });
+
+      await contentEditor.setSerializedContent(testMarkdown);
+
+      expect(contentEditor.changed).toBe(false);
+    });
+
+    it('returns false when an initial document is not set and the document is empty', () => {
+      expect(contentEditor.changed).toBe(false);
+    });
 
-    beforeEach(() => {
-      deserializer.deserialize.mockRejectedValueOnce(error);
+    it('returns true when an initial document is not set and the document is not empty', () => {
+      contentEditor.tiptapEditor.commands.insertContent('new content');
+
+      expect(contentEditor.changed).toBe(true);
     });
+  });
+
+  describe('when setSerializedContent succeeds', () => {
+    it('sets the deserialized document in the tiptap editor object', async () => {
+      const document = testDoc();
+
+      deserializer.deserialize.mockResolvedValueOnce({ document });
 
-    it('emits loadingError event', async () => {
-      eventHub.$on(LOADING_ERROR_EVENT, (e) => {
-        expect(e).toBe('error');
-      });
+      await contentEditor.setSerializedContent(testMarkdown);
 
-      await expect(() => contentEditor.setSerializedContent('**bold text**')).rejects.toEqual(
-        error,
-      );
+      expect(contentEditor.tiptapEditor.state.doc.toJSON()).toEqual(document.toJSON());
     });
   });
 });
diff --git a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js
index 204c48f8de107..36a926990f29d 100644
--- a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js
+++ b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js
@@ -302,19 +302,15 @@ describe('WikiForm', () => {
     });
 
     it.each`
-      format        | enabled  | action
+      format        | exists   | action
       ${'markdown'} | ${true}  | ${'displays'}
       ${'rdoc'}     | ${false} | ${'hides'}
       ${'asciidoc'} | ${false} | ${'hides'}
       ${'org'}      | ${false} | ${'hides'}
-    `('$action toggle editing mode button when format is $format', async ({ format, enabled }) => {
+    `('$action toggle editing mode button when format is $format', async ({ format, exists }) => {
       await setFormat(format);
 
-      expect(findToggleEditingModeButton().exists()).toBe(enabled);
-    });
-
-    it('displays toggle editing mode button', () => {
-      expect(findToggleEditingModeButton().exists()).toBe(true);
+      expect(findToggleEditingModeButton().exists()).toBe(exists);
     });
 
     describe('when content editor is not active', () => {
@@ -351,15 +347,8 @@ describe('WikiForm', () => {
     });
 
     describe('when content editor is active', () => {
-      let mockContentEditor;
-
       beforeEach(() => {
         createWrapper();
-        mockContentEditor = {
-          getSerializedContent: jest.fn(),
-          setSerializedContent: jest.fn(),
-        };
-
         findToggleEditingModeButton().vm.$emit('input', 'richText');
       });
 
@@ -368,14 +357,7 @@ describe('WikiForm', () => {
       });
 
       describe('when clicking the toggle editing mode button', () => {
-        const contentEditorFakeSerializedContent = 'fake content';
-
         beforeEach(async () => {
-          mockContentEditor.getSerializedContent.mockReturnValueOnce(
-            contentEditorFakeSerializedContent,
-          );
-
-          findContentEditor().vm.$emit('initialized', mockContentEditor);
           await findToggleEditingModeButton().vm.$emit('input', 'source');
           await nextTick();
         });
@@ -387,10 +369,6 @@ describe('WikiForm', () => {
         it('displays the classic editor', () => {
           expect(findClassicEditor().exists()).toBe(true);
         });
-
-        it('updates the classic editor content field', () => {
-          expect(findContent().element.value).toBe(contentEditorFakeSerializedContent);
-        });
       });
 
       describe('when content editor is loading', () => {
@@ -480,8 +458,14 @@ describe('WikiForm', () => {
       });
 
       describe('when wiki content is updated', () => {
+        const updatedMarkdown = 'hello **world**';
+
         beforeEach(() => {
-          findContentEditor().vm.$emit('change', { empty: false });
+          findContentEditor().vm.$emit('change', {
+            empty: false,
+            changed: true,
+            markdown: updatedMarkdown,
+          });
         });
 
         it('sets before unload warning', () => {
@@ -512,16 +496,8 @@ describe('WikiForm', () => {
           });
         });
 
-        it('updates content from content editor on form submit', async () => {
-          // old value
-          expect(findContent().element.value).toBe('  My page content  ');
-
-          // wait for content editor to load
-          await waitForPromises();
-
-          await triggerFormSubmit();
-
-          expect(findContent().element.value).toBe('hello **world**');
+        it('sets content field to the content editor updated markdown', async () => {
+          expect(findContent().element.value).toBe(updatedMarkdown);
         });
       });
     });
diff --git a/spec/frontend_integration/content_editor/content_editor_integration_spec.js b/spec/frontend_integration/content_editor/content_editor_integration_spec.js
index 12cd6dcad83f0..7781a463fd6d6 100644
--- a/spec/frontend_integration/content_editor/content_editor_integration_spec.js
+++ b/spec/frontend_integration/content_editor/content_editor_integration_spec.js
@@ -1,6 +1,7 @@
 import { nextTick } from 'vue';
 import { mountExtended } from 'helpers/vue_test_utils_helper';
 import { ContentEditor } from '~/content_editor';
+import waitForPromises from 'helpers/wait_for_promises';
 
 /**
  * This spec exercises some workflows in the Content Editor without mocking
@@ -10,32 +11,34 @@ import { ContentEditor } from '~/content_editor';
 describe('content_editor', () => {
   let wrapper;
   let renderMarkdown;
-  let contentEditorService;
 
-  const buildWrapper = () => {
-    renderMarkdown = jest.fn();
+  const buildWrapper = ({ markdown = '' } = {}) => {
     wrapper = mountExtended(ContentEditor, {
       propsData: {
         renderMarkdown,
         uploadsPath: '/',
-      },
-      listeners: {
-        initialized(contentEditor) {
-          contentEditorService = contentEditor;
-        },
+        markdown,
       },
     });
   };
 
+  const waitUntilContentIsLoaded = async () => {
+    await waitForPromises();
+    await nextTick();
+  };
+
+  beforeEach(() => {
+    renderMarkdown = jest.fn();
+  });
+
   describe('when loading initial content', () => {
     describe('when the initial content is empty', () => {
       it('still hides the loading indicator', async () => {
-        buildWrapper();
-
         renderMarkdown.mockResolvedValue('');
 
-        await contentEditorService.setSerializedContent('');
-        await nextTick();
+        buildWrapper();
+
+        await waitUntilContentIsLoaded();
 
         expect(wrapper.findByTestId('content-editor-loading-indicator').exists()).toBe(false);
       });
@@ -44,14 +47,13 @@ describe('content_editor', () => {
     describe('when the initial content is not empty', () => {
       const initialContent = '<p><strong>bold text</strong></p>';
       beforeEach(async () => {
-        buildWrapper();
-
         renderMarkdown.mockResolvedValue(initialContent);
 
-        await contentEditorService.setSerializedContent('**bold text**');
-        await nextTick();
+        buildWrapper();
+
+        await waitUntilContentIsLoaded();
       });
-      it('hides the loading indicator', async () => {
+      it('hides the loading indicator', () => {
         expect(wrapper.findByTestId('content-editor-loading-indicator').exists()).toBe(false);
       });
 
@@ -70,27 +72,29 @@ describe('content_editor', () => {
     });
 
     it('processes and renders footnote ids alongside the footnote definition', async () => {
-      buildWrapper();
-
-      await contentEditorService.setSerializedContent(`
+      buildWrapper({
+        markdown: `
 This reference tag is a mix of letters and numbers [^footnote].
 
 [^footnote]: This is another footnote.
-    `);
-      await nextTick();
+        `,
+      });
+
+      await waitUntilContentIsLoaded();
 
       expect(wrapper.text()).toContain('footnote: This is another footnote');
     });
 
     it('processes and displays reference definitions', async () => {
-      buildWrapper();
-
-      await contentEditorService.setSerializedContent(`
+      buildWrapper({
+        markdown: `
 [GitLab][gitlab]
 
 [gitlab]: https://gitlab.com
-      `);
-      await nextTick();
+        `,
+      });
+
+      await waitUntilContentIsLoaded();
 
       expect(wrapper.find('pre').text()).toContain('[gitlab]: https://gitlab.com');
     });
@@ -99,9 +103,7 @@ This reference tag is a mix of letters and numbers [^footnote].
   it('renders table of contents', async () => {
     jest.useFakeTimers();
 
-    buildWrapper();
-
-    renderMarkdown.mockResolvedValue(`
+    renderMarkdown.mockResolvedValueOnce(`
 <ul class="section-nav">
 </ul>
 <h1 dir="auto" data-sourcepos="3:1-3:11">
@@ -112,16 +114,17 @@ This reference tag is a mix of letters and numbers [^footnote].
 </h2>
     `);
 
-    await contentEditorService.setSerializedContent(`
+    buildWrapper({
+      markdown: `
 [TOC]
 
 # Heading 1
 
 ## Heading 2
-    `);
+      `,
+    });
 
-    await nextTick();
-    jest.runAllTimers();
+    await waitUntilContentIsLoaded();
 
     expect(wrapper.findByTestId('table-of-contents').text()).toContain('Heading 1');
     expect(wrapper.findByTestId('table-of-contents').text()).toContain('Heading 2');
-- 
GitLab