@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.
@@ -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&nbsp;
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&nbsp;
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rpcbase/ui",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "license": "SSPL-1.0",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org/"