Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts

src/app/(sb)
2 changes: 1 addition & 1 deletion .storybook/main.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down
13 changes: 13 additions & 0 deletions src/app/StoryForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
'use client';

import { usePathname, useSearchParams } from 'next/navigation';

export const StoryForm = ({ saveStory }: { saveStory: (url: string) => Promise<void> }) => {
const url = `${usePathname()}?${useSearchParams()}`;
return (
<form action={saveStory.bind(null, url)}>
Save current route
<button type="submit">Go</button>
</form>
);
};
80 changes: 80 additions & 0 deletions src/app/StorybookModal.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
style={{
position: 'fixed',
bottom: '20px',
left: '20px',
width: '300px',
height: '200px',
background: '#eee',
}}
>
<ul>
{Object.entries(storyIndex).map(([id, { title, name }]) => (
<li>
<Link href={`/storybook-redirect/${id}`}>
{title}: {name}
</Link>
</li>
))}

<li>
<StoryForm saveStory={saveStory} />
</li>
</ul>
</div>
);
}
20 changes: 20 additions & 0 deletions src/app/builds/[number]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<pre>
Build page, build number: {number}, sort: {sort} Build data:{' '}
{JSON.stringify(await getBuild(number))}
</pre>
</div>
);
}
7 changes: 6 additions & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -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'] });
Expand All @@ -16,7 +17,11 @@ export default function RootLayout({
}>) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
<body className={inter.className}>
<StorybookModal />

{children}
</body>
</html>
);
}
4 changes: 4 additions & 0 deletions src/data/__mock__/getBuild.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { mockFn } from '../../mock';
import { getBuild as getBuildOriginal } from '../getBuild';

export const getBuild = mockFn(getBuildOriginal, 'getBuild');
7 changes: 7 additions & 0 deletions src/data/getBuild.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export async function getBuild(number: number) {
return {
number,
name: `Build #${number}`,
status: 'PASSED',
};
}
60 changes: 60 additions & 0 deletions src/middleware.ts
Original file line number Diff line number Diff line change
@@ -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;
}
82 changes: 82 additions & 0 deletions src/mock.ts
Original file line number Diff line number Diff line change
@@ -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<string, MockData> = {};

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<string, any>;
export function mockFn<TFunction extends (...args: any[]) => any>(
original: TFunction,
mockKey: string
) {
return async (...args: Parameters<TFunction>) => {
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}`)];
})
);
}
35 changes: 35 additions & 0 deletions src/page-stories/build.stories.ts
Original file line number Diff line number Diff line change
@@ -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',
},
},
},
};
16 changes: 16 additions & 0 deletions src/storyIndex.ts
Original file line number Diff line number Diff line change
@@ -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',
},
};
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
}
],
"paths": {
"@/*": ["./src/*"]
"@/*": ["./src/data/__mock__/*", "./src/data/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
Expand Down