Skip to content
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

SvelteKit: Add experimental page and navigation mocking #24795

Merged
merged 33 commits into from
Nov 22, 2023
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
dca97a1
feat: tighter integration with sveltekit
paoloricciuti Nov 9, 2023
0a103b1
fix callbacks for goto, enhance, invalidate, invalidateAll
paoloricciuti Nov 10, 2023
a19c1e4
add tests
paoloricciuti Nov 10, 2023
8bcd99a
Merge branch 'next' into next
paoloricciuti Nov 10, 2023
a42fc78
add check field to update store and write README
paoloricciuti Nov 10, 2023
e184135
add components to files
paoloricciuti Nov 10, 2023
adc9704
add mocks files to files
paoloricciuti Nov 10, 2023
dddc1c9
remove logs + fix afternavigate tests
paoloricciuti Nov 10, 2023
1693051
Merge remote-tracking branch 'upstream/next' into next
paoloricciuti Nov 13, 2023
9cb6088
Merge branch 'next' into next
paoloricciuti Nov 13, 2023
ca7cc86
Merge branch 'next' into next
paoloricciuti Nov 18, 2023
445b8d4
move logic to preview.ts from SvelteDecorator
paoloricciuti Nov 20, 2023
a1ddf3c
Merge branch 'next' into next
paoloricciuti Nov 20, 2023
c686a26
fix CJS to ESM
paoloricciuti Nov 20, 2023
e48e387
Merge branch 'next' into next
paoloricciuti Nov 21, 2023
2db2a5c
Merge branch 'next' into next
JReinhold Nov 21, 2023
2d21b39
Update code/frameworks/sveltekit/README.md
paoloricciuti Nov 21, 2023
63b1b0e
Update code/frameworks/sveltekit/README.md
paoloricciuti Nov 21, 2023
230089a
fix PR comments
paoloricciuti Nov 21, 2023
b542502
Update code/frameworks/sveltekit/src/mocks/app/stores.ts
paoloricciuti Nov 21, 2023
9de928d
better regex handling of links + add experiemental
paoloricciuti Nov 21, 2023
f57a6b8
add actions as default behavior, cleanup decorator
JReinhold Nov 21, 2023
da77256
e2e test for default sveltekit actions
JReinhold Nov 21, 2023
e3def1d
Merge branch 'fix-svelte-renderer-firing-decorator-twice' of github.c…
JReinhold Nov 21, 2023
4784208
fix decorator e2e test in dev
JReinhold Nov 21, 2023
3b7ee70
Merge branch 'next' into next
paoloricciuti Nov 22, 2023
b274849
Merge branch 'next' into next
JReinhold Nov 22, 2023
967f685
make parameter types public
JReinhold Nov 22, 2023
9593add
fix types for SveltekitParameters
paoloricciuti Nov 22, 2023
9e1bad0
remove afterNavigate import
paoloricciuti Nov 22, 2023
70ceaaf
Merge branch 'next' into next
paoloricciuti Nov 22, 2023
d86a2a4
Merge branch 'next' into next
paoloricciuti Nov 22, 2023
62e7fbb
Merge branch 'next' of github.com:storybookjs/storybook into paoloric…
JReinhold Nov 22, 2023
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
49 changes: 46 additions & 3 deletions code/frameworks/sveltekit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@ However SvelteKit has some [Kit-specific modules](https://kit.svelte.dev/docs/mo
| **Module** | **Status** | **Note** |
| ---------------------------------------------------------------------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| [`$app/environment`](https://kit.svelte.dev/docs/modules#$app-environment) | ✅ Supported | `version` is always empty in Storybook. |
| [`$app/forms`](https://kit.svelte.dev/docs/modules#$app-forms) | ⏳ Future | Will use mocks. Tracked in [#20999](https://github.com/storybookjs/storybook/issues/20999) |
| [`$app/navigation`](https://kit.svelte.dev/docs/modules#$app-navigation) | ⏳ Future | Will use mocks. Tracked in [#20999](https://github.com/storybookjs/storybook/issues/20999) |
| [`$app/forms`](https://kit.svelte.dev/docs/modules#$app-forms) | ✅ Supported | See [How to mock](#how-to-mock) |
| [`$app/navigation`](https://kit.svelte.dev/docs/modules#$app-navigation) | ✅ Supported | See [How to mock](#how-to-mock) |
| [`$app/paths`](https://kit.svelte.dev/docs/modules#$app-paths) | ✅ Supported | Requires SvelteKit 1.4.0 or newer |
| [`$app/stores`](https://kit.svelte.dev/docs/modules#$app-stores) | ✅ Supported | Mocks planned, so you can set different store values per story. |
| [`$app/stores`](https://kit.svelte.dev/docs/modules#$app-stores) | ✅ Supported | See [How to mock](#how-to-mock) |
| [`$env/dynamic/private`](https://kit.svelte.dev/docs/modules#$env-dynamic-private) | ⛔ Not supported | They are meant to only be available server-side, and Storybook renders all components on the client. |
| [`$env/dynamic/public`](https://kit.svelte.dev/docs/modules#$env-dynamic-public) | 🚧 Partially supported | Only supported in development mode. Storybook is built as a static app with no server-side API so cannot dynamically serve content. |
| [`$env/static/private`](https://kit.svelte.dev/docs/modules#$env-static-private) | ⛔ Not supported | They are meant to only be available server-side, and Storybook renders all components on the client. |
Expand Down Expand Up @@ -125,3 +125,46 @@ You'll experience this if anything in your story is importing from `$app/forms`
## Acknowledgements

Integrating with SvelteKit would not have been possible if it weren't for the fantastic efforts by the Svelte core team - especially [Ben McCann](https://twitter.com/benjaminmccann) - to make integrations with the wider ecosystem possible.

## How to mock

To mock a SvelteKit import you can make use of the `parameters.sveltekit` object either on the `Story`, on the `Template` or on the `Meta`

```ts
export const MyStory = {
parameters: {
sveltekit: {
stores: {
page: {
data: {
test: 'passed',
},
},
navigating: {
route: {
id: '/storybook',
},
},
updated: true,
},
},
},
};
```

on this object you can add the name of the module you are mocking (in the example above we are mocking the `stores` module which correspond to `$app/stores`) and than pass the following kind of objects

| Module | Path in parameters | Kind of objects |
| ----------------------------------------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| import { page } from "$app/stores" | `parameters.sveltekit.stores.page` | A Partial of the page store |
| import { navigating } from "$app/stores" | `parameters.sveltekit.stores.navigating` | A Partial of the navigating store |
| import { updated } from "$app/stores" | `parameters.sveltekit.stores.updated` | A boolean representing the value of updated (you can also access `check()` which will be a noop) |
| import { goto } from "$app/navigation" | `parameters.sveltekit.navigation.goto` | A callback that will be called whenever goto is called |
| import { invalidate } from "$app/navigation" | `parameters.sveltekit.navigation.invalidate` | A callback that will be called whenever invalidate is called |
| import { invalidateAll } from "$app/navigation" | `parameters.sveltekit.navigation.invalidateAll` | A callback that will be called whenever invalidateAll is called |
| import { afterNavigate } from "$app/navigation" | `parameters.sveltekit.navigation.afterNavigate` | An object that will be passed to the afterNavigate function (which will be invoked onMount) called |
| import { enhance } from "$app/forms" | `parameters.sveltekit.forms.enhance` | A callback that will called when a form with `use:enhance` is submitted |

All the other functions are still exported as `noop` from the mocked modules so that your application will still work. There was no way of make them work in a customizable way.

Additionally you can pass an object to `parameter.sveltekit.linkOverrides` where the keys are regex representing a link and the values are functions. If you have an `<a />` tag inside your code with the `href` attribute that matches one or more regex the corresponding function will be called.
16 changes: 15 additions & 1 deletion code/frameworks/sveltekit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,19 @@
"require": "./dist/index.js",
"import": "./dist/index.mjs"
},
"./preview": {
"types": "./dist/preview.d.ts",
"require": "./dist/preview.js",
"import": "./dist/preview.mjs"
},
"./dist/preview.js": {
"types": "./dist/preview.d.ts",
"require": "./dist/preview.js"
},
"./dist/preview.mjs": {
"types": "./dist/preview.d.ts",
"import": "./dist/preview.mjs"
},
"./preset": {
"types": "./dist/preset.d.ts",
"require": "./dist/preset.js"
Expand All @@ -43,7 +56,7 @@
"README.md",
"*.js",
"*.d.ts",
"!src/**/*"
"src/mocks/**/*"
],
"scripts": {
"check": "node --loader ../../../scripts/node_modules/esbuild-register/loader.js -r ../../../scripts/node_modules/esbuild-register/register.js ../../../scripts/prepare/check.ts",
Expand Down Expand Up @@ -72,6 +85,7 @@
"bundler": {
"entries": [
"./src/index.ts",
"./src/preview.ts",
"./src/preset.ts"
],
"platform": "node"
Expand Down
17 changes: 17 additions & 0 deletions code/frameworks/sveltekit/src/mocks/app/forms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export function enhance(form: HTMLFormElement) {
const listener = (e: Event) => {
e.preventDefault();
const event = new CustomEvent('storybook:enhance');
window.dispatchEvent(event);
};
form.addEventListener('submit', listener);
return {
destroy() {
form.removeEventListener('submit', listener);
},
};
}

export function applyAction() {}

export function deserialize() {}
43 changes: 43 additions & 0 deletions code/frameworks/sveltekit/src/mocks/app/navigation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { getContext, onMount, setContext } from 'svelte';

export async function goto(...args: any[]) {
const event = new CustomEvent('storybook:goto', {
detail: args,
});
window.dispatchEvent(event);
}

export function setAfterNavigateArgument(afterNavigateArgs: any) {
setContext('after-navigate-args', afterNavigateArgs);
}

export function afterNavigate(cb: any) {
const argument = getContext('after-navigate-args');
onMount(() => {
if (cb && cb instanceof Function) {
cb(argument);
}
});
}

export function onNavigate() {}

export function beforeNavigate() {}

export function disableScrollHandling() {}

export async function invalidate(...args: any[]) {
const event = new CustomEvent('storybook:invalidate', {
detail: args,
});
window.dispatchEvent(event);
}

export async function invalidateAll() {
const event = new CustomEvent('storybook:invalidateAll');
window.dispatchEvent(event);
}

export function preloadCode() {}

export function preloadData() {}
34 changes: 34 additions & 0 deletions code/frameworks/sveltekit/src/mocks/app/stores.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { getContext, setContext } from 'svelte';

function createMockedStore(contextName: string) {
return [
{
subscribe(runner: any) {
const page = getContext(contextName);
runner(page);
return () => {};
},
},
(value: unknown) => {
setContext(contextName, value);
},
] as const;
}

export const [page, setPage] = createMockedStore('page-ctx');
export const [navigating, setNavigating] = createMockedStore('navigating-ctx');
const [updated, setUpdated] = createMockedStore('updated-ctx');

Object.defineProperty(updated, 'check', {
value: () => {},
});

export { updated, setUpdated };

export function getStores() {
return {
page,
navigating,
updated,
};
}
31 changes: 23 additions & 8 deletions code/frameworks/sveltekit/src/plugins/config-overrides.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,27 @@
import type { Plugin } from 'vite';
import { resolve } from 'node:path';
import { mergeConfig, type Plugin } from 'vite';

export function configOverrides() {
return {
// SvelteKit sets SSR, we need it to be false when building
name: 'storybook:sveltekit-overrides',
apply: 'build',
config: () => {
return { build: { ssr: false } };
return [
{
// SvelteKit sets SSR, we need it to be false when building
name: 'storybook:sveltekit-overrides',
apply: 'build',
config: () => {
return { build: { ssr: false } };
},
},
} satisfies Plugin;
{
name: 'storybook:sveltekit-mock-stores',
enforce: 'post',
config: (config) =>
mergeConfig(config, {
resolve: {
alias: {
$app: resolve(__dirname, '../src/mocks/app/'),
},
},
}),
},
] satisfies Plugin[];
}
4 changes: 4 additions & 0 deletions code/frameworks/sveltekit/src/preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ export const core: PresetProperty<'core', StorybookConfig> = {
builder: getAbsolutePath('@storybook/builder-vite'),
renderer: getAbsolutePath('@storybook/svelte'),
};
export const previewAnnotations: StorybookConfig['previewAnnotations'] = (entry = []) => [
...entry,
join(dirname(require.resolve('@storybook/sveltekit/package.json')), 'dist/preview.mjs'),
];

export const viteFinal: NonNullable<StorybookConfig['viteFinal']> = async (config, options) => {
const baseConfig = await svelteViteFinal(config, options);
Expand Down
79 changes: 79 additions & 0 deletions code/frameworks/sveltekit/src/preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type { Decorator } from '@storybook/svelte';

import { onMount } from 'svelte';
import { setAfterNavigateArgument } from './mocks/app/navigation';
import { setNavigating, setPage, setUpdated } from './mocks/app/stores';

export const decorators: Decorator[] = [
(Story, ctx) => {
setPage(ctx.parameters?.sveltekit?.stores?.page);
setUpdated(ctx.parameters?.sveltekit?.stores?.updated);
setNavigating(ctx.parameters?.sveltekit?.stores?.navigating);
setAfterNavigateArgument(ctx.parameters?.sveltekit?.navigation?.afterNavigate);

onMount(() => {
const globalClickListener = (e: MouseEvent) => {
const path = e.composedPath();
const hasLink = path.findLast((el) => el instanceof HTMLElement && el.tagName === 'A');
if (hasLink && hasLink instanceof HTMLAnchorElement) {
const to = hasLink.getAttribute('href');
if (ctx?.parameters?.sveltekit?.linkOverrides && to) {
Object.entries(ctx.parameters.sveltekit.linkOverrides).forEach(([link, override]) => {
if (override instanceof Function) {
const regex = new RegExp(link);
if (regex.test(to)) {
override();
}
}
});
}
e.preventDefault();
}
};

function createListeners(baseModule: string, functions: string[]) {
const toRemove: Array<{
eventType: string;
listener: (event: { detail: any[] }) => void;
}> = [];
functions.forEach((func) => {
if (
ctx?.parameters?.sveltekit?.[baseModule]?.[func] &&
ctx.parameters.sveltekit[baseModule][func] instanceof Function
) {
const listener = ({ detail = [] as any[] }) => {
const args = Array.isArray(detail) ? detail : [];
ctx.parameters.sveltekit[baseModule][func](...args);
};
const eventType = `storybook:${func}`;
toRemove.push({ eventType, listener });
// @ts-expect-error apparently you can't add a custom listener to the window with TS
window.addEventListener(eventType, listener);
}
});
return () => {
toRemove.forEach(({ eventType, listener }) => {
// @ts-expect-error apparently you can't remove a custom listener to the window with TS
window.removeEventListener(eventType, listener);
});
};
}

const removeNavigationListeners = createListeners('navigation', [
'goto',
'invalidate',
'invalidateAll',
]);
const removeFormsListeners = createListeners('forms', ['enhance']);
window.addEventListener('click', globalClickListener);

return () => {
window.removeEventListener('click', globalClickListener);
removeNavigationListeners();
removeFormsListeners();
};
});

return Story();
},
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<script>
import { enhance } from '$app/forms';
</script>

<form use:enhance>
<button>enhance</button>
</form>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<a href="/storybook">Storybook</a>
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<script>
import { goto, invalidate, invalidateAll, afterNavigate } from '$app/navigation';

export let afterNavigateFn;
if(afterNavigateFn){
afterNavigate(afterNavigateFn);
}
</script>

<button
on:click={() => {
goto('/storybook');
}}>goto</button
>

<button
on:click={() => {
invalidate('/storybook');
}}>invalidate</button
>

<button
on:click={() => {
invalidateAll();
}}>invalidateAll</button
>
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<script>
import { page, navigating, updated, getStores } from '$app/stores';

let { navigating: navigatingStore, page: pageStore, updated: updatedStore } = getStores();

updated.check();
</script>

<p>Directly importing</p>
<pre>{JSON.stringify($page, null, 2)}</pre>
<pre>{JSON.stringify($navigating, null, 2)}</pre>
<pre>{JSON.stringify($updated, null, 2)}</pre>

<p>With getStores</p>
<pre>{JSON.stringify($pageStore, null, 2)}</pre>
<pre>{JSON.stringify($navigatingStore, null, 2)}</pre>
<pre>{JSON.stringify($updatedStore, null, 2)}</pre>
Loading