Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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.
- Loading branch information
1 parent
327dd0b
commit 6c5efb6
Showing
4 changed files
with
338 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
5 changes: 5 additions & 0 deletions
5
app/assets/javascripts/discourse/app/templates/components/mount-widget.hbs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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
97
app/assets/javascripts/discourse/app/widgets/render-glimmer.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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"; |
221 changes: 221 additions & 0 deletions
221
app/assets/javascripts/discourse/tests/integration/components/widgets/render-glimmer-test.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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"); | ||
}); | ||
}); |
6c5efb6
There was a problem hiding this comment.
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