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', ); + * ``` + * + * 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', ); + * ``` + * + * 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.