Commit fe1aa985 authored by Denys Mishunov's avatar Denys Mishunov Committed by Natalia Tepluhina

Branch off the Live Preview into a separate extension

parent 437f277f
import { debounce } from 'lodash'; import { EditorMarkdownPreviewExtension } from '~/editor/extensions/source_editor_markdown_livepreview_ext';
import { BLOB_PREVIEW_ERROR } from '~/blob_edit/constants';
import createFlash from '~/flash';
import { sanitize } from '~/lib/dompurify';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import syntaxHighlight from '~/syntax_highlight';
import {
EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS,
EXTENSION_MARKDOWN_PREVIEW_ACTION_ID,
EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH,
EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS,
EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY,
} from '../constants';
import { SourceEditorExtension } from './source_editor_extension_base';
const getPreview = (text, previewMarkdownPath) => {
return axios
.post(previewMarkdownPath, {
text,
})
.then(({ data }) => {
return data.body;
});
};
const setupDomElement = ({ injectToEl = null } = {}) => {
const previewEl = document.createElement('div');
previewEl.classList.add(EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS);
previewEl.style.display = 'none';
if (injectToEl) {
injectToEl.appendChild(previewEl);
}
return previewEl;
};
export class EditorMarkdownExtension extends SourceEditorExtension {
constructor({ instance, previewMarkdownPath, ...args } = {}) {
super({ instance, ...args });
Object.assign(instance, {
previewMarkdownPath,
preview: {
el: undefined,
action: undefined,
shown: false,
modelChangeListener: undefined,
},
});
this.setupPreviewAction.call(instance);
instance.getModel().onDidChangeLanguage(({ newLanguage, oldLanguage } = {}) => {
if (newLanguage === 'markdown' && oldLanguage !== newLanguage) {
instance.setupPreviewAction();
} else {
instance.cleanup();
}
});
instance.onDidChangeModel(() => {
const model = instance.getModel();
if (model) {
const { language } = model.getLanguageIdentifier();
instance.cleanup();
if (language === 'markdown') {
instance.setupPreviewAction();
}
}
});
}
static togglePreviewLayout() {
const { width, height } = this.getLayoutInfo();
const newWidth = this.preview.shown
? width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH
: width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH;
this.layout({ width: newWidth, height });
}
static togglePreviewPanel() {
const parentEl = this.getDomNode().parentElement;
const { el: previewEl } = this.preview;
parentEl.classList.toggle(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS);
if (previewEl.style.display === 'none') {
// Show the preview panel
this.fetchPreview();
} else {
// Hide the preview panel
previewEl.style.display = 'none';
}
}
cleanup() {
if (this.preview.modelChangeListener) {
this.preview.modelChangeListener.dispose();
}
this.preview.action.dispose();
if (this.preview.shown) {
EditorMarkdownExtension.togglePreviewPanel.call(this);
EditorMarkdownExtension.togglePreviewLayout.call(this);
}
this.preview.shown = false;
}
fetchPreview() {
const { el: previewEl } = this.preview;
getPreview(this.getValue(), this.previewMarkdownPath)
.then((data) => {
previewEl.innerHTML = sanitize(data);
syntaxHighlight(previewEl.querySelectorAll('.js-syntax-highlight'));
previewEl.style.display = 'block';
})
.catch(() => createFlash(BLOB_PREVIEW_ERROR));
}
setupPreviewAction() {
if (this.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)) return;
this.preview.action = this.addAction({
id: EXTENSION_MARKDOWN_PREVIEW_ACTION_ID,
label: __('Preview Markdown'),
keybindings: [
// eslint-disable-next-line no-bitwise,no-undef
monaco.KeyMod.chord(monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KEY_P),
],
contextMenuGroupId: 'navigation',
contextMenuOrder: 1.5,
// Method that will be executed when the action is triggered.
// @param ed The editor instance is passed in as a convenience
run(instance) {
instance.togglePreview();
},
});
}
togglePreview() {
if (!this.preview?.el) {
this.preview.el = setupDomElement({ injectToEl: this.getDomNode().parentElement });
}
EditorMarkdownExtension.togglePreviewLayout.call(this);
EditorMarkdownExtension.togglePreviewPanel.call(this);
if (!this.preview?.shown) {
this.preview.modelChangeListener = this.onDidChangeModelContent(
debounce(this.fetchPreview.bind(this), EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY),
);
} else {
this.preview.modelChangeListener.dispose();
}
this.preview.shown = !this.preview?.shown;
}
export class EditorMarkdownExtension extends EditorMarkdownPreviewExtension {
getSelectedText(selection = this.getSelection()) { getSelectedText(selection = this.getSelection()) {
const { startLineNumber, endLineNumber, startColumn, endColumn } = selection; const { startLineNumber, endLineNumber, startColumn, endColumn } = selection;
const valArray = this.getValue().split('\n'); const valArray = this.getValue().split('\n');
......
import { debounce } from 'lodash';
import { BLOB_PREVIEW_ERROR } from '~/blob_edit/constants';
import createFlash from '~/flash';
import { sanitize } from '~/lib/dompurify';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import syntaxHighlight from '~/syntax_highlight';
import {
EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS,
EXTENSION_MARKDOWN_PREVIEW_ACTION_ID,
EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH,
EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS,
EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY,
} from '../constants';
import { SourceEditorExtension } from './source_editor_extension_base';
const getPreview = (text, previewMarkdownPath) => {
return axios
.post(previewMarkdownPath, {
text,
})
.then(({ data }) => {
return data.body;
});
};
const setupDomElement = ({ injectToEl = null } = {}) => {
const previewEl = document.createElement('div');
previewEl.classList.add(EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS);
previewEl.style.display = 'none';
if (injectToEl) {
injectToEl.appendChild(previewEl);
}
return previewEl;
};
export class EditorMarkdownPreviewExtension extends SourceEditorExtension {
constructor({ instance, previewMarkdownPath, ...args } = {}) {
super({ instance, ...args });
Object.assign(instance, {
previewMarkdownPath,
preview: {
el: undefined,
action: undefined,
shown: false,
modelChangeListener: undefined,
},
});
this.setupPreviewAction.call(instance);
instance.getModel().onDidChangeLanguage(({ newLanguage, oldLanguage } = {}) => {
if (newLanguage === 'markdown' && oldLanguage !== newLanguage) {
instance.setupPreviewAction();
} else {
instance.cleanup();
}
});
instance.onDidChangeModel(() => {
const model = instance.getModel();
if (model) {
const { language } = model.getLanguageIdentifier();
instance.cleanup();
if (language === 'markdown') {
instance.setupPreviewAction();
}
}
});
}
static togglePreviewLayout() {
const { width, height } = this.getLayoutInfo();
const newWidth = this.preview.shown
? width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH
: width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH;
this.layout({ width: newWidth, height });
}
static togglePreviewPanel() {
const parentEl = this.getDomNode().parentElement;
const { el: previewEl } = this.preview;
parentEl.classList.toggle(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS);
if (previewEl.style.display === 'none') {
// Show the preview panel
this.fetchPreview();
} else {
// Hide the preview panel
previewEl.style.display = 'none';
}
}
cleanup() {
if (this.preview.modelChangeListener) {
this.preview.modelChangeListener.dispose();
}
this.preview.action.dispose();
if (this.preview.shown) {
EditorMarkdownPreviewExtension.togglePreviewPanel.call(this);
EditorMarkdownPreviewExtension.togglePreviewLayout.call(this);
}
this.preview.shown = false;
}
fetchPreview() {
const { el: previewEl } = this.preview;
getPreview(this.getValue(), this.previewMarkdownPath)
.then((data) => {
previewEl.innerHTML = sanitize(data);
syntaxHighlight(previewEl.querySelectorAll('.js-syntax-highlight'));
previewEl.style.display = 'block';
})
.catch(() => createFlash(BLOB_PREVIEW_ERROR));
}
setupPreviewAction() {
if (this.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)) return;
this.preview.action = this.addAction({
id: EXTENSION_MARKDOWN_PREVIEW_ACTION_ID,
label: __('Preview Markdown'),
keybindings: [
// eslint-disable-next-line no-bitwise,no-undef
monaco.KeyMod.chord(monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KEY_P),
],
contextMenuGroupId: 'navigation',
contextMenuOrder: 1.5,
// Method that will be executed when the action is triggered.
// @param ed The editor instance is passed in as a convenience
run(instance) {
instance.togglePreview();
},
});
}
togglePreview() {
if (!this.preview?.el) {
this.preview.el = setupDomElement({ injectToEl: this.getDomNode().parentElement });
}
EditorMarkdownPreviewExtension.togglePreviewLayout.call(this);
EditorMarkdownPreviewExtension.togglePreviewPanel.call(this);
if (!this.preview?.shown) {
this.preview.modelChangeListener = this.onDidChangeModelContent(
debounce(this.fetchPreview.bind(this), EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY),
);
} else {
this.preview.modelChangeListener.dispose();
}
this.preview.shown = !this.preview?.shown;
}
}
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import { Range, Position, editor as monacoEditor } from 'monaco-editor'; import { Range, Position } from 'monaco-editor';
import waitForPromises from 'helpers/wait_for_promises';
import {
EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS,
EXTENSION_MARKDOWN_PREVIEW_ACTION_ID,
EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH,
EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS,
EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY,
} from '~/editor/constants';
import { EditorMarkdownExtension } from '~/editor/extensions/source_editor_markdown_ext'; import { EditorMarkdownExtension } from '~/editor/extensions/source_editor_markdown_ext';
import SourceEditor from '~/editor/source_editor'; import SourceEditor from '~/editor/source_editor';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import syntaxHighlight from '~/syntax_highlight';
jest.mock('~/syntax_highlight');
jest.mock('~/flash');
describe('Markdown Extension for Source Editor', () => { describe('Markdown Extension for Source Editor', () => {
let editor; let editor;
let instance; let instance;
let editorEl; let editorEl;
let panelSpy;
let mockAxios; let mockAxios;
const previewMarkdownPath = '/gitlab/fooGroup/barProj/preview_markdown'; const previewMarkdownPath = '/gitlab/fooGroup/barProj/preview_markdown';
const firstLine = 'This is a'; const firstLine = 'This is a';
const secondLine = 'multiline'; const secondLine = 'multiline';
const thirdLine = 'string with some **markup**'; const thirdLine = 'string with some **markup**';
const text = `${firstLine}\n${secondLine}\n${thirdLine}`; const text = `${firstLine}\n${secondLine}\n${thirdLine}`;
const plaintextPath = 'foo.txt';
const markdownPath = 'foo.md'; const markdownPath = 'foo.md';
const responseData = '<div>FooBar</div>';
const setSelection = (startLineNumber = 1, startColumn = 1, endLineNumber = 1, endColumn = 1) => { const setSelection = (startLineNumber = 1, startColumn = 1, endLineNumber = 1, endColumn = 1) => {
const selection = new Range(startLineNumber, startColumn, endLineNumber, endColumn); const selection = new Range(startLineNumber, startColumn, endLineNumber, endColumn);
...@@ -42,11 +26,6 @@ describe('Markdown Extension for Source Editor', () => { ...@@ -42,11 +26,6 @@ describe('Markdown Extension for Source Editor', () => {
const selectionToString = () => instance.getSelection().toString(); const selectionToString = () => instance.getSelection().toString();
const positionToString = () => instance.getPosition().toString(); const positionToString = () => instance.getPosition().toString();
const togglePreview = async () => {
instance.togglePreview();
await waitForPromises();
};
beforeEach(() => { beforeEach(() => {
mockAxios = new MockAdapter(axios); mockAxios = new MockAdapter(axios);
setFixtures('<div id="editor" data-editor-loading></div>'); setFixtures('<div id="editor" data-editor-loading></div>');
...@@ -58,7 +37,6 @@ describe('Markdown Extension for Source Editor', () => { ...@@ -58,7 +37,6 @@ describe('Markdown Extension for Source Editor', () => {
blobContent: text, blobContent: text,
}); });
instance.use(new EditorMarkdownExtension({ instance, previewMarkdownPath })); instance.use(new EditorMarkdownExtension({ instance, previewMarkdownPath }));
panelSpy = jest.spyOn(EditorMarkdownExtension, 'togglePreviewPanel');
}); });
afterEach(() => { afterEach(() => {
...@@ -67,345 +45,6 @@ describe('Markdown Extension for Source Editor', () => { ...@@ -67,345 +45,6 @@ describe('Markdown Extension for Source Editor', () => {
mockAxios.restore(); mockAxios.restore();
}); });
it('sets up the instance', () => {
expect(instance.preview).toEqual({
el: undefined,
action: expect.any(Object),
shown: false,
modelChangeListener: undefined,
});
expect(instance.previewMarkdownPath).toBe(previewMarkdownPath);
});
describe('model language changes listener', () => {
let cleanupSpy;
let actionSpy;
beforeEach(async () => {
cleanupSpy = jest.spyOn(instance, 'cleanup');
actionSpy = jest.spyOn(instance, 'setupPreviewAction');
await togglePreview();
});
it('cleans up when switching away from markdown', () => {
expect(instance.cleanup).not.toHaveBeenCalled();
expect(instance.setupPreviewAction).not.toHaveBeenCalled();
instance.updateModelLanguage(plaintextPath);
expect(cleanupSpy).toHaveBeenCalled();
expect(actionSpy).not.toHaveBeenCalled();
});
it.each`
oldLanguage | newLanguage | setupCalledTimes
${'plaintext'} | ${'markdown'} | ${1}
${'markdown'} | ${'markdown'} | ${0}
${'markdown'} | ${'plaintext'} | ${0}
${'markdown'} | ${undefined} | ${0}
${undefined} | ${'markdown'} | ${1}
`(
'correctly handles re-enabling of the action when switching from $oldLanguage to $newLanguage',
({ oldLanguage, newLanguage, setupCalledTimes } = {}) => {
expect(actionSpy).not.toHaveBeenCalled();
instance.updateModelLanguage(oldLanguage);
instance.updateModelLanguage(newLanguage);
expect(actionSpy).toHaveBeenCalledTimes(setupCalledTimes);
},
);
});
describe('model change listener', () => {
let cleanupSpy;
let actionSpy;
beforeEach(() => {
cleanupSpy = jest.spyOn(instance, 'cleanup');
actionSpy = jest.spyOn(instance, 'setupPreviewAction');
instance.togglePreview();
});
afterEach(() => {
jest.clearAllMocks();
});
it('does not do anything if there is no model', () => {
instance.setModel(null);
expect(cleanupSpy).not.toHaveBeenCalled();
expect(actionSpy).not.toHaveBeenCalled();
});
it('cleans up the preview when the model changes', () => {
instance.setModel(monacoEditor.createModel('foo'));
expect(cleanupSpy).toHaveBeenCalled();
});
it.each`
language | setupCalledTimes
${'markdown'} | ${1}
${'plaintext'} | ${0}
${undefined} | ${0}
`(
'correctly handles actions when the new model is $language',
({ language, setupCalledTimes } = {}) => {
instance.setModel(monacoEditor.createModel('foo', language));
expect(actionSpy).toHaveBeenCalledTimes(setupCalledTimes);
},
);
});
describe('cleanup', () => {
beforeEach(async () => {
mockAxios.onPost().reply(200, { body: responseData });
await togglePreview();
});
it('disposes the modelChange listener and does not fetch preview on content changes', () => {
expect(instance.preview.modelChangeListener).toBeDefined();
jest.spyOn(instance, 'fetchPreview');
instance.cleanup();
instance.setValue('Foo Bar');
jest.advanceTimersByTime(EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY);
expect(instance.fetchPreview).not.toHaveBeenCalled();
});
it('removes the contextual menu action', () => {
expect(instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)).toBeDefined();
instance.cleanup();
expect(instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)).toBe(null);
});
it('toggles the `shown` flag', () => {
expect(instance.preview.shown).toBe(true);
instance.cleanup();
expect(instance.preview.shown).toBe(false);
});
it('toggles the panel only if the preview is visible', () => {
const { el: previewEl } = instance.preview;
const parentEl = previewEl.parentElement;
expect(previewEl).toBeVisible();
expect(parentEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(true);
instance.cleanup();
expect(previewEl).toBeHidden();
expect(parentEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(
false,
);
instance.cleanup();
expect(previewEl).toBeHidden();
expect(parentEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(
false,
);
});
it('toggles the layout only if the preview is visible', () => {
const { width } = instance.getLayoutInfo();
expect(instance.preview.shown).toBe(true);
instance.cleanup();
const { width: newWidth } = instance.getLayoutInfo();
expect(newWidth === width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH).toBe(true);
instance.cleanup();
expect(newWidth === width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH).toBe(true);
});
});
describe('fetchPreview', () => {
const fetchPreview = async () => {
instance.fetchPreview();
await waitForPromises();
};
let previewMarkdownSpy;
beforeEach(() => {
previewMarkdownSpy = jest.fn().mockImplementation(() => [200, { body: responseData }]);
mockAxios.onPost(previewMarkdownPath).replyOnce((req) => previewMarkdownSpy(req));
});
it('correctly fetches preview based on previewMarkdownPath', async () => {
await fetchPreview();
expect(previewMarkdownSpy).toHaveBeenCalledWith(
expect.objectContaining({ data: JSON.stringify({ text }) }),
);
});
it('puts the fetched content into the preview DOM element', async () => {
instance.preview.el = editorEl.parentElement;
await fetchPreview();
expect(instance.preview.el.innerHTML).toEqual(responseData);
});
it('applies syntax highlighting to the preview content', async () => {
instance.preview.el = editorEl.parentElement;
await fetchPreview();
expect(syntaxHighlight).toHaveBeenCalled();
});
it('catches the errors when fetching the preview', async () => {
mockAxios.onPost().reply(500);
await fetchPreview();
expect(createFlash).toHaveBeenCalled();
});
});
describe('setupPreviewAction', () => {
it('adds the contextual menu action', () => {
expect(instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)).toBeDefined();
});
it('does not set up action if one already exists', () => {
jest.spyOn(instance, 'addAction').mockImplementation();
instance.setupPreviewAction();
expect(instance.addAction).not.toHaveBeenCalled();
});
it('toggles preview when the action is triggered', () => {
jest.spyOn(instance, 'togglePreview').mockImplementation();
expect(instance.togglePreview).not.toHaveBeenCalled();
const action = instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID);
action.run();
expect(instance.togglePreview).toHaveBeenCalled();
});
});
describe('togglePreview', () => {
beforeEach(() => {
mockAxios.onPost().reply(200, { body: responseData });
});
it('toggles preview flag on instance', () => {
expect(instance.preview.shown).toBe(false);
instance.togglePreview();
expect(instance.preview.shown).toBe(true);
instance.togglePreview();
expect(instance.preview.shown).toBe(false);
});
describe('panel DOM element set up', () => {
it('sets up an element to contain the preview and stores it on instance', () => {
expect(instance.preview.el).toBeUndefined();
instance.togglePreview();
expect(instance.preview.el).toBeDefined();
expect(instance.preview.el.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS)).toBe(
true,
);
});
it('re-uses existing preview DOM element on repeated calls', () => {
instance.togglePreview();
const origPreviewEl = instance.preview.el;
instance.togglePreview();
expect(instance.preview.el).toBe(origPreviewEl);
});
it('hides the preview DOM element by default', () => {
panelSpy.mockImplementation();
instance.togglePreview();
expect(instance.preview.el.style.display).toBe('none');
});
});
describe('preview layout setup', () => {
it('sets correct preview layout', () => {
jest.spyOn(instance, 'layout');
const { width, height } = instance.getLayoutInfo();
instance.togglePreview();
expect(instance.layout).toHaveBeenCalledWith({
width: width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH,
height,
});
});
});
describe('preview panel', () => {
it('toggles preview CSS class on the editor', () => {
expect(editorEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(
false,
);
instance.togglePreview();
expect(editorEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(
true,
);
instance.togglePreview();
expect(editorEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(
false,
);
});
it('toggles visibility of the preview DOM element', async () => {
await togglePreview();
expect(instance.preview.el.style.display).toBe('block');
await togglePreview();
expect(instance.preview.el.style.display).toBe('none');
});
describe('hidden preview DOM element', () => {
it('listens to model changes and re-fetches preview', async () => {
expect(mockAxios.history.post).toHaveLength(0);
await togglePreview();
expect(mockAxios.history.post).toHaveLength(1);
instance.setValue('New Value');
await waitForPromises();
expect(mockAxios.history.post).toHaveLength(2);
});
it('stores disposable listener for model changes', async () => {
expect(instance.preview.modelChangeListener).toBeUndefined();
await togglePreview();
expect(instance.preview.modelChangeListener).toBeDefined();
});
});
describe('already visible preview', () => {
beforeEach(async () => {
await togglePreview();
mockAxios.resetHistory();
});
it('does not re-fetch the preview', () => {
instance.togglePreview();
expect(mockAxios.history.post).toHaveLength(0);
});
it('disposes the model change event listener', () => {
const disposeSpy = jest.fn();
instance.preview.modelChangeListener = {
dispose: disposeSpy,
};
instance.togglePreview();
expect(disposeSpy).toHaveBeenCalled();
});
});
});
});
describe('getSelectedText', () => { describe('getSelectedText', () => {
it('does not fail if there is no selection and returns the empty string', () => { it('does not fail if there is no selection and returns the empty string', () => {
jest.spyOn(instance, 'getSelection'); jest.spyOn(instance, 'getSelection');
......
import MockAdapter from 'axios-mock-adapter';
import { editor as monacoEditor } from 'monaco-editor';
import waitForPromises from 'helpers/wait_for_promises';
import {
EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS,
EXTENSION_MARKDOWN_PREVIEW_ACTION_ID,
EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH,
EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS,
EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY,
} from '~/editor/constants';
import { EditorMarkdownPreviewExtension } from '~/editor/extensions/source_editor_markdown_livepreview_ext';
import SourceEditor from '~/editor/source_editor';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import syntaxHighlight from '~/syntax_highlight';
jest.mock('~/syntax_highlight');
jest.mock('~/flash');
describe('Markdown Live Preview Extension for Source Editor', () => {
let editor;
let instance;
let editorEl;
let panelSpy;
let mockAxios;
const previewMarkdownPath = '/gitlab/fooGroup/barProj/preview_markdown';
const firstLine = 'This is a';
const secondLine = 'multiline';
const thirdLine = 'string with some **markup**';
const text = `${firstLine}\n${secondLine}\n${thirdLine}`;
const plaintextPath = 'foo.txt';
const markdownPath = 'foo.md';
const responseData = '<div>FooBar</div>';
const togglePreview = async () => {
instance.togglePreview();
await waitForPromises();
};
beforeEach(() => {
mockAxios = new MockAdapter(axios);
setFixtures('<div id="editor" data-editor-loading></div>');
editorEl = document.getElementById('editor');
editor = new SourceEditor();
instance = editor.createInstance({
el: editorEl,
blobPath: markdownPath,
blobContent: text,
});
instance.use(new EditorMarkdownPreviewExtension({ instance, previewMarkdownPath }));
panelSpy = jest.spyOn(EditorMarkdownPreviewExtension, 'togglePreviewPanel');
});
afterEach(() => {
instance.dispose();
editorEl.remove();
mockAxios.restore();
});
it('sets up the instance', () => {
expect(instance.preview).toEqual({
el: undefined,
action: expect.any(Object),
shown: false,
modelChangeListener: undefined,
});
expect(instance.previewMarkdownPath).toBe(previewMarkdownPath);
});
describe('model language changes listener', () => {
let cleanupSpy;
let actionSpy;
beforeEach(async () => {
cleanupSpy = jest.spyOn(instance, 'cleanup');
actionSpy = jest.spyOn(instance, 'setupPreviewAction');
await togglePreview();
});
it('cleans up when switching away from markdown', () => {
expect(instance.cleanup).not.toHaveBeenCalled();
expect(instance.setupPreviewAction).not.toHaveBeenCalled();
instance.updateModelLanguage(plaintextPath);
expect(cleanupSpy).toHaveBeenCalled();
expect(actionSpy).not.toHaveBeenCalled();
});
it.each`
oldLanguage | newLanguage | setupCalledTimes
${'plaintext'} | ${'markdown'} | ${1}
${'markdown'} | ${'markdown'} | ${0}
${'markdown'} | ${'plaintext'} | ${0}
${'markdown'} | ${undefined} | ${0}
${undefined} | ${'markdown'} | ${1}
`(
'correctly handles re-enabling of the action when switching from $oldLanguage to $newLanguage',
({ oldLanguage, newLanguage, setupCalledTimes } = {}) => {
expect(actionSpy).not.toHaveBeenCalled();
instance.updateModelLanguage(oldLanguage);
instance.updateModelLanguage(newLanguage);
expect(actionSpy).toHaveBeenCalledTimes(setupCalledTimes);
},
);
});
describe('model change listener', () => {
let cleanupSpy;
let actionSpy;
beforeEach(() => {
cleanupSpy = jest.spyOn(instance, 'cleanup');
actionSpy = jest.spyOn(instance, 'setupPreviewAction');
instance.togglePreview();
});
afterEach(() => {
jest.clearAllMocks();
});
it('does not do anything if there is no model', () => {
instance.setModel(null);
expect(cleanupSpy).not.toHaveBeenCalled();
expect(actionSpy).not.toHaveBeenCalled();
});
it('cleans up the preview when the model changes', () => {
instance.setModel(monacoEditor.createModel('foo'));
expect(cleanupSpy).toHaveBeenCalled();
});
it.each`
language | setupCalledTimes
${'markdown'} | ${1}
${'plaintext'} | ${0}
${undefined} | ${0}
`(
'correctly handles actions when the new model is $language',
({ language, setupCalledTimes } = {}) => {
instance.setModel(monacoEditor.createModel('foo', language));
expect(actionSpy).toHaveBeenCalledTimes(setupCalledTimes);
},
);
});
describe('cleanup', () => {
beforeEach(async () => {
mockAxios.onPost().reply(200, { body: responseData });
await togglePreview();
});
it('disposes the modelChange listener and does not fetch preview on content changes', () => {
expect(instance.preview.modelChangeListener).toBeDefined();
jest.spyOn(instance, 'fetchPreview');
instance.cleanup();
instance.setValue('Foo Bar');
jest.advanceTimersByTime(EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY);
expect(instance.fetchPreview).not.toHaveBeenCalled();
});
it('removes the contextual menu action', () => {
expect(instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)).toBeDefined();
instance.cleanup();
expect(instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)).toBe(null);
});
it('toggles the `shown` flag', () => {
expect(instance.preview.shown).toBe(true);
instance.cleanup();
expect(instance.preview.shown).toBe(false);
});
it('toggles the panel only if the preview is visible', () => {
const { el: previewEl } = instance.preview;
const parentEl = previewEl.parentElement;
expect(previewEl).toBeVisible();
expect(parentEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(true);
instance.cleanup();
expect(previewEl).toBeHidden();
expect(parentEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(
false,
);
instance.cleanup();
expect(previewEl).toBeHidden();
expect(parentEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(
false,
);
});
it('toggles the layout only if the preview is visible', () => {
const { width } = instance.getLayoutInfo();
expect(instance.preview.shown).toBe(true);
instance.cleanup();
const { width: newWidth } = instance.getLayoutInfo();
expect(newWidth === width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH).toBe(true);
instance.cleanup();
expect(newWidth === width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH).toBe(true);
});
});
describe('fetchPreview', () => {
const fetchPreview = async () => {
instance.fetchPreview();
await waitForPromises();
};
let previewMarkdownSpy;
beforeEach(() => {
previewMarkdownSpy = jest.fn().mockImplementation(() => [200, { body: responseData }]);
mockAxios.onPost(previewMarkdownPath).replyOnce((req) => previewMarkdownSpy(req));
});
it('correctly fetches preview based on previewMarkdownPath', async () => {
await fetchPreview();
expect(previewMarkdownSpy).toHaveBeenCalledWith(
expect.objectContaining({ data: JSON.stringify({ text }) }),
);
});
it('puts the fetched content into the preview DOM element', async () => {
instance.preview.el = editorEl.parentElement;
await fetchPreview();
expect(instance.preview.el.innerHTML).toEqual(responseData);
});
it('applies syntax highlighting to the preview content', async () => {
instance.preview.el = editorEl.parentElement;
await fetchPreview();
expect(syntaxHighlight).toHaveBeenCalled();
});
it('catches the errors when fetching the preview', async () => {
mockAxios.onPost().reply(500);
await fetchPreview();
expect(createFlash).toHaveBeenCalled();
});
});
describe('setupPreviewAction', () => {
it('adds the contextual menu action', () => {
expect(instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)).toBeDefined();
});
it('does not set up action if one already exists', () => {
jest.spyOn(instance, 'addAction').mockImplementation();
instance.setupPreviewAction();
expect(instance.addAction).not.toHaveBeenCalled();
});
it('toggles preview when the action is triggered', () => {
jest.spyOn(instance, 'togglePreview').mockImplementation();
expect(instance.togglePreview).not.toHaveBeenCalled();
const action = instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID);
action.run();
expect(instance.togglePreview).toHaveBeenCalled();
});
});
describe('togglePreview', () => {
beforeEach(() => {
mockAxios.onPost().reply(200, { body: responseData });
});
it('toggles preview flag on instance', () => {
expect(instance.preview.shown).toBe(false);
instance.togglePreview();
expect(instance.preview.shown).toBe(true);
instance.togglePreview();
expect(instance.preview.shown).toBe(false);
});
describe('panel DOM element set up', () => {
it('sets up an element to contain the preview and stores it on instance', () => {
expect(instance.preview.el).toBeUndefined();
instance.togglePreview();
expect(instance.preview.el).toBeDefined();
expect(instance.preview.el.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS)).toBe(
true,
);
});
it('re-uses existing preview DOM element on repeated calls', () => {
instance.togglePreview();
const origPreviewEl = instance.preview.el;
instance.togglePreview();
expect(instance.preview.el).toBe(origPreviewEl);
});
it('hides the preview DOM element by default', () => {
panelSpy.mockImplementation();
instance.togglePreview();
expect(instance.preview.el.style.display).toBe('none');
});
});
describe('preview layout setup', () => {
it('sets correct preview layout', () => {
jest.spyOn(instance, 'layout');
const { width, height } = instance.getLayoutInfo();
instance.togglePreview();
expect(instance.layout).toHaveBeenCalledWith({
width: width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH,
height,
});
});
});
describe('preview panel', () => {
it('toggles preview CSS class on the editor', () => {
expect(editorEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(
false,
);
instance.togglePreview();
expect(editorEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(
true,
);
instance.togglePreview();
expect(editorEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(
false,
);
});
it('toggles visibility of the preview DOM element', async () => {
await togglePreview();
expect(instance.preview.el.style.display).toBe('block');
await togglePreview();
expect(instance.preview.el.style.display).toBe('none');
});
describe('hidden preview DOM element', () => {
it('listens to model changes and re-fetches preview', async () => {
expect(mockAxios.history.post).toHaveLength(0);
await togglePreview();
expect(mockAxios.history.post).toHaveLength(1);
instance.setValue('New Value');
await waitForPromises();
expect(mockAxios.history.post).toHaveLength(2);
});
it('stores disposable listener for model changes', async () => {
expect(instance.preview.modelChangeListener).toBeUndefined();
await togglePreview();
expect(instance.preview.modelChangeListener).toBeDefined();
});
});
describe('already visible preview', () => {
beforeEach(async () => {
await togglePreview();
mockAxios.resetHistory();
});
it('does not re-fetch the preview', () => {
instance.togglePreview();
expect(mockAxios.history.post).toHaveLength(0);
});
it('disposes the model change event listener', () => {
const disposeSpy = jest.fn();
instance.preview.modelChangeListener = {
dispose: disposeSpy,
};
instance.togglePreview();
expect(disposeSpy).toHaveBeenCalled();
});
});
});
});
});
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