Skip to content

Commit

Permalink
Proposal: Storybook scopes
Browse files Browse the repository at this point in the history
Proposal: Storybook scopes
  • Loading branch information
Dschungelabenteuer committed May 16, 2022
1 parent 385b7d7 commit 97e5a5d
Show file tree
Hide file tree
Showing 24 changed files with 569 additions and 24 deletions.
7 changes: 6 additions & 1 deletion lib/api/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import Store, { Options } from './store';
import getInitialState from './initial-state';
import type { StoriesHash, Story, Root, Group } from './lib/stories';
import type { ComposedRef, Refs } from './modules/refs';
import type { Scope, ScopeState } from './modules/scopes';
import { isGroup, isRoot, isStory } from './lib/stories';

import * as provider from './modules/provider';
Expand All @@ -35,6 +36,7 @@ import * as channel from './modules/channel';
import * as notifications from './modules/notifications';
import * as settings from './modules/settings';
import * as releaseNotes from './modules/release-notes';
import * as scopes from './modules/scopes';
import * as stories from './modules/stories';
import * as refs from './modules/refs';
import * as layout from './modules/layout';
Expand All @@ -60,6 +62,7 @@ export type ModuleArgs = RouterData &
};

export type State = layout.SubState &
scopes.SubState &
stories.SubState &
refs.SubState &
notifications.SubState &
Expand All @@ -75,6 +78,7 @@ export type State = layout.SubState &
export type API = addons.SubAPI &
channel.SubAPI &
provider.SubAPI &
scopes.SubAPI &
stories.SubAPI &
refs.SubAPI &
globals.SubAPI &
Expand Down Expand Up @@ -204,6 +208,7 @@ class ManagerProvider extends Component<ManagerProviderProps, State> {
settings,
releaseNotes,
shortcuts,
scopes,
stories,
refs,
globals,
Expand Down Expand Up @@ -331,7 +336,7 @@ export function useStorybookApi(): API {
return api;
}

export type { StoriesHash, Story, Root, Group, ComposedRef, Refs };
export type { StoriesHash, Story, Scope, ScopeState, Root, Group, ComposedRef, Refs };
export { ManagerConsumer as Consumer, ManagerProvider as Provider, isGroup, isRoot, isStory };

export interface EventMap {
Expand Down
46 changes: 35 additions & 11 deletions lib/api/src/lib/stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export interface Root {
isComponent: false;
isRoot: true;
isLeaf: false;
scopes?: string[];
renderLabel?: (item: Root) => React.ReactNode;
startCollapsed?: boolean;
}
Expand All @@ -51,6 +52,7 @@ export interface Group {
isRoot: false;
isLeaf: false;
renderLabel?: (item: Group) => React.ReactNode;
scopes?: string[];
// MDX docs-only stories are "Group" type
parameters?: {
docsOnly?: boolean;
Expand All @@ -71,6 +73,7 @@ export interface Story {
isRoot: false;
isLeaf: true;
renderLabel?: (item: Story) => React.ReactNode;
scopes?: string[];
prepared: boolean;
parameters?: {
fileName: string;
Expand All @@ -91,6 +94,7 @@ export interface StoryInput {
name: string;
refId?: string;
kind: StoryKind;
scopes?: string[];
parameters: {
fileName: string;
options: {
Expand Down Expand Up @@ -121,6 +125,7 @@ export interface StoryIndexStory {
id: StoryId;
name: StoryName;
title: ComponentTitle;
scopes?: string[];
importPath: Path;
}
export interface StoryIndex {
Expand Down Expand Up @@ -183,16 +188,20 @@ export const transformStoryIndexToStoriesHash = (
{ provider }: { provider: Provider }
): StoriesHash => {
const countByTitle = countBy(Object.values(index.stories), 'title');
const input = Object.entries(index.stories).reduce((acc, [id, { title, name, importPath }]) => {
const docsOnly = name === 'Page' && countByTitle[title] === 1;
acc[id] = {
id,
kind: title,
name,
parameters: { fileName: importPath, options: {}, docsOnly },
};
return acc;
}, {} as StoriesRaw);
const input = Object.entries(index.stories).reduce(
(acc, [id, { title, name, scopes, importPath }]) => {
const docsOnly = name === 'Page' && countByTitle[title] === 1;
acc[id] = {
id,
kind: title,
name,
...(scopes?.length && { scopes }),
parameters: { fileName: importPath, options: {}, docsOnly },
};
return acc;
},
{} as StoriesRaw
);

return transformStoriesRawToStoriesHash(input, { provider, prepared: false });
};
Expand All @@ -203,9 +212,10 @@ export const transformStoriesRawToStoriesHash = (
): StoriesHash => {
const values = Object.values(input).filter(Boolean);
const usesOldHierarchySeparator = values.some(({ kind }) => kind.match(/\.|\|/)); // dot or pipe
const unscopedPaths = new Set<string>([]);

const storiesHashOutOfOrder = values.reduce((acc, item) => {
const { kind, parameters } = item;
const { kind, parameters, scopes } = item;
const { sidebar = {}, showRoots: deprecatedShowRoots } = provider.getConfig();
const { showRoots = deprecatedShowRoots, collapsedRoots = [], renderLabel } = sidebar;

Expand Down Expand Up @@ -272,13 +282,21 @@ export const transformStoriesRawToStoriesHash = (

const paths = [...rootAndGroups.map(({ id }) => id), item.id];

// If the leaf is not scoped, we'll eventually recursively unscope the whole path.
if (!scopes?.length) {
paths.forEach((path) => {
unscopedPaths.add(path);
});
}

// Ok, now let's add everything to the store
rootAndGroups.forEach((group, index) => {
const child = paths[index + 1];
const { id } = group;
acc[id] = merge(acc[id] || {}, {
...group,
...(child && { children: [child] }),
...(scopes?.length && { scopes }),
});
});

Expand All @@ -290,6 +308,7 @@ export const transformStoriesRawToStoriesHash = (
isLeaf: true,
isComponent: false,
isRoot: false,
...(scopes?.length && { scopes }),
renderLabel,
prepared,
};
Expand All @@ -301,6 +320,11 @@ export const transformStoriesRawToStoriesHash = (
if (!acc[item.id]) {
// If we were already inserted as part of a group, that's great.
acc[item.id] = item;

if (unscopedPaths.has(item.id) && acc[item.id].scopes) {
delete acc[item.id].scopes;
}

const { children } = item;
if (children) {
const childNodes = children.map((id) => storiesHashOutOfOrder[id]) as (Story | Group)[];
Expand Down
2 changes: 1 addition & 1 deletion lib/api/src/modules/globals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { ModuleFn } from '../index';

import { getEventMetadata } from '../lib/events';

interface SetGlobalsPayload {
export interface SetGlobalsPayload {
globals: Globals;
globalTypes: GlobalTypes;
}
Expand Down
78 changes: 78 additions & 0 deletions lib/api/src/modules/scopes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { SET_GLOBALS } from '@storybook/core-events';
import { ModuleFn } from '../index';
import { SetGlobalsPayload } from './globals';

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

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

export type SubState = ScopeState;

export interface SubAPI {
getActiveScope(): Scope['value'];
setActiveScope(scope: Scope['value']): Promise<Scope['value']>;
getScopes(): Scope[];
setScopes(scopes: Scope[]): Promise<Scope[]>;
setScopeGlobal(scope: Scope['value']): Promise<void>;
}

export const init: ModuleFn = ({ store, fullAPI }) => {
const api: SubAPI = {
getActiveScope(): Scope['value'] {
return store.getState().scope.active;
},
async setActiveScope(activeScope: Scope['value']) {
const { scope } = store.getState();
const updatedScope = { ...scope, active: activeScope };
await store.setState({ scope: updatedScope });
return activeScope;
},
getScopes(): Scope[] {
return store.getState().scope?.list ?? [];
},
async setScopes(scopes: Scope[]) {
const { scope } = store.getState();
const updatedScope = { ...scope, list: scopes };
await store.setState({ scope: updatedScope });
return scopes;
},
async setScopeGlobal(scope: Scope['value']) {
this.setActiveScope(scope);
const globals = fullAPI.getGlobals();
fullAPI.updateGlobals({
...globals,
scope,
});
},
};

const state: SubState = {
list: [],
active: undefined,
};

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

await store.setState({
scope: {
list: [],
active: undefined,
},
});
};

return { api, state, init: initModule };
};
127 changes: 127 additions & 0 deletions lib/api/src/tests/scopes.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { SET_GLOBALS } from '@storybook/core-events';

import { init as initScopes } from '../modules/scopes';

let scopesApi;
let scopesInit;
let store;
let provider;
let currentState;

const fullAPI = {
callbacks: {},
on(event, fn) {
this.callbacks[event] = this.callbacks[event] || [];
this.callbacks[event].push(fn);
},
async emit(event, ...args) {
const callbacks = [];
this.callbacks[event]?.forEach((cb) => {
callbacks.push(cb(...args));
});
await Promise.all(callbacks);
},
getGlobals: jest.fn(),
updateGlobals: jest.fn(),
};

beforeEach(async () => {
currentState = {
scope: {
list: [
{ title: 'Scope A', value: 'scope-a' },
{ title: 'Scope B', value: 'scope-b' },
],
},
};
store = {
getState: () => currentState,
setState: jest.fn((patch) => {
currentState = {
...currentState,
...(typeof patch === 'function' ? patch(currentState) : patch),
};
}),
};
provider = { getConfig: jest.fn(() => ({})) };
const { api, init } = initScopes({ store, provider, fullAPI });
scopesApi = api;
scopesInit = init;
});

describe('scope API', () => {
describe('getActiveScope', () => {
it('should return active scope', () => {
const activeScope = 'scope-a';
currentState.scope.active = activeScope;
expect(scopesApi.getActiveScope()).toStrictEqual(activeScope);
});
});

describe('setActiveScope', () => {
it('should set active scope', async () => {
const newScope = 'scope-b';
currentState.scope.active = 'scope-a';
await scopesApi.setActiveScope(newScope);
expect(currentState.scope.active).toStrictEqual(newScope);
await scopesApi.setActiveScope(undefined);
expect(currentState.scope.active).toStrictEqual(undefined);
});
});

describe('getScopes', () => {
it('should return scope list', () => {
expect(scopesApi.getScopes()).toStrictEqual(currentState.scope.list);
});
});

describe('setScopes', () => {
it('should set scope list', async () => {
const newScopes = [
{ title: 'Scope C', value: 'scope-c' },
{ title: 'Scope D', value: 'scope-d' },
];
await scopesApi.setScopes(newScopes);
expect(currentState.scope.list).toStrictEqual(newScopes);
});
});

describe('setScopeGlobal', () => {
it('should update globals and scope state', async () => {
const newScope = 'scope-b';
await scopesApi.setScopeGlobal(newScope);
expect(fullAPI.updateGlobals).toHaveBeenCalledWith(
expect.objectContaining({
scope: newScope,
})
);
expect(currentState.scope.active).toStrictEqual(newScope);
});
});

describe('initModule', () => {
it('starts by setting default scope state', async () => {
await scopesInit();
expect(currentState.scope.list).toStrictEqual([]);
expect(currentState.scope.active).toStrictEqual(undefined);
});

it('adds scopes and sets defaults active scope', async () => {
const newScopes = [
{ title: 'Scope C', value: 'scope-c' },
{ title: 'Scope D', value: 'scope-d' },
];
await scopesInit();
await fullAPI.emit(SET_GLOBALS, {
globalTypes: {
scope: {
list: newScopes,
defaultValue: newScopes[0].value,
},
},
});
expect(currentState.scope.list).toStrictEqual(newScopes);
expect(currentState.scope.active).toStrictEqual(newScopes[0].value);
});
});
});
Loading

0 comments on commit 97e5a5d

Please sign in to comment.