diff --git a/app/assets/javascripts/discourse/app/components/header/home-logo.gjs b/app/assets/javascripts/discourse/app/components/header/home-logo.gjs index 37404fdb196..2dab793b189 100644 --- a/app/assets/javascripts/discourse/app/components/header/home-logo.gjs +++ b/app/assets/javascripts/discourse/app/components/header/home-logo.gjs @@ -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() { diff --git a/app/assets/javascripts/discourse/app/components/header/user-dropdown/notifications.gjs b/app/assets/javascripts/discourse/app/components/header/user-dropdown/notifications.gjs index f09cd9f029a..281a08ac2fd 100644 --- a/app/assets/javascripts/discourse/app/components/header/user-dropdown/notifications.gjs +++ b/app/assets/javascripts/discourse/app/components/header/user-dropdown/notifications.gjs @@ -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 && diff --git a/app/assets/javascripts/discourse/app/initializers/freeze-valid-transformers.js b/app/assets/javascripts/discourse/app/initializers/freeze-valid-transformers.js new file mode 100644 index 00000000000..aa8476f5738 --- /dev/null +++ b/app/assets/javascripts/discourse/app/initializers/freeze-valid-transformers.js @@ -0,0 +1,9 @@ +import { _freezeValidTransformerNames } from "discourse/lib/transformer"; + +export default { + before: "inject-discourse-objects", + + initialize() { + _freezeValidTransformerNames(); + }, +}; diff --git a/app/assets/javascripts/discourse/app/lib/plugin-api.gjs b/app/assets/javascripts/discourse/app/lib/plugin-api.gjs index a5cb16fd7b0..4c8fff1f3a1 100644 --- a/app/assets/javascripts/discourse/app/lib/plugin-api.gjs +++ b/app/assets/javascripts/discourse/app/lib/plugin-api.gjs @@ -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)); } /** diff --git a/app/assets/javascripts/discourse/app/lib/transformer.js b/app/assets/javascripts/discourse/app/lib/transformer.js new file mode 100644 index 00000000000..4eb2ad18499 --- /dev/null +++ b/app/assets/javascripts/discourse/app/lib/transformer.js @@ -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(); +} diff --git a/app/assets/javascripts/discourse/app/lib/transformer/registry.js b/app/assets/javascripts/discourse/app/lib/transformer/registry.js new file mode 100644 index 00000000000..985246e89e1 --- /dev/null +++ b/app/assets/javascripts/discourse/app/lib/transformer/registry.js @@ -0,0 +1,5 @@ +export const VALUE_TRANSFORMERS = Object.freeze([ + // use only lowercase names + "header-notifications-avatar-size", + "home-logo-href", +]); diff --git a/app/assets/javascripts/discourse/app/widgets/home-logo.js b/app/assets/javascripts/discourse/app/widgets/home-logo.js index d3ef69c7799..eb1d1b2fd0a 100644 --- a/app/assets/javascripts/discourse/app/widgets/home-logo.js +++ b/app/assets/javascripts/discourse/app/widgets/home-logo.js @@ -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 = {}) { diff --git a/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js b/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js index cc8d6a0440c..743d2df712c 100644 --- a/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js +++ b/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js @@ -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() { diff --git a/app/assets/javascripts/discourse/tests/integration/components/home-logo-test.gjs b/app/assets/javascripts/discourse/tests/integration/components/home-logo-test.gjs index eda96645f20..e32b875d9e2 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/home-logo-test.gjs +++ b/app/assets/javascripts/discourse/tests/integration/components/home-logo-test.gjs @@ -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) { diff --git a/app/assets/javascripts/discourse/tests/integration/components/widgets/home-logo-test.gjs b/app/assets/javascripts/discourse/tests/integration/components/widgets/home-logo-test.gjs index b0488fe76c8..7be02975ef6 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/widgets/home-logo-test.gjs +++ b/app/assets/javascripts/discourse/tests/integration/components/widgets/home-logo-test.gjs @@ -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) { 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 new file mode 100644 index 00000000000..a31ee31ebfe --- /dev/null +++ b/app/assets/javascripts/discourse/tests/unit/lib/value-transformer-test.js @@ -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"` + ); + }); + }); +}); diff --git a/docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md b/docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md index a73f972f65d..0a5eac379e2 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.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.