-
Notifications
You must be signed in to change notification settings - Fork 24
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(next-app-router) implement an adapter for next-app-router #75
base: main
Are you sure you want to change the base?
Changes from all commits
d890bbf
b25a6b9
aff6f94
c91a77d
f889afa
52df74e
0453ae1
d9144d2
4e7a8a6
6ad8f08
6fe5a4d
f32ddfe
e614027
8ef361b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
import { Suspense } from 'react'; | ||
import { OverlayFunnel } from '../../src/overlay/OverlayCaseFunnel'; | ||
|
||
export default function Page() { | ||
return ( | ||
<Suspense> | ||
<OverlayFunnel />; | ||
</Suspense> | ||
); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 <TestAppRouterFunnel />; | ||
return ( | ||
<Suspense> | ||
<TestAppRouterFunnel /> | ||
</Suspense> | ||
); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 ( | ||
<funnel.Render | ||
SelectSchool={({ history }) => <SchoolInput onNext={(school) => history.push('StartDate', { school: school })} />} | ||
StartDate={funnel.Render.overlay({ | ||
render: ({ history, context }) => ( | ||
<StartDate | ||
startDate={context.startDate} | ||
onNext={(startDate) => history.push('Confirm', { school: context.school, startDate: startDate })} | ||
/> | ||
), | ||
})} | ||
Confirm={({ context }) => ( | ||
<div> | ||
<div>school: {context.school}</div> | ||
<div>startDate: {context.startDate}</div> | ||
</div> | ||
)} | ||
/> | ||
); | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 ( | ||
<div> | ||
<h2>Select Your School</h2> | ||
<input type="radio" value={'A'} checked={school === 'A'} onChange={(e) => setSchool(e.target.value)} /> | ||
<input type="radio" value={'B'} checked={school === 'B'} onChange={(e) => setSchool(e.target.value)} /> | ||
<input type="radio" value={'C'} checked={school === 'C'} onChange={(e) => setSchool(e.target.value)} /> | ||
<button onClick={() => onNext(school)}>school next</button> | ||
</div> | ||
); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 ( | ||
<div> | ||
<input type="date" value={date} onChange={(e) => setDate(e.target.value)} /> | ||
<button onClick={() => onNext(date)}>overlay next</button> | ||
</div> | ||
); | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
'use client'; | ||
import { OverlayProvider } from 'overlay-kit'; | ||
export const Providers = ({ children }: { children: React.ReactNode }) => { | ||
return <OverlayProvider>{children}</OverlayProvider>; | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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": ">=16.8" | ||
}, | ||
"sideEffects": false | ||
} |
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,75 @@ | ||||||||||||||||||||||||||||||||||
'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<Record<string, any>>({}); | ||||||||||||||||||||||||||||||||||
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); | ||||||||||||||||||||||||||||||||||
const newHistoryState = { | ||||||||||||||||||||||||||||||||||
...state, | ||||||||||||||||||||||||||||||||||
[`${id}.context`]: newState.context, | ||||||||||||||||||||||||||||||||||
[`${id}.histories`]: [...(history ?? []), newState], | ||||||||||||||||||||||||||||||||||
}; | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
newSearchParams.set(`${id}.step`, newState.step); | ||||||||||||||||||||||||||||||||||
window.history.pushState(newHistoryState, '', `?${newSearchParams.toString()}`); | ||||||||||||||||||||||||||||||||||
setState(newHistoryState); | ||||||||||||||||||||||||||||||||||
Comment on lines
+48
to
+56
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Instead of removing the dependency on |
||||||||||||||||||||||||||||||||||
}, | ||||||||||||||||||||||||||||||||||
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(newHistoryState); | ||||||||||||||||||||||||||||||||||
}, | ||||||||||||||||||||||||||||||||||
go(index) { | ||||||||||||||||||||||||||||||||||
window.history.go(index); | ||||||||||||||||||||||||||||||||||
}, | ||||||||||||||||||||||||||||||||||
}), | ||||||||||||||||||||||||||||||||||
[history, currentIndex, currentState, searchParams, id, state], | ||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
I think history.state is not to need |
||||||||||||||||||||||||||||||||||
); | ||||||||||||||||||||||||||||||||||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 <button onClick={() => funnel.history.push('B', { id: 'vitest' })}>Go B</button>; | ||
} | ||
case 'B': { | ||
return ( | ||
<div> | ||
<button onClick={() => window.history.back()}>Go Back</button> | ||
<div>{funnel.context.id}</div> | ||
</div> | ||
); | ||
} | ||
default: { | ||
throw new Error('Invalid step'); | ||
} | ||
} | ||
} | ||
|
||
render(<FunnelTest />); | ||
|
||
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 () => { | ||
|
||
}) | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
{ | ||
"extends": "./tsconfig.json", | ||
"exclude": ["test"] | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Next.js version 13 required react version 18.2.0, So it would be nice to version this as well.