DEV: Introduce `RenderGlimmer` helper for use in widgets (#17592)
This allows an arbitrary Glimmer template to be rendered inside a Widget. That template can include any kind content, including Classic Ember components and Glimmer components. This leans on Ember's official `{{#in-element}}` helper which means that all component lifecycle hooks are called correctly. This is a modern replacement for our existing `ComponentConnector` implementation. We'll deprecate `ComponentConnector` in the near future. Example usage: ```javascript // (inside an existing widget) html(){ return [ new RenderGlimmer( this, "div.my-wrapper-class", hbs`<MyComponent @arg1={{@data.arg1}} />`, { arg1: "some argument value" } ), ] } ``` See `widgets/render-glimmer.js` for documentation, and `render-glimmer-test` for more example uses.
This commit is contained in:
parent
327dd0beb3
commit
6c5efb61c9
|
@ -6,6 +6,7 @@ import DirtyKeys from "discourse/lib/dirty-keys";
|
|||
import { WidgetClickHook } from "discourse/widgets/hooks";
|
||||
import { camelize } from "@ember/string";
|
||||
import { getRegister } from "discourse-common/lib/get-owner";
|
||||
import ArrayProxy from "@ember/array/proxy";
|
||||
|
||||
let _cleanCallbacks = {};
|
||||
export function addWidgetCleanCallback(widgetName, fn) {
|
||||
|
@ -18,6 +19,7 @@ export function resetWidgetCleanCallbacks() {
|
|||
}
|
||||
|
||||
export default Component.extend({
|
||||
layoutName: "components/mount-widget",
|
||||
_tree: null,
|
||||
_rootNode: null,
|
||||
_timeout: null,
|
||||
|
@ -36,6 +38,10 @@ export default Component.extend({
|
|||
this._widgetClass =
|
||||
queryRegistry(name) || this.register.lookupFactory(`widget:${name}`);
|
||||
|
||||
if (this._widgetClass?.class) {
|
||||
this._widgetClass = this._widgetClass.class;
|
||||
}
|
||||
|
||||
if (!this._widgetClass) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Error: Could not find widget: ${name}`);
|
||||
|
@ -43,6 +49,7 @@ export default Component.extend({
|
|||
|
||||
this._childEvents = [];
|
||||
this._connected = [];
|
||||
this._childComponents = ArrayProxy.create({ content: [] });
|
||||
this._dispatched = [];
|
||||
this.dirtyKeys = new DirtyKeys(name);
|
||||
},
|
||||
|
@ -151,4 +158,12 @@ export default Component.extend({
|
|||
}
|
||||
}
|
||||
},
|
||||
|
||||
mountChildComponent(info) {
|
||||
this._childComponents.pushObject(info);
|
||||
},
|
||||
|
||||
unmountChildComponent(info) {
|
||||
this._childComponents.removeObject(info);
|
||||
},
|
||||
});
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
{{#each this._childComponents as |info|}}
|
||||
{{#in-element info.element}}
|
||||
<info.component @data={{info.data}}/>
|
||||
{{/in-element}}
|
||||
{{/each}}
|
|
@ -0,0 +1,97 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { setComponentTemplate } from "@ember/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { assert } from "@ember/debug";
|
||||
|
||||
/*
|
||||
|
||||
This class allows you to render arbitrary Glimmer templates inside widgets.
|
||||
That glimmer template can include Classic and/or Glimmer components.
|
||||
|
||||
Example usage:
|
||||
|
||||
```
|
||||
import { hbs } from "ember-cli-htmlbars";
|
||||
|
||||
// NOTE: If your file is already importing the `hbs` helper from "discourse/widgets/hbs-compiler"
|
||||
// you'll need to rename that import to `import widgetHbs from "discourse/widgets/hbs-compiler"`
|
||||
// before adding the `ember-cli-htmlbars` import.
|
||||
|
||||
...
|
||||
|
||||
// (inside an existing widget)
|
||||
html(){
|
||||
return [
|
||||
new RenderGlimmer(
|
||||
this,
|
||||
"div.my-wrapper-class",
|
||||
hbs`<MyComponent @arg1={{@data.arg1}} />`,
|
||||
{
|
||||
arg1: "some argument value"
|
||||
}
|
||||
),
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
*/
|
||||
|
||||
export default class RenderGlimmer {
|
||||
/**
|
||||
* Create a RenderGlimmer instance
|
||||
* @param widget - the widget instance which is rendering this content
|
||||
* @param tagName - tagName for the wrapper element (e.g. `div.my-class`)
|
||||
* @param template - a glimmer template compiled via ember-cli-htmlbars
|
||||
* @param data - will be made available at `@data` in your template
|
||||
*/
|
||||
constructor(widget, tagName, template, data) {
|
||||
assert(
|
||||
"`template` should be a template compiled via `ember-cli-htmlbars`",
|
||||
template.name === "factory"
|
||||
);
|
||||
this.tagName = tagName;
|
||||
this.widget = widget;
|
||||
this.template = template;
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
init() {
|
||||
const [type, ...classNames] = this.tagName.split(".");
|
||||
this.element = document.createElement(type);
|
||||
this.element.classList.add(...classNames);
|
||||
this.connectComponent();
|
||||
return this.element;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this._componentInfo) {
|
||||
this.widget._findView().unmountChildComponent(this._componentInfo);
|
||||
}
|
||||
}
|
||||
|
||||
update(prev) {
|
||||
this._componentInfo = prev._componentInfo;
|
||||
if (prev.data !== this.data) {
|
||||
this._componentInfo.data = this.data;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
connectComponent() {
|
||||
const { element, template, widget } = this;
|
||||
|
||||
const component = class extends Component {};
|
||||
setComponentTemplate(template, component);
|
||||
|
||||
this._componentInfo = {
|
||||
element,
|
||||
component,
|
||||
@tracked data: this.data,
|
||||
};
|
||||
const parentMountWidgetComponent = widget._findView();
|
||||
parentMountWidgetComponent.mountChildComponent(this._componentInfo);
|
||||
}
|
||||
}
|
||||
|
||||
RenderGlimmer.prototype.type = "Widget";
|
|
@ -0,0 +1,221 @@
|
|||
import { module, test } from "qunit";
|
||||
import { exists, query } from "discourse/tests/helpers/qunit-helpers";
|
||||
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
|
||||
import { click, fillIn, render } from "@ember/test-helpers";
|
||||
import { hbs } from "ember-cli-htmlbars";
|
||||
import widgetHbs from "discourse/widgets/hbs-compiler";
|
||||
import Widget from "discourse/widgets/widget";
|
||||
import ClassicComponent from "@ember/component";
|
||||
import RenderGlimmer from "discourse/widgets/render-glimmer";
|
||||
|
||||
class DemoWidget extends Widget {
|
||||
static actionTriggered = false;
|
||||
tagName = "div.my-widget";
|
||||
|
||||
html(attrs) {
|
||||
return [
|
||||
this.attach("button", {
|
||||
label: "rerender",
|
||||
className: "triggerRerender",
|
||||
action: "dummyAction",
|
||||
}),
|
||||
new RenderGlimmer(
|
||||
this,
|
||||
"div.glimmer-wrapper",
|
||||
hbs`<div class='glimmer-content'>
|
||||
arg1={{@data.arg1}} dynamicArg={{@data.dynamicArg}}
|
||||
</div>
|
||||
<DemoComponent @arg1={{@data.arg1}} @dynamicArg={{@data.dynamicArg}} @action={{@data.actionForComponentToTrigger}}/>`,
|
||||
{
|
||||
...attrs,
|
||||
actionForComponentToTrigger: this.actionForComponentToTrigger,
|
||||
}
|
||||
),
|
||||
];
|
||||
}
|
||||
dummyAction() {}
|
||||
actionForComponentToTrigger() {
|
||||
DemoWidget.actionTriggered = true;
|
||||
}
|
||||
}
|
||||
|
||||
class DemoComponent extends ClassicComponent {
|
||||
static eventLog = [];
|
||||
classNames = ["demo-component"];
|
||||
|
||||
init() {
|
||||
DemoComponent.eventLog.push("init");
|
||||
super.init(...arguments);
|
||||
}
|
||||
|
||||
didInsertElement() {
|
||||
DemoComponent.eventLog.push("didInsertElement");
|
||||
}
|
||||
|
||||
willDestroyElement() {
|
||||
DemoComponent.eventLog.push("willDestroyElement");
|
||||
}
|
||||
|
||||
didReceiveAttrs() {
|
||||
DemoComponent.eventLog.push("didReceiveAttrs");
|
||||
}
|
||||
|
||||
willDestroy() {
|
||||
DemoComponent.eventLog.push("willDestroy");
|
||||
}
|
||||
|
||||
layout = hbs`<DButton class="component-action-button" @label="component_action" @action={{@action}} />`;
|
||||
}
|
||||
|
||||
module("Integration | Component | Widget | render-glimmer", function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
DemoComponent.eventLog = [];
|
||||
DemoWidget.actionTriggered = false;
|
||||
this.registry.register("widget:demo-widget", DemoWidget);
|
||||
this.registry.register("component:demo-component", DemoComponent);
|
||||
});
|
||||
|
||||
hooks.afterEach(function () {
|
||||
this.registry.unregister("widget:demo-widget");
|
||||
this.registry.unregister("component:demo-component");
|
||||
});
|
||||
|
||||
test("argument handling", async function (assert) {
|
||||
await render(
|
||||
hbs`
|
||||
<Input class='dynamic-value-input' @type="text" @value={{this.dynamicValue}} />
|
||||
<MountWidget @widget="demo-widget" @args={{hash arg1="val1" dynamicArg=this.dynamicValue}} />`
|
||||
);
|
||||
|
||||
assert.true(exists("div.my-widget"), "widget is rendered");
|
||||
assert.true(exists("div.glimmer-content"), "glimmer content is rendered");
|
||||
assert.strictEqual(
|
||||
query("div.glimmer-content").innerText,
|
||||
"arg1=val1 dynamicArg=",
|
||||
"arguments are passed through"
|
||||
);
|
||||
|
||||
await fillIn("input.dynamic-value-input", "somedynamicvalue");
|
||||
assert.strictEqual(
|
||||
query("div.glimmer-content").innerText,
|
||||
"arg1=val1 dynamicArg=",
|
||||
"changed arguments do not change before rerender"
|
||||
);
|
||||
|
||||
await click(".my-widget button");
|
||||
assert.strictEqual(
|
||||
query("div.glimmer-content").innerText,
|
||||
"arg1=val1 dynamicArg=somedynamicvalue",
|
||||
"changed arguments are applied after rerender"
|
||||
);
|
||||
});
|
||||
|
||||
test("child component lifecycle", async function (assert) {
|
||||
assert.deepEqual(
|
||||
DemoComponent.eventLog,
|
||||
[],
|
||||
"component event log starts empty"
|
||||
);
|
||||
|
||||
await render(
|
||||
hbs`
|
||||
<Input class='dynamic-value-input' @type="text" @value={{this.dynamicValue}} />
|
||||
{{#unless (eq this.dynamicValue 'hidden')}}
|
||||
<MountWidget @widget="demo-widget" @args={{hash arg1="val1" dynamicArg=this.dynamicValue}} />
|
||||
{{/unless}}`
|
||||
);
|
||||
|
||||
assert.true(exists("div.my-widget"), "widget is rendered");
|
||||
assert.true(exists("div.glimmer-content"), "glimmer content is rendered");
|
||||
assert.true(exists("div.demo-component"), "demo component is rendered");
|
||||
|
||||
assert.deepEqual(
|
||||
DemoComponent.eventLog,
|
||||
["init", "didReceiveAttrs", "didInsertElement"],
|
||||
"component is initialized correctly"
|
||||
);
|
||||
|
||||
DemoComponent.eventLog = [];
|
||||
|
||||
await fillIn("input.dynamic-value-input", "somedynamicvalue");
|
||||
assert.deepEqual(
|
||||
DemoComponent.eventLog,
|
||||
[],
|
||||
"component is not notified of attr change before widget rerender"
|
||||
);
|
||||
|
||||
await click(".my-widget button");
|
||||
assert.deepEqual(
|
||||
DemoComponent.eventLog,
|
||||
["didReceiveAttrs"],
|
||||
"component is notified of attr change during widget rerender"
|
||||
);
|
||||
|
||||
DemoComponent.eventLog = [];
|
||||
|
||||
await fillIn("input.dynamic-value-input", "hidden");
|
||||
assert.deepEqual(
|
||||
DemoComponent.eventLog,
|
||||
["willDestroyElement", "willDestroy"],
|
||||
"destroy hooks are run correctly"
|
||||
);
|
||||
|
||||
DemoComponent.eventLog = [];
|
||||
|
||||
await fillIn("input.dynamic-value-input", "visibleAgain");
|
||||
assert.deepEqual(
|
||||
DemoComponent.eventLog,
|
||||
["init", "didReceiveAttrs", "didInsertElement"],
|
||||
"component can be reinitialized"
|
||||
);
|
||||
});
|
||||
|
||||
test("trigger widget actions from component", async function (assert) {
|
||||
assert.false(
|
||||
DemoWidget.actionTriggered,
|
||||
"widget event has not been triggered yet"
|
||||
);
|
||||
|
||||
await render(
|
||||
hbs`
|
||||
<Input class='dynamic-value-input' @type="text" @value={{this.dynamicValue}} />
|
||||
{{#unless (eq this.dynamicValue 'hidden')}}
|
||||
<MountWidget @widget="demo-widget" @args={{hash arg1="val1" dynamicArg=this.dynamicValue}} />
|
||||
{{/unless}}`
|
||||
);
|
||||
|
||||
assert.true(
|
||||
exists("div.demo-component button"),
|
||||
"component button is rendered"
|
||||
);
|
||||
|
||||
await click("div.demo-component button");
|
||||
assert.true(DemoWidget.actionTriggered, "widget event is triggered");
|
||||
});
|
||||
|
||||
test("developer ergonomics", function (assert) {
|
||||
assert.throws(
|
||||
() => {
|
||||
// eslint-disable-next-line no-new
|
||||
new RenderGlimmer(this, "div", `<NotActuallyATemplate />`);
|
||||
},
|
||||
/`template` should be a template compiled via `ember-cli-htmlbars`/,
|
||||
"it raises a useful error when passed a string instead of a template"
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => {
|
||||
// eslint-disable-next-line no-new
|
||||
new RenderGlimmer(this, "div", widgetHbs`{{using-the-wrong-compiler}}`);
|
||||
},
|
||||
/`template` should be a template compiled via `ember-cli-htmlbars`/,
|
||||
"it raises a useful error when passed a widget-hbs-compiler template"
|
||||
);
|
||||
|
||||
// eslint-disable-next-line no-new
|
||||
new RenderGlimmer(this, "div", hbs`<TheCorrectCompiler />`);
|
||||
assert.true(true, "it doesn't raise an error for correct params");
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue