diff --git a/code/cypress/helper.ts b/code/cypress/helper.ts index 088c5fdfdf34..2622eb0eb1f0 100644 --- a/code/cypress/helper.ts +++ b/code/cypress/helper.ts @@ -20,7 +20,7 @@ export const visit = (route = '') => { expect(element).not.null; if (element !== null) { - expect(element.querySelector('#root > *')).not.null; + expect(element.querySelector('#root > *, #docs-root > *')).not.null; } }); }); diff --git a/code/lib/api/src/lib/stories.ts b/code/lib/api/src/lib/stories.ts index 08404b3af2c3..9be760fe3d8f 100644 --- a/code/lib/api/src/lib/stories.ts +++ b/code/lib/api/src/lib/stories.ts @@ -5,6 +5,7 @@ import { dedent } from 'ts-dedent'; import mapValues from 'lodash/mapValues'; import countBy from 'lodash/countBy'; import global from 'global'; +import { toId, sanitize } from '@storybook/csf'; import type { StoryId, ComponentTitle, @@ -13,8 +14,9 @@ import type { Args, ArgTypes, Parameters, + ComponentId, } from '@storybook/csf'; -import { sanitize } from '@storybook/csf'; +import type { DocsOptions } from '@storybook/core-common'; import { combineParameters } from '../index'; import merge from './merge'; @@ -145,6 +147,7 @@ export interface SetStoriesStory { id: StoryId; name: string; refId?: string; + componentId?: ComponentId; kind: StoryKind; parameters: { fileName: string; @@ -268,16 +271,21 @@ export interface PreparedStoryIndex { export const transformSetStoriesStoryDataToStoriesHash = ( data: SetStoriesStoryData, - { provider, docsMode }: { provider: Provider; docsMode: boolean } + { provider, docsOptions }: { provider: Provider; docsOptions: DocsOptions } ) => - transformStoryIndexToStoriesHash(transformSetStoriesStoryDataToPreparedStoryIndex(data), { - provider, - docsMode, - }); + transformStoryIndexToStoriesHash( + transformSetStoriesStoryDataToPreparedStoryIndex(data, { docsOptions }), + { + provider, + docsOptions, + } + ); const transformSetStoriesStoryDataToPreparedStoryIndex = ( - stories: SetStoriesStoryData + stories: SetStoriesStoryData, + { docsOptions }: { docsOptions: DocsOptions } ): PreparedStoryIndex => { + const seenTitles = new Set(); const entries: PreparedStoryIndex['entries'] = Object.entries(stories).reduce( (acc, [id, story]) => { if (!story) return acc; @@ -296,6 +304,19 @@ 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', @@ -340,10 +361,10 @@ export const transformStoryIndexToStoriesHash = ( index: PreparedStoryIndex, { provider, - docsMode, + docsOptions, }: { provider: Provider; - docsMode: boolean; + docsOptions: DocsOptions; } ): StoriesHash => { if (!index.v) throw new Error('Composition: Missing stories.json version'); @@ -364,7 +385,7 @@ export const transformStoryIndexToStoriesHash = ( } const storiesHashOutOfOrder = Object.values(entryValues).reduce((acc, item) => { - if (docsMode && item.type !== 'docs') return acc; + if (docsOptions.docsMode && item.type !== 'docs') return acc; // First, split the title into a set of names, separated by '/' and trimmed. const { title } = item; diff --git a/code/lib/api/src/modules/refs.ts b/code/lib/api/src/modules/refs.ts index 1a0d0ff316d9..98c1e5fce683 100644 --- a/code/lib/api/src/modules/refs.ts +++ b/code/lib/api/src/modules/refs.ts @@ -133,7 +133,7 @@ const map = ( }; export const init: ModuleFn = ( - { store, provider, singleStory, docsOptions: { docsMode } = {} }, + { store, provider, singleStory, docsOptions = {} }, { runCheck = true } = {} ) => { const api: SubAPI = { @@ -249,10 +249,10 @@ export const init: ModuleFn = ( if (setStoriesData) { storiesHash = transformSetStoriesStoryDataToStoriesHash( map(setStoriesData, ref, { storyMapper }), - { provider, docsMode } + { provider, docsOptions } ); } else if (storyIndex) { - storiesHash = transformStoryIndexToStoriesHash(storyIndex, { provider, docsMode }); + storiesHash = transformStoryIndexToStoriesHash(storyIndex, { provider, docsOptions }); } if (storiesHash) storiesHash = addRefIds(storiesHash, ref); diff --git a/code/lib/api/src/modules/stories.ts b/code/lib/api/src/modules/stories.ts index e280a22fd67f..6a9f9f1813db 100644 --- a/code/lib/api/src/modules/stories.ts +++ b/code/lib/api/src/modules/stories.ts @@ -122,7 +122,7 @@ export const init: ModuleFn = ({ provider, storyId: initialStoryId, viewMode: initialViewMode, - docsOptions: { docsMode } = {}, + docsOptions = {}, }) => { const api: SubAPI = { storyId: toId, @@ -211,7 +211,7 @@ export const init: ModuleFn = ({ // Now create storiesHash by reordering the above by group const hash = transformSetStoriesStoryDataToStoriesHash(input, { provider, - docsMode, + docsOptions, }); await store.setState({ @@ -359,7 +359,7 @@ export const init: ModuleFn = ({ setStoryList: async (storyIndex: StoryIndex) => { const hash = transformStoryIndexToStoriesHash(storyIndex, { provider, - docsMode, + docsOptions, }); await store.setState({ diff --git a/code/lib/api/src/tests/stories.test.ts b/code/lib/api/src/tests/stories.test.ts index 9005c86d2e63..cffa81cbaac8 100644 --- a/code/lib/api/src/tests/stories.test.ts +++ b/code/lib/api/src/tests/stories.test.ts @@ -430,6 +430,79 @@ describe('stories API', () => { children: ['b--1'], }); }); + + it('adds docs entries when docsPage is enabled', () => { + const navigate = jest.fn(); + const store = createMockStore({}); + + const { + api: { setStories }, + } = initStories({ + store, + navigate, + provider, + docsOptions: { docsPage: true, defaultName: 'Docs' }, + } as any as any); + + provider.getConfig.mockReturnValue({ sidebar: { showRoots: false } }); + setStories(setStoriesData); + + 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['b-c--docs']).toMatchObject({ + type: 'docs', + id: 'b-c--docs', + parent: 'b-c', + title: 'b/c', + name: 'Docs', + storiesImports: [], + }); + + expect(storedStoriesHash['b-d--docs']).toMatchObject({ + type: 'docs', + id: 'b-d--docs', + parent: 'b-d', + title: 'b/d', + name: 'Docs', + storiesImports: [], + }); + + expect(storedStoriesHash['b-e--docs']).toMatchObject({ + type: 'docs', + id: 'b-e--docs', + parent: 'b-e', + title: 'b/e', + name: 'Docs', + storiesImports: [], + }); + }); }); // Can't currently run these tests as cannot set this on the events diff --git a/code/lib/builder-webpack5/src/preview/iframe-webpack.config.ts b/code/lib/builder-webpack5/src/preview/iframe-webpack.config.ts index 479da574dcd6..19ccb2759216 100644 --- a/code/lib/builder-webpack5/src/preview/iframe-webpack.config.ts +++ b/code/lib/builder-webpack5/src/preview/iframe-webpack.config.ts @@ -9,7 +9,7 @@ import TerserWebpackPlugin from 'terser-webpack-plugin'; import VirtualModulePlugin from 'webpack-virtual-modules'; import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin'; -import type { Options, CoreConfig } from '@storybook/core-common'; +import type { Options, CoreConfig, DocsOptions } from '@storybook/core-common'; import { stringifyProcessEnvs, handlebars, @@ -87,6 +87,7 @@ export default async ( const coreOptions = await presets.apply('core'); const builderOptions: BuilderOptions = typeof coreOptions.builder === 'string' ? {} : coreOptions.builder?.options || {}; + const docsOptions = await presets.apply('docs'); const configs = [ ...(await presets.apply('config', [], options)), @@ -214,6 +215,7 @@ export default async ( ...specifier, importPathMatcher: specifier.importPathMatcher.source, })), + DOCS_OPTIONS: docsOptions, SERVER_CHANNEL_URL: serverChannelUrl, }, headHtmlSnippet, diff --git a/code/lib/client-api/src/StoryStoreFacade.ts b/code/lib/client-api/src/StoryStoreFacade.ts index 0499f1520b4f..dc037df6c481 100644 --- a/code/lib/client-api/src/StoryStoreFacade.ts +++ b/code/lib/client-api/src/StoryStoreFacade.ts @@ -2,7 +2,13 @@ import global from 'global'; import { dedent } from 'ts-dedent'; import { SynchronousPromise } from 'synchronous-promise'; -import { toId, isExportStory, storyNameFromExport } from '@storybook/csf'; +import { + toId, + isExportStory, + storyNameFromExport, + ComponentTitle, + ComponentId, +} from '@storybook/csf'; import type { StoryId, AnyFramework, Parameters, StoryFn } from '@storybook/csf'; import { StoryStore, userOrAutoTitle, sortStoriesV6 } from '@storybook/store'; import type { @@ -16,6 +22,7 @@ import type { } from '@storybook/store'; import { logger } from '@storybook/client-logger'; import deprecate from 'util-deprecate'; +import type { DocsOptions } from '@storybook/core-common'; export interface GetStorybookStory { name: string; @@ -34,7 +41,7 @@ const docs2Warning = deprecate(() => {}, export class StoryStoreFacade { projectAnnotations: NormalizedProjectAnnotations; - entries: StoryIndex['entries']; + entries: Record; csfExports: Record; @@ -71,19 +78,27 @@ export class StoryStoreFacade { const storyEntries = Object.entries(this.entries); // Add the kind parameters and global parameters to each entry const sortableV6: [StoryId, Story, Parameters, Parameters][] = storyEntries.map( - ([storyId, { importPath }]) => { + ([storyId, { type, importPath, ...entry }]) => { const exports = this.csfExports[importPath]; const csfFile = store.processCSFFileWithCache( exports, importPath, exports.default.title ); - return [ - storyId, - store.storyFromCSFFile({ storyId, csfFile }), - csfFile.meta.parameters, - this.projectAnnotations.parameters, - ]; + + let storyLike: Story; + if (type === 'story') { + storyLike = store.storyFromCSFFile({ storyId, csfFile }); + } else { + storyLike = { + ...entry, + story: entry.name, + kind: entry.title, + componentId: toId(entry.componentId || entry.title), + parameters: { fileName: importPath }, + } as any; + } + return [storyId, storyLike, csfFile.meta.parameters, this.projectAnnotations.parameters]; } ); @@ -188,6 +203,8 @@ 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]) => { @@ -199,12 +216,29 @@ export class StoryStoreFacade { 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, + }; + } + this.entries[id] = { + type: 'story', id, name, title, importPath: fileName, - type: 'story', + componentId, }; }); }