import React, { type ReactElement } from "react"; import { act, fireEvent, render, waitFor } from "@testing-library/react-native"; import { Alert, Keyboard } from "react-native"; import { KeyboardEvents } from "react-native-keyboard-controller"; import type { FormBannerMessage } from "."; import { Form, FormBannerMessageType } from "."; import type { FormBannerErrors } from "./types"; import { FormSubmitErrorType } from "./types"; import { atlantisContextDefaultValues } from "../AtlantisContext"; import * as atlantisContext from "../AtlantisContext/AtlantisContext"; import { Text } from "../Text"; import { Checkbox } from "../Checkbox"; import { InputNumber } from "../InputNumber"; import { Switch } from "../Switch"; import { Option, Select } from "../Select"; import { InputText } from "../InputText"; jest.mock("lodash/debounce", () => { return jest.fn(fn => { fn.cancel = jest.fn(); return fn; }); }); const onSubmitMock = jest.fn().mockImplementation(() => { return Promise.resolve(() => Promise.resolve()); }); const onSuccessMock = jest.fn(); const onErrorMock = jest.fn(); const onChangeMock = jest.fn(); const onChangeSelectMock = jest.fn(); const onChangeSwitchMock = jest.fn(); const mockScrollTo = jest.fn(); const mockScrollToEnd = jest.fn(); const mockScrollToTop = jest.fn(); const mockMeasureInWindow = jest.fn( (callback: (x: number, y: number, width: number, height: number) => void) => { callback(0, 0, 0, 0); }, ); const mockScrollViewRef = { get current() { return { scrollTo: mockScrollTo, scrollToEnd: mockScrollToEnd }; }, // React assigns the ref while rendering KeyboardAwareScrollView. Keep the // test double available to the keyboard-hide callback instead. set current(value: unknown) { void value; }, }; const mockBottomViewRef = { get current() { return { measureInWindow: mockMeasureInWindow }; }, set current(value: unknown) { void value; }, }; jest.mock("./hooks/useFormViewRefs", () => ({ useFormViewRefs: () => { return { scrollViewRef: mockScrollViewRef, bottomViewRef: mockBottomViewRef, scrollToTop: mockScrollToTop, }; }, })); const bannerError = { title: "My error", messages: ["userError1", "userError2"], }; const noticeMessage = { messageType: FormBannerMessageType.NoticeMessage, message: "Take note of this information.", }; const warningMessage = { messageType: FormBannerMessageType.WarningMessage, message: "Caution is warranted in this case.", }; const testInputTextName = "test"; const testInputTextNameExclude = "exclude"; const testInputTextPlaceholderExclude = "Test Exclude"; const testInputTextPlaceholder = "Test Input"; const testSelectName = "testSelect"; const testSwitchName = "testSwitch"; const testInputNumberName = "testNumber"; const testCheckboxName = "testCheckbox"; const switchLabel = "switchLabel"; const checkboxLabel = "checkboxLabel"; const selectLabel = "selectLabel"; const saveButtonText = "Save"; const requiredInputText = "This field is required"; const minLengthText = "Test is too short"; interface FormFields { [testInputTextName]: string; [testSelectName]: string; [testSwitchName]: boolean; [testInputNumberName]: number; } interface FormTestProps { readonly onSubmit: jest.Mock; readonly sendBannerErrors?: boolean; readonly sendNetworkErrors?: boolean; readonly saveLabel?: string; readonly renderStickySection?: ( onSubmit: () => void, label: string | undefined, isSubmitting: boolean, ) => ReactElement; readonly initialLoading?: boolean; readonly initialValues?: FormFields; readonly bannerMessages?: FormBannerMessage[]; readonly localCacheKey?: string; readonly localCacheExclude?: string[]; readonly localCacheId?: string[] | string; readonly onBeforeSubmit?: jest.Mock; readonly renderFooter?: React.ReactNode; readonly saveButtonOffset?: number; readonly UNSAFE_allowDiscardLocalCacheWhenOffline?: boolean; } function FormTest(props: FormTestProps) { return ; } function MockForm({ onSubmit, sendBannerErrors = false, sendNetworkErrors = false, saveLabel, renderStickySection, initialLoading = false, initialValues = undefined, bannerMessages, localCacheKey, localCacheExclude, onBeforeSubmit, localCacheId, renderFooter, saveButtonOffset, UNSAFE_allowDiscardLocalCacheWhenOffline = false, }: FormTestProps) { const formErrors: FormBannerErrors = {}; if (sendBannerErrors) { formErrors.bannerError = bannerError; } if (sendNetworkErrors) { formErrors.networkError = "Ouch"; } return (
{Array.isArray(localCacheExclude) && localCacheExclude.length > 0 && ( )} ); } const testPresetValues = { [testInputTextName]: "PresetValueTestText", [testSelectName]: "2", [testSwitchName]: true, [testInputNumberName]: 123454321, [testCheckboxName]: true, }; async function wait(milliseconds = 0): Promise { await new Promise(resolve => setTimeout(resolve as unknown as () => void, milliseconds), ); } afterEach(() => { jest.clearAllMocks(); }); const loadingLabel = "Loading"; const tryAgainLabel = "Try again"; describe("Form", () => { describe("Initial Load", () => { it("should show activity indicator", () => { const { getByLabelText } = render( , ); expect(getByLabelText(loadingLabel)).toBeTruthy(); }); it("should be populated with provided initialValues", () => { const { getByDisplayValue, getByLabelText } = render( , ); expect( getByDisplayValue(testPresetValues[testInputTextName]), ).toBeDefined(); expect( getByDisplayValue(testPresetValues[testInputNumberName].toString()), ).toBeDefined(); expect(getByLabelText(switchLabel).props.value).toEqual(true); expect( getByLabelText(checkboxLabel).props.accessibilityState.checked, ).toEqual(true); }); }); describe("Save", () => { it("renders the save button of the form", () => { const { getByLabelText } = render(); const saveButton = getByLabelText(saveButtonText); expect(saveButton).toBeTruthy(); }); it("displays relevant validation errors when trying to save the form", async () => { const { getByLabelText, getAllByText } = render( , ); const saveButton = getByLabelText(saveButtonText); await waitFor(() => { fireEvent.press(saveButton); }); expect( getAllByText(requiredInputText, { includeHiddenElements: true }), ).toBeDefined(); const newValue = "A"; await waitFor(() => { fireEvent.changeText( getByLabelText(testInputTextPlaceholder), newValue, ); }); expect( getAllByText(minLengthText, { includeHiddenElements: true }), ).toBeDefined(); }); it("should submit correct create form data", async () => { const { getByLabelText, getByText } = render( , ); const saveButton = getByLabelText(saveButtonText); const newValue = "New Value"; fireEvent.changeText(getByLabelText(testInputTextPlaceholder), newValue); expect(onChangeMock).toHaveBeenCalled(); fireEvent(getByLabelText(switchLabel), "onValueChange", true); expect(onChangeSwitchMock).toHaveBeenCalled(); fireEvent( getByText(selectLabel, { includeHiddenElements: true }), "onChange", "2", ); expect(onChangeSelectMock).toHaveBeenCalled(); fireEvent.press(saveButton); await waitFor(() => { expect(onSubmitMock).toHaveBeenCalledWith({ [testInputTextName]: newValue, [testSelectName]: "2", [testSwitchName]: true, }); }); }); it("should dismiss keyboard when form is saved", async () => { const keyboardDismissSpy = jest.spyOn(Keyboard, "dismiss"); const { getByLabelText } = render(); const newValue = "New Value"; fireEvent.changeText(getByLabelText(testInputTextPlaceholder), newValue); const saveButton = getByLabelText(saveButtonText); fireEvent.press(saveButton); await waitFor(() => { expect(onSubmitMock).toHaveBeenCalled(); }); expect(keyboardDismissSpy).toHaveBeenCalled(); }); it("renders a save button with a custom label if provided", () => { const customSaveButtonText = "MySave"; const { getByLabelText } = render( , ); const saveButton = getByLabelText(customSaveButtonText); expect(saveButton).toBeTruthy(); }); it("renders a custom sticky component if provided", () => { const customSaveButtonText = "CheckboxOverload"; const { getByLabelText } = render( ( )} />, ); const overrideCheckbox = getByLabelText(customSaveButtonText); expect(overrideCheckbox).toBeTruthy(); }); }); describe("Submitting", () => { it("should show submission spinner when submitting form data", async () => { const timeoutSubmit = jest.fn().mockImplementation(() => { return new Promise(res => setTimeout(() => res(() => Promise.resolve()), 1000), ); }); const { getByLabelText } = await waitFor(() => render(), ); const saveButton = getByLabelText(saveButtonText); fireEvent.press(saveButton); expect(getByLabelText(loadingLabel)).toBeTruthy(); }); it("should call beforeSubmit if one is provided", async () => { const beforeSubmitMock = jest.fn().mockImplementation(() => { return Promise.resolve(true); }); const { getByLabelText } = render( , ); const newValue = "New Value"; fireEvent.changeText(getByLabelText(testInputTextPlaceholder), newValue); const saveButton = getByLabelText(saveButtonText); fireEvent.press(saveButton); await waitFor(() => { expect(beforeSubmitMock).toHaveBeenCalled(); expect(onSubmitMock).toHaveBeenCalled(); }); }); it("does not submit if beforeSubmit returns false", async () => { const beforeSubmitMock = jest.fn().mockImplementation(() => { return Promise.resolve(false); }); const { getByLabelText } = render( , ); const newValue = "New Value"; fireEvent.changeText(getByLabelText(testInputTextPlaceholder), newValue); const saveButton = getByLabelText(saveButtonText); fireEvent.press(saveButton); await waitFor(() => { expect(beforeSubmitMock).toHaveBeenCalled(); expect(onSubmitMock).not.toHaveBeenCalled(); }); }); describe("While offline", () => { const mockSubmit = jest.fn().mockImplementation(() => Promise.reject({ errorType: FormSubmitErrorType.NetworkError, }), ); const setup = () => { const view = render( , ); const { getByLabelText } = view; const newValue = "New Value"; fireEvent.changeText( getByLabelText(testInputTextPlaceholder), newValue, ); fireEvent.press(getByLabelText(saveButtonText)); return view; }; it("should show offline alert when attempting to save while offline", async () => { const alertSpy = jest.spyOn(Alert, "alert"); setup(); await act(wait); expect(alertSpy).toHaveBeenCalledTimes(1); expect(alertSpy).toHaveBeenCalledWith( "Network Unavailable", "Check your internet connection and try again later.", expect.anything(), ); }); it("Submits Form on successful retry", async () => { const alertSpy = jest.spyOn(Alert, "alert"); setup(); await act(wait); const alertActions = alertSpy.mock.calls[0][2]; const retryAction = alertActions?.find( action => action.text === tryAgainLabel, ); mockSubmit.mockImplementationOnce(() => Promise.resolve()); retryAction?.onPress?.(); await act(wait); expect(alertSpy).toHaveBeenCalledTimes(1); expect(onSuccessMock).toHaveBeenCalled(); }); it("reshows an Alert on unsuccessful retry", async () => { const alertSpy = jest.spyOn(Alert, "alert"); setup(); await act(wait); const alertActions = alertSpy.mock.calls[0][2]; const retryAction = alertActions?.find( action => action.text === tryAgainLabel, ); retryAction?.onPress?.(); await act(wait); expect(alertSpy).toHaveBeenCalledTimes(2); }); }); }); describe("Error Banner", () => { const atlantisContextSpy = jest.spyOn( atlantisContext, "useAtlantisContext", ); atlantisContextSpy.mockReturnValue({ ...atlantisContextDefaultValues, isOnline: false, }); it("renders user errors when provided", async () => { const { getByText } = render( , ); const expectedErrors = bannerError.messages; expect(getByText(new RegExp(expectedErrors[0]))).toBeTruthy(); expect(getByText(new RegExp(expectedErrors[1]))).toBeTruthy(); }); it("does not render user errors when not provided", async () => { const { queryByText } = render(); const expectedErrors = bannerError.messages; expect(queryByText(expectedErrors[0])).toBeNull(); expect(queryByText(expectedErrors[1])).toBeNull(); }); }); describe("Message Banner", () => { it.each([ [FormBannerMessageType.NoticeMessage, noticeMessage], [FormBannerMessageType.WarningMessage, warningMessage], ])("renders a %s when provided", async (_messageType, message) => { const { getByText } = render( , ); expect(getByText(message.message)).toBeDefined(); }); it("renders multiple messages when provided", async () => { const { getByText } = render( , ); expect(getByText(noticeMessage.message)).toBeDefined(); expect(getByText(warningMessage.message)).toBeDefined(); }); }); describe("Render footer", () => { it("render a footer when provided", () => { const footerMessage = "Hello, I'm a footer!"; const { getByText } = render( {footerMessage}} />, ); expect(getByText(footerMessage)).toBeDefined(); }); }); describe("Safe Area", () => { it("does render a safe area when there's NO saveButtonOffset provided", () => { const { getByTestId } = render(); expect(getByTestId("ATL-FormSafeArea")).toBeDefined(); }); it("does NOT render a safe area when there's a saveButtonOffset is provided", () => { const { queryByTestId } = render( , ); expect(queryByTestId("ATL-FormSafeArea")).toBeNull(); }); }); describe("Keyboard dismissal", () => { it("does not imperatively scroll the form to its end", async () => { const listeners = new Map void>(); const addListener = KeyboardEvents.addListener as jest.Mock; const registerListener = (eventName: string, listener: () => void) => { listeners.set(eventName, listener); return { remove: jest.fn() }; }; addListener .mockImplementationOnce(registerListener) .mockImplementationOnce(registerListener); render(); const hideListener = [...listeners.entries()].find(([eventName]) => eventName.endsWith("Hide"), )?.[1]; expect(hideListener).toBeDefined(); if (!hideListener) { throw new Error("Expected a keyboard hide listener"); } await act(async () => { hideListener(); }); expect(mockScrollToEnd).not.toHaveBeenCalled(); }); }); describe("Leaving the form", () => { let mockUseConfirmBeforeBack: jest.Mock; let mockRemoveLocalCache: jest.Mock; const atlantisContextSpy = jest.spyOn( atlantisContext, "useAtlantisContext", ); beforeEach(() => { mockUseConfirmBeforeBack = jest .fn() .mockReturnValue({ current: jest.fn() }); mockRemoveLocalCache = jest.fn(); jest .spyOn( require("../Form/context/AtlantisFormContext"), "useAtlantisFormContext", ) .mockReturnValue({ useConfirmBeforeBack: mockUseConfirmBeforeBack, useInternalFormLocalCache: () => ({ setLocalCache: jest.fn(), removeLocalCache: mockRemoveLocalCache, }), edgeToEdgeEnabled: false, }); }); afterEach(() => { jest.restoreAllMocks(); }); describe("when UNSAFE_allowDiscardLocalCacheWhenOffline is false", () => { it("should NOT pass onAcceptEvent when offline", () => { atlantisContextSpy.mockReturnValue({ ...atlantisContextDefaultValues, isOnline: false, }); render( , ); expect(mockUseConfirmBeforeBack).toHaveBeenCalled(); const callArgs = mockUseConfirmBeforeBack.mock.calls[0][0]; expect(callArgs.onAcceptEvent).toBeUndefined(); expect(callArgs.showLostProgressMessage).toBe(false); }); it("should pass onAcceptEvent when online", () => { atlantisContextSpy.mockReturnValue({ ...atlantisContextDefaultValues, isOnline: true, }); render( , ); expect(mockUseConfirmBeforeBack).toHaveBeenCalled(); const callArgs = mockUseConfirmBeforeBack.mock.calls[0][0]; expect(callArgs.onAcceptEvent).toBe(mockRemoveLocalCache); expect(callArgs.showLostProgressMessage).toBe(true); }); }); describe("when UNSAFE_allowDiscardLocalCacheWhenOffline is true", () => { it("should pass onAcceptEvent when offline", () => { atlantisContextSpy.mockReturnValue({ ...atlantisContextDefaultValues, isOnline: false, }); render( , ); expect(mockUseConfirmBeforeBack).toHaveBeenCalled(); const callArgs = mockUseConfirmBeforeBack.mock.calls[0][0]; expect(callArgs.onAcceptEvent).toBe(mockRemoveLocalCache); expect(callArgs.showLostProgressMessage).toBe(true); }); it("should pass onAcceptEvent when online", () => { atlantisContextSpy.mockReturnValue({ ...atlantisContextDefaultValues, isOnline: true, }); render( , ); expect(mockUseConfirmBeforeBack).toHaveBeenCalled(); const callArgs = mockUseConfirmBeforeBack.mock.calls[0][0]; expect(callArgs.onAcceptEvent).toBe(mockRemoveLocalCache); expect(callArgs.showLostProgressMessage).toBe(true); }); }); describe("without localCacheKey", () => { it("should always show lost progress message when no cache key is provided", () => { atlantisContextSpy.mockReturnValue({ ...atlantisContextDefaultValues, isOnline: false, }); render( , ); expect(mockUseConfirmBeforeBack).toHaveBeenCalled(); const callArgs = mockUseConfirmBeforeBack.mock.calls[0][0]; expect(callArgs.showLostProgressMessage).toBe(true); }); }); }); });