Commit 042b8730 authored by Nathan Friend's avatar Nathan Friend

Merge branch '217801-step-1-update-editor-lite' into 'master'

Step 1 - Update editor lite and component with global id

See merge request gitlab-org/gitlab!39232
parents d8b5d166 783795bb
...@@ -20,6 +20,13 @@ export default { ...@@ -20,6 +20,13 @@ export default {
required: false, required: false,
default: '', default: '',
}, },
// This is used to help uniquely create a monaco model
// even if two blob's share a file path.
fileGlobalId: {
type: String,
required: false,
default: '',
},
}, },
data() { data() {
return { return {
...@@ -36,7 +43,11 @@ export default { ...@@ -36,7 +43,11 @@ export default {
el: this.$refs.editor, el: this.$refs.editor,
blobPath: this.fileName, blobPath: this.fileName,
blobContent: this.value, blobContent: this.value,
blobGlobalId: this.fileGlobalId,
}); });
this.editor.onChangeContent(debounce(this.onFileChange.bind(this), 250));
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
if (!performance.getEntriesByName(SNIPPET_MARK_BLOBS_CONTENT).length) { if (!performance.getEntriesByName(SNIPPET_MARK_BLOBS_CONTENT).length) {
performance.mark(SNIPPET_MARK_BLOBS_CONTENT); performance.mark(SNIPPET_MARK_BLOBS_CONTENT);
...@@ -45,16 +56,19 @@ export default { ...@@ -45,16 +56,19 @@ export default {
} }
}); });
}, },
beforeDestroy() {
this.editor.dispose();
},
methods: { methods: {
triggerFileChange: debounce(function debouncedFileChange() { onFileChange() {
this.$emit('input', this.editor.getValue()); this.$emit('input', this.editor.getValue());
}, 250), },
}, },
}; };
</script> </script>
<template> <template>
<div class="file-content code"> <div class="file-content code">
<div id="editor" ref="editor" data-editor-loading @keyup="triggerFileChange"> <div id="editor" ref="editor" data-editor-loading>
<pre class="editor-loading-content">{{ value }}</pre> <pre class="editor-loading-content">{{ value }}</pre>
</div> </div>
</div> </div>
......
import Editor from '~/editor/editor_lite'; import Editor from '~/editor/editor_lite';
export function initEditorLite({ el, blobPath, blobContent }) { export function initEditorLite({ el, ...args }) {
if (!el) { if (!el) {
throw new Error(`"el" parameter is required to initialize Editor`); throw new Error(`"el" parameter is required to initialize Editor`);
} }
const editor = new Editor(); const editor = new Editor();
editor.createInstance({ editor.createInstance({
el, el,
blobPath, ...args,
blobContent,
}); });
return editor; return editor;
......
...@@ -3,6 +3,7 @@ import { DEFAULT_THEME, themes } from '~/ide/lib/themes'; ...@@ -3,6 +3,7 @@ import { DEFAULT_THEME, themes } from '~/ide/lib/themes';
import languages from '~/ide/lib/languages'; import languages from '~/ide/lib/languages';
import { defaultEditorOptions } from '~/ide/lib/editor_options'; import { defaultEditorOptions } from '~/ide/lib/editor_options';
import { registerLanguages } from '~/ide/utils'; import { registerLanguages } from '~/ide/utils';
import { joinPaths } from '~/lib/utils/url_utility';
import { clearDomElement } from './utils'; import { clearDomElement } from './utils';
export default class Editor { export default class Editor {
...@@ -30,7 +31,16 @@ export default class Editor { ...@@ -30,7 +31,16 @@ export default class Editor {
monacoEditor.setTheme(theme ? themeName : DEFAULT_THEME); monacoEditor.setTheme(theme ? themeName : DEFAULT_THEME);
} }
createInstance({ el = undefined, blobPath = '', blobContent = '' } = {}) { /**
* Creates a monaco instance with the given options.
*
* @param {Object} options Options used to initialize monaco.
* @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.blobContent The content to initialize the monacoEditor.
* @param {string} options.blobGlobalId This is used to help globally identify monaco instances that are created with the same blobPath.
*/
createInstance({ el = undefined, blobPath = '', blobContent = '', blobGlobalId = '' } = {}) {
if (!el) return; if (!el) return;
this.editorEl = el; this.editorEl = el;
this.blobContent = blobContent; this.blobContent = blobContent;
...@@ -38,11 +48,9 @@ export default class Editor { ...@@ -38,11 +48,9 @@ export default class Editor {
clearDomElement(this.editorEl); clearDomElement(this.editorEl);
this.model = monacoEditor.createModel( const uriFilePath = joinPaths('gitlab', blobGlobalId, blobPath);
this.blobContent,
undefined, this.model = monacoEditor.createModel(this.blobContent, undefined, Uri.file(uriFilePath));
new Uri('gitlab', false, this.blobPath),
);
monacoEditor.onDidCreateEditor(this.renderEditor.bind(this)); monacoEditor.onDidCreateEditor(this.renderEditor.bind(this));
...@@ -51,6 +59,11 @@ export default class Editor { ...@@ -51,6 +59,11 @@ export default class Editor {
} }
dispose() { dispose() {
if (this.model) {
this.model.dispose();
this.model = null;
}
return this.instance && this.instance.dispose(); return this.instance && this.instance.dispose();
} }
...@@ -58,6 +71,10 @@ export default class Editor { ...@@ -58,6 +71,10 @@ export default class Editor {
delete this.editorEl.dataset.editorLoading; delete this.editorEl.dataset.editorLoading;
} }
onChangeContent(fn) {
return this.model.onDidChangeContent(fn);
}
updateModelLanguage(path) { updateModelLanguage(path) {
if (path === this.blobPath) return; if (path === this.blobPath) return;
this.blobPath = path; this.blobPath = path;
......
...@@ -101,7 +101,7 @@ export default { ...@@ -101,7 +101,7 @@ export default {
size="lg" size="lg"
class="loading-animation prepend-top-20 append-bottom-20" class="loading-animation prepend-top-20 append-bottom-20"
/> />
<blob-content-edit v-else v-model="content" :file-name="filePath" /> <blob-content-edit v-else v-model="content" :file-global-id="id" :file-name="filePath" />
</div> </div>
</div> </div>
</template> </template>
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import BlobEditContent from '~/blob/components/blob_edit_content.vue'; import BlobEditContent from '~/blob/components/blob_edit_content.vue';
import { initEditorLite } from '~/blob/utils'; import * as utils from '~/blob/utils';
import Editor from '~/editor/editor_lite';
import { nextTick } from 'vue'; import { nextTick } from 'vue';
jest.mock('~/blob/utils', () => ({ jest.mock('~/editor/editor_lite');
initEditorLite: jest.fn(),
}));
describe('Blob Header Editing', () => { describe('Blob Header Editing', () => {
let wrapper; let wrapper;
const value = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.'; const value = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.';
const fileName = 'lorem.txt'; const fileName = 'lorem.txt';
const fileGlobalId = 'snippet_777';
function createComponent(props = {}) { function createComponent(props = {}) {
wrapper = shallowMount(BlobEditContent, { wrapper = shallowMount(BlobEditContent, {
propsData: { propsData: {
value, value,
fileName, fileName,
fileGlobalId,
...props, ...props,
}, },
}); });
} }
beforeEach(() => { beforeEach(() => {
jest.spyOn(utils, 'initEditorLite');
createComponent(); createComponent();
}); });
...@@ -30,6 +33,15 @@ describe('Blob Header Editing', () => { ...@@ -30,6 +33,15 @@ describe('Blob Header Editing', () => {
wrapper.destroy(); wrapper.destroy();
}); });
const triggerChangeContent = val => {
jest.spyOn(Editor.prototype, 'getValue').mockReturnValue(val);
const [cb] = Editor.prototype.onChangeContent.mock.calls[0];
cb();
jest.runOnlyPendingTimers();
};
describe('rendering', () => { describe('rendering', () => {
it('matches the snapshot', () => { it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot(); expect(wrapper.element).toMatchSnapshot();
...@@ -51,18 +63,15 @@ describe('Blob Header Editing', () => { ...@@ -51,18 +63,15 @@ describe('Blob Header Editing', () => {
it('initialises Editor Lite', () => { it('initialises Editor Lite', () => {
const el = wrapper.find({ ref: 'editor' }).element; const el = wrapper.find({ ref: 'editor' }).element;
expect(initEditorLite).toHaveBeenCalledWith({ expect(utils.initEditorLite).toHaveBeenCalledWith({
el, el,
blobPath: fileName, blobPath: fileName,
blobGlobalId: fileGlobalId,
blobContent: value, blobContent: value,
}); });
}); });
it('reacts to the changes in fileName', () => { it('reacts to the changes in fileName', () => {
wrapper.vm.editor = {
updateModelLanguage: jest.fn(),
};
const newFileName = 'ipsum.txt'; const newFileName = 'ipsum.txt';
wrapper.setProps({ wrapper.setProps({
...@@ -70,21 +79,20 @@ describe('Blob Header Editing', () => { ...@@ -70,21 +79,20 @@ describe('Blob Header Editing', () => {
}); });
return nextTick().then(() => { return nextTick().then(() => {
expect(wrapper.vm.editor.updateModelLanguage).toHaveBeenCalledWith(newFileName); expect(Editor.prototype.updateModelLanguage).toHaveBeenCalledWith(newFileName);
}); });
}); });
it('registers callback with editor onChangeContent', () => {
expect(Editor.prototype.onChangeContent).toHaveBeenCalledWith(expect.any(Function));
});
it('emits input event when the blob content is changed', () => { it('emits input event when the blob content is changed', () => {
const editorEl = wrapper.find({ ref: 'editor' }); expect(wrapper.emitted().input).toBeUndefined();
wrapper.vm.editor = {
getValue: jest.fn().mockReturnValue(value),
};
editorEl.trigger('keyup'); triggerChangeContent(value);
return nextTick().then(() => { expect(wrapper.emitted().input).toEqual([[value]]);
expect(wrapper.emitted().input[0]).toEqual([value]);
});
}); });
}); });
}); });
import Editor from '~/editor/editor_lite'; import Editor from '~/editor/editor_lite';
import * as utils from '~/blob/utils'; import * as utils from '~/blob/utils';
const mockCreateMonacoInstance = jest.fn(); jest.mock('~/editor/editor_lite');
jest.mock('~/editor/editor_lite', () => {
return jest.fn().mockImplementation(() => {
return { createInstance: mockCreateMonacoInstance };
});
});
describe('Blob utilities', () => { describe('Blob utilities', () => {
beforeEach(() => {
Editor.mockClear();
});
describe('initEditorLite', () => { describe('initEditorLite', () => {
let editorEl; let editorEl;
const blobPath = 'foo.txt'; const blobPath = 'foo.txt';
const blobContent = 'Foo bar'; const blobContent = 'Foo bar';
const blobGlobalId = 'snippet_777';
beforeEach(() => { beforeEach(() => {
setFixtures('<div id="editor"></div>'); editorEl = document.createElement('div');
editorEl = document.getElementById('editor');
}); });
describe('Monaco editor', () => { describe('Monaco editor', () => {
...@@ -29,25 +20,21 @@ describe('Blob utilities', () => { ...@@ -29,25 +20,21 @@ describe('Blob utilities', () => {
expect(Editor).toHaveBeenCalled(); expect(Editor).toHaveBeenCalled();
}); });
it('creates the instance with the passed parameters', () => { it.each([[{}], [{ blobPath, blobContent, blobGlobalId }]])(
utils.initEditorLite({ el: editorEl }); 'creates the instance with the passed parameters %s',
expect(mockCreateMonacoInstance.mock.calls[0]).toEqual([ extraParams => {
{ const params = {
el: editorEl, el: editorEl,
blobPath: undefined, ...extraParams,
blobContent: undefined, };
},
]); expect(Editor.prototype.createInstance).not.toHaveBeenCalled();
utils.initEditorLite({ el: editorEl, blobPath, blobContent }); utils.initEditorLite(params);
expect(mockCreateMonacoInstance.mock.calls[1]).toEqual([
{ expect(Editor.prototype.createInstance).toHaveBeenCalledWith(params);
el: editorEl, },
blobPath, );
blobContent,
},
]);
});
}); });
}); });
}); });
...@@ -2,13 +2,15 @@ import { editor as monacoEditor, languages as monacoLanguages, Uri } from 'monac ...@@ -2,13 +2,15 @@ import { editor as monacoEditor, languages as monacoLanguages, Uri } from 'monac
import Editor from '~/editor/editor_lite'; import Editor from '~/editor/editor_lite';
import { DEFAULT_THEME, themes } from '~/ide/lib/themes'; import { DEFAULT_THEME, themes } from '~/ide/lib/themes';
const URI_PREFIX = 'gitlab';
describe('Base editor', () => { describe('Base editor', () => {
let editorEl; let editorEl;
let editor; let editor;
const blobContent = 'Foo Bar'; const blobContent = 'Foo Bar';
const blobPath = 'test.md'; const blobPath = 'test.md';
const uri = new Uri('gitlab', false, blobPath); const blobGlobalId = 'snippet_777';
const fakeModel = { foo: 'bar' }; const fakeModel = { foo: 'bar', dispose: jest.fn() };
beforeEach(() => { beforeEach(() => {
setFixtures('<div id="editor" data-editor-loading></div>'); setFixtures('<div id="editor" data-editor-loading></div>');
...@@ -21,6 +23,8 @@ describe('Base editor', () => { ...@@ -21,6 +23,8 @@ describe('Base editor', () => {
editorEl.remove(); editorEl.remove();
}); });
const createUri = (...paths) => Uri.file([URI_PREFIX, ...paths].join('/'));
it('initializes Editor with basic properties', () => { it('initializes Editor with basic properties', () => {
expect(editor).toBeDefined(); expect(editor).toBeDefined();
expect(editor.editorEl).toBe(null); expect(editor.editorEl).toBe(null);
...@@ -65,7 +69,7 @@ describe('Base editor', () => { ...@@ -65,7 +69,7 @@ describe('Base editor', () => {
it('creates model to be supplied to Monaco editor', () => { it('creates model to be supplied to Monaco editor', () => {
editor.createInstance({ el: editorEl, blobPath, blobContent }); editor.createInstance({ el: editorEl, blobPath, blobContent });
expect(modelSpy).toHaveBeenCalledWith(blobContent, undefined, uri); expect(modelSpy).toHaveBeenCalledWith(blobContent, undefined, createUri(blobPath));
expect(setModel).toHaveBeenCalledWith(fakeModel); expect(setModel).toHaveBeenCalledWith(fakeModel);
}); });
...@@ -75,6 +79,16 @@ describe('Base editor', () => { ...@@ -75,6 +79,16 @@ describe('Base editor', () => {
expect(editor.editorEl).not.toBe(null); expect(editor.editorEl).not.toBe(null);
expect(instanceSpy).toHaveBeenCalledWith(editorEl, expect.anything()); expect(instanceSpy).toHaveBeenCalledWith(editorEl, expect.anything());
}); });
it('with blobGlobalId, creates model with id in uri', () => {
editor.createInstance({ el: editorEl, blobPath, blobContent, blobGlobalId });
expect(modelSpy).toHaveBeenCalledWith(
blobContent,
undefined,
createUri(blobGlobalId, blobPath),
);
});
}); });
describe('implementation', () => { describe('implementation', () => {
...@@ -82,10 +96,6 @@ describe('Base editor', () => { ...@@ -82,10 +96,6 @@ describe('Base editor', () => {
editor.createInstance({ el: editorEl, blobPath, blobContent }); editor.createInstance({ el: editorEl, blobPath, blobContent });
}); });
afterEach(() => {
editor.model.dispose();
});
it('correctly proxies value from the model', () => { it('correctly proxies value from the model', () => {
expect(editor.getValue()).toEqual(blobContent); expect(editor.getValue()).toEqual(blobContent);
}); });
...@@ -132,10 +142,6 @@ describe('Base editor', () => { ...@@ -132,10 +142,6 @@ describe('Base editor', () => {
editor.createInstance({ el: editorEl, blobPath, blobContent }); editor.createInstance({ el: editorEl, blobPath, blobContent });
}); });
afterEach(() => {
editor.model.dispose();
});
it('is extensible with the extensions', () => { it('is extensible with the extensions', () => {
expect(editor.foo).toBeUndefined(); expect(editor.foo).toBeUndefined();
......
...@@ -17,6 +17,7 @@ exports[`Snippet Blob Edit component rendering matches the snapshot 1`] = ` ...@@ -17,6 +17,7 @@ exports[`Snippet Blob Edit component rendering matches the snapshot 1`] = `
/> />
<blob-content-edit-stub <blob-content-edit-stub
fileglobalid="0a3d"
filename="lorem.txt" filename="lorem.txt"
value="Lorem ipsum dolor sit amet, consectetur adipiscing elit." value="Lorem ipsum dolor sit amet, consectetur adipiscing elit."
/> />
......
...@@ -51,6 +51,10 @@ describe('Snippet Blob Edit component', () => { ...@@ -51,6 +51,10 @@ describe('Snippet Blob Edit component', () => {
} }
beforeEach(() => { beforeEach(() => {
// This component generates a random id. Soon this will be abstracted away, but for now let's make this deterministic.
// see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/38855
jest.spyOn(Math, 'random').mockReturnValue(0.04);
axiosMock = new AxiosMockAdapter(axios); axiosMock = new AxiosMockAdapter(axios);
createComponent(); createComponent();
}); });
...@@ -68,7 +72,11 @@ describe('Snippet Blob Edit component', () => { ...@@ -68,7 +72,11 @@ describe('Snippet Blob Edit component', () => {
it('renders required components', () => { it('renders required components', () => {
expect(findComponent(BlobHeaderEdit).exists()).toBe(true); expect(findComponent(BlobHeaderEdit).exists()).toBe(true);
expect(findComponent(BlobContentEdit).exists()).toBe(true); expect(findComponent(BlobContentEdit).props()).toEqual({
fileGlobalId: expect.any(String),
fileName: '',
value: '',
});
}); });
it('renders loader if existing blob is supplied but no content is fetched yet', () => { it('renders loader if existing blob is supplied but no content is fetched yet', () => {
......
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