Commit 01d4d6fc authored by Enrique Alcántara's avatar Enrique Alcántara

Merge branch '216834-frontmatter-wysiwyg-removal' into 'master'

Resolve "Remove YAML front matter from editable content in the Static Site Editor"

See merge request gitlab-org/gitlab!33441
parents e65dfa05 26097553
...@@ -3,6 +3,8 @@ import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_ ...@@ -3,6 +3,8 @@ import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_
import PublishToolbar from './publish_toolbar.vue'; import PublishToolbar from './publish_toolbar.vue';
import EditHeader from './edit_header.vue'; import EditHeader from './edit_header.vue';
import UnsavedChangesConfirmDialog from './unsaved_changes_confirm_dialog.vue'; import UnsavedChangesConfirmDialog from './unsaved_changes_confirm_dialog.vue';
import parseSourceFile from '~/static_site_editor/services/parse_source_file';
import { EDITOR_TYPES } from '~/vue_shared/components/rich_content_editor/constants';
export default { export default {
components: { components: {
...@@ -32,18 +34,43 @@ export default { ...@@ -32,18 +34,43 @@ export default {
}, },
data() { data() {
return { return {
editableContent: this.content,
saveable: false, saveable: false,
parsedSource: parseSourceFile(this.content),
editorMode: EDITOR_TYPES.wysiwyg,
}; };
}, },
computed: { computed: {
editableContent() {
return this.parsedSource.editable;
},
editableKey() {
return this.isWysiwygMode ? 'body' : 'raw';
},
isWysiwygMode() {
return this.editorMode === EDITOR_TYPES.wysiwyg;
},
modified() { modified() {
return this.content !== this.editableContent; return this.isWysiwygMode
? this.parsedSource.isModifiedBody()
: this.parsedSource.isModifiedRaw();
}, },
}, },
methods: { methods: {
syncSource() {
if (this.isWysiwygMode) {
this.parsedSource.syncBody();
return;
}
this.parsedSource.syncRaw();
},
onModeChange(mode) {
this.editorMode = mode;
this.syncSource();
},
onSubmit() { onSubmit() {
this.$emit('submit', { content: this.editableContent }); this.syncSource();
this.$emit('submit', { content: this.editableContent.raw });
}, },
}, },
}; };
...@@ -51,7 +78,12 @@ export default { ...@@ -51,7 +78,12 @@ export default {
<template> <template>
<div class="d-flex flex-grow-1 flex-column h-100"> <div class="d-flex flex-grow-1 flex-column h-100">
<edit-header class="py-2" :title="title" /> <edit-header class="py-2" :title="title" />
<rich-content-editor v-model="editableContent" class="mb-9 h-100" /> <rich-content-editor
v-model="editableContent[editableKey]"
:initial-edit-type="editorMode"
class="mb-9 h-100"
@modeChange="onModeChange"
/>
<unsaved-changes-confirm-dialog :modified="modified" /> <unsaved-changes-confirm-dialog :modified="modified" />
<publish-toolbar <publish-toolbar
class="gl-fixed gl-left-0 gl-bottom-0 gl-w-full" class="gl-fixed gl-left-0 gl-bottom-0 gl-w-full"
......
const parseSourceFile = raw => {
const frontMatterRegex = /(^---$[\s\S]*?^---$)/m;
const preGroupedRegex = /([\s\S]*?)(^---$[\s\S]*?^---$)(\s*)([\s\S]*)/m; // preFrontMatter, frontMatter, spacing, and content
let initial;
let editable;
const hasFrontMatter = source => frontMatterRegex.test(source);
const buildPayload = (source, header, spacing, body) => {
return { raw: source, header, spacing, body };
};
const parse = source => {
if (hasFrontMatter(source)) {
const match = source.match(preGroupedRegex);
const [, preFrontMatter, frontMatter, spacing, content] = match;
const header = preFrontMatter + frontMatter;
return buildPayload(source, header, spacing, content);
}
return buildPayload(source, '', '', source);
};
const computedRaw = () => `${editable.header}${editable.spacing}${editable.body}`;
const syncBody = () => {
/*
We re-parse as markdown editing could have added non-body changes (preFrontMatter, frontMatter, or spacing).
Re-parsing additionally gets us the desired body that was extracted from the mutated editable.raw
Additionally we intentionally mutate the existing editable's key values as opposed to reassigning the object itself so consumers of the potentially reactive property stay in sync.
*/
Object.assign(editable, parse(editable.raw));
};
const syncRaw = () => {
editable.raw = computedRaw();
};
const isModifiedRaw = () => initial.raw !== editable.raw;
const isModifiedBody = () => initial.raw !== computedRaw();
initial = parse(raw);
editable = parse(raw);
return {
editable,
isModifiedRaw,
isModifiedBody,
syncRaw,
syncBody,
};
};
export default parseSourceFile;
...@@ -34,6 +34,7 @@ export const EDITOR_OPTIONS = { ...@@ -34,6 +34,7 @@ export const EDITOR_OPTIONS = {
}; };
export const EDITOR_TYPES = { export const EDITOR_TYPES = {
markdown: 'markdown',
wysiwyg: 'wysiwyg', wysiwyg: 'wysiwyg',
}; };
......
...@@ -29,13 +29,13 @@ export const generateToolbarItem = config => { ...@@ -29,13 +29,13 @@ export const generateToolbarItem = config => {
}; };
}; };
export const addCustomEventListener = (editorInstance, event, handler) => { export const addCustomEventListener = (editorApi, event, handler) => {
editorInstance.eventManager.addEventType(event); editorApi.eventManager.addEventType(event);
editorInstance.eventManager.listen(event, handler); editorApi.eventManager.listen(event, handler);
}; };
export const removeCustomEventListener = (editorInstance, event, handler) => export const removeCustomEventListener = (editorApi, event, handler) =>
editorInstance.eventManager.removeEventHandler(event, handler); editorApi.eventManager.removeEventHandler(event, handler);
export const addImage = ({ editor }, image) => editor.exec('AddImage', image); export const addImage = ({ editor }, image) => editor.exec('AddImage', image);
......
...@@ -52,6 +52,12 @@ export default { ...@@ -52,6 +52,12 @@ export default {
default: EDITOR_PREVIEW_STYLE, default: EDITOR_PREVIEW_STYLE,
}, },
}, },
data() {
return {
editorApi: null,
previousMode: null,
};
},
computed: { computed: {
editorOptions() { editorOptions() {
return { ...EDITOR_OPTIONS, ...this.options }; return { ...EDITOR_OPTIONS, ...this.options };
...@@ -60,23 +66,46 @@ export default { ...@@ -60,23 +66,46 @@ export default {
return this.$refs.editor; return this.$refs.editor;
}, },
}, },
watch: {
value(newVal) {
const isSameMode = this.previousMode === this.editorApi.currentMode;
if (!isSameMode) {
/*
The ToastUI Editor consumes its content via the `initial-value` prop and then internally
manages changes. If we desire the `v-model` to work as expected, we need to manually call
`setMarkdown`. However, if we do this in each v-model change we'll continually prevent
the editor from internally managing changes. Thus we use the `previousMode` flag as
confirmation to actually update its internals. This is initially designed so that front
matter is excluded from editing in wysiwyg mode, but included in markdown mode.
*/
this.editorInstance.invoke('setMarkdown', newVal);
this.previousMode = this.editorApi.currentMode;
}
},
},
beforeDestroy() { beforeDestroy() {
removeCustomEventListener( removeCustomEventListener(
this.editorInstance, this.editorApi,
CUSTOM_EVENTS.openAddImageModal, CUSTOM_EVENTS.openAddImageModal,
this.onOpenAddImageModal, this.onOpenAddImageModal,
); );
this.editorApi.eventManager.removeEventHandler('changeMode', this.onChangeMode);
}, },
methods: { methods: {
onContentChanged() { onContentChanged() {
this.$emit('input', getMarkdown(this.editorInstance)); this.$emit('input', getMarkdown(this.editorInstance));
}, },
onLoad(editorInstance) { onLoad(editorApi) {
this.editorApi = editorApi;
addCustomEventListener( addCustomEventListener(
editorInstance, this.editorApi,
CUSTOM_EVENTS.openAddImageModal, CUSTOM_EVENTS.openAddImageModal,
this.onOpenAddImageModal, this.onOpenAddImageModal,
); );
this.editorApi.eventManager.listen('changeMode', this.onChangeMode);
}, },
onOpenAddImageModal() { onOpenAddImageModal() {
this.$refs.addImageModal.show(); this.$refs.addImageModal.show();
...@@ -84,6 +113,9 @@ export default { ...@@ -84,6 +113,9 @@ export default {
onAddImage(image) { onAddImage(image) {
addImage(this.editorInstance, image); addImage(this.editorInstance, image);
}, },
onChangeMode(newMode) {
this.$emit('modeChange', newMode);
},
}, },
}; };
</script> </script>
......
---
title: Update Static Site Editor WYSIWYG mode to hide front matter
merge_request: 33441
author:
type: added
...@@ -7,12 +7,17 @@ import PublishToolbar from '~/static_site_editor/components/publish_toolbar.vue' ...@@ -7,12 +7,17 @@ import PublishToolbar from '~/static_site_editor/components/publish_toolbar.vue'
import EditHeader from '~/static_site_editor/components/edit_header.vue'; import EditHeader from '~/static_site_editor/components/edit_header.vue';
import UnsavedChangesConfirmDialog from '~/static_site_editor/components/unsaved_changes_confirm_dialog.vue'; import UnsavedChangesConfirmDialog from '~/static_site_editor/components/unsaved_changes_confirm_dialog.vue';
import { sourceContentTitle as title, sourceContent as content, returnUrl } from '../mock_data'; import {
sourceContentTitle as title,
sourceContent as content,
sourceContentBody as body,
returnUrl,
} from '../mock_data';
describe('~/static_site_editor/components/edit_area.vue', () => { describe('~/static_site_editor/components/edit_area.vue', () => {
let wrapper; let wrapper;
const savingChanges = true; const savingChanges = true;
const newContent = `new ${content}`; const newBody = `new ${body}`;
const buildWrapper = (propsData = {}) => { const buildWrapper = (propsData = {}) => {
wrapper = shallowMount(EditArea, { wrapper = shallowMount(EditArea, {
...@@ -46,7 +51,7 @@ describe('~/static_site_editor/components/edit_area.vue', () => { ...@@ -46,7 +51,7 @@ describe('~/static_site_editor/components/edit_area.vue', () => {
it('renders rich content editor', () => { it('renders rich content editor', () => {
expect(findRichContentEditor().exists()).toBe(true); expect(findRichContentEditor().exists()).toBe(true);
expect(findRichContentEditor().props('value')).toBe(content); expect(findRichContentEditor().props('value')).toBe(body);
}); });
it('renders publish toolbar', () => { it('renders publish toolbar', () => {
...@@ -65,7 +70,7 @@ describe('~/static_site_editor/components/edit_area.vue', () => { ...@@ -65,7 +70,7 @@ describe('~/static_site_editor/components/edit_area.vue', () => {
describe('when content changes', () => { describe('when content changes', () => {
beforeEach(() => { beforeEach(() => {
findRichContentEditor().vm.$emit('input', newContent); findRichContentEditor().vm.$emit('input', newBody);
return wrapper.vm.$nextTick(); return wrapper.vm.$nextTick();
}); });
...@@ -79,7 +84,7 @@ describe('~/static_site_editor/components/edit_area.vue', () => { ...@@ -79,7 +84,7 @@ describe('~/static_site_editor/components/edit_area.vue', () => {
}); });
it('sets publish toolbar as not saveable when content changes are rollback', () => { it('sets publish toolbar as not saveable when content changes are rollback', () => {
findRichContentEditor().vm.$emit('input', content); findRichContentEditor().vm.$emit('input', body);
return wrapper.vm.$nextTick().then(() => { return wrapper.vm.$nextTick().then(() => {
expect(findPublishToolbar().props('saveable')).toBe(false); expect(findPublishToolbar().props('saveable')).toBe(false);
......
export const sourceContent = ` export const sourceContentHeader = `---
---
layout: handbook-page-toc layout: handbook-page-toc
title: Handbook title: Handbook
twitter_image: '/images/tweets/handbook-gitlab.png' twitter_image: '/images/tweets/handbook-gitlab.png'
--- ---`;
export const sourceContentSpacing = `
## On this page `;
export const sourceContentBody = `## On this page
{:.no_toc .hidden-md .hidden-lg} {:.no_toc .hidden-md .hidden-lg}
- TOC - TOC
{:toc .hidden-md .hidden-lg} {:toc .hidden-md .hidden-lg}
`; `;
export const sourceContent = `${sourceContentHeader}${sourceContentSpacing}${sourceContentBody}`;
export const sourceContentTitle = 'Handbook'; export const sourceContentTitle = 'Handbook';
export const username = 'gitlabuser'; export const username = 'gitlabuser';
......
import {
sourceContent as content,
sourceContentHeader as header,
sourceContentSpacing as spacing,
sourceContentBody as body,
} from '../mock_data';
import parseSourceFile from '~/static_site_editor/services/parse_source_file';
describe('parseSourceFile', () => {
const contentSimple = content;
const contentComplex = [content, content, content].join('');
describe('the editable shape and its expected values', () => {
it.each`
sourceContent | sourceHeader | sourceSpacing | sourceBody | desc
${contentSimple} | ${header} | ${spacing} | ${body} | ${'extracts header'}
${contentComplex} | ${header} | ${spacing} | ${[body, content, content].join('')} | ${'extracts body'}
`('$desc', ({ sourceContent, sourceHeader, sourceSpacing, sourceBody }) => {
const { editable } = parseSourceFile(sourceContent);
expect(editable).toMatchObject({
raw: sourceContent,
header: sourceHeader,
spacing: sourceSpacing,
body: sourceBody,
});
});
it('returns the same front matter regardless of front matter duplication', () => {
const parsedSourceSimple = parseSourceFile(contentSimple);
const parsedSourceComplex = parseSourceFile(contentComplex);
expect(parsedSourceSimple.editable.header).toBe(parsedSourceComplex.editable.header);
});
});
describe('editable body to raw content default and changes', () => {
it.each`
sourceContent | desc
${contentSimple} | ${'returns false by default for both raw and body'}
${contentComplex} | ${'returns false by default for both raw and body'}
`('$desc', ({ sourceContent }) => {
const parsedSource = parseSourceFile(sourceContent);
expect(parsedSource.isModifiedRaw()).toBe(false);
expect(parsedSource.isModifiedBody()).toBe(false);
});
it.each`
sourceContent | editableKey | syncKey | isModifiedKey | desc
${contentSimple} | ${'body'} | ${'syncRaw'} | ${'isModifiedRaw'} | ${'returns true after modification and sync'}
${contentSimple} | ${'raw'} | ${'syncBody'} | ${'isModifiedBody'} | ${'returns true after modification and sync'}
${contentComplex} | ${'body'} | ${'syncRaw'} | ${'isModifiedRaw'} | ${'returns true after modification and sync'}
${contentComplex} | ${'raw'} | ${'syncBody'} | ${'isModifiedBody'} | ${'returns true after modification and sync'}
`('$desc', ({ sourceContent, editableKey, syncKey, isModifiedKey }) => {
const parsedSource = parseSourceFile(sourceContent);
parsedSource.editable[editableKey] += 'Added content';
parsedSource[syncKey]();
expect(parsedSource[isModifiedKey]()).toBe(true);
});
});
});
...@@ -75,11 +75,11 @@ describe('Rich Content Editor', () => { ...@@ -75,11 +75,11 @@ describe('Rich Content Editor', () => {
describe('when editor is loaded', () => { describe('when editor is loaded', () => {
it('adds the CUSTOM_EVENTS.openAddImageModal custom event listener', () => { it('adds the CUSTOM_EVENTS.openAddImageModal custom event listener', () => {
const mockInstance = { eventManager: { addEventType: jest.fn(), listen: jest.fn() } }; const mockEditorApi = { eventManager: { addEventType: jest.fn(), listen: jest.fn() } };
findEditor().vm.$emit('load', mockInstance); findEditor().vm.$emit('load', mockEditorApi);
expect(addCustomEventListener).toHaveBeenCalledWith( expect(addCustomEventListener).toHaveBeenCalledWith(
mockInstance, mockEditorApi,
CUSTOM_EVENTS.openAddImageModal, CUSTOM_EVENTS.openAddImageModal,
wrapper.vm.onOpenAddImageModal, wrapper.vm.onOpenAddImageModal,
); );
...@@ -88,13 +88,13 @@ describe('Rich Content Editor', () => { ...@@ -88,13 +88,13 @@ describe('Rich Content Editor', () => {
describe('when editor is destroyed', () => { describe('when editor is destroyed', () => {
it('removes the CUSTOM_EVENTS.openAddImageModal custom event listener', () => { it('removes the CUSTOM_EVENTS.openAddImageModal custom event listener', () => {
const mockInstance = { eventManager: { removeEventHandler: jest.fn() } }; const mockEditorApi = { eventManager: { removeEventHandler: jest.fn() } };
wrapper.vm.$refs.editor = mockInstance; wrapper.vm.editorApi = mockEditorApi;
wrapper.vm.$destroy(); wrapper.vm.$destroy();
expect(removeCustomEventListener).toHaveBeenCalledWith( expect(removeCustomEventListener).toHaveBeenCalledWith(
mockInstance, mockEditorApi,
CUSTOM_EVENTS.openAddImageModal, CUSTOM_EVENTS.openAddImageModal,
wrapper.vm.onOpenAddImageModal, wrapper.vm.onOpenAddImageModal,
); );
......
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