DEV: Convert DButton to a Glimmer Component (#17767)

This change is intended to be backwards-compatible with all the previous arguments to `DButton`.

A deprecation warning will be triggered when a string is passed to the `@action` argument. This kind of action bubbling has been deprecated in Ember for some time, and should be updated to use closure actions.

Co-authored-by: Dan Gebhardt <dan@cerebris.com>
Co-authored-by: David Taylor <david@taylorhq.com>
This commit is contained in:
Peter Wagenet 2022-11-17 02:55:15 -08:00 committed by GitHub
parent 3fd0423b1b
commit a88902950a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 207 additions and 147 deletions

View File

@ -1,7 +0,0 @@
import DButton from "discourse/components/d-button";
export default DButton.extend({
click() {
$("input.bulk-select:not(checked)").click();
},
});

View File

@ -0,0 +1,9 @@
<DButton
@translatedTitle={{this.translatedTitle}}
@label={{@label}}
@action={{@action}}
@icon={{@icon}}
@forwardEvent={{@forwardEvent}}
class="btn-primary create {{if @disabledSubmit "disabled"}}"
...attributes
/>

View File

@ -1,10 +1,9 @@
import Button from "discourse/components/d-button"; import Component from "@glimmer/component";
import I18n from "I18n"; import I18n from "I18n";
import { translateModKey } from "discourse/lib/utilities"; import { translateModKey } from "discourse/lib/utilities";
export default Button.extend({ export default class ComposerSaveButton extends Component {
classNameBindings: [":btn-primary", ":create", "disableSubmit:disabled"], get translatedTitle() {
translatedTitle: I18n.t("composer.title", { return I18n.t("composer.title", { modifier: translateModKey("Meta+") });
modifier: translateModKey("Meta+"), }
}), }
});

View File

@ -1,161 +1,137 @@
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
import { action } from "@ember/object";
import { empty, equal, notEmpty } from "@ember/object/computed"; import { empty, equal, notEmpty } from "@ember/object/computed";
import Component from "@ember/component"; import GlimmerComponentWithDeprecatedParentView from "discourse/components/glimmer-component-with-deprecated-parent-view";
import deprecated from "discourse-common/lib/deprecated";
import DiscourseURL from "discourse/lib/url"; import DiscourseURL from "discourse/lib/url";
import I18n from "I18n"; import I18n from "I18n";
import { computed } from "@ember/object";
import discourseComputed from "discourse-common/utils/decorators";
export default Component.extend({ const ACTION_AS_STRING_DEPRECATION_ARGS = [
tagName: "button", "DButton no longer supports @action as a string. Please refactor to use an closure action instead.",
// subclasses need this { id: "discourse.d-button-action-string" },
layoutName: "components/d-button", ];
form: null,
type: "button",
title: null,
translatedTitle: null,
label: null,
translatedLabel: null,
ariaLabel: null,
ariaExpanded: null,
ariaControls: null,
translatedAriaLabel: null,
forwardEvent: false,
preventFocus: false,
onKeyDown: null,
router: service(),
isLoading: computed({ export default class DButton extends GlimmerComponentWithDeprecatedParentView {
set(key, value) { @service router;
this.set("forceDisabled", !!value);
return value;
},
}),
classNameBindings: [ @notEmpty("args.icon")
"isLoading:is-loading", btnIcon;
"btnLink::btn",
"btnLink",
"noText",
"btnType",
],
attributeBindings: [
"form",
"isDisabled:disabled",
"computedTitle:title",
"computedAriaLabel:aria-label",
"computedAriaExpanded:aria-expanded",
"ariaControls:aria-controls",
"tabindex",
"type",
],
isDisabled: computed("disabled", "forceDisabled", function () { @equal("args.display", "link")
return this.forceDisabled || this.disabled; btnLink;
}),
forceDisabled: false, @empty("computedLabel")
noText;
btnIcon: notEmpty("icon"), constructor() {
super(...arguments);
if (typeof this.args.action === "string") {
deprecated(...ACTION_AS_STRING_DEPRECATION_ARGS);
}
}
btnLink: equal("display", "link"), get forceDisabled() {
return !!this.args.isLoading;
}
@discourseComputed("icon", "computedLabel") get isDisabled() {
btnType(icon, translatedLabel) { return this.forceDisabled || this.args.disabled;
if (icon) { }
return translatedLabel ? "btn-icon-text" : "btn-icon";
} else if (translatedLabel) { get btnType() {
if (this.args.icon) {
return this.computedLabel ? "btn-icon-text" : "btn-icon";
} else if (this.computedLabel) {
return "btn-text"; return "btn-text";
} }
}, }
noText: empty("computedLabel"), get computedTitle() {
if (this.args.title) {
@discourseComputed("title", "translatedTitle") return I18n.t(this.args.title);
computedTitle(title, translatedTitle) {
if (title) {
return I18n.t(title);
} }
return translatedTitle; return this.args.translatedTitle;
}, }
@discourseComputed("label", "translatedLabel") get computedLabel() {
computedLabel(label, translatedLabel) { if (this.args.label) {
if (label) { return I18n.t(this.args.label);
return I18n.t(label);
} }
return translatedLabel; return this.args.translatedLabel;
}, }
@discourseComputed("ariaLabel", "translatedAriaLabel") get computedAriaLabel() {
computedAriaLabel(ariaLabel, translatedAriaLabel) { if (this.args.ariaLabel) {
if (ariaLabel) { return I18n.t(this.args.ariaLabel);
return I18n.t(ariaLabel);
} }
if (translatedAriaLabel) { if (this.args.translatedAriaLabel) {
return translatedAriaLabel; return this.args.translatedAriaLabel;
} }
}, }
@discourseComputed("ariaExpanded") get computedAriaExpanded() {
computedAriaExpanded(ariaExpanded) { if (this.args.ariaExpanded === true) {
if (ariaExpanded === true) {
return "true"; return "true";
} }
if (ariaExpanded === false) { if (this.args.ariaExpanded === false) {
return "false"; return "false";
} }
}, }
@action
keyDown(e) { keyDown(e) {
if (this.onKeyDown) { if (this.args.onKeyDown) {
e.stopPropagation(); e.stopPropagation();
this.onKeyDown(e); this.args.onKeyDown(e);
} else if (e.key === "Enter") { } else if (e.key === "Enter") {
this._triggerAction(e); this._triggerAction(e);
return false;
} }
}, }
@action
click(event) { click(event) {
return this._triggerAction(event); return this._triggerAction(event);
}, }
@action
mouseDown(event) { mouseDown(event) {
if (this.preventFocus) { if (this.args.preventFocus) {
event.preventDefault(); event.preventDefault();
} }
}, }
_triggerAction(event) { _triggerAction(event) {
let { action, route, href } = this; const { action: actionVal, route, href } = this.args;
if (action || route || href?.length) { if (actionVal || route || href?.length) {
if (action) { if (actionVal) {
if (typeof action === "string") { const { actionParam, forwardEvent } = this.args;
// Note: This is deprecated in new Embers and needs to be removed in the future.
// There is already a warning in the console. if (typeof actionVal === "string") {
this.sendAction("action", this.actionParam); deprecated(...ACTION_AS_STRING_DEPRECATION_ARGS);
} else if (typeof action === "object" && action.value) { if (this.parentView?.send) {
if (this.forwardEvent) { this.parentView.send(actionVal, actionParam);
action.value(this.actionParam, event);
} else { } else {
action.value(this.actionParam); throw new Error(
"DButton could not find a target for the action. Use a closure action instead"
);
} }
} else if (typeof this.action === "function") { } else if (typeof actionVal === "object" && actionVal.value) {
if (this.forwardEvent) { if (forwardEvent) {
action(this.actionParam, event); actionVal.value(actionParam, event);
} else { } else {
action(this.actionParam); actionVal.value(actionParam);
}
} else if (typeof actionVal === "function") {
if (forwardEvent) {
actionVal(actionParam, event);
} else {
actionVal(actionParam);
} }
} }
} } else if (route) {
if (route) {
this.router.transitionTo(route); this.router.transitionTo(route);
} } else if (href?.length) {
if (href?.length) {
DiscourseURL.routeTo(href); DiscourseURL.routeTo(href);
} }
@ -164,5 +140,5 @@ export default Component.extend({
return false; return false;
} }
}, }
}); }

View File

@ -0,0 +1,32 @@
import Component from "@glimmer/component";
import {
CustomComponentManager,
setInternalComponentManager,
} from "@glimmer/manager";
import EmberGlimmerComponentManager from "@glimmer/component/-private/ember-component-manager";
class GlimmerComponentWithParentViewManager extends CustomComponentManager {
create(owner, componentClass, args, environment, dynamicScope) {
const result = super.create(...arguments);
result.component.parentView = dynamicScope.view;
dynamicScope.view = result.component;
return result;
}
}
/**
* This component has a lightly-extended version of Ember's default Glimmer component manager.
* It gives Glimmer components the ability to reference their parent view which can be useful
* when building backwards-compatible versions of components. Any use of the parentView property
* of the component should be considered deprecated.
*/
export default class GlimmerComponentWithDeprecatedParentView extends Component {}
setInternalComponentManager(
new GlimmerComponentWithParentViewManager(
(owner) => new EmberGlimmerComponentManager(owner)
),
GlimmerComponentWithDeprecatedParentView
);

View File

@ -1,7 +0,0 @@
import Button from "discourse/components/d-button";
export default Button.extend({
label: "topic.reply.title",
icon: "reply",
action: "showLogin",
});

View File

@ -1,16 +1,36 @@
{{#if this.isLoading}} {{! template-lint-disable no-down-event-binding }}
{{~d-icon "spinner" class="loading-icon"~}} <button
{{else}} {{! For legacy compatibility. Prefer passing class as attributes. }}
{{#if this.icon}} class="{{@class}} {{if @isLoading "is-loading"}} {{if this.btnLink "btn-link" "btn"}} {{if this.noText "no-text"}} {{this.btnType}}"
{{~d-icon this.icon~}} {{! For legacy compatibility. Prefer passing these as html attributes. }}
id={{@id}}
form={{@form}}
aria-controls={{@ariaControls}}
aria-expanded={{this.computedAriaExpanded}}
tabindex={{@tabindex}}
type={{or @type "button"}}
...attributes
disabled={{this.isDisabled}}
title={{this.computedTitle}}
aria-label={{this.computedAriaLabel}}
{{on "keydown" this.keyDown}}
{{on "click" this.click}}
{{on "mousedown" this.mouseDown}}
>
{{#if @isLoading}}
{{~d-icon "spinner" class="loading-icon"~}}
{{else}}
{{#if @icon}}
{{~d-icon @icon~}}
{{/if}}
{{/if}} {{/if}}
{{/if}}
{{~#if this.computedLabel~}} {{~#if this.computedLabel~}}
<span class="d-button-label">{{html-safe this.computedLabel}}{{#if this.ellipsis}}&hellip;{{/if}}</span> <span class="d-button-label">{{html-safe this.computedLabel}}{{#if this.ellipsis}}&hellip;{{/if}}</span>
{{~else if (not (has-block))~}} {{~else if (not (has-block))~}}
&#8203; &#8203;
{{! Zero-width space character, so icon-only button height = regular button height }} {{! Zero-width space character, so icon-only button height = regular button height }}
{{~/if~}} {{~/if~}}
{{yield}} {{yield}}
</button>

View File

@ -1,9 +1,10 @@
import { module, test } from "qunit"; import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test"; import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { render, triggerKeyEvent } from "@ember/test-helpers"; import { click, render, triggerKeyEvent } from "@ember/test-helpers";
import { exists, query } from "discourse/tests/helpers/qunit-helpers"; import { exists, query } from "discourse/tests/helpers/qunit-helpers";
import I18n from "I18n"; import I18n from "I18n";
import { hbs } from "ember-cli-htmlbars"; import { hbs } from "ember-cli-htmlbars";
import ClassicComponent from "@ember/component";
module("Integration | Component | d-button", function (hooks) { module("Integration | Component | d-button", function (hooks) {
setupRenderingTest(hooks); setupRenderingTest(hooks);
@ -241,4 +242,41 @@ module("Integration | Component | d-button", function (hooks) {
await triggerKeyEvent(".btn", "keydown", "Enter"); await triggerKeyEvent(".btn", "keydown", "Enter");
assert.strictEqual(this.foo, "bar"); assert.strictEqual(this.foo, "bar");
}); });
test("@action function is triggered on click", async function (assert) {
this.set("foo", null);
this.set("action", () => {
this.set("foo", "bar");
});
await render(hbs`<DButton @action={{this.action}} />`);
await click(".btn");
assert.strictEqual(this.foo, "bar");
});
test("@action can sendAction when passed a string", async function (assert) {
this.set("foo", null);
this.set("legacyActionTriggered", () => this.set("foo", "bar"));
this.classicComponent = ClassicComponent.extend({
actions: {
myLegacyAction() {
this.legacyActionTriggered();
},
},
});
await render(
hbs`<this.classicComponent @legacyActionTriggered={{this.legacyActionTriggered}}>
<DButton @action="myLegacyAction" />
</this.classicComponent>
`
);
await click(".btn");
assert.strictEqual(this.foo, "bar");
});
}); });