Skip to content

Commit

Permalink
Merge pull request #10 from storybookjs/kasper/redirect-boundary
Browse files Browse the repository at this point in the history
Bunch of fixes and improvements
  • Loading branch information
kasperpeulen committed May 15, 2024
2 parents f50ac23 + 9eee515 commit e82fca8
Show file tree
Hide file tree
Showing 38 changed files with 1,317 additions and 902 deletions.
2 changes: 1 addition & 1 deletion .storybook/decorators.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Decorator } from '@storybook/react'
import { Layout } from 'app/layout'
import { Layout } from '#app/layout'

export const PageDecorator: Decorator = (Story) => {
return (
Expand Down
1 change: 0 additions & 1 deletion .storybook/main.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { StorybookConfig } from '@storybook/nextjs'
import * as path from 'node:path'

const config: StorybookConfig = {
stories: ['../docs/**/*.mdx', '../**/*.stories.@(js|jsx|mjs|ts|tsx)'],
Expand Down
22 changes: 14 additions & 8 deletions .storybook/preview.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import '../app/style.css'
import { resetMockDB } from '#lib/db.mock'
import type { Preview } from '@storybook/react'
import { onMockCall } from '@storybook/test'
import MockDate from 'mockdate'
import { initialize, mswLoader } from 'msw-storybook-addon'
import * as MockDate from 'mockdate'
import { initializeDB } from '#lib/db.mock'

onMockCall((spy, args) => {
console.log(spy.name, args)
})
initialize({ onUnhandledRequest: 'bypass' })

const preview: Preview = {
parameters: {
Expand All @@ -16,11 +14,19 @@ const preview: Preview = {
date: /Date$/i,
},
},
test: {
// This is needed until Next will update to the React 19 beta: https://github.com/vercel/next.js/pull/65058
// In the React 19 beta ErrorBoundary errors (such as redirect) are only logged, and not thrown.
dangerouslyIgnoreUnhandledErrors: true,
},
nextjs: { appDirectory: true },
},
loaders: [mswLoader],
beforeEach() {
MockDate.set('2024-05-04T14:00:00.000Z')
resetMockDB()
// Fixed dates for consistent screenshots
MockDate.set('2024-04-18T12:24:02Z')
// reset the database to avoid hanging state between stories
initializeDB()
},
}

Expand Down
30 changes: 17 additions & 13 deletions .storybook/test-runner.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,32 @@
import { TestRunnerConfig, getStoryContext } from '@storybook/test-runner';
import { MINIMAL_VIEWPORTS } from '@storybook/addon-viewport';
import { TestRunnerConfig, getStoryContext } from '@storybook/test-runner'
import { MINIMAL_VIEWPORTS } from '@storybook/addon-viewport'

const DEFAULT_VIEWPORT_SIZE = { width: 1280, height: 720 };
const DEFAULT_VIEWPORT_SIZE = { width: 1280, height: 720 }

const config: TestRunnerConfig = {
async preVisit(page, story) {
const context = await getStoryContext(page, story);
const viewportName = context.parameters?.viewport?.defaultViewport;
const viewportParameter = MINIMAL_VIEWPORTS[viewportName];
const context = await getStoryContext(page, story)
const viewportName = context.parameters?.viewport?.defaultViewport
const viewportParameter = MINIMAL_VIEWPORTS[viewportName]

if (viewportParameter) {
if (
viewportParameter &&
viewportParameter.styles &&
typeof viewportParameter.styles === 'object'
) {
const viewportSize = Object.entries(viewportParameter.styles).reduce(
(acc, [screen, size]) => ({
...acc,
// make sure your viewport config in Storybook only uses numbers, not percentages
[screen]: parseInt(size),
}),
{}
);
{} as { width: number; height: number },
)

page.setViewportSize(viewportSize);
page.setViewportSize(viewportSize)
} else {
page.setViewportSize(DEFAULT_VIEWPORT_SIZE);
page.setViewportSize(DEFAULT_VIEWPORT_SIZE)
}
},
};
export default config;
}
export default config
1 change: 1 addition & 0 deletions app/actions.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ import * as actions from './actions'
export const saveNote = fn(actions.saveNote).mockName('saveNote')
export const deleteNote = fn(actions.deleteNote).mockName('deleteNote')
export const logout = fn(actions.logout).mockName('logout')
export const login = fn(actions.login).mockName('login')
25 changes: 14 additions & 11 deletions app/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { redirect } from 'next/navigation'
import { getUserFromSession } from '#lib/session'

export async function saveNote(
noteId: string | undefined,
noteId: number | undefined,
title: string,
body: string,
) {
Expand All @@ -16,28 +16,25 @@ export async function saveNote(
if (!user) {
redirect('/')
}

if (!noteId) {
noteId = Date.now().toString()
}

const payload = {
id: noteId,
title: title.slice(0, 255),
body: body.slice(0, 2048),
createdBy: user,
}

await db.note.upsert({
if (!noteId) {
const newNote = await db.note.create({ data: payload })
redirect(`/note/${newNote.id}`)
}
await db.note.update({
where: { id: noteId },
update: payload,
create: payload,
data: payload,
})

redirect(`/note/${noteId}`)
}

export async function deleteNote(noteId: string) {
export async function deleteNote(noteId: number) {
await db.note.delete({
where: {
id: noteId,
Expand All @@ -53,3 +50,9 @@ export async function logout() {

redirect('/')
}

export async function login() {
redirect(
`https://github.com/login/oauth/authorize?client_id=${process.env.OAUTH_CLIENT_KEY}&allow_signup=false`,
)
}
57 changes: 57 additions & 0 deletions app/auth/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { redirect } from 'next/navigation'
import { cookies } from 'next/headers'
import { createUserCookie, userCookieKey } from '#lib/session'

const CLIENT_ID = process.env.OAUTH_CLIENT_KEY
const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET

export async function GET(request: Request) {
const code = new URL(request.url).searchParams.get('code')

let token = ''
try {
const data = await (
await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
body: JSON.stringify({
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
code,
}),
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
})
).json()

const accessToken = data.access_token

// Let's also fetch the user info and store it in the session.
if (accessToken) {
const userInfo = await (
await fetch('https://api.github.com/user', {
method: 'GET',
headers: {
Authorization: `token ${accessToken}`,
Accept: 'application/json',
},
})
).json()

token = userInfo.login
}
} catch (err: any) {
console.error(err)
redirect('/')
}

if (!token) {
console.error('Github authorization failed')
redirect('/')
}

const cookieValue = await createUserCookie(token)
cookies().set(userCookieKey, `${cookieValue}; Secure; HttpOnly`)
redirect('/')
}
83 changes: 72 additions & 11 deletions app/note/[id]/page.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,36 +1,43 @@
import { Meta, StoryObj } from '@storybook/react'
import { useSearchParams } from '@storybook/nextjs/navigation.mock'
import { cookies } from '@storybook/nextjs/headers.mock'
import Page from '#app/note/[id]/page'
import { db } from '#lib/db'
import { http } from 'msw'
import { expect, userEvent, waitFor, within } from '@storybook/test'
import Page from './page'
import { db, initializeDB } from '#lib/db.mock'
import { createUserCookie, userCookieKey } from '#lib/session'
import { PageDecorator } from '#.storybook/decorators'
import { login } from '#app/actions.mock'
import * as auth from '#app/auth/route'

const meta = {
component: Page,
parameters: { layout: 'fullscreen' },
decorators: [PageDecorator],
async beforeEach() {
await db.note.create({
data: {
id: '1',
title: 'Module mocking in Storybook?',
body: "Yup, that's a thing now! 🎉",
createdBy: 'storybookjs',
},
})
await db.note.create({
data: {
id: '2',
title: 'RSC support as well??',
body: 'RSC is pretty cool, even cooler that Storybook supports it!',
createdBy: 'storybookjs',
},
})
},
args: {
params: { id: '2' },
parameters: {
layout: 'fullscreen',
nextjs: {
navigation: {
pathname: '/note/[id]',
query: { id: '1' },
},
},
},
args: { params: { id: '1' } },
} satisfies Meta<typeof Page>

export default meta
Expand All @@ -45,14 +52,68 @@ export const LoggedIn: Story = {

export const NotLoggedIn: Story = {}

export const WithSearchFilter: Story = {
export const LoginShouldGetOAuthTokenAndSetCookie: Story = {
parameters: {
msw: {
// Mock out OAUTH
handlers: [
http.post(
'https://github.com/login/oauth/access_token',
async ({ request }) => {
let json = (await request.json()) as any
return Response.json({ access_token: json.code })
},
),
http.get('https://api.github.com/user', async ({ request }) =>
Response.json({
login: request.headers.get('Authorization')?.replace('token ', ''),
}),
),
],
},
},
beforeEach() {
useSearchParams.mockReturnValue({ get: () => 'RSC' })
// Point the login implementation to the endpoint github would have redirected too.
login.mockImplementation(async () => {
return await auth.GET(new Request('/auth?code=storybookjs'))
})
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
await expect(cookies().get(userCookieKey)?.value).toBeUndefined()
await userEvent.click(
await canvas.findByRole('menuitem', { name: /login to add/i }),
)
await waitFor(async () => {
await expect(cookies().get(userCookieKey)?.value).toContain('storybookjs')
})
},
}

export const LogoutShouldDeleteCookie: Story = {
async beforeEach() {
cookies().set(userCookieKey, await createUserCookie('storybookjs'))
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
await expect(cookies().get(userCookieKey)?.value).toContain('storybookjs')
await userEvent.click(await canvas.findByRole('button', { name: 'logout' }))
await expect(cookies().get(userCookieKey)).toBeUndefined()
},
}

export const SearchInputShouldFilterNotes: Story = {
parameters: {
nextjs: {
navigation: {
query: { q: 'RSC' },
},
},
},
}

export const EmptyState: Story = {
async beforeEach() {
await db.note.deleteMany()
initializeDB({}) // init an empty DB
},
}
11 changes: 2 additions & 9 deletions app/note/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,15 @@ import NoteUI from '#components/note-ui'
import { db } from '#lib/db'

export const metadata = {
robots: {
index: false,
},
robots: { index: false },
}

type Props = {
params: { id: string }
}

export default async function Page({ params }: Props) {
const note = await db.note.findUnique({
where: {
id: params.id,
},
})

const note = await db.note.findUnique({ where: { id: Number(params.id) } })
if (note === null) {
return (
<div className="note--empty-state">
Expand Down

0 comments on commit e82fca8

Please sign in to comment.