FEATURE: Add automatic before and after outlets to wrapper plugin outlets (#24254)

Recently, Discourse introduced the concept of wrapper plugin outlets, which enables plugins and theme-components lo replace the wrapped content:

```
        <PluginOutlet @name="wrapper-outlet-example" @outletArgs={{hash model=@model}}>
          <div>Overridable content</div>
        </PluginOutlet>
```

This commit adds automatic outlets that are placed `before` and `after wrapper plugin outlets. Connectors them can leverage these new automatic outlets to mount content at these positions, which greatly enhances the use case of the wrapper outlets.

These new auto outlets can be used in two ways:

- Using the standard folder base structure: the folder name that identifies the outlet in which the connector must be mounted must add the suffixes `__before`or `__after` to the outlet name. For the outlet in the example above, the connector should be placed into the `.../connectors/wrapper-outlet-example__before`or `.../connectors/wrapper-outlet-example__after`folders.

- Using API calls: this commit also introduces two new plugin APIs, `api.renderBeforeWrapperOutlet` and `renderAfterWrapperOutlet`. These new APIs can be used in the same way as `api.renderInOutlet`but will only work for wrapper outlets.

  For the outlet above when using these new APIs alongside the gjs file format, one could define a component to be placed before the content of the outlet like:

  ```
  api.renderBeforeWrapperOutlet('wrapper-outlet-example', <template>Hello from before the content</template>);
  ```

  or after:

  ```
  api.renderAfterWrapperOutlet('wrapper-outlet-example', <template>Hello from after the content</template>);
  ```
This commit is contained in:
Sérgio Saquetim 2024-02-22 15:25:34 -03:00 committed by GitHub
parent 1df473b530
commit 57ab42d4ca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 178 additions and 4 deletions

View File

@ -22,8 +22,16 @@
{{/if}}
{{/each}}
</this.wrapperComponent>
{{else if this.connectorsExist}}
{{else if (this.connectorsExist hasBlock=(has-block))}}
{{! The modern path: no wrapper element = no classic component }}
{{#if (has-block)}}
<PluginOutlet
@name={{concat @name "__before"}}
@outletArgs={{this.outletArgsWithDeprecations}}
/>
{{/if}}
{{#each (this.getConnectors hasBlock=(has-block)) as |c|}}
{{#if c.componentClass}}
<c.componentClass
@ -47,6 +55,13 @@
{{else}}
{{yield}}
{{/each}}
{{#if (has-block)}}
<PluginOutlet
@name={{concat @name "__after"}}
@outletArgs={{this.outletArgsWithDeprecations}}
/>
{{/if}}
{{else}}
{{yield}}
{{/if}}

View File

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

View File

@ -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', <template>Before the outlet</template>);
* ```
*
* 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', <template>After the outlet</template>);
* ```
*
* 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
*

View File

@ -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`<span class='before-result'>Before wrapped content</span>`
);
await render(hbs`
<PluginOutlet @name="outlet-with-default" @outletArgs={{hash shouldDisplay=true}}>
<span class='result'>Core implementation</span>
</PluginOutlet>
`);
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`<span class='before-result'>First connector before the wrapped content</span>`
);
registerTemporaryModule(
`${TEMPLATE_PREFIX}/outlet-with-default__before/my-connector2`,
hbs`<span class='before-result2'>Second connector before the wrapped content</span>`
);
await render(hbs`
<PluginOutlet @name="outlet-with-default" @outletArgs={{hash shouldDisplay=true}}>
<span class='result'>Core implementation</span>
</PluginOutlet>
`);
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`<span class='after-result'>After wrapped content</span>`
);
await render(hbs`
<PluginOutlet @name="outlet-with-default" @outletArgs={{hash shouldDisplay=true}}>
<span class='result'>Core implementation</span>
</PluginOutlet>
`);
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`<span class='after-result'>First connector after the wrapped content</span>`
);
registerTemporaryModule(
`${TEMPLATE_PREFIX}/outlet-with-default__after/my-connector2`,
hbs`<span class='after-result2'>Second connector after the wrapped content</span>`
);
await render(hbs`
<PluginOutlet @name="outlet-with-default" @outletArgs={{hash shouldDisplay=true}}>
<span class='result'>Core implementation</span>
</PluginOutlet>
`);
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");
});
}
);

View File

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