레거시 컨트롤러의 모달을 새로운 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개의 좋아요

정말 훌륭해 보여요. 덕분에 제 모달을 Ember 4로 전환할 수 있겠다는 희망이 생겼어요. 제가 작성하는 Ember 코드를 저도 겨우 이해하는 수준이라, 제가 이해할 수 있는 문서를 작성하는 게 쉽지 않거든요. 정말 감사합니다.

8개의 좋아요

튜토리얼 감사합니다! 예제를 참고하는 것이 매우 유용했습니다. 덕분에 1시간 만에 커스텀 플러그인의 깨진 모달 문제를 해결할 수 있었습니다.

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에는 모달 handlebars 템플릿에 해당하는 JS 파일이 없습니다.

지시에 따라 handlebars 템플릿을 수정했나요?

콘솔에 오류가 있나요?

2개의 좋아요

피드백 감사합니다! 제 쪽에서 큰 실수를 했습니다. 파일을 .../discourse/templates/components/modal 디렉터리로 옮겼는데, 실제로는 .../discourse/components/modal로 옮겨야 했습니다. 이제 .js 컨트롤러가 있든 없든 예상대로 잘 작동하고 있습니다. 감사합니다!

3개의 좋아요

head_tag.html 내부의 스크립트에서 showModal()을 호출하는 방법을 알려주실 수 있을까요? 제 경우에는 클릭 이벤트를 감지하고 조건을 확인한 후 커스텀 모달을 표시하기 위해 다음 코드를 사용해야 합니다.

document.querySelector(".actions .double-button .toggle-like");
1개의 좋아요

데이비드, 이렇게 명확하게 문서화해 주셔서 정말 감사합니다!

우리 최대 규모의 플러그인에서 3.2의 비추천(deprecation) 항목을 오후 한 시간 만에 거의 전부 정리해 두었습니다.

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

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