diff --git a/.gitignore b/.gitignore index fd3dbb5..2d6fcba 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +src/app/(sb) \ No newline at end of file diff --git a/.storybook/main.ts b/.storybook/main.ts index 429a3ad..dd924cb 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -1,7 +1,7 @@ import type { StorybookConfig } from '@storybook/nextjs-server'; const config: StorybookConfig = { - stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + stories: ['../src/page-stories/*.stories.@(js|jsx|mjs|ts|tsx)'], addons: [ '@storybook/addon-onboarding', '@storybook/addon-links', diff --git a/src/app/StoryForm.tsx b/src/app/StoryForm.tsx new file mode 100644 index 0000000..7a29d0d --- /dev/null +++ b/src/app/StoryForm.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { usePathname, useSearchParams } from 'next/navigation'; + +export const StoryForm = ({ saveStory }: { saveStory: (url: string) => Promise }) => { + const url = `${usePathname()}?${useSearchParams()}`; + return ( +
+ Save current route + +
+ ); +}; diff --git a/src/app/StorybookModal.tsx b/src/app/StorybookModal.tsx new file mode 100644 index 0000000..3785ed0 --- /dev/null +++ b/src/app/StorybookModal.tsx @@ -0,0 +1,80 @@ +import Link from 'next/link'; +import { storyIndex } from '../storyIndex'; +import { getLastRequestMockData } from '../mock'; +import { StoryForm } from './StoryForm'; +import { URL } from 'url'; +import { readFile, writeFile } from 'fs/promises'; + +export function StorybookModal({}) { + async function saveStory(url: string) { + 'use server'; + + const $mock = getLastRequestMockData(); + + // TODO this code is obviously super custom + const { pathname, searchParams } = new URL(url, 'https://whatever.com'); + const sort = searchParams.get('sort'); + const $url = { + number: pathname.split('/').at(-1), + ...(sort && { sort }), + }; + + const title = 'Pages / Build'; + const name = 'New Build'; + const exportName = 'NewBuild'; + const id = 'pages-build--new-build'; + + const story = ` + export const ${exportName} = { + args: { + $url: ${JSON.stringify($url)}, + $mock: ${JSON.stringify($mock)}, + }, + }; + `; + + const csfFile = './src/page-stories/build.stories.ts'; + const csfContents = (await readFile(csfFile)).toString('utf-8'); + await writeFile(csfFile, `${csfContents}\n\n${story}`); + + const indexEntry = ` + '${id}': { + title: '${title}', + name: '${name}', + csf: stories, + key: '${exportName}', + }, + `; + + const indexFile = './src/storyIndex.ts'; + const indexContents = (await readFile(indexFile)).toString('utf8'); + await writeFile(indexFile, indexContents.replace('};', `${indexEntry}\n};`)); + } + + return ( +
+ +
+ ); +} diff --git a/src/app/builds/[number]/page.tsx b/src/app/builds/[number]/page.tsx new file mode 100644 index 0000000..27c9a2b --- /dev/null +++ b/src/app/builds/[number]/page.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +import { getBuild } from '@/getBuild'; + +export default async function Build({ + params: { number }, + searchParams: { sort = 'asc' }, +}: { + params: { number: string }; + searchParams: { [key: string]: string | string[] | undefined }; +}) { + return ( +
+
+        Build page, build number: {number}, sort: {sort} Build data:{' '}
+        {JSON.stringify(await getBuild(number))}
+      
+
+ ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 4ff5d3c..bc3a630 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from 'next'; import { Inter } from 'next/font/google'; +import { StorybookModal } from './StorybookModal'; // import "./globals.css"; const inter = Inter({ subsets: ['latin'] }); @@ -16,7 +17,11 @@ export default function RootLayout({ }>) { return ( - {children} + + + + {children} + ); } diff --git a/src/data/__mock__/getBuild.ts b/src/data/__mock__/getBuild.ts new file mode 100644 index 0000000..6f56e6a --- /dev/null +++ b/src/data/__mock__/getBuild.ts @@ -0,0 +1,4 @@ +import { mockFn } from '../../mock'; +import { getBuild as getBuildOriginal } from '../getBuild'; + +export const getBuild = mockFn(getBuildOriginal, 'getBuild'); diff --git a/src/data/getBuild.ts b/src/data/getBuild.ts new file mode 100644 index 0000000..ff09e0b --- /dev/null +++ b/src/data/getBuild.ts @@ -0,0 +1,7 @@ +export async function getBuild(number: number) { + return { + number, + name: `Build #${number}`, + status: 'PASSED', + }; +} diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..a465ef3 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { composeStory } from '@storybook/react'; +import { storyIndex } from './storyIndex'; + +const sessionIdCookieName = '__storybookSessionId__'; +const storyCookieName = '__storyId__'; + +export function storyMiddleware(request: NextRequest) { + const storyId = request.url.split('/').at(-1); + + if (!storyId) throw new Error('no storyId'); + if (!(storyId in storyIndex)) throw new Error(`unknown storyId: '${storyId}`); + + const data = storyIndex[storyId as keyof typeof storyIndex]; + + const { args } = composeStory( + // @ts-expect-error fix types + data.csf[data.key], + data.csf.default, + {}, + data.key + ); + + // TODO compose stories doesn't handle URLs (we could use parameters temporarily?) + const { url } = data.csf.default; + + // TODO make this bit generic + const newUrl = + url.replace('[number]', (args.$url?.number || '').toString()) + + (args.$url.sort ? `?sort=${args.$url.sort}` : ''); + + const response = NextResponse.redirect(new URL(newUrl, request.url)); + response.cookies.set(storyCookieName, storyId); + return response; +} + +export function middleware(request: NextRequest) { + if (request.nextUrl.pathname.startsWith('/storybook-redirect/')) { + return storyMiddleware(request); + } + + const sessionCookie = request.cookies.get(sessionIdCookieName); + const sessionId = sessionCookie?.value || Math.random().toString(); + + // Clone the request headers and set a new header `x-hello-from-middleware1` + const requestHeaders = new Headers(request.headers); + requestHeaders.set(sessionIdCookieName, sessionId); + + const response = NextResponse.next({ + request: { + // New request headers + headers: requestHeaders, + }, + }); + + if (!sessionCookie) { + response.cookies.set(sessionIdCookieName, sessionId); + } + return response; +} diff --git a/src/mock.ts b/src/mock.ts new file mode 100644 index 0000000..3de1cb2 --- /dev/null +++ b/src/mock.ts @@ -0,0 +1,82 @@ +import { cookies, headers } from 'next/headers'; +// @ts-expect-error wrong react version +import { cache } from 'react'; +import { storyIndex } from './storyIndex'; +import { composeStory } from '@storybook/react'; + +const getStoryMockData = cache(() => { + const storyId = cookies().get('__storyId__')?.value; + console.log(`Getting mock data for '${storyId}'`); + + if (!storyId) return null; + if (!(storyId in storyIndex)) throw new Error(`unknown storyId: '${storyId}`); + + const data = storyIndex[storyId as keyof typeof storyIndex]; + + const { args } = composeStory( + // @ts-expect-error fix types + data.csf[data.key], + data.csf.default, + {}, + data.key + ); + + return args.$mock; +}); + +type MockData = { [key: string]: any }; +const lastRequestMockDataPerSession: Record = {}; + +const getSessionId = () => { + const sessionId = headers().get('__storybookSessionId__'); + if (!sessionId) throw new Error('Unknown sessionId'); + + return sessionId; +}; + +export function getLastRequestMockData(): MockData | void { + return lastRequestMockDataPerSession[getSessionId()]; +} + +const getRequestMockData = cache(() => ({}) as MockData); +const setSessionMockDataKey = (key: string, data: any) => { + const sessionId = getSessionId(); + + const requestMockData = getRequestMockData(); + requestMockData[key] = data; + lastRequestMockDataPerSession[sessionId] = requestMockData; +}; + +type Exports = Record; +export function mockFn any>( + original: TFunction, + mockKey: string +) { + return async (...args: Parameters) => { + const mockData = getStoryMockData(); + + if (mockData?.[mockKey]) { + return mockData[mockKey]; + } + + const data = await original(...args); + setSessionMockDataKey(mockKey, data); + return data; + }; +} + +export function mockModule(original: Exports, mockKey: string) { + return Object.fromEntries( + Object.entries(original).map(([exportName, exportValue]) => { + if (!(exportValue instanceof Function)) { + console.warn( + `We don't currently handle non-functional exports for ${exportName} export of ${mockKey} mock, returning original value` + ); + return [exportName, exportValue]; + } + + // TODO: should we instead assume the mock is an object + return [exportName, mockFn(exportValue, `${mockKey}.${exportName}`)]; + }) + ); +} diff --git a/src/page-stories/build.stories.ts b/src/page-stories/build.stories.ts new file mode 100644 index 0000000..4bc746a --- /dev/null +++ b/src/page-stories/build.stories.ts @@ -0,0 +1,35 @@ +const meta = { + title: 'Pages/Build', + url: '/builds/[number]', +}; + +export default meta; + +export const BuildSix = { + args: { + $url: { + number: 6, + }, + $mock: { + getBuild: { + number: 6, + status: 'ACCEPTED', + }, + }, + }, +}; + +export const BuildSevenDesc = { + args: { + $url: { + number: 7, + sort: 'desc', + }, + $mock: { + getBuild: { + number: 7, + status: 'IN_PROGRESS', + }, + }, + }, +}; diff --git a/src/storyIndex.ts b/src/storyIndex.ts new file mode 100644 index 0000000..cc43040 --- /dev/null +++ b/src/storyIndex.ts @@ -0,0 +1,16 @@ +import * as stories from './page-stories/build.stories'; + +export const storyIndex = { + 'pages-build--build-six': { + title: 'Pages / Build', + name: 'Build Six', + csf: stories, + key: 'BuildSix', + }, + 'pages-build--build-seven-desc': { + title: 'Pages / Build', + name: 'Build Seven Desc', + csf: stories, + key: 'BuildSevenDesc', + }, +}; diff --git a/tsconfig.json b/tsconfig.json index 7b28589..24087d0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,7 @@ } ], "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/data/__mock__/*", "./src/data/*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],