From 40fd82e2d196db955334582cae6040ba5c1fa6fd Mon Sep 17 00:00:00 2001 From: Osama Sayegh Date: Thu, 25 Aug 2022 15:41:58 +0300 Subject: [PATCH] DEV: Add model transformer plugin API (#18081) This API allows plugins to transform a list of model objects before they're rendered in the UI. At the moment, this API is limited to items/lists of the experimental user menu, but it may be extended in the future to other parts of the app. Additional context can be found in https://github.com/discourse/discourse/pull/18046. --- .../app/components/user-menu/messages-list.js | 7 ++- .../discourse/app/lib/model-transformers.js | 34 ++++++++++++++ .../discourse/app/lib/plugin-api.js | 32 +++++++++++++ .../javascripts/discourse/app/models/topic.js | 5 ++ .../tests/acceptance/user-menu-test.js | 29 ++++++++++++ .../discourse/tests/fixtures/user-menu.js | 46 ------------------- .../discourse/tests/helpers/qunit-helpers.js | 2 + 7 files changed, 107 insertions(+), 48 deletions(-) create mode 100644 app/assets/javascripts/discourse/app/lib/model-transformers.js diff --git a/app/assets/javascripts/discourse/app/components/user-menu/messages-list.js b/app/assets/javascripts/discourse/app/components/user-menu/messages-list.js index 92364a36693..241cc94eb1a 100644 --- a/app/assets/javascripts/discourse/app/components/user-menu/messages-list.js +++ b/app/assets/javascripts/discourse/app/components/user-menu/messages-list.js @@ -5,6 +5,7 @@ import showModal from "discourse/lib/show-modal"; import I18n from "I18n"; import UserMenuNotificationItem from "discourse/lib/user-menu/notification-item"; import UserMenuMessageItem from "discourse/lib/user-menu/message-item"; +import Topic from "discourse/models/topic"; export default class UserMenuMessagesList extends UserMenuNotificationsList { get dismissTypes() { @@ -47,7 +48,7 @@ export default class UserMenuMessagesList extends UserMenuNotificationsList { fetchItems() { return ajax( `/u/${this.currentUser.username}/user-menu-private-messages` - ).then((data) => { + ).then(async (data) => { const content = []; data.notifications.forEach((rawNotification) => { const notification = Notification.create(rawNotification); @@ -60,8 +61,10 @@ export default class UserMenuMessagesList extends UserMenuNotificationsList { }) ); }); + const topics = data.topics.map((t) => Topic.create(t)); + await Topic.applyTransformations(topics); content.push( - ...data.topics.map((topic) => { + ...topics.map((topic) => { return new UserMenuMessageItem({ message: topic }); }) ); diff --git a/app/assets/javascripts/discourse/app/lib/model-transformers.js b/app/assets/javascripts/discourse/app/lib/model-transformers.js new file mode 100644 index 00000000000..39565842332 --- /dev/null +++ b/app/assets/javascripts/discourse/app/lib/model-transformers.js @@ -0,0 +1,34 @@ +import { consolePrefix } from "discourse/lib/source-identifier"; + +let modelTransformersMap = {}; + +export function registerModelTransformer(modelName, func) { + if (!modelTransformersMap[modelName]) { + modelTransformersMap[modelName] = []; + } + const transformer = { + prefix: consolePrefix(), + execute: func, + }; + modelTransformersMap[modelName].push(transformer); +} + +export async function applyModelTransformations(modelName, models) { + for (const transformer of modelTransformersMap[modelName] || []) { + try { + await transformer.execute(models); + } catch (err) { + // eslint-disable-next-line no-console + console.error( + transformer.prefix, + `transformer for the \`${modelName}\` model failed with:`, + err, + err.stack + ); + } + } +} + +export function resetModelTransformers() { + modelTransformersMap = {}; +} diff --git a/app/assets/javascripts/discourse/app/lib/plugin-api.js b/app/assets/javascripts/discourse/app/lib/plugin-api.js index bc36114503f..666d5624cc0 100644 --- a/app/assets/javascripts/discourse/app/lib/plugin-api.js +++ b/app/assets/javascripts/discourse/app/lib/plugin-api.js @@ -100,6 +100,7 @@ import { addSidebarSection } from "discourse/lib/sidebar/custom-sections"; import DiscourseURL from "discourse/lib/url"; import { registerNotificationTypeRenderer } from "discourse/lib/notification-types-manager"; import { registerUserMenuTab } from "discourse/lib/user-menu/tab"; +import { registerModelTransformer } from "discourse/lib/model-transformers"; // If you add any methods to the API ensure you bump up the version number // based on Semantic Versioning 2.0.0. Please update the changelog at @@ -1926,6 +1927,37 @@ class PluginApi { registerUserMenuTab(func) { registerUserMenuTab(func); } + + /** + * EXPERIMENTAL. Do not use. + * Apply transformation using a callback on a list of model instances of a + * specific type. Currently, this API only works on lists rendered in the + * user menu such as notifications, bookmarks and topics (i.e. messages), but + * it may be extended to other lists in other parts of the app. + * + * You can pass an `async` callback to this API and it'll be `await`ed and + * block rendering until the callback finishes executing. + * + * ``` + * api.registerModelTransformer("topic", async (topics) => { + * for (const topic of topics) { + * const decryptedTitle = await decryptTitle(topic.encrypted_title); + * if (decryptedTitle) { + * topic.fancy_title = decryptedTitle; + * } + * } + * }); + * ``` + * + * @callback registerModelTransformerCallback + * @param {Object[]} A list of model instances + * + * @param {string} modelName - Model type on which transformation should be applied. Currently the only valid type is "topic". + * @param {registerModelTransformerCallback} transformer - Callback function that receives a list of model objects of the specified type and applies transformation on them. + */ + registerModelTransformer(modelName, transformer) { + registerModelTransformer(modelName, transformer); + } } // from http://stackoverflow.com/questions/6832596/how-to-compare-software-version-number-using-js-only-number diff --git a/app/assets/javascripts/discourse/app/models/topic.js b/app/assets/javascripts/discourse/app/models/topic.js index 53b4af4e209..1776e9f5d1e 100644 --- a/app/assets/javascripts/discourse/app/models/topic.js +++ b/app/assets/javascripts/discourse/app/models/topic.js @@ -22,6 +22,7 @@ import { popupAjaxError } from "discourse/lib/ajax-error"; import { resolveShareUrl } from "discourse/helpers/share-url"; import DiscourseURL, { userPath } from "discourse/lib/url"; import deprecated from "discourse-common/lib/deprecated"; +import { applyModelTransformations } from "discourse/lib/model-transformers"; export function loadTopicView(topic, args) { const data = deepMerge({}, args); @@ -866,6 +867,10 @@ Topic.reopenClass({ return ajax(`/t/${topicId}/slow_mode`, { type: "PUT", data }); }, + + async applyTransformations(topics) { + await applyModelTransformations("topic", topics); + }, }); function moveResult(result) { diff --git a/app/assets/javascripts/discourse/tests/acceptance/user-menu-test.js b/app/assets/javascripts/discourse/tests/acceptance/user-menu-test.js index 902d3d661d1..72cbbc7311d 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/user-menu-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/user-menu-test.js @@ -14,6 +14,8 @@ import { withPluginApi } from "discourse/lib/plugin-api"; import { NOTIFICATION_TYPES } from "discourse/tests/fixtures/concerns/notification-types"; import UserMenuFixtures from "discourse/tests/fixtures/user-menu"; import TopicFixtures from "discourse/tests/fixtures/topic"; +import { Promise } from "rsvp"; +import { later } from "@ember/runloop"; import I18n from "I18n"; acceptance("User menu", function (needs) { @@ -187,6 +189,33 @@ acceptance("User menu", function (needs) { ); }); + test("messages tab applies model transformations registered by plugins", async function (assert) { + withPluginApi("0.1", (api) => { + api.registerModelTransformer("topic", (topics) => { + topics.forEach((topic) => { + topic.fancy_title = `pluginTransformer#1 ${topic.fancy_title}`; + }); + }); + api.registerModelTransformer("topic", async (topics) => { + // sleep 1 ms + await new Promise((resolve) => later(resolve, 1)); + topics.forEach((topic) => { + topic.fancy_title = `pluginTransformer#2 ${topic.fancy_title}`; + }); + }); + }); + + await visit("/"); + await click(".d-header-icons .current-user"); + await click("#user-menu-button-messages"); + + const messages = queryAll("#quick-access-messages ul li.message"); + assert.strictEqual( + messages[0].textContent.replace(/\s+/g, " ").trim(), + "mixtape pluginTransformer#2 pluginTransformer#1 BUG: Can not render emoji properly" + ); + }); + test("the profile tab", async function (assert) { updateCurrentUser({ draft_count: 13 }); await visit("/"); diff --git a/app/assets/javascripts/discourse/tests/fixtures/user-menu.js b/app/assets/javascripts/discourse/tests/fixtures/user-menu.js index a96893ea661..a645cce16a0 100644 --- a/app/assets/javascripts/discourse/tests/fixtures/user-menu.js +++ b/app/assets/javascripts/discourse/tests/fixtures/user-menu.js @@ -136,52 +136,6 @@ export default { primary_group_id: null, }, ], - fancy_title: "BUG: Can not render emoji properly :confused:", - slug: "bug-can-not-render-emoji-properly", - posts_count: 1, - reply_count: 0, - highest_post_number: 2, - image_url: null, - created_at: "2019-07-26T01:29:24.008Z", - last_posted_at: "2019-07-26T01:29:24.177Z", - bumped: true, - bumped_at: "2019-07-26T01:29:24.177Z", - unseen: false, - last_read_post_number: 2, - unread_posts: 0, - pinned: false, - unpinned: null, - visible: true, - closed: false, - archived: false, - notification_level: 3, - bookmarked: false, - bookmarks: [], - liked: false, - views: 5, - like_count: 0, - has_summary: false, - archetype: "private_message", - last_poster_username: "mixtape", - category_id: null, - pinned_globally: false, - featured_link: null, - posters: [ - { - extras: "latest single", - description: "Original Poster, Most Recent Poster", - user_id: 13, - primary_group_id: null, - }, - ], - participants: [ - { - extras: "latest", - description: null, - user_id: 13, - primary_group_id: null, - }, - ], } ], } diff --git a/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js b/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js index 741a8c23208..4de353d1435 100644 --- a/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js +++ b/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js @@ -75,6 +75,7 @@ import { resetSidebarSection } from "discourse/lib/sidebar/custom-sections"; import { resetNotificationTypeRenderers } from "discourse/lib/notification-types-manager"; import { resetUserMenuTabs } from "discourse/lib/user-menu/tab"; import { reset as resetLinkLookup } from "discourse/lib/link-lookup"; +import { resetModelTransformers } from "discourse/lib/model-transformers"; export function currentUser() { return User.create(sessionFixtures["/session/current.json"].current_user); @@ -206,6 +207,7 @@ export function testCleanup(container, app) { clearExtraHeaderIcons(); resetUserMenuTabs(); resetLinkLookup(); + resetModelTransformers(); } export function discourseModule(name, options) {