diff --git a/code/lib/api/src/lib/stories.ts b/code/lib/api/src/lib/stories.ts index 4d07501514fa..f4c2d73f7d38 100644 --- a/code/lib/api/src/lib/stories.ts +++ b/code/lib/api/src/lib/stories.ts @@ -3,7 +3,7 @@ import React from 'react'; import { dedent } from 'ts-dedent'; import mapValues from 'lodash/mapValues'; import countBy from 'lodash/countBy'; -import { toId, sanitize } from '@storybook/csf'; +import { sanitize } from '@storybook/csf'; import type { StoryId, ComponentTitle, @@ -235,7 +235,7 @@ export const denormalizeStoryParameters = ({ const TITLE_PATH_SEPARATOR = /\s*\/\s*/; -// We used to received a bit more data over the channel on the SET_STORIES event, including +// We recieve a bit more data over the channel on the SET_INDEX event (v6 store), including // the full parameters for each story. type PreparedIndexEntry = IndexEntry & { parameters?: Parameters; @@ -252,19 +252,14 @@ export const transformSetStoriesStoryDataToStoriesHash = ( data: SetStoriesStoryData, { provider, docsOptions }: { provider: Provider; docsOptions: DocsOptions } ) => - transformStoryIndexToStoriesHash( - transformSetStoriesStoryDataToPreparedStoryIndex(data, { docsOptions }), - { - provider, - docsOptions, - } - ); + transformStoryIndexToStoriesHash(transformSetStoriesStoryDataToPreparedStoryIndex(data), { + provider, + docsOptions, + }); const transformSetStoriesStoryDataToPreparedStoryIndex = ( - stories: SetStoriesStoryData, - { docsOptions }: { docsOptions: DocsOptions } + stories: SetStoriesStoryData ): PreparedStoryIndex => { - const seenTitles = new Set(); const entries: PreparedStoryIndex['entries'] = Object.entries(stories).reduce( (acc, [id, story]) => { if (!story) return acc; @@ -283,19 +278,6 @@ const transformSetStoriesStoryDataToPreparedStoryIndex = ( ...base, }; } else { - if (!seenTitles.has(base.title) && docsOptions.docsPage) { - const name = docsOptions.defaultName; - const docsId = toId(story.componentId || base.title, name); - seenTitles.add(base.title); - acc[docsId] = { - type: 'docs', - storiesImports: [], - ...base, - id: docsId, - name, - }; - } - const { argTypes, args, initialArgs } = story; acc[id] = { type: 'story', diff --git a/code/lib/api/src/modules/stories.ts b/code/lib/api/src/modules/stories.ts index 1080eb9f8e1a..ba31a1a8cba8 100644 --- a/code/lib/api/src/modules/stories.ts +++ b/code/lib/api/src/modules/stories.ts @@ -9,6 +9,7 @@ import { STORY_CHANGED, SELECT_STORY, SET_STORIES, + SET_INDEX, STORY_SPECIFIED, STORY_INDEX_INVALIDATED, CONFIG_ERROR, @@ -18,20 +19,19 @@ import { logger } from '@storybook/client-logger'; import { getEventMetadata } from '../lib/events'; import { denormalizeStoryParameters, - transformSetStoriesStoryDataToStoriesHash, transformStoryIndexToStoriesHash, getComponentLookupList, getStoriesLookupList, HashEntry, LeafEntry, addPreparedStories, + PreparedStoryIndex, } from '../lib/stories'; import type { StoriesHash, StoryEntry, StoryId, - SetStoriesStoryData, SetStoriesPayload, StoryIndex, } from '../lib/stories'; @@ -66,7 +66,7 @@ export interface SubAPI { obj?: { ref?: string; viewMode?: ViewMode } ) => void; getCurrentStoryData: () => LeafEntry; - setStories: (stories: SetStoriesStoryData, failed?: Error) => Promise; + setIndex: (index: PreparedStoryIndex) => Promise; jumpToComponent: (direction: Direction) => void; jumpToStory: (direction: Direction) => void; getData: (storyId: StoryId, refId?: string) => LeafEntry; @@ -86,8 +86,7 @@ export interface SubAPI { direction: Direction, toSiblingGroup: boolean // when true, skip over leafs within the same group ): StoryId; - fetchStoryList: () => Promise; - setStoryList: (storyList: StoryIndex) => Promise; + fetchIndex: () => Promise; updateStory: (storyId: StoryId, update: StoryUpdate, ref?: ComposedRef) => Promise; } @@ -198,19 +197,6 @@ export const init: ModuleFn = ({ api.selectStory(result, undefined, { ref: refId }); } }, - setStories: async (input, error) => { - // Now create storiesHash by reordering the above by group - const hash = transformSetStoriesStoryDataToStoriesHash(input, { - provider, - docsOptions, - }); - - await store.setState({ - storiesHash: hash, - storiesConfigured: true, - storiesFailed: error, - }); - }, selectFirstStory: () => { const { storiesHash } = store.getState(); const firstStory = Object.keys(storiesHash).find((id) => storiesHash[id].type === 'story'); @@ -322,7 +308,7 @@ export const init: ModuleFn = ({ options: { target: refId }, }); }, - fetchStoryList: async () => { + fetchIndex: async () => { try { const result = await fetch(STORY_INDEX_PATH); if (result.status !== 200) throw new Error(await result.text()); @@ -335,7 +321,7 @@ export const init: ModuleFn = ({ return; } - await fullAPI.setStoryList(storyIndex); + await fullAPI.setIndex(storyIndex); } catch (err) { store.setState({ storiesConfigured: true, @@ -343,7 +329,10 @@ export const init: ModuleFn = ({ }); } }, - setStoryList: async (storyIndex: StoryIndex) => { + // The story index we receive on SET_INDEX is "prepared" in that it has parameters + // The story index we receive on fetchStoryIndex is not, but all the prepared fields are optional + // so we can cast one to the other easily enough + setIndex: async (storyIndex: PreparedStoryIndex) => { const newHash = transformStoryIndexToStoriesHash(storyIndex, { provider, docsOptions, @@ -453,18 +442,25 @@ export const init: ModuleFn = ({ } }); - fullAPI.on(SET_STORIES, function handler(data: SetStoriesPayload) { + fullAPI.on(SET_INDEX, function handler(index: PreparedStoryIndex) { const { ref } = getEventMetadata(this, fullAPI); - const setStoriesData = data.v ? denormalizeStoryParameters(data) : data.stories; if (!ref) { - if (!data.v) { - throw new Error('Unexpected legacy SET_STORIES event from local source'); - } - - fullAPI.setStories(setStoriesData); + fullAPI.setIndex(index); const options = fullAPI.getCurrentParameter('options'); fullAPI.setOptions(removeRemovedOptions(options)); + } else { + fullAPI.setRef(ref.id, { ...ref, storyIndex: index }, true); + } + }); + + // For composition back-compatibilty + fullAPI.on(SET_STORIES, function handler(data: SetStoriesPayload) { + const { ref } = getEventMetadata(this, fullAPI); + const setStoriesData = data.v ? denormalizeStoryParameters(data) : data.stories; + + if (!ref) { + throw new Error('Cannot call SET_STORIES for local frame'); } else { fullAPI.setRef(ref.id, { ...ref, setStoriesData }, true); } @@ -509,8 +505,8 @@ export const init: ModuleFn = ({ }); if (FEATURES?.storyStoreV7) { - provider.serverChannel?.on(STORY_INDEX_INVALIDATED, () => fullAPI.fetchStoryList()); - await fullAPI.fetchStoryList(); + provider.serverChannel?.on(STORY_INDEX_INVALIDATED, () => fullAPI.fetchIndex()); + await fullAPI.fetchIndex(); } }; diff --git a/code/lib/api/src/tests/stories.test.ts b/code/lib/api/src/tests/stories.test.ts index a686680e2b3d..78a25b503e18 100644 --- a/code/lib/api/src/tests/stories.test.ts +++ b/code/lib/api/src/tests/stories.test.ts @@ -11,6 +11,7 @@ import { STORY_PREPARED, STORY_INDEX_INVALIDATED, CONFIG_ERROR, + SET_INDEX, } from '@storybook/core-events'; import { EventEmitter } from 'events'; import global from 'global'; @@ -19,23 +20,23 @@ import { mockChannel } from '@storybook/addons'; import { getEventMetadata } from '../lib/events'; import { init as initStories } from '../modules/stories'; -import { StoryEntry, SetStoriesStoryData, SetStoriesStory, StoryIndex } from '../lib/stories'; +import { StoryEntry, StoryIndex, PreparedStoryIndex } from '../lib/stories'; import type Store from '../store'; import { ModuleArgs } from '..'; -const mockStories = jest.fn(); +const mockGetEntries = jest.fn(); jest.mock('../lib/events'); jest.mock('global', () => ({ ...(mockJest.requireActual('global') as Record), - fetch: mockJest.fn(() => ({ json: () => ({ v: 4, entries: mockStories() }) })), + fetch: mockJest.fn(() => ({ json: () => ({ v: 4, entries: mockGetEntries() }) })), FEATURES: { storyStoreV7: true }, CONFIG_TYPE: 'DEVELOPMENT', })); const getEventMetadataMock = getEventMetadata as ReturnType; -const mockIndex = { +const mockEntries: StoryIndex['entries'] = { 'component-a--story-1': { id: 'component-a--story-1', title: 'Component A', @@ -69,59 +70,13 @@ function createMockStore(initialState = {}) { const provider = { getConfig: jest.fn().mockReturnValue({}), serverChannel: mockChannel() }; -const parameters = {} as SetStoriesStory['parameters']; -const setStoriesData: SetStoriesStoryData = { - 'a--1': { - kind: 'a', - name: '1', - parameters, - id: 'a--1', - args: {}, - } as SetStoriesStory, - 'a--2': { - kind: 'a', - name: '2', - parameters, - id: 'a--2', - args: {}, - } as SetStoriesStory, - 'b-c--1': { - kind: 'b/c', - name: '1', - parameters, - id: 'b-c--1', - args: {}, - } as SetStoriesStory, - 'b-d--1': { - kind: 'b/d', - name: '1', - parameters, - id: 'b-d--1', - args: {}, - } as SetStoriesStory, - 'b-d--2': { - kind: 'b/d', - name: '2', - parameters, - id: 'b-d--2', - args: { a: 'b' }, - } as SetStoriesStory, - 'custom-id--1': { - kind: 'b/e', - name: '1', - parameters, - id: 'custom-id--1', - args: {}, - } as SetStoriesStory, -}; - beforeEach(() => { provider.getConfig.mockReset().mockReturnValue({}); provider.serverChannel = mockChannel(); - mockStories.mockReset().mockReturnValue(mockIndex); + mockGetEntries.mockReset().mockReturnValue(mockEntries); global.fetch .mockReset() - .mockReturnValue({ status: 200, json: () => ({ v: 4, entries: mockStories() }) }); + .mockReturnValue({ status: 200, json: () => ({ v: 4, entries: mockGetEntries() }) }); getEventMetadataMock.mockReturnValue({ sourceType: 'local' } as any); getEventMetadataMock.mockReturnValue({ sourceType: 'local' } as any); @@ -143,156 +98,64 @@ describe('stories API', () => { }); }); - describe('setStories', () => { - beforeEach(() => { - mockStories.mockRejectedValue(new Error('Fetch failed') as never); - }); - - it('stores basic kinds and stories w/ correct keys', () => { + describe('setIndex', () => { + it('sets the initial set of stories in the stories hash', async () => { const navigate = jest.fn(); - const store = createMockStore({}); - - const { - api: { setStories }, - } = initStories({ store, navigate, provider } as any as any); + const store = createMockStore(); + const fullAPI = Object.assign(new EventEmitter()); - provider.getConfig.mockReturnValue({ sidebar: { showRoots: false } }); - setStories(setStoriesData); + const { api } = initStories({ store, navigate, provider, fullAPI } as any); + Object.assign(fullAPI, api); + api.setIndex({ v: 4, entries: mockEntries }); const { storiesHash: storedStoriesHash } = store.getState(); // We need exact key ordering, even if in theory JS doesn't guarantee it expect(Object.keys(storedStoriesHash)).toEqual([ - 'a', - 'a--1', - 'a--2', - 'b', - 'b-c', - 'b-c--1', - 'b-d', - 'b-d--1', - 'b-d--2', - 'b-e', - 'custom-id--1', + 'component-a', + 'component-a--story-1', + 'component-a--story-2', + 'component-b', + 'component-b--story-3', ]); - expect(storedStoriesHash.a).toMatchObject({ - type: 'component', - id: 'a', - children: ['a--1', 'a--2'], - }); - - expect(storedStoriesHash['a--1']).toMatchObject({ - type: 'story', - id: 'a--1', - parent: 'a', - title: 'a', - name: '1', - parameters, - args: {}, - prepared: true, - }); - - expect(storedStoriesHash['a--2']).toMatchObject({ - type: 'story', - id: 'a--2', - parent: 'a', - title: 'a', - name: '2', - parameters, - args: {}, - prepared: true, - }); - - expect(storedStoriesHash.b).toMatchObject({ - type: 'group', - id: 'b', - children: ['b-c', 'b-d', 'b-e'], - }); - - expect(storedStoriesHash['b-c']).toMatchObject({ - type: 'component', - id: 'b-c', - parent: 'b', - children: ['b-c--1'], - }); - - expect(storedStoriesHash['b-c--1']).toMatchObject({ - type: 'story', - id: 'b-c--1', - parent: 'b-c', - title: 'b/c', - name: '1', - parameters, - args: {}, - prepared: true, - }); - - expect(storedStoriesHash['b-d']).toMatchObject({ - type: 'component', - id: 'b-d', - parent: 'b', - children: ['b-d--1', 'b-d--2'], - }); - - expect(storedStoriesHash['b-d--1']).toMatchObject({ - type: 'story', - id: 'b-d--1', - parent: 'b-d', - title: 'b/d', - name: '1', - parameters, - args: {}, - prepared: true, - }); - - expect(storedStoriesHash['b-d--2']).toMatchObject({ - type: 'story', - id: 'b-d--2', - parent: 'b-d', - title: 'b/d', - name: '2', - parameters, - args: { a: 'b' }, - prepared: true, - }); - - expect(storedStoriesHash['b-e']).toMatchObject({ + expect(storedStoriesHash['component-a']).toMatchObject({ type: 'component', - id: 'b-e', - parent: 'b', - children: ['custom-id--1'], + id: 'component-a', + children: ['component-a--story-1', 'component-a--story-2'], }); - expect(storedStoriesHash['custom-id--1']).toMatchObject({ + expect(storedStoriesHash['component-a--story-1']).toMatchObject({ type: 'story', - id: 'custom-id--1', - parent: 'b-e', - title: 'b/e', - name: '1', - parameters, - args: {}, - prepared: true, + id: 'component-a--story-1', + parent: 'component-a', + title: 'Component A', + name: 'Story 1', + prepared: false, }); + expect( + (storedStoriesHash['component-a--story-1'] as StoryEntry as StoryEntry).args + ).toBeUndefined(); }); it('trims whitespace of group/component names (which originate from the kind)', () => { const navigate = jest.fn(); const store = createMockStore(); + const fullAPI = Object.assign(new EventEmitter()); - const { - api: { setStories }, - } = initStories({ store, navigate, provider } as any); + const { api } = initStories({ store, navigate, provider, fullAPI } as any); + Object.assign(fullAPI, api); - setStories({ - 'design-system-some-component--my-story': { - id: 'design-system-some-component--my-story', - kind: ' Design System / Some Component ', // note the leading/trailing whitespace around each part of the path - name: ' My Story ', // we only trim the path, so this will be kept as-is (it may intentionally have whitespace) - parameters, - args: {}, + api.setIndex({ + v: 4, + entries: { + 'design-system-some-component--my-story': { + id: 'design-system-some-component--my-story', + title: ' Design System / Some Component ', // note the leading/trailing whitespace around each part of the path + name: ' My Story ', // we only trim the path, so this will be kept as-is (it may intentionally have whitespace) + importPath: './path/to/some-component.ts', + }, }, }); - const { storiesHash: storedStoriesHash } = store.getState(); // We need exact key ordering, even if in theory JS doesn't guarantee it @@ -316,22 +179,64 @@ describe('stories API', () => { }); }); + it('moves rootless stories to the front of the list', async () => { + const navigate = jest.fn(); + const store = createMockStore(); + const fullAPI = Object.assign(new EventEmitter()); + + const { api } = initStories({ store, navigate, provider, fullAPI } as any); + Object.assign(fullAPI, api); + + api.setIndex({ + v: 4, + entries: { + 'root-first--story-1': { + id: 'root-first--story-1', + title: 'Root/First', + name: 'Story 1', + importPath: './path/to/root/first.ts', + }, + ...mockEntries, + }, + }); + const { storiesHash: storedStoriesHash } = store.getState(); + + // We need exact key ordering, even if in theory JS doesn't guarantee it + expect(Object.keys(storedStoriesHash)).toEqual([ + 'component-a', + 'component-a--story-1', + 'component-a--story-2', + 'component-b', + 'component-b--story-3', + 'root', + 'root-first', + 'root-first--story-1', + ]); + expect(storedStoriesHash.root).toMatchObject({ + type: 'root', + id: 'root', + children: ['root-first'], + }); + }); + it('sets roots when showRoots = true', () => { const navigate = jest.fn(); const store = createMockStore(); + const fullAPI = Object.assign(new EventEmitter()); - const { - api: { setStories }, - } = initStories({ store, navigate, provider } as any); + const { api } = initStories({ store, navigate, provider, fullAPI } as any); + Object.assign(fullAPI, api); provider.getConfig.mockReturnValue({ sidebar: { showRoots: true } }); - setStories({ - 'a-b--1': { - kind: 'a/b', - name: '1', - parameters, - id: 'a-b--1', - args: {}, + api.setIndex({ + v: 4, + entries: { + 'a-b--1': { + id: 'a-b--1', + title: 'a/b', + name: '1', + importPath: './a/b.ts', + }, }, }); @@ -356,27 +261,27 @@ describe('stories API', () => { parent: 'a-b', name: '1', title: 'a/b', - parameters, - args: {}, }); }); it('does not put bare stories into a root when showRoots = true', () => { const navigate = jest.fn(); const store = createMockStore(); + const fullAPI = Object.assign(new EventEmitter()); - const { - api: { setStories }, - } = initStories({ store, navigate, provider } as any); + const { api } = initStories({ store, navigate, provider, fullAPI } as any); + Object.assign(fullAPI, api); provider.getConfig.mockReturnValue({ sidebar: { showRoots: true } }); - setStories({ - 'a--1': { - kind: 'a', - name: '1', - parameters, - id: 'a--1', - args: {}, + api.setIndex({ + v: 4, + entries: { + 'a--1': { + id: 'a--1', + title: 'a', + name: '1', + importPath: './a.ts', + }, }, }); @@ -395,8 +300,6 @@ describe('stories API', () => { parent: 'a', title: 'a', name: '1', - parameters, - args: {}, }); }); @@ -405,15 +308,19 @@ describe('stories API', () => { it('does the right thing for out of order stories', async () => { const navigate = jest.fn(); const store = createMockStore(); + const fullAPI = Object.assign(new EventEmitter()); - const { - api: { setStories }, - } = initStories({ store, navigate, provider } as any); + const { api } = initStories({ store, navigate, provider, fullAPI } as any); + Object.assign(fullAPI, api); - await setStories({ - 'a--1': { kind: 'a', name: '1', parameters, id: 'a--1', args: {} }, - 'b--1': { kind: 'b', name: '1', parameters, id: 'b--1', args: {} }, - 'a--2': { kind: 'a', name: '2', parameters, id: 'a--2', args: {} }, + provider.getConfig.mockReturnValue({ sidebar: { showRoots: true } }); + api.setIndex({ + v: 4, + entries: { + 'a--1': { title: 'a', name: '1', id: 'a--1', importPath: './a.ts' }, + 'b--1': { title: 'b', name: '1', id: 'b--1', importPath: './b.ts' }, + 'a--2': { title: 'a', name: '2', id: 'a--2', importPath: './a.ts' }, + }, }); const { storiesHash: storedStoriesHash } = store.getState(); @@ -433,77 +340,247 @@ describe('stories API', () => { }); }); - it('adds docs entries when docsPage is enabled', () => { + // Entries on the SET_STORIES event will be prepared + it('handles properly prepared stories', async () => { const navigate = jest.fn(); - const store = createMockStore({}); + const store = createMockStore(); + const fullAPI = Object.assign(new EventEmitter()); - const { - api: { setStories }, - } = initStories({ - store, - navigate, - provider, - docsOptions: { docsPage: true, defaultName: 'Docs' }, - } as any as any); + const { api } = initStories({ store, navigate, provider, fullAPI } as any); + Object.assign(fullAPI, api); - provider.getConfig.mockReturnValue({ sidebar: { showRoots: false } }); - setStories(setStoriesData); + api.setIndex({ + v: 4, + entries: { + 'prepared--story': { + id: 'prepared--story', + title: 'Prepared', + name: 'Story', + importPath: './path/to/prepared.ts', + parameters: { parameter: 'exists' }, + args: { arg: 'exists' }, + }, + }, + }); const { storiesHash: storedStoriesHash } = store.getState(); - // We need exact key ordering, even if in theory JS doesn't guarantee it - expect(Object.keys(storedStoriesHash)).toEqual([ - 'a', - 'a--docs', - 'a--1', - 'a--2', - 'b', - 'b-c', - 'b-c--docs', - 'b-c--1', - 'b-d', - 'b-d--docs', - 'b-d--1', - 'b-d--2', - 'b-e', - 'b-e--docs', - 'custom-id--1', - ]); - expect(storedStoriesHash['a--docs']).toMatchObject({ - type: 'docs', - id: 'a--docs', - parent: 'a', - title: 'a', - name: 'Docs', - storiesImports: [], + expect(storedStoriesHash['prepared--story']).toMatchObject({ + type: 'story', + id: 'prepared--story', + parent: 'prepared', + title: 'Prepared', + name: 'Story', + prepared: true, + parameters: { parameter: 'exists' }, + args: { arg: 'exists' }, }); + }); - expect(storedStoriesHash['b-c--docs']).toMatchObject({ - type: 'docs', - id: 'b-c--docs', - parent: 'b-c', - title: 'b/c', - name: 'Docs', - storiesImports: [], + it('retains prepared-ness of stories', async () => { + const navigate = jest.fn(); + const store = createMockStore(); + const fullAPI = Object.assign(new EventEmitter(), { setOptions: jest.fn() }); + + const { api, init } = initStories({ store, navigate, provider, fullAPI } as any); + Object.assign(fullAPI, api); + init(); + + api.setIndex({ v: 4, entries: mockEntries }); + + fullAPI.emit(STORY_PREPARED, { + id: 'component-a--story-1', + parameters: { a: 'b' }, + args: { c: 'd' }, + }); + // Let the promise/await chain resolve + await new Promise((r) => setTimeout(r, 0)); + expect(store.getState().storiesHash['component-a--story-1'] as StoryEntry).toMatchObject({ + prepared: true, + parameters: { a: 'b' }, + args: { c: 'd' }, + }); + + api.setIndex({ v: 4, entries: mockEntries }); + + // Let the promise/await chain resolve + await new Promise((r) => setTimeout(r, 0)); + expect(store.getState().storiesHash['component-a--story-1'] as StoryEntry).toMatchObject({ + prepared: true, + parameters: { a: 'b' }, + args: { c: 'd' }, + }); + }); + + describe('docs entries', () => { + const docsEntries: StoryIndex['entries'] = { + 'component-a--page': { + id: 'component-a--page', + title: 'Component A', + name: 'Page', + importPath: './path/to/component-a.ts', + }, + 'component-a--story-2': { + id: 'component-a--story-2', + title: 'Component A', + name: 'Story 2', + importPath: './path/to/component-a.ts', + }, + 'component-b-docs': { + type: 'docs' as const, + id: 'component-b--docs', + title: 'Component B', + name: 'Docs', + importPath: './path/to/component-b.ts', + storiesImports: [], + }, + 'component-c--story-4': { + id: 'component-c--story-4', + title: 'Component c', + name: 'Story 4', + importPath: './path/to/component-c.ts', + }, + }; + + it('handles docs entries', async () => { + const navigate = jest.fn(); + const store = createMockStore(); + const fullAPI = Object.assign(new EventEmitter()); + + const { api } = initStories({ store, navigate, provider, fullAPI } as any); + Object.assign(fullAPI, api); + + api.setIndex({ v: 4, entries: docsEntries }); + + const { storiesHash: storedStoriesHash } = store.getState(); + + // We need exact key ordering, even if in theory JS doesn't guarantee it + expect(Object.keys(storedStoriesHash)).toEqual([ + 'component-a', + 'component-a--page', + 'component-a--story-2', + 'component-b', + 'component-b--docs', + 'component-c', + 'component-c--story-4', + ]); + expect(storedStoriesHash['component-a--page'].type).toBe('story'); + expect(storedStoriesHash['component-a--story-2'].type).toBe('story'); + expect(storedStoriesHash['component-b--docs'].type).toBe('docs'); + expect(storedStoriesHash['component-c--story-4'].type).toBe('story'); + }); + + describe('when DOCS_MODE = true', () => { + it('strips out story entries', async () => { + const navigate = jest.fn(); + const store = createMockStore(); + const fullAPI = Object.assign(new EventEmitter()); + + const { api } = initStories({ + store, + navigate, + provider, + fullAPI, + docsOptions: { docsMode: true }, + } as any); + Object.assign(fullAPI, api); + + api.setIndex({ v: 4, entries: docsEntries }); + + const { storiesHash: storedStoriesHash } = store.getState(); + + expect(Object.keys(storedStoriesHash)).toEqual(['component-b', 'component-b--docs']); + }); + }); + }); + }); + + describe('SET_INDEX event', () => { + it('calls setIndex w/ the data', () => { + const fullAPI = Object.assign(new EventEmitter()); + const navigate = jest.fn(); + const store = createMockStore(); + + const { init, api } = initStories({ store, navigate, provider, fullAPI } as any); + Object.assign(fullAPI, api, { + setIndex: jest.fn(), + setOptions: jest.fn(), + }); + init(); + + fullAPI.emit(SET_INDEX, { v: 4, entries: mockEntries }); + + expect(fullAPI.setIndex).toHaveBeenCalled(); + }); + + it('calls setOptions w/ first story parameter', () => { + const fullAPI = Object.assign(new EventEmitter()); + const navigate = jest.fn(); + const store = createMockStore(); + + const { init, api } = initStories({ store, navigate, provider, fullAPI } as any); + Object.assign(fullAPI, api, { + setIndex: jest.fn(), + setOptions: jest.fn(), + getCurrentParameter: jest.fn().mockReturnValue('options'), }); + init(); + + fullAPI.emit(SET_INDEX, { v: 4, entries: mockEntries }); - expect(storedStoriesHash['b-d--docs']).toMatchObject({ - type: 'docs', - id: 'b-d--docs', - parent: 'b-d', - title: 'b/d', - name: 'Docs', - storiesImports: [], + expect(fullAPI.setOptions).toHaveBeenCalledWith('options'); + }); + }); + + describe('fetchIndex', () => { + it('deals with 500 errors', async () => { + const navigate = jest.fn(); + const store = createMockStore({}); + const fullAPI = Object.assign(new EventEmitter(), {}); + + global.fetch.mockReturnValue({ status: 500, text: async () => new Error('sorting error') }); + const { api, init } = initStories({ store, navigate, provider, fullAPI } as any); + Object.assign(fullAPI, api); + + await init(); + + const { storiesConfigured, storiesFailed } = store.getState(); + expect(storiesConfigured).toBe(true); + expect(storiesFailed?.message).toMatch(/sorting error/); + }); + + it('watches for the INVALIDATE event and refetches -- and resets the hash', async () => { + const navigate = jest.fn(); + const store = createMockStore(); + const fullAPI = Object.assign(new EventEmitter(), { + setIndex: jest.fn(), }); - expect(storedStoriesHash['b-e--docs']).toMatchObject({ - type: 'docs', - id: 'b-e--docs', - parent: 'b-e', - title: 'b/e', - name: 'Docs', - storiesImports: [], + const { api, init } = initStories({ store, navigate, provider, fullAPI } as any); + Object.assign(fullAPI, api); + + global.fetch.mockClear(); + await init(); + expect(global.fetch).toHaveBeenCalledTimes(1); + + global.fetch.mockClear(); + mockGetEntries.mockReturnValueOnce({ + 'component-a--story-1': { + type: 'story', + id: 'component-a--story-1', + title: 'Component A', + name: 'Story 1', + importPath: './path/to/component-a.ts', + }, }); + provider.serverChannel.emit(STORY_INDEX_INVALIDATED); + expect(global.fetch).toHaveBeenCalledTimes(1); + + // Let the promise/await chain resolve + await new Promise((r) => setTimeout(r, 0)); + const { storiesHash: storedStoriesHash } = store.getState(); + + expect(Object.keys(storedStoriesHash)).toEqual(['component-a', 'component-a--story-1']); }); }); @@ -558,6 +635,26 @@ describe('stories API', () => { }); describe('args handling', () => { + const parameters = {}; + const preparedEntries: PreparedStoryIndex['entries'] = { + 'a--1': { + title: 'a', + name: '1', + parameters, + id: 'a--1', + args: { a: 'b' }, + importPath: './a.ts', + }, + 'b--1': { + title: 'b', + name: '1', + parameters, + id: 'b--1', + args: { x: 'y' }, + importPath: './b.ts', + }, + }; + it('changes args properly, per story when receiving STORY_ARGS_UPDATED', () => { const navigate = jest.fn(); const store = createMockStore(); @@ -565,11 +662,8 @@ describe('stories API', () => { const { api, init } = initStories({ store, navigate, provider, fullAPI } as any); - Object.assign(fullAPI, api); - (fullAPI as any as typeof api).setStories({ - 'a--1': { kind: 'a', name: '1', parameters, id: 'a--1', args: { a: 'b' } }, - 'b--1': { kind: 'b', name: '1', parameters, id: 'b--1', args: { x: 'y' } }, - }); + const { setIndex } = Object.assign(fullAPI, api); + setIndex({ v: 4, entries: preparedEntries }); const { storiesHash: initialStoriesHash } = store.getState(); expect((initialStoriesHash['a--1'] as StoryEntry).args).toEqual({ a: 'b' }); @@ -589,8 +683,9 @@ describe('stories API', () => { const fullAPI = new EventEmitter(); const { init, api } = initStories({ store, navigate, provider, fullAPI } as any); - Object.assign(fullAPI, api); - (fullAPI as any).updateRef = jest.fn(); + Object.assign(fullAPI, api, { + updateRef: jest.fn(), + }); init(); getEventMetadataMock.mockReturnValueOnce({ @@ -611,13 +706,9 @@ describe('stories API', () => { const store = createMockStore(); const { api, init } = initStories({ store, navigate, provider, fullAPI } as any); + const { setIndex } = Object.assign(fullAPI, api); + setIndex({ v: 4, entries: preparedEntries }); - api.setStories({ - 'a--1': { kind: 'a', name: '1', parameters, id: 'a--1', args: { a: 'b' } }, - 'b--1': { kind: 'b', name: '1', parameters, id: 'b--1', args: { x: 'y' } }, - }); - - Object.assign(fullAPI, api); init(); api.updateStoryArgs({ id: 'a--1' } as StoryEntry, { foo: 'bar' }); @@ -643,12 +734,9 @@ describe('stories API', () => { const { api, init } = initStories({ store, navigate, provider, fullAPI } as any); - api.setStories({ - 'a--1': { kind: 'a', name: '1', parameters, id: 'a--1', args: { a: 'b' } }, - 'b--1': { kind: 'b', name: '1', parameters, id: 'b--1', args: { x: 'y' } }, - }); + const { setIndex } = Object.assign(fullAPI, api); + setIndex({ v: 4, entries: preparedEntries }); - Object.assign(fullAPI, api); init(); api.updateStoryArgs({ id: 'a--1', refId: 'refId' } as StoryEntry, { foo: 'bar' }); @@ -670,12 +758,8 @@ describe('stories API', () => { const { api, init } = initStories({ store, navigate, provider, fullAPI } as any); - api.setStories({ - 'a--1': { kind: 'a', name: '1', parameters, id: 'a--1', args: { a: 'b' } }, - 'b--1': { kind: 'b', name: '1', parameters, id: 'b--1', args: { x: 'y' } }, - }); - - Object.assign(fullAPI, api); + const { setIndex } = Object.assign(fullAPI, api); + setIndex({ v: 4, entries: preparedEntries }); init(); api.resetStoryArgs({ id: 'a--1' } as StoryEntry, ['foo']); @@ -701,12 +785,8 @@ describe('stories API', () => { const { api, init } = initStories({ store, navigate, provider, fullAPI } as any); - api.setStories({ - 'a--1': { kind: 'a', name: '1', parameters, id: 'a--1', args: { a: 'b' } }, - 'b--1': { kind: 'b', name: '1', parameters, id: 'b--1', args: { x: 'y' } }, - }); - - Object.assign(fullAPI, api); + const { setIndex } = Object.assign(fullAPI, api); + setIndex({ v: 4, entries: preparedEntries }); init(); api.resetStoryArgs({ id: 'a--1', refId: 'refId' } as StoryEntry, ['foo']); @@ -720,16 +800,55 @@ describe('stories API', () => { }); }); + const navigationEntries: StoryIndex['entries'] = { + 'a--1': { + title: 'a', + name: '1', + id: 'a--1', + importPath: './a.ts', + }, + 'a--2': { + title: 'a', + name: '2', + id: 'a--2', + importPath: './a.ts', + }, + 'b-c--1': { + title: 'b/c', + name: '1', + id: 'b-c--1', + importPath: './b/c.ts', + }, + 'b-d--1': { + title: 'b/d', + name: '1', + id: 'b-d--1', + importPath: './b/d.ts', + }, + 'b-d--2': { + title: 'b/d', + name: '2', + id: 'b-d--2', + importPath: './b/d.ts', + }, + 'custom-id--1': { + title: 'b/e', + name: '1', + id: 'custom-id--1', + importPath: './b/.ts', + }, + }; + describe('jumpToStory', () => { it('works forward', () => { const navigate = jest.fn(); const store = createMockStore({ storyId: 'a--1', viewMode: 'story' }); const { - api: { setStories, jumpToStory }, + api: { setIndex, jumpToStory }, state, } = initStories({ store, navigate, provider } as any); - setStories(setStoriesData); + setIndex({ v: 4, entries: navigationEntries }); jumpToStory(1); expect(navigate).toHaveBeenCalledWith('/story/a--2'); @@ -740,9 +859,9 @@ describe('stories API', () => { const store = createMockStore({ storyId: 'a--2', viewMode: 'story' }); const { - api: { setStories, jumpToStory }, + api: { setIndex, jumpToStory }, } = initStories({ store, navigate, provider } as any); - setStories(setStoriesData); + setIndex({ v: 4, entries: navigationEntries }); jumpToStory(-1); expect(navigate).toHaveBeenCalledWith('/story/a--1'); @@ -756,13 +875,13 @@ describe('stories API', () => { }); const { - api: { setStories, jumpToStory }, + api: { setIndex, jumpToStory }, } = initStories({ store, navigate, provider, } as any); - setStories(setStoriesData); + setIndex({ v: 4, entries: navigationEntries }); jumpToStory(1); expect(navigate).not.toHaveBeenCalled(); @@ -773,9 +892,9 @@ describe('stories API', () => { const store = createMockStore({ storyId: 'a--1', viewMode: 'story' }); const { - api: { setStories, jumpToStory }, + api: { setIndex, jumpToStory }, } = initStories({ store, navigate, provider } as any); - setStories(setStoriesData); + setIndex({ v: 4, entries: navigationEntries }); jumpToStory(-1); expect(navigate).not.toHaveBeenCalled(); @@ -786,9 +905,9 @@ describe('stories API', () => { const store = createMockStore(); const { - api: { setStories, jumpToStory }, + api: { setIndex, jumpToStory }, } = initStories({ store, navigate, provider } as any); - setStories(setStoriesData); + setIndex({ v: 4, entries: navigationEntries }); jumpToStory(1); expect(navigate).not.toHaveBeenCalled(); @@ -802,11 +921,11 @@ describe('stories API', () => { const storyId = 'a--1'; const { - api: { setStories, findSiblingStoryId }, + api: { setIndex, findSiblingStoryId }, state, } = initStories({ store, navigate, storyId, viewMode: 'story', provider } as any); store.setState(state); - setStories(setStoriesData); + setIndex({ v: 4, entries: navigationEntries }); const result = findSiblingStoryId(storyId, store.getState().storiesHash, 1, false); expect(result).toBe('a--2'); @@ -817,11 +936,11 @@ describe('stories API', () => { const storyId = 'a--1'; const { - api: { setStories, findSiblingStoryId }, + api: { setIndex, findSiblingStoryId }, state, } = initStories({ store, navigate, storyId, viewMode: 'story', provider } as any); store.setState(state); - setStories(setStoriesData); + setIndex({ v: 4, entries: navigationEntries }); const result = findSiblingStoryId(storyId, store.getState().storiesHash, 1, true); expect(result).toBe('b-c--1'); @@ -833,11 +952,11 @@ describe('stories API', () => { const store = createMockStore(); const { - api: { setStories, jumpToComponent }, + api: { setIndex, jumpToComponent }, state, } = initStories({ store, navigate, storyId: 'a--1', viewMode: 'story', provider } as any); store.setState(state); - setStories(setStoriesData); + setIndex({ v: 4, entries: navigationEntries }); jumpToComponent(1); expect(navigate).toHaveBeenCalledWith('/story/b-c--1'); @@ -848,11 +967,11 @@ describe('stories API', () => { const store = createMockStore(); const { - api: { setStories, jumpToComponent }, + api: { setIndex, jumpToComponent }, state, } = initStories({ store, navigate, storyId: 'b-c--1', viewMode: 'story', provider } as any); store.setState(state); - setStories(setStoriesData); + setIndex({ v: 4, entries: navigationEntries }); jumpToComponent(-1); expect(navigate).toHaveBeenCalledWith('/story/a--1'); @@ -863,7 +982,7 @@ describe('stories API', () => { const store = createMockStore(); const { - api: { setStories, jumpToComponent }, + api: { setIndex, jumpToComponent }, state, } = initStories({ store, @@ -873,7 +992,7 @@ describe('stories API', () => { provider, } as any); store.setState(state); - setStories(setStoriesData); + setIndex({ v: 4, entries: navigationEntries }); jumpToComponent(1); expect(navigate).not.toHaveBeenCalled(); @@ -884,11 +1003,11 @@ describe('stories API', () => { const store = createMockStore(); const { - api: { setStories, jumpToComponent }, + api: { setIndex, jumpToComponent }, state, } = initStories({ store, navigate, storyId: 'a--2', viewMode: 'story', provider } as any); store.setState(state); - setStories(setStoriesData); + setIndex({ v: 4, entries: navigationEntries }); jumpToComponent(-1); expect(navigate).not.toHaveBeenCalled(); @@ -900,9 +1019,9 @@ describe('stories API', () => { const navigate = jest.fn(); const store = createMockStore({ storyId: 'a--1', viewMode: 'story' }); const { - api: { setStories, selectStory }, + api: { setIndex, selectStory }, } = initStories({ store, navigate, provider } as any); - setStories(setStoriesData); + setIndex({ v: 4, entries: navigationEntries }); selectStory('a--2'); expect(navigate).toHaveBeenCalledWith('/story/a--2'); @@ -912,442 +1031,157 @@ describe('stories API', () => { const navigate = jest.fn(); const store = createMockStore({ storyId: 'a--1', viewMode: 'docs' }); const { - api: { setStories, selectStory }, - } = initStories({ store, navigate, provider } as any); - setStories({ - ...setStoriesData, - 'intro--docs': { - id: 'intro--docs', - kind: 'Intro', - name: 'Page', - parameters: { docsOnly: true } as any, - args: {}, - }, - }); - - selectStory('intro'); - expect(navigate).toHaveBeenCalledWith('/docs/intro--docs'); - }); - - describe('legacy api', () => { - it('allows navigating to a combination of title + name', () => { - const navigate = jest.fn(); - const store = createMockStore({ storyId: 'a--1', viewMode: 'story' }); - const { - api: { setStories, selectStory }, - } = initStories({ store, navigate, provider } as any); - setStories(setStoriesData); - - selectStory('a', '2'); - expect(navigate).toHaveBeenCalledWith('/story/a--2'); - }); - - it('allows navigating to a given name (in the current component)', () => { - const navigate = jest.fn(); - const store = createMockStore({ storyId: 'a--1', viewMode: 'story' }); - const { - api: { setStories, selectStory }, - } = initStories({ store, navigate, provider } as any); - setStories(setStoriesData); - - selectStory(undefined, '2'); - expect(navigate).toHaveBeenCalledWith('/story/a--2'); - }); - }); - - it('allows navigating away from the settings pages', () => { - const navigate = jest.fn(); - const store = createMockStore({ storyId: 'a--1', viewMode: 'settings' }); - const { - api: { setStories, selectStory }, - } = initStories({ store, navigate, provider } as any); - setStories(setStoriesData); - - selectStory('a--2'); - expect(navigate).toHaveBeenCalledWith('/story/a--2'); - }); - - it('allows navigating to first story in component on call by component id', () => { - const navigate = jest.fn(); - const store = createMockStore({ storyId: 'a--1', viewMode: 'story' }); - const { - api: { setStories, selectStory }, - } = initStories({ store, navigate, provider } as any); - setStories(setStoriesData); - - selectStory('a'); - expect(navigate).toHaveBeenCalledWith('/story/a--1'); - }); - - it('allows navigating to first story in group on call by group id', () => { - const navigate = jest.fn(); - const store = createMockStore({ storyId: 'a--1', viewMode: 'story' }); - const { - api: { setStories, selectStory }, - } = initStories({ store, navigate, provider } as any); - setStories(setStoriesData); - - selectStory('b'); - expect(navigate).toHaveBeenCalledWith('/story/b-c--1'); - }); - - it('allows navigating to first story in component on call by title', () => { - const navigate = jest.fn(); - const store = createMockStore({ storyId: 'a--1', viewMode: 'story' }); - const { - api: { setStories, selectStory }, - } = initStories({ store, navigate, provider } as any); - setStories(setStoriesData); - - selectStory('A'); - expect(navigate).toHaveBeenCalledWith('/story/a--1'); - }); - - it('allows navigating to the first story of the current component if passed nothing', () => { - const navigate = jest.fn(); - const store = createMockStore({ storyId: 'a--2', viewMode: 'story' }); - const { - api: { setStories, selectStory }, + api: { setIndex, selectStory }, } = initStories({ store, navigate, provider } as any); - setStories(setStoriesData); - - selectStory(); - expect(navigate).toHaveBeenCalledWith('/story/a--1'); - }); - - describe('component permalinks', () => { - it('allows navigating to kind/storyname (legacy api)', () => { - const navigate = jest.fn(); - const store = createMockStore(); - - const { - api: { selectStory, setStories }, - state, - } = initStories({ store, navigate, provider } as any); - store.setState(state); - setStories(setStoriesData); - - selectStory('b/e', '1'); - expect(navigate).toHaveBeenCalledWith('/story/custom-id--1'); - }); - - it('allows navigating to component permalink/storyname (legacy api)', () => { - const navigate = jest.fn(); - const store = createMockStore(); - - const { - api: { selectStory, setStories }, - state, - } = initStories({ store, navigate, provider } as any); - store.setState(state); - setStories(setStoriesData); - - selectStory('custom-id', '1'); - expect(navigate).toHaveBeenCalledWith('/story/custom-id--1'); - }); - - it('allows navigating to first story in kind on call by kind', () => { - const navigate = jest.fn(); - const store = createMockStore(); - - const { - api: { selectStory, setStories }, - state, - } = initStories({ store, navigate, provider } as any); - store.setState(state); - setStories(setStoriesData); - - selectStory('b/e'); - expect(navigate).toHaveBeenCalledWith('/story/custom-id--1'); - }); - }); - }); - - describe('fetchStoryIndex', () => { - it('deals with 500 errors', async () => { - const navigate = jest.fn(); - const store = createMockStore({}); - const fullAPI = Object.assign(new EventEmitter(), {}); - - global.fetch.mockReturnValue({ status: 500, text: async () => new Error('sorting error') }); - const { api, init } = initStories({ store, navigate, provider, fullAPI } as any); - Object.assign(fullAPI, api); - - await init(); - - const { storiesConfigured, storiesFailed } = store.getState(); - expect(storiesConfigured).toBe(true); - expect(storiesFailed?.message).toMatch(/sorting error/); - }); - - it('sets the initial set of stories in the stories hash', async () => { - const navigate = jest.fn(); - const store = createMockStore(); - const fullAPI = Object.assign(new EventEmitter(), { - setStories: jest.fn(), - }); - - const { api, init } = initStories({ store, navigate, provider, fullAPI } as any); - Object.assign(fullAPI, api); - - await init(); - - const { storiesHash: storedStoriesHash } = store.getState(); - - // We need exact key ordering, even if in theory JS doesn't guarantee it - expect(Object.keys(storedStoriesHash)).toEqual([ - 'component-a', - 'component-a--story-1', - 'component-a--story-2', - 'component-b', - 'component-b--story-3', - ]); - expect(storedStoriesHash['component-a']).toMatchObject({ - type: 'component', - id: 'component-a', - children: ['component-a--story-1', 'component-a--story-2'], - }); - - expect(storedStoriesHash['component-a--story-1']).toMatchObject({ - type: 'story', - id: 'component-a--story-1', - parent: 'component-a', - title: 'Component A', - name: 'Story 1', - prepared: false, - }); - expect( - (storedStoriesHash['component-a--story-1'] as StoryEntry as StoryEntry).args - ).toBeUndefined(); - }); - - it('moves rootless stories to the front of the list', async () => { - mockStories.mockReset().mockReturnValue({ - 'root-first--story-1': { - id: 'root-first--story-1', - title: 'Root/First', - name: 'Story 1', - importPath: './path/to/root/first.ts', - }, - ...mockIndex, - }); - - const navigate = jest.fn(); - const store = createMockStore(); - const fullAPI = Object.assign(new EventEmitter(), { - setStories: jest.fn(), - }); - - const { api, init } = initStories({ store, navigate, provider, fullAPI } as any); - Object.assign(fullAPI, api); - - await init(); - - const { storiesHash: storedStoriesHash } = store.getState(); - - // We need exact key ordering, even if in theory JS doesn't guarantee it - expect(Object.keys(storedStoriesHash)).toEqual([ - 'component-a', - 'component-a--story-1', - 'component-a--story-2', - 'component-b', - 'component-b--story-3', - 'root', - 'root-first', - 'root-first--story-1', - ]); - expect(storedStoriesHash.root).toMatchObject({ - type: 'root', - id: 'root', - children: ['root-first'], - }); - }); - - it('watches for the INVALIDATE event and refetches -- and resets the hash', async () => { - const navigate = jest.fn(); - const store = createMockStore(); - const fullAPI = Object.assign(new EventEmitter(), { - setStories: jest.fn(), - }); - - const { api, init } = initStories({ store, navigate, provider, fullAPI } as any); - Object.assign(fullAPI, api); - - global.fetch.mockClear(); - await init(); - expect(global.fetch).toHaveBeenCalledTimes(1); - - global.fetch.mockClear(); - mockStories.mockReturnValueOnce({ - 'component-a--story-1': { - type: 'story', - id: 'component-a--story-1', - title: 'Component A', - name: 'Story 1', - importPath: './path/to/component-a.ts', + setIndex({ + v: 4, + entries: { + ...navigationEntries, + 'intro--docs': { + type: 'docs', + id: 'intro--docs', + title: 'Intro', + name: 'Page', + importPath: './intro.mdx', + storiesImports: [], + }, }, }); - provider.serverChannel.emit(STORY_INDEX_INVALIDATED); - expect(global.fetch).toHaveBeenCalledTimes(1); - - // Let the promise/await chain resolve - await new Promise((r) => setTimeout(r, 0)); - const { storiesHash: storedStoriesHash } = store.getState(); - expect(Object.keys(storedStoriesHash)).toEqual(['component-a', 'component-a--story-1']); + selectStory('intro'); + expect(navigate).toHaveBeenCalledWith('/docs/intro--docs'); }); - it('retains prepared-ness of stories', async () => { - const navigate = jest.fn(); - const store = createMockStore(); - const fullAPI = Object.assign(new EventEmitter(), { - setStories: jest.fn(), - setOptions: jest.fn(), - }); + describe('legacy api', () => { + it('allows navigating to a combination of title + name', () => { + const navigate = jest.fn(); + const store = createMockStore({ storyId: 'a--1', viewMode: 'story' }); + const { + api: { setIndex, selectStory }, + } = initStories({ store, navigate, provider } as any); + setIndex({ v: 4, entries: navigationEntries }); - const { api, init } = initStories({ store, navigate, provider, fullAPI } as any); - Object.assign(fullAPI, api); + selectStory('a', '2'); + expect(navigate).toHaveBeenCalledWith('/story/a--2'); + }); - global.fetch.mockClear(); - await init(); - expect(global.fetch).toHaveBeenCalledTimes(1); + it('allows navigating to a given name (in the current component)', () => { + const navigate = jest.fn(); + const store = createMockStore({ storyId: 'a--1', viewMode: 'story' }); + const { + api: { setIndex, selectStory }, + } = initStories({ store, navigate, provider } as any); + setIndex({ v: 4, entries: navigationEntries }); - fullAPI.emit(STORY_PREPARED, { - id: 'component-a--story-1', - parameters: { a: 'b' }, - args: { c: 'd' }, - }); - // Let the promise/await chain resolve - await new Promise((r) => setTimeout(r, 0)); - expect(store.getState().storiesHash['component-a--story-1'] as StoryEntry).toMatchObject({ - prepared: true, - parameters: { a: 'b' }, - args: { c: 'd' }, + selectStory(undefined, '2'); + expect(navigate).toHaveBeenCalledWith('/story/a--2'); }); + }); - global.fetch.mockClear(); - provider.serverChannel.emit(STORY_INDEX_INVALIDATED); - expect(global.fetch).toHaveBeenCalledTimes(1); + it('allows navigating away from the settings pages', () => { + const navigate = jest.fn(); + const store = createMockStore({ storyId: 'a--1', viewMode: 'settings' }); + const { + api: { setIndex, selectStory }, + } = initStories({ store, navigate, provider } as any); + setIndex({ v: 4, entries: navigationEntries }); - // Let the promise/await chain resolve - await new Promise((r) => setTimeout(r, 0)); - expect(store.getState().storiesHash['component-a--story-1'] as StoryEntry).toMatchObject({ - prepared: true, - parameters: { a: 'b' }, - args: { c: 'd' }, - }); + selectStory('a--2'); + expect(navigate).toHaveBeenCalledWith('/story/a--2'); }); - it('handles docs entries', async () => { - mockStories.mockReset().mockReturnValue({ - 'component-a--page': { - id: 'component-a--page', - title: 'Component A', - name: 'Page', - importPath: './path/to/component-a.ts', - }, - 'component-a--story-2': { - id: 'component-a--story-2', - title: 'Component A', - name: 'Story 2', - importPath: './path/to/component-a.ts', - }, - 'component-b': { - type: 'docs', - id: 'component-b--docs', - title: 'Component B', - name: 'Docs', - importPath: './path/to/component-b.ts', - storiesImports: [], - }, - 'component-c--story-4': { - id: 'component-c--story-4', - title: 'Component c', - name: 'Story 4', - importPath: './path/to/component-c.ts', - }, - }); + it('allows navigating to first story in component on call by component id', () => { + const navigate = jest.fn(); + const store = createMockStore({ storyId: 'a--1', viewMode: 'story' }); + const { + api: { setIndex, selectStory }, + } = initStories({ store, navigate, provider } as any); + setIndex({ v: 4, entries: navigationEntries }); + + selectStory('a'); + expect(navigate).toHaveBeenCalledWith('/story/a--1'); + }); + it('allows navigating to first story in group on call by group id', () => { const navigate = jest.fn(); - const store = createMockStore(); - const fullAPI = Object.assign(new EventEmitter(), { - setStories: jest.fn(), - }); + const store = createMockStore({ storyId: 'a--1', viewMode: 'story' }); + const { + api: { setIndex, selectStory }, + } = initStories({ store, navigate, provider } as any); + setIndex({ v: 4, entries: navigationEntries }); - const { api, init } = initStories({ store, navigate, provider, fullAPI } as any); - Object.assign(fullAPI, api); + selectStory('b'); + expect(navigate).toHaveBeenCalledWith('/story/b-c--1'); + }); - await init(); + it('allows navigating to first story in component on call by title', () => { + const navigate = jest.fn(); + const store = createMockStore({ storyId: 'a--1', viewMode: 'story' }); + const { + api: { setIndex, selectStory }, + } = initStories({ store, navigate, provider } as any); + setIndex({ v: 4, entries: navigationEntries }); - const { storiesHash: storedStoriesHash } = store.getState(); + selectStory('A'); + expect(navigate).toHaveBeenCalledWith('/story/a--1'); + }); - // We need exact key ordering, even if in theory JS doesn't guarantee it - expect(Object.keys(storedStoriesHash)).toEqual([ - 'component-a', - 'component-a--page', - 'component-a--story-2', - 'component-b', - 'component-b--docs', - 'component-c', - 'component-c--story-4', - ]); - expect(storedStoriesHash['component-a--page'].type).toBe('story'); - expect(storedStoriesHash['component-a--story-2'].type).toBe('story'); - expect(storedStoriesHash['component-b--docs'].type).toBe('docs'); - expect(storedStoriesHash['component-c--story-4'].type).toBe('story'); + it('allows navigating to the first story of the current component if passed nothing', () => { + const navigate = jest.fn(); + const store = createMockStore({ storyId: 'a--2', viewMode: 'story' }); + const { + api: { setIndex, selectStory }, + } = initStories({ store, navigate, provider } as any); + setIndex({ v: 4, entries: navigationEntries }); + + selectStory(); + expect(navigate).toHaveBeenCalledWith('/story/a--1'); }); - describe('when DOCS_MODE = true', () => { - it('strips out story entries', async () => { - mockStories.mockReset().mockReturnValue({ - 'component-a--page': { - id: 'component-a--page', - title: 'Component A', - name: 'Page', - importPath: './path/to/component-a.ts', - }, - 'component-a--story-2': { - id: 'component-a--story-2', - title: 'Component A', - name: 'Story 2', - importPath: './path/to/component-a.ts', - }, - 'component-b': { - type: 'docs', - id: 'component-b--docs', - title: 'Component B', - name: 'Docs', - importPath: './path/to/component-b.ts', - storiesImports: [], - }, - 'component-c--story-4': { - id: 'component-c--story-4', - title: 'Component c', - name: 'Story 4', - importPath: './path/to/component-c.ts', - }, - }); + describe('component permalinks', () => { + it('allows navigating to kind/storyname (legacy api)', () => { + const navigate = jest.fn(); + const store = createMockStore(); + + const { + api: { selectStory, setIndex }, + state, + } = initStories({ store, navigate, provider } as any); + store.setState(state); + setIndex({ v: 4, entries: navigationEntries }); + + selectStory('b/e', '1'); + expect(navigate).toHaveBeenCalledWith('/story/custom-id--1'); + }); + it('allows navigating to component permalink/storyname (legacy api)', () => { const navigate = jest.fn(); const store = createMockStore(); - const fullAPI = Object.assign(new EventEmitter(), { - setStories: jest.fn(), - }); - const { api, init } = initStories({ - store, - navigate, - provider, - fullAPI, - docsOptions: { docsMode: true }, - } as any); - Object.assign(fullAPI, api); + const { + api: { selectStory, setIndex }, + state, + } = initStories({ store, navigate, provider } as any); + store.setState(state); + setIndex({ v: 4, entries: navigationEntries }); + + selectStory('custom-id', '1'); + expect(navigate).toHaveBeenCalledWith('/story/custom-id--1'); + }); - await init(); + it('allows navigating to first story in kind on call by kind', () => { + const navigate = jest.fn(); + const store = createMockStore(); - const { storiesHash: storedStoriesHash } = store.getState(); + const { + api: { selectStory, setIndex }, + state, + } = initStories({ store, navigate, provider } as any); + store.setState(state); + setIndex({ v: 4, entries: navigationEntries }); - expect(Object.keys(storedStoriesHash)).toEqual(['component-b', 'component-b--docs']); + selectStory('b/e'); + expect(navigate).toHaveBeenCalledWith('/story/custom-id--1'); }); }); }); @@ -1465,123 +1299,6 @@ describe('stories API', () => { }); describe('v2 SET_STORIES event', () => { - it('normalizes parameters and calls setStories for local stories', () => { - const fullAPI = Object.assign(new EventEmitter(), { - setStories: jest.fn(), - setOptions: jest.fn(), - findRef: jest.fn(), - getCurrentParameter: jest.fn(), - }); - const navigate = jest.fn(); - const store = createMockStore(); - - const { init, api } = initStories({ store, navigate, provider, fullAPI } as any); - Object.assign(fullAPI, api, { setStories: jest.fn() }); - init(); - - const setStoriesPayload = { - v: 2, - globalParameters: { global: 'global' }, - kindParameters: { a: { kind: 'kind' } }, - stories: { 'a--1': { kind: 'a', parameters: { story: 'story' } } }, - }; - fullAPI.emit(SET_STORIES, setStoriesPayload); - - expect(fullAPI.setStories).toHaveBeenCalledWith({ - 'a--1': { kind: 'a', parameters: { global: 'global', kind: 'kind', story: 'story' } }, - }); - }); - - it('prefers parameters.docsOnly to inferred docsOnly status', async () => { - const navigate = jest.fn(); - const store = createMockStore(); - const fullAPI = Object.assign(new EventEmitter(), { - setOptions: jest.fn(), - findRef: jest.fn(), - }); - - const { api, init } = initStories({ store, navigate, provider, fullAPI } as any); - Object.assign(fullAPI, api); - - await init(); - - const setStoriesPayload = { - v: 2, - globalParameters: { global: 'global' }, - kindParameters: { a: { kind: 'kind' } }, - stories: { - 'component-a--docs': { - type: 'story', - kind: 'Component A', - name: 'Docs', // Called 'Docs' rather than 'Page' - importPath: './path/to/component-a.ts', - parameters: { - docsOnly: true, - }, - }, - }, - }; - fullAPI.emit(SET_STORIES, setStoriesPayload); - - const { storiesHash: storedStoriesHash } = store.getState(); - expect(storedStoriesHash['component-a--docs']).toMatchObject({ - type: 'docs', - id: 'component-a--docs', - parent: 'component-a', - title: 'Component A', - name: 'Docs', - }); - }); - - describe('when DOCS_MODE = true', () => { - it('strips out stories entries', async () => { - const navigate = jest.fn(); - const store = createMockStore(); - const fullAPI = Object.assign(new EventEmitter(), { - setOptions: jest.fn(), - findRef: jest.fn(), - }); - - const { api, init } = initStories({ - store, - navigate, - provider, - fullAPI, - docsOptions: { docsMode: true }, - } as any); - Object.assign(fullAPI, api); - - await init(); - - const setStoriesPayload = { - v: 2, - globalParameters: { global: 'global' }, - kindParameters: { a: { kind: 'kind' } }, - stories: { - 'component-a--docs': { - type: 'story', - kind: 'Component A', - name: 'Docs', // Called 'Docs' rather than 'Page' - importPath: './path/to/component-a.ts', - parameters: { - docsOnly: true, - }, - }, - 'component-a--story': { - title: 'Story', - kind: 'Component A', - importPath: './path/to/component-a.ts', - parameters: { story: 'story' }, - }, - }, - }; - fullAPI.emit(SET_STORIES, setStoriesPayload); - - const { storiesHash: storedStoriesHash } = store.getState(); - expect(Object.keys(storedStoriesHash)).toEqual(['component-a', 'component-a--docs']); - }); - }); - it('normalizes parameters and calls setRef for external stories', () => { const fullAPI = Object.assign(new EventEmitter()); const navigate = jest.fn(); @@ -1589,7 +1306,7 @@ describe('stories API', () => { const { init, api } = initStories({ store, navigate, provider, fullAPI } as any); Object.assign(fullAPI, api, { - setStories: jest.fn(), + setIndex: jest.fn(), findRef: jest.fn(), setRef: jest.fn(), }); @@ -1607,7 +1324,7 @@ describe('stories API', () => { }; fullAPI.emit(SET_STORIES, setStoriesPayload); - expect(fullAPI.setStories).not.toHaveBeenCalled(); + expect(fullAPI.setIndex).not.toHaveBeenCalled(); expect(fullAPI.setRef).toHaveBeenCalledWith( 'ref', { @@ -1619,31 +1336,6 @@ describe('stories API', () => { true ); }); - - it('calls setOptions w/ first story parameter', () => { - const fullAPI = Object.assign(new EventEmitter(), { - setStories: jest.fn(), - setOptions: jest.fn(), - findRef: jest.fn(), - }); - const navigate = jest.fn(); - const store = createMockStore(); - - const { init, api } = initStories({ store, navigate, provider, fullAPI } as any); - Object.assign(fullAPI, api, { getCurrentParameter: jest.fn().mockReturnValue('options') }); - init(); - - store.setState({}); - const setStoriesPayload = { - v: 2, - globalParameters: {}, - kindParameters: { a: {} }, - stories: { 'a--1': { kind: 'a' } }, - }; - fullAPI.emit(SET_STORIES, setStoriesPayload); - - expect(fullAPI.setOptions).toHaveBeenCalledWith('options'); - }); }); describe('legacy (v1) SET_STORIES event', () => { it('calls setRef with stories', () => { @@ -1653,7 +1345,7 @@ describe('stories API', () => { const { init, api } = initStories({ store, navigate, provider, fullAPI } as any); Object.assign(fullAPI, api, { - setStories: jest.fn(), + setIndex: jest.fn(), findRef: jest.fn(), setRef: jest.fn(), }); @@ -1668,7 +1360,7 @@ describe('stories API', () => { }; fullAPI.emit(SET_STORIES, setStoriesPayload); - expect(fullAPI.setStories).not.toHaveBeenCalled(); + expect(fullAPI.setIndex).not.toHaveBeenCalled(); expect(fullAPI.setRef).toHaveBeenCalledWith( 'ref', { diff --git a/code/lib/client-api/src/ClientApi.test.ts b/code/lib/client-api/src/ClientApi.test.ts index fc21c0763e4f..d4b44e04a1da 100644 --- a/code/lib/client-api/src/ClientApi.test.ts +++ b/code/lib/client-api/src/ClientApi.test.ts @@ -34,6 +34,9 @@ describe('ClientApi', () => { }; clientApi.storiesOf('kind1', module1 as unknown as NodeModule).add('story1', jest.fn()); clientApi.storiesOf('kind2', module2 as unknown as NodeModule).add('story2', jest.fn()); + // This gets called by configure + // eslint-disable-next-line no-underscore-dangle + clientApi._loadAddedExports(); expect(Object.keys(clientApi.getStoryIndex().entries)).toEqual([ 'kind1--story1', @@ -42,6 +45,7 @@ describe('ClientApi', () => { disposeCallback(); clientApi.storiesOf('kind1', module1 as unknown as NodeModule).add('story1', jest.fn()); + await new Promise((r) => setTimeout(r, 0)); expect(Object.keys(clientApi.getStoryIndex().entries)).toEqual([ 'kind1--story1', 'kind2--story2', diff --git a/code/lib/client-api/src/ClientApi.ts b/code/lib/client-api/src/ClientApi.ts index 35ee1170af2a..75807696f919 100644 --- a/code/lib/client-api/src/ClientApi.ts +++ b/code/lib/client-api/src/ClientApi.ts @@ -23,6 +23,7 @@ import { composeStepRunners, StoryStore, normalizeInputTypes, + ModuleExports, } from '@storybook/store'; import type { NormalizedComponentAnnotations, Path, ModuleImportFn } from '@storybook/store'; import type { ClientApiAddons, StoryApi } from '@storybook/addons'; @@ -216,6 +217,19 @@ export class ClientApi { this.facade.projectAnnotations.argTypesEnhancers.push(enhancer); }; + // Because of the API of `storiesOf().add()` we don't have a good "end" call for a + // storiesOf file to finish adding stories, and us to load it into the facade as a + // single psuedo-CSF file. So instead we just keep collecting the CSF files and load + // them all into the facade at the end. + _addedExports = {} as Record; + + _loadAddedExports() { + // eslint-disable-next-line no-underscore-dangle + Object.entries(this._addedExports).forEach(([fileName, fileExports]) => + this.facade.addStoriesFromExports(fileName, fileExports) + ); + } + // what are the occasions that "m" is a boolean vs an obj storiesOf = (kind: string, m?: NodeModule): StoryApi => { if (!kind && typeof kind !== 'string') { @@ -243,12 +257,9 @@ export class ClientApi { let fileName = baseFilename; let i = 1; // Deal with `storiesOf()` being called twice in the same file. - // On HMR, `this.csfExports[fileName]` will be reset to `{}`, so an empty object is due - // to this export, not a second call of `storiesOf()`. - while ( - this.facade.csfExports[fileName] && - Object.keys(this.facade.csfExports[fileName]).length > 0 - ) { + // On HMR, we clear _addedExports[fileName] below. + // eslint-disable-next-line no-underscore-dangle + while (this._addedExports[fileName]) { i += 1; fileName = `${baseFilename}-${i}`; } @@ -259,6 +270,8 @@ export class ClientApi { m.hot.accept(); m.hot.dispose(() => { this.facade.clearFilenameExports(fileName); + // eslint-disable-next-line no-underscore-dangle + delete this._addedExports[fileName]; // We need to update the importFn as soon as the module re-evaluates // (and calls storiesOf() again, etc). We could call `onImportFnChanged()` @@ -266,6 +279,8 @@ export class ClientApi { // debounce it somehow for initial startup. Instead, we'll take advantage of // the fact that the evaluation of the module happens immediately in the same tick setTimeout(() => { + // eslint-disable-next-line no-underscore-dangle + this._loadAddedExports(); this.onImportFnChanged?.({ importFn: this.importFn.bind(this) }); }, 0); }); @@ -297,7 +312,8 @@ export class ClientApi { parameters: {}, }; // We map these back to a simple default export, even though we have type guarantees at this point - this.facade.csfExports[fileName] = { default: meta }; + // eslint-disable-next-line no-underscore-dangle + this._addedExports[fileName] = { default: meta }; let counter = 0; api.add = (storyName: string, storyFn: StoryFn, parameters: Parameters = {}) => { @@ -318,7 +334,8 @@ export class ClientApi { // eslint-disable-next-line no-underscore-dangle const storyId = parameters.__id || toId(kind, storyName); - const csfExports = this.facade.csfExports[fileName]; + // eslint-disable-next-line no-underscore-dangle + const csfExports = this._addedExports[fileName]; // Whack a _ on the front incase it is "default" csfExports[`story${counter}`] = { name: storyName, @@ -332,13 +349,6 @@ export class ClientApi { }; counter += 1; - this.facade.entries[storyId] = { - id: storyId, - title: csfExports.default.title, - name: storyName, - importPath: fileName, - type: 'story', - }; return api; }; diff --git a/code/lib/client-api/src/StoryStoreFacade.ts b/code/lib/client-api/src/StoryStoreFacade.ts index 87ad62aa8624..efa2204763ca 100644 --- a/code/lib/client-api/src/StoryStoreFacade.ts +++ b/code/lib/client-api/src/StoryStoreFacade.ts @@ -2,13 +2,7 @@ import global from 'global'; import { dedent } from 'ts-dedent'; import { SynchronousPromise } from 'synchronous-promise'; -import { - toId, - isExportStory, - storyNameFromExport, - ComponentTitle, - ComponentId, -} from '@storybook/csf'; +import { toId, isExportStory, storyNameFromExport, ComponentId } from '@storybook/csf'; import type { StoryId, AnyFramework, Parameters, StoryFn } from '@storybook/csf'; import { StoryStore, userOrAutoTitle, sortStoriesV6 } from '@storybook/store'; import type { @@ -198,35 +192,20 @@ export class StoryStoreFacade { }); } - const docsOptions = (global.DOCS_OPTIONS || {}) as DocsOptions; - const seenTitles = new Set(); - Object.entries(sortedExports) - .filter(([key]) => isExportStory(key, defaultExport)) - .forEach(([key, storyExport]: [string, any]) => { - const exportName = storyNameFromExport(key); - const id = storyExport.parameters?.__id || toId(componentId || title, exportName); - const name = - (typeof storyExport !== 'function' && storyExport.name) || - storyExport.storyName || - storyExport.story?.name || - exportName; - - if (!seenTitles.has(title) && docsOptions.docsPage) { - const name = docsOptions.defaultName; - const docsId = toId(componentId || title, name); - seenTitles.add(title); - this.entries[docsId] = { - type: 'docs', - standalone: false, - id: docsId, - title, - name, - importPath: fileName, - storiesImports: [], - componentId, - }; - } + const storyExports = Object.entries(sortedExports).filter(([key]) => + isExportStory(key, defaultExport) + ); + + storyExports.forEach(([key, storyExport]: [string, any]) => { + const exportName = storyNameFromExport(key); + const id = storyExport.parameters?.__id || toId(componentId || title, exportName); + const name = + (typeof storyExport !== 'function' && storyExport.name) || + storyExport.storyName || + storyExport.story?.name || + exportName; + if (!storyExport.parameters?.docsOnly) { this.entries[id] = { type: 'story', id, @@ -235,6 +214,27 @@ export class StoryStoreFacade { importPath: fileName, componentId, }; - }); + } + }); + + // NOTE: this logic is equivalent to the `extractStories` function of `StoryIndexGenerator` + const docsOptions = (global.DOCS_OPTIONS || {}) as DocsOptions; + if (docsOptions.enabled && storyExports.length) { + // We will use tags soon and this crappy filename test will go away + if (fileName.match(/\.mdx$/) || docsOptions.docsPage) { + const name = docsOptions.defaultName; + const docsId = toId(componentId || title, name); + this.entries[docsId] = { + type: 'docs', + standalone: false, + id: docsId, + title, + name, + importPath: fileName, + storiesImports: [], + componentId, + }; + } + } } } diff --git a/code/lib/core-client/src/preview/start.test.ts b/code/lib/core-client/src/preview/start.test.ts index f8ad52fb8474..b592d1d7101d 100644 --- a/code/lib/core-client/src/preview/start.test.ts +++ b/code/lib/core-client/src/preview/start.test.ts @@ -1,5 +1,5 @@ /* global window */ -import Events from '@storybook/core-events'; +import Events, { STORY_UNCHANGED } from '@storybook/core-events'; import { waitForRender, @@ -10,7 +10,8 @@ import { } from '@storybook/preview-web/dist/cjs/PreviewWeb.mockdata'; // @ts-expect-error (Converted from ts-ignore) import { WebView } from '@storybook/preview-web/dist/cjs/WebView'; -import { setGlobalRender } from '@storybook/client-api'; +import { ModuleExports, Path, setGlobalRender } from '@storybook/client-api'; +import global from 'global'; import { start } from './start'; @@ -31,6 +32,9 @@ jest.mock('global', () => ({ FEATURES: { breakingChangesV7: true, }, + DOCS_OPTIONS: { + enabled: true, + }, })); jest.mock('@storybook/channel-postmessage', () => ({ createChannel: () => mockChannel })); @@ -52,7 +56,21 @@ beforeEach(() => { emitter.removeAllListeners(); }); +afterEach(() => { + // I'm not sure why this is required (it seems just afterEach is required really) + mockChannel.emit.mockClear(); +}); + +function makeRequireContext(importMap: Record) { + const req = (path: Path) => importMap[path]; + req.keys = () => Object.keys(importMap); + return req; +} + describe('start', () => { + beforeEach(() => { + global.DOCS_OPTIONS = { enabled: false }; + }); describe('when configure is called with storiesOf only', () => { it('loads and renders the first story correctly', async () => { const renderToDOM = jest.fn(); @@ -72,24 +90,17 @@ describe('start', () => { await waitForRender(); expect( - mockChannel.emit.mock.calls.find((call: [string, any]) => call[0] === Events.SET_STORIES)[1] + mockChannel.emit.mock.calls.find((call: [string, any]) => call[0] === Events.SET_INDEX)[1] ).toMatchInlineSnapshot(` Object { - "globalParameters": Object {}, - "globals": Object {}, - "kindParameters": Object { - "Component A": Object {}, - "Component B": Object {}, - }, - "stories": Object { + "entries": Object { "component-a--story-one": Object { "argTypes": Object {}, "args": Object {}, - "component": undefined, "componentId": "component-a", "id": "component-a--story-one", + "importPath": "file1", "initialArgs": Object {}, - "kind": "Component A", "name": "Story One", "parameters": Object { "__id": "component-a--story-one", @@ -97,19 +108,16 @@ describe('start', () => { "fileName": "file1", "framework": "test", }, - "playFunction": undefined, - "story": "Story One", - "subcomponents": undefined, "title": "Component A", + "type": "story", }, "component-a--story-two": Object { "argTypes": Object {}, "args": Object {}, - "component": undefined, "componentId": "component-a", "id": "component-a--story-two", + "importPath": "file1", "initialArgs": Object {}, - "kind": "Component A", "name": "Story Two", "parameters": Object { "__id": "component-a--story-two", @@ -117,19 +125,16 @@ describe('start', () => { "fileName": "file1", "framework": "test", }, - "playFunction": undefined, - "story": "Story Two", - "subcomponents": undefined, "title": "Component A", + "type": "story", }, "component-b--story-three": Object { "argTypes": Object {}, "args": Object {}, - "component": undefined, "componentId": "component-b", "id": "component-b--story-three", + "importPath": "file2", "initialArgs": Object {}, - "kind": "Component B", "name": "Story Three", "parameters": Object { "__id": "component-b--story-three", @@ -137,13 +142,11 @@ describe('start', () => { "fileName": "file2", "framework": "test", }, - "playFunction": undefined, - "story": "Story Three", - "subcomponents": undefined, "title": "Component B", + "type": "story", }, }, - "v": 2, + "v": 4, } `); @@ -161,59 +164,6 @@ describe('start', () => { ); }); - it('sends over docs only stories', async () => { - const renderToDOM = jest.fn(); - - const { configure, clientApi } = start(renderToDOM); - - configure('test', () => { - clientApi - .storiesOf('Component A', { id: 'file1' } as NodeModule) - .add('Story One', jest.fn(), { docsOnly: true, docs: {} }); - }); - - await waitForEvents([Events.SET_STORIES]); - expect( - mockChannel.emit.mock.calls.find((call: [string, any]) => call[0] === Events.SET_STORIES)[1] - ).toMatchInlineSnapshot(` - Object { - "globalParameters": Object {}, - "globals": Object {}, - "kindParameters": Object { - "Component A": Object {}, - }, - "stories": Object { - "component-a--story-one": Object { - "argTypes": Object {}, - "args": Object {}, - "component": undefined, - "componentId": "component-a", - "id": "component-a--story-one", - "initialArgs": Object {}, - "kind": "Component A", - "name": "Story One", - "parameters": Object { - "__id": "component-a--story-one", - "__isArgsStory": false, - "docs": Object {}, - "docsOnly": true, - "fileName": "file1", - "framework": "test", - }, - "playFunction": undefined, - "story": "Story One", - "subcomponents": undefined, - "title": "Component A", - }, - }, - "v": 2, - } - `); - - // Wait a second to let the docs "render" finish (and maybe throw) - await waitForQuiescence(); - }); - it('deals with stories with "default" name', async () => { const renderToDOM = jest.fn(); @@ -293,9 +243,9 @@ describe('start', () => { expect(mockChannel.emit).toHaveBeenCalledWith(Events.STORY_RENDERED, 'component-a--default'); const storiesOfData = mockChannel.emit.mock.calls.find( - (call: [string, any]) => call[0] === Events.SET_STORIES + (call: [string, any]) => call[0] === Events.SET_INDEX )[1]; - expect(Object.values(storiesOfData.stories).map((s: any) => s.parameters.fileName)).toEqual([ + expect(Object.values(storiesOfData.entries).map((s: any) => s.parameters.fileName)).toEqual([ 'file1', 'file1-2', 'file1-3', @@ -363,7 +313,7 @@ describe('start', () => { it('supports HMR when a story file changes', async () => { const renderToDOM = jest.fn(({ storyFn }) => storyFn()); - const { configure, clientApi, forceReRender } = start(renderToDOM); + const { configure, clientApi } = start(renderToDOM); let disposeCallback: () => void; const module = { @@ -396,7 +346,7 @@ describe('start', () => { expect(secondImplementation).toHaveBeenCalled(); }); - it('re-emits SET_STORIES when a story is added', async () => { + it('re-emits SET_INDEX when a story is added', async () => { const renderToDOM = jest.fn(({ storyFn }) => storyFn()); const { configure, clientApi, forceReRender } = start(renderToDOM); @@ -424,25 +374,19 @@ describe('start', () => { .add('default', jest.fn()) .add('new', jest.fn()); - await waitForEvents([Events.SET_STORIES]); + await waitForEvents([Events.SET_INDEX]); expect( - mockChannel.emit.mock.calls.find((call: [string, any]) => call[0] === Events.SET_STORIES)[1] + mockChannel.emit.mock.calls.find((call: [string, any]) => call[0] === Events.SET_INDEX)[1] ).toMatchInlineSnapshot(` Object { - "globalParameters": Object {}, - "globals": Object {}, - "kindParameters": Object { - "Component A": Object {}, - }, - "stories": Object { + "entries": Object { "component-a--default": Object { "argTypes": Object {}, "args": Object {}, - "component": undefined, "componentId": "component-a", "id": "component-a--default", + "importPath": "file1", "initialArgs": Object {}, - "kind": "Component A", "name": "default", "parameters": Object { "__id": "component-a--default", @@ -450,19 +394,16 @@ describe('start', () => { "fileName": "file1", "framework": "test", }, - "playFunction": undefined, - "story": "default", - "subcomponents": undefined, "title": "Component A", + "type": "story", }, "component-a--new": Object { "argTypes": Object {}, "args": Object {}, - "component": undefined, "componentId": "component-a", "id": "component-a--new", + "importPath": "file1", "initialArgs": Object {}, - "kind": "Component A", "name": "new", "parameters": Object { "__id": "component-a--new", @@ -470,18 +411,16 @@ describe('start', () => { "fileName": "file1", "framework": "test", }, - "playFunction": undefined, - "story": "new", - "subcomponents": undefined, "title": "Component A", + "type": "story", }, }, - "v": 2, + "v": 4, } `); }); - it('re-emits SET_STORIES when a story file is removed', async () => { + it('re-emits SET_INDEX when a story file is removed', async () => { const renderToDOM = jest.fn(({ storyFn }) => storyFn()); const { configure, clientApi, forceReRender } = start(renderToDOM); @@ -501,26 +440,19 @@ describe('start', () => { clientApi.storiesOf('Component B', moduleB as any).add('default', jest.fn()); }); - await waitForEvents([Events.SET_STORIES]); + await waitForEvents([Events.SET_INDEX]); expect( - mockChannel.emit.mock.calls.find((call: [string, any]) => call[0] === Events.SET_STORIES)[1] + mockChannel.emit.mock.calls.find((call: [string, any]) => call[0] === Events.SET_INDEX)[1] ).toMatchInlineSnapshot(` Object { - "globalParameters": Object {}, - "globals": Object {}, - "kindParameters": Object { - "Component A": Object {}, - "Component B": Object {}, - }, - "stories": Object { + "entries": Object { "component-a--default": Object { "argTypes": Object {}, "args": Object {}, - "component": undefined, "componentId": "component-a", "id": "component-a--default", + "importPath": "file1", "initialArgs": Object {}, - "kind": "Component A", "name": "default", "parameters": Object { "__id": "component-a--default", @@ -528,19 +460,16 @@ describe('start', () => { "fileName": "file1", "framework": "test", }, - "playFunction": undefined, - "story": "default", - "subcomponents": undefined, "title": "Component A", + "type": "story", }, "component-b--default": Object { "argTypes": Object {}, "args": Object {}, - "component": undefined, "componentId": "component-b", "id": "component-b--default", + "importPath": "file2", "initialArgs": Object {}, - "kind": "Component B", "name": "default", "parameters": Object { "__id": "component-b--default", @@ -548,37 +477,29 @@ describe('start', () => { "fileName": "file2", "framework": "test", }, - "playFunction": undefined, - "story": "default", - "subcomponents": undefined, "title": "Component B", + "type": "story", }, }, - "v": 2, + "v": 4, } `); mockChannel.emit.mockClear(); disposeCallback(); - await waitForEvents([Events.SET_STORIES]); + await waitForEvents([Events.SET_INDEX]); expect( - mockChannel.emit.mock.calls.find((call: [string, any]) => call[0] === Events.SET_STORIES)[1] + mockChannel.emit.mock.calls.find((call: [string, any]) => call[0] === Events.SET_INDEX)[1] ).toMatchInlineSnapshot(` Object { - "globalParameters": Object {}, - "globals": Object {}, - "kindParameters": Object { - "Component A": Object {}, - }, - "stories": Object { + "entries": Object { "component-a--default": Object { "argTypes": Object {}, "args": Object {}, - "component": undefined, "componentId": "component-a", "id": "component-a--default", + "importPath": "file1", "initialArgs": Object {}, - "kind": "Component A", "name": "default", "parameters": Object { "__id": "component-a--default", @@ -586,13 +507,11 @@ describe('start', () => { "fileName": "file1", "framework": "test", }, - "playFunction": undefined, - "story": "default", - "subcomponents": undefined, "title": "Component A", + "type": "story", }, }, - "v": 2, + "v": 4, } `); }); @@ -615,55 +534,44 @@ describe('start', () => { await waitForRender(); expect( - mockChannel.emit.mock.calls.find((call: [string, any]) => call[0] === Events.SET_STORIES)[1] + mockChannel.emit.mock.calls.find((call: [string, any]) => call[0] === Events.SET_INDEX)[1] ).toMatchInlineSnapshot(` Object { - "globalParameters": Object {}, - "globals": Object {}, - "kindParameters": Object { - "Component C": Object {}, - }, - "stories": Object { + "entries": Object { "component-c--story-one": Object { "argTypes": Object {}, "args": Object {}, - "component": undefined, - "componentId": "component-c", + "componentId": undefined, "id": "component-c--story-one", + "importPath": "exports-map-0", "initialArgs": Object {}, - "kind": "Component C", "name": "Story One", "parameters": Object { "__isArgsStory": false, "fileName": "exports-map-0", "framework": "test", }, - "playFunction": undefined, - "story": "Story One", - "subcomponents": undefined, "title": "Component C", + "type": "story", }, "component-c--story-two": Object { "argTypes": Object {}, "args": Object {}, - "component": undefined, - "componentId": "component-c", + "componentId": undefined, "id": "component-c--story-two", + "importPath": "exports-map-0", "initialArgs": Object {}, - "kind": "Component C", "name": "Story Two", "parameters": Object { "__isArgsStory": false, "fileName": "exports-map-0", "framework": "test", }, - "playFunction": undefined, - "story": "Story Two", - "subcomponents": undefined, "title": "Component C", + "type": "story", }, }, - "v": 2, + "v": 4, } `); @@ -725,7 +633,7 @@ describe('start', () => { expect(secondImplementation).toHaveBeenCalled(); }); - it('re-emits SET_STORIES when a story is added', async () => { + it('re-emits SET_INDEX when a story is added', async () => { const renderToDOM = jest.fn(({ storyFn }) => storyFn()); let disposeCallback: (data: object) => void; @@ -748,81 +656,67 @@ describe('start', () => { disposeCallback(module.hot.data); configure('test', () => [{ ...componentCExports, StoryThree: jest.fn() }], module as any); - await waitForEvents([Events.SET_STORIES]); + await waitForEvents([Events.SET_INDEX]); expect( - mockChannel.emit.mock.calls.find((call: [string, any]) => call[0] === Events.SET_STORIES)[1] + mockChannel.emit.mock.calls.find((call: [string, any]) => call[0] === Events.SET_INDEX)[1] ).toMatchInlineSnapshot(` Object { - "globalParameters": Object {}, - "globals": Object {}, - "kindParameters": Object { - "Component C": Object {}, - }, - "stories": Object { + "entries": Object { "component-c--story-one": Object { "argTypes": Object {}, "args": Object {}, - "component": undefined, - "componentId": "component-c", + "componentId": undefined, "id": "component-c--story-one", + "importPath": "exports-map-0", "initialArgs": Object {}, - "kind": "Component C", "name": "Story One", "parameters": Object { "__isArgsStory": false, "fileName": "exports-map-0", "framework": "test", }, - "playFunction": undefined, - "story": "Story One", - "subcomponents": undefined, "title": "Component C", + "type": "story", }, "component-c--story-three": Object { "argTypes": Object {}, "args": Object {}, - "component": undefined, - "componentId": "component-c", + "componentId": undefined, "id": "component-c--story-three", + "importPath": "exports-map-0", "initialArgs": Object {}, - "kind": "Component C", "name": "Story Three", "parameters": Object { "__isArgsStory": false, "fileName": "exports-map-0", "framework": "test", }, - "playFunction": undefined, - "story": "Story Three", - "subcomponents": undefined, "title": "Component C", + "type": "story", }, "component-c--story-two": Object { "argTypes": Object {}, "args": Object {}, - "component": undefined, - "componentId": "component-c", + "componentId": undefined, "id": "component-c--story-two", + "importPath": "exports-map-0", "initialArgs": Object {}, - "kind": "Component C", "name": "Story Two", "parameters": Object { "__isArgsStory": false, "fileName": "exports-map-0", "framework": "test", }, - "playFunction": undefined, - "story": "Story Two", - "subcomponents": undefined, "title": "Component C", + "type": "story", }, }, - "v": 2, + "v": 4, } `); }); - it('re-emits SET_STORIES when a story file is removed', async () => { + it('re-emits SET_INDEX when a story file is removed', async () => { const renderToDOM = jest.fn(({ storyFn }) => storyFn()); let disposeCallback: (data: object) => void; @@ -843,77 +737,62 @@ describe('start', () => { module as any ); - await waitForEvents([Events.SET_STORIES]); + await waitForEvents([Events.SET_INDEX]); expect( - mockChannel.emit.mock.calls.find((call: [string, any]) => call[0] === Events.SET_STORIES)[1] + mockChannel.emit.mock.calls.find((call: [string, any]) => call[0] === Events.SET_INDEX)[1] ).toMatchInlineSnapshot(` Object { - "globalParameters": Object {}, - "globals": Object {}, - "kindParameters": Object { - "Component C": Object {}, - "Component D": Object {}, - }, - "stories": Object { + "entries": Object { "component-c--story-one": Object { "argTypes": Object {}, "args": Object {}, - "component": undefined, - "componentId": "component-c", + "componentId": undefined, "id": "component-c--story-one", + "importPath": "exports-map-0", "initialArgs": Object {}, - "kind": "Component C", "name": "Story One", "parameters": Object { "__isArgsStory": false, "fileName": "exports-map-0", "framework": "test", }, - "playFunction": undefined, - "story": "Story One", - "subcomponents": undefined, "title": "Component C", + "type": "story", }, "component-c--story-two": Object { "argTypes": Object {}, "args": Object {}, - "component": undefined, - "componentId": "component-c", + "componentId": undefined, "id": "component-c--story-two", + "importPath": "exports-map-0", "initialArgs": Object {}, - "kind": "Component C", "name": "Story Two", "parameters": Object { "__isArgsStory": false, "fileName": "exports-map-0", "framework": "test", }, - "playFunction": undefined, - "story": "Story Two", - "subcomponents": undefined, "title": "Component C", + "type": "story", }, "component-d--story-four": Object { "argTypes": Object {}, "args": Object {}, - "component": undefined, - "componentId": "component-d", + "componentId": undefined, "id": "component-d--story-four", + "importPath": "exports-map-1", "initialArgs": Object {}, - "kind": "Component D", "name": "Story Four", "parameters": Object { "__isArgsStory": false, "fileName": "exports-map-1", "framework": "test", }, - "playFunction": undefined, - "story": "Story Four", - "subcomponents": undefined, "title": "Component D", + "type": "story", }, }, - "v": 2, + "v": 4, } `); await waitForRender(); @@ -922,61 +801,50 @@ describe('start', () => { disposeCallback(module.hot.data); configure('test', () => [componentCExports], module as any); - await waitForEvents([Events.SET_STORIES]); + await waitForEvents([Events.SET_INDEX]); expect( - mockChannel.emit.mock.calls.find((call: [string, any]) => call[0] === Events.SET_STORIES)[1] + mockChannel.emit.mock.calls.find((call: [string, any]) => call[0] === Events.SET_INDEX)[1] ).toMatchInlineSnapshot(` Object { - "globalParameters": Object {}, - "globals": Object {}, - "kindParameters": Object { - "Component C": Object {}, - }, - "stories": Object { + "entries": Object { "component-c--story-one": Object { "argTypes": Object {}, "args": Object {}, - "component": undefined, - "componentId": "component-c", + "componentId": undefined, "id": "component-c--story-one", + "importPath": "exports-map-0", "initialArgs": Object {}, - "kind": "Component C", "name": "Story One", "parameters": Object { "__isArgsStory": false, "fileName": "exports-map-0", "framework": "test", }, - "playFunction": undefined, - "story": "Story One", - "subcomponents": undefined, "title": "Component C", + "type": "story", }, "component-c--story-two": Object { "argTypes": Object {}, "args": Object {}, - "component": undefined, - "componentId": "component-c", + "componentId": undefined, "id": "component-c--story-two", + "importPath": "exports-map-0", "initialArgs": Object {}, - "kind": "Component C", "name": "Story Two", "parameters": Object { "__isArgsStory": false, "fileName": "exports-map-0", "framework": "test", }, - "playFunction": undefined, - "story": "Story Two", - "subcomponents": undefined, "title": "Component C", + "type": "story", }, }, - "v": 2, + "v": 4, } `); - await waitForRender(); + await waitForEvents([STORY_UNCHANGED]); }); it('allows you to override the render function in project annotations', async () => { @@ -1008,6 +876,53 @@ describe('start', () => { expect(frameworkRender).not.toHaveBeenCalled(); expect(projectRender).toHaveBeenCalled(); }); + + describe('docs', () => { + beforeEach(() => { + global.DOCS_OPTIONS = { enabled: true }; + }); + + // NOTE: MDX files are only ever passed as CSF + it('sends over docs only stories as entries', async () => { + const renderToDOM = jest.fn(); + + const { configure } = start(renderToDOM); + + configure( + 'test', + makeRequireContext({ + './Introduction.stories.mdx': { + default: { title: 'Introduction' }, + _Page: { name: 'Page', parameters: { docsOnly: true } }, + }, + }) + ); + + await waitForEvents([Events.SET_INDEX]); + expect( + mockChannel.emit.mock.calls.find((call: [string, any]) => call[0] === Events.SET_INDEX)[1] + ).toMatchInlineSnapshot(` + Object { + "entries": Object { + "introduction": Object { + "componentId": undefined, + "id": "introduction", + "importPath": "./Introduction.stories.mdx", + "name": undefined, + "standalone": false, + "storiesImports": Array [], + "title": "Introduction", + "type": "docs", + }, + }, + "v": 4, + } + `); + + // Wait a second to let the docs "render" finish (and maybe throw) + await waitForQuiescence(); + }); + }); }); describe('when configure is called with a combination', () => { @@ -1030,25 +945,17 @@ describe('start', () => { await waitForRender(); expect( - mockChannel.emit.mock.calls.find((call: [string, any]) => call[0] === Events.SET_STORIES)[1] + mockChannel.emit.mock.calls.find((call: [string, any]) => call[0] === Events.SET_INDEX)[1] ).toMatchInlineSnapshot(` Object { - "globalParameters": Object {}, - "globals": Object {}, - "kindParameters": Object { - "Component A": Object {}, - "Component B": Object {}, - "Component C": Object {}, - }, - "stories": Object { + "entries": Object { "component-a--story-one": Object { "argTypes": Object {}, "args": Object {}, - "component": undefined, "componentId": "component-a", "id": "component-a--story-one", + "importPath": "file1", "initialArgs": Object {}, - "kind": "Component A", "name": "Story One", "parameters": Object { "__id": "component-a--story-one", @@ -1056,19 +963,16 @@ describe('start', () => { "fileName": "file1", "framework": "test", }, - "playFunction": undefined, - "story": "Story One", - "subcomponents": undefined, "title": "Component A", + "type": "story", }, "component-a--story-two": Object { "argTypes": Object {}, "args": Object {}, - "component": undefined, "componentId": "component-a", "id": "component-a--story-two", + "importPath": "file1", "initialArgs": Object {}, - "kind": "Component A", "name": "Story Two", "parameters": Object { "__id": "component-a--story-two", @@ -1076,19 +980,16 @@ describe('start', () => { "fileName": "file1", "framework": "test", }, - "playFunction": undefined, - "story": "Story Two", - "subcomponents": undefined, "title": "Component A", + "type": "story", }, "component-b--story-three": Object { "argTypes": Object {}, "args": Object {}, - "component": undefined, "componentId": "component-b", "id": "component-b--story-three", + "importPath": "file2", "initialArgs": Object {}, - "kind": "Component B", "name": "Story Three", "parameters": Object { "__id": "component-b--story-three", @@ -1096,51 +997,43 @@ describe('start', () => { "fileName": "file2", "framework": "test", }, - "playFunction": undefined, - "story": "Story Three", - "subcomponents": undefined, "title": "Component B", + "type": "story", }, "component-c--story-one": Object { "argTypes": Object {}, "args": Object {}, - "component": undefined, - "componentId": "component-c", + "componentId": undefined, "id": "component-c--story-one", + "importPath": "exports-map-0", "initialArgs": Object {}, - "kind": "Component C", "name": "Story One", "parameters": Object { "__isArgsStory": false, "fileName": "exports-map-0", "framework": "test", }, - "playFunction": undefined, - "story": "Story One", - "subcomponents": undefined, "title": "Component C", + "type": "story", }, "component-c--story-two": Object { "argTypes": Object {}, "args": Object {}, - "component": undefined, - "componentId": "component-c", + "componentId": undefined, "id": "component-c--story-two", + "importPath": "exports-map-0", "initialArgs": Object {}, - "kind": "Component C", "name": "Story Two", "parameters": Object { "__isArgsStory": false, "fileName": "exports-map-0", "framework": "test", }, - "playFunction": undefined, - "story": "Story Two", - "subcomponents": undefined, "title": "Component C", + "type": "story", }, }, - "v": 2, + "v": 4, } `); @@ -1157,6 +1050,14 @@ describe('start', () => { 'story-root' ); }); + + describe('docsPage', () => { + beforeEach(() => { + global.DOCS_OPTIONS = { enabled: true, docsPage: true, defaultTitle: 'Docs' }; + }); + + it('adds stories for each component', async () => {}); + }); }); describe('auto-title', () => { @@ -1172,38 +1073,30 @@ describe('start', () => { const { configure } = start(renderToDOM); configure('test', () => [componentDExports]); - await waitForEvents([Events.SET_STORIES]); + await waitForEvents([Events.SET_INDEX]); expect( - mockChannel.emit.mock.calls.find((call: [string, any]) => call[0] === Events.SET_STORIES)[1] + mockChannel.emit.mock.calls.find((call: [string, any]) => call[0] === Events.SET_INDEX)[1] ).toMatchInlineSnapshot(` Object { - "globalParameters": Object {}, - "globals": Object {}, - "kindParameters": Object { - "auto-title": Object {}, - }, - "stories": Object { + "entries": Object { "auto-title--story-one": Object { "argTypes": Object {}, "args": Object {}, - "component": "Component D", - "componentId": "auto-title", + "componentId": undefined, "id": "auto-title--story-one", + "importPath": "exports-map-0", "initialArgs": Object {}, - "kind": "auto-title", "name": "Story One", "parameters": Object { "__isArgsStory": false, "fileName": "exports-map-0", "framework": "test", }, - "playFunction": undefined, - "story": "Story One", - "subcomponents": undefined, "title": "auto-title", + "type": "story", }, }, - "v": 2, + "v": 4, } `); diff --git a/code/lib/core-client/src/preview/start.ts b/code/lib/core-client/src/preview/start.ts index bd64f168d571..31dcb2079055 100644 --- a/code/lib/core-client/src/preview/start.ts +++ b/code/lib/core-client/src/preview/start.ts @@ -113,6 +113,8 @@ export function start( // function in case it throws. So we also need to process its output there also const getProjectAnnotations = () => { const { added, removed } = executeLoadableForChanges(loadable, m); + // eslint-disable-next-line no-underscore-dangle + clientApi._loadAddedExports(); Array.from(added.entries()).forEach(([fileName, fileExports]) => clientApi.facade.addStoriesFromExports(fileName, fileExports) diff --git a/code/lib/core-events/src/index.ts b/code/lib/core-events/src/index.ts index 0fb8d7bed579..b9a130fc9ead 100644 --- a/code/lib/core-events/src/index.ts +++ b/code/lib/core-events/src/index.ts @@ -8,8 +8,10 @@ enum events { STORY_SPECIFIED = 'storySpecified', // Emitted by Provider.setOptions is called from an manager-addon or manager.js file SET_CONFIG = 'setConfig', - // Emitted by the preview whenever the list of stories changes (in batches) + // Emitted by the preview whenever the list of stories changes (in batches) - legacy pre-7.0 event SET_STORIES = 'setStories', + // Emitted by the preview whenever the list of entries changes - legacy event for v6 store + SET_INDEX = 'setIndex', // Set the current story selection in the preview SET_CURRENT_STORY = 'setCurrentStory', // The current story changed due to the above @@ -70,6 +72,7 @@ export const { STORY_INDEX_INVALIDATED, STORY_SPECIFIED, SET_STORIES, + SET_INDEX, SET_CONFIG, SET_CURRENT_STORY, CURRENT_STORY_WAS_SET, diff --git a/code/lib/preview-web/src/PreviewWeb.tsx b/code/lib/preview-web/src/PreviewWeb.tsx index f11f67600746..3d2081896f2d 100644 --- a/code/lib/preview-web/src/PreviewWeb.tsx +++ b/code/lib/preview-web/src/PreviewWeb.tsx @@ -5,7 +5,7 @@ import { PRELOAD_ENTRIES, PREVIEW_KEYDOWN, SET_CURRENT_STORY, - SET_STORIES, + SET_INDEX, STORY_ARGS_UPDATED, STORY_CHANGED, STORY_ERRORED, @@ -104,7 +104,7 @@ export class PreviewWeb extends Preview { return super.initializeWithStoryIndex(storyIndex).then(() => { if (!global.FEATURES?.storyStoreV7) { - this.channel.emit(SET_STORIES, this.storyStore.getSetStoriesPayload()); + this.channel.emit(SET_INDEX, this.storyStore.getSetIndexPayload()); } return this.selectSpecifiedStory(); @@ -184,7 +184,7 @@ export class PreviewWeb extends Preview extends Preview extends Preview { }); }); + describe('getSetIndexPayload', () => { + it('add parameters/args to index correctly', async () => { + const store = new StoryStore(); + store.setProjectAnnotations(projectAnnotations); + store.initialize({ storyIndex, importFn, cache: false }); + await store.cacheAllCSFFiles(); + + expect(store.getSetIndexPayload()).toMatchInlineSnapshot(` + Object { + "entries": Object { + "component-one--a": Object { + "argTypes": Object { + "a": Object { + "name": "a", + "type": Object { + "name": "string", + }, + }, + "foo": Object { + "name": "foo", + "type": Object { + "name": "string", + }, + }, + }, + "args": Object { + "foo": "a", + }, + "id": "component-one--a", + "importPath": "./src/ComponentOne.stories.js", + "initialArgs": Object { + "foo": "a", + }, + "name": "A", + "parameters": Object { + "__isArgsStory": false, + "fileName": "./src/ComponentOne.stories.js", + }, + "title": "Component One", + "type": "story", + }, + "component-one--b": Object { + "argTypes": Object { + "a": Object { + "name": "a", + "type": Object { + "name": "string", + }, + }, + "foo": Object { + "name": "foo", + "type": Object { + "name": "string", + }, + }, + }, + "args": Object { + "foo": "b", + }, + "id": "component-one--b", + "importPath": "./src/ComponentOne.stories.js", + "initialArgs": Object { + "foo": "b", + }, + "name": "B", + "parameters": Object { + "__isArgsStory": false, + "fileName": "./src/ComponentOne.stories.js", + }, + "title": "Component One", + "type": "story", + }, + "component-two--c": Object { + "argTypes": Object { + "a": Object { + "name": "a", + "type": Object { + "name": "string", + }, + }, + "foo": Object { + "name": "foo", + "type": Object { + "name": "string", + }, + }, + }, + "args": Object { + "foo": "c", + }, + "id": "component-two--c", + "importPath": "./src/ComponentTwo.stories.js", + "initialArgs": Object { + "foo": "c", + }, + "name": "C", + "parameters": Object { + "__isArgsStory": false, + "fileName": "./src/ComponentTwo.stories.js", + }, + "title": "Component Two", + "type": "story", + }, + }, + "v": 4, + } + `); + }); + }); + describe('cacheAllCsfFiles', () => { describe('if the store is not yet initialized', () => { it('waits for initialization', async () => { diff --git a/code/lib/store/src/StoryStore.ts b/code/lib/store/src/StoryStore.ts index 43d37866efb1..4e4ab3841796 100644 --- a/code/lib/store/src/StoryStore.ts +++ b/code/lib/store/src/StoryStore.ts @@ -31,6 +31,7 @@ import type { IndexEntry, StoryIndexV3, ModuleExports, + PreparedStoryIndex, } from './types'; import { HooksContext } from './hooks'; @@ -351,6 +352,30 @@ export class StoryStore { }; }; + getSetIndexPayload(): PreparedStoryIndex { + if (!this.storyIndex) throw new Error('getSetIndexPayload called before initialization'); + + const stories = this.extract({ includeDocsOnly: true }); + + return { + v: 4, + entries: Object.fromEntries( + Object.entries(this.storyIndex.entries).map(([id, entry]) => [ + id, + stories[id] + ? { + ...entry, + args: stories[id].initialArgs, + initialArgs: stories[id].initialArgs, + argTypes: stories[id].argTypes, + parameters: stories[id].parameters, + } + : entry, + ]) + ), + }; + } + raw(): BoundStory[] { return Object.values(this.extract()) .map(({ id }: { id: StoryId }) => this.fromId(id)) diff --git a/code/lib/store/src/types.ts b/code/lib/store/src/types.ts index d816fab56a2f..7be119cd6bea 100644 --- a/code/lib/store/src/types.ts +++ b/code/lib/store/src/types.ts @@ -21,6 +21,7 @@ import type { ComponentId, PartialStoryFn, Parameters, + ArgTypes, } from '@storybook/csf'; import type { StoryIndexEntry, @@ -133,6 +134,20 @@ export interface StoryIndex { entries: Record; } +// We send a bit more data over the channel on the SET_INDEX event, including +// the full parameters for each story. +type PreparedIndexEntry = IndexEntry & { + parameters?: Parameters; + argTypes?: ArgTypes; + args?: Args; + initialArgs?: Args; +}; + +export interface PreparedStoryIndex { + v: number; + entries: Record; +} + export type StorySpecifier = StoryId | { name: StoryName; title: ComponentTitle } | '*'; export interface SelectionSpecifier { diff --git a/code/ui/manager/src/globals/exports.ts b/code/ui/manager/src/globals/exports.ts index f9652e7a9c78..41bd80cb7487 100644 --- a/code/ui/manager/src/globals/exports.ts +++ b/code/ui/manager/src/globals/exports.ts @@ -130,6 +130,7 @@ export default { 'SET_CONFIG', 'SET_CURRENT_STORY', 'SET_GLOBALS', + 'SET_INDEX', 'SET_STORIES', 'SHARED_STATE_CHANGED', 'SHARED_STATE_SET',