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 19 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 @@ -100,6 +100,49 @@ yarn remove storybook-builder-vite
yarn remove @storybook/builder-vite
```

## How to mock

To mock a SvelteKit import you can set it on `parameters.sveltekit`:

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

You can add the name of the module you want to mock to `parameters.sveltekit` (in the example above we are mocking the `stores` module which correspond to `$app/stores`) and then 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.hrefs` 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.

## Troubleshooting

### Error: `ERR! SyntaxError: Identifier '__esbuild_register_import_meta_url__' has already been declared` when starting Storybook
Expand Down
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,
};
}
2 changes: 1 addition & 1 deletion code/frameworks/sveltekit/src/plugins/config-overrides.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Plugin } from 'vite';
import { type Plugin } from 'vite';

export function configOverrides() {
return {
Expand Down
17 changes: 17 additions & 0 deletions code/frameworks/sveltekit/src/plugins/mock-sveltekit-stores.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { resolve } from 'node:path';
import { mergeConfig, type Plugin } from 'vite';

export function mockSveltekitStores() {
return {
name: 'storybook:sveltekit-mock-stores',
enforce: 'post',
config: (config) =>
mergeConfig(config, {
resolve: {
alias: {
$app: resolve(__dirname, '../src/mocks/app/'),
},
},
}),
} satisfies Plugin;
}
9 changes: 8 additions & 1 deletion code/frameworks/sveltekit/src/preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { PresetProperty } from '@storybook/types';
import { withoutVitePlugins } from '@storybook/builder-vite';
import { dirname, join } from 'path';
import { configOverrides } from './plugins/config-overrides';
import { mockSveltekitStores } from './plugins/mock-sveltekit-stores';
import { type StorybookConfig } from './types';

const getAbsolutePath = <I extends string>(input: I): I =>
Expand All @@ -13,6 +14,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 All @@ -25,7 +30,9 @@ export const viteFinal: NonNullable<StorybookConfig['viteFinal']> = async (confi
'vite-plugin-sveltekit-compile',
'vite-plugin-sveltekit-guard',
])
).concat(configOverrides());
)
.concat(configOverrides())
.concat(mockSveltekitStores());

return { ...baseConfig, plugins };
};
104 changes: 104 additions & 0 deletions code/frameworks/sveltekit/src/preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
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) => {
// we add a global click event listener and we check if there's a link in the composedPath
const path = e.composedPath();
const hasLink = path.findLast((el) => el instanceof HTMLElement && el.tagName === 'A');
if (hasLink && hasLink instanceof HTMLAnchorElement) {
// if it has a link we get the href of the link and we check over every provided href using the
// key as a regex
const to = hasLink.getAttribute('href');
if (ctx?.parameters?.sveltekit?.hrefs && to) {
Object.entries(ctx.parameters.sveltekit.hrefs).forEach(([link, override]) => {
if (override instanceof Function) {
const regex = new RegExp(link);
if (regex.test(to)) {
// if the regex pass we call the function the user provided
override();
}
}
});
}
e.preventDefault();
}
};

/**
* Function that create and add listeners for the event that are emitted by
* the mocked functions. The event name is based on the function name
*
* eg. storybook:goto, storybook:invalidateAll
* @param baseModule the base module where the function lives (navigation|forms)
* @param functions the list of functions in that module that emit events
* @returns a function to remove all the listener added
*/
function createListeners(baseModule: string, functions: string[]) {
// the array of every added listener, we can use this in the return function
// to clean them
const toRemove: Array<{
eventType: string;
listener: (event: { detail: any[] }) => void;
}> = [];
functions.forEach((func) => {
// we loop over every function and check if the user actually passed
// a function in sveltekit[baseModule][func] eg. sveltekit.navigation.goto
if (
ctx?.parameters?.sveltekit?.[baseModule]?.[func] &&
ctx.parameters.sveltekit[baseModule][func] instanceof Function
) {
// we create the listener that will just get the detail array from the custom element
// and call the user provided function spreading this args in...this will basically call
// the function that the user provide with the same arguments the function is invoked to

// eg. if it calls goto("/my-route") inside the component the function sveltekit.navigation.goto
// it provided to storybook will be called with "/my-route"
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 });
// add the listener to window
// @ts-expect-error apparently you can't add a custom listener to the window with TS
window.addEventListener(eventType, listener);
}
});
return () => {
// loop over every listener added and remove them
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
>
Loading