diff --git a/lib/api/src/index.tsx b/lib/api/src/index.tsx index 8f7d21781c07..e5093ac93a41 100644 --- a/lib/api/src/index.tsx +++ b/lib/api/src/index.tsx @@ -146,20 +146,27 @@ export const combineParameters = (...parameterSets: Parameters[]) => return undefined; }); -export type ModuleFn = ( - m: ModuleArgs -) => Module; - -interface Module { - init?: () => void; - api?: APIType; - state?: StateType; +interface ModuleWithInit { + init: () => void | Promise; + api: APIType; + state: StateType; } +type ModuleWithoutInit = Omit< + ModuleWithInit, + 'init' +>; + +export type ModuleFn = ( + m: ModuleArgs +) => HasInit extends true + ? ModuleWithInit + : ModuleWithoutInit; + class ManagerProvider extends Component { api: API = {} as API; - modules: Module[]; + modules: (ModuleWithInit | ModuleWithoutInit)[]; static displayName = 'Manager'; @@ -261,9 +268,9 @@ class ManagerProvider extends Component { initModules = () => { // Now every module has had a chance to set its API, call init on each module which gives it // a chance to do things that call other modules' APIs. - this.modules.forEach(({ init }) => { - if (init) { - init(); + this.modules.forEach((module) => { + if ('init' in module) { + module.init(); } }); }; diff --git a/lib/api/src/lib/stories.ts b/lib/api/src/lib/stories.ts index 08d3c526a018..7ab8b5ae18b7 100644 --- a/lib/api/src/lib/stories.ts +++ b/lib/api/src/lib/stories.ts @@ -3,6 +3,7 @@ import React from 'react'; import deprecate from 'util-deprecate'; import dedent from 'ts-dedent'; import mapValues from 'lodash/mapValues'; +import countBy from 'lodash/countBy'; import global from 'global'; import type { StoryId, @@ -137,9 +138,13 @@ export type StoryIndexEntry = BaseIndexEntry & { type?: 'story'; }; +interface V3IndexEntry extends BaseIndexEntry { + parameters?: Parameters; +} + export interface StoryIndexV3 { v: 3; - stories: Record>; + stories: Record; } export type DocsIndexEntry = BaseIndexEntry & { @@ -247,6 +252,28 @@ const transformSetStoriesStoryDataToPreparedStoryIndex = ( return { v: 4, entries }; }; +const transformStoryIndexV3toV4 = (index: StoryIndexV3): PreparedStoryIndex => { + const countByTitle = countBy(Object.values(index.stories), 'title'); + return { + v: 4, + entries: Object.values(index.stories).reduce((acc, entry) => { + let type: IndexEntry['type'] = 'story'; + if ( + entry.parameters?.docsOnly || + (entry.name === 'Page' && countByTitle[entry.title] === 1) + ) { + type = 'docs'; + } + acc[entry.id] = { + type, + ...(type === 'docs' && { storiesImports: [] }), + ...entry, + }; + return acc; + }, {} as PreparedStoryIndex['entries']), + }; +}; + export const transformStoryIndexToStoriesHash = ( index: PreparedStoryIndex, { @@ -255,7 +282,11 @@ export const transformStoryIndexToStoriesHash = ( provider: Provider; } ): StoriesHash => { - const entryValues = Object.values(index.entries); + if (!index.v) throw new Error('Composition: Missing stories.json version'); + + const v4Index = index.v === 4 ? index : transformStoryIndexV3toV4(index as any); + + const entryValues = Object.values(v4Index.entries); const { sidebar = {}, showRoots: deprecatedShowRoots } = provider.getConfig(); const { showRoots = deprecatedShowRoots, collapsedRoots = [], renderLabel } = sidebar; const usesOldHierarchySeparator = entryValues.some(({ title }) => title.match(/\.|\|/)); // dot or pipe diff --git a/lib/api/src/modules/addons.ts b/lib/api/src/modules/addons.ts index 500e4bf16442..92edf0ace214 100644 --- a/lib/api/src/modules/addons.ts +++ b/lib/api/src/modules/addons.ts @@ -63,10 +63,9 @@ type Panels = Collection; type StateMerger = (input: S) => S; -interface StoryInput { - parameters: { - [parameterName: string]: any; - }; +export interface SubState { + selectedPanel: string; + addons: Record; } export interface SubAPI { @@ -96,7 +95,7 @@ export function ensurePanel(panels: Panels, selectedPanel?: string, currentPanel return currentPanel; } -export const init: ModuleFn = ({ provider, store, fullAPI }) => { +export const init: ModuleFn = ({ provider, store, fullAPI }) => { const api: SubAPI = { getElements: (type) => provider.getElements(type), getPanels: () => api.getElements(types.PANEL), diff --git a/lib/api/src/modules/channel.ts b/lib/api/src/modules/channel.ts index 9f67a0e3fcf3..f526302b4df2 100644 --- a/lib/api/src/modules/channel.ts +++ b/lib/api/src/modules/channel.ts @@ -14,7 +14,9 @@ export interface SubAPI { expandAll: () => void; } -export const init: ModuleFn = ({ provider }) => { +export type SubState = Record; + +export const init: ModuleFn = ({ provider }) => { const api: SubAPI = { getChannel: () => provider.channel, on: (type, cb) => { @@ -33,5 +35,5 @@ export const init: ModuleFn = ({ provider }) => { api.emit(STORIES_EXPAND_ALL); }, }; - return { api }; + return { api, state: {} }; }; diff --git a/lib/api/src/modules/provider.ts b/lib/api/src/modules/provider.ts index 87d3ca926351..ea9a27b4c3fe 100644 --- a/lib/api/src/modules/provider.ts +++ b/lib/api/src/modules/provider.ts @@ -40,9 +40,10 @@ export interface SubAPI { renderPreview?: Provider['renderPreview']; } -export const init: ModuleFn = ({ provider, fullAPI }) => { +export const init: ModuleFn = ({ provider, fullAPI }) => { return { api: provider.renderPreview ? { renderPreview: provider.renderPreview } : {}, + state: {}, init: () => { provider.handleAPI(fullAPI); }, diff --git a/lib/api/src/modules/refs.ts b/lib/api/src/modules/refs.ts index b41c8b382aec..52ba371890e4 100644 --- a/lib/api/src/modules/refs.ts +++ b/lib/api/src/modules/refs.ts @@ -5,11 +5,11 @@ import { SetStoriesStory, StoriesHash, transformStoryIndexToStoriesHash, - StoryIndexEntry, SetStoriesStoryData, + StoryIndex, } from '../lib/stories'; -import type { ModuleFn, StoryId } from '../index'; +import type { ModuleFn } from '../index'; const { location, fetch } = global; @@ -20,9 +20,9 @@ export interface SubState { type Versions = Record; export type SetRefData = Partial< - Omit & { - v: number; - stories?: SetStoriesStoryData; + ComposedRef & { + setStoriesData: SetStoriesStoryData; + storyIndex: StoryIndex; } >; @@ -99,14 +99,24 @@ const addRefIds = (input: StoriesHash, ref: ComposedRef): StoriesHash => { }, {} as StoriesHash); }; -const handle = async (request: Response | false): Promise => { - if (request) { - return Promise.resolve(request) - .then((response) => (response.ok ? response.json() : {})) - .catch((error) => ({ error })); +async function handleRequest(request: Response | false): Promise { + if (!request) return {}; + + try { + const response = await request; + if (!response.ok) return {}; + + const json = await response.json(); + + if (json.stories) { + return { storyIndex: json }; + } + + return json as SetRefData; + } catch (error) { + return { error }; } - return {}; -}; +} const map = ( input: SetStoriesStoryData, @@ -122,7 +132,10 @@ const map = ( return input; }; -export const init: ModuleFn = ({ store, provider, singleStory }, { runCheck = true } = {}) => { +export const init: ModuleFn = ( + { store, provider, singleStory }, + { runCheck = true } = {} +) => { const api: SubAPI = { findRef: (source) => { const refs = api.getRefs(); @@ -190,9 +203,9 @@ export const init: ModuleFn = ({ store, provider, singleStory }, { runCheck = tr `, } as Error; } else if (storiesFetch.ok) { - const [stories, metadata] = await Promise.all([ - handle(storiesFetch), - handle( + const [storyIndex, metadata] = await Promise.all([ + handleRequest(storiesFetch), + handleRequest( fetch(`${url}/metadata.json${query}`, { headers: { Accept: 'application/json', @@ -203,7 +216,7 @@ export const init: ModuleFn = ({ store, provider, singleStory }, { runCheck = tr ), ]); - Object.assign(loadedData, { ...stories, ...metadata }); + Object.assign(loadedData, { ...storyIndex, ...metadata }); } const versions = @@ -214,8 +227,7 @@ export const init: ModuleFn = ({ store, provider, singleStory }, { runCheck = tr url, ...loadedData, ...(versions ? { versions } : {}), - error: loadedData.error, - type: !loadedData.stories ? 'auto-inject' : 'lazy', + type: !loadedData.storyIndex ? 'auto-inject' : 'lazy', }); }, @@ -225,29 +237,23 @@ export const init: ModuleFn = ({ store, provider, singleStory }, { runCheck = tr return refs; }, - setRef: (id, { stories, v, ...rest }, ready = false) => { + setRef: (id, { storyIndex, setStoriesData, ...rest }, ready = false) => { if (singleStory) return; const { storyMapper = defaultStoryMapper } = provider.getConfig(); const ref = api.getRefs()[id]; let storiesHash: StoriesHash; - - if (stories) { - if (v === 2) { - storiesHash = transformSetStoriesStoryDataToStoriesHash( - map(stories, ref, { storyMapper }), - { - provider, - } - ); - } else if (!v) { - throw new Error('Composition: Missing stories.json version'); - } else { - const index = stories as unknown as Record; - storiesHash = transformStoryIndexToStoriesHash({ v, entries: index }, { provider }); - } - storiesHash = addRefIds(storiesHash, ref); + if (setStoriesData) { + storiesHash = transformSetStoriesStoryDataToStoriesHash( + map(setStoriesData, ref, { storyMapper }), + { + provider, + } + ); + } else if (storyIndex) { + storiesHash = transformStoryIndexToStoriesHash(storyIndex, { provider }); } + if (storiesHash) storiesHash = addRefIds(storiesHash, ref); api.updateRef(id, { stories: storiesHash, ...rest, ready }); }, diff --git a/lib/api/src/modules/release-notes.ts b/lib/api/src/modules/release-notes.ts index 69bf26d2bdb5..a7788070b5d8 100644 --- a/lib/api/src/modules/release-notes.ts +++ b/lib/api/src/modules/release-notes.ts @@ -29,7 +29,7 @@ export interface SubState { releaseNotesViewed: string[]; } -export const init: ModuleFn = ({ store }) => { +export const init: ModuleFn = ({ store }) => { const releaseNotesData = getReleaseNotesData(); const getReleaseNotesViewed = () => { const { releaseNotesViewed: persistedReleaseNotesViewed } = store.getState(); @@ -58,7 +58,5 @@ export const init: ModuleFn = ({ store }) => { }, }; - const initModule = () => {}; - - return { init: initModule, api }; + return { state: { releaseNotesViewed: [] }, api }; }; diff --git a/lib/api/src/modules/settings.ts b/lib/api/src/modules/settings.ts index 5ac9efaf8bc7..a0cbf8ec5693 100644 --- a/lib/api/src/modules/settings.ts +++ b/lib/api/src/modules/settings.ts @@ -15,7 +15,7 @@ export interface SubState { settings: Settings; } -export const init: ModuleFn = ({ store, navigate, fullAPI }) => { +export const init: ModuleFn = ({ store, navigate, fullAPI }) => { const isSettingsScreenActive = () => { const { path } = fullAPI.getUrlState(); return !!(path || '').match(/^\/settings/); @@ -49,9 +49,5 @@ export const init: ModuleFn = ({ store, navigate, fullAPI }) => { }, }; - const initModule = async () => { - await store.setState({ settings: { lastTrackedStoryId: null } }); - }; - - return { init: initModule, api }; + return { state: { settings: { lastTrackedStoryId: null } }, api }; }; diff --git a/lib/api/src/modules/stories.ts b/lib/api/src/modules/stories.ts index 78eb1a00f8db..2abb81002575 100644 --- a/lib/api/src/modules/stories.ts +++ b/lib/api/src/modules/stories.ts @@ -91,16 +91,6 @@ export interface SubAPI { updateStory: (storyId: StoryId, update: StoryUpdate, ref?: ComposedRef) => Promise; } -interface Meta { - ref?: ComposedRef; - source?: string; - sourceType?: 'local' | 'external'; - sourceLocation?: string; - refId?: string; - v?: number; - type: string; -} - const deprecatedOptionsParameterWarnings: Record void> = [ 'enableShortcuts', 'theme', @@ -125,7 +115,7 @@ function checkDeprecatedOptionParameters(options?: Record) { }); } -export const init: ModuleFn = ({ +export const init: ModuleFn = ({ fullAPI, store, navigate, @@ -382,7 +372,7 @@ export const init: ModuleFn = ({ const storyIndex = (await result.json()) as StoryIndex; // We can only do this if the stories.json is a proper storyIndex - if (storyIndex.v !== 4) { + if (storyIndex.v < 3) { logger.warn(`Skipping story index with version v${storyIndex.v}, awaiting SET_STORIES.`); return; } @@ -502,19 +492,19 @@ export const init: ModuleFn = ({ fullAPI.on(SET_STORIES, function handler(data: SetStoriesPayload) { const { ref } = getEventMetadata(this, fullAPI); - const stories = data.v ? denormalizeStoryParameters(data) : data.stories; + 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(stories); + fullAPI.setStories(setStoriesData); const options = fullAPI.getCurrentParameter('options'); checkDeprecatedOptionParameters(options); fullAPI.setOptions(options); } else { - fullAPI.setRef(ref.id, { ...ref, ...data, stories }, true); + fullAPI.setRef(ref.id, { ...ref, setStoriesData }, true); } }); diff --git a/lib/api/src/tests/refs.test.js b/lib/api/src/tests/refs.test.js index c2d6850f02b0..2ba6126e24fd 100644 --- a/lib/api/src/tests/refs.test.js +++ b/lib/api/src/tests/refs.test.js @@ -314,7 +314,6 @@ describe('Refs API', () => { Object { "refs": Object { "fake": Object { - "error": undefined, "id": "fake", "ready": false, "stories": Object {}, @@ -390,7 +389,6 @@ describe('Refs API', () => { Object { "refs": Object { "fake": Object { - "error": undefined, "id": "fake", "ready": false, "stories": Object {}, @@ -547,7 +545,6 @@ describe('Refs API', () => { Object { "refs": Object { "fake": Object { - "error": undefined, "id": "fake", "ready": false, "stories": Object {}, @@ -614,7 +611,6 @@ describe('Refs API', () => { Object { "refs": Object { "fake": Object { - "error": undefined, "id": "fake", "ready": false, "stories": undefined, @@ -699,6 +695,143 @@ describe('Refs API', () => { } `); }); + + describe('v3 compatibility', () => { + it('infers docs only if there is only one story and it has the name "Page"', async () => { + // given + const { api } = initRefs({ provider, store }, { runCheck: false }); + + const index = { + v: 3, + stories: { + 'component-a--page': { + id: 'component-a--page', + title: 'Component A', + name: 'Page', // Called "Page" but not only story + 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--page': { + id: 'component-b--page', + title: 'Component B', + name: 'Page', // Page and only story + importPath: './path/to/component-b.ts', + }, + 'component-c--story-4': { + id: 'component-c--story-4', + title: 'Component c', + name: 'Story 4', // Only story but not page + importPath: './path/to/component-c.ts', + }, + }, + }; + setupResponses( + { + ok: true, + response: async () => index, + }, + { + ok: true, + response: async () => index, + }, + { + ok: true, + response: async () => { + throw new Error('not ok'); + }, + }, + { + ok: true, + response: async () => ({ + versions: {}, + }), + } + ); + + await api.checkRef({ + id: 'fake', + url: 'https://example.com', + title: 'Fake', + }); + + const { refs } = store.setState.mock.calls[0][0]; + const hash = refs.fake.stories; + + // We need exact key ordering, even if in theory JS doesn't guarantee it + expect(Object.keys(hash)).toEqual([ + 'component-a', + 'component-a--page', + 'component-a--story-2', + 'component-b', + 'component-b--page', + 'component-c', + 'component-c--story-4', + ]); + expect(hash['component-a--page'].type).toBe('story'); + expect(hash['component-a--story-2'].type).toBe('story'); + expect(hash['component-b--page'].type).toBe('docs'); + expect(hash['component-c--story-4'].type).toBe('story'); + }); + + it('prefers parameters.docsOnly to inferred docsOnly status', async () => { + const { api } = initRefs({ provider, store }, { runCheck: false }); + + const index = { + v: 3, + stories: { + 'component-a--docs': { + id: 'component-a--docs', + title: 'Component A', + name: 'Docs', // Called 'Docs' rather than 'Page' + importPath: './path/to/component-a.ts', + parameters: { + docsOnly: true, + }, + }, + }, + }; + setupResponses( + { + ok: true, + response: async () => index, + }, + { + ok: true, + response: async () => index, + }, + { + ok: true, + response: async () => { + throw new Error('not ok'); + }, + }, + { + ok: true, + response: async () => ({ + versions: {}, + }), + } + ); + + await api.checkRef({ + id: 'fake', + url: 'https://example.com', + title: 'Fake', + }); + + const { refs } = store.setState.mock.calls[0][0]; + const hash = refs.fake.stories; + + // We need exact key ordering, even if in theory JS doesn't guarantee it + expect(Object.keys(hash)).toEqual(['component-a', 'component-a--docs']); + expect(hash['component-a--docs'].type).toBe('docs'); + }); + }); }); it('errors on unknown version', async () => { diff --git a/lib/api/src/tests/stories.test.ts b/lib/api/src/tests/stories.test.ts index 38ff3b63e38f..6aedbf8909ea 100644 --- a/lib/api/src/tests/stories.test.ts +++ b/lib/api/src/tests/stories.test.ts @@ -1,3 +1,5 @@ +/// ; + import { STORY_ARGS_UPDATED, UPDATE_STORY_ARGS, @@ -52,12 +54,6 @@ const mockIndex = { }, }; -beforeEach(() => { - getEventMetadataMock.mockReturnValue({ sourceType: 'local' } as any); - getEventMetadataMock.mockReturnValue({ sourceType: 'local' } as any); - mockStories.mockReset().mockReturnValue(mockIndex); -}); - function createMockStore(initialState = {}) { let state = initialState; return { @@ -120,9 +116,13 @@ const setStoriesData: SetStoriesStoryData = { beforeEach(() => { provider.getConfig.mockReset().mockReturnValue({}); provider.serverChannel = mockChannel(); + mockStories.mockReset().mockReturnValue(mockIndex); global.fetch .mockReset() .mockReturnValue({ status: 200, json: () => ({ v: 4, entries: mockStories() }) }); + + getEventMetadataMock.mockReturnValue({ sourceType: 'local' } as any); + getEventMetadataMock.mockReturnValue({ sourceType: 'local' } as any); }); describe('stories API', () => { @@ -887,7 +887,7 @@ describe('stories API', () => { } = initStories({ store, navigate, provider } as any); setStories(setStoriesData); - selectStory(null, '2'); + selectStory(undefined, '2'); expect(navigate).toHaveBeenCalledWith('/story/a--2'); }); }); @@ -1014,7 +1014,7 @@ describe('stories API', () => { const { storiesConfigured, storiesFailed } = store.getState(); expect(storiesConfigured).toBe(true); - expect(storiesFailed.message).toMatch(/sorting error/); + expect(storiesFailed?.message).toMatch(/sorting error/); }); it('sets the initial set of stories in the stories hash', async () => { @@ -1134,9 +1134,6 @@ describe('stories API', () => { expect(Object.keys(storedStoriesHash)).toEqual(['component-a', 'component-a--story-1']); }); - // TODO: we should re-implement this for v3 story index - it.skip('infers docs only if there is only one story and it has the name "Page"', async () => {}); - it('handles docs entries', async () => { mockStories.mockReset().mockReturnValue({ 'component-a--page': { @@ -1337,7 +1334,7 @@ describe('stories API', () => { const { storiesConfigured, storiesFailed } = store.getState(); expect(storiesConfigured).toBe(true); - expect(storiesFailed.message).toMatch(/Failed to run configure/); + expect(storiesFailed?.message).toMatch(/Failed to run configure/); }); }); @@ -1399,10 +1396,7 @@ describe('stories API', () => { 'ref', { id: 'ref', - v: 2, - globalParameters: { global: 'global' }, - kindParameters: { a: { kind: 'kind' } }, - stories: { + setStoriesData: { 'a--1': { kind: 'a', parameters: { global: 'global', kind: 'kind', story: 'story' } }, }, }, @@ -1463,7 +1457,7 @@ describe('stories API', () => { 'ref', { id: 'ref', - stories: { + setStoriesData: { 'a--1': {}, }, },