DEV: Add behavior transformers (#27409)

This commit introduces the `behaviorTransformer` API to safely override behaviors defined in Discourse.

Two new plugin APIs are introduced:

- `addBehaviorTransformerName` which allows plugins and theme-components to add a new valid transformer name if they want to provide overridable behaviors;
- `registerBehaviorTransformer` to register a transformer to override behaviors.

It also introduces the function `applyBehaviorTransformer` which can be imported from `discourse/lib/transformer`. This is used to mark a callback containing the desired behavior as overridable and applies the transformer logic.

How does it work?

## Marking a behavior as overridable:
 
To mark a behavior as overridable, in Discourse core, first the transformer name must be added to `app/assets/javascripts/discourse/app/lib/transformer/registry.js`. For plugins and theme-components, use the plugin API `addBehaviorTransformerName` instead.

Then, in your component or class, use the function `applyBehaviorTransformer` to mark the Behavior as overridable and handle the logic:

- example:

```js
  ...
  @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 }
    );
  },
  ...	
```

## Overriding a behavior in plugins or themes

To override a behavior in plugins, themes, or TCs use the plugin API `registerBehaviorTransformer`:

- Example:

```js
withPluginApi("1.35.0", (api) => {
  api.registerBehaviorTransformer("example-transformer", ({ context, next }) => {
    console.log('we can introduce new behavior here instead', context);

    next(); // call next to execute the expected behavior
  });
});
```
This commit is contained in:
Sérgio Saquetim 2024-07-31 16:39:22 -03:00 committed by GitHub
parent 366dfec16c
commit 7b14cd98c7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 1219 additions and 72 deletions

View File

@ -1,12 +1,15 @@
import Component from "@ember/component"; import Component from "@ember/component";
import { action } from "@ember/object";
import { service } from "@ember/service"; import { service } from "@ember/service";
import $ from "jquery"; import $ from "jquery";
import { applyBehaviorTransformer } from "discourse/lib/transformer";
import LoadMore from "discourse/mixins/load-more"; import LoadMore from "discourse/mixins/load-more";
import { observes, on } from "discourse-common/utils/decorators"; import { observes, on } from "discourse-common/utils/decorators";
export default Component.extend(LoadMore, { export default Component.extend(LoadMore, {
classNames: ["contents"], classNames: ["contents"],
eyelineSelector: ".topic-list-item", eyelineSelector: ".topic-list-item",
appEvents: service(),
documentTitle: service(), documentTitle: service(),
@on("didInsertElement") @on("didInsertElement")
@ -32,21 +35,28 @@ export default Component.extend(LoadMore, {
this.documentTitle.updateContextCount(this.incomingCount); this.documentTitle.updateContextCount(this.incomingCount);
}, },
actions: { @action
loadMore() { loadMore() {
this.documentTitle.updateContextCount(0); applyBehaviorTransformer(
this.model.loadMore().then(({ moreTopicsUrl, newTopics } = {}) => { "discovery-topic-list-load-more",
if ( () => {
newTopics && this.documentTitle.updateContextCount(0);
newTopics.length && return this.model
this.bulkSelectHelper?.bulkSelectEnabled .loadMore()
) { .then(({ moreTopicsUrl, newTopics } = {}) => {
this.bulkSelectHelper.addTopics(newTopics); if (
} newTopics &&
if (moreTopicsUrl && $(window).height() >= $(document).height()) { newTopics.length &&
this.send("loadMore"); this.bulkSelectHelper?.bulkSelectEnabled
} ) {
}); this.bulkSelectHelper.addTopics(newTopics);
}, }
if (moreTopicsUrl && $(window).height() >= $(document).height()) {
this.send("loadMore");
}
});
},
{ model: this.model }
);
}, },
}); });

View File

@ -145,7 +145,12 @@
<PluginOutlet <PluginOutlet
@name="after-topic-list" @name="after-topic-list"
@connectorTagName="div" @connectorTagName="div"
@outletArgs={{hash category=@category tag=@tag}} @outletArgs={{hash
category=@category
tag=@tag
loadingMore=@model.loadingMore
canLoadMore=@model.canLoadMore
}}
/> />
</span> </span>
</DiscoveryTopicsList> </DiscoveryTopicsList>

View File

@ -95,6 +95,7 @@ import { includeAttributes } from "discourse/lib/transform-post";
import { import {
_addTransformerName, _addTransformerName,
_registerTransformer, _registerTransformer,
transformerTypes,
} from "discourse/lib/transformer"; } from "discourse/lib/transformer";
import { registerUserMenuTab } from "discourse/lib/user-menu/tab"; import { registerUserMenuTab } from "discourse/lib/user-menu/tab";
import { replaceFormatter } from "discourse/lib/utilities"; 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 // docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md whenever you change the version
// using the format described at https://keepachangelog.com/en/1.0.0/. // 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 = [ const DEPRECATED_HEADER_WIDGETS = [
"header", "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: * 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) { 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 * @param {*} [valueCallback.context] the optional context in which the value is being transformed
*/ */
registerValueTransformer(transformerName, valueCallback) { registerValueTransformer(transformerName, valueCallback) {
_registerTransformer(transformerName, valueCallback); _registerTransformer(
transformerName,
transformerTypes.VALUE,
valueCallback
);
} }
/** /**
@ -2082,7 +2175,11 @@ class PluginApi {
* *
*/ */
registerHomeLogoHrefCallback(callback) { registerHomeLogoHrefCallback(callback) {
_registerTransformer("home-logo-href", ({ value }) => callback(value)); _registerTransformer(
"home-logo-href",
transformerTypes.VALUE,
({ value }) => callback(value)
);
} }
/** /**

View File

@ -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"; import { isTesting } from "discourse-common/config/environment";
// add core transformer names const CORE_TRANSFORMER = "CORE";
const validCoreTransformerNames = new Set( const PLUGIN_TRANSFORMER = "PLUGIN";
VALUE_TRANSFORMERS.map((name) => name.toLowerCase())
);
// do not add anything directly to this set, use addValueTransformerName instead export const transformerTypes = Object.freeze({
const validPluginTransformerNames = new Set(); // 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(); const transformersRegistry = new Map();
@ -33,78 +80,174 @@ export function _freezeValidTransformerNames() {
registryOpened = true; registryOpened = true;
} }
function _normalizeTransformerName(name, type) {
return `${name}/${type.toLowerCase()}`;
}
/** /**
* Adds a new valid transformer name. * Adds a new valid transformer name.
* *
* INTERNAL API: use pluginApi.addValueTransformerName instead. * INTERNAL API: use pluginApi.addValueTransformerName instead.
* *
* DO NOT USE THIS FUNCTION TO ADD CORE TRANSFORMER NAMES. Instead register them directly in the * 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} 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) { if (registryOpened) {
throw new Error( 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.` `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 // eslint-disable-next-line no-console
console.warn( 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; return;
} }
if (validPluginTransformerNames.has(name)) { // eslint-disable-next-line no-console
// eslint-disable-next-line no-console console.warn(
console.warn( `${apiName}: transformer "${existingInfo.name}" is already registered`
`api.addValueTransformerName: transformer "${name}" is already registered.` );
);
return;
}
validPluginTransformerNames.add(name.toLowerCase());
} }
/** /**
* 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 {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) { if (!registryOpened) {
throw new Error( 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.` `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 // eslint-disable-next-line no-console
console.warn( console.warn(
`api.registerValueTransformer: transformer "${transformerName}" is unknown and will be ignored. ` + `${apiName}: transformer "${transformerName}" is unknown and will be ignored. ` +
"Perhaps you misspelled it?" "Is the name correct? Are you using the correct API for the transformer type?"
); );
} }
if (typeof callback !== "function") { if (typeof callback !== "function") {
throw new Error( 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); 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 * @returns {*} the transformed value
*/ */
export function applyValueTransformer(transformerName, defaultValue, context) { export function applyValueTransformer(transformerName, defaultValue, context) {
if (!transformerExists(transformerName)) { const normalizedTransformerName = _normalizeTransformerName(
transformerName,
transformerTypes.VALUE
);
if (!transformerNameExists(normalizedTransformerName)) {
throw new Error( 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) { if (!transformers) {
return defaultValue; return defaultValue;
} }
@ -151,20 +302,56 @@ export function applyValueTransformer(transformerName, defaultValue, context) {
return newValue; 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 * Check if a transformer name exists
* *
* @param {string} name the name to check * @param normalizedName the normalized name to check
* @returns {boolean} * @returns {boolean} true if the transformer name exists, false otherwise
*/ */
export function transformerExists(name) { function transformerNameExists(normalizedName) {
return ( return validTransformerNames.has(normalizedName);
validCoreTransformerNames.has(name) || validPluginTransformerNames.has(name)
);
} }
///////// Testing helpers ///////// 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 * 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. * override the registry opened state finishes running.
@ -221,6 +408,15 @@ export function resetTransformers() {
registryOpened = testRegistryOpenedState; registryOpened = testRegistryOpenedState;
} }
validPluginTransformerNames.clear(); clearPluginTransformers();
transformersRegistry.clear(); transformersRegistry.clear();
} }
/**
* Clears all transformer names registered using the plugin API
*/
function clearPluginTransformers() {
validTransformerNames = new Map(
[...validTransformerNames].filter(([, type]) => type === CORE_TRANSFORMER)
);
}

View File

@ -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([ export const VALUE_TRANSFORMERS = Object.freeze([
// use only lowercase names // use only lowercase names
"header-notifications-avatar-size", "header-notifications-avatar-size",

View File

@ -6,8 +6,10 @@ import { withPluginApi } from "discourse/lib/plugin-api";
import { import {
acceptNewTransformerNames, acceptNewTransformerNames,
acceptTransformerRegistrations, acceptTransformerRegistrations,
applyBehaviorTransformer,
applyValueTransformer, applyValueTransformer,
transformerExists, transformerTypes,
transformerWasAdded,
} from "discourse/lib/transformer"; } from "discourse/lib/transformer";
module("Unit | Utility | transformers", function (hooks) { module("Unit | Utility | transformers", function (hooks) {
@ -31,7 +33,7 @@ module("Unit | Utility | transformers", function (hooks) {
withPluginApi("1.34.0", (api) => { withPluginApi("1.34.0", (api) => {
api.addValueTransformerName("whatever"); 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 // testing warning about core transformers
assert.strictEqual( assert.strictEqual(
this.consoleWarnStub.calledWith( this.consoleWarnStub.calledWith(
sinon.match(/matches an existing core transformer/) sinon.match(/matches existing core transformer/)
), ),
true, true,
"logs warning to the console about existing core transformer with the same name" "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) => { withPluginApi("1.34.0", (api) => {
assert.strictEqual( assert.strictEqual(
transformerExists("a-new-plugin-transformer"), transformerWasAdded(
"a-new-plugin-transformer",
transformerTypes.VALUE
),
false, false,
"initially the transformer does not exists" "initially the transformer does not exists"
); );
api.addValueTransformerName("a-new-plugin-transformer"); // second time log a warning api.addValueTransformerName("a-new-plugin-transformer"); // second time log a warning
assert.strictEqual( assert.strictEqual(
transformerExists("a-new-plugin-transformer"), transformerWasAdded(
"a-new-plugin-transformer",
transformerTypes.VALUE
),
true, true,
"the new transformer was added" "the new transformer was added"
); );
@ -130,7 +138,7 @@ module("Unit | Utility | transformers", function (hooks) {
withPluginApi("1.34.0", (api) => { withPluginApi("1.34.0", (api) => {
api.registerValueTransformer("whatever", "foo"); 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) { test("raises an exception if the transformer name does not exist", function (assert) {
assert.throws( assert.throws(
() => applyValueTransformer("whatever", "foo"), () => 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`
);
});
});
}); });

View File

@ -7,6 +7,11 @@ in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 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). 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 ## [1.34.0] - 2024-06-06
- Added `registerValueTransformer` which allows registering a transformer callback to override values defined in Discourse modules - Added `registerValueTransformer` which allows registering a transformer callback to override values defined in Discourse modules