Commit 0dae1d82 authored by Enrique Alcantara's avatar Enrique Alcantara

Toggle between Source and Rich Text views in Wikis

Replace the existing mechanism to load the Content
Editor in Wikis with a button that toggles between
source and rich text views.

When switching between modes, the changes applied by
the user in either editor are preserved
parent 8da8bc98
......@@ -15,6 +15,7 @@ import { setUrlFragment } from '~/lib/utils/url_utility';
import { __, s__, sprintf } from '~/locale';
import Tracking from '~/tracking';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import {
CONTENT_EDITOR_LOADED_ACTION,
SAVED_USING_CONTENT_EDITOR_ACTION,
......@@ -104,6 +105,8 @@ export default {
newPage: s__('WikiPage|Create page'),
},
cancel: s__('WikiPage|Cancel'),
editSourceButtonText: s__('WikiPage|Edit source'),
editRichTextButtonText: s__('WikiPage|Edit rich text'),
},
contentEditorFeedbackIssue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/332629',
components: {
......@@ -123,7 +126,7 @@ export default {
directives: {
GlModalDirective,
},
mixins: [trackingMixin],
mixins: [trackingMixin, glFeatureFlagMixin()],
inject: ['formatOptions', 'pageInfo'],
data() {
return {
......@@ -131,7 +134,6 @@ export default {
format: this.pageInfo.format || 'markdown',
content: this.pageInfo.content || '',
isContentEditorAlertDismissed: false,
isContentEditorLoading: true,
useContentEditor: false,
commitMessage: '',
isDirty: false,
......@@ -164,6 +166,11 @@ 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
......@@ -188,7 +195,23 @@ export default {
return this.format === 'markdown';
},
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() {
return this.noContent || !this.title || this.contentEditorRenderFailed;
......@@ -212,6 +235,14 @@ export default {
.then(({ data }) => data.body);
},
toggleEditingMode() {
if (this.useContentEditor) {
this.content = this.contentEditor.getSerializedContent();
}
this.useContentEditor = !this.useContentEditor;
},
async handleFormSubmit(e) {
e.preventDefault();
......@@ -405,6 +436,17 @@ export default {
}}</label>
</div>
<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
v-if="showContentEditorAlert"
class="gl-mb-6"
......@@ -498,7 +540,7 @@ export default {
<div class="error-alert"></div>
<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
><code>{{ linkExample }}</code></template
>
......@@ -513,7 +555,7 @@ export default {
></template
>
</gl-sprintf>
<span v-else>
<span v-if="displaySwitchBackToClassicEditorMessage">
{{ $options.i18n.contentEditor.switchToOldEditor.helpText }}
<gl-button variant="link" @click="confirmSwitchToOldEditor">{{
$options.i18n.contentEditor.switchToOldEditor.label
......
......@@ -21,6 +21,10 @@ module WikiActions
before_action :load_sidebar, except: [:pages]
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
@valid_encoding = valid_encoding?
end
......
---
name: wiki_switch_between_content_editor_raw_markdown
introduced_by_url:
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/345398
milestone: '14.5'
type: development
group: group::editor
default_enabled: false
......@@ -39122,6 +39122,12 @@ msgstr ""
msgid "WikiPage|Create page"
msgstr ""
msgid "WikiPage|Edit rich text"
msgstr ""
msgid "WikiPage|Edit source"
msgstr ""
msgid "WikiPage|Format"
msgstr ""
......
import { nextTick } from 'vue';
import { GlLoadingIcon, GlModal } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { mount, shallowMount } from '@vue/test-utils';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { mockTracking } from 'helpers/tracking_helper';
......@@ -32,12 +33,15 @@ describe('WikiForm', () => {
const findSubmitButton = () => wrapper.findByTestId('wiki-submit-button');
const findCancelButton = () => wrapper.findByRole('link', { name: 'Cancel' });
const findUseNewEditorButton = () => wrapper.findByRole('button', { name: 'Use the new editor' });
const findToggleEditingModeButton = () => wrapper.findByTestId('toggle-editing-mode-button');
const findDismissContentEditorAlertButton = () =>
wrapper.findByRole('button', { name: 'Try this later' });
const findSwitchToOldEditorButton = () =>
wrapper.findByRole('button', { name: 'Switch me back to the classic editor.' });
const findTitleHelpLink = () => wrapper.findByRole('link', { name: 'More Information.' });
const findMarkdownHelpLink = () => wrapper.findByTestId('wiki-markdown-help-link');
const findContentEditor = () => wrapper.findComponent(ContentEditor);
const findClassicEditor = () => wrapper.findComponent(MarkdownField);
const setFormat = (value) => {
const format = findFormat();
......@@ -73,18 +77,24 @@ describe('WikiForm', () => {
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(
mount(
WikiForm,
{
provide: {
formatOptions: {
Markdown: 'markdown',
RDoc: 'rdoc',
AsciiDoc: 'asciidoc',
Org: 'org',
},
formatOptions,
glFeatures,
pageInfo: {
...(persisted ? pageInfoPersisted : pageInfoNew),
...pageInfo,
......@@ -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(() => {
trackingSpy = mockTracking(undefined, null, jest.spyOn);
mock = new MockAdapter(axios);
......@@ -193,14 +224,13 @@ describe('WikiForm', () => {
});
describe('when wiki content is updated', () => {
beforeEach(() => {
beforeEach(async () => {
createWrapper(true);
const input = findContent();
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', () => {
......@@ -279,6 +309,92 @@ describe('WikiForm', () => {
);
});
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(async () => {
findToggleEditingModeButton().vm.$emit('click');
await nextTick();
});
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(),
};
wrapper.setData({ useContentEditor: true });
});
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(async () => {
mockContentEditor.getSerializedContent.mockReturnValueOnce(
contentEditorFakeSerializedContent,
);
findContentEditor().vm.$emit('initialized', mockContentEditor);
findToggleEditingModeButton().vm.$emit('click');
await nextTick();
});
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', () => {
beforeEach(() => {
createWrapper(true);
......@@ -306,8 +422,8 @@ describe('WikiForm', () => {
});
const assertOldEditorIsVisible = () => {
expect(wrapper.findComponent(ContentEditor).exists()).toBe(false);
expect(wrapper.findComponent(MarkdownField).exists()).toBe(true);
expect(findContentEditor().exists()).toBe(false);
expect(findClassicEditor().exists()).toBe(true);
expect(findSubmitButton().props('disabled')).toBe(false);
expect(wrapper.text()).not.toContain(
......@@ -376,10 +492,6 @@ describe('WikiForm', () => {
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', () => {
expect(wrapper.text()).toContain('Tell us your experiences with the new Markdown editor');
});
......@@ -412,16 +524,8 @@ describe('WikiForm', () => {
});
describe('when wiki content is updated', () => {
beforeEach(async () => {
// wait for content editor to load
await waitForPromises();
wrapper.vm.contentEditor.tiptapEditor.commands.setContent(
'<p>hello __world__ from content editor</p>',
true,
);
return wrapper.vm.$nextTick();
beforeEach(() => {
findContentEditor().vm.$emit('change', { empty: false });
});
it('sets before unload warning', () => {
......@@ -432,7 +536,7 @@ describe('WikiForm', () => {
it('unsets before unload warning on form submit', async () => {
triggerFormSubmit();
await wrapper.vm.$nextTick();
await nextTick();
const e = dispatchBeforeUnload();
expect(e.preventDefault).not.toHaveBeenCalled();
......
......@@ -138,11 +138,26 @@ RSpec.shared_examples 'User updates wiki page' do
end
context 'when using the content editor' do
before do
click_button 'Use the new editor'
context 'with feature flag on' do
before do
click_button 'Edit rich text'
end
it_behaves_like 'edits content using the content editor'
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
......
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