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

[Request] A simple way to bind component story state to args #164

Open
spykr opened this issue Jan 22, 2024 · 2 comments
Open

[Request] A simple way to bind component story state to args #164

spykr opened this issue Jan 22, 2024 · 2 comments
Labels
enhancement New feature or request

Comments

@spykr
Copy link

spykr commented Jan 22, 2024

Issue

I would like an easy way to have story state that is bound to the component but can still be controlled via args.

When writing stories for React it's very simple to have story state where the initial value is based on the story args, e.g.

const Template = (args) => {
  // Initial state is based on the args
  const [isOpen, setOpen] = useState(args.isOpen);

  return <Accordion {...args} isOpen={isOpen} onChange={setOpen} />;
};

export const Open = {
  render: Template,
  args: { isOpen: true },
};

export const Closed = {
  render: Template,
  args: { isOpen: false },
};

This allows you to have stories that 1) share a template, 2) show how the component reacts to different props, 3) are still fully interactive!

As a bonus, making it so the story state reacts to changes in the "Controls" panel is a bit more work but still straightforward:

const Template = (args) => {
  // Initial state is based on the args
  const [isOpen, setOpen] = useState(args.isOpen);
  // State updates when the args are changed
  useEffect(() => {
    setOpen(args.isOpen);
  }, [args.isOpen]);

  return <Accordion {...args} isOpen={isOpen} onChange={setOpen} />;
};

Reproducing this in addon-svelte-csf has proven difficult because I haven't found an easy way to access the story args in the initial render of the story. I've come up with a workaround (see below) but it feels very hacky and I would love to know if there's a built-in way to do it instead.

EDIT: See my comment below the OP, I ended up coming up with a much nicer workaround.

Example workaround

Note: This workaround has a limitation in that changes to state that happen inside the story code (e.g. form submission changing the loading state) will not update the story args, but this is the same as in my React example above and I think this is acceptable if not expected.

Form.svelte:

<script lang="ts">
	import { createEventDispatcher } from 'svelte';

	export let loading = false;
	export let error = '';

	const dispatch = createEventDispatcher();

	let email = '';

	function handleSubmit(event: SubmitEvent) {
		event.preventDefault();
		dispatch('submit', { email });
	}
</script>

<form on:submit={handleSubmit}>
	<div>
		<label for="email">Email address</label>
		<input bind:value={email} id="email" type="email" disabled={loading} required />
	</div>
	{#if error}
		<div>{error}</div>
	{/if}
	<button type="submit" disabled={loading}>Submit</button>
</form>

Form.stories.svelte:

<script context="module" lang="ts">
	import type { Meta } from '@storybook/svelte';

	import Form from './Form.svelte';

	export const meta = {
		title: 'Form',
		component: Form,
		argTypes: {
			loading: { control: 'boolean' },
			error: { control: 'text' },
		},
		args: {
			loading: false,
			error: '',
		},
	} satisfies Meta<Form>;
</script>

<script lang="ts">
	import { beforeUpdate, onMount } from 'svelte';
	import { Story, Template } from '@storybook/addon-svelte-csf';

	let loading = false;
	let error = '';

	async function handleSubmit() {
		loading = true;
		error = '';

		setTimeout(() => {
			loading = false;
			error = 'An error occurred, please try again.';
		}, 1000);
	}

	// WORKAROUND: Set the initial state from the initial story args
	let firstUpdate = true;
	beforeUpdate(() => {
		const id = __STORYBOOK_PREVIEW__.currentRender.id;
		const args = __STORYBOOK_PREVIEW__.currentRender.store.args.argsByStoryId[id];
		if (args && firstUpdate) {
			loading = args.loading;
			error = args.error;
			firstUpdate = false;
		}
	});

	// WORKAROUND: Listen for changes to the story args and update the state
	onMount(() => {
		function handleArgsUpdated({ args }) {
			loading = args.loading;
			error = args.error;
		}

		__STORYBOOK_PREVIEW__.channel.on('storyArgsUpdated', handleArgsUpdated);
		return () => __STORYBOOK_PREVIEW__.channel.off('storyArgsUpdated', handleArgsUpdated);
	});
</script>

<Template>
	<Form bind:loading bind:error on:submit={handleSubmit} />
</Template>

<Story name="Default" />

<Story name="Loading" args={{ loading: true }} />

<Story name="Error" args={{ error: "We couldn't find an account with that email address." }} />
@spykr spykr added the bug Something isn't working label Jan 22, 2024
@spykr
Copy link
Author

spykr commented Jan 22, 2024

Okay so I dug in to the source code a bit more and actually ended up coming up with a much nicer workaround than the one in the OP (4 lines of code instead of 18). However I am still curious to know if this is indeed a supported use case and/or if there's a nicer way to do it.

<script lang="ts">
	import { getContext } from 'svelte';
	import { Story, Template } from '@storybook/addon-svelte-csf';

	let loading = false;
	let error = '';

        // WORKAROUND: Update state to match the args on mount and when the args change
        // (Much more concise than the alternative in the OP)
	const { argsStore } = getContext('storybook-registration-context-component') || {};
	$: if (argsStore) {
		({ loading, error } = $argsStore);
	}

	async function handleSubmit() {
		loading = true;
		error = '';

		setTimeout(() => {
			loading = false;
			error = 'An error occurred, please try again.';
		}, 1000);
	}
</script>

<Template>
	<Form bind:loading bind:error on:submit={handleSubmit} />
</Template>

EDIT: Third time's the charm? After playing with the above solution a bit more I realised that changes to the bound variables from inside the component could reset the state to match the initial args. This is my latest iteration which avoids that issue...

const { argsStore } = getContext('storybook-registration-context-component') || {};
argsStore?.subscribe((args) => {
	({ loading, error } = args);
});

@JReinhold JReinhold added enhancement New feature or request and removed bug Something isn't working labels Jan 23, 2024
@j3rem1e
Copy link
Contributor

j3rem1e commented Feb 3, 2024

You should probably use an intermediate component to do that. I don't think it's a good practice to mix args with test states..

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

3 participants