-
Notifications
You must be signed in to change notification settings - Fork 23
Expand file tree
/
Copy pathSideModalForm.tsx
More file actions
165 lines (149 loc) · 5 KB
/
SideModalForm.tsx
File metadata and controls
165 lines (149 loc) · 5 KB
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
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/
import { useEffect, useId, useState, type ReactNode } from 'react'
import type { FieldValues, UseFormReturn } from 'react-hook-form'
import { NavigationType, useNavigationType } from 'react-router'
import type { ApiError } from '@oxide/api'
import { Button } from '~/ui/lib/Button'
import { Modal } from '~/ui/lib/Modal'
import { SideModal } from '~/ui/lib/SideModal'
type CreateFormProps = {
formType: 'create'
/** Only needed if you need to override the default button text (`Create ${resourceName}`) */
submitLabel?: string
}
type EditFormProps = {
formType: 'edit'
/** Not permitted, as all edit form buttons should read `Update ${resourceName}` */
submitLabel?: never
}
type SideModalFormProps<TFieldValues extends FieldValues> = {
form: UseFormReturn<TFieldValues>
children: ReactNode
onDismiss: () => void
resourceName: string
/** Must be provided with a reason describing why it's disabled */
submitDisabled?: string
// require loading and error so we can't forget to hook them up. there are a
// few forms that don't need them, so we'll use dummy values
/** Error from the API call */
submitError: ApiError | null
loading: boolean
/** Only needed if you need to override the default title (Create/Edit ${resourceName}) */
title?: string
subtitle?: ReactNode
onSubmit?: (values: TFieldValues) => void
} & (CreateFormProps | EditFormProps)
/**
* Only animate the modal in when we're navigating by a client-side click.
* Don't animate on a fresh pageload or on back/forward. The latter may be
* slightly awkward but it also makes some sense. I do not believe there is
* any way to distinguish between fresh pageload and back/forward.
*/
function useShouldAnimateModal() {
return useNavigationType() === NavigationType.Push
}
export function SideModalForm<TFieldValues extends FieldValues>({
form,
formType,
children,
onDismiss,
resourceName,
submitDisabled,
submitError,
title,
onSubmit,
submitLabel,
loading,
subtitle,
}: SideModalFormProps<TFieldValues>) {
const id = useId()
useEffect(() => {
if (submitError?.errorCode === 'ObjectAlreadyExists' && 'name' in form.getValues()) {
// @ts-expect-error
form.setError('name', { message: 'Name already exists' }, { shouldFocus: true })
}
}, [submitError, form])
const label =
formType === 'edit'
? `Update ${resourceName}`
: submitLabel || title || `Create ${resourceName}`
// must be destructured up here to subscribe to changes. inlining
// form.formState.isDirty does not work
const { isDirty, isSubmitting } = form.formState
const [showNavGuard, setShowNavGuard] = useState(false)
return (
<SideModal
onDismiss={() => (isDirty ? setShowNavGuard(true) : onDismiss())}
isOpen
title={title || `${formType === 'edit' ? 'Edit' : 'Create'} ${resourceName}`}
animate={useShouldAnimateModal()}
subtitle={subtitle}
errors={submitError ? [submitError.message] : []}
>
<SideModal.Body>
<form
id={id}
className="ox-form is-side-modal"
autoComplete="off"
onSubmit={(e) => {
if (!onSubmit) return
// This modal being in a portal doesn't prevent the submit event
// from bubbling up out of the portal. Normally that's not a
// problem, but sometimes (e.g., instance create) we render the
// SideModalForm from inside another form, in which case submitting
// the inner form submits the outer form unless we stop propagation
e.stopPropagation()
form.handleSubmit(onSubmit)(e)
}}
>
{children}
</form>
</SideModal.Body>
{onSubmit && (
<SideModal.Footer error={!!submitError}>
<Button variant="ghost" size="sm" onClick={onDismiss}>
Cancel
</Button>
<Button
type="submit"
size="sm"
disabled={!!submitDisabled}
disabledReason={submitDisabled}
loading={loading || isSubmitting}
form={id}
>
{label}
</Button>
</SideModal.Footer>
)}
{showNavGuard && (
<Modal
isOpen
onDismiss={() => setShowNavGuard(false)}
title="Confirm navigation"
width="narrow"
overlay={false}
>
<Modal.Section>
Are you sure you want to leave this form?
<br />
All progress will be lost.
</Modal.Section>
<Modal.Footer
onAction={onDismiss}
onDismiss={() => setShowNavGuard(false)}
cancelText="Keep editing"
actionText="Leave form"
actionType="danger"
/>
</Modal>
)}
</SideModal>
)
}