-
Notifications
You must be signed in to change notification settings - Fork 0
/
EasyFormDialog.tsx
337 lines (294 loc) · 10 KB
/
EasyFormDialog.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
import React, { useContext, useState, PropsWithChildren, useRef } from 'react'
import {
getSubmitEnabled,
ItiReactCoreContext,
} from '@interface-technologies/iti-react-core'
import moment from 'moment-timezone'
import { ActionDialog } from './Dialog'
import { useCtrlEnterListener } from '../../hooks'
/** @deprecated */
export type EasyFormDialogFormData = { [name: string]: string | boolean }
/* eslint-disable */
/** @deprecated */
function formToObject(form: any): EasyFormDialogFormData {
const array = form.serializeArray()
const obj: EasyFormDialogFormData = {}
for (const pair of array) {
obj[pair.name] = pair.value
}
// serializeArray() ignores checkbox if it's unchecked and puts its value as "on"
// if it is checked. This doesn't play well with web API so here we turn the
// checkboxes into booleans.
const checkboxes = form.find('[type="checkbox"]').toArray()
for (const checkboxEl of checkboxes) {
const checkbox = $(checkboxEl)
const name = checkbox.attr('name')
if (name) {
obj[name] = checkbox.is(':checked')
}
}
return obj
}
/* eslint-enable */
export type EasyFormDialogOnSubmitReturn<TResponseData> =
| {
shouldClose?: boolean
responseData: TResponseData
}
| undefined
/** The props type of [[`EasyFormDialog`]]. */
export interface EasyFormDialogProps {
/** The title of the dialog. Can be a JSX element. */
title: React.ReactNode
/** The text of the submit button. */
submitButtonText: string
/** The CSS class of the submit button. */
submitButtonClass?: string
/** The text of the cancel button. Defaults to "Cancel". */
cancelButtonText?: string
/**
* Allows you to disable the submit button even if `getSubmitEnabled()`
* would return true.
*
* This can be useful if you want to disable the submit button while a query
* is in progress.
*/
submitEnabled?: boolean
/** A boolean indicating if the form is valid. */
formIsValid: boolean
/** A boolean indicating if validation feedback is being shown. */
showValidation: boolean
/** A callback that fires when the dialog is submitted. */
onShowValidationChange(showValidation: boolean): void
/**
* A callback that fires after the `submit` function succeeds.
*
* If the `submit` function returned `responseData`, it is passed to your
* `onSuccess` function.
*
* Your `onSuccess` callback must return a promise. The submit button will
* continue showing a loading indicator until the promise resolves. This is
* to support refetching the data that was updated by the form submission.
*/
onSuccess(payload: unknown | undefined): Promise<void>
/**
* A callback that fires when the dialog has completely closed. Your
* `onClose` callback should call, for example, `setDialogVisible(false)` so
* that the `EasyFormDialog` is no longer rendered.
*/
onClose(): void
/**
* A callback that fires when the form is submitted. You will typically
* perform an API call in your `submit` function.
*
* Your `submit` function can optionally return an object in the shape
*
* ```
* {
* shouldClose?: boolean
* responseData: unknown
* }
* ```
*
* Using `formData` is deprecated. Use controlled components instead.
*
* `formData` will be `{}` if the optional peer dependency `jquery` is not
* installed.
*/
onSubmit(
formData: EasyFormDialogFormData
): Promise<EasyFormDialogOnSubmitReturn<unknown>> | Promise<void>
/**
* An uncommonly-used callback that fires when the user clicks the cancel button.
*/
onCancel?(): void
/**
* This prop accepts a ref object that holds a function of type `() =>
* void`. You can execute the function to programmatically close the dialog:
*
* ```
* closeRef.current()
* ```
*/
closeRef?: React.MutableRefObject<() => void>
/** The CSS class added to the underlying Bootstrap modal. */
modalClass?: string
/**
* Set to `false` to disable the default behavior of focusing the first
* input.
*/
focusFirst?: boolean
/**
* Set to `false` to hide the modal footer, which contains the submit and
* cancel buttons.
*/
showFooter?: boolean
}
/**
* A wrapper around [[`ActionDialog`]] that removes a lot of the boilerplate needed
* for dialogs that contain a form.
*
* ```tsx
* interface ExampleProps {
* onSuccess(responseData: number): Promise<void>
* onClose(): void
* }
*
* export function Example({
* onSuccess,
* onClose,
* }: ExampleProps): ReactElement {
* const { onChildValidChange, allFieldsValid } = useFieldValidity()
* const [showValidation, setShowValidation] = useState(false)
* const vProps = { showValidation, onValidChange: onChildValidChange }
*
* const [myNumber, setMyNumber] = useState('')
*
* async function submit() {
* await api.product.performOperation()
*
* return {
* responseData: parseInt(myNumber),
* }
* }
*
* return (
* <EasyFormDialog
* title="Enter a Number"
* submitButtonText="Submit"
* formIsValid={allFieldsValid}
* showValidation={showValidation}
* onShowValidationChange={setShowValidation}
* onSubmit={submit}
* onSuccess={onSuccess}
* onClose={onClose}
* >
* <FormGroup label="My number">
* {(id) => (
* <ValidatedInput
* id={id}
* name="myNumber"
* validators={[Validators.required(), Validators.integer()]}
* value={myNumber}
* onChange={setMyNumber}
* {...vProps}
* />
* )}
* </FormGroup>
* </EasyFormDialog>
* )
* }
* ```
*/
export function EasyFormDialog({
title,
submitButtonText,
submitEnabled: propsSubmitEnabled = true,
submitButtonClass,
cancelButtonText,
formIsValid,
showValidation,
onShowValidationChange,
onSuccess,
modalClass,
focusFirst,
onClose,
onSubmit,
onCancel,
showFooter,
children,
closeRef: propsCloseRef,
}: PropsWithChildren<EasyFormDialogProps>): React.ReactElement {
const internalCloseRef = useRef(() => {})
const closeRef = propsCloseRef ?? internalCloseRef
const submitEnabled =
propsSubmitEnabled && getSubmitEnabled(formIsValid, showValidation)
const { onError } = useContext(ItiReactCoreContext)
const [submitting, setSubmitting] = useState(false)
const submittedTimeRef = useRef<moment.Moment>()
const formRef = useRef<HTMLFormElement | null>(null)
async function submit(): Promise<void> {
if (!submitEnabled) return
if (submitting) return
onShowValidationChange(true)
if (!formIsValid) return
// Prevent double submit when Ctrl+Enter is pressed in Firefox
if (
submittedTimeRef.current &&
moment().diff(submittedTimeRef.current, 'ms') < 200
) {
return
}
submittedTimeRef.current = moment()
setSubmitting(true)
let formData: EasyFormDialogFormData = {}
// jQuery is an optional peer dependency.
// We have to be careful not to depend on @types/jquery either.
/* eslint-disable */
const jq = (window as any).jQuery
if (jq) {
if (!formRef.current) throw new Error('formRef.current is null.')
formData = formToObject(jq(formRef.current))
}
/* eslint-enable */
try {
// hack to allow onSubmit to return void
const onSubmitReturnValue = (await onSubmit(
formData
)) as EasyFormDialogOnSubmitReturn<unknown>
const shouldClose = onSubmitReturnValue?.shouldClose ?? true
const responseData = onSubmitReturnValue?.responseData
if (shouldClose) {
// onSuccess may be loading data, so wait for it to finish before hiding the modal
// and setting submitting=false
await onSuccess(responseData)
closeRef.current()
}
} catch (e) {
onError(e)
return
}
setSubmitting(false)
}
useCtrlEnterListener(submit, submitEnabled)
return (
<ActionDialog
closeRef={closeRef}
title={title}
actionButtonText={submitButtonText}
actionButtonEnabled={submitEnabled}
actionButtonClass={submitButtonClass}
cancelButtonText={cancelButtonText}
action={() => void submit()}
actionInProgress={submitting}
modalClass={modalClass}
onClose={onClose}
focusFirst={focusFirst}
showFooter={showFooter}
onCancel={onCancel}
>
<form
ref={formRef}
onSubmit={(e) => {
e.preventDefault()
void submit()
}}
noValidate
>
{children}
{/* So that pressing enter while in the form submits it.
Set height to 0 instead of using `display: none` so that
Safari will recognize the submit button. `position-absolute` is
required to prevent the button from taking up space, I don't
know why. Set tabIndex to -1 so the user cannot tab to the invisible
button. */}
<input
type="submit"
className="p-0 border-0 position-absolute"
style={{ height: 0 }}
tabIndex={-1}
/>
</form>
</ActionDialog>
)
}