diff --git a/src/contexts/network-connection-context.ts b/src/contexts/network-connection-context.ts new file mode 100644 index 0000000..79128f0 --- /dev/null +++ b/src/contexts/network-connection-context.ts @@ -0,0 +1,6 @@ +import { NetworkConnection } from "andculturecode-javascript-core"; +import React from "react"; + +export const NetworkConnectionContext = React.createContext< + NetworkConnection | undefined +>(undefined); diff --git a/src/hooks/use-network-connection.test.tsx b/src/hooks/use-network-connection.test.tsx new file mode 100644 index 0000000..c547c94 --- /dev/null +++ b/src/hooks/use-network-connection.test.tsx @@ -0,0 +1,59 @@ +import { render } from "@testing-library/react"; +import { renderHook } from "@testing-library/react-hooks"; +import { + NetworkConnection, + NetworkInformationUtils, +} from "andculturecode-javascript-core"; +import React from "react"; +import { NetworkConnectionProvider } from "../providers/network-connection-provider"; +import { useNetworkConnection } from "./use-network-connection"; + +// ----------------------------------------------------------------------------------------- +// #region Mocks +// ----------------------------------------------------------------------------------------- + +const getNetworkConnectionMock = jest.spyOn( + NetworkInformationUtils, + "getNetworkConnection" +); + +// #endregion Mocks + +describe("useNetworkConnection", () => { + describe("when used outside NetworkConnectionProvider", () => { + it("throws error", () => { + // Arrange & Act + const { result } = renderHook(() => useNetworkConnection()); + + // Assert + expect(result.error).toBeDefined(); + }); + }); + + describe("when used inside NetworkConnectionProvider", () => { + it("returns network connection information", () => { + // Arrange + let networkConnection: NetworkConnection; + const expectedNetworkConnection: NetworkConnection = { + isOnline: true, + }; + + getNetworkConnectionMock.mockReturnValue(expectedNetworkConnection); + + const TestComponent = () => { + networkConnection = useNetworkConnection(); + return
; + }; + + // Act + render( + + + + ); + + // Assert + expect(networkConnection).toEqual(expectedNetworkConnection); + }); + }); +}); diff --git a/src/hooks/use-network-connection.ts b/src/hooks/use-network-connection.ts new file mode 100644 index 0000000..77e4d61 --- /dev/null +++ b/src/hooks/use-network-connection.ts @@ -0,0 +1,18 @@ +import { useContext } from "react"; +import { NetworkConnection } from "andculturecode-javascript-core"; +import { NetworkConnectionContext } from "../contexts/network-connection-context"; + +/** + * Hook that returns the current network connection information + */ +export const useNetworkConnection = (): NetworkConnection => { + const networkConnection = useContext(NetworkConnectionContext); + + if (networkConnection == null) { + throw new Error( + "useNetworkConnection must be used within a NetworkConnectionProvider component" + ); + } + + return networkConnection; +}; diff --git a/src/index.ts b/src/index.ts index f9ecc62..e132a1f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ export { NestedRoutesByProperty, NestedRoutesByPropertyProps, } from "./components/routing/nested-routes-by-property"; +export { NetworkConnectionProvider } from "./providers/network-connection-provider"; export { Redirects, RedirectsProps } from "./components/routing/redirects"; // #endregion Components @@ -31,6 +32,7 @@ export { useAsyncEffect } from "./hooks/use-async-effect"; export { useCancellablePromise } from "./hooks/use-cancellable-promise"; export { useDebounce } from "./hooks/use-debounce"; export { useLocalization } from "./hooks/use-localization"; +export { useNetworkConnection } from "./hooks/use-network-connection"; export { useOnClickOutside } from "./hooks/use-onclick-outside"; export { usePageErrors } from "./hooks/use-page-errors"; export { useSortedAlphabetically } from "./hooks/use-sorted-alphabetically"; diff --git a/src/providers/network-connection-provider.test.tsx b/src/providers/network-connection-provider.test.tsx new file mode 100644 index 0000000..c58d59a --- /dev/null +++ b/src/providers/network-connection-provider.test.tsx @@ -0,0 +1,216 @@ +import React from "react"; +import { act, cleanup, render } from "@testing-library/react"; +import { NetworkConnectionProvider } from "./network-connection-provider"; +import { + NetworkConnection, + NetworkInformationUtils, +} from "andculturecode-javascript-core"; +import { useNetworkConnection } from "../hooks/use-network-connection"; +import { FactoryType } from "../tests/factories/factory-type"; +import { Factory } from "rosie"; + +// ----------------------------------------------------------------------------------------- +// #region Types +// ----------------------------------------------------------------------------------------- + +type TypeOfKey = Type[Key]; + +// #endregion Types + +// ----------------------------------------------------------------------------------------- +// #region Interfaces +// ----------------------------------------------------------------------------------------- + +interface SetupSutOptions { + mockConnections?: Array>; +} + +interface SetupSutResults { + TestComponent: () => JSX.Element; + networkConnectionResults: { + all: Array; + current?: NetworkConnection; + }; +} + +// #endregion Interfaces + +// ----------------------------------------------------------------------------------------- +// #region Setup +// ----------------------------------------------------------------------------------------- + +const getNetworkConnectionMock = jest.spyOn( + NetworkInformationUtils, + "getNetworkConnection" +); + +const setupMocks = (mockConnections: Array>) => { + getNetworkConnectionMock.mockReset(); + for (let index = 0; index < mockConnections.length; index++) { + const mockImplementation = + index === mockConnections.length - 1 + ? getNetworkConnectionMock.mockImplementation + : getNetworkConnectionMock.mockImplementationOnce; + + mockImplementation(() => { + return { + isOnline: true, + ...mockConnections[index], + }; + }); + } +}; + +const setupSut = (options?: SetupSutOptions): SetupSutResults => { + const { mockConnections = [] } = options ?? {}; + + setupMocks(mockConnections); + + const networkConnectionResults: TypeOfKey< + SetupSutResults, + "networkConnectionResults" + > = { + all: [] as NetworkConnection[], + }; + + function TestComponent() { + const connection = useNetworkConnection(); + networkConnectionResults.all.push(connection); + networkConnectionResults.current = connection; + + return
; + } + + return { + TestComponent, + networkConnectionResults, + }; +}; + +// #endregion Setup + +describe("NetworkConnectionProvider", () => { + it("renders initial network connection state", () => { + // Arrange + const expectedConnection = Factory.build( + FactoryType.NetworkConnection + ); + const { networkConnectionResults, TestComponent } = setupSut({ + mockConnections: [ + Factory.build(FactoryType.NetworkConnection), + expectedConnection, + ], + }); + + // Act + render( + + + + ); + + // Assert + expect(networkConnectionResults.all.length).toEqual(2); + expect(networkConnectionResults.current).toEqual(expectedConnection); + }); + + it("adds an event listener", () => { + // Arrange + const addEventListener = jest.fn(); + const networkConnection = Factory.build( + FactoryType.NetworkConnection, + { + addEventListener, + } + ); + const { TestComponent } = setupSut({ + mockConnections: [networkConnection], + }); + + // Act + render( + + + + ); + + // Assert + expect(addEventListener).toBeCalled(); + }); + + describe("when change event is called", () => { + it("loads network connection into state", () => { + // Arrange + let changeEventCallback = () => {}; + + const expectedNetworkConnection = Factory.build( + FactoryType.NetworkConnection + ); + + const mockConnections: Array = [ + Factory.build(FactoryType.NetworkConnection), + Factory.build( + FactoryType.NetworkConnection, + { + addEventListener: ( + event: "change", + callback: VoidFunction + ) => { + changeEventCallback = callback; + }, + } + ), + expectedNetworkConnection, + ]; + + const { networkConnectionResults, TestComponent } = setupSut({ + mockConnections, + }); + + // Act + render( + + + + ); + + act(() => changeEventCallback()); + + // Assert + expect(networkConnectionResults.all.length).toEqual( + mockConnections.length + ); + expect(networkConnectionResults.current).toEqual( + expectedNetworkConnection + ); + }); + }); + + describe("when unmounted", () => { + it("calls removeEventlistener for cleanup", async () => { + // Arrange + const removeEventListener = jest.fn(); + const networkConnection = Factory.build( + FactoryType.NetworkConnection, + { + removeEventListener, + } + ); + const { TestComponent } = setupSut({ + mockConnections: [networkConnection], + }); + + // Act + render( + + + + ); + + await cleanup(); + + // Assert + expect(removeEventListener).toBeCalled(); + }); + }); +}); diff --git a/src/providers/network-connection-provider.tsx b/src/providers/network-connection-provider.tsx new file mode 100644 index 0000000..77772f0 --- /dev/null +++ b/src/providers/network-connection-provider.tsx @@ -0,0 +1,76 @@ +import React, { + PropsWithChildren, + useCallback, + useEffect, + useState, +} from "react"; +import { + NetworkConnection, + NetworkInformationUtils, +} from "andculturecode-javascript-core"; +import { NetworkConnectionContext } from "../contexts/network-connection-context"; + +/** + * Wrapper provider component that provides context to the `useNetworkConnection` hook + */ +export const NetworkConnectionProvider: React.FC = ( + props: PropsWithChildren +) => { + const { children } = props; + + const [state, setState] = useState( + NetworkInformationUtils.getNetworkConnection() + ); + + const loadNetworkInformation = useCallback((isOnline?: boolean) => { + const networkConnection = NetworkInformationUtils.getNetworkConnection(); + + if (networkConnection == null) { + return; + } + + setState((prev) => ({ + ...prev, + ...networkConnection, + isOnline: isOnline ?? networkConnection.isOnline, + })); + }, []); + + useEffect( + function handleNetworkChangeEvents() { + const networkConnection = NetworkInformationUtils.getNetworkConnection(); + + const createNetworkChangeHandler = (isOnline?: boolean) => () => + loadNetworkInformation(isOnline); + + const handleNetworkChange = createNetworkChangeHandler(); + const handleOffline = createNetworkChangeHandler(false); + const handleOnline = createNetworkChangeHandler(true); + + networkConnection?.addEventListener?.( + "change", + handleNetworkChange + ); + window?.addEventListener?.("online", handleOnline); + window?.addEventListener?.("offline", handleOffline); + + loadNetworkInformation(); + + return function cleanup() { + networkConnection?.removeEventListener?.( + "change", + handleNetworkChange + ); + window?.removeEventListener?.("online", handleOnline); + window?.removeEventListener?.("offline", handleOffline); + }; + }, + [loadNetworkInformation] + ); + + return ( + + {children} + + ); +}; diff --git a/src/tests/factories/factory-type.ts b/src/tests/factories/factory-type.ts index 6b72648..13c1c38 100644 --- a/src/tests/factories/factory-type.ts +++ b/src/tests/factories/factory-type.ts @@ -1,4 +1,5 @@ const FactoryType = { + NetworkConnection: "NetworkConnection", RouteDefinition: { Default: "RouteDefinition", Nested: "RouteDefinition.Nested", diff --git a/src/tests/factories/index.ts b/src/tests/factories/index.ts index 65eb52c..26f68cb 100644 --- a/src/tests/factories/index.ts +++ b/src/tests/factories/index.ts @@ -4,3 +4,4 @@ export { } from "./route-definition-factory"; export { RouteMapFactory } from "./route-map-factory"; export { StubResourceRecordFactory } from "andculturecode-javascript-testing"; +export { NetworkConnectionFactory } from "./network-connection-factory"; diff --git a/src/tests/factories/network-connection-factory.ts b/src/tests/factories/network-connection-factory.ts new file mode 100644 index 0000000..c60e361 --- /dev/null +++ b/src/tests/factories/network-connection-factory.ts @@ -0,0 +1,26 @@ +import { NetworkConnection } from "andculturecode-javascript-core"; +import { Factory } from "rosie"; +import { FactoryType } from "./factory-type"; + +// ----------------------------------------------------------------------------------------- +// #region Factory +// ----------------------------------------------------------------------------------------- + +export const NetworkConnectionFactory = Factory.define( + FactoryType.NetworkConnection +) + .attr("isOnline", true) + .attr("addEventListener", eventListenerFactory) + .attr("removeEventListener", eventListenerFactory); + +// #endregion Factory + +// ----------------------------------------------------------------------------------------- +// #region Functions +// ----------------------------------------------------------------------------------------- + +function eventListenerFactory() { + return () => {}; +} + +// #endregion Functions