FEATURE: Introduce `api.renderInOutlet` (#23719)

Until now, plugins/themes had to follow very specific directory structures to set up plugin outlet connectors. This commit introduces a new `api.renderInOutlet` API which makes things much more flexible. Any Ember component definition can be passed to this API, and will then be rendered into the named outlet.

For example:

```javascript
import MyComponent from "discourse/plugins/my-plugin/components/my-component";
api.renderInOutlet('user-profile-primary', MyComponent);
```

When using this API alongside the gjs file format, components can be defined inline like

```javascript
api.renderInOutlet('user-profile-primary', <template>Hello world</template>);
```
This commit is contained in:
David Taylor 2023-10-05 11:56:55 +01:00 committed by GitHub
parent 8a7b5b00ea
commit c00fd3e17d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 92 additions and 15 deletions

View File

@ -77,7 +77,10 @@ import { addUsernameSelectorDecorator } from "discourse/helpers/decorate-usernam
import { addWidgetCleanCallback } from "discourse/components/mount-widget";
import deprecated from "discourse-common/lib/deprecated";
import { disableNameSuppression } from "discourse/widgets/poster-name";
import { extraConnectorClass } from "discourse/lib/plugin-connectors";
import {
extraConnectorClass,
extraConnectorComponent,
} from "discourse/lib/plugin-connectors";
import { getOwnerWithFallback } from "discourse-common/lib/get-owner";
import { h } from "virtual-dom";
import { includeAttributes } from "discourse/lib/transform-post";
@ -134,7 +137,7 @@ import { isTesting } from "discourse-common/config/environment";
// 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.12.0";
export const PLUGIN_API_VERSION = "1.13.0";
// This helper prevents us from applying the same `modifyClass` over and over in test mode.
function canModify(klass, type, resolverName, changes) {
@ -940,6 +943,31 @@ class PluginApi {
extraConnectorClass(`${outletName}/${connectorName}`, klass);
}
/**
* Register a component to be rendered in a particular outlet.
*
* For example, if the outlet is `user-profile-primary`, you could register
* a component like
*
* ```javascript
* import MyComponent from "discourse/plugins/my-plugin/components/my-component";
* api.renderInOutlet('user-profile-primary', MyComponent);
* ```
*
* Alternatively, a component could be defined inline using gjs:
*
* ```javascript
* api.renderInOutlet('user-profile-primary', <template>Hello world</template>);
* ```
*
* @param {string} outletName - Name of plugin outlet to render into
* @param {Component} klass - Component class definition to be rendered
*
*/
renderInOutlet(outletName, klass) {
extraConnectorComponent(outletName, klass);
}
/**
* Register a button to display at the bottom of a topic
*

View File

@ -10,9 +10,11 @@ import templateOnly from "@ember/component/template-only";
let _connectorCache;
let _rawConnectorCache;
let _extraConnectorClasses = {};
let _extraConnectorComponents = {};
export function resetExtraClasses() {
_extraConnectorClasses = {};
_extraConnectorComponents = {};
}
// Note: In plugins, define a class by path and it will be wired up automatically
@ -21,6 +23,17 @@ export function extraConnectorClass(name, obj) {
_extraConnectorClasses[name] = obj;
}
export function extraConnectorComponent(outletName, klass) {
if (!hasInternalComponentManager(klass)) {
throw new Error("klass is not an Ember component");
}
if (outletName.includes("/")) {
throw new Error("invalid outlet name");
}
_extraConnectorComponents[outletName] ??= [];
_extraConnectorComponents[outletName].push(klass);
}
const OUTLET_REGEX =
/^discourse(\/[^\/]+)*?(?<template>\/templates)?\/connectors\/(?<outlet>[^\/]+)\/(?<name>[^\/\.]+)$/;
@ -87,7 +100,9 @@ class ConnectorInfo {
get connectorClass() {
if (this.classModule) {
return require(this.classModule).default;
return this.classModule;
} else if (this.classModuleName) {
return require(this.classModuleName).default;
} else {
return _extraConnectorClasses[`${this.outletName}/${this.connectorName}`];
}
@ -101,7 +116,7 @@ class ConnectorInfo {
get humanReadableName() {
return `${this.outletName}/${this.connectorName} (${
this.classModule || this.templateModule
this.classModuleName || this.templateModule
})`;
}
@ -160,7 +175,7 @@ function buildConnectorCache() {
if (isTemplate) {
info.templateModule = moduleName;
} else {
info.classModule = moduleName;
info.classModuleName = moduleName;
}
}
);
@ -169,6 +184,18 @@ function buildConnectorCache() {
_connectorCache[info.outletName] ??= [];
_connectorCache[info.outletName].push(info);
}
for (const [outletName, components] of Object.entries(
_extraConnectorComponents
)) {
for (const klass of components) {
const info = new ConnectorInfo(outletName);
info.classModule = klass;
_connectorCache[info.outletName] ??= [];
_connectorCache[info.outletName].push(info);
}
}
}
export function connectorsExist(outletName) {

View File

@ -3,14 +3,16 @@ import { count, exists, query } from "discourse/tests/helpers/qunit-helpers";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { click, render, settled } from "@ember/test-helpers";
import { action } from "@ember/object";
import { extraConnectorClass } from "discourse/lib/plugin-connectors";
import {
extraConnectorClass,
extraConnectorComponent,
} from "discourse/lib/plugin-connectors";
import { hbs } from "ember-cli-htmlbars";
import { registerTemporaryModule } from "discourse/tests/helpers/temporary-module-helper";
import { getOwner } from "@ember/application";
import Component from "@glimmer/component";
import templateOnly from "@ember/component/template-only";
import { withSilencedDeprecationsAsync } from "discourse-common/lib/deprecated";
import { setComponentTemplate } from "@glimmer/manager";
import sinon from "sinon";
const TEMPLATE_PREFIX = "discourse/plugins/some-plugin/templates/connectors";
@ -57,13 +59,13 @@ module("Integration | Component | plugin-outlet", function (hooks) {
registerTemporaryModule(
`${TEMPLATE_PREFIX}/test-name/hello`,
hbs`<span class='hello-username'>{{this.username}}</span>
<button class='say-hello' {{on "click" (action "sayHello")}}></button>
<button class='say-hello-using-this' {{on "click" this.sayHello}}></button>
<button type="button" class='say-hello' {{on "click" (action "sayHello")}}></button>
<button type="button" class='say-hello-using-this' {{on "click" this.sayHello}}></button>
<span class='hello-result'>{{this.hello}}</span>`
);
registerTemporaryModule(
`${TEMPLATE_PREFIX}/test-name/hi`,
hbs`<button class='say-hi' {{on "click" (action "sayHi")}}></button>
hbs`<button type="button" class='say-hi' {{on "click" (action "sayHi")}}></button>
<span class='hi-result'>{{this.hi}}</span>`
);
registerTemporaryModule(
@ -427,13 +429,9 @@ module(
setupRenderingTest(hooks);
hooks.beforeEach(function () {
const template = hbs`<span class='gjs-test'>Hello world</span>`;
const component = templateOnly();
setComponentTemplate(template, component);
registerTemporaryModule(
`${CLASS_PREFIX}/test-name/my-connector`,
component
<template><span class="gjs-test">Hello world</span></template>
);
});
@ -444,3 +442,21 @@ module(
});
}
);
module(
"Integration | Component | plugin-outlet | extraConnectorComponent",
function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
extraConnectorComponent("test-name", <template>
<span class="gjs-test">Hello world from gjs</span>
</template>);
});
test("renders the component in the outlet", async function (assert) {
await render(hbs`<PluginOutlet @name="test-name" />`);
assert.dom(".gjs-test").hasText("Hello world from gjs");
});
}
);

View File

@ -7,6 +7,12 @@ 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.13.0] - 2023-10-05
### Added
- Introduces `renderInOutlet` API for rendering components into plugin outlets
## [1.12.0] - 2023-09-06
### Added