Commit d06ca3a1 authored by Jacques Erasmus's avatar Jacques Erasmus

Merge branch '342729-switch-between-editing-content-editor-source-editor' into 'master'

Toggle between Source and Rich Text editing modes in Wikis

See merge request gitlab-org/gitlab!74457
parents cdff7b0a e167f1d9
...@@ -15,6 +15,7 @@ import { setUrlFragment } from '~/lib/utils/url_utility'; ...@@ -15,6 +15,7 @@ import { setUrlFragment } from '~/lib/utils/url_utility';
import { __, s__, sprintf } from '~/locale'; import { __, s__, sprintf } from '~/locale';
import Tracking from '~/tracking'; import Tracking from '~/tracking';
import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { import {
CONTENT_EDITOR_LOADED_ACTION, CONTENT_EDITOR_LOADED_ACTION,
SAVED_USING_CONTENT_EDITOR_ACTION, SAVED_USING_CONTENT_EDITOR_ACTION,
...@@ -104,6 +105,8 @@ export default { ...@@ -104,6 +105,8 @@ export default {
newPage: s__('WikiPage|Create page'), newPage: s__('WikiPage|Create page'),
}, },
cancel: s__('WikiPage|Cancel'), cancel: s__('WikiPage|Cancel'),
editSourceButtonText: s__('WikiPage|Edit source'),
editRichTextButtonText: s__('WikiPage|Edit rich text'),
}, },
contentEditorFeedbackIssue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/332629', contentEditorFeedbackIssue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/332629',
components: { components: {
...@@ -123,7 +126,7 @@ export default { ...@@ -123,7 +126,7 @@ export default {
directives: { directives: {
GlModalDirective, GlModalDirective,
}, },
mixins: [trackingMixin], mixins: [trackingMixin, glFeatureFlagMixin()],
inject: ['formatOptions', 'pageInfo'], inject: ['formatOptions', 'pageInfo'],
data() { data() {
return { return {
...@@ -131,7 +134,6 @@ export default { ...@@ -131,7 +134,6 @@ export default {
format: this.pageInfo.format || 'markdown', format: this.pageInfo.format || 'markdown',
content: this.pageInfo.content || '', content: this.pageInfo.content || '',
isContentEditorAlertDismissed: false, isContentEditorAlertDismissed: false,
isContentEditorLoading: true,
useContentEditor: false, useContentEditor: false,
commitMessage: '', commitMessage: '',
isDirty: false, isDirty: false,
...@@ -164,6 +166,11 @@ export default { ...@@ -164,6 +166,11 @@ export default {
linkExample() { linkExample() {
return MARKDOWN_LINK_TEXT[this.format]; return MARKDOWN_LINK_TEXT[this.format];
}, },
toggleEditingModeButtonText() {
return this.isContentEditorActive
? this.$options.i18n.editSourceButtonText
: this.$options.i18n.editRichTextButtonText;
},
submitButtonText() { submitButtonText() {
return this.pageInfo.persisted return this.pageInfo.persisted
? this.$options.i18n.submitButton.existingPage ? this.$options.i18n.submitButton.existingPage
...@@ -188,7 +195,23 @@ export default { ...@@ -188,7 +195,23 @@ export default {
return this.format === 'markdown'; return this.format === 'markdown';
}, },
showContentEditorAlert() { showContentEditorAlert() {
return this.isMarkdownFormat && !this.useContentEditor && !this.isContentEditorAlertDismissed; return (
!this.glFeatures.wikiSwitchBetweenContentEditorRawMarkdown &&
this.isMarkdownFormat &&
!this.useContentEditor &&
!this.isContentEditorAlertDismissed
);
},
showSwitchEditingModeButton() {
return this.glFeatures.wikiSwitchBetweenContentEditorRawMarkdown && this.isMarkdownFormat;
},
displayWikiSpecificMarkdownHelp() {
return !this.isContentEditorActive;
},
displaySwitchBackToClassicEditorMessage() {
return (
!this.glFeatures.wikiSwitchBetweenContentEditorRawMarkdown && this.isContentEditorActive
);
}, },
disableSubmitButton() { disableSubmitButton() {
return this.noContent || !this.title || this.contentEditorRenderFailed; return this.noContent || !this.title || this.contentEditorRenderFailed;
...@@ -212,6 +235,14 @@ export default { ...@@ -212,6 +235,14 @@ export default {
.then(({ data }) => data.body); .then(({ data }) => data.body);
}, },
toggleEditingMode() {
if (this.useContentEditor) {
this.content = this.contentEditor.getSerializedContent();
}
this.useContentEditor = !this.useContentEditor;
},
async handleFormSubmit(e) { async handleFormSubmit(e) {
e.preventDefault(); e.preventDefault();
...@@ -405,6 +436,17 @@ export default { ...@@ -405,6 +436,17 @@ export default {
}}</label> }}</label>
</div> </div>
<div class="col-sm-10"> <div class="col-sm-10">
<div
v-if="showSwitchEditingModeButton"
class="gl-display-flex gl-justify-content-end gl-mb-3"
>
<gl-button
data-testid="toggle-editing-mode-button"
variant="link"
@click="toggleEditingMode"
>{{ toggleEditingModeButtonText }}</gl-button
>
</div>
<gl-alert <gl-alert
v-if="showContentEditorAlert" v-if="showContentEditorAlert"
class="gl-mb-6" class="gl-mb-6"
...@@ -498,7 +540,7 @@ export default { ...@@ -498,7 +540,7 @@ export default {
<div class="error-alert"></div> <div class="error-alert"></div>
<div class="form-text gl-text-gray-600"> <div class="form-text gl-text-gray-600">
<gl-sprintf v-if="!isContentEditorActive" :message="$options.i18n.linksHelpText"> <gl-sprintf v-if="displayWikiSpecificMarkdownHelp" :message="$options.i18n.linksHelpText">
<template #linkExample <template #linkExample
><code>{{ linkExample }}</code></template ><code>{{ linkExample }}</code></template
> >
...@@ -513,7 +555,7 @@ export default { ...@@ -513,7 +555,7 @@ export default {
></template ></template
> >
</gl-sprintf> </gl-sprintf>
<span v-else> <span v-if="displaySwitchBackToClassicEditorMessage">
{{ $options.i18n.contentEditor.switchToOldEditor.helpText }} {{ $options.i18n.contentEditor.switchToOldEditor.helpText }}
<gl-button variant="link" @click="confirmSwitchToOldEditor">{{ <gl-button variant="link" @click="confirmSwitchToOldEditor">{{
$options.i18n.contentEditor.switchToOldEditor.label $options.i18n.contentEditor.switchToOldEditor.label
......
...@@ -21,6 +21,10 @@ module WikiActions ...@@ -21,6 +21,10 @@ module WikiActions
before_action :load_sidebar, except: [:pages] before_action :load_sidebar, except: [:pages]
before_action :set_content_class before_action :set_content_class
before_action do
push_frontend_feature_flag(:wiki_switch_between_content_editor_raw_markdown, @group, default_enabled: :yaml)
end
before_action only: [:show, :edit, :update] do before_action only: [:show, :edit, :update] do
@valid_encoding = valid_encoding? @valid_encoding = valid_encoding?
end end
......
---
name: wiki_switch_between_content_editor_raw_markdown
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/74457
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/345398
milestone: '14.6'
type: development
group: group::editor
default_enabled: false
...@@ -39140,6 +39140,12 @@ msgstr "" ...@@ -39140,6 +39140,12 @@ msgstr ""
msgid "WikiPage|Create page" msgid "WikiPage|Create page"
msgstr "" msgstr ""
msgid "WikiPage|Edit rich text"
msgstr ""
msgid "WikiPage|Edit source"
msgstr ""
msgid "WikiPage|Format" msgid "WikiPage|Format"
msgstr "" msgstr ""
......
import { nextTick } from 'vue';
import { GlLoadingIcon, GlModal } from '@gitlab/ui'; import { GlLoadingIcon, GlModal } from '@gitlab/ui';
import { mount } from '@vue/test-utils'; import { mount, shallowMount } from '@vue/test-utils';
import axios from 'axios'; import axios from 'axios';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import { mockTracking } from 'helpers/tracking_helper'; import { mockTracking } from 'helpers/tracking_helper';
...@@ -32,12 +33,15 @@ describe('WikiForm', () => { ...@@ -32,12 +33,15 @@ describe('WikiForm', () => {
const findSubmitButton = () => wrapper.findByTestId('wiki-submit-button'); const findSubmitButton = () => wrapper.findByTestId('wiki-submit-button');
const findCancelButton = () => wrapper.findByRole('link', { name: 'Cancel' }); const findCancelButton = () => wrapper.findByRole('link', { name: 'Cancel' });
const findUseNewEditorButton = () => wrapper.findByRole('button', { name: 'Use the new editor' }); const findUseNewEditorButton = () => wrapper.findByRole('button', { name: 'Use the new editor' });
const findToggleEditingModeButton = () => wrapper.findByTestId('toggle-editing-mode-button');
const findDismissContentEditorAlertButton = () => const findDismissContentEditorAlertButton = () =>
wrapper.findByRole('button', { name: 'Try this later' }); wrapper.findByRole('button', { name: 'Try this later' });
const findSwitchToOldEditorButton = () => const findSwitchToOldEditorButton = () =>
wrapper.findByRole('button', { name: 'Switch me back to the classic editor.' }); wrapper.findByRole('button', { name: 'Switch me back to the classic editor.' });
const findTitleHelpLink = () => wrapper.findByRole('link', { name: 'More Information.' }); const findTitleHelpLink = () => wrapper.findByRole('link', { name: 'More Information.' });
const findMarkdownHelpLink = () => wrapper.findByTestId('wiki-markdown-help-link'); const findMarkdownHelpLink = () => wrapper.findByTestId('wiki-markdown-help-link');
const findContentEditor = () => wrapper.findComponent(ContentEditor);
const findClassicEditor = () => wrapper.findComponent(MarkdownField);
const setFormat = (value) => { const setFormat = (value) => {
const format = findFormat(); const format = findFormat();
...@@ -73,18 +77,24 @@ describe('WikiForm', () => { ...@@ -73,18 +77,24 @@ describe('WikiForm', () => {
path: '/project/path/-/wikis/home', path: '/project/path/-/wikis/home',
}; };
function createWrapper(persisted = false, { pageInfo } = {}) { const formatOptions = {
Markdown: 'markdown',
RDoc: 'rdoc',
AsciiDoc: 'asciidoc',
Org: 'org',
};
function createWrapper(
persisted = false,
{ pageInfo, glFeatures = { wikiSwitchBetweenContentEditorRawMarkdown: false } } = {},
) {
wrapper = extendedWrapper( wrapper = extendedWrapper(
mount( mount(
WikiForm, WikiForm,
{ {
provide: { provide: {
formatOptions: { formatOptions,
Markdown: 'markdown', glFeatures,
RDoc: 'rdoc',
AsciiDoc: 'asciidoc',
Org: 'org',
},
pageInfo: { pageInfo: {
...(persisted ? pageInfoPersisted : pageInfoNew), ...(persisted ? pageInfoPersisted : pageInfoNew),
...pageInfo, ...pageInfo,
...@@ -96,6 +106,27 @@ describe('WikiForm', () => { ...@@ -96,6 +106,27 @@ describe('WikiForm', () => {
); );
} }
const createShallowWrapper = (
persisted = false,
{ pageInfo, glFeatures = { wikiSwitchBetweenContentEditorRawMarkdown: false } } = {},
) => {
wrapper = extendedWrapper(
shallowMount(WikiForm, {
provide: {
formatOptions,
glFeatures,
pageInfo: {
...(persisted ? pageInfoPersisted : pageInfoNew),
...pageInfo,
},
},
stubs: {
MarkdownField,
},
}),
);
};
beforeEach(() => { beforeEach(() => {
trackingSpy = mockTracking(undefined, null, jest.spyOn); trackingSpy = mockTracking(undefined, null, jest.spyOn);
mock = new MockAdapter(axios); mock = new MockAdapter(axios);
...@@ -193,14 +224,13 @@ describe('WikiForm', () => { ...@@ -193,14 +224,13 @@ describe('WikiForm', () => {
}); });
describe('when wiki content is updated', () => { describe('when wiki content is updated', () => {
beforeEach(() => { beforeEach(async () => {
createWrapper(true); createWrapper(true);
const input = findContent(); const input = findContent();
input.setValue(' Lorem ipsum dolar sit! '); input.setValue(' Lorem ipsum dolar sit! ');
input.element.dispatchEvent(new Event('input'));
return wrapper.vm.$nextTick(); await input.trigger('input');
}); });
it('sets before unload warning', () => { it('sets before unload warning', () => {
...@@ -279,6 +309,100 @@ describe('WikiForm', () => { ...@@ -279,6 +309,100 @@ describe('WikiForm', () => {
); );
}); });
describe('when wikiSwitchBetweenContentEditorRawMarkdown feature flag is not enabled', () => {
beforeEach(() => {
createShallowWrapper(true, {
glFeatures: { wikiSwitchBetweenContentEditorRawMarkdown: false },
});
});
it('hides toggle editing mode button', () => {
expect(findToggleEditingModeButton().exists()).toBe(false);
});
});
describe('when wikiSwitchBetweenContentEditorRawMarkdown feature flag is enabled', () => {
beforeEach(() => {
createShallowWrapper(true, {
glFeatures: { wikiSwitchBetweenContentEditorRawMarkdown: true },
});
});
it('hides gl-alert containing "use new editor" button', () => {
expect(findUseNewEditorButton().exists()).toBe(false);
});
it('displays toggle editing mode button', () => {
expect(findToggleEditingModeButton().exists()).toBe(true);
});
describe('when content editor is not active', () => {
it('displays "Edit rich text" label in the toggle editing mode button', () => {
expect(findToggleEditingModeButton().text()).toBe('Edit rich text');
});
describe('when clicking the toggle editing mode button', () => {
beforeEach(() => {
findToggleEditingModeButton().vm.$emit('click');
});
it('hides the classic editor', () => {
expect(findClassicEditor().exists()).toBe(false);
});
it('hides the content editor', () => {
expect(findContentEditor().exists()).toBe(true);
});
});
});
describe('when content editor is active', () => {
let mockContentEditor;
beforeEach(() => {
mockContentEditor = {
getSerializedContent: jest.fn(),
setSerializedContent: jest.fn(),
};
findToggleEditingModeButton().vm.$emit('click');
});
it('hides switch to old editor button', () => {
expect(findSwitchToOldEditorButton().exists()).toBe(false);
});
it('displays "Edit source" label in the toggle editing mode button', () => {
expect(findToggleEditingModeButton().text()).toBe('Edit source');
});
describe('when clicking the toggle editing mode button', () => {
const contentEditorFakeSerializedContent = 'fake content';
beforeEach(() => {
mockContentEditor.getSerializedContent.mockReturnValueOnce(
contentEditorFakeSerializedContent,
);
findContentEditor().vm.$emit('initialized', mockContentEditor);
findToggleEditingModeButton().vm.$emit('click');
});
it('hides the content editor', () => {
expect(findContentEditor().exists()).toBe(false);
});
it('displays the classic editor', () => {
expect(findClassicEditor().exists()).toBe(true);
});
it('updates the classic editor content field', () => {
expect(findContent().element.value).toBe(contentEditorFakeSerializedContent);
});
});
});
});
describe('wiki content editor', () => { describe('wiki content editor', () => {
beforeEach(() => { beforeEach(() => {
createWrapper(true); createWrapper(true);
...@@ -306,8 +430,8 @@ describe('WikiForm', () => { ...@@ -306,8 +430,8 @@ describe('WikiForm', () => {
}); });
const assertOldEditorIsVisible = () => { const assertOldEditorIsVisible = () => {
expect(wrapper.findComponent(ContentEditor).exists()).toBe(false); expect(findContentEditor().exists()).toBe(false);
expect(wrapper.findComponent(MarkdownField).exists()).toBe(true); expect(findClassicEditor().exists()).toBe(true);
expect(findSubmitButton().props('disabled')).toBe(false); expect(findSubmitButton().props('disabled')).toBe(false);
expect(wrapper.text()).not.toContain( expect(wrapper.text()).not.toContain(
...@@ -376,10 +500,6 @@ describe('WikiForm', () => { ...@@ -376,10 +500,6 @@ describe('WikiForm', () => {
findUseNewEditorButton().trigger('click'); findUseNewEditorButton().trigger('click');
}); });
it('shows a loading indicator for the rich text editor', () => {
expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
});
it('shows a tip to send feedback', () => { it('shows a tip to send feedback', () => {
expect(wrapper.text()).toContain('Tell us your experiences with the new Markdown editor'); expect(wrapper.text()).toContain('Tell us your experiences with the new Markdown editor');
}); });
...@@ -412,16 +532,8 @@ describe('WikiForm', () => { ...@@ -412,16 +532,8 @@ describe('WikiForm', () => {
}); });
describe('when wiki content is updated', () => { describe('when wiki content is updated', () => {
beforeEach(async () => { beforeEach(() => {
// wait for content editor to load findContentEditor().vm.$emit('change', { empty: false });
await waitForPromises();
wrapper.vm.contentEditor.tiptapEditor.commands.setContent(
'<p>hello __world__ from content editor</p>',
true,
);
return wrapper.vm.$nextTick();
}); });
it('sets before unload warning', () => { it('sets before unload warning', () => {
...@@ -432,7 +544,7 @@ describe('WikiForm', () => { ...@@ -432,7 +544,7 @@ describe('WikiForm', () => {
it('unsets before unload warning on form submit', async () => { it('unsets before unload warning on form submit', async () => {
triggerFormSubmit(); triggerFormSubmit();
await wrapper.vm.$nextTick(); await nextTick();
const e = dispatchBeforeUnload(); const e = dispatchBeforeUnload();
expect(e.preventDefault).not.toHaveBeenCalled(); expect(e.preventDefault).not.toHaveBeenCalled();
......
...@@ -138,11 +138,26 @@ RSpec.shared_examples 'User updates wiki page' do ...@@ -138,11 +138,26 @@ RSpec.shared_examples 'User updates wiki page' do
end end
context 'when using the content editor' do context 'when using the content editor' do
before do context 'with feature flag on' do
click_button 'Use the new editor' before do
click_button 'Edit rich text'
end
it_behaves_like 'edits content using the content editor'
end end
it_behaves_like 'edits content using the content editor' context 'with feature flag off' do
before do
stub_feature_flags(wiki_switch_between_content_editor_raw_markdown: false)
visit(wiki_path(wiki))
click_link('Edit')
click_button 'Use the new editor'
end
it_behaves_like 'edits content using the content editor'
end
end end
end end
......
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