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

Core: Add story state to store #9817

Closed
wants to merge 8 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions examples/official-storybook/stories/core/state.stories.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import React from 'react';
import PropTypes from 'prop-types';

export default {
title: 'Core/State',
};

export const PassedToStory = ({ state: { name } }) => (
<pre>The value of the name field is {name}</pre>
);

PassedToStory.propTypes = {
state: PropTypes.shape({
name: PropTypes.string,
}).isRequired,
};
192 changes: 114 additions & 78 deletions lib/client-api/src/story_store.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import createChannel from '@storybook/channel-postmessage';
import { toId } from '@storybook/csf';
import addons from '@storybook/addons';
import addons, { mockChannel } from '@storybook/addons';
import Events from '@storybook/core-events';

import StoryStore from './story_store';
Expand All @@ -16,27 +16,29 @@ jest.mock('@storybook/node-logger', () => ({

const channel = createChannel({ page: 'preview' });

const make = (kind, name, storyFn, parameters = {}) => [
{
kind,
name,
storyFn,
parameters,
id: toId(kind, name),
},
{
applyDecorators: defaultDecorateStory,
getDecorators: () => [],
},
];
// make a story and add it to the store
const addStoryToStore = (store, kind, name, storyFn, parameters = {}) =>
store.addStory(
{
kind,
name,
storyFn,
parameters,
id: toId(kind, name),
},
{
applyDecorators: defaultDecorateStory,
getDecorators: () => [],
}
);

describe('preview.story_store', () => {
describe('raw storage', () => {
it('stores hash object', () => {
const store = new StoryStore({ channel });
store.addStory(...make('a', '1', () => 0), undefined);
store.addStory(...make('a', '2', () => 0));
store.addStory(...make('b', '1', () => 0));
addStoryToStore(store, 'a', '1', () => 0);
addStoryToStore(store, 'a', '2', () => 0);
addStoryToStore(store, 'b', '1', () => 0);

const extracted = store.extract();

Expand All @@ -49,10 +51,68 @@ describe('preview.story_store', () => {
kind: 'a',
name: '1',
parameters: expect.any(Object),
state: {},
});
});
});

describe('state', () => {
it('setStoryState changes the state of a story, per-key', () => {
const store = new StoryStore({ channel });
addStoryToStore(store, 'a', '1', () => 0);
expect(store.getRawStory('a', '1').state).toEqual({});

store.setStoryState('a--1', { foo: 'bar' });
expect(store.getRawStory('a', '1').state).toEqual({ foo: 'bar' });

store.setStoryState('a--1', { baz: 'bing' });
expect(store.getRawStory('a', '1').state).toEqual({ foo: 'bar', baz: 'bing' });
});

it('is passed to the story in the context', () => {
const storyFn = jest.fn();
const store = new StoryStore({ channel });
addStoryToStore(store, 'a', '1', storyFn);
store.setStoryState('a--1', { foo: 'bar' });
store.getRawStory('a', '1').storyFn();

expect(storyFn).toHaveBeenCalledWith(
expect.objectContaining({
state: { foo: 'bar' },
})
);
});

it('setStoryState emits STORY_STATE_CHANGED', () => {
const onStateChangedChannel = jest.fn();
const onStateChangedStore = jest.fn();
const testChannel = mockChannel();
testChannel.on(Events.STORY_STATE_CHANGED, onStateChangedChannel);

const store = new StoryStore({ channel: testChannel });
store.on(Events.STORY_STATE_CHANGED, onStateChangedStore);
addStoryToStore(store, 'a', '1', () => 0);

store.setStoryState('a--1', { foo: 'bar' });
expect(onStateChangedChannel).toHaveBeenCalledWith('a--1', { foo: 'bar' });
expect(onStateChangedStore).toHaveBeenCalledWith('a--1', { foo: 'bar' });

store.setStoryState('a--1', { baz: 'bing' });
expect(onStateChangedChannel).toHaveBeenCalledWith('a--1', { foo: 'bar', baz: 'bing' });
expect(onStateChangedStore).toHaveBeenCalledWith('a--1', { foo: 'bar', baz: 'bing' });
});

it('should update if the CHANGE_STORY_STATE event is received', () => {
const testChannel = mockChannel();
const store = new StoryStore({ channel: testChannel });
addStoryToStore(store, 'a', '1', () => 0);

testChannel.emit(Events.CHANGE_STORY_STATE, 'a--1', { foo: 'bar' });

expect(store.getRawStory('a', '1').state).toEqual({ foo: 'bar' });
});
});

describe('storySort', () => {
it('sorts stories using given function', () => {
const parameters = {
Expand All @@ -65,13 +125,13 @@ describe('preview.story_store', () => {
},
};
const store = new StoryStore({ channel });
store.addStory(...make('a/a', '1', () => 0, parameters));
store.addStory(...make('a/a', '2', () => 0, parameters));
store.addStory(...make('a/b', '1', () => 0, parameters));
store.addStory(...make('b/b1', '1', () => 0, parameters));
store.addStory(...make('b/b10', '1', () => 0, parameters));
store.addStory(...make('b/b9', '1', () => 0, parameters));
store.addStory(...make('c', '1', () => 0, parameters));
addStoryToStore(store, 'a/a', '1', () => 0, parameters);
addStoryToStore(store, 'a/a', '2', () => 0, parameters);
addStoryToStore(store, 'a/b', '1', () => 0, parameters);
addStoryToStore(store, 'b/b1', '1', () => 0, parameters);
addStoryToStore(store, 'b/b10', '1', () => 0, parameters);
addStoryToStore(store, 'b/b9', '1', () => 0, parameters);
addStoryToStore(store, 'c', '1', () => 0, parameters);

const extracted = store.extract();

Expand All @@ -95,13 +155,13 @@ describe('preview.story_store', () => {
},
};
const store = new StoryStore({ channel });
store.addStory(...make('a/b', '1', () => 0, parameters));
store.addStory(...make('a/a', '2', () => 0, parameters));
store.addStory(...make('a/a', '1', () => 0, parameters));
store.addStory(...make('c', '1', () => 0, parameters));
store.addStory(...make('b/b10', '1', () => 0, parameters));
store.addStory(...make('b/b9', '1', () => 0, parameters));
store.addStory(...make('b/b1', '1', () => 0, parameters));
addStoryToStore(store, 'a/b', '1', () => 0, parameters);
addStoryToStore(store, 'a/a', '2', () => 0, parameters);
addStoryToStore(store, 'a/a', '1', () => 0, parameters);
addStoryToStore(store, 'c', '1', () => 0, parameters);
addStoryToStore(store, 'b/b10', '1', () => 0, parameters);
addStoryToStore(store, 'b/b9', '1', () => 0, parameters);
addStoryToStore(store, 'b/b1', '1', () => 0, parameters);

const extracted = store.extract();

Expand All @@ -126,14 +186,14 @@ describe('preview.story_store', () => {
},
};
const store = new StoryStore({ channel });
store.addStory(...make('a/b', '1', () => 0, parameters));
store.addStory(...make('a', '1', () => 0, parameters));
store.addStory(...make('c', '1', () => 0, parameters));
store.addStory(...make('b/bd', '1', () => 0, parameters));
store.addStory(...make('b/bb', '1', () => 0, parameters));
store.addStory(...make('b/ba', '1', () => 0, parameters));
store.addStory(...make('b/bc', '1', () => 0, parameters));
store.addStory(...make('b', '1', () => 0, parameters));
addStoryToStore(store, 'a/b', '1', () => 0, parameters);
addStoryToStore(store, 'a', '1', () => 0, parameters);
addStoryToStore(store, 'c', '1', () => 0, parameters);
addStoryToStore(store, 'b/bd', '1', () => 0, parameters);
addStoryToStore(store, 'b/bb', '1', () => 0, parameters);
addStoryToStore(store, 'b/ba', '1', () => 0, parameters);
addStoryToStore(store, 'b/bc', '1', () => 0, parameters);
addStoryToStore(store, 'b', '1', () => 0, parameters);

const extracted = store.extract();

Expand All @@ -159,14 +219,14 @@ describe('preview.story_store', () => {
},
};
const store = new StoryStore({ channel });
store.addStory(...make('a/b', '1', () => 0, parameters));
store.addStory(...make('a', '1', () => 0, parameters));
store.addStory(...make('c', '1', () => 0, parameters));
store.addStory(...make('b/bd', '1', () => 0, parameters));
store.addStory(...make('b/bb', '1', () => 0, parameters));
store.addStory(...make('b/ba', '1', () => 0, parameters));
store.addStory(...make('b/bc', '1', () => 0, parameters));
store.addStory(...make('b', '1', () => 0, parameters));
addStoryToStore(store, 'a/b', '1', () => 0, parameters);
addStoryToStore(store, 'a', '1', () => 0, parameters);
addStoryToStore(store, 'c', '1', () => 0, parameters);
addStoryToStore(store, 'b/bd', '1', () => 0, parameters);
addStoryToStore(store, 'b/bb', '1', () => 0, parameters);
addStoryToStore(store, 'b/ba', '1', () => 0, parameters);
addStoryToStore(store, 'b/bc', '1', () => 0, parameters);
addStoryToStore(store, 'b', '1', () => 0, parameters);

const extracted = store.extract();

Expand Down Expand Up @@ -224,17 +284,18 @@ describe('preview.story_store', () => {
});

describe('removeStoryKind', () => {
// eslint-disable-next-line jest/expect-expect
it('should not error even if there is no kind', () => {
const store = new StoryStore({ channel });
store.removeStoryKind('kind');
});
it('should remove the kind', () => {
const store = new StoryStore({ channel });
addons.setChannel(channel);
store.addStory(...make('kind-1', 'story-1.1', () => 0));
store.addStory(...make('kind-1', 'story-1.2', () => 0));
store.addStory(...make('kind-2', 'story-2.1', () => 0));
store.addStory(...make('kind-2', 'story-2.2', () => 0));
addStoryToStore(store, 'kind-1', 'story-1.1', () => 0);
addStoryToStore(store, 'kind-1', 'story-1.2', () => 0);
addStoryToStore(store, 'kind-2', 'story-2.1', () => 0);
addStoryToStore(store, 'kind-2', 'story-2.2', () => 0);

store.removeStoryKind('kind-1');

Expand All @@ -248,8 +309,8 @@ describe('preview.story_store', () => {
it('should remove the story', () => {
const store = new StoryStore({ channel });
addons.setChannel(channel);
store.addStory(...make('kind-1', 'story-1.1', () => 0));
store.addStory(...make('kind-1', 'story-1.2', () => 0));
addStoryToStore(store, 'kind-1', 'story-1.1', () => 0);
addStoryToStore(store, 'kind-1', 'story-1.2', () => 0);

store.remove(toId('kind-1', 'story-1.1'));

Expand All @@ -258,29 +319,4 @@ describe('preview.story_store', () => {
expect(store.fromId(toId('kind-1', 'story-1.2'))).toBeTruthy();
});
});

describe('story sorting', () => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a whole other, larger sorting test.

const storySort = (a, b) => a[1].id.localeCompare(b[1].id);
it('should use the sorting function of the story parameter object', () => {
const store = new StoryStore({ channel });
store.addStory(
...make('kind-2', 'a-story-2.1', () => 0, { fileName: 'bar.js', options: { storySort } })
);
store.addStory(
...make('kind-1', 'z-story-1.1', () => 0, { fileName: 'foo.js', options: { storySort } })
);
store.addStory(
...make('kind-1', 'story-1.2', () => 0, { fileName: 'foo-2.js', options: { storySort } })
);
store.addStory(
...make('kind-2', 'story-2.1', () => 0, { fileName: 'bar.js', options: { storySort } })
);

const stories = Object.values(store.extract()) as any[];
expect(stories[0].id).toBe('kind-1--story-1-2');
expect(stories[1].id).toBe('kind-1--z-story-1-1');
expect(stories[2].id).toBe('kind-2--a-story-2-1');
expect(stories[3].id).toBe('kind-2--story-2-1');
});
});
});
32 changes: 19 additions & 13 deletions lib/client-api/src/story_store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,24 +14,13 @@ import {
StoreData,
AddStoryArgs,
StoreItem,
StoryState,
ErrorLike,
GetStorybookKind,
} from './types';
import { HooksContext } from './hooks';
import storySort from './storySort';

// TODO: these are copies from components/nav/lib
// refactor to DRY
const toKey = (input: string) =>
input.replace(/[^a-z0-9]+([a-z0-9])/gi, (...params) => params[1].toUpperCase());

let count = 0;

const getId = (): number => {
count += 1;
return count;
};

tmeasday marked this conversation as resolved.
Show resolved Hide resolved
const toExtracted = <T>(obj: T) =>
Object.entries(obj).reduce((acc, [key, value]) => {
if (typeof value === 'function') {
Expand Down Expand Up @@ -87,13 +76,17 @@ export default class StoryStore extends EventEmitter {
this._data = {} as any;
this._revision = 0;
this._selection = {} as any;
this._channel = params.channel;
this._error = undefined;
this._kindOrder = {};

if (params.channel) this.setChannel(params.channel);
}

setChannel = (channel: Channel) => {
this._channel = channel;
channel.on(Events.CHANGE_STORY_STATE, (id: string, newState: StoryState) =>
this.setStoryState(id, newState)
);
};

// NEW apis
Expand Down Expand Up @@ -232,6 +225,7 @@ export default class StoryStore extends EventEmitter {
...p,
hooks,
parameters: { ...parameters, ...(p && p.parameters) },
state: _data[id].state,
});

_data[id] = {
Expand All @@ -243,6 +237,7 @@ export default class StoryStore extends EventEmitter {
storyFn,

parameters,
state: {},
};

// Store 1-based order of kind loading to preserve sorting on HMR
Expand Down Expand Up @@ -308,6 +303,17 @@ export default class StoryStore extends EventEmitter {
this.pushToManager();
}

setStoryState(id: string, newState: StoryState) {
if (!this._data[id]) throw new Error(`No story for id ${id}`);
const { state } = this._data[id];
this._data[id].state = { ...state, ...newState };
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any way to delete something from state? Do we care?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not currently. Let's wait until that use case comes up, maybe?


// TODO: Sort out what is going on with both the store and the channel being event emitters.
// It has something to do with React Native, but need to get to the bottom of it
this._channel.emit(Events.STORY_STATE_CHANGED, id, this._data[id].state);
this.emit(Events.STORY_STATE_CHANGED, id, this._data[id].state);
}

// This API is a reimplementation of Storybook's original getStorybook() API.
// As such it may not behave *exactly* the same, but aims to. Some notes:
// - It is *NOT* sorted by the user's sort function, but remains sorted in "insertion order"
Expand Down
10 changes: 10 additions & 0 deletions lib/client-api/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export interface StoreItem extends StoryContext {
story: string;
storyFn: StoryFn;
hooks: HooksContext;
parameters: StoryParameters;
state: StoryState;
}

export interface StoreData {
Expand All @@ -43,6 +45,14 @@ export interface AddStoryArgs {
parameters: Parameters;
}

export interface StoryParameters {
[key: string]: any;
}

export interface StoryState {
[key: string]: any;
}

export interface ClientApiAddon<StoryFnReturnType = unknown> extends Addon {
apply: (a: StoryApi<StoryFnReturnType>, b: any[]) => any;
}
Expand Down
Loading