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 (
);
}
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);
});
});
});
});