FEATURE: adds a new plugin api to decorate plugin outlets (#8937)

FEATURE: adds a new plugin api to decorate plugin outlets (#8937)

api.decoratePluginOutlet(
  "discovery-list-container-top",
  elem => {
    if (elem.classList.contains("foo")) {
      elem.style.backgroundColor = "yellow";
    }
  }
);
diff --git a/app/assets/javascripts/discourse/components/plugin-connector.js.es6 b/app/assets/javascripts/discourse/components/plugin-connector.js.es6
index 4fbb83e30e..b89e242763 100644
--- a/app/assets/javascripts/discourse/components/plugin-connector.js.es6
+++ b/app/assets/javascripts/discourse/components/plugin-connector.js.es6
@@ -2,6 +2,19 @@ import Component from "@ember/component";
 import { defineProperty, computed } from "@ember/object";
 import deprecated from "discourse-common/lib/deprecated";
 import { buildArgsWithDeprecations } from "discourse/lib/plugin-connectors";
+import { afterRender } from "discourse-common/utils/decorators";
+
+let _decorators = {};
+
+// Don't call this directly: use `plugin-api/decoratePluginOutlet`
+export function addPluginOutletDecorator(outletName, callback) {
+  _decorators[outletName] = _decorators[outletName] || [];
+  _decorators[outletName].push(callback);
+}
+
+export function resetDecorators() {
+  _decorators = {};
+}
 
 export default Component.extend({
   init() {
@@ -45,6 +58,19 @@ export default Component.extend({
     connectorClass.setupComponent.call(this, merged, this);
   },
 
+  didReceiveAttrs() {
+    this._super(...arguments);
+
+    this._decoratePluginOutlets();
+  },
+
+  @afterRender
+  _decoratePluginOutlets() {
+    (_decorators[this.connector.outletName] || []).forEach(dec =>
+      dec(this.element, this.args)
+    );
+  },
+
   willDestroyElement() {
     this._super(...arguments);
 
diff --git a/app/assets/javascripts/discourse/lib/plugin-api.js.es6 b/app/assets/javascripts/discourse/lib/plugin-api.js.es6
index 09ebe31f43..2fcedd76b9 100644
--- a/app/assets/javascripts/discourse/lib/plugin-api.js.es6
+++ b/app/assets/javascripts/discourse/lib/plugin-api.js.es6
@@ -1,6 +1,7 @@
 import deprecated from "discourse-common/lib/deprecated";
 import { iconNode } from "discourse-common/lib/icon-library";
 import { addDecorator } from "discourse/widgets/post-cooked";
+import { addPluginOutletDecorator } from "discourse/components/plugin-connector";
 import ComposerEditor from "discourse/components/composer-editor";
 import DiscourseBanner from "discourse/components/discourse-banner";
 import { addButton } from "discourse/widgets/post-menu";
@@ -51,7 +52,7 @@ import Composer from "discourse/models/composer";
 import { on } from "@ember/object/evented";
 
 // If you add any methods to the API ensure you bump up this number
-const PLUGIN_API_VERSION = "0.8.37";
+const PLUGIN_API_VERSION = "0.8.38";
 
 class PluginApi {
   constructor(version, container) {
@@ -983,6 +984,29 @@ class PluginApi {
   addGlobalNotice(id, text, options) {
     addGlobalNotice(id, text, options);
   }
+
+  /**
+   * Used for decorating the rendered HTML content of a plugin-outlet after it's been rendered
+   *
+   * `callback` will be called when it is time to decorate it.
+   *
+   * For example, to add a yellow background to a connector:
+   *
+   * `‍``
+   * api.decoratePluginOutlet(
+   *   "discovery-list-container-top",
+   *   (elem, args) => {
+   *     if (elem.classList.contains("foo")) {
+   *       elem.style.backgroundColor = "yellow";
+   *     }
+   *   }
+   * );
+   * `‍``
+   *
+   **/
+  decoratePluginOutlet(outletName, callback, opts) {
+    addPluginOutletDecorator(outletName, callback, opts || {});
+  }
 }
 
 let _pluginv01;
diff --git a/app/assets/javascripts/discourse/lib/plugin-connectors.js.es6 b/app/assets/javascripts/discourse/lib/plugin-connectors.js.es6
index 20c356e99d..8c6efa86a2 100644
--- a/app/assets/javascripts/discourse/lib/plugin-connectors.js.es6
+++ b/app/assets/javascripts/discourse/lib/plugin-connectors.js.es6
@@ -73,6 +73,7 @@ function buildConnectorCache() {
     _connectorCache[outletName] = _connectorCache[outletName] || [];
 
     _connectorCache[outletName].push({
+      outletName,
       templateName: resource.replace("javascripts/", ""),
       template: Ember.TEMPLATES[resource],
       classNames: `${outletName}-outlet ${uniqueName}`,
diff --git a/test/javascripts/acceptance/plugin-outlet-decorator-test.js.es6 b/test/javascripts/acceptance/plugin-outlet-decorator-test.js.es6
new file mode 100644
index 0000000000..38600b6e43
--- /dev/null
+++ b/test/javascripts/acceptance/plugin-outlet-decorator-test.js.es6
@@ -0,0 +1,61 @@
+import { acceptance } from "helpers/qunit-helpers";
+import { withPluginApi } from "discourse/lib/plugin-api";
+
+const PREFIX = "javascripts/single-test/connectors";
+acceptance("Plugin Outlet - Decorator", {
+  loggedIn: true,
+
+  beforeEach() {
+    Ember.TEMPLATES[
+      `${PREFIX}/discovery-list-container-top/foo`
+    ] = Ember.HTMLBars.compile("FOO");
+    Ember.TEMPLATES[
+      `${PREFIX}/discovery-list-container-top/bar`
+    ] = Ember.HTMLBars.compile("BAR");
+
+    withPluginApi("0.8.38", api => {
+      api.decoratePluginOutlet(
+        "discovery-list-container-top",
+        (elem, args) => {
+          if (elem.classList.contains("foo")) {
+            elem.style.backgroundColor = "yellow";
+
+            if (args.category) {
+              elem.classList.add("in-category");
+            } else {
+              elem.classList.remove("in-category");
+            }
+          }
+        },
+        { id: "yellow-decorator" }
+      );
+    });
+  },
+
+  afterEach() {
+    delete Ember.TEMPLATES[`${PREFIX}/discovery-list-container-top/foo`];
+    delete Ember.TEMPLATES[`${PREFIX}/discovery-list-container-top/bar`];
+  }
+});
+
+QUnit.test(
+  "Calls the plugin callback with the rendered outlet",
+  async assert => {
+    await visit("/");
+
+    const fooConnector = find(".discovery-list-container-top-outlet.foo ")[0];
+    const barConnector = find(".discovery-list-container-top-outlet.bar ")[0];
+
+    assert.ok(exists(fooConnector));
+    assert.equal(fooConnector.style.backgroundColor, "yellow");
+    assert.equal(barConnector.style.backgroundColor, "");
+
+    await visit("/c/bug");
+
+    assert.ok(fooConnector.classList.contains("in-category"));
+
+    await visit("/");
+
+    assert.notOk(fooConnector.classList.contains("in-category"));
+  }
+);
diff --git a/test/javascripts/helpers/qunit-helpers.js.es6 b/test/javascripts/helpers/qunit-helpers.js.es6
index 25dcb2a3d8..34d92149b7 100644
--- a/test/javascripts/helpers/qunit-helpers.js.es6
+++ b/test/javascripts/helpers/qunit-helpers.js.es6
@@ -18,6 +18,7 @@ import { initSearchData } from "discourse/widgets/search-menu";
 import { resetDecorators } from "discourse/widgets/widget";
 import { resetWidgetCleanCallbacks } from "discourse/components/mount-widget";
 import { resetDecorators as resetPostCookedDecorators } from "discourse/widgets/post-cooked";
+import { resetDecorators as resetPluginOutletDecorators } from "discourse/components/plugin-connector";
 import { resetCache as resetOneboxCache } from "pretty-text/oneboxer";
 import { resetCustomPostMessageCallbacks } from "discourse/controllers/topic";
 import User from "discourse/models/user";
@@ -128,6 +129,7 @@ export function acceptance(name, options) {
       initSearchData();
       resetDecorators();
       resetPostCookedDecorators();
+      resetPluginOutletDecorators();
       resetOneboxCache();
       resetCustomPostMessageCallbacks();
       Discourse._runInitializer("instanceInitializers", function(

GitHub sha: 64051594

This commit appears in #8937 which was approved by @ZogStriP and @eviltrout. It was merged by @jjaffeux.