Commit 2dddbe79 authored by Himanshu Kapoor's avatar Himanshu Kapoor Committed by Alex Kalderimis

Add Content Editor support for wikis

Content Editor is a WYSIWYG editor that allows you to edit
markdown content, without having to understand the technicalities
of markdown syntax.

Changelog: added
parent 60a07d1a
...@@ -9,6 +9,13 @@ export class ContentEditor { ...@@ -9,6 +9,13 @@ export class ContentEditor {
return this._tiptapEditor; return this._tiptapEditor;
} }
get empty() {
const doc = this.tiptapEditor?.state.doc;
// Makes sure the document has more than one empty paragraph
return doc.childCount === 0 || (doc.childCount === 1 && doc.child(0).childCount === 0);
}
async setSerializedContent(serializedContent) { async setSerializedContent(serializedContent) {
const { _tiptapEditor: editor, _serializer: serializer } = this; const { _tiptapEditor: editor, _serializer: serializer } = this;
......
...@@ -14,8 +14,17 @@ import axios from '~/lib/utils/axios_utils'; ...@@ -14,8 +14,17 @@ import axios from '~/lib/utils/axios_utils';
import csrf from '~/lib/utils/csrf'; import csrf from '~/lib/utils/csrf';
import { setUrlFragment } from '~/lib/utils/url_utility'; import { setUrlFragment } from '~/lib/utils/url_utility';
import { s__, sprintf } from '~/locale'; import { s__, sprintf } from '~/locale';
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 {
WIKI_CONTENT_EDITOR_TRACKING_LABEL,
CONTENT_EDITOR_LOADED_ACTION,
SAVED_USING_CONTENT_EDITOR_ACTION,
} from '../constants';
const trackingMixin = Tracking.mixin({
label: WIKI_CONTENT_EDITOR_TRACKING_LABEL,
});
const MARKDOWN_LINK_TEXT = { const MARKDOWN_LINK_TEXT = {
markdown: '[Link Title](page-slug)', markdown: '[Link Title](page-slug)',
...@@ -104,7 +113,7 @@ export default { ...@@ -104,7 +113,7 @@ export default {
directives: { directives: {
GlModalDirective, GlModalDirective,
}, },
mixins: [glFeatureFlagMixin()], mixins: [trackingMixin],
inject: ['formatOptions', 'pageInfo'], inject: ['formatOptions', 'pageInfo'],
data() { data() {
return { return {
...@@ -120,6 +129,10 @@ export default { ...@@ -120,6 +129,10 @@ export default {
}; };
}, },
computed: { computed: {
noContent() {
if (this.isContentEditorActive) return this.contentEditor?.empty;
return !this.content;
},
csrfToken() { csrfToken() {
return csrf.token; return csrf.token;
}, },
...@@ -161,10 +174,10 @@ export default { ...@@ -161,10 +174,10 @@ export default {
return this.format === 'markdown'; return this.format === 'markdown';
}, },
showContentEditorButton() { showContentEditorButton() {
return this.isMarkdownFormat && !this.useContentEditor && this.glFeatures.wikiContentEditor; return this.isMarkdownFormat && !this.useContentEditor;
}, },
disableSubmitButton() { disableSubmitButton() {
return !this.content || !this.title || this.contentEditorRenderFailed; return this.noContent || !this.title || this.contentEditorRenderFailed;
}, },
isContentEditorActive() { isContentEditorActive() {
return this.isMarkdownFormat && this.useContentEditor; return this.isMarkdownFormat && this.useContentEditor;
...@@ -188,6 +201,8 @@ export default { ...@@ -188,6 +201,8 @@ export default {
handleFormSubmit() { handleFormSubmit() {
if (this.useContentEditor) { if (this.useContentEditor) {
this.content = this.contentEditor.getSerializedContent(); this.content = this.contentEditor.getSerializedContent();
this.trackFormSubmit();
} }
this.isDirty = false; this.isDirty = false;
...@@ -236,6 +251,8 @@ export default { ...@@ -236,6 +251,8 @@ export default {
try { try {
await this.contentEditor.setSerializedContent(this.content); await this.contentEditor.setSerializedContent(this.content);
this.isContentEditorLoading = false; this.isContentEditorLoading = false;
this.trackContentEditorLoaded();
} catch (e) { } catch (e) {
this.contentEditorRenderFailed = true; this.contentEditorRenderFailed = true;
} }
...@@ -258,6 +275,16 @@ export default { ...@@ -258,6 +275,16 @@ export default {
this.$refs.confirmSwitchToOldEditorModal.show(); this.$refs.confirmSwitchToOldEditorModal.show();
} }
}, },
async trackContentEditorLoaded() {
await this.track(CONTENT_EDITOR_LOADED_ACTION);
},
async trackFormSubmit() {
if (this.isContentEditorActive) {
await this.track(SAVED_USING_CONTENT_EDITOR_ACTION);
}
},
}, },
}; };
</script> </script>
......
export const WIKI_CONTENT_EDITOR_TRACKING_LABEL = 'wiki_content_editor';
export const CONTENT_EDITOR_LOADED_ACTION = 'content_editor_loaded';
export const SAVED_USING_CONTENT_EDITOR_ACTION = 'saved_using_content_editor';
...@@ -6,8 +6,4 @@ class Projects::WikisController < Projects::ApplicationController ...@@ -6,8 +6,4 @@ class Projects::WikisController < Projects::ApplicationController
alias_method :container, :project alias_method :container, :project
feature_category :wiki feature_category :wiki
before_action do
push_frontend_feature_flag(:wiki_content_editor, project, default_enabled: :yaml)
end
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
milestone: '13.12'
...@@ -2,15 +2,23 @@ import { GlLoadingIcon, GlModal } from '@gitlab/ui'; ...@@ -2,15 +2,23 @@ import { GlLoadingIcon, GlModal } from '@gitlab/ui';
import { mount } from '@vue/test-utils'; import { mount } 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 { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import ContentEditor from '~/content_editor/components/content_editor.vue'; import ContentEditor from '~/content_editor/components/content_editor.vue';
import WikiForm from '~/pages/shared/wikis/components/wiki_form.vue'; import WikiForm from '~/pages/shared/wikis/components/wiki_form.vue';
import {
WIKI_CONTENT_EDITOR_TRACKING_LABEL,
CONTENT_EDITOR_LOADED_ACTION,
SAVED_USING_CONTENT_EDITOR_ACTION,
} from '~/pages/shared/wikis/constants';
import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import MarkdownField from '~/vue_shared/components/markdown/field.vue';
describe('WikiForm', () => { describe('WikiForm', () => {
let wrapper; let wrapper;
let mock; let mock;
let trackingSpy;
const findForm = () => wrapper.find('form'); const findForm = () => wrapper.find('form');
const findTitle = () => wrapper.find('#wiki_title'); const findTitle = () => wrapper.find('#wiki_title');
...@@ -60,10 +68,7 @@ describe('WikiForm', () => { ...@@ -60,10 +68,7 @@ describe('WikiForm', () => {
path: '/project/path/-/wikis/home', path: '/project/path/-/wikis/home',
}; };
function createWrapper( function createWrapper(persisted = false, { pageInfo } = {}) {
persisted = false,
{ pageInfo, glFeatures } = { glFeatures: { wikiContentEditor: false } },
) {
wrapper = extendedWrapper( wrapper = extendedWrapper(
mount( mount(
WikiForm, WikiForm,
...@@ -79,7 +84,6 @@ describe('WikiForm', () => { ...@@ -79,7 +84,6 @@ describe('WikiForm', () => {
...(persisted ? pageInfoPersisted : pageInfoNew), ...(persisted ? pageInfoPersisted : pageInfoNew),
...pageInfo, ...pageInfo,
}, },
glFeatures,
}, },
}, },
{ attachToDocument: true }, { attachToDocument: true },
...@@ -88,6 +92,7 @@ describe('WikiForm', () => { ...@@ -88,6 +92,7 @@ describe('WikiForm', () => {
} }
beforeEach(() => { beforeEach(() => {
trackingSpy = mockTracking(undefined, null, jest.spyOn);
mock = new MockAdapter(axios); mock = new MockAdapter(axios);
}); });
...@@ -193,13 +198,21 @@ describe('WikiForm', () => { ...@@ -193,13 +198,21 @@ describe('WikiForm', () => {
expect(e.preventDefault).toHaveBeenCalledTimes(1); expect(e.preventDefault).toHaveBeenCalledTimes(1);
}); });
it('when form submitted, unsets before unload warning', async () => { describe('form submit', () => {
triggerFormSubmit(); beforeEach(async () => {
triggerFormSubmit();
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
});
const e = dispatchBeforeUnload(); it('when form submitted, unsets before unload warning', async () => {
expect(e.preventDefault).not.toHaveBeenCalled(); const e = dispatchBeforeUnload();
expect(e.preventDefault).not.toHaveBeenCalled();
});
it('does not trigger tracking event', async () => {
expect(trackingSpy).not.toHaveBeenCalled();
});
}); });
}); });
...@@ -251,9 +264,9 @@ describe('WikiForm', () => { ...@@ -251,9 +264,9 @@ describe('WikiForm', () => {
); );
}); });
describe('when feature flag wikiContentEditor is enabled', () => { describe('wiki content editor', () => {
beforeEach(() => { beforeEach(() => {
createWrapper(true, { glFeatures: { wikiContentEditor: true } }); createWrapper(true);
}); });
it.each` it.each`
...@@ -368,6 +381,15 @@ describe('WikiForm', () => { ...@@ -368,6 +381,15 @@ describe('WikiForm', () => {
expect(wrapper.findComponent(ContentEditor).exists()).toBe(true); expect(wrapper.findComponent(ContentEditor).exists()).toBe(true);
}); });
it('sends tracking event when editor loads', async () => {
// wait for content editor to load
await waitForPromises();
expect(trackingSpy).toHaveBeenCalledWith(undefined, CONTENT_EDITOR_LOADED_ACTION, {
label: WIKI_CONTENT_EDITOR_TRACKING_LABEL,
});
});
it('disables the format dropdown', () => { it('disables the format dropdown', () => {
expect(findFormat().element.getAttribute('disabled')).toBeDefined(); expect(findFormat().element.getAttribute('disabled')).toBeDefined();
}); });
...@@ -400,6 +422,16 @@ describe('WikiForm', () => { ...@@ -400,6 +422,16 @@ describe('WikiForm', () => {
}); });
}); });
it('triggers tracking event on form submit', async () => {
triggerFormSubmit();
await wrapper.vm.$nextTick();
expect(trackingSpy).toHaveBeenCalledWith(undefined, SAVED_USING_CONTENT_EDITOR_ACTION, {
label: WIKI_CONTENT_EDITOR_TRACKING_LABEL,
});
});
it('updates content from content editor on form submit', async () => { it('updates content from content editor on form submit', async () => {
// old value // old value
expect(findContent().element.value).toBe('My page content'); expect(findContent().element.value).toBe('My page content');
......
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