레거시 컨트롤러의 모달을 새로운 DModal 컴포넌트 API로 변환하기

:information_source: 새로운 Modal을 구현하고 있다면, 여기의 주요 문서를 확인해 보세요. 이 주제는 기존 컨트롤러 기반 Modal을 새로운 컴포넌트 기반 API로 마이그레이션하는 방법에 대해 설명합니다.

과거에 Discourse는 모달을 렌더링하기 위해 Ember-컨트롤러 기반 API를 사용했습니다. 모달을 호출하려면 컨트롤러 이름을 문자열로 showModal()에 전달해야 했습니다. 내부적으로 이는 Ember의 Route#renderTemplate API를 사용했는데, 이 API는 Ember 3.x에서 비추천(deprecated) 상태이며 Ember 4.x에서 제거될 예정입니다.

Discourse가 Ember 4.x 이상으로 업그레이드할 수 있도록, 모달을 위한 새로운 컴포넌트 기반 API를 도입했습니다. 이 새로운 API는 Ember의 ‘선언적’ 설계 패턴을 수용하며, 깔끔한 DDAU(Data Down, Actions Up) 의미를 제공하도록 설계되었습니다.

단계 1: 파일 이동

컨트롤러 JS 파일과 템플릿 파일을 /components/modal 디렉터리로 이동하세요. 이렇게 하면 '동반(colocated) 컴포넌트’가 되어 다른 JS 모듈과 마찬가지로 가져올 수 있습니다.

단계 2: JS 파일 업데이트

이제 컴포넌트 JS 정의를 @ember/controller가 아닌 @ember/component을 상속하도록 업데이트하세요 [1]. ModalFunctionality 믹신을 제거하고, 아래 표에 따라 해당 함수 사용법을 업데이트하세요:

이전 이후
flash()clearFlash() 컴포넌트에 flash 속성을 생성하고 <DModal>@flash 인자로 전달하세요. 기본적으로 알림은 alert 클래스로 스타일링되며 이는 ‘error’ 클래스의 복사본이지만, @flashType 인자를 사용하여 오버라이드할 수 있습니다.
showModal() discourse/lib/show-modal에서 showModal 함수를 가져오세요
closeModal 액션 컴포넌트로 자동 전달되는 closeModal 인자를 호출하세요

구식 스타일의 모달 컨트롤러는 ‘영구적으로’ 존재했기 때문에 상태를 수동으로 정리해야 했습니다. 새로운 컴포넌트 기반 API에서는 모달이 표시/숨겨질 때 컴포넌트가 생성되고 파괴됩니다. 많은 경우 기존 라이프사이클 훅이 더 이상 필요하지 않음을 의미합니다.

여전히 라이프사이클 기반 로직이 필요하다면 다음 표를 사용하세요:

이전 이후
onShow() 표준 Ember 컴포넌트 라이프사이클(init() 또는 Ember modifier) 사용
afterRender 표준 Ember 컴포넌트 라이프사이클(init() 또는 Ember modifier) 사용
beforeClose() 컴포넌트로 전달되는 @closeModal 인자를 감싸는 래퍼를 생성하세요. 클로즈 래퍼의 참조를 DModal에 전달하여 <DModal @closeModal={{this.myCloseModalWrapper}}처럼 사용하세요
onClose() 표준 Ember 컴포넌트 라이프사이클(willDestroy() 또는 Ember modifier) 사용

단계 3: 템플릿 업데이트

<DModalBody> 래퍼를 <DModal>로 교체하세요. 몇 가지 새로운 속성을 추가합니다:

  • 새로운 @closeModal 인자를 전달하세요.
  • 명시적인 클래스를 추가하세요. 기존 동작과 일치시키기 위해 컨트롤러 파일명에 -modal을 추가하세요.

예를 들어, 모달 컨트롤러가 close-topic.js였다면, 새로운 <DModal> 호출은 다음과 유사해 보일 것입니다:

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

DModalBody 호출에 다른 인자가 포함된 경우, 아래 표에 따라 업데이트하세요:

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 일반 html 속성을 가진 앵글 브래킷 문법 사용: <DModal class="blah">
@titleAriaElementId 일반 html 속성을 가진 앵글 브래킷 문법 사용: <DModal aria-labelledby="blah">
@dismissable, @submitOnEnter, @headerClass 변경 없음

기존 <DModalBody> 컴포넌트 뒤에 렌더링되던 푸터 콘텐츠가 있었다면, <DModal> 내부에 이를 도입하기 위해 새로운 <:footer> 이름 지정 블록(named block)을 사용하세요. 이름 지정 블록을 사용할 때, 본문 콘텐츠는 <:body></:body>로 감싸야 합니다. 예를 들어:

<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>

단계 4: showModal 호출 지점 업데이트

이전에는 모달이 showModal API를 사용하여 렌더링되었는데, 이는 문자열(컨트롤러 이름)과 여러 옵션을 취했습니다. 컨트롤러 인스턴스를 반환하여 조작할 수 있었습니다:

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);
  });
}

새로운 컴포넌트 기반 모달을 렌더링하려면 ‘modal’ 서비스를 주입하거나(getOwner(this).lookup("service:modal")와 같은 방식으로 액세스) show() 함수를 호출해야 합니다.

show()는 첫 번째 인자로 새로운 컴포넌트 클래스의 참조를 취합니다. 여전히 지원되는 유일한 옵션은 'model’로, 모달에 필요한 모든 데이터/액션을 전달하는 데 사용할 수 있습니다.

컴포넌트 인스턴스에 대한 참조는 반환되지 않습니다. 대신 show()는 모달이 닫힐 때 이행(resolve)되는 프롬ises를 반환합니다. 이 프롬ises는 @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 },
    });
  });
}

대안으로, 주요 DModal 문서에 설명된 선언적 API로 마이그레이션할 수 있습니다.

기존 옵션의 기능은 다음과 같이 재현할 수 있습니다:

Old showModal opt Solution
admin 컴포넌트에 해당 없음 - 제거하세요
templateName 컴포넌트에 해당 없음 - 제거하세요
title <DModal @title={{i18n "blah"}}>로 이동
titleTranslated <DModal @title="blah">로 이동. 필요할 경우 model의 데이터 기반으로 계산할 수 있습니다
modalClass <DModal class="blah">로 이동
titleAriaElementId <DModal aria-labelledby="blah">로 이동
panels 컴포넌트에서 탭을 구현하기 위해 <:headerBelowTitle> 이름 지정 블록 사용 (예시)
model 변경 없음

단계 5: 테스트

테스트는 대부분 동일하게 유지되어야 합니다. 가장 흔한 문제는 다음과 같습니다:

  • 모달은 더 이상 이름 기반의 기본 클래스를 가지지 않습니다. 클래스는 템플릿에서 명시적으로 지정해야 합니다(단계 3의 시작 부분 참조)

  • 모달이 닫힐 때 d-modal 래퍼가 더 이상 DOM에 유지되지 않습니다. 모든 모달이 닫혔는지 확인하려면 assert.dom('.d-modal').doesNotExist()와 같은 체크를 사용하세요

성공!

이제 모달이 이전과 동일하게 작동해야 합니다. 새로운 API의 이점을 더 많이 활용하려면, 선언적 전략으로 showModal 호출을 교체하고 모달을 Glimmer 컴포넌트로 변환하는 것을 고려해 보세요.

예시

Discourse 코어의 일부 모달을 새로운 API로 변환하는 과정을 보여주는 예시 커밋입니다:


이 문서는 버전 관리됩니다 - github에서 변경 사항을 제안하세요.


  1. 이 가이드에서는 Ember 컨트롤러에서 가장 쉬운 마이그레이션 경로를 제공했기 때문에 클래식 Ember 컴포넌트를 권장합니다. 하지만 간단한 모달이거나 리팩토링에 시간을 들이는 것이 괜찮다면, 모던 Glimmer 컴포넌트가 더 나은 선택입니다. ↩︎

20개의 좋아요

This looks really great. It gives me hope they I can convert my modals to ember 4. I only barely understand the ember code that I write, so writing documentation that I can understand is not easy. Thanks very much for this.

8개의 좋아요

Thanks for the tutorial! Looking at the examples was highly useful. Had been able to fix my custom plugin modal broken in an hour.

4개의 좋아요

I’m working on this conversion right now, but running into an issue:

Previously, our modal did not have a corresponding controller/JS definition, and we were able to show the modal through showModal($HBS_FILE_NAME). Since the new show() requires a component to be passed in, I need to introduce this JS definition (is this a correct assuption?).

I added something like:

import Component from '@glimmer/component';

export default class SomeModal extends Component {

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

and have the previous .hbs file (with required changes to DModal) both in the /components/modal directory with the same file name. When trying to render the modal (via getOwner(this).lookup("service:modal").show(SomeModal)), I see my constructor log printed in console, but the modal is not rendered.

Is there any other configuration needed in the controller/JS definition needed for this change? Any guidance would be much appreciated!

You don’t need it if you’re not adding any code.

You can have just the .hbs file.

discourse-templates, for example, doesn’t have a corresponding JS file for the modal handlebars template.

Did you adapt your handlebars template following the instructions?

Are there any errors in the console?

2개의 좋아요

Thanks for the feedback! Huge :facepalm: on my end, I had moved files to the .../discourse/templates/components/modal dir, instead of .../discourse/components/modal. Things are working as expected now (with or without the .js controller), thank you!

3개의 좋아요

Could you show me how to call showModal() from scrip inside a head_tag.html please? In my case I need to use

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

to catch the click event, check the condition and then show a custom modal.

1개의 좋아요

Really appreciate the effort you made here to document this so clearly, David!

I’ve all but managed to clear deprecations for 3.2 in an afternoon on our biggest plugin.

3개의 좋아요

How do you now access an existing modal in core to modify it?

In the past I’ve used this (which no longer works):
api.modifyClass("controller:poll-ui-builder", {

In this particular case, that class name seems to be declared nicely and is unchanged.

2개의 좋아요

Depending on what you need to modify, I think the best solution would be to use a PluginOutlet to inject your custom code, or a PluginOutlet Wrapper to replace/conditionally show the core implementation. (You can PR to add an outlet if its not available)

If you really want to use modifyClass it should be still possible, it’s just that the modal is a component now and its nested in components/modal so you would access it like:

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

   // insert custom code
});
4개의 좋아요