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:
David Taylor 2022-07-21 18:58:52 +01:00 committed by GitHub
parent 327dd0beb3
commit 6c5efb61c9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 338 additions and 0 deletions

View File

@ -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);
},
});

View File

@ -0,0 +1,5 @@
{{#each this._childComponents as |info|}}
{{#in-element info.element}}
<info.component @data={{info.data}}/>
{{/in-element}}
{{/each}}

View File

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

View File

@ -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");
});
});