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

Automated "All stories" story #505

Open
sregg opened this issue Aug 10, 2023 · 9 comments
Open

Automated "All stories" story #505

sregg opened this issue Aug 10, 2023 · 9 comments

Comments

@sregg
Copy link

sregg commented Aug 10, 2023

Is your feature request related to a problem? Please describe.
When I have a lot of stories for a given component, it can be tedious to switch stories (i.e. side bar, click story, side bar, click story, etc...). I'd like to be able to see all stories in one screen. Especially for small components like buttons or list items.

Describe the solution you'd like
It'd be great to have the ability to automatically add an "All" story where all stories are rendered after an other with their title.

Describe alternatives you've considered
Infinite Red's Ignite had some cool components like UseCase for achieving this (see PR when they removed all that here) but the new CSF format doesn't support this (i.e. you can't easily render multiple components in one story).

Are you able to assist bring the feature to reality?
Yes!

Additional context
Example of Story in Ignite:

Something similar but automated would be awesome.

@sregg
Copy link
Author

sregg commented Aug 10, 2023

I just found this: https://storybook.js.org/addons/storybook-addon-variants
It'd be great to support this on mobile or build something similar.

@dannyhw
Copy link
Member

dannyhw commented Aug 10, 2023

You can pretty easily make something like this happen with csf, excuse the formatting I'm just doing this from memory.

const meta = {
   title: "Button",
   component: Button,
}

export const Story1 = {
  args: { 
    text: "text1"
  }
}

export const Story2 = {
  args: { 
    text: "text2"
  }
}

export const AllStory = {
  render ()=>{
    return (<>
      <Button {...Story1.args}>
      <Button {...Story2.args}>
   </>)
 }
}

@sregg
Copy link
Author

sregg commented Aug 10, 2023

Nice! I didn't know about this render() function 🎉
Looks like we could automatize this though.

import React from 'react';

import { CookStats } from './CookStats';

import type { Meta, StoryObj } from '@storybook/react-native';
import type { CookStatsProps } from './types';
import { InlineStorySectionHeader } from '~/storybook/components/InlineStorySectionHeader';

const CookStatsMeta = {
  title: 'Components/CookStats',
  component: CookStats,
  args: {
    size: 'large',
    distanceFromCustomer: '3.8mi',
  },
  argTypes: {
    size: {
      type: { name: 'enum', value: ['small', 'large'] },
    },
  },
} as Meta<CookStatsProps>;

export default CookStatsMeta;

export const WithEverything: StoryObj<CookStatsProps> = {
  args: {
    orderCount: 1126,
    averageRating: 4.7,
    ratingCount: 304,
  },
};

export const NoOrderCount: StoryObj<CookStatsProps> = {
  args: {
    averageRating: 4.2,
    ratingCount: 304,
  },
};

export const NoRating: StoryObj<CookStatsProps> = {
  args: {
    orderCount: 19,
  },
};

export const NoOrderCountNoRating: StoryObj<CookStatsProps> = {
  args: {},
};

export const All: StoryObj<CookStatsProps> = {
  render: () => (
    <>
      <InlineStorySectionHeader title="WithEverything" />
      <CookStats
        {...(CookStatsMeta.args as CookStatsProps)}
        {...WithEverything.args}
      />
      <InlineStorySectionHeader title="NoOrderCount" />
      <CookStats
        {...(CookStatsMeta.args as CookStatsProps)}
        {...NoOrderCount.args}
      />
      <InlineStorySectionHeader title="NoRating" />
      <CookStats
        {...(CookStatsMeta.args as CookStatsProps)}
        {...NoRating.args}
      />
      <InlineStorySectionHeader title="NoOrderCountNoRating" />
      <CookStats
        {...(CookStatsMeta.args as CookStatsProps)}
        {...NoOrderCountNoRating.args}
      />
    </>
  ),
};
image

@sregg
Copy link
Author

sregg commented Aug 10, 2023

I can create a util function that I can reuse in my project:

export function renderAllStories<T>(meta: Meta<T>, stories: StoryObj<T>[]) {
  return (
    <>
      {stories.map((story, index) => (
        <Fragment key={index}>
          <InlineStorySectionHeader title={story.name ?? ''} />
          {meta.component && <meta.component {...meta.args} {...story.args} />}
        </Fragment>
      ))}
    </>
  );
}

and use it like so:

export const All: StoryObj<CookStatsProps> = {
  render: () =>
    renderAllStories(CookStatsMeta, [
      WithEverything,
      NoOrderCount,
      NoRating,
      NoOrderCountNoRating,
    ]),
};

Small question: how does Storybook get the name of the story objects (e.g. WithEverything)?

@dannyhw
Copy link
Member

dannyhw commented Aug 10, 2023

when you import/require a module it has all the named exports and the default export in there like

module.exports = {
 Name1: { stuff here}
default: {meta stuff here}
}

so when you import that file its like

const file = require("location")

this file has all of those things

so yeah you could actually do this more automated yet probably by getting the current module and looping through it

@dannyhw
Copy link
Member

dannyhw commented Aug 10, 2023

@sregg since you can get the current module from just accessing "module", you should be able to do this:

export const AllStory = {
  render: () => {
    return (
      <>
        {Object.entries(module.exports)
          .filter(([key, _val]) => key !== "default")
          .map(([key, val]) => {
            if (val.args) {
              return <Icon key={key} {...val.args} />;
            }
          })}
      </>
    );
  },
};

@sregg
Copy link
Author

sregg commented Aug 11, 2023

Sounds good. I'll see if I have time to build that into this package itself.
Maybe with a showAllStoriesStory flag in Meta.

@machadogj
Copy link

FYI, I did a very basic looper in my app. looks like this:

image

The code is fairly vanilla, but it does depend on a few packages:

  • react-native-vector-icons/MaterialIcons
  • @react-native-async-storage/async-storage
import React, { useEffect, useState, useCallback } from 'react';
import { Text, View, TouchableOpacity } from 'react-native';
import Icon from 'react-native-vector-icons/MaterialIcons';
import AsyncStorage from '@react-native-async-storage/async-storage';

interface Story {
    name: string;
    args: any;
}

interface StoryLooperProps {
    stories: Story[];
    component: React.ComponentType<any>;
}

const STORAGE_KEY_PLAYING = '@story_looper_playing';
const STORAGE_KEY_CONTROL_BAR = '@story_looper_control_bar';
const STORAGE_KEY_STORY_INDEX = '@story_looper_story_index';

const StoryLooper: React.FC<StoryLooperProps> = ({ stories, component: Component }) => {
    const [storyIndex, setStoryIndex] = useState(0);
    const [isPlaying, setIsPlaying] = useState(true);
    const [isControlBarVisible, setIsControlBarVisible] = useState(true);

    useEffect(() => {
        const loadSavedState = async () => {
            try {
                const savedPlaying = await AsyncStorage.getItem(STORAGE_KEY_PLAYING);
                const savedControlBar = await AsyncStorage.getItem(STORAGE_KEY_CONTROL_BAR);
                const savedStoryIndex = await AsyncStorage.getItem(STORAGE_KEY_STORY_INDEX);

                if (savedPlaying !== null) {
                    setIsPlaying(savedPlaying === 'true');
                }
                if (savedControlBar !== null) {
                    setIsControlBarVisible(savedControlBar === 'true');
                }
                if (savedStoryIndex !== null) {
                    const parsedIndex = parseInt(savedStoryIndex, 10);
                    if (!isNaN(parsedIndex) && parsedIndex >= 0 && parsedIndex < stories.length) {
                        setStoryIndex(parsedIndex);
                    }
                }
            } catch (error) {
                console.error('Error loading saved state:', error);
            }
        };

        loadSavedState();
    }, [stories.length]);

    const goToNextStory = useCallback(() => {
        setStoryIndex((prevIndex) => {
            const newIndex = (prevIndex + 1) % stories.length;
            AsyncStorage.setItem(STORAGE_KEY_STORY_INDEX, newIndex.toString()).catch(error => {
                console.error('Error saving story index:', error);
            });
            return newIndex;
        });
    }, [stories.length]);

    const goToPreviousStory = useCallback(() => {
        setStoryIndex((prevIndex) => {
            const newIndex = (prevIndex - 1 + stories.length) % stories.length;
            AsyncStorage.setItem(STORAGE_KEY_STORY_INDEX, newIndex.toString()).catch(error => {
                console.error('Error saving story index:', error);
            });
            return newIndex;
        });
    }, [stories.length]);

    useEffect(() => {
        let interval: NodeJS.Timeout;
        if (isPlaying) {
            interval = setInterval(goToNextStory, 2000);
        }
        return () => clearInterval(interval);
    }, [isPlaying, goToNextStory]);

    const togglePlayPause = async () => {
        const newIsPlaying = !isPlaying;
        setIsPlaying(newIsPlaying);
        try {
            await AsyncStorage.setItem(STORAGE_KEY_PLAYING, newIsPlaying.toString());
        } catch (error) {
            console.error('Error saving playing state:', error);
        }
    };

    const toggleControlBar = async () => {
        const newIsControlBarVisible = !isControlBarVisible;
        setIsControlBarVisible(newIsControlBarVisible);
        try {
            await AsyncStorage.setItem(STORAGE_KEY_CONTROL_BAR, newIsControlBarVisible.toString());
        } catch (error) {
            console.error('Error saving control bar state:', error);
        }
    };

    const currentStory = stories[storyIndex];

    return (
        <View style={{flex: 1, position: 'relative'}}>
            <Component {...currentStory.args} />
            {isControlBarVisible ? (
                <View style={{
                    position: 'absolute',
                    top: 0,
                    left: 0,
                    right: 0,
                    flexDirection: 'row',
                    alignItems: 'center',
                    height: 50,
                    paddingHorizontal: 10,
                    backgroundColor: '#ddd',
                    opacity: 0.8,
                }}>
                    <Text style={{
                        fontSize: 14,
                        fontWeight: 'bold',
                        color: 'black',
                        flex: 1,
                    }}>{currentStory.name}</Text>
                    <TouchableOpacity onPress={goToPreviousStory} style={{ marginRight: 20 }}>
                        <Icon name="fast-rewind" size={24} color="black" />
                    </TouchableOpacity>
                    <TouchableOpacity onPress={togglePlayPause} style={{ marginRight: 20 }}>
                        <Icon name={isPlaying ? 'pause' : 'play-arrow'} size={24} color="black" />
                    </TouchableOpacity>
                    <TouchableOpacity onPress={goToNextStory} style={{ marginRight: 20 }}>
                        <Icon name="fast-forward" size={24} color="black" />
                    </TouchableOpacity>
                    <TouchableOpacity onPress={toggleControlBar}>
                        <Icon name="visibility-off" size={24} color="black" />
                    </TouchableOpacity>
                </View>
            ) : (
                <TouchableOpacity
                    onPress={toggleControlBar}
                    style={{
                        position: 'absolute',
                        top: 10,
                        right: 10,
                        backgroundColor: '#ddd',
                        borderRadius: 15,
                        padding: 5,
                        opacity: 0.8,
                    }}
                >
                    <Icon name="visibility" size={24} color="black" />
                </TouchableOpacity>
            )}
        </View>
    );
};

export default StoryLooper;

And it requires that you define a name in every story to display which story it's showing.

@pietgk
Copy link

pietgk commented Dec 12, 2024

FYI: Wrote a Button-All.stories.tsx that works for me.

import React from "react";
import type { Meta, StoryObj, ReactRenderer } from "@storybook/react";
import type { ListRenderItemInfo } from "react-native";
import { FlatList } from "react-native";
import { action } from "@storybook/addon-actions";
import type {
  BaseAnnotations,
  ComponentAnnotations,
  StoryAnnotations,
  StoryContext,
} from "storybook/internal/types";
import type { ButtonProps } from "./Button";
import { Button } from "./Button";
import * as AllStories from "./Button.stories";
import { Divider } from "../Divider";

type TStory<TProps> = ListRenderItemInfo<
  [
    string,
    (
      | ComponentAnnotations<ReactRenderer, TProps>
      | StoryAnnotations<ReactRenderer, TProps>
      | BaseAnnotations<ReactRenderer, TProps>
    )
  ]
>;

const renderButtonStory = <TProps extends ButtonProps>( // Record<string, unknown>>(
  story: TStory<TProps>
) => {
  const [key, val] = story.item;
  const props = val.args
    ? ({ ...val.args } as TProps)
    : ({
        text: key,
        onPress: () => action(`onPress${key}`),
      } as unknown as TProps);
  props.text = key;
  props.onPress = () => action(`onPress${key}`);
  if (val.render) {
    return val.render(props, {} as StoryContext<ReactRenderer, TProps>);
  }
  return (
    <Button key={key} {...props} onPress={() => action(`onPress${key}`)} />
  );
};

const ButtonAllStories = () => {
  const data = Object.entries(AllStories).filter(
    (story) => story[0] !== "default"
  );
  return (
    <FlatList
      data={data}
      renderItem={renderButtonStory<ButtonProps>}
      ItemSeparatorComponent={() => <Divider />}
      keyExtractor={(story) => story[0]}
    ></FlatList>
  );
};

const meta: Meta<FlatList> = {
  title: "mindlerui/Atoms/Button/All",
  component: ButtonAllStories,
};

export default meta;
type Story = StoryObj<typeof ButtonAllStories>;

export const All: Story = {
  args: {},
};

Its a bit unfinished but its working with typescript

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

No branches or pull requests

4 participants