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:
parent
8a7b5b00ea
commit
c00fd3e17d
|
@ -77,7 +77,10 @@ import { addUsernameSelectorDecorator } from "discourse/helpers/decorate-usernam
|
||||||
import { addWidgetCleanCallback } from "discourse/components/mount-widget";
|
import { addWidgetCleanCallback } from "discourse/components/mount-widget";
|
||||||
import deprecated from "discourse-common/lib/deprecated";
|
import deprecated from "discourse-common/lib/deprecated";
|
||||||
import { disableNameSuppression } from "discourse/widgets/poster-name";
|
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 { getOwnerWithFallback } from "discourse-common/lib/get-owner";
|
||||||
import { h } from "virtual-dom";
|
import { h } from "virtual-dom";
|
||||||
import { includeAttributes } from "discourse/lib/transform-post";
|
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
|
// docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md whenever you change the version
|
||||||
// using the format described at https://keepachangelog.com/en/1.0.0/.
|
// 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.
|
// This helper prevents us from applying the same `modifyClass` over and over in test mode.
|
||||||
function canModify(klass, type, resolverName, changes) {
|
function canModify(klass, type, resolverName, changes) {
|
||||||
|
@ -940,6 +943,31 @@ class PluginApi {
|
||||||
extraConnectorClass(`${outletName}/${connectorName}`, klass);
|
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
|
* Register a button to display at the bottom of a topic
|
||||||
*
|
*
|
||||||
|
|
|
@ -10,9 +10,11 @@ import templateOnly from "@ember/component/template-only";
|
||||||
let _connectorCache;
|
let _connectorCache;
|
||||||
let _rawConnectorCache;
|
let _rawConnectorCache;
|
||||||
let _extraConnectorClasses = {};
|
let _extraConnectorClasses = {};
|
||||||
|
let _extraConnectorComponents = {};
|
||||||
|
|
||||||
export function resetExtraClasses() {
|
export function resetExtraClasses() {
|
||||||
_extraConnectorClasses = {};
|
_extraConnectorClasses = {};
|
||||||
|
_extraConnectorComponents = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: In plugins, define a class by path and it will be wired up automatically
|
// 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;
|
_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 =
|
const OUTLET_REGEX =
|
||||||
/^discourse(\/[^\/]+)*?(?<template>\/templates)?\/connectors\/(?<outlet>[^\/]+)\/(?<name>[^\/\.]+)$/;
|
/^discourse(\/[^\/]+)*?(?<template>\/templates)?\/connectors\/(?<outlet>[^\/]+)\/(?<name>[^\/\.]+)$/;
|
||||||
|
|
||||||
|
@ -87,7 +100,9 @@ class ConnectorInfo {
|
||||||
|
|
||||||
get connectorClass() {
|
get connectorClass() {
|
||||||
if (this.classModule) {
|
if (this.classModule) {
|
||||||
return require(this.classModule).default;
|
return this.classModule;
|
||||||
|
} else if (this.classModuleName) {
|
||||||
|
return require(this.classModuleName).default;
|
||||||
} else {
|
} else {
|
||||||
return _extraConnectorClasses[`${this.outletName}/${this.connectorName}`];
|
return _extraConnectorClasses[`${this.outletName}/${this.connectorName}`];
|
||||||
}
|
}
|
||||||
|
@ -101,7 +116,7 @@ class ConnectorInfo {
|
||||||
|
|
||||||
get humanReadableName() {
|
get humanReadableName() {
|
||||||
return `${this.outletName}/${this.connectorName} (${
|
return `${this.outletName}/${this.connectorName} (${
|
||||||
this.classModule || this.templateModule
|
this.classModuleName || this.templateModule
|
||||||
})`;
|
})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -160,7 +175,7 @@ function buildConnectorCache() {
|
||||||
if (isTemplate) {
|
if (isTemplate) {
|
||||||
info.templateModule = moduleName;
|
info.templateModule = moduleName;
|
||||||
} else {
|
} else {
|
||||||
info.classModule = moduleName;
|
info.classModuleName = moduleName;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
@ -169,6 +184,18 @@ function buildConnectorCache() {
|
||||||
_connectorCache[info.outletName] ??= [];
|
_connectorCache[info.outletName] ??= [];
|
||||||
_connectorCache[info.outletName].push(info);
|
_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) {
|
export function connectorsExist(outletName) {
|
||||||
|
|
|
@ -3,14 +3,16 @@ import { count, exists, query } from "discourse/tests/helpers/qunit-helpers";
|
||||||
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
|
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
|
||||||
import { click, render, settled } from "@ember/test-helpers";
|
import { click, render, settled } from "@ember/test-helpers";
|
||||||
import { action } from "@ember/object";
|
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 { hbs } from "ember-cli-htmlbars";
|
||||||
import { registerTemporaryModule } from "discourse/tests/helpers/temporary-module-helper";
|
import { registerTemporaryModule } from "discourse/tests/helpers/temporary-module-helper";
|
||||||
import { getOwner } from "@ember/application";
|
import { getOwner } from "@ember/application";
|
||||||
import Component from "@glimmer/component";
|
import Component from "@glimmer/component";
|
||||||
import templateOnly from "@ember/component/template-only";
|
import templateOnly from "@ember/component/template-only";
|
||||||
import { withSilencedDeprecationsAsync } from "discourse-common/lib/deprecated";
|
import { withSilencedDeprecationsAsync } from "discourse-common/lib/deprecated";
|
||||||
import { setComponentTemplate } from "@glimmer/manager";
|
|
||||||
import sinon from "sinon";
|
import sinon from "sinon";
|
||||||
|
|
||||||
const TEMPLATE_PREFIX = "discourse/plugins/some-plugin/templates/connectors";
|
const TEMPLATE_PREFIX = "discourse/plugins/some-plugin/templates/connectors";
|
||||||
|
@ -57,13 +59,13 @@ module("Integration | Component | plugin-outlet", function (hooks) {
|
||||||
registerTemporaryModule(
|
registerTemporaryModule(
|
||||||
`${TEMPLATE_PREFIX}/test-name/hello`,
|
`${TEMPLATE_PREFIX}/test-name/hello`,
|
||||||
hbs`<span class='hello-username'>{{this.username}}</span>
|
hbs`<span class='hello-username'>{{this.username}}</span>
|
||||||
<button class='say-hello' {{on "click" (action "sayHello")}}></button>
|
<button type="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-using-this' {{on "click" this.sayHello}}></button>
|
||||||
<span class='hello-result'>{{this.hello}}</span>`
|
<span class='hello-result'>{{this.hello}}</span>`
|
||||||
);
|
);
|
||||||
registerTemporaryModule(
|
registerTemporaryModule(
|
||||||
`${TEMPLATE_PREFIX}/test-name/hi`,
|
`${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>`
|
<span class='hi-result'>{{this.hi}}</span>`
|
||||||
);
|
);
|
||||||
registerTemporaryModule(
|
registerTemporaryModule(
|
||||||
|
@ -427,13 +429,9 @@ module(
|
||||||
setupRenderingTest(hooks);
|
setupRenderingTest(hooks);
|
||||||
|
|
||||||
hooks.beforeEach(function () {
|
hooks.beforeEach(function () {
|
||||||
const template = hbs`<span class='gjs-test'>Hello world</span>`;
|
|
||||||
const component = templateOnly();
|
|
||||||
setComponentTemplate(template, component);
|
|
||||||
|
|
||||||
registerTemporaryModule(
|
registerTemporaryModule(
|
||||||
`${CLASS_PREFIX}/test-name/my-connector`,
|
`${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");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
|
@ -7,6 +7,12 @@ in this file.
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
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).
|
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
|
## [1.12.0] - 2023-09-06
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
Loading…
Reference in New Issue