diff --git a/examples/nextjs-app-router/app/layout.tsx b/examples/nextjs-app-router/app/layout.tsx index f3d4978..c4fd231 100644 --- a/examples/nextjs-app-router/app/layout.tsx +++ b/examples/nextjs-app-router/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from 'next'; import './globals.css'; +import { Providers } from '~/src/providers'; export const metadata: Metadata = { title: 'Create Next App', @@ -13,7 +14,9 @@ export default function RootLayout({ }>) { return ( - {children} + + {children} + ); } diff --git a/examples/nextjs-app-router/app/overlay/page.tsx b/examples/nextjs-app-router/app/overlay/page.tsx new file mode 100644 index 0000000..97ac467 --- /dev/null +++ b/examples/nextjs-app-router/app/overlay/page.tsx @@ -0,0 +1,10 @@ +import { Suspense } from 'react'; +import { OverlayFunnel } from '../../src/overlay/OverlayCaseFunnel'; + +export default function Page() { + return ( + + ; + + ); +} diff --git a/examples/nextjs-app-router/app/page.tsx b/examples/nextjs-app-router/app/page.tsx index d90362e..7306d99 100644 --- a/examples/nextjs-app-router/app/page.tsx +++ b/examples/nextjs-app-router/app/page.tsx @@ -1,9 +1,10 @@ -'use client'; -import dynamic from 'next/dynamic'; -const TestAppRouterFunnel = dynamic(() => - import('../src/funnel').then(({ TestAppRouterFunnel }) => TestAppRouterFunnel), -); +import { Suspense } from 'react'; +import { TestAppRouterFunnel } from '~/src/funnel'; + export default function Home() { - //A pre-render error occurs in @use-funnel/browser 0.0.5 version. - return ; + return ( + + + + ); } diff --git a/examples/nextjs-app-router/e2e/app-router-funnel.spec.ts b/examples/nextjs-app-router/e2e/app-router-funnel.spec.ts index 7271ac1..0ce3f86 100644 --- a/examples/nextjs-app-router/e2e/app-router-funnel.spec.ts +++ b/examples/nextjs-app-router/e2e/app-router-funnel.spec.ts @@ -16,4 +16,17 @@ test('can move the steps of the funnel using history.push.', async ({ page }) => await page.goBack(); await expect(page.getByText('start')).toBeVisible(); + + await page.getByRole('button', { name: 'navigate to overlay funnel' }).click(); + + await expect(page.getByText(/Select Your School/)).toBeVisible(); + await page.getByRole('button', { name: /school next/ }).click(); + await expect(page.getByText(/overlay next/)).toBeVisible(); + await expect(page.getByText(/school next/)).toBeVisible(); + + await page.click('input[type="date"]'); + await page.fill('input[type="date"]', '2024-01-01'); + await page.getByRole('button', { name: 'overlay next' }).click(); + await expect(page.getByText(/school: A/)).toBeVisible(); + await expect(page.getByText(/startDate: 2024-01-01/)).toBeVisible(); }); diff --git a/examples/nextjs-app-router/package.json b/examples/nextjs-app-router/package.json index a882a1b..e298f3b 100644 --- a/examples/nextjs-app-router/package.json +++ b/examples/nextjs-app-router/package.json @@ -10,9 +10,10 @@ "e2e": "pnpm exec playwright test" }, "dependencies": { - "@use-funnel/browser": "workspace:^", "@use-funnel/core": "workspace:^", + "@use-funnel/next-app-router": "workspace:^", "next": "14.2.13", + "overlay-kit": "^1.4.1", "react": "^18", "react-dom": "^18" }, diff --git a/examples/nextjs-app-router/src/funnel.tsx b/examples/nextjs-app-router/src/funnel.tsx index d0afc2c..62f07ae 100644 --- a/examples/nextjs-app-router/src/funnel.tsx +++ b/examples/nextjs-app-router/src/funnel.tsx @@ -1,19 +1,23 @@ 'use client'; -import { useFunnel } from '@use-funnel/browser'; +import { useFunnel } from '@use-funnel/next-app-router'; +import { useRouter } from 'next/navigation'; export const TestAppRouterFunnel = () => { const funnel = useFunnel({ id: FUNNEL_ID, initial: { step: 'start', context: {} } }); + const router = useRouter(); return ( - ( -
-

start

- - -
- )} - end={() =>
end
} - /> + <> + ( +
+

start

+ +
+ )} + end={() =>
end
} + /> + + ); }; diff --git a/examples/nextjs-app-router/src/overlay/OverlayCaseFunnel.tsx b/examples/nextjs-app-router/src/overlay/OverlayCaseFunnel.tsx new file mode 100644 index 0000000..a1d34f3 --- /dev/null +++ b/examples/nextjs-app-router/src/overlay/OverlayCaseFunnel.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { useFunnel } from '@use-funnel/next-app-router'; +import { SchoolInput } from './SchoolInput'; +import { StartDate } from './StartDate'; + +export const OverlayFunnel = () => { + const funnel = useFunnel<{ + SelectSchool: { school?: string }; + StartDate: { school: string; startDate?: string }; + Confirm: { school: string; startDate: string }; + }>({ id: 'general', initial: { context: {}, step: 'SelectSchool' } }); + + return ( + history.push('StartDate', { school: school })} />} + StartDate={funnel.Render.overlay({ + render: ({ history, context }) => ( + history.push('Confirm', { school: context.school, startDate: startDate })} + /> + ), + })} + Confirm={({ context }) => ( +
+
school: {context.school}
+
startDate: {context.startDate}
+
+ )} + /> + ); +}; diff --git a/examples/nextjs-app-router/src/overlay/SchoolInput.tsx b/examples/nextjs-app-router/src/overlay/SchoolInput.tsx new file mode 100644 index 0000000..07148e1 --- /dev/null +++ b/examples/nextjs-app-router/src/overlay/SchoolInput.tsx @@ -0,0 +1,18 @@ +import { useState } from 'react'; + +interface Props { + onNext: (school: string) => void; +} + +export function SchoolInput({ onNext }: Props) { + const [school, setSchool] = useState('A'); + return ( +
+

Select Your School

+ setSchool(e.target.value)} /> + setSchool(e.target.value)} /> + setSchool(e.target.value)} /> + +
+ ); +} diff --git a/examples/nextjs-app-router/src/overlay/StartDate.tsx b/examples/nextjs-app-router/src/overlay/StartDate.tsx new file mode 100644 index 0000000..a376494 --- /dev/null +++ b/examples/nextjs-app-router/src/overlay/StartDate.tsx @@ -0,0 +1,12 @@ +import { ReactNode, useState } from 'react'; + +export const StartDate = ({ startDate, onNext }: { startDate?: string; onNext: (startDate: string) => void }) => { + const [date, setDate] = useState(startDate ?? ''); + + return ( +
+ setDate(e.target.value)} /> + +
+ ); +}; diff --git a/examples/nextjs-app-router/src/providers.tsx b/examples/nextjs-app-router/src/providers.tsx new file mode 100644 index 0000000..3aa771d --- /dev/null +++ b/examples/nextjs-app-router/src/providers.tsx @@ -0,0 +1,5 @@ +'use client'; +import { OverlayProvider } from 'overlay-kit'; +export const Providers = ({ children }: { children: React.ReactNode }) => { + return {children}; +}; diff --git a/packages/next-app-router/package.json b/packages/next-app-router/package.json new file mode 100644 index 0000000..33148be --- /dev/null +++ b/packages/next-app-router/package.json @@ -0,0 +1,65 @@ +{ + "name": "@use-funnel/next-app-router", + "version": "0.0.0", + "description": "", + "type": "module", + "main": "./dist/index.js", + "publishConfig": { + "access": "public", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "module": "./dist/index.js", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + } + }, + "files": [ + "dist", + "package.json" + ], + "scripts": { + "test": "vitest run", + "test:unit": "vitest --root test/", + "build": "rimraf dist && concurrently \"pnpm:build:*\"", + "build:dist": "tsup", + "build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly", + "prepublish": "pnpm test && pnpm build" + }, + "keywords": [], + "author": "", + "repository": { + "type": "git", + "url": "https://github.com/toss/use-funnel.git", + "directory": "packages/next-app-router" + }, + "license": "MIT", + "homepage": "https://use-funnel.slash.page/", + "bugs": "https://github.com/toss/use-funnel/issues", + "dependencies": { + "@use-funnel/core": "workspace:^" + }, + "devDependencies": { + "@testing-library/react": "^15.0.7", + "@testing-library/user-event": "^14.5.2", + "@types/react": "^18.3.2", + "@types/react-dom": "^18.3.0", + "concurrently": "^8.2.2", + "globals": "^15.3.0", + "jsdom": "^24.1.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "rimraf": "^5.0.7", + "tsup": "^8.0.2", + "typescript": "^5.1.6", + "vitest": "^1.6.0" + }, + "peerDependencies": { + "next": ">=13", + "react": ">=18.2" + }, + "sideEffects": false +} diff --git a/packages/next-app-router/src/index.ts b/packages/next-app-router/src/index.ts new file mode 100644 index 0000000..335bddb --- /dev/null +++ b/packages/next-app-router/src/index.ts @@ -0,0 +1,90 @@ +'use client'; +import { createUseFunnel } from '@use-funnel/core'; +import { useSearchParams } from 'next/navigation'; +import { useLayoutEffect, useMemo, useState } from 'react'; + +export const useFunnel = createUseFunnel(({ id, initialState }) => { + const searchParams = useSearchParams(); + const [state, setState] = useState>({}); + useLayoutEffect(() => { + if (typeof window !== 'undefined') { + setState(window.history.state); + } + + function handlePopState(event: PopStateEvent) { + setState(event.state); + } + window.addEventListener('popstate', handlePopState); + return () => { + window.removeEventListener('popstate', handlePopState); + }; + }, []); + + const currentStep = searchParams.get(`${id}.step`); + const currentContext = state?.[`${id}.context`]; + + const currentState = useMemo(() => { + return currentStep != null && currentContext != null + ? ({ + step: currentStep, + context: currentContext, + } as typeof initialState) + : initialState; + }, [currentStep, currentContext, initialState]); + + const history: (typeof initialState)[] = useMemo( + () => state?.[`${id}.histories`] ?? [currentState], + [state, currentState], + ); + + const currentIndex = history.length - 1; + return useMemo( + () => ({ + history, + currentIndex, + currentState, + push(newState) { + const newSearchParams = new URLSearchParams(searchParams); + newSearchParams.set(`${id}.step`, newState.step); + const newHistoryState = { + ...state, + [`${id}.context`]: newState.context, + [`${id}.histories`]: [...(history ?? []), newState], + }; + window.history.pushState(newHistoryState, '', `?${newSearchParams.toString()}`); + + setState((prevHistoryState) => { + const newHistoryState = { + ...prevHistoryState, + [`${id}.context`]: newState.context, + [`${id}.histories`]: [...(history ?? []), newState], + }; + return newHistoryState; + }); + }, + replace(newState) { + const newSearchParams = new URLSearchParams(searchParams); + newSearchParams.set(`${id}.step`, newState.step); + const newHistoryState = { + ...state, + [`${id}.context`]: newState.context, + [`${id}.histories`]: [...(history ?? []), newState], + }; + window.history.replaceState(newHistoryState, '', `?${newSearchParams.toString()}`); + + setState((prevHistoryState) => { + const newHistoryState = { + ...prevHistoryState, + [`${id}.context`]: newState.context, + [`${id}.histories`]: [...(history ?? []), newState], + }; + return newHistoryState; + }); + }, + go(index) { + window.history.go(index); + }, + }), + [history, currentIndex, currentState, searchParams, id, state], + ); +}); diff --git a/packages/next-app-router/test/index.test.tsx b/packages/next-app-router/test/index.test.tsx new file mode 100644 index 0000000..792b798 --- /dev/null +++ b/packages/next-app-router/test/index.test.tsx @@ -0,0 +1,55 @@ +import { render, screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import { describe, expect, test } from 'vitest'; + +import { useFunnel } from '../src/index.js'; + +describe('Test useFunnel next-app-router router', () => { + test('should work', async () => { + function FunnelTest() { + const funnel = useFunnel<{ + A: { id?: string }; + B: { id: string }; + }>({ + id: 'vitest', + initial: { + step: 'A', + context: {}, + }, + }); + switch (funnel.step) { + case 'A': { + return ; + } + case 'B': { + return ( +
+ +
{funnel.context.id}
+
+ ); + } + default: { + throw new Error('Invalid step'); + } + } + } + + render(); + + expect(screen.queryByText('Go B')).not.toBeNull(); + + const user = userEvent.setup(); + await user.click(screen.getByText('Go B')); + + expect(screen.queryByText('vitest')).not.toBeNull(); + await user.click(screen.getByText('Go Back')); + + expect(screen.queryByText('vitest')).toBeNull(); + expect(screen.queryByText('Go B')).not.toBeNull(); + }); + + test('hello' , async () => { + + }) +}); diff --git a/packages/next-app-router/tsconfig.build.json b/packages/next-app-router/tsconfig.build.json new file mode 100644 index 0000000..2772261 --- /dev/null +++ b/packages/next-app-router/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["test"] +} diff --git a/packages/next-app-router/tsconfig.json b/packages/next-app-router/tsconfig.json new file mode 100644 index 0000000..f04f541 --- /dev/null +++ b/packages/next-app-router/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "module": "esnext", + "target": "esnext", + "moduleResolution": "bundler", + "allowJs": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "experimentalDecorators": true, + "jsx": "react-jsx", + "strict": true, + "resolveJsonModule": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "declaration": true, + "outDir": "dist" + }, + "include": ["src", "test"] +} diff --git a/packages/next-app-router/tsup.config.js b/packages/next-app-router/tsup.config.js new file mode 100644 index 0000000..a6b9aca --- /dev/null +++ b/packages/next-app-router/tsup.config.js @@ -0,0 +1,6 @@ +import { defineConfig } from 'tsup' + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm'], +}) diff --git a/packages/next-app-router/vitest.config.js b/packages/next-app-router/vitest.config.js new file mode 100644 index 0000000..93b8cf9 --- /dev/null +++ b/packages/next-app-router/vitest.config.js @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; +export default defineConfig({ + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['vitestSetup.ts'], + }, +}); diff --git a/packages/next-app-router/vitestSetup.ts b/packages/next-app-router/vitestSetup.ts new file mode 100644 index 0000000..0ba2a2a --- /dev/null +++ b/packages/next-app-router/vitestSetup.ts @@ -0,0 +1,36 @@ +import { beforeEach, beforeAll, vi, afterEach } from 'vitest'; + +let currentUrl = new URL('http://localhost/'); + +export const setMockUrl = (url: string) => { + currentUrl = new URL(url); +}; + +export const mockSearchParamsGet = vi.fn((param: string) => { + return currentUrl.searchParams.get(param); +}); + +beforeAll(() => { + Object.defineProperty(window, 'location', { + value: { + href: currentUrl.href, + search: currentUrl.search, + }, + writable: true, + }); + + vi.mock('next/navigation', () => ({ + useSearchParams: () => ({ + get: mockSearchParamsGet, + }), + })); +}); + +beforeEach(() => { + setMockUrl('http://localhost/'); + vi.resetAllMocks(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ccd357..f7750a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -108,15 +108,18 @@ importers: examples/nextjs-app-router: dependencies: - '@use-funnel/browser': - specifier: workspace:^ - version: link:../../packages/browser '@use-funnel/core': specifier: workspace:^ version: link:../../packages/core + '@use-funnel/next-app-router': + specifier: workspace:^ + version: link:../../packages/next-app-router next: specifier: 14.2.13 version: 14.2.13(@playwright/test@1.47.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + overlay-kit: + specifier: ^1.4.1 + version: 1.4.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: specifier: ^18 version: 18.3.1 @@ -329,6 +332,55 @@ importers: specifier: ^1.6.0 version: 1.6.0(@types/node@20.14.9)(jsdom@24.1.0)(terser@5.31.1) + packages/next-app-router: + dependencies: + '@use-funnel/core': + specifier: workspace:^ + version: link:../core + next: + specifier: '>=13' + version: 14.2.13(@playwright/test@1.47.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + devDependencies: + '@testing-library/react': + specifier: ^15.0.7 + version: 15.0.7(@types/react@18.3.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@testing-library/user-event': + specifier: ^14.5.2 + version: 14.5.2(@testing-library/dom@10.1.0) + '@types/react': + specifier: ^18.3.2 + version: 18.3.2 + '@types/react-dom': + specifier: ^18.3.0 + version: 18.3.0 + concurrently: + specifier: ^8.2.2 + version: 8.2.2 + globals: + specifier: ^15.3.0 + version: 15.3.0 + jsdom: + specifier: ^24.1.0 + version: 24.1.0 + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + rimraf: + specifier: ^5.0.7 + version: 5.0.7 + tsup: + specifier: ^8.0.2 + version: 8.0.2(postcss@8.4.38)(ts-node@10.9.2(@types/node@20.14.9)(typescript@5.4.5))(typescript@5.4.5) + typescript: + specifier: ^5.1.6 + version: 5.4.5 + vitest: + specifier: ^1.6.0 + version: 1.6.0(@types/node@20.14.9)(jsdom@24.1.0)(terser@5.31.1) + packages/react-navigation-native: dependencies: '@use-funnel/core': @@ -5653,6 +5705,12 @@ packages: outvariant@1.4.0: resolution: {integrity: sha512-AlWY719RF02ujitly7Kk/0QlV+pXGFDHrHf9O2OKqyqgBieaPOIeuSkL8sRK6j2WK+/ZAURq2kZsY0d8JapUiw==} + overlay-kit@1.4.1: + resolution: {integrity: sha512-BSzpilw1pM1j6/r04Qwc/FHnWe0Hio0qbhdepJuihx352q+n/FPUAr297MqjKanniH722VDPxFdq7JFjErIXdQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + p-filter@2.1.0: resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==} engines: {node: '>=8'} @@ -14191,6 +14249,12 @@ snapshots: outvariant@1.4.0: {} + overlay-kit@1.4.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + use-sync-external-store: 1.2.2(react@18.3.1) + p-filter@2.1.0: dependencies: p-map: 2.1.0