diff --git a/example/package.json b/example/package.json index 171ed5f..a76ff1f 100644 --- a/example/package.json +++ b/example/package.json @@ -20,6 +20,7 @@ "react": "18.2.0", "react-is": "^18.2.0", "react-native": "0.71.6", + "react-native-mmkv-storage": "^0.9.1", "react-native-svg": "^13.8.0", "styled-components": "^5.3.9" }, diff --git a/example/src/App.tsx b/example/src/App.tsx index 356ea21..338d0de 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -113,6 +113,7 @@ export function App(): ReactElement { onBackdropPress="continue" motion="bounce" onStop={onStopTour} + autoStart="always" > {({ start }) => ( <> diff --git a/package/package.json b/package/package.json index e571394..c4d90da 100644 --- a/package/package.json +++ b/package/package.json @@ -36,6 +36,8 @@ }, "dependencies": { "fast-equals": "^5.0.1", + "object-hash": "^3.0.0", + "react-native-mmkv-storage": "^0.9.1", "react-native-responsive-dimensions": "^3.1.1", "styled-components": "^5.3.9" }, @@ -47,6 +49,7 @@ "@testing-library/react-native": "^11.5.4", "@types/jest": "^29.4.1", "@types/node": "^18.15.3", + "@types/object-hash": "^3.0.2", "@types/react-test-renderer": "^18.0.0", "@types/sinon": "^10.0.13", "@types/styled-components": "^5.1.26", @@ -73,6 +76,7 @@ "peerDependencies": { "react": ">=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 04062fc..6e9a277 100644 --- a/package/src/index.ts +++ b/package/src/index.ts @@ -21,6 +21,7 @@ export { SpotlightTour, 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 d036476..beff8e6 100644 --- a/package/src/lib/SpotlightTour.context.ts +++ b/package/src/lib/SpotlightTour.context.ts @@ -33,6 +33,11 @@ export enum Position { */ export type Motion = "bounce" | "slide" | "fade"; +/** + * Possible tour autostart options + */ +export type AutoStartOptions = "never" | "always" | "once"; + export interface RenderProps { /** * The index of the current step the tour is on. diff --git a/package/src/lib/SpotlightTour.provider.tsx b/package/src/lib/SpotlightTour.provider.tsx index 2a82b30..db7a49b 100644 --- a/package/src/lib/SpotlightTour.provider.tsx +++ b/package/src/lib/SpotlightTour.provider.tsx @@ -1,9 +1,13 @@ -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, @@ -18,6 +22,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 @@ -54,6 +64,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 @@ -92,6 +108,7 @@ export interface SpotlightTourProviderProps { */ export const SpotlightTourProvider = forwardRef((props, ref) => { const { + autoStart = "never", children, motion = "bounce", nativeDriver = true, @@ -101,10 +118,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 }), @@ -118,7 +137,7 @@ export const SpotlightTourProvider = forwardRef setCurrent(index)); + .then(() => setCurrent(index)); } }, [steps]); @@ -128,7 +147,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 84f92d4..19a6863 100644 --- a/package/test/helpers/TestTour.tsx +++ b/package/test/helpers/TestTour.tsx @@ -1,9 +1,18 @@ import React from "react"; import { Button, Text, TouchableOpacity, View } from "react-native"; -import { Align, AttachStep, Position, SpotlightTourProvider, TourStep, useSpotlightTour } from "../../src"; +import { + Align, + AttachStep, + Position, + SpotlightTourProvider, + TourStep, + useSpotlightTour, + AutoStartOptions, +} from "../../src"; interface TestScreenProps { + autoStart?: AutoStartOptions; steps?: TourStep[]; } @@ -54,9 +63,9 @@ const defaultSteps = [ { ...BASE_STEP, position: Position.TOP }, ]; -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 4da4051..3f90f4c 100644 --- a/package/test/integration/index.test.tsx +++ b/package/test/integration/index.test.tsx @@ -285,4 +285,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 72997ba..9ce94ef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3275,6 +3275,7 @@ __metadata: "@testing-library/react-native": ^11.5.4 "@types/jest": ^29.4.1 "@types/node": ^18.15.3 + "@types/object-hash": ^3.0.2 "@types/react-test-renderer": ^18.0.0 "@types/sinon": ^10.0.13 "@types/styled-components": ^5.1.26 @@ -3284,9 +3285,11 @@ __metadata: fast-equals: ^5.0.1 jest: ^29.5.0 metro-react-native-babel-preset: ^0.76.1 + object-hash: ^3.0.0 react: ^18.2.0 react-is: ^18.2.0 react-native: 0.71.6 + react-native-mmkv-storage: ^0.9.1 react-native-responsive-dimensions: ^3.1.1 react-native-svg: ^13.8.0 react-test-renderer: ^18.2.0 @@ -3303,6 +3306,7 @@ __metadata: peerDependencies: react: ">=16.8.0" react-native: ">=0.50.0" + react-native-mmkv-storage: ">=0.9.1" react-native-svg: ">=12.1.0" peerDependenciesMeta: react: @@ -3513,6 +3517,13 @@ __metadata: languageName: node linkType: hard +"@types/object-hash@npm:^3.0.2": + version: 3.0.2 + resolution: "@types/object-hash@npm:3.0.2" + checksum: 0332e59074e7df2e74c093a7419c05c1e1c5ae1e12d3779f3240b3031835ff045b4ac591362be0b411ace24d3b5e760386b434761c33af25904f7a3645cb3785 + languageName: node + linkType: hard + "@types/parse-json@npm:^4.0.0": version: 4.0.0 resolution: "@types/parse-json@npm:4.0.0" @@ -6504,6 +6515,7 @@ __metadata: react: 18.2.0 react-is: ^18.2.0 react-native: 0.71.6 + react-native-mmkv-storage: ^0.9.1 react-native-svg: ^13.8.0 styled-components: ^5.3.9 typescript: ^4.9.5 @@ -10857,6 +10869,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" @@ -11785,6 +11804,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"