将模态框从遗留控制器转换为新的DModal组件API

:information_source: If you’re implementing a new Modal, check out the main docs here. This topic describes how to migrate an existing controller-based Modal to the new Component-based API.

In the past, Discourse used an Ember-Controller-based API for rendering modals. To invoke the modal, you would pass a string with the name of the controller to showModal(). Under the covers, this made use of Ember’s Route#renderTemplate API, which is deprecated in Ember 3.x and will be removed in Ember 4.x.

To allow Discourse to upgrade to Ember 4.x and beyond, we’ve introduced a new component-based API for modals. This new API embraces Ember’s ‘declarative’ design patterns, and aims to provide clean DDAU (data down actions up) semantics.

Step 1: Move Files

Move the controller JS file and the template file to the /components/modal directory. This makes them a ‘colocated component’ which can be imported just like any other JS module.

Step 2: Update the JS file

Then, update the component JS definition to extend from @ember/component instead of @ember/controller [1]. Remove the ModalFunctionality mixin and update any uses of its functions according to the table below:

Before After
flash() and clearFlash() Create a flash property in your component and pass it to the @flash argument of <DModal>. By default the alert will be styled with the alert class which is a copy of the ‘error’ class, but it can be overridden using the @flashType argument.
showModal() Import the showModal function from discourse/lib/show-modal
closeModal action Invoke the closeModal argument which is automatically passed into your component

Old-style Modal Controllers would live ‘forever’, which meant we had to manually cleanup state. With the new Component-based API, the component will be created and destroyed when the modal is shown/hidden. In many cases that means your old lifecycle hooks are no longer required.

If you still need some lifecycle-based logic, use this table:

Before After
onShow() Use standard Ember component lifecycle (init() or Ember modifier)
afterRender Use standard Ember component lifecycle (init() or Ember modifier)
beforeClose() create a wrapper around the @closeModal argument which is passed into your component. Pass a reference to your close wrapper into DModal like <DModal @closeModal={{this.myCloseModalWrapper}}>
onClose() Use standard Ember component lifecycle (willDestroy() or Ember modifier)

Step 3: Update the Template

Replace the <DModalBody> wrapper with <DModal>. Add some new attributes:

  • Pass through the new @closeModal argument
  • Add an explicit class. To match the old behavior, take your controller filename and add -modal.

For example, if your modal controller was called close-topic.js, the new <DModal> invocation would look something like this:

<DModal @closeModal={{@closeModal}} class="close-topic-modal">

If the DModalBody invocation includes any other arguments, update them based on the table below:

Before After
@title="title_key" @title={{i18n "title_key"}}
@rawTitle="translated title" @title="translated title"
@subtitle="subtitle_key" @subtitle={{i18n "subtitle_key"}}
@rawSubtitle="translated subtitle" @subtitle="translated subtitle"
@class @bodyClass
@modalClass Use angle-bracket syntax with regular html attribute: <DModal class="blah">
@titleAriaElementId Use angle-bracket syntax with regular html attribute: <DModal aria-labelledby="blah">
@dismissable, @submitOnEnter, @headerClass Unchanged

If there was any footer content rendered after the old <DModalBody> component, use the new <:footer> named block to introduce it inside <DModal>. When using any named blocks, the body content should be wrapped in <:body></:body>. For example:

<DModal @closeModal={{@closeModal}}>
  <:body>
    Hello world, this is the content of the modal
  </:body>
  <:footer>
    This is the footer content. A `.modal-footer` wrapper will be added
    automatically
  </:footer>
</DModal>

Step 4: Update the showModal call sites

Previously, modals would be rendered using the showModal API, which would take a string (the controller name) and a number of opts. It would return an instance of the controller which could be manipulated:

import showModal from "discourse/lib/show-modal";

export default class extends Component {
  showMyModal() {
    const controller = showModal("my-modal", {
      title: "My Modal Title",
      modalClass: "my-modal-class",
      model: { topic: this.topic },
    });

    controller.set("updateTopic", this.updateTopic);
  });
}

To render new component-based Modals you should inject the ‘modal’ service (or access it using something like getOwner(this).lookup("service:modal")) and call the show() function.

show() takes a reference to the new Component class as the first argument. The only opt still supported is ‘model’, which can be used to pass all data/actions required for your Modal.

No reference to the component instance will be returned. Instead, show() returns a promise which will resolve when the modal is closed. The promise will resolve with any data which was passed to @closeModal.

import MyModal from "discourse/components/my-modal";
import { service } from "@ember/service";

export default class extends Component {
  @service modal;

  showMyModal() {
    this.modal.show(MyModal, {
      model: { topic: this.topic, updateTopic: this.updateTopic },
    });
  });
}

Alternatively, migrate to the declarative API described in the main DModal documentation.

The functionality of the old options can be replicated as follows:

Old showModal opt Solution
admin n/a for component - remove it
templateName n/a for components - remove it
title move to <DModal @title={{i18n "blah"}}>
titleTranslated move to <DModal @title="blah">. This could be computed based on data from model if needed
modalClass move to <DModal class="blah">
titleAriaElementId move to <DModal aria-labelledby="blah">
panels Use the <:headerBelowTitle> named block to implement tabs in your component (example)
model unchanged

Step 5: Tests

Any tests should largely remain the same. The most common issue are:

  • Modals no longer have a default class based on their name. Classes must be specified explicitly in the template (see beginning of Step 3)

  • The d-modal wrapper no longer persists in the DOM when the modal is closed. To check all modals are closed, use a check like assert.dom('.d-modal').doesNotExist()

Profit!

Your modal should now work as it did before. To take further advantage of the new API, you may want to consider replacing showModal calls with a declarative strategy, and converting your Modal to be a Glimmer component.

Examples

Here are some example commits which demonstrate converting some of Discourse core’s modals to the new API:


This document is version controlled - suggest changes on github.


  1. Classic Ember Components are recommended in this guide because they provided the easiest migration path from Ember Controllers. But for simple modals, or if you’re happy to spend some time refactoring, modern Glimmer components are the better choice. ↩︎

20 个赞

这看起来真的很棒。这让我有希望可以将我的模态框转换为 Ember 4。我几乎不理解我写的 Ember 代码,所以写我能理解的文档并不容易。非常感谢。

8 个赞

感谢教程!查看示例非常有帮助。在一个小时内就修复了我自定义插件的模态框。

4 个赞

我目前正在处理此转换,但遇到一个问题:

以前,我们的模态没有相应的控制器/JS 定义,我们可以通过 showModal($HBS_FILE_NAME) 来显示模态。由于新的 show() 需要传入一个组件,因此我需要引入此 JS 定义(这是正确的假设吗?)。

我添加了类似以下内容:

import Component from '@glimmer/component';

export default class SomeModal extends Component {

  constructor() {
    super(...arguments);
    console.log('Modal constructor')
  }
}

并将之前的 .hbs 文件(对 DModal 进行了必要的更改)都放在了 /components/modal 目录下,文件名相同。当尝试渲染模态(通过 getOwner(this).lookup("service:modal").show(SomeModal))时,我在控制台中看到了我的构造函数日志,但模态并未渲染。

此更改是否需要在控制器/JS 定义中进行其他配置? 任何指导都将不胜感激!

如果你不添加任何代码,就不需要它。

你只需要 .hbs 文件。

例如,discourse-templates 没有对应的 JS 文件来处理模态框的 handlebars 模板。

你是否按照说明调整了你的 handlebars 模板?

控制台中是否有任何错误?

2 个赞

感谢您的反馈!我犯了一个巨大的 :facepalm: 错误,我把文件移到了 .../discourse/templates/components/modal 目录,而不是 .../discourse/components/modal。现在一切都按预期工作了(无论是否有 .js 控制器),谢谢您!

3 个赞

能否请您演示一下如何在 head_tag.html 文件中的脚本内调用 showModal()?在我的例子中,我需要使用

document.querySelector(".actions .double-button .toggle-like");

来捕获点击事件,检查条件,然后显示一个自定义模态框。

1 个赞

David,非常感谢你如此清晰地记录了这一切!

在一下午的时间里,我就已经基本清除了我们最大插件的 3.2 版本弃用项。

3 个赞

如何在新版核心中访问现有模态以对其进行修改?

过去我曾使用过(但现在已失效)此方法:
api.modifyClass("controller:poll-ui-builder", {

在这种特定情况下,该类名似乎已正确声明且未更改。

2 个赞

根据您需要修改的内容,我认为最好的解决方案是使用 PluginOutlet 来注入您的自定义代码,或者使用 PluginOutlet Wrapper 来替换/有条件地显示核心实现。(如果插座不可用,您可以提交 PR 来添加它)

如果您确实想使用 modifyClass,它仍然是可能的,只是模态框现在是一个组件,并且嵌套在 components/modal 中,因此您可以通过以下方式访问它:

api.modifyClass("component:modal/poll-ui-builder", {
  pluginId: "your-custom-plugin-id",

  // 插入自定义代码
});
4 个赞