Commit a0a2d536 authored by Miguel Rincon's avatar Miguel Rincon

Merge branch '325201-content-editor-wiki' into 'master'

Add Content Editor as an option when creating or editing a Wiki Page [RUN ALL RSPEC] [RUN AS-IF-FOSS]

See merge request gitlab-org/gitlab!57370
parents 7c36265f cb69a12b
......@@ -17,10 +17,8 @@ export default {
};
</script>
<template>
<div
class="gl-display-flex gl-flex-direction-column gl-p-3 gl-border-solid gl-border-1 gl-border-gray-200 gl-rounded-base"
>
<top-toolbar class="gl-mb-3" :editor="editor" />
<editor-content class="md" :editor="editor" />
<div class="md md-area" :class="{ 'is-focused': editor.focused }">
<top-toolbar class="gl-mb-4" :editor="editor" />
<editor-content :editor="editor" />
</div>
</template>
......@@ -18,7 +18,12 @@ import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants';
import CodeBlockHighlight from '../extensions/code_block_highlight';
import createMarkdownSerializer from './markdown_serializer';
const createEditor = async ({ content, renderMarkdown, serializer: customSerializer } = {}) => {
const createEditor = async ({
content,
renderMarkdown,
serializer: customSerializer,
...options
} = {}) => {
if (!customSerializer && !isFunction(renderMarkdown)) {
throw new Error(PROVIDE_SERIALIZER_OR_RENDERER_ERROR);
}
......@@ -41,14 +46,10 @@ const createEditor = async ({ content, renderMarkdown, serializer: customSeriali
],
editorProps: {
attributes: {
/*
* Adds some padding to the contenteditable element where the user types.
* Otherwise, the text cursor is not visible when its position is at the
* beginning of a line.
*/
class: 'gl-py-4 gl-px-5',
class: 'gl-outline-0!',
},
},
...options,
});
const serializer = customSerializer || createMarkdownSerializer({ render: renderMarkdown });
......
<script>
import { GlForm, GlIcon, GlLink, GlButton, GlSprintf } from '@gitlab/ui';
import { GlForm, GlIcon, GlLink, GlButton, GlSprintf, GlAlert, GlLoadingIcon } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import csrf from '~/lib/utils/csrf';
import { setUrlFragment } from '~/lib/utils/url_utility';
import { __, s__, sprintf } from '~/locale';
import { s__, sprintf } from '~/locale';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
const MARKDOWN_LINK_TEXT = {
markdown: '[Link Title](page-slug)',
......@@ -14,20 +16,31 @@ const MARKDOWN_LINK_TEXT = {
export default {
components: {
GlAlert,
GlForm,
GlSprintf,
GlIcon,
GlLink,
GlButton,
MarkdownField,
GlLoadingIcon,
ContentEditor: () =>
import(
/* webpackChunkName: 'content_editor' */ '~/content_editor/components/content_editor.vue'
),
},
mixins: [glFeatureFlagMixin()],
inject: ['formatOptions', 'pageInfo'],
data() {
return {
title: this.pageInfo.title?.trim() || '',
format: this.pageInfo.format || 'markdown',
content: this.pageInfo.content?.trim() || '',
isContentEditorLoading: true,
useContentEditor: false,
commitMessage: '',
editor: null,
isDirty: false,
};
},
computed: {
......@@ -52,7 +65,7 @@ export default {
return MARKDOWN_LINK_TEXT[this.format];
},
submitButtonText() {
if (this.pageInfo.persisted) return __('Save changes');
if (this.pageInfo.persisted) return s__('WikiPage|Save changes');
return s__('WikiPage|Create page');
},
cancelFormPath() {
......@@ -62,20 +75,50 @@ export default {
wikiSpecificMarkdownHelpPath() {
return setUrlFragment(this.pageInfo.markdownHelpPath, 'wiki-specific-markdown');
},
isMarkdownFormat() {
return this.format === 'markdown';
},
showContentEditorButton() {
return this.isMarkdownFormat && !this.useContentEditor && this.glFeatures.wikiContentEditor;
},
isContentEditorActive() {
return this.isMarkdownFormat && this.useContentEditor;
},
},
mounted() {
this.updateCommitMessage();
window.addEventListener('beforeunload', this.onPageUnload);
},
destroyed() {
window.removeEventListener('beforeunload', this.onPageUnload);
},
methods: {
getContentHTML(content) {
return axios
.post(this.pageInfo.markdownPreviewPath, { text: content })
.then(({ data }) => data.body);
},
handleFormSubmit() {
window.removeEventListener('beforeunload', this.onBeforeUnload);
if (this.useContentEditor) {
this.content = this.editor.getSerializedContent();
}
this.isDirty = false;
},
handleContentChange() {
window.addEventListener('beforeunload', this.onBeforeUnload);
this.isDirty = true;
},
onBeforeUnload() {
onPageUnload(event) {
if (!this.isDirty) return undefined;
event.preventDefault();
// eslint-disable-next-line no-param-reassign
event.returnValue = '';
return '';
},
......@@ -88,6 +131,28 @@ export default {
const newCommitMessage = sprintf(this.commitMessageI18n, { pageTitle: newTitle }, false);
this.commitMessage = newCommitMessage;
},
async initContentEditor() {
this.isContentEditorLoading = true;
this.useContentEditor = true;
const createEditor = await import(
/* webpackChunkName: 'content_editor' */ '~/content_editor/services/create_editor'
);
this.editor =
this.editor ||
(await createEditor.default({
renderMarkdown: (markdown) => this.getContentHTML(markdown),
onUpdate: () => this.handleContentChange(),
}));
await this.editor.setSerializedContent(this.content);
this.isContentEditorLoading = false;
},
switchToOldEditor() {
this.useContentEditor = false;
},
},
};
</script>
......@@ -99,6 +164,30 @@ export default {
class="wiki-form common-note-form gl-mt-3 js-quick-submit"
@submit="handleFormSubmit"
>
<gl-alert
v-if="isContentEditorActive"
class="gl-mb-6"
:dismissible="false"
variant="danger"
:primary-button-text="s__('WikiPage|Switch to old editor')"
@primaryAction="switchToOldEditor()"
>
<p>
{{
s__(
"WikiPage|You are editing this page with Content Editor. This editor is in beta and may not display the page's contents properly.",
)
}}
</p>
<p>
{{
s__(
"WikiPage|Switching to the old editor will discard any changes you've made in the new editor.",
)
}}
</p>
</gl-alert>
<input :value="csrfToken" type="hidden" name="authenticity_token" />
<input v-if="pageInfo.persisted" type="hidden" name="_method" value="put" />
<input
......@@ -135,8 +224,8 @@ export default {
'WikiPage|Tip: You can specify the full path for the new file. We will automatically create any missing directories.',
)
}}
<gl-link :href="helpPath" target="_blank" data-testid="wiki-title-help-link"
><gl-icon name="question-o" /> {{ __('More Information.') }}</gl-link
<gl-link :href="helpPath" target="_blank"
><gl-icon name="question-o" /> {{ s__('WikiPage|More Information.') }}</gl-link
>
</span>
</div>
......@@ -147,12 +236,26 @@ export default {
s__('WikiPage|Format')
}}</label>
</div>
<div class="col-sm-10">
<select id="wiki_format" v-model="format" class="form-control" name="wiki[format]">
<div class="col-sm-10 gl-display-flex gl-flex-wrap">
<select
id="wiki_format"
v-model="format"
class="form-control"
name="wiki[format]"
:disabled="isContentEditorActive"
>
<option v-for="(key, label) of formatOptions" :key="key" :value="key">
{{ label }}
</option>
</select>
<gl-button
v-if="showContentEditorButton"
category="secondary"
variant="confirm"
class="gl-mt-4"
@click="initContentEditor"
>{{ s__('WikiPage|Use new editor') }}</gl-button
>
</div>
</div>
<div class="form-group row">
......@@ -163,6 +266,7 @@ export default {
</div>
<div class="col-sm-10">
<markdown-field
v-if="!isContentEditorActive"
:markdown-preview-path="pageInfo.markdownPreviewPath"
:can-attach-file="true"
:enable-autocomplete="true"
......@@ -189,10 +293,17 @@ export default {
</textarea>
</template>
</markdown-field>
<div v-if="isContentEditorActive">
<gl-loading-icon v-if="isContentEditorLoading" class="bordered-box gl-w-full gl-py-6" />
<content-editor v-else :editor="editor" />
<input id="wiki_content" v-model.trim="content" type="hidden" name="wiki[content]" />
</div>
<div class="clearfix"></div>
<div class="error-alert"></div>
<div class="form-text gl-text-gray-600">
<div v-if="!isContentEditorActive" class="form-text gl-text-gray-600">
<gl-sprintf
:message="
s__(
......@@ -245,9 +356,7 @@ export default {
:disabled="!content || !title"
>{{ submitButtonText }}</gl-button
>
<gl-button :href="cancelFormPath" class="float-right" data-testid="wiki-cancel-button">{{
__('Cancel')
}}</gl-button>
<gl-button :href="cancelFormPath" class="float-right">{{ s__('WikiPage|Cancel') }}</gl-button>
</div>
</gl-form>
</template>
......@@ -6,4 +6,8 @@ class Projects::WikisController < Projects::ApplicationController
alias_method :container, :project
feature_category :wiki
before_action do
push_frontend_feature_flag(:wiki_content_editor, project, default_enabled: :yaml)
end
end
---
name: wiki_content_editor
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/57370
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/255919
group: group::editor
type: development
default_enabled: false
......@@ -21038,9 +21038,6 @@ msgstr ""
msgid "More Information"
msgstr ""
msgid "More Information."
msgstr ""
msgid "More Slack commands"
msgstr ""
......@@ -36083,6 +36080,9 @@ 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|Cancel"
msgstr ""
msgid "WikiPage|Commit message"
msgstr ""
......@@ -36098,9 +36098,21 @@ msgstr ""
msgid "WikiPage|Format"
msgstr ""
msgid "WikiPage|More Information."
msgstr ""
msgid "WikiPage|Page title"
msgstr ""
msgid "WikiPage|Save changes"
msgstr ""
msgid "WikiPage|Switch to old editor"
msgstr ""
msgid "WikiPage|Switching to the old editor will discard any changes you've made in the new editor."
msgstr ""
msgid "WikiPage|Tip: You can move this page by adding the path to the beginning of the title."
msgstr ""
......@@ -36116,9 +36128,15 @@ msgstr ""
msgid "WikiPage|Update %{pageTitle}"
msgstr ""
msgid "WikiPage|Use new editor"
msgstr ""
msgid "WikiPage|Write your content or drag files here…"
msgstr ""
msgid "WikiPage|You are editing this page with Content Editor. This editor is in beta and may not display the page's contents properly."
msgstr ""
msgid "Wikis"
msgstr ""
......
import { shallowMount } from '@vue/test-utils';
import { mount } from '@vue/test-utils';
import { EditorContent } from 'tiptap';
import waitForPromises from 'helpers/wait_for_promises';
import ContentEditor from '~/content_editor/components/content_editor.vue';
import TopToolbar from '~/content_editor/components/top_toolbar.vue';
import createEditor from '~/content_editor/services/create_editor';
import createMarkdownSerializer from '~/content_editor/services/markdown_serializer';
describe('ContentEditor', () => {
let wrapper;
let editor;
const buildWrapper = async () => {
editor = await createEditor({ serializer: createMarkdownSerializer({ toHTML: () => '' }) });
wrapper = shallowMount(ContentEditor, {
const createWrapper = async (_editor) => {
wrapper = mount(ContentEditor, {
propsData: {
editor,
editor: _editor,
},
});
};
beforeEach(async () => {
editor = await createEditor({ renderMarkdown: () => 'sample text' });
createWrapper(editor);
await waitForPromises();
});
afterEach(() => {
wrapper.destroy();
});
it('renders editor content component and attaches editor instance', async () => {
await buildWrapper();
expect(wrapper.findComponent(EditorContent).props().editor).toBe(editor);
});
it('renders top toolbar component and attaches editor instance', async () => {
await buildWrapper();
expect(wrapper.findComponent(TopToolbar).props().editor).toBe(editor);
});
});
......@@ -11,12 +11,12 @@ describe('content_editor/services/create_editor', () => {
deserialize: jest.fn(),
});
it('sets gl-py-4 gl-px-5 class selectors to editor attributes', async () => {
it('sets gl-outline-0! class selector to editor attributes', async () => {
const editor = await createEditor({ renderMarkdown });
expect(editor.options.editorProps).toMatchObject({
attributes: {
class: 'gl-py-4 gl-px-5',
class: 'gl-outline-0!',
},
});
});
......
import { GlAlert, GlButton, GlLoadingIcon } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import ContentEditor from '~/content_editor/components/content_editor.vue';
import WikiForm from '~/pages/shared/wikis/components/wiki_form.vue';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
describe('WikiForm', () => {
let wrapper;
......@@ -11,10 +17,26 @@ describe('WikiForm', () => {
const findContent = () => wrapper.find('#wiki_content');
const findMessage = () => wrapper.find('#wiki_message');
const findSubmitButton = () => wrapper.findByTestId('wiki-submit-button');
const findCancelButton = () => wrapper.findByTestId('wiki-cancel-button');
const findTitleHelpLink = () => wrapper.findByTestId('wiki-title-help-link');
const findCancelButton = () => wrapper.findByRole('link', { name: 'Cancel' });
const findUseNewEditorButton = () => wrapper.findByRole('button', { name: 'Use new editor' });
const findTitleHelpLink = () => wrapper.findByRole('link', { name: 'More Information.' });
const findMarkdownHelpLink = () => wrapper.findByTestId('wiki-markdown-help-link');
const setFormat = (value) => {
const format = findFormat();
format.find(`option[value=${value}]`).setSelected();
format.element.dispatchEvent(new Event('change'));
};
const triggerFormSubmit = () => findForm().element.dispatchEvent(new Event('submit'));
const dispatchBeforeUnload = () => {
const e = new Event('beforeunload');
jest.spyOn(e, 'preventDefault');
window.dispatchEvent(e);
return e;
};
const pageInfoNew = {
persisted: false,
uploadsPath: '/project/path/-/wikis/attachments',
......@@ -35,7 +57,10 @@ describe('WikiForm', () => {
path: '/project/path/-/wikis/home',
};
function createWrapper(persisted = false, pageInfo = {}) {
function createWrapper(
persisted = false,
{ pageInfo, glFeatures } = { glFeatures: { wikiContentEditor: false } },
) {
wrapper = extendedWrapper(
mount(
WikiForm,
......@@ -51,13 +76,12 @@ describe('WikiForm', () => {
...(persisted ? pageInfoPersisted : pageInfoNew),
...pageInfo,
},
glFeatures,
},
},
{ attachToDocument: true },
),
);
jest.spyOn(wrapper.vm, 'onBeforeUnload');
}
afterEach(() => {
......@@ -101,7 +125,7 @@ describe('WikiForm', () => {
`('updates the link help message when format=$value is selected', async ({ value, text }) => {
createWrapper();
findFormat().find(`option[value=${value}]`).setSelected();
setFormat(value);
await wrapper.vm.$nextTick();
......@@ -113,9 +137,9 @@ describe('WikiForm', () => {
await wrapper.vm.$nextTick();
window.dispatchEvent(new Event('beforeunload'));
expect(wrapper.vm.onBeforeUnload).not.toHaveBeenCalled();
const e = dispatchBeforeUnload();
expect(typeof e.returnValue).not.toBe('string');
expect(e.preventDefault).not.toHaveBeenCalled();
});
it.each`
......@@ -156,19 +180,18 @@ describe('WikiForm', () => {
});
it('sets before unload warning', () => {
window.dispatchEvent(new Event('beforeunload'));
const e = dispatchBeforeUnload();
expect(wrapper.vm.onBeforeUnload).toHaveBeenCalled();
expect(e.preventDefault).toHaveBeenCalledTimes(1);
});
it('when form submitted, unsets before unload warning', async () => {
findForm().element.dispatchEvent(new Event('submit'));
triggerFormSubmit();
await wrapper.vm.$nextTick();
window.dispatchEvent(new Event('beforeunload'));
expect(wrapper.vm.onBeforeUnload).not.toHaveBeenCalled();
const e = dispatchBeforeUnload();
expect(e.preventDefault).not.toHaveBeenCalled();
});
});
......@@ -219,4 +242,161 @@ describe('WikiForm', () => {
},
);
});
describe('when feature flag wikiContentEditor is enabled', () => {
beforeEach(() => {
createWrapper(true, { glFeatures: { wikiContentEditor: true } });
});
it.each`
format | buttonExists
${'markdown'} | ${true}
${'rdoc'} | ${false}
`(
'switch to new editor button exists: $buttonExists if format is $format',
async ({ format, buttonExists }) => {
setFormat(format);
await wrapper.vm.$nextTick();
expect(findUseNewEditorButton().exists()).toBe(buttonExists);
},
);
const assertOldEditorIsVisible = () => {
expect(wrapper.findComponent(ContentEditor).exists()).toBe(false);
expect(wrapper.findComponent(MarkdownField).exists()).toBe(true);
expect(wrapper.findComponent(GlAlert).exists()).toBe(false);
};
it('shows old editor by default', assertOldEditorIsVisible);
describe('switch format to rdoc', () => {
beforeEach(async () => {
setFormat('rdoc');
await wrapper.vm.$nextTick();
});
it('continues to show the old editor', assertOldEditorIsVisible);
describe('switch format back to markdown', () => {
beforeEach(async () => {
setFormat('rdoc');
await wrapper.vm.$nextTick();
});
it(
'still shows the old editor and does not automatically switch to the content editor ',
assertOldEditorIsVisible,
);
});
});
describe('clicking "use new editor"', () => {
let mock;
beforeEach(async () => {
mock = new MockAdapter(axios);
mock.onPost(/preview-markdown/).reply(200, { body: '<p>hello <strong>world</strong></p>' });
findUseNewEditorButton().trigger('click');
await wrapper.vm.$nextTick();
});
afterEach(() => {
mock.restore();
});
it('shows a loading indicator for the rich text editor', () => {
expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
});
it('shows a warning alert that the rich text editor is in beta', () => {
expect(wrapper.findComponent(GlAlert).text()).toContain(
"You are editing this page with Content Editor. This editor is in beta and may not display the page's contents properly.",
);
});
it('shows the rich text editor when loading finishes', async () => {
// wait for content editor to load
await waitForPromises();
expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(false);
expect(wrapper.findComponent(ContentEditor).exists()).toBe(true);
});
it('disables the format dropdown', () => {
expect(findFormat().element.getAttribute('disabled')).toBeDefined();
});
describe('when wiki content is updated', () => {
beforeEach(async () => {
// wait for content editor to load
await waitForPromises();
wrapper.vm.editor.setContent('<p>hello __world__ from content editor</p>', true);
await waitForPromises();
return wrapper.vm.$nextTick();
});
it('sets before unload warning', () => {
const e = dispatchBeforeUnload();
expect(e.preventDefault).toHaveBeenCalledTimes(1);
});
it('unsets before unload warning on form submit', async () => {
triggerFormSubmit();
await wrapper.vm.$nextTick();
const e = dispatchBeforeUnload();
expect(e.preventDefault).not.toHaveBeenCalled();
});
});
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();
triggerFormSubmit();
await wrapper.vm.$nextTick();
expect(findContent().element.value).toBe('hello **world**');
});
describe('clicking "switch to old editor"', () => {
beforeEach(async () => {
// wait for content editor to load
await waitForPromises();
wrapper.vm.editor.setContent('<p>hello __world__ from content editor</p>', true);
wrapper.findComponent(GlAlert).findComponent(GlButton).trigger('click');
await wrapper.vm.$nextTick();
});
it('switches to old editor', () => {
expect(wrapper.findComponent(ContentEditor).exists()).toBe(false);
expect(wrapper.findComponent(MarkdownField).exists()).toBe(true);
});
it('does not show a warning alert about content editor', () => {
expect(wrapper.findComponent(GlAlert).exists()).toBe(false);
});
it('the old editor retains its old value and does not use the content from the content editor', () => {
expect(findContent().element.value).toBe('My page content');
});
});
});
});
});
......@@ -24,8 +24,8 @@ RSpec.shared_examples 'User creates wiki page' do
page.within(".wiki-form") do
fill_in(:wiki_content, with: "")
page.execute_script("window.onbeforeunload = null")
page.execute_script("document.querySelector('.wiki-form').submit()")
page.accept_alert # manually force form submit
end
expect(page).to have_content("The form contains the following error:").and have_content("Content can't be blank")
......
......@@ -93,8 +93,8 @@ RSpec.shared_examples 'User updates wiki page' do
it 'shows a validation error message if the form is force submitted', :js do
fill_in(:wiki_content, with: '')
page.execute_script("window.onbeforeunload = null")
page.execute_script("document.querySelector('.wiki-form').submit()")
page.accept_alert # manually force form submit
expect(page).to have_selector('.wiki-form')
expect(page).to have_content('Edit Page')
......
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