storybookjs / storybook

Storybook is the industry standard workshop for building, documenting, and testing UI components in isolation
https://storybook.js.org
MIT License
84.04k stars 9.24k forks source link

Add scopes to pages, components and stories #18235

Open Dschungelabenteuer opened 2 years ago

Dschungelabenteuer commented 2 years ago

Proposal: Storybook scopes

Filter out stories and documentation pages based on pre-defined scopes.

Try it out | Demo repository

Disclaimer: I'm sorry for the very long issue, I wanted to include my reasoning and used it as a notepad to have a clearer insight of the way Storybook works internally. Also, I might be completely mistaken or overthinking something which is already possible, please let me know!

Motivation

I use Storybook on a daily basis: besides being a major part of my UI development workflow, it also holds some technical documentation I write for my dear colleagues. Some might prefer using dedicated tools, but I do like to keep things centralized and Storybook's docs addon just works great to me. I'm especially addicted to MDX pages which let me include React components to get my documentation even more interactive and impactful.

It ended up being the single source of truth for a few different target audiences who would occasionally browse the Storybook to read the few pages which are relevant to them:

I would like to keep things as much clean and straight-forward as possible for these target audiences: I would like to be able to share my Storybook to different target audiences in a way that only the pages, components and stories that are relevant to them are displayed.

Example of something I would expect

Principles and concerns

From the perspective of any maintainer of any Storybook

As the main maintainer of our Storybook, there are three concerns I want to keep in mind while meeting my above needs:

  1. Browsing experience: This is the main motivation behind this proposal, I want to offer our Storybook's consumers the best possible browsing experience: they should easily find what they are looking for and not be overwhelmed by content which is not relevant to them.
  2. Maintainability: Addressing this first concern must not be at the cost of maintainability because developer experience also is important: we don't need additional time and effort consuming maintainance to lead to unnecessary mental workload.
  3. DRY: Obviously, we should avoid code duplication. Addressing the first concern should not lead us to naively duplicate content, this would otherwise lead to maintainability issues.

From the perspective of Storybook's governance

  1. Adoption: Adopting this proposal should be straight-forward for anyone used to the Storybook ecosystem.
  2. Easy toggle: This should be an opt-in feature which can easily be opted-out from a single place (e.g. from preview.ts).
  3. Future-proof: This should work fine with upcoming features such as mdx2 support and on-demand architecture via storyStorev7.
  4. Conflict-free: People should be guaranteed that adopting this proposal has zero risk of creating any conflict with their existing configuration or stories.

Early considerations

Maintainability Browsing experience DRY
πŸ’‘ I could have one Storybook per target audience.
Some of them might share common pages and stories: building them independantly would be… overkill? ❌ βœ… ❌
πŸ’‘ I could take advantage of Storybook composition to embed common pages.
This gets complicated whenever I have multiple ties:
  • x is only relevant to A and B
  • y is only relevant to A and C
  • z is only relevant to B and C
❌ ❌ βœ…
πŸ’‘ I could have top-level categories (the uppercase ones) for each target audience.
I could not find a way to place the very same page at different positions within the documentation hierarchy (e.g. by setting title as an array of strings instead of a single string): this would force me to duplicate common content, which is not desirable. Besides, despite being better organized with collapsible categories, all of the content (including parts which might not be relevant to one user) would still appear in the sidebar. πŸ˜• πŸ˜• ❌
πŸ’‘ Maybe I could customize story loading.
I have to admit I've never ever tried this, but StorybookConfig interface explicitly sets the stories property as an array and I have no idea if this could fulfill my needs. ? ? ?
πŸ’‘ I could just give up and wait until someone figures this out for me…
Of course not, I want to give it a try and help the open source community, whatever my modest contribution is worth.

Proposal overview

The simplest and most generic solution i've came up with is the idea of scopes. One would define specific pages, components or stories as part of a specific scope (which could represent what I previously called a target audience or just a documentation category). These scopes could then be applied to the Storybook to filter out pages and stories to only display the ones relevant to the applied scope. This solution could work through three different steps:

1. Define the scope list

We first need to define the list of scopes which will be available in the Storybook. I'd assume the best place to define these would be as a globalType within the preview configuration file, since they can easily be accessed from addons and the Storybook manager.

// .storybook/preview.js
export const globalTypes = {
  scope: {
    // List of available scopes.
    list: [
      { value: 'developers-a', title: 'Developers (Team A)' },
      { value: 'developers-b', title: 'Developers (Team B)' },
      { value: 'designers', title: 'Designers' },
      { value: 'unused', title: 'Unused' },
    ],
    // Default scope.
    defaultValue: 'developers-a',
  },
};

2. Scope pages, components or stories

To keep things consistent and facilitate adoption, the ideal approach to define page/component or story scopes would be to stick as much as possible to the way parameters are defined, which is a known pattern in Storybook (parameters, decorators, etc). Then why wouldn't we just define scopes as parameters? Here are some reasons:

  1. This could cause conflicts as some people might already use a "scope" parameter with their own logic.
  2. The inheritance logic might be slightly different: while more specific scopes - just like parameters - take precedence, they are not merged. This means if a component is scoped to Developers and one of its stories is scoped to Designers, this story will only be scoped to Designers instead of both Designers and Developers.
  3. Scopes are meant to be fundamentally different from parameters. Most of the time, parameters are just used to change the behaviour of an addon, a page, a component or a story. Scopes inherently change the bahaviour of the Manager (specifically the Sidebar and its child components).

Meta-level (page/component) scope definition

CSF MDX
```ts // Button.stories.ts|tsx import React from 'react'; import { ComponentMeta } from '@storybook/react'; import { Button } from './Button'; export default { title: 'Button', component: Button, scopes: [ 'developers-a', 'developers-b' ], } as ComponentMeta; ``` ```tsx import { Meta } from '@storybook/addon-docs'; import { Button } from './Button'; ```

Story-level scope definition

CSF MDX
```ts // Button.stories.ts|tsx import React from 'react'; import { ComponentMeta } from '@storybook/react'; import { Button } from './Button'; export default { title: 'Button', component: Button, }; const Template = (args) => ({}); export const Primary = Template.bind({}); Primary.scopes = [ 'developers-a', 'developers-b' ]; ``` ```tsx import { Canvas, Meta, Story } from '@storybook/addon-docs'; import { Button } from './Button'; export const Template = (args) =>({}); {Template.bind({})} ```

3. Apply scope

Implementation details

Unfortunately, this does not seem to be achievable independantly from Storybook's codebase by writing a simple standalone addon because these can only interact with tabs, panels and toolbar. What we are trying to achieve, however, requires changes within Storybook's sidebar.

Scope API

We need a new API module which will add a state property to Storybook's context:

export type Scope = {
  value: string;
  title: string;
};

export interface ScopeState {
  list: Scope[];
  active?: Scope['value'];
}

When initialized, the module sets the following state property with these default values:

const scope {
  list: [];
  active: undefined;
}

The initialization process waits for the SET_GLOBALS core event to get the available scopes and the default scope from globalTypes if the active scope was not set by a scope global (e.g. from URL Query Parameters):

fullAPI.on(
  SET_GLOBALS,
  async function handleGlobalsReady({ globals, globalTypes }: SetGlobalsPayload) {
    await api.setScopes(globalTypes.scope?.list ?? []);
    await api.setActiveScope(globals?.scope ?? globalTypes.scope?.defaultValue);
  }
);

The API exposes the following methods:

Method Args Description
getActiveScope None. Returns the active scope
async setActiveScope scope: string Sets the active scope
getScopes None. Returns the scope list
async setScopes scopes: string[] Sets the scope list
async setScopeGlobal scopes: string[] Sets the scope global (this will add a query param to the URL so that you can directly share a Storybook with a predefined scope)

Implement scope logic in Sidebar

The logic is pretty straight-forward: when a scope is applied (state.scope.active !== undefined), stories displayed in the sidebar are filtered to only show the ones matching the applied scope or the ones with no scopes defined (this means they're not scoped and visible for everyone). This happens in the @storybook/ui package:

  1. we have to get the scope state and pass it down from Sidebar's container as a prop.
  2. we need to wrap the story list with a useScope function to filter out stories before they're passed down to consumer child components.
  3. since we're in the @storybook/ui, let's also develop a ScopePicker component to let users easily switch scopes from the Sidebar.

The base logic is ready, but we're still missing a major point: the list of stories (as we know it in the Sidebar component) do not hold any information about scopes. It can't just work out of the box.

Have the list of stories know about scopes

  1. The list of stories in the Sidebar component comes from the "storiesHash" state property
  2. which is built based on a list of raw stories objects
  3. which are either based on a predefined Story Index (Storybook's list of summarized stories) or built on bootup:
#### **If you use the on-demand architecture (`storyStoreV7` feature)** #### **If you use the (until then) default architecture**
Despite lazy-loading stories, Storybook still calculates an initial Story Index so that all stories appear in the Sidebar, even though they still haven't been evaluated. This Story Index is served as soon as possible from `@storybook/core-server` via the `./stories.json` endpoint. Under the hood, this Story Index is built with the help of the `@storybook/csf-tools` package, and more specifically with the `CsfFile` class. It basically traverses a CSF file's AST and feeds properties which represent CSF content: "meta" and "stories". * On `@storybook/csf-tools`'s side, we have to feed both properties with possibly defined scopes. * On `@storybook/core-server`'s side, we have to make sure scopes are actually included in the raw output as we extract stories. This is where we apply the "inheritance logic" _(1)_. All of the stories are loaded at bootup time and available client-side. The Story Index is served based on `@storybook/client-api`'s `StoryStoreFacade` which gathers stories-related data it collected when booting up Storybook. Under the hood, it uses `@storybook/store`'s CSF processing utilities to build the list of raw stories: * In the `prepareStory` function, we have to include the possibly defined scopes of the story. This is where we apply the "inheritance logic" _(1)_: 1. Page/component-level scopes should automatically be exported by `normalizeComponentAnnotations` since it does not filter out properties it doesn't know. 2. Story-level scopes have to be specifically passed in the `normalizeStory` function output otherwise it will get filtered out even though they're part of the story annotations.

(1): The inheritance logic makes story-level scopes take precedence over page/component-level ones (without merging any of them). This means scopes will use the following decision tree:

If a story is scoped, it will simply stick to this scope. If it is not, it will look for a component-level scope. If the component is scoped, its scopes will be applied to the story. If it is not, the story won't be scoped at all.

Have pages, components and stories propagate their scopes up the hierarchy

Imagine a story which is only scoped for "Designers" in a component which is only scoped for "Developers (Team A)". As a designer, to be able to navigate to this story from the sidebar, I need to have access to the component it belongs to: how can a leaf still live in the tree if its branch is cut down? (not sure that image makes any sense :-]).

Why aren't we doing this directly where we're applying the "inheritance logic"?

This is precisely and probably when we want to propagate scopes up the hierarchy. This happens in @storybook/api's transformStoriesRawToStoriesHash which is eventually called from both default and on-demand architectures.

What about MDX?

Since MDX is transformed into CSF under the hood, we need to step into this transformation process. This is handled by @storybook/mdx1-csf and @storybook/mdx2-csf packages which both are outside Storybook's monorepository.

We need a quick change in both of them so that this:

<Meta
  title="Project/Homepage"
  scopes={['developers-a']}
  component={Homepage}
/>
...
<Story name="Designer Only Story" scopes={['designers']}>
  {Template.bind({})}
</Story>

gets transformed into some CSF code containing our scopes:

const componentMeta = {
  title: 'Project/Homepage',
  scopes: ['developers-a'],
  // ...
};
// ...
export const designerOnlyStory = Template.bind({});
designerOnlyStory.scopes = ['designers'];
// ...
export default componentData

Caveats

Summary

This is not a Pull Request yet as I assume this should rather be discussed beforehand, but I took the liberty of trying to implement this myself. Here is what it implies:

Changes on Storybook's monorepo

See changes

Changes on Storybook's other packages

Package Changes
@storybook/mdx1-csf See changes
@storybook/mdx2-csf See changes
@storybook/csf Unfortunately, I could not find where to make these changes. It seems to target this repo but could not find the relevant code.

Notes

shilman commented 2 years ago

Hi @Dschungelabenteuer !! This looks fantastic and resembles a proposal we are kicking around: https://www.notion.so/chromatic-ui/Story-tags-586c7d9ff24f4792bc42a7d840b1142b.

Can you please check this out and let's discuss how to merge the two proposals? We were planning to work on this in 7.x, but could potentially do it as part of 7.0 especially if you can help make it happen.

Also, related issue: https://github.com/storybookjs/storybook/issues/7711

Dschungelabenteuer commented 2 years ago

Hey @shilman ! This is awesome, thanks for letting me know! I gave it a quick read, this indeed looks pretty similar (which may mean I did not completely miss the point, I'm so glad 😊).

Of course I'd be happy to help make it happen! I'll give it an in-depth read tomorrow. Should I comment the notion page or is it rather for maintainers/chromatic team?

reutgrubervcita commented 2 years ago

I was just looking for a way to make it possible to search my storybook library according to tags, and not just component names, so this looks awesome! I would love it if it happened!

domyen commented 1 month ago

Thanks for such a comprehensive doc. We now have tags implemented behind the scenes and are considering the UX for it.

@Dschungelabenteuer if you're still open to it, we'd love to get your feedback on a few design prototypes.

Dschungelabenteuer commented 1 month ago

@domyen of course I am :) I gather we're talking about UI design prototypes here, since tags are now a thing?

domyen commented 3 weeks ago

Yup, UI design prototypes.