diff --git a/app/assets/javascripts/discourse/app/components/discovery-topics-list.js b/app/assets/javascripts/discourse/app/components/discovery-topics-list.js index 8e37fad0c3c..7cb37e34bd6 100644 --- a/app/assets/javascripts/discourse/app/components/discovery-topics-list.js +++ b/app/assets/javascripts/discourse/app/components/discovery-topics-list.js @@ -1,12 +1,15 @@ import Component from "@ember/component"; +import { action } from "@ember/object"; import { service } from "@ember/service"; import $ from "jquery"; +import { applyBehaviorTransformer } from "discourse/lib/transformer"; import LoadMore from "discourse/mixins/load-more"; import { observes, on } from "discourse-common/utils/decorators"; export default Component.extend(LoadMore, { classNames: ["contents"], eyelineSelector: ".topic-list-item", + appEvents: service(), documentTitle: service(), @on("didInsertElement") @@ -32,21 +35,28 @@ export default Component.extend(LoadMore, { this.documentTitle.updateContextCount(this.incomingCount); }, - actions: { - loadMore() { - this.documentTitle.updateContextCount(0); - this.model.loadMore().then(({ moreTopicsUrl, newTopics } = {}) => { - if ( - newTopics && - newTopics.length && - this.bulkSelectHelper?.bulkSelectEnabled - ) { - this.bulkSelectHelper.addTopics(newTopics); - } - if (moreTopicsUrl && $(window).height() >= $(document).height()) { - this.send("loadMore"); - } - }); - }, + @action + loadMore() { + applyBehaviorTransformer( + "discovery-topic-list-load-more", + () => { + this.documentTitle.updateContextCount(0); + return this.model + .loadMore() + .then(({ moreTopicsUrl, newTopics } = {}) => { + if ( + newTopics && + newTopics.length && + this.bulkSelectHelper?.bulkSelectEnabled + ) { + this.bulkSelectHelper.addTopics(newTopics); + } + if (moreTopicsUrl && $(window).height() >= $(document).height()) { + this.send("loadMore"); + } + }); + }, + { model: this.model } + ); }, }); diff --git a/app/assets/javascripts/discourse/app/components/discovery/topics.hbs b/app/assets/javascripts/discourse/app/components/discovery/topics.hbs index f65557a6879..0a79bb7e9e9 100644 --- a/app/assets/javascripts/discourse/app/components/discovery/topics.hbs +++ b/app/assets/javascripts/discourse/app/components/discovery/topics.hbs @@ -145,7 +145,12 @@ diff --git a/app/assets/javascripts/discourse/app/lib/plugin-api.gjs b/app/assets/javascripts/discourse/app/lib/plugin-api.gjs index 3fd4a2d8b5c..8bb9829c5a0 100644 --- a/app/assets/javascripts/discourse/app/lib/plugin-api.gjs +++ b/app/assets/javascripts/discourse/app/lib/plugin-api.gjs @@ -95,6 +95,7 @@ import { includeAttributes } from "discourse/lib/transform-post"; import { _addTransformerName, _registerTransformer, + transformerTypes, } from "discourse/lib/transformer"; import { registerUserMenuTab } from "discourse/lib/user-menu/tab"; import { replaceFormatter } from "discourse/lib/utilities"; @@ -155,7 +156,7 @@ import { modifySelectKit } from "select-kit/mixins/plugin-api"; // docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md whenever you change the version // using the format described at https://keepachangelog.com/en/1.0.0/. -export const PLUGIN_API_VERSION = "1.34.0"; +export const PLUGIN_API_VERSION = "1.35.0"; const DEPRECATED_HEADER_WIDGETS = [ "header", @@ -333,9 +334,97 @@ class PluginApi { } /** - * Add a new valid transformer name. + * Add a new valid behavior transformer name. * - * Use this API to add a new transformer name that can be used in the `registerValueTransformer` API. + * Use this API to add a new behavior transformer name that can be used in the `registerValueTransformer` API. + * + * Notice that this API must be used in a pre-initializer, executed before `freeze-valid-transformers`, otherwise it will throw an error: + * + * Example: + * + * // pre-initializers/my-transformers.js + * + * export default { + * before: "freeze-valid-transformers", + * + * initialize() { + * withPluginApi("1.33.0", (api) => { + * api.addBehaviorTransformerName("my-unique-transformer-name"); + * }), + * }, + * }; + * + * @param name the name of the new transformer + * + */ + addBehaviorTransformerName(name) { + _addTransformerName(name, transformerTypes.BEHAVIOR); + } + + /** + * Register a transformer to override behavior defined in Discourse. + * + * Example: to perform an action before the expected behavior + * ``` + * api.registerBehaviorTransformer("example-transformer", ({next, context}) => { + * exampleNewAction(); // action performed before the expected behavior + * + * next(); //iterates over the transformer queue processing the behaviors + * }); + * ``` + * + * Example: to perform an action after the expected behavior + * ``` + * api.registerBehaviorTransformer("example-transformer", ({next, context}) => { + * next(); //iterates over the transformer queue processing the behaviors + * + * exampleNewAction(); // action performed after the expected behavior + * }); + * ``` + * + * Example: to use a value returned by the expected behavior to decide if an action must be performed + * ``` + * api.registerBehaviorTransformer("example-transformer", ({next, context}) => { + * const expected = next(); //iterates over the transformer queue processing the behaviors + * + * if (expected === "EXPECTED") { + * exampleNewAction(); // action performed after the expected behavior + * } + * }); + * ``` + * + * Example: to abort the expected behavior based on a condition + * ``` + * api.registerValueTransformer("example-transformer", ({next, context}) => { + * if (context.property) { + * // not calling next() on a behavior transformer aborts executing the expected behavior + * + * return; + * } + * + * next(); + * }); + * ``` + * + * @param {string} transformerName the name of the transformer + * @param {function({next, context})} behaviorCallback callback to be used to transform or override the behavior. + * @param {*} behaviorCallback.next callback that executes the remaining transformer queue producing the expected + * behavior. Notice that this includes the default behavior and if next() is not called in your transformer's callback + * the default behavior will be completely overridden + * @param {*} [behaviorCallback.context] the optional context in which the behavior is being transformed + */ + registerBehaviorTransformer(transformerName, behaviorCallback) { + _registerTransformer( + transformerName, + transformerTypes.BEHAVIOR, + behaviorCallback + ); + } + + /** + * Add a new valid value transformer name. + * + * Use this API to add a new value transformer name that can be used in the `registerValueTransformer` API. * * Notice that this API must be used in a pre-initializer, executed before `freeze-valid-transformers`, otherwise it will throw an error: * @@ -357,7 +446,7 @@ class PluginApi { * */ addValueTransformerName(name) { - _addTransformerName(name); + _addTransformerName(name, transformerTypes.VALUE); } /** @@ -392,7 +481,11 @@ class PluginApi { * @param {*} [valueCallback.context] the optional context in which the value is being transformed */ registerValueTransformer(transformerName, valueCallback) { - _registerTransformer(transformerName, valueCallback); + _registerTransformer( + transformerName, + transformerTypes.VALUE, + valueCallback + ); } /** @@ -2082,7 +2175,11 @@ class PluginApi { * */ registerHomeLogoHrefCallback(callback) { - _registerTransformer("home-logo-href", ({ value }) => callback(value)); + _registerTransformer( + "home-logo-href", + transformerTypes.VALUE, + ({ value }) => callback(value) + ); } /** diff --git a/app/assets/javascripts/discourse/app/lib/transformer.js b/app/assets/javascripts/discourse/app/lib/transformer.js index 4eb2ad18499..ccbca9dfc2a 100644 --- a/app/assets/javascripts/discourse/app/lib/transformer.js +++ b/app/assets/javascripts/discourse/app/lib/transformer.js @@ -1,13 +1,60 @@ -import { VALUE_TRANSFORMERS } from "discourse/lib/transformer/registry"; +import { DEBUG } from "@glimmer/env"; +import { capitalize } from "@ember/string"; +import { + BEHAVIOR_TRANSFORMERS, + VALUE_TRANSFORMERS, +} from "discourse/lib/transformer/registry"; import { isTesting } from "discourse-common/config/environment"; -// add core transformer names -const validCoreTransformerNames = new Set( - VALUE_TRANSFORMERS.map((name) => name.toLowerCase()) -); +const CORE_TRANSFORMER = "CORE"; +const PLUGIN_TRANSFORMER = "PLUGIN"; -// do not add anything directly to this set, use addValueTransformerName instead -const validPluginTransformerNames = new Set(); +export const transformerTypes = Object.freeze({ + // key and value must match + BEHAVIOR: "BEHAVIOR", + VALUE: "VALUE", +}); + +/** + * Valid core transformer names initialization. + * + * Some checks are performed to ensure there are no repeated names between the multiple transformer types. + * + * The list can be edited in `discourse/lib/transformer/registry` + */ +let validTransformerNames = new Map(); + +// Initialize the valid transformer names, notice that we perform some checks to ensure the transformer names are +// correctly defined, i.e., lowercase and unique. +[ + [BEHAVIOR_TRANSFORMERS, transformerTypes.BEHAVIOR], + [VALUE_TRANSFORMERS, transformerTypes.VALUE], +].forEach(([list, transformerType]) => { + list.forEach((name) => { + if (DEBUG) { + if (name !== name.toLowerCase()) { + throw new Error( + `Transformer name "${name}" must be lowercase. Found in ${transformerType} transformers.` + ); + } + + const existingInfo = findTransformerInfoByName(name); + + if (existingInfo) { + const candidateName = `${name}/${transformerType.toLowerCase()}`; + + throw new Error( + `Transformer name "${candidateName}" can't be added. The transformer ${existingInfo.name} already exists.` + ); + } + } + + validTransformerNames.set( + _normalizeTransformerName(name, transformerType), + CORE_TRANSFORMER + ); + }); +}); const transformersRegistry = new Map(); @@ -33,78 +80,174 @@ export function _freezeValidTransformerNames() { registryOpened = true; } +function _normalizeTransformerName(name, type) { + return `${name}/${type.toLowerCase()}`; +} + /** * Adds a new valid transformer name. * * INTERNAL API: use pluginApi.addValueTransformerName instead. * * DO NOT USE THIS FUNCTION TO ADD CORE TRANSFORMER NAMES. Instead register them directly in the - * validCoreTransformerNames set above. + * validTransformerNames set above. * * @param {string} name the name to register + * @param {string} transformerType the type of the transformer being added */ -export function _addTransformerName(name) { +export function _addTransformerName(name, transformerType) { + const apiName = `api.add${capitalize( + transformerType.toLowerCase() + )}TransformerName`; + + if (name !== name.toLowerCase()) { + throw new Error( + `${apiName}: transformer name "${name}" must be lowercase.` + ); + } + if (registryOpened) { throw new Error( - "api.registerValueTransformer was called when the system is no longer accepting new names to be added.\n" + + `${apiName} was called when the system is no longer accepting new names to be added.` + `Move your code to a pre-initializer that runs before "freeze-valid-transformers" to avoid this error.` ); } - if (validCoreTransformerNames.has(name)) { + const existingInfo = findTransformerInfoByName(name); + + if (!existingInfo) { + validTransformerNames.set( + _normalizeTransformerName(name, transformerType), + PLUGIN_TRANSFORMER + ); + + return; + } + + if (existingInfo.source === CORE_TRANSFORMER) { // eslint-disable-next-line no-console console.warn( - `api.addValueTransformerName: transformer "${name}" matches an existing core transformer and shouldn't be re-registered using the the API.` + `${apiName}: transformer "${name}" matches existing core transformer "${existingInfo.name}" and shouldn't be re-registered using the the API.` ); return; } - if (validPluginTransformerNames.has(name)) { - // eslint-disable-next-line no-console - console.warn( - `api.addValueTransformerName: transformer "${name}" is already registered.` - ); - return; - } - - validPluginTransformerNames.add(name.toLowerCase()); + // eslint-disable-next-line no-console + console.warn( + `${apiName}: transformer "${existingInfo.name}" is already registered` + ); } /** - * Register a value transformer. + * Registers a transformer. * - * INTERNAL API: use pluginApi.registerValueTransformer instead. + * INTERNAL API: use pluginApi.registerBehaviorTransformer or pluginApi.registerValueTransformer instead. * * @param {string} transformerName the name of the transformer - * @param {function({value, context})} callback callback that will transform the value. + * @param {string} transformerType the type of the transformer being registered + * @param {function} callback callback that will transform the value. */ -export function _registerTransformer(transformerName, callback) { +export function _registerTransformer( + transformerName, + transformerType, + callback +) { + if (!transformerTypes[transformerType]) { + throw new Error(`Invalid transformer type: ${transformerType}`); + } + + const apiName = `api.register${capitalize( + transformerType.toLowerCase() + )}Transformer`; + if (!registryOpened) { throw new Error( - "api.registerValueTransformer was called while the system was still accepting new transformer names to be added.\n" + + `${apiName} was called while the system was still accepting new transformer names to be added.\n` + `Move your code to an initializer or a pre-initializer that runs after "freeze-valid-transformers" to avoid this error.` ); } - if (!transformerExists(transformerName)) { + const normalizedTransformerName = _normalizeTransformerName( + transformerName, + transformerType + ); + + if (!transformerNameExists(normalizedTransformerName)) { // eslint-disable-next-line no-console console.warn( - `api.registerValueTransformer: transformer "${transformerName}" is unknown and will be ignored. ` + - "Perhaps you misspelled it?" + `${apiName}: transformer "${transformerName}" is unknown and will be ignored. ` + + "Is the name correct? Are you using the correct API for the transformer type?" ); } if (typeof callback !== "function") { throw new Error( - "api.registerValueTransformer requires the valueCallback argument to be a function" + `${apiName} requires the callback argument to be a function` ); } - const existingTransformers = transformersRegistry.get(transformerName) || []; + const existingTransformers = + transformersRegistry.get(normalizedTransformerName) || []; existingTransformers.push(callback); - transformersRegistry.set(transformerName, existingTransformers); + transformersRegistry.set(normalizedTransformerName, existingTransformers); +} + +export function applyBehaviorTransformer( + transformerName, + defaultCallback, + context +) { + const normalizedTransformerName = _normalizeTransformerName( + transformerName, + transformerTypes.BEHAVIOR + ); + + if (!transformerNameExists(normalizedTransformerName)) { + throw new Error( + `applyBehaviorTransformer: transformer name "${transformerName}" does not exist. ` + + "Was the transformer name properly added? Is the transformer name correct? Is the type equals BEHAVIOR? " + + "applyBehaviorTransformer can only be used with BEHAVIOR transformers." + ); + } + + if (typeof defaultCallback !== "function") { + throw new Error( + `applyBehaviorTransformer requires the callback argument to be a function` + ); + } + + if ( + typeof (context ?? undefined) !== "undefined" && + !(typeof context === "object" && context.constructor === Object) + ) { + throw `applyBehaviorTransformer("${transformerName}", ...): context must be a simple JS object or nullish.`; + } + + const transformers = transformersRegistry.get(normalizedTransformerName); + const appliedContext = { ...context }; + if (!appliedContext._unstable_self && this) { + appliedContext._unstable_self = this; + } + + if (!transformers) { + return defaultCallback({ context: appliedContext }); + } + + const callbackQueue = [...transformers, defaultCallback]; + + function nextCallback() { + const currentCallback = callbackQueue.shift(); + + if (!currentCallback) { + return; + } + + return currentCallback({ context: appliedContext, next: nextCallback }); + } + + return nextCallback(); } /** @@ -117,9 +260,16 @@ export function _registerTransformer(transformerName, callback) { * @returns {*} the transformed value */ export function applyValueTransformer(transformerName, defaultValue, context) { - if (!transformerExists(transformerName)) { + const normalizedTransformerName = _normalizeTransformerName( + transformerName, + transformerTypes.VALUE + ); + + if (!transformerNameExists(normalizedTransformerName)) { throw new Error( - `applyValueTransformer: transformer name "${transformerName}" does not exist. Did you forget to register it?` + `applyValueTransformer: transformer name "${transformerName}" does not exist. ` + + "Was the transformer name properly added? Is the transformer name correct? Is the type equals VALUE? " + + "applyValueTransformer can only be used with VALUE transformers." ); } @@ -135,7 +285,8 @@ export function applyValueTransformer(transformerName, defaultValue, context) { ); } - const transformers = transformersRegistry.get(transformerName); + const transformers = transformersRegistry.get(normalizedTransformerName); + if (!transformers) { return defaultValue; } @@ -151,20 +302,56 @@ export function applyValueTransformer(transformerName, defaultValue, context) { return newValue; } +/** + * @typedef {Object} TransformerInfo + * @property {string} name - The normalized name of the transformer + * @property {string} source - The source of the transformer + */ + +/** + * Find a transformer info by name, without considering the type + * + * @param name the name of the transformer + * + * @returns {TransformerInfo | null} info the transformer info or null if not found + */ +function findTransformerInfoByName(name) { + for (const searchedType of Object.keys(transformerTypes)) { + const searchedName = _normalizeTransformerName(name, searchedType); + const source = validTransformerNames?.get(searchedName); + + if (source) { + return { name: searchedName, source }; + } + } + + return null; +} + /** * Check if a transformer name exists * - * @param {string} name the name to check - * @returns {boolean} + * @param normalizedName the normalized name to check + * @returns {boolean} true if the transformer name exists, false otherwise */ -export function transformerExists(name) { - return ( - validCoreTransformerNames.has(name) || validPluginTransformerNames.has(name) - ); +function transformerNameExists(normalizedName) { + return validTransformerNames.has(normalizedName); } ///////// Testing helpers +/** + * Check if a transformer was added + * + * @param {string} name the name to check + * @param {string} type the type of the transformer + * + * @returns {boolean} true if a transformer with the given name and type exists, false otherwise + */ +export function transformerWasAdded(name, type) { + return validTransformerNames.has(_normalizeTransformerName(name, type)); +} + /** * Stores the initial state of `registryOpened` to allow the correct reset after a test that needs to manually * override the registry opened state finishes running. @@ -221,6 +408,15 @@ export function resetTransformers() { registryOpened = testRegistryOpenedState; } - validPluginTransformerNames.clear(); + clearPluginTransformers(); transformersRegistry.clear(); } + +/** + * Clears all transformer names registered using the plugin API + */ +function clearPluginTransformers() { + validTransformerNames = new Map( + [...validTransformerNames].filter(([, type]) => type === CORE_TRANSFORMER) + ); +} diff --git a/app/assets/javascripts/discourse/app/lib/transformer/registry.js b/app/assets/javascripts/discourse/app/lib/transformer/registry.js index 985246e89e1..bde4aef4d0a 100644 --- a/app/assets/javascripts/discourse/app/lib/transformer/registry.js +++ b/app/assets/javascripts/discourse/app/lib/transformer/registry.js @@ -1,3 +1,8 @@ +export const BEHAVIOR_TRANSFORMERS = Object.freeze([ + // use only lowercase names + "discovery-topic-list-load-more", +]); + export const VALUE_TRANSFORMERS = Object.freeze([ // use only lowercase names "header-notifications-avatar-size", diff --git a/app/assets/javascripts/discourse/tests/unit/lib/value-transformer-test.js b/app/assets/javascripts/discourse/tests/unit/lib/value-transformer-test.js index a31ee31ebfe..4cf815862c7 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/value-transformer-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/value-transformer-test.js @@ -6,8 +6,10 @@ import { withPluginApi } from "discourse/lib/plugin-api"; import { acceptNewTransformerNames, acceptTransformerRegistrations, + applyBehaviorTransformer, applyValueTransformer, - transformerExists, + transformerTypes, + transformerWasAdded, } from "discourse/lib/transformer"; module("Unit | Utility | transformers", function (hooks) { @@ -31,7 +33,7 @@ module("Unit | Utility | transformers", function (hooks) { withPluginApi("1.34.0", (api) => { api.addValueTransformerName("whatever"); }), - /was called when the system is no longer accepting new names to be added/ + /addValueTransformerName was called when the system is no longer accepting new names to be added/ ); }); @@ -44,7 +46,7 @@ module("Unit | Utility | transformers", function (hooks) { // testing warning about core transformers assert.strictEqual( this.consoleWarnStub.calledWith( - sinon.match(/matches an existing core transformer/) + sinon.match(/matches existing core transformer/) ), true, "logs warning to the console about existing core transformer with the same name" @@ -75,13 +77,19 @@ module("Unit | Utility | transformers", function (hooks) { withPluginApi("1.34.0", (api) => { assert.strictEqual( - transformerExists("a-new-plugin-transformer"), + transformerWasAdded( + "a-new-plugin-transformer", + transformerTypes.VALUE + ), false, "initially the transformer does not exists" ); api.addValueTransformerName("a-new-plugin-transformer"); // second time log a warning assert.strictEqual( - transformerExists("a-new-plugin-transformer"), + transformerWasAdded( + "a-new-plugin-transformer", + transformerTypes.VALUE + ), true, "the new transformer was added" ); @@ -130,7 +138,7 @@ module("Unit | Utility | transformers", function (hooks) { withPluginApi("1.34.0", (api) => { api.registerValueTransformer("whatever", "foo"); }), - /requires the valueCallback argument to be a function/ + /api.registerValueTransformer requires the callback argument to be a function/ ); }); @@ -176,7 +184,7 @@ module("Unit | Utility | transformers", function (hooks) { test("raises an exception if the transformer name does not exist", function (assert) { assert.throws( () => applyValueTransformer("whatever", "foo"), - /does not exist. Did you forget to register it/ + /does not exist./ ); }); @@ -407,4 +415,825 @@ module("Unit | Utility | transformers", function (hooks) { ); }); }); + + module("pluginApi.addBehaviorTransformerName", function (innerHooks) { + innerHooks.beforeEach(function () { + this.consoleWarnStub = sinon.stub(console, "warn"); + }); + + innerHooks.afterEach(function () { + this.consoleWarnStub.restore(); + }); + + test("raises an exception if the system is already accepting transformers to registered", function (assert) { + // there is no need to freeze the list of valid transformers because that happen when the test application is + // initialized in `setupTest` + + assert.throws( + () => + withPluginApi("1.34.0", (api) => { + api.addBehaviorTransformerName("whatever"); + }), + /addBehaviorTransformerName was called when the system is no longer accepting new names to be added/ + ); + }); + + test("warns if name is already registered", function (assert) { + acceptNewTransformerNames(); + + withPluginApi("1.34.0", (api) => { + api.addBehaviorTransformerName("home-logo-href"); // existing core transformer + + // testing warning about core transformers + assert.strictEqual( + this.consoleWarnStub.calledWith( + sinon.match(/matches existing core transformer/) + ), + true, + "logs warning to the console about existing core transformer with the same name" + ); + + // testing warning about plugin transformers + this.consoleWarnStub.reset(); + + api.addBehaviorTransformerName("new-plugin-transformer"); // first time should go through + assert.strictEqual( + this.consoleWarnStub.notCalled, + true, + "did not log warning to the console" + ); + + api.addBehaviorTransformerName("new-plugin-transformer"); // second time log a warning + + assert.strictEqual( + this.consoleWarnStub.calledWith(sinon.match(/is already registered/)), + true, + "logs warning to the console about transformer already added with the same name" + ); + }); + }); + + test("adds a new transformer name", function (assert) { + acceptNewTransformerNames(); + + withPluginApi("1.34.0", (api) => { + assert.strictEqual( + transformerWasAdded( + "a-new-plugin-transformer", + transformerTypes.BEHAVIOR + ), + false, + "initially the transformer does not exists" + ); + api.addBehaviorTransformerName("a-new-plugin-transformer"); // second time log a warning + assert.strictEqual( + transformerWasAdded( + "a-new-plugin-transformer", + transformerTypes.BEHAVIOR + ), + true, + "the new transformer was added" + ); + }); + }); + }); + + module("pluginApi.registerBehaviorTransformer", function (innerHooks) { + innerHooks.beforeEach(function () { + this.consoleWarnStub = sinon.stub(console, "warn"); + }); + + innerHooks.afterEach(function () { + this.consoleWarnStub.restore(); + }); + + test("raises an exception if the application the system is still waiting for transformer names to be registered", function (assert) { + acceptNewTransformerNames(); + + assert.throws( + () => + withPluginApi("1.34.0", (api) => { + api.registerBehaviorTransformer("whatever", () => "foo"); // the name doesn't really matter at this point + }), + /was called while the system was still accepting new transformer names/ + ); + }); + + test("warns if transformer is unknown", function (assert) { + withPluginApi("1.34.0", (api) => { + api.registerBehaviorTransformer("whatever", () => "foo"); + + // testing warning about core transformers + assert.strictEqual( + this.consoleWarnStub.calledWith( + sinon.match(/is unknown and will be ignored/) + ), + true + ); + }); + }); + + test("raises an exception if the callback parameter is not a function", function (assert) { + assert.throws( + () => + withPluginApi("1.34.0", (api) => { + api.registerBehaviorTransformer("whatever", "foo"); + }), + /api.registerBehaviorTransformer requires the callback argument to be a function/ + ); + }); + + test("registering a new transformer works", function (assert) { + acceptNewTransformerNames(); + + withPluginApi("1.34.0", (api) => { + api.addBehaviorTransformerName("test-transformer"); + acceptTransformerRegistrations(); + + let value = null; + + const transformerWasRegistered = (name) => + applyBehaviorTransformer(name, () => (value = "DEFAULT_CALLBACK"), { + setValue: (v) => (value = v), + }); + + assert.strictEqual( + value, + null, + "value is null. behavior callback was not executed yet" + ); + + transformerWasRegistered("test-transformer"); + assert.strictEqual( + value, + "DEFAULT_CALLBACK", + "value was set by the default callback. transformer is not registered yet" + ); + + api.registerBehaviorTransformer("test-transformer", ({ context }) => + context.setValue("TRANSFORMED_CALLBACK") + ); + + transformerWasRegistered("test-transformer"); + assert.strictEqual( + value, + "TRANSFORMED_CALLBACK", + "the transformer was registered successfully. the value did change." + ); + }); + }); + }); + + module("applyBehaviorTransformer", function (innerHooks) { + innerHooks.beforeEach(function () { + acceptNewTransformerNames(); + + withPluginApi("1.34.0", (api) => { + api.addBehaviorTransformerName("test-behavior1-transformer"); + api.addBehaviorTransformerName("test-behavior2-transformer"); + }); + + acceptTransformerRegistrations(); + }); + + test("raises an exception if the transformer name does not exist", function (assert) { + assert.throws( + () => applyBehaviorTransformer("whatever", "foo"), + /applyBehaviorTransformer: transformer name(.*)does not exist./ + ); + }); + + test("raises an exception if the callback argument provided is not a function", function (assert) { + assert.throws( + () => applyBehaviorTransformer("test-behavior1-transformer", "foo"), + /requires the callback argument/ + ); + }); + + test("accepts only simple objects as context", function (assert) { + const notThrows = (testCallback) => { + try { + testCallback(); + return true; + } catch (error) { + return false; + } + }; + + assert.ok( + notThrows(() => + applyBehaviorTransformer("test-behavior1-transformer", () => true) + ), + "it won't throw an error if context is not passed" + ); + + assert.ok( + notThrows(() => + applyBehaviorTransformer( + "test-behavior1-transformer", + () => true, + undefined + ) + ), + "it won't throw an error if context is undefined" + ); + + assert.ok( + notThrows(() => + applyBehaviorTransformer( + "test-behavior1-transformer", + () => true, + null + ) + ), + "it won't throw an error if context is null" + ); + + assert.ok( + notThrows(() => + applyBehaviorTransformer("test-behavior1-transformer", () => true, { + pojo: true, + property: "foo", + }) + ), + "it won't throw an error if context is a POJO" + ); + + assert.throws( + () => + applyBehaviorTransformer( + "test-behavior1-transformer", + () => true, + "" + ), + /context must be a simple JS object/, + "it will throw an error if context is a string" + ); + + assert.throws( + () => + applyBehaviorTransformer("test-behavior1-transformer", () => true, 0), + /context must be a simple JS object/, + "it will throw an error if context is a number" + ); + + assert.throws( + () => + applyBehaviorTransformer( + "test-behavior1-transformer", + () => true, + false + ), + /context must be a simple JS object/, + "it will throw an error if context is a boolean behavior" + ); + + assert.throws( + () => + applyBehaviorTransformer( + "test-behavior1-transformer", + () => true, + () => "function" + ), + /context must be a simple JS object/, + "it will throw an error if context is a function" + ); + + assert.throws( + () => + applyBehaviorTransformer( + "test-behavior1-transformer", + () => true, + EmberObject.create({ + test: true, + }) + ), + /context must be a simple JS object/, + "it will throw an error if context is an Ember object" + ); + + assert.throws( + () => + applyBehaviorTransformer( + "test-behavior1-transformer", + () => true, + EmberObject.create({ + test: true, + }) + ), + /context must be a simple JS object/, + "it will throw an error if context is an Ember component" + ); + + class Testable {} + assert.throws( + () => + applyBehaviorTransformer( + "test-behavior1-transformer", + () => true, + new Testable() + ), + /context must be a simple JS object/, + "it will throw an error if context is an instance of a class" + ); + }); + + test("applying the transformer works", function (assert) { + class Testable { + #value; + + constructor(value) { + this.#value = value; + } + + get value() { + return this.#value; + } + + multiplyValue() { + applyBehaviorTransformer( + "test-behavior1-transformer", + () => { + this.#value *= 2; + }, + { value: this.#value, setValue: (v) => (this.#value = v) } + ); + } + + incValue() { + applyBehaviorTransformer( + "test-behavior2-transformer", + () => { + this.#value += 1; + }, + { value: this.#value, setValue: (v) => (this.#value = v) } + ); + } + } + + const testObject1 = new Testable(1); + testObject1.multiplyValue(); + + const testObject2 = new Testable(2); + testObject2.multiplyValue(); + + assert.deepEqual( + [testObject1.value, testObject2.value], + [2, 4], + "the default behavior doubles the value" + ); + + withPluginApi("1.34.0", (api) => { + api.registerBehaviorTransformer( + "test-behavior1-transformer", + ({ context }) => { + context.setValue(context.value * 10); + } + ); + }); + + testObject1.multiplyValue(); + testObject2.multiplyValue(); + + assert.deepEqual( + [testObject1.value, testObject2.value], + [20, 40], + "when a transformer was registered, the method now performs1 transformed behavior" + ); + + testObject1.incValue(); + testObject2.incValue(); + + assert.deepEqual( + [testObject1.value, testObject2.value], + [21, 41], + "transformer names without transformers registered are not affected" + ); + }); + + test("applying the transformer works with Promises", async function (assert) { + function delayedValue(value) { + return new Promise((resolve) => { + setTimeout(() => { + resolve(value); + }, 50); + }); + } + + class Testable { + #value = null; + + get value() { + return this.#value; + } + + clearValue() { + this.#value = null; + } + + #asyncFetchValue() { + return delayedValue("slow foo"); + } + + initializeValue() { + return applyBehaviorTransformer( + "test-behavior1-transformer", + () => { + return this.#asyncFetchValue().then((v) => (this.#value = v)); + }, + { + getValue: () => this.#value, + setValue: (v) => (this.#value = v), + } + ); + } + } + + const testObject = new Testable(); + assert.deepEqual(testObject.value, null, "initially the value is null"); + + withPluginApi("1.34.0", (api) => { + api.registerBehaviorTransformer( + "test-behavior1-transformer", + ({ context, next }) => { + return next() + .then(() => delayedValue(" was too late")) + .then((otherValue) => + context.setValue(context.getValue() + otherValue) + ); + } + ); + }); + + const done = assert.async(); + testObject.initializeValue().then(() => { + assert.deepEqual( + testObject.value, + "slow foo was too late", + "the value was changed after the async behavior" + ); + done(); + }); + }); + + test("applying the transformer works with async/await behavior", async function (assert) { + async function delayedValue(value) { + return await new Promise((resolve) => { + setTimeout(() => { + resolve(value); + }, 50); + }); + } + + class Testable { + #value = null; + + get value() { + return this.#value; + } + + clearValue() { + this.#value = null; + } + + async #asyncFetchValue() { + return await delayedValue("slow foo"); + } + + async initializeValue() { + await applyBehaviorTransformer( + "test-behavior1-transformer", + async () => { + this.#value = await this.#asyncFetchValue(); + }, + { + getValue: () => this.#value, + setValue: (v) => (this.#value = v), + } + ); + } + } + + const testObject = new Testable(); + assert.deepEqual(testObject.value, null, "initially the value is null"); + + await testObject.initializeValue(); + assert.deepEqual( + testObject.value, + "slow foo", + "the value was changed after the async behavior" + ); + + withPluginApi("1.34.0", (api) => { + api.registerBehaviorTransformer( + "test-behavior1-transformer", + async ({ context, next }) => { + await next(); + const otherValue = await delayedValue(" was too late"); + + context.setValue(context.getValue() + otherValue); + } + ); + }); + + testObject.clearValue(); + await testObject.initializeValue(); + assert.deepEqual( + testObject.value, + "slow foo was too late", + "when a transformer was registered, the method now performs transformed behavior" + ); + }); + + test("the transformer callback can receive an optional context object", function (assert) { + let behavior = null; + let expectedContext = null; + + withPluginApi("1.34.0", (api) => { + api.registerBehaviorTransformer( + "test-behavior1-transformer", + ({ context }) => { + behavior = "ALTERED"; + expectedContext = context; + + return true; + } + ); + }); + + applyBehaviorTransformer( + "test-behavior1-transformer", + () => (behavior = "DEFAULT"), + { + prop1: true, + prop2: false, + } + ); + + assert.strictEqual(behavior, "ALTERED", "the behavior was transformed"); + assert.deepEqual( + expectedContext, + { + prop1: true, + prop2: false, + }, + "the callback received the expected context" + ); + }); + + test("the transformers can call next to keep moving through the callback queue", function (assert) { + class Testable { + #value = []; + + resetValue() { + this.#value = []; + } + + buildValue() { + return applyBehaviorTransformer( + "test-behavior1-transformer", + () => this.#value.push("!"), + { pushValue: (v) => this.#value.push(v) } + ); + } + + get value() { + return this.#value.join(""); + } + } + + const testObject = new Testable(); + testObject.buildValue(); + + assert.deepEqual( + testObject.value, + "!", + `initially buildValue value only generates "!"` + ); + + withPluginApi("1.34.0", (api) => { + api.registerBehaviorTransformer( + "test-behavior1-transformer", + ({ context, next }) => { + context.pushValue("co"); + next(); + } + ); + api.registerBehaviorTransformer( + "test-behavior1-transformer", + ({ context, next }) => { + context.pushValue("rr"); + next(); + } + ); + api.registerBehaviorTransformer( + "test-behavior1-transformer", + ({ context, next }) => { + context.pushValue("ect"); + next(); + } + ); + }); + + testObject.resetValue(); + testObject.buildValue(); + + assert.strictEqual( + testObject.value, + "correct!", + `the transformers applied in the sequence will produce the word "correct!"` + ); + }); + + test("when a transformer does not call next() the next transformers in the queue are not processed", function (assert) { + class Testable { + #value = []; + + resetValue() { + this.#value = []; + } + + buildValue() { + return applyBehaviorTransformer( + "test-behavior1-transformer", + () => this.#value.push("!"), + { pushValue: (v) => this.#value.push(v) } + ); + } + + get value() { + return this.#value.join(""); + } + } + + const testObject = new Testable(); + testObject.buildValue(); + + assert.deepEqual( + testObject.value, + "!", + `initially buildValue value only generates "!"` + ); + + withPluginApi("1.34.0", (api) => { + api.registerBehaviorTransformer( + "test-behavior1-transformer", + ({ context }) => { + context.pushValue("stopped"); + } + ); + + // the transformer below won't be called because next() is not called in the callback of the transformer above + api.registerBehaviorTransformer( + "test-behavior1-transformer", + ({ context, next }) => { + context.pushValue(" at the end"); + next(); + } + ); + }); + + testObject.resetValue(); + testObject.buildValue(); + + assert.strictEqual( + testObject.value, + "stopped", + // if the sequence had been executed completely, it would have produced "stopped at the end!" + `the transformers applied in the sequence will only produce the word "stopped"` + ); + }); + + test("calling next() before the transformed behavior changes the order the queue is executed", function (assert) { + class Testable { + #value = []; + + resetValue() { + this.#value = []; + } + + buildValue() { + return applyBehaviorTransformer( + "test-behavior1-transformer", + () => this.#value.push("!"), + { pushValue: (v) => this.#value.push(v) } + ); + } + + get value() { + return this.#value.join(" "); + } + } + + const testObject = new Testable(); + testObject.buildValue(); + + assert.deepEqual( + testObject.value, + "!", + `initially buildValue value only generates "!"` + ); + + withPluginApi("1.34.0", (api) => { + api.registerBehaviorTransformer( + "test-behavior1-transformer", + ({ context, next }) => { + next(); + context.pushValue("reverted"); + } + ); + api.registerBehaviorTransformer( + "test-behavior1-transformer", + ({ context, next }) => { + next(); + context.pushValue("was"); + } + ); + api.registerBehaviorTransformer( + "test-behavior1-transformer", + ({ context }) => { + context.pushValue("order"); + } + ); + }); + + testObject.resetValue(); + testObject.buildValue(); + + assert.strictEqual( + testObject.value, + "order was reverted", + `the transformers applied in the sequence will produce the expression "order was reverted"` + ); + }); + + test("if `this` is set when applying the behavior transformer it is passed in the context as _unstable_self", function (assert) { + class Testable { + #value = []; + + resetValue() { + this.#value = []; + } + + buildValue() { + return applyBehaviorTransformer.call( + this, + "test-behavior1-transformer", + () => this.#value.push("!") + ); + } + + get value() { + return this.#value.join(" "); + } + + pushValue(v) { + this.#value.push(v); + } + } + + const testObject = new Testable(); + testObject.buildValue(); + + assert.deepEqual( + testObject.value, + "!", + `initially buildValue value only generates "!"` + ); + + withPluginApi("1.34.0", (api) => { + api.registerBehaviorTransformer( + "test-behavior1-transformer", + ({ next, context }) => { + context._unstable_self.pushValue("added"); + next(); + } + ); + + api.registerBehaviorTransformer( + "test-behavior1-transformer", + ({ next, context: { _unstable_self } }) => { + _unstable_self.pushValue("other"); + next(); + } + ); + + api.registerBehaviorTransformer( + "test-behavior1-transformer", + ({ context: { _unstable_self } }) => { + _unstable_self.pushValue("items"); + } + ); + }); + + testObject.resetValue(); + testObject.buildValue(); + + assert.strictEqual( + testObject.value, + "added other items", + `the transformers used _unstable_self to access the component instance that called applyBehaviorTransformer` + ); + }); + }); }); diff --git a/docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md b/docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md index 0a5eac379e2..74b77774880 100644 --- a/docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md +++ b/docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md @@ -7,6 +7,11 @@ in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.35.0] - 2024-07-30 + +- Added `registerBehaviorTransformer` which allows registering a transformer callback to override behavior defined in Discourse modules +- Added `addBehaviorTransformerName` which allows plugins/TCs to register a new transformer to override behavior defined in their modules + ## [1.34.0] - 2024-06-06 - Added `registerValueTransformer` which allows registering a transformer callback to override values defined in Discourse modules