Merge branch '288317-extension-module' into 'master'

Source Editor Extension module

See merge request gitlab-org/gitlab!73797
This commit is contained in:
Vitaly Slobodin 2021-11-10 18:59:29 +00:00
commit 129e61735e
5 changed files with 247 additions and 11 deletions

View file

@ -1,15 +1,15 @@
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { __ } from '~/locale'; import { s__ } from '~/locale';
export const SOURCE_EDITOR_INSTANCE_ERROR_NO_EL = __( export const SOURCE_EDITOR_INSTANCE_ERROR_NO_EL = s__(
'"el" parameter is required for createInstance()', 'SourceEditor|"el" parameter is required for createInstance()',
); );
export const URI_PREFIX = 'gitlab'; export const URI_PREFIX = 'gitlab';
export const CONTENT_UPDATE_DEBOUNCE = DEFAULT_DEBOUNCE_AND_THROTTLE_MS; export const CONTENT_UPDATE_DEBOUNCE = DEFAULT_DEBOUNCE_AND_THROTTLE_MS;
export const ERROR_INSTANCE_REQUIRED_FOR_EXTENSION = __( export const ERROR_INSTANCE_REQUIRED_FOR_EXTENSION = s__(
'Source Editor instance is required to set up an extension.', 'SourceEditor|Source Editor instance is required to set up an extension.',
); );
export const EDITOR_READY_EVENT = 'editor-ready'; export const EDITOR_READY_EVENT = 'editor-ready';
@ -20,6 +20,10 @@ export const EDITOR_TYPE_DIFF = 'vs.editor.IDiffEditor';
export const EDITOR_CODE_INSTANCE_FN = 'createInstance'; export const EDITOR_CODE_INSTANCE_FN = 'createInstance';
export const EDITOR_DIFF_INSTANCE_FN = 'createDiffInstance'; export const EDITOR_DIFF_INSTANCE_FN = 'createDiffInstance';
export const EDITOR_EXTENSION_DEFINITION_ERROR = s__(
'SourceEditor|Extension definition should be either a class or a function',
);
// //
// EXTENSIONS' CONSTANTS // EXTENSIONS' CONSTANTS
// //

View file

@ -0,0 +1,116 @@
// THIS IS AN EXAMPLE
//
// This file contains a basic documented example of the Source Editor extensions'
// API for your convenience. You can copy/paste it into your own file
// and adjust as you see fit
//
export class MyFancyExtension {
/**
* THE LIFE-CYCLE CALLBACKS
*/
/**
* Is called before the extension gets used by an instance,
* Use `onSetup` to setup Monaco directly:
* actions, keystrokes, update options, etc.
* Is called only once before the extension gets registered
*
* @param { Object } [setupOptions] The setupOptions object
* @param { Object } [instance] The Source Editor instance
*/
// eslint-disable-next-line class-methods-use-this,no-unused-vars
onSetup(setupOptions, instance) {}
/**
* The first thing called after the extension is
* registered and used by an instance.
* Is called every time the extension is applied
*
* @param { Object } [instance] The Source Editor instance
*/
// eslint-disable-next-line class-methods-use-this,no-unused-vars
onUse(instance) {}
/**
* Is called before un-using an extension. Can be used for time-critical
* actions like cleanup, reverting visual changes, and other user-facing
* updates.
*
* @param { Object } [instance] The Source Editor instance
*/
// eslint-disable-next-line class-methods-use-this,no-unused-vars
onBeforeUnuse(instance) {}
/**
* Is called right after an extension is removed from an instance (un-used)
* Can be used for non time-critical tasks like cleanup on the Monaco level
* (removing actions, keystrokes, etc.).
* onUnuse() will be executed during the browser's idle period
* (https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback)
*
* @param { Object } [instance] The Source Editor instance
*/
// eslint-disable-next-line class-methods-use-this,no-unused-vars
onUnuse(instance) {}
/**
* The public API of the extension: these are the methods that will be exposed
* to the end user
* @returns {Object}
*/
provides() {
return {
basic: () => {
// The most basic method not depending on anything
// Use: instance.basic();
// eslint-disable-next-line @gitlab/require-i18n-strings
return 'Foo Bar';
},
basicWithProp: () => {
// The methods with access to the props of the extension.
// The props can be either hardcoded (for example in `onSetup`), or
// can be dynamically passed as part of `setupOptions` object when
// using the extension.
// Use: instance.use({ definition: MyFancyExtension, setupOptions: { foo: 'bar' }});
return this.foo;
},
basicWithPropsAsList: (prop1, prop2) => {
// Just a simple method with local props
// The props are passed as usually.
// Use: instance.basicWithPropsAsList(prop1, prop2);
// eslint-disable-next-line @gitlab/require-i18n-strings
return `The prop1 is ${prop1}; the prop2 is ${prop2}`;
},
basicWithInstance: (instance) => {
// The method accessing the instance methods: either own or provided
// by previously-registered extensions
// `instance` is always supplied to all methods in provides() as THE LAST
// argument.
// You don't need to explicitly pass instance to this method:
// Use: instance.basicWithInstance();
// eslint-disable-next-line @gitlab/require-i18n-strings
return `We have access to the whole Instance! ${instance.alpha()}`;
},
advancedWithInstanceAndProps: ({ author, book } = {}, firstname, lastname, instance) => {
// Advanced method where
// { author, book } — are the props passed as an object
// prop1, prop2 — are the props passed as simple list
// instance — is automatically supplied, no need to pass it to
// the method explicitly
// Use: instance.advancedWithInstanceAndProps(
// {
// author: 'Franz Kafka',
// book: 'The Transformation'
// },
// 'Franz',
// 'Kafka'
// );
return `
The author is ${author}; the book is ${book}
The author's name is ${firstname}; the last name is ${lastname}
We have access to the whole Instance! For example, 'instance.alpha()': ${instance.alpha()}`;
},
};
}
}

View file

@ -0,0 +1,17 @@
import { EDITOR_EXTENSION_DEFINITION_ERROR } from './constants';
export default class EditorExtension {
constructor({ definition, setupOptions } = {}) {
if (typeof definition !== 'function') {
throw new Error(EDITOR_EXTENSION_DEFINITION_ERROR);
}
this.name = definition.name; // both class- and fn-based extensions have a name
this.setupOptions = setupOptions;
// eslint-disable-next-line new-cap
this.obj = new definition();
}
get api() {
return this.obj.provides();
}
}

View file

@ -70,9 +70,6 @@ msgstr ""
msgid "\"%{repository_name}\" size (%{repository_size}) is larger than the limit of %{limit}." msgid "\"%{repository_name}\" size (%{repository_size}) is larger than the limit of %{limit}."
msgstr "" msgstr ""
msgid "\"el\" parameter is required for createInstance()"
msgstr ""
msgid "#%{issueIid} (closed)" msgid "#%{issueIid} (closed)"
msgstr "" msgstr ""
@ -32577,9 +32574,6 @@ msgstr ""
msgid "Source Branch" msgid "Source Branch"
msgstr "" msgstr ""
msgid "Source Editor instance is required to set up an extension."
msgstr ""
msgid "Source IP" msgid "Source IP"
msgstr "" msgstr ""
@ -32598,6 +32592,15 @@ msgstr ""
msgid "Source project cannot be found." msgid "Source project cannot be found."
msgstr "" msgstr ""
msgid "SourceEditor|\"el\" parameter is required for createInstance()"
msgstr ""
msgid "SourceEditor|Extension definition should be either a class or a function"
msgstr ""
msgid "SourceEditor|Source Editor instance is required to set up an extension."
msgstr ""
msgid "Sourcegraph" msgid "Sourcegraph"
msgstr "" msgstr ""

View file

@ -0,0 +1,96 @@
import EditorExtension from '~/editor/source_editor_extension';
import { EDITOR_EXTENSION_DEFINITION_ERROR } from '~/editor/constants';
class MyClassExtension {
// eslint-disable-next-line class-methods-use-this
provides() {
return {
shared: () => 'extension',
classExtMethod: () => 'class own method',
};
}
}
function MyFnExtension() {
return {
fnExtMethod: () => 'fn own method',
provides: () => {
return {
shared: () => 'extension',
};
},
};
}
const MyConstExt = () => {
return {
provides: () => {
return {
shared: () => 'extension',
constExtMethod: () => 'const own method',
};
},
};
};
describe('Editor Extension', () => {
const dummyObj = { foo: 'bar' };
it.each`
definition | setupOptions
${undefined} | ${undefined}
${undefined} | ${{}}
${undefined} | ${dummyObj}
${{}} | ${dummyObj}
${dummyObj} | ${dummyObj}
`(
'throws when definition = $definition and setupOptions = $setupOptions',
({ definition, setupOptions }) => {
const constructExtension = () => new EditorExtension({ definition, setupOptions });
expect(constructExtension).toThrowError(EDITOR_EXTENSION_DEFINITION_ERROR);
},
);
it.each`
definition | setupOptions | expectedName
${MyClassExtension} | ${undefined} | ${'MyClassExtension'}
${MyClassExtension} | ${{}} | ${'MyClassExtension'}
${MyClassExtension} | ${dummyObj} | ${'MyClassExtension'}
${MyFnExtension} | ${undefined} | ${'MyFnExtension'}
${MyFnExtension} | ${{}} | ${'MyFnExtension'}
${MyFnExtension} | ${dummyObj} | ${'MyFnExtension'}
${MyConstExt} | ${undefined} | ${'MyConstExt'}
${MyConstExt} | ${{}} | ${'MyConstExt'}
${MyConstExt} | ${dummyObj} | ${'MyConstExt'}
`(
'correctly creates extension for definition = $definition and setupOptions = $setupOptions',
({ definition, setupOptions, expectedName }) => {
const extension = new EditorExtension({ definition, setupOptions });
// eslint-disable-next-line new-cap
const constructedDefinition = new definition();
expect(extension).toEqual(
expect.objectContaining({
name: expectedName,
setupOptions,
}),
);
expect(extension.obj.constructor.prototype).toBe(constructedDefinition.constructor.prototype);
},
);
describe('api', () => {
it.each`
definition | expectedKeys
${MyClassExtension} | ${['shared', 'classExtMethod']}
${MyFnExtension} | ${['shared']}
${MyConstExt} | ${['shared', 'constExtMethod']}
`('correctly returns API for $definition', ({ definition, expectedKeys }) => {
const extension = new EditorExtension({ definition });
const expectedApi = Object.fromEntries(
expectedKeys.map((key) => [key, expect.any(Function)]),
);
expect(extension.api).toEqual(expect.objectContaining(expectedApi));
});
});
});