From 6e6f0ef60082d88092fc8022ff95e6e463302fa1 Mon Sep 17 00:00:00 2001 From: Tom Coleman Date: Thu, 9 Sep 2021 21:13:52 +1000 Subject: [PATCH] Change start/ClientAPI to be a facade in front of the new store --- lib/client-api/package.json | 4 +- lib/client-api/src/ClientApi.test.ts | 140 ++ lib/client-api/src/ClientApi.ts | 397 ++++ lib/client-api/src/StoryStoreFacade.ts | 170 ++ lib/client-api/src/args.test.ts | 240 --- lib/client-api/src/args.ts | 118 -- lib/client-api/src/client_api.test.ts | 675 ------- lib/client-api/src/client_api.ts | 314 --- lib/client-api/src/config_api.ts | 23 - lib/client-api/src/decorators.test.ts | 137 -- lib/client-api/src/decorators.ts | 66 - lib/client-api/src/ensureArgTypes.ts | 10 - lib/client-api/src/filterArgTypes.ts | 24 - lib/client-api/src/hooks.test.js | 581 ------ lib/client-api/src/hooks.ts | 74 - lib/client-api/src/index.ts | 33 +- lib/client-api/src/inferArgTypes.ts | 51 - lib/client-api/src/inferControls.test.ts | 124 -- lib/client-api/src/inferControls.ts | 74 - lib/client-api/src/parameters.test.ts | 33 - lib/client-api/src/parameters.ts | 40 - lib/client-api/src/pathToId.ts | 7 - lib/client-api/src/pathToid.test.ts | 15 - lib/client-api/src/queryparams.ts | 18 - lib/client-api/src/simulate-pageload.ts | 101 - lib/client-api/src/storySort.ts | 2 +- lib/client-api/src/story_store.test.ts | 1687 ----------------- lib/client-api/src/story_store.ts | 854 --------- lib/client-api/src/types.ts | 69 +- lib/core-client/package.json | 4 +- lib/core-client/src/preview/NoDocs.tsx | 39 - .../src/preview/StoryRenderer.test.ts | 536 ------ lib/core-client/src/preview/StoryRenderer.tsx | 345 ---- .../src/preview/executeLoadable.ts | 93 + lib/core-client/src/preview/index.ts | 8 +- lib/core-client/src/preview/loadCsf.test.ts | 482 ----- lib/core-client/src/preview/loadCsf.ts | 228 --- .../src/preview/normalizeStory.test.ts | 208 -- lib/core-client/src/preview/normalizeStory.ts | 102 - .../src/preview/parseArgsParam.test.ts | 255 --- lib/core-client/src/preview/parseArgsParam.ts | 73 - lib/core-client/src/preview/start.test.ts | 1174 +++++++++++- lib/core-client/src/preview/start.ts | 156 +- lib/core-client/src/preview/url.test.ts | 115 -- lib/core-client/src/preview/url.ts | 95 - lib/core-client/typings.d.ts | 2 + 46 files changed, 1990 insertions(+), 8006 deletions(-) create mode 100644 lib/client-api/src/ClientApi.test.ts create mode 100644 lib/client-api/src/ClientApi.ts create mode 100644 lib/client-api/src/StoryStoreFacade.ts delete mode 100644 lib/client-api/src/args.test.ts delete mode 100644 lib/client-api/src/args.ts delete mode 100644 lib/client-api/src/client_api.test.ts delete mode 100644 lib/client-api/src/client_api.ts delete mode 100644 lib/client-api/src/config_api.ts delete mode 100644 lib/client-api/src/decorators.test.ts delete mode 100644 lib/client-api/src/decorators.ts delete mode 100644 lib/client-api/src/ensureArgTypes.ts delete mode 100644 lib/client-api/src/filterArgTypes.ts delete mode 100644 lib/client-api/src/hooks.test.js delete mode 100644 lib/client-api/src/hooks.ts delete mode 100644 lib/client-api/src/inferArgTypes.ts delete mode 100644 lib/client-api/src/inferControls.test.ts delete mode 100644 lib/client-api/src/inferControls.ts delete mode 100644 lib/client-api/src/parameters.test.ts delete mode 100644 lib/client-api/src/parameters.ts delete mode 100644 lib/client-api/src/pathToId.ts delete mode 100644 lib/client-api/src/pathToid.test.ts delete mode 100644 lib/client-api/src/queryparams.ts delete mode 100644 lib/client-api/src/simulate-pageload.ts delete mode 100644 lib/client-api/src/story_store.test.ts delete mode 100644 lib/client-api/src/story_store.ts delete mode 100644 lib/core-client/src/preview/NoDocs.tsx delete mode 100644 lib/core-client/src/preview/StoryRenderer.test.ts delete mode 100644 lib/core-client/src/preview/StoryRenderer.tsx create mode 100644 lib/core-client/src/preview/executeLoadable.ts delete mode 100644 lib/core-client/src/preview/loadCsf.test.ts delete mode 100644 lib/core-client/src/preview/loadCsf.ts delete mode 100644 lib/core-client/src/preview/normalizeStory.test.ts delete mode 100644 lib/core-client/src/preview/normalizeStory.ts delete mode 100644 lib/core-client/src/preview/parseArgsParam.test.ts delete mode 100644 lib/core-client/src/preview/parseArgsParam.ts delete mode 100644 lib/core-client/src/preview/url.test.ts delete mode 100644 lib/core-client/src/preview/url.ts diff --git a/lib/client-api/package.json b/lib/client-api/package.json index 505f63574ef1..c46784d1ab43 100644 --- a/lib/client-api/package.json +++ b/lib/client-api/package.json @@ -45,10 +45,12 @@ "@storybook/channels": "6.4.0-alpha.34", "@storybook/client-logger": "6.4.0-alpha.34", "@storybook/core-events": "6.4.0-alpha.34", - "@storybook/csf": "0.0.1", + "@storybook/csf": "0.0.2--canary.b1d5348.0", + "@storybook/store": "6.4.0-alpha.34", "@types/qs": "^6.9.5", "@types/webpack-env": "^1.16.0", "core-js": "^3.8.2", + "fast-deep-equal": "^3.1.3", "global": "^4.4.0", "lodash": "^4.17.20", "memoizerific": "^1.11.3", diff --git a/lib/client-api/src/ClientApi.test.ts b/lib/client-api/src/ClientApi.test.ts new file mode 100644 index 000000000000..e7a64b67d05d --- /dev/null +++ b/lib/client-api/src/ClientApi.test.ts @@ -0,0 +1,140 @@ +import addons, { mockChannel } from '@storybook/addons'; +import { ClientApi } from './ClientApi'; + +beforeEach(() => { + addons.setChannel(mockChannel()); +}); + +describe('ClientApi', () => { + describe('setAddon', () => { + it('should register addons', () => { + const clientApi = new ClientApi(); + let data; + + clientApi.setAddon({ + aa() { + data = 'foo'; + }, + }); + + // @ts-ignore + clientApi.storiesOf('none', module).aa(); + expect(data).toBe('foo'); + }); + + it('should not remove previous addons', () => { + const clientApi = new ClientApi(); + const data = []; + + clientApi.setAddon({ + aa() { + data.push('foo'); + }, + }); + + clientApi.setAddon({ + bb() { + data.push('bar'); + }, + }); + + // @ts-ignore + clientApi.storiesOf('none', module).aa().bb(); + expect(data).toEqual(['foo', 'bar']); + }); + + it('should call with the clientApi context', () => { + const clientApi = new ClientApi(); + let data; + + clientApi.setAddon({ + aa() { + data = typeof this.add; + }, + }); + + // @ts-ignore + clientApi.storiesOf('none', module).aa(); + expect(data).toBe('function'); + }); + + it('should be able to access addons added previously', () => { + const clientApi = new ClientApi(); + let data; + + clientApi.setAddon({ + aa() { + data = 'foo'; + }, + }); + + clientApi.setAddon({ + bb() { + this.aa(); + }, + }); + + // @ts-ignore + clientApi.storiesOf('none', module).bb(); + expect(data).toBe('foo'); + }); + + it('should be able to access the current kind', () => { + const clientApi = new ClientApi(); + const kind = 'dfdwf3e3'; + let data; + + clientApi.setAddon({ + aa() { + data = this.kind; + }, + }); + + // @ts-ignore + clientApi.storiesOf(kind, module).aa(); + expect(data).toBe(kind); + }); + }); + + describe('fetchStoryIndex', () => { + it('should remember the order that files were added in', async () => { + const clientApi = new ClientApi(); + const store = { + processCSFFileWithCache: jest.fn(() => ({ meta: { title: 'title' } })), + storyFromCSFFile: jest.fn(({ storyId }) => ({ + parameters: { fileName: storyId.split('-')[0].replace('kind', 'file') }, + })), + }; + clientApi.storyStore = store as any; + + let disposeCallback: () => void; + const module1 = { + id: 'file1', + hot: { + data: {}, + accept: jest.fn(), + dispose(cb: () => void) { + disposeCallback = cb; + }, + }, + }; + const module2 = { + id: 'file2', + }; + clientApi.storiesOf('kind1', (module1 as unknown) as NodeModule).add('story1', jest.fn()); + clientApi.storiesOf('kind2', (module2 as unknown) as NodeModule).add('story2', jest.fn()); + + expect(Object.keys(clientApi.fetchStoryIndex().stories)).toEqual([ + 'kind1--story1', + 'kind2--story2', + ]); + + disposeCallback(); + clientApi.storiesOf('kind1', (module1 as unknown) as NodeModule).add('story1', jest.fn()); + expect(Object.keys(clientApi.fetchStoryIndex().stories)).toEqual([ + 'kind1--story1', + 'kind2--story2', + ]); + }); + }); +}); diff --git a/lib/client-api/src/ClientApi.ts b/lib/client-api/src/ClientApi.ts new file mode 100644 index 000000000000..2ea5dd022476 --- /dev/null +++ b/lib/client-api/src/ClientApi.ts @@ -0,0 +1,397 @@ +import deprecate from 'util-deprecate'; +import dedent from 'ts-dedent'; +import global from 'global'; +import { logger } from '@storybook/client-logger'; +import { + AnyFramework, + toId, + DecoratorFunction, + Parameters, + ArgTypesEnhancer, + ArgsEnhancer, + LoaderFunction, + StoryFn, + sanitize, + ComponentTitle, + Globals, + GlobalTypes, + LegacyStoryFn, +} from '@storybook/csf'; +import { + NormalizedComponentAnnotations, + Path, + ModuleImportFn, + combineParameters, + StoryStore, + normalizeInputTypes, +} from '@storybook/store'; +import { ClientApiAddons, StoryApi } from '@storybook/addons'; + +import { StoryStoreFacade } from './StoryStoreFacade'; + +const { FEATURES } = global; + +export interface GetStorybookStory { + name: string; + render: LegacyStoryFn; +} + +export interface GetStorybookKind { + kind: string; + fileName: string; + stories: GetStorybookStory[]; +} + +// ClientApi (and StoreStore) are really singletons. However they are not created until the +// relevant framework instanciates them via `start.js`. The good news is this happens right away. +let singleton: ClientApi; + +const warningAlternatives = { + addDecorator: `Instead, use \`export const decorators = [];\` in your \`preview.js\`.`, + addParameters: `Instead, use \`export const parameters = {};\` in your \`preview.js\`.`, + addLoaders: `Instead, use \`export const loaders = [];\` in your \`preview.js\`.`, +}; + +const warningMessage = (method: keyof typeof warningAlternatives) => + deprecate( + () => {}, + dedent` + \`${method}\` is deprecated, and will be removed in Storybook 7.0. + + ${warningAlternatives[method]} + + Read more at https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#deprecated-addparameters-and-adddecorator).` + ); + +const warnings = { + addDecorator: warningMessage('addDecorator'), + addParameters: warningMessage('addParameters'), + addLoaders: warningMessage('addLoaders'), +}; + +const checkMethod = (method: string, deprecationWarning: boolean) => { + if (FEATURES?.storyStoreV7) { + throw new Error( + dedent`You cannot use \`${method}\` with the new Story Store. + + ${warningAlternatives[method as keyof typeof warningAlternatives]}` + ); + } + + if (!singleton) { + throw new Error(`Singleton client API not yet initialized, cannot call \`${method}\`.`); + } + + if (deprecationWarning) { + warnings[method as keyof typeof warningAlternatives](); + } +}; + +export const addDecorator = ( + decorator: DecoratorFunction, + deprecationWarning = true +) => { + checkMethod('addDecorator', deprecationWarning); + singleton.addDecorator(decorator); +}; + +export const addParameters = (parameters: Parameters, deprecationWarning = true) => { + checkMethod('addParameters', deprecationWarning); + singleton.addParameters(parameters); +}; + +export const addLoader = (loader: LoaderFunction, deprecationWarning = true) => { + checkMethod('addLoader', deprecationWarning); + singleton.addLoader(loader); +}; + +export const addArgsEnhancer = (enhancer: ArgsEnhancer) => { + checkMethod('addArgsEnhancer', false); + singleton.addArgsEnhancer(enhancer); +}; + +export const addArgTypesEnhancer = (enhancer: ArgTypesEnhancer) => { + checkMethod('addArgTypesEnhancer', false); + singleton.addArgTypesEnhancer(enhancer); +}; + +export const getGlobalRender = () => { + checkMethod('getGlobalRender', false); + return singleton.facade.projectAnnotations.render; +}; + +export const setGlobalRender = (render: StoryFn) => { + checkMethod('setGlobalRender', false); + singleton.facade.projectAnnotations.render = render; +}; + +const invalidStoryTypes = new Set(['string', 'number', 'boolean', 'symbol']); +export class ClientApi { + facade: StoryStoreFacade; + + storyStore?: StoryStore; + + private addons: ClientApiAddons; + + onImportFnChanged?: ({ importFn }: { importFn: ModuleImportFn }) => void; + + // If we don't get passed modules so don't know filenames, we can + // just use numeric indexes + private lastFileName = 0; + + constructor({ storyStore }: { storyStore?: StoryStore } = {}) { + this.facade = new StoryStoreFacade(); + + this.addons = {}; + + this.storyStore = storyStore; + + singleton = this; + } + + importFn(path: Path) { + return this.facade.importFn(path); + } + + fetchStoryIndex() { + if (!this.storyStore) { + throw new Error('Cannot fetch story index before setting storyStore'); + } + return this.facade.fetchStoryIndex(this.storyStore); + } + + setAddon = deprecate( + (addon: any) => { + this.addons = { ...this.addons, ...addon }; + }, + dedent` + \`setAddon\` is deprecated and will be removed in Storybook 7.0. + + https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#deprecated-setaddon + ` + ); + + addDecorator = (decorator: DecoratorFunction) => { + this.facade.projectAnnotations.decorators.push(decorator); + }; + + clearDecorators = deprecate( + () => { + this.facade.projectAnnotations.decorators = []; + }, + dedent` + \`clearDecorators\` is deprecated and will be removed in Storybook 7.0. + + https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#deprecated-cleardecorators + ` + ); + + addParameters = ({ + globals, + globalTypes, + ...parameters + }: Parameters & { globals?: Globals; globalTypes?: GlobalTypes }) => { + this.facade.projectAnnotations.parameters = combineParameters( + this.facade.projectAnnotations.parameters, + parameters + ); + if (globals) { + this.facade.projectAnnotations.globals = { + ...this.facade.projectAnnotations.globals, + ...globals, + }; + } + if (globalTypes) { + this.facade.projectAnnotations.globalTypes = { + ...this.facade.projectAnnotations.globalTypes, + ...normalizeInputTypes(globalTypes), + }; + } + }; + + addLoader = (loader: LoaderFunction) => { + this.facade.projectAnnotations.loaders.push(loader); + }; + + addArgsEnhancer = (enhancer: ArgsEnhancer) => { + this.facade.projectAnnotations.argsEnhancers.push(enhancer); + }; + + addArgTypesEnhancer = (enhancer: ArgTypesEnhancer) => { + this.facade.projectAnnotations.argTypesEnhancers.push(enhancer); + }; + + // what are the occasions that "m" is a boolean vs an obj + storiesOf = (kind: string, m?: NodeModule): StoryApi => { + if (!kind && typeof kind !== 'string') { + throw new Error('Invalid or missing kind provided for stories, should be a string'); + } + + if (!m) { + logger.warn( + `Missing 'module' parameter for story with a kind of '${kind}'. It will break your HMR` + ); + } + + if (m) { + const proto = Object.getPrototypeOf(m); + if (proto.exports && proto.exports.default) { + // FIXME: throw an error in SB6.0 + logger.error( + `Illegal mix of CSF default export and storiesOf calls in a single file: ${proto.i}` + ); + } + } + + // eslint-disable-next-line no-plusplus + const baseFilename = m && m.id ? `${m.id}` : (this.lastFileName++).toString(); + 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 + ) { + i += 1; + fileName = `${baseFilename}-${i}`; + } + + if (m && m.hot && m.hot.accept) { + // This module used storiesOf(), so when it re-runs on HMR, it will reload + // itself automatically without us needing to look at our imports + m.hot.accept(); + m.hot.dispose(() => { + this.facade.clearFilenameExports(fileName); + + // We need to update the importFn as soon as the module re-evaluates + // (and calls storiesOf() again, etc). We could call `onImportFnChanged()` + // at the end of every setStories call (somehow), but then we'd need to + // 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(() => { + this.onImportFnChanged?.({ importFn: this.importFn.bind(this) }); + }, 0); + }); + } + + let hasAdded = false; + const api: StoryApi = { + kind: kind.toString(), + add: () => api, + addDecorator: () => api, + addLoader: () => api, + addParameters: () => api, + }; + + // apply addons + Object.keys(this.addons).forEach((name) => { + const addon = this.addons[name]; + api[name] = (...args: any[]) => { + addon.apply(api, args); + return api; + }; + }); + + const meta: NormalizedComponentAnnotations = { + id: sanitize(kind), + title: kind, + decorators: [], + loaders: [], + 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 }; + + api.add = (storyName: string, storyFn: StoryFn, parameters: Parameters = {}) => { + hasAdded = true; + + if (typeof storyName !== 'string') { + throw new Error(`Invalid or missing storyName provided for a "${kind}" story.`); + } + + if (!storyFn || Array.isArray(storyFn) || invalidStoryTypes.has(typeof storyFn)) { + throw new Error( + `Cannot load story "${storyName}" in "${kind}" due to invalid format. Storybook expected a function/object but received ${typeof storyFn} instead.` + ); + } + + const { decorators, loaders, ...storyParameters } = parameters; + + const csfExports = this.facade.csfExports[fileName]; + // Whack a _ on the front incase it is "default" + csfExports[`_${sanitize(storyName)}`] = { + name: storyName, + parameters: { fileName, ...storyParameters }, + decorators, + loaders, + render: storyFn, + }; + + // eslint-disable-next-line no-underscore-dangle + const storyId = parameters.__id || toId(kind, storyName); + this.facade.stories[storyId] = { + title: csfExports.default.title, + name: storyName, + importPath: fileName, + }; + + return api; + }; + + api.addDecorator = (decorator: DecoratorFunction) => { + if (hasAdded) + throw new Error(`You cannot add a decorator after the first story for a kind. +Read more here: https://github.com/storybookjs/storybook/blob/master/MIGRATION.md#can-no-longer-add-decoratorsparameters-after-stories`); + + meta.decorators.push(decorator); + return api; + }; + + api.addLoader = (loader: LoaderFunction) => { + if (hasAdded) throw new Error(`You cannot add a loader after the first story for a kind.`); + + meta.loaders.push(loader); + return api; + }; + + api.addParameters = (parameters: Parameters) => { + if (hasAdded) + throw new Error(`You cannot add parameters after the first story for a kind. +Read more here: https://github.com/storybookjs/storybook/blob/master/MIGRATION.md#can-no-longer-add-decoratorsparameters-after-stories`); + + meta.parameters = combineParameters(meta.parameters, parameters); + return api; + }; + + return api; + }; + + getStorybook = (): GetStorybookKind[] => { + const { stories } = this.storyStore.storyIndex; + + const kinds: Record> = {}; + Object.entries(stories).forEach(([storyId, { title, name, importPath }]) => { + if (!kinds[title]) { + kinds[title] = { kind: title, fileName: importPath, stories: [] }; + } + + const { storyFn } = this.storyStore.fromId(storyId); + + kinds[title].stories.push({ name, render: storyFn }); + }); + + return Object.values(kinds); + }; + + // @deprecated + raw = () => { + return this.storyStore.raw(); + }; + + // @deprecated + get _storyStore() { + return this.storyStore; + } +} diff --git a/lib/client-api/src/StoryStoreFacade.ts b/lib/client-api/src/StoryStoreFacade.ts new file mode 100644 index 000000000000..7ea75e432fd3 --- /dev/null +++ b/lib/client-api/src/StoryStoreFacade.ts @@ -0,0 +1,170 @@ +import stable from 'stable'; +import { + StoryId, + AnyFramework, + toId, + isExportStory, + Parameters, + StoryFn, + storyNameFromExport, +} from '@storybook/csf'; +import { + NormalizedProjectAnnotations, + Path, + StoryIndex, + ModuleExports, + StoryStore, + Story, +} from '@storybook/store'; +import { Comparator } from '@storybook/addons'; + +import { storySort } from './storySort'; + +export interface GetStorybookStory { + name: string; + render: StoryFn; +} + +export interface GetStorybookKind { + kind: string; + fileName: string; + stories: GetStorybookStory[]; +} + +export class StoryStoreFacade { + projectAnnotations: NormalizedProjectAnnotations; + + stories: StoryIndex['stories']; + + csfExports: Record; + + constructor() { + this.projectAnnotations = { + loaders: [], + decorators: [], + parameters: {}, + argsEnhancers: [], + argTypesEnhancers: [], + }; + + this.stories = {}; + + this.csfExports = {}; + } + + // This doesn't actually import anything because the client-api loads fully + // on startup, but this is a shim after all. + importFn(path: Path) { + return this.csfExports[path]; + } + + fetchStoryIndex(store: StoryStore) { + const fileNameOrder = Object.keys(this.csfExports); + const storySortParameter = this.projectAnnotations.parameters?.options?.storySort; + + const storyEntries = Object.entries(this.stories); + // Add the kind parameters and global parameters to each entry + const stories: [StoryId, Story, Parameters, Parameters][] = storyEntries.map( + ([storyId, { importPath }]) => { + const exports = this.csfExports[importPath]; + const csfFile = store.processCSFFileWithCache(exports, exports.default.title); + return [ + storyId, + store.storyFromCSFFile({ storyId, csfFile }), + csfFile.meta.parameters, + this.projectAnnotations.parameters, + ]; + } + ); + + if (storySortParameter) { + let sortFn: Comparator; + if (typeof storySortParameter === 'function') { + sortFn = storySortParameter; + } else { + sortFn = storySort(storySortParameter); + } + stable.inplace(stories, sortFn); + } else { + stable.inplace( + stories, + (s1, s2) => + fileNameOrder.indexOf(s1[1].parameters.fileName) - + fileNameOrder.indexOf(s2[1].parameters.fileName) + ); + } + + return { + v: 3, + stories: stories.reduce((acc, [id]) => { + acc[id] = this.stories[id]; + return acc; + }, {} as StoryIndex['stories']), + }; + } + + clearFilenameExports(fileName: Path) { + if (!this.csfExports[fileName]) { + return; + } + + // Clear this module's stories from the storyList and existing exports + Object.entries(this.stories).forEach(([id, { importPath }]) => { + if (importPath === fileName) { + delete this.stories[id]; + } + }); + + // We keep this as an empty record so we can use it to maintain component order + this.csfExports[fileName] = {}; + } + + // NOTE: we could potentially share some of this code with the stories.json generation + addStoriesFromExports(fileName: Path, fileExports: ModuleExports) { + // if the export haven't changed since last time we added them, this is a no-op + if (this.csfExports[fileName] === fileExports) { + return; + } + // OTOH, if they have changed, let's clear them out first + this.clearFilenameExports(fileName); + + const { default: defaultExport, __namedExportsOrder, ...namedExports } = fileExports; + const { id: componentId, title } = defaultExport || {}; + if (!title) { + throw new Error( + `Unexpected default export without title in '${fileName}': ${JSON.stringify( + fileExports.default + )}` + ); + } + + this.csfExports[fileName] = { + ...fileExports, + default: { + ...defaultExport, + parameters: { + fileName, + ...defaultExport.parameters, + }, + }, + }; + + Object.entries(namedExports) + .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; + + this.stories[id] = { + name, + title, + importPath: fileName, + }; + }); + } +} diff --git a/lib/client-api/src/args.test.ts b/lib/client-api/src/args.test.ts deleted file mode 100644 index 952ab73fa29f..000000000000 --- a/lib/client-api/src/args.test.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { once } from '@storybook/client-logger'; -import { combineArgs, mapArgsToTypes, validateOptions } from './args'; - -const stringType = { name: 'string' }; -const numberType = { name: 'number' }; -const booleanType = { name: 'boolean' }; -const enumType = { name: 'enum' }; -const functionType = { name: 'function' }; -const numArrayType = { name: 'array', value: numberType }; -const boolObjectType = { name: 'object', value: { bool: booleanType } }; - -jest.mock('@storybook/client-logger'); - -enum ArgsMapTestEnumWithoutInitializer { - EnumValue, - EnumValue2, -} - -enum ArgsMapTestEnumWithStringInitializer { - EnumValue = 'EnumValue', -} - -enum ArgsMapTestEnumWithNumericInitializer { - EnumValue = 4, -} - -describe('mapArgsToTypes', () => { - it('maps strings', () => { - expect(mapArgsToTypes({ a: 'str' }, { a: { type: stringType } })).toStrictEqual({ a: 'str' }); - expect(mapArgsToTypes({ a: 42 }, { a: { type: stringType } })).toStrictEqual({ a: '42' }); - }); - - it('maps enums', () => { - expect( - mapArgsToTypes({ a: ArgsMapTestEnumWithoutInitializer.EnumValue }, { a: { type: enumType } }) - ).toEqual({ a: 0 }); - expect( - mapArgsToTypes({ a: ArgsMapTestEnumWithoutInitializer.EnumValue2 }, { a: { type: enumType } }) - ).toEqual({ a: 1 }); - expect( - mapArgsToTypes( - { a: ArgsMapTestEnumWithStringInitializer.EnumValue }, - { a: { type: enumType } } - ) - ).toEqual({ a: 'EnumValue' }); - expect( - mapArgsToTypes( - { a: ArgsMapTestEnumWithNumericInitializer.EnumValue }, - { a: { type: enumType } } - ) - ).toEqual({ a: 4 }); - }); - - it('maps numbers', () => { - expect(mapArgsToTypes({ a: '42' }, { a: { type: numberType } })).toStrictEqual({ a: 42 }); - expect(mapArgsToTypes({ a: '4.2' }, { a: { type: numberType } })).toStrictEqual({ a: 4.2 }); - expect(mapArgsToTypes({ a: 'a' }, { a: { type: numberType } })).toStrictEqual({ a: NaN }); - }); - - it('maps booleans', () => { - expect(mapArgsToTypes({ a: 'true' }, { a: { type: booleanType } })).toStrictEqual({ a: true }); - expect(mapArgsToTypes({ a: 'false' }, { a: { type: booleanType } })).toStrictEqual({ - a: false, - }); - expect(mapArgsToTypes({ a: 'yes' }, { a: { type: booleanType } })).toStrictEqual({ a: false }); - }); - - it('maps sparse arrays', () => { - // eslint-disable-next-line no-sparse-arrays - expect(mapArgsToTypes({ a: [, '2', undefined] }, { a: { type: numArrayType } })).toStrictEqual({ - // eslint-disable-next-line no-sparse-arrays - a: [, 2, undefined], - }); - }); - - it('omits functions', () => { - expect(mapArgsToTypes({ a: 'something' }, { a: { type: functionType } })).toStrictEqual({}); - }); - - it('omits unknown keys', () => { - expect(mapArgsToTypes({ a: 'string' }, { b: { type: stringType } })).toStrictEqual({}); - }); - - it('passes through unmodified if no type is specified', () => { - expect(mapArgsToTypes({ a: { b: 1 } }, { a: { type: undefined } })).toStrictEqual({ - a: { b: 1 }, - }); - }); - - it('passes string for object type', () => { - expect(mapArgsToTypes({ a: 'A' }, { a: { type: boolObjectType } })).toStrictEqual({ a: 'A' }); - }); - - it('passes number for object type', () => { - expect(mapArgsToTypes({ a: 1.2 }, { a: { type: boolObjectType } })).toStrictEqual({ a: 1.2 }); - }); - - it('deeply maps objects', () => { - expect( - mapArgsToTypes( - { - key: { - arr: ['1', '2'], - obj: { bool: 'true' }, - }, - }, - { - key: { - type: { - name: 'object', - value: { - arr: numArrayType, - obj: boolObjectType, - }, - }, - }, - } - ) - ).toStrictEqual({ - key: { - arr: [1, 2], - obj: { bool: true }, - }, - }); - }); - - it('deeply maps arrays', () => { - expect( - mapArgsToTypes( - { - key: [ - { - arr: ['1', '2'], - obj: { bool: 'true' }, - }, - ], - }, - { - key: { - type: { - name: 'array', - value: { - name: 'object', - value: { - arr: numArrayType, - obj: boolObjectType, - }, - }, - }, - }, - } - ) - ).toStrictEqual({ - key: [ - { - arr: [1, 2], - obj: { bool: true }, - }, - ], - }); - }); -}); - -describe('combineArgs', () => { - it('merges args', () => { - expect(combineArgs({ foo: 1 }, { bar: 2 })).toStrictEqual({ foo: 1, bar: 2 }); - }); - - it('merges sparse arrays', () => { - // eslint-disable-next-line no-sparse-arrays - expect(combineArgs({ foo: [1, 2, 3] }, { foo: [, 4, undefined] })).toStrictEqual({ - foo: [1, 4], - }); - }); - - it('deeply merges args', () => { - expect(combineArgs({ foo: { bar: [1, 2], baz: true } }, { foo: { bar: [3] } })).toStrictEqual({ - foo: { bar: [3, 2], baz: true }, - }); - }); - - it('omits keys with undefined value', () => { - expect(combineArgs({ foo: 1 }, { foo: undefined })).toStrictEqual({}); - }); -}); - -describe('validateOptions', () => { - // https://github.com/storybookjs/storybook/issues/15630 - it('does not set args to `undefined` if they are unset', () => { - expect(validateOptions({}, { a: {} })).toStrictEqual({}); - }); - - it('omits arg and warns if value is not one of options', () => { - expect(validateOptions({ a: 1 }, { a: { options: [2, 3] } })).toStrictEqual({}); - expect(once.warn).toHaveBeenCalledWith( - "Received illegal value for 'a'. Supported options: 2, 3" - ); - }); - - it('includes arg if value is one of options', () => { - expect(validateOptions({ a: 1 }, { a: { options: [1, 2] } })).toStrictEqual({ a: 1 }); - }); - - it('includes arg if value is undefined', () => { - expect(validateOptions({ a: undefined }, { a: { options: [1, 2] } })).toStrictEqual({ - a: undefined, - }); - }); - - it('includes arg if no options are specified', () => { - expect(validateOptions({ a: 1 }, { a: {} })).toStrictEqual({ a: 1 }); - }); - - it('ignores options and logs an error if options is not an array', () => { - expect(validateOptions({ a: 1 }, { a: { options: { 2: 'two' } } })).toStrictEqual({ a: 1 }); - expect(once.error).toHaveBeenCalledWith( - expect.stringContaining("Invalid argType: 'a.options' should be an array") - ); - }); - - it('logs an error if options contains non-primitive values', () => { - expect( - validateOptions({ a: { one: 1 } }, { a: { options: [{ one: 1 }, { two: 2 }] } }) - ).toStrictEqual({ a: { one: 1 } }); - expect(once.error).toHaveBeenCalledWith( - expect.stringContaining("Invalid argType: 'a.options' should only contain primitives") - ); - expect(once.warn).not.toHaveBeenCalled(); - }); - - it('supports arrays', () => { - expect(validateOptions({ a: [1, 2] }, { a: { options: [1, 2, 3] } })).toStrictEqual({ - a: [1, 2], - }); - expect(validateOptions({ a: [1, 2, 4] }, { a: { options: [2, 3] } })).toStrictEqual({}); - expect(once.warn).toHaveBeenCalledWith( - "Received illegal value for 'a[0]'. Supported options: 2, 3" - ); - }); -}); diff --git a/lib/client-api/src/args.ts b/lib/client-api/src/args.ts deleted file mode 100644 index 85af91b9fedd..000000000000 --- a/lib/client-api/src/args.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { Args, ArgTypes } from '@storybook/addons'; -import { once } from '@storybook/client-logger'; -import isPlainObject from 'lodash/isPlainObject'; -import dedent from 'ts-dedent'; - -type ValueType = { name: string; value?: ObjectValueType | ValueType }; -type ObjectValueType = Record; - -const INCOMPATIBLE = Symbol('incompatible'); -const map = (arg: unknown, type: ValueType): any => { - if (arg === undefined || arg === null || !type) return arg; - switch (type.name) { - case 'string': - return String(arg); - case 'enum': - return arg; - case 'number': - return Number(arg); - case 'boolean': - return arg === 'true'; - case 'array': - if (!type.value || !Array.isArray(arg)) return INCOMPATIBLE; - return arg.reduce((acc, item, index) => { - const mapped = map(item, type.value as ValueType); - if (mapped !== INCOMPATIBLE) acc[index] = mapped; - return acc; - }, new Array(arg.length)); - case 'object': - if (typeof arg === 'string' || typeof arg === 'number') return arg; - if (!type.value || typeof arg !== 'object') return INCOMPATIBLE; - return Object.entries(arg).reduce((acc, [key, val]) => { - const mapped = map(val, (type.value as ObjectValueType)[key]); - return mapped === INCOMPATIBLE ? acc : Object.assign(acc, { [key]: mapped }); - }, {} as Args); - default: - return INCOMPATIBLE; - } -}; - -export const mapArgsToTypes = (args: Args, argTypes: ArgTypes): Args => { - return Object.entries(args).reduce((acc, [key, value]) => { - if (!argTypes[key]) return acc; - const mapped = map(value, argTypes[key].type); - return mapped === INCOMPATIBLE ? acc : Object.assign(acc, { [key]: mapped }); - }, {}); -}; - -export const combineArgs = (value: any, update: any): Args => { - if (Array.isArray(value) && Array.isArray(update)) { - return update - .reduce( - (acc, upd, index) => { - acc[index] = combineArgs(value[index], update[index]); - return acc; - }, - [...value] - ) - .filter((v: any) => v !== undefined); - } - if (!isPlainObject(value) || !isPlainObject(update)) return update; - return Object.keys({ ...value, ...update }).reduce((acc, key) => { - if (key in update) { - const combined = combineArgs(value[key], update[key]); - if (combined !== undefined) acc[key] = combined; - } else { - acc[key] = value[key]; - } - return acc; - }, {} as any); -}; - -export const validateOptions = (args: Args, argTypes: ArgTypes): Args => { - return Object.entries(argTypes).reduce((acc, [key, { options }]) => { - if (!options) { - if (key in args) { - acc[key] = args[key]; - } - return acc; - } - - if (!Array.isArray(options)) { - once.error(dedent` - Invalid argType: '${key}.options' should be an array. - - More info: https://storybook.js.org/docs/react/api/argtypes - `); - acc[key] = args[key]; - return acc; - } - - if (options.some((opt) => opt && ['object', 'function'].includes(typeof opt))) { - once.error(dedent` - Invalid argType: '${key}.options' should only contain primitives. Use a 'mapping' for complex values. - - More info: https://storybook.js.org/docs/react/writing-stories/args#mapping-to-complex-arg-values - `); - acc[key] = args[key]; - return acc; - } - - const isArray = Array.isArray(args[key]); - const invalidIndex = isArray && args[key].findIndex((val: any) => !options.includes(val)); - const isValidArray = isArray && invalidIndex === -1; - - if (args[key] === undefined || options.includes(args[key]) || isValidArray) { - acc[key] = args[key]; - return acc; - } - - const field = isArray ? `${key}[${invalidIndex}]` : key; - const supportedOptions = options - .map((opt: any) => (typeof opt === 'string' ? `'${opt}'` : String(opt))) - .join(', '); - once.warn(`Received illegal value for '${field}'. Supported options: ${supportedOptions}`); - - return acc; - }, {} as Args); -}; diff --git a/lib/client-api/src/client_api.test.ts b/lib/client-api/src/client_api.test.ts deleted file mode 100644 index 0dc6fc1edc99..000000000000 --- a/lib/client-api/src/client_api.test.ts +++ /dev/null @@ -1,675 +0,0 @@ -import { logger } from '@storybook/client-logger'; -import { addons, mockChannel } from '@storybook/addons'; -import Events from '@storybook/core-events'; -import ClientApi from './client_api'; -import ConfigApi from './config_api'; -import StoryStore from './story_store'; - -const getContext = (clientApiOptions = {}) => { - const channel = mockChannel(); - addons.setChannel(channel); - const storyStore = new StoryStore({ channel }); - const clientApi = new ClientApi({ storyStore, ...clientApiOptions }); - const configApi = new ConfigApi({ storyStore }); - - return { - configApi, - storyStore, - channel, - clientApi, - }; -}; - -jest.mock('@storybook/client-logger', () => ({ - logger: { warn: jest.fn(), log: jest.fn() }, -})); - -describe('preview.client_api', () => { - describe('setAddon', () => { - it('should register addons', () => { - const { clientApi } = getContext(); - let data; - - clientApi.setAddon({ - aa() { - data = 'foo'; - }, - }); - - clientApi.storiesOf('none', module).aa(); - expect(data).toBe('foo'); - }); - - it('should not remove previous addons', () => { - const { clientApi } = getContext(); - const data = []; - - clientApi.setAddon({ - aa() { - data.push('foo'); - }, - }); - - clientApi.setAddon({ - bb() { - data.push('bar'); - }, - }); - - clientApi.storiesOf('none', module).aa().bb(); - expect(data).toEqual(['foo', 'bar']); - }); - - it('should call with the clientApi context', () => { - const { clientApi } = getContext(); - let data; - - clientApi.setAddon({ - aa() { - data = typeof this.add; - }, - }); - - clientApi.storiesOf('none', module).aa(); - expect(data).toBe('function'); - }); - - it('should be able to access addons added previously', () => { - const { clientApi } = getContext(); - let data; - - clientApi.setAddon({ - aa() { - data = 'foo'; - }, - }); - - clientApi.setAddon({ - bb() { - this.aa(); - }, - }); - - clientApi.storiesOf('none', module).bb(); - expect(data).toBe('foo'); - }); - - it('should be able to access the current kind', () => { - const { clientApi } = getContext(); - const kind = 'dfdwf3e3'; - let data; - - clientApi.setAddon({ - aa() { - data = this.kind; - }, - }); - - clientApi.storiesOf(kind, module).aa(); - expect(data).toBe(kind); - }); - }); - - describe('addParameters', () => { - it('should add parameters', () => { - const { clientApi, storyStore } = getContext(); - const { storiesOf } = clientApi; - - clientApi.addParameters({ a: 1 }); - storiesOf('kind', module).add('name', (_args, { parameters }) => parameters); - - const result = storyStore.fromId('kind--name').storyFn(); - // @ts-ignore - const { docs, fileName, options, argTypes, __isArgsStory, ...rest } = result; - - expect(rest).toEqual({ a: 1 }); - }); - - it('should merge options', () => { - const { clientApi, storyStore } = getContext(); - const { storiesOf } = clientApi; - - clientApi.addParameters({ options: { a: '1' } }); - clientApi.addParameters({ options: { b: '2' } }); - storiesOf('kind', module).add('name', (_args, { parameters }) => parameters); - - // @ts-ignore - const { - options: { hierarchyRootSeparator, hierarchySeparator, ...rest }, - } = storyStore.fromId('kind--name').storyFn(); - - expect(rest).toEqual({ a: '1', b: '2' }); - }); - - it('should override specific properties in options', () => { - const { clientApi, storyStore } = getContext(); - const { storiesOf } = clientApi; - - clientApi.addParameters({ backgrounds: ['value'], options: { a: '1', b: '3' } }); - clientApi.addParameters({ options: { a: '2' } }); - storiesOf('kind', module).add('name', (_args, { parameters }) => parameters); - - // @ts-ignore - const { - options: { hierarchyRootSeparator, hierarchySeparator, ...rest }, - backgrounds, - } = storyStore.fromId('kind--name').storyFn(); - - expect(backgrounds).toEqual(['value']); - expect(rest).toEqual({ a: '2', b: '3' }); - }); - - it('should replace top level properties and override specific properties in options', () => { - const { clientApi, storyStore } = getContext(); - const { storiesOf } = clientApi; - - clientApi.addParameters({ backgrounds: ['value'], options: { a: '1', b: '3' } }); - clientApi.addParameters({ backgrounds: [], options: { a: '2' } }); - storiesOf('kind', module).add('name', (_args, { parameters }) => parameters); - - // @ts-ignore - const { - options: { hierarchyRootSeparator, hierarchySeparator, ...rest }, - backgrounds, - } = storyStore.fromId('kind--name').storyFn(); - - expect(backgrounds).toEqual([]); - expect(rest).toEqual({ a: '2', b: '3' }); - }); - - it('should deep merge in options', () => { - const { clientApi, storyStore } = getContext(); - const { storiesOf } = clientApi; - - clientApi.addParameters({ options: { a: '1', b: '2', theming: { c: '3' } } }); - clientApi.addParameters({ options: { theming: { c: '4', d: '5' } } }); - storiesOf('kind', module).add('name', (_args, { parameters }) => parameters); - - // @ts-ignore - const { - options: { hierarchyRootSeparator, hierarchySeparator, ...rest }, - } = storyStore.fromId('kind--name').storyFn(); - - expect(rest).toEqual({ a: '1', b: '2', theming: { c: '4', d: '5' } }); - }); - }); - - describe('addDecorator', () => { - it('should add local decorators', () => { - const { - clientApi: { storiesOf }, - storyStore, - } = getContext(); - - storiesOf('kind', module) - .addDecorator((fn) => `aa-${fn()}`) - .add('name', () => 'Hello'); - - expect(storyStore.fromId('kind--name').storyFn()).toBe('aa-Hello'); - }); - - it('should add global decorators', () => { - const { - clientApi: { addDecorator, storiesOf }, - storyStore, - } = getContext(); - - addDecorator((fn) => `bb-${fn()}`); - - storiesOf('kind', module).add('name', () => 'Hello'); - const f = storyStore.fromId('x'); - - expect(storyStore.fromId('kind--name').storyFn()).toBe('bb-Hello'); - }); - - it('should not add global decorators twice', () => { - const { - clientApi: { addDecorator, storiesOf }, - storyStore, - } = getContext(); - - const decorator = (fn) => `bb-${fn()}`; - addDecorator(decorator); - addDecorator(decorator); // this one is ignored - - storiesOf('kind', module).add('name', () => 'Hello'); - const f = storyStore.fromId('x'); - - expect(storyStore.fromId('kind--name').storyFn()).toBe('bb-Hello'); - }); - - it('should utilize both decorators at once', () => { - const { - clientApi: { addDecorator, storiesOf }, - storyStore, - } = getContext(); - - addDecorator((fn) => `aa-${fn()}`); - - storiesOf('kind', module) - .addDecorator((fn) => `bb-${fn()}`) - .add('name', () => 'Hello'); - - expect(storyStore.fromId('kind--name').storyFn()).toBe('aa-bb-Hello'); - }); - - it('should pass the context', () => { - const { - clientApi: { storiesOf }, - storyStore, - } = getContext(); - - storiesOf('kind', module) - .addDecorator((fn) => `aa-${fn()}`) - .add('name', (_args, c) => `${c.kind}-${c.name}`); - - const result = storyStore.fromId('kind--name').storyFn(); - expect(result).toBe(`aa-kind-name`); - }); - - it('should have access to the context', () => { - const { - clientApi: { storiesOf }, - storyStore, - } = getContext(); - - storiesOf('kind', module) - .addDecorator((fn, { kind, name }) => `${kind}-${name}-${fn()}`) - .add('name', () => 'Hello'); - - const result = storyStore.fromId('kind--name').storyFn(); - expect(result).toBe(`kind-name-Hello`); - }); - }); - - describe('clearDecorators', () => { - it('should remove all global decorators', () => { - const { clientApi, storyStore } = getContext(); - const { storiesOf } = clientApi; - - clientApi.addDecorator(() => 'foo'); - clientApi.clearDecorators(); - - storiesOf('kind', module).add('name', () => 'bar'); - - const result = storyStore.fromId('kind--name').storyFn(); - expect(result).toBe(`bar`); - }); - }); - - describe('getStorybook', () => { - it('should transform the storybook to an array with filenames', () => { - const { - clientApi: { getStorybook, storiesOf }, - } = getContext(); - - let book; - - book = getStorybook(); - expect(book).toEqual([]); - - storiesOf('kind 1', module) - .add('name 1', () => '1') - .add('name 2', () => '2'); - - storiesOf('kind 2', module) - .add('name 1', () => '1') - .add('name 2', () => '2'); - - book = getStorybook(); - - expect(book).toEqual([ - expect.objectContaining({ - fileName: expect.any(String), - kind: 'kind 1', - stories: [ - { - name: 'name 1', - render: expect.any(Function), - }, - { - name: 'name 2', - render: expect.any(Function), - }, - ], - }), - expect.objectContaining({ - fileName: expect.any(String), - kind: 'kind 2', - stories: [ - { - name: 'name 1', - render: expect.any(Function), - }, - { - name: 'name 2', - render: expect.any(Function), - }, - ], - }), - ]); - }); - - it('reads filename from module', () => { - const { - clientApi: { getStorybook, storiesOf }, - } = getContext(); - - const fn = jest.fn(); - storiesOf('kind', { id: 'foo.js' } as NodeModule).add('name', fn); - - const storybook = getStorybook(); - - expect(storybook).toEqual([ - { - kind: 'kind', - fileName: 'foo.js', - stories: [ - { - name: 'name', - render: expect.any(Function), - }, - ], - }, - ]); - }); - - it('should stringify ids from module', () => { - const { - clientApi: { getStorybook, storiesOf }, - } = getContext(); - - const fn = jest.fn(); - storiesOf('kind', { id: 1211 } as NodeModule).add('name', fn); - - const storybook = getStorybook(); - - expect(storybook).toEqual([ - { - kind: 'kind', - fileName: '1211', - stories: [ - { - name: 'name', - render: expect.any(Function), - }, - ], - }, - ]); - }); - }); - - describe('hot module loading', () => { - class MockModule { - id = 'mock-module-id'; - - hot = { - callbacks: [], - dispose(fn) { - this.callbacks.push(fn); - }, - reload() { - this.callbacks.forEach((fn) => fn()); - }, - }; - } - - it('should replace a kind when the module reloads', () => { - const { - clientApi: { storiesOf, getStorybook }, - } = getContext(); - const mod = new MockModule(); - - const stories = [jest.fn(), jest.fn()]; - - expect(getStorybook()).toEqual([]); - - storiesOf('kind', (mod as unknown) as NodeModule).add('story', stories[0]); - - const firstStorybook = getStorybook(); - expect(firstStorybook).toEqual([ - { - fileName: expect.any(String), - kind: 'kind', - stories: [{ name: 'story', render: expect.anything() }], - }, - ]); - - firstStorybook[0].stories[0].render(); - expect(stories[0]).toHaveBeenCalled(); - - mod.hot.reload(); - expect(getStorybook()).toEqual([]); - - storiesOf('kind', module).add('story', stories[1]); - - const secondStorybook = getStorybook(); - expect(secondStorybook).toEqual([ - { - fileName: expect.any(String), - kind: 'kind', - stories: [{ name: 'story', render: expect.anything() }], - }, - ]); - secondStorybook[0].stories[0].render(); - expect(stories[1]).toHaveBeenCalled(); - expect(logger.warn).not.toHaveBeenCalled(); - }); - - it('should maintain kind order when the module reloads', async () => { - const { - clientApi: { storiesOf, getStorybook }, - storyStore, - channel, - } = getContext(); - const module0 = new MockModule(); - const module1 = new MockModule(); - const module2 = new MockModule(); - - const mockChannelEmit = jest.fn(); - channel.emit = mockChannelEmit; - - expect(getStorybook()).toEqual([]); - - storyStore.startConfiguring(); - storiesOf('kind0', (module0 as unknown) as NodeModule).add('story0-docs-only', jest.fn(), { - docsOnly: true, - }); - storiesOf('kind1', (module1 as unknown) as NodeModule).add('story1', jest.fn()); - storiesOf('kind2', (module2 as unknown) as NodeModule).add('story2', jest.fn()); - storyStore.finishConfiguring(); - - let [event, args] = mockChannelEmit.mock.calls[1]; - expect(event).toEqual(Events.SET_STORIES); - expect(Object.values(args.stories as [{ kind: string }]).map((v) => v.kind)).toEqual([ - 'kind0', - 'kind1', - 'kind2', - ]); - expect(getStorybook().map((story) => story.kind)).toEqual(['kind1', 'kind2']); - - mockChannelEmit.mockClear(); - - // simulate an HMR of kind1, which would cause it to go to the end - // if the original order is not maintained - module1.hot.reload(); - storyStore.startConfiguring(); - storiesOf('kind1', (module1 as unknown) as NodeModule).add('story1', jest.fn()); - storyStore.finishConfiguring(); - - // eslint-disable-next-line prefer-destructuring - [event, args] = mockChannelEmit.mock.calls[1]; - - expect(event).toEqual(Events.SET_STORIES); - expect(Object.values(args.stories as [{ kind: string }]).map((v) => v.kind)).toEqual([ - 'kind0', - 'kind1', - 'kind2', - ]); - expect(getStorybook().map((story) => story.kind)).toEqual(['kind1', 'kind2']); - }); - - it('should call `module.hot.dispose` inside add and storiesOf by default', () => { - const mod = (new MockModule() as unknown) as NodeModule; - const mockHotDispose = jest.fn(); - mod.hot.dispose = mockHotDispose; - - const { - clientApi: { storiesOf, getStorybook }, - } = getContext(); - - storiesOf('kind', mod).add('story', jest.fn()); - - expect(mockHotDispose.mock.calls.length).toEqual(2); - }); - - it('should not call `module.hot.dispose` inside add when noStoryModuleAddMethodHotDispose is true', () => { - const mod = (new MockModule() as unknown) as NodeModule; - const mockHotDispose = jest.fn(); - mod.hot.dispose = mockHotDispose; - - const { - clientApi: { storiesOf, getStorybook }, - } = getContext({ noStoryModuleAddMethodHotDispose: true }); - - storiesOf('kind', mod).add('story', jest.fn()); - - expect(mockHotDispose.mock.calls.length).toEqual(1); - }); - }); - - describe('parameters', () => { - it('collects parameters across different modalities', () => { - const { - storyStore, - clientApi: { storiesOf, addParameters }, - } = getContext(); - - addParameters({ a: 'global', b: 'global', c: 'global' }); - - const kind = storiesOf('kind', module); - kind.addParameters({ b: 'kind', c: 'kind' }); - - kind.add('name', jest.fn(), { c: 'story' }); - - expect(storyStore.fromId('kind--name').parameters).toEqual({ - a: 'global', - b: 'kind', - c: 'story', - __isArgsStory: false, - fileName: expect.any(String), - argTypes: {}, - }); - }); - - it('combines object parameters per-key', () => { - const { - storyStore, - clientApi: { storiesOf, addParameters }, - } = getContext(); - - addParameters({ - addon1: 'global string value', - addon2: ['global array value'], - addon3: { - global: true, - sub: { global: true }, - }, - }); - - storiesOf('kind', module) - .addParameters({ - addon1: 'local string value', - addon2: ['local array value'], - addon3: { - local: true, - sub: { - local: true, - }, - }, - }) - .add('name', jest.fn(), { - addon1: 'local string value', - addon2: ['local array value'], - addon3: { - local: true, - sub: { - local: true, - }, - }, - }); - - expect(storyStore.fromId('kind--name').parameters).toEqual({ - addon1: 'local string value', - addon2: ['local array value'], - addon3: { - global: true, - local: true, - sub: { - global: true, - local: true, - }, - }, - __isArgsStory: false, - fileName: expect.any(String), - argTypes: {}, - }); - }); - }); - - describe('storiesOf', () => { - describe('add', () => { - it('should replace stories when adding the same story', () => { - const stories = [jest.fn().mockReturnValue('story1'), jest.fn().mockReturnValue('story2')]; - - const { - clientApi: { storiesOf, getStorybook }, - } = getContext(); - - expect(getStorybook()).toEqual([]); - - storiesOf('kind', module).add('story', stories[0]); - { - const book = getStorybook(); - expect(book).toHaveLength(1); - - const entry = book[0]; - expect(entry.kind).toMatch('kind'); - expect(entry.stories).toHaveLength(1); - expect(entry.stories[0].name).toBe('story'); - - expect(entry.stories[0].render()).toBe('story1'); - } - - storiesOf('kind', module).add('story', stories[1]); - // @ts-ignore - expect(logger.warn.mock.calls[0][0]).toMatch( - /Story with id kind--story already exists in the store/ - ); - { - const book = getStorybook(); - expect(book).toHaveLength(1); - - const entry = book[0]; - expect(entry.kind).toMatch('kind'); - expect(entry.stories).toHaveLength(1); - expect(entry.stories[0].name).toBe('story'); - - expect(entry.stories[0].render()).toBe('story2'); - } - }); - - it('should throw an error if story is in wrong format', () => { - const { - clientApi: { storiesOf }, - } = getContext(); - - expect(() => { - storiesOf('kind', module).add('test', 'String that should be a function instead' as any); - }).toThrow( - 'Cannot load story "test" in "kind" due to invalid format. Storybook expected a function/object but received string instead.' - ); - }); - }); - }); -}); diff --git a/lib/client-api/src/client_api.ts b/lib/client-api/src/client_api.ts deleted file mode 100644 index 8da382433ad3..000000000000 --- a/lib/client-api/src/client_api.ts +++ /dev/null @@ -1,314 +0,0 @@ -/* eslint no-underscore-dangle: 0 */ -import deprecate from 'util-deprecate'; -import dedent from 'ts-dedent'; -import { logger } from '@storybook/client-logger'; -import { StoryFn, Parameters, LoaderFunction, DecorateStoryFunction } from '@storybook/addons'; -import { toId } from '@storybook/csf'; - -import { - ClientApiParams, - DecoratorFunction, - ClientApiAddons, - StoryApi, - ArgsEnhancer, - ArgTypesEnhancer, -} from './types'; -import { applyHooks } from './hooks'; -import StoryStore from './story_store'; -import { defaultDecorateStory } from './decorators'; - -// ClientApi (and StoreStore) are really singletons. However they are not created until the -// relevant framework instanciates them via `start.js`. The good news is this happens right away. -let singleton: ClientApi; - -const addDecoratorDeprecationWarning = deprecate( - () => {}, - `\`addDecorator\` is deprecated, and will be removed in Storybook 7.0. -Instead, use \`export const decorators = [];\` in your \`preview.js\`. -Read more at https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#deprecated-addparameters-and-adddecorator).` -); -export const addDecorator = (decorator: DecoratorFunction, deprecationWarning = true) => { - if (!singleton) - throw new Error(`Singleton client API not yet initialized, cannot call addDecorator`); - - if (deprecationWarning) addDecoratorDeprecationWarning(); - - singleton.addDecorator(decorator); -}; - -const addParametersDeprecationWarning = deprecate( - () => {}, - `\`addParameters\` is deprecated, and will be removed in Storybook 7.0. -Instead, use \`export const parameters = {};\` in your \`preview.js\`. -Read more at https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#deprecated-addparameters-and-adddecorator).` -); -export const addParameters = (parameters: Parameters, deprecationWarning = true) => { - if (!singleton) - throw new Error(`Singleton client API not yet initialized, cannot call addParameters`); - - if (deprecationWarning) addParametersDeprecationWarning(); - - singleton.addParameters(parameters); -}; - -const addLoaderDeprecationWarning = deprecate( - () => {}, - `\`addLoader\` is deprecated, and will be removed in Storybook 7.0. -Instead, use \`export const loaders = [];\` in your \`preview.js\`. -Read more at https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#deprecated-addparameters-and-adddecorator).` -); -export const addLoader = (loader: LoaderFunction, deprecationWarning = true) => { - if (!singleton) - throw new Error(`Singleton client API not yet initialized, cannot call addParameters`); - - if (deprecationWarning) addLoaderDeprecationWarning(); - - singleton.addLoader(loader); -}; - -export const addArgsEnhancer = (enhancer: ArgsEnhancer) => { - if (!singleton) - throw new Error(`Singleton client API not yet initialized, cannot call addArgsEnhancer`); - - singleton.addArgsEnhancer(enhancer); -}; - -export const addArgTypesEnhancer = (enhancer: ArgTypesEnhancer) => { - if (!singleton) - throw new Error(`Singleton client API not yet initialized, cannot call addArgTypesEnhancer`); - - singleton.addArgTypesEnhancer(enhancer); -}; - -export const getGlobalRender = () => { - if (!singleton) - throw new Error(`Singleton client API not yet initialized, cannot call getGlobalRender`); - - return singleton.globalRender; -}; - -export const setGlobalRender = (render: StoryFn) => { - if (!singleton) - throw new Error(`Singleton client API not yet initialized, cannot call setGobalRender`); - singleton.globalRender = render; -}; - -const invalidStoryTypes = new Set(['string', 'number', 'boolean', 'symbol']); -export default class ClientApi { - private _storyStore: StoryStore; - - private _addons: ClientApiAddons; - - private _decorateStory: DecorateStoryFunction; - - private _globalRender: StoryFn; - - // React Native Fast refresh doesn't allow multiple dispose calls - private _noStoryModuleAddMethodHotDispose: boolean; - - constructor({ - storyStore, - decorateStory = defaultDecorateStory, - noStoryModuleAddMethodHotDispose, - }: ClientApiParams) { - this._storyStore = storyStore; - this._addons = {}; - - this._noStoryModuleAddMethodHotDispose = noStoryModuleAddMethodHotDispose || false; - - this._decorateStory = decorateStory; - - if (!storyStore) throw new Error('storyStore is required'); - - singleton = this; - } - - setAddon = deprecate( - (addon: any) => { - this._addons = { - ...this._addons, - ...addon, - }; - }, - dedent` - \`setAddon\` is deprecated and will be removed in Storybook 7.0. - - https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#deprecated-setaddon - ` - ); - - addDecorator = (decorator: DecoratorFunction) => { - this._storyStore.addGlobalMetadata({ decorators: [decorator] }); - }; - - clearDecorators = deprecate( - () => { - this._storyStore.clearGlobalDecorators(); - }, - dedent` - \`clearDecorators\` is deprecated and will be removed in Storybook 7.0. - - https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#deprecated-cleardecorators - ` - ); - - addParameters = (parameters: Parameters) => { - this._storyStore.addGlobalMetadata({ parameters }); - }; - - addLoader = (loader: LoaderFunction) => { - this._storyStore.addGlobalMetadata({ loaders: [loader] }); - }; - - addArgsEnhancer = (enhancer: ArgsEnhancer) => { - this._storyStore.addArgsEnhancer(enhancer); - }; - - addArgTypesEnhancer = (enhancer: ArgTypesEnhancer) => { - this._storyStore.addArgTypesEnhancer(enhancer); - }; - - get globalRender(): StoryFn { - return this._globalRender; - } - - set globalRender(render: StoryFn) { - this._globalRender = render; - } - - // what are the occasions that "m" is a boolean vs an obj - storiesOf = ( - kind: string, - m: NodeModule - ): StoryApi => { - if (!kind && typeof kind !== 'string') { - throw new Error('Invalid or missing kind provided for stories, should be a string'); - } - - if (!m) { - logger.warn( - `Missing 'module' parameter for story with a kind of '${kind}'. It will break your HMR` - ); - } - - if (m) { - const proto = Object.getPrototypeOf(m); - if (proto.exports && proto.exports.default) { - // FIXME: throw an error in SB6.0 - logger.error( - `Illegal mix of CSF default export and storiesOf calls in a single file: ${proto.i}` - ); - } - } - - if (m && m.hot && m.hot.dispose) { - m.hot.dispose(() => { - const { _storyStore } = this; - // If HMR dispose happens in a story file, we know that HMR will pass up to the configuration file (preview.js) - // and be handled by the HMR.allow in config_api, leading to a re-run of configuration. - // So configuration is about to happen--we can skip the safety check. - _storyStore.removeStoryKind(kind, { allowUnsafe: true }); - }); - } - - let hasAdded = false; - const api: StoryApi = { - kind: kind.toString(), - add: () => api, - addDecorator: () => api, - addLoader: () => api, - addParameters: () => api, - }; - - // apply addons - Object.keys(this._addons).forEach((name) => { - const addon = this._addons[name]; - api[name] = (...args: any[]) => { - addon.apply(api, args); - return api; - }; - }); - - api.add = ( - storyName: string, - storyFn: StoryFn, - parameters: Parameters = {} - ) => { - hasAdded = true; - - const id = parameters.__id || toId(kind, storyName); - - if (typeof storyName !== 'string') { - throw new Error(`Invalid or missing storyName provided for a "${kind}" story.`); - } - - if (!storyFn || Array.isArray(storyFn) || invalidStoryTypes.has(typeof storyFn)) { - throw new Error( - `Cannot load story "${storyName}" in "${kind}" due to invalid format. Storybook expected a function/object but received ${typeof storyFn} instead.` - ); - } - - if (!this._noStoryModuleAddMethodHotDispose && m && m.hot && m.hot.dispose) { - m.hot.dispose(() => { - const { _storyStore } = this; - // See note about allowUnsafe above - _storyStore.remove(id, { allowUnsafe: true }); - }); - } - - const fileName = m && m.id ? `${m.id}` : undefined; - - const { decorators, loaders, ...storyParameters } = parameters; - this._storyStore.addStory( - { - id, - kind, - name: storyName, - storyFn, - parameters: { fileName, ...storyParameters }, - decorators, - loaders, - }, - { - applyDecorators: applyHooks(this._decorateStory), - } - ); - return api; - }; - - api.addDecorator = (decorator: DecoratorFunction) => { - if (hasAdded) - throw new Error(`You cannot add a decorator after the first story for a kind. -Read more here: https://github.com/storybookjs/storybook/blob/master/MIGRATION.md#can-no-longer-add-decoratorsparameters-after-stories`); - - this._storyStore.addKindMetadata(kind, { decorators: [decorator] }); - return api; - }; - - api.addLoader = (loader: LoaderFunction) => { - if (hasAdded) throw new Error(`You cannot add a loader after the first story for a kind.`); - - this._storyStore.addKindMetadata(kind, { loaders: [loader] }); - return api; - }; - - api.addParameters = (parameters: Parameters) => { - if (hasAdded) - throw new Error(`You cannot add parameters after the first story for a kind. -Read more here: https://github.com/storybookjs/storybook/blob/master/MIGRATION.md#can-no-longer-add-decoratorsparameters-after-stories`); - - this._storyStore.addKindMetadata(kind, { parameters }); - return api; - }; - - return api; - }; - - getStorybook = () => this._storyStore.getStorybook(); - - raw = () => this._storyStore.raw(); - - // FIXME: temporary expose the store for react-native - // Longer term react-native should use the Provider/Consumer api - store = () => this._storyStore; -} diff --git a/lib/client-api/src/config_api.ts b/lib/client-api/src/config_api.ts deleted file mode 100644 index ed4a6d226381..000000000000 --- a/lib/client-api/src/config_api.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* eslint no-underscore-dangle: 0 */ -import StoryStore from './story_store'; - -export default class ConfigApi { - _storyStore: StoryStore; - - constructor({ storyStore }: { storyStore: StoryStore }) { - this._storyStore = storyStore; - } - - configure = (loaders: () => void, module: NodeModule, showDeprecationWarning = true) => { - this._storyStore.startConfiguring(); - - try { - loaders(); - - this._storyStore.clearError(); - } catch (err) { - this._storyStore.setError(err); - } - this._storyStore.finishConfiguring(); - }; -} diff --git a/lib/client-api/src/decorators.test.ts b/lib/client-api/src/decorators.test.ts deleted file mode 100644 index e4ebe6506010..000000000000 --- a/lib/client-api/src/decorators.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { StoryContext } from '@storybook/addons'; - -import { defaultDecorateStory } from './decorators'; - -function makeContext(input: Record = {}): StoryContext { - return { - id: 'id', - kind: 'kind', - name: 'name', - viewMode: 'story', - parameters: {}, - ...input, - } as StoryContext; -} - -describe('client-api.decorators', () => { - it('calls decorators in out to in order', () => { - const order = []; - const decorators = [ - (s) => order.push(1) && s(), - (s) => order.push(2) && s(), - (s) => order.push(3) && s(), - ]; - const decorated = defaultDecorateStory(() => order.push(4), decorators); - - expect(order).toEqual([]); - decorated(makeContext()); - expect(order).toEqual([3, 2, 1, 4]); - }); - - it('passes context through to sub decorators', () => { - const contexts = []; - const decorators = [ - (s, c) => contexts.push(c) && s({ k: 1 }), - (s, c) => contexts.push(c) && s({ k: 2 }), - (s, c) => contexts.push(c) && s({ k: 3 }), - ]; - const decorated = defaultDecorateStory((c) => contexts.push(c), decorators); - - expect(contexts).toEqual([]); - decorated(makeContext({ k: 0 })); - expect(contexts.map((c) => c.k)).toEqual([0, 3, 2, 1]); - }); - - it('passes context through to sub decorators additively', () => { - const contexts = []; - const decorators = [ - (s, c) => contexts.push(c) && s({ b: 1 }), - (s, c) => contexts.push(c) && s({ c: 2 }), - (s, c) => contexts.push(c) && s({ d: 3 }), - ]; - const decorated = defaultDecorateStory((c) => contexts.push(c), decorators); - - expect(contexts).toEqual([]); - decorated(makeContext({ a: 0 })); - expect(contexts.map(({ a, b, c, d }) => ({ a, b, c, d }))).toEqual([ - { a: 0, b: undefined, c: undefined, d: undefined }, - { a: 0, b: undefined, c: undefined, d: 3 }, - { a: 0, b: undefined, c: 2, d: 3 }, - { a: 0, b: 1, c: 2, d: 3 }, - ]); - }); - - it('does not recreate decorated story functions each time', () => { - const decoratedStories = []; - const decorators = [ - (s, c) => { - decoratedStories.push = s; - return s(); - }, - ]; - const decorated = defaultDecorateStory(() => 0, decorators); - - decorated(makeContext()); - decorated(makeContext()); - expect(decoratedStories[0]).toBe(decoratedStories[1]); - }); - - // NOTE: important point--this test would not work if we called `decoratedOne` twice simultaneously - // both story functions would receive {story: 2}. The assumption here is that we'll never render - // the same story twice at the same time. - it('does not interleave contexts if two decorated stories are call simultaneously', async () => { - const contexts = []; - let resolve; - const fence = new Promise((r) => { - resolve = r; - }); - const decorators = [ - async (s, c) => { - // The fence here simulates async-ness in react rendering an element (`` doesn't run `S()` straight away) - await fence; - s(); - }, - ]; - const decoratedOne = defaultDecorateStory((c) => contexts.push(c), decorators); - const decoratedTwo = defaultDecorateStory((c) => contexts.push(c), decorators); - - decoratedOne(makeContext({ value: 1 })); - decoratedTwo(makeContext({ value: 2 })); - - resolve(); - await fence; - - expect(contexts[0].value).toBe(1); - expect(contexts[1].value).toBe(2); - }); - - it('merges contexts', () => { - const contexts = []; - const decorators = [(s, c) => contexts.push(c) && s({ c: 'd' })]; - const decorated = defaultDecorateStory((c) => contexts.push(c), decorators); - - expect(contexts).toEqual([]); - decorated(makeContext({ a: 'b' })); - expect(contexts).toEqual([ - expect.objectContaining({ a: 'b' }), - expect.objectContaining({ a: 'b', c: 'd' }), - ]); - }); - - it('DOES NOT merge core metadata or pass through core metadata keys in context', () => { - const contexts = []; - const decorators = [ - (s, c) => - contexts.push(c) && - s({ parameters: { c: 'd' }, id: 'notId', kind: 'notKind', name: 'notName' }), - ]; - const decorated = defaultDecorateStory((c) => contexts.push(c), decorators); - - expect(contexts).toEqual([]); - decorated(makeContext({ parameters: { a: 'b' } })); - expect(contexts).toEqual([ - expect.objectContaining({ parameters: { a: 'b' }, id: 'id', kind: 'kind', name: 'name' }), - expect.objectContaining({ parameters: { a: 'b' }, id: 'id', kind: 'kind', name: 'name' }), - ]); - }); -}); diff --git a/lib/client-api/src/decorators.ts b/lib/client-api/src/decorators.ts deleted file mode 100644 index d5fa38ff88fb..000000000000 --- a/lib/client-api/src/decorators.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { StoryContext, StoryContextUpdate, PartialStoryFn, LegacyStoryFn } from '@storybook/addons'; -import { DecoratorFunction } from './types'; - -const defaultContext: StoryContext = { - id: 'unspecified', - name: 'unspecified', - kind: 'unspecified', - parameters: {}, - args: {}, - argTypes: {}, - globals: {}, -}; - -export const decorateStory = ( - storyFn: LegacyStoryFn, - decorator: DecoratorFunction, - bindWithContext: (storyFn: LegacyStoryFn) => PartialStoryFn -): LegacyStoryFn => { - // Bind the partially decorated storyFn so that when it is called it always knows about the story context, - // no matter what it is passed directly. This is because we cannot guarantee a decorator will - // pass the context down to the next decorated story in the chain. - const boundStoryFunction = bindWithContext(storyFn); - - return (context: StoryContext) => decorator(boundStoryFunction, context); -}; - -type ContextStore = { value: StoryContext }; - -export const defaultDecorateStory = ( - storyFn: LegacyStoryFn, - decorators: DecoratorFunction[] -): LegacyStoryFn => { - // We use a trick to avoid recreating the bound story function inside `decorateStory`. - // Instead we pass it a context "getter", which is defined once (at "decoration time") - // The getter reads a variable which is scoped to this call of `decorateStory` - // (ie to this story), so there is no possibility of overlap. - // This will break if you call the same story twice interleaved - // (React might do it if you rendered the same story twice in the one ReactDom.render call, for instance) - const contextStore: ContextStore = { value: defaultContext }; - - /** - * When you call the story function inside a decorator, e.g.: - * - * ```jsx - *
{storyFn({ foo: 'bar' })}
- * ``` - * - * This will override the `foo` property on the `innerContext`, which gets - * merged in with the default context - */ - const bindWithContext = (decoratedStoryFn: LegacyStoryFn): PartialStoryFn => - // (NOTE: You cannot override the parameters key, it is fixed) - ({ id, name, kind, parameters, ...contextUpdate }: StoryContextUpdate = {}) => { - contextStore.value = { ...contextStore.value, ...contextUpdate }; - return decoratedStoryFn(contextStore.value); - }; - - const decoratedWithContextStore = decorators.reduce( - (story, decorator) => decorateStory(story, decorator, bindWithContext), - storyFn - ); - return (context = defaultContext) => { - contextStore.value = context; - return decoratedWithContextStore(context); // Pass the context directly into the first decorator - }; -}; diff --git a/lib/client-api/src/ensureArgTypes.ts b/lib/client-api/src/ensureArgTypes.ts deleted file mode 100644 index b03aa0291a6f..000000000000 --- a/lib/client-api/src/ensureArgTypes.ts +++ /dev/null @@ -1,10 +0,0 @@ -import mapValues from 'lodash/mapValues'; -import { ArgTypesEnhancer } from './types'; -import { combineParameters } from './parameters'; - -export const ensureArgTypes: ArgTypesEnhancer = (context) => { - const { argTypes: userArgTypes = {}, args = {} } = context.parameters; - if (!args) return userArgTypes; - const argTypes = mapValues(args, (_arg, name) => ({ name })); - return combineParameters(argTypes, userArgTypes); -}; diff --git a/lib/client-api/src/filterArgTypes.ts b/lib/client-api/src/filterArgTypes.ts deleted file mode 100644 index 60b19d742069..000000000000 --- a/lib/client-api/src/filterArgTypes.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { ArgTypes } from '@storybook/addons'; -import pickBy from 'lodash/pickBy'; - -export type PropDescriptor = string[] | RegExp; - -const matches = (name: string, descriptor: PropDescriptor) => - Array.isArray(descriptor) ? descriptor.includes(name) : name.match(descriptor); - -export const filterArgTypes = ( - argTypes: ArgTypes, - include?: PropDescriptor, - exclude?: PropDescriptor -) => { - if (!include && !exclude) { - return argTypes; - } - return ( - argTypes && - pickBy(argTypes, (argType, key) => { - const name = argType.name || key; - return (!include || matches(name, include)) && (!exclude || !matches(name, exclude)); - }) - ); -}; diff --git a/lib/client-api/src/hooks.test.js b/lib/client-api/src/hooks.test.js deleted file mode 100644 index 8ee8c590470d..000000000000 --- a/lib/client-api/src/hooks.test.js +++ /dev/null @@ -1,581 +0,0 @@ -import { - FORCE_RE_RENDER, - STORY_RENDERED, - UPDATE_STORY_ARGS, - RESET_STORY_ARGS, - UPDATE_GLOBALS, -} from '@storybook/core-events'; -import { addons } from '@storybook/addons'; -import { defaultDecorateStory } from './decorators'; -import { - applyHooks, - useEffect, - useMemo, - useCallback, - useRef, - useState, - useReducer, - useChannel, - useParameter, - useStoryContext, - HooksContext, - useArgs, - useGlobals, -} from './hooks'; - -jest.mock('@storybook/client-logger', () => ({ - logger: { warn: jest.fn(), log: jest.fn() }, -})); - -const SOME_EVENT = 'someEvent'; -let mockChannel; -let hooks; -let onSomeEvent; -let removeSomeEventListener; -beforeEach(() => { - onSomeEvent = jest.fn(); - removeSomeEventListener = jest.fn(); - mockChannel = { - emit: jest.fn(), - on(event, callback) { - switch (event) { - case STORY_RENDERED: - callback(); - break; - case SOME_EVENT: - onSomeEvent(event, callback); - break; - default: - } - }, - removeListener(event, callback) { - if (event === SOME_EVENT) { - removeSomeEventListener(event, callback); - } - }, - }; - hooks = new HooksContext(); - addons.setChannel(mockChannel); -}); - -const decorateStory = applyHooks(defaultDecorateStory); - -const run = (storyFn, decorators = [], context) => - decorateStory(storyFn, decorators)({ ...context, hooks }); - -describe('Preview hooks', () => { - describe('useEffect', () => { - it('triggers the effect from story function', () => { - const effect = jest.fn(); - run(() => { - useEffect(effect); - }); - expect(effect).toHaveBeenCalledTimes(1); - }); - it('triggers the effect from decorator', () => { - const effect = jest.fn(); - run(() => {}, [ - (storyFn) => { - useEffect(effect); - return storyFn(); - }, - ]); - expect(effect).toHaveBeenCalledTimes(1); - }); - it('triggers the effect from decorator if story call comes before useEffect', () => { - const effect = jest.fn(); - run(() => {}, [ - (storyFn) => { - const story = storyFn(); - useEffect(effect); - return story; - }, - ]); - expect(effect).toHaveBeenCalledTimes(1); - }); - it('retriggers the effect if no deps array is provided', () => { - const effect = jest.fn(); - const storyFn = () => { - useEffect(effect); - }; - run(storyFn); - run(storyFn); - expect(effect).toHaveBeenCalledTimes(2); - }); - it("doesn't retrigger the effect if empty deps array is provided", () => { - const effect = jest.fn(); - const storyFn = () => { - useEffect(effect, []); - }; - run(storyFn); - run(storyFn); - expect(effect).toHaveBeenCalledTimes(1); - }); - it("doesn't retrigger the effect from decorator if story has changed", () => { - const effect = jest.fn(); - const decorator = (storyFn) => { - useEffect(effect, []); - return storyFn(); - }; - run(() => {}, [decorator]); - run(() => {}, [decorator]); - expect(effect).toHaveBeenCalledTimes(1); - }); - it("doesn't retrigger the effect from decorator if story has changed and story call comes before useEffect", () => { - const effect = jest.fn(); - const decorator = (storyFn) => { - const story = storyFn(); - useEffect(effect, []); - return story; - }; - run(() => {}, [decorator]); - run(() => {}, [decorator]); - expect(effect).toHaveBeenCalledTimes(1); - }); - it("doesn't retrigger the effect from if decorator calls story twice", () => { - const effect = jest.fn(); - const story = () => { - useEffect(effect, []); - }; - const decorator = (storyFn) => { - storyFn(); - return storyFn(); - }; - run(story, [decorator]); - expect(effect).toHaveBeenCalledTimes(1); - }); - it('retriggers the effect if some of the deps are changed', () => { - const effect = jest.fn(); - let counter = 0; - const storyFn = () => { - useEffect(effect, [counter]); - counter += 1; - }; - run(storyFn); - run(storyFn); - expect(effect).toHaveBeenCalledTimes(2); - }); - it("doesn't retrigger the effect if none of the deps are changed", () => { - const effect = jest.fn(); - const storyFn = () => { - useEffect(effect, [0]); - }; - run(storyFn); - run(storyFn); - expect(effect).toHaveBeenCalledTimes(1); - }); - it('performs cleanup when retriggering', () => { - const destroy = jest.fn(); - const storyFn = () => { - useEffect(() => destroy); - }; - run(storyFn); - run(storyFn); - expect(destroy).toHaveBeenCalledTimes(1); - }); - it("doesn't perform cleanup when keeping the current effect", () => { - const destroy = jest.fn(); - const storyFn = () => { - useEffect(() => destroy, []); - }; - run(storyFn); - run(storyFn); - expect(destroy).not.toHaveBeenCalled(); - }); - it('performs cleanup when removing the decorator', () => { - const destroy = jest.fn(); - run(() => {}, [ - (storyFn) => { - useEffect(() => destroy); - return storyFn(); - }, - ]); - run(() => {}); - expect(destroy).toHaveBeenCalledTimes(1); - }); - }); - describe('useChannel', () => { - it('calls .on when rendering the decorator', () => { - const handler = () => {}; - run(() => {}, [ - (storyFn) => { - useChannel({ - [SOME_EVENT]: handler, - }); - return storyFn(); - }, - ]); - expect(onSomeEvent).toHaveBeenCalledTimes(1); - expect(removeSomeEventListener).toHaveBeenCalledTimes(0); - }); - it('calls .removeListener when removing the decorator', () => { - const handler = () => {}; - run(() => {}, [ - (storyFn) => { - useChannel({ - [SOME_EVENT]: handler, - }); - return storyFn(); - }, - ]); - expect(onSomeEvent).toHaveBeenCalledTimes(1); - expect(removeSomeEventListener).toHaveBeenCalledTimes(0); - run(() => {}); - expect(removeSomeEventListener).toHaveBeenCalledTimes(1); - }); - }); - describe('useStoryContext', () => { - it('returns current context', () => { - const context = {}; - run( - () => { - expect(useStoryContext()).toEqual({ ...context, hooks }); - }, - [], - context - ); - }); - }); - describe('useParameter', () => { - it('will pull value from storyStore', () => { - run( - () => {}, - [ - (storyFn) => { - expect(useParameter('foo', 4)).toEqual(42); - return storyFn(); - }, - ], - { parameters: { foo: 42 } } - ); - }); - it('will return default value', () => { - run( - () => {}, - [ - (storyFn) => { - expect(useParameter('bar', 4)).toEqual(4); - return storyFn(); - }, - ], - { parameters: {} } - ); - }); - it('will return undefined when no value is found', () => { - run( - () => {}, - [ - (storyFn) => { - expect(useParameter('bar')).toBe(undefined); - return storyFn(); - }, - ], - { parameters: {} } - ); - }); - }); - describe('useMemo', () => { - it('performs the calculation', () => { - let result; - const nextCreate = jest.fn(() => 'foo'); - const storyFn = () => { - result = useMemo(nextCreate, []); - }; - run(storyFn); - expect(nextCreate).toHaveBeenCalledTimes(1); - expect(result).toBe('foo'); - }); - it('performs the calculation once if deps are unchanged', () => { - let result; - const nextCreate = jest.fn(() => 'foo'); - const storyFn = () => { - result = useMemo(nextCreate, []); - }; - run(storyFn); - run(storyFn); - expect(nextCreate).toHaveBeenCalledTimes(1); - expect(result).toBe('foo'); - }); - it('performs the calculation again if deps are changed', () => { - let result; - let counter = 0; - const nextCreate = jest.fn(() => counter); - const storyFn = () => { - counter += 1; - result = useMemo(nextCreate, [counter]); - }; - run(storyFn); - run(storyFn); - expect(nextCreate).toHaveBeenCalledTimes(2); - expect(result).toBe(counter); - }); - }); - describe('useCallback', () => { - it('returns the callback', () => { - let result; - const callback = () => {}; - const storyFn = () => { - result = useCallback(callback, []); - }; - run(storyFn); - expect(result).toBe(callback); - }); - it('returns the previous callback reference if deps are unchanged', () => { - const callbacks = []; - const storyFn = () => { - const callback = useCallback(() => {}, []); - callbacks.push(callback); - }; - run(storyFn); - run(storyFn); - expect(callbacks[0]).toBe(callbacks[1]); - }); - it('creates new callback reference if deps are changed', () => { - const callbacks = []; - let counter = 0; - const storyFn = () => { - counter += 1; - const callback = useCallback(() => {}, [counter]); - callbacks.push(callback); - }; - run(storyFn); - run(storyFn); - expect(callbacks[0]).not.toBe(callbacks[1]); - }); - }); - describe('useRef', () => { - it('attaches initial value', () => { - let ref; - const storyFn = () => { - ref = useRef('foo'); - }; - run(storyFn); - expect(ref.current).toBe('foo'); - }); - it('stores mutations', () => { - let refValueFromSecondRender; - let counter = 0; - const storyFn = () => { - counter += 1; - const ref = useRef('foo'); - - if (counter === 1) { - ref.current = 'bar'; - } else { - refValueFromSecondRender = ref.current; - } - }; - run(storyFn); - run(storyFn); - expect(refValueFromSecondRender).toBe('bar'); - }); - }); - describe('useState', () => { - it('sets initial state', () => { - let state; - const storyFn = () => { - [state] = useState('foo'); - }; - run(storyFn); - expect(state).toBe('foo'); - }); - it('calculates initial state', () => { - let state; - const storyFn = () => { - [state] = useState(() => 'foo'); - }; - run(storyFn); - expect(state).toBe('foo'); - }); - it('performs synchronous state updates', () => { - let state; - let setState; - const storyFn = jest.fn(() => { - [state, setState] = useState('foo'); - if (state === 'foo') { - setState('bar'); - } - }); - run(storyFn); - expect(storyFn).toHaveBeenCalledTimes(2); - expect(state).toBe('bar'); - }); - it('triggers only the last effect when updating state synchronously', () => { - const effects = [jest.fn(), jest.fn()]; - const storyFn = jest.fn(() => { - const [effectIndex, setEffectIndex] = useState(0); - useEffect(effects[effectIndex], [effectIndex]); - if (effectIndex === 0) { - setEffectIndex(1); - } - }); - run(storyFn); - expect(effects[0]).not.toHaveBeenCalled(); - expect(effects[1]).toHaveBeenCalledTimes(1); - }); - it('performs synchronous state updates with updater function', () => { - let state; - let setState; - const storyFn = jest.fn(() => { - [state, setState] = useState(0); - if (state < 3) { - setState((prevState) => prevState + 1); - } - }); - run(storyFn); - expect(storyFn).toHaveBeenCalledTimes(4); - expect(state).toBe(3); - }); - it('performs asynchronous state updates', () => { - let state; - let setState; - const storyFn = jest.fn(() => { - [state, setState] = useState('foo'); - }); - run(storyFn); - setState('bar'); - expect(mockChannel.emit).toHaveBeenCalledWith(FORCE_RE_RENDER); - run(storyFn); - expect(state).toBe('bar'); - }); - }); - describe('useReducer', () => { - it('sets initial state', () => { - let state; - const storyFn = () => { - [state] = useReducer(() => 'bar', 'foo'); - }; - run(storyFn); - expect(state).toBe('foo'); - }); - it('calculates initial state', () => { - let state; - const storyFn = () => { - [state] = useReducer( - () => 'bar', - 'foo', - (arg) => arg - ); - }; - run(storyFn); - expect(state).toBe('foo'); - }); - it('performs synchronous state updates', () => { - let state; - let dispatch; - const storyFn = jest.fn(() => { - [state, dispatch] = useReducer((prevState, action) => { - switch (action) { - case 'INCREMENT': - return prevState + 1; - default: - return prevState; - } - }, 0); - if (state < 3) { - dispatch('INCREMENT'); - } - }); - run(storyFn); - expect(storyFn).toHaveBeenCalledTimes(4); - expect(state).toBe(3); - }); - it('performs asynchronous state updates', () => { - let state; - let dispatch; - const storyFn = jest.fn(() => { - [state, dispatch] = useReducer((prevState, action) => { - switch (action) { - case 'INCREMENT': - return prevState + 1; - default: - return prevState; - } - }, 0); - }); - run(storyFn); - dispatch('INCREMENT'); - expect(mockChannel.emit).toHaveBeenCalledWith(FORCE_RE_RENDER); - run(storyFn); - expect(state).toBe(1); - }); - }); - describe('useArgs', () => { - it('will pull args from context', () => { - run( - () => {}, - [ - (storyFn) => { - expect(useArgs()[0]).toEqual({ a: 'b' }); - return storyFn(); - }, - ], - { args: { a: 'b' } } - ); - }); - it('will emit UPDATE_STORY_ARGS when called', () => { - run( - () => {}, - [ - (storyFn) => { - useArgs()[1]({ a: 'b' }); - expect(mockChannel.emit).toHaveBeenCalledWith(UPDATE_STORY_ARGS, { - storyId: '1', - updatedArgs: { a: 'b' }, - }); - return storyFn(); - }, - ], - { id: '1', args: {} } - ); - }); - it('will emit RESET_STORY_ARGS when called', () => { - run( - () => {}, - [ - (storyFn) => { - useArgs()[2](['a']); - expect(mockChannel.emit).toHaveBeenCalledWith(RESET_STORY_ARGS, { - storyId: '1', - argNames: ['a'], - }); - useArgs()[2](); - expect(mockChannel.emit).toHaveBeenCalledWith(RESET_STORY_ARGS, { - storyId: '1', - }); - return storyFn(); - }, - ], - { id: '1', args: {} } - ); - }); - }); - - describe('useGlobals', () => { - it('will pull globals from context', () => { - run( - () => {}, - [ - (storyFn) => { - expect(useGlobals()[0]).toEqual({ a: 'b' }); - return storyFn(); - }, - ], - { globals: { a: 'b' } } - ); - }); - it('will emit UPDATE_GLOBALS when called', () => { - run( - () => {}, - [ - (storyFn) => { - useGlobals()[1]({ a: 'b' }); - expect(mockChannel.emit).toHaveBeenCalledWith(UPDATE_GLOBALS, { globals: { a: 'b' } }); - return storyFn(); - }, - ], - { globals: {} } - ); - }); - }); -}); diff --git a/lib/client-api/src/hooks.ts b/lib/client-api/src/hooks.ts deleted file mode 100644 index 69ad8b95e691..000000000000 --- a/lib/client-api/src/hooks.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { SHARED_STATE_CHANGED, SHARED_STATE_SET } from '@storybook/core-events'; - -import { - addons, - HooksContext, - applyHooks, - useMemo, - useCallback, - useRef, - useState, - useReducer, - useEffect, - useChannel, - useStoryContext, - useParameter, - useArgs, - useGlobals, -} from '@storybook/addons'; - -export { - HooksContext, - applyHooks, - useMemo, - useCallback, - useRef, - useState, - useReducer, - useEffect, - useChannel, - useStoryContext, - useParameter, - useArgs, - useGlobals, -}; - -export function useSharedState(sharedId: string, defaultState?: S): [S, (s: S) => void] { - const channel = addons.getChannel(); - - const [lastValue] = - channel.last(`${SHARED_STATE_CHANGED}-manager-${sharedId}`) || - channel.last(`${SHARED_STATE_SET}-manager-${sharedId}`) || - []; - - const [state, setState] = useState(lastValue || defaultState); - - const allListeners = useMemo( - () => ({ - [`${SHARED_STATE_CHANGED}-manager-${sharedId}`]: (s: S) => setState(s), - [`${SHARED_STATE_SET}-manager-${sharedId}`]: (s: S) => setState(s), - }), - [sharedId] - ); - - const emit = useChannel(allListeners, [sharedId]); - - useEffect(() => { - // init - if (defaultState !== undefined && !lastValue) { - emit(`${SHARED_STATE_SET}-client-${sharedId}`, defaultState); - } - }, [sharedId]); - - return [ - state, - (s) => { - setState(s); - emit(`${SHARED_STATE_CHANGED}-client-${sharedId}`, s); - }, - ]; -} - -export function useAddonState(addonId: string, defaultState?: S): [S, (s: S) => void] { - return useSharedState(addonId, defaultState); -} diff --git a/lib/client-api/src/index.ts b/lib/client-api/src/index.ts index 7004397f9899..d70bbdf8c571 100644 --- a/lib/client-api/src/index.ts +++ b/lib/client-api/src/index.ts @@ -1,29 +1,18 @@ -import ClientApi, { +import { + ClientApi, addDecorator, addParameters, addLoader, addArgsEnhancer, addArgTypesEnhancer, setGlobalRender, -} from './client_api'; -import { defaultDecorateStory } from './decorators'; -import { combineParameters } from './parameters'; -import StoryStore from './story_store'; -import ConfigApi from './config_api'; -import pathToId from './pathToId'; -import { simulatePageLoad, simulateDOMContentLoaded } from './simulate-pageload'; - -import { getQueryParams, getQueryParam } from './queryparams'; - -import { filterArgTypes } from './filterArgTypes'; +} from './ClientApi'; -export * from './hooks'; export * from './types'; -export * from './parameters'; -// FIXME: for react-argtypes.stories; remove on refactor -export * from './inferControls'; -export type { PropDescriptor } from './filterArgTypes'; +// Typescript isn't happy that we are overwriting some types from store here +// @ts-ignore +export * from '@storybook/store'; export { addArgsEnhancer, @@ -33,14 +22,4 @@ export { addParameters, setGlobalRender, ClientApi, - combineParameters, - ConfigApi, - defaultDecorateStory, - filterArgTypes, - getQueryParam, - getQueryParams, - pathToId, - simulateDOMContentLoaded, - simulatePageLoad, - StoryStore, }; diff --git a/lib/client-api/src/inferArgTypes.ts b/lib/client-api/src/inferArgTypes.ts deleted file mode 100644 index ed2a3b1cacfd..000000000000 --- a/lib/client-api/src/inferArgTypes.ts +++ /dev/null @@ -1,51 +0,0 @@ -import mapValues from 'lodash/mapValues'; -import dedent from 'ts-dedent'; -import { logger } from '@storybook/client-logger'; -import { SBType, ArgTypesEnhancer } from './types'; -import { combineParameters } from './parameters'; - -const inferType = (value: any, name: string, visited: Set): SBType => { - const type = typeof value; - switch (type) { - case 'boolean': - case 'string': - case 'number': - case 'function': - return { name: type }; - case 'symbol': - return { name: 'other', value: 'symbol' }; - default: - break; - } - if (value) { - if (visited.has(value)) { - logger.warn(dedent` - We've detected a cycle in arg '${name}'. Args should be JSON-serializable. - - More info: https://storybook.js.org/docs/react/essentials/controls#fully-custom-args - `); - return { name: 'other', value: 'cyclic object' }; - } - visited.add(value); - if (Array.isArray(value)) { - const childType: SBType = - value.length > 0 - ? inferType(value[0], name, new Set(visited)) - : { name: 'other', value: 'unknown' }; - return { name: 'array', value: childType }; - } - const fieldTypes = mapValues(value, (field) => inferType(field, name, new Set(visited))); - return { name: 'object', value: fieldTypes }; - } - return { name: 'object', value: {} }; -}; - -export const inferArgTypes: ArgTypesEnhancer = (context) => { - const { id, parameters } = context; - const { argTypes: userArgTypes = {}, args = {} } = parameters; - if (!args) return userArgTypes; - const argTypes = mapValues(args, (arg, key) => ({ - type: inferType(arg, `${id}.${key}`, new Set()), - })); - return combineParameters(argTypes, userArgTypes); -}; diff --git a/lib/client-api/src/inferControls.test.ts b/lib/client-api/src/inferControls.test.ts deleted file mode 100644 index 1905431fc6a1..000000000000 --- a/lib/client-api/src/inferControls.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { StoryContext } from '@storybook/addons'; -import { logger } from '@storybook/client-logger'; -import { inferControls } from './inferControls'; - -const getStoryContext = (customParams = {}): StoryContext => ({ - id: '', - kind: '', - name: '', - args: {}, - globals: {}, - argTypes: {}, - parameters: { - argTypes: { - label: { control: 'text' }, - labelName: { control: 'text' }, - borderWidth: { control: { type: 'number', min: 0, max: 10 } }, - }, - __isArgsStory: true, - ...customParams, - }, -}); - -describe('inferControls', () => { - describe('with custom matchers', () => { - let warnSpy: jest.SpyInstance; - beforeEach(() => { - warnSpy = jest.spyOn(logger, 'warn'); - warnSpy.mockImplementation(() => {}); - }); - afterEach(() => { - warnSpy.mockRestore(); - }); - - it('should return color type when using color matcher', () => { - // passing a string, should return control type color - const inferredControls = inferControls( - getStoryContext({ - argTypes: { - background: { - type: { - name: 'string', - }, - name: 'background', - }, - }, - controls: { - matchers: { - color: /background/, - }, - }, - }) - ); - - expect(inferredControls.background.control.type).toEqual('color'); - }); - - it('should return inferred type when using color matcher but arg passed is not a string', () => { - const sampleTypes = [ - { - name: 'object', - value: { - rgb: { - name: 'number', - }, - }, - }, - { name: 'number' }, - { name: 'boolean' }, - ]; - - sampleTypes.forEach((type) => { - const inferredControls = inferControls( - getStoryContext({ - argTypes: { - background: { - // passing an object which is unsupported - // should ignore color control and infer the type instead - type, - name: 'background', - }, - }, - controls: { - matchers: { - color: /background/, - }, - }, - }) - ); - - expect(warnSpy).toHaveBeenCalled(); - expect(inferredControls.background.control.type).toEqual(type.name); - }); - }); - }); - - it('should return argTypes as is when no exclude or include is passed', () => { - const controls = inferControls(getStoryContext()); - expect(Object.keys(controls)).toEqual(['label', 'labelName', 'borderWidth']); - }); - - it('should return filtered argTypes when include is passed', () => { - const [includeString, includeArray, includeRegex] = [ - inferControls(getStoryContext({ controls: { include: 'label' } })), - inferControls(getStoryContext({ controls: { include: ['label'] } })), - inferControls(getStoryContext({ controls: { include: /label*/ } })), - ]; - - expect(Object.keys(includeString)).toEqual(['label', 'labelName']); - expect(Object.keys(includeArray)).toEqual(['label']); - expect(Object.keys(includeRegex)).toEqual(['label', 'labelName']); - }); - - it('should return filtered argTypes when exclude is passed', () => { - const [excludeString, excludeArray, excludeRegex] = [ - inferControls(getStoryContext({ controls: { exclude: 'label' } })), - inferControls(getStoryContext({ controls: { exclude: ['label'] } })), - inferControls(getStoryContext({ controls: { exclude: /label*/ } })), - ]; - - expect(Object.keys(excludeString)).toEqual(['borderWidth']); - expect(Object.keys(excludeArray)).toEqual(['labelName', 'borderWidth']); - expect(Object.keys(excludeRegex)).toEqual(['borderWidth']); - }); -}); diff --git a/lib/client-api/src/inferControls.ts b/lib/client-api/src/inferControls.ts deleted file mode 100644 index 85439860763d..000000000000 --- a/lib/client-api/src/inferControls.ts +++ /dev/null @@ -1,74 +0,0 @@ -import mapValues from 'lodash/mapValues'; -import { ArgType } from '@storybook/addons'; -import { logger } from '@storybook/client-logger'; - -import { SBEnumType, ArgTypesEnhancer } from './types'; -import { combineParameters } from './parameters'; -import { filterArgTypes } from './filterArgTypes'; - -type ControlsMatchers = { - date: RegExp; - color: RegExp; -}; - -const inferControl = (argType: ArgType, name: string, matchers: ControlsMatchers): any => { - const { type, options } = argType; - if (!type && !options) { - return undefined; - } - - // args that end with background or color e.g. iconColor - if (matchers.color && matchers.color.test(name)) { - const controlType = argType.type.name; - - if (controlType === 'string') { - return { control: { type: 'color' } }; - } - - logger.warn( - `Addon controls: Control of type color only supports string, received "${controlType}" instead` - ); - } - - // args that end with date e.g. purchaseDate - if (matchers.date && matchers.date.test(name)) { - return { control: { type: 'date' } }; - } - - switch (type.name) { - case 'array': - return { control: { type: 'object' } }; - case 'boolean': - return { control: { type: 'boolean' } }; - case 'string': - return { control: { type: 'text' } }; - case 'number': - return { control: { type: 'number' } }; - case 'enum': { - const { value } = type as SBEnumType; - return { control: { type: value?.length <= 5 ? 'radio' : 'select' }, options: value }; - } - case 'function': - case 'symbol': - case 'void': - return null; - default: - return { control: { type: options ? 'select' : 'object' } }; - } -}; - -export const inferControls: ArgTypesEnhancer = (context) => { - const { - __isArgsStory, - argTypes, - controls: { include = null, exclude = null, matchers = {} } = {}, - } = context.parameters; - if (!__isArgsStory) return argTypes; - - const filteredArgTypes = filterArgTypes(argTypes, include, exclude); - const withControls = mapValues(filteredArgTypes, (argType, name) => { - return argType?.type && inferControl(argType, name, matchers); - }); - - return combineParameters(withControls, filteredArgTypes); -}; diff --git a/lib/client-api/src/parameters.test.ts b/lib/client-api/src/parameters.test.ts deleted file mode 100644 index e0ed1bc03b70..000000000000 --- a/lib/client-api/src/parameters.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { combineParameters } from './parameters'; - -describe('client-api.parameters', () => { - it('merges different sets of parameters by key, preferencing last', () => { - expect(combineParameters({ a: 'b', c: 'd' }, { e: 'f', a: 'g' })).toEqual({ - a: 'g', - c: 'd', - e: 'f', - }); - }); - - it('merges sub-keys', () => { - expect(combineParameters({ ns: { a: 'b', c: 'd' } }, { ns: { e: 'f', a: 'g' } })).toEqual({ - ns: { - a: 'g', - c: 'd', - e: 'f', - }, - }); - }); - - it('treats array values as scalars', () => { - expect(combineParameters({ ns: { array: [1, 2, 3] } }, { ns: { array: [3, 4, 5] } })).toEqual({ - ns: { - array: [3, 4, 5], - }, - }); - }); - - it('ignores undefined additions', () => { - expect(combineParameters({ a: 1 }, { a: 2 }, { a: undefined })).toEqual({ a: 2 }); - }); -}); diff --git a/lib/client-api/src/parameters.ts b/lib/client-api/src/parameters.ts deleted file mode 100644 index 8f3b0010dde1..000000000000 --- a/lib/client-api/src/parameters.ts +++ /dev/null @@ -1,40 +0,0 @@ -// Utilities for handling parameters -import { Parameters } from '@storybook/addons'; -import isPlainObject from 'lodash/isPlainObject'; - -/** - * Safely combine parameters recursively. Only copy objects when needed. - * Algorithm = always overwrite the existing value UNLESS both values - * are plain objects. In this case flag the key as "special" and handle - * it with a heuristic. - */ -export const combineParameters = (...parameterSets: Parameters[]) => { - const mergeKeys: Record = {}; - const combined = parameterSets.reduce((acc, p) => { - Object.entries(p).forEach(([key, value]) => { - const existing = acc[key]; - if (Array.isArray(value) || typeof existing === 'undefined') { - acc[key] = value; - } else if (isPlainObject(value) && isPlainObject(existing)) { - // do nothing, we'll handle this later - mergeKeys[key] = true; - } else if (typeof value !== 'undefined') { - acc[key] = value; - } - }); - return acc; - }, {} as Parameters); - - Object.keys(mergeKeys).forEach((key) => { - const mergeValues = parameterSets - .map((p) => p[key]) - .filter((value) => typeof value !== 'undefined'); - if (mergeValues.every((value) => isPlainObject(value))) { - combined[key] = combineParameters(...mergeValues); - } else { - combined[key] = mergeValues[mergeValues.length - 1]; - } - }); - - return combined; -}; diff --git a/lib/client-api/src/pathToId.ts b/lib/client-api/src/pathToId.ts deleted file mode 100644 index fe534f5dfddf..000000000000 --- a/lib/client-api/src/pathToId.ts +++ /dev/null @@ -1,7 +0,0 @@ -export default function pathToId(path: string) { - const match = (path || '').match(/^\/story\/(.+)/); - if (!match) { - throw new Error(`Invalid path '${path}', must start with '/story/'`); - } - return match[1]; -} diff --git a/lib/client-api/src/pathToid.test.ts b/lib/client-api/src/pathToid.test.ts deleted file mode 100644 index 222c9923520d..000000000000 --- a/lib/client-api/src/pathToid.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import pathToId from './pathToId'; - -describe('pathToId', () => { - describe('pathToId', () => { - it('errors on invalid path', () => { - expect(() => pathToId('/something/random')).toThrow(/Invalid/); - }); - it('errors empty path', () => { - expect(() => pathToId(null)).toThrow(/Invalid/); - }); - it('succeeds on a valid path', () => { - expect(pathToId('/story/some--id')).toBe('some--id'); - }); - }); -}); diff --git a/lib/client-api/src/queryparams.ts b/lib/client-api/src/queryparams.ts deleted file mode 100644 index e9d120f6263f..000000000000 --- a/lib/client-api/src/queryparams.ts +++ /dev/null @@ -1,18 +0,0 @@ -import global from 'global'; -import { parse } from 'qs'; - -const { document } = global; - -export const getQueryParams = () => { - // document.location is not defined in react-native - if (document && document.location && document.location.search) { - return parse(document.location.search, { ignoreQueryPrefix: true }); - } - return {}; -}; - -export const getQueryParam = (key: string) => { - const params = getQueryParams(); - - return params[key]; -}; diff --git a/lib/client-api/src/simulate-pageload.ts b/lib/client-api/src/simulate-pageload.ts deleted file mode 100644 index ee4124b9bfdb..000000000000 --- a/lib/client-api/src/simulate-pageload.ts +++ /dev/null @@ -1,101 +0,0 @@ -import global from 'global'; - -const { document } = global; - -// https://html.spec.whatwg.org/multipage/scripting.html -const runScriptTypes = [ - 'application/javascript', - 'application/ecmascript', - 'application/x-ecmascript', - 'application/x-javascript', - 'text/ecmascript', - 'text/javascript', - 'text/javascript1.0', - 'text/javascript1.1', - 'text/javascript1.2', - 'text/javascript1.3', - 'text/javascript1.4', - 'text/javascript1.5', - 'text/jscript', - 'text/livescript', - 'text/x-ecmascript', - 'text/x-javascript', -]; - -const SCRIPT = 'script'; -const SCRIPTS_ROOT_ID = 'scripts-root'; - -// trigger DOMContentLoaded -export function simulateDOMContentLoaded() { - const DOMContentLoadedEvent = document.createEvent('Event'); - DOMContentLoadedEvent.initEvent('DOMContentLoaded', true, true); - document.dispatchEvent(DOMContentLoadedEvent); -} - -function insertScript($script: any, callback: any, $scriptRoot: any) { - const scriptEl = document.createElement('script'); - scriptEl.type = 'text/javascript'; - if ($script.src) { - scriptEl.onload = callback; - scriptEl.onerror = callback; - scriptEl.src = $script.src; - } else { - scriptEl.textContent = $script.innerText; - } - - // re-insert the script tag so it executes. - if ($scriptRoot) $scriptRoot.appendChild(scriptEl); - else document.head.appendChild(scriptEl); - - // clean-up - $script.parentNode.removeChild($script); - - // run the callback immediately for inline scripts - if (!$script.src) callback(); -} - -// runs an array of async functions in sequential order -/* eslint-disable no-param-reassign, no-plusplus */ -function insertScriptsSequentially(scriptsToExecute: any[], callback: any, index = 0) { - scriptsToExecute[index](() => { - index++; - if (index === scriptsToExecute.length) { - callback(); - } else { - insertScriptsSequentially(scriptsToExecute, callback, index); - } - }); -} - -export function simulatePageLoad($container: any) { - let $scriptsRoot = document.getElementById(SCRIPTS_ROOT_ID); - if (!$scriptsRoot) { - $scriptsRoot = document.createElement('div'); - $scriptsRoot.id = SCRIPTS_ROOT_ID; - document.body.appendChild($scriptsRoot); - } else { - $scriptsRoot.innerHTML = ''; - } - const $scripts = Array.from($container.querySelectorAll(SCRIPT)); - - if ($scripts.length) { - const scriptsToExecute: any[] = []; - $scripts.forEach(($script: any) => { - const typeAttr = $script.getAttribute('type'); - - // only run script tags without the type attribute - // or with a javascript mime attribute value from the list - if (!typeAttr || runScriptTypes.includes(typeAttr)) { - scriptsToExecute.push((callback: any) => insertScript($script, callback, $scriptsRoot)); - } - }); - - // insert the script tags sequentially - // to preserve execution order - if (scriptsToExecute.length) { - insertScriptsSequentially(scriptsToExecute, simulateDOMContentLoaded, undefined); - } - } else { - simulateDOMContentLoaded(); - } -} diff --git a/lib/client-api/src/storySort.ts b/lib/client-api/src/storySort.ts index eeaf781e995c..73f6eeb686cc 100644 --- a/lib/client-api/src/storySort.ts +++ b/lib/client-api/src/storySort.ts @@ -1,4 +1,4 @@ -import { StorySortObjectParameter, StorySortComparator } from '@storybook/addons'; +import { StorySortComparator, StorySortObjectParameter } from '@storybook/addons'; const STORY_KIND_PATH_SEPARATOR = /\s*\/\s*/; diff --git a/lib/client-api/src/story_store.test.ts b/lib/client-api/src/story_store.test.ts deleted file mode 100644 index 52fefd509ac6..000000000000 --- a/lib/client-api/src/story_store.test.ts +++ /dev/null @@ -1,1687 +0,0 @@ -/* eslint-disable no-underscore-dangle */ -import createChannel from '@storybook/channel-postmessage'; -import { toId } from '@storybook/csf'; -import { addons, mockChannel } from '@storybook/addons'; -import Events from '@storybook/core-events'; - -import StoryStore from './story_store'; -import { defaultDecorateStory } from './decorators'; - -jest.mock('@storybook/node-logger', () => ({ - logger: { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - }, -})); - -let channel; -beforeEach(() => { - channel = createChannel({ page: 'preview' }); -}); - -function addReverseSorting(store) { - store.addGlobalMetadata({ - decorators: [], - parameters: { - options: { - // Test function does reverse alphabetical ordering. - storySort: (a: any, b: any): number => - a[1].kind === b[1].kind - ? 0 - : -1 * a[1].id.localeCompare(b[1].id, undefined, { numeric: true }), - }, - }, - }); -} - -// make a story and add it to the store -const addStoryToStore = (store, kind, name, storyFn, parameters = {}) => - store.addStory( - { - kind, - name, - storyFn, - parameters, - id: toId(kind, name), - }, - { - // FIXME: need applyHooks, but this breaks the current tests - applyDecorators: defaultDecorateStory, - } - ); - -describe('preview.story_store', () => { - describe('extract', () => { - it('produces stories objects with inherited (denormalized) metadata', () => { - const store = new StoryStore({ channel }); - - store.addGlobalMetadata({ parameters: { global: 'global' }, decorators: [] }); - - store.addKindMetadata('a', { parameters: { kind: 'kind' }, decorators: [] }); - - addStoryToStore(store, 'a', '1', () => 0, { story: 'story' }); - addStoryToStore(store, 'a', '2', () => 0); - addStoryToStore(store, 'b', '1', () => 0); - - const extracted = store.extract(); - - // We need exact key ordering, even if in theory JS doesn't guarantee it - expect(Object.keys(extracted)).toEqual(['a--1', 'a--2', 'b--1']); - - // content of item should be correct - expect(extracted['a--1']).toMatchObject({ - id: 'a--1', - kind: 'a', - name: '1', - parameters: { global: 'global', kind: 'kind', story: 'story' }, - }); - }); - }); - - describe('getDataForManager', () => { - it('produces stories objects with normalized metadata', () => { - const store = new StoryStore({ channel }); - - store.addGlobalMetadata({ parameters: { global: 'global' }, decorators: [] }); - - store.addKindMetadata('a', { parameters: { kind: 'kind' }, decorators: [] }); - - addStoryToStore(store, 'a', '1', () => 0, { story: 'story' }); - - const { v, globalParameters, kindParameters, stories } = store.getDataForManager(); - - expect(v).toBe(2); - expect(globalParameters).toEqual({ global: 'global' }); - expect(Object.keys(kindParameters)).toEqual(['a']); - expect(kindParameters.a).toEqual({ kind: 'kind' }); - - expect(Object.keys(stories)).toEqual(['a--1']); - expect(stories['a--1']).toMatchObject({ - id: 'a--1', - kind: 'a', - name: '1', - parameters: { story: 'story' }, - }); - }); - }); - - describe('getStoriesJsonData', () => { - it('produces stories objects with normalized metadata', () => { - const store = new StoryStore({ channel }); - - store.addGlobalMetadata({ parameters: { global: 'global' }, decorators: [] }); - - store.addKindMetadata('a', { parameters: { kind: 'kind' }, decorators: [] }); - - addStoryToStore(store, 'a', '1', () => 0, { story: 'story' }); - - const { v, globalParameters, kindParameters, stories } = store.getStoriesJsonData(); - - expect(v).toBe(2); - expect(globalParameters).toEqual({}); - expect(kindParameters).toEqual({ a: {} }); - expect(kindParameters.a).toEqual({}); - - expect(Object.keys(stories)).toEqual(['a--1']); - expect(stories['a--1']).toMatchObject({ - id: 'a--1', - kind: 'a', - name: '1', - parameters: { __isArgsStory: false }, - }); - }); - }); - - describe('getRawStory', () => { - it('produces a story with inherited decorators applied', () => { - const store = new StoryStore({ channel }); - - const globalDecorator = jest.fn().mockImplementation((s) => s()); - store.addGlobalMetadata({ parameters: {}, decorators: [globalDecorator] }); - - const kindDecorator = jest.fn().mockImplementation((s) => s()); - store.addKindMetadata('a', { parameters: {}, decorators: [kindDecorator] }); - - const story = jest.fn(); - addStoryToStore(store, 'a', '1', story); - - const context = store.getRawStory('a', '1'); - context.getDecorated()(context); - - expect(globalDecorator).toHaveBeenCalled(); - expect(kindDecorator).toHaveBeenCalled(); - expect(story).toHaveBeenCalled(); - }); - }); - - describe('args', () => { - it('composes component-level and story-level args, favoring story-level', () => { - const store = new StoryStore({ channel }); - store.addKindMetadata('a', { - parameters: { args: { arg1: 1, arg2: 2, arg3: 3, arg4: { complex: 'object' } } }, - }); - addStoryToStore(store, 'a', '1', () => 0, { - args: { - arg1: 4, - arg2: undefined, - arg4: { other: 'object ' }, - }, - }); - expect(store.getRawStory('a', '1').args).toEqual({ - arg1: 4, - arg2: undefined, - arg3: 3, - arg4: { other: 'object ' }, - }); - }); - - it('is initialized to the value stored in parameters.args[name] || parameters.argType[name].defaultValue', () => { - const store = new StoryStore({ channel }); - addStoryToStore(store, 'a', '1', () => 0, { - argTypes: { - arg1: { defaultValue: 'arg1' }, - arg2: { defaultValue: 2 }, - arg3: { defaultValue: { complex: { object: ['type'] } } }, - arg4: {}, - arg5: {}, - arg6: { defaultValue: 0 }, // See https://github.com/storybookjs/storybook/issues/12767 - }, - args: { - arg2: 3, - arg4: 'foo', - arg7: false, - }, - }); - expect(store.getRawStory('a', '1').args).toEqual({ - arg1: 'arg1', - arg2: 3, - arg3: { complex: { object: ['type'] } }, - arg4: 'foo', - arg6: 0, - arg7: false, - }); - }); - - it('automatically infers argTypes based on args', () => { - const store = new StoryStore({ channel }); - store.startConfiguring(); - addStoryToStore(store, 'a', '1', () => 0, { - args: { - arg1: 3, - arg2: 'foo', - arg3: false, - }, - }); - expect(store.getRawStory('a', '1').argTypes).toEqual({ - arg1: { name: 'arg1', type: { name: 'number' } }, - arg2: { name: 'arg2', type: { name: 'string' } }, - arg3: { name: 'arg3', type: { name: 'boolean' } }, - }); - }); - - it('updateStoryArgs changes the args of a story, per-key', () => { - const store = new StoryStore({ channel }); - addStoryToStore(store, 'a', '1', () => 0); - expect(store.getRawStory('a', '1').args).toEqual({}); - - store.updateStoryArgs('a--1', { foo: 'bar' }); - expect(store.getRawStory('a', '1').args).toEqual({ foo: 'bar' }); - - store.updateStoryArgs('a--1', { baz: 'bing' }); - expect(store.getRawStory('a', '1').args).toEqual({ foo: 'bar', baz: 'bing' }); - }); - - it('is passed to the story in the context', () => { - const storyFn = jest.fn(); - const store = new StoryStore({ channel }); - addStoryToStore(store, 'a', '1', storyFn, { passArgsFirst: false }); - store.updateStoryArgs('a--1', { foo: 'bar' }); - store.getRawStory('a', '1').storyFn(); - - expect(storyFn).toHaveBeenCalledWith( - expect.objectContaining({ - args: { foo: 'bar' }, - }) - ); - }); - - it('mapping changes arg values that are passed to the story in the context', () => { - const storyFn = jest.fn(); - const store = new StoryStore({ channel }); - addStoryToStore(store, 'a', '1', storyFn, { - argTypes: { - one: { mapping: { 1: 'mapped' } }, - two: { mapping: { 1: 'no match' } }, - }, - args: { one: 1, two: 2, three: 3 }, - }); - store.getRawStory('a', '1').storyFn(); - - expect(storyFn).toHaveBeenCalledWith( - { one: 'mapped', two: 2, three: 3 }, - expect.objectContaining({ args: { one: 'mapped', two: 2, three: 3 } }) - ); - }); - - it('updateStoryArgs emits STORY_ARGS_UPDATED', () => { - const onArgsChangedChannel = jest.fn(); - const testChannel = mockChannel(); - testChannel.on(Events.STORY_ARGS_UPDATED, onArgsChangedChannel); - - const store = new StoryStore({ channel: testChannel }); - addStoryToStore(store, 'a', '1', () => 0); - - store.updateStoryArgs('a--1', { foo: 'bar' }); - expect(onArgsChangedChannel).toHaveBeenCalledWith({ storyId: 'a--1', args: { foo: 'bar' } }); - - store.updateStoryArgs('a--1', { baz: 'bing' }); - expect(onArgsChangedChannel).toHaveBeenCalledWith({ - storyId: 'a--1', - args: { foo: 'bar', baz: 'bing' }, - }); - }); - - it('should update if the UPDATE_STORY_ARGS event is received', () => { - const testChannel = mockChannel(); - const store = new StoryStore({ channel: testChannel }); - addStoryToStore(store, 'a', '1', () => 0); - - testChannel.emit(Events.UPDATE_STORY_ARGS, { storyId: 'a--1', updatedArgs: { foo: 'bar' } }); - - expect(store.getRawStory('a', '1').args).toEqual({ foo: 'bar' }); - }); - - it('passes args as the first argument to the story if `parameters.passArgsFirst` is true', () => { - const store = new StoryStore({ channel }); - - store.addKindMetadata('a', { - parameters: { - argTypes: { - a: { defaultValue: 1 }, - }, - }, - decorators: [], - }); - - const storyOne = jest.fn(); - addStoryToStore(store, 'a', '1', storyOne, { passArgsFirst: false }); - - store.getRawStory('a', '1').storyFn(); - expect(storyOne).toHaveBeenCalledWith( - expect.objectContaining({ - args: { a: 1 }, - parameters: expect.objectContaining({}), - }) - ); - - const storyTwo = jest.fn(); - addStoryToStore(store, 'a', '2', storyTwo, { passArgsFirst: true }); - store.getRawStory('a', '2').storyFn(); - expect(storyTwo).toHaveBeenCalledWith( - { a: 1 }, - expect.objectContaining({ - args: { a: 1 }, - parameters: expect.objectContaining({}), - }) - ); - }); - - it('resetStoryArgs resets a single arg', () => { - const store = new StoryStore({ channel }); - addStoryToStore(store, 'a', '1', () => 0); - expect(store.getRawStory('a', '1').args).toEqual({}); - - store.updateStoryArgs('a--1', { foo: 'bar', bar: 'baz' }); - expect(store.getRawStory('a', '1').args).toEqual({ foo: 'bar', bar: 'baz' }); - - store.resetStoryArgs('a--1', ['foo']); - expect(store.getRawStory('a', '1').args).toEqual({ bar: 'baz' }); - }); - - it('resetStoryArgs resets all args', () => { - const store = new StoryStore({ channel }); - addStoryToStore(store, 'a', '1', () => 0); - expect(store.getRawStory('a', '1').args).toEqual({}); - - store.updateStoryArgs('a--1', { foo: 'bar', bar: 'baz' }); - expect(store.getRawStory('a', '1').args).toEqual({ foo: 'bar', bar: 'baz' }); - - store.resetStoryArgs('a--1'); - expect(store.getRawStory('a', '1').args).toEqual({}); - }); - - it('resetStoryArgs emits STORY_ARGS_UPDATED', () => { - const onArgsChangedChannel = jest.fn(); - const testChannel = mockChannel(); - testChannel.on(Events.STORY_ARGS_UPDATED, onArgsChangedChannel); - - const store = new StoryStore({ channel: testChannel }); - addStoryToStore(store, 'a', '1', () => 0); - - store.updateStoryArgs('a--1', { foo: 'bar' }); - expect(onArgsChangedChannel).toHaveBeenCalledWith({ storyId: 'a--1', args: { foo: 'bar' } }); - - store.resetStoryArgs('a--1'); - expect(onArgsChangedChannel).toHaveBeenCalledWith({ - storyId: 'a--1', - args: {}, - }); - }); - - it('should reset if the RESET_STORY_ARGS event is received', () => { - const testChannel = mockChannel(); - const store = new StoryStore({ channel: testChannel }); - addStoryToStore(store, 'a', '1', () => 0); - - store.updateStoryArgs('a--1', { foo: 'bar', bar: 'baz' }); - - testChannel.emit(Events.RESET_STORY_ARGS, { storyId: 'a--1', argNames: ['foo'] }); - expect(store.getRawStory('a', '1').args).toEqual({ bar: 'baz' }); - - testChannel.emit(Events.RESET_STORY_ARGS, { storyId: 'a--1' }); - expect(store.getRawStory('a', '1').args).toEqual({}); - }); - }); - - describe('globals', () => { - it('is initialized to the value stored in parameters.globals on the first story', () => { - const store = new StoryStore({ channel }); - store.addGlobalMetadata({ - decorators: [], - parameters: { - globals: { - arg1: 'arg1', - arg2: 2, - arg3: { complex: { object: ['type'] } }, - }, - }, - }); - addStoryToStore(store, 'a', '1', () => 0); - store.finishConfiguring(); - expect(store.getRawStory('a', '1').globals).toEqual({ - arg1: 'arg1', - arg2: 2, - arg3: { complex: { object: ['type'] } }, - }); - }); - - it('is initialized to the default values stored in parameters.globalsTypes on the first story', () => { - const store = new StoryStore({ channel }); - store.addGlobalMetadata({ - decorators: [], - parameters: { - globals: { - arg1: 'arg1', - arg2: 2, - }, - globalTypes: { - arg2: { defaultValue: 'arg2' }, - arg3: { defaultValue: { complex: { object: ['type'] } } }, - }, - }, - }); - addStoryToStore(store, 'a', '1', () => 0); - store.finishConfiguring(); - expect(store.getRawStory('a', '1').globals).toEqual({ - // NOTE: we keep arg1, even though it doesn't have a globalArgType - arg1: 'arg1', - arg2: 2, - arg3: { complex: { object: ['type'] } }, - }); - }); - - it('on HMR it sensibly re-initializes with memory', () => { - const store = new StoryStore({ channel }); - addons.setChannel(channel); - store.startConfiguring(); - store.addGlobalMetadata({ - decorators: [], - parameters: { - globals: { - arg1: 'arg1', - arg2: 2, - arg4: 4, - }, - globalTypes: { - arg2: { defaultValue: 'arg2' }, - arg3: { defaultValue: { complex: { object: ['type'] } } }, - arg4: {}, - }, - }, - }); - addStoryToStore(store, 'a', '1', () => 0); - store.finishConfiguring(); - - expect(store.getRawStory('a', '1').globals).toEqual({ - // We keep arg1, even though it doesn't have a globalArgType, as it is set in globals - arg1: 'arg1', - // We use the value of arg2 that was set in globals - arg2: 2, - arg3: { complex: { object: ['type'] } }, - arg4: 4, - }); - expect(store._argTypesEnhancers.length).toBe(3); - - // HMR - store.startConfiguring(); - store.addGlobalMetadata({ - decorators: [], - parameters: { - globals: { - arg2: 3, - }, - globalTypes: { - arg2: { defaultValue: 'arg2' }, - arg3: { defaultValue: { complex: { object: ['changed'] } } }, - // XXX: note this currently wouldn't fail because parameters.globals.arg4 isn't cleared - // due to #10005, see below - arg4: {}, // has no default value set but we need to make sure we don't lose it - arg5: { defaultValue: 'new' }, - }, - }, - }); - store.finishConfiguring(); - expect(store._argTypesEnhancers.length).toBe(3); - - expect(store.getRawStory('a', '1').globals).toEqual({ - // You cannot remove a global arg in HMR currently, because you cannot remove the - // parameter (see https://github.com/storybookjs/storybook/issues/10005) - arg1: 'arg1', - // We should keep the previous values because we cannot tell if the user changed it or not in the UI - // and we don't want to revert to the defaults every HMR - arg2: 2, - arg3: { complex: { object: ['type'] } }, - arg4: 4, - // We take the new value here as it wasn't defined before - arg5: 'new', - }); - }); - - it('sensibly re-initializes with memory based on session storage', () => { - const store = new StoryStore({ channel }); - store.setSelectionSpecifier({ - storySpecifier: '*', - viewMode: 'story', - globals: { - arg1: 'arg1', - arg2: 2, - arg3: { complex: { object: ['type'] } }, - arg4: 4, - }, - }); - addons.setChannel(channel); - - addStoryToStore(store, 'a', '1', () => 0); - store.addGlobalMetadata({ - decorators: [], - parameters: { - globals: { - arg2: 3, - }, - globalTypes: { - arg2: { defaultValue: 'arg2' }, - arg3: { defaultValue: { complex: { object: ['changed'] } } }, - arg4: {}, // has no default value set but we need to make sure we don't lose it - arg5: { defaultValue: 'new' }, - }, - }, - }); - store.finishConfiguring(); - - expect(store.getRawStory('a', '1').globals).toEqual({ - // We should keep the previous values because we cannot tell if the user changed it or not in the UI - // and we don't want to revert to the defaults every HMR - // arg1 is missing because it's not one of allowedGlobals - arg2: 2, - arg3: { complex: { object: ['type'] } }, - arg4: 4, - // We take the new value here as it wasn't defined before - arg5: 'new', - }); - }); - - it('updateGlobals changes the global args', () => { - const store = new StoryStore({ channel }); - addStoryToStore(store, 'a', '1', () => 0); - expect(store.getRawStory('a', '1').globals).toEqual({}); - - store.updateGlobals({ foo: 'bar' }); - expect(store.getRawStory('a', '1').globals).toEqual({ foo: 'bar' }); - - store.updateGlobals({ baz: 'bing' }); - expect(store.getRawStory('a', '1').globals).toEqual({ foo: 'bar', baz: 'bing' }); - }); - - it('is passed to the story in the context', () => { - const storyFn = jest.fn(); - const store = new StoryStore({ channel }); - - store.updateGlobals({ foo: 'bar' }); - addStoryToStore(store, 'a', '1', storyFn, { passArgsFirst: false }); - store.getRawStory('a', '1').storyFn(); - - expect(storyFn).toHaveBeenCalledWith( - expect.objectContaining({ - globals: { foo: 'bar' }, - }) - ); - - store.updateGlobals({ baz: 'bing' }); - store.getRawStory('a', '1').storyFn(); - - expect(storyFn).toHaveBeenCalledWith( - expect.objectContaining({ - globals: { foo: 'bar', baz: 'bing' }, - }) - ); - }); - - it('updateGlobals emits GLOBALS_UPDATED', () => { - const onGlobalsChangedChannel = jest.fn(); - const testChannel = mockChannel(); - testChannel.on(Events.GLOBALS_UPDATED, onGlobalsChangedChannel); - - const store = new StoryStore({ channel: testChannel }); - addStoryToStore(store, 'a', '1', () => 0); - store.addGlobalMetadata({ - parameters: { - globalTypes: { - foo: { defaultValue: 'Foo' }, - bar: { defaultValue: 'Bar' }, - }, - globals: { baz: 'Baz', qux: 'Qux' }, - }, - }); - store.finishConfiguring(); - - store.updateGlobals({ foo: 'FUD' }); - expect(onGlobalsChangedChannel).toHaveBeenCalledWith({ - globals: { foo: 'FUD', bar: 'Bar', baz: 'Baz', qux: 'Qux' }, - initialGlobals: { foo: 'Foo', bar: 'Bar', baz: 'Baz', qux: 'Qux' }, - }); - - store.updateGlobals({ baz: 'BING' }); - expect(onGlobalsChangedChannel).toHaveBeenCalledWith({ - globals: { foo: 'FUD', bar: 'Bar', baz: 'BING', qux: 'Qux' }, - initialGlobals: { foo: 'Foo', bar: 'Bar', baz: 'Baz', qux: 'Qux' }, - }); - }); - - it('should update if the UPDATE_GLOBALS event is received', () => { - const testChannel = mockChannel(); - const store = new StoryStore({ channel: testChannel }); - addStoryToStore(store, 'a', '1', () => 0); - - testChannel.emit(Events.UPDATE_GLOBALS, { globals: { foo: 'bar' } }); - - expect(store.getRawStory('a', '1').globals).toEqual({ foo: 'bar' }); - }); - - it('DOES NOT pass globals as the first argument to the story if `parameters.passArgsFirst` is true', () => { - const store = new StoryStore({ channel }); - - const storyOne = jest.fn(); - addStoryToStore(store, 'a', '1', storyOne, { passArgsFirst: false }); - - store.updateGlobals({ foo: 'bar' }); - - store.getRawStory('a', '1').storyFn(); - expect(storyOne).toHaveBeenCalledWith( - expect.objectContaining({ - globals: { foo: 'bar' }, - }) - ); - - const storyTwo = jest.fn(); - addStoryToStore(store, 'a', '2', storyTwo, { passArgsFirst: true }); - store.getRawStory('a', '2').storyFn(); - expect(storyTwo).toHaveBeenCalledWith( - {}, - expect.objectContaining({ - globals: { foo: 'bar' }, - }) - ); - }); - }); - - describe('argsEnhancer', () => { - it('allows you to add args', () => { - const store = new StoryStore({ channel }); - - const enhancer = jest.fn((context) => ({ c: 'd' })); - store.addArgsEnhancer(enhancer); - - addStoryToStore(store, 'a', '1', (args: any) => 0, { args: { a: 'b' } }); - - expect(enhancer).toHaveBeenCalledWith(expect.objectContaining({ args: { a: 'b' } })); - expect(store.getRawStory('a', '1').args).toEqual({ a: 'b', c: 'd' }); - }); - - it('does not pass result of earlier enhancers into subsequent ones, but composes their output', () => { - const store = new StoryStore({ channel }); - - const enhancerOne = jest.fn((context) => ({ c: 'd' })); - store.addArgsEnhancer(enhancerOne); - - const enhancerTwo = jest.fn((context) => ({ e: 'f' })); - store.addArgsEnhancer(enhancerTwo); - - addStoryToStore(store, 'a', '1', (args: any) => 0, { args: { a: 'b' } }); - - expect(enhancerOne).toHaveBeenCalledWith(expect.objectContaining({ args: { a: 'b' } })); - expect(enhancerTwo).toHaveBeenCalledWith(expect.objectContaining({ args: { a: 'b' } })); - expect(store.getRawStory('a', '1').args).toEqual({ a: 'b', c: 'd', e: 'f' }); - }); - }); - - describe('argTypesEnhancer', () => { - it('records when the given story processes args', () => { - const store = new StoryStore({ channel }); - - const enhancer = jest.fn((context) => ({ ...context.parameters.argTypes, c: 'd' })); - store.addArgTypesEnhancer(enhancer); - - addStoryToStore(store, 'a', '1', (args: any) => 0, { argTypes: { a: 'b' } }); - - expect(enhancer).toHaveBeenCalledWith( - expect.objectContaining({ parameters: { __isArgsStory: true, argTypes: { a: 'b' } } }) - ); - expect(store.getRawStory('a', '1').parameters.argTypes).toEqual({ a: 'b', c: 'd' }); - }); - - it('allows you to alter argTypes when stories are added', () => { - const store = new StoryStore({ channel }); - - const enhancer = jest.fn((context) => ({ ...context.parameters.argTypes, c: 'd' })); - store.addArgTypesEnhancer(enhancer); - - addStoryToStore(store, 'a', '1', () => 0, { argTypes: { a: 'b' } }); - - expect(enhancer).toHaveBeenCalledWith( - expect.objectContaining({ parameters: { __isArgsStory: false, argTypes: { a: 'b' } } }) - ); - expect(store.getRawStory('a', '1').parameters.argTypes).toEqual({ a: 'b', c: 'd' }); - }); - - it('recursively passes argTypes to successive enhancers', () => { - const store = new StoryStore({ channel }); - - const firstEnhancer = jest.fn((context) => ({ ...context.parameters.argTypes, c: 'd' })); - store.addArgTypesEnhancer(firstEnhancer); - const secondEnhancer = jest.fn((context) => ({ ...context.parameters.argTypes, e: 'f' })); - store.addArgTypesEnhancer(secondEnhancer); - - addStoryToStore(store, 'a', '1', () => 0, { argTypes: { a: 'b' } }); - - expect(firstEnhancer).toHaveBeenCalledWith( - expect.objectContaining({ parameters: { __isArgsStory: false, argTypes: { a: 'b' } } }) - ); - expect(secondEnhancer).toHaveBeenCalledWith( - expect.objectContaining({ - parameters: { __isArgsStory: false, argTypes: { a: 'b', c: 'd' } }, - }) - ); - expect(store.getRawStory('a', '1').parameters.argTypes).toEqual({ a: 'b', c: 'd', e: 'f' }); - }); - - it('does not merge argType enhancer results', () => { - const store = new StoryStore({ channel }); - - const enhancer = jest.fn().mockReturnValue({ c: 'd' }); - store.addArgTypesEnhancer(enhancer); - - addStoryToStore(store, 'a', '1', () => 0, { argTypes: { a: 'b' } }); - - expect(enhancer).toHaveBeenCalledWith( - expect.objectContaining({ parameters: { __isArgsStory: false, argTypes: { a: 'b' } } }) - ); - expect(store.getRawStory('a', '1').parameters.argTypes).toEqual({ c: 'd' }); - }); - - it('allows you to alter argTypes when stories are re-added', () => { - const store = new StoryStore({ channel }); - addons.setChannel(channel); - - const enhancer = jest.fn((context) => ({ ...context.parameters.argTypes, c: 'd' })); - store.addArgTypesEnhancer(enhancer); - - addStoryToStore(store, 'a', '1', () => 0, { argTypes: { a: 'b' } }); - - enhancer.mockClear(); - store.removeStoryKind('a'); - - addStoryToStore(store, 'a', '1', () => 0, { argTypes: { e: 'f' } }); - expect(enhancer).toHaveBeenCalledWith( - expect.objectContaining({ parameters: { __isArgsStory: false, argTypes: { e: 'f' } } }) - ); - expect(store.getRawStory('a', '1').parameters.argTypes).toEqual({ e: 'f', c: 'd' }); - }); - - it('automatically infers argTypes from args', () => { - const store = new StoryStore({ channel }); - store.startConfiguring(); - addStoryToStore(store, 'a', '1', () => 0, { args: { a: null, b: 'hello', c: 9 } }); - expect(store.getRawStory('a', '1').parameters.argTypes).toMatchInlineSnapshot(` - Object { - "a": Object { - "name": "a", - "type": Object { - "name": "object", - "value": Object {}, - }, - }, - "b": Object { - "name": "b", - "type": Object { - "name": "string", - }, - }, - "c": Object { - "name": "c", - "type": Object { - "name": "number", - }, - }, - } - `); - }); - - it('adds user and default enhancers', () => { - const store = new StoryStore({ channel }); - expect(store._argTypesEnhancers.length).toBe(1); - - const enhancer = () => ({}); - store.addArgTypesEnhancer(enhancer); - expect(store._argTypesEnhancers.length).toBe(2); - - store.startConfiguring(); - expect(store._argTypesEnhancers.length).toBe(4); - - addStoryToStore(store, 'a', '1', () => 0); - addStoryToStore(store, 'a', '2', () => 0); - store.finishConfiguring(); - expect(store._argTypesEnhancers.length).toBe(4); - }); - }); - - describe('selection specifiers', () => { - describe('if you use *', () => { - it('selects the first story in the store', () => { - const store = new StoryStore({ channel }); - store.setSelectionSpecifier({ storySpecifier: '*', viewMode: 'story' }); - - addStoryToStore(store, 'a', '1', () => 0); - addStoryToStore(store, 'a', '2', () => 0); - addStoryToStore(store, 'b', '1', () => 0); - store.finishConfiguring(); - - expect(store.getSelection()).toEqual({ storyId: 'a--1', viewMode: 'story' }); - }); - - it('takes into account sorting', () => { - const store = new StoryStore({ channel }); - store.setSelectionSpecifier({ storySpecifier: '*', viewMode: 'story' }); - addReverseSorting(store); - - addStoryToStore(store, 'a', '1', () => 0); - addStoryToStore(store, 'a', '2', () => 0); - addStoryToStore(store, 'b', '1', () => 0); - store.finishConfiguring(); - - expect(store.getSelection()).toEqual({ storyId: 'b--1', viewMode: 'story' }); - }); - - it('selects nothing if there are no stories', () => { - const store = new StoryStore({ channel }); - store.setSelectionSpecifier({ storySpecifier: '*', viewMode: 'story' }); - store.finishConfiguring(); - - expect(store.getSelection()).toEqual(undefined); - }); - }); - - describe('if you use a component or group id', () => { - it('selects the first story for the component', () => { - const store = new StoryStore({ channel }); - store.setSelectionSpecifier({ storySpecifier: 'b', viewMode: 'story' }); - - addStoryToStore(store, 'a', '1', () => 0); - addStoryToStore(store, 'a', '2', () => 0); - addStoryToStore(store, 'b', '1', () => 0); - store.finishConfiguring(); - - expect(store.getSelection()).toEqual({ storyId: 'b--1', viewMode: 'story' }); - }); - - it('selects the first story for the group', () => { - const store = new StoryStore({ channel }); - store.setSelectionSpecifier({ storySpecifier: 'g2', viewMode: 'story' }); - - addStoryToStore(store, 'g1/a', '1', () => 0); - addStoryToStore(store, 'g2/a', '1', () => 0); - addStoryToStore(store, 'g2/b', '1', () => 0); - store.finishConfiguring(); - - expect(store.getSelection()).toEqual({ storyId: 'g2-a--1', viewMode: 'story' }); - }); - - // Making sure the fix #11571 doesn't break this - it('selects the first story if there are two stories in the group of different lengths', () => { - const store = new StoryStore({ channel }); - store.setSelectionSpecifier({ storySpecifier: 'a', viewMode: 'story' }); - addStoryToStore(store, 'a', 'long-long-long', () => 0); - addStoryToStore(store, 'a', 'short', () => 0); - store.finishConfiguring(); - - expect(store.getSelection()).toEqual({ storyId: 'a--long-long-long', viewMode: 'story' }); - }); - - it('selects nothing if the component or group does not exist', () => { - const store = new StoryStore({ channel }); - store.setSelectionSpecifier({ storySpecifier: 'c', viewMode: 'story' }); - - addStoryToStore(store, 'a', '1', () => 0); - addStoryToStore(store, 'a', '2', () => 0); - addStoryToStore(store, 'b', '1', () => 0); - store.finishConfiguring(); - - expect(store.getSelection()).toEqual(undefined); - }); - }); - - describe('if you use a storyId', () => { - it('selects a specific story', () => { - const store = new StoryStore({ channel }); - store.setSelectionSpecifier({ storySpecifier: 'a--2', viewMode: 'story' }); - addStoryToStore(store, 'a', '1', () => 0); - addStoryToStore(store, 'a', '2', () => 0); - addStoryToStore(store, 'b', '1', () => 0); - store.finishConfiguring(); - - expect(store.getSelection()).toEqual({ storyId: 'a--2', viewMode: 'story' }); - }); - - it('selects nothing if you the story does not exist', () => { - const store = new StoryStore({ channel }); - store.setSelectionSpecifier({ storySpecifier: 'a--3', viewMode: 'story' }); - addStoryToStore(store, 'a', '1', () => 0); - addStoryToStore(store, 'a', '2', () => 0); - addStoryToStore(store, 'b', '1', () => 0); - store.finishConfiguring(); - - expect(store.getSelection()).toEqual(undefined); - }); - - // See #11571 - it('does NOT select an earlier story that this story id is a prefix of', () => { - const store = new StoryStore({ channel }); - store.setSelectionSpecifier({ storySpecifier: 'a--3', viewMode: 'story' }); - addStoryToStore(store, 'a', '31', () => 0); - addStoryToStore(store, 'a', '3', () => 0); - store.finishConfiguring(); - - expect(store.getSelection()).toEqual({ storyId: 'a--3', viewMode: 'story' }); - }); - }); - - describe('with args', () => { - it('overrides args on the story', () => { - const store = new StoryStore({ channel }); - const argTypes = { - a: { type: { name: 'number' }, defaultValue: 1 }, - b: { type: { name: 'number' }, defaultValue: 2 }, - c: { type: { name: 'boolean' } }, - }; - store.setSelectionSpecifier({ - storySpecifier: 'a--1', - viewMode: 'story', - args: { - a: 2, - b: 'two', - c: 'true', - }, - }); - addStoryToStore(store, 'a', '1', () => 0, { argTypes }); - store.finishConfiguring(); - - expect(store._stories['a--1'].args).toEqual({ a: 2, b: NaN, c: true }); - }); - }); - - describe('if you use no specifier', () => { - it('selects nothing', () => { - const store = new StoryStore({ channel }); - addStoryToStore(store, 'a', '1', () => 0); - addStoryToStore(store, 'a', '2', () => 0); - addStoryToStore(store, 'b', '1', () => 0); - store.finishConfiguring(); - - expect(store.getSelection()).toEqual(undefined); - }); - }); - - describe('HMR behaviour', () => { - it('retains successful selection', () => { - const store = new StoryStore({ channel }); - store.setSelectionSpecifier({ storySpecifier: 'a--1', viewMode: 'story' }); - addStoryToStore(store, 'a', '1', () => 0); - store.finishConfiguring(); - - expect(store.getSelection()).toEqual({ storyId: 'a--1', viewMode: 'story' }); - - store.startConfiguring(); - store.removeStoryKind('a'); - addStoryToStore(store, 'a', '1', () => 0); - store.finishConfiguring(); - - expect(store.getSelection()).toEqual({ storyId: 'a--1', viewMode: 'story' }); - }); - - it('tries again with a specifier if it failed the first time', () => { - const store = new StoryStore({ channel }); - store.setSelectionSpecifier({ storySpecifier: 'a--2', viewMode: 'story' }); - addStoryToStore(store, 'a', '1', () => 0); - store.finishConfiguring(); - - expect(store.getSelection()).toEqual(undefined); - - store.startConfiguring(); - store.removeStoryKind('a'); - addStoryToStore(store, 'a', '1', () => 0); - addStoryToStore(store, 'a', '2', () => 0); - store.finishConfiguring(); - - expect(store.getSelection()).toEqual({ storyId: 'a--2', viewMode: 'story' }); - }); - - it('DOES NOT try again if the selection changed in the meantime', () => { - const store = new StoryStore({ channel }); - store.setSelectionSpecifier({ storySpecifier: 'a--2', viewMode: 'story' }); - addStoryToStore(store, 'a', '1', () => 0); - store.finishConfiguring(); - - expect(store.getSelection()).toEqual(undefined); - store.setSelection({ storyId: 'a--1', viewMode: 'story' }); - expect(store.getSelection()).toEqual({ storyId: 'a--1', viewMode: 'story' }); - - store.startConfiguring(); - store.removeStoryKind('a'); - addStoryToStore(store, 'a', '1', () => 0); - addStoryToStore(store, 'a', '2', () => 0); - store.finishConfiguring(); - - expect(store.getSelection()).toEqual({ storyId: 'a--1', viewMode: 'story' }); - }); - }); - }); - - describe('storySort', () => { - it('sorts stories using given function', () => { - const store = new StoryStore({ channel }); - addReverseSorting(store); - addStoryToStore(store, 'a/a', '1', () => 0); - addStoryToStore(store, 'a/a', '2', () => 0); - addStoryToStore(store, 'a/b', '1', () => 0); - addStoryToStore(store, 'b/b1', '1', () => 0); - addStoryToStore(store, 'b/b10', '1', () => 0); - addStoryToStore(store, 'b/b9', '1', () => 0); - addStoryToStore(store, 'c', '1', () => 0); - - const extracted = store.extract(); - - expect(Object.keys(extracted)).toEqual([ - 'c--1', - 'b-b10--1', - 'b-b9--1', - 'b-b1--1', - 'a-b--1', - 'a-a--1', - 'a-a--2', - ]); - }); - - it('sorts stories alphabetically', () => { - const store = new StoryStore({ channel }); - store.addGlobalMetadata({ - decorators: [], - parameters: { - options: { - storySort: { - method: 'alphabetical', - }, - }, - }, - }); - addStoryToStore(store, 'a/b', '1', () => 0); - addStoryToStore(store, 'a/a', '2', () => 0); - addStoryToStore(store, 'a/a', '1', () => 0); - addStoryToStore(store, 'c', '1', () => 0); - addStoryToStore(store, 'b/b10', '1', () => 0); - addStoryToStore(store, 'b/b9', '1', () => 0); - addStoryToStore(store, 'b/b1', '1', () => 0); - - const extracted = store.extract(); - - expect(Object.keys(extracted)).toEqual([ - 'a-a--2', - 'a-a--1', - 'a-b--1', - 'b-b1--1', - 'b-b9--1', - 'b-b10--1', - 'c--1', - ]); - }); - - it('sorts stories in specified order or alphabetically', () => { - const store = new StoryStore({ channel }); - store.addGlobalMetadata({ - decorators: [], - parameters: { - options: { - storySort: { - method: 'alphabetical', - order: ['b', ['bc', 'ba', 'bb'], 'a', 'c'], - }, - }, - }, - }); - addStoryToStore(store, 'a/b', '1', () => 0); - addStoryToStore(store, 'a', '1', () => 0); - addStoryToStore(store, 'c', '1', () => 0); - addStoryToStore(store, 'b/bd', '1', () => 0); - addStoryToStore(store, 'b/bb', '1', () => 0); - addStoryToStore(store, 'b/ba', '1', () => 0); - addStoryToStore(store, 'b/bc', '1', () => 0); - addStoryToStore(store, 'b', '1', () => 0); - - const extracted = store.extract(); - - expect(Object.keys(extracted)).toEqual([ - 'b--1', - 'b-bc--1', - 'b-ba--1', - 'b-bb--1', - 'b-bd--1', - 'a--1', - 'a-b--1', - 'c--1', - ]); - }); - - it('sorts stories in specified order or alphabetically with wildcards', () => { - const store = new StoryStore({ channel }); - store.addGlobalMetadata({ - decorators: [], - parameters: { - options: { - storySort: { - method: 'alphabetical', - order: ['b', ['bc', '*', 'bb'], '*', 'c'], - }, - }, - }, - }); - addStoryToStore(store, 'a/b', '1', () => 0); - addStoryToStore(store, 'a', '1', () => 0); - addStoryToStore(store, 'c', '1', () => 0); - addStoryToStore(store, 'b/bd', '1', () => 0); - addStoryToStore(store, 'b/bb', '1', () => 0); - addStoryToStore(store, 'b/ba', '1', () => 0); - addStoryToStore(store, 'b/bc', '1', () => 0); - addStoryToStore(store, 'b', '1', () => 0); - - const extracted = store.extract(); - - expect(Object.keys(extracted)).toEqual([ - 'b--1', - 'b-bc--1', - 'b-ba--1', - 'b-bd--1', - 'b-bb--1', - 'a--1', - 'a-b--1', - 'c--1', - ]); - }); - - it('sorts stories in specified order or by configure order', () => { - const store = new StoryStore({ channel }); - store.addGlobalMetadata({ - decorators: [], - parameters: { - options: { - storySort: { - method: 'configure', - order: ['b', 'a', 'c'], - }, - }, - }, - }); - addStoryToStore(store, 'a/b', '1', () => 0); - addStoryToStore(store, 'a', '1', () => 0); - addStoryToStore(store, 'c', '1', () => 0); - addStoryToStore(store, 'b/bd', '1', () => 0); - addStoryToStore(store, 'b/bb', '1', () => 0); - addStoryToStore(store, 'b/ba', '1', () => 0); - addStoryToStore(store, 'b/bc', '1', () => 0); - addStoryToStore(store, 'b', '1', () => 0); - - const extracted = store.extract(); - - expect(Object.keys(extracted)).toEqual([ - 'b--1', - 'b-bd--1', - 'b-bb--1', - 'b-ba--1', - 'b-bc--1', - 'a--1', - 'a-b--1', - 'c--1', - ]); - }); - - it('sorts stories in specified order or by configure order with wildcard', () => { - const store = new StoryStore({ channel }); - store.addGlobalMetadata({ - decorators: [], - parameters: { - options: { - storySort: { - method: 'configure', - order: ['b', '*', 'c'], - }, - }, - }, - }); - addStoryToStore(store, 'a/b', '1', () => 0); - addStoryToStore(store, 'a', '1', () => 0); - addStoryToStore(store, 'c', '1', () => 0); - addStoryToStore(store, 'b/bd', '1', () => 0); - addStoryToStore(store, 'b/bb', '1', () => 0); - addStoryToStore(store, 'b/ba', '1', () => 0); - addStoryToStore(store, 'b/bc', '1', () => 0); - addStoryToStore(store, 'b', '1', () => 0); - addStoryToStore(store, 'e', '1', () => 0); - addStoryToStore(store, 'd', '1', () => 0); - - const extracted = store.extract(); - - expect(Object.keys(extracted)).toEqual([ - 'b--1', - 'b-bd--1', - 'b-bb--1', - 'b-ba--1', - 'b-bc--1', - 'a--1', - 'a-b--1', - 'e--1', - 'd--1', - 'c--1', - ]); - }); - - it('sorts stories in specified order including story names or configure', () => { - const store = new StoryStore({ channel }); - store.addGlobalMetadata({ - decorators: [], - parameters: { - options: { - storySort: { - method: 'configure', - order: ['b', ['bc', 'ba', 'bb'], 'a', 'c'], - includeNames: true, - }, - }, - }, - }); - addStoryToStore(store, 'a/b', '1', () => 0); - addStoryToStore(store, 'a', '2', () => 0); - addStoryToStore(store, 'a', '1', () => 0); - addStoryToStore(store, 'c', '1', () => 0); - addStoryToStore(store, 'b/bd', '1', () => 0); - addStoryToStore(store, 'b/bb', '1', () => 0); - addStoryToStore(store, 'b/ba', '1', () => 0); - addStoryToStore(store, 'b/bc', '1', () => 0); - addStoryToStore(store, 'b', '1', () => 0); - - const extracted = store.extract(); - - expect(Object.keys(extracted)).toEqual([ - 'b-bc--1', - 'b-ba--1', - 'b-bb--1', - 'b-bd--1', - 'b--1', - 'a-b--1', - 'a--2', - 'a--1', - 'c--1', - ]); - }); - - it('sorts stories in specified order including story names or alphabetically', () => { - const store = new StoryStore({ channel }); - store.addGlobalMetadata({ - decorators: [], - parameters: { - options: { - storySort: { - method: 'alphabetical', - order: ['b', ['bc', 'ba', 'bb'], 'a', 'c'], - includeNames: true, - }, - }, - }, - }); - addStoryToStore(store, 'a/b', '1', () => 0); - addStoryToStore(store, 'a', '2', () => 0); - addStoryToStore(store, 'a', '1', () => 0); - addStoryToStore(store, 'c', '1', () => 0); - addStoryToStore(store, 'b/bd', '1', () => 0); - addStoryToStore(store, 'b/bb', '1', () => 0); - addStoryToStore(store, 'b/ba', '1', () => 0); - addStoryToStore(store, 'b/bc', '1', () => 0); - addStoryToStore(store, 'b', '1', () => 0); - - const extracted = store.extract(); - - expect(Object.keys(extracted)).toEqual([ - 'b-bc--1', - 'b-ba--1', - 'b-bb--1', - 'b--1', - 'b-bd--1', - 'a--1', - 'a--2', - 'a-b--1', - 'c--1', - ]); - }); - - it('passes kind and global parameters to sort', () => { - const store = new StoryStore({ channel }); - const storySort = jest.fn(); - store.addGlobalMetadata({ - decorators: [], - parameters: { - options: { - storySort, - }, - global: 'global', - }, - }); - store.addKindMetadata('a', { parameters: { kind: 'kind' }, decorators: [] }); - addStoryToStore(store, 'a', '1', () => 0, { story: '1' }); - addStoryToStore(store, 'a', '2', () => 0, { story: '2' }); - const extracted = store.extract(); - - expect(storySort).toHaveBeenCalledWith( - [ - 'a--1', - expect.objectContaining({ - parameters: expect.objectContaining({ story: '1' }), - }), - { kind: 'kind' }, - expect.objectContaining({ global: 'global' }), - ], - [ - 'a--2', - expect.objectContaining({ - parameters: expect.objectContaining({ story: '2' }), - }), - { kind: 'kind' }, - expect.objectContaining({ global: 'global' }), - ] - ); - }); - }); - - describe('configuration', () => { - it('does not allow addStory if not configuring, unless allowUsafe=true', () => { - const store = new StoryStore({ channel }); - store.finishConfiguring(); - - expect(() => addStoryToStore(store, 'a', '1', () => 0)).toThrow( - 'Cannot add a story when not configuring' - ); - - expect(() => - store.addStory( - { - kind: 'a', - name: '1', - storyFn: () => 0, - parameters: {}, - id: 'a--1', - }, - { - applyDecorators: defaultDecorateStory, - allowUnsafe: true, - } - ) - ).not.toThrow(); - }); - - it('does not allow remove if not configuring, unless allowUsafe=true', () => { - const store = new StoryStore({ channel }); - addons.setChannel(channel); - addStoryToStore(store, 'a', '1', () => 0); - store.finishConfiguring(); - - expect(() => store.remove('a--1')).toThrow('Cannot remove a story when not configuring'); - expect(() => store.remove('a--1', { allowUnsafe: true })).not.toThrow(); - }); - - it('does not allow removeStoryKind if not configuring, unless allowUsafe=true', () => { - const store = new StoryStore({ channel }); - addons.setChannel(channel); - addStoryToStore(store, 'a', '1', () => 0); - store.finishConfiguring(); - - expect(() => store.removeStoryKind('a')).toThrow('Cannot remove a kind when not configuring'); - expect(() => store.removeStoryKind('a', { allowUnsafe: true })).not.toThrow(); - }); - - it('waits for configuration to be over before emitting SET_STORIES', () => { - const onSetStories = jest.fn(); - channel.on(Events.SET_STORIES, onSetStories); - const store = new StoryStore({ channel }); - - addStoryToStore(store, 'a', '1', () => 0); - expect(onSetStories).not.toHaveBeenCalled(); - - store.finishConfiguring(); - expect(onSetStories).toHaveBeenCalledWith({ - v: 2, - globals: {}, - globalParameters: {}, - kindParameters: { a: {} }, - stories: { - 'a--1': expect.objectContaining({ - id: 'a--1', - }), - }, - }); - }); - - it('correctly emits globals with SET_STORIES', () => { - const onSetStories = jest.fn(); - channel.on(Events.SET_STORIES, onSetStories); - const store = new StoryStore({ channel }); - - store.addGlobalMetadata({ - decorators: [], - parameters: { - globalTypes: { - arg1: { defaultValue: 'arg1' }, - }, - }, - }); - - addStoryToStore(store, 'a', '1', () => 0); - expect(onSetStories).not.toHaveBeenCalled(); - - store.finishConfiguring(); - expect(onSetStories).toHaveBeenCalledWith({ - v: 2, - globals: { arg1: 'arg1' }, - globalParameters: { - // NOTE: Currently globalArg[Types] are emitted as parameters but this may not remain - globalTypes: { - arg1: { defaultValue: 'arg1' }, - }, - }, - kindParameters: { a: {} }, - stories: { - 'a--1': expect.objectContaining({ - id: 'a--1', - }), - }, - }); - }); - - it('emits an empty SET_STORIES if no stories were added during configuration', () => { - const onSetStories = jest.fn(); - channel.on(Events.SET_STORIES, onSetStories); - const store = new StoryStore({ channel }); - - store.finishConfiguring(); - expect(onSetStories).toHaveBeenCalledWith({ - v: 2, - globals: {}, - globalParameters: {}, - kindParameters: {}, - stories: {}, - }); - }); - - it('allows configuration as second time (HMR)', () => { - const onSetStories = jest.fn(); - channel.on(Events.SET_STORIES, onSetStories); - const store = new StoryStore({ channel }); - store.finishConfiguring(); - - onSetStories.mockClear(); - store.startConfiguring(); - addStoryToStore(store, 'a', '1', () => 0); - store.finishConfiguring(); - - expect(onSetStories).toHaveBeenCalledWith({ - v: 2, - globals: {}, - globalParameters: {}, - kindParameters: { a: {} }, - stories: { - 'a--1': expect.objectContaining({ - id: 'a--1', - }), - }, - }); - }); - }); - - describe('HMR behaviour', () => { - it('emits the right things after removing a story', () => { - const onSetStories = jest.fn(); - channel.on(Events.SET_STORIES, onSetStories); - const store = new StoryStore({ channel }); - - // For hooks - addons.setChannel(channel); - - store.startConfiguring(); - addStoryToStore(store, 'kind-1', 'story-1.1', () => 0); - addStoryToStore(store, 'kind-1', 'story-1.2', () => 0); - store.finishConfiguring(); - - onSetStories.mockClear(); - store.startConfiguring(); - store.remove(toId('kind-1', 'story-1.1')); - store.finishConfiguring(); - - expect(onSetStories).toHaveBeenCalledWith({ - v: 2, - globals: {}, - globalParameters: {}, - kindParameters: { 'kind-1': {} }, - stories: { - 'kind-1--story-1-2': expect.objectContaining({ - id: 'kind-1--story-1-2', - }), - }, - }); - - expect(store.fromId(toId('kind-1', 'story-1.1'))).toBeFalsy(); - expect(store.fromId(toId('kind-1', 'story-1.2'))).toBeTruthy(); - }); - - it('emits the right things after removing a kind', () => { - const onSetStories = jest.fn(); - channel.on(Events.SET_STORIES, onSetStories); - const store = new StoryStore({ channel }); - - // For hooks - addons.setChannel(channel); - - store.startConfiguring(); - addStoryToStore(store, 'kind-1', 'story-1.1', () => 0); - addStoryToStore(store, 'kind-1', 'story-1.2', () => 0); - addStoryToStore(store, 'kind-2', 'story-2.1', () => 0); - addStoryToStore(store, 'kind-2', 'story-2.2', () => 0); - store.finishConfiguring(); - - onSetStories.mockClear(); - store.startConfiguring(); - store.removeStoryKind('kind-1'); - store.finishConfiguring(); - - expect(onSetStories).toHaveBeenCalledWith({ - v: 2, - globals: {}, - globalParameters: {}, - kindParameters: { 'kind-1': {}, 'kind-2': {} }, - stories: { - 'kind-2--story-2-1': expect.objectContaining({ - id: 'kind-2--story-2-1', - }), - 'kind-2--story-2-2': expect.objectContaining({ - id: 'kind-2--story-2-2', - }), - }, - }); - - expect(store.fromId(toId('kind-1', 'story-1.1'))).toBeFalsy(); - expect(store.fromId(toId('kind-2', 'story-2.1'))).toBeTruthy(); - }); - - // eslint-disable-next-line jest/expect-expect - it('should not error even if you remove a kind that does not exist', () => { - const store = new StoryStore({ channel }); - store.removeStoryKind('kind'); - }); - }); - - describe('CURRENT_STORY_WAS_SET', () => { - it('is emitted when configuration ends', () => { - const onCurrentStoryWasSet = jest.fn(); - channel.on(Events.CURRENT_STORY_WAS_SET, onCurrentStoryWasSet); - const store = new StoryStore({ channel }); - - store.finishConfiguring(); - expect(onCurrentStoryWasSet).toHaveBeenCalled(); - }); - - it('is emitted when setSelection is called', () => { - const onCurrentStoryWasSet = jest.fn(); - channel.on(Events.CURRENT_STORY_WAS_SET, onCurrentStoryWasSet); - const store = new StoryStore({ channel }); - store.finishConfiguring(); - - onCurrentStoryWasSet.mockClear(); - store.setSelection({ storyId: 'a--1', viewMode: 'story' }); - expect(onCurrentStoryWasSet).toHaveBeenCalled(); - }); - }); - - describe('STORY_SPECIFIED', () => { - it('is emitted when configuration ends if a specifier was set', () => { - const onStorySpecified = jest.fn(); - channel.on(Events.STORY_SPECIFIED, onStorySpecified); - const store = new StoryStore({ channel }); - addStoryToStore(store, 'kind-1', 'story-1.1', () => 0); - store.setSelectionSpecifier({ storySpecifier: '*', viewMode: 'story' }); - - store.finishConfiguring(); - expect(onStorySpecified).toHaveBeenCalled(); - }); - - it('is NOT emitted when setSelection is called', () => { - const onStorySpecified = jest.fn(); - channel.on(Events.STORY_SPECIFIED, onStorySpecified); - const store = new StoryStore({ channel }); - addStoryToStore(store, 'kind-1', 'story-1.1', () => 0); - store.setSelectionSpecifier({ storySpecifier: '*', viewMode: 'story' }); - store.finishConfiguring(); - - onStorySpecified.mockClear(); - store.setSelection({ storyId: 'a--1', viewMode: 'story' }); - expect(onStorySpecified).not.toHaveBeenCalled(); - }); - }); - - describe('In Single Story mode', () => { - describe('when storySpecifier is story id', () => { - it('adds only one story specified in selection specifier when addStory is called', () => { - const store = new StoryStore({ channel }); - store.setSelectionSpecifier({ - storySpecifier: toId('kind-1', 'story-1.1'), - viewMode: 'story', - singleStory: true, - }); - - store.startConfiguring(); - addStoryToStore(store, 'kind-1', 'story-1.1', () => 0); - addStoryToStore(store, 'kind-1', 'story-1.2', () => 0); - store.finishConfiguring(); - - expect(store.fromId(toId('kind-1', 'story-1.1'))).toBeTruthy(); - expect(store.fromId(toId('kind-1', 'story-1.2'))).toBeFalsy(); - }); - - it('adds only kind metadata specified in selection specifier when addKindMetadata is called', () => { - const store = new StoryStore({ channel }); - store.setSelectionSpecifier({ - storySpecifier: toId('kind-1', 'story-1.1'), - viewMode: 'story', - singleStory: true, - }); - - store.startConfiguring(); - store.addKindMetadata('kind-1', {}); - store.addKindMetadata('kind-2', {}); - store.finishConfiguring(); - - expect(store._kinds['kind-1']).toBeDefined(); - expect(store._kinds['kind-2']).not.toBeDefined(); - }); - }); - - describe('when storySpecifier is object', () => { - it('adds only one story specified in selection specifier when addStory is called', () => { - const store = new StoryStore({ channel }); - store.setSelectionSpecifier({ - storySpecifier: { kind: 'kind-1', name: 'story-1.1' }, - viewMode: 'story', - singleStory: true, - }); - - store.startConfiguring(); - addStoryToStore(store, 'kind-1', 'story-1.1', () => 0); - addStoryToStore(store, 'kind-1', 'story-1.2', () => 0); - store.finishConfiguring(); - - expect(store.fromId(toId('kind-1', 'story-1.1'))).toBeTruthy(); - expect(store.fromId(toId('kind-1', 'story-1.2'))).toBeFalsy(); - }); - - it('adds only kind metadata specified in selection specifier when addKindMetadata is called', () => { - const store = new StoryStore({ channel }); - store.setSelectionSpecifier({ - storySpecifier: { kind: 'kind-1', name: 'story-1.1' }, - viewMode: 'story', - singleStory: true, - }); - - store.startConfiguring(); - store.addKindMetadata('kind-1', {}); - store.addKindMetadata('kind-2', {}); - store.finishConfiguring(); - - expect(store._kinds['kind-1']).toBeDefined(); - expect(store._kinds['kind-2']).not.toBeDefined(); - }); - }); - }); -}); diff --git a/lib/client-api/src/story_store.ts b/lib/client-api/src/story_store.ts deleted file mode 100644 index 73c8f2bdb908..000000000000 --- a/lib/client-api/src/story_store.ts +++ /dev/null @@ -1,854 +0,0 @@ -/* eslint no-underscore-dangle: 0 */ -import memoize from 'memoizerific'; -import dedent from 'ts-dedent'; -import stable from 'stable'; -import mapValues from 'lodash/mapValues'; -import pick from 'lodash/pick'; -import deprecate from 'util-deprecate'; - -import { Channel } from '@storybook/channels'; -import Events from '@storybook/core-events'; -import { logger } from '@storybook/client-logger'; -import { sanitize, toId } from '@storybook/csf'; -import { - Comparator, - Parameters, - Args, - LegacyStoryFn, - ArgsStoryFn, - StoryContext, - StoryKind, - StoryId, -} from '@storybook/addons'; -import { - DecoratorFunction, - StoryMetadata, - StoreData, - AddStoryArgs, - StoreItem, - PublishedStoreItem, - ErrorLike, - GetStorybookKind, - ArgsEnhancer, - ArgTypesEnhancer, - StoreSelectionSpecifier, - StoreSelection, - StorySpecifier, -} from './types'; -import { combineArgs, mapArgsToTypes, validateOptions } from './args'; -import { HooksContext } from './hooks'; -import { storySort } from './storySort'; -import { combineParameters } from './parameters'; -import { ensureArgTypes } from './ensureArgTypes'; -import { inferArgTypes } from './inferArgTypes'; -import { inferControls } from './inferControls'; - -interface StoryOptions { - includeDocsOnly?: boolean; -} - -type KindMetadata = StoryMetadata & { order: number }; - -function extractSanitizedKindNameFromStorySpecifier(storySpecifier: StorySpecifier): string { - if (typeof storySpecifier === 'string') { - return storySpecifier.split('--').shift(); - } - - return sanitize(storySpecifier.kind); -} - -function extractIdFromStorySpecifier(storySpecifier: StorySpecifier): string { - if (typeof storySpecifier === 'string') { - return storySpecifier; - } - - return toId(storySpecifier.kind, storySpecifier.name); -} - -const isStoryDocsOnly = (parameters?: Parameters) => { - return parameters && parameters.docsOnly; -}; - -const includeStory = (story: StoreItem, options: StoryOptions = { includeDocsOnly: false }) => { - if (options.includeDocsOnly) { - return true; - } - return !isStoryDocsOnly(story.parameters); -}; - -const checkGlobals = (parameters: Parameters) => { - const { globals, globalTypes } = parameters; - if (globals || globalTypes) { - logger.error( - 'Global args/argTypes can only be set globally', - JSON.stringify({ - globals, - globalTypes, - }) - ); - } -}; - -const checkStorySort = (parameters: Parameters) => { - const { options } = parameters; - if (options?.storySort) logger.error('The storySort option parameter can only be set globally'); -}; - -const storyFnWarning = deprecate( - () => {}, - dedent` - \`storyFn\` is deprecated and will be removed in Storybook 7.0. - - https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#deprecated-storyfn` -); - -const argTypeDefaultValueWarning = deprecate( - () => {}, - dedent` - \`argType.defaultValue\` is deprecated and will be removed in Storybook 7.0. - - https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#deprecated-argtype-defaultValue` -); - -interface AllowUnsafeOption { - allowUnsafe?: boolean; -} - -const toExtracted = (obj: T) => - Object.entries(obj).reduce((acc, [key, value]) => { - if (typeof value === 'function') { - return acc; - } - // NOTE: We're serializing argTypes twice, at the top-level and also in parameters. - // We currently rely on useParameters in the manager, so strip out the top-level argTypes - // instead for performance. - if (['hooks', 'argTypes'].includes(key)) { - return acc; - } - if (Array.isArray(value)) { - return Object.assign(acc, { [key]: value.slice().sort() }); - } - return Object.assign(acc, { [key]: value }); - }, {}); - -export default class StoryStore { - _error?: ErrorLike; - - _channel: Channel; - - _configuring: boolean; - - _globals: Args; - - _initialGlobals: Args; - - _defaultGlobals: Args; - - _globalMetadata: StoryMetadata; - - // Keyed on kind name - _kinds: Record; - - // Keyed on storyId - _stories: StoreData; - - _argsEnhancers: ArgsEnhancer[]; - - _argTypesEnhancers: ArgTypesEnhancer[]; - - _selectionSpecifier?: StoreSelectionSpecifier; - - _selection?: StoreSelection; - - constructor(params: { channel: Channel }) { - // Assume we are configuring until we hear otherwise - this._configuring = true; - this._globals = {}; - this._defaultGlobals = {}; - this._initialGlobals = {}; - this._globalMetadata = { parameters: {}, decorators: [], loaders: [] }; - this._kinds = {}; - this._stories = {}; - this._argsEnhancers = []; - this._argTypesEnhancers = [ensureArgTypes]; - this._error = undefined; - this._channel = params.channel; - - this.setupListeners(); - } - - setupListeners() { - // Channel can be null in StoryShots - if (!this._channel) return; - - this._channel.on(Events.SET_CURRENT_STORY, ({ storyId, viewMode }) => - this.setSelection({ storyId, viewMode }) - ); - - this._channel.on( - Events.UPDATE_STORY_ARGS, - ({ storyId, updatedArgs }: { storyId: string; updatedArgs: Args }) => - this.updateStoryArgs(storyId, updatedArgs) - ); - - this._channel.on( - Events.RESET_STORY_ARGS, - ({ storyId, argNames }: { storyId: string; argNames?: string[] }) => - this.resetStoryArgs(storyId, argNames) - ); - - this._channel.on(Events.UPDATE_GLOBALS, ({ globals }: { globals: Args }) => - this.updateGlobals(globals) - ); - } - - startConfiguring() { - this._configuring = true; - - const safePush = (enhancer: ArgTypesEnhancer, enhancers: ArgTypesEnhancer[]) => { - if (!enhancers.includes(enhancer)) enhancers.push(enhancer); - }; - // run these at the end - safePush(inferArgTypes, this._argTypesEnhancers); - safePush(inferControls, this._argTypesEnhancers); - } - - finishConfiguring() { - this._configuring = false; - - const { globals = {}, globalTypes = {} } = this._globalMetadata.parameters; - const allowedGlobals = new Set([...Object.keys(globals), ...Object.keys(globalTypes)]); - const defaultGlobals = Object.entries( - globalTypes as Record - ).reduce((acc, [arg, { defaultValue }]) => { - if (defaultValue) acc[arg] = defaultValue; - return acc; - }, {} as Args); - - this._initialGlobals = { ...defaultGlobals, ...globals }; - - // To deal with HMR & persistence, we consider the previous value of global args, and: - // 1. Remove any keys that are not in the new parameter - // 2. Preference any keys that were already set - // 3. Use any new keys from the new parameter - this._globals = Object.entries(this._globals || {}).reduce( - (acc, [key, previousValue]) => { - if (allowedGlobals.has(key)) acc[key] = previousValue; - - return acc; - }, - { ...this._initialGlobals } - ); - - // Set the current selection based on the current selection specifier, if selection is not yet set - const stories = this.sortedStories(); - let foundStory; - if (this._selectionSpecifier && !this._selection) { - const { - storySpecifier, - viewMode, - args: urlArgs, - globals: urlGlobals, - } = this._selectionSpecifier; - - if (urlGlobals) { - const allowedUrlGlobals = Object.entries(urlGlobals).reduce((acc, [key, value]) => { - if (allowedGlobals.has(key)) acc[key] = value; - return acc; - }, {} as Args); - this._globals = combineParameters(this._globals, allowedUrlGlobals); - } - - if (storySpecifier === '*') { - // '*' means select the first story. If there is none, we have no selection. - [foundStory] = stories; - } else if (typeof storySpecifier === 'string') { - // Find the story with the exact id that matches the specifier (see #11571) - foundStory = Object.values(stories).find((s) => s.id === storySpecifier); - if (!foundStory) { - // Fallback to the first story that starts with the specifier - foundStory = Object.values(stories).find((s) => s.id.startsWith(storySpecifier)); - } - } else { - // Try and find a story matching the name/kind, setting no selection if they don't exist. - const { name, kind } = storySpecifier; - foundStory = this.getRawStory(kind, name); - } - - if (foundStory) { - if (urlArgs) { - const mappedUrlArgs = mapArgsToTypes(urlArgs, foundStory.argTypes); - foundStory.args = combineArgs(foundStory.args, mappedUrlArgs); - } - foundStory.args = validateOptions(foundStory.args, foundStory.argTypes); - this.setSelection({ storyId: foundStory.id, viewMode }); - this._channel.emit(Events.STORY_SPECIFIED, { storyId: foundStory.id, viewMode }); - } - } - - // If we didn't find a story matching the specifier, we always want to emit CURRENT_STORY_WAS_SET anyway - // in order to tell the StoryRenderer to render something (a "missing story" view) - if (!foundStory && this._channel) { - this._channel.emit(Events.CURRENT_STORY_WAS_SET, this._selection); - } - - this.pushToManager(); - } - - addGlobalMetadata({ parameters = {}, decorators = [], loaders = [] }: StoryMetadata) { - if (parameters) { - const { args, argTypes } = parameters; - if (args || argTypes) - logger.warn( - 'Found args/argTypes in global parameters.', - JSON.stringify({ args, argTypes }) - ); - } - const globalParameters = this._globalMetadata.parameters; - - this._globalMetadata.parameters = combineParameters(globalParameters, parameters); - - function _safeAdd(items: any[], collection: any[], caption: string) { - items.forEach((item) => { - if (collection.includes(item)) { - logger.warn(`You tried to add a duplicate ${caption}, this is not expected`, item); - } else { - collection.push(item); - } - }); - } - - _safeAdd(decorators, this._globalMetadata.decorators, 'decorator'); - _safeAdd(loaders, this._globalMetadata.loaders, 'loader'); - } - - clearGlobalDecorators() { - this._globalMetadata.decorators = []; - } - - ensureKind(kind: string) { - if (!this._kinds[kind]) { - this._kinds[kind] = { - order: Object.keys(this._kinds).length, - parameters: {}, - decorators: [], - loaders: [], - }; - } - } - - addKindMetadata(kind: string, { parameters = {}, decorators = [], loaders = [] }: StoryMetadata) { - if (this.shouldBlockAddingKindMetadata(kind)) { - return; - } - - this.ensureKind(kind); - if (parameters) { - checkGlobals(parameters); - checkStorySort(parameters); - } - this._kinds[kind].parameters = combineParameters(this._kinds[kind].parameters, parameters); - - this._kinds[kind].decorators.push(...decorators); - this._kinds[kind].loaders.push(...loaders); - } - - addArgsEnhancer(argsEnhancer: ArgsEnhancer) { - if (Object.keys(this._stories).length > 0) - throw new Error('Cannot add an args enhancer to the store after a story has been added.'); - - this._argsEnhancers.push(argsEnhancer); - } - - addArgTypesEnhancer(argTypesEnhancer: ArgTypesEnhancer) { - if (Object.keys(this._stories).length > 0) - throw new Error('Cannot add an argTypes enhancer to the store after a story has been added.'); - - this._argTypesEnhancers.push(argTypesEnhancer); - } - - // Combine the global, kind & story parameters of a story - combineStoryParameters(parameters: Parameters, kind: StoryKind) { - return combineParameters( - this._globalMetadata.parameters, - this._kinds[kind].parameters, - parameters - ); - } - - shouldBlockAddingStory(id: string): boolean { - return ( - this.isSingleStoryMode() && - id !== extractIdFromStorySpecifier(this._selectionSpecifier.storySpecifier) - ); - } - - shouldBlockAddingKindMetadata(kind: string): boolean { - return ( - this.isSingleStoryMode() && - sanitize(kind) !== - extractSanitizedKindNameFromStorySpecifier(this._selectionSpecifier.storySpecifier) - ); - } - - addStory( - { - id, - kind, - name, - storyFn: original, - parameters: storyParameters = {}, - decorators: storyDecorators = [], - loaders: storyLoaders = [], - }: AddStoryArgs, - { - applyDecorators, - allowUnsafe = false, - }: { - applyDecorators: (fn: LegacyStoryFn, decorators: DecoratorFunction[]) => any; - } & AllowUnsafeOption - ) { - if (!this._configuring && !allowUnsafe) - throw new Error( - 'Cannot add a story when not configuring, see https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#story-store-immutable-outside-of-configuration' - ); - - if (this.shouldBlockAddingStory(id)) { - return; - } - - checkGlobals(storyParameters); - checkStorySort(storyParameters); - - const { _stories } = this; - - if (_stories[id]) { - logger.warn(dedent` - Story with id ${id} already exists in the store! - - Perhaps you added the same story twice, or you have a name collision? - Story ids need to be unique -- ensure you aren't using the same names modulo url-sanitization. - `); - } - - const identification = { - id, - kind, - name, - story: name, // legacy - }; - - // immutable original storyFn - const getOriginal = () => original; - - this.ensureKind(kind); - const kindMetadata: KindMetadata = this._kinds[kind]; - const decorators = [ - ...storyDecorators, - ...kindMetadata.decorators, - ...this._globalMetadata.decorators, - ]; - const loaders = [...this._globalMetadata.loaders, ...kindMetadata.loaders, ...storyLoaders]; - - const finalStoryFn = (context: StoryContext) => { - const { args = {}, argTypes = {}, parameters } = context; - const { passArgsFirst = true } = parameters; - const mapped = { - ...context, - args: Object.entries(args).reduce((acc, [key, val]) => { - const { mapping } = argTypes[key] || {}; - acc[key] = mapping && val in mapping ? mapping[val] : val; - return acc; - }, {} as Args), - }; - return passArgsFirst - ? (original as ArgsStoryFn)(mapped.args, mapped) - : (original as LegacyStoryFn)(mapped); - }; - - // lazily decorate the story when it's loaded - const getDecorated: () => LegacyStoryFn = memoize(1)(() => - applyDecorators(finalStoryFn, decorators) - ); - - const hooks = new HooksContext(); - - // We need the combined parameters now in order to calculate argTypes, but we won't keep them - const combinedParameters = this.combineStoryParameters(storyParameters, kind); - - // We are going to make various UI changes in both the manager and the preview - // based on whether it's an "args story", i.e. whether the story accepts a first - // argument which is an `Args` object. Here we store it as a parameter on every story - // for convenience, but we preface it with `__` to denote that it's an internal API - // and that users probably shouldn't look at it. - const { passArgsFirst = true } = combinedParameters; - const __isArgsStory = passArgsFirst && original.length > 0; - - const { argTypes = {} } = this._argTypesEnhancers.reduce( - (accumulatedParameters: Parameters, enhancer) => ({ - ...accumulatedParameters, - argTypes: enhancer({ - ...identification, - storyFn: original, - parameters: accumulatedParameters, - args: {}, - argTypes: {}, - globals: {}, - originalStoryFn: getOriginal(), - }), - }), - { __isArgsStory, ...combinedParameters } - ); - - const storyParametersWithArgTypes = { ...storyParameters, argTypes, __isArgsStory }; - - const storyFn: LegacyStoryFn = (runtimeContext: StoryContext) => { - storyFnWarning(); - return getDecorated()({ - ...identification, - ...runtimeContext, - // Calculate "combined" parameters at render time (NOTE: for perf we could just use combinedParameters from above?) - parameters: this.combineStoryParameters(storyParametersWithArgTypes, kind), - hooks, - args: _stories[id].args, - argTypes, - globals: this._globals, - viewMode: this._selection?.viewMode, - originalStoryFn: getOriginal(), - }); - }; - - const unboundStoryFn: LegacyStoryFn = (context: StoryContext) => getDecorated()(context); - - const applyLoaders = async () => { - const context = { - ...identification, - // Calculate "combined" parameters at render time (NOTE: for perf we could just use combinedParameters from above?) - parameters: this.combineStoryParameters(storyParametersWithArgTypes, kind), - hooks, - args: _stories[id].args, - argTypes, - globals: this._globals, - viewMode: this._selection?.viewMode, - originalStoryFn: getOriginal(), - }; - const loadResults = await Promise.all(loaders.map((loader) => loader(context))); - const loaded = Object.assign({}, ...loadResults); - return { ...context, loaded }; - }; - - // Pull out parameters.args.$ || .argTypes.$.defaultValue into initialArgs - const passedArgs: Args = { - ...this._kinds[kind].parameters.args, - ...storyParameters.args, - }; - const defaultArgs: Args = Object.entries( - argTypes as Record - ).reduce((acc, [arg, { defaultValue }]) => { - if (typeof defaultValue !== 'undefined') { - acc[arg] = defaultValue; - } - return acc; - }, {} as Args); - if (Object.keys(defaultArgs).length > 0) { - argTypeDefaultValueWarning(); - } - - const initialArgsBeforeEnhancers = { ...defaultArgs, ...passedArgs }; - const initialArgs = this._argsEnhancers.reduce( - (accumulatedArgs: Args, enhancer) => ({ - ...accumulatedArgs, - ...enhancer({ - ...identification, - parameters: combinedParameters, - args: initialArgsBeforeEnhancers, - argTypes, - globals: {}, - originalStoryFn: getOriginal(), - }), - }), - initialArgsBeforeEnhancers - ); - - const runPlayFunction = async () => { - const { play } = combinedParameters as { play?: () => any }; - return play ? play() : undefined; - }; - - _stories[id] = { - ...identification, - - hooks, - getDecorated, - getOriginal, - applyLoaders, - runPlayFunction, - storyFn, - unboundStoryFn, - - parameters: storyParametersWithArgTypes, - args: initialArgs, - argTypes, - initialArgs, - }; - } - - remove = (id: string, { allowUnsafe = false }: AllowUnsafeOption = {}): void => { - if (!this._configuring && !allowUnsafe) - throw new Error( - 'Cannot remove a story when not configuring, see https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#story-store-immutable-outside-of-configuration' - ); - - const { _stories } = this; - const story = _stories[id]; - delete _stories[id]; - - if (story) story.hooks.clean(); - }; - - removeStoryKind(kind: string, { allowUnsafe = false }: AllowUnsafeOption = {}) { - if (!this._configuring && !allowUnsafe) - throw new Error( - 'Cannot remove a kind when not configuring, see https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#story-store-immutable-outside-of-configuration' - ); - - if (!this._kinds[kind]) return; - - this._kinds[kind].parameters = {}; - this._kinds[kind].decorators = []; - - this.cleanHooksForKind(kind); - this._stories = Object.entries(this._stories).reduce((acc: StoreData, [id, story]) => { - if (story.kind !== kind) acc[id] = story; - - return acc; - }, {}); - } - - updateGlobals(newGlobals: Args) { - this._globals = { ...this._globals, ...newGlobals }; - this._channel.emit(Events.GLOBALS_UPDATED, { - globals: this._globals, - initialGlobals: this._initialGlobals, - }); - } - - updateStoryArgs(id: string, newArgs: Args) { - if (!this._stories[id]) throw new Error(`No story for id ${id}`); - const { args } = this._stories[id]; - this._stories[id].args = { ...args, ...newArgs }; - - this._channel.emit(Events.STORY_ARGS_UPDATED, { storyId: id, args: this._stories[id].args }); - } - - resetStoryArgs(id: string, argNames?: string[]) { - if (!this._stories[id]) throw new Error(`No story for id ${id}`); - const { args, initialArgs } = this._stories[id]; - - this._stories[id].args = { ...args }; // Make a copy to avoid problems - (argNames || Object.keys(args)).forEach((name) => { - // We overwrite like this to ensure we can reset to falsey values - this._stories[id].args[name] = initialArgs[name]; - }); - - this._channel.emit(Events.STORY_ARGS_UPDATED, { storyId: id, args: this._stories[id].args }); - } - - fromId = (id: string): PublishedStoreItem | null => { - try { - const data = this._stories[id as string]; - - if (!data || !data.getDecorated) { - return null; - } - - return this.mergeAdditionalDataToStory(data); - } catch (e) { - logger.warn('failed to get story:', this._stories); - logger.error(e); - return null; - } - }; - - raw(options?: StoryOptions): PublishedStoreItem[] { - return Object.values(this._stories) - .filter((i) => !!i.getDecorated) - .filter((i) => includeStory(i, options)) - .map((i) => this.mergeAdditionalDataToStory(i)); - } - - sortedStories(): StoreItem[] { - // NOTE: when kinds are HMR'ed they get temporarily removed from the `_stories` array - // and thus lose order. However `_kinds[x].order` preservers the original load order - const kindOrder = mapValues(this._kinds, ({ order }) => order); - const storySortParameter = this._globalMetadata.parameters?.options?.storySort; - - const storyEntries = Object.entries(this._stories); - // Add the kind parameters and global parameters to each entry - const stories: [ - StoryId, - StoreItem, - Parameters, - Parameters - ][] = storyEntries.map(([id, story]) => [ - id, - story, - this._kinds[story.kind].parameters, - this._globalMetadata.parameters, - ]); - if (storySortParameter) { - let sortFn: Comparator; - if (typeof storySortParameter === 'function') { - sortFn = storySortParameter; - } else { - sortFn = storySort(storySortParameter); - } - stable.inplace(stories, sortFn); - } else { - stable.inplace(stories, (s1, s2) => kindOrder[s1[1].kind] - kindOrder[s2[1].kind]); - } - return stories.map(([id, s]) => s); - } - - extract(options: StoryOptions & { normalizeParameters?: boolean } = {}) { - const stories = this.sortedStories(); - - // removes function values from all stories so they are safe to transport over the channel - return stories.reduce((acc, story) => { - if (!includeStory(story, options)) return acc; - - const extracted = toExtracted(story); - if (options.normalizeParameters) return Object.assign(acc, { [story.id]: extracted }); - - const { parameters, kind } = extracted as { - parameters: Parameters; - kind: StoryKind; - }; - return Object.assign(acc, { - [story.id]: Object.assign(extracted, { - parameters: this.combineStoryParameters(parameters, kind), - }), - }); - }, {}); - } - - clearError() { - this._error = null; - } - - setError = (err: ErrorLike) => { - this._error = err; - }; - - getError = (): ErrorLike | undefined => this._error; - - setSelectionSpecifier(selectionSpecifier: StoreSelectionSpecifier): void { - this._selectionSpecifier = selectionSpecifier; - } - - setSelection(selection: StoreSelection): void { - this._selection = selection; - - if (this._channel) { - this._channel.emit(Events.CURRENT_STORY_WAS_SET, this._selection); - } - } - - isSingleStoryMode(): boolean { - if (!this._selectionSpecifier) { - return false; - } - - const { singleStory, storySpecifier } = this._selectionSpecifier; - return storySpecifier && storySpecifier !== '*' && singleStory; - } - - getSelection = (): StoreSelection => this._selection; - - getDataForManager = () => { - return { - v: 2, - globalParameters: this._globalMetadata.parameters, - globals: this._globals, - error: this.getError(), - kindParameters: mapValues(this._kinds, (metadata) => metadata.parameters), - stories: this.extract({ includeDocsOnly: true, normalizeParameters: true }), - }; - }; - - getStoriesJsonData = () => { - const value = this.getDataForManager(); - const allowed = ['fileName', 'docsOnly', 'framework', '__id', '__isArgsStory']; - - return { - v: 2, - globalParameters: pick(value.globalParameters, allowed), - kindParameters: mapValues(value.kindParameters, (v) => pick(v, allowed)), - stories: mapValues(value.stories, (v: any) => ({ - ...pick(v, ['id', 'name', 'kind', 'story']), - parameters: pick(v.parameters, allowed), - })), - }; - }; - - pushToManager = () => { - if (this._channel) { - // send to the parent frame. - this._channel.emit(Events.SET_STORIES, this.getDataForManager()); - } - }; - - getStoryKinds() { - return Array.from(new Set(this.raw().map((s) => s.kind))); - } - - getStoriesForKind = (kind: string) => this.raw().filter((story) => story.kind === kind); - - getRawStory(kind: string, name: string) { - return this.getStoriesForKind(kind).find((s) => s.name === name); - } - - cleanHooks(id: string) { - if (this._stories[id]) { - this._stories[id].hooks.clean(); - } - } - - cleanHooksForKind(kind: string) { - this.getStoriesForKind(kind).map((story) => this.cleanHooks(story.id)); - } - - // This API is a re-implementation of Storybook's original getStorybook() API. - // As such it may not behave *exactly* the same, but aims to. Some notes: - // - It is *NOT* sorted by the user's sort function, but remains sorted in "insertion order" - // - It does not include docs-only stories - getStorybook(): GetStorybookKind[] { - return Object.values( - this.raw().reduce((kinds: { [kind: string]: GetStorybookKind }, story) => { - if (!includeStory(story)) return kinds; - - const { - kind, - name, - storyFn, - parameters: { fileName }, - } = story; - - // eslint-disable-next-line no-param-reassign - if (!kinds[kind]) kinds[kind] = { kind, fileName, stories: [] }; - - kinds[kind].stories.push({ name, render: storyFn }); - - return kinds; - }, {}) - ).sort((s1, s2) => this._kinds[s1.kind].order - this._kinds[s2.kind].order); - } - - private mergeAdditionalDataToStory(story: StoreItem): PublishedStoreItem { - return { - ...story, - parameters: this.combineStoryParameters(story.parameters, story.kind), - globals: this._globals, - }; - } -} diff --git a/lib/client-api/src/types.ts b/lib/client-api/src/types.ts index 131a6c30e021..31e82c104517 100644 --- a/lib/client-api/src/types.ts +++ b/lib/client-api/src/types.ts @@ -4,7 +4,6 @@ import { StoryName, StoryKind, ViewMode, - StoryIdentifier, StoryFn, Parameters, Args, @@ -12,11 +11,24 @@ import { StoryApi, DecoratorFunction, LoaderFunction, - DecorateStoryFunction, StoryContext, } from '@storybook/addons'; -import StoryStore from './story_store'; -import { HooksContext } from './hooks'; +import { AnyFramework, StoryIdentifier, ProjectAnnotations } from '@storybook/csf'; +import { StoryStore, HooksContext } from '@storybook/store'; + +export type { + SBType, + SBScalarType, + SBArrayType, + SBObjectType, + SBEnumType, + SBIntersectionType, + SBUnionType, + SBOtherType, +} from '@storybook/csf'; + +// NOTE: these types are really just here for back-compat. Many of them don't have much meaning +// Remove in 7.0 export interface ErrorLike { message: string; @@ -62,7 +74,7 @@ export type StoreItem = StoryIdentifier & { runPlayFunction: () => Promise; storyFn: StoryFn; unboundStoryFn: StoryFn; - hooks: HooksContext; + hooks: HooksContext; args: Args; initialArgs: Args; argTypes: ArgTypes; @@ -77,8 +89,8 @@ export interface StoreData { } export interface ClientApiParams { - storyStore: StoryStore; - decorateStory?: DecorateStoryFunction; + storyStore: StoryStore; + decorateStory?: ProjectAnnotations['applyDecorators']; noStoryModuleAddMethodHotDispose?: boolean; } @@ -118,46 +130,3 @@ export type RenderContextWithoutStoryContext = StoreItem & { export type RenderContext = RenderContextWithoutStoryContext & { storyContext: StoryContext; }; - -interface SBBaseType { - required?: boolean; - raw?: string; -} - -export type SBScalarType = SBBaseType & { - name: 'boolean' | 'string' | 'number' | 'function'; -}; - -export type SBArrayType = SBBaseType & { - name: 'array'; - value: SBType; -}; -export type SBObjectType = SBBaseType & { - name: 'object'; - value: Record; -}; -export type SBEnumType = SBBaseType & { - name: 'enum'; - value: (string | number)[]; -}; -export type SBIntersectionType = SBBaseType & { - name: 'intersection'; - value: SBType[]; -}; -export type SBUnionType = SBBaseType & { - name: 'union'; - value: SBType[]; -}; -export type SBOtherType = SBBaseType & { - name: 'other'; - value: string; -}; - -export type SBType = - | SBScalarType - | SBEnumType - | SBArrayType - | SBObjectType - | SBIntersectionType - | SBUnionType - | SBOtherType; diff --git a/lib/core-client/package.json b/lib/core-client/package.json index de687b7386c9..76aafeffd4fb 100644 --- a/lib/core-client/package.json +++ b/lib/core-client/package.json @@ -45,7 +45,9 @@ "@storybook/client-api": "6.4.0-alpha.34", "@storybook/client-logger": "6.4.0-alpha.34", "@storybook/core-events": "6.4.0-alpha.34", - "@storybook/csf": "0.0.1", + "@storybook/csf": "0.0.2--canary.b1d5348.0", + "@storybook/preview-web": "6.4.0-alpha.34", + "@storybook/store": "6.4.0-alpha.34", "@storybook/ui": "6.4.0-alpha.34", "airbnb-js-shims": "^2.2.1", "ansi-to-html": "^0.6.11", diff --git a/lib/core-client/src/preview/NoDocs.tsx b/lib/core-client/src/preview/NoDocs.tsx deleted file mode 100644 index b6932363d1ac..000000000000 --- a/lib/core-client/src/preview/NoDocs.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import React, { CSSProperties } from 'react'; - -const wrapper = { - fontSize: '14px', - letterSpacing: '0.2px', - margin: '10px 0', -}; - -const main = { - margin: 'auto', - padding: 30, - borderRadius: 10, - background: 'rgba(0,0,0,0.03)', -}; - -const heading: CSSProperties = { - textAlign: 'center', -}; - -export const NoDocs = () => ( -
-
-

No Docs

-

- Sorry, but there are no docs for the selected story. To add them, set the story's  - docs parameter. If you think this is an error: -

-
    -
  • Please check the story definition.
  • -
  • Please check the Storybook config.
  • -
  • Try reloading the page.
  • -
-

- If the problem persists, check the browser console, or the terminal you've run Storybook - from. -

-
-
-); diff --git a/lib/core-client/src/preview/StoryRenderer.test.ts b/lib/core-client/src/preview/StoryRenderer.test.ts deleted file mode 100644 index 177e79cd5cf6..000000000000 --- a/lib/core-client/src/preview/StoryRenderer.test.ts +++ /dev/null @@ -1,536 +0,0 @@ -// a -import { StoryStore, defaultDecorateStory } from '@storybook/client-api'; -import createChannel from '@storybook/channel-postmessage'; -import { - FORCE_RE_RENDER, - CURRENT_STORY_WAS_SET, - STORY_ERRORED, - STORY_THREW_EXCEPTION, - STORY_MISSING, - DOCS_RENDERED, - STORY_CHANGED, - STORY_UNCHANGED, - STORY_RENDERED, -} from '@storybook/core-events'; -import { toId } from '@storybook/csf'; -import { - addons, - StoryFn, - StoryKind, - StoryName, - Parameters, - LoaderFunction, - applyHooks, -} from '@storybook/addons'; -import ReactDOM from 'react-dom'; - -import { StoryRenderer } from './StoryRenderer'; - -jest.mock('react-dom'); - -jest.mock('@storybook/client-logger', () => ({ - logger: { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - }, -})); - -jest.mock('global', () => ({ - // @ts-ignore - ...global, - FEATURES: { previewCsfV3: true }, -})); - -function prepareRenderer() { - const render = jest.fn(({ storyFn }) => storyFn()); - const channel = createChannel({ page: 'preview' }); - addons.setChannel(channel); - const storyStore = new StoryStore({ channel: null }); - const renderer = new StoryRenderer({ render, channel, storyStore }); - - // mock out all the dom-touching functions - renderer.applyLayout = jest.fn(); - renderer.showErrorDisplay = jest.fn(); - renderer.showNoPreview = jest.fn(); - renderer.showMain = jest.fn(); - renderer.showDocs = jest.fn(); - renderer.showStory = jest.fn(); - - return { render, channel, storyStore, renderer }; -} - -function addStory( - storyStore: StoryStore, - kind: StoryKind, - name: StoryName, - parameters: Parameters = {}, - loaders: LoaderFunction[] = [], - storyFn: StoryFn = jest.fn() -) { - const id = toId(kind, name); - storyStore.addStory( - { id, kind, name, storyFn, parameters, loaders }, - { - applyDecorators: applyHooks(defaultDecorateStory), - } - ); - return id; -} - -function addAndSelectStory( - storyStore: StoryStore, - kind: StoryKind, - name: StoryName, - parameters: Parameters = {}, - loaders: LoaderFunction[] = undefined -) { - const storyFn = jest.fn(); - const id = addStory(storyStore, kind, name, parameters, loaders, storyFn); - storyStore.setSelection({ storyId: id, viewMode: 'story' }); - return storyFn; -} - -describe('core.preview.StoryRenderer', () => { - it('renders the current story with the correct context', async () => { - const { render, channel, storyStore, renderer } = prepareRenderer(); - - const onStoryRendered = jest.fn(); - channel.on(STORY_RENDERED, onStoryRendered); - - addAndSelectStory(storyStore, 'a', '1', { p: 'q' }); - - await renderer.renderCurrentStory(false); - expect(render).toHaveBeenCalledWith( - expect.objectContaining({ - id: 'a--1', - kind: 'a', - name: '1', - parameters: { __isArgsStory: false, argTypes: {}, p: 'q' }, - forceRender: false, - - showMain: expect.any(Function), - showError: expect.any(Function), - showException: expect.any(Function), - }) - ); - - render.mockClear(); - await renderer.renderCurrentStory(true); - expect(render).toHaveBeenCalledWith( - expect.objectContaining({ - forceRender: true, - }) - ); - - // the render function does something async so we need to jump to the end of the promise queue - await Promise.resolve(null); - - expect(onStoryRendered).toHaveBeenCalledWith('a--1'); - }); - - it('calls play functions on render', async () => { - const { render, channel, storyStore, renderer } = prepareRenderer(); - - const onStoryRendered = jest.fn(); - channel.on(STORY_RENDERED, onStoryRendered); - - const play = jest.fn(); - addAndSelectStory(storyStore, 'a', '1', { p: 'q', play }); - - await renderer.renderCurrentStory(false); - expect(render).toHaveBeenCalled(); - - render.mockClear(); - await renderer.renderCurrentStory(true); - expect(render).toHaveBeenCalledWith( - expect.objectContaining({ - forceRender: true, - }) - ); - - // the render function does something async so we need to jump to the end of the promise queue - await Promise.resolve(null); - - expect(onStoryRendered).toHaveBeenCalledWith('a--1'); - expect(play).toHaveBeenCalled(); - }); - - describe('loaders', () => { - it('loads data asynchronously and passes to stories', async () => { - const { channel, storyStore, renderer } = prepareRenderer(); - - const onStoryRendered = jest.fn(); - channel.on(STORY_RENDERED, onStoryRendered); - - const loaders = [async () => new Promise((r) => setTimeout(() => r({ foo: 7 }), 100))]; - const storyFn = addAndSelectStory(storyStore, 'a', '1', {}, loaders); - - await renderer.renderCurrentStory(false); - expect(storyFn).toHaveBeenCalledWith( - {}, - expect.objectContaining({ - id: 'a--1', - kind: 'a', - name: '1', - loaded: { foo: 7 }, - }) - ); - - expect(onStoryRendered).toHaveBeenCalledWith('a--1'); - }); - it('later loaders override earlier loaders', async () => { - const { channel, storyStore, renderer } = prepareRenderer(); - - const onStoryRendered = jest.fn(); - channel.on(STORY_RENDERED, onStoryRendered); - - const loaders = [ - async () => new Promise((r) => setTimeout(() => r({ foo: 7 }), 100)), - async () => new Promise((r) => setTimeout(() => r({ foo: 3 }), 50)), - ]; - const storyFn = addAndSelectStory(storyStore, 'a', '1', {}, loaders); - - await renderer.renderCurrentStory(false); - expect(storyFn).toHaveBeenCalledWith( - {}, - expect.objectContaining({ - id: 'a--1', - kind: 'a', - name: '1', - loaded: { foo: 3 }, - }) - ); - - expect(onStoryRendered).toHaveBeenCalledWith('a--1'); - }); - it('more specific loaders override more generic loaders', async () => { - const { channel, storyStore, renderer } = prepareRenderer(); - - const onStoryRendered = jest.fn(); - channel.on(STORY_RENDERED, onStoryRendered); - - storyStore.addGlobalMetadata({ loaders: [async () => ({ foo: 1, bar: 1, baz: 1 })] }); - storyStore.addKindMetadata('a', { loaders: [async () => ({ foo: 3, bar: 3 })] }); - const storyFn = addAndSelectStory(storyStore, 'a', '1', {}, [async () => ({ foo: 5 })]); - - await renderer.renderCurrentStory(false); - expect(storyFn).toHaveBeenCalledWith( - {}, - expect.objectContaining({ - id: 'a--1', - kind: 'a', - name: '1', - loaded: { foo: 5, bar: 3, baz: 1 }, - }) - ); - - expect(onStoryRendered).toHaveBeenCalledWith('a--1'); - }); - }); - describe('errors', () => { - it('renders an error if a config error is set on the store', async () => { - const { render, storyStore, renderer } = prepareRenderer(); - const err = { message: 'message', stack: 'stack' }; - storyStore.setError(err); - storyStore.finishConfiguring(); - await renderer.renderCurrentStory(false); - - expect(render).not.toHaveBeenCalled(); - expect(renderer.showErrorDisplay).toHaveBeenCalledWith(err); - }); - - it('renders an error if the story calls renderError', async () => { - const { render, channel, storyStore, renderer } = prepareRenderer(); - - const onStoryErrored = jest.fn(); - channel.on(STORY_ERRORED, onStoryErrored); - - const err = { title: 'title', description: 'description' }; - render.mockImplementation(({ showError }) => showError(err)); - - addAndSelectStory(storyStore, 'a', '1'); - await renderer.renderCurrentStory(false); - - expect(renderer.showErrorDisplay).toHaveBeenCalledWith({ - message: 'title', - stack: 'description', - }); - expect(onStoryErrored).toHaveBeenCalledWith(err); - }); - - it('renders an exception if the story calls renderException', async () => { - const { render, channel, storyStore, renderer } = prepareRenderer(); - - const onStoryThrewException = jest.fn(); - channel.on(STORY_THREW_EXCEPTION, onStoryThrewException); - - const err = { message: 'message', stack: 'stack' }; - render.mockImplementation(({ showException }) => showException(err)); - - addAndSelectStory(storyStore, 'a', '1'); - await renderer.renderCurrentStory(false); - - expect(renderer.showErrorDisplay).toHaveBeenCalledWith(err); - expect(onStoryThrewException).toHaveBeenCalledWith(err); - }); - - it('renders an exception if the render function throws', async () => { - const { render, channel, storyStore, renderer } = prepareRenderer(); - - const onStoryThrewException = jest.fn(); - channel.on(STORY_THREW_EXCEPTION, onStoryThrewException); - - const err = { message: 'message', stack: 'stack' }; - render.mockImplementation(() => { - throw err; - }); - - addAndSelectStory(storyStore, 'a', '1'); - await renderer.renderCurrentStory(false); - - expect(renderer.showErrorDisplay).toHaveBeenCalledWith(err); - expect(onStoryThrewException).toHaveBeenCalledWith(err); - }); - - it('renders an error if the story is missing', async () => { - const { render, channel, storyStore, renderer } = prepareRenderer(); - - const onStoryMissing = jest.fn(); - channel.on(STORY_MISSING, onStoryMissing); - - addStory(storyStore, 'a', '1'); - storyStore.setSelection({ storyId: 'b--2', viewMode: 'story' }); - await renderer.renderCurrentStory(false); - - expect(render).not.toHaveBeenCalled(); - - expect(renderer.showNoPreview).toHaveBeenCalled(); - expect(onStoryMissing).toHaveBeenCalledWith('b--2'); - }); - }); - - describe('docs mode', () => { - it('renders docs and emits when rendering a docs story', async () => { - const { render, channel, storyStore, renderer } = prepareRenderer(); - - const onDocsRendered = jest.fn(); - channel.on(DOCS_RENDERED, onDocsRendered); - ((ReactDOM.render as unknown) as jest.Mock).mockImplementationOnce( - (element, node, callback) => callback() - ); - - addStory(storyStore, 'a', '1'); - storyStore.setSelection({ storyId: 'a--1', viewMode: 'docs' }); - await renderer.renderCurrentStory(false); - - // Although the docs React component may ultimately render the story we are mocking out - // `react-dom` and just check that *something* is being rendered by react at this point - expect(render).not.toHaveBeenCalled(); - expect(onDocsRendered).toHaveBeenCalledWith('a'); - }); - - it('hides the root and shows the docs root as well as main when rendering a docs story', async () => { - const { storyStore, renderer } = prepareRenderer(); - addStory(storyStore, 'a', '1'); - storyStore.setSelection({ storyId: 'a--1', viewMode: 'docs' }); - await renderer.renderCurrentStory(false); - - expect(renderer.showDocs).toHaveBeenCalled(); - expect(renderer.showMain).toHaveBeenCalled(); - }); - - it('hides the docs root and shows the root, but does not show main when rendering a normal story', () => { - const { render, storyStore, renderer } = prepareRenderer(); - addStory(storyStore, 'a', '1'); - - storyStore.setSelection({ storyId: 'a--1', viewMode: 'docs' }); - - render.mockImplementationOnce(({ showMain }) => { - // showMain has not yet been called - expect(renderer.showStory).toHaveBeenCalled(); - expect(renderer.showMain).not.toHaveBeenCalled(); - - // now the render function implementation calls showMain - showMain(); - expect(renderer.showMain).toHaveBeenCalled(); - }); - - (renderer.showStory as jest.Mock).mockClear(); - (renderer.showMain as jest.Mock).mockClear(); - storyStore.setSelection({ storyId: 'a--1', viewMode: 'story' }); - }); - }); - - describe('re-rendering behaviour', () => { - it('does not re-render if nothing changed', async () => { - const { render, channel, storyStore, renderer } = prepareRenderer(); - addAndSelectStory(storyStore, 'a', '1'); - await renderer.renderCurrentStory(false); - - const onStoryUnchanged = jest.fn(); - channel.on(STORY_UNCHANGED, onStoryUnchanged); - - render.mockClear(); - await renderer.renderCurrentStory(false); - expect(render).not.toHaveBeenCalled(); - // Not sure why STORY_UNCHANGED is called with all this stuff - expect(onStoryUnchanged).toHaveBeenCalledWith({ - id: 'a--1', - kind: 'a', - name: '1', - viewMode: 'story', - getDecorated: expect.any(Function), - }); - }); - it('does re-render the current story if it has not changed if forceRender is true', async () => { - const { render, channel, storyStore, renderer } = prepareRenderer(); - addAndSelectStory(storyStore, 'a', '1'); - await renderer.renderCurrentStory(false); - - const onStoryChanged = jest.fn(); - channel.on(STORY_CHANGED, onStoryChanged); - - render.mockClear(); - await renderer.renderCurrentStory(true); - expect(render).toHaveBeenCalled(); - - expect(onStoryChanged).not.toHaveBeenCalled(); - }); - it('does re-render if the selected story changes', async () => { - const { render, channel, storyStore, renderer } = prepareRenderer(); - addStory(storyStore, 'a', '1'); - addAndSelectStory(storyStore, 'a', '2'); - await renderer.renderCurrentStory(false); - - const onStoryChanged = jest.fn(); - channel.on(STORY_CHANGED, onStoryChanged); - - render.mockClear(); - storyStore.setSelection({ storyId: 'a--1', viewMode: 'story' }); - await renderer.renderCurrentStory(false); - expect(render).toHaveBeenCalled(); - - expect(onStoryChanged).toHaveBeenCalledWith('a--1'); - }); - it('does re-render if the story implementation changes', async () => { - const { render, channel, storyStore, renderer } = prepareRenderer(); - addAndSelectStory(storyStore, 'a', '1'); - await renderer.renderCurrentStory(false); - - const onStoryChanged = jest.fn(); - channel.on(STORY_CHANGED, onStoryChanged); - - render.mockClear(); - storyStore.removeStoryKind('a'); - addAndSelectStory(storyStore, 'a', '1'); - await renderer.renderCurrentStory(false); - - expect(render).toHaveBeenCalled(); - expect(onStoryChanged).not.toHaveBeenCalled(); - }); - it('does re-render if the view mode changes', async () => { - const { render, channel, storyStore, renderer } = prepareRenderer(); - addAndSelectStory(storyStore, 'a', '1'); - storyStore.setSelection({ storyId: 'a--1', viewMode: 'docs' }); - await renderer.renderCurrentStory(false); - - const onStoryChanged = jest.fn(); - channel.on(STORY_CHANGED, onStoryChanged); - - render.mockClear(); - storyStore.setSelection({ storyId: 'a--1', viewMode: 'story' }); - await renderer.renderCurrentStory(false); - - expect(render).toHaveBeenCalled(); - expect(onStoryChanged).toHaveBeenCalledWith('a--1'); - }); - }); - - describe('hooks', () => { - it('cleans up kind hooks when changing kind in docs mode', async () => { - const { storyStore, renderer } = prepareRenderer(); - addAndSelectStory(storyStore, 'a', '1'); - addAndSelectStory(storyStore, 'b', '1'); - storyStore.setSelection({ storyId: 'a--1', viewMode: 'docs' }); - await renderer.renderCurrentStory(false); - - storyStore.cleanHooksForKind = jest.fn(); - - storyStore.setSelection({ storyId: 'b--1', viewMode: 'docs' }); - await renderer.renderCurrentStory(false); - - expect(storyStore.cleanHooksForKind).toHaveBeenCalledWith('a'); - }); - it('does not clean up hooks when changing story but not kind in docs mode', async () => { - const { storyStore, renderer } = prepareRenderer(); - addAndSelectStory(storyStore, 'a', '1'); - addAndSelectStory(storyStore, 'a', '2'); - storyStore.setSelection({ storyId: 'a--1', viewMode: 'docs' }); - await renderer.renderCurrentStory(false); - - storyStore.cleanHooksForKind = jest.fn(); - - storyStore.setSelection({ storyId: 'a--2', viewMode: 'docs' }); - await renderer.renderCurrentStory(false); - - expect(storyStore.cleanHooksForKind).not.toHaveBeenCalled(); - }); - it('cleans up kind hooks when changing view mode from docs', async () => { - const { storyStore, renderer } = prepareRenderer(); - addAndSelectStory(storyStore, 'a', '1'); - storyStore.setSelection({ storyId: 'a--1', viewMode: 'docs' }); - await renderer.renderCurrentStory(false); - - storyStore.cleanHooksForKind = jest.fn(); - - storyStore.setSelection({ storyId: 'a--1', viewMode: 'story' }); - await renderer.renderCurrentStory(false); - - expect(storyStore.cleanHooksForKind).toHaveBeenCalledWith('a'); - }); - it('cleans up story hooks when changing story in story mode', async () => { - const { storyStore, renderer } = prepareRenderer(); - addStory(storyStore, 'a', '1'); - addAndSelectStory(storyStore, 'a', '2'); - await renderer.renderCurrentStory(false); - - storyStore.cleanHooks = jest.fn(); - - storyStore.setSelection({ storyId: 'a--1', viewMode: 'story' }); - await renderer.renderCurrentStory(false); - - expect(storyStore.cleanHooks).toHaveBeenCalledWith('a--2'); - }); - it('cleans up story hooks when changing view mode from story', async () => { - const { storyStore, renderer } = prepareRenderer(); - addAndSelectStory(storyStore, 'a', '1'); - await renderer.renderCurrentStory(false); - - storyStore.cleanHooks = jest.fn(); - - storyStore.setSelection({ storyId: 'a--1', viewMode: 'docs' }); - await renderer.renderCurrentStory(false); - - expect(storyStore.cleanHooks).toHaveBeenCalledWith('a--1'); - }); - }); - - describe('event handling', () => { - it('renders on CURRENT_STORY_WAS_SET', () => { - const { channel, renderer } = prepareRenderer(); - renderer.renderCurrentStory = jest.fn(); - - channel.emit(CURRENT_STORY_WAS_SET); - expect(renderer.renderCurrentStory).toHaveBeenCalledWith(false); - }); - - it('force renders on FORCE_RE_RENDER', () => { - const { channel, renderer } = prepareRenderer(); - renderer.renderCurrentStory = jest.fn(); - - channel.emit(FORCE_RE_RENDER); - expect(renderer.renderCurrentStory).toHaveBeenCalledWith(true); - }); - }); -}); diff --git a/lib/core-client/src/preview/StoryRenderer.tsx b/lib/core-client/src/preview/StoryRenderer.tsx deleted file mode 100644 index 2633f22eaeb5..000000000000 --- a/lib/core-client/src/preview/StoryRenderer.tsx +++ /dev/null @@ -1,345 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; -import global from 'global'; -import AnsiToHtml from 'ansi-to-html'; -import dedent from 'ts-dedent'; - -import { StoryId, StoryKind, StoryFn, ViewMode, Channel } from '@storybook/addons'; -import Events from '@storybook/core-events'; -import { logger } from '@storybook/client-logger'; -import { StoryStore } from '@storybook/client-api'; - -import { NoDocs } from './NoDocs'; -import { RenderStoryFunction, RenderContextWithoutStoryContext } from './types'; - -const { document, FEATURES = {} } = global; - -// We have "changed" story if this changes -interface RenderMetadata { - id: StoryId; - kind: StoryKind; - viewMode: ViewMode; - getDecorated: () => StoryFn; -} - -const layoutClassMap = { - centered: 'sb-main-centered', - fullscreen: 'sb-main-fullscreen', - padded: 'sb-main-padded', -} as const; -type Layout = keyof typeof layoutClassMap | 'none'; - -const classes = { - MAIN: 'sb-show-main', - NOPREVIEW: 'sb-show-nopreview', - ERROR: 'sb-show-errordisplay', -}; - -const ansiConverter = new AnsiToHtml({ - escapeXML: true, -}); - -/** - * StoryRenderer is responsible for rendering the correct story to the screen - * - * It is very much concerned with drawing to the screen and will do things like change classes - * on the body etc. - */ -export class StoryRenderer { - render: RenderStoryFunction; - - channel?: Channel; - - storyStore: StoryStore; - - previousMetadata?: RenderMetadata; - - previousLayoutClass?: typeof layoutClassMap[keyof typeof layoutClassMap] | null; - - constructor({ - render, - channel, - storyStore, - }: { - render: RenderStoryFunction; - channel?: Channel; - storyStore: StoryStore; - }) { - this.render = render; - this.channel = channel; - this.storyStore = storyStore; - - this.setupListeners(); - } - - setupListeners() { - // Channel can be null in StoryShots - if (this.channel) { - this.channel.on(Events.CURRENT_STORY_WAS_SET, () => this.renderCurrentStory(false)); - this.channel.on(Events.STORY_ARGS_UPDATED, () => this.forceReRender()); - this.channel.on(Events.GLOBALS_UPDATED, () => this.forceReRender()); - this.channel.on(Events.FORCE_RE_RENDER, () => this.forceReRender()); - } - } - - forceReRender() { - this.renderCurrentStory(true); - } - - async renderCurrentStory(forceRender: boolean) { - const { storyStore } = this; - - const loadError = storyStore.getError(); - if (loadError) { - this.showErrorDisplay(loadError); - return; - } - - const { storyId, viewMode: urlViewMode } = storyStore.getSelection() || {}; - - const data = storyStore.fromId(storyId); - const { kind, id, parameters = {}, getDecorated } = data || {}; - const { docsOnly, layout } = parameters; - - const metadata: RenderMetadata = { - id, - kind, - viewMode: docsOnly ? 'docs' : urlViewMode, - getDecorated, - }; - - this.applyLayout(metadata.viewMode === 'docs' ? 'fullscreen' : layout); - - const context: RenderContextWithoutStoryContext = { - id: storyId, // <- in case data is null, at least we'll know what we tried to render - ...data, - forceRender, - showMain: () => this.showMain(), - showError: ({ title, description }: { title: string; description: string }) => - this.renderError({ title, description }), - showException: (err: Error) => this.renderException(err), - }; - - await this.renderStoryIfChanged({ metadata, context }); - } - - async renderStoryIfChanged({ - metadata, - context, - }: { - metadata: RenderMetadata; - context: RenderContextWithoutStoryContext; - }) { - const { forceRender, name } = context; - - const { previousMetadata, storyStore } = this; - - const storyChanged = !previousMetadata || previousMetadata.id !== metadata.id; - // getDecorated is a function that returns a decorated story function. It'll change whenever the story - // is reloaded into the store, which means the module the story was defined in was HMR-ed. - const implementationChanged = - !previousMetadata || previousMetadata.getDecorated !== metadata.getDecorated; - const viewModeChanged = !previousMetadata || previousMetadata.viewMode !== metadata.viewMode; - const kindChanged = !previousMetadata || previousMetadata.kind !== metadata.kind; - - // Don't re-render the story if nothing has changed to justify it - if (!forceRender && !storyChanged && !implementationChanged && !viewModeChanged) { - this.channel.emit(Events.STORY_UNCHANGED, { - ...metadata, - name, - }); - return; - } - - // If we are rendering something new (as opposed to re-rendering the same or first story), emit - if (previousMetadata && (storyChanged || kindChanged || viewModeChanged)) { - this.channel.emit(Events.STORY_CHANGED, metadata.id); - } - - switch (previousMetadata ? previousMetadata.viewMode : 'story') { - case 'docs': - if (kindChanged || viewModeChanged) { - this.storyStore.cleanHooksForKind(previousMetadata.kind); - ReactDOM.unmountComponentAtNode(document.getElementById('docs-root')); - } - break; - case 'story': - default: - if (previousMetadata && (storyChanged || viewModeChanged)) { - this.storyStore.cleanHooks(previousMetadata.id); - ReactDOM.unmountComponentAtNode(document.getElementById('root')); - } - } - - // Docs view renders into a different root ID to avoid conflicts - // with the user's view layer. Therefore we need to clean up whenever - // we transition between view modes - if (viewModeChanged) { - switch (metadata.viewMode) { - case 'docs': { - this.showMain(); - this.showDocs(); - break; - } - case 'story': - default: { - if (previousMetadata) { - this.showStory(); - } - } - } - } - // Given a cleaned up state, render the appropriate view mode - switch (metadata.viewMode) { - case 'docs': { - this.renderDocs({ context, storyStore }); - break; - } - case 'story': - default: { - await this.renderStory({ context }); - break; - } - } - - this.previousMetadata = metadata; - - if (!forceRender && metadata.viewMode !== 'docs') { - document.documentElement.scrollTop = 0; - document.documentElement.scrollLeft = 0; - } - } - - applyLayout(layout: Layout = 'padded') { - if (layout === 'none') { - document.body.classList.remove(this.previousLayoutClass); - this.previousLayoutClass = null; - return; - } - - this.checkIfLayoutExists(layout); - - const layoutClass = layoutClassMap[layout]; - - document.body.classList.remove(this.previousLayoutClass); - document.body.classList.add(layoutClass); - this.previousLayoutClass = layoutClass; - } - - checkIfLayoutExists(layout: keyof typeof layoutClassMap) { - if (!layoutClassMap[layout]) { - logger.warn( - dedent`The desired layout: ${layout} is not a valid option. - The possible options are: ${Object.keys(layoutClassMap).join(', ')}, none.` - ); - } - } - - showErrorDisplay({ message = '', stack = '' }) { - document.getElementById('error-message').innerHTML = ansiConverter.toHtml(message); - document.getElementById('error-stack').innerHTML = ansiConverter.toHtml(stack); - - document.body.classList.remove(classes.MAIN); - document.body.classList.remove(classes.NOPREVIEW); - - document.body.classList.add(classes.ERROR); - } - - showNoPreview() { - document.body.classList.remove(classes.MAIN); - document.body.classList.remove(classes.ERROR); - - document.body.classList.add(classes.NOPREVIEW); - } - - showMain() { - document.body.classList.remove(classes.NOPREVIEW); - document.body.classList.remove(classes.ERROR); - - document.body.classList.add(classes.MAIN); - } - - showDocs() { - document.getElementById('root').setAttribute('hidden', 'true'); - document.getElementById('docs-root').removeAttribute('hidden'); - } - - showStory() { - document.getElementById('docs-root').setAttribute('hidden', 'true'); - document.getElementById('root').removeAttribute('hidden'); - } - - async renderStory({ - context, - context: { id, getDecorated }, - }: { - context: RenderContextWithoutStoryContext; - }) { - if (getDecorated) { - try { - const { applyLoaders, runPlayFunction, unboundStoryFn, forceRender } = context; - const storyContext = await applyLoaders(); - const storyFn = () => unboundStoryFn(storyContext); - await this.render({ ...context, storyContext, storyFn }); - if (FEATURES.previewCsfV3 && !forceRender) { - await runPlayFunction(); - } - this.channel.emit(Events.STORY_RENDERED, id); - } catch (err) { - this.renderException(err); - } - } else { - this.showNoPreview(); - this.channel.emit(Events.STORY_MISSING, id); - } - } - - renderDocs({ - context, - storyStore, - }: { - context: RenderContextWithoutStoryContext; - storyStore: StoryStore; - }) { - const { kind, parameters, id } = context; - if (id === '*' || !parameters) { - return; - } - - const docs = parameters.docs || {}; - if (docs.page && !docs.container) { - throw new Error('No `docs.container` set, did you run `addon-docs/preset`?'); - } - - const DocsContainer = - docs.container || (({ children }: { children: Element }) => <>{children}); - const Page = docs.page || NoDocs; - // Docs context includes the storyStore. Probably it would be better if it didn't but that can be fixed in a later refactor - ReactDOM.render( - - - , - document.getElementById('docs-root'), - () => this.channel.emit(Events.DOCS_RENDERED, kind) - ); - } - - // renderException is used if we fail to render the story and it is uncaught by the app layer - renderException(err: Error) { - this.channel.emit(Events.STORY_THREW_EXCEPTION, err); - this.showErrorDisplay(err); - - // Log the stack to the console. So, user could check the source code. - logger.error(err); - } - - // renderError is used by the various app layers to inform the user they have done something - // wrong -- for instance returned the wrong thing from a story - renderError({ title, description }: { title: string; description: string }) { - this.channel.emit(Events.STORY_ERRORED, { title, description }); - this.showErrorDisplay({ - message: title, - stack: description, - }); - } -} diff --git a/lib/core-client/src/preview/executeLoadable.ts b/lib/core-client/src/preview/executeLoadable.ts new file mode 100644 index 000000000000..c4c3f27a80d6 --- /dev/null +++ b/lib/core-client/src/preview/executeLoadable.ts @@ -0,0 +1,93 @@ +import { logger } from '@storybook/client-logger'; +import { Path, ModuleExports } from '@storybook/store'; +import { Loadable, RequireContext, LoaderFunction } from './types'; + +/** + * Executes a Loadable (function that returns exports or require context(s)) + * and returns a map of filename => module exports + * + * @param loadable Loadable + * @returns Map + */ +export function executeLoadable(loadable: Loadable) { + let reqs = null; + // todo discuss / improve type check + if (Array.isArray(loadable)) { + reqs = loadable; + } else if ((loadable as RequireContext).keys) { + reqs = [loadable as RequireContext]; + } + + let exportsMap = new Map(); + if (reqs) { + reqs.forEach((req) => { + req.keys().forEach((filename: string) => { + try { + const fileExports = req(filename) as ModuleExports; + exportsMap.set( + typeof req.resolve === 'function' ? req.resolve(filename) : filename, + fileExports + ); + } catch (error) { + const errorString = + error.message && error.stack ? `${error.message}\n ${error.stack}` : error.toString(); + logger.warn(`Unexpected error while loading ${filename}: ${errorString}`); + } + }); + }); + } else { + const exported = (loadable as LoaderFunction)(); + if (Array.isArray(exported) && exported.every((obj) => obj.default != null)) { + exportsMap = new Map( + exported.map((fileExports, index) => [`exports-map-${index}`, fileExports]) + ); + } else if (exported) { + logger.warn( + `Loader function passed to 'configure' should return void or an array of module exports that all contain a 'default' export. Received: ${JSON.stringify( + exported + )}` + ); + } + } + + return exportsMap; +} + +/** + * Executes a Loadable (function that returns exports or require context(s)) + * and compares it's output to the last time it was run (as stored on a node module) + * + * @param loadable Loadable + * @param m NodeModule + * @returns { added: Map, removed: Map } + */ +export function executeLoadableForChanges(loadable: Loadable, m?: NodeModule) { + let lastExportsMap: ReturnType = + m?.hot?.data?.lastExportsMap || new Map(); + if (m?.hot?.dispose) { + m.hot.accept(); + m.hot.dispose((data) => { + // eslint-disable-next-line no-param-reassign + data.lastExportsMap = lastExportsMap; + }); + } + + const exportsMap = executeLoadable(loadable); + const added = new Map(); + Array.from(exportsMap.entries()) + // Ignore files that do not have a default export + .filter(([, fileExports]) => !!fileExports.default) + // Ignore exports that are equal (by reference) to last time, this means the file hasn't changed + .filter(([fileName, fileExports]) => lastExportsMap.get(fileName) !== fileExports) + .forEach(([fileName, fileExports]) => added.set(fileName, fileExports)); + + const removed = new Map(); + Array.from(lastExportsMap.keys()) + .filter((fileName) => !exportsMap.has(fileName)) + .forEach((fileName) => removed.set(fileName, lastExportsMap.get(fileName))); + + // Save the value for the dispose() call above + lastExportsMap = exportsMap; + + return { added, removed }; +} diff --git a/lib/core-client/src/preview/index.ts b/lib/core-client/src/preview/index.ts index ea453a574297..88fa36b24983 100644 --- a/lib/core-client/src/preview/index.ts +++ b/lib/core-client/src/preview/index.ts @@ -1,13 +1,13 @@ -import { ClientApi, StoryStore, ConfigApi } from '@storybook/client-api'; +import { ClientApi } from '@storybook/client-api'; +import { StoryStore } from '@storybook/store'; import { toId } from '@storybook/csf'; -import start from './start'; +import { start } from './start'; export default { start, toId, ClientApi, - ConfigApi, StoryStore, }; -export { start, toId, ClientApi, ConfigApi, StoryStore }; +export { start, toId, ClientApi, StoryStore }; diff --git a/lib/core-client/src/preview/loadCsf.test.ts b/lib/core-client/src/preview/loadCsf.test.ts deleted file mode 100644 index ed1ec9b46e00..000000000000 --- a/lib/core-client/src/preview/loadCsf.test.ts +++ /dev/null @@ -1,482 +0,0 @@ -import { ConfigApi, ClientApi, StoryStore } from '@storybook/client-api'; -import { logger } from '@storybook/client-logger'; -import { RequireContext } from './types'; - -import { loadCsf } from './loadCsf'; - -jest.mock('@storybook/client-logger', () => ({ - logger: { warn: jest.fn(), debug: jest.fn() }, -})); - -let cbs: ((data: any) => void)[]; -let mod: NodeModule; -beforeEach(() => { - cbs = []; - mod = ({ - hot: { - data: {}, - dispose: (cb: (data: any) => void) => cbs.push(cb), - accept: jest.fn(), - }, - } as unknown) as NodeModule; -}); - -function doHMRDispose() { - cbs.forEach((cb) => cb(mod.hot.data)); - cbs = []; -} - -afterEach(() => { - doHMRDispose(); -}); - -function makeMocks() { - const configApi = ({ configure: (x: Function) => x() } as unknown) as ConfigApi; - const storyStore = ({ - removeStoryKind: jest.fn(), - } as unknown) as StoryStore; - const clientApi = ({ - storiesOf: jest.fn().mockImplementation(() => ({ - addParameters: jest.fn(), - addDecorator: jest.fn(), - add: jest.fn(), - })), - } as unknown) as ClientApi; - - const context = { configApi, storyStore, clientApi }; - const configure = loadCsf(context); - return { ...context, configure }; -} - -function makeRequireContext(map: Record): RequireContext { - const context = (key: string) => map[key]; - - return Object.assign(context, { - keys: () => Object.keys(map), - resolve: (key: string) => key, - }); -} - -describe('core.preview.loadCsf', () => { - it('calls storiesOf and add correctly from CSF exports', () => { - const { configure, clientApi } = makeMocks(); - - const input = { - a: { - default: { - title: 'a', - }, - 1: () => 0, - 2: () => 0, - }, - b: { - default: { - title: 'b', - }, - 1: () => 0, - 2: Object.assign(() => 0, { storyName: 'two' }), - }, - }; - configure('react', makeRequireContext(input), mod); - - const mockedStoriesOf = clientApi.storiesOf as jest.Mock; - expect(mockedStoriesOf).toHaveBeenCalledWith('a', true); - const aApi = mockedStoriesOf.mock.results[0].value; - const extras: any = { decorators: [], args: {}, argTypes: {}, loaders: [] }; - expect(aApi.add).toHaveBeenCalledWith('1', input.a[1], { __id: 'a--1', ...extras }); - expect(aApi.add).toHaveBeenCalledWith('2', input.a[2], { __id: 'a--2', ...extras }); - - expect(mockedStoriesOf).toHaveBeenCalledWith('b', true); - const bApi = mockedStoriesOf.mock.results[1].value; - expect(bApi.add).toHaveBeenCalledWith('1', input.b[1], { __id: 'b--1', ...extras }); - expect(bApi.add).toHaveBeenCalledWith('two', input.b[2], { __id: 'b--2', ...extras }); - }); - - it('adds stories in the right order if __namedExportsOrder is supplied', () => { - const { configure, clientApi } = makeMocks(); - - const input = { - a: { - default: { - title: 'a', - }, - // Note the export order doesn't work if the exports are numbers - x: () => 0, - y: () => 0, - z: () => 0, - w: () => 0, - __namedExportsOrder: ['w', 'x', 'z', 'y'], - }, - }; - configure('react', makeRequireContext(input), mod); - - const mockedStoriesOf = clientApi.storiesOf as jest.Mock; - const aApi = mockedStoriesOf.mock.results[0].value; - expect(aApi.add.mock.calls.map((c: string[]) => c[0])).toEqual(['W', 'X', 'Z', 'Y']); - }); - - it('filters exports using includeStories', () => { - const { configure, clientApi } = makeMocks(); - - const input = { - a: { - default: { - title: 'a', - includeStories: ['x', 'z'], - }, - // Note the export order doesn't work if the exports are numbers - x: () => 0, - y: () => 0, - z: () => 0, - w: () => 0, - }, - }; - configure('react', makeRequireContext(input), mod); - - const mockedStoriesOf = clientApi.storiesOf as jest.Mock; - const aApi = mockedStoriesOf.mock.results[0].value; - expect(aApi.add.mock.calls.map((c: string[]) => c[0])).toEqual(['X', 'Z']); - }); - - it('filters exports using excludeStories', () => { - const { configure, clientApi } = makeMocks(); - - const input = { - a: { - default: { - title: 'a', - excludeStories: ['x', 'z'], - }, - x: () => 0, - y: () => 0, - z: () => 0, - w: () => 0, - }, - }; - configure('react', makeRequireContext(input), mod); - - const mockedStoriesOf = clientApi.storiesOf as jest.Mock; - const aApi = mockedStoriesOf.mock.results[0].value; - expect(aApi.add.mock.calls.map((c: string[]) => c[0])).toEqual(['Y', 'W']); - }); - - it('allows setting componentId', () => { - const { configure, clientApi } = makeMocks(); - - const input = { - a: { - default: { - title: 'a', - id: 'random', - }, - x: () => 0, - }, - }; - configure('react', makeRequireContext(input), mod); - - const mockedStoriesOf = clientApi.storiesOf as jest.Mock; - const aApi = mockedStoriesOf.mock.results[0].value; - expect(aApi.add).toHaveBeenCalledWith('X', input.a.x, { - __id: 'random--x', - decorators: [], - args: {}, - argTypes: {}, - loaders: [], - }); - }); - - it('sets various parameters on components', () => { - const { configure, clientApi } = makeMocks(); - - const input = { - a: { - default: { - title: 'a', - component: 'c', - subcomponents: 'scs', - }, - }, - }; - configure('react', makeRequireContext(input), mod); - - const mockedStoriesOf = clientApi.storiesOf as jest.Mock; - const aApi = mockedStoriesOf.mock.results[0].value; - expect(aApi.addParameters).toHaveBeenCalledWith({ - framework: 'react', - component: 'c', - subcomponents: 'scs', - fileName: 'a', - }); - }); - - it('allows setting component parameters, decorators, and args/argTypes', () => { - const { configure, clientApi } = makeMocks(); - - const decorator = jest.fn(); - const input = { - a: { - default: { - title: 'a', - parameters: { x: 'y' }, - decorators: [decorator], - args: { b: 1 }, - argTypes: { b: 'string' }, - }, - x: () => 0, - }, - }; - configure('react', makeRequireContext(input), mod); - - const mockedStoriesOf = clientApi.storiesOf as jest.Mock; - const aApi = mockedStoriesOf.mock.results[0].value; - expect(aApi.addParameters).toHaveBeenCalledWith( - expect.objectContaining({ x: 'y', args: { b: 1 }, argTypes: { b: 'string' } }) - ); - expect(aApi.addDecorator).toHaveBeenCalledWith(decorator); - }); - - it('deprecates setting story parameters and decorators, and args/argTypes with story object', () => { - const { configure, clientApi } = makeMocks(); - - const decorator = jest.fn(); - const input = { - a: { - default: { - title: 'a', - }, - x: Object.assign(() => 0, { - story: { - name: 'CustomName', - parameters: { x: 'y' }, - decorators: [decorator], - args: { b: 1 }, - argTypes: { b: 'string' }, - }, - }), - }, - }; - configure('react', makeRequireContext(input), mod); - - const mockedStoriesOf = clientApi.storiesOf as jest.Mock; - const aApi = mockedStoriesOf.mock.results[0].value; - expect(aApi.add).toHaveBeenCalledWith('CustomName', input.a.x, { - x: 'y', - decorators: [decorator], - __id: 'a--x', - args: { b: 1 }, - argTypes: { b: 'string' }, - loaders: [], - }); - expect(logger.debug).toHaveBeenCalled(); - }); - - it('allows setting story parameters and decorators, and args/argTypes', () => { - const { configure, clientApi } = makeMocks(); - - const decorator = jest.fn(); - const input = { - a: { - default: { - title: 'a', - }, - x: Object.assign(() => 0, { - parameters: { x: 'y' }, - decorators: [decorator], - args: { b: 1 }, - argTypes: { b: 'string' }, - }), - }, - }; - configure('react', makeRequireContext(input), mod); - - const mockedStoriesOf = clientApi.storiesOf as jest.Mock; - const aApi = mockedStoriesOf.mock.results[0].value; - expect(aApi.add).toHaveBeenCalledWith('X', input.a.x, { - x: 'y', - decorators: [decorator], - __id: 'a--x', - args: { b: 1 }, - argTypes: { b: 'string' }, - loaders: [], - }); - expect(logger.debug).not.toHaveBeenCalled(); - }); - - it('allows mixing story parameters and decorators, and args/argTypes and deprecated story params', () => { - const { configure, clientApi } = makeMocks(); - - const decoratorOld = jest.fn(); - const decoratorNew = jest.fn(); - const input = { - a: { - default: { - title: 'a', - }, - x: Object.assign(() => 0, { - parameters: { x: 'y' }, - decorators: [decoratorNew], - args: { b: 1 }, - argTypes: { b: 'string' }, - story: { - parameters: { x: 'z', y: 'z' }, - decorators: [decoratorOld], - args: { b: 2, c: 2 }, - argTypes: { b: 'number', c: 'number' }, - }, - }), - }, - }; - configure('react', makeRequireContext(input), mod); - - const mockedStoriesOf = clientApi.storiesOf as jest.Mock; - const aApi = mockedStoriesOf.mock.results[0].value; - expect(aApi.add).toHaveBeenCalledWith('X', input.a.x, { - x: 'y', - y: 'z', - decorators: [decoratorNew, decoratorOld], - __id: 'a--x', - args: { b: 1, c: 2 }, - argTypes: { b: 'string', c: 'number' }, - loaders: [], - }); - expect(logger.debug).toHaveBeenCalled(); - }); - - it('handles HMR correctly when adding stories', () => { - const { configure, clientApi, storyStore } = makeMocks(); - - const firstInput = { - a: { - default: { - title: 'a', - }, - x: () => 0, - }, - }; - configure('react', makeRequireContext(firstInput), mod); - - // HMR dispose callbacks - doHMRDispose(); - - const mockedStoriesOf = clientApi.storiesOf as jest.Mock; - mockedStoriesOf.mockClear(); - const secondInput = { - ...firstInput, - b: { - default: { - title: 'b', - }, - x: () => 0, - }, - }; - configure('react', makeRequireContext(secondInput), mod); - - expect(storyStore.removeStoryKind).not.toHaveBeenCalled(); - expect(mockedStoriesOf).toHaveBeenCalledWith('b', true); - }); - - it('handles HMR correctly when removing stories', () => { - const { configure, clientApi, storyStore } = makeMocks(); - - const firstInput = { - a: { - default: { - title: 'a', - }, - x: () => 0, - }, - b: { - default: { - title: 'b', - }, - x: () => 0, - }, - }; - configure('react', makeRequireContext(firstInput), mod); - - // HMR dispose callbacks - doHMRDispose(); - - const mockedStoriesOf = clientApi.storiesOf as jest.Mock; - mockedStoriesOf.mockClear(); - const secondInput = { - a: firstInput.a, - }; - configure('react', makeRequireContext(secondInput), mod); - - expect(storyStore.removeStoryKind).toHaveBeenCalledWith('b'); - expect(mockedStoriesOf).not.toHaveBeenCalled(); - }); - - it('handles HMR correctly when changing stories', () => { - const { configure, clientApi, storyStore } = makeMocks(); - - const firstInput = { - a: { - default: { - title: 'a', - }, - x: () => 0, - }, - - b: { - default: { - title: 'b', - }, - x: () => 0, - }, - }; - configure('react', makeRequireContext(firstInput), mod); - - // HMR dispose callbacks - doHMRDispose(); - - const mockedStoriesOf = clientApi.storiesOf as jest.Mock; - mockedStoriesOf.mockClear(); - const secondInput = { - ...firstInput, - a: { - default: { - title: 'a', - }, - x: () => 0, - y: () => 0, - }, - }; - configure('react', makeRequireContext(secondInput), mod); - - expect(storyStore.removeStoryKind).toHaveBeenCalledTimes(1); - expect(storyStore.removeStoryKind).toHaveBeenCalledWith('a'); - expect(mockedStoriesOf).toHaveBeenCalledWith('a', true); - }); - - it('gives a warning if there are no exported stories', () => { - const { configure } = makeMocks(); - - const input = { - a: { - default: { - title: 'MissingExportsComponent', - }, - // no named exports, will not present a story - }, - }; - configure('react', makeRequireContext(input), mod); - expect(logger.warn).toHaveBeenCalled(); - }); - - it('does not give a warning if there are exported stories', () => { - const { configure } = makeMocks(); - - const input = { - a: { - default: { - title: 'MissingExportsComponent', - }, - x: () => 0, - }, - }; - configure('react', makeRequireContext(input), mod); - expect(logger.warn).not.toHaveBeenCalled(); - }); -}); diff --git a/lib/core-client/src/preview/loadCsf.ts b/lib/core-client/src/preview/loadCsf.ts deleted file mode 100644 index 421d8dba09e0..000000000000 --- a/lib/core-client/src/preview/loadCsf.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { ConfigApi, ClientApi, StoryStore } from '@storybook/client-api'; -import { isExportStory } from '@storybook/csf'; -import { logger } from '@storybook/client-logger'; -import dedent from 'ts-dedent'; -import deprecate from 'util-deprecate'; - -import { Loadable, LoaderFunction, RequireContext } from './types'; -import { normalizeStory } from './normalizeStory'; -import { autoTitle } from './autoTitle'; - -const duplicateKindWarning = deprecate( - (kindName: string) => { - logger.warn(`Duplicate title: '${kindName}'`); - }, - dedent` - Duplicate title used in multiple files; use unique titles or a primary file for a component with re-exported stories. - - https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#deprecated-support-for-duplicate-kinds - ` -); - -let previousExports = new Map(); -const loadStories = ( - loadable: Loadable, - framework: string, - { clientApi, storyStore }: { clientApi: ClientApi; storyStore: StoryStore } -) => () => { - // Make sure we don't try to define a kind more than once within the same load - const loadedKinds = new Set(); - - let reqs = null; - // todo discuss / improve type check - if (Array.isArray(loadable)) { - reqs = loadable; - } else if ((loadable as RequireContext).keys) { - reqs = [loadable as RequireContext]; - } - - let currentExports = new Map(); - if (reqs) { - reqs.forEach((req) => { - req.keys().forEach((filename: string) => { - try { - const fileExports = req(filename); - currentExports.set( - fileExports, - // todo discuss: types infer that this is RequireContext; no checks needed? - // NOTE: turns out `babel-plugin-require-context-hook` doesn't implement this (yet) - typeof req.resolve === 'function' ? req.resolve(filename) : filename - ); - } catch (error) { - const errorString = error.message && error.stack ? `${error.message}\n ${error.stack}` : error.toString(); - logger.warn(`Unexpected error while loading ${filename}: ${errorString}`); - } - }); - }); - } else { - const exported = (loadable as LoaderFunction)(); - if (Array.isArray(exported) && exported.every((obj) => obj.default != null)) { - currentExports = new Map(exported.map((fileExports) => [fileExports, null])); - } else if (exported) { - logger.warn( - `Loader function passed to 'configure' should return void or an array of module exports that all contain a 'default' export. Received: ${JSON.stringify( - exported - )}` - ); - } - } - - const removed = Array.from(previousExports.keys()).filter((exp) => !currentExports.has(exp)); - removed.forEach((exp) => { - if (exp.default) { - storyStore.removeStoryKind(exp.default.title); - } - }); - - const added = Array.from(currentExports.keys()).filter((exp) => !previousExports.has(exp)); - - added.forEach((fileExports) => { - // An old-style story file - if (!fileExports.default) { - return; - } - - const { default: defaultExport, __namedExportsOrder, ...namedExports } = fileExports; - let exports = namedExports; - - const fileName = currentExports.get(fileExports); - const title = autoTitle(defaultExport, fileName); - if (!title) { - throw new Error( - `Unexpected default export without title: ${JSON.stringify(fileExports.default)}` - ); - } - - const meta = { ...defaultExport, title }; - - // prefer a user/loader provided `__namedExportsOrder` array if supplied - // we do this as es module exports are always ordered alphabetically - // see https://github.com/storybookjs/storybook/issues/9136 - if (Array.isArray(__namedExportsOrder)) { - exports = {}; - __namedExportsOrder.forEach((name) => { - if (namedExports[name]) { - exports[name] = namedExports[name]; - } - }); - } - - const { - title: kindName, - parameters: kindParameters, - decorators: kindDecorators, - loaders: kindLoaders = [], - component, - subcomponents, - args: kindArgs, - argTypes: kindArgTypes, - } = meta; - - if (loadedKinds.has(kindName)) { - duplicateKindWarning(kindName); - } - loadedKinds.add(kindName); - - // We pass true here to avoid the warning about HMR. It's cool clientApi, we got this - // todo discuss: TS now wants a NodeModule; should we fix this differently? - const kind = clientApi.storiesOf(kindName, true as any); - - // we should always have a framework, rest optional - kind.addParameters({ - framework, - component, - subcomponents, - fileName, - ...kindParameters, - args: kindArgs, - argTypes: kindArgTypes, - }); - - // todo add type - (kindDecorators || []).forEach((decorator: any) => { - kind.addDecorator(decorator); - }); - - kindLoaders.forEach((loader: any) => { - kind.addLoader(loader); - }); - - const storyExports = Object.keys(exports); - if (storyExports.length === 0) { - logger.warn( - dedent` - Found a story file for "${kindName}" but no exported stories. - Check the docs for reference: https://storybook.js.org/docs/formats/component-story-format/ - ` - ); - return; - } - - storyExports.forEach((key) => { - if (isExportStory(key, meta)) { - const { name, storyFn, parameters } = normalizeStory( - key, - exports[key], - meta, - clientApi.globalRender - ); - kind.add(name, storyFn, parameters); - } - }); - }); - previousExports = currentExports; -}; - -const configureDeprecationWarning = deprecate( - () => {}, - `\`configure()\` is deprecated and will be removed in Storybook 7.0. -Please use the \`stories\` field of \`main.js\` to load stories. -Read more at https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#deprecated-configure` -); -let loaded = false; -export const loadCsf = ({ - clientApi, - storyStore, - configApi, -}: { - clientApi: ClientApi; - storyStore: StoryStore; - configApi: ConfigApi; -}) => - /** - * Load a collection of stories. If it has a default export, assume that it is a module-style - * file and process its named exports as stories. If not, assume it's an old-style - * storiesof file and require it. - * - * @param {*} framework - name of framework in use, e.g. "react" - * @param {*} loadable a require.context `req`, an array of `req`s, or a loader function that returns void or an array of exports - * @param {*} m - ES module object for hot-module-reloading (HMR) - * @param {boolean} showDeprecationWarning - show the deprecation warning (default true) - */ - (framework: string, loadable: Loadable, m: NodeModule, showDeprecationWarning = true) => { - if (showDeprecationWarning) { - configureDeprecationWarning(); - } - - if (typeof m === 'string') { - throw new Error( - `Invalid module '${m}'. Did you forget to pass \`module\` as the second argument to \`configure\`"?` - ); - } - - if (m && m.hot && m.hot.dispose) { - ({ previousExports = new Map() } = m.hot.data || {}); - m.hot.dispose((data) => { - loaded = false; - // eslint-disable-next-line no-param-reassign - data.previousExports = previousExports; - }); - m.hot.accept(); - } - if (loaded) { - logger.warn('Unexpected loaded state. Did you call `load` twice?'); - } - loaded = true; - - configApi.configure(loadStories(loadable, framework, { clientApi, storyStore }), m); - }; diff --git a/lib/core-client/src/preview/normalizeStory.test.ts b/lib/core-client/src/preview/normalizeStory.test.ts deleted file mode 100644 index ec424dfff2c3..000000000000 --- a/lib/core-client/src/preview/normalizeStory.test.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { normalizeV2, normalizeV3 } from './normalizeStory'; - -const globalRender = 'global-render'; - -describe('normalizeStory', () => { - describe('user-provided story function', () => { - describe('v2', () => { - it('should normalize into an object', () => { - const storyFn = () => {}; - const meta = { title: 'title' }; - expect(normalizeV2('storyExport', storyFn, meta, globalRender)).toMatchInlineSnapshot(` - Object { - "name": "Story Export", - "parameters": Object { - "__id": "title--story-export", - "argTypes": Object {}, - "args": Object {}, - "decorators": Array [], - "loaders": Array [], - }, - "storyFn": [Function], - } - `); - }); - }); - describe('v3', () => { - it('should normalize into an object', () => { - const storyFn = () => {}; - const meta = { title: 'title' }; - expect(normalizeV3('storyExport', storyFn, meta, globalRender)).toMatchInlineSnapshot(` - Object { - "name": "Story Export", - "parameters": Object { - "__id": "title--story-export", - "argTypes": Object {}, - "args": Object {}, - "decorators": Array [], - "loaders": Array [], - "play": undefined, - }, - "storyFn": [Function], - } - `); - }); - it('should throw on story annotation', async () => { - const storyFn = () => {}; - storyFn.story = { name: 'v1 style name' }; - const meta = { title: 'title' }; - await expect(async () => - normalizeV3('storyExport', storyFn, meta, globalRender) - ).rejects.toThrow(); - }); - }); - }); - describe('user-provided story object', () => { - describe('v2', () => { - it('should treat it the same as if it was a function', () => { - const storyObj = {}; - const meta = { title: 'title' }; - expect(normalizeV2('storyExport', storyObj, meta, globalRender)).toMatchInlineSnapshot(` - Object { - "name": "Story Export", - "parameters": Object { - "__id": "title--story-export", - "argTypes": Object {}, - "args": Object {}, - "decorators": Array [], - "loaders": Array [], - }, - "storyFn": Object {}, - } - `); - }); - }); - describe('v3', () => { - describe('render function', () => { - it('implicit render function', () => { - const storyObj = {}; - const meta = { title: 'title' }; - const normalized = normalizeV3('storyExport', storyObj, meta, globalRender); - expect(normalized.storyFn).toBe(globalRender); - }); - - it('user-provided story render function', () => { - const storyObj = { render: () => 'story' }; - const meta = { title: 'title', render: () => 'meta' }; - const normalized = normalizeV3('storyExport', storyObj, meta, globalRender); - expect(normalized.storyFn).toBe(storyObj.render); - }); - - it('user-provided meta render function', () => { - const storyObj = {}; - const meta = { title: 'title', render: () => 'meta' }; - const normalized = normalizeV3('storyExport', storyObj, meta, globalRender); - expect(normalized.storyFn).toBe(meta.render); - }); - }); - - describe('play function', () => { - it('no render function', () => { - const storyObj = {}; - const meta = { title: 'title' }; - const normalized = normalizeV3('storyExport', storyObj, meta, globalRender); - expect(normalized.parameters.play).toBeUndefined(); - }); - - it('user-provided story render function', () => { - const storyObj = { play: () => 'story' }; - const meta = { title: 'title', play: () => 'meta' }; - const normalized = normalizeV3('storyExport', storyObj, meta, globalRender); - expect(normalized.parameters.play).toBe(storyObj.play); - }); - - it('user-provided meta render function', () => { - const storyObj = {}; - const meta = { title: 'title', play: () => 'meta' }; - const normalized = normalizeV3('storyExport', storyObj, meta, globalRender); - expect(normalized.parameters.play).toBe(meta.play); - }); - }); - - describe('annotations', () => { - it('empty annotations', () => { - const storyObj = {}; - const meta = { title: 'title' }; - const normalized = normalizeV3('storyExport', storyObj, meta, globalRender); - expect(normalized).toMatchInlineSnapshot(` - Object { - "name": "Story Export", - "parameters": Object { - "__id": "title--story-export", - "argTypes": Object {}, - "args": Object {}, - "decorators": Array [], - "loaders": Array [], - "play": undefined, - }, - "storyFn": "global-render", - } - `); - }); - - it('full annotations', () => { - const storyObj = { - name: 'story name', - parameters: { storyParam: 'val' }, - decorators: [() => {}], - loaders: [() => {}], - args: { storyArg: 'val' }, - argTypes: { storyArgType: 'val' }, - }; - const meta = { title: 'title' }; - const normalized = normalizeV3('storyExport', storyObj, meta, globalRender); - expect(normalized).toMatchInlineSnapshot(` - Object { - "name": "story name", - "parameters": Object { - "__id": "title--story-export", - "argTypes": Object { - "storyArgType": "val", - }, - "args": Object { - "storyArg": "val", - }, - "decorators": Array [ - [Function], - ], - "loaders": Array [ - [Function], - ], - "play": undefined, - "storyParam": "val", - }, - "storyFn": "global-render", - } - `); - }); - - it('meta annotations', () => { - const storyObj = {}; - const meta = { - title: 'title', - parameters: { metaParam: 'val' }, - decorators: [() => {}], - loaders: [() => {}], - args: { metaArg: 'val' }, - argTypes: { metaArgType: 'val' }, - }; - const normalized = normalizeV3('storyExport', storyObj, meta, globalRender); - expect(normalized).toMatchInlineSnapshot(` - Object { - "name": "Story Export", - "parameters": Object { - "__id": "title--story-export", - "argTypes": Object {}, - "args": Object {}, - "decorators": Array [], - "loaders": Array [], - "play": undefined, - }, - "storyFn": "global-render", - } - `); - }); - }); - }); - }); -}); diff --git a/lib/core-client/src/preview/normalizeStory.ts b/lib/core-client/src/preview/normalizeStory.ts deleted file mode 100644 index b7bfe1c99b7f..000000000000 --- a/lib/core-client/src/preview/normalizeStory.ts +++ /dev/null @@ -1,102 +0,0 @@ -import global from 'global'; -import { logger } from '@storybook/client-logger'; -import { storyNameFromExport, toId } from '@storybook/csf'; -import dedent from 'ts-dedent'; -import deprecate from 'util-deprecate'; - -const { FEATURES = {} } = global; - -const deprecatedStoryAnnotation = dedent` -CSF .story annotations deprecated; annotate story functions directly: -- StoryFn.story.name => StoryFn.storyName -- StoryFn.story.(parameters|decorators) => StoryFn.(parameters|decorators) -See https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#hoisted-csf-annotations for details and codemod. -`; - -const deprecatedStoryAnnotationWarning = deprecate(() => {}, deprecatedStoryAnnotation); - -/** - * Utilities for normalizing a story to support different - */ -export const normalizeV2 = (key: string, storyExport: any, meta: any, globalRender: any) => { - const exportType = typeof storyExport; - if (exportType !== 'function') { - logger.info(`Unexpected story export "${key}": expected function, received "${exportType}".`); - } - - const storyFn = storyExport; - const { story } = storyFn; - const { storyName = story?.name } = storyFn; - - // storyFn.x and storyFn.story.x get merged with - // storyFn.x taking precedence in the merge - const storyParams = { ...story?.parameters, ...storyFn.parameters }; - const decorators = [...(storyFn.decorators || []), ...(story?.decorators || [])]; - const loaders = [...(storyFn.loaders || []), ...(story?.loaders || [])]; - const args = { ...story?.args, ...storyFn.args }; - const argTypes = { ...story?.argTypes, ...storyFn.argTypes }; - - if (story) { - logger.debug('deprecated story', story); - deprecatedStoryAnnotationWarning(); - } - - const exportName = storyNameFromExport(key); - const parameters = { - ...storyParams, - __id: toId(meta.id || meta.title, exportName), - decorators, - loaders, - args, - argTypes, - }; - - return { - name: storyName || exportName, - storyFn, - parameters, - }; -}; - -export const normalizeV3 = (key: string, storyExport: any, meta: any, globalRender: any) => { - let storyObject = storyExport; - if (typeof storyExport === 'function') { - storyObject = { ...storyExport }; - storyObject.render = storyExport; - } - - if (storyObject.story) { - throw new Error(deprecatedStoryAnnotation); - } - - const { - render, - play, - parameters: storyParams, - decorators = [], - loaders = [], - args = {}, - argTypes = {}, - } = storyObject; - - const storyFn = render || meta.render || globalRender; - const exportName = storyNameFromExport(key); - - const parameters = { - ...storyParams, - __id: toId(meta.id || meta.title, exportName), - decorators, - loaders, - args, - argTypes, - play: play || meta.play, - }; - - return { - name: storyObject.name || storyObject.storyName || exportName, - storyFn, - parameters, - }; -}; - -export const normalizeStory = FEATURES.previewCsfV3 ? normalizeV3 : normalizeV2; diff --git a/lib/core-client/src/preview/parseArgsParam.test.ts b/lib/core-client/src/preview/parseArgsParam.test.ts deleted file mode 100644 index 2fd4bfe24aa8..000000000000 --- a/lib/core-client/src/preview/parseArgsParam.test.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { parseArgsParam } from './parseArgsParam'; - -jest.mock('@storybook/client-logger', () => ({ - once: { warn: jest.fn() }, -})); - -describe('parseArgsParam', () => { - it('parses a simple key-value pair', () => { - const args = parseArgsParam('key:val'); - expect(args).toStrictEqual({ key: 'val' }); - }); - - it('parses spaces', () => { - const args = parseArgsParam('key:one+two+three'); - expect(args).toStrictEqual({ key: 'one two three' }); - }); - - it('parses null', () => { - const args = parseArgsParam('key:!null'); - expect(args).toStrictEqual({ key: null }); - }); - - it('parses undefined', () => { - const args = parseArgsParam('key:!undefined'); - expect(args).toStrictEqual({ key: undefined }); - }); - - it('parses hex color values', () => { - const args = parseArgsParam('key:!hex(ff4785)'); - expect(args).toStrictEqual({ key: '#ff4785' }); - }); - - it('parses rgba color values', () => { - const args = parseArgsParam('rgb:!rgb(255,71,133);rgba:!rgba(255,71,133,0.5)'); - expect(args).toStrictEqual({ rgb: 'rgb(255, 71, 133)', rgba: 'rgba(255, 71, 133, 0.5)' }); - }); - - it('parses hsla color values', () => { - const args = parseArgsParam('hsl:!hsl(45,99,70);hsla:!hsla(45,99,70,0.5)'); - expect(args).toStrictEqual({ hsl: 'hsl(45, 99%, 70%)', hsla: 'hsla(45, 99%, 70%, 0.5)' }); - }); - - it('parses Date', () => { - const args = parseArgsParam('key:!date(2001-02-03T04:05:06.789Z)'); - expect(args).toStrictEqual({ key: new Date('2001-02-03T04:05:06.789Z') }); - }); - - it('parses Date with timezone offset', () => { - const args = parseArgsParam('key:!date(2001-02-03T04:05:06.789+09:00)'); - expect(args).toStrictEqual({ key: new Date('2001-02-03T04:05:06.789+09:00') }); - }); - - it('parses Date without timezone', () => { - const args = parseArgsParam('key:!date(2001-02-03T04:05:06.789)'); - expect(args).toStrictEqual({ key: expect.any(Date) }); // depends on local timezone - }); - - it('parses Date without second fraction', () => { - const args = parseArgsParam('key:!date(2001-02-03T04:05:06Z)'); - expect(args).toStrictEqual({ key: new Date('2001-02-03T04:05:06.000Z') }); - }); - - it('parses Date without time', () => { - const args = parseArgsParam('key:!date(2001-02-03)'); - expect(args).toStrictEqual({ key: expect.any(Date) }); // depends on local timezone - }); - - it('does not parse Date without prefix', () => { - const args = parseArgsParam('key:2001-02-03T04:05:06.789Z'); - expect(args).toStrictEqual({}); - }); - - it('parses multiple values', () => { - const args = parseArgsParam('one:A;two:B;three:C'); - expect(args).toStrictEqual({ one: 'A', two: 'B', three: 'C' }); - }); - - it('parses arrays', () => { - const args = parseArgsParam('arr[]:A;arr[]:B;arr[]:C'); - expect(args).toStrictEqual({ arr: ['A', 'B', 'C'] }); - }); - - it('parses arrays with indices', () => { - const args = parseArgsParam('arr[0]:A;arr[1]:B;arr[2]:C'); - expect(args).toStrictEqual({ arr: ['A', 'B', 'C'] }); - }); - - it('parses sparse arrays', () => { - const args = parseArgsParam('arr[0]:A;arr[2]:C'); - // eslint-disable-next-line no-sparse-arrays - expect(args).toStrictEqual({ arr: ['A', , 'C'] }); - }); - - it('parses repeated values as arrays', () => { - const args = parseArgsParam('arr:A;arr:B;arr:C'); - expect(args).toStrictEqual({ arr: ['A', 'B', 'C'] }); - }); - - it('parses simple objects', () => { - const args = parseArgsParam('obj.one:A;obj.two:B'); - expect(args).toStrictEqual({ obj: { one: 'A', two: 'B' } }); - }); - - it('parses nested objects', () => { - const args = parseArgsParam('obj.foo.one:A;obj.foo.two:B;obj.bar.one:A'); - expect(args).toStrictEqual({ obj: { foo: { one: 'A', two: 'B' }, bar: { one: 'A' } } }); - }); - - it('parses arrays in objects', () => { - expect(parseArgsParam('obj.foo[]:A;obj.foo[]:B')).toStrictEqual({ obj: { foo: ['A', 'B'] } }); - expect(parseArgsParam('obj.foo[0]:A;obj.foo[1]:B')).toStrictEqual({ obj: { foo: ['A', 'B'] } }); - // eslint-disable-next-line no-sparse-arrays - expect(parseArgsParam('obj.foo[1]:B')).toStrictEqual({ obj: { foo: [, 'B'] } }); - expect(parseArgsParam('obj.foo:A;obj.foo:B')).toStrictEqual({ obj: { foo: ['A', 'B'] } }); - }); - - it('parses single object in array', () => { - const args = parseArgsParam('arr[].one:A;arr[].two:B'); - expect(args).toStrictEqual({ arr: [{ one: 'A', two: 'B' }] }); - }); - - it('parses multiple objects in array', () => { - expect(parseArgsParam('arr[0].key:A;arr[1].key:B')).toStrictEqual({ - arr: [{ key: 'A' }, { key: 'B' }], - }); - expect(parseArgsParam('arr[0][key]:A;arr[1][key]:B')).toStrictEqual({ - arr: [{ key: 'A' }, { key: 'B' }], - }); - }); - - it('parses nested object in array', () => { - expect(parseArgsParam('arr[].foo.bar:val')).toStrictEqual({ arr: [{ foo: { bar: 'val' } }] }); - expect(parseArgsParam('arr[][foo][bar]:val')).toStrictEqual({ arr: [{ foo: { bar: 'val' } }] }); - }); - - describe('key sanitization', () => { - it("omits keys that aren't in the extended alphanumeric set", () => { - expect(parseArgsParam('a`b:val')).toStrictEqual({}); - expect(parseArgsParam('a~b:val')).toStrictEqual({}); - expect(parseArgsParam('a!b:val')).toStrictEqual({}); - expect(parseArgsParam('a@b:val')).toStrictEqual({}); - expect(parseArgsParam('a#b:val')).toStrictEqual({}); - expect(parseArgsParam('a$b:val')).toStrictEqual({}); - expect(parseArgsParam('a%b:val')).toStrictEqual({}); - expect(parseArgsParam('a^b:val')).toStrictEqual({}); - expect(parseArgsParam('a&b:val')).toStrictEqual({}); - expect(parseArgsParam('a*b:val')).toStrictEqual({}); - expect(parseArgsParam('a(b:val')).toStrictEqual({}); - expect(parseArgsParam('a)b:val')).toStrictEqual({}); - expect(parseArgsParam('a=b:val')).toStrictEqual({}); - expect(parseArgsParam('"b":val')).toStrictEqual({}); - expect(parseArgsParam('a/b:val')).toStrictEqual({}); - expect(parseArgsParam('a\\b:val')).toStrictEqual({}); - expect(parseArgsParam('a|b:val')).toStrictEqual({}); - expect(parseArgsParam('a[b:val')).toStrictEqual({}); - expect(parseArgsParam('a]b:val')).toStrictEqual({}); - expect(parseArgsParam('a{b:val')).toStrictEqual({}); - expect(parseArgsParam('a}b:val')).toStrictEqual({}); - expect(parseArgsParam('a?b:val')).toStrictEqual({}); - expect(parseArgsParam('ab:val')).toStrictEqual({}); - expect(parseArgsParam('a,b:val')).toStrictEqual({}); - }); - - it('allows keys that are in the extended alphanumeric set', () => { - expect(parseArgsParam(' key :val')).toStrictEqual({ ' key ': 'val' }); - expect(parseArgsParam('+key+:val')).toStrictEqual({ ' key ': 'val' }); - expect(parseArgsParam('-key-:val')).toStrictEqual({ '-key-': 'val' }); - expect(parseArgsParam('_key_:val')).toStrictEqual({ _key_: 'val' }); - expect(parseArgsParam('KEY123:val')).toStrictEqual({ KEY123: 'val' }); - expect(parseArgsParam('1:val')).toStrictEqual({ '1': 'val' }); - }); - - it('also applies to nested object keys', () => { - expect(parseArgsParam('obj.a!b:val')).toStrictEqual({}); - expect(parseArgsParam('obj[a!b]:val')).toStrictEqual({}); - expect(parseArgsParam('arr[][a!b]:val')).toStrictEqual({}); - expect(parseArgsParam('arr[0][a!b]:val')).toStrictEqual({}); - }); - - it('completely omits an arg when a (deeply) nested key is invalid', () => { - expect(parseArgsParam('obj.foo.a!b:val;obj.foo.bar:val;obj.baz:val')).toStrictEqual({}); - expect(parseArgsParam('obj.foo[][a!b]:val;obj.foo.bar:val;obj.baz:val')).toStrictEqual({}); - expect(parseArgsParam('obj.foo.a!b:val;key:val')).toStrictEqual({ key: 'val' }); - }); - }); - - describe('value sanitization', () => { - it("omits values that aren't in the extended alphanumeric set", () => { - expect(parseArgsParam('key:a`b')).toStrictEqual({}); - expect(parseArgsParam('key:a~b')).toStrictEqual({}); - expect(parseArgsParam('key:a!b')).toStrictEqual({}); - expect(parseArgsParam('key:a@b')).toStrictEqual({}); - expect(parseArgsParam('key:a#b')).toStrictEqual({}); - expect(parseArgsParam('key:a$b')).toStrictEqual({}); - expect(parseArgsParam('key:a%b')).toStrictEqual({}); - expect(parseArgsParam('key:a^b')).toStrictEqual({}); - expect(parseArgsParam('key:a&b')).toStrictEqual({}); - expect(parseArgsParam('key:a*b')).toStrictEqual({}); - expect(parseArgsParam('key:a(b')).toStrictEqual({}); - expect(parseArgsParam('key:a)b')).toStrictEqual({}); - expect(parseArgsParam('key:a=b')).toStrictEqual({}); - expect(parseArgsParam('key:a[b')).toStrictEqual({}); - expect(parseArgsParam('key:a]b')).toStrictEqual({}); - expect(parseArgsParam('key:a{b')).toStrictEqual({}); - expect(parseArgsParam('key:a}b')).toStrictEqual({}); - expect(parseArgsParam('key:a\\b')).toStrictEqual({}); - expect(parseArgsParam('key:a|b')).toStrictEqual({}); - expect(parseArgsParam("key:a'b")).toStrictEqual({}); - expect(parseArgsParam('key:a"b')).toStrictEqual({}); - expect(parseArgsParam('key:a,b')).toStrictEqual({}); - expect(parseArgsParam('key:a.b')).toStrictEqual({}); - expect(parseArgsParam('key:ab')).toStrictEqual({}); - expect(parseArgsParam('key:a/b')).toStrictEqual({}); - expect(parseArgsParam('key:a?b')).toStrictEqual({}); - }); - - it('allows values that are in the extended alphanumeric set', () => { - expect(parseArgsParam('key: val ')).toStrictEqual({ key: ' val ' }); - expect(parseArgsParam('key:+val+')).toStrictEqual({ key: ' val ' }); - expect(parseArgsParam('key:_val_')).toStrictEqual({ key: '_val_' }); - expect(parseArgsParam('key:-val-')).toStrictEqual({ key: '-val-' }); - expect(parseArgsParam('key:VAL123')).toStrictEqual({ key: 'VAL123' }); - }); - - it('allows and parses valid (fractional) numbers', () => { - expect(parseArgsParam('key:1')).toStrictEqual({ key: 1 }); - expect(parseArgsParam('key:1.2')).toStrictEqual({ key: 1.2 }); - expect(parseArgsParam('key:-1.2')).toStrictEqual({ key: -1.2 }); - expect(parseArgsParam('key:1.')).toStrictEqual({}); - expect(parseArgsParam('key:.2')).toStrictEqual({}); - expect(parseArgsParam('key:1.2.3')).toStrictEqual({}); - }); - - it('also applies to nested object and array values', () => { - expect(parseArgsParam('obj.key:a!b')).toStrictEqual({}); - expect(parseArgsParam('obj[key]:a!b')).toStrictEqual({}); - expect(parseArgsParam('arr[][key]:a!b')).toStrictEqual({}); - expect(parseArgsParam('arr[0][key]:a!b')).toStrictEqual({}); - expect(parseArgsParam('arr[]:a!b')).toStrictEqual({}); - expect(parseArgsParam('arr[0]:a!b')).toStrictEqual({}); - }); - - it('completely omits an arg when a (deeply) nested value is invalid', () => { - expect(parseArgsParam('obj.key:a!b;obj.foo:val;obj.bar.baz:val')).toStrictEqual({}); - expect(parseArgsParam('obj.arr[]:a!b;obj.foo:val;obj.bar.baz:val')).toStrictEqual({}); - expect(parseArgsParam('obj.arr[0]:val;obj.arr[1]:a!b;obj.foo:val')).toStrictEqual({}); - expect(parseArgsParam('obj.arr[][one]:a!b;obj.arr[][two]:val')).toStrictEqual({}); - expect(parseArgsParam('arr[]:val;arr[]:a!b;key:val')).toStrictEqual({ key: 'val' }); - expect(parseArgsParam('arr[0]:val;arr[1]:a!1;key:val')).toStrictEqual({ key: 'val' }); - expect(parseArgsParam('arr[0]:val;arr[2]:a!1;key:val')).toStrictEqual({ key: 'val' }); - }); - }); -}); diff --git a/lib/core-client/src/preview/parseArgsParam.ts b/lib/core-client/src/preview/parseArgsParam.ts deleted file mode 100644 index 78b46618015f..000000000000 --- a/lib/core-client/src/preview/parseArgsParam.ts +++ /dev/null @@ -1,73 +0,0 @@ -import qs from 'qs'; -import dedent from 'ts-dedent'; -import { Args } from '@storybook/addons'; -import { once } from '@storybook/client-logger'; -import isPlainObject from 'lodash/isPlainObject'; - -// Keep this in sync with validateArgs in router/src/utils.ts -const VALIDATION_REGEXP = /^[a-zA-Z0-9 _-]*$/; -const NUMBER_REGEXP = /^-?[0-9]+(\.[0-9]+)?$/; -const HEX_REGEXP = /^#([a-f0-9]{3,4}|[a-f0-9]{6}|[a-f0-9]{8})$/i; -const COLOR_REGEXP = /^(rgba?|hsla?)\(([0-9]{1,3}),\s?([0-9]{1,3})%?,\s?([0-9]{1,3})%?,?\s?([0-9](\.[0-9]{1,2})?)?\)$/i; -const validateArgs = (key = '', value: unknown): boolean => { - if (key === null) return false; - if (key === '' || !VALIDATION_REGEXP.test(key)) return false; - if (value === null || value === undefined) return true; // encoded as `!null` or `!undefined` - if (value instanceof Date) return true; // encoded as modified ISO string - if (typeof value === 'number' || typeof value === 'boolean') return true; - if (typeof value === 'string') { - return ( - VALIDATION_REGEXP.test(value) || - NUMBER_REGEXP.test(value) || - HEX_REGEXP.test(value) || - COLOR_REGEXP.test(value) - ); - } - if (Array.isArray(value)) return value.every((v) => validateArgs(key, v)); - if (isPlainObject(value)) return Object.entries(value).every(([k, v]) => validateArgs(k, v)); - return false; -}; - -const QS_OPTIONS = { - delimiter: ';', // we're parsing a single query param - allowDots: true, // objects are encoded using dot notation - allowSparse: true, // arrays will be merged on top of their initial value - decoder( - str: string, - defaultDecoder: (str: string, decoder?: any, charset?: string) => string, - charset: string, - type: 'key' | 'value' - ) { - if (type === 'value' && str.startsWith('!')) { - if (str === '!undefined') return undefined; - if (str === '!null') return null; - if (str.startsWith('!date(') && str.endsWith(')')) return new Date(str.slice(6, -1)); - if (str.startsWith('!hex(') && str.endsWith(')')) return `#${str.slice(5, -1)}`; - - const color = str.slice(1).match(COLOR_REGEXP); - if (color) { - if (str.startsWith('!rgba')) - return `${color[1]}(${color[2]}, ${color[3]}, ${color[4]}, ${color[5]})`; - if (str.startsWith('!hsla')) - return `${color[1]}(${color[2]}, ${color[3]}%, ${color[4]}%, ${color[5]})`; - return str.startsWith('!rgb') - ? `${color[1]}(${color[2]}, ${color[3]}, ${color[4]})` - : `${color[1]}(${color[2]}, ${color[3]}%, ${color[4]}%)`; - } - } - if (type === 'value' && NUMBER_REGEXP.test(str)) return Number(str); - return defaultDecoder(str, defaultDecoder, charset); - }, -}; -export const parseArgsParam = (argsString: string): Args => { - const parts = argsString.split(';').map((part) => part.replace('=', '~').replace(':', '=')); - return Object.entries(qs.parse(parts.join(';'), QS_OPTIONS)).reduce((acc, [key, value]) => { - if (validateArgs(key, value)) return Object.assign(acc, { [key]: value }); - once.warn(dedent` - Omitted potentially unsafe URL args. - - More info: https://storybook.js.org/docs/react/writing-stories/args#setting-args-through-the-url - `); - return acc; - }, {} as Args); -}; diff --git a/lib/core-client/src/preview/start.test.ts b/lib/core-client/src/preview/start.test.ts index 9fdffa184303..4cf2c1b62514 100644 --- a/lib/core-client/src/preview/start.test.ts +++ b/lib/core-client/src/preview/start.test.ts @@ -1,134 +1,1118 @@ -import global from 'global'; +import Events from '@storybook/core-events'; -import start from './start'; +import { + waitForRender, + waitForEvents, + emitter, + mockChannel, +} from '@storybook/preview-web/dist/cjs/PreviewWeb.mockdata'; -const { document, window: globalWindow } = global; +import { start } from './start'; + +jest.mock('@storybook/preview-web/dist/cjs/WebView'); -jest.mock('@storybook/client-logger'); jest.mock('global', () => ({ + // @ts-ignore + ...jest.requireActual('global'), history: { replaceState: jest.fn() }, - location: { search: '' }, - navigator: { userAgent: 'browser', platform: '' }, - window: { - __STORYBOOK_CLIENT_API__: undefined, - addEventListener: jest.fn(), - postMessage: jest.fn(), - location: { search: '' }, - history: { replaceState: jest.fn() }, - matchMedia: jest.fn().mockReturnValue({ matches: false }), - }, document: { - addEventListener: jest.fn(), - getElementById: jest.fn().mockReturnValue({}), - body: { classList: { add: jest.fn(), remove: jest.fn() }, style: {} }, - documentElement: {}, - location: { search: '?id=kind--story&args=a:2;b:two;c:true' }, + location: { + pathname: 'pathname', + search: '?id=*', + }, + }, + FEATURES: { + breakingChangesV7: true, }, })); -afterEach(() => { - globalWindow.__STORYBOOK_CLIENT_API__ = undefined; +jest.mock('@storybook/channel-postmessage', () => () => mockChannel); +jest.mock('react-dom'); + +beforeEach(() => { + mockChannel.emit.mockClear(); + // Preview doesn't clean itself up as it isn't designed to ever be stopped :shrug: + emitter.removeAllListeners(); }); -it('returns apis', () => { - const render = jest.fn(); +describe('start', () => { + describe('when configure is called with storiesOf only', () => { + it('loads and renders the first story correctly', async () => { + const render = jest.fn(); - const result = start(render); + const { configure, clientApi } = start(render); - expect(result).toEqual( - expect.objectContaining({ - configure: expect.any(Function), - channel: expect.any(Object), - clientApi: expect.any(Object), - configApi: expect.any(Object), - forceReRender: expect.any(Function), - }) - ); -}); + configure('test', () => { + clientApi + .storiesOf('Component A', { id: 'file1' } as NodeModule) + .add('Story One', jest.fn()) + .add('Story Two', jest.fn()); -it('reuses the current client api when the lib is reloaded', () => { - const render = jest.fn(); + clientApi + .storiesOf('Component B', { id: 'file2' } as NodeModule) + .add('Story Three', jest.fn()); + }); - const { clientApi } = start(render); + 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 {}, + "Component B": Object {}, + }, + "stories": Array [ + 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 { + "__isArgsStory": false, + "fileName": "file1", + "framework": "test", + }, + "story": "Story One", + "subcomponents": undefined, + "title": "Component A", + }, + Object { + "argTypes": Object {}, + "args": Object {}, + "component": undefined, + "componentId": "component-a", + "id": "component-a--story-two", + "initialArgs": Object {}, + "kind": "Component A", + "name": "Story Two", + "parameters": Object { + "__isArgsStory": false, + "fileName": "file1", + "framework": "test", + }, + "story": "Story Two", + "subcomponents": undefined, + "title": "Component A", + }, + Object { + "argTypes": Object {}, + "args": Object {}, + "component": undefined, + "componentId": "component-b", + "id": "component-b--story-three", + "initialArgs": Object {}, + "kind": "Component B", + "name": "Story Three", + "parameters": Object { + "__isArgsStory": false, + "fileName": "file2", + "framework": "test", + }, + "story": "Story Three", + "subcomponents": undefined, + "title": "Component B", + }, + ], + "v": 2, + } + `); - const valueOfClientApi = globalWindow.__STORYBOOK_CLIENT_API__; + expect(mockChannel.emit).toHaveBeenCalledWith( + Events.STORY_RENDERED, + 'component-a--story-one' + ); - const { clientApi: newClientApi } = start(render); + expect(render).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'component-a--story-one', + }), + undefined + ); + }); - expect(clientApi).toEqual(newClientApi); - expect(clientApi).toEqual(valueOfClientApi); -}); + it('sends over docs only stories', async () => { + const render = jest.fn(); -// With async rendering we need to wait for various promises to resolve. -// Sleeping for 0 ms allows all the async (but instantaneous) calls to run -// through the event loop. -const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + const { configure, clientApi } = start(render); -it('calls render when you add a story', async () => { - const render = jest.fn(); + configure('test', () => { + clientApi + .storiesOf('Component A', { id: 'file1' } as NodeModule) + .add('Story One', jest.fn(), { docsOnly: true, docs: {} }); + }); - const { clientApi, configApi } = start(render); + 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": Array [ + 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 { + "__isArgsStory": false, + "docs": Object {}, + "docsOnly": true, + "fileName": "file1", + "framework": "test", + }, + "story": "Story One", + "subcomponents": undefined, + "title": "Component A", + }, + ], + "v": 2, + } + `); + }); - configApi.configure(() => { - clientApi.storiesOf('kind', {} as NodeModule).add('story', () => {}); - }, {} as NodeModule); + it('deals with stories with "default" name', async () => { + const render = jest.fn(); - await sleep(0); - expect(render).toHaveBeenCalledWith(expect.objectContaining({ kind: 'kind', name: 'story' })); -}); + const { configure, clientApi } = start(render); + + configure('test', () => { + clientApi.storiesOf('Component A', { id: 'file1' } as NodeModule).add('default', jest.fn()); + }); + + await waitForRender(); + + expect(mockChannel.emit).toHaveBeenCalledWith(Events.STORY_RENDERED, 'component-a--default'); + }); + + it('deals with storiesOf from the same file twice', async () => { + const render = jest.fn(); + + const { configure, clientApi } = start(render); + + configure('test', () => { + clientApi.storiesOf('Component A', { id: 'file1' } as NodeModule).add('default', jest.fn()); + clientApi.storiesOf('Component B', { id: 'file1' } as NodeModule).add('default', jest.fn()); + clientApi.storiesOf('Component C', { id: 'file1' } as NodeModule).add('default', jest.fn()); + }); + + await waitForEvents([Events.SET_STORIES]); + + 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 + )[1]; + expect(Object.values(storiesOfData.stories).map((s: any) => s.parameters.fileName)).toEqual([ + 'file1', + 'file1-2', + 'file1-3', + ]); + }); + + it('allows global metadata via client-api', async () => { + const render = jest.fn(({ storyFn }) => storyFn()); + + const { configure, clientApi } = start(render); + + const loader = jest.fn(async () => ({ val: 'loaded' })); + const decorator = jest.fn(); + configure('test', () => { + clientApi.addLoader(loader); + clientApi.addDecorator(decorator); + clientApi.addParameters({ param: 'global' }); + clientApi.storiesOf('Component A', { id: 'file1' } as NodeModule).add('default', jest.fn()); + }); + + await waitForRender(); + + expect(loader).toHaveBeenCalled(); + expect(decorator).toHaveBeenCalled(); + expect(render).toHaveBeenCalledWith( + expect.objectContaining({ + storyContext: expect.objectContaining({ + parameters: expect.objectContaining({ + framework: 'test', + param: 'global', + }), + }), + }), + undefined + ); + }); + + it('supports forceRerender()', async () => { + const render = jest.fn(({ storyFn }) => storyFn()); + + const { configure, clientApi, forceReRender } = start(render); + + configure('test', () => { + clientApi.storiesOf('Component A', { id: 'file1' } as NodeModule).add('default', jest.fn()); + }); + + await waitForRender(); + expect(mockChannel.emit).toHaveBeenCalledWith(Events.STORY_RENDERED, 'component-a--default'); + + mockChannel.emit.mockClear(); + forceReRender(); + + await waitForRender(); + expect(mockChannel.emit).toHaveBeenCalledWith(Events.STORY_RENDERED, 'component-a--default'); + }); + + it('supports HMR when a story file changes', async () => { + const render = jest.fn(({ storyFn }) => storyFn()); + + const { configure, clientApi, forceReRender } = start(render); + + let disposeCallback: () => void; + const module = { + id: 'file1', + hot: { + accept: jest.fn(), + dispose(cb: () => void) { + disposeCallback = cb; + }, + }, + }; + const firstImplementation = jest.fn(); + configure('test', () => { + clientApi.storiesOf('Component A', module as any).add('default', firstImplementation); + }); + + await waitForRender(); + expect(mockChannel.emit).toHaveBeenCalledWith(Events.STORY_RENDERED, 'component-a--default'); + expect(firstImplementation).toHaveBeenCalled(); + expect(module.hot.accept).toHaveBeenCalled(); + expect(disposeCallback).toBeDefined(); + + mockChannel.emit.mockClear(); + disposeCallback(); + const secondImplementation = jest.fn(); + clientApi.storiesOf('Component A', module as any).add('default', secondImplementation); + + await waitForRender(); + expect(mockChannel.emit).toHaveBeenCalledWith(Events.STORY_RENDERED, 'component-a--default'); + expect(secondImplementation).toHaveBeenCalled(); + }); + + it('re-emits SET_STORIES when a story is added', async () => { + const render = jest.fn(({ storyFn }) => storyFn()); + + const { configure, clientApi, forceReRender } = start(render); + + let disposeCallback: () => void; + const module = { + id: 'file1', + hot: { + accept: jest.fn(), + dispose(cb: () => void) { + disposeCallback = cb; + }, + }, + }; + configure('test', () => { + clientApi.storiesOf('Component A', module as any).add('default', jest.fn()); + }); + + await waitForRender(); + + mockChannel.emit.mockClear(); + disposeCallback(); + clientApi + .storiesOf('Component A', module as any) + .add('default', jest.fn()) + .add('new', jest.fn()); + + 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": Array [ + Object { + "argTypes": Object {}, + "args": Object {}, + "component": undefined, + "componentId": "component-a", + "id": "component-a--default", + "initialArgs": Object {}, + "kind": "Component A", + "name": "default", + "parameters": Object { + "__isArgsStory": false, + "fileName": "file1", + "framework": "test", + }, + "story": "default", + "subcomponents": undefined, + "title": "Component A", + }, + Object { + "argTypes": Object {}, + "args": Object {}, + "component": undefined, + "componentId": "component-a", + "id": "component-a--new", + "initialArgs": Object {}, + "kind": "Component A", + "name": "new", + "parameters": Object { + "__isArgsStory": false, + "fileName": "file1", + "framework": "test", + }, + "story": "new", + "subcomponents": undefined, + "title": "Component A", + }, + ], + "v": 2, + } + `); + }); + + it('re-emits SET_STORIES when a story file is removed', async () => { + const render = jest.fn(({ storyFn }) => storyFn()); -it('uses args from URL param when rendering story', async () => { - const render = jest.fn(); - const argTypes = { - a: { type: { name: 'number' }, defaultValue: 1 }, - b: { type: { name: 'number' }, defaultValue: 1 }, - c: { type: { name: 'boolean' } }, + const { configure, clientApi, forceReRender } = start(render); + + let disposeCallback: () => void; + const moduleB = { + id: 'file2', + hot: { + accept: jest.fn(), + dispose(cb: () => void) { + disposeCallback = cb; + }, + }, + }; + configure('test', () => { + clientApi.storiesOf('Component A', { id: 'file1' } as any).add('default', jest.fn()); + clientApi.storiesOf('Component B', moduleB as any).add('default', jest.fn()); + }); + + 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 {}, + "Component B": Object {}, + }, + "stories": Array [ + Object { + "argTypes": Object {}, + "args": Object {}, + "component": undefined, + "componentId": "component-a", + "id": "component-a--default", + "initialArgs": Object {}, + "kind": "Component A", + "name": "default", + "parameters": Object { + "__isArgsStory": false, + "fileName": "file1", + "framework": "test", + }, + "story": "default", + "subcomponents": undefined, + "title": "Component A", + }, + Object { + "argTypes": Object {}, + "args": Object {}, + "component": undefined, + "componentId": "component-b", + "id": "component-b--default", + "initialArgs": Object {}, + "kind": "Component B", + "name": "default", + "parameters": Object { + "__isArgsStory": false, + "fileName": "file2", + "framework": "test", + }, + "story": "default", + "subcomponents": undefined, + "title": "Component B", + }, + ], + "v": 2, + } + `); + mockChannel.emit.mockClear(); + disposeCallback(); + + 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": Array [ + Object { + "argTypes": Object {}, + "args": Object {}, + "component": undefined, + "componentId": "component-a", + "id": "component-a--default", + "initialArgs": Object {}, + "kind": "Component A", + "name": "default", + "parameters": Object { + "__isArgsStory": false, + "fileName": "file1", + "framework": "test", + }, + "story": "default", + "subcomponents": undefined, + "title": "Component A", + }, + ], + "v": 2, + } + `); + }); + }); + + const componentCExports = { + default: { + title: 'Component C', + }, + StoryOne: jest.fn(), + StoryTwo: jest.fn(), }; - const { clientApi, configApi } = start(render); + describe('when configure is called with CSF only', () => { + it('loads and renders the first story correctly', async () => { + const render = jest.fn(); - configApi.configure(() => { - clientApi.storiesOf('kind', {} as NodeModule).add('story', () => {}, { argTypes }); - }, {} as NodeModule); + const { configure } = start(render); + configure('test', () => [componentCExports]); - await sleep(0); - expect(render).toHaveBeenCalledWith( - expect.objectContaining({ kind: 'kind', name: 'story', args: { a: 2, b: NaN, c: true } }) - ); -}); + 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 C": Object {}, + }, + "stories": Array [ + Object { + "argTypes": Object {}, + "args": Object {}, + "component": undefined, + "componentId": "component-c", + "id": "component-c--story-one", + "initialArgs": Object {}, + "kind": "Component C", + "name": "Story One", + "parameters": Object { + "__isArgsStory": false, + "fileName": "exports-map-0", + "framework": "test", + }, + "story": "Story One", + "subcomponents": undefined, + "title": "Component C", + }, + Object { + "argTypes": Object {}, + "args": Object {}, + "component": undefined, + "componentId": "component-c", + "id": "component-c--story-two", + "initialArgs": Object {}, + "kind": "Component C", + "name": "Story Two", + "parameters": Object { + "__isArgsStory": false, + "fileName": "exports-map-0", + "framework": "test", + }, + "story": "Story Two", + "subcomponents": undefined, + "title": "Component C", + }, + ], + "v": 2, + } + `); + + expect(mockChannel.emit).toHaveBeenCalledWith( + Events.STORY_RENDERED, + 'component-c--story-one' + ); + + expect(render).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'component-c--story-one', + }), + undefined + ); + }); + + it('supports HMR when a story file changes', async () => { + const render = jest.fn(({ storyFn }) => storyFn()); + + let disposeCallback: (data: object) => void; + const module = { + id: 'file1', + hot: { + data: {}, + accept: jest.fn(), + dispose(cb: () => void) { + disposeCallback = cb; + }, + }, + }; + + const { configure } = start(render); + configure('test', () => [componentCExports], module as any); + + await waitForRender(); + expect(mockChannel.emit).toHaveBeenCalledWith( + Events.STORY_RENDERED, + 'component-c--story-one' + ); + expect(componentCExports.StoryOne).toHaveBeenCalled(); + expect(module.hot.accept).toHaveBeenCalled(); + expect(disposeCallback).toBeDefined(); + + mockChannel.emit.mockClear(); + disposeCallback(module.hot.data); + const secondImplementation = jest.fn(); + configure( + 'test', + () => [{ ...componentCExports, StoryOne: secondImplementation }], + module as any + ); + + await waitForRender(); + expect(mockChannel.emit).toHaveBeenCalledWith( + Events.STORY_RENDERED, + 'component-c--story-one' + ); + expect(secondImplementation).toHaveBeenCalled(); + }); + + it('re-emits SET_STORIES when a story is added', async () => { + const render = jest.fn(({ storyFn }) => storyFn()); + + let disposeCallback: (data: object) => void; + const module = { + id: 'file1', + hot: { + data: {}, + accept: jest.fn(), + dispose(cb: () => void) { + disposeCallback = cb; + }, + }, + }; + const { configure } = start(render); + configure('test', () => [componentCExports], module as any); + + await waitForRender(); + + mockChannel.emit.mockClear(); + disposeCallback(module.hot.data); + configure('test', () => [{ ...componentCExports, StoryThree: jest.fn() }], module as any); + + 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 C": Object {}, + }, + "stories": Array [ + Object { + "argTypes": Object {}, + "args": Object {}, + "component": undefined, + "componentId": "component-c", + "id": "component-c--story-one", + "initialArgs": Object {}, + "kind": "Component C", + "name": "Story One", + "parameters": Object { + "__isArgsStory": false, + "fileName": "exports-map-0", + "framework": "test", + }, + "story": "Story One", + "subcomponents": undefined, + "title": "Component C", + }, + Object { + "argTypes": Object {}, + "args": Object {}, + "component": undefined, + "componentId": "component-c", + "id": "component-c--story-two", + "initialArgs": Object {}, + "kind": "Component C", + "name": "Story Two", + "parameters": Object { + "__isArgsStory": false, + "fileName": "exports-map-0", + "framework": "test", + }, + "story": "Story Two", + "subcomponents": undefined, + "title": "Component C", + }, + Object { + "argTypes": Object {}, + "args": Object {}, + "component": undefined, + "componentId": "component-c", + "id": "component-c--story-three", + "initialArgs": Object {}, + "kind": "Component C", + "name": "Story Three", + "parameters": Object { + "__isArgsStory": false, + "fileName": "exports-map-0", + "framework": "test", + }, + "story": "Story Three", + "subcomponents": undefined, + "title": "Component C", + }, + ], + "v": 2, + } + `); + }); + + it('re-emits SET_STORIES when a story file is removed', async () => { + const render = jest.fn(({ storyFn }) => storyFn()); -it('emits an exception and shows error when your story throws', async () => { - const render = jest.fn().mockImplementation(() => { - throw new Error('Some exception'); + let disposeCallback: (data: object) => void; + const module = { + id: 'file1', + hot: { + data: {}, + accept: jest.fn(), + dispose(cb: () => void) { + disposeCallback = cb; + }, + }, + }; + const { configure } = start(render); + configure( + 'test', + () => [componentCExports, { default: { title: 'Component D' }, StoryFour: jest.fn() }], + module as any + ); + + 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 C": Object {}, + "Component D": Object {}, + }, + "stories": Array [ + Object { + "argTypes": Object {}, + "args": Object {}, + "component": undefined, + "componentId": "component-c", + "id": "component-c--story-one", + "initialArgs": Object {}, + "kind": "Component C", + "name": "Story One", + "parameters": Object { + "__isArgsStory": false, + "fileName": "exports-map-0", + "framework": "test", + }, + "story": "Story One", + "subcomponents": undefined, + "title": "Component C", + }, + Object { + "argTypes": Object {}, + "args": Object {}, + "component": undefined, + "componentId": "component-c", + "id": "component-c--story-two", + "initialArgs": Object {}, + "kind": "Component C", + "name": "Story Two", + "parameters": Object { + "__isArgsStory": false, + "fileName": "exports-map-0", + "framework": "test", + }, + "story": "Story Two", + "subcomponents": undefined, + "title": "Component C", + }, + Object { + "argTypes": Object {}, + "args": Object {}, + "component": undefined, + "componentId": "component-d", + "id": "component-d--story-four", + "initialArgs": Object {}, + "kind": "Component D", + "name": "Story Four", + "parameters": Object { + "__isArgsStory": false, + "fileName": "exports-map-1", + "framework": "test", + }, + "story": "Story Four", + "subcomponents": undefined, + "title": "Component D", + }, + ], + "v": 2, + } + `); + + mockChannel.emit.mockClear(); + disposeCallback(module.hot.data); + configure('test', () => [componentCExports], module as any); + + 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 C": Object {}, + }, + "stories": Array [ + Object { + "argTypes": Object {}, + "args": Object {}, + "component": undefined, + "componentId": "component-c", + "id": "component-c--story-one", + "initialArgs": Object {}, + "kind": "Component C", + "name": "Story One", + "parameters": Object { + "__isArgsStory": false, + "fileName": "exports-map-0", + "framework": "test", + }, + "story": "Story One", + "subcomponents": undefined, + "title": "Component C", + }, + Object { + "argTypes": Object {}, + "args": Object {}, + "component": undefined, + "componentId": "component-c", + "id": "component-c--story-two", + "initialArgs": Object {}, + "kind": "Component C", + "name": "Story Two", + "parameters": Object { + "__isArgsStory": false, + "fileName": "exports-map-0", + "framework": "test", + }, + "story": "Story Two", + "subcomponents": undefined, + "title": "Component C", + }, + ], + "v": 2, + } + `); + }); }); - const { clientApi, configApi } = start(render); + describe('when configure is called with a combination', () => { + it('loads and renders the first story correctly', async () => { + const render = jest.fn(); - configApi.configure(() => { - clientApi.storiesOf('kind', {} as NodeModule).add('story1', () => {}); - }, {} as NodeModule); + const { configure, clientApi } = start(render); + configure('test', () => { + clientApi + .storiesOf('Component A', { id: 'file1' } as NodeModule) + .add('Story One', jest.fn()) + .add('Story Two', jest.fn()); - await sleep(0); - expect(render).toHaveBeenCalled(); - expect(document.body.classList.add).toHaveBeenCalledWith('sb-show-errordisplay'); -}); + clientApi + .storiesOf('Component B', { id: 'file2' } as NodeModule) + .add('Story Three', jest.fn()); -it('emits an error and shows error when your framework calls showError', async () => { - const error = { - title: 'Some error', - description: 'description', - }; - const render = jest.fn().mockImplementation(({ showError }) => { - showError(error); + return [componentCExports]; + }); + + 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 {}, + "Component B": Object {}, + "Component C": Object {}, + }, + "stories": Array [ + 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 { + "__isArgsStory": false, + "fileName": "file1", + "framework": "test", + }, + "story": "Story One", + "subcomponents": undefined, + "title": "Component A", + }, + Object { + "argTypes": Object {}, + "args": Object {}, + "component": undefined, + "componentId": "component-a", + "id": "component-a--story-two", + "initialArgs": Object {}, + "kind": "Component A", + "name": "Story Two", + "parameters": Object { + "__isArgsStory": false, + "fileName": "file1", + "framework": "test", + }, + "story": "Story Two", + "subcomponents": undefined, + "title": "Component A", + }, + Object { + "argTypes": Object {}, + "args": Object {}, + "component": undefined, + "componentId": "component-b", + "id": "component-b--story-three", + "initialArgs": Object {}, + "kind": "Component B", + "name": "Story Three", + "parameters": Object { + "__isArgsStory": false, + "fileName": "file2", + "framework": "test", + }, + "story": "Story Three", + "subcomponents": undefined, + "title": "Component B", + }, + Object { + "argTypes": Object {}, + "args": Object {}, + "component": undefined, + "componentId": "component-c", + "id": "component-c--story-one", + "initialArgs": Object {}, + "kind": "Component C", + "name": "Story One", + "parameters": Object { + "__isArgsStory": false, + "fileName": "exports-map-0", + "framework": "test", + }, + "story": "Story One", + "subcomponents": undefined, + "title": "Component C", + }, + Object { + "argTypes": Object {}, + "args": Object {}, + "component": undefined, + "componentId": "component-c", + "id": "component-c--story-two", + "initialArgs": Object {}, + "kind": "Component C", + "name": "Story Two", + "parameters": Object { + "__isArgsStory": false, + "fileName": "exports-map-0", + "framework": "test", + }, + "story": "Story Two", + "subcomponents": undefined, + "title": "Component C", + }, + ], + "v": 2, + } + `); + + expect(mockChannel.emit).toHaveBeenCalledWith( + Events.STORY_RENDERED, + 'component-a--story-one' + ); + + expect(render).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'component-a--story-one', + }), + undefined + ); + }); }); - const { clientApi, configApi } = start(render); + // These tests need to be in here, as they require a convoluted hookup between + // a ClientApi and a StoryStore + describe('ClientApi.getStorybook', () => { + it('should transform the storybook to an array with filenames, empty', () => { + const { configure, clientApi } = start(jest.fn()); + + configure('test', () => {}); + expect(clientApi.getStorybook()).toEqual([]); + }); + + it('should transform the storybook to an array with filenames, full', () => { + const { configure, clientApi } = start(jest.fn()); - configApi.configure(() => { - clientApi.storiesOf('kind', {} as NodeModule).add('story', () => {}); - }, {} as NodeModule); + configure('test', () => { + clientApi + .storiesOf('kind 1', { id: 'file1' } as any) + .add('name 1', () => '1') + .add('name 2', () => '2'); - await sleep(0); - expect(render).toHaveBeenCalled(); - expect(document.body.classList.add).toHaveBeenCalledWith('sb-show-errordisplay'); + clientApi + .storiesOf('kind 2', { id: 'file2' } as any) + .add('name 1', () => '1') + .add('name 2', () => '2'); + }); + + expect(clientApi.getStorybook()).toEqual([ + expect.objectContaining({ + fileName: expect.any(String), + kind: 'kind 1', + stories: [ + { + name: 'name 1', + render: expect.any(Function), + }, + { + name: 'name 2', + render: expect.any(Function), + }, + ], + }), + expect.objectContaining({ + fileName: expect.any(String), + kind: 'kind 2', + stories: [ + { + name: 'name 1', + render: expect.any(Function), + }, + { + name: 'name 2', + render: expect.any(Function), + }, + ], + }), + ]); + }); + + it('reads filename from module', async () => { + const { configure, clientApi } = start(jest.fn()); + + const fn = jest.fn(); + configure('test', () => { + clientApi.storiesOf('kind', { id: 'foo.js' } as NodeModule).add('name', fn); + }); + + const storybook = clientApi.getStorybook(); + + expect(storybook).toEqual([ + { + kind: 'kind', + fileName: 'foo.js', + stories: [ + { + name: 'name', + render: expect.any(Function), + }, + ], + }, + ]); + }); + + it('should stringify ids from module', async () => { + const { configure, clientApi } = start(jest.fn()); + + const fn = jest.fn(); + configure('test', () => { + clientApi.storiesOf('kind', { id: 1211 } as any).add('name', fn); + }); + + const storybook = clientApi.getStorybook(); + + expect(storybook).toEqual([ + { + kind: 'kind', + fileName: '1211', + stories: [ + { + name: 'name', + render: expect.any(Function), + }, + ], + }, + ]); + }); + }); }); diff --git a/lib/core-client/src/preview/start.ts b/lib/core-client/src/preview/start.ts index 3769a61b263d..ec47f0e5b7ad 100644 --- a/lib/core-client/src/preview/start.ts +++ b/lib/core-client/src/preview/start.ts @@ -1,102 +1,92 @@ import global from 'global'; - -import { addons, DecorateStoryFunction, Channel } from '@storybook/addons'; +import { ClientApi } from '@storybook/client-api'; +import { WebProjectAnnotations, PreviewWeb } from '@storybook/preview-web'; +import { AnyFramework, ArgsStoryFn } from '@storybook/csf'; import createChannel from '@storybook/channel-postmessage'; -import { ClientApi, ConfigApi, StoryStore } from '@storybook/client-api'; +import { addons } from '@storybook/addons'; import Events from '@storybook/core-events'; +import { Path } from '@storybook/store'; -import { getSelectionSpecifierFromPath, setPath } from './url'; -import { RenderStoryFunction } from './types'; -import { loadCsf } from './loadCsf'; -import { StoryRenderer } from './StoryRenderer'; +import { Loadable } from './types'; +import { executeLoadableForChanges } from './executeLoadable'; -const { navigator, window: globalWindow } = global; -const isBrowser = - navigator && - navigator.userAgent && - navigator.userAgent !== 'storyshots' && - !(navigator.userAgent.indexOf('Node.js') > -1) && - !(navigator.userAgent.indexOf('jsdom') > -1); +const { window: globalWindow } = global; -function getOrCreateChannel() { - let channel = null; - if (isBrowser) { - try { - channel = addons.getChannel(); - } catch (e) { - channel = createChannel({ page: 'preview' }); - addons.setChannel(channel); - } - } +export function start( + renderToDOM: WebProjectAnnotations['renderToDOM'], + { + decorateStory, + render, + }: { + decorateStory?: WebProjectAnnotations['applyDecorators']; + render?: ArgsStoryFn; + } = {} +) { + const channel = createChannel({ page: 'preview' }); + addons.setChannel(channel); - return channel; -} + let preview: PreviewWeb; + const clientApi = new ClientApi(); -function getClientApi(decorateStory: DecorateStoryFunction, channel?: Channel) { - let storyStore: StoryStore; - let clientApi: ClientApi; - if ( - typeof globalWindow !== 'undefined' && - globalWindow.__STORYBOOK_CLIENT_API__ && - globalWindow.__STORYBOOK_STORY_STORE__ - ) { - clientApi = globalWindow.__STORYBOOK_CLIENT_API__; - storyStore = globalWindow.__STORYBOOK_STORY_STORE__; - } else { - storyStore = new StoryStore({ channel }); - clientApi = new ClientApi({ storyStore, decorateStory }); + if (globalWindow) { + globalWindow.__STORYBOOK_CLIENT_API__ = clientApi; + globalWindow.__STORYBOOK_ADDONS_CHANNEL__ = channel; } - return { clientApi, storyStore }; -} -function focusInInput(event: Event) { - const target = event.target as Element; - return /input|textarea/i.test(target.tagName) || target.getAttribute('contenteditable') !== null; -} + return { + forceReRender: () => channel.emit(Events.FORCE_RE_RENDER), + getStorybook: (): void[] => [], + raw: (): void => {}, -// todo improve typings -export default function start( - render: RenderStoryFunction, - { decorateStory }: { decorateStory?: DecorateStoryFunction } = {} -) { - const channel = getOrCreateChannel(); - const { clientApi, storyStore } = getClientApi(decorateStory, channel); - const configApi = new ConfigApi({ storyStore }); - const storyRenderer = new StoryRenderer({ render, channel, storyStore }); + clientApi, + // This gets called each time the user calls configure (i.e. once per HMR) + // The first time, it constructs the preview, subsequently it updates it + configure(framework: string, loadable: Loadable, m?: NodeModule) { + clientApi.addParameters({ framework }); - // Only try and do URL/event based stuff in a browser context (i.e. not in storyshots) - if (isBrowser) { - const selectionSpecifier = getSelectionSpecifierFromPath(); - if (selectionSpecifier) { - storyStore.setSelectionSpecifier(selectionSpecifier); - } + // We need to run the `executeLoadableForChanges` function *inside* the `getProjectAnnotations + // function in case it throws. So we also need to process its output there also + const getProjectAnnotations = () => { + const { added, removed } = executeLoadableForChanges(loadable, m); - channel.on(Events.CURRENT_STORY_WAS_SET, setPath); + Array.from(added.entries()).forEach(([fileName, fileExports]) => + clientApi.facade.addStoriesFromExports(fileName, fileExports) + ); - // Handle keyboard shortcuts - globalWindow.onkeydown = (event: KeyboardEvent) => { - if (!focusInInput(event)) { - // We have to pick off the keys of the event that we need on the other side - const { altKey, ctrlKey, metaKey, shiftKey, key, code, keyCode } = event; - channel.emit(Events.PREVIEW_KEYDOWN, { - event: { altKey, ctrlKey, metaKey, shiftKey, key, code, keyCode }, + Array.from(removed.entries()).forEach(([fileName]) => + clientApi.facade.clearFilenameExports(fileName) + ); + + return { + ...clientApi.facade.projectAnnotations, + render, + renderToDOM, + applyDecorators: decorateStory, + }; + }; + + if (!preview) { + preview = new PreviewWeb({ + importFn: (path: Path) => clientApi.importFn(path), + getProjectAnnotations, + fetchStoryIndex: () => clientApi.fetchStoryIndex(), }); - } - }; - } + if (globalWindow) { + // eslint-disable-next-line no-underscore-dangle + globalWindow.__STORYBOOK_PREVIEW__ = preview; + globalWindow.__STORYBOOK_STORY_STORE__ = preview.storyStore; + } - if (typeof globalWindow !== 'undefined') { - globalWindow.__STORYBOOK_CLIENT_API__ = clientApi; - globalWindow.__STORYBOOK_STORY_STORE__ = storyStore; - globalWindow.__STORYBOOK_ADDONS_CHANNEL__ = channel; // may not be defined - } + // These two bits are a bit ugly, but due to dependencies, `ClientApi` cannot have + // direct reference to `PreviewWeb`, so we need to patch in bits + clientApi.onImportFnChanged = preview.onImportFnChanged.bind(preview); + clientApi.storyStore = preview.storyStore; - const configure = loadCsf({ clientApi, storyStore, configApi }); - return { - configure, - clientApi, - configApi, - channel, - forceReRender: () => storyRenderer.forceReRender(), + preview.initialize({ cacheAllCSFFiles: true, sync: true }); + } else { + getProjectAnnotations(); + preview.onImportFnChanged({ importFn: (path: Path) => clientApi.importFn(path) }); + } + }, }; } diff --git a/lib/core-client/src/preview/url.test.ts b/lib/core-client/src/preview/url.test.ts deleted file mode 100644 index a63b7c3d9ca8..000000000000 --- a/lib/core-client/src/preview/url.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import global from 'global'; - -import { pathToId, setPath, parseQueryParameters, getSelectionSpecifierFromPath } from './url'; - -const { history, document } = global; - -jest.mock('global', () => ({ - history: { replaceState: jest.fn() }, - document: { - location: { - pathname: 'pathname', - search: '', - }, - }, -})); - -describe('url', () => { - describe('pathToId', () => { - it('should parse valid ids', () => { - expect(pathToId('/story/story--id')).toEqual('story--id'); - }); - it('should error on invalid ids', () => { - [null, '', '/whatever/story/story--id'].forEach((path) => { - expect(() => pathToId(path)).toThrow(/Invalid/); - }); - }); - }); - - describe('setPath', () => { - it('should navigate to storyId', () => { - setPath({ storyId: 'story--id', viewMode: 'story' }); - expect(history.replaceState).toHaveBeenCalledWith( - {}, - '', - 'pathname?id=story--id&viewMode=story' - ); - }); - it('should replace legacy parameters but preserve others', () => { - document.location.search = 'foo=bar&selectedStory=selStory&selectedKind=selKind'; - setPath({ storyId: 'story--id', viewMode: 'story' }); - expect(history.replaceState).toHaveBeenCalledWith( - {}, - '', - 'pathname?foo=bar&id=story--id&viewMode=story' - ); - }); - it('should ignore + keep hashes', () => { - document.location.search = 'foo=bar&selectedStory=selStory&selectedKind=selKind'; - document.location.hash = '#foobar'; - setPath({ storyId: 'story--id', viewMode: 'story' }); - expect(history.replaceState).toHaveBeenCalledWith( - {}, - '', - 'pathname?foo=bar&id=story--id&viewMode=story#foobar' - ); - }); - }); - - describe('parseQueryParameters', () => { - it('should parse id', () => { - expect(parseQueryParameters('?foo=bar&id=story--id')).toBe('story--id'); - }); - it('should not parse non-ids', () => { - expect(parseQueryParameters('')).toBeUndefined(); - }); - }); - - describe('getSelectionSpecifierFromPath', () => { - it('should handle no search', () => { - document.location.search = ''; - expect(getSelectionSpecifierFromPath()).toEqual(null); - }); - it('should handle id queries', () => { - document.location.search = '?id=story--id'; - expect(getSelectionSpecifierFromPath()).toEqual({ - storySpecifier: 'story--id', - viewMode: 'story', - singleStory: false, - }); - }); - it('should handle id queries with *', () => { - document.location.search = '?id=*'; - expect(getSelectionSpecifierFromPath()).toEqual({ - storySpecifier: '*', - viewMode: 'story', - singleStory: false, - }); - }); - it('should redirect legacy queries', () => { - document.location.search = '?selectedKind=kind&selectedStory=story'; - expect(getSelectionSpecifierFromPath()).toEqual({ - storySpecifier: { kind: 'kind', name: 'story' }, - viewMode: 'story', - singleStory: false, - }); - }); - it('should parse args', () => { - document.location.search = '?id=story--id&args=obj.key:val'; - expect(getSelectionSpecifierFromPath()).toEqual({ - storySpecifier: 'story--id', - viewMode: 'story', - singleStory: false, - args: { obj: { key: 'val' } }, - }); - }); - it('should handle singleStory param', () => { - document.location.search = '?id=abc&singleStory=true'; - expect(getSelectionSpecifierFromPath()).toEqual({ - storySpecifier: 'abc', - viewMode: 'story', - singleStory: true, - }); - }); - }); -}); diff --git a/lib/core-client/src/preview/url.ts b/lib/core-client/src/preview/url.ts deleted file mode 100644 index 38dda280cbd0..000000000000 --- a/lib/core-client/src/preview/url.ts +++ /dev/null @@ -1,95 +0,0 @@ -import global from 'global'; -import qs from 'qs'; -import deprecate from 'util-deprecate'; -import { StoreSelectionSpecifier, StoreSelection } from '@storybook/client-api'; -import { StoryId, ViewMode } from '@storybook/addons'; - -import { parseArgsParam } from './parseArgsParam'; - -const { history, document } = global; - -export function pathToId(path: string) { - const match = (path || '').match(/^\/story\/(.+)/); - if (!match) { - throw new Error(`Invalid path '${path}', must start with '/story/'`); - } - return match[1]; -} - -// todo add proper types -export const setPath = (selection?: StoreSelection) => { - if (!selection) { - return; - } - - const { storyId, viewMode }: { storyId: StoryId; viewMode: ViewMode } = selection; - const { search = '', hash = '' } = document.location; - const { path, selectedKind, selectedStory, ...rest } = qs.parse(search, { - ignoreQueryPrefix: true, - }); - const query = qs.stringify( - { ...rest, id: storyId, viewMode }, - { encode: false, addQueryPrefix: true } - ); - - history.replaceState({}, '', `${document.location.pathname}${query}${hash}`); -}; - -export const parseQueryParameters = (search: string) => { - const { id } = qs.parse(search, { ignoreQueryPrefix: true }); - return id; -}; - -type ValueOf = T[keyof T]; -const isObject = (val: Record) => - val != null && typeof val === 'object' && Array.isArray(val) === false; - -const getFirstString = (v: ValueOf): string | void => { - if (typeof v === 'string') { - return v; - } - if (Array.isArray(v)) { - return getFirstString(v[0]); - } - if (isObject(v)) { - // @ts-ignore - return getFirstString(Object.values(v)); - } - return undefined; -}; - -const deprecatedLegacyQuery = deprecate( - () => 0, - `URL formats with \`selectedKind\` and \`selectedName\` query parameters are deprecated. -Use \`id=$storyId\` instead. -See https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#new-url-structure` -); - -export const getSelectionSpecifierFromPath: () => StoreSelectionSpecifier = () => { - const query = qs.parse(document.location.search, { ignoreQueryPrefix: true }); - const args = typeof query.args === 'string' ? parseArgsParam(query.args) : undefined; - const globals = typeof query.globals === 'string' ? parseArgsParam(query.globals) : undefined; - - let viewMode = getFirstString(query.viewMode) as ViewMode; - if (typeof viewMode !== 'string' || !viewMode.match(/docs|story/)) { - viewMode = 'story'; - } - - const singleStory = getFirstString(query.singleStory) === 'true'; - const path = getFirstString(query.path); - const storyId = path ? pathToId(path) : getFirstString(query.id); - - if (storyId) { - return { storySpecifier: storyId, args, globals, viewMode, singleStory }; - } - - // Legacy URL format - const kind = getFirstString(query.selectedKind); - const name = getFirstString(query.selectedStory); - - if (kind && name) { - deprecatedLegacyQuery(); - return { storySpecifier: { kind, name }, args, globals, viewMode, singleStory }; - } - return null; -}; diff --git a/lib/core-client/typings.d.ts b/lib/core-client/typings.d.ts index faea2fff4fbe..87e788474eab 100644 --- a/lib/core-client/typings.d.ts +++ b/lib/core-client/typings.d.ts @@ -1,4 +1,6 @@ declare module 'ansi-to-html'; +declare module '@storybook/preview-web/dist/cjs/PreviewWeb.mockdata'; + declare class AnsiToHtml { constructor(options: { escapeHtml: boolean });