DEV: Introduce new component-based DModal API (#21304)

Ember 4.x will be removing the 'named outlet' feature, which were previously relying on to render modal 'controllers' and their associated templates. This commit updates the modal.show API to accept a component class, and also introduces a declarative API which can be used by including the <DModal component directly in your template.

For more information on the API design, and conversion instructions from the current API, see these Meta topics:

DModal API: https://meta.discourse.org/t/268304
Conversion: https://meta.discourse.org/t/268057
This commit is contained in:
David Taylor 2023-07-03 10:51:27 +01:00 committed by GitHub
parent 45c504d024
commit b3a23bd9d6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 922 additions and 244 deletions

View File

@ -0,0 +1,9 @@
{{#if @inline}}
{{yield}}
{{else if @element}}
{{#if @append}}
{{#in-element @element insertBefore=null}}{{yield}}{{/in-element}}
{{else}}
{{#in-element @element}}{{yield}}{{/in-element}}
{{/if}}
{{/if}}

View File

@ -1,3 +1,5 @@
{{! Remove when legacy modals are dropped (deprecation: discourse.modal-controllers) }}
<div
id={{@id}}
class={{concat-class "modal-body" @class}}

View File

@ -1,8 +1,14 @@
// Remove when legacy modals are dropped (deprecation: discourse.modal-controllers)
import Component from "@glimmer/component";
import { disableImplicitInjections } from "discourse/lib/implicit-injections";
import { action } from "@ember/object";
import { tracked } from "@glimmer/tracking";
import { inject as service } from "@ember/service";
import { DEBUG } from "@glimmer/env";
const LEGACY_ERROR =
"d-modal-body should only be used inside a legacy controller-based d-modal. https://meta.discourse.org/t/268057";
function pick(object, keys) {
const result = {};
@ -23,6 +29,14 @@ export default class DModalBody extends Component {
@action
didInsert(element) {
if (element.closest(".d-modal:not(.d-modal-legacy")) {
// eslint-disable-next-line no-console
console.error(LEGACY_ERROR);
if (DEBUG) {
throw new Error(LEGACY_ERROR);
}
}
this.appEvents.trigger("modal-body:clearFlash");
const fixedParent = element.closest(".d-modal.fixed-modal");

View File

@ -0,0 +1,95 @@
{{! Remove when legacy modals are dropped (deprecation: discourse.modal-controllers) }}
{{! template-lint-disable no-pointer-down-event-binding }}
{{! template-lint-disable no-invalid-interactive }}
<div
class={{concat-class
this.modalClass
this.modalStyle
(if this.hasPanels "has-panels")
(if @hidden "hidden")
"d-modal-legacy"
}}
id={{if (not-eq this.modalStyle "inline-modal") "discourse-modal"}}
data-keyboard="false"
aria-modal="true"
role="dialog"
aria-labelledby={{this.ariaLabelledby}}
...attributes
{{did-insert this.setupListeners}}
{{will-destroy this.cleanupListeners}}
{{on "mousedown" this.handleMouseDown}}
>
<div class="modal-outer-container">
<div class="modal-middle-container">
<div class="modal-inner-container">
<PluginOutlet @name="above-modal-header" @connectorTagName="div" />
<div class="modal-header {{this.headerClass}}">
{{#if this.dismissable}}
<DButton
@icon="times"
@action={{route-action "closeModal" "initiatedByCloseButton"}}
@class="btn-flat modal-close close"
@title="modal.close"
/>
{{/if}}
<div class="modal-title-wrapper">
{{#if this.title}}
<div class="title">
<h3 id="discourse-modal-title">{{this.title}}</h3>
{{#if this.subtitle}}
<p class="subtitle">{{this.subtitle}}</p>
{{/if}}
</div>
{{/if}}
<span id="modal-header-after-title"></span>
</div>
{{#if this.panels}}
<ul class="modal-tabs">
{{#each this.panels as |panel|}}
<ModalTab
@panel={{panel}}
@panelsLength={{this.panels.length}}
@selectedPanel={{@selectedPanel}}
@onSelectPanel={{@onSelectPanel}}
/>
{{/each}}
</ul>
{{/if}}
</div>
<div
id="modal-alert"
role="alert"
class={{if
this.flash
(concat-class
"alert" (concat "alert-" (or this.flash.messageClass "success"))
)
}}
>
{{~this.flash.text~}}
</div>
{{yield}}
{{#each this.errors as |error|}}
<div class="alert alert-error">
<button
type="button"
class="close"
data-dismiss="alert"
aria-label={{i18n "modal.dismiss_error"}}
>×</button>
{{error}}
</div>
{{/each}}
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,253 @@
// Remove when legacy modals are dropped (deprecation: discourse.modal-controllers)
import Component from "@glimmer/component";
import I18n from "I18n";
import { next, schedule } from "@ember/runloop";
import { bind } from "discourse-common/utils/decorators";
import { disableImplicitInjections } from "discourse/lib/implicit-injections";
import { inject as service } from "@ember/service";
import { action } from "@ember/object";
import { tracked } from "@glimmer/tracking";
@disableImplicitInjections
export default class DModal extends Component {
@service appEvents;
@service modal;
@tracked wrapperElement;
@tracked modalBodyData = {};
@tracked flash;
get modalStyle() {
if (this.args.modalStyle === "inline-modal") {
return "inline-modal";
} else {
return "fixed-modal";
}
}
get submitOnEnter() {
if ("submitOnEnter" in this.modalBodyData) {
return this.modalBodyData.submitOnEnter;
} else {
return true;
}
}
get dismissable() {
if ("dismissable" in this.modalBodyData) {
return this.modalBodyData.dismissable;
} else {
return true;
}
}
get title() {
if (this.modalBodyData.title) {
return I18n.t(this.modalBodyData.title);
} else if (this.modalBodyData.rawTitle) {
return this.modalBodyData.rawTitle;
} else {
return this.args.title;
}
}
get subtitle() {
if (this.modalBodyData.subtitle) {
return I18n.t(this.modalBodyData.subtitle);
}
return this.modalBodyData.rawSubtitle || this.args.subtitle;
}
get headerClass() {
return this.modalBodyData.headerClass;
}
get panels() {
return this.args.panels;
}
get errors() {
return this.args.errors;
}
@action
setupListeners(element) {
this.appEvents.on("modal:body-shown", this._modalBodyShown);
this.appEvents.on("modal-body:flash", this._flash);
this.appEvents.on("modal-body:clearFlash", this._clearFlash);
document.documentElement.addEventListener(
"keydown",
this._handleModalEvents
);
this.wrapperElement = element;
}
@action
cleanupListeners() {
this.appEvents.off("modal:body-shown", this._modalBodyShown);
this.appEvents.off("modal-body:flash", this._flash);
this.appEvents.off("modal-body:clearFlash", this._clearFlash);
document.documentElement.removeEventListener(
"keydown",
this._handleModalEvents
);
}
get ariaLabelledby() {
if (this.modalBodyData.titleAriaElementId) {
return this.modalBodyData.titleAriaElementId;
} else if (this.args.titleAriaElementId) {
return this.args.titleAriaElementId;
} else if (this.args.title) {
return "discourse-modal-title";
}
}
get modalClass() {
return this.modalBodyData.modalClass || this.args.modalClass;
}
triggerClickOnEnter(e) {
if (!this.submitOnEnter) {
return false;
}
// skip when in a form or a textarea element
if (
e.target.closest("form") ||
(document.activeElement && document.activeElement.nodeName === "TEXTAREA")
) {
return false;
}
return true;
}
@action
handleMouseDown(e) {
if (!this.dismissable) {
return;
}
if (
e.target.classList.contains("modal-middle-container") ||
e.target.classList.contains("modal-outer-container")
) {
// Send modal close (which bubbles to ApplicationRoute) if clicked outside.
// We do this because some CSS of ours seems to cover the backdrop and makes
// it unclickable.
return this.args.closeModal?.("initiatedByClickOut");
}
}
@bind
_modalBodyShown(data) {
if (this.isDestroying || this.isDestroyed) {
return;
}
if (data.fixed) {
this.modal.hidden = false;
}
this.modalBodyData = data;
next(() => {
schedule("afterRender", () => {
this._trapTab();
});
});
}
@bind
_handleModalEvents(event) {
if (this.args.hidden) {
return;
}
if (event.key === "Escape" && this.dismissable) {
next(() => this.args.closeModal("initiatedByESC"));
}
if (event.key === "Enter" && this.triggerClickOnEnter(event)) {
this.wrapperElement.querySelector(".modal-footer .btn-primary")?.click();
event.preventDefault();
}
if (event.key === "Tab") {
this._trapTab(event);
}
}
_trapTab(event) {
if (this.args.hidden) {
return true;
}
const innerContainer = this.wrapperElement.querySelector(
".modal-inner-container"
);
if (!innerContainer) {
return;
}
let focusableElements =
'[autofocus], a, input, select, textarea, summary, [tabindex]:not([tabindex="-1"])';
if (!event) {
// on first trap we don't allow to focus modal-close
// and apply manual focus only if we don't have any autofocus element
const autofocusedElement = innerContainer.querySelector("[autofocus]");
if (
!autofocusedElement ||
document.activeElement !== autofocusedElement
) {
// if there's not autofocus, or the activeElement, is not the autofocusable element
// attempt to focus the first of the focusable elements or just the modal-body
// to make it possible to scroll with arrow down/up
(
autofocusedElement ||
innerContainer.querySelector(
focusableElements + ", button:not(.modal-close)"
) ||
innerContainer.querySelector(".modal-body")
)?.focus();
}
return;
}
focusableElements += ", button:enabled";
const firstFocusableElement =
innerContainer.querySelector(focusableElements);
const focusableContent = innerContainer.querySelectorAll(focusableElements);
const lastFocusableElement = focusableContent[focusableContent.length - 1];
if (event.shiftKey) {
if (document.activeElement === firstFocusableElement) {
lastFocusableElement?.focus();
event.preventDefault();
}
} else {
if (document.activeElement === lastFocusableElement) {
(
innerContainer.querySelector(".modal-close") || firstFocusableElement
)?.focus();
event.preventDefault();
}
}
}
@bind
_clearFlash() {
this.flash = null;
}
@bind
_flash(msg) {
this.flash = msg;
}
}

View File

@ -1,94 +1,101 @@
{{! template-lint-disable no-pointer-down-event-binding }}
{{! template-lint-disable no-invalid-interactive }}
<div
class={{concat-class
"modal"
"d-modal"
this.modalClass
this.modalStyle
(if this.hasPanels "has-panels")
(if @hidden "hidden")
}}
id={{if (not-eq this.modalStyle "inline-modal") "discourse-modal"}}
data-keyboard="false"
aria-modal="true"
role="dialog"
aria-labelledby={{this.ariaLabelledby}}
...attributes
{{did-insert this.setupListeners}}
{{will-destroy this.cleanupListeners}}
{{on "mousedown" this.handleMouseDown}}
<ConditionalInElement
@element={{this.modal.containerElement}}
@inline={{@inline}}
>
<div class="modal-outer-container">
<div class="modal-middle-container">
<div class="modal-inner-container">
<PluginOutlet @name="above-modal-header" @connectorTagName="div" />
<div class="modal-header {{this.headerClass}}">
{{#if this.dismissable}}
<DButton
@icon="times"
@action={{route-action "closeModal" "initiatedByCloseButton"}}
@class="btn-flat modal-close close"
@title="modal.close"
/>
{{/if}}
<div
class={{concat-class
"modal"
"d-modal"
(if @inline "inline-modal" "fixed-modal")
}}
data-keyboard="false"
aria-modal="true"
role="dialog"
aria-labelledby={{if @title "discourse-modal-title"}}
...attributes
{{did-insert this.setupListeners}}
{{will-destroy this.cleanupListeners}}
{{on "mouseup" this.handleMouseUp}}
>
<div class="modal-outer-container">
<div class="modal-middle-container">
<div class="modal-inner-container">
{{yield to="aboveHeader"}}
<div class="modal-title-wrapper">
{{#if this.title}}
<div class="title">
<h3 id="discourse-modal-title">{{this.title}}</h3>
{{#if this.subtitle}}
<p class="subtitle">{{this.subtitle}}</p>
{{/if}}
</div>
{{/if}}
<span id="modal-header-after-title"></span>
</div>
{{#if this.panels}}
<ul class="modal-tabs">
{{#each this.panels as |panel|}}
<ModalTab
@panel={{panel}}
@panelsLength={{this.panels.length}}
@selectedPanel={{@selectedPanel}}
@onSelectPanel={{@onSelectPanel}}
/>
{{/each}}
</ul>
{{/if}}
</div>
<div
id="modal-alert"
role="alert"
class={{if
this.flash
(concat-class
"alert" (concat "alert-" (or this.flash.messageClass "success"))
{{#if
(or
this.dismissable
@title
(has-block "headerBelowTitle")
(has-block "headerAboveTitle")
)
}}
>
{{~this.flash.text~}}
</div>
<div class={{concat-class "modal-header" @headerClass}}>
{{#if this.dismissable}}
<DButton
@icon="times"
@action={{this.handleCloseButton}}
@class="btn-flat modal-close close"
@title="modal.close"
/>
{{/if}}
{{yield}}
{{yield to="headerAboveTitle"}}
{{#each this.errors as |error|}}
<div class="alert alert-error">
<button
type="button"
class="close"
data-dismiss="alert"
aria-label={{i18n "modal.dismiss_error"}}
>×</button>
{{error}}
<div class="modal-title-wrapper">
{{#if @title}}
<div class="title">
<h3 id="discourse-modal-title">{{@title}}</h3>
{{#if @subtitle}}
<p class="subtitle">{{@subtitle}}</p>
{{/if}}
</div>
{{/if}}
{{yield to="headerBelowTitle"}}
</div>
</div>
{{/if}}
{{yield to="belowHeader"}}
{{#if @flash}}
<div
id="modal-alert"
role="alert"
class={{concat-class
"alert"
(concat "alert-" (or @flashType "success"))
}}
>
{{~@flash~}}
</div>
{{/if}}
<div class="modal-body" tabindex="-1">
{{#if (has-block "body")}}
{{yield to="body"}}
{{else}}
{{yield}}
{{/if}}
</div>
{{/each}}
{{#if (has-block "footer")}}
<div class="modal-footer">
{{yield to="footer"}}
</div>
{{/if}}
{{yield to="belowFooter"}}
</div>
</div>
</div>
</div>
</div>
{{#unless @inline}}
<div class="modal-backdrop"></div>
{{/unless}}
</ConditionalInElement>

View File

@ -1,121 +1,54 @@
import Component from "@glimmer/component";
import I18n from "I18n";
import { next, schedule } from "@ember/runloop";
import { bind } from "discourse-common/utils/decorators";
import { disableImplicitInjections } from "discourse/lib/implicit-injections";
import { inject as service } from "@ember/service";
import { action } from "@ember/object";
import { tracked } from "@glimmer/tracking";
import { inject as service } from "@ember/service";
export const CLOSE_INITIATED_BY_BUTTON = "initiatedByCloseButton";
export const CLOSE_INITIATED_BY_ESC = "initiatedByESC";
export const CLOSE_INITIATED_BY_CLICK_OUTSIDE = "initiatedByClickOut";
export const CLOSE_INITIATED_BY_MODAL_SHOW = "initiatedByModalShow";
@disableImplicitInjections
export default class DModal extends Component {
@service appEvents;
@service modal;
@tracked wrapperElement;
@tracked modalBodyData = {};
@tracked flash;
get modalStyle() {
if (this.args.modalStyle === "inline-modal") {
return "inline-modal";
} else {
return "fixed-modal";
}
}
get submitOnEnter() {
if ("submitOnEnter" in this.modalBodyData) {
return this.modalBodyData.submitOnEnter;
} else {
return true;
}
}
get dismissable() {
if ("dismissable" in this.modalBodyData) {
return this.modalBodyData.dismissable;
} else {
return true;
}
}
get title() {
if (this.modalBodyData.title) {
return I18n.t(this.modalBodyData.title);
} else if (this.modalBodyData.rawTitle) {
return this.modalBodyData.rawTitle;
} else {
return this.args.title;
}
}
get subtitle() {
if (this.modalBodyData.subtitle) {
return I18n.t(this.modalBodyData.subtitle);
}
return this.modalBodyData.rawSubtitle || this.args.subtitle;
}
get headerClass() {
return this.modalBodyData.headerClass;
}
get panels() {
return this.args.panels;
}
get errors() {
return this.args.errors;
}
@action
setupListeners(element) {
this.appEvents.on("modal:body-shown", this._modalBodyShown);
this.appEvents.on("modal-body:flash", this._flash);
this.appEvents.on("modal-body:clearFlash", this._clearFlash);
document.documentElement.addEventListener(
"keydown",
this._handleModalEvents
this.handleDocumentKeydown
);
this.wrapperElement = element;
this.trapTab();
}
@action
cleanupListeners() {
this.appEvents.off("modal:body-shown", this._modalBodyShown);
this.appEvents.off("modal-body:flash", this._flash);
this.appEvents.off("modal-body:clearFlash", this._clearFlash);
document.documentElement.removeEventListener(
"keydown",
this._handleModalEvents
this.handleDocumentKeydown
);
}
get ariaLabelledby() {
if (this.modalBodyData.titleAriaElementId) {
return this.modalBodyData.titleAriaElementId;
} else if (this.args.titleAriaElementId) {
return this.args.titleAriaElementId;
} else if (this.args.title) {
return "discourse-modal-title";
get dismissable() {
if (!this.args.closeModal) {
return false;
} else if ("dismissable" in this.args) {
return this.args.dismissable;
} else {
return true;
}
}
get modalClass() {
return this.modalBodyData.modalClass || this.args.modalClass;
}
triggerClickOnEnter(e) {
if (!this.submitOnEnter) {
shouldTriggerClickOnEnter(event) {
if (this.args.submitOnEnter === false) {
return false;
}
// skip when in a form or a textarea element
if (
e.target.closest("form") ||
(document.activeElement && document.activeElement.nodeName === "TEXTAREA")
event.target.closest("form") ||
document.activeElement?.nodeName === "TEXTAREA"
) {
return false;
}
@ -124,7 +57,11 @@ export default class DModal extends Component {
}
@action
handleMouseDown(e) {
handleMouseUp(e) {
if (e.button !== 0) {
return; // Non-default mouse button
}
if (!this.dismissable) {
return;
}
@ -133,53 +70,34 @@ export default class DModal extends Component {
e.target.classList.contains("modal-middle-container") ||
e.target.classList.contains("modal-outer-container")
) {
// Send modal close (which bubbles to ApplicationRoute) if clicked outside.
// We do this because some CSS of ours seems to cover the backdrop and makes
// it unclickable.
return this.args.closeModal?.("initiatedByClickOut");
}
}
@bind
_modalBodyShown(data) {
if (this.isDestroying || this.isDestroyed) {
return;
}
if (data.fixed) {
this.modal.hidden = false;
}
this.modalBodyData = data;
next(() => {
schedule("afterRender", () => {
this._trapTab();
return this.args.closeModal?.({
initiatedBy: CLOSE_INITIATED_BY_CLICK_OUTSIDE,
});
});
}
}
@bind
_handleModalEvents(event) {
@action
handleDocumentKeydown(event) {
if (this.args.hidden) {
return;
}
if (event.key === "Escape" && this.dismissable) {
next(() => this.args.closeModal("initiatedByESC"));
this.args.closeModal({ initiatedBy: CLOSE_INITIATED_BY_ESC });
}
if (event.key === "Enter" && this.triggerClickOnEnter(event)) {
if (event.key === "Enter" && this.shouldTriggerClickOnEnter(event)) {
this.wrapperElement.querySelector(".modal-footer .btn-primary")?.click();
event.preventDefault();
}
if (event.key === "Tab") {
this._trapTab(event);
this.trapTab(event);
}
}
_trapTab(event) {
@action
trapTab(event) {
if (this.args.hidden) {
return true;
}
@ -239,13 +157,8 @@ export default class DModal extends Component {
}
}
@bind
_clearFlash() {
this.flash = null;
}
@bind
_flash(msg) {
this.flash = msg;
@action
handleCloseButton() {
this.args.closeModal({ initiatedBy: CLOSE_INITIATED_BY_BUTTON });
}
}

View File

@ -1,7 +1,24 @@
<DModal
@modalClass={{concat-class
this.modal.modalClass
(if this.modal.opts.panels "has-tabs")
<div class="modal-container" {{did-insert this.modal.setContainerElement}}>
</div>
{{#if this.modal.modalBodyComponent}}
<this.modal.modalBodyComponent
@model={{this.modal.opts.model}}
@closeModal={{this.closeModal}}
/>
{{/if}}
{{! Legacy modals depend on this wrapper being in the DOM at all times. Eventually this will be dropped.
For now, we mitigate the potential impact on things like tests by removing the `modal` and `d-modal` classes when inactive }}
<DModalLegacy
@modalClass={{if
this.modal.isLegacy
(concat-class
"modal"
"d-modal"
this.modal.modalClass
(if this.modal.opts.panels "has-tabs")
)
}}
@title={{this.modal.title}}
@titleAriaElementId={{this.modal.opts.titleAriaElementId}}
@ -9,9 +26,8 @@
@selectedPanel={{this.modal.selectedPanel}}
@onSelectPanel={{this.modal.onSelectPanel}}
@hidden={{this.modal.hidden}}
@class="hidden"
@errors={{this.modal.errors}}
@closeModal={{this.closeModal}}
>
{{outlet "modalBody"}}
</DModal>
</DModalLegacy>

View File

@ -6,7 +6,7 @@ export default class ModalContainer extends Component {
@service modal;
@action
closeModal(initiatedBy) {
this.modal.close(initiatedBy);
closeModal(data) {
this.modal.close(data);
}
}

View File

@ -1,3 +1,5 @@
// Remove when legacy modals are dropped (deprecation: discourse.modal-controllers)
import { getOwner } from "discourse-common/lib/get-owner";
/**
@ -17,6 +19,11 @@ import { getOwner } from "discourse-common/lib/get-owner";
* @returns {Controller} The modal controller instance
*/
export default function showModal(name, opts) {
if (typeof name !== "string") {
throw new Error(
"`discourse/lib/show-modal` can only be used with the legacy controller-based API. To use the new component-based API, inject the modal service and call modal.show(). https://meta.discourse.org/t/268057"
);
}
opts = opts || {};
let container = getOwner(this);

View File

@ -1,16 +1,80 @@
import Service, { inject as service } from "@ember/service";
import { tracked } from "@glimmer/tracking";
import { getOwner } from "@ember/application";
import I18n from "I18n";
import { dasherize } from "@ember/string";
import { action } from "@ember/object";
import { disableImplicitInjections } from "discourse/lib/implicit-injections";
import { tracked } from "@glimmer/tracking";
import { CLOSE_INITIATED_BY_MODAL_SHOW } from "discourse/components/d-modal";
import deprecated from "discourse-common/lib/deprecated";
const LEGACY_OPTS = new Set([
"admin",
"templateName",
"title",
"titleTranslated",
"modalClass",
"titleAriaElementId",
"panels",
]);
@disableImplicitInjections
export default class ModalService extends Service {
class ModalService extends Service {
@tracked modalBodyComponent;
@tracked opts = {};
@tracked containerElement;
#resolveShowPromise;
@action
setContainerElement(element) {
this.containerElement = element;
}
/**
* Render a modal
*
* @param {Component} modal - a reference to the component class for the modal
* @param {Object} [options] - options
* @param {string} [options.model] - An object which will be passed as the `@model` argument on the component
*
* @returns {Promise} A promise that resolves when the modal is closed, with any data passed to closeModal
*/
show(modal, opts) {
this.close({ initiatedBy: CLOSE_INITIATED_BY_MODAL_SHOW });
const promise = new Promise((resolve) => {
this.#resolveShowPromise = resolve;
});
this.opts = opts || {};
this.modalBodyComponent = modal;
const unsupportedOpts = Object.keys(opts).filter((key) =>
LEGACY_OPTS.has(key)
);
if (unsupportedOpts.length > 0) {
throw new Error(
`${unsupportedOpts.join(
", "
)} are not supported in the component-based modal API. See https://meta.discourse.org/t/268057`
);
}
return promise;
}
close(data) {
this.#resolveShowPromise?.(data);
this.#resolveShowPromise = this.modalBodyComponent = null;
this.opts = {};
}
}
// Remove all logic below when legacy modals are dropped (deprecation: discourse.modal-controllers)
export default class ModalServiceWithLegacySupport extends ModalService {
@service appEvents;
@tracked name;
@tracked opts = {};
@tracked selectedPanel;
@tracked hidden = true;
@ -35,7 +99,7 @@ export default class ModalService extends Service {
}
get modalClass() {
if (!this.#isRendered) {
if (!this.isLegacy) {
return null;
}
@ -50,7 +114,22 @@ export default class ModalService extends Service {
this.modalClassOverride = value;
}
show(name, opts = {}) {
show(modal, opts = {}) {
if (typeof modal !== "string") {
return super.show(modal, opts);
}
deprecated(
"Defining modals using a controller is deprecated. Use the component-based API instead.",
{
id: "discourse.modal-controllers",
since: "3.1",
dropFrom: "3.2",
url: "https://meta.discourse.org/t/268057",
}
);
const name = modal;
const container = getOwner(this);
const route = container.lookup("route:application");
@ -96,10 +175,14 @@ export default class ModalService extends Service {
}
controller.set("flashMessage", null);
return controller;
return (this.activeController = controller);
}
close(initiatedBy) {
if (!this.isLegacy) {
super.close(...arguments);
}
const controllerName = this.name;
const controller = controllerName
? getOwner(this).lookup(`controller:${controllerName}`)
@ -137,18 +220,26 @@ export default class ModalService extends Service {
this.onSelectPanel =
null;
this.opts = {};
super.close();
}
hide() {
$(".d-modal.fixed-modal").modal("hide");
if (this.isLegacy) {
$(".d-modal.fixed-modal").modal("hide");
} else {
throw "hide/reopen are not supported for component-based modals";
}
}
reopen() {
$(".d-modal.fixed-modal").modal("show");
if (this.isLegacy) {
$(".d-modal.fixed-modal").modal("show");
} else {
throw "hide/reopen are not supported for component-based modals";
}
}
get #isRendered() {
return !!this.name;
get isLegacy() {
return this.name && !this.modalBodyComponent;
}
}

View File

@ -8,5 +8,6 @@ globalThis.deprecationWorkflow.config = {
{ handler: "silence", matchId: "route-disconnect-outlet" },
{ handler: "silence", matchId: "this-property-fallback" },
{ handler: "silence", matchId: "discourse.select-kit" },
{ handler: "silence", matchId: "discourse.modal-controllers" },
],
};

View File

@ -38,7 +38,7 @@ acceptance("Do not disturb", function (needs) {
await click(tiles[0]);
assert.ok(query(".d-modal.hidden"), "modal is hidden");
assert.dom(".d-modal").doesNotExist("modal is hidden");
assert.ok(
exists(".header-dropdown-toggle .do-not-disturb-background .d-icon-moon"),
@ -68,10 +68,9 @@ acceptance("Do not disturb", function (needs) {
"Enter"
);
assert.ok(
query(".d-modal.hidden"),
"DND modal is hidden after making a choice"
);
assert
.dom(".d-modal")
.doesNotExist("DND modal is hidden after making a choice");
assert.ok(
exists(".header-dropdown-toggle .do-not-disturb-background .d-icon-moon"),

View File

@ -12,7 +12,7 @@ import showModal from "discourse/lib/show-modal";
import { registerTemporaryModule } from "../helpers/temporary-module-helper";
import { getOwner } from "discourse-common/lib/get-owner";
acceptance("Modal", function (needs) {
acceptance("Legacy Modal", function (needs) {
let _translations;
needs.hooks.beforeEach(() => {
_translations = I18n.translations;

View File

@ -0,0 +1,113 @@
import { acceptance } from "discourse/tests/helpers/qunit-helpers";
import { click, settled, triggerKeyEvent, visit } from "@ember/test-helpers";
import { test } from "qunit";
import { hbs } from "ember-cli-htmlbars";
import { getOwner } from "@ember/application";
import Component from "@glimmer/component";
import { setComponentTemplate } from "@glimmer/manager";
import {
CLOSE_INITIATED_BY_BUTTON,
CLOSE_INITIATED_BY_CLICK_OUTSIDE,
CLOSE_INITIATED_BY_ESC,
CLOSE_INITIATED_BY_MODAL_SHOW,
} from "discourse/components/d-modal";
import { action } from "@ember/object";
class MyModalClass extends Component {
@action
closeWithCustomData() {
this.args.closeModal({ hello: "world" });
}
}
setComponentTemplate(
hbs`
<DModal
@closeModal={{@closeModal}}
@title="Hello World"
>
Modal content is {{@model.text}}
<button class='custom-data' {{on "click" this.closeWithCustomData}}></button>
</DModal>
`,
MyModalClass
);
acceptance("Modal service: component-based API", function () {
test("displays correctly", async function (assert) {
await visit("/");
assert.dom(".d-modal").doesNotExist("there is no modal at first");
const modalService = getOwner(this).lookup("service:modal");
let promise = modalService.show(MyModalClass, {
model: { text: "working" },
});
await settled();
assert.dom(".d-modal").exists("modal should appear");
assert.dom(".d-modal .title h3").hasText("Hello World");
assert.dom(".d-modal .modal-body").hasText("Modal content is working");
await click(".modal-outer-container");
assert.dom(".d-modal").doesNotExist("disappears on click outside");
assert.deepEqual(
await promise,
{ initiatedBy: CLOSE_INITIATED_BY_CLICK_OUTSIDE },
"promise resolves with correct initiator"
);
promise = modalService.show(MyModalClass, { model: { text: "working" } });
await settled();
assert.dom(".d-modal").exists("modal reappears");
await triggerKeyEvent("#main-outlet", "keydown", "Escape");
assert.dom(".d-modal").doesNotExist("disappears on escape");
assert.deepEqual(
await promise,
{ initiatedBy: CLOSE_INITIATED_BY_ESC },
"promise resolves with correct initiator"
);
promise = modalService.show(MyModalClass, { model: { text: "working" } });
await settled();
assert.dom(".d-modal").exists("modal reappears");
await click(".d-modal .modal-close");
assert.dom(".d-modal").doesNotExist("disappears when close button clicked");
assert.deepEqual(
await promise,
{ initiatedBy: CLOSE_INITIATED_BY_BUTTON },
"promise resolves with correct initiator"
);
promise = modalService.show(MyModalClass, { model: { text: "working" } });
await settled();
assert.dom(".d-modal").exists("modal reappears");
await click(".d-modal .modal-close");
assert.dom(".d-modal").doesNotExist("disappears when close button clicked");
assert.deepEqual(
await promise,
{ initiatedBy: CLOSE_INITIATED_BY_BUTTON },
"promise resolves with correct initiator"
);
promise = modalService.show(MyModalClass, { model: { text: "first" } });
await settled();
assert.dom(".d-modal").exists("modal reappears");
modalService.show(MyModalClass, { model: { text: "second" } });
await settled();
assert
.dom(".d-modal .modal-body")
.hasText("Modal content is second", "new modal replaces old");
assert.deepEqual(
await promise,
{ initiatedBy: CLOSE_INITIATED_BY_MODAL_SHOW },
"first modal promise resolves with correct initiator"
);
});
// (See also, `tests/integration/component/d-modal-test.js`)
});

View File

@ -0,0 +1,75 @@
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { click, render, settled } from "@ember/test-helpers";
import { hbs } from "ember-cli-htmlbars";
module("Integration | Component | d-modal", function (hooks) {
setupRenderingTest(hooks);
test("title and subtitle", async function (assert) {
await render(
hbs`<DModal @inline={{true}} @title="Modal Title" @subtitle="Modal Subtitle" />`
);
assert.dom(".d-modal .title h3").hasText("Modal Title");
assert.dom(".d-modal .subtitle").hasText("Modal Subtitle");
});
test("named blocks", async function (assert) {
await render(
hbs`
<DModal @inline={{true}}>
<:aboveHeader>aboveHeaderContent</:aboveHeader>
<:headerAboveTitle>headerAboveTitleContent</:headerAboveTitle>
<:headerBelowTitle>headerBelowTitleContent</:headerBelowTitle>
<:belowHeader>belowHeaderContent</:belowHeader>
<:body>bodyContent</:body>
<:footer>footerContent</:footer>
<:belowFooter>belowFooterContent</:belowFooter>
</DModal>
`
);
assert.dom(".d-modal").includesText("aboveHeaderContent");
assert.dom(".d-modal").includesText("headerAboveTitleContent");
assert.dom(".d-modal").includesText("headerBelowTitleContent");
assert.dom(".d-modal").includesText("belowHeaderContent");
assert.dom(".d-modal").includesText("bodyContent");
assert.dom(".d-modal").includesText("footerContent");
assert.dom(".d-modal").includesText("belowFooterContent");
});
test("flash", async function (assert) {
await render(
hbs`<DModal @inline={{true}} @flash="Some message" @flashType="error"/> `
);
assert.dom(".d-modal .alert.alert-error").hasText("Some message");
});
test("dismissable", async function (assert) {
let closeModalCalled = false;
this.closeModal = () => (closeModalCalled = true);
this.set("dismissable", false);
await render(
hbs`<DModal @inline={{true}} @closeModal={{this.closeModal}} @dismissable={{this.dismissable}}/>`
);
assert
.dom(".d-modal .modal-close")
.doesNotExist("close button is not shown when dismissable=false");
this.set("dismissable", true);
await settled();
assert
.dom(".d-modal .modal-close")
.exists("close button is visible when dismissable=true");
await click(".d-modal .modal-close");
assert.true(
closeModalCalled,
"closeModal is called when close button clicked"
);
closeModalCalled = false;
});
});

View File

@ -1,15 +1,57 @@
<StyleguideExample @title="<DModal>">
<DModal
@closeModal={{@dummyAction}}
@modalStyle="inline-modal"
@title={{i18n "styleguide.sections.modal.header"}}
>
<DModalBody>
{{html-safe @dummy.lorem}}
</DModalBody>
{{! template-lint-disable no-potential-path-strings}}
<div class="modal-footer">
{{i18n "styleguide.sections.modal.footer"}}
</div>
</DModal>
<StyleguideExample @title="<DModal>">
<Styleguide::Component>
<DModal
@closeModal={{fn (mut this.inline) true}}
@inline={{this.inline}}
@title={{this.title}}
@subtitle={{this.subtitle}}
@flash={{this.flash}}
@flashType={{this.flashType}}
@errors={{this.errors}}
@dismissable={{this.dismissable}}
>
<:body>
{{this.body}}
</:body>
<:footer>
{{i18n "styleguide.sections.modal.footer"}}
</:footer>
</DModal>
</Styleguide::Component>
<Styleguide::Controls>
<Styleguide::Controls::Row @name="@inline">
<DToggleSwitch @state={{this.inline}} {{on "click" this.toggleInline}} />
</Styleguide::Controls::Row>
<Styleguide::Controls::Row @name="@dismissable">
<DToggleSwitch
@state={{this.dismissable}}
{{on "click" this.toggleDismissable}}
/>
</Styleguide::Controls::Row>
<Styleguide::Controls::Row @name="@title">
<Input @value={{this.title}} />
</Styleguide::Controls::Row>
<Styleguide::Controls::Row @name="@subtitle">
<Input @value={{this.subtitle}} />
</Styleguide::Controls::Row>
<Styleguide::Controls::Row @name="<:body>">
<Textarea @value={{this.body}} />
</Styleguide::Controls::Row>
<Styleguide::Controls::Row @name="@flash">
<Input @value={{this.flash}} />
</Styleguide::Controls::Row>
<Styleguide::Controls::Row @name="@flashType">
<ComboBox
@value={{this.flashType}}
@content={{this.flashTypes}}
@onChange={{fn (mut this.flashType)}}
@valueProperty={{null}}
@nameProperty={{null}}
/>
</Styleguide::Controls::Row>
</Styleguide::Controls>
</StyleguideExample>

View File

@ -0,0 +1,39 @@
import { action } from "@ember/object";
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import I18n from "I18n";
export default class extends Component {
@tracked inline = true;
@tracked dismissable = true;
@tracked title = I18n.t("styleguide.sections.modal.header");
@tracked body = this.args.dummy.shortLorem;
@tracked subtitle = "";
@tracked flash = "";
@tracked flashType = "success";
flashTypes = ["success", "info", "warning", "error"];
@action
toggleInline() {
this.inline = !this.inline;
if (!this.inline) {
// Make sure there is a way to dismiss the modal
this.dismissable = true;
}
}
@action
toggleDismissable() {
this.dismissable = !this.dismissable;
if (!this.dismissable) {
// Make sure there is a way to dismiss the modal
this.inline = true;
}
}
@action
toggleShowFooter() {
this.showFooter = !this.showFooter;
}
}

View File

@ -262,6 +262,8 @@ export function createData(store) {
}),
lorem: cooked,
shortLorem:
"Lorem ipsum dolor sit amet, et nec quis viderer prompta, ex omnium ponderum insolens eos, sed discere invenire principes in. Fuisset constituto per ad. Est no scripta propriae facilisis, viderer impedit deserunt in mel. Quot debet facilisis ne vix, nam in detracto tacimates. At quidam petentium vulputate pro. Alia iudico repudiandae ad vel, erat omnis epicuri eos id. Et illum dolor graeci vel, quo feugiat consulatu ei.",
topicTimerUpdateDate: "2017-10-18 18:00",