Commit d90198ee authored by David O'Regan's avatar David O'Regan

Merge branch '292943-se-integration' into 'master'

Source Editor refactoring integration

See merge request gitlab-org/gitlab!76489
parents ca85f477 021bb329
import $ from 'jquery'; import $ from 'jquery';
import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base';
import { FileTemplateExtension } from '~/editor/extensions/source_editor_file_template_ext'; import { FileTemplateExtension } from '~/editor/extensions/source_editor_file_template_ext';
import SourceEditor from '~/editor/source_editor'; import SourceEditor from '~/editor/source_editor';
import { getBlobLanguage } from '~/editor/utils'; import { getBlobLanguage } from '~/editor/utils';
...@@ -26,23 +27,29 @@ export default class EditBlob { ...@@ -26,23 +27,29 @@ export default class EditBlob {
this.editor.focus(); this.editor.focus();
} }
fetchMarkdownExtension() { async fetchMarkdownExtension() {
import('~/editor/extensions/source_editor_markdown_ext') try {
.then(({ EditorMarkdownExtension: MarkdownExtension } = {}) => { const [
this.editor.use( { EditorMarkdownExtension: MarkdownExtension },
new MarkdownExtension({ { EditorMarkdownPreviewExtension: MarkdownLivePreview },
instance: this.editor, ] = await Promise.all([
previewMarkdownPath: this.options.previewMarkdownPath, import('~/editor/extensions/source_editor_markdown_ext'),
}), import('~/editor/extensions/source_editor_markdown_livepreview_ext'),
); ]);
this.hasMarkdownExtension = true; this.editor.use([
addEditorMarkdownListeners(this.editor); { definition: MarkdownExtension },
}) {
.catch((e) => definition: MarkdownLivePreview,
createFlash({ setupOptions: { previewMarkdownPath: this.options.previewMarkdownPath },
message: `${BLOB_EDITOR_ERROR}: ${e}`, },
}), ]);
); } catch (e) {
createFlash({
message: `${BLOB_EDITOR_ERROR}: ${e}`,
});
}
this.hasMarkdownExtension = true;
addEditorMarkdownListeners(this.editor);
} }
configureMonacoEditor() { configureMonacoEditor() {
...@@ -60,7 +67,7 @@ export default class EditBlob { ...@@ -60,7 +67,7 @@ export default class EditBlob {
blobPath: fileNameEl.value, blobPath: fileNameEl.value,
blobContent: editorEl.innerText, blobContent: editorEl.innerText,
}); });
this.editor.use(new FileTemplateExtension({ instance: this.editor })); this.editor.use([{ definition: SourceEditorExtension }, { definition: FileTemplateExtension }]);
fileNameEl.addEventListener('change', () => { fileNameEl.addEventListener('change', () => {
this.editor.updateModelLanguage(fileNameEl.value); this.editor.updateModelLanguage(fileNameEl.value);
......
...@@ -42,6 +42,10 @@ export const EDITOR_EXTENSION_STORE_IS_MISSING_ERROR = s__( ...@@ -42,6 +42,10 @@ export const EDITOR_EXTENSION_STORE_IS_MISSING_ERROR = s__(
// EXTENSIONS' CONSTANTS // EXTENSIONS' CONSTANTS
// //
// Source Editor Base Extension
export const EXTENSION_BASE_LINE_LINK_ANCHOR_CLASS = 'link-anchor';
export const EXTENSION_BASE_LINE_NUMBERS_CLASS = 'line-numbers';
// For CI config schemas the filename must match // For CI config schemas the filename must match
// '*.gitlab-ci.yml' regardless of project configuration. // '*.gitlab-ci.yml' regardless of project configuration.
// https://gitlab.com/gitlab-org/gitlab/-/issues/293641 // https://gitlab.com/gitlab-org/gitlab/-/issues/293641
......
...@@ -6,6 +6,16 @@ ...@@ -6,6 +6,16 @@
// //
export class MyFancyExtension { export class MyFancyExtension {
/**
* A required getter returning the extension's name
* We have to provide it for every extension instead of relying on the built-in
* `name` prop because the prop does not survive the webpack's minification
* and the name mangling.
* @returns {string}
*/
static get extensionName() {
return 'MyFancyExtension';
}
/** /**
* THE LIFE-CYCLE CALLBACKS * THE LIFE-CYCLE CALLBACKS
*/ */
......
import ciSchemaPath from '~/editor/schema/ci.json'; import ciSchemaPath from '~/editor/schema/ci.json';
import { registerSchema } from '~/ide/utils'; import { registerSchema } from '~/ide/utils';
import { SourceEditorExtension } from './source_editor_extension_base';
export class CiSchemaExtension extends SourceEditorExtension { export class CiSchemaExtension {
/** static get extensionName() {
* Registers a syntax schema to the editor based on project return 'CiSchema';
* identifier and commit. }
* // eslint-disable-next-line class-methods-use-this
* The schema is added to the file that is currently edited provides() {
* in the editor. return {
* registerCiSchema: (instance) => {
* @param {Object} opts // In order for workers loaded from `data://` as the
* @param {String} opts.projectNamespace // ones loaded by monaco editor, we use absolute URLs
* @param {String} opts.projectPath // to fetch schema files, hence the `gon.gitlab_url`
* @param {String?} opts.ref - Current ref. Defaults to main // reference. This prevents error:
*/ // "Failed to execute 'fetch' on 'WorkerGlobalScope'"
registerCiSchema() { const absoluteSchemaUrl = gon.gitlab_url + ciSchemaPath;
// In order for workers loaded from `data://` as the const modelFileName = instance.getModel().uri.path.split('/').pop();
// ones loaded by monaco editor, we use absolute URLs
// to fetch schema files, hence the `gon.gitlab_url`
// reference. This prevents error:
// "Failed to execute 'fetch' on 'WorkerGlobalScope'"
const absoluteSchemaUrl = gon.gitlab_url + ciSchemaPath;
const modelFileName = this.getModel().uri.path.split('/').pop();
registerSchema({ registerSchema({
uri: absoluteSchemaUrl, uri: absoluteSchemaUrl,
fileMatch: [modelFileName], fileMatch: [modelFileName],
}); });
},
};
} }
} }
import { Range } from 'monaco-editor'; import { Range } from 'monaco-editor';
import { waitForCSSLoaded } from '~/helpers/startup_css_helper'; import {
import { ERROR_INSTANCE_REQUIRED_FOR_EXTENSION, EDITOR_TYPE_CODE } from '../constants'; EDITOR_TYPE_CODE,
EXTENSION_BASE_LINE_LINK_ANCHOR_CLASS,
EXTENSION_BASE_LINE_NUMBERS_CLASS,
} from '../constants';
const hashRegexp = new RegExp('#?L', 'g'); const hashRegexp = new RegExp('#?L', 'g');
const createAnchor = (href) => { const createAnchor = (href) => {
const fragment = new DocumentFragment(); const fragment = new DocumentFragment();
const el = document.createElement('a'); const el = document.createElement('a');
el.classList.add('link-anchor'); el.classList.add(EXTENSION_BASE_LINE_LINK_ANCHOR_CLASS);
el.href = href; el.href = href;
fragment.appendChild(el); fragment.appendChild(el);
el.addEventListener('contextmenu', (e) => { el.addEventListener('contextmenu', (e) => {
...@@ -17,38 +20,46 @@ const createAnchor = (href) => { ...@@ -17,38 +20,46 @@ const createAnchor = (href) => {
}; };
export class SourceEditorExtension { export class SourceEditorExtension {
constructor({ instance, ...options } = {}) { static get extensionName() {
if (instance) { return 'BaseExtension';
Object.assign(instance, options); }
SourceEditorExtension.highlightLines(instance);
if (instance.getEditorType && instance.getEditorType() === EDITOR_TYPE_CODE) { // eslint-disable-next-line class-methods-use-this
SourceEditorExtension.setupLineLinking(instance); onUse(instance) {
} SourceEditorExtension.highlightLines(instance);
SourceEditorExtension.deferRerender(instance); if (instance.getEditorType && instance.getEditorType() === EDITOR_TYPE_CODE) {
} else if (Object.entries(options).length) { SourceEditorExtension.setupLineLinking(instance);
throw new Error(ERROR_INSTANCE_REQUIRED_FOR_EXTENSION);
} }
} }
static deferRerender(instance) { static onMouseMoveHandler(e) {
waitForCSSLoaded(() => { const target = e.target.element;
instance.layout(); if (target.classList.contains(EXTENSION_BASE_LINE_NUMBERS_CLASS)) {
}); const lineNum = e.target.position.lineNumber;
const hrefAttr = `#L${lineNum}`;
let lineLink = target.querySelector('a');
if (!lineLink) {
lineLink = createAnchor(hrefAttr);
target.appendChild(lineLink);
}
}
} }
static removeHighlights(instance) { static setupLineLinking(instance) {
Object.assign(instance, { instance.onMouseMove(SourceEditorExtension.onMouseMoveHandler);
lineDecorations: instance.deltaDecorations(instance.lineDecorations || [], []), instance.onMouseDown((e) => {
const isCorrectAnchor = e.target.element.classList.contains(
EXTENSION_BASE_LINE_LINK_ANCHOR_CLASS,
);
if (!isCorrectAnchor) {
return;
}
if (instance.lineDecorations) {
instance.deltaDecorations(instance.lineDecorations, []);
}
}); });
} }
/**
* Returns a function that can only be invoked once between
* each browser screen repaint.
* @param {Object} instance - The Source Editor instance
* @param {Array} bounds - The [start, end] array with start
* and end coordinates for highlighting
*/
static highlightLines(instance, bounds = null) { static highlightLines(instance, bounds = null) {
const [start, end] = const [start, end] =
bounds && Array.isArray(bounds) bounds && Array.isArray(bounds)
...@@ -74,29 +85,29 @@ export class SourceEditorExtension { ...@@ -74,29 +85,29 @@ export class SourceEditorExtension {
} }
} }
static onMouseMoveHandler(e) { // eslint-disable-next-line class-methods-use-this
const target = e.target.element; provides() {
if (target.classList.contains('line-numbers')) { return {
const lineNum = e.target.position.lineNumber; /**
const hrefAttr = `#L${lineNum}`; * Removes existing line decorations and updates the reference on the instance
let el = target.querySelector('a'); * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance
if (!el) { */
el = createAnchor(hrefAttr); removeHighlights: (instance) => {
target.appendChild(el); Object.assign(instance, {
} lineDecorations: instance.deltaDecorations(instance.lineDecorations || [], []),
} });
} },
static setupLineLinking(instance) { /**
instance.onMouseMove(SourceEditorExtension.onMouseMoveHandler); * Returns a function that can only be invoked once between
instance.onMouseDown((e) => { * each browser screen repaint.
const isCorrectAnchor = e.target.element.classList.contains('link-anchor'); * @param {Array} bounds - The [start, end] array with start
if (!isCorrectAnchor) { * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance
return; * and end coordinates for highlighting
} */
if (instance.lineDecorations) { highlightLines(instance, bounds = null) {
instance.deltaDecorations(instance.lineDecorations, []); SourceEditorExtension.highlightLines(instance, bounds);
} },
}); };
} }
} }
import { Position } from 'monaco-editor'; import { Position } from 'monaco-editor';
import { SourceEditorExtension } from './source_editor_extension_base';
export class FileTemplateExtension extends SourceEditorExtension { export class FileTemplateExtension {
navigateFileStart() { static get extensionName() {
this.setPosition(new Position(1, 1)); return 'FileTemplate';
}
// eslint-disable-next-line class-methods-use-this
provides() {
return {
navigateFileStart: (instance) => {
instance.setPosition(new Position(1, 1));
},
};
} }
} }
import { EditorMarkdownPreviewExtension } from '~/editor/extensions/source_editor_markdown_livepreview_ext'; export class EditorMarkdownExtension {
static get extensionName() {
export class EditorMarkdownExtension extends EditorMarkdownPreviewExtension { return 'EditorMarkdown';
getSelectedText(selection = this.getSelection()) {
const { startLineNumber, endLineNumber, startColumn, endColumn } = selection;
const valArray = this.getValue().split('\n');
let text = '';
if (startLineNumber === endLineNumber) {
text = valArray[startLineNumber - 1].slice(startColumn - 1, endColumn - 1);
} else {
const startLineText = valArray[startLineNumber - 1].slice(startColumn - 1);
const endLineText = valArray[endLineNumber - 1].slice(0, endColumn - 1);
for (let i = startLineNumber, k = endLineNumber - 1; i < k; i += 1) {
text += `${valArray[i]}`;
if (i !== k - 1) text += `\n`;
}
text = text
? [startLineText, text, endLineText].join('\n')
: [startLineText, endLineText].join('\n');
}
return text;
} }
replaceSelectedText(text, select = undefined) { // eslint-disable-next-line class-methods-use-this
const forceMoveMarkers = !select; provides() {
this.executeEdits('', [{ range: this.getSelection(), text, forceMoveMarkers }]); return {
} getSelectedText: (instance, selection = instance.getSelection()) => {
const { startLineNumber, endLineNumber, startColumn, endColumn } = selection;
moveCursor(dx = 0, dy = 0) { const valArray = instance.getValue().split('\n');
const pos = this.getPosition(); let text = '';
pos.column += dx; if (startLineNumber === endLineNumber) {
pos.lineNumber += dy; text = valArray[startLineNumber - 1].slice(startColumn - 1, endColumn - 1);
this.setPosition(pos); } else {
} const startLineText = valArray[startLineNumber - 1].slice(startColumn - 1);
const endLineText = valArray[endLineNumber - 1].slice(0, endColumn - 1);
/** for (let i = startLineNumber, k = endLineNumber - 1; i < k; i += 1) {
* Adjust existing selection to select text within the original selection. text += `${valArray[i]}`;
* - If `selectedText` is not supplied, we fetch selected text with if (i !== k - 1) text += `\n`;
* }
* ALGORITHM: text = text
* ? [startLineText, text, endLineText].join('\n')
* MULTI-LINE SELECTION : [startLineText, endLineText].join('\n');
* 1. Find line that contains `toSelect` text. }
* 2. Using the index of this line and the position of `toSelect` text in it, return text;
* construct: },
* * newStartLineNumber replaceSelectedText: (instance, text, select) => {
* * newStartColumn const forceMoveMarkers = !select;
* instance.executeEdits('', [{ range: instance.getSelection(), text, forceMoveMarkers }]);
* SINGLE-LINE SELECTION },
* 1. Use `startLineNumber` from the current selection as `newStartLineNumber` moveCursor: (instance, dx = 0, dy = 0) => {
* 2. Find the position of `toSelect` text in it to get `newStartColumn` const pos = instance.getPosition();
* pos.column += dx;
* 3. `newEndLineNumber` — Since this method is supposed to be used with pos.lineNumber += dy;
* markdown decorators that are pretty short, the `newEndLineNumber` is instance.setPosition(pos);
* suggested to be assumed the same as the startLine. },
* 4. `newEndColumn` — pretty obvious /**
* 5. Adjust the start and end positions of the current selection * Adjust existing selection to select text within the original selection.
* 6. Re-set selection on the instance * - If `selectedText` is not supplied, we fetch selected text with
* *
* @param {string} toSelect - New text to select within current selection. * ALGORITHM:
* @param {string} selectedText - Currently selected text. It's just a *
* shortcut: If it's not supplied, we fetch selected text from the instance * MULTI-LINE SELECTION
*/ * 1. Find line that contains `toSelect` text.
selectWithinSelection(toSelect, selectedText) { * 2. Using the index of this line and the position of `toSelect` text in it,
const currentSelection = this.getSelection(); * construct:
if (currentSelection.isEmpty() || !toSelect) { * * newStartLineNumber
return; * * newStartColumn
} *
const text = selectedText || this.getSelectedText(currentSelection); * SINGLE-LINE SELECTION
let lineShift; * 1. Use `startLineNumber` from the current selection as `newStartLineNumber`
let newStartLineNumber; * 2. Find the position of `toSelect` text in it to get `newStartColumn`
let newStartColumn; *
* 3. `newEndLineNumber` — Since this method is supposed to be used with
* markdown decorators that are pretty short, the `newEndLineNumber` is
* suggested to be assumed the same as the startLine.
* 4. `newEndColumn` — pretty obvious
* 5. Adjust the start and end positions of the current selection
* 6. Re-set selection on the instance
*
* @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance. Is passed automatically.
* @param {string} toSelect - New text to select within current selection.
* @param {string} selectedText - Currently selected text. It's just a
* shortcut: If it's not supplied, we fetch selected text from the instance
*/
selectWithinSelection: (instance, toSelect, selectedText) => {
const currentSelection = instance.getSelection();
if (currentSelection.isEmpty() || !toSelect) {
return;
}
const text = selectedText || instance.getSelectedText(currentSelection);
let lineShift;
let newStartLineNumber;
let newStartColumn;
const textLines = text.split('\n'); const textLines = text.split('\n');
if (textLines.length > 1) { if (textLines.length > 1) {
// Multi-line selection // Multi-line selection
lineShift = textLines.findIndex((line) => line.indexOf(toSelect) !== -1); lineShift = textLines.findIndex((line) => line.indexOf(toSelect) !== -1);
newStartLineNumber = currentSelection.startLineNumber + lineShift; newStartLineNumber = currentSelection.startLineNumber + lineShift;
newStartColumn = textLines[lineShift].indexOf(toSelect) + 1; newStartColumn = textLines[lineShift].indexOf(toSelect) + 1;
} else { } else {
// Single-line selection // Single-line selection
newStartLineNumber = currentSelection.startLineNumber; newStartLineNumber = currentSelection.startLineNumber;
newStartColumn = currentSelection.startColumn + text.indexOf(toSelect); newStartColumn = currentSelection.startColumn + text.indexOf(toSelect);
} }
const newEndLineNumber = newStartLineNumber; const newEndLineNumber = newStartLineNumber;
const newEndColumn = newStartColumn + toSelect.length; const newEndColumn = newStartColumn + toSelect.length;
const newSelection = currentSelection const newSelection = currentSelection
.setStartPosition(newStartLineNumber, newStartColumn) .setStartPosition(newStartLineNumber, newStartColumn)
.setEndPosition(newEndLineNumber, newEndColumn); .setEndPosition(newEndLineNumber, newEndColumn);
this.setSelection(newSelection); instance.setSelection(newSelection);
},
};
} }
} }
...@@ -12,9 +12,8 @@ import { ...@@ -12,9 +12,8 @@ import {
EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS, EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS,
EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY, EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY,
} from '../constants'; } from '../constants';
import { SourceEditorExtension } from './source_editor_extension_base';
const getPreview = (text, previewMarkdownPath) => { const fetchPreview = (text, previewMarkdownPath) => {
return axios return axios
.post(previewMarkdownPath, { .post(previewMarkdownPath, {
text, text,
...@@ -34,19 +33,20 @@ const setupDomElement = ({ injectToEl = null } = {}) => { ...@@ -34,19 +33,20 @@ const setupDomElement = ({ injectToEl = null } = {}) => {
return previewEl; return previewEl;
}; };
export class EditorMarkdownPreviewExtension extends SourceEditorExtension { export class EditorMarkdownPreviewExtension {
constructor({ instance, previewMarkdownPath, ...args } = {}) { static get extensionName() {
super({ instance, ...args }); return 'EditorMarkdownPreview';
Object.assign(instance, { }
previewMarkdownPath,
preview: { onSetup(instance, setupOptions) {
el: undefined, this.preview = {
action: undefined, el: undefined,
shown: false, action: undefined,
modelChangeListener: undefined, shown: false,
}, modelChangeListener: undefined,
}); path: setupOptions.previewMarkdownPath,
this.setupPreviewAction.call(instance); };
this.setupPreviewAction(instance);
instance.getModel().onDidChangeLanguage(({ newLanguage, oldLanguage } = {}) => { instance.getModel().onDidChangeLanguage(({ newLanguage, oldLanguage } = {}) => {
if (newLanguage === 'markdown' && oldLanguage !== newLanguage) { if (newLanguage === 'markdown' && oldLanguage !== newLanguage) {
...@@ -68,43 +68,31 @@ export class EditorMarkdownPreviewExtension extends SourceEditorExtension { ...@@ -68,43 +68,31 @@ export class EditorMarkdownPreviewExtension extends SourceEditorExtension {
}); });
} }
static togglePreviewLayout() { togglePreviewLayout(instance) {
const { width, height } = this.getLayoutInfo(); const { width, height } = instance.getLayoutInfo();
const newWidth = this.preview.shown const newWidth = this.preview.shown
? width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH ? width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH
: width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH; : width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH;
this.layout({ width: newWidth, height }); instance.layout({ width: newWidth, height });
} }
static togglePreviewPanel() { togglePreviewPanel(instance) {
const parentEl = this.getDomNode().parentElement; const parentEl = instance.getDomNode().parentElement;
const { el: previewEl } = this.preview; const { el: previewEl } = this.preview;
parentEl.classList.toggle(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS); parentEl.classList.toggle(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS);
if (previewEl.style.display === 'none') { if (previewEl.style.display === 'none') {
// Show the preview panel // Show the preview panel
this.fetchPreview(); this.fetchPreview(instance);
} else { } else {
// Hide the preview panel // Hide the preview panel
previewEl.style.display = 'none'; previewEl.style.display = 'none';
} }
} }
cleanup() { fetchPreview(instance) {
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; const { el: previewEl } = this.preview;
getPreview(this.getValue(), this.previewMarkdownPath) fetchPreview(instance.getValue(), this.preview.path)
.then((data) => { .then((data) => {
previewEl.innerHTML = sanitize(data); previewEl.innerHTML = sanitize(data);
syntaxHighlight(previewEl.querySelectorAll('.js-syntax-highlight')); syntaxHighlight(previewEl.querySelectorAll('.js-syntax-highlight'));
...@@ -113,10 +101,10 @@ export class EditorMarkdownPreviewExtension extends SourceEditorExtension { ...@@ -113,10 +101,10 @@ export class EditorMarkdownPreviewExtension extends SourceEditorExtension {
.catch(() => createFlash(BLOB_PREVIEW_ERROR)); .catch(() => createFlash(BLOB_PREVIEW_ERROR));
} }
setupPreviewAction() { setupPreviewAction(instance) {
if (this.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)) return; if (instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)) return;
this.preview.action = this.addAction({ this.preview.action = instance.addAction({
id: EXTENSION_MARKDOWN_PREVIEW_ACTION_ID, id: EXTENSION_MARKDOWN_PREVIEW_ACTION_ID,
label: __('Preview Markdown'), label: __('Preview Markdown'),
keybindings: [ keybindings: [
...@@ -128,27 +116,52 @@ export class EditorMarkdownPreviewExtension extends SourceEditorExtension { ...@@ -128,27 +116,52 @@ export class EditorMarkdownPreviewExtension extends SourceEditorExtension {
// Method that will be executed when the action is triggered. // Method that will be executed when the action is triggered.
// @param ed The editor instance is passed in as a convenience // @param ed The editor instance is passed in as a convenience
run(instance) { run(inst) {
instance.togglePreview(); inst.togglePreview();
}, },
}); });
} }
togglePreview() { provides() {
if (!this.preview?.el) { return {
this.preview.el = setupDomElement({ injectToEl: this.getDomNode().parentElement }); markdownPreview: this.preview,
}
EditorMarkdownPreviewExtension.togglePreviewLayout.call(this);
EditorMarkdownPreviewExtension.togglePreviewPanel.call(this);
if (!this.preview?.shown) { cleanup: (instance) => {
this.preview.modelChangeListener = this.onDidChangeModelContent( if (this.preview.modelChangeListener) {
debounce(this.fetchPreview.bind(this), EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY), this.preview.modelChangeListener.dispose();
); }
} else { this.preview.action.dispose();
this.preview.modelChangeListener.dispose(); if (this.preview.shown) {
} this.togglePreviewPanel(instance);
this.togglePreviewLayout(instance);
}
this.preview.shown = false;
},
fetchPreview: (instance) => this.fetchPreview(instance),
this.preview.shown = !this.preview?.shown; setupPreviewAction: (instance) => this.setupPreviewAction(instance),
togglePreview: (instance) => {
if (!this.preview?.el) {
this.preview.el = setupDomElement({ injectToEl: instance.getDomNode().parentElement });
}
this.togglePreviewLayout(instance);
this.togglePreviewPanel(instance);
if (!this.preview?.shown) {
this.preview.modelChangeListener = instance.onDidChangeModelContent(
debounce(
this.fetchPreview.bind(this, instance),
EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY,
),
);
} else {
this.preview.modelChangeListener.dispose();
}
this.preview.shown = !this.preview?.shown;
},
};
} }
} }
/**
* A WebIDE Extension options for Source Editor
* @typedef {Object} WebIDEExtensionOptions
* @property {Object} modelManager The root manager for WebIDE models
* @property {Object} store The state store for communication
* @property {Object} file
* @property {Object} options The Monaco editor options
*/
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { KeyCode, KeyMod, Range } from 'monaco-editor'; import { KeyCode, KeyMod, Range } from 'monaco-editor';
import { EDITOR_TYPE_DIFF } from '~/editor/constants'; import { EDITOR_TYPE_DIFF } from '~/editor/constants';
import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base';
import Disposable from '~/ide/lib/common/disposable'; import Disposable from '~/ide/lib/common/disposable';
import { editorOptions } from '~/ide/lib/editor_options'; import { editorOptions } from '~/ide/lib/editor_options';
import keymap from '~/ide/lib/keymap.json'; import keymap from '~/ide/lib/keymap.json';
...@@ -11,154 +19,168 @@ const isDiffEditorType = (instance) => { ...@@ -11,154 +19,168 @@ const isDiffEditorType = (instance) => {
}; };
export const UPDATE_DIMENSIONS_DELAY = 200; export const UPDATE_DIMENSIONS_DELAY = 200;
const defaultOptions = {
modelManager: undefined,
store: undefined,
file: undefined,
options: {},
};
export class EditorWebIdeExtension extends SourceEditorExtension { const addActions = (instance, store) => {
constructor({ instance, modelManager, ...options } = {}) { const getKeyCode = (key) => {
super({ const monacoKeyMod = key.indexOf('KEY_') === 0;
instance,
...options,
modelManager,
disposable: new Disposable(),
debouncedUpdate: debounce(() => {
instance.updateDimensions();
}, UPDATE_DIMENSIONS_DELAY),
});
window.addEventListener('resize', instance.debouncedUpdate, false);
instance.onDidDispose(() => {
window.removeEventListener('resize', instance.debouncedUpdate);
// catch any potential errors with disposing the error
// this is mainly for tests caused by elements not existing
try {
instance.disposable.dispose();
} catch (e) {
if (process.env.NODE_ENV !== 'test') {
// eslint-disable-next-line no-console
console.error(e);
}
}
});
EditorWebIdeExtension.addActions(instance); return monacoKeyMod ? KeyCode[key] : KeyMod[key];
} };
static addActions(instance) { keymap.forEach((command) => {
const { store } = instance; const { bindings, id, label, action } = command;
const getKeyCode = (key) => {
const monacoKeyMod = key.indexOf('KEY_') === 0;
return monacoKeyMod ? KeyCode[key] : KeyMod[key]; const keybindings = bindings.map((binding) => {
}; const keys = binding.split('+');
keymap.forEach((command) => { // eslint-disable-next-line no-bitwise
const { bindings, id, label, action } = command; return keys.length > 1 ? getKeyCode(keys[0]) | getKeyCode(keys[1]) : getKeyCode(keys[0]);
const keybindings = bindings.map((binding) => {
const keys = binding.split('+');
// eslint-disable-next-line no-bitwise
return keys.length > 1 ? getKeyCode(keys[0]) | getKeyCode(keys[1]) : getKeyCode(keys[0]);
});
instance.addAction({
id,
label,
keybindings,
run() {
store.dispatch(action.name, action.params);
return null;
},
});
}); });
}
createModel(file, head = null) {
return this.modelManager.addModel(file, head);
}
attachModel(model) {
if (isDiffEditorType(this)) {
this.setModel({
original: model.getOriginalModel(),
modified: model.getModel(),
});
return; instance.addAction({
} id,
label,
this.setModel(model.getModel()); keybindings,
run() {
store.dispatch(action.name, action.params);
return null;
},
});
});
};
this.updateOptions( const renderSideBySide = (domElement) => {
editorOptions.reduce((acc, obj) => { return domElement.offsetWidth >= 700;
Object.keys(obj).forEach((key) => { };
Object.assign(acc, {
[key]: obj[key](model),
});
});
return acc;
}, {}),
);
}
attachMergeRequestModel(model) { const updateInstanceDimensions = (instance) => {
this.setModel({ instance.layout();
original: model.getBaseModel(), if (isDiffEditorType(instance)) {
modified: model.getModel(), instance.updateOptions({
renderSideBySide: renderSideBySide(instance.getDomNode()),
}); });
} }
};
updateDimensions() { export class EditorWebIdeExtension {
this.layout(); static get extensionName() {
this.updateDiffView(); return 'EditorWebIde';
} }
setPos({ lineNumber, column }) { /**
this.revealPositionInCenter({ * Set up the WebIDE extension for Source Editor
lineNumber, * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance
column, * @param {WebIDEExtensionOptions} setupOptions
}); */
this.setPosition({ onSetup(instance, setupOptions = defaultOptions) {
lineNumber, this.modelManager = setupOptions.modelManager;
column, this.store = setupOptions.store;
}); this.file = setupOptions.file;
this.options = setupOptions.options;
this.disposable = new Disposable();
this.debouncedUpdate = debounce(() => {
updateInstanceDimensions(instance);
}, UPDATE_DIMENSIONS_DELAY);
addActions(instance, setupOptions.store);
} }
onPositionChange(cb) { onUse(instance) {
if (!this.onDidChangeCursorPosition) { window.addEventListener('resize', this.debouncedUpdate, false);
return;
}
this.disposable.add(this.onDidChangeCursorPosition((e) => cb(this, e))); instance.onDidDispose(() => {
this.onUnuse();
});
} }
updateDiffView() { onUnuse() {
if (!isDiffEditorType(this)) { window.removeEventListener('resize', this.debouncedUpdate);
return;
// catch any potential errors with disposing the error
// this is mainly for tests caused by elements not existing
try {
this.disposable.dispose();
} catch (e) {
if (process.env.NODE_ENV !== 'test') {
// eslint-disable-next-line no-console
console.error(e);
}
} }
this.updateOptions({
renderSideBySide: EditorWebIdeExtension.renderSideBySide(this.getDomNode()),
});
} }
replaceSelectedText(text) { provides() {
let selection = this.getSelection(); return {
const range = new Range( createModel: (instance, file, head = null) => {
selection.startLineNumber, return this.modelManager.addModel(file, head);
selection.startColumn, },
selection.endLineNumber, attachModel: (instance, model) => {
selection.endColumn, if (isDiffEditorType(instance)) {
); instance.setModel({
original: model.getOriginalModel(),
modified: model.getModel(),
});
this.executeEdits('', [{ range, text }]); return;
}
selection = this.getSelection(); instance.setModel(model.getModel());
this.setPosition({ lineNumber: selection.endLineNumber, column: selection.endColumn });
} instance.updateOptions(
editorOptions.reduce((acc, obj) => {
Object.keys(obj).forEach((key) => {
Object.assign(acc, {
[key]: obj[key](model),
});
});
return acc;
}, {}),
);
},
attachMergeRequestModel: (instance, model) => {
instance.setModel({
original: model.getBaseModel(),
modified: model.getModel(),
});
},
updateDimensions: (instance) => updateInstanceDimensions(instance),
setPos: (instance, { lineNumber, column }) => {
instance.revealPositionInCenter({
lineNumber,
column,
});
instance.setPosition({
lineNumber,
column,
});
},
onPositionChange: (instance, cb) => {
if (typeof instance.onDidChangeCursorPosition !== 'function') {
return;
}
static renderSideBySide(domElement) { this.disposable.add(instance.onDidChangeCursorPosition((e) => cb(instance, e)));
return domElement.offsetWidth >= 700; },
replaceSelectedText: (instance, text) => {
let selection = instance.getSelection();
const range = new Range(
selection.startLineNumber,
selection.startColumn,
selection.endLineNumber,
selection.endColumn,
);
instance.executeEdits('', [{ range, text }]);
selection = instance.getSelection();
instance.setPosition({ lineNumber: selection.endLineNumber, column: selection.endColumn });
},
};
} }
} }
import { editor as monacoEditor, Uri } from 'monaco-editor'; import { editor as monacoEditor, Uri } from 'monaco-editor';
import { waitForCSSLoaded } from '~/helpers/startup_css_helper';
import { defaultEditorOptions } from '~/ide/lib/editor_options'; import { defaultEditorOptions } from '~/ide/lib/editor_options';
import languages from '~/ide/lib/languages'; import languages from '~/ide/lib/languages';
import { registerLanguages } from '~/ide/utils'; import { registerLanguages } from '~/ide/utils';
...@@ -11,10 +12,39 @@ import { ...@@ -11,10 +12,39 @@ import {
EDITOR_TYPE_DIFF, EDITOR_TYPE_DIFF,
} from './constants'; } from './constants';
import { clearDomElement, setupEditorTheme, getBlobLanguage } from './utils'; import { clearDomElement, setupEditorTheme, getBlobLanguage } from './utils';
import EditorInstance from './source_editor_instance';
const instanceRemoveFromRegistry = (editor, instance) => {
const index = editor.instances.findIndex((inst) => inst === instance);
editor.instances.splice(index, 1);
};
const instanceDisposeModels = (editor, instance, model) => {
const instanceModel = instance.getModel() || model;
if (!instanceModel) {
return;
}
if (instance.getEditorType() === EDITOR_TYPE_DIFF) {
const { original, modified } = instanceModel;
if (original) {
original.dispose();
}
if (modified) {
modified.dispose();
}
} else {
instanceModel.dispose();
}
};
export default class SourceEditor { export default class SourceEditor {
/**
* Constructs a global editor.
* @param {Object} options - Monaco config options used to create the editor
*/
constructor(options = {}) { constructor(options = {}) {
this.instances = []; this.instances = [];
this.extensionsStore = new Map();
this.options = { this.options = {
extraEditorClassName: 'gl-source-editor', extraEditorClassName: 'gl-source-editor',
...defaultEditorOptions, ...defaultEditorOptions,
...@@ -26,19 +56,6 @@ export default class SourceEditor { ...@@ -26,19 +56,6 @@ export default class SourceEditor {
registerLanguages(...languages); registerLanguages(...languages);
} }
static mixIntoInstance(source, inst) {
if (!inst) {
return;
}
const isClassInstance = source.constructor.prototype !== Object.prototype;
const sanitizedSource = isClassInstance ? source.constructor.prototype : source;
Object.getOwnPropertyNames(sanitizedSource).forEach((prop) => {
if (prop !== 'constructor') {
Object.assign(inst, { [prop]: source[prop] });
}
});
}
static prepareInstance(el) { static prepareInstance(el) {
if (!el) { if (!el) {
throw new Error(SOURCE_EDITOR_INSTANCE_ERROR_NO_EL); throw new Error(SOURCE_EDITOR_INSTANCE_ERROR_NO_EL);
...@@ -78,71 +95,17 @@ export default class SourceEditor { ...@@ -78,71 +95,17 @@ export default class SourceEditor {
return diffModel; return diffModel;
} }
static convertMonacoToELInstance = (inst) => {
const sourceEditorInstanceAPI = {
updateModelLanguage: (path) => {
return SourceEditor.instanceUpdateLanguage(inst, path);
},
use: (exts = []) => {
return SourceEditor.instanceApplyExtension(inst, exts);
},
};
const handler = {
get(target, prop, receiver) {
if (Reflect.has(sourceEditorInstanceAPI, prop)) {
return sourceEditorInstanceAPI[prop];
}
return Reflect.get(target, prop, receiver);
},
};
return new Proxy(inst, handler);
};
static instanceUpdateLanguage(inst, path) {
const lang = getBlobLanguage(path);
const model = inst.getModel();
return monacoEditor.setModelLanguage(model, lang);
}
static instanceApplyExtension(inst, exts = []) {
const extensions = [].concat(exts);
extensions.forEach((extension) => {
SourceEditor.mixIntoInstance(extension, inst);
});
return inst;
}
static instanceRemoveFromRegistry(editor, instance) {
const index = editor.instances.findIndex((inst) => inst === instance);
editor.instances.splice(index, 1);
}
static instanceDisposeModels(editor, instance, model) {
const instanceModel = instance.getModel() || model;
if (!instanceModel) {
return;
}
if (instance.getEditorType() === EDITOR_TYPE_DIFF) {
const { original, modified } = instanceModel;
if (original) {
original.dispose();
}
if (modified) {
modified.dispose();
}
} else {
instanceModel.dispose();
}
}
/** /**
* Creates a monaco instance with the given options. * Creates a Source Editor Instance with the given options.
* * @param {Object} options Options used to initialize the instance.
* @param {Object} options Options used to initialize monaco. * @param {Element} options.el The element to attach the instance for.
* @param {Element} options.el The element which will be used to create the monacoEditor.
* @param {string} options.blobPath The path used as the URI of the model. Monaco uses the extension of this path to determine the language. * @param {string} options.blobPath The path used as the URI of the model. Monaco uses the extension of this path to determine the language.
* @param {string} options.blobContent The content to initialize the monacoEditor. * @param {string} options.blobContent The content to initialize the monacoEditor.
* @param {string} options.blobOriginalContent The original blob's content. Is used when creating a Diff Instance.
* @param {string} options.blobGlobalId This is used to help globally identify monaco instances that are created with the same blobPath. * @param {string} options.blobGlobalId This is used to help globally identify monaco instances that are created with the same blobPath.
* @param {Boolean} options.isDiff Flag to enable creation of a Diff Instance?
* @param {...*} options.instanceOptions Configuration options used to instantiate an instance.
* @returns {EditorInstance}
*/ */
createInstance({ createInstance({
el = undefined, el = undefined,
...@@ -156,13 +119,18 @@ export default class SourceEditor { ...@@ -156,13 +119,18 @@ export default class SourceEditor {
SourceEditor.prepareInstance(el); SourceEditor.prepareInstance(el);
const createEditorFn = isDiff ? 'createDiffEditor' : 'create'; const createEditorFn = isDiff ? 'createDiffEditor' : 'create';
const instance = SourceEditor.convertMonacoToELInstance( const instance = new EditorInstance(
monacoEditor[createEditorFn].call(this, el, { monacoEditor[createEditorFn].call(this, el, {
...this.options, ...this.options,
...instanceOptions, ...instanceOptions,
}), }),
this.extensionsStore,
); );
waitForCSSLoaded(() => {
instance.layout();
});
let model; let model;
if (instanceOptions.model !== null) { if (instanceOptions.model !== null) {
model = SourceEditor.createEditorModel({ model = SourceEditor.createEditorModel({
...@@ -176,8 +144,8 @@ export default class SourceEditor { ...@@ -176,8 +144,8 @@ export default class SourceEditor {
} }
instance.onDidDispose(() => { instance.onDidDispose(() => {
SourceEditor.instanceRemoveFromRegistry(this, instance); instanceRemoveFromRegistry(this, instance);
SourceEditor.instanceDisposeModels(this, instance, model); instanceDisposeModels(this, instance, model);
}); });
this.instances.push(instance); this.instances.push(instance);
...@@ -185,6 +153,11 @@ export default class SourceEditor { ...@@ -185,6 +153,11 @@ export default class SourceEditor {
return instance; return instance;
} }
/**
* Create a Diff Instance
* @param {Object} args Options to be passed further down to createInstance() with the same signature
* @returns {EditorInstance}
*/
createDiffInstance(args) { createDiffInstance(args) {
return this.createInstance({ return this.createInstance({
...args, ...args,
...@@ -192,6 +165,10 @@ export default class SourceEditor { ...@@ -192,6 +165,10 @@ export default class SourceEditor {
}); });
} }
/**
* Dispose global editor
* Automatically disposes all the instances registered for this editor
*/
dispose() { dispose() {
this.instances.forEach((instance) => instance.dispose()); this.instances.forEach((instance) => instance.dispose());
} }
......
...@@ -5,10 +5,10 @@ export default class EditorExtension { ...@@ -5,10 +5,10 @@ export default class EditorExtension {
if (typeof definition !== 'function') { if (typeof definition !== 'function') {
throw new Error(EDITOR_EXTENSION_DEFINITION_ERROR); throw new Error(EDITOR_EXTENSION_DEFINITION_ERROR);
} }
this.name = definition.name; // both class- and fn-based extensions have a name
this.setupOptions = setupOptions; this.setupOptions = setupOptions;
// eslint-disable-next-line new-cap // eslint-disable-next-line new-cap
this.obj = new definition(); this.obj = new definition();
this.extensionName = definition.extensionName || this.obj.extensionName; // both class- and fn-based extensions have a name
} }
get api() { get api() {
......
...@@ -13,7 +13,7 @@ ...@@ -13,7 +13,7 @@
* A Source Editor Extension * A Source Editor Extension
* @typedef {Object} SourceEditorExtension * @typedef {Object} SourceEditorExtension
* @property {Object} obj * @property {Object} obj
* @property {string} name * @property {string} extensionName
* @property {Object} api * @property {Object} api
*/ */
...@@ -43,12 +43,12 @@ const utils = { ...@@ -43,12 +43,12 @@ const utils = {
} }
}, },
getStoredExtension: (extensionsStore, name) => { getStoredExtension: (extensionsStore, extensionName) => {
if (!extensionsStore) { if (!extensionsStore) {
logError(EDITOR_EXTENSION_STORE_IS_MISSING_ERROR); logError(EDITOR_EXTENSION_STORE_IS_MISSING_ERROR);
return undefined; return undefined;
} }
return extensionsStore.get(name); return extensionsStore.get(extensionName);
}, },
}; };
...@@ -73,30 +73,18 @@ export default class EditorInstance { ...@@ -73,30 +73,18 @@ export default class EditorInstance {
if (methodExtension) { if (methodExtension) {
const extension = extensionsStore.get(methodExtension); const extension = extensionsStore.get(methodExtension);
return (...args) => extension.api[prop].call(seInstance, receiver, ...args); if (typeof extension.api[prop] === 'function') {
return extension.api[prop].bind(extension.obj, receiver);
}
return extension.api[prop];
} }
return Reflect.get(seInstance[prop] ? seInstance : target, prop, receiver); return Reflect.get(seInstance[prop] ? seInstance : target, prop, receiver);
}, },
set(target, prop, value) {
Object.assign(seInstance, {
[prop]: value,
});
return true;
},
}; };
const instProxy = new Proxy(rootInstance, getHandler); const instProxy = new Proxy(rootInstance, getHandler);
/** this.dispatchExtAction = EditorInstance.useUnuse.bind(instProxy, extensionsStore);
* Main entry point to apply an extension to the instance
* @param {SourceEditorExtensionDefinition}
*/
this.use = EditorInstance.useUnuse.bind(instProxy, extensionsStore, this.useExtension);
/**
* Main entry point to un-use an extension and remove it from the instance
* @param {SourceEditorExtension}
*/
this.unuse = EditorInstance.useUnuse.bind(instProxy, extensionsStore, this.unuseExtension);
return instProxy; return instProxy;
} }
...@@ -141,7 +129,7 @@ export default class EditorInstance { ...@@ -141,7 +129,7 @@ export default class EditorInstance {
} }
// Existing Extension Path // Existing Extension Path
const existingExt = utils.getStoredExtension(extensionsStore, definition.name); const existingExt = utils.getStoredExtension(extensionsStore, definition.extensionName);
if (existingExt) { if (existingExt) {
if (isEqual(extension.setupOptions, existingExt.setupOptions)) { if (isEqual(extension.setupOptions, existingExt.setupOptions)) {
return existingExt; return existingExt;
...@@ -168,14 +156,14 @@ export default class EditorInstance { ...@@ -168,14 +156,14 @@ export default class EditorInstance {
* @param {Map} extensionsStore - The global registry for the extension instances * @param {Map} extensionsStore - The global registry for the extension instances
*/ */
registerExtension(extension, extensionsStore) { registerExtension(extension, extensionsStore) {
const { name } = extension; const { extensionName } = extension;
const hasExtensionRegistered = const hasExtensionRegistered =
extensionsStore.has(name) && extensionsStore.has(extensionName) &&
isEqual(extension.setupOptions, extensionsStore.get(name).setupOptions); isEqual(extension.setupOptions, extensionsStore.get(extensionName).setupOptions);
if (hasExtensionRegistered) { if (hasExtensionRegistered) {
return; return;
} }
extensionsStore.set(name, extension); extensionsStore.set(extensionName, extension);
const { obj: extensionObj } = extension; const { obj: extensionObj } = extension;
if (extensionObj.onUse) { if (extensionObj.onUse) {
extensionObj.onUse(this); extensionObj.onUse(this);
...@@ -187,7 +175,7 @@ export default class EditorInstance { ...@@ -187,7 +175,7 @@ export default class EditorInstance {
* @param {SourceEditorExtension} extension - Instance of Source Editor extension * @param {SourceEditorExtension} extension - Instance of Source Editor extension
*/ */
registerExtensionMethods(extension) { registerExtensionMethods(extension) {
const { api, name } = extension; const { api, extensionName } = extension;
if (!api) { if (!api) {
return; return;
...@@ -197,7 +185,7 @@ export default class EditorInstance { ...@@ -197,7 +185,7 @@ export default class EditorInstance {
if (this[prop]) { if (this[prop]) {
logError(sprintf(EDITOR_EXTENSION_NAMING_CONFLICT_ERROR, { prop })); logError(sprintf(EDITOR_EXTENSION_NAMING_CONFLICT_ERROR, { prop }));
} else { } else {
this.methods[prop] = name; this.methods[prop] = extensionName;
} }
}, this); }, this);
} }
...@@ -215,10 +203,10 @@ export default class EditorInstance { ...@@ -215,10 +203,10 @@ export default class EditorInstance {
if (!extension) { if (!extension) {
throw new Error(EDITOR_EXTENSION_NOT_SPECIFIED_FOR_UNUSE_ERROR); throw new Error(EDITOR_EXTENSION_NOT_SPECIFIED_FOR_UNUSE_ERROR);
} }
const { name } = extension; const { extensionName } = extension;
const existingExt = utils.getStoredExtension(extensionsStore, name); const existingExt = utils.getStoredExtension(extensionsStore, extensionName);
if (!existingExt) { if (!existingExt) {
throw new Error(sprintf(EDITOR_EXTENSION_NOT_REGISTERED_ERROR, { name })); throw new Error(sprintf(EDITOR_EXTENSION_NOT_REGISTERED_ERROR, { extensionName }));
} }
const { obj: extensionObj } = existingExt; const { obj: extensionObj } = existingExt;
if (extensionObj.onBeforeUnuse) { if (extensionObj.onBeforeUnuse) {
...@@ -235,12 +223,12 @@ export default class EditorInstance { ...@@ -235,12 +223,12 @@ export default class EditorInstance {
* @param {SourceEditorExtension} extension - Instance of Source Editor extension to un-use * @param {SourceEditorExtension} extension - Instance of Source Editor extension to un-use
*/ */
unregisterExtensionMethods(extension) { unregisterExtensionMethods(extension) {
const { api, name } = extension; const { api, extensionName } = extension;
if (!api) { if (!api) {
return; return;
} }
Object.keys(api).forEach((method) => { Object.keys(api).forEach((method) => {
utils.removeExtFromMethod(method, name, this.methods); utils.removeExtFromMethod(method, extensionName, this.methods);
}); });
} }
...@@ -259,6 +247,24 @@ export default class EditorInstance { ...@@ -259,6 +247,24 @@ export default class EditorInstance {
monacoEditor.setModelLanguage(model, lang); monacoEditor.setModelLanguage(model, lang);
} }
/**
* Main entry point to apply an extension to the instance
* @param {SourceEditorExtensionDefinition[]|SourceEditorExtensionDefinition} extDefs - The extension(s) to use
* @returns {EditorExtension|*}
*/
use(extDefs) {
return this.dispatchExtAction(this.useExtension, extDefs);
}
/**
* Main entry point to remove an extension to the instance
* @param {SourceEditorExtension[]|SourceEditorExtension} exts -
* @returns {*}
*/
unuse(exts) {
return this.dispatchExtAction(this.unuseExtension, exts);
}
/** /**
* Get the methods returned by extensions. * Get the methods returned by extensions.
* @returns {Array} * @returns {Array}
......
...@@ -7,6 +7,7 @@ import { ...@@ -7,6 +7,7 @@ import {
EDITOR_CODE_INSTANCE_FN, EDITOR_CODE_INSTANCE_FN,
EDITOR_DIFF_INSTANCE_FN, EDITOR_DIFF_INSTANCE_FN,
} from '~/editor/constants'; } from '~/editor/constants';
import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base';
import { EditorWebIdeExtension } from '~/editor/extensions/source_editor_webide_ext'; import { EditorWebIdeExtension } from '~/editor/extensions/source_editor_webide_ext';
import SourceEditor from '~/editor/source_editor'; import SourceEditor from '~/editor/source_editor';
import createFlash from '~/flash'; import createFlash from '~/flash';
...@@ -302,30 +303,32 @@ export default { ...@@ -302,30 +303,32 @@ export default {
...instanceOptions, ...instanceOptions,
...this.editorOptions, ...this.editorOptions,
}); });
this.editor.use([
this.editor.use( {
new EditorWebIdeExtension({ definition: SourceEditorExtension,
instance: this.editor, },
modelManager: this.modelManager, {
store: this.$store, definition: EditorWebIdeExtension,
file: this.file, setupOptions: {
options: this.editorOptions, modelManager: this.modelManager,
}), store: this.$store,
); file: this.file,
options: this.editorOptions,
},
},
]);
if ( if (
this.fileType === MARKDOWN_FILE_TYPE && this.fileType === MARKDOWN_FILE_TYPE &&
this.editor?.getEditorType() === EDITOR_TYPE_CODE && this.editor?.getEditorType() === EDITOR_TYPE_CODE &&
this.previewMarkdownPath this.previewMarkdownPath
) { ) {
import('~/editor/extensions/source_editor_markdown_ext') import('~/editor/extensions/source_editor_markdown_livepreview_ext')
.then(({ EditorMarkdownExtension: MarkdownExtension } = {}) => { .then(({ EditorMarkdownPreviewExtension: MarkdownLivePreview }) => {
this.editor.use( this.editor.use({
new MarkdownExtension({ definition: MarkdownLivePreview,
instance: this.editor, setupOptions: { previewMarkdownPath: this.previewMarkdownPath },
previewMarkdownPath: this.previewMarkdownPath, });
}),
);
}) })
.catch((e) => .catch((e) =>
createFlash({ createFlash({
......
...@@ -19,7 +19,7 @@ export default { ...@@ -19,7 +19,7 @@ export default {
if (this.glFeatures.schemaLinting) { if (this.glFeatures.schemaLinting) {
const editorInstance = this.$refs.editor.getEditor(); const editorInstance = this.$refs.editor.getEditor();
editorInstance.use(new CiSchemaExtension({ instance: editorInstance })); editorInstance.use({ definition: CiSchemaExtension });
editorInstance.registerCiSchema(); editorInstance.registerCiSchema();
} }
}, },
......
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import EditBlob from '~/blob_edit/edit_blob'; import EditBlob from '~/blob_edit/edit_blob';
import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base';
import { FileTemplateExtension } from '~/editor/extensions/source_editor_file_template_ext'; import { FileTemplateExtension } from '~/editor/extensions/source_editor_file_template_ext';
import { EditorMarkdownExtension } from '~/editor/extensions/source_editor_markdown_ext'; import { EditorMarkdownExtension } from '~/editor/extensions/source_editor_markdown_ext';
import { EditorMarkdownPreviewExtension } from '~/editor/extensions/source_editor_markdown_livepreview_ext';
import SourceEditor from '~/editor/source_editor'; import SourceEditor from '~/editor/source_editor';
jest.mock('~/editor/source_editor'); jest.mock('~/editor/source_editor');
jest.mock('~/editor/extensions/source_editor_markdown_ext'); jest.mock('~/editor/extensions/source_editor_extension_base');
jest.mock('~/editor/extensions/source_editor_file_template_ext'); jest.mock('~/editor/extensions/source_editor_file_template_ext');
jest.mock('~/editor/extensions/source_editor_markdown_ext');
jest.mock('~/editor/extensions/source_editor_markdown_livepreview_ext');
const PREVIEW_MARKDOWN_PATH = '/foo/bar/preview_markdown'; const PREVIEW_MARKDOWN_PATH = '/foo/bar/preview_markdown';
const defaultExtensions = [
{ definition: SourceEditorExtension },
{ definition: FileTemplateExtension },
];
const markdownExtensions = [
{ definition: EditorMarkdownExtension },
{
definition: EditorMarkdownPreviewExtension,
setupOptions: { previewMarkdownPath: PREVIEW_MARKDOWN_PATH },
},
];
describe('Blob Editing', () => { describe('Blob Editing', () => {
const useMock = jest.fn(); const useMock = jest.fn();
...@@ -29,7 +44,9 @@ describe('Blob Editing', () => { ...@@ -29,7 +44,9 @@ describe('Blob Editing', () => {
jest.spyOn(SourceEditor.prototype, 'createInstance').mockReturnValue(mockInstance); jest.spyOn(SourceEditor.prototype, 'createInstance').mockReturnValue(mockInstance);
}); });
afterEach(() => { afterEach(() => {
SourceEditorExtension.mockClear();
EditorMarkdownExtension.mockClear(); EditorMarkdownExtension.mockClear();
EditorMarkdownPreviewExtension.mockClear();
FileTemplateExtension.mockClear(); FileTemplateExtension.mockClear();
}); });
...@@ -45,26 +62,22 @@ describe('Blob Editing', () => { ...@@ -45,26 +62,22 @@ describe('Blob Editing', () => {
await waitForPromises(); await waitForPromises();
}; };
it('loads FileTemplateExtension by default', async () => { it('loads SourceEditorExtension and FileTemplateExtension by default', async () => {
await initEditor(); await initEditor();
expect(useMock).toHaveBeenCalledWith(expect.any(FileTemplateExtension)); expect(useMock).toHaveBeenCalledWith(defaultExtensions);
expect(FileTemplateExtension).toHaveBeenCalledTimes(1);
}); });
describe('Markdown', () => { describe('Markdown', () => {
it('does not load MarkdownExtension by default', async () => { it('does not load MarkdownExtensions by default', async () => {
await initEditor(); await initEditor();
expect(EditorMarkdownExtension).not.toHaveBeenCalled(); expect(EditorMarkdownExtension).not.toHaveBeenCalled();
expect(EditorMarkdownPreviewExtension).not.toHaveBeenCalled();
}); });
it('loads MarkdownExtension only for the markdown files', async () => { it('loads MarkdownExtension only for the markdown files', async () => {
await initEditor(true); await initEditor(true);
expect(useMock).toHaveBeenCalledWith(expect.any(EditorMarkdownExtension)); expect(useMock).toHaveBeenCalledTimes(2);
expect(EditorMarkdownExtension).toHaveBeenCalledTimes(1); expect(useMock.mock.calls[1]).toEqual([markdownExtensions]);
expect(EditorMarkdownExtension).toHaveBeenCalledWith({
instance: mockInstance,
previewMarkdownPath: PREVIEW_MARKDOWN_PATH,
});
}); });
}); });
......
/* eslint-disable max-classes-per-file */
// Helpers
export const spyOnApi = (extension, spiesObj = {}) => {
const origApi = extension.api;
if (extension?.obj) {
jest.spyOn(extension.obj, 'provides').mockReturnValue({
...origApi,
...spiesObj,
});
}
};
// Dummy Extensions
export class SEClassExtension { export class SEClassExtension {
static get extensionName() {
return 'SEClassExtension';
}
// eslint-disable-next-line class-methods-use-this // eslint-disable-next-line class-methods-use-this
provides() { provides() {
return { return {
...@@ -10,6 +28,7 @@ export class SEClassExtension { ...@@ -10,6 +28,7 @@ export class SEClassExtension {
export function SEFnExtension() { export function SEFnExtension() {
return { return {
extensionName: 'SEFnExtension',
fnExtMethod: () => 'fn own method', fnExtMethod: () => 'fn own method',
provides: () => { provides: () => {
return { return {
...@@ -21,6 +40,7 @@ export function SEFnExtension() { ...@@ -21,6 +40,7 @@ export function SEFnExtension() {
export const SEConstExt = () => { export const SEConstExt = () => {
return { return {
extensionName: 'SEConstExt',
provides: () => { provides: () => {
return { return {
constExtMethod: () => 'const own method', constExtMethod: () => 'const own method',
...@@ -29,36 +49,39 @@ export const SEConstExt = () => { ...@@ -29,36 +49,39 @@ export const SEConstExt = () => {
}; };
}; };
export function SEWithSetupExt() { export class SEWithSetupExt {
return { static get extensionName() {
onSetup: (instance, setupOptions = {}) => { return 'SEWithSetupExt';
if (setupOptions && !Array.isArray(setupOptions)) { }
Object.entries(setupOptions).forEach(([key, value]) => { // eslint-disable-next-line class-methods-use-this
Object.assign(instance, { onSetup(instance, setupOptions = {}) {
[key]: value, if (setupOptions && !Array.isArray(setupOptions)) {
}); Object.entries(setupOptions).forEach(([key, value]) => {
Object.assign(instance, {
[key]: value,
}); });
} });
}, }
provides: () => { }
return { provides() {
returnInstanceAndProps: (instance, stringProp, objProp = {}) => { return {
return [stringProp, objProp, instance]; returnInstanceAndProps: (instance, stringProp, objProp = {}) => {
}, return [stringProp, objProp, instance];
returnInstance: (instance) => { },
return instance; returnInstance: (instance) => {
}, return instance;
giveMeContext: () => { },
return this; giveMeContext: () => {
}, return this;
}; },
}, };
}; }
} }
export const conflictingExtensions = { export const conflictingExtensions = {
WithInstanceExt: () => { WithInstanceExt: () => {
return { return {
extensionName: 'WithInstanceExt',
provides: () => { provides: () => {
return { return {
use: () => 'A conflict with instance', use: () => 'A conflict with instance',
...@@ -69,6 +92,7 @@ export const conflictingExtensions = { ...@@ -69,6 +92,7 @@ export const conflictingExtensions = {
}, },
WithAnotherExt: () => { WithAnotherExt: () => {
return { return {
extensionName: 'WithAnotherExt',
provides: () => { provides: () => {
return { return {
shared: () => 'A conflict with extension', shared: () => 'A conflict with extension',
......
...@@ -23,7 +23,7 @@ describe('~/editor/editor_ci_config_ext', () => { ...@@ -23,7 +23,7 @@ describe('~/editor/editor_ci_config_ext', () => {
blobPath, blobPath,
blobContent: '', blobContent: '',
}); });
instance.use(new CiSchemaExtension()); instance.use({ definition: CiSchemaExtension });
}; };
beforeAll(() => { beforeAll(() => {
......
...@@ -40,7 +40,7 @@ describe('Editor Extension', () => { ...@@ -40,7 +40,7 @@ describe('Editor Extension', () => {
expect(extension).toEqual( expect(extension).toEqual(
expect.objectContaining({ expect.objectContaining({
name: expectedName, extensionName: expectedName,
setupOptions, setupOptions,
}), }),
); );
......
...@@ -32,11 +32,17 @@ describe('Source Editor Instance', () => { ...@@ -32,11 +32,17 @@ describe('Source Editor Instance', () => {
]; ];
const fooFn = jest.fn(); const fooFn = jest.fn();
const fooProp = 'foo';
class DummyExt { class DummyExt {
// eslint-disable-next-line class-methods-use-this
get extensionName() {
return 'DummyExt';
}
// eslint-disable-next-line class-methods-use-this // eslint-disable-next-line class-methods-use-this
provides() { provides() {
return { return {
fooFn, fooFn,
fooProp,
}; };
} }
} }
...@@ -64,7 +70,7 @@ describe('Source Editor Instance', () => { ...@@ -64,7 +70,7 @@ describe('Source Editor Instance', () => {
}); });
describe('proxy', () => { describe('proxy', () => {
it('returns prop from an extension if extension provides it', () => { it('returns a method from an extension if extension provides it', () => {
seInstance = new SourceEditorInstance(); seInstance = new SourceEditorInstance();
seInstance.use({ definition: DummyExt }); seInstance.use({ definition: DummyExt });
...@@ -73,6 +79,13 @@ describe('Source Editor Instance', () => { ...@@ -73,6 +79,13 @@ describe('Source Editor Instance', () => {
expect(fooFn).toHaveBeenCalled(); expect(fooFn).toHaveBeenCalled();
}); });
it('returns a prop from an extension if extension provides it', () => {
seInstance = new SourceEditorInstance();
seInstance.use({ definition: DummyExt });
expect(seInstance.fooProp).toBe('foo');
});
it.each` it.each`
stringPropToPass | objPropToPass | setupOptions stringPropToPass | objPropToPass | setupOptions
${undefined} | ${undefined} | ${undefined} ${undefined} | ${undefined} | ${undefined}
...@@ -118,20 +131,20 @@ describe('Source Editor Instance', () => { ...@@ -118,20 +131,20 @@ describe('Source Editor Instance', () => {
it("correctly sets the context of the 'this' keyword for the extension's methods", () => { it("correctly sets the context of the 'this' keyword for the extension's methods", () => {
seInstance = new SourceEditorInstance(); seInstance = new SourceEditorInstance();
seInstance.use({ definition: SEWithSetupExt }); const extension = seInstance.use({ definition: SEWithSetupExt });
expect(seInstance.giveMeContext().constructor).toEqual(SEWithSetupExt); expect(seInstance.giveMeContext()).toEqual(extension.obj);
}); });
it('returns props from SE instance itself if no extension provides the prop', () => { it('returns props from SE instance itself if no extension provides the prop', () => {
seInstance = new SourceEditorInstance({ seInstance = new SourceEditorInstance({
use: fooFn, use: fooFn,
}); });
jest.spyOn(seInstance, 'use').mockImplementation(() => {}); const spy = jest.spyOn(seInstance.constructor.prototype, 'use').mockImplementation(() => {});
expect(seInstance.use).not.toHaveBeenCalled(); expect(spy).not.toHaveBeenCalled();
expect(fooFn).not.toHaveBeenCalled(); expect(fooFn).not.toHaveBeenCalled();
seInstance.use(); seInstance.use();
expect(seInstance.use).toHaveBeenCalled(); expect(spy).toHaveBeenCalled();
expect(fooFn).not.toHaveBeenCalled(); expect(fooFn).not.toHaveBeenCalled();
}); });
......
...@@ -9,7 +9,6 @@ describe('Markdown Extension for Source Editor', () => { ...@@ -9,7 +9,6 @@ describe('Markdown Extension for Source Editor', () => {
let instance; let instance;
let editorEl; let editorEl;
let mockAxios; let mockAxios;
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**';
...@@ -36,7 +35,7 @@ describe('Markdown Extension for Source Editor', () => { ...@@ -36,7 +35,7 @@ describe('Markdown Extension for Source Editor', () => {
blobPath: markdownPath, blobPath: markdownPath,
blobContent: text, blobContent: text,
}); });
instance.use(new EditorMarkdownExtension({ instance, previewMarkdownPath })); instance.use({ definition: EditorMarkdownExtension });
}); });
afterEach(() => { afterEach(() => {
...@@ -164,13 +163,11 @@ describe('Markdown Extension for Source Editor', () => { ...@@ -164,13 +163,11 @@ describe('Markdown Extension for Source Editor', () => {
}); });
it('does not fail when only `toSelect` is supplied and fetches the text from selection', () => { it('does not fail when only `toSelect` is supplied and fetches the text from selection', () => {
jest.spyOn(instance, 'getSelectedText');
const toSelect = 'string'; const toSelect = 'string';
selectSecondAndThirdLines(); selectSecondAndThirdLines();
instance.selectWithinSelection(toSelect); instance.selectWithinSelection(toSelect);
expect(instance.getSelectedText).toHaveBeenCalled();
expect(selectionToString()).toBe(`[3,1 -> 3,${toSelect.length + 1}]`); expect(selectionToString()).toBe(`[3,1 -> 3,${toSelect.length + 1}]`);
}); });
......
...@@ -13,6 +13,7 @@ import SourceEditor from '~/editor/source_editor'; ...@@ -13,6 +13,7 @@ import SourceEditor from '~/editor/source_editor';
import createFlash from '~/flash'; import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import syntaxHighlight from '~/syntax_highlight'; import syntaxHighlight from '~/syntax_highlight';
import { spyOnApi } from './helpers';
jest.mock('~/syntax_highlight'); jest.mock('~/syntax_highlight');
jest.mock('~/flash'); jest.mock('~/flash');
...@@ -23,6 +24,7 @@ describe('Markdown Live Preview Extension for Source Editor', () => { ...@@ -23,6 +24,7 @@ describe('Markdown Live Preview Extension for Source Editor', () => {
let editorEl; let editorEl;
let panelSpy; let panelSpy;
let mockAxios; let mockAxios;
let extension;
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';
...@@ -47,8 +49,11 @@ describe('Markdown Live Preview Extension for Source Editor', () => { ...@@ -47,8 +49,11 @@ describe('Markdown Live Preview Extension for Source Editor', () => {
blobPath: markdownPath, blobPath: markdownPath,
blobContent: text, blobContent: text,
}); });
instance.use(new EditorMarkdownPreviewExtension({ instance, previewMarkdownPath })); extension = instance.use({
panelSpy = jest.spyOn(EditorMarkdownPreviewExtension, 'togglePreviewPanel'); definition: EditorMarkdownPreviewExtension,
setupOptions: { previewMarkdownPath },
});
panelSpy = jest.spyOn(extension.obj.constructor.prototype, 'togglePreviewPanel');
}); });
afterEach(() => { afterEach(() => {
...@@ -57,14 +62,14 @@ describe('Markdown Live Preview Extension for Source Editor', () => { ...@@ -57,14 +62,14 @@ describe('Markdown Live Preview Extension for Source Editor', () => {
mockAxios.restore(); mockAxios.restore();
}); });
it('sets up the instance', () => { it('sets up the preview on the instance', () => {
expect(instance.preview).toEqual({ expect(instance.markdownPreview).toEqual({
el: undefined, el: undefined,
action: expect.any(Object), action: expect.any(Object),
shown: false, shown: false,
modelChangeListener: undefined, modelChangeListener: undefined,
path: previewMarkdownPath,
}); });
expect(instance.previewMarkdownPath).toBe(previewMarkdownPath);
}); });
describe('model language changes listener', () => { describe('model language changes listener', () => {
...@@ -72,14 +77,22 @@ describe('Markdown Live Preview Extension for Source Editor', () => { ...@@ -72,14 +77,22 @@ describe('Markdown Live Preview Extension for Source Editor', () => {
let actionSpy; let actionSpy;
beforeEach(async () => { beforeEach(async () => {
cleanupSpy = jest.spyOn(instance, 'cleanup'); cleanupSpy = jest.fn();
actionSpy = jest.spyOn(instance, 'setupPreviewAction'); actionSpy = jest.fn();
spyOnApi(extension, {
cleanup: cleanupSpy,
setupPreviewAction: actionSpy,
});
await togglePreview(); await togglePreview();
}); });
afterEach(() => {
jest.clearAllMocks();
});
it('cleans up when switching away from markdown', () => { it('cleans up when switching away from markdown', () => {
expect(instance.cleanup).not.toHaveBeenCalled(); expect(cleanupSpy).not.toHaveBeenCalled();
expect(instance.setupPreviewAction).not.toHaveBeenCalled(); expect(actionSpy).not.toHaveBeenCalled();
instance.updateModelLanguage(plaintextPath); instance.updateModelLanguage(plaintextPath);
...@@ -110,8 +123,12 @@ describe('Markdown Live Preview Extension for Source Editor', () => { ...@@ -110,8 +123,12 @@ describe('Markdown Live Preview Extension for Source Editor', () => {
let actionSpy; let actionSpy;
beforeEach(() => { beforeEach(() => {
cleanupSpy = jest.spyOn(instance, 'cleanup'); cleanupSpy = jest.fn();
actionSpy = jest.spyOn(instance, 'setupPreviewAction'); actionSpy = jest.fn();
spyOnApi(extension, {
cleanup: cleanupSpy,
setupPreviewAction: actionSpy,
});
instance.togglePreview(); instance.togglePreview();
}); });
...@@ -153,14 +170,17 @@ describe('Markdown Live Preview Extension for Source Editor', () => { ...@@ -153,14 +170,17 @@ describe('Markdown Live Preview Extension for Source Editor', () => {
}); });
it('disposes the modelChange listener and does not fetch preview on content changes', () => { it('disposes the modelChange listener and does not fetch preview on content changes', () => {
expect(instance.preview.modelChangeListener).toBeDefined(); expect(instance.markdownPreview.modelChangeListener).toBeDefined();
jest.spyOn(instance, 'fetchPreview'); const fetchPreviewSpy = jest.fn();
spyOnApi(extension, {
fetchPreview: fetchPreviewSpy,
});
instance.cleanup(); instance.cleanup();
instance.setValue('Foo Bar'); instance.setValue('Foo Bar');
jest.advanceTimersByTime(EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY); jest.advanceTimersByTime(EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY);
expect(instance.fetchPreview).not.toHaveBeenCalled(); expect(fetchPreviewSpy).not.toHaveBeenCalled();
}); });
it('removes the contextual menu action', () => { it('removes the contextual menu action', () => {
...@@ -172,13 +192,13 @@ describe('Markdown Live Preview Extension for Source Editor', () => { ...@@ -172,13 +192,13 @@ describe('Markdown Live Preview Extension for Source Editor', () => {
}); });
it('toggles the `shown` flag', () => { it('toggles the `shown` flag', () => {
expect(instance.preview.shown).toBe(true); expect(instance.markdownPreview.shown).toBe(true);
instance.cleanup(); instance.cleanup();
expect(instance.preview.shown).toBe(false); expect(instance.markdownPreview.shown).toBe(false);
}); });
it('toggles the panel only if the preview is visible', () => { it('toggles the panel only if the preview is visible', () => {
const { el: previewEl } = instance.preview; const { el: previewEl } = instance.markdownPreview;
const parentEl = previewEl.parentElement; const parentEl = previewEl.parentElement;
expect(previewEl).toBeVisible(); expect(previewEl).toBeVisible();
...@@ -200,7 +220,7 @@ describe('Markdown Live Preview Extension for Source Editor', () => { ...@@ -200,7 +220,7 @@ describe('Markdown Live Preview Extension for Source Editor', () => {
it('toggles the layout only if the preview is visible', () => { it('toggles the layout only if the preview is visible', () => {
const { width } = instance.getLayoutInfo(); const { width } = instance.getLayoutInfo();
expect(instance.preview.shown).toBe(true); expect(instance.markdownPreview.shown).toBe(true);
instance.cleanup(); instance.cleanup();
...@@ -234,13 +254,13 @@ describe('Markdown Live Preview Extension for Source Editor', () => { ...@@ -234,13 +254,13 @@ describe('Markdown Live Preview Extension for Source Editor', () => {
}); });
it('puts the fetched content into the preview DOM element', async () => { it('puts the fetched content into the preview DOM element', async () => {
instance.preview.el = editorEl.parentElement; instance.markdownPreview.el = editorEl.parentElement;
await fetchPreview(); await fetchPreview();
expect(instance.preview.el.innerHTML).toEqual(responseData); expect(instance.markdownPreview.el.innerHTML).toEqual(responseData);
}); });
it('applies syntax highlighting to the preview content', async () => { it('applies syntax highlighting to the preview content', async () => {
instance.preview.el = editorEl.parentElement; instance.markdownPreview.el = editorEl.parentElement;
await fetchPreview(); await fetchPreview();
expect(syntaxHighlight).toHaveBeenCalled(); expect(syntaxHighlight).toHaveBeenCalled();
}); });
...@@ -266,14 +286,17 @@ describe('Markdown Live Preview Extension for Source Editor', () => { ...@@ -266,14 +286,17 @@ describe('Markdown Live Preview Extension for Source Editor', () => {
}); });
it('toggles preview when the action is triggered', () => { it('toggles preview when the action is triggered', () => {
jest.spyOn(instance, 'togglePreview').mockImplementation(); const togglePreviewSpy = jest.fn();
spyOnApi(extension, {
togglePreview: togglePreviewSpy,
});
expect(instance.togglePreview).not.toHaveBeenCalled(); expect(togglePreviewSpy).not.toHaveBeenCalled();
const action = instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID); const action = instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID);
action.run(); action.run();
expect(instance.togglePreview).toHaveBeenCalled(); expect(togglePreviewSpy).toHaveBeenCalled();
}); });
}); });
...@@ -283,39 +306,39 @@ describe('Markdown Live Preview Extension for Source Editor', () => { ...@@ -283,39 +306,39 @@ describe('Markdown Live Preview Extension for Source Editor', () => {
}); });
it('toggles preview flag on instance', () => { it('toggles preview flag on instance', () => {
expect(instance.preview.shown).toBe(false); expect(instance.markdownPreview.shown).toBe(false);
instance.togglePreview(); instance.togglePreview();
expect(instance.preview.shown).toBe(true); expect(instance.markdownPreview.shown).toBe(true);
instance.togglePreview(); instance.togglePreview();
expect(instance.preview.shown).toBe(false); expect(instance.markdownPreview.shown).toBe(false);
}); });
describe('panel DOM element set up', () => { describe('panel DOM element set up', () => {
it('sets up an element to contain the preview and stores it on instance', () => { it('sets up an element to contain the preview and stores it on instance', () => {
expect(instance.preview.el).toBeUndefined(); expect(instance.markdownPreview.el).toBeUndefined();
instance.togglePreview(); instance.togglePreview();
expect(instance.preview.el).toBeDefined(); expect(instance.markdownPreview.el).toBeDefined();
expect(instance.preview.el.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS)).toBe( expect(
true, instance.markdownPreview.el.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS),
); ).toBe(true);
}); });
it('re-uses existing preview DOM element on repeated calls', () => { it('re-uses existing preview DOM element on repeated calls', () => {
instance.togglePreview(); instance.togglePreview();
const origPreviewEl = instance.preview.el; const origPreviewEl = instance.markdownPreview.el;
instance.togglePreview(); instance.togglePreview();
expect(instance.preview.el).toBe(origPreviewEl); expect(instance.markdownPreview.el).toBe(origPreviewEl);
}); });
it('hides the preview DOM element by default', () => { it('hides the preview DOM element by default', () => {
panelSpy.mockImplementation(); panelSpy.mockImplementation();
instance.togglePreview(); instance.togglePreview();
expect(instance.preview.el.style.display).toBe('none'); expect(instance.markdownPreview.el.style.display).toBe('none');
}); });
}); });
...@@ -350,9 +373,9 @@ describe('Markdown Live Preview Extension for Source Editor', () => { ...@@ -350,9 +373,9 @@ describe('Markdown Live Preview Extension for Source Editor', () => {
it('toggles visibility of the preview DOM element', async () => { it('toggles visibility of the preview DOM element', async () => {
await togglePreview(); await togglePreview();
expect(instance.preview.el.style.display).toBe('block'); expect(instance.markdownPreview.el.style.display).toBe('block');
await togglePreview(); await togglePreview();
expect(instance.preview.el.style.display).toBe('none'); expect(instance.markdownPreview.el.style.display).toBe('none');
}); });
describe('hidden preview DOM element', () => { describe('hidden preview DOM element', () => {
...@@ -367,9 +390,9 @@ describe('Markdown Live Preview Extension for Source Editor', () => { ...@@ -367,9 +390,9 @@ describe('Markdown Live Preview Extension for Source Editor', () => {
}); });
it('stores disposable listener for model changes', async () => { it('stores disposable listener for model changes', async () => {
expect(instance.preview.modelChangeListener).toBeUndefined(); expect(instance.markdownPreview.modelChangeListener).toBeUndefined();
await togglePreview(); await togglePreview();
expect(instance.preview.modelChangeListener).toBeDefined(); expect(instance.markdownPreview.modelChangeListener).toBeDefined();
}); });
}); });
...@@ -386,7 +409,7 @@ describe('Markdown Live Preview Extension for Source Editor', () => { ...@@ -386,7 +409,7 @@ describe('Markdown Live Preview Extension for Source Editor', () => {
it('disposes the model change event listener', () => { it('disposes the model change event listener', () => {
const disposeSpy = jest.fn(); const disposeSpy = jest.fn();
instance.preview.modelChangeListener = { instance.markdownPreview.modelChangeListener = {
dispose: disposeSpy, dispose: disposeSpy,
}; };
instance.togglePreview(); instance.togglePreview();
......
This diff is collapsed.
...@@ -2,6 +2,10 @@ import { Document } from 'yaml'; ...@@ -2,6 +2,10 @@ import { Document } from 'yaml';
import SourceEditor from '~/editor/source_editor'; import SourceEditor from '~/editor/source_editor';
import { YamlEditorExtension } from '~/editor/extensions/source_editor_yaml_ext'; import { YamlEditorExtension } from '~/editor/extensions/source_editor_yaml_ext';
import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base'; import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base';
import { spyOnApi } from 'jest/editor/helpers';
let baseExtension;
let yamlExtension;
const getEditorInstance = (editorInstanceOptions = {}) => { const getEditorInstance = (editorInstanceOptions = {}) => {
setFixtures('<div id="editor"></div>'); setFixtures('<div id="editor"></div>');
...@@ -16,7 +20,10 @@ const getEditorInstance = (editorInstanceOptions = {}) => { ...@@ -16,7 +20,10 @@ const getEditorInstance = (editorInstanceOptions = {}) => {
const getEditorInstanceWithExtension = (extensionOptions = {}, editorInstanceOptions = {}) => { const getEditorInstanceWithExtension = (extensionOptions = {}, editorInstanceOptions = {}) => {
setFixtures('<div id="editor"></div>'); setFixtures('<div id="editor"></div>');
const instance = getEditorInstance(editorInstanceOptions); const instance = getEditorInstance(editorInstanceOptions);
instance.use(new YamlEditorExtension({ instance, ...extensionOptions })); [baseExtension, yamlExtension] = instance.use([
{ definition: SourceEditorExtension },
{ definition: YamlEditorExtension, setupOptions: extensionOptions },
]);
// Remove the below once // Remove the below once
// https://gitlab.com/gitlab-org/gitlab/-/issues/325992 is resolved // https://gitlab.com/gitlab-org/gitlab/-/issues/325992 is resolved
...@@ -29,19 +36,16 @@ const getEditorInstanceWithExtension = (extensionOptions = {}, editorInstanceOpt ...@@ -29,19 +36,16 @@ const getEditorInstanceWithExtension = (extensionOptions = {}, editorInstanceOpt
describe('YamlCreatorExtension', () => { describe('YamlCreatorExtension', () => {
describe('constructor', () => { describe('constructor', () => {
it('saves constructor options', () => { it('saves setupOptions options on the extension, but does not expose those to instance', () => {
const highlightPath = 'foo';
const instance = getEditorInstanceWithExtension({ const instance = getEditorInstanceWithExtension({
highlightPath: 'foo', highlightPath,
enableComments: true, enableComments: true,
}); });
expect(instance).toEqual( expect(yamlExtension.obj.highlightPath).toBe(highlightPath);
expect.objectContaining({ expect(yamlExtension.obj.enableComments).toBe(true);
options: expect.objectContaining({ expect(instance.highlightPath).toBeUndefined();
highlightPath: 'foo', expect(instance.enableComments).toBeUndefined();
enableComments: true,
}),
}),
);
}); });
it('dumps values loaded with the model constructor options', () => { it('dumps values loaded with the model constructor options', () => {
...@@ -55,7 +59,7 @@ describe('YamlCreatorExtension', () => { ...@@ -55,7 +59,7 @@ describe('YamlCreatorExtension', () => {
it('registers the onUpdate() function', () => { it('registers the onUpdate() function', () => {
const instance = getEditorInstance(); const instance = getEditorInstance();
const onDidChangeModelContent = jest.spyOn(instance, 'onDidChangeModelContent'); const onDidChangeModelContent = jest.spyOn(instance, 'onDidChangeModelContent');
instance.use(new YamlEditorExtension({ instance })); instance.use({ definition: YamlEditorExtension });
expect(onDidChangeModelContent).toHaveBeenCalledWith(expect.any(Function)); expect(onDidChangeModelContent).toHaveBeenCalledWith(expect.any(Function));
}); });
...@@ -82,21 +86,21 @@ describe('YamlCreatorExtension', () => { ...@@ -82,21 +86,21 @@ describe('YamlCreatorExtension', () => {
it('should call transformComments if enableComments is true', () => { it('should call transformComments if enableComments is true', () => {
const instance = getEditorInstanceWithExtension({ enableComments: true }); const instance = getEditorInstanceWithExtension({ enableComments: true });
const transformComments = jest.spyOn(YamlEditorExtension, 'transformComments'); const transformComments = jest.spyOn(YamlEditorExtension, 'transformComments');
YamlEditorExtension.initFromModel(instance, model); instance.initFromModel(model);
expect(transformComments).toHaveBeenCalled(); expect(transformComments).toHaveBeenCalled();
}); });
it('should not call transformComments if enableComments is false', () => { it('should not call transformComments if enableComments is false', () => {
const instance = getEditorInstanceWithExtension({ enableComments: false }); const instance = getEditorInstanceWithExtension({ enableComments: false });
const transformComments = jest.spyOn(YamlEditorExtension, 'transformComments'); const transformComments = jest.spyOn(YamlEditorExtension, 'transformComments');
YamlEditorExtension.initFromModel(instance, model); instance.initFromModel(model);
expect(transformComments).not.toHaveBeenCalled(); expect(transformComments).not.toHaveBeenCalled();
}); });
it('should call setValue with the stringified model', () => { it('should call setValue with the stringified model', () => {
const instance = getEditorInstanceWithExtension(); const instance = getEditorInstanceWithExtension();
const setValue = jest.spyOn(instance, 'setValue'); const setValue = jest.spyOn(instance, 'setValue');
YamlEditorExtension.initFromModel(instance, model); instance.initFromModel(model);
expect(setValue).toHaveBeenCalledWith(doc.toString()); expect(setValue).toHaveBeenCalledWith(doc.toString());
}); });
}); });
...@@ -240,26 +244,35 @@ foo: ...@@ -240,26 +244,35 @@ foo:
it("should call setValue with the stringified doc if the editor's value is empty", () => { it("should call setValue with the stringified doc if the editor's value is empty", () => {
const instance = getEditorInstanceWithExtension(); const instance = getEditorInstanceWithExtension();
const setValue = jest.spyOn(instance, 'setValue'); const setValue = jest.spyOn(instance, 'setValue');
const updateValue = jest.spyOn(instance, 'updateValue'); const updateValueSpy = jest.fn();
spyOnApi(yamlExtension, {
updateValue: updateValueSpy,
});
instance.setDoc(doc); instance.setDoc(doc);
expect(setValue).toHaveBeenCalledWith(doc.toString()); expect(setValue).toHaveBeenCalledWith(doc.toString());
expect(updateValue).not.toHaveBeenCalled(); expect(updateValueSpy).not.toHaveBeenCalled();
}); });
it("should call updateValue with the stringified doc if the editor's value is not empty", () => { it("should call updateValue with the stringified doc if the editor's value is not empty", () => {
const instance = getEditorInstanceWithExtension({}, { value: 'asjkdhkasjdh' }); const instance = getEditorInstanceWithExtension({}, { value: 'asjkdhkasjdh' });
const setValue = jest.spyOn(instance, 'setValue'); const setValue = jest.spyOn(instance, 'setValue');
const updateValue = jest.spyOn(instance, 'updateValue'); const updateValueSpy = jest.fn();
spyOnApi(yamlExtension, {
updateValue: updateValueSpy,
});
instance.setDoc(doc); instance.setDoc(doc);
expect(setValue).not.toHaveBeenCalled(); expect(setValue).not.toHaveBeenCalled();
expect(updateValue).toHaveBeenCalledWith(doc.toString()); expect(updateValueSpy).toHaveBeenCalledWith(instance, doc.toString());
}); });
it('should trigger the onUpdate method', () => { it('should trigger the onUpdate method', () => {
const instance = getEditorInstanceWithExtension(); const instance = getEditorInstanceWithExtension();
const onUpdate = jest.spyOn(instance, 'onUpdate'); const onUpdateSpy = jest.fn();
spyOnApi(yamlExtension, {
onUpdate: onUpdateSpy,
});
instance.setDoc(doc); instance.setDoc(doc);
expect(onUpdate).toHaveBeenCalled(); expect(onUpdateSpy).toHaveBeenCalled();
}); });
}); });
...@@ -320,9 +333,12 @@ foo: ...@@ -320,9 +333,12 @@ foo:
it('calls highlight', () => { it('calls highlight', () => {
const highlightPath = 'foo'; const highlightPath = 'foo';
const instance = getEditorInstanceWithExtension({ highlightPath }); const instance = getEditorInstanceWithExtension({ highlightPath });
instance.highlight = jest.fn(); // Here we do not spy on the public API method of the extension, but rather
// the public method of the extension's instance.
// This is required based on how `onUpdate` works
const highlightSpy = jest.spyOn(yamlExtension.obj, 'highlight');
instance.onUpdate(); instance.onUpdate();
expect(instance.highlight).toHaveBeenCalledWith(highlightPath); expect(highlightSpy).toHaveBeenCalledWith(instance, highlightPath);
}); });
}); });
...@@ -350,8 +366,12 @@ foo: ...@@ -350,8 +366,12 @@ foo:
beforeEach(() => { beforeEach(() => {
instance = getEditorInstanceWithExtension({ highlightPath: highlightPathOnSetup }, { value }); instance = getEditorInstanceWithExtension({ highlightPath: highlightPathOnSetup }, { value });
highlightLinesSpy = jest.spyOn(SourceEditorExtension, 'highlightLines'); highlightLinesSpy = jest.fn();
removeHighlightsSpy = jest.spyOn(SourceEditorExtension, 'removeHighlights'); removeHighlightsSpy = jest.fn();
spyOnApi(baseExtension, {
highlightLines: highlightLinesSpy,
removeHighlights: removeHighlightsSpy,
});
}); });
afterEach(() => { afterEach(() => {
...@@ -361,7 +381,7 @@ foo: ...@@ -361,7 +381,7 @@ foo:
it('saves the highlighted path in highlightPath', () => { it('saves the highlighted path in highlightPath', () => {
const path = 'foo.bar'; const path = 'foo.bar';
instance.highlight(path); instance.highlight(path);
expect(instance.options.highlightPath).toEqual(path); expect(yamlExtension.obj.highlightPath).toEqual(path);
}); });
it('calls highlightLines with a number of lines', () => { it('calls highlightLines with a number of lines', () => {
...@@ -374,14 +394,14 @@ foo: ...@@ -374,14 +394,14 @@ foo:
instance.highlight(null); instance.highlight(null);
expect(removeHighlightsSpy).toHaveBeenCalledWith(instance); expect(removeHighlightsSpy).toHaveBeenCalledWith(instance);
expect(highlightLinesSpy).not.toHaveBeenCalled(); expect(highlightLinesSpy).not.toHaveBeenCalled();
expect(instance.options.highlightPath).toBeNull(); expect(yamlExtension.obj.highlightPath).toBeNull();
}); });
it('throws an error if path is invalid and does not change the highlighted path', () => { it('throws an error if path is invalid and does not change the highlighted path', () => {
expect(() => instance.highlight('invalidPath[0]')).toThrow( expect(() => instance.highlight('invalidPath[0]')).toThrow(
'The node invalidPath[0] could not be found inside the document.', 'The node invalidPath[0] could not be found inside the document.',
); );
expect(instance.options.highlightPath).toEqual(highlightPathOnSetup); expect(yamlExtension.obj.highlightPath).toEqual(highlightPathOnSetup);
expect(highlightLinesSpy).not.toHaveBeenCalled(); expect(highlightLinesSpy).not.toHaveBeenCalled();
expect(removeHighlightsSpy).not.toHaveBeenCalled(); expect(removeHighlightsSpy).not.toHaveBeenCalled();
}); });
......
...@@ -9,7 +9,7 @@ import waitUsingRealTimer from 'helpers/wait_using_real_timer'; ...@@ -9,7 +9,7 @@ import waitUsingRealTimer from 'helpers/wait_using_real_timer';
import { exampleConfigs, exampleFiles } from 'jest/ide/lib/editorconfig/mock_data'; import { exampleConfigs, exampleFiles } from 'jest/ide/lib/editorconfig/mock_data';
import { EDITOR_CODE_INSTANCE_FN, EDITOR_DIFF_INSTANCE_FN } from '~/editor/constants'; import { EDITOR_CODE_INSTANCE_FN, EDITOR_DIFF_INSTANCE_FN } from '~/editor/constants';
import { EditorMarkdownExtension } from '~/editor/extensions/source_editor_markdown_ext'; import { EditorMarkdownExtension } from '~/editor/extensions/source_editor_markdown_ext';
import { EditorWebIdeExtension } from '~/editor/extensions/source_editor_webide_ext'; import { EditorMarkdownPreviewExtension } from '~/editor/extensions/source_editor_markdown_livepreview_ext';
import SourceEditor from '~/editor/source_editor'; import SourceEditor from '~/editor/source_editor';
import RepoEditor from '~/ide/components/repo_editor.vue'; import RepoEditor from '~/ide/components/repo_editor.vue';
import { import {
...@@ -23,6 +23,8 @@ import service from '~/ide/services'; ...@@ -23,6 +23,8 @@ import service from '~/ide/services';
import { createStoreOptions } from '~/ide/stores'; import { createStoreOptions } from '~/ide/stores';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue'; import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue';
import SourceEditorInstance from '~/editor/source_editor_instance';
import { spyOnApi } from 'jest/editor/helpers';
import { file } from '../helpers'; import { file } from '../helpers';
const PREVIEW_MARKDOWN_PATH = '/foo/bar/preview_markdown'; const PREVIEW_MARKDOWN_PATH = '/foo/bar/preview_markdown';
...@@ -101,6 +103,7 @@ describe('RepoEditor', () => { ...@@ -101,6 +103,7 @@ describe('RepoEditor', () => {
let createDiffInstanceSpy; let createDiffInstanceSpy;
let createModelSpy; let createModelSpy;
let applyExtensionSpy; let applyExtensionSpy;
let extensionsStore;
const waitForEditorSetup = () => const waitForEditorSetup = () =>
new Promise((resolve) => { new Promise((resolve) => {
...@@ -120,6 +123,7 @@ describe('RepoEditor', () => { ...@@ -120,6 +123,7 @@ describe('RepoEditor', () => {
}); });
await waitForPromises(); await waitForPromises();
vm = wrapper.vm; vm = wrapper.vm;
extensionsStore = wrapper.vm.globalEditor.extensionsStore;
jest.spyOn(vm, 'getFileData').mockResolvedValue(); jest.spyOn(vm, 'getFileData').mockResolvedValue();
jest.spyOn(vm, 'getRawFileData').mockResolvedValue(); jest.spyOn(vm, 'getRawFileData').mockResolvedValue();
}; };
...@@ -127,28 +131,12 @@ describe('RepoEditor', () => { ...@@ -127,28 +131,12 @@ describe('RepoEditor', () => {
const findEditor = () => wrapper.find('[data-testid="editor-container"]'); const findEditor = () => wrapper.find('[data-testid="editor-container"]');
const findTabs = () => wrapper.findAll('.ide-mode-tabs .nav-links li'); const findTabs = () => wrapper.findAll('.ide-mode-tabs .nav-links li');
const findPreviewTab = () => wrapper.find('[data-testid="preview-tab"]'); const findPreviewTab = () => wrapper.find('[data-testid="preview-tab"]');
const expectEditorMarkdownExtension = (shouldHaveExtension) => {
if (shouldHaveExtension) {
expect(applyExtensionSpy).toHaveBeenCalledWith(
wrapper.vm.editor,
expect.any(EditorMarkdownExtension),
);
// TODO: spying on extensions causes Jest to blow up, so we have to assert on
// the public property the extension adds, as opposed to the args passed to the ctor
expect(wrapper.vm.editor.previewMarkdownPath).toBe(PREVIEW_MARKDOWN_PATH);
} else {
expect(applyExtensionSpy).not.toHaveBeenCalledWith(
wrapper.vm.editor,
expect.any(EditorMarkdownExtension),
);
}
};
beforeEach(() => { beforeEach(() => {
createInstanceSpy = jest.spyOn(SourceEditor.prototype, EDITOR_CODE_INSTANCE_FN); createInstanceSpy = jest.spyOn(SourceEditor.prototype, EDITOR_CODE_INSTANCE_FN);
createDiffInstanceSpy = jest.spyOn(SourceEditor.prototype, EDITOR_DIFF_INSTANCE_FN); createDiffInstanceSpy = jest.spyOn(SourceEditor.prototype, EDITOR_DIFF_INSTANCE_FN);
createModelSpy = jest.spyOn(monacoEditor, 'createModel'); createModelSpy = jest.spyOn(monacoEditor, 'createModel');
applyExtensionSpy = jest.spyOn(SourceEditor, 'instanceApplyExtension'); applyExtensionSpy = jest.spyOn(SourceEditorInstance.prototype, 'use');
jest.spyOn(service, 'getFileData').mockResolvedValue(); jest.spyOn(service, 'getFileData').mockResolvedValue();
jest.spyOn(service, 'getRawFileData').mockResolvedValue(); jest.spyOn(service, 'getRawFileData').mockResolvedValue();
}); });
...@@ -275,14 +263,13 @@ describe('RepoEditor', () => { ...@@ -275,14 +263,13 @@ describe('RepoEditor', () => {
); );
it('installs the WebIDE extension', async () => { it('installs the WebIDE extension', async () => {
const extensionSpy = jest.spyOn(SourceEditor, 'instanceApplyExtension');
await createComponent(); await createComponent();
expect(extensionSpy).toHaveBeenCalled(); expect(applyExtensionSpy).toHaveBeenCalled();
Reflect.ownKeys(EditorWebIdeExtension.prototype) const ideExtensionApi = extensionsStore.get('EditorWebIde').api;
.filter((fn) => fn !== 'constructor') Reflect.ownKeys(ideExtensionApi).forEach((fn) => {
.forEach((fn) => { expect(vm.editor[fn]).toBeDefined();
expect(vm.editor[fn]).toBe(EditorWebIdeExtension.prototype[fn]); expect(vm.editor.methods[fn]).toBe('EditorWebIde');
}); });
}); });
it.each` it.each`
...@@ -301,7 +288,20 @@ describe('RepoEditor', () => { ...@@ -301,7 +288,20 @@ describe('RepoEditor', () => {
async ({ activeFile, viewer, shouldHaveMarkdownExtension } = {}) => { async ({ activeFile, viewer, shouldHaveMarkdownExtension } = {}) => {
await createComponent({ state: { viewer }, activeFile }); await createComponent({ state: { viewer }, activeFile });
expectEditorMarkdownExtension(shouldHaveMarkdownExtension); if (shouldHaveMarkdownExtension) {
expect(applyExtensionSpy).toHaveBeenCalledWith({
definition: EditorMarkdownPreviewExtension,
setupOptions: { previewMarkdownPath: PREVIEW_MARKDOWN_PATH },
});
// TODO: spying on extensions causes Jest to blow up, so we have to assert on
// the public property the extension adds, as opposed to the args passed to the ctor
expect(wrapper.vm.editor.markdownPreview.path).toBe(PREVIEW_MARKDOWN_PATH);
} else {
expect(applyExtensionSpy).not.toHaveBeenCalledWith(
wrapper.vm.editor,
expect.any(EditorMarkdownExtension),
);
}
}, },
); );
}); });
...@@ -329,18 +329,6 @@ describe('RepoEditor', () => { ...@@ -329,18 +329,6 @@ describe('RepoEditor', () => {
expect(vm.model).toBe(existingModel); expect(vm.model).toBe(existingModel);
}); });
it('adds callback methods', () => {
jest.spyOn(vm.editor, 'onPositionChange');
jest.spyOn(vm.model, 'onChange');
jest.spyOn(vm.model, 'updateOptions');
vm.setupEditor();
expect(vm.editor.onPositionChange).toHaveBeenCalledTimes(1);
expect(vm.model.onChange).toHaveBeenCalledTimes(1);
expect(vm.model.updateOptions).toHaveBeenCalledWith(vm.rules);
});
it('updates state with the value of the model', () => { it('updates state with the value of the model', () => {
const newContent = 'As Gregor Samsa\n awoke one morning\n'; const newContent = 'As Gregor Samsa\n awoke one morning\n';
vm.model.setValue(newContent); vm.model.setValue(newContent);
...@@ -366,53 +354,48 @@ describe('RepoEditor', () => { ...@@ -366,53 +354,48 @@ describe('RepoEditor', () => {
describe('editor updateDimensions', () => { describe('editor updateDimensions', () => {
let updateDimensionsSpy; let updateDimensionsSpy;
let updateDiffViewSpy;
beforeEach(async () => { beforeEach(async () => {
await createComponent(); await createComponent();
updateDimensionsSpy = jest.spyOn(vm.editor, 'updateDimensions'); const ext = extensionsStore.get('EditorWebIde');
updateDiffViewSpy = jest.spyOn(vm.editor, 'updateDiffView').mockImplementation(); updateDimensionsSpy = jest.fn();
spyOnApi(ext, {
updateDimensions: updateDimensionsSpy,
});
}); });
it('calls updateDimensions only when panelResizing is false', async () => { it('calls updateDimensions only when panelResizing is false', async () => {
expect(updateDimensionsSpy).not.toHaveBeenCalled(); expect(updateDimensionsSpy).not.toHaveBeenCalled();
expect(updateDiffViewSpy).not.toHaveBeenCalled();
expect(vm.$store.state.panelResizing).toBe(false); // default value expect(vm.$store.state.panelResizing).toBe(false); // default value
vm.$store.state.panelResizing = true; vm.$store.state.panelResizing = true;
await vm.$nextTick(); await vm.$nextTick();
expect(updateDimensionsSpy).not.toHaveBeenCalled(); expect(updateDimensionsSpy).not.toHaveBeenCalled();
expect(updateDiffViewSpy).not.toHaveBeenCalled();
vm.$store.state.panelResizing = false; vm.$store.state.panelResizing = false;
await vm.$nextTick(); await vm.$nextTick();
expect(updateDimensionsSpy).toHaveBeenCalledTimes(1); expect(updateDimensionsSpy).toHaveBeenCalledTimes(1);
expect(updateDiffViewSpy).toHaveBeenCalledTimes(1);
vm.$store.state.panelResizing = true; vm.$store.state.panelResizing = true;
await vm.$nextTick(); await vm.$nextTick();
expect(updateDimensionsSpy).toHaveBeenCalledTimes(1); expect(updateDimensionsSpy).toHaveBeenCalledTimes(1);
expect(updateDiffViewSpy).toHaveBeenCalledTimes(1);
}); });
it('calls updateDimensions when rightPane is toggled', async () => { it('calls updateDimensions when rightPane is toggled', async () => {
expect(updateDimensionsSpy).not.toHaveBeenCalled(); expect(updateDimensionsSpy).not.toHaveBeenCalled();
expect(updateDiffViewSpy).not.toHaveBeenCalled();
expect(vm.$store.state.rightPane.isOpen).toBe(false); // default value expect(vm.$store.state.rightPane.isOpen).toBe(false); // default value
vm.$store.state.rightPane.isOpen = true; vm.$store.state.rightPane.isOpen = true;
await vm.$nextTick(); await vm.$nextTick();
expect(updateDimensionsSpy).toHaveBeenCalledTimes(1); expect(updateDimensionsSpy).toHaveBeenCalledTimes(1);
expect(updateDiffViewSpy).toHaveBeenCalledTimes(1);
vm.$store.state.rightPane.isOpen = false; vm.$store.state.rightPane.isOpen = false;
await vm.$nextTick(); await vm.$nextTick();
expect(updateDimensionsSpy).toHaveBeenCalledTimes(2); expect(updateDimensionsSpy).toHaveBeenCalledTimes(2);
expect(updateDiffViewSpy).toHaveBeenCalledTimes(2);
}); });
}); });
...@@ -447,7 +430,11 @@ describe('RepoEditor', () => { ...@@ -447,7 +430,11 @@ describe('RepoEditor', () => {
activeFile: dummyFile.markdown, activeFile: dummyFile.markdown,
}); });
updateDimensionsSpy = jest.spyOn(vm.editor, 'updateDimensions'); const ext = extensionsStore.get('EditorWebIde');
updateDimensionsSpy = jest.fn();
spyOnApi(ext, {
updateDimensions: updateDimensionsSpy,
});
changeViewMode(FILE_VIEW_MODE_PREVIEW); changeViewMode(FILE_VIEW_MODE_PREVIEW);
await vm.$nextTick(); await vm.$nextTick();
......
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { EDITOR_READY_EVENT } from '~/editor/constants'; import { EDITOR_READY_EVENT } from '~/editor/constants';
import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base';
import TextEditor from '~/pipeline_editor/components/editor/text_editor.vue'; import TextEditor from '~/pipeline_editor/components/editor/text_editor.vue';
import { import {
mockCiConfigPath, mockCiConfigPath,
...@@ -59,10 +58,6 @@ describe('Pipeline Editor | Text editor component', () => { ...@@ -59,10 +58,6 @@ describe('Pipeline Editor | Text editor component', () => {
const findEditor = () => wrapper.findComponent(MockSourceEditor); const findEditor = () => wrapper.findComponent(MockSourceEditor);
beforeEach(() => {
SourceEditorExtension.deferRerender = jest.fn();
});
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
......
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