@rpcbase/ui 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Modal/HashStateModal.js +32 -0
- package/Modal/Modal.js +97 -0
- package/Modal/ModalForm/AlertBanner.js +83 -0
- package/Modal/ModalForm/index.js +193 -0
- package/Modal/index.js +10 -0
- package/Modal/withHashStateModal.js +24 -0
- package/package.json +1 -1
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/* @flow */
|
|
2
|
+
import assert from "assert"
|
|
3
|
+
|
|
4
|
+
import {useHashState} from "@rpcbase/client/hashState"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
const HashStateModal = ({hashPropName, children}) => {
|
|
8
|
+
const {hashState, serializeHashState} = useHashState()
|
|
9
|
+
|
|
10
|
+
if (children) {
|
|
11
|
+
assert(typeof children === "function", "HashStateModal children must be a function")
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const show = !!hashState[hashPropName]
|
|
15
|
+
|
|
16
|
+
console.log("WOWOOW", show)
|
|
17
|
+
|
|
18
|
+
if (!show) return null
|
|
19
|
+
|
|
20
|
+
const onHide = () => {
|
|
21
|
+
serializeHashState({
|
|
22
|
+
[hashPropName]: null,
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const hashProps = {}
|
|
27
|
+
hashProps[hashPropName] = hashState[hashPropName]
|
|
28
|
+
|
|
29
|
+
return children({onHide, ...hashProps})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export default HashStateModal
|
package/Modal/Modal.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/* @flow */
|
|
2
|
+
import {forwardRef, useEffect, useRef} from "react"
|
|
3
|
+
import _debounce from "lodash/debounce"
|
|
4
|
+
import BSModal from "react-bootstrap/Modal"
|
|
5
|
+
|
|
6
|
+
import "./modal.scss"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
const MAX_RETRIES = 256
|
|
10
|
+
const DELAY_MS = 20
|
|
11
|
+
|
|
12
|
+
const Modal = forwardRef(
|
|
13
|
+
(
|
|
14
|
+
{
|
|
15
|
+
show = true,
|
|
16
|
+
dark = false,
|
|
17
|
+
scrollable = true,
|
|
18
|
+
animation = true,
|
|
19
|
+
className = "",
|
|
20
|
+
children,
|
|
21
|
+
...props
|
|
22
|
+
},
|
|
23
|
+
_ref,
|
|
24
|
+
) => {
|
|
25
|
+
const internalRef = useRef(null)
|
|
26
|
+
const ref = _ref || internalRef
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (!show) return
|
|
30
|
+
|
|
31
|
+
let attemptsCount = 0
|
|
32
|
+
let ro
|
|
33
|
+
|
|
34
|
+
const setup = () => {
|
|
35
|
+
const bodyEl = ref.current?.dialog?.querySelector(".modal-body")
|
|
36
|
+
if (!bodyEl) {
|
|
37
|
+
attemptsCount++
|
|
38
|
+
if (attemptsCount > MAX_RETRIES) {
|
|
39
|
+
throw new Error("unable to initialize after max attempts")
|
|
40
|
+
}
|
|
41
|
+
setTimeout(setup, DELAY_MS)
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const checkApplyScroller = _debounce(() => {
|
|
46
|
+
const contentEl = ref.current?.dialog?.querySelector(".modal-content")
|
|
47
|
+
// TODO: this shouldn't happen, but it only happens with modalforms... investigate
|
|
48
|
+
// additionally we're eventually going to ditch the react-bootstrap modals and use our own so don't waste so much time on this
|
|
49
|
+
if (!contentEl) return
|
|
50
|
+
|
|
51
|
+
if (bodyEl.scrollHeight > bodyEl.clientHeight) {
|
|
52
|
+
if (!contentEl.classList.contains("has-scroller")) {
|
|
53
|
+
contentEl.classList.add("has-scroller")
|
|
54
|
+
}
|
|
55
|
+
} else {
|
|
56
|
+
if (contentEl.classList.contains("has-scroller")) {
|
|
57
|
+
contentEl.classList.remove("has-scroller")
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}, DELAY_MS)
|
|
61
|
+
|
|
62
|
+
ro = new ResizeObserver((entries) => {
|
|
63
|
+
checkApplyScroller()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
ro.observe(bodyEl)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
setup()
|
|
70
|
+
|
|
71
|
+
return () => {
|
|
72
|
+
ro?.disconnect()
|
|
73
|
+
}
|
|
74
|
+
}, [show])
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<BSModal
|
|
78
|
+
className={cx({"is-dark": dark}, className)}
|
|
79
|
+
centered={true}
|
|
80
|
+
scrollable={scrollable}
|
|
81
|
+
animation={animation}
|
|
82
|
+
ref={ref}
|
|
83
|
+
show={show}
|
|
84
|
+
{...props}
|
|
85
|
+
>
|
|
86
|
+
{children}
|
|
87
|
+
</BSModal>
|
|
88
|
+
)
|
|
89
|
+
},
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
Modal.Dialog = BSModal.Dialog
|
|
93
|
+
Modal.Header = BSModal.Header
|
|
94
|
+
Modal.Body = BSModal.Body
|
|
95
|
+
Modal.Footer = BSModal.Footer
|
|
96
|
+
|
|
97
|
+
export default Modal
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/* @flow */
|
|
2
|
+
import {forwardRef, useImperativeHandle, useState, useEffect} from "react"
|
|
3
|
+
import {useFormContext} from "react-hook-form"
|
|
4
|
+
import Alert from "react-bootstrap/Alert"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
const AlertBanner = forwardRef(({modalRef, ...props}, ref) => {
|
|
8
|
+
const {formState} = useFormContext()
|
|
9
|
+
|
|
10
|
+
const [alertContent, setAlertContent] = useState(null)
|
|
11
|
+
|
|
12
|
+
// remove alert if we are done submitting and there are no dirty fields
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
const hasDirtyFields = Object.keys(formState.dirtyFields).length > 0
|
|
15
|
+
if (alertContent && !formState.isSubmitting && !hasDirtyFields) {
|
|
16
|
+
setAlertContent(null)
|
|
17
|
+
}
|
|
18
|
+
}, [alertContent, formState.isSubmitting, formState.dirtyFields])
|
|
19
|
+
|
|
20
|
+
const onClickDiscardAndClose = () => {
|
|
21
|
+
modalRef.current.forceClose()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const bounce = () => {
|
|
25
|
+
requestAnimationFrame(() => {
|
|
26
|
+
const el = document.querySelector(".bouncy-alert")
|
|
27
|
+
|
|
28
|
+
el.classList.add("bounce")
|
|
29
|
+
|
|
30
|
+
setTimeout(() => {
|
|
31
|
+
requestAnimationFrame(() => {
|
|
32
|
+
el.classList.remove("bounce")
|
|
33
|
+
})
|
|
34
|
+
// WARNING: timeout must match value in css
|
|
35
|
+
}, 220)
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
useImperativeHandle(ref, () => ({
|
|
40
|
+
onRequestHide: () => {
|
|
41
|
+
const {isSubmitting, dirtyFields} = formState
|
|
42
|
+
|
|
43
|
+
// do not close when form submitting
|
|
44
|
+
if (isSubmitting) {
|
|
45
|
+
setAlertContent(
|
|
46
|
+
<Alert variant={"danger"} className="bouncy-alert d-flex">
|
|
47
|
+
<div>Cannot close while submitting, please do not close this page.</div>
|
|
48
|
+
</Alert>,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
bounce()
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
// when form is dirty do not close
|
|
55
|
+
else if (Object.keys(dirtyFields).length > 0) {
|
|
56
|
+
setAlertContent(
|
|
57
|
+
<Alert variant={"light"} className="bouncy-alert d-flex">
|
|
58
|
+
<div>Are you sure you want to close without saving?</div>
|
|
59
|
+
<div role="button" onClick={onClickDiscardAndClose} className="link-primary ms-2">
|
|
60
|
+
Discard and Close
|
|
61
|
+
</div>
|
|
62
|
+
</Alert>,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
bounce()
|
|
66
|
+
return false
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
setAlertContent(null)
|
|
70
|
+
return true
|
|
71
|
+
},
|
|
72
|
+
}))
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div className="modal-form-alerts w-100" style={{top: -70}}>
|
|
76
|
+
{alertContent}
|
|
77
|
+
</div>
|
|
78
|
+
)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
AlertBanner.displayName = "AlertBanner"
|
|
82
|
+
|
|
83
|
+
export default AlertBanner
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/* @flow */
|
|
2
|
+
import debug from "debug"
|
|
3
|
+
import {forwardRef, useImperativeHandle, useEffect, useState, useRef} from "react"
|
|
4
|
+
import {FormProvider} from "react-hook-form"
|
|
5
|
+
|
|
6
|
+
import ActivityIndicator from "../../ActivityIndicator"
|
|
7
|
+
import SubmitButton from "../../SubmitButton"
|
|
8
|
+
|
|
9
|
+
import Modal from "../Modal"
|
|
10
|
+
|
|
11
|
+
import AlertBanner from "./AlertBanner"
|
|
12
|
+
|
|
13
|
+
import "./modal-form.scss"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
const log = debug("form")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
const ExtLink = ({to}) => {
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<a target="_blank" rel="noopener noreferrer" href={to}>
|
|
23
|
+
{to}
|
|
24
|
+
</a>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
const SHOW_LOADER_DELAY = 120 // ms
|
|
30
|
+
|
|
31
|
+
// Bootstrap Modal integrated with hook form
|
|
32
|
+
const ModalForm = forwardRef(
|
|
33
|
+
(
|
|
34
|
+
{
|
|
35
|
+
title,
|
|
36
|
+
icon,
|
|
37
|
+
footerLink,
|
|
38
|
+
submitTitle,
|
|
39
|
+
submittingTitle,
|
|
40
|
+
show,
|
|
41
|
+
onHide,
|
|
42
|
+
isLiveSubmit = false,
|
|
43
|
+
form,
|
|
44
|
+
onSubmit,
|
|
45
|
+
children,
|
|
46
|
+
...props
|
|
47
|
+
},
|
|
48
|
+
_ref,
|
|
49
|
+
) => {
|
|
50
|
+
const {formState, watch, handleSubmit} = form
|
|
51
|
+
|
|
52
|
+
const internalRef = useRef(null)
|
|
53
|
+
const ref = _ref || internalRef
|
|
54
|
+
|
|
55
|
+
const alertBannerRef = useRef(null)
|
|
56
|
+
|
|
57
|
+
// timeoutRef: only start displaying isBusy after a short delay
|
|
58
|
+
// to avoid screen flashing when the data loads fast enough
|
|
59
|
+
const timeoutRef = useRef(null)
|
|
60
|
+
const [isBusy, setIsBusy] = useState(false)
|
|
61
|
+
|
|
62
|
+
useImperativeHandle(ref, () => ({
|
|
63
|
+
...ref.current,
|
|
64
|
+
forceClose: () => {
|
|
65
|
+
onHide()
|
|
66
|
+
},
|
|
67
|
+
}))
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
// busy when either submitting or loading (async getDefaultValues)
|
|
71
|
+
if (formState.isSubmitting || formState.isLoading) {
|
|
72
|
+
timeoutRef.current = setTimeout(() => {
|
|
73
|
+
setIsBusy(true)
|
|
74
|
+
}, SHOW_LOADER_DELAY)
|
|
75
|
+
} else {
|
|
76
|
+
if (timeoutRef.current) {
|
|
77
|
+
window.clearTimeout(timeoutRef.current)
|
|
78
|
+
}
|
|
79
|
+
setIsBusy(false)
|
|
80
|
+
}
|
|
81
|
+
}, [formState.isSubmitting, formState.isLoading, setIsBusy])
|
|
82
|
+
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (isLiveSubmit) {
|
|
85
|
+
const subscription = watch(handleSubmit(onSubmit))
|
|
86
|
+
return () => subscription.unsubscribe()
|
|
87
|
+
}
|
|
88
|
+
}, [isLiveSubmit, handleSubmit, watch])
|
|
89
|
+
|
|
90
|
+
const onHideHandler = () => {
|
|
91
|
+
log("onHideHandler")
|
|
92
|
+
if (!isLiveSubmit) {
|
|
93
|
+
onHide()
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (alertBannerRef.current.onRequestHide()) {
|
|
98
|
+
onHide()
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const onClickSubmit = async() => {
|
|
103
|
+
log("onClickSubmit")
|
|
104
|
+
await handleSubmit(onSubmit)()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<Modal
|
|
109
|
+
className="modal-form"
|
|
110
|
+
show={show}
|
|
111
|
+
onHide={onHideHandler}
|
|
112
|
+
ref={ref}
|
|
113
|
+
backdrop={isBusy ? "static" : true}
|
|
114
|
+
{...props}
|
|
115
|
+
>
|
|
116
|
+
<FormProvider {...form}>
|
|
117
|
+
<AlertBanner ref={alertBannerRef} modalRef={ref} />
|
|
118
|
+
<Modal.Header closeButton>
|
|
119
|
+
<div>
|
|
120
|
+
{icon && (
|
|
121
|
+
<img
|
|
122
|
+
width={20}
|
|
123
|
+
height={20}
|
|
124
|
+
style={{marginTop: 0}}
|
|
125
|
+
className="me-2"
|
|
126
|
+
src={`/static/icons/${icon}.svg`}
|
|
127
|
+
/>
|
|
128
|
+
)}
|
|
129
|
+
{title}
|
|
130
|
+
</div>
|
|
131
|
+
</Modal.Header>
|
|
132
|
+
<Modal.Body className="pb-3" style={{position: "relative"}}>
|
|
133
|
+
<div className={cx("modal-form-body", {"is-busy": isBusy})}>
|
|
134
|
+
<fieldset disabled={isBusy}>{children}</fieldset>
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
{isBusy && (
|
|
138
|
+
<div className="loading-overlay">
|
|
139
|
+
{isLiveSubmit && (
|
|
140
|
+
<>
|
|
141
|
+
<ActivityIndicator />
|
|
142
|
+
<div className="mt-2 fw-normal">
|
|
143
|
+
{formState.isLoading && <>Loading...</>}
|
|
144
|
+
{formState.isSubmitting && <>{submittingTitle || "Submitting..."}</>}
|
|
145
|
+
</div>
|
|
146
|
+
</>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
)}
|
|
150
|
+
</Modal.Body>
|
|
151
|
+
|
|
152
|
+
{footerLink && isLiveSubmit && (
|
|
153
|
+
<Modal.Footer className="justify-content-start bg-light">
|
|
154
|
+
For more information, check out
|
|
155
|
+
<ExtLink to={footerLink} />
|
|
156
|
+
</Modal.Footer>
|
|
157
|
+
)}
|
|
158
|
+
|
|
159
|
+
{footerLink && !isLiveSubmit && (
|
|
160
|
+
<Modal.Footer className="p-0 justify-content-start align-items-start flex-column">
|
|
161
|
+
<div className="d-flex flex-row-reverse justify-content-start w-100 py-2">
|
|
162
|
+
<SubmitButton
|
|
163
|
+
className="me-3"
|
|
164
|
+
disabled={formState.isSubmitting || formState.isLoading}
|
|
165
|
+
isLoading={formState.isSubmitting}
|
|
166
|
+
onClick={onClickSubmit}
|
|
167
|
+
>
|
|
168
|
+
{formState.isSubmitting ? submittingTitle || submitTitle : submitTitle}
|
|
169
|
+
</SubmitButton>
|
|
170
|
+
|
|
171
|
+
<button
|
|
172
|
+
className="btn btn-link btn-cancel"
|
|
173
|
+
disabled={formState.isSubmitting}
|
|
174
|
+
onClick={onHideHandler}
|
|
175
|
+
>
|
|
176
|
+
Cancel
|
|
177
|
+
</button>
|
|
178
|
+
</div>
|
|
179
|
+
<div className="w-100 bg-light border-top mx-0 my-0 p-3">
|
|
180
|
+
For more information, check out
|
|
181
|
+
<ExtLink to={footerLink} />
|
|
182
|
+
</div>
|
|
183
|
+
</Modal.Footer>
|
|
184
|
+
)}
|
|
185
|
+
</FormProvider>
|
|
186
|
+
</Modal>
|
|
187
|
+
)
|
|
188
|
+
},
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
ModalForm.displayName = "ModalForm"
|
|
192
|
+
|
|
193
|
+
export default ModalForm
|
package/Modal/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/* @flow */
|
|
2
|
+
import Modal from "./Modal"
|
|
3
|
+
import ModalForm from "./ModalForm"
|
|
4
|
+
import withHashStateModal from "./withHashStateModal"
|
|
5
|
+
import HashStateModal from "./HashStateModal"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
export {ModalForm, HashStateModal, withHashStateModal}
|
|
9
|
+
|
|
10
|
+
export default Modal
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/* @flow */
|
|
2
|
+
import {useHashState} from "@rpcbase/client/hashState"
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
const withHashStateModal = (Component, hashPropName) => () => {
|
|
6
|
+
const {hashState, serializeHashState} = useHashState()
|
|
7
|
+
|
|
8
|
+
const show = !!hashState[hashPropName]
|
|
9
|
+
|
|
10
|
+
if (!show) return null
|
|
11
|
+
|
|
12
|
+
const onHide = () => {
|
|
13
|
+
serializeHashState({
|
|
14
|
+
[hashPropName]: null,
|
|
15
|
+
})
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const hashProps = {}
|
|
19
|
+
hashProps[hashPropName] = hashState[hashPropName]
|
|
20
|
+
|
|
21
|
+
return <Component onHide={onHide} {...hashProps} />
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default withHashStateModal
|