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