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 是一個蓋版視窗,使用者必須要按下
Confirm
或Cancel
按鈕才能繼續操作,所以選擇使用Promise
物件來等待使用者按下按鈕。執行
confirmBox
的時候,預期會 create 一個promise
物件,並在裡面將 React Component 掛載到某個 div 上面(ReactDOM.render
),並將resolve
、reject
function 當作 props 一併傳到 modal 中使用。因為 modal 擁有了
resolve
、reject
這兩個可以繼續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,並等待使用者按下按鈕之後接續呼叫
resolve
、reject
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,會省下未來不少時間。