diff --git a/MIGRATION.md b/MIGRATION.md index 51244da8b190..1c3d926b1410 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -11,6 +11,7 @@ - [Rolling back](#rolling-back) - [New addon presets](#new-addon-presets) - [Removed Deprecated APIs](#removed-deprecated-apis) + - [New setStories event](#new-setstories-event) - [Client API changes](#client-api-changes) - [Removed Legacy Story APIs](#removed-legacy-story-apis) - [Can no longer add decorators/parameters after stories](#can-no-longer-add-decoratorsparameters-after-stories) @@ -295,6 +296,31 @@ See the migration guides for further details: - [Unified docs preset](#unified-docs-preset) - [Addon centered decorator deprecated](#addon-centered-decorator-deprecated) +### New setStories event + +The `setStories`/`SET_STORIES` event has changed and now denormalizes global and kind-level parameters. The new format of the event data is: + +```js +{ + globalParameters: { p: 'q' }, + kindParameters: { kind: { p: 'q' } }, + stories: /* as before but with only story-level parameters */ +} +``` + +If you want the full denormalized parameters for a story, you can do something like: + +```js +import { combineParameters } from '@storybook/api'; + +const story = data.stories[storyId]; +const parameters = combineParameters( + data.globalParameters, + data.kindParameters[story.kind], + story.parameters +); +``` + ### Client API changes #### Removed Legacy Story APIs @@ -347,6 +373,10 @@ _You cannot set parameters from decorators_ Parameters are intended to be statically set at story load time. So setting them via a decorator doesn't quite make sense. If you were using this to control the rendering of a story, chances are using the new `args` feature is a more idiomatic way to do this. +_You can only set storySort globally_ + +If you want to change the ordering of stories, use `export const parameters = { options: { storySort: ... } }` in `preview.js`. + ### Simplified Render Context The `RenderContext` that is passed to framework rendering layers in order to render a story has been simplified, dropping a few members that were not used by frameworks to render stories. In particular, the following have been removed: diff --git a/docs/src/pages/configurations/options-parameter/index.md b/docs/src/pages/configurations/options-parameter/index.md index 3695c7c04155..f137efe8d3d9 100644 --- a/docs/src/pages/configurations/options-parameter/index.md +++ b/docs/src/pages/configurations/options-parameter/index.md @@ -77,12 +77,10 @@ addons.setConfig({ ### showRoots -Import and use `addParameters` with the `options` key in your `preview.js` file. +Export `parameters` with the `options` key in your `preview.js` file. ```js -import { addParameters } from '@storybook/react'; - -addParameters({ +export const parameters = { options: { /** * display the top-level grouping as a "root" in the sidebar @@ -100,22 +98,19 @@ By default, stories are sorted in the order in which they were imported. This ca The most powerful method of sorting is to provide a function to `storySort`. Any custom sorting can be achieved with this method. ```js -import { addParameters } from '@storybook/react'; - -addParameters({ +export const parameters = { options: { storySort: (a, b) => a[1].kind === b[1].kind ? 0 : a[1].id.localeCompare(b[1].id, undefined, { numeric: true }), }, -}); +}; ``` The `storySort` can also accept a configuration object. ```js -import { addParameters, configure } from '@storybook/react'; -addParameters({ +export parameters = { options: { storySort: { method: 'alphabetical', // Optional, defaults to 'configure'. @@ -123,7 +118,7 @@ addParameters({ locales: 'en-US', // Optional, defaults to system locale. }, }, -}); +}; ``` To sort your stories alphabetically, set `method` to `'alphabetical'` and optionally set the `locales` string. To sort your stories using a custom list, use the `order` array; stories that don't match an item in the `order` list will appear after the items in the list. @@ -131,15 +126,13 @@ To sort your stories alphabetically, set `method` to `'alphabetical'` and option The `order` array can accept a nested array in order to sort 2nd-level story kinds. For example: ```js -import { addParameters, configure } from '@storybook/react'; - -addParameters({ +export parameters = { options: { storySort: { order: ['Intro', 'Pages', ['Home', 'Login', 'Admin'], 'Components'], }, }, -}); +}; ``` Which would result in this story ordering: diff --git a/examples/angular-cli/src/stories/__snapshots__/core.stories.storyshot b/examples/angular-cli/src/stories/__snapshots__/core.stories.storyshot index 7dc28150d59a..17f58bc133bd 100644 --- a/examples/angular-cli/src/stories/__snapshots__/core.stories.storyshot +++ b/examples/angular-cli/src/stories/__snapshots__/core.stories.storyshot @@ -19,6 +19,7 @@ exports[`Storyshots Core/Parameters passed to story 1`] = ` "globalParameter": "globalParameter", "framework": "angular", "chapterParameter": "chapterParameter", + "argTypes": {}, "storyParameter": "storyParameter", "__id": "core-parameters--passed-to-story" } diff --git a/examples/riot-kitchen-sink/src/stories/__snapshots__/core.stories.storyshot b/examples/riot-kitchen-sink/src/stories/__snapshots__/core.stories.storyshot index 0e6717ef4062..7ecb1475089f 100644 --- a/examples/riot-kitchen-sink/src/stories/__snapshots__/core.stories.storyshot +++ b/examples/riot-kitchen-sink/src/stories/__snapshots__/core.stories.storyshot @@ -12,6 +12,7 @@ exports[`Storyshots Core/Parameters passed to story 1`] = ` "globalParameter": "globalParameter", "framework": "riot", "chapterParameter": "chapterParameter", + "argTypes": {}, "__id": "core-parameters--passed-to-story", "storyParameter": "storyParameter", "id": "root", diff --git a/examples/vue-kitchen-sink/src/stories/__snapshots__/core.stories.storyshot b/examples/vue-kitchen-sink/src/stories/__snapshots__/core.stories.storyshot index cfbb67314b4c..5c813cd2f1d3 100644 --- a/examples/vue-kitchen-sink/src/stories/__snapshots__/core.stories.storyshot +++ b/examples/vue-kitchen-sink/src/stories/__snapshots__/core.stories.storyshot @@ -8,6 +8,7 @@ exports[`Storyshots Core/Parameters passed to story 1`] = ` "globalParameter": "globalParameter", "framework": "vue", "chapterParameter": "chapterParameter", + "argTypes": {}, "storyParameter": "storyParameter", "__id": "core-parameters--passed-to-story" } diff --git a/examples/vue-kitchen-sink/src/stories/__snapshots__/custom-decorators.stories.storyshot b/examples/vue-kitchen-sink/src/stories/__snapshots__/custom-decorators.stories.storyshot index 6225239f379d..b8d70fe82ab0 100644 --- a/examples/vue-kitchen-sink/src/stories/__snapshots__/custom-decorators.stories.storyshot +++ b/examples/vue-kitchen-sink/src/stories/__snapshots__/custom-decorators.stories.storyshot @@ -50,6 +50,7 @@ exports[`Storyshots Custom/Decorator for Vue With Data 1`] = ` "parameters": { "globalParameter": "globalParameter", "framework": "vue", + "argTypes": {}, "__id": "custom-decorator-for-vue--with-data" }, "args": {}, diff --git a/lib/api/src/index.tsx b/lib/api/src/index.tsx index dc8b056dd6ba..1efde6e885bd 100644 --- a/lib/api/src/index.tsx +++ b/lib/api/src/index.tsx @@ -9,12 +9,13 @@ import React, { useMemo, useRef, } from 'react'; +import { mergeWith } from 'lodash'; import { - SET_STORIES, STORY_CHANGED, SHARED_STATE_CHANGED, SHARED_STATE_SET, + SET_STORIES, } from '@storybook/core-events'; import { RenderData as RouterData } from '@storybook/router'; import { Listener } from '@storybook/channels'; @@ -93,9 +94,14 @@ export type ManagerProviderProps = RouterData & children: ReactNode | ((props: Combo) => ReactNode); }; +// These types are duplicated in addons. +export type StoryId = string; +export type StoryKind = string; + export interface Args { [key: string]: any; } + export interface ArgType { name?: string; description?: string; @@ -107,6 +113,19 @@ export interface ArgTypes { [key: string]: ArgType; } +export interface Parameters { + [key: string]: any; +} + +// This is duplicated from @storybook/client-api for the reasons mentioned in lib-addons/types.js +export const combineParameters = (...parameterSets: Parameters[]) => + mergeWith({}, ...parameterSets, (objValue: any, srcValue: any) => { + // Treat arrays as scalars: + if (Array.isArray(srcValue)) return srcValue; + + return undefined; + }); + export type ModuleFn = (m: ModuleArgs) => Module; interface Module { diff --git a/lib/api/src/lib/stories.ts b/lib/api/src/lib/stories.ts index f52e64783e08..b5e9c6e1cfaf 100644 --- a/lib/api/src/lib/stories.ts +++ b/lib/api/src/lib/stories.ts @@ -1,13 +1,14 @@ import deprecate from 'util-deprecate'; import dedent from 'ts-dedent'; import { sanitize, parseKind } from '@storybook/csf'; +import { mapValues } from 'lodash'; -import { Args } from '../index'; +import { StoryId, StoryKind, Args, Parameters, combineParameters } from '../index'; import merge from './merge'; import { Provider } from '../modules/provider'; import { ViewMode } from '../modules/addons'; -export type StoryId = string; +export { StoryId }; export interface Root { id: StoryId; @@ -48,7 +49,7 @@ export interface Story { depth: number; parent: StoryId; name: string; - kind: string; + kind: StoryKind; refId?: string; children?: StoryId[]; isComponent: boolean; @@ -73,7 +74,7 @@ export interface StoryInput { id: StoryId; name: string; refId?: string; - kind: string; + kind: StoryKind; children: string[]; parameters: { fileName: string; @@ -103,6 +104,20 @@ export interface StoriesRaw { [id: string]: StoryInput; } +export interface SetStoriesPayload { + v?: number; + stories: StoriesRaw; +} + +export interface SetStoriesPayloadV2 extends SetStoriesPayload { + v: 2; + globalParameters: Parameters; + kindParameters: { + [kind: string]: Parameters; + }; + stories: StoriesRaw; +} + const warnUsingHierarchySeparatorsAndShowRoots = deprecate( () => {}, dedent` @@ -135,6 +150,21 @@ const toGroup = (name: string) => ({ id: toKey(name), }); +export const denormalizeStoryParameters = ({ + globalParameters, + kindParameters, + stories, +}: SetStoriesPayloadV2): StoriesRaw => { + return mapValues(stories, (storyData) => ({ + ...storyData, + parameters: combineParameters( + globalParameters, + kindParameters[storyData.kind], + (storyData.parameters as unknown) as Parameters + ), + })); +}; + export const transformStoriesRawToStoriesHash = ( input: StoriesRaw, base: StoriesHash, diff --git a/lib/api/src/modules/stories.ts b/lib/api/src/modules/stories.ts index 8bc1c3ea4c95..2774f0cc77f2 100644 --- a/lib/api/src/modules/stories.ts +++ b/lib/api/src/modules/stories.ts @@ -5,21 +5,24 @@ import { UPDATE_STORY_ARGS, STORY_ARGS_UPDATED, STORY_CHANGED, - SET_STORIES, SELECT_STORY, + SET_STORIES, } from '@storybook/core-events'; import { logger } from '@storybook/client-logger'; import { + denormalizeStoryParameters, transformStoriesRawToStoriesHash, StoriesHash, Story, Group, - StoriesRaw, + SetStoriesPayload, StoryId, isStory, Root, isRoot, + StoriesRaw, + SetStoriesPayloadV2, } from '../lib/stories'; import { Args, ModuleFn } from '../index'; @@ -302,30 +305,36 @@ export const init: ModuleFn = ({ } }); - fullAPI.on(SET_STORIES, function handleSetStories(data: { stories: StoriesRaw }) { + fullAPI.on(SET_STORIES, function handleSetStories(data: SetStoriesPayload) { // the event originates from an iframe, event.source is the iframe's location origin + pathname - const { storyId } = store.getState(); const { source }: { source: string } = this; const [sourceType, sourceLocation] = getSourceType(source); + // TODO: what is the mechanism where we warn here? + if (data.v && data.v > 2) + // eslint-disable-next-line no-console + console.warn(`Received SET_STORIES event with version ${data.v}, we'll try and handle it`); + + const stories = data.v + ? denormalizeStoryParameters(data as SetStoriesPayloadV2) + : data.stories; + switch (sourceType) { // if it's a local source, we do nothing special case 'local': { - fullAPI.setStories(data.stories); - const options = storyId - ? fullAPI.getParameters(storyId, 'options') - : fullAPI.getParameters(Object.keys(data.stories)[0], 'options'); - fullAPI.setOptions(options); + if (!data.v) throw new Error('Unexpected legacy SET_STORIES event from local source'); + + fullAPI.setStories(stories); + + fullAPI.setOptions((data as SetStoriesPayloadV2).globalParameters.options); break; } // if it's a ref, we need to map the incoming stories to a prefixed version, so it cannot conflict with others case 'external': { const ref = fullAPI.findRef(sourceLocation); - if (ref) { - console.log('ref2', ref); - fullAPI.setRef(ref.id, { ...ref, ...data }, true); + fullAPI.setRef(ref.id, { ...ref, ...data, stories }, true); break; } } diff --git a/lib/api/src/tests/stories.test.js b/lib/api/src/tests/stories.test.js index 086eea8eb695..419fd1faddf5 100644 --- a/lib/api/src/tests/stories.test.js +++ b/lib/api/src/tests/stories.test.js @@ -1,5 +1,5 @@ import EventEmitter from 'event-emitter'; -import { STORY_ARGS_UPDATED, UPDATE_STORY_ARGS } from '@storybook/core-events'; +import { STORY_ARGS_UPDATED, UPDATE_STORY_ARGS, SET_STORIES } from '@storybook/core-events'; import { init as initStories } from '../modules/stories'; @@ -791,4 +791,118 @@ describe('stories API', () => { }); }); }); + describe('v2 SET_STORIES event', () => { + it('normalizes parameters and calls setStories for local stories', () => { + const fullAPI = { on: jest.fn(), setStories: jest.fn(), setOptions: jest.fn() }; + const navigate = jest.fn(); + const store = createMockStore(); + + const { init } = initStories({ store, navigate, provider, fullAPI }); + init(); + + const onSetStories = fullAPI.on.mock.calls.find(([event]) => event === SET_STORIES)[1]; + + const setStoriesPayload = { + v: 2, + globalParameters: { global: 'global' }, + kindParameters: { a: { kind: 'kind' } }, + stories: { 'a--1': { kind: 'a', parameters: { story: 'story' } } }, + }; + onSetStories.call({ source: 'http://localhost' }, setStoriesPayload); + + expect(fullAPI.setStories).toHaveBeenCalledWith({ + 'a--1': { kind: 'a', parameters: { global: 'global', kind: 'kind', story: 'story' } }, + }); + }); + + it('normalizes parameters and calls setRef for external stories', () => { + const fullAPI = { + on: jest.fn(), + findRef: jest.fn().mockReturnValue({ id: 'ref' }), + setRef: jest.fn(), + }; + const navigate = jest.fn(); + const store = createMockStore(); + + const { init } = initStories({ store, navigate, provider, fullAPI }); + init(); + + const onSetStories = fullAPI.on.mock.calls.find(([event]) => event === SET_STORIES)[1]; + + const setStoriesPayload = { + v: 2, + globalParameters: { global: 'global' }, + kindParameters: { a: { kind: 'kind' } }, + stories: { 'a--1': { kind: 'a', parameters: { story: 'story' } } }, + }; + onSetStories.call({ source: 'http://external' }, setStoriesPayload); + + expect(fullAPI.setRef).toHaveBeenCalledWith( + 'ref', + { + id: 'ref', + v: 2, + globalParameters: { global: 'global' }, + kindParameters: { a: { kind: 'kind' } }, + stories: { + 'a--1': { kind: 'a', parameters: { global: 'global', kind: 'kind', story: 'story' } }, + }, + }, + true + ); + }); + + it('calls setOptions with global options parameters', () => { + const fullAPI = { on: jest.fn(), setStories: jest.fn(), setOptions: jest.fn() }; + const navigate = jest.fn(); + const store = createMockStore(); + + const { init } = initStories({ store, navigate, provider, fullAPI }); + init(); + + const onSetStories = fullAPI.on.mock.calls.find(([event]) => event === SET_STORIES)[1]; + + const setStoriesPayload = { + v: 2, + globalParameters: { options: 'options' }, + kindParameters: { a: { options: 'should-be-ignored' } }, + stories: { 'a--1': { kind: 'a', parameters: { options: 'should-be-ignored-also' } } }, + }; + onSetStories.call({ source: 'http://localhost' }, setStoriesPayload); + + expect(fullAPI.setOptions).toHaveBeenCalledWith('options'); + }); + }); + describe('legacy (v1) SET_STORIES event', () => { + it('calls setRef with stories', () => { + const fullAPI = { + on: jest.fn(), + findRef: jest.fn().mockReturnValue({ id: 'ref' }), + setRef: jest.fn(), + }; + const navigate = jest.fn(); + const store = createMockStore(); + + const { init } = initStories({ store, navigate, provider, fullAPI }); + init(); + + const onSetStories = fullAPI.on.mock.calls.find(([event]) => event === SET_STORIES)[1]; + + const setStoriesPayload = { + stories: { 'a--1': {} }, + }; + onSetStories.call({ source: 'http://external' }, setStoriesPayload); + + expect(fullAPI.setRef).toHaveBeenCalledWith( + 'ref', + { + id: 'ref', + stories: { + 'a--1': {}, + }, + }, + true + ); + }); + }); }); diff --git a/lib/client-api/src/client_api.test.ts b/lib/client-api/src/client_api.test.ts index de8aed63283a..ddeed2dc0791 100644 --- a/lib/client-api/src/client_api.test.ts +++ b/lib/client-api/src/client_api.test.ts @@ -120,7 +120,7 @@ describe('preview.client_api', () => { const result = storyStore.fromId('kind--name').storyFn(); // @ts-ignore - const { docs, fileName, options, ...rest } = result; + const { docs, fileName, options, argTypes, ...rest } = result; expect(rest).toEqual({ a: 1 }); }); @@ -601,6 +601,7 @@ describe('preview.client_api', () => { b: 'kind', c: 'story', fileName: expect.any(String), + argTypes: {}, }); }); @@ -653,6 +654,7 @@ describe('preview.client_api', () => { }, }, fileName: expect.any(String), + argTypes: {}, }); }); }); diff --git a/lib/client-api/src/story_store.test.ts b/lib/client-api/src/story_store.test.ts index 690b36d63ac5..4fcb0602f1c3 100644 --- a/lib/client-api/src/story_store.test.ts +++ b/lib/client-api/src/story_store.test.ts @@ -37,7 +37,7 @@ const addStoryToStore = (store, kind, name, storyFn, parameters = {}) => describe('preview.story_store', () => { describe('extract', () => { - it('produces stories objects with inherited metadata', () => { + it('produces stories objects with inherited (denormalized) metadata', () => { const store = new StoryStore({ channel }); store.addGlobalMetadata({ parameters: { global: 'global' }, decorators: [] }); @@ -63,6 +63,33 @@ describe('preview.story_store', () => { }); }); + describe('getDataForManager', () => { + it('produces stories objects with normalized metadata', () => { + const store = new StoryStore({ channel }); + + store.addGlobalMetadata({ parameters: { global: 'global' }, decorators: [] }); + + store.addKindMetadata('a', { parameters: { kind: 'kind' }, decorators: [] }); + + addStoryToStore(store, 'a', '1', () => 0, { story: 'story' }); + + const { v, globalParameters, kindParameters, stories } = store.getDataForManager(); + + expect(v).toBe(2); + expect(globalParameters).toEqual({ global: 'global' }); + expect(Object.keys(kindParameters)).toEqual(['a']); + expect(kindParameters.a).toEqual({ kind: 'kind' }); + + expect(Object.keys(stories)).toEqual(['a--1']); + expect(stories['a--1']).toMatchObject({ + id: 'a--1', + kind: 'a', + name: '1', + parameters: { story: 'story' }, + }); + }); + }); + describe('getRawStory', () => { it('produces a story with inherited decorators applied', () => { const store = new StoryStore({ channel }); @@ -427,23 +454,26 @@ describe('preview.story_store', () => { describe('storySort', () => { it('sorts stories using given function', () => { - const parameters = { - options: { - // Test function does reverse alphabetical ordering. - storySort: (a: any, b: any): number => - a[1].kind === b[1].kind - ? 0 - : -1 * a[1].id.localeCompare(b[1].id, undefined, { numeric: true }), - }, - }; const store = new StoryStore({ channel }); - 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); + store.addGlobalMetadata({ + decorators: [], + parameters: { + options: { + // Test function does reverse alphabetical ordering. + storySort: (a: any, b: any): number => + a[1].kind === b[1].kind + ? 0 + : -1 * a[1].id.localeCompare(b[1].id, undefined, { numeric: true }), + }, + }, + }); + addStoryToStore(store, 'a/a', '1', () => 0); + addStoryToStore(store, 'a/a', '2', () => 0); + addStoryToStore(store, 'a/b', '1', () => 0); + addStoryToStore(store, 'b/b1', '1', () => 0); + addStoryToStore(store, 'b/b10', '1', () => 0); + addStoryToStore(store, 'b/b9', '1', () => 0); + addStoryToStore(store, 'c', '1', () => 0); const extracted = store.extract(); @@ -459,21 +489,24 @@ describe('preview.story_store', () => { }); it('sorts stories alphabetically', () => { - const parameters = { - options: { - storySort: { - method: 'alphabetical', + const store = new StoryStore({ channel }); + store.addGlobalMetadata({ + decorators: [], + parameters: { + options: { + storySort: { + method: 'alphabetical', + }, }, }, - }; - const store = new StoryStore({ channel }); - 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); + }); + addStoryToStore(store, 'a/b', '1', () => 0); + addStoryToStore(store, 'a/a', '2', () => 0); + addStoryToStore(store, 'a/a', '1', () => 0); + addStoryToStore(store, 'c', '1', () => 0); + addStoryToStore(store, 'b/b10', '1', () => 0); + addStoryToStore(store, 'b/b9', '1', () => 0); + addStoryToStore(store, 'b/b1', '1', () => 0); const extracted = store.extract(); @@ -489,23 +522,26 @@ describe('preview.story_store', () => { }); it('sorts stories in specified order or alphabetically', () => { - const parameters = { - options: { - storySort: { - method: 'alphabetical', - order: ['b', ['bc', 'ba', 'bb'], 'a', 'c'], + const store = new StoryStore({ channel }); + store.addGlobalMetadata({ + decorators: [], + parameters: { + options: { + storySort: { + method: 'alphabetical', + order: ['b', ['bc', 'ba', 'bb'], 'a', 'c'], + }, }, }, - }; - const store = new StoryStore({ channel }); - 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); + }); + addStoryToStore(store, 'a/b', '1', () => 0); + addStoryToStore(store, 'a', '1', () => 0); + addStoryToStore(store, 'c', '1', () => 0); + addStoryToStore(store, 'b/bd', '1', () => 0); + addStoryToStore(store, 'b/bb', '1', () => 0); + addStoryToStore(store, 'b/ba', '1', () => 0); + addStoryToStore(store, 'b/bc', '1', () => 0); + addStoryToStore(store, 'b', '1', () => 0); const extracted = store.extract(); @@ -522,23 +558,26 @@ describe('preview.story_store', () => { }); it('sorts stories in specified order or by configure order', () => { - const parameters = { - options: { - storySort: { - method: 'configure', - order: ['b', 'a', 'c'], + const store = new StoryStore({ channel }); + store.addGlobalMetadata({ + decorators: [], + parameters: { + options: { + storySort: { + method: 'configure', + order: ['b', 'a', 'c'], + }, }, }, - }; - const store = new StoryStore({ channel }); - 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); + }); + addStoryToStore(store, 'a/b', '1', () => 0); + addStoryToStore(store, 'a', '1', () => 0); + addStoryToStore(store, 'c', '1', () => 0); + addStoryToStore(store, 'b/bd', '1', () => 0); + addStoryToStore(store, 'b/bb', '1', () => 0); + addStoryToStore(store, 'b/ba', '1', () => 0); + addStoryToStore(store, 'b/bc', '1', () => 0); + addStoryToStore(store, 'b', '1', () => 0); const extracted = store.extract(); @@ -611,6 +650,9 @@ describe('preview.story_store', () => { store.finishConfiguring(); expect(onSetStories).toHaveBeenCalledWith({ + v: 2, + globalParameters: {}, + kindParameters: { a: {} }, stories: { 'a--1': expect.objectContaining({ id: 'a--1', @@ -625,7 +667,12 @@ describe('preview.story_store', () => { const store = new StoryStore({ channel }); store.finishConfiguring(); - expect(onSetStories).toHaveBeenCalledWith({ stories: {} }); + expect(onSetStories).toHaveBeenCalledWith({ + v: 2, + globalParameters: {}, + kindParameters: {}, + stories: {}, + }); }); it('allows configuration as second time (HMR)', () => { @@ -640,6 +687,9 @@ describe('preview.story_store', () => { store.finishConfiguring(); expect(onSetStories).toHaveBeenCalledWith({ + v: 2, + globalParameters: {}, + kindParameters: { a: {} }, stories: { 'a--1': expect.objectContaining({ id: 'a--1', @@ -669,6 +719,9 @@ describe('preview.story_store', () => { store.finishConfiguring(); expect(onSetStories).toHaveBeenCalledWith({ + v: 2, + globalParameters: {}, + kindParameters: { 'kind-1': {} }, stories: { 'kind-1--story-1-2': expect.objectContaining({ id: 'kind-1--story-1-2', @@ -701,6 +754,9 @@ describe('preview.story_store', () => { store.finishConfiguring(); expect(onSetStories).toHaveBeenCalledWith({ + v: 2, + globalParameters: {}, + kindParameters: { 'kind-1': {}, 'kind-2': {} }, stories: { 'kind-2--story-2-1': expect.objectContaining({ id: 'kind-2--story-2-1', diff --git a/lib/client-api/src/story_store.ts b/lib/client-api/src/story_store.ts index 2300868af042..98234d8ec6e8 100644 --- a/lib/client-api/src/story_store.ts +++ b/lib/client-api/src/story_store.ts @@ -2,6 +2,7 @@ import memoize from 'memoizerific'; import dedent from 'ts-dedent'; import stable from 'stable'; +import { mapValues } from 'lodash'; import { Channel } from '@storybook/channels'; import Events from '@storybook/core-events'; @@ -15,6 +16,7 @@ import { LegacyStoryFn, ArgsStoryFn, StoryContext, + StoryKind, } from '@storybook/addons'; import { DecoratorFunction, @@ -68,6 +70,11 @@ const checkGlobalArgs = (parameters: Parameters) => { } }; +const checkStorySort = (parameters: Parameters) => { + const { options } = parameters; + if (options?.storySort) logger.error('The storySort option parameter can only be set globally'); +}; + type AllowUnsafeOption = { allowUnsafe?: boolean }; const toExtracted = (obj: T) => @@ -213,7 +220,10 @@ export default class StoryStore { addKindMetadata(kind: string, { parameters, decorators }: StoryMetadata) { this.ensureKind(kind); - if (parameters) checkGlobalArgs(parameters); + if (parameters) { + checkGlobalArgs(parameters); + checkStorySort(parameters); + } this._kinds[kind].parameters = combineParameters(this._kinds[kind].parameters, parameters); this._kinds[kind].decorators.push(...decorators); @@ -226,6 +236,15 @@ export default class StoryStore { this._argTypesEnhancers.push(argTypesEnhancer); } + // Combine the global, kind & story parameters of a story + combineStoryParameters(parameters: Parameters, kind: StoryKind) { + return combineParameters( + this._globalMetadata.parameters, + this._kinds[kind].parameters, + parameters + ); + } + addStory( { id, @@ -247,7 +266,10 @@ export default class StoryStore { 'Cannot add a story when not configuring, see https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#story-store-immutable-outside-of-configuration' ); - if (storyParameters) checkGlobalArgs(storyParameters); + if (storyParameters) { + checkGlobalArgs(storyParameters); + checkStorySort(storyParameters); + } const { _stories } = this; @@ -277,14 +299,26 @@ export default class StoryStore { ...kindMetadata.decorators, ...this._globalMetadata.decorators, ]; - const parametersBeforeEnhancement = combineParameters( - this._globalMetadata.parameters, - kindMetadata.parameters, - storyParameters + + const finalStoryFn = (context: StoryContext) => { + const { passArgsFirst } = context.parameters; + return passArgsFirst || typeof passArgsFirst === 'undefined' + ? (original as ArgsStoryFn)(context.args, context) + : original(context); + }; + + // lazily decorate the story when it's loaded + const getDecorated: () => LegacyStoryFn = memoize(1)(() => + applyDecorators(finalStoryFn, decorators) ); - const parameters = this._argTypesEnhancers.reduce( - (accumlatedParameters, enhancer) => ({ + const hooks = new HooksContext(); + + // We need the combined parameters now in order to calculate argTypes, but we won't keep them + const combinedParameters = this.combineStoryParameters(storyParameters, kind); + + const { argTypes = {} } = this._argTypesEnhancers.reduce( + (accumlatedParameters: Parameters, enhancer) => ({ ...accumlatedParameters, argTypes: enhancer({ ...identification, @@ -294,45 +328,30 @@ export default class StoryStore { globalArgs: {}, }), }), - parametersBeforeEnhancement - ); - - let finalStoryFn: LegacyStoryFn; - const { passArgsFirst } = parameters; - if (passArgsFirst || typeof passArgsFirst === 'undefined') { - finalStoryFn = (context: StoryContext) => (original as ArgsStoryFn)(context.args, context); - } else { - finalStoryFn = original as LegacyStoryFn; - } - - // lazily decorate the story when it's loaded - const getDecorated: () => LegacyStoryFn = memoize(1)(() => - applyDecorators(finalStoryFn, decorators) + combinedParameters ); - const hooks = new HooksContext(); + const storyParametersWithArgTypes = { ...storyParameters, argTypes }; const storyFn: LegacyStoryFn = (runtimeContext: StoryContext) => getDecorated()({ ...identification, ...runtimeContext, - parameters, + // Calculate "combined" parameters at render time (NOTE: for perf we could just use combinedParameters from above?) + parameters: this.combineStoryParameters(storyParametersWithArgTypes, kind), hooks, args: _stories[id].args, globalArgs: this._globalArgs, }); // Pull out parameters.args.$ || .argTypes.$.defaultValue into initialArgs - const initialArgs: Args = parameters.args || {}; - const defaultArgs: Args = parameters.argTypes - ? Object.entries(parameters.argTypes as Record).reduce( - (acc, [arg, { defaultValue }]) => { - if (defaultValue) acc[arg] = defaultValue; - return acc; - }, - {} as Args - ) - : {}; + const initialArgs: Args = combinedParameters.args; + const defaultArgs: Args = Object.entries( + argTypes as Record + ).reduce((acc, [arg, { defaultValue }]) => { + if (defaultValue) acc[arg] = defaultValue; + return acc; + }, {} as Args); _stories[id] = { ...identification, @@ -342,7 +361,7 @@ export default class StoryStore { getOriginal, storyFn, - parameters, + parameters: { ...storyParameters, argTypes }, args: { ...defaultArgs, ...initialArgs }, }; } @@ -402,6 +421,7 @@ export default class StoryStore { return { ...data, + parameters: this.combineStoryParameters(data.parameters, data.kind), globalArgs: this._globalArgs, }; } catch (e) { @@ -418,41 +438,41 @@ export default class StoryStore { .map(({ id }) => this.fromId(id)); } - extract(options?: StoryOptions) { + extract(options: StoryOptions & { normalizeParameters?: boolean } = {}) { const stories = Object.entries(this._stories); - // determine if we should apply a sort to the stories or use default import order - if (Object.values(this._stories).length > 0) { - const index = Object.keys(this._stories).find( - (key) => - !!( - this._stories[key] && - this._stories[key].parameters && - this._stories[key].parameters.options - ) - ); - if (index && this._stories[index].parameters.options.storySort) { - const storySortParameter = this._stories[index].parameters.options.storySort; - let sortFn: Comparator; - if (typeof storySortParameter === 'function') { - sortFn = storySortParameter; - } else { - sortFn = storySort(storySortParameter); - } - stable.inplace(stories, sortFn); + + const storySortParameter = this._globalMetadata.parameters?.options?.storySort; + if (storySortParameter) { + let sortFn: Comparator; + if (typeof storySortParameter === 'function') { + sortFn = storySortParameter; } else { - // NOTE: when kinds are HMR'ed they get temporarily removed from the `_stories` array - // and thus lose order. However `_kindOrder` preservers the original load order - stable.inplace( - stories, - (s1, s2) => this._kinds[s1[1].kind].order - this._kinds[s2[1].kind].order - ); + sortFn = storySort(storySortParameter); } + stable.inplace(stories, sortFn); + } else { + // NOTE: when kinds are HMR'ed they get temporarily removed from the `_stories` array + // and thus lose order. However `_kindOrder` preservers the original load order + stable.inplace( + stories, + (s1, s2) => this._kinds[s1[1].kind].order - this._kinds[s2[1].kind].order + ); } + // removes function values from all stories so they are safe to transport over the channel - return stories.reduce( - (a, [k, v]) => (includeStory(v, options) ? Object.assign(a, { [k]: toExtracted(v) }) : a), - {} - ); + return stories.reduce((acc, [id, story]) => { + if (!includeStory(story, options)) return acc; + + const extracted = toExtracted(story); + if (options.normalizeParameters) return Object.assign(acc, { [id]: extracted }); + + const { parameters, kind } = extracted as { parameters: Parameters; kind: StoryKind }; + return Object.assign(acc, { + [id]: Object.assign(extracted, { + parameters: this.combineStoryParameters(parameters, kind), + }), + }); + }, {}); } clearError() { @@ -480,16 +500,19 @@ export default class StoryStore { getSelection = (): Selection => this._selection; - getStoriesForManager = () => { - return this.extract({ includeDocsOnly: true }); + getDataForManager = () => { + return { + v: 2, + globalParameters: this._globalMetadata.parameters, + kindParameters: mapValues(this._kinds, (metadata) => metadata.parameters), + stories: this.extract({ includeDocsOnly: true, normalizeParameters: true }), + }; }; pushToManager = () => { if (this._channel) { - const stories = this.getStoriesForManager(); - // send to the parent frame. - this._channel.emit(Events.SET_STORIES, { stories }); + this._channel.emit(Events.SET_STORIES, this.getDataForManager()); } }; diff --git a/lib/core/src/client/preview/StoryRenderer.test.ts b/lib/core/src/client/preview/StoryRenderer.test.ts index c452dc878fdd..24b181d79d57 100644 --- a/lib/core/src/client/preview/StoryRenderer.test.ts +++ b/lib/core/src/client/preview/StoryRenderer.test.ts @@ -75,7 +75,7 @@ describe('core.preview.StoryRenderer', () => { id: 'a--1', kind: 'a', name: '1', - parameters: { p: 'q' }, + parameters: { argTypes: {}, p: 'q' }, forceRender: false, showMain: expect.any(Function),