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 I18n from "I18n";
import UserMenuNotificationItem from "discourse/lib/user-menu/notification-item"; import UserMenuNotificationItem from "discourse/lib/user-menu/notification-item";
import UserMenuMessageItem from "discourse/lib/user-menu/message-item"; import UserMenuMessageItem from "discourse/lib/user-menu/message-item";
import Topic from "discourse/models/topic";
export default class UserMenuMessagesList extends UserMenuNotificationsList { export default class UserMenuMessagesList extends UserMenuNotificationsList {
get dismissTypes() { get dismissTypes() {
@ -47,7 +48,7 @@ export default class UserMenuMessagesList extends UserMenuNotificationsList {
fetchItems() { fetchItems() {
return ajax( return ajax(
`/u/${this.currentUser.username}/user-menu-private-messages` `/u/${this.currentUser.username}/user-menu-private-messages`
).then((data) => { ).then(async (data) => {
const content = []; const content = [];
data.notifications.forEach((rawNotification) => { data.notifications.forEach((rawNotification) => {
const notification = Notification.create(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( content.push(
...data.topics.map((topic) => { ...topics.map((topic) => {
return new UserMenuMessageItem({ message: 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 DiscourseURL from "discourse/lib/url";
import { registerNotificationTypeRenderer } from "discourse/lib/notification-types-manager"; import { registerNotificationTypeRenderer } from "discourse/lib/notification-types-manager";
import { registerUserMenuTab } from "discourse/lib/user-menu/tab"; 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 // 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 // based on Semantic Versioning 2.0.0. Please update the changelog at
@ -1926,6 +1927,37 @@ class PluginApi {
registerUserMenuTab(func) { registerUserMenuTab(func) {
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 // 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 { resolveShareUrl } from "discourse/helpers/share-url";
import DiscourseURL, { userPath } from "discourse/lib/url"; import DiscourseURL, { userPath } from "discourse/lib/url";
import deprecated from "discourse-common/lib/deprecated"; import deprecated from "discourse-common/lib/deprecated";
import { applyModelTransformations } from "discourse/lib/model-transformers";
export function loadTopicView(topic, args) { export function loadTopicView(topic, args) {
const data = deepMerge({}, args); const data = deepMerge({}, args);
@ -866,6 +867,10 @@ Topic.reopenClass({
return ajax(`/t/${topicId}/slow_mode`, { type: "PUT", data }); return ajax(`/t/${topicId}/slow_mode`, { type: "PUT", data });
}, },
async applyTransformations(topics) {
await applyModelTransformations("topic", topics);
},
}); });
function moveResult(result) { 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 { NOTIFICATION_TYPES } from "discourse/tests/fixtures/concerns/notification-types";
import UserMenuFixtures from "discourse/tests/fixtures/user-menu"; import UserMenuFixtures from "discourse/tests/fixtures/user-menu";
import TopicFixtures from "discourse/tests/fixtures/topic"; import TopicFixtures from "discourse/tests/fixtures/topic";
import { Promise } from "rsvp";
import { later } from "@ember/runloop";
import I18n from "I18n"; import I18n from "I18n";
acceptance("User menu", function (needs) { 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) { test("the profile tab", async function (assert) {
updateCurrentUser({ draft_count: 13 }); updateCurrentUser({ draft_count: 13 });
await visit("/"); await visit("/");

View File

@ -136,52 +136,6 @@ export default {
primary_group_id: null, 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 { resetNotificationTypeRenderers } from "discourse/lib/notification-types-manager";
import { resetUserMenuTabs } from "discourse/lib/user-menu/tab"; import { resetUserMenuTabs } from "discourse/lib/user-menu/tab";
import { reset as resetLinkLookup } from "discourse/lib/link-lookup"; import { reset as resetLinkLookup } from "discourse/lib/link-lookup";
import { resetModelTransformers } from "discourse/lib/model-transformers";
export function currentUser() { export function currentUser() {
return User.create(sessionFixtures["/session/current.json"].current_user); return User.create(sessionFixtures["/session/current.json"].current_user);
@ -206,6 +207,7 @@ export function testCleanup(container, app) {
clearExtraHeaderIcons(); clearExtraHeaderIcons();
resetUserMenuTabs(); resetUserMenuTabs();
resetLinkLookup(); resetLinkLookup();
resetModelTransformers();
} }
export function discourseModule(name, options) { export function discourseModule(name, options) {