Skip to content

Commit

Permalink
DEV: Introduce RenderGlimmer helper for use in widgets (#17592)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
davidtaylorhq committed Jul 21, 2022
1 parent 327dd0b commit 6c5efb6
Show file tree
Hide file tree
Showing 4 changed files with 338 additions and 0 deletions.
15 changes: 15 additions & 0 deletions app/assets/javascripts/discourse/app/components/mount-widget.js
Expand Up @@ -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) {
Expand All @@ -18,6 +19,7 @@ export function resetWidgetCleanCallbacks() {
}

export default Component.extend({
layoutName: "components/mount-widget",
_tree: null,
_rootNode: null,
_timeout: null,
Expand All @@ -36,13 +38,18 @@ 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}`);
}

this._childEvents = [];
this._connected = [];
this._childComponents = ArrayProxy.create({ content: [] });
this._dispatched = [];
this.dirtyKeys = new DirtyKeys(name);
},
Expand Down Expand Up @@ -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}}
97 changes: 97 additions & 0 deletions app/assets/javascripts/discourse/app/widgets/render-glimmer.js
@@ -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");
});
});

1 comment on commit 6c5efb6

@discoursebot
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This commit has been mentioned on Discourse Meta. There might be relevant details there:

https://meta.discourse.org/t/render-a-component-within-a-widget-using-select-kit-components-within-plugin-code/84462/7

Please sign in to comment.