diff --git a/app/assets/javascripts/editor/constants.js b/app/assets/javascripts/editor/constants.js index d40d19000fb3..d44bfdfb966e 100644 --- a/app/assets/javascripts/editor/constants.js +++ b/app/assets/javascripts/editor/constants.js @@ -1,15 +1,15 @@ 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 = __( - '"el" parameter is required for createInstance()', +export const SOURCE_EDITOR_INSTANCE_ERROR_NO_EL = s__( + 'SourceEditor|"el" parameter is required for createInstance()', ); export const URI_PREFIX = 'gitlab'; export const CONTENT_UPDATE_DEBOUNCE = DEFAULT_DEBOUNCE_AND_THROTTLE_MS; -export const ERROR_INSTANCE_REQUIRED_FOR_EXTENSION = __( - 'Source Editor instance is required to set up an extension.', +export const ERROR_INSTANCE_REQUIRED_FOR_EXTENSION = s__( + 'SourceEditor|Source Editor instance is required to set up an extension.', ); 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_DIFF_INSTANCE_FN = 'createDiffInstance'; +export const EDITOR_EXTENSION_DEFINITION_ERROR = s__( + 'SourceEditor|Extension definition should be either a class or a function', +); + // // EXTENSIONS' CONSTANTS // diff --git a/app/assets/javascripts/editor/extensions/example_source_editor_extension.js b/app/assets/javascripts/editor/extensions/example_source_editor_extension.js new file mode 100644 index 000000000000..119a2aea9eb2 --- /dev/null +++ b/app/assets/javascripts/editor/extensions/example_source_editor_extension.js @@ -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()}`; + }, + }; + } +} diff --git a/app/assets/javascripts/editor/source_editor_extension.js b/app/assets/javascripts/editor/source_editor_extension.js new file mode 100644 index 000000000000..664bcabcf452 --- /dev/null +++ b/app/assets/javascripts/editor/source_editor_extension.js @@ -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(); + } +} diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 3dd4beae4e18..beb79fd2d3ff 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -70,9 +70,6 @@ msgstr "" msgid "\"%{repository_name}\" size (%{repository_size}) is larger than the limit of %{limit}." msgstr "" -msgid "\"el\" parameter is required for createInstance()" -msgstr "" - msgid "#%{issueIid} (closed)" msgstr "" @@ -32577,9 +32574,6 @@ msgstr "" msgid "Source Branch" msgstr "" -msgid "Source Editor instance is required to set up an extension." -msgstr "" - msgid "Source IP" msgstr "" @@ -32598,6 +32592,15 @@ msgstr "" msgid "Source project cannot be found." 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" msgstr "" diff --git a/spec/frontend/editor/source_editor_extension_spec.js b/spec/frontend/editor/source_editor_extension_spec.js new file mode 100644 index 000000000000..ebeeae7e42f6 --- /dev/null +++ b/spec/frontend/editor/source_editor_extension_spec.js @@ -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)); + }); + }); +});