@jobber/components-native 0.38.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.
Files changed (184) hide show
  1. package/dist/src/AtlantisContext/AtlantisContext.js +2 -0
  2. package/dist/src/Form/Form.js +187 -0
  3. package/dist/src/Form/Form.style.js +33 -0
  4. package/dist/src/Form/components/FormActionBar/FormActionBar.js +21 -0
  5. package/dist/src/Form/components/FormActionBar/FormActionBar.style.js +5 -0
  6. package/dist/src/Form/components/FormActionBar/index.js +1 -0
  7. package/dist/src/Form/components/FormBody/FormBody.js +20 -0
  8. package/dist/src/Form/components/FormBody/FormBody.style.js +26 -0
  9. package/dist/src/Form/components/FormBody/index.js +1 -0
  10. package/dist/src/Form/components/FormCache/FormCache.js +34 -0
  11. package/dist/src/Form/components/FormErrorBanner/FormErrorBanner.js +21 -0
  12. package/dist/src/Form/components/FormErrorBanner/index.js +1 -0
  13. package/dist/src/Form/components/FormErrorBanner/messages.js +13 -0
  14. package/dist/src/Form/components/FormMask/FormMask.js +11 -0
  15. package/dist/src/Form/components/FormMask/FormMask.style.js +15 -0
  16. package/dist/src/Form/components/FormMask/index.js +1 -0
  17. package/dist/src/Form/components/FormMessage/FormMessage.js +48 -0
  18. package/dist/src/Form/components/FormMessage/components/InternalFormMessage/InternalFormMessage.js +28 -0
  19. package/dist/src/Form/components/FormMessage/components/InternalFormMessage/InternalFormMessage.style.js +17 -0
  20. package/dist/src/Form/components/FormMessage/components/InternalFormMessage/index.js +1 -0
  21. package/dist/src/Form/components/FormMessage/components/InternalFormMessage/messages.js +8 -0
  22. package/dist/src/Form/components/FormMessage/index.js +1 -0
  23. package/dist/src/Form/components/FormMessageBanner/FormMessageBanner.js +15 -0
  24. package/dist/src/Form/components/FormMessageBanner/index.js +1 -0
  25. package/dist/src/Form/components/FormSaveButton/FormSaveButton.js +69 -0
  26. package/dist/src/Form/components/FormSaveButton/index.js +1 -0
  27. package/dist/src/Form/components/FormSaveButton/messages.js +8 -0
  28. package/dist/src/Form/constants.js +2 -0
  29. package/dist/src/Form/context/AtlantisFormContext.js +16 -0
  30. package/dist/src/Form/context/index.js +1 -0
  31. package/dist/src/Form/context/types.js +1 -0
  32. package/dist/src/Form/hooks/useFormViewRefs.js +14 -0
  33. package/dist/src/Form/hooks/useInternalForm.js +37 -0
  34. package/dist/src/Form/hooks/useOfflineHandler.js +24 -0
  35. package/dist/src/Form/hooks/useSaveButtonPosition.js +25 -0
  36. package/dist/src/Form/hooks/useScreenInformation.js +15 -0
  37. package/dist/src/Form/hooks/useScrollToError/index.js +1 -0
  38. package/dist/src/Form/hooks/useScrollToError/useScrollToError.js +63 -0
  39. package/dist/src/Form/index.js +4 -0
  40. package/dist/src/Form/messages.js +28 -0
  41. package/dist/src/Form/types.js +10 -0
  42. package/dist/src/InputDate/InputDate.js +76 -0
  43. package/dist/src/InputDate/index.js +1 -0
  44. package/dist/src/InputDate/messages.js +8 -0
  45. package/dist/src/Menu/Menu.js +67 -0
  46. package/dist/src/Menu/Menu.style.js +6 -0
  47. package/dist/src/Menu/components/MenuOption/MenuOption.js +25 -0
  48. package/dist/src/Menu/components/MenuOption/MenuOption.style.js +10 -0
  49. package/dist/src/Menu/components/MenuOption/index.js +1 -0
  50. package/dist/src/Menu/components/Overlay/Overlay.js +9 -0
  51. package/dist/src/Menu/components/Overlay/Overlay.style.js +6 -0
  52. package/dist/src/Menu/components/Overlay/index.js +1 -0
  53. package/dist/src/Menu/index.js +1 -0
  54. package/dist/src/Menu/messages.js +8 -0
  55. package/dist/src/Menu/types.js +1 -0
  56. package/dist/src/Menu/utils.js +84 -0
  57. package/dist/src/index.js +3 -0
  58. package/dist/tsconfig.tsbuildinfo +1 -1
  59. package/dist/types/src/AtlantisContext/AtlantisContext.d.ts +7 -1
  60. package/dist/types/src/Form/Form.d.ts +4 -0
  61. package/dist/types/src/Form/Form.style.d.ts +31 -0
  62. package/dist/types/src/Form/components/FormActionBar/FormActionBar.d.ts +13 -0
  63. package/dist/types/src/Form/components/FormActionBar/FormActionBar.style.d.ts +15 -0
  64. package/dist/types/src/Form/components/FormActionBar/index.d.ts +2 -0
  65. package/dist/types/src/Form/components/FormBody/FormBody.d.ts +10 -0
  66. package/dist/types/src/Form/components/FormBody/FormBody.style.d.ts +24 -0
  67. package/dist/types/src/Form/components/FormBody/index.d.ts +1 -0
  68. package/dist/types/src/Form/components/FormCache/FormCache.d.ts +10 -0
  69. package/dist/types/src/Form/components/FormErrorBanner/FormErrorBanner.d.ts +3 -0
  70. package/dist/types/src/Form/components/FormErrorBanner/index.d.ts +1 -0
  71. package/dist/types/src/Form/components/FormErrorBanner/messages.d.ts +12 -0
  72. package/dist/types/src/Form/components/FormMask/FormMask.d.ts +2 -0
  73. package/dist/types/src/Form/components/FormMask/FormMask.style.d.ts +13 -0
  74. package/dist/types/src/Form/components/FormMask/index.d.ts +1 -0
  75. package/dist/types/src/Form/components/FormMessage/FormMessage.d.ts +19 -0
  76. package/dist/types/src/Form/components/FormMessage/components/InternalFormMessage/InternalFormMessage.d.ts +8 -0
  77. package/dist/types/src/Form/components/FormMessage/components/InternalFormMessage/InternalFormMessage.style.d.ts +20 -0
  78. package/dist/types/src/Form/components/FormMessage/components/InternalFormMessage/index.d.ts +1 -0
  79. package/dist/types/src/Form/components/FormMessage/components/InternalFormMessage/messages.d.ts +7 -0
  80. package/dist/types/src/Form/components/FormMessage/index.d.ts +1 -0
  81. package/dist/types/src/Form/components/FormMessageBanner/FormMessageBanner.d.ts +7 -0
  82. package/dist/types/src/Form/components/FormMessageBanner/index.d.ts +1 -0
  83. package/dist/types/src/Form/components/FormSaveButton/FormSaveButton.d.ts +3 -0
  84. package/dist/types/src/Form/components/FormSaveButton/index.d.ts +1 -0
  85. package/dist/types/src/Form/components/FormSaveButton/messages.d.ts +7 -0
  86. package/dist/types/src/Form/constants.d.ts +2 -0
  87. package/dist/types/src/Form/context/AtlantisFormContext.d.ts +12 -0
  88. package/dist/types/src/Form/context/index.d.ts +2 -0
  89. package/dist/types/src/Form/context/types.d.ts +26 -0
  90. package/dist/types/src/Form/hooks/useFormViewRefs.d.ts +10 -0
  91. package/dist/types/src/Form/hooks/useInternalForm.d.ts +19 -0
  92. package/dist/types/src/Form/hooks/useOfflineHandler.d.ts +1 -0
  93. package/dist/types/src/Form/hooks/useSaveButtonPosition.d.ts +12 -0
  94. package/dist/types/src/Form/hooks/useScreenInformation.d.ts +8 -0
  95. package/dist/types/src/Form/hooks/useScrollToError/index.d.ts +1 -0
  96. package/dist/types/src/Form/hooks/useScrollToError/useScrollToError.d.ts +10 -0
  97. package/dist/types/src/Form/index.d.ts +5 -0
  98. package/dist/types/src/Form/messages.d.ts +27 -0
  99. package/dist/types/src/Form/types.d.ts +199 -0
  100. package/dist/types/src/InputDate/InputDate.d.ts +74 -0
  101. package/dist/types/src/InputDate/index.d.ts +1 -0
  102. package/dist/types/src/InputDate/messages.d.ts +7 -0
  103. package/dist/types/src/InputNumber/InputNumber.d.ts +1 -1
  104. package/dist/types/src/Menu/Menu.d.ts +3 -0
  105. package/dist/types/src/Menu/Menu.style.d.ts +18 -0
  106. package/dist/types/src/Menu/components/MenuOption/MenuOption.d.ts +3 -0
  107. package/dist/types/src/Menu/components/MenuOption/MenuOption.style.d.ts +8 -0
  108. package/dist/types/src/Menu/components/MenuOption/index.d.ts +1 -0
  109. package/dist/types/src/Menu/components/Overlay/Overlay.d.ts +3 -0
  110. package/dist/types/src/Menu/components/Overlay/Overlay.style.d.ts +12 -0
  111. package/dist/types/src/Menu/components/Overlay/index.d.ts +1 -0
  112. package/dist/types/src/Menu/index.d.ts +2 -0
  113. package/dist/types/src/Menu/messages.d.ts +7 -0
  114. package/dist/types/src/Menu/types.d.ts +22 -0
  115. package/dist/types/src/Menu/utils.d.ts +10 -0
  116. package/dist/types/src/index.d.ts +3 -0
  117. package/package.json +3 -2
  118. package/src/AtlantisContext/AtlantisContext.tsx +10 -1
  119. package/src/Form/Form.style.ts +34 -0
  120. package/src/Form/Form.test.tsx +588 -0
  121. package/src/Form/Form.tsx +296 -0
  122. package/src/Form/components/FormActionBar/FormActionBar.style.ts +11 -0
  123. package/src/Form/components/FormActionBar/FormActionBar.tsx +63 -0
  124. package/src/Form/components/FormActionBar/index.ts +2 -0
  125. package/src/Form/components/FormBody/FormBody.style.ts +27 -0
  126. package/src/Form/components/FormBody/FormBody.tsx +62 -0
  127. package/src/Form/components/FormBody/index.ts +1 -0
  128. package/src/Form/components/FormCache/FormCache.tsx +50 -0
  129. package/src/Form/components/FormErrorBanner/FormErrorBanner.test.tsx +124 -0
  130. package/src/Form/components/FormErrorBanner/FormErrorBanner.tsx +34 -0
  131. package/src/Form/components/FormErrorBanner/index.ts +1 -0
  132. package/src/Form/components/FormErrorBanner/messages.ts +14 -0
  133. package/src/Form/components/FormMask/FormMask.style.tsx +16 -0
  134. package/src/Form/components/FormMask/FormMask.tsx +19 -0
  135. package/src/Form/components/FormMask/index.ts +1 -0
  136. package/src/Form/components/FormMessage/FormMessage.test.tsx +72 -0
  137. package/src/Form/components/FormMessage/FormMessage.tsx +63 -0
  138. package/src/Form/components/FormMessage/components/InternalFormMessage/InternalFormMessage.style.ts +18 -0
  139. package/src/Form/components/FormMessage/components/InternalFormMessage/InternalFormMessage.tsx +55 -0
  140. package/src/Form/components/FormMessage/components/InternalFormMessage/index.ts +1 -0
  141. package/src/Form/components/FormMessage/components/InternalFormMessage/messages.ts +10 -0
  142. package/src/Form/components/FormMessage/index.ts +1 -0
  143. package/src/Form/components/FormMessageBanner/FormMessageBanner.test.tsx +27 -0
  144. package/src/Form/components/FormMessageBanner/FormMessageBanner.tsx +33 -0
  145. package/src/Form/components/FormMessageBanner/index.ts +1 -0
  146. package/src/Form/components/FormSaveButton/FormSaveButton.test.tsx +159 -0
  147. package/src/Form/components/FormSaveButton/FormSaveButton.tsx +103 -0
  148. package/src/Form/components/FormSaveButton/index.ts +1 -0
  149. package/src/Form/components/FormSaveButton/messages.ts +9 -0
  150. package/src/Form/constants.ts +2 -0
  151. package/src/Form/context/AtlantisFormContext.test.tsx +45 -0
  152. package/src/Form/context/AtlantisFormContext.tsx +21 -0
  153. package/src/Form/context/index.ts +5 -0
  154. package/src/Form/context/types.ts +34 -0
  155. package/src/Form/hooks/useFormViewRefs.ts +23 -0
  156. package/src/Form/hooks/useInternalForm.ts +99 -0
  157. package/src/Form/hooks/useOfflineHandler.ts +36 -0
  158. package/src/Form/hooks/useSaveButtonPosition.ts +52 -0
  159. package/src/Form/hooks/useScreenInformation.ts +25 -0
  160. package/src/Form/hooks/useScrollToError/index.ts +1 -0
  161. package/src/Form/hooks/useScrollToError/useScrollToError.test.tsx +103 -0
  162. package/src/Form/hooks/useScrollToError/useScrollToError.ts +102 -0
  163. package/src/Form/index.ts +13 -0
  164. package/src/Form/messages.ts +33 -0
  165. package/src/Form/types.ts +255 -0
  166. package/src/InputDate/InputDate.test.tsx +295 -0
  167. package/src/InputDate/InputDate.tsx +231 -0
  168. package/src/InputDate/index.ts +1 -0
  169. package/src/InputDate/messages.ts +9 -0
  170. package/src/InputNumber/InputNumber.tsx +1 -1
  171. package/src/Menu/Menu.style.ts +16 -0
  172. package/src/Menu/Menu.test.tsx +201 -0
  173. package/src/Menu/Menu.tsx +116 -0
  174. package/src/Menu/components/MenuOption/MenuOption.style.tsx +11 -0
  175. package/src/Menu/components/MenuOption/MenuOption.tsx +63 -0
  176. package/src/Menu/components/MenuOption/index.ts +1 -0
  177. package/src/Menu/components/Overlay/Overlay.style.ts +13 -0
  178. package/src/Menu/components/Overlay/Overlay.tsx +16 -0
  179. package/src/Menu/components/Overlay/index.ts +1 -0
  180. package/src/Menu/index.ts +6 -0
  181. package/src/Menu/messages.ts +9 -0
  182. package/src/Menu/types.ts +25 -0
  183. package/src/Menu/utils.ts +151 -0
  184. package/src/index.ts +3 -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,2 @@
1
+ export { FormActionBar } from "./FormActionBar";
2
+ export type { FormActionBarProps } from "./FormActionBar";
@@ -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
+ });