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.
This commit is contained in:
Osama Sayegh 2022-08-25 15:41:58 +03:00 committed by GitHub
parent 9ebebfb4cc
commit 40fd82e2d1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 107 additions and 48 deletions

View File

@ -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 });
})
);

View File

@ -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 = {};
}

View File

@ -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

View File

@ -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) {

View File

@ -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("/");

View File

@ -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,
},
],
}
],
}

View File

@ -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) {