diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 9f3c52a..1a14de6 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -74,6 +74,9 @@ PODS: - hermes-engine/Pre-built (= 0.72.4) - hermes-engine/Pre-built (0.72.4) - libevent (2.1.12) + - MMKV (1.2.13): + - MMKVCore (~> 1.2.13) + - MMKVCore (1.2.16) - OpenSSL-Universal (1.1.1100) - RCT-Folly (2021.07.22.00): - boost @@ -375,6 +378,9 @@ PODS: - React-jsinspector (0.72.4) - React-logger (0.72.4): - glog + - react-native-mmkv-storage (0.9.1): + - MMKV (= 1.2.13) + - React-Core - React-NativeModulesApple (0.72.4): - hermes-engine - React-callinvoker @@ -538,6 +544,7 @@ DEPENDENCIES: - React-jsiexecutor (from `../node_modules/react-native/ReactCommon/jsiexecutor`) - React-jsinspector (from `../node_modules/react-native/ReactCommon/jsinspector`) - React-logger (from `../node_modules/react-native/ReactCommon/logger`) + - react-native-mmkv-storage (from `../node_modules/react-native-mmkv-storage`) - React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) - React-perflogger (from `../node_modules/react-native/ReactCommon/reactperflogger`) - React-RCTActionSheet (from `../node_modules/react-native/Libraries/ActionSheetIOS`) @@ -571,6 +578,8 @@ SPEC REPOS: - FlipperKit - fmt - libevent + - MMKV + - MMKVCore - OpenSSL-Universal - SocketRocket - YogaKit @@ -619,6 +628,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon/jsinspector" React-logger: :path: "../node_modules/react-native/ReactCommon/logger" + react-native-mmkv-storage: + :path: "../node_modules/react-native-mmkv-storage" React-NativeModulesApple: :path: "../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios" React-perflogger: @@ -676,6 +687,8 @@ SPEC CHECKSUMS: glog: 04b94705f318337d7ead9e6d17c019bd9b1f6b1b hermes-engine: 81191603c4eaa01f5e4ae5737a9efcf64756c7b2 libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913 + MMKV: aac95d817a100479445633f2b3ed8961b4ac5043 + MMKVCore: 9cfef4c48c6c46f66226fc2e4634d78490206a48 OpenSSL-Universal: ebc357f1e6bc71fa463ccb2fe676756aff50e88c RCT-Folly: 424b8c9a7a0b9ab2886ffe9c3b041ef628fd4fb1 RCTRequired: c0569ecc035894e4a68baecb30fe6a7ea6e399f9 @@ -692,6 +705,7 @@ SPEC CHECKSUMS: React-jsiexecutor: c7f826e40fa9cab5d37cab6130b1af237332b594 React-jsinspector: aaed4cf551c4a1c98092436518c2d267b13a673f React-logger: da1ebe05ae06eb6db4b162202faeafac4b435e77 + react-native-mmkv-storage: cfb6854594cfdc5f7383a9e464bb025417d1721c React-NativeModulesApple: edb5ace14f73f4969df6e7b1f3e41bef0012740f React-perflogger: 496a1a3dc6737f964107cb3ddae7f9e265ddda58 React-RCTActionSheet: 02904b932b50e680f4e26e7a686b33ebf7ef3c00 diff --git a/example/package.json b/example/package.json index 9f3a523..e21b408 100644 --- a/example/package.json +++ b/example/package.json @@ -23,6 +23,7 @@ "react-dom": "^18.2.0", "react-is": "^18.2.0", "react-native": "0.72.4", + "react-native-mmkv-storage": "^0.9.1", "react-native-spotlight-tour": "workspace:^", "react-native-svg": "^13.14.0", "react-native-web": "^0.19.9", diff --git a/example/src/App.tsx b/example/src/App.tsx index f7b7506..ca47041 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -117,6 +117,7 @@ export function App(): ReactElement { }} onBackdropPress="continue" onStop={onStopTour} + autoStart="always" motion="bounce" shape="circle" > diff --git a/package/package.json b/package/package.json index c84e5fe..ab35ed3 100644 --- a/package/package.json +++ b/package/package.json @@ -37,7 +37,9 @@ "packageManager": "yarn@3.6.3", "dependencies": { "@floating-ui/react-native": "^0.10.1", + "object-hash": "^3.0.0", "react-fast-compare": "^3.2.2", + "react-native-mmkv-storage": "^0.9.1", "react-native-responsive-dimensions": "^3.1.1", "styled-components": "^6.0.8" }, @@ -48,6 +50,7 @@ "@testing-library/react-native": "^12.3.0", "@types/jest": "^29.5.5", "@types/node": "^20.6.3", + "@types/object-hash": "^3.0.6", "@types/react-test-renderer": "^18.0.2", "@types/sinon": "^10.0.16", "babel-jest": "^29.7.0", @@ -73,6 +76,7 @@ "react": ">=16.8.0", "react-dom": ">=16.8.0", "react-native": ">=0.50.0", + "react-native-mmkv-storage": ">=0.9.1", "react-native-svg": ">=12.1.0" }, "peerDependenciesMeta": { diff --git a/package/src/helpers/storage.ts b/package/src/helpers/storage.ts new file mode 100644 index 0000000..0328de1 --- /dev/null +++ b/package/src/helpers/storage.ts @@ -0,0 +1,5 @@ +import { MMKVLoader } from "react-native-mmkv-storage"; + +const storage = new MMKVLoader().initialize(); + +export default storage; diff --git a/package/src/index.ts b/package/src/index.ts index 2b99798..0d4d852 100644 --- a/package/src/index.ts +++ b/package/src/index.ts @@ -22,6 +22,7 @@ export { StopParams, TourStep, useSpotlightTour, + AutoStartOptions, } from "./lib/SpotlightTour.context"; export { SpotlightTourProvider, diff --git a/package/src/lib/SpotlightTour.context.ts b/package/src/lib/SpotlightTour.context.ts index 006ca63..ff74ee8 100644 --- a/package/src/lib/SpotlightTour.context.ts +++ b/package/src/lib/SpotlightTour.context.ts @@ -10,6 +10,11 @@ import { LayoutRectangle } from "react-native"; */ export type Motion = "bounce" | "slide" | "fade"; +/** + * Possible tour autostart options + */ +export type AutoStartOptions = "never" | "always" | "once"; + /** * Possible shape for the tour spotlight: * - `circle` diff --git a/package/src/lib/SpotlightTour.provider.tsx b/package/src/lib/SpotlightTour.provider.tsx index 0483d6b..3a766ae 100644 --- a/package/src/lib/SpotlightTour.provider.tsx +++ b/package/src/lib/SpotlightTour.provider.tsx @@ -1,17 +1,14 @@ import { flip, offset, shift } from "@floating-ui/react-native"; -import React, { - forwardRef, - useCallback, - useImperativeHandle, - useMemo, - useRef, - useState, -} from "react"; +import hash from "object-hash"; +import React, { forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState, useEffect } from "react"; import { ColorValue, LayoutRectangle } from "react-native"; +import { useMMKVStorage } from "react-native-mmkv-storage"; import { ChildFn, isChildFunction } from "../helpers/common"; +import storage from "../helpers/storage"; import { + AutoStartOptions, BackdropPressBehavior, Motion, OSConfig, @@ -27,6 +24,12 @@ import { import { TourOverlay, TourOverlayRef } from "./components/tour-overlay/TourOverlay.component"; export interface SpotlightTourProviderProps { + /** + * Sets the default behaviour when the tour starts. + * + * @default never + */ + autoStart?: AutoStartOptions; // never - always - once /** * The children to render in the provider. It accepts either a React * component, or a function that returns a React component. When the child is @@ -71,6 +74,12 @@ export interface SpotlightTourProviderProps { */ onBackdropPress?: BackdropPressBehavior; /** + * Handler which gets executed when {@link SpotlightTour.start|start} is + * invoked. It receives the {@link SpotlightTour.current|current} step index + * so you can access the current step where the tour starts. + */ + onStart?: () => void; + /* * Handler which gets executed when {@link SpotlightTour.stop|stop} is * invoked. It receives the {@link StopParams} so * you can access the `current` step index where the tour stopped @@ -116,6 +125,7 @@ export interface SpotlightTourProviderProps { */ export const SpotlightTourProvider = forwardRef((props, ref) => { const { + autoStart = "never", children, floatingProps = { middleware: [flip(), offset(4), shift()], @@ -130,10 +140,12 @@ export const SpotlightTourProvider = forwardRef(); const [spot, setSpot] = useState(ZERO_SPOT); + const [tourId, setTourId] = useMMKVStorage("tourId", storage, ""); const overlay = useRef({ hideTooltip: () => Promise.resolve({ finished: false }), @@ -156,7 +168,24 @@ export const SpotlightTourProvider = forwardRef { renderStep(0); - }, [renderStep]); + onStart?.(); + }, [renderStep, onStart]); + + const startOnce = useCallback(() => { + if (!tourId) { + setTourId(hash(steps)); + renderStep(0); + onStart?.(); + } + }, [renderStep, onStart, steps]); + + useEffect(() => { + if (autoStart === "always") { + start(); + } else if (autoStart === "once") { + startOnce(); + } + }, [renderStep, autoStart]); const stop = useCallback((): void => { setCurrent(prev => { diff --git a/package/test/helpers/TestTour.tsx b/package/test/helpers/TestTour.tsx index 05dee98..39af25b 100644 --- a/package/test/helpers/TestTour.tsx +++ b/package/test/helpers/TestTour.tsx @@ -1,9 +1,10 @@ import React from "react"; import { Button, Text, TouchableOpacity, View } from "react-native"; -import { AttachStep, SpotlightTourProvider, TourStep, useSpotlightTour } from "../../src"; +import { AttachStep, AutoStartOptions, SpotlightTourProvider, TourStep, useSpotlightTour } from "../../src"; interface TestScreenProps { + autoStart?: AutoStartOptions; steps?: TourStep[]; } @@ -55,9 +56,9 @@ const defaultSteps = [ { ...BASE_STEP }, ]; -export const TestScreen: React.FC = ({ steps }) => { +export const TestScreen: React.FC = ({ steps, autoStart }) => { return ( - + ); diff --git a/package/test/integration/index.test.tsx b/package/test/integration/index.test.tsx index 9a402ec..bb6709b 100644 --- a/package/test/integration/index.test.tsx +++ b/package/test/integration/index.test.tsx @@ -222,4 +222,38 @@ describe("[Integration] index.test.tsx", () => { }); }); }); + + describe("autoStart property", () => { + describe("when the autoStart property is set to never", () => { + it("the overlay is not shown", async () => { + const { getByText, queryByTestId } = render(); + await waitFor(() => expect(getByText("Start")).toBePresent()); + expect(queryByTestId("Overlay View")).toBeNull(); + }); + }); + + describe("when the autoStart property is set to always", () => { + it("shows the overlay view", async () => { + const { getByTestId } = render(); + await waitFor(() => expect(getByTestId("Overlay View")).toBePresent()); + }); + }); + + describe("when the autoStart property is set to once", () => { + describe("when the device is not registered", () => { + it("shows the overlay view", async() => { + const { getByTestId } = render(); + await waitFor(() => expect(getByTestId("Overlay View")).toBePresent()); + }); + }); + describe("when the device is already registered", () => { + it("the overlay is not shown", async () => { + const { queryByTestId } = render(); + await waitFor(() => { + expect(queryByTestId("Overlay View")).toBeNull(); + }); + }); + }); + }); + }); }); diff --git a/package/test/setup.ts b/package/test/setup.ts index 0398075..09ca5e1 100644 --- a/package/test/setup.ts +++ b/package/test/setup.ts @@ -1,5 +1,7 @@ /* eslint-disable max-classes-per-file */ +import { useState } from "react"; import { Animated, LayoutRectangle } from "react-native"; +import { MMKVInstance } from "react-native-mmkv-storage"; import { isAnimatedTimingInterpolation, @@ -136,7 +138,18 @@ jest timing: timingMock, }, }; - }); + }) + /* eslint-disable @typescript-eslint/no-unused-vars */ + .mock("react-native-mmkv-storage", () => ({ + MMKVLoader: jest.fn().mockImplementation(() => ({ + initialize: () => jest.fn(), + })), + useMMKVStorage: (_key: string, _storage: MMKVInstance, defaultValue: string) => { + const [value, setValue] = useState(defaultValue); + const setMockValue = (newValue: string): void => setValue(newValue); + return [value, setMockValue]; + }, + })); afterEach(() => { jest.resetAllMocks(); diff --git a/yarn.lock b/yarn.lock index 8b84401..672bd58 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3811,6 +3811,13 @@ __metadata: languageName: node linkType: hard +"@types/object-hash@npm:^3.0.6": + version: 3.0.6 + resolution: "@types/object-hash@npm:3.0.6" + checksum: 2c7979d4e540af817b99c09fb4c2fed1c0b0e14342df474d8dcde4165a82c440b038341fd66fe998d9f86acdd5cccc65ba8092315e39e7c2114f945fa70aaa56 + languageName: node + linkType: hard + "@types/parse-json@npm:^4.0.0": version: 4.0.0 resolution: "@types/parse-json@npm:4.0.0" @@ -6923,6 +6930,7 @@ __metadata: react-dom: ^18.2.0 react-is: ^18.2.0 react-native: 0.72.4 + react-native-mmkv-storage: ^0.9.1 react-native-spotlight-tour: "workspace:^" react-native-svg: ^13.14.0 react-native-web: ^0.19.9 @@ -11646,6 +11654,13 @@ __metadata: languageName: node linkType: hard +"object-hash@npm:^3.0.0": + version: 3.0.0 + resolution: "object-hash@npm:3.0.0" + checksum: 80b4904bb3857c52cc1bfd0b52c0352532ca12ed3b8a6ff06a90cd209dfda1b95cee059a7625eb9da29537027f68ac4619363491eedb2f5d3dddbba97494fd6c + languageName: node + linkType: hard + "object-inspect@npm:^1.12.3, object-inspect@npm:^1.9.0": version: 1.12.3 resolution: "object-inspect@npm:1.12.3" @@ -12581,6 +12596,17 @@ __metadata: languageName: node linkType: hard +"react-native-mmkv-storage@npm:^0.9.1": + version: 0.9.1 + resolution: "react-native-mmkv-storage@npm:0.9.1" + peerDependencies: + react-native: "*" + bin: + mmkv-link: autolink/postlink/run.js + checksum: 564f1cd971d20c9db03cff2d1cf8d55a2c69bd5a907eebbee7bcdc0592917bea5c787b424b1910931c649b3f17c9a361385565af585ec72fb7d1b63b9f2570af + languageName: node + linkType: hard + "react-native-responsive-dimensions@npm:^3.1.1": version: 3.1.1 resolution: "react-native-responsive-dimensions@npm:3.1.1" @@ -12623,16 +12649,19 @@ __metadata: "@testing-library/react-native": ^12.3.0 "@types/jest": ^29.5.5 "@types/node": ^20.6.3 + "@types/object-hash": ^3.0.6 "@types/react-test-renderer": ^18.0.2 "@types/sinon": ^10.0.16 babel-jest: ^29.7.0 expect-type: ^0.16.0 jest: ^29.7.0 metro-react-native-babel-preset: ^0.77.0 + object-hash: ^3.0.0 react: ^18.2.0 react-fast-compare: ^3.2.2 react-is: ^18.2.0 react-native: 0.72.4 + react-native-mmkv-storage: ^0.9.1 react-native-responsive-dimensions: ^3.1.1 react-native-svg: ^13.14.0 react-test-renderer: ^18.2.0 @@ -12650,6 +12679,7 @@ __metadata: react: ">=16.8.0" react-dom: ">=16.8.0" react-native: ">=0.50.0" + react-native-mmkv-storage: ">=0.9.1" react-native-svg: ">=12.1.0" peerDependenciesMeta: react: