DEV: Introduce a value transformer front-end plugin API (#27090)

This commit introduces the `valueTransformer`API to safely override values defined in Discourse.

Two new plugin APIs are introduced:

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

It also introduces the function `applyValueTransformer` which can be imported from `discourse/lib/transformer`. This function marks the desired value as overridable and applies the transformer logic.

How does it work?

## Marking a value as overridable:
 
To mark a value 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 `addValueTransformerName` instead.

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

- example:

```js
export default class HomeLogo extends Component {
  @service session;
  @service site;
  ...
  get href() {
    return applyValueTransformer("home-logo-href", getURL("/"));
  }	
```

## Overriding a value in plugins or themes

To override a value in plugins, themes, or TCs use the plugin API `registerValueTransformer`:

- Example:

```js
withPluginApi("1.34.0", (api) => {
  api.registerValueTransformer("example-transformer", ({ value }) => {
    return "new-value";
  });
});
```
This commit is contained in:
Sérgio Saquetim 2024-06-12 15:21:52 -03:00 committed by GitHub
parent e5dac3b422
commit 9668592aab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 744 additions and 47 deletions

View File

@ -6,20 +6,11 @@ import { service } from "@ember/service";
import PluginOutlet from "discourse/components/plugin-outlet";
import concatClass from "discourse/helpers/concat-class";
import { wantsNewWindow } from "discourse/lib/intercept-click";
import { applyValueTransformer } from "discourse/lib/transformer";
import DiscourseURL from "discourse/lib/url";
import getURL from "discourse-common/lib/get-url";
import HomeLogoContents from "./home-logo-contents";
let hrefCallback;
export function registerHomeLogoHrefCallback(callback) {
hrefCallback = callback;
}
export function clearHomeLogoHrefCallback() {
hrefCallback = null;
}
export default class HomeLogo extends Component {
@service session;
@service site;
@ -28,11 +19,7 @@ export default class HomeLogo extends Component {
darkModeAvailable = this.session.darkModeAvailable;
get href() {
if (hrefCallback) {
return hrefCallback();
}
return getURL("/");
return applyValueTransformer("home-logo-href", getURL("/"));
}
get showMobileLogo() {

View File

@ -7,17 +7,18 @@ import {
addExtraUserClasses,
renderAvatar,
} from "discourse/helpers/user-avatar";
import { applyValueTransformer } from "discourse/lib/transformer";
import icon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
import UserTip from "../../user-tip";
import UserStatusBubble from "./user-status-bubble";
const DEFAULT_AVATAR_SIZE = "medium";
export default class Notifications extends Component {
@service currentUser;
@service siteSettings;
avatarSize = "medium";
get avatar() {
const avatarAttrs = addExtraUserClasses(this.currentUser, {});
return htmlSafe(
@ -32,6 +33,13 @@ export default class Notifications extends Component {
);
}
get avatarSize() {
return applyValueTransformer(
"header-notifications-avatar-size",
DEFAULT_AVATAR_SIZE
);
}
get _shouldHighlightAvatar() {
return (
!this.currentUser.read_first_notification &&

View File

@ -0,0 +1,9 @@
import { _freezeValidTransformerNames } from "discourse/lib/transformer";
export default {
before: "inject-discourse-objects",
initialize() {
_freezeValidTransformerNames();
},
};

View File

@ -13,7 +13,6 @@ import { addCategorySortCriteria } from "discourse/components/edit-category-sett
import { forceDropdownForMenuPanels as glimmerForceDropdownForMenuPanels } from "discourse/components/glimmer-site-header";
import { addGlobalNotice } from "discourse/components/global-notice";
import { headerButtonsDAG } from "discourse/components/header";
import { registerHomeLogoHrefCallback } from "discourse/components/header/home-logo";
import { headerIconsDAG } from "discourse/components/header/icons";
import { _addBulkButton } from "discourse/components/modal/topic-bulk-actions";
import MountWidget, {
@ -93,6 +92,10 @@ import {
import { registerCustomTagSectionLinkPrefixIcon } from "discourse/lib/sidebar/user/tags-section/base-tag-section-link";
import { consolePrefix } from "discourse/lib/source-identifier";
import { includeAttributes } from "discourse/lib/transform-post";
import {
_addTransformerName,
_registerTransformer,
} from "discourse/lib/transformer";
import { registerUserMenuTab } from "discourse/lib/user-menu/tab";
import { replaceFormatter } from "discourse/lib/utilities";
import { addCardClickListenerSelector } from "discourse/mixins/card-contents-base";
@ -110,7 +113,6 @@ import { setNewCategoryDefaultColors } from "discourse/routes/new-category";
import { setNotificationsLimit } from "discourse/routes/user-notifications";
import { addComposerSaveErrorCallback } from "discourse/services/composer";
import { attachAdditionalPanel } from "discourse/widgets/header";
import { registerHomeLogoHrefCallback as registerHomeLogoHrefCallbackOnWidget } from "discourse/widgets/home-logo";
import { addPostClassesCallback } from "discourse/widgets/post";
import { addDecorator } from "discourse/widgets/post-cooked";
import {
@ -153,7 +155,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.33.0";
export const PLUGIN_API_VERSION = "1.34.0";
const DEPRECATED_HEADER_WIDGETS = [
"header",
@ -328,6 +330,69 @@ class PluginApi {
return klass;
}
/**
* Add a new valid transformer name.
*
* Use this API to add a new 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.addValueTransformerName("my-unique-transformer-name");
* }),
* },
* };
*
* @param name the name of the new transformer
*
*/
addValueTransformerName(name) {
_addTransformerName(name);
}
/**
* Register a transformer to override values defined in Discourse.
*
* Example: return a static value
* ```
* api.registerValueTransformer("example-transformer", () => "value");
* ```
*
* Example: transform the current value
* ```
* api.registerValueTransformer("example-transformer", ({value}) => value * 10);
* ```
*
* Example: transform the current value based on a context property
* ```
* api.registerValueTransformer("example-transformer", ({value, context}) => {
* if (context.property) {
* return value * 10;
* }
*
* return value;
* });
* ```
*
* @param {string} transformerName the name of the transformer
* @param {function({value, context})} valueCallback callback to be used to transform the value. To avoid potential
* errors or unexpected behavior the callback must be a pure function, i.e. return the transform value instead of
* mutating the input value, return the same output for the same input and not have any side effects.
* @param {*} valueCallback.value the value to be transformed
* @param {*} [valueCallback.context] the optional context in which the value is being transformed
*/
registerValueTransformer(transformerName, valueCallback) {
_registerTransformer(transformerName, valueCallback);
}
/**
* If you want to use custom icons in your discourse application,
* you can register a renderer that will return an icon in the
@ -2015,8 +2080,7 @@ class PluginApi {
*
*/
registerHomeLogoHrefCallback(callback) {
registerHomeLogoHrefCallback(callback);
registerHomeLogoHrefCallbackOnWidget(callback); // for compatibility with the legacy header
_registerTransformer("home-logo-href", ({ value }) => callback(value));
}
/**

View File

@ -0,0 +1,226 @@
import { 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())
);
// do not add anything directly to this set, use addValueTransformerName instead
const validPluginTransformerNames = new Set();
const transformersRegistry = new Map();
/**
* Indicates if the registry is open for registration.
*
* When the registry is closed, the system accepts adding new transformer names and throws an error when trying to
* register a transformer.
*
* When the registry is open, the system will throw an error if a transformer name is added and will accept registering
* transformers to be applied.
*
* @type {boolean}
*/
let registryOpened = false;
/**
* Freezes the valid transformers list and open the registry to accept new transform registrations.
*
* INTERNAL API: to be used only in `initializers/freeze-valid-transformers`
*/
export function _freezeValidTransformerNames() {
registryOpened = true;
}
/**
* 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.
*
* @param {string} name the name to register
*/
export function _addTransformerName(name) {
if (registryOpened) {
throw new Error(
"api.registerValueTransformer was called when the system is no longer accepting new names to be added.\n" +
`Move your code to a pre-initializer that runs before "freeze-valid-transformers" to avoid this error.`
);
}
if (validCoreTransformerNames.has(name)) {
// 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.`
);
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());
}
/**
* Register a value transformer.
*
* INTERNAL API: use pluginApi.registerValueTransformer instead.
*
* @param {string} transformerName the name of the transformer
* @param {function({value, context})} callback callback that will transform the value.
*/
export function _registerTransformer(transformerName, callback) {
if (!registryOpened) {
throw new Error(
"api.registerValueTransformer 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)) {
// eslint-disable-next-line no-console
console.warn(
`api.registerValueTransformer: transformer "${transformerName}" is unknown and will be ignored. ` +
"Perhaps you misspelled it?"
);
}
if (typeof callback !== "function") {
throw new Error(
"api.registerValueTransformer requires the valueCallback argument to be a function"
);
}
const existingTransformers = transformersRegistry.get(transformerName) || [];
existingTransformers.push(callback);
transformersRegistry.set(transformerName, existingTransformers);
}
/**
* Apply a transformer to a value
*
* @param {string} transformerName the name of the transformer applied
* @param {*} defaultValue the default value
* @param {*} [context] the optional context to pass to the transformer callbacks.
*
* @returns {*} the transformed value
*/
export function applyValueTransformer(transformerName, defaultValue, context) {
if (!transformerExists(transformerName)) {
throw new Error(
`applyValueTransformer: transformer name "${transformerName}" does not exist. Did you forget to register it?`
);
}
if (
typeof (context ?? undefined) !== "undefined" &&
!(typeof context === "object" && context.constructor === Object)
) {
throw (
`applyValueTransformer("${transformerName}", ...): context must be a simple JS object or nullish.\n` +
"Avoid passing complex objects in the context, like for example, component instances or objects that carry " +
"mutable state directly. This can induce users to registry transformers with callbacks causing side effects " +
"and mutating the context directly. Inevitably, this leads to fragile integrations."
);
}
const transformers = transformersRegistry.get(transformerName);
if (!transformers) {
return defaultValue;
}
let newValue = defaultValue;
const transformerPoolSize = transformers.length;
for (let i = 0; i < transformerPoolSize; i++) {
const valueCallback = transformers[i];
newValue = valueCallback({ value: newValue, context });
}
return newValue;
}
/**
* Check if a transformer name exists
*
* @param {string} name the name to check
* @returns {boolean}
*/
export function transformerExists(name) {
return (
validCoreTransformerNames.has(name) || validPluginTransformerNames.has(name)
);
}
///////// Testing helpers
/**
* 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.
*
* @type {boolean | null}
*/
let testRegistryOpenedState = null; // initially set to null bto allow testing if it was initialized
/**
* Opens the transformers registry for registration
*
* USE ONLY FOR TESTING PURPOSES.
*/
export function acceptNewTransformerNames() {
if (!isTesting()) {
throw new Error("Use `acceptNewTransformerNames` only in tests.");
}
if (testRegistryOpenedState === null) {
testRegistryOpenedState = registryOpened;
}
registryOpened = false;
}
/**
* Closes the transformers registry for registration
*
* USE ONLY FOR TESTING PURPOSES.
*/
export function acceptTransformerRegistrations() {
if (!isTesting()) {
throw new Error("Use `acceptTransformerRegistrations` only in tests.");
}
if (testRegistryOpenedState === null) {
testRegistryOpenedState = registryOpened;
}
registryOpened = true;
}
/**
* Resets the transformers initial state
*
* USE ONLY FOR TESTING PURPOSES.
*/
export function resetTransformers() {
if (!isTesting()) {
throw new Error("Use `resetTransformers` only in tests.");
}
if (testRegistryOpenedState !== null) {
registryOpened = testRegistryOpenedState;
}
validPluginTransformerNames.clear();
transformersRegistry.clear();
}

View File

@ -0,0 +1,5 @@
export const VALUE_TRANSFORMERS = Object.freeze([
// use only lowercase names
"header-notifications-avatar-size",
"home-logo-href",
]);

View File

@ -1,22 +1,13 @@
// deprecated in favor of components/header/home-logo.gjs
import { h } from "virtual-dom";
import { wantsNewWindow } from "discourse/lib/intercept-click";
import { applyValueTransformer } from "discourse/lib/transformer";
import DiscourseURL from "discourse/lib/url";
import Session from "discourse/models/session";
import { createWidget } from "discourse/widgets/widget";
import getURL from "discourse-common/lib/get-url";
import { iconNode } from "discourse-common/lib/icon-library";
let hrefCallback;
export function registerHomeLogoHrefCallback(callback) {
hrefCallback = callback;
}
export function clearHomeLogoHrefCallback() {
hrefCallback = null;
}
export default createWidget("home-logo", {
services: ["session"],
tagName: "div.title",
@ -34,11 +25,10 @@ export default createWidget("home-logo", {
href() {
const href = this.settings.href;
if (hrefCallback) {
return hrefCallback();
}
return typeof href === "function" ? href() : href;
return applyValueTransformer(
"home-logo-href",
typeof href === "function" ? href() : href
);
},
logoUrl(opts = {}) {

View File

@ -65,6 +65,7 @@ import {
resetHighestReadCache,
setTopicList,
} from "discourse/lib/topic-list-tracker";
import { resetTransformers } from "discourse/lib/transformer";
import { clearRewrites } from "discourse/lib/url";
import { resetUserMenuTabs } from "discourse/lib/user-menu/tab";
import {
@ -246,6 +247,7 @@ export function testCleanup(container, app) {
clearPopupMenuOptions();
clearAdditionalAdminSidebarSectionLinks();
resetAdminPluginConfigNav();
resetTransformers();
}
function cleanupCssGeneratorTags() {

View File

@ -1,13 +1,10 @@
import { getOwner } from "@ember/application";
import { render } from "@ember/test-helpers";
import { module, test } from "qunit";
import HomeLogo, {
clearHomeLogoHrefCallback as clearComponentHomeLogoHrefCallback,
} from "discourse/components/header/home-logo";
import HomeLogo from "discourse/components/header/home-logo";
import { withPluginApi } from "discourse/lib/plugin-api";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { query } from "discourse/tests/helpers/qunit-helpers";
import { clearHomeLogoHrefCallback as clearWidgetHomeLogoHrefCallback } from "discourse/widgets/home-logo";
const bigLogo = "/images/d-logo-sketch.png?test";
const smallLogo = "/images/d-logo-sketch-small.png?test";
@ -23,8 +20,6 @@ module("Integration | Component | home-logo", function (hooks) {
this.session = getOwner(this).lookup("service:session");
this.session.set("darkModeAvailable", null);
this.session.set("defaultColorSchemeIsDark", null);
clearWidgetHomeLogoHrefCallback();
clearComponentHomeLogoHrefCallback();
});
test("basics", async function (assert) {

View File

@ -2,12 +2,10 @@
import { getOwner } from "@ember/application";
import { render } from "@ember/test-helpers";
import { module, test } from "qunit";
import { clearHomeLogoHrefCallback as clearComponentHomeLogoHrefCallback } from "discourse/components/header/home-logo";
import MountWidget from "discourse/components/mount-widget";
import { withPluginApi } from "discourse/lib/plugin-api";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { count, exists, query } from "discourse/tests/helpers/qunit-helpers";
import { clearHomeLogoHrefCallback as clearWidgetHomeLogoHrefCallback } from "discourse/widgets/home-logo";
const bigLogo = "/images/d-logo-sketch.png?test";
const smallLogo = "/images/d-logo-sketch-small.png?test";
@ -23,8 +21,6 @@ module("Integration | Component | Widget | home-logo", function (hooks) {
this.session = getOwner(this).lookup("service:session");
this.session.set("darkModeAvailable", null);
this.session.set("defaultColorSchemeIsDark", null);
clearWidgetHomeLogoHrefCallback();
clearComponentHomeLogoHrefCallback();
});
test("basics", async function (assert) {

View File

@ -0,0 +1,410 @@
import EmberObject from "@ember/object";
import { setupTest } from "ember-qunit";
import { module, test } from "qunit";
import sinon from "sinon";
import { withPluginApi } from "discourse/lib/plugin-api";
import {
acceptNewTransformerNames,
acceptTransformerRegistrations,
applyValueTransformer,
transformerExists,
} from "discourse/lib/transformer";
module("Unit | Utility | transformers", function (hooks) {
setupTest(hooks);
module("pluginApi.addValueTransformerName", 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.addValueTransformerName("whatever");
}),
/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.addValueTransformerName("home-logo-href"); // existing core transformer
// testing warning about core transformers
assert.strictEqual(
this.consoleWarnStub.calledWith(
sinon.match(/matches an 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.addValueTransformerName("new-plugin-transformer"); // first time should go through
assert.strictEqual(
this.consoleWarnStub.notCalled,
true,
"did not log warning to the console"
);
api.addValueTransformerName("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(
transformerExists("a-new-plugin-transformer"),
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"),
true,
"the new transformer was added"
);
});
});
});
module("pluginApi.registerValueTransformer", 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.registerValueTransformer("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.registerValueTransformer("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.registerValueTransformer("whatever", "foo");
}),
/requires the valueCallback argument to be a function/
);
});
test("registering a new transformer works", function (assert) {
acceptNewTransformerNames();
withPluginApi("1.34.0", (api) => {
api.addValueTransformerName("test-transformer");
acceptTransformerRegistrations();
const transformerWasRegistered = (name) =>
applyValueTransformer(name, false);
assert.strictEqual(
transformerWasRegistered("test-transformer"),
false,
"value did not change. transformer is not registered yet"
);
api.registerValueTransformer("test-transformer", () => true);
assert.strictEqual(
transformerWasRegistered("test-transformer"),
true,
"the transformer was registered successfully. the value did change."
);
});
});
});
module("applyValueTransformer", function (innerHooks) {
innerHooks.beforeEach(function () {
acceptNewTransformerNames();
withPluginApi("1.34.0", (api) => {
api.addValueTransformerName("test-value1-transformer");
api.addValueTransformerName("test-value2-transformer");
});
acceptTransformerRegistrations();
});
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/
);
});
test("accepts only simple objects as context", function (assert) {
const notThrows = (testCallback) => {
try {
testCallback();
return true;
} catch (error) {
return false;
}
};
assert.ok(
notThrows(() =>
applyValueTransformer("test-value1-transformer", "foo")
),
"it won't throw an error if context is not passed"
);
assert.ok(
notThrows(() =>
applyValueTransformer("test-value1-transformer", "foo", undefined)
),
"it won't throw an error if context is undefined"
);
assert.ok(
notThrows(() =>
applyValueTransformer("test-value1-transformer", "foo", null)
),
"it won't throw an error if context is null"
);
assert.ok(
notThrows(() =>
applyValueTransformer("test-value1-transformer", "foo", {
pojo: true,
property: "foo",
})
),
"it won't throw an error if context is a POJO"
);
assert.throws(
() => applyValueTransformer("test-value1-transformer", "foo", ""),
/context must be a simple JS object/,
"it will throw an error if context is a string"
);
assert.throws(
() => applyValueTransformer("test-value1-transformer", "foo", 0),
/context must be a simple JS object/,
"it will throw an error if context is a number"
);
assert.throws(
() => applyValueTransformer("test-value1-transformer", "foo", false),
/context must be a simple JS object/,
"it will throw an error if context is a boolean value"
);
assert.throws(
() =>
applyValueTransformer(
"test-value1-transformer",
"foo",
() => "function"
),
/context must be a simple JS object/,
"it will throw an error if context is a function"
);
assert.throws(
() =>
applyValueTransformer(
"test-value1-transformer",
"foo",
EmberObject.create({
test: true,
})
),
/context must be a simple JS object/,
"it will throw an error if context is an Ember object"
);
assert.throws(
() =>
applyValueTransformer(
"test-value1-transformer",
"foo",
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(
() =>
applyValueTransformer(
"test-value1-transformer",
"foo",
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 value1() {
return applyValueTransformer("test-value1-transformer", this.#value);
}
get value2() {
return applyValueTransformer("test-value2-transformer", this.#value);
}
}
const testObject1 = new Testable(1);
const testObject2 = new Testable(2);
assert.deepEqual(
[
testObject1.value1,
testObject1.value2,
testObject2.value1,
testObject2.value2,
],
[1, 1, 2, 2],
"it returns the default values when there are no transformers registered"
);
withPluginApi("1.34.0", (api) => {
api.registerValueTransformer("test-value1-transformer", ({ value }) => {
return value * 10;
});
});
assert.deepEqual(
[testObject1.value1, testObject2.value1],
[10, 20],
"when a transformer was registered, it returns the transformed value"
);
assert.deepEqual(
[testObject1.value2, testObject2.value2],
[1, 2],
"transformer names without transformers registered are not affected"
);
});
test("the transformer callback can receive an optional context object", function (assert) {
let expectedContext = null;
withPluginApi("1.34.0", (api) => {
api.registerValueTransformer(
"test-value1-transformer",
// eslint-disable-next-line no-unused-vars
({ value, context }) => {
expectedContext = context; // this function should be pure, but we're using side effects just for the test
return true;
}
);
});
const value = applyValueTransformer("test-value1-transformer", false, {
prop1: true,
prop2: false,
});
assert.strictEqual(value, true, "the value was transformed");
assert.deepEqual(
expectedContext,
{
prop1: true,
prop2: false,
},
"the callback received the expected context"
);
});
test("multiple transformers registered for the same name will be applied in sequence", function (assert) {
class Testable {
get sequence() {
return applyValueTransformer("test-value1-transformer", ["r"]);
}
}
const testObject = new Testable();
assert.deepEqual(
testObject.sequence,
["r"],
`initially the sequence contains only the element "r"`
);
withPluginApi("1.34.0", (api) => {
api.registerValueTransformer("test-value1-transformer", ({ value }) => {
return ["r", ...value];
});
api.registerValueTransformer("test-value1-transformer", ({ value }) => {
return [...value, "e", "c"];
});
api.registerValueTransformer("test-value1-transformer", ({ value }) => {
return ["o", ...value];
});
api.registerValueTransformer("test-value1-transformer", ({ value }) => {
return ["c", ...value, "t"];
});
});
assert.strictEqual(
testObject.sequence.join(""),
"correct",
`the transformers applied in the expected sequence will produce the word "correct"`
);
});
});
});

View File

@ -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.34.0] - 2024-06-06
- Added `registerValueTransformer` which allows registering a transformer callback to override values defined in Discourse modules
- Added `addValueTransformerName` which allows plugins/TCs to register a new transformer to override values defined in their modules
## [1.33.0] - 2024-06-06
- Added `addCustomUserFieldValidationCallback` which allows to set a callback to change the validation and user facing message when attempting to save the signup form.