Repository: storybook
Issue: Add scopes to pages, components and stories
Labels: feature request,ui,core,csf

# Proposal: Storybook scopes

Filter out stories and documentation pages based on pre-defined scopes.

[Try it out](https://capable-lolly-f468af.netlify.app) | [Demo repository](https://github.com/Dschungelabenteuer/storybook-scopes-example)

> 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:
* Confirmed designers and developers would browse components documentation, design tokens, or look back on best practices and procedures.
* Developers would care about code documentation while designers would not.
* We would point specific pages at newcomers to step into the project: introduction to the project architecture and core concepts, etc.

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](https://user-images.githubusercontent.com/16818271/168597913-b28976ea-0a78-401f-81fd-679858cdb9ce.png)

## 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

<table>
  <thead>
    <tr>
      <th></th>
      <th>Maintainability</th>
      <th>Browsing experience</th>
      <th>DRY</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td colspan="4">
        💡 I could have one Storybook per target audience.
      </td>
    </tr>
    <tr>
      <td>
        Some of them might share common pages and stories: building them independantly would be… overkill?
      </td>
      <td>❌</td>
      <td>✅</td>
      <td>❌</td>
    </tr>
    <tr>
      <td colspan="4">
        💡 I could take advantage of Storybook composition to embed common pages.
      </td>
    </tr>
    <tr>
      <td>
        This gets complicated whenever I have multiple ties:
        <ul>
          <li><em>x</em> is only relevant to <em>A</em> and <em>B</em></li>
          <li><em>y</em> is only relevant to <em>A</em> and <em>C</em></li>
          <li><em>z</em> is only relevant to <em>B</em> and <em>C</em></li>
        </ul>
      </td>
      <td>❌</td>
      <td>❌</td>
      <td>✅</td>
    </tr>
    <tr>
      <td colspan="4">
        💡 I could have top-level categories (the uppercase ones) for each target audience.
      </td>
    </tr>
    <tr>
      <td>
          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.
      </td>
      <td>😕</td>
      <td>😕</td>
      <td>❌</td>
    </tr>
    <tr>
      <td colspan="4">
        💡 Maybe I could  <a href="https://storybook.js.org/docs/react/configure/overview#with-a-custom-implementation">customize story loading.</a>
      </td>
    </tr>
    <tr>
      <td>
        I have to admit I've never ever tried this, but <code>StorybookConfig</code> interface <a href="https://github.com/storybookjs/storybook/blob/next/lib/core-common/src/types.ts#L417">explicitly</a> sets the <code>stories</code> property as an array and I have no idea if this could fulfill my needs.
      </td>
      <td>?</td>
      <td>?</td>
      <td>?</td>
    </tr>
    <tr>
      <td colspan="4">
        💡 I could just give up and wait until someone figures this out for me…
      </td>
    </tr>
    <tr>
      <td>
        Of course not, I want to give it a try and help the open source community, whatever my modest contribution is worth.
      </td>
      <td></td>
      <td></td>
      <td></td>
    </tr>
  </tbody>
</table>


## 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.

```js
// .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**

<table>
  <thead>
    <tr>
      <th>CSF</th>
      <th>MDX</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>

```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<typeof Button>;
```
</td>
<td>

```tsx
<!-- Button.stories.mdx -->

import { Meta } from '@storybook/addon-docs';

import { Button } from './Button';

<Meta
  title="Button"
  component={Button}
  scopes={[
    'developers-a',
    'developers-b'
  ]}
/>
```
</td>
    </tr>
  </tbody>
</table>

#### **Story-level scope definition**


<table>
  <thead>
    <tr>
      <th>CSF</th>
      <th>MDX</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>

```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'
];
```
</td>
<td>

```tsx
<!-- Button.stories.mdx -->

import { Canvas, Meta, Story } from '@storybook/addon-docs';

import { Button } from './Button';

<Meta title="Button" component={Button} />

export const Template = (args) =>({});

<Canvas>
  <Story
    name="Primary"
    scopes={[
      'developers-a',
      'developers-b'
    ]}>
    {Template.bind({})}
  </Story>
</Canvas>
```
</td>
    </tr>
  </tbody>
</table>


### 3. Apply scope

* By using a select in the sidebar
* By using an URL Query Parameter

## 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:

```ts
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:

```ts
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):

```ts
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:

<table>
<tr>
  <th>

#### **If you use the on-demand architecture (`storyStoreV7` feature)**
  </th>
  <th>

#### **If you use the (until then) default architecture**
  </th>
</tr>
<tr>
<td>

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)_.
</td>
<td>

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.
</td>
</tr>
</table>

_(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:_

<span id="notion"></span>

![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.](https://user-images.githubusercontent.com/16818271/168597914-94d71c41-a3e9-46f0-aed5-891a31f31d46.png)

#### **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"?
* I feel like it would be a good practice to keep stories and their scopes as pure as possible inside the Story Index. One could argue that at this stage, scopes are not pure anymore since we already applied some inheritance logic, but the "scope propagation" we're talking about here is a very case-specific logic which is only related to the way Storybook's Sidebar works as a tree.
* I'm not even sure this would be possible since it requires every page/component/story's parent node to be already set: some of those are not built until the raw stories are transformed to storiesHash (e.g. top-level components, categories).

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:

```tsx
<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:

```tsx
const componentMeta = {
  title: 'Project/Homepage',
  scopes: ['developers-a'],
  // ...
};
// ...
export const designerOnlyStory = Template.bind({});
designerOnlyStory.scopes = ['designers'];
// ...
export default componentData
```

## Caveats

* Docs view does not take scopes into consideration: one component's filtered-out stories will still appear. 

## 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

* `@storybook/api`
* `@storybook/client-api`
* `@storybook/core-server`
* `@storybook/csf-tools`
* `@storybook/store`
* `@storybook/ui`

[See changes](https://github.com/Dschungelabenteuer/storybook/commit/97e5a5d849dfb43f60e6d4440028afe9e9c44de5)

### Changes on Storybook's other packages

| **Package** | **Changes** |
| ----------- | ------------|
| `@storybook/mdx1-csf` | [See changes](https://github.com/Dschungelabenteuer/mdx1-csf/commit/1c0cad097ec7af542c577932986236653f1cbedc) |
| `@storybook/mdx2-csf` | [See changes](https://github.com/Dschungelabenteuer/mdx2-csf/commit/cb15518a3df6717684f15837c2cb87ac07e21fb6) |
| `@storybook/csf`      | Unfortunately, I could not find where to make these changes. It seems to target [this repo](https://github.com/ComponentDriven/csf) but could not find the relevant code. |

### Notes

* I could/did not run the E2E tests to check these changes.
* Some snapshot tests are failing (maybe because they ran on a Windows environment).