@jobber/components-native 0.39.0 → 0.40.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/dist/src/Form/Form.js +187 -0
- package/dist/src/Form/Form.style.js +33 -0
- package/dist/src/Form/components/FormActionBar/FormActionBar.js +21 -0
- package/dist/src/Form/components/FormActionBar/FormActionBar.style.js +5 -0
- package/dist/src/Form/components/FormActionBar/index.js +1 -0
- package/dist/src/Form/components/FormBody/FormBody.js +20 -0
- package/dist/src/Form/components/FormBody/FormBody.style.js +26 -0
- package/dist/src/Form/components/FormBody/index.js +1 -0
- package/dist/src/Form/components/FormCache/FormCache.js +34 -0
- package/dist/src/Form/components/FormErrorBanner/FormErrorBanner.js +21 -0
- package/dist/src/Form/components/FormErrorBanner/index.js +1 -0
- package/dist/src/Form/components/FormErrorBanner/messages.js +13 -0
- package/dist/src/Form/components/FormMask/FormMask.js +11 -0
- package/dist/src/Form/components/FormMask/FormMask.style.js +15 -0
- package/dist/src/Form/components/FormMask/index.js +1 -0
- package/dist/src/Form/components/FormMessage/FormMessage.js +48 -0
- package/dist/src/Form/components/FormMessage/components/InternalFormMessage/InternalFormMessage.js +28 -0
- package/dist/src/Form/components/FormMessage/components/InternalFormMessage/InternalFormMessage.style.js +17 -0
- package/dist/src/Form/components/FormMessage/components/InternalFormMessage/index.js +1 -0
- package/dist/src/Form/components/FormMessage/components/InternalFormMessage/messages.js +8 -0
- package/dist/src/Form/components/FormMessage/index.js +1 -0
- package/dist/src/Form/components/FormMessageBanner/FormMessageBanner.js +15 -0
- package/dist/src/Form/components/FormMessageBanner/index.js +1 -0
- package/dist/src/Form/components/FormSaveButton/FormSaveButton.js +69 -0
- package/dist/src/Form/components/FormSaveButton/index.js +1 -0
- package/dist/src/Form/components/FormSaveButton/messages.js +8 -0
- package/dist/src/Form/constants.js +2 -0
- package/dist/src/Form/context/AtlantisFormContext.js +16 -0
- package/dist/src/Form/context/index.js +1 -0
- package/dist/src/Form/context/types.js +1 -0
- package/dist/src/Form/hooks/useFormViewRefs.js +14 -0
- package/dist/src/Form/hooks/useInternalForm.js +37 -0
- package/dist/src/Form/hooks/useOfflineHandler.js +24 -0
- package/dist/src/Form/hooks/useSaveButtonPosition.js +25 -0
- package/dist/src/Form/hooks/useScreenInformation.js +15 -0
- package/dist/src/Form/hooks/useScrollToError/index.js +1 -0
- package/dist/src/Form/hooks/useScrollToError/useScrollToError.js +63 -0
- package/dist/src/Form/index.js +4 -0
- package/dist/src/Form/messages.js +28 -0
- package/dist/src/Form/types.js +10 -0
- package/dist/src/index.js +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types/src/Form/Form.d.ts +4 -0
- package/dist/types/src/Form/Form.style.d.ts +31 -0
- package/dist/types/src/Form/components/FormActionBar/FormActionBar.d.ts +13 -0
- package/dist/types/src/Form/components/FormActionBar/FormActionBar.style.d.ts +15 -0
- package/dist/types/src/Form/components/FormActionBar/index.d.ts +2 -0
- package/dist/types/src/Form/components/FormBody/FormBody.d.ts +10 -0
- package/dist/types/src/Form/components/FormBody/FormBody.style.d.ts +24 -0
- package/dist/types/src/Form/components/FormBody/index.d.ts +1 -0
- package/dist/types/src/Form/components/FormCache/FormCache.d.ts +10 -0
- package/dist/types/src/Form/components/FormErrorBanner/FormErrorBanner.d.ts +3 -0
- package/dist/types/src/Form/components/FormErrorBanner/index.d.ts +1 -0
- package/dist/types/src/Form/components/FormErrorBanner/messages.d.ts +12 -0
- package/dist/types/src/Form/components/FormMask/FormMask.d.ts +2 -0
- package/dist/types/src/Form/components/FormMask/FormMask.style.d.ts +13 -0
- package/dist/types/src/Form/components/FormMask/index.d.ts +1 -0
- package/dist/types/src/Form/components/FormMessage/FormMessage.d.ts +19 -0
- package/dist/types/src/Form/components/FormMessage/components/InternalFormMessage/InternalFormMessage.d.ts +8 -0
- package/dist/types/src/Form/components/FormMessage/components/InternalFormMessage/InternalFormMessage.style.d.ts +20 -0
- package/dist/types/src/Form/components/FormMessage/components/InternalFormMessage/index.d.ts +1 -0
- package/dist/types/src/Form/components/FormMessage/components/InternalFormMessage/messages.d.ts +7 -0
- package/dist/types/src/Form/components/FormMessage/index.d.ts +1 -0
- package/dist/types/src/Form/components/FormMessageBanner/FormMessageBanner.d.ts +7 -0
- package/dist/types/src/Form/components/FormMessageBanner/index.d.ts +1 -0
- package/dist/types/src/Form/components/FormSaveButton/FormSaveButton.d.ts +3 -0
- package/dist/types/src/Form/components/FormSaveButton/index.d.ts +1 -0
- package/dist/types/src/Form/components/FormSaveButton/messages.d.ts +7 -0
- package/dist/types/src/Form/constants.d.ts +2 -0
- package/dist/types/src/Form/context/AtlantisFormContext.d.ts +12 -0
- package/dist/types/src/Form/context/index.d.ts +2 -0
- package/dist/types/src/Form/context/types.d.ts +26 -0
- package/dist/types/src/Form/hooks/useFormViewRefs.d.ts +10 -0
- package/dist/types/src/Form/hooks/useInternalForm.d.ts +19 -0
- package/dist/types/src/Form/hooks/useOfflineHandler.d.ts +1 -0
- package/dist/types/src/Form/hooks/useSaveButtonPosition.d.ts +12 -0
- package/dist/types/src/Form/hooks/useScreenInformation.d.ts +8 -0
- package/dist/types/src/Form/hooks/useScrollToError/index.d.ts +1 -0
- package/dist/types/src/Form/hooks/useScrollToError/useScrollToError.d.ts +10 -0
- package/dist/types/src/Form/index.d.ts +5 -0
- package/dist/types/src/Form/messages.d.ts +27 -0
- package/dist/types/src/Form/types.d.ts +199 -0
- package/dist/types/src/InputNumber/InputNumber.d.ts +1 -1
- package/dist/types/src/index.d.ts +1 -0
- package/package.json +3 -2
- package/src/Form/Form.style.ts +34 -0
- package/src/Form/Form.test.tsx +588 -0
- package/src/Form/Form.tsx +296 -0
- package/src/Form/components/FormActionBar/FormActionBar.style.ts +11 -0
- package/src/Form/components/FormActionBar/FormActionBar.tsx +63 -0
- package/src/Form/components/FormActionBar/index.ts +2 -0
- package/src/Form/components/FormBody/FormBody.style.ts +27 -0
- package/src/Form/components/FormBody/FormBody.tsx +62 -0
- package/src/Form/components/FormBody/index.ts +1 -0
- package/src/Form/components/FormCache/FormCache.tsx +50 -0
- package/src/Form/components/FormErrorBanner/FormErrorBanner.test.tsx +124 -0
- package/src/Form/components/FormErrorBanner/FormErrorBanner.tsx +34 -0
- package/src/Form/components/FormErrorBanner/index.ts +1 -0
- package/src/Form/components/FormErrorBanner/messages.ts +14 -0
- package/src/Form/components/FormMask/FormMask.style.tsx +16 -0
- package/src/Form/components/FormMask/FormMask.tsx +19 -0
- package/src/Form/components/FormMask/index.ts +1 -0
- package/src/Form/components/FormMessage/FormMessage.test.tsx +72 -0
- package/src/Form/components/FormMessage/FormMessage.tsx +63 -0
- package/src/Form/components/FormMessage/components/InternalFormMessage/InternalFormMessage.style.ts +18 -0
- package/src/Form/components/FormMessage/components/InternalFormMessage/InternalFormMessage.tsx +55 -0
- package/src/Form/components/FormMessage/components/InternalFormMessage/index.ts +1 -0
- package/src/Form/components/FormMessage/components/InternalFormMessage/messages.ts +10 -0
- package/src/Form/components/FormMessage/index.ts +1 -0
- package/src/Form/components/FormMessageBanner/FormMessageBanner.test.tsx +27 -0
- package/src/Form/components/FormMessageBanner/FormMessageBanner.tsx +33 -0
- package/src/Form/components/FormMessageBanner/index.ts +1 -0
- package/src/Form/components/FormSaveButton/FormSaveButton.test.tsx +159 -0
- package/src/Form/components/FormSaveButton/FormSaveButton.tsx +103 -0
- package/src/Form/components/FormSaveButton/index.ts +1 -0
- package/src/Form/components/FormSaveButton/messages.ts +9 -0
- package/src/Form/constants.ts +2 -0
- package/src/Form/context/AtlantisFormContext.test.tsx +45 -0
- package/src/Form/context/AtlantisFormContext.tsx +21 -0
- package/src/Form/context/index.ts +5 -0
- package/src/Form/context/types.ts +34 -0
- package/src/Form/hooks/useFormViewRefs.ts +23 -0
- package/src/Form/hooks/useInternalForm.ts +99 -0
- package/src/Form/hooks/useOfflineHandler.ts +36 -0
- package/src/Form/hooks/useSaveButtonPosition.ts +52 -0
- package/src/Form/hooks/useScreenInformation.ts +25 -0
- package/src/Form/hooks/useScrollToError/index.ts +1 -0
- package/src/Form/hooks/useScrollToError/useScrollToError.test.tsx +103 -0
- package/src/Form/hooks/useScrollToError/useScrollToError.ts +102 -0
- package/src/Form/index.ts +13 -0
- package/src/Form/messages.ts +33 -0
- package/src/Form/types.ts +255 -0
- package/src/InputNumber/InputNumber.tsx +1 -1
- package/src/index.ts +1 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { FieldValues, FormProvider } from "react-hook-form";
|
|
3
|
+
import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view";
|
|
4
|
+
import {
|
|
5
|
+
Keyboard,
|
|
6
|
+
LayoutChangeEvent,
|
|
7
|
+
Platform,
|
|
8
|
+
View,
|
|
9
|
+
findNodeHandle,
|
|
10
|
+
} from "react-native";
|
|
11
|
+
import { styles } from "./Form.style";
|
|
12
|
+
import { FormErrorBanner } from "./components/FormErrorBanner";
|
|
13
|
+
import { KEYBOARD_SAVE_BUTTON_DISTANCE } from "./constants";
|
|
14
|
+
import { FormMessageBanner } from "./components/FormMessageBanner";
|
|
15
|
+
import {
|
|
16
|
+
FormErrors,
|
|
17
|
+
FormProps,
|
|
18
|
+
FormSubmitErrorType,
|
|
19
|
+
FormValues,
|
|
20
|
+
InternalFormProps,
|
|
21
|
+
} from "./types";
|
|
22
|
+
import { FormMask } from "./components/FormMask";
|
|
23
|
+
import { useInternalForm } from "./hooks/useInternalForm";
|
|
24
|
+
import { useFormViewRefs } from "./hooks/useFormViewRefs";
|
|
25
|
+
import { useScreenInformation } from "./hooks/useScreenInformation";
|
|
26
|
+
import { FormMessage } from "./components/FormMessage";
|
|
27
|
+
import { FormBody, useBottomPadding } from "./components/FormBody";
|
|
28
|
+
import { useOfflineHandler } from "./hooks/useOfflineHandler";
|
|
29
|
+
import { useScrollToError } from "./hooks/useScrollToError";
|
|
30
|
+
import { FormSaveButton } from "./components/FormSaveButton";
|
|
31
|
+
import { useSaveButtonPosition } from "./hooks/useSaveButtonPosition";
|
|
32
|
+
import { FormCache } from "./components/FormCache/FormCache";
|
|
33
|
+
import { InputAccessoriesProvider } from "../InputText";
|
|
34
|
+
import { tokens } from "../utils/design";
|
|
35
|
+
import { ErrorMessageProvider } from "../ErrorMessageWrapper";
|
|
36
|
+
|
|
37
|
+
export function Form<T extends FieldValues, S>({
|
|
38
|
+
initialLoading,
|
|
39
|
+
...rest
|
|
40
|
+
}: FormProps<T, S>): JSX.Element {
|
|
41
|
+
const child = initialLoading ? <FormMask /> : <InternalForm {...rest} />;
|
|
42
|
+
return (
|
|
43
|
+
<InputAccessoriesProvider>
|
|
44
|
+
<ErrorMessageProvider>{child}</ErrorMessageProvider>
|
|
45
|
+
</InputAccessoriesProvider>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// eslint-disable-next-line max-statements
|
|
50
|
+
function InternalForm<T extends FieldValues, S>({
|
|
51
|
+
children,
|
|
52
|
+
onBeforeSubmit,
|
|
53
|
+
onSubmit,
|
|
54
|
+
onSubmitError,
|
|
55
|
+
onSubmitSuccess,
|
|
56
|
+
bannerErrors,
|
|
57
|
+
bannerMessages,
|
|
58
|
+
initialValues,
|
|
59
|
+
mode = "onTouched",
|
|
60
|
+
reValidateMode = "onChange",
|
|
61
|
+
formRef,
|
|
62
|
+
saveButtonLabel,
|
|
63
|
+
renderStickySection,
|
|
64
|
+
localCacheKey,
|
|
65
|
+
localCacheExclude,
|
|
66
|
+
localCacheId,
|
|
67
|
+
secondaryActions,
|
|
68
|
+
saveButtonOffset,
|
|
69
|
+
showStickySaveButton = false,
|
|
70
|
+
renderFooter,
|
|
71
|
+
}: InternalFormProps<T, S>) {
|
|
72
|
+
const { scrollViewRef, bottomViewRef, scrollToTop } = useFormViewRefs();
|
|
73
|
+
const [saveButtonHeight, setSaveButtonHeight] = useState(0);
|
|
74
|
+
const [messageBannerHeight, setMessageBannerHeight] = useState(0);
|
|
75
|
+
const {
|
|
76
|
+
formMethods,
|
|
77
|
+
handleSubmit,
|
|
78
|
+
isSubmitting,
|
|
79
|
+
removeListenerRef,
|
|
80
|
+
setLocalCache,
|
|
81
|
+
} = useInternalForm<T, S>({
|
|
82
|
+
mode,
|
|
83
|
+
reValidateMode,
|
|
84
|
+
initialValues,
|
|
85
|
+
formRef,
|
|
86
|
+
localCacheKey,
|
|
87
|
+
localCacheExclude,
|
|
88
|
+
localCacheId,
|
|
89
|
+
scrollViewRef,
|
|
90
|
+
saveButtonHeight,
|
|
91
|
+
messageBannerHeight,
|
|
92
|
+
});
|
|
93
|
+
const { windowHeight, headerHeight } = useScreenInformation();
|
|
94
|
+
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
|
95
|
+
const [keyboardScreenY, setKeyboardScreenY] = useState(0);
|
|
96
|
+
const [formContentHeight, setFormContentHeight] = useState(0);
|
|
97
|
+
const [isBottomSheetOpen, setIsBottomSheetOpen] = useState(false);
|
|
98
|
+
const paddingBottom = useBottomPadding();
|
|
99
|
+
|
|
100
|
+
const { saveButtonPosition } = useSaveButtonPosition({
|
|
101
|
+
formContentHeight,
|
|
102
|
+
isBottomSheetOpen,
|
|
103
|
+
showStickySaveButton,
|
|
104
|
+
keyboardHeight,
|
|
105
|
+
keyboardScreenY,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const [isSecondaryActionLoading, setIsSecondaryActionLoading] =
|
|
109
|
+
useState<boolean>(false);
|
|
110
|
+
|
|
111
|
+
const extraViewHeight = paddingBottom + KEYBOARD_SAVE_BUTTON_DISTANCE;
|
|
112
|
+
const calculatedKeyboardHeight = keyboardHeight - extraViewHeight;
|
|
113
|
+
|
|
114
|
+
useScrollToError({
|
|
115
|
+
formState: formMethods.formState,
|
|
116
|
+
refNode: findNodeHandle(scrollViewRef.current),
|
|
117
|
+
setFocus: formMethods.setFocus,
|
|
118
|
+
scrollToPosition: scrollViewRef.current?.scrollToPosition,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const handleOfflineSubmit = useOfflineHandler();
|
|
122
|
+
|
|
123
|
+
const keyboardProps = Platform.select({
|
|
124
|
+
ios: {
|
|
125
|
+
onKeyboardWillHide: handleKeyboardHide,
|
|
126
|
+
onKeyboardWillShow: handleKeyboardShow,
|
|
127
|
+
},
|
|
128
|
+
android: {
|
|
129
|
+
onKeyboardDidHide: handleKeyboardHide,
|
|
130
|
+
onKeyboardDidShow: handleKeyboardShow,
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const onLayout = (event: LayoutChangeEvent) => {
|
|
135
|
+
setMessageBannerHeight(event.nativeEvent.layout.height);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<FormProvider {...formMethods}>
|
|
140
|
+
<>
|
|
141
|
+
{(isSubmitting || isSecondaryActionLoading) && <FormMask />}
|
|
142
|
+
|
|
143
|
+
{/* FormCache isolates watching entire form value changes to only this component */}
|
|
144
|
+
<FormCache
|
|
145
|
+
localCacheKey={localCacheKey}
|
|
146
|
+
localCacheExclude={localCacheExclude}
|
|
147
|
+
setLocalCache={setLocalCache}
|
|
148
|
+
/>
|
|
149
|
+
|
|
150
|
+
<FormBody
|
|
151
|
+
keyboardHeight={calculateSaveButtonOffset()}
|
|
152
|
+
submit={handleSubmit(internalSubmit)}
|
|
153
|
+
isFormSubmitting={isSubmitting}
|
|
154
|
+
saveButtonLabel={saveButtonLabel}
|
|
155
|
+
shouldRenderActionBar={saveButtonPosition === "sticky"}
|
|
156
|
+
renderStickySection={renderStickySection}
|
|
157
|
+
secondaryActions={secondaryActions}
|
|
158
|
+
setSecondaryActionLoading={setIsSecondaryActionLoading}
|
|
159
|
+
setSaveButtonHeight={setSaveButtonHeight}
|
|
160
|
+
saveButtonOffset={saveButtonOffset}
|
|
161
|
+
>
|
|
162
|
+
<KeyboardAwareScrollView
|
|
163
|
+
enableResetScrollToCoords={false}
|
|
164
|
+
enableAutomaticScroll={true}
|
|
165
|
+
keyboardOpeningTime={
|
|
166
|
+
Platform.OS === "ios" ? tokens["timing-slowest"] : 0
|
|
167
|
+
}
|
|
168
|
+
keyboardShouldPersistTaps={"handled"}
|
|
169
|
+
ref={scrollViewRef}
|
|
170
|
+
{...keyboardProps}
|
|
171
|
+
extraHeight={headerHeight}
|
|
172
|
+
contentContainerStyle={
|
|
173
|
+
!keyboardHeight && styles.scrollContentContainer
|
|
174
|
+
}
|
|
175
|
+
>
|
|
176
|
+
<View
|
|
177
|
+
onLayout={({ nativeEvent }) => {
|
|
178
|
+
setFormContentHeight(nativeEvent.layout.height);
|
|
179
|
+
}}
|
|
180
|
+
>
|
|
181
|
+
<View onLayout={onLayout}>
|
|
182
|
+
<FormMessageBanner bannerMessages={bannerMessages} />
|
|
183
|
+
<FormErrorBanner
|
|
184
|
+
networkError={bannerErrors?.networkError}
|
|
185
|
+
bannerError={bannerErrors?.bannerError}
|
|
186
|
+
/>
|
|
187
|
+
</View>
|
|
188
|
+
<View style={styles.formChildContainer}>
|
|
189
|
+
<>
|
|
190
|
+
<View style={styles.formContent}>{children}</View>
|
|
191
|
+
{saveButtonPosition === "inline" && (
|
|
192
|
+
<View style={styles.fixedSaveButton}>
|
|
193
|
+
{renderStickySection ? (
|
|
194
|
+
renderStickySection(
|
|
195
|
+
handleSubmit(internalSubmit),
|
|
196
|
+
saveButtonLabel,
|
|
197
|
+
isSubmitting,
|
|
198
|
+
)
|
|
199
|
+
) : (
|
|
200
|
+
<FormSaveButton
|
|
201
|
+
primaryAction={handleSubmit(internalSubmit)}
|
|
202
|
+
label={saveButtonLabel}
|
|
203
|
+
loading={isSubmitting}
|
|
204
|
+
secondaryActions={secondaryActions}
|
|
205
|
+
setSecondaryActionLoading={
|
|
206
|
+
setIsSecondaryActionLoading
|
|
207
|
+
}
|
|
208
|
+
onOpenBottomSheet={() => setIsBottomSheetOpen(true)}
|
|
209
|
+
onCloseBottomSheet={() => setIsBottomSheetOpen(false)}
|
|
210
|
+
/>
|
|
211
|
+
)}
|
|
212
|
+
</View>
|
|
213
|
+
)}
|
|
214
|
+
{renderFooter}
|
|
215
|
+
</>
|
|
216
|
+
</View>
|
|
217
|
+
</View>
|
|
218
|
+
<View style={styles.safeArea} ref={bottomViewRef} />
|
|
219
|
+
</KeyboardAwareScrollView>
|
|
220
|
+
</FormBody>
|
|
221
|
+
</>
|
|
222
|
+
<FormMessage />
|
|
223
|
+
</FormProvider>
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
function handleKeyboardShow(frames: Record<string, any>) {
|
|
227
|
+
setKeyboardScreenY(frames.endCoordinates.screenY);
|
|
228
|
+
setKeyboardHeight(frames.endCoordinates.height);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function handleKeyboardHide() {
|
|
232
|
+
bottomViewRef?.current?.measureInWindow(
|
|
233
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
234
|
+
(_x: number, y: number, _width: number, _height: number) => {
|
|
235
|
+
// This fixes extra whitespace below the form if it was scrolled down while the keyboard was open
|
|
236
|
+
// i.e. a View below the form is higher than the bottom of the window
|
|
237
|
+
if (y < windowHeight) {
|
|
238
|
+
scrollViewRef?.current?.scrollToEnd();
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
);
|
|
242
|
+
setKeyboardHeight(0);
|
|
243
|
+
setKeyboardScreenY(0);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function internalSubmit(data: FormValues<T>) {
|
|
247
|
+
let performSubmit = true;
|
|
248
|
+
if (onBeforeSubmit) {
|
|
249
|
+
performSubmit = await onBeforeSubmit(data);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (performSubmit) {
|
|
253
|
+
Keyboard.dismiss();
|
|
254
|
+
|
|
255
|
+
return onSubmit(data)
|
|
256
|
+
.then((result: S) => {
|
|
257
|
+
removeListenerRef.current?.();
|
|
258
|
+
onSubmitSuccess(result);
|
|
259
|
+
})
|
|
260
|
+
.catch(handleSubmitCatch);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function handleSubmitCatch(error: FormErrors) {
|
|
265
|
+
// Scroll to top of form to see error
|
|
266
|
+
scrollToTop();
|
|
267
|
+
onSubmitError(error);
|
|
268
|
+
|
|
269
|
+
if (error?.errorType === FormSubmitErrorType.NetworkError) {
|
|
270
|
+
// @ts-expect-error We are making the form submission fail so that we can
|
|
271
|
+
// prevent the isSubmitSuccess to be true
|
|
272
|
+
formMethods.setError("offline", "Error saving form.");
|
|
273
|
+
|
|
274
|
+
handleOfflineSubmit(handleRetry, clearFormErrors)();
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function clearFormErrors() {
|
|
279
|
+
// @ts-expect-error We are clearing the error that we previously set
|
|
280
|
+
// when the form had no internet connection
|
|
281
|
+
formMethods.clearErrors("offline");
|
|
282
|
+
}
|
|
283
|
+
function handleRetry() {
|
|
284
|
+
clearFormErrors();
|
|
285
|
+
return handleSubmit(internalSubmit)();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function calculateSaveButtonOffset() {
|
|
289
|
+
if (saveButtonOffset) {
|
|
290
|
+
// Included the space-base because it's the padding of the FormActionBar
|
|
291
|
+
return calculatedKeyboardHeight - saveButtonOffset + tokens["space-base"];
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return calculatedKeyboardHeight;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { StyleSheet } from "react-native";
|
|
2
|
+
import { tokens } from "../../../utils/design";
|
|
3
|
+
|
|
4
|
+
export const styles = StyleSheet.create({
|
|
5
|
+
saveButton: {
|
|
6
|
+
padding: tokens["space-base"],
|
|
7
|
+
backgroundColor: tokens["color-surface"],
|
|
8
|
+
width: "100%",
|
|
9
|
+
...tokens["shadow-high"],
|
|
10
|
+
},
|
|
11
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { LayoutChangeEvent, StyleSheet, View } from "react-native";
|
|
3
|
+
import Reanimated from "react-native-reanimated";
|
|
4
|
+
import { styles } from "./FormActionBar.style";
|
|
5
|
+
import { SecondaryActionProp } from "../../types";
|
|
6
|
+
import { FormSaveButton } from "../FormSaveButton";
|
|
7
|
+
|
|
8
|
+
const ReanimatedView = Reanimated.createAnimatedComponent(View);
|
|
9
|
+
|
|
10
|
+
export interface FormActionBarProps {
|
|
11
|
+
readonly keyboardHeight: number;
|
|
12
|
+
readonly isFormSubmitting: boolean;
|
|
13
|
+
readonly saveButtonLabel?: string;
|
|
14
|
+
readonly submit: () => Promise<void> | void;
|
|
15
|
+
readonly setSaveButtonHeight?: (height: number) => void;
|
|
16
|
+
readonly renderStickySection?: (
|
|
17
|
+
onSubmit: () => void,
|
|
18
|
+
label: string | undefined,
|
|
19
|
+
isSubmitting: boolean,
|
|
20
|
+
) => JSX.Element;
|
|
21
|
+
readonly secondaryActions?: SecondaryActionProp[];
|
|
22
|
+
readonly setSecondaryActionLoading?: (bool: boolean) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function FormActionBar({
|
|
26
|
+
keyboardHeight,
|
|
27
|
+
submit,
|
|
28
|
+
isFormSubmitting,
|
|
29
|
+
saveButtonLabel,
|
|
30
|
+
renderStickySection,
|
|
31
|
+
setSaveButtonHeight,
|
|
32
|
+
secondaryActions,
|
|
33
|
+
setSecondaryActionLoading,
|
|
34
|
+
}: FormActionBarProps): JSX.Element {
|
|
35
|
+
const buttonStyle = StyleSheet.flatten([
|
|
36
|
+
styles.saveButton,
|
|
37
|
+
{
|
|
38
|
+
position: keyboardHeight > 0 ? "absolute" : "relative",
|
|
39
|
+
bottom: 0,
|
|
40
|
+
},
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
const onLayout = (event: LayoutChangeEvent) => {
|
|
44
|
+
setSaveButtonHeight && setSaveButtonHeight(event.nativeEvent.layout.height);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
//@ts-expect-error tsc-ci
|
|
49
|
+
<ReanimatedView style={buttonStyle} onLayout={onLayout}>
|
|
50
|
+
{renderStickySection ? (
|
|
51
|
+
renderStickySection(submit, saveButtonLabel, isFormSubmitting)
|
|
52
|
+
) : (
|
|
53
|
+
<FormSaveButton
|
|
54
|
+
setSecondaryActionLoading={setSecondaryActionLoading}
|
|
55
|
+
primaryAction={submit}
|
|
56
|
+
loading={isFormSubmitting}
|
|
57
|
+
label={saveButtonLabel}
|
|
58
|
+
secondaryActions={secondaryActions}
|
|
59
|
+
/>
|
|
60
|
+
)}
|
|
61
|
+
</ReanimatedView>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { StyleSheet } from "react-native";
|
|
2
|
+
import { tokens } from "../../../utils/design";
|
|
3
|
+
|
|
4
|
+
export const styles = StyleSheet.create({
|
|
5
|
+
container: {
|
|
6
|
+
flex: 1,
|
|
7
|
+
flexGrow: 1,
|
|
8
|
+
width: "100%",
|
|
9
|
+
},
|
|
10
|
+
safeArea: {
|
|
11
|
+
backgroundColor: tokens["color-surface"],
|
|
12
|
+
},
|
|
13
|
+
scrollContentContainer: {
|
|
14
|
+
flexGrow: 1,
|
|
15
|
+
},
|
|
16
|
+
scrollView: {
|
|
17
|
+
flexGrow: 1,
|
|
18
|
+
},
|
|
19
|
+
formChildContainer: {
|
|
20
|
+
flexGrow: 1,
|
|
21
|
+
justifyContent: "flex-start",
|
|
22
|
+
},
|
|
23
|
+
activityIndicator: {
|
|
24
|
+
marginVertical: tokens["space-base"],
|
|
25
|
+
flex: 1,
|
|
26
|
+
},
|
|
27
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import React, { useMemo } from "react";
|
|
2
|
+
import { View } from "react-native";
|
|
3
|
+
import { styles } from "./FormBody.style";
|
|
4
|
+
import { useScreenInformation } from "../../hooks/useScreenInformation";
|
|
5
|
+
import { FormActionBar, FormActionBarProps } from "../FormActionBar";
|
|
6
|
+
import { tokens } from "../../../utils/design";
|
|
7
|
+
|
|
8
|
+
interface FormBodyProps extends FormActionBarProps {
|
|
9
|
+
children: JSX.Element;
|
|
10
|
+
shouldRenderActionBar?: boolean;
|
|
11
|
+
saveButtonOffset?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function FormBody({
|
|
15
|
+
isFormSubmitting,
|
|
16
|
+
submit,
|
|
17
|
+
keyboardHeight,
|
|
18
|
+
children,
|
|
19
|
+
saveButtonLabel,
|
|
20
|
+
renderStickySection,
|
|
21
|
+
shouldRenderActionBar = true,
|
|
22
|
+
secondaryActions,
|
|
23
|
+
setSecondaryActionLoading,
|
|
24
|
+
setSaveButtonHeight,
|
|
25
|
+
saveButtonOffset,
|
|
26
|
+
}: FormBodyProps): JSX.Element {
|
|
27
|
+
const paddingBottom = useBottomPadding();
|
|
28
|
+
const fullViewPadding = useMemo(() => ({ paddingBottom }), [paddingBottom]);
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<>
|
|
32
|
+
<View style={[styles.container]}>
|
|
33
|
+
{children}
|
|
34
|
+
{shouldRenderActionBar && (
|
|
35
|
+
<FormActionBar
|
|
36
|
+
setSecondaryActionLoading={setSecondaryActionLoading}
|
|
37
|
+
keyboardHeight={keyboardHeight}
|
|
38
|
+
submit={submit}
|
|
39
|
+
isFormSubmitting={isFormSubmitting}
|
|
40
|
+
saveButtonLabel={saveButtonLabel}
|
|
41
|
+
renderStickySection={renderStickySection}
|
|
42
|
+
secondaryActions={secondaryActions}
|
|
43
|
+
setSaveButtonHeight={setSaveButtonHeight}
|
|
44
|
+
/>
|
|
45
|
+
)}
|
|
46
|
+
</View>
|
|
47
|
+
|
|
48
|
+
{shouldRenderActionBar && !saveButtonOffset && (
|
|
49
|
+
<View
|
|
50
|
+
style={[fullViewPadding, styles.safeArea]}
|
|
51
|
+
testID="ATL-FormSafeArea"
|
|
52
|
+
/>
|
|
53
|
+
)}
|
|
54
|
+
</>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function useBottomPadding(): number {
|
|
59
|
+
const { insets } = useScreenInformation();
|
|
60
|
+
const extraBottomSpace = insets.bottom - tokens["space-base"];
|
|
61
|
+
return extraBottomSpace >= 0 ? extraBottomSpace : 0;
|
|
62
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { FormBody, useBottomPadding } from "./FormBody";
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import React, { useEffect, useMemo } from "react";
|
|
2
|
+
import { FieldValues, useFormContext, useWatch } from "react-hook-form";
|
|
3
|
+
import omit from "lodash/omit";
|
|
4
|
+
|
|
5
|
+
interface FormCacheProps<T extends FieldValues> {
|
|
6
|
+
localCacheId?: string | string[];
|
|
7
|
+
localCacheKey?: string;
|
|
8
|
+
localCacheExclude?: string[];
|
|
9
|
+
setLocalCache: (data: T) => void;
|
|
10
|
+
}
|
|
11
|
+
export function FormCache<T extends FieldValues>({
|
|
12
|
+
localCacheExclude,
|
|
13
|
+
localCacheKey,
|
|
14
|
+
setLocalCache,
|
|
15
|
+
}: FormCacheProps<T>): JSX.Element {
|
|
16
|
+
const { control, formState } = useFormContext<T>();
|
|
17
|
+
const { isDirty } = formState;
|
|
18
|
+
|
|
19
|
+
const formData = useWatch<T>({ control });
|
|
20
|
+
const shouldExclude = useMemo(() => {
|
|
21
|
+
return Array.isArray(localCacheExclude) && localCacheExclude.length > 0;
|
|
22
|
+
}, [localCacheExclude]);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
!localCacheKey &&
|
|
26
|
+
console.log(
|
|
27
|
+
"No `localCacheKey` specified on Form. Local copy of form data is now disabled.",
|
|
28
|
+
);
|
|
29
|
+
}, [localCacheKey]);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Automatically save form data locally
|
|
33
|
+
*/
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (!isDirty) return;
|
|
36
|
+
|
|
37
|
+
if (shouldExclude) {
|
|
38
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
39
|
+
// @ts-ignore The type below is not working. It seems to be an issue with react-hook-form
|
|
40
|
+
// https://github.com/react-hook-form/react-hook-form/issues/2978
|
|
41
|
+
setLocalCache(omit(formData, localCacheExclude));
|
|
42
|
+
} else {
|
|
43
|
+
// @ts-expect-error Typescript thinks that the FieldValues defined in useWatch is different
|
|
44
|
+
// from the one in useFormContext
|
|
45
|
+
setLocalCache(formData);
|
|
46
|
+
}
|
|
47
|
+
}, [formData, isDirty, localCacheExclude, setLocalCache, shouldExclude]);
|
|
48
|
+
|
|
49
|
+
return <></>;
|
|
50
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { cleanup, render } from "@testing-library/react-native";
|
|
3
|
+
import { useIntl } from "react-intl";
|
|
4
|
+
import { FormErrorBanner } from "./FormErrorBanner";
|
|
5
|
+
import { messages as formErrorBannerMessages } from "./messages";
|
|
6
|
+
import { defaultValues as contextDefaultValue } from "../../../AtlantisContext";
|
|
7
|
+
import * as atlantisContext from "../../../AtlantisContext/AtlantisContext";
|
|
8
|
+
|
|
9
|
+
describe("FormErrorBanner", () => {
|
|
10
|
+
const atlantisContextSpy = jest.spyOn(atlantisContext, "useAtlantisContext");
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
atlantisContextSpy.mockReturnValue({
|
|
14
|
+
...contextDefaultValue,
|
|
15
|
+
isOnline: true,
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const { formatMessage } = useIntl();
|
|
20
|
+
const networkError = new Error();
|
|
21
|
+
const userError = {
|
|
22
|
+
title: "My error",
|
|
23
|
+
messages: ["userError1", "userError2"],
|
|
24
|
+
};
|
|
25
|
+
const validationErrors = [
|
|
26
|
+
"This is the first validation error",
|
|
27
|
+
"This is the second validation error",
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
it("should render Offline banner when offline", () => {
|
|
31
|
+
atlantisContextSpy.mockReturnValue({
|
|
32
|
+
...contextDefaultValue,
|
|
33
|
+
isOnline: false,
|
|
34
|
+
});
|
|
35
|
+
const { getByText, queryByText } = render(
|
|
36
|
+
<FormErrorBanner
|
|
37
|
+
// @ts-expect-error tsc-ci
|
|
38
|
+
networkError={networkError}
|
|
39
|
+
bannerError={userError}
|
|
40
|
+
validationErrors={validationErrors}
|
|
41
|
+
actionLabel="Action"
|
|
42
|
+
/>,
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
// Show: Offline Message
|
|
46
|
+
expect(
|
|
47
|
+
getByText(formatMessage(formErrorBannerMessages.offlineError)),
|
|
48
|
+
).toBeDefined();
|
|
49
|
+
|
|
50
|
+
// Hide: Network Error, User Error, Validation Error
|
|
51
|
+
expect(
|
|
52
|
+
queryByText(formatMessage(formErrorBannerMessages.networkError)),
|
|
53
|
+
).toBeNull();
|
|
54
|
+
|
|
55
|
+
expect(queryByText(userError.title)).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("should render network error banner when online and network errors exist", () => {
|
|
59
|
+
const { getByText, queryByText } = render(
|
|
60
|
+
<FormErrorBanner
|
|
61
|
+
// @ts-expect-error tsc-ci
|
|
62
|
+
networkError={networkError}
|
|
63
|
+
bannerError={userError}
|
|
64
|
+
actionLabel="action"
|
|
65
|
+
/>,
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
// Show: Network Error
|
|
69
|
+
expect(
|
|
70
|
+
getByText(formatMessage(formErrorBannerMessages.networkError)),
|
|
71
|
+
).toBeDefined();
|
|
72
|
+
|
|
73
|
+
// Hide: Offline Message, User Error, Validation Error
|
|
74
|
+
expect(
|
|
75
|
+
queryByText(formatMessage(formErrorBannerMessages.offlineError)),
|
|
76
|
+
).toBeNull();
|
|
77
|
+
|
|
78
|
+
expect(queryByText(userError.title)).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("should render user error banner when online and user errors exist", () => {
|
|
82
|
+
const { getByText, queryByText } = render(
|
|
83
|
+
<FormErrorBanner bannerError={userError} />,
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// Show: User Error
|
|
87
|
+
expect(getByText(userError.title)).toBeDefined();
|
|
88
|
+
expect(getByText(userError.messages[0])).toBeDefined();
|
|
89
|
+
expect(getByText(userError.messages[1])).toBeDefined();
|
|
90
|
+
|
|
91
|
+
// Hide: Offline Message, Network Error, Validation Error
|
|
92
|
+
expect(
|
|
93
|
+
queryByText(formatMessage(formErrorBannerMessages.offlineError)),
|
|
94
|
+
).toBeNull();
|
|
95
|
+
|
|
96
|
+
expect(
|
|
97
|
+
queryByText(formatMessage(formErrorBannerMessages.networkError)),
|
|
98
|
+
).toBeNull();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("should render user error banner with just title when online", () => {
|
|
102
|
+
const userErrorJustTitle = {
|
|
103
|
+
title: "My error",
|
|
104
|
+
};
|
|
105
|
+
const { getByText, queryByText } = render(
|
|
106
|
+
<FormErrorBanner bannerError={userErrorJustTitle} />,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// Show: User Error
|
|
110
|
+
expect(getByText(userErrorJustTitle.title)).toBeDefined();
|
|
111
|
+
|
|
112
|
+
// Hide: Offline Message, Network Error, Validation Error
|
|
113
|
+
expect(
|
|
114
|
+
queryByText(formatMessage(formErrorBannerMessages.offlineError)),
|
|
115
|
+
).toBeNull();
|
|
116
|
+
|
|
117
|
+
expect(
|
|
118
|
+
queryByText(formatMessage(formErrorBannerMessages.networkError)),
|
|
119
|
+
).toBeNull();
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
afterEach(jest.clearAllMocks);
|
|
124
|
+
afterEach(cleanup);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { useIntl } from "react-intl";
|
|
3
|
+
import { messages } from "./messages";
|
|
4
|
+
import { FormBannerErrors } from "../../types";
|
|
5
|
+
import { useAtlantisContext } from "../../../AtlantisContext";
|
|
6
|
+
import { Banner } from "../../../Banner";
|
|
7
|
+
|
|
8
|
+
export function FormErrorBanner({
|
|
9
|
+
networkError,
|
|
10
|
+
bannerError,
|
|
11
|
+
}: FormBannerErrors): JSX.Element {
|
|
12
|
+
const { formatMessage } = useIntl();
|
|
13
|
+
const { isOnline } = useAtlantisContext();
|
|
14
|
+
|
|
15
|
+
if (!isOnline) {
|
|
16
|
+
return (
|
|
17
|
+
<Banner text={formatMessage(messages.offlineError)} type={"error"} />
|
|
18
|
+
);
|
|
19
|
+
} else if (networkError) {
|
|
20
|
+
return (
|
|
21
|
+
<Banner text={formatMessage(messages.networkError)} type={"error"} />
|
|
22
|
+
);
|
|
23
|
+
} else if (bannerError) {
|
|
24
|
+
return (
|
|
25
|
+
<Banner
|
|
26
|
+
text={bannerError.title}
|
|
27
|
+
details={bannerError.messages}
|
|
28
|
+
type={"error"}
|
|
29
|
+
/>
|
|
30
|
+
);
|
|
31
|
+
} else {
|
|
32
|
+
return <></>;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { FormErrorBanner } from "./FormErrorBanner";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { defineMessages } from "react-intl";
|
|
2
|
+
|
|
3
|
+
export const messages = defineMessages({
|
|
4
|
+
networkError: {
|
|
5
|
+
id: "networkError",
|
|
6
|
+
defaultMessage: "Could not save changes",
|
|
7
|
+
description: "Displayed when a general server error occurs during save",
|
|
8
|
+
},
|
|
9
|
+
offlineError: {
|
|
10
|
+
id: "offlineError",
|
|
11
|
+
defaultMessage: "Currently offline. Check your internet connection.",
|
|
12
|
+
description: "Error message to be shown when the app is offline",
|
|
13
|
+
},
|
|
14
|
+
});
|