React Promise Modal

專案中很常出現按鈕點下跳出 “確認視窗” 的行為,有時候是刪除的警告確認、有時候是資料送出的確認。這些有共同操作行為的 Component 都應該做成共用的元件以方便在各種情況下重複使用, 才不會某 A 做一個版本,某 B 做另一個版本,大家都自己寫自己的造成維護上的困難。

此文章不會詳細列出各項程式碼,如果有興趣可以 clone source code 下來研究研究。

封面圖來源:https://mrdoob.com/lab/javascript/effects/ie6/


期望可以用以下程式碼達到我要的目的:

import React from 'react'

import { confirmBox } from './promiseModal'

class App extends React.Component {
  render() {
    <button
      onClick={() => {
        confirmBox({
          title: 'Logout',
          description: 'Are you sure to logout?',
        }).then(() => {
          console.log('Confirm, Do some logout action...')
        }).catch(() => {
          console.log('Cancel, Nothing happened...')  
        })
      }}
    >
      Logout
    </button>
  }
}

confirmbox 執行並帶入客製化 params,期望最後會回傳 promise 物件,並由 .then() 或是 .catch() 來知道使用者點下 Confirm 還是 Cancel 再做後續的動作。


實作步驟分析

  • 準備一個基本的 popup modal component(確認視窗)。

  • 因為 modal 是一個蓋版視窗,使用者必須要按下 ConfirmCancel 按鈕才能繼續操作,所以選擇使用 Promise 物件來等待使用者按下按鈕。

  • 執行 confirmBox 的時候,預期會 create 一個 promise 物件,並在裡面將 React Component 掛載到某個 div 上面(ReactDOM.render),並將 resolvereject function 當作 props 一併傳到 modal 中使用。

  • 因為 modal 擁有了 resolvereject 這兩個可以繼續 promise 的 function,所以可以在按下 Confirm 按鈕的時候呼叫 resolve,按下 Cancel 按鈕的時候呼叫 reject 來達成 Promise Chain 的目的。

  • 無論按下哪一個按鈕後都應該要清除、卸載,動態掛上去的 React Component。

  • 以下解說都會省略 css code, 有興趣可以看 source code


Step 0. 準備範例按鈕

class App extends Component {
  render() {
    return (
      <div className="App">
        <button
          type='button'
          className='btn-example logout'
        >
          Logout
        </button>

        <button
          type='button'
          className='btn-example submit'
        >
          Submit
        </button>

        <button
          type='button'
          className='btn-example delete'
        >
          Destroy
        </button>
      </div>
    );
  }
}


Step 1. 準備基本 popup modal

使用 react-modal 來快速建造一個確認視窗的 modal。

import React, { useState } from 'react'
import PropTypes from 'prop-types'
import Modal from 'react-modal'

const ConfirmBox = (props) => {
  const {
    color, // 客製化顏色
    title, // 客製化文字
    description, // 客製化文字
    onConfirm, // 預期收到 resolve function
    onCancel, // 預期收到 reject function
    confirmText, // 客製化文字
    cancelText, // 客製化文字
  } = props

  // `useState` 是 react 16.8 新功能 hooks, 等同於 state/setState 效果
  // 初始值為 true
  const [isOpen, setIsOpen] = useState(true) 

  const onClickConfirm = () => {
    // 按下 Confirm 將 modal 關閉, 並執行 onConfirm (resolve function)
    setIsOpen(false)
    onConfirm()
  }

  const onClickCancel = () => {
    // 按下 Confirm 將 modal 關閉, 並執行 onCancel (reject function)
    setIsOpen(false)
    onCancel()
  }

  return (
    <Modal
      overlayClassName='promise-modal-overlay'
      className='confirm-box'
      isOpen={isOpen}
      onRequestClose={onClickCancel} // 點下 modal 之外的區域時觸發 (modal overlay)
      shouldReturnFocusAfterClose={false}
    >
     <div className={`modal-container color-${color}`}>

      <div className='modal-header'>
        <h3>{ title }</h3>
        <button
          type='button'
          className='close-btn'
          onClick={onClickCancel}
        >
          X
        </button>
      </div>

      <div className='modal-content'>
        <p>{ description }</p>
        <div className='button-list'>
          <button
            type='button'
            className='cancel'
            onClick={onClickCancel}
          >
            { cancelText }
          </button>
          <button
            type='button'
            className='confirm'
            onClick={() => onClickConfirm()}
          >
            { confirmText }
          </button>
        </div>
      </div>
     </div> 
    </Modal>
  )
}

測試一下剛剛做的 Component Render 有沒有正確,丟到測試頁面看看 js/css 有沒有正確。


Step 2. 製作 Promise function

流程可以拆分為以下:

  • 動態產生不重複的 div element,為了讓 ReactDom.render 掛載上去。
  • new 一個 Promise 物件,並在第一個 promise 裡面將 Modal Component 掛載上去。
  • 成功 render React Modal 之後就會在介面上看到蓋版 Modal,並等待使用者按下按鈕之後接續呼叫 resolvereject function。
  • 卸載、移除先前動態產生的 Component、Element。
import React from 'react'
import ReactDOM from 'react-dom'
import Modal from 'react-modal'

// 卸載、移除先前動態產生的 Component、Element
const unsetPromiseModal = (wrapper) => {
  setTimeout(() => {
    ReactDOM.unmountComponentAtNode(wrapper)
    wrapper.remove()
  }, 300)
}

// 動態產生不重複的 div element
const findOrCreateWrapper = (identifyID) => {
  let wrapper = document.getElementById(identifyID)
  if (wrapper === null) {
    wrapper = document.body.appendChild(document.createElement('div'))
    wrapper.id = identifyID
  }
  return wrapper
}

// params 預期接收要被掛載的 Component, 跟客製化 options
const promisableModal = (Component, options = {}) => {

  // 動態產生不重複的 div element
  const wrapper = findOrCreateWrapper(`promise-modal-wrapper-${Date.now()}`)

  // 無障礙網頁辨識用, 參考:http://reactcommunity.org/react-modal/accessibility/#
  Modal.setAppElement(wrapper)

  const promise = new Promise((resolve, reject) => {
    ReactDOM.render( // 掛載 React Component
      <Component
        {...options} // 帶入客製化參數(props)
        onConfirm={resolve} // resolve 傳給 onConfirm
        onCancel={reject} // reject 傳給 onCancel
      />,
      wrapper,
    )
  })

  return promise.then(() => {
    // 使用者按下 Confirm 之後執行此段
    unsetPromiseModal(wrapper)
    return true
  }, () => {
    // 使用者按下 Cancel 之後執行此段
    unsetPromiseModal(wrapper)
    return Promise.reject(false)
  })
}

export default promisableModal

由於最後回傳的是一個 promise 物件,如果接續著呼叫 .then() 會在使用者按下 Confirm 後接續執行;如果接續著呼叫 .catch() 會在使用者按下 Cancel 後接續執行。


Step 3. 組合

import './style.scss' // base modal overlay csss

import promisableModal from './promisableModal'
import ConfirmBox from './components/ConfirmBox'

// 將 ConfirmBox 和 promisableModal 做組合
const confirmBox = props => promisableModal(ConfirmBox, props)

export {
  promisableModal,
  confirmBox,
}

因為已經寫好了可以 promisable 的 function,所以未來如果想要擴充其他種類型的 modal,都可以透過 promisable 輸出,例如未來我如果想做一個編輯蓋版視窗:


... 省略

import EditBox from './components/EditBox'

const editBox = props => promisableModal(EditBox, props)

export {
  promisableModal,
  confirmBox,
  editBox, // 新的擴充
}


Step 4. 使用

三種不同情境的確認視窗,都只要呼叫同一個 promise function 來達成目的,達到 reuse 的目的。

import React, { Component } from 'react'
import './App.scss'

import { confirmBox } from './promiseModal'

class App extends Component {
  render() {
    return (
      <div className="App">
        <button
          type='button'
          className='btn-example logout'
          onClick={() => {
            confirmBox({
              color: 'primary',
              title: 'Logout',
              description: 'Are you sure to logout?',
            }).then(() => {
              console.log('%c Click Confirm!, prepare to logout ...', 'color: blue')
            }).catch(() => {
              console.log('%c Click Cancel!, nothing happened ...', 'color: blue')
            }) 
          }}
        >
          Logout
        </button>

        <button
          type='button'
          className='btn-example submit'
          onClick={() => {
            confirmBox({
              color: 'info',
              title: 'Submit Form',
              description: 'Are you sure to submit form?',
              confirmText: 'Submit',
            }).then(() => {
              console.log('%c Click Confirm!, prepare to fetch data to backend api ...', 'color: green')
            }).catch(() => {
              console.log('%c Click Cancel!, nothing happened ...', 'color: green')
            }) 
          }}
        >
          Submit
        </button>

        <button
          type='button'
          className='btn-example delete'
          onClick={() => {
            confirmBox({
              color: 'danger',
              title: 'Delete Product',
              description: 'Are you sure to delete this product?',
              confirmText: 'Delete',
            }).then(() => {
              console.log('%c Click Confirm!, prepare to fetch data to delete product ...', 'color: red')
            }).catch(() => {
              console.log('%c Click Cancel!, nothing happened ...', 'color: red')
            }) 
          }}
        >
          Destroy
        </button>
      </div>
    );
  }
}


Don’t Repeat Yourself,這個範例的元件在我先前的幾份專案中都有用到,而且都只是複製最初的那一份出來調整 css 而已。

寫 React 的時候不妨思考一下專案中哪些是常常共用的元件,試著練習抽離出來做成 shared component,會省下未來不少時間。