diff --git a/app/assets/javascripts/discourse/app/components/plugin-outlet.hbs b/app/assets/javascripts/discourse/app/components/plugin-outlet.hbs
index 15c15cb1c29..496e6a6a682 100644
--- a/app/assets/javascripts/discourse/app/components/plugin-outlet.hbs
+++ b/app/assets/javascripts/discourse/app/components/plugin-outlet.hbs
@@ -22,8 +22,16 @@
{{/if}}
{{/each}}
-{{else if this.connectorsExist}}
+{{else if (this.connectorsExist hasBlock=(has-block))}}
{{! The modern path: no wrapper element = no classic component }}
+
+ {{#if (has-block)}}
+
+ {{/if}}
+
{{#each (this.getConnectors hasBlock=(has-block)) as |c|}}
{{#if c.componentClass}}
+ {{/if}}
{{else}}
{{yield}}
{{/if}}
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/app/components/plugin-outlet.js b/app/assets/javascripts/discourse/app/components/plugin-outlet.js
index 5c621d18e10..e82a18374c6 100644
--- a/app/assets/javascripts/discourse/app/components/plugin-outlet.js
+++ b/app/assets/javascripts/discourse/app/components/plugin-outlet.js
@@ -102,8 +102,14 @@ export default class PluginOutletComponent extends GlimmerComponentWithDeprecate
return connectors;
}
- get connectorsExist() {
- return connectorsExist(this.args.name);
+ @bind
+ connectorsExist({ hasBlock } = {}) {
+ return (
+ connectorsExist(this.args.name) ||
+ (hasBlock &&
+ (connectorsExist(this.args.name + "__before") ||
+ connectorsExist(this.args.name + "__after")))
+ );
}
// Traditionally, pluginOutlets had an argument named 'args'. However, that name is reserved
diff --git a/app/assets/javascripts/discourse/app/lib/plugin-api.js b/app/assets/javascripts/discourse/app/lib/plugin-api.js
index 715aee9c39f..8c61ed1ffdf 100644
--- a/app/assets/javascripts/discourse/app/lib/plugin-api.js
+++ b/app/assets/javascripts/discourse/app/lib/plugin-api.js
@@ -142,7 +142,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.25.0";
+export const PLUGIN_API_VERSION = "1.26.0";
// This helper prevents us from applying the same `modifyClass` over and over in test mode.
function canModify(klass, type, resolverName, changes) {
@@ -1005,6 +1005,72 @@ class PluginApi {
extraConnectorComponent(outletName, klass);
}
+ /**
+ * Render a component before the content of a wrapper outlet and does not override it's content
+ *
+ * For example, if the outlet is `discovery-list-area`, you could register
+ * a component like
+ *
+ * ```javascript
+ * import MyComponent from "discourse/plugins/my-plugin/components/my-component";
+ * api.renderBeforeWrapperOutlet('discovery-list-area', MyComponent);
+ * ```
+ *
+ * Alternatively, a component could be defined inline using gjs:
+ *
+ * ```javascript
+ * api.renderBeforeWrapperOutlet('discovery-list-area', Before the outlet);
+ * ```
+ *
+ * Note:
+ * - the content of the outlet is not overridden when using this API, and unlike the main outlet,
+ * multiple connectors can be registered for the same outlet.
+ * - this API only works with wrapper outlets. It won't have any effect on standard outlets.
+ * - when passing a component definition to an outlet like this, the default
+ * `@connectorTagName` of the outlet is not used. If you need a wrapper element, you'll
+ * need to add it to your component's template.
+ *
+ * @param {string} outletName - Name of plugin outlet to render into
+ * @param {Component} klass - Component class definition to be rendered
+ *
+ */
+ renderBeforeWrapperOutlet(outletName, klass) {
+ this.renderInOutlet(`${outletName}__before`, klass);
+ }
+
+ /**
+ * Render a component after the content of a wrapper outlet and does not override it's content
+ *
+ * For example, if the outlet is `discovery-list-area`, you could register
+ * a component like
+ *
+ * ```javascript
+ * import MyComponent from "discourse/plugins/my-plugin/components/my-component";
+ * api.renderAfterWrapperOutlet('discovery-list-area', MyComponent);
+ * ```
+ *
+ * Alternatively, a component could be defined inline using gjs:
+ *
+ * ```javascript
+ * api.renderAfterWrapperOutlet('discovery-list-area', After the outlet);
+ * ```
+ *
+ * Note:
+ * - the content of the outlet is not overridden when using this API, and unlike the main outlet,
+ * multiple connectors can be registered for the same outlet.
+ * - this API only works with wrapper outlets. It won't have any effect on standard outlets.
+ * - when passing a component definition to an outlet like this, the default
+ * `@connectorTagName` of the outlet is not used. If you need a wrapper element, you'll
+ * need to add it to your component's template.
+ *
+ * @param {string} outletName - Name of plugin outlet to render into
+ * @param {Component} klass - Component class definition to be rendered
+ *
+ */
+ renderAfterWrapperOutlet(outletName, klass) {
+ this.renderInOutlet(`${outletName}__after`, klass);
+ }
+
/**
* Register a button to display at the bottom of a topic
*
diff --git a/app/assets/javascripts/discourse/tests/integration/components/plugin-outlet-test.gjs b/app/assets/javascripts/discourse/tests/integration/components/plugin-outlet-test.gjs
index 6f41ab0f6c0..1114ca662ab 100644
--- a/app/assets/javascripts/discourse/tests/integration/components/plugin-outlet-test.gjs
+++ b/app/assets/javascripts/discourse/tests/integration/components/plugin-outlet-test.gjs
@@ -226,6 +226,88 @@ module("Integration | Component | plugin-outlet", function (hooks) {
.dom(".broken-theme-alert-banner")
.exists("Error banner is shown to admins");
});
+
+ test("can render content in a automatic outlet generated before the wrapped content", async function (assert) {
+ registerTemporaryModule(
+ `${TEMPLATE_PREFIX}/outlet-with-default__before/my-connector`,
+ hbs`Before wrapped content`
+ );
+
+ await render(hbs`
+
+ Core implementation
+
+ `);
+
+ assert.dom(".result").hasText("Plugin implementation");
+ assert.dom(".before-result").hasText("Before wrapped content");
+ });
+
+ test("can render multiple connector `before` the same wrapped content", async function (assert) {
+ registerTemporaryModule(
+ `${TEMPLATE_PREFIX}/outlet-with-default__before/my-connector`,
+ hbs`First connector before the wrapped content`
+ );
+ registerTemporaryModule(
+ `${TEMPLATE_PREFIX}/outlet-with-default__before/my-connector2`,
+ hbs`Second connector before the wrapped content`
+ );
+
+ await render(hbs`
+
+ Core implementation
+
+ `);
+
+ assert.dom(".result").hasText("Plugin implementation");
+ assert
+ .dom(".before-result")
+ .hasText("First connector before the wrapped content");
+ assert
+ .dom(".before-result2")
+ .hasText("Second connector before the wrapped content");
+ });
+
+ test("can render content in a automatic outlet generated after the wrapped content", async function (assert) {
+ registerTemporaryModule(
+ `${TEMPLATE_PREFIX}/outlet-with-default__after/my-connector`,
+ hbs`After wrapped content`
+ );
+
+ await render(hbs`
+
+ Core implementation
+
+ `);
+
+ assert.dom(".result").hasText("Plugin implementation");
+ assert.dom(".after-result").hasText("After wrapped content");
+ });
+
+ test("can render multiple connector `after` the same wrapped content", async function (assert) {
+ registerTemporaryModule(
+ `${TEMPLATE_PREFIX}/outlet-with-default__after/my-connector`,
+ hbs`First connector after the wrapped content`
+ );
+ registerTemporaryModule(
+ `${TEMPLATE_PREFIX}/outlet-with-default__after/my-connector2`,
+ hbs`Second connector after the wrapped content`
+ );
+
+ await render(hbs`
+
+ Core implementation
+
+ `);
+
+ assert.dom(".result").hasText("Plugin implementation");
+ assert
+ .dom(".after-result")
+ .hasText("First connector after the wrapped content");
+ assert
+ .dom(".after-result2")
+ .hasText("Second connector after the wrapped content");
+ });
}
);
diff --git a/docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md b/docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md
index 9e826ff51e6..97ef5f3b1a0 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.26.0] - 2024-02-21
+
+- Added `renderBeforeWrapperOutlet` which is used for rendering components before the content of wrapper plugin outlets
+- Added `renderAfterWrapperOutlet` which is used for rendering components after the content of wrapper plugin outlets
+
## [1.25.0] - 2024-02-05
- Added `addComposerImageWrapperButton` which is used to add a custom button to the composer preview's image wrapper that appears on hover of an uploaded image.