diff --git a/code/lib/cli/src/upgrade.test.ts b/code/lib/cli/src/upgrade.test.ts index 5e16a20ae938..0c70a5ad2e22 100644 --- a/code/lib/cli/src/upgrade.test.ts +++ b/code/lib/cli/src/upgrade.test.ts @@ -34,6 +34,8 @@ describe.each([ ['@storybook/preset-create-react-app', false], ['@storybook/linter-config', false], ['@storybook/design-system', false], + ['@storybook/addon-styling', false], + ['@storybook/addon-styling-webpack', false], ['@nx/storybook', false], ['@nrwl/storybook', false], ])('isCorePackage', (input, output) => { diff --git a/code/lib/cli/src/upgrade.ts b/code/lib/cli/src/upgrade.ts index af8e53ce6a95..a125e77a950f 100644 --- a/code/lib/cli/src/upgrade.ts +++ b/code/lib/cli/src/upgrade.ts @@ -29,6 +29,8 @@ const excludeList = [ '@storybook/addon-bench', '@storybook/addon-console', '@storybook/addon-postcss', + '@storybook/addon-styling', + '@storybook/addon-styling-webpack', '@storybook/babel-plugin-require-context-hook', '@storybook/bench', '@storybook/builder-vite', diff --git a/code/lib/manager-api/src/index.tsx b/code/lib/manager-api/src/index.tsx index 52c2f3000986..688b4a2d6837 100644 --- a/code/lib/manager-api/src/index.tsx +++ b/code/lib/manager-api/src/index.tsx @@ -65,6 +65,7 @@ import * as version from './modules/versions'; import * as whatsnew from './modules/whatsnew'; import * as globals from './modules/globals'; +import type { ModuleFn } from './lib/types'; export * from './lib/shortcut'; @@ -76,14 +77,6 @@ export { ActiveTabs }; export const ManagerContext = createContext({ api: undefined, state: getInitialState({}) }); -export type ModuleArgs = RouterData & - API_ProviderData & { - mode?: 'production' | 'development'; - state: State; - fullAPI: API; - store: Store; - }; - export type State = layout.SubState & stories.SubState & refs.SubState & @@ -152,28 +145,10 @@ export const combineParameters = (...parameterSets: Parameters[]) => return undefined; }); -interface ModuleWithInit { - init: () => void | Promise; - api: APIType; - state: StateType; -} - -type ModuleWithoutInit = Omit< - ModuleWithInit, - 'init' ->; - -export type ModuleFn = ( - m: ModuleArgs, - options?: any -) => HasInit extends true - ? ModuleWithInit - : ModuleWithoutInit; - class ManagerProvider extends Component { api: API = {} as API; - modules: (ModuleWithInit | ModuleWithoutInit)[]; + modules: ReturnType[]; static displayName = 'Manager'; diff --git a/code/lib/manager-api/src/lib/types.tsx b/code/lib/manager-api/src/lib/types.tsx new file mode 100644 index 000000000000..a195f514999e --- /dev/null +++ b/code/lib/manager-api/src/lib/types.tsx @@ -0,0 +1,22 @@ +import type { API_ProviderData } from '@storybook/types'; +import type { RouterData } from '@storybook/router'; + +import type { API, State } from '../index'; +import type Store from '../store'; + +export type ModuleFn = ( + m: ModuleArgs, + options?: any +) => { + init?: () => void | Promise; + api: APIType; + state: StateType; +}; + +export type ModuleArgs = RouterData & + API_ProviderData & { + mode?: 'production' | 'development'; + state: State; + fullAPI: API; + store: Store; + }; diff --git a/code/lib/manager-api/src/modules/addons.ts b/code/lib/manager-api/src/modules/addons.ts index 87027262d631..84fd51d7f206 100644 --- a/code/lib/manager-api/src/modules/addons.ts +++ b/code/lib/manager-api/src/modules/addons.ts @@ -7,7 +7,7 @@ import type { API_StateMerger, } from '@storybook/types'; import { Addon_TypesEnum } from '@storybook/types'; -import type { ModuleFn } from '../index'; +import type { ModuleFn } from '../lib/types'; import type { Options } from '../store'; export interface SubState { diff --git a/code/lib/manager-api/src/modules/channel.ts b/code/lib/manager-api/src/modules/channel.ts index e6c178ae32b4..c83c342c5253 100644 --- a/code/lib/manager-api/src/modules/channel.ts +++ b/code/lib/manager-api/src/modules/channel.ts @@ -3,7 +3,8 @@ import { STORIES_COLLAPSE_ALL, STORIES_EXPAND_ALL } from '@storybook/core-events import type { Listener } from '@storybook/channels'; import type { API_Provider } from '@storybook/types'; -import type { API, ModuleFn } from '../index'; +import type { API } from '../index'; +import type { ModuleFn } from '../lib/types'; export interface SubAPI { /** diff --git a/code/lib/manager-api/src/modules/globals.ts b/code/lib/manager-api/src/modules/globals.ts index 9b8d47069564..393deb58c4a2 100644 --- a/code/lib/manager-api/src/modules/globals.ts +++ b/code/lib/manager-api/src/modules/globals.ts @@ -3,7 +3,7 @@ import { logger } from '@storybook/client-logger'; import { dequal as deepEqual } from 'dequal'; import type { SetGlobalsPayload, Globals, GlobalTypes } from '@storybook/types'; -import type { ModuleFn } from '../index'; +import type { ModuleFn } from '../lib/types'; // eslint-disable-next-line import/no-cycle import { getEventMetadata } from '../lib/events'; @@ -32,7 +32,7 @@ export interface SubAPI { updateGlobals: (newGlobals: Globals) => void; } -export const init: ModuleFn = ({ store, fullAPI }) => { +export const init: ModuleFn = ({ store, fullAPI, provider }) => { const api: SubAPI = { getGlobals() { return store.getState().globals; @@ -42,7 +42,7 @@ export const init: ModuleFn = ({ store, fullAPI }) => { }, updateGlobals(newGlobals) { // Only emit the message to the local ref - fullAPI.emit(UPDATE_GLOBALS, { + provider.channel.emit(UPDATE_GLOBALS, { globals: newGlobals, options: { target: 'storybook-preview-iframe', @@ -62,8 +62,9 @@ export const init: ModuleFn = ({ store, fullAPI }) => { } }; - const initModule = () => { - fullAPI.on(GLOBALS_UPDATED, function handleGlobalsUpdated({ globals }: { globals: Globals }) { + provider.channel.on( + GLOBALS_UPDATED, + function handleGlobalsUpdated({ globals }: { globals: Globals }) { const { ref } = getEventMetadata(this, fullAPI); if (!ref) { @@ -73,10 +74,13 @@ export const init: ModuleFn = ({ store, fullAPI }) => { 'received a GLOBALS_UPDATED from a non-local ref. This is not currently supported.' ); } - }); + } + ); - // Emitted by the preview on initialization - fullAPI.on(SET_GLOBALS, function handleSetStories({ globals, globalTypes }: SetGlobalsPayload) { + // Emitted by the preview on initialization + provider.channel.on( + SET_GLOBALS, + function handleSetStories({ globals, globalTypes }: SetGlobalsPayload) { const { ref } = getEventMetadata(this, fullAPI); const currentGlobals = store.getState()?.globals; @@ -93,12 +97,11 @@ export const init: ModuleFn = ({ store, fullAPI }) => { ) { api.updateGlobals(currentGlobals); } - }); - }; + } + ); return { api, state, - init: initModule, }; }; diff --git a/code/lib/manager-api/src/modules/layout.ts b/code/lib/manager-api/src/modules/layout.ts index bbe43af9863d..f37e51e19942 100644 --- a/code/lib/manager-api/src/modules/layout.ts +++ b/code/lib/manager-api/src/modules/layout.ts @@ -7,7 +7,8 @@ import type { ThemeVars } from '@storybook/theming'; import type { API_Layout, API_PanelPositions, API_UI } from '@storybook/types'; import merge from '../lib/merge'; -import type { State, ModuleFn } from '../index'; +import type { State } from '../index'; +import type { ModuleFn } from '../lib/types'; const { document } = global; @@ -284,7 +285,7 @@ export const init: ModuleFn = ({ store, provider, singleStory, fullAPI }) => { state: merge(api.getInitialOptions(), persisted), init: () => { api.setOptions(merge(api.getInitialOptions(), persisted)); - fullAPI.on(SET_CONFIG, () => { + provider.channel.on(SET_CONFIG, () => { api.setOptions(merge(api.getInitialOptions(), persisted)); }); }, diff --git a/code/lib/manager-api/src/modules/notifications.ts b/code/lib/manager-api/src/modules/notifications.ts index 1f1059dc1939..83e95d3928ca 100644 --- a/code/lib/manager-api/src/modules/notifications.ts +++ b/code/lib/manager-api/src/modules/notifications.ts @@ -1,5 +1,5 @@ import type { API_Notification } from '@storybook/types'; -import type { ModuleFn } from '../index'; +import type { ModuleFn } from '../lib/types'; export interface SubState { notifications: API_Notification[]; diff --git a/code/lib/manager-api/src/modules/provider.ts b/code/lib/manager-api/src/modules/provider.ts index 272fc0d1839c..c150bf90bf8b 100644 --- a/code/lib/manager-api/src/modules/provider.ts +++ b/code/lib/manager-api/src/modules/provider.ts @@ -1,11 +1,11 @@ import type { API_IframeRenderer } from '@storybook/types'; -import type { ModuleFn } from '../index'; +import type { ModuleFn } from '../lib/types'; export interface SubAPI { renderPreview?: API_IframeRenderer; } -export const init: ModuleFn = ({ provider, fullAPI }) => { +export const init: ModuleFn = ({ provider, fullAPI }) => { return { api: provider.renderPreview ? { renderPreview: provider.renderPreview } : {}, state: {}, diff --git a/code/lib/manager-api/src/modules/refs.ts b/code/lib/manager-api/src/modules/refs.ts index 5fe2462dfbb3..3ca68f09be89 100644 --- a/code/lib/manager-api/src/modules/refs.ts +++ b/code/lib/manager-api/src/modules/refs.ts @@ -15,7 +15,7 @@ import { transformStoryIndexToStoriesHash, } from '../lib/stories'; -import type { ModuleFn } from '../index'; +import type { ModuleFn } from '../lib/types'; const { location, fetch } = global; @@ -154,7 +154,7 @@ const map = ( return input; }; -export const init: ModuleFn = ( +export const init: ModuleFn = ( { store, provider, singleStory, docsOptions = {} }, { runCheck = true } = {} ) => { diff --git a/code/lib/manager-api/src/modules/settings.ts b/code/lib/manager-api/src/modules/settings.ts index 2439a5954bcc..4c850f9aca1a 100644 --- a/code/lib/manager-api/src/modules/settings.ts +++ b/code/lib/manager-api/src/modules/settings.ts @@ -1,5 +1,5 @@ import type { API_Settings, StoryId } from '@storybook/types'; -import type { ModuleFn } from '../index'; +import type { ModuleFn } from '../lib/types'; export interface SubAPI { storeSelection: () => void; @@ -78,5 +78,8 @@ export const init: ModuleFn = ({ store, navigate, fullAPI }) = }, }; - return { state: { settings: { lastTrackedStoryId: null } }, api }; + return { + state: { settings: { lastTrackedStoryId: null } }, + api, + }; }; diff --git a/code/lib/manager-api/src/modules/shortcuts.ts b/code/lib/manager-api/src/modules/shortcuts.ts index 8dcf942f4bc7..be5592e98118 100644 --- a/code/lib/manager-api/src/modules/shortcuts.ts +++ b/code/lib/manager-api/src/modules/shortcuts.ts @@ -2,7 +2,7 @@ import { global } from '@storybook/global'; import { FORCE_REMOUNT, PREVIEW_KEYDOWN } from '@storybook/core-events'; -import type { ModuleFn } from '../index'; +import type { ModuleFn } from '../lib/types'; import type { KeyboardEventLike } from '../lib/shortcut'; import { shortcutMatchesShortcut, eventToShortcut } from '../lib/shortcut'; @@ -152,7 +152,7 @@ function focusInInput(event: KeyboardEvent) { return /input|textarea/i.test(target.tagName) || target.getAttribute('contenteditable') !== null; } -export const init: ModuleFn = ({ store, fullAPI }) => { +export const init: ModuleFn = ({ store, fullAPI, provider }) => { const api: SubAPI = { // Getting and setting shortcuts getShortcutKeys(): API_Shortcuts { @@ -397,13 +397,13 @@ export const init: ModuleFn = ({ store, fullAPI }) => { // Listen for keydown events in the manager document.addEventListener('keydown', (event: KeyboardEvent) => { if (!focusInInput(event)) { - fullAPI.handleKeydownEvent(event); + api.handleKeydownEvent(event); } }); // Also listen to keydown events sent over the channel - fullAPI.on(PREVIEW_KEYDOWN, (data: { event: KeyboardEventLike }) => { - fullAPI.handleKeydownEvent(data.event); + provider.channel.on(PREVIEW_KEYDOWN, (data: { event: KeyboardEventLike }) => { + api.handleKeydownEvent(data.event); }); }; diff --git a/code/lib/manager-api/src/modules/stories.ts b/code/lib/manager-api/src/modules/stories.ts index 78b8b086e106..b295af6730db 100644 --- a/code/lib/manager-api/src/modules/stories.ts +++ b/code/lib/manager-api/src/modules/stories.ts @@ -21,6 +21,7 @@ import type { API_ViewMode, API_StatusState, API_StatusUpdate, + API_FilterFunction, } from '@storybook/types'; import { PRELOAD_ENTRIES, @@ -39,6 +40,7 @@ import { STORY_MISSING, DOCS_PREPARED, SET_CURRENT_STORY, + SET_CONFIG, } from '@storybook/core-events'; import { logger } from '@storybook/client-logger'; @@ -53,7 +55,8 @@ import { addPreparedStories, } from '../lib/stories'; -import type { ComposedRef, ModuleFn } from '../index'; +import type { ComposedRef } from '../index'; +import type { ModuleFn } from '../lib/types'; const { FEATURES, fetch } = global; const STORY_INDEX_PATH = './index.json'; @@ -71,6 +74,7 @@ export interface SubState extends API_LoadedRefData { storyId: StoryId; viewMode: API_ViewMode; status: API_StatusState; + filters: Record; } export interface SubAPI { @@ -259,6 +263,14 @@ export interface SubAPI { * @returns {Promise} A promise that resolves when the status has been updated. */ experimental_updateStatus: (addonId: string, update: API_StatusUpdate) => Promise; + /** + * Updates the filtering of the index. + * + * @param {string} addonId - The ID of the addon to update. + * @param {API_FilterFunction} filterFunction - A function that returns a boolean based on the story, index and status. + * @returns {Promise} A promise that resolves when the state has been updated. + */ + experimental_setFilter: (addonId: string, filterFunction: API_FilterFunction) => Promise; } const removedOptions = ['enableShortcuts', 'theme', 'showRoots']; @@ -278,7 +290,7 @@ function removeRemovedOptions = Record = ({ +export const init: ModuleFn = ({ fullAPI, store, navigate, @@ -468,7 +480,7 @@ export const init: ModuleFn = ({ }, updateStoryArgs: (story, updatedArgs) => { const { id: storyId, refId } = story; - fullAPI.emit(UPDATE_STORY_ARGS, { + provider.channel.emit(UPDATE_STORY_ARGS, { storyId, updatedArgs, options: { target: refId }, @@ -476,7 +488,7 @@ export const init: ModuleFn = ({ }, resetStoryArgs: (story, argNames?: [string]) => { const { id: storyId, refId } = story; - fullAPI.emit(RESET_STORY_ARGS, { + provider.channel.emit(RESET_STORY_ARGS, { storyId, argNames, options: { target: refId }, @@ -495,7 +507,7 @@ export const init: ModuleFn = ({ return; } - await fullAPI.setIndex(storyIndex); + await api.setIndex(storyIndex); } catch (err) { await store.setState({ indexError: err }); } @@ -503,7 +515,7 @@ export const init: ModuleFn = ({ // The story index we receive on SET_INDEX is "prepared" in that it has parameters // The story index we receive on fetchStoryIndex is not, but all the prepared fields are optional // so we can cast one to the other easily enough - setIndex: async (storyIndex: API_PreparedStoryIndex) => { + setIndex: async (storyIndex) => { const newHash = transformStoryIndexToStoriesHash(storyIndex, { provider, docsOptions, @@ -556,7 +568,7 @@ export const init: ModuleFn = ({ await fullAPI.updateRef(refId, { index }); } }, - setPreviewInitialized: async (ref?: ComposedRef): Promise => { + setPreviewInitialized: async (ref) => { if (!ref) { store.setState({ previewInitialized: true }); } else { @@ -576,180 +588,193 @@ export const init: ModuleFn = ({ await store.setState({ status: newStatus }, { persistence: 'session' }); }, + experimental_setFilter: async (id, filterFunction) => { + await store.setState({ filters: { ...store.getState().filters, [id]: filterFunction } }); + }, }; - const initModule = async () => { - // On initial load, the local iframe will select the first story (or other "selection specifier") - // and emit STORY_SPECIFIED with the id. We need to ensure we respond to this change. - fullAPI.on( - STORY_SPECIFIED, - function handler({ - storyId, - viewMode, - }: { - storyId: string; - viewMode: API_ViewMode; - [k: string]: any; - }) { - const { sourceType } = getEventMetadata(this, fullAPI); - - if (sourceType === 'local') { - const state = store.getState(); - const isCanvasRoute = - state.path === '/' || state.viewMode === 'story' || state.viewMode === 'docs'; - const stateHasSelection = state.viewMode && state.storyId; - const stateSelectionDifferent = state.viewMode !== viewMode || state.storyId !== storyId; - /** - * When storybook starts, we want to navigate to the first story. - * But there are a few exceptions: - * - If the current storyId and viewMode are already set/correct. - * - If the user has navigated away already. - * - If the user started storybook with a specific page-URL like "/settings/about" - */ - if (isCanvasRoute) { - if (stateHasSelection && stateSelectionDifferent) { - // The manager state is correct, the preview state is lagging behind - fullAPI.emit(SET_CURRENT_STORY, { storyId: state.storyId, viewMode: state.viewMode }); - } else if (stateSelectionDifferent) { - // The preview state is correct, the manager state is lagging behind - navigate(`/${viewMode}/${storyId}`); - } + // On initial load, the local iframe will select the first story (or other "selection specifier") + // and emit STORY_SPECIFIED with the id. We need to ensure we respond to this change. + provider.channel.on( + STORY_SPECIFIED, + function handler({ + storyId, + viewMode, + }: { + storyId: string; + viewMode: API_ViewMode; + [k: string]: any; + }) { + const { sourceType } = getEventMetadata(this, fullAPI); + + if (sourceType === 'local') { + const state = store.getState(); + const isCanvasRoute = + state.path === '/' || state.viewMode === 'story' || state.viewMode === 'docs'; + const stateHasSelection = state.viewMode && state.storyId; + const stateSelectionDifferent = state.viewMode !== viewMode || state.storyId !== storyId; + /** + * When storybook starts, we want to navigate to the first story. + * But there are a few exceptions: + * - If the current storyId and viewMode are already set/correct. + * - If the user has navigated away already. + * - If the user started storybook with a specific page-URL like "/settings/about" + */ + if (isCanvasRoute) { + if (stateHasSelection && stateSelectionDifferent) { + // The manager state is correct, the preview state is lagging behind + provider.channel.emit(SET_CURRENT_STORY, { + storyId: state.storyId, + viewMode: state.viewMode, + }); + } else if (stateSelectionDifferent) { + // The preview state is correct, the manager state is lagging behind + navigate(`/${viewMode}/${storyId}`); } } } - ); - - // The CURRENT_STORY_WAS_SET event is the best event to use to tell if a ref is ready. - // Until the ref has a selection, it will not render anything (e.g. while waiting for - // the preview.js file or the index to load). Once it has a selection, it will render its own - // preparing spinner. - fullAPI.on(CURRENT_STORY_WAS_SET, function handler() { - const { ref } = getEventMetadata(this, fullAPI); - fullAPI.setPreviewInitialized(ref); - }); + } + ); + + // The CURRENT_STORY_WAS_SET event is the best event to use to tell if a ref is ready. + // Until the ref has a selection, it will not render anything (e.g. while waiting for + // the preview.js file or the index to load). Once it has a selection, it will render its own + // preparing spinner. + provider.channel.on(CURRENT_STORY_WAS_SET, function handler() { + const { ref } = getEventMetadata(this, fullAPI); + api.setPreviewInitialized(ref); + }); - fullAPI.on(STORY_CHANGED, function handler() { - const { sourceType } = getEventMetadata(this, fullAPI); + provider.channel.on(STORY_CHANGED, function handler() { + const { sourceType } = getEventMetadata(this, fullAPI); - if (sourceType === 'local') { - const options = fullAPI.getCurrentParameter('options'); + if (sourceType === 'local') { + const options = api.getCurrentParameter('options'); - if (options) { - fullAPI.setOptions(removeRemovedOptions(options)); - } + if (options) { + fullAPI.setOptions(removeRemovedOptions(options)); } - }); + } + }); - fullAPI.on(STORY_PREPARED, function handler({ id, ...update }: StoryPreparedPayload) { - const { ref, sourceType } = getEventMetadata(this, fullAPI); - fullAPI.updateStory(id, { ...update, prepared: true }, ref); + provider.channel.on(STORY_PREPARED, function handler({ id, ...update }: StoryPreparedPayload) { + const { ref, sourceType } = getEventMetadata(this, fullAPI); + api.updateStory(id, { ...update, prepared: true }, ref); - if (!ref) { - if (!store.getState().hasCalledSetOptions) { - const { options } = update.parameters; - fullAPI.setOptions(removeRemovedOptions(options)); - store.setState({ hasCalledSetOptions: true }); - } + if (!ref) { + if (!store.getState().hasCalledSetOptions) { + const { options } = update.parameters; + fullAPI.setOptions(removeRemovedOptions(options)); + store.setState({ hasCalledSetOptions: true }); } + } - if (sourceType === 'local') { - const { storyId, index, refId } = store.getState(); - - // create a list of related stories to be preloaded - const toBePreloaded = Array.from( - new Set([ - api.findSiblingStoryId(storyId, index, 1, true), - api.findSiblingStoryId(storyId, index, -1, true), - ]) - ).filter(Boolean); - - fullAPI.emit(PRELOAD_ENTRIES, { - ids: toBePreloaded, - options: { target: refId }, - }); - } - }); + if (sourceType === 'local') { + const { storyId, index, refId } = store.getState(); - fullAPI.on(DOCS_PREPARED, function handler({ id, ...update }: DocsPreparedPayload) { - const { ref } = getEventMetadata(this, fullAPI); - fullAPI.updateStory(id, { ...update, prepared: true }, ref); - }); + // create a list of related stories to be preloaded + const toBePreloaded = Array.from( + new Set([ + api.findSiblingStoryId(storyId, index, 1, true), + api.findSiblingStoryId(storyId, index, -1, true), + ]) + ).filter(Boolean); - fullAPI.on(SET_INDEX, function handler(index: API_PreparedStoryIndex) { - const { ref } = getEventMetadata(this, fullAPI); + provider.channel.emit(PRELOAD_ENTRIES, { + ids: toBePreloaded, + options: { target: refId }, + }); + } + }); - if (!ref) { - fullAPI.setIndex(index); - const options = fullAPI.getCurrentParameter('options'); - fullAPI.setOptions(removeRemovedOptions(options)); - } else { - fullAPI.setRef(ref.id, { ...ref, storyIndex: index }, true); - } - }); + provider.channel.on(DOCS_PREPARED, function handler({ id, ...update }: DocsPreparedPayload) { + const { ref } = getEventMetadata(this, fullAPI); + api.updateStory(id, { ...update, prepared: true }, ref); + }); + + provider.channel.on(SET_INDEX, function handler(index: API_PreparedStoryIndex) { + const { ref } = getEventMetadata(this, fullAPI); + + if (!ref) { + api.setIndex(index); + const options = api.getCurrentParameter('options'); + fullAPI.setOptions(removeRemovedOptions(options)); + } else { + fullAPI.setRef(ref.id, { ...ref, storyIndex: index }, true); + } + }); + + // For composition back-compatibilty + provider.channel.on(SET_STORIES, function handler(data: SetStoriesPayload) { + const { ref } = getEventMetadata(this, fullAPI); + const setStoriesData = data.v ? denormalizeStoryParameters(data) : data.stories; + + if (!ref) { + throw new Error('Cannot call SET_STORIES for local frame'); + } else { + fullAPI.setRef(ref.id, { ...ref, setStoriesData }, true); + } + }); - // For composition back-compatibilty - fullAPI.on(SET_STORIES, function handler(data: SetStoriesPayload) { + provider.channel.on( + SELECT_STORY, + function handler({ + kind, + title = kind, + story, + name = story, + storyId, + ...rest + }: { + kind?: StoryKind; + title?: ComponentTitle; + story?: StoryName; + name?: StoryName; + storyId: string; + viewMode: API_ViewMode; + }) { const { ref } = getEventMetadata(this, fullAPI); - const setStoriesData = data.v ? denormalizeStoryParameters(data) : data.stories; if (!ref) { - throw new Error('Cannot call SET_STORIES for local frame'); + fullAPI.selectStory(storyId || title, name, rest); } else { - fullAPI.setRef(ref.id, { ...ref, setStoriesData }, true); + fullAPI.selectStory(storyId || title, name, { ...rest, ref: ref.id }); } - }); - - fullAPI.on( - SELECT_STORY, - function handler({ - kind, - title = kind, - story, - name = story, - storyId, - ...rest - }: { - kind?: StoryKind; - title?: ComponentTitle; - story?: StoryName; - name?: StoryName; - storyId: string; - viewMode: API_ViewMode; - }) { - const { ref } = getEventMetadata(this, fullAPI); - - if (!ref) { - fullAPI.selectStory(storyId || title, name, rest); - } else { - fullAPI.selectStory(storyId || title, name, { ...rest, ref: ref.id }); - } - } - ); - - fullAPI.on( - STORY_ARGS_UPDATED, - function handleStoryArgsUpdated({ storyId, args }: { storyId: StoryId; args: Args }) { - const { ref } = getEventMetadata(this, fullAPI); - fullAPI.updateStory(storyId, { args }, ref); - } - ); + } + ); - // When there's a preview error, we don't show it in the manager, but simply - fullAPI.on(CONFIG_ERROR, function handleConfigError(err) { + provider.channel.on( + STORY_ARGS_UPDATED, + function handleStoryArgsUpdated({ storyId, args }: { storyId: StoryId; args: Args }) { const { ref } = getEventMetadata(this, fullAPI); - fullAPI.setPreviewInitialized(ref); - }); + api.updateStory(storyId, { args }, ref); + } + ); - fullAPI.on(STORY_MISSING, function handleConfigError(err) { - const { ref } = getEventMetadata(this, fullAPI); - fullAPI.setPreviewInitialized(ref); - }); + // When there's a preview error, we don't show it in the manager, but simply + provider.channel.on(CONFIG_ERROR, function handleConfigError(err) { + const { ref } = getEventMetadata(this, fullAPI); + api.setPreviewInitialized(ref); + }); - if (FEATURES?.storyStoreV7) { - fullAPI.on(STORY_INDEX_INVALIDATED, () => fullAPI.fetchIndex()); - await fullAPI.fetchIndex(); + provider.channel.on(STORY_MISSING, function handleConfigError(err) { + const { ref } = getEventMetadata(this, fullAPI); + api.setPreviewInitialized(ref); + }); + + provider.channel.on(SET_CONFIG, () => { + const config = provider.getConfig(); + if (config?.sidebar?.filters) { + store.setState({ + filters: { + ...store.getState().filters, + ...config?.sidebar?.filters, + }, + }); } - }; + }); + + const config = provider.getConfig(); return { api, @@ -759,7 +784,13 @@ export const init: ModuleFn = ({ hasCalledSetOptions: false, previewInitialized: false, status: {}, + filters: config?.sidebar?.filters || {}, + }, + init: async () => { + if (FEATURES?.storyStoreV7) { + provider.channel.on(STORY_INDEX_INVALIDATED, () => api.fetchIndex()); + await api.fetchIndex(); + } }, - init: initModule, }; }; diff --git a/code/lib/manager-api/src/modules/url.ts b/code/lib/manager-api/src/modules/url.ts index b8e4dcf6ebf1..c6cfc5abbd80 100644 --- a/code/lib/manager-api/src/modules/url.ts +++ b/code/lib/manager-api/src/modules/url.ts @@ -11,7 +11,7 @@ import { dequal as deepEqual } from 'dequal'; import { global } from '@storybook/global'; import type { API_Layout, API_UI } from '@storybook/types'; -import type { ModuleArgs, ModuleFn } from '../index'; +import type { ModuleArgs, ModuleFn } from '../lib/types'; const { window: globalWindow } = global; @@ -116,7 +116,9 @@ export interface SubAPI { setQueryParams: (input: QueryParams) => void; } -export const init: ModuleFn = ({ store, navigate, state, provider, fullAPI, ...rest }) => { +export const init: ModuleFn = (moduleArgs) => { + const { store, navigate, provider, fullAPI } = moduleArgs; + const navigateTo = ( path: string, queryParams: Record = {}, @@ -153,7 +155,7 @@ export const init: ModuleFn = ({ store, navigate, state, provider, fullAPI, ...r }; if (!deepEqual(customQueryParams, update)) { store.setState({ customQueryParams: update }); - fullAPI.emit(UPDATE_QUERY_PARAMS, update); + provider.channel.emit(UPDATE_QUERY_PARAMS, update); } }, navigateUrl(url, options) { @@ -161,49 +163,48 @@ export const init: ModuleFn = ({ store, navigate, state, provider, fullAPI, ...r }, }; - const initModule = () => { - // Sets `args` parameter in URL, omitting any args that have their initial value or cannot be unserialized safely. - const updateArgsParam = () => { - const { path, queryParams, viewMode } = fullAPI.getUrlState(); - if (viewMode !== 'story') return; - - const currentStory = fullAPI.getCurrentStoryData(); - if (currentStory?.type !== 'story') return; - - const { args, initialArgs } = currentStory; - const argsString = buildArgsParam(initialArgs, args); - navigateTo(path, { ...queryParams, args: argsString }, { replace: true }); - api.setQueryParams({ args: argsString }); - }; - - fullAPI.on(SET_CURRENT_STORY, () => updateArgsParam()); - - let handleOrId: any; - fullAPI.on(STORY_ARGS_UPDATED, () => { - if ('requestIdleCallback' in globalWindow) { - if (handleOrId) globalWindow.cancelIdleCallback(handleOrId); - handleOrId = globalWindow.requestIdleCallback(updateArgsParam, { timeout: 1000 }); - } else { - if (handleOrId) clearTimeout(handleOrId); - setTimeout(updateArgsParam, 100); - } - }); - - fullAPI.on(GLOBALS_UPDATED, ({ globals, initialGlobals }) => { - const { path, queryParams } = fullAPI.getUrlState(); - const globalsString = buildArgsParam(initialGlobals, globals); - navigateTo(path, { ...queryParams, globals: globalsString }, { replace: true }); - api.setQueryParams({ globals: globalsString }); - }); - - fullAPI.on(NAVIGATE_URL, (url: string, options: NavigateOptions) => { - fullAPI.navigateUrl(url, options); - }); + /** + * Sets `args` parameter in URL, omitting any args that have their initial value or cannot be unserialized safely. + */ + const updateArgsParam = () => { + const { path, queryParams, viewMode } = api.getUrlState(); + if (viewMode !== 'story') return; + + const currentStory = fullAPI.getCurrentStoryData(); + if (currentStory?.type !== 'story') return; + + const { args, initialArgs } = currentStory; + const argsString = buildArgsParam(initialArgs, args); + navigateTo(path, { ...queryParams, args: argsString }, { replace: true }); + api.setQueryParams({ args: argsString }); }; + provider.channel.on(SET_CURRENT_STORY, () => updateArgsParam()); + + let handleOrId: any; + provider.channel.on(STORY_ARGS_UPDATED, () => { + if ('requestIdleCallback' in globalWindow) { + if (handleOrId) globalWindow.cancelIdleCallback(handleOrId); + handleOrId = globalWindow.requestIdleCallback(updateArgsParam, { timeout: 1000 }); + } else { + if (handleOrId) clearTimeout(handleOrId); + setTimeout(updateArgsParam, 100); + } + }); + + provider.channel.on(GLOBALS_UPDATED, ({ globals, initialGlobals }) => { + const { path, queryParams } = api.getUrlState(); + const globalsString = buildArgsParam(initialGlobals, globals); + navigateTo(path, { ...queryParams, globals: globalsString }, { replace: true }); + api.setQueryParams({ globals: globalsString }); + }); + + provider.channel.on(NAVIGATE_URL, (url: string, options: NavigateOptions) => { + api.navigateUrl(url, options); + }); + return { api, - state: initialUrlSupport({ store, navigate, state, provider, fullAPI, ...rest }), - init: initModule, + state: initialUrlSupport(moduleArgs), }; }; diff --git a/code/lib/manager-api/src/modules/versions.ts b/code/lib/manager-api/src/modules/versions.ts index 49ff24be9b1f..2d90a14fcd69 100644 --- a/code/lib/manager-api/src/modules/versions.ts +++ b/code/lib/manager-api/src/modules/versions.ts @@ -5,7 +5,7 @@ import memoize from 'memoizerific'; import type { API_UnknownEntries, API_Version, API_Versions } from '@storybook/types'; import { version as currentVersion } from '../version'; -import type { ModuleFn } from '../index'; +import type { ModuleFn } from '../lib/types'; const { VERSIONCHECK } = global; diff --git a/code/lib/manager-api/src/modules/whatsnew.ts b/code/lib/manager-api/src/modules/whatsnew.ts index 5890eaae6fc7..6ee90558bc7c 100644 --- a/code/lib/manager-api/src/modules/whatsnew.ts +++ b/code/lib/manager-api/src/modules/whatsnew.ts @@ -6,7 +6,7 @@ import { SET_WHATS_NEW_CACHE, TOGGLE_WHATS_NEW_NOTIFICATIONS, } from '@storybook/core-events'; -import type { ModuleFn } from '../index'; +import type { ModuleFn } from '../lib/types'; export type SubState = { whatsNewData?: WhatsNewData; @@ -20,7 +20,7 @@ export type SubAPI = { const WHATS_NEW_NOTIFICATION_ID = 'whats-new'; -export const init: ModuleFn = ({ fullAPI, store }) => { +export const init: ModuleFn = ({ fullAPI, store, provider }) => { const state: SubState = { whatsNewData: undefined, }; @@ -47,7 +47,7 @@ export const init: ModuleFn = ({ fullAPI, store }) => { ...state.whatsNewData, disableWhatsNewNotifications: !state.whatsNewData.disableWhatsNewNotifications, }); - fullAPI.emit(TOGGLE_WHATS_NEW_NOTIFICATIONS, { + provider.channel.emit(TOGGLE_WHATS_NEW_NOTIFICATIONS, { disableWhatsNewNotifications: state.whatsNewData.disableWhatsNewNotifications, }); } @@ -55,20 +55,24 @@ export const init: ModuleFn = ({ fullAPI, store }) => { }; function getLatestWhatsNewPost(): Promise { - fullAPI.emit(REQUEST_WHATS_NEW_DATA); + provider.channel.emit(REQUEST_WHATS_NEW_DATA); return new Promise((resolve) => - fullAPI.once(RESULT_WHATS_NEW_DATA, ({ data }: { data: WhatsNewData }) => resolve(data)) + provider.channel.once(RESULT_WHATS_NEW_DATA, ({ data }: { data: WhatsNewData }) => + resolve(data) + ) ); } function setWhatsNewCache(cache: WhatsNewCache): void { - fullAPI.emit(SET_WHATS_NEW_CACHE, cache); + provider.channel.emit(SET_WHATS_NEW_CACHE, cache); } const initModule = async () => { // The server channel doesn't exist in production, and we don't want to show what's new in production storybooks. - if (global.CONFIG_TYPE !== 'DEVELOPMENT') return; + if (global.CONFIG_TYPE !== 'DEVELOPMENT') { + return; + } const whatsNewData = await getLatestWhatsNewPost(); setWhatsNewState(whatsNewData); @@ -92,7 +96,9 @@ export const init: ModuleFn = ({ fullAPI, store }) => { }, icon: { name: 'hearthollow' }, onClear({ dismissed }) { - if (dismissed) setWhatsNewCache({ lastDismissedPost: whatsNewData.url }); + if (dismissed) { + setWhatsNewCache({ lastDismissedPost: whatsNewData.url }); + } }, }); } diff --git a/code/lib/manager-api/src/tests/globals.test.ts b/code/lib/manager-api/src/tests/globals.test.ts index babd449131b2..1f51a113935a 100644 --- a/code/lib/manager-api/src/tests/globals.test.ts +++ b/code/lib/manager-api/src/tests/globals.test.ts @@ -1,9 +1,10 @@ import { EventEmitter } from 'events'; import { SET_STORIES, SET_GLOBALS, UPDATE_GLOBALS, GLOBALS_UPDATED } from '@storybook/core-events'; -import type { ModuleArgs, API } from '../index'; +import type { API } from '../index'; import type { SubAPI } from '../modules/globals'; import { init as initModule } from '../modules/globals'; +import type { ModuleArgs } from '../lib/types'; const { logger } = require('@storybook/client-logger'); const { getEventMetadata } = require('../lib/events'); @@ -27,7 +28,8 @@ function createMockStore() { describe('globals API', () => { it('sets a sensible initialState', () => { const store = createMockStore(); - const { state } = initModule({ store } as unknown as ModuleArgs); + const channel = new EventEmitter(); + const { state } = initModule({ store, provider: { channel } } as unknown as ModuleArgs); expect(state).toEqual({ globals: {}, @@ -36,13 +38,15 @@ describe('globals API', () => { }); it('set global args on SET_GLOBALS', () => { - const api = Object.assign(new EventEmitter(), { findRef: jest.fn() }); + const channel = new EventEmitter(); const store = createMockStore(); - const { state, init } = initModule({ store, fullAPI: api } as unknown as ModuleArgs); + const { state } = initModule({ + store, + provider: { channel }, + } as unknown as ModuleArgs); store.setState(state); - init(); - api.emit(SET_GLOBALS, { + channel.emit(SET_GLOBALS, { globals: { a: 'b' }, globalTypes: { a: { type: { name: 'string' } } }, }); @@ -53,26 +57,34 @@ describe('globals API', () => { }); it('ignores SET_STORIES from other refs', () => { - const api = Object.assign(new EventEmitter(), { findRef: jest.fn() }); + const channel = new EventEmitter(); + const api = { findRef: jest.fn() }; const store = createMockStore(); - const { state, init } = initModule({ store, fullAPI: api } as unknown as ModuleArgs); + const { state } = initModule({ + store, + fullAPI: api, + provider: { channel }, + } as unknown as ModuleArgs); store.setState(state); - init(); getEventMetadata.mockReturnValueOnce({ sourceType: 'external', ref: { id: 'ref' } }); - api.emit(SET_STORIES, { globals: { a: 'b' } }); + channel.emit(SET_STORIES, { globals: { a: 'b' } }); expect(store.getState()).toEqual({ globals: {}, globalTypes: {} }); }); it('ignores SET_GLOBALS from other refs', () => { - const api = Object.assign(new EventEmitter(), { findRef: jest.fn() }); + const api = { findRef: jest.fn() }; + const channel = new EventEmitter(); const store = createMockStore(); - const { state, init } = initModule({ store, fullAPI: api } as unknown as ModuleArgs); + const { state } = initModule({ + store, + fullAPI: api, + provider: { channel }, + } as unknown as ModuleArgs); store.setState(state); - init(); getEventMetadata.mockReturnValueOnce({ sourceType: 'external', ref: { id: 'ref' } }); - api.emit(SET_GLOBALS, { + channel.emit(SET_GLOBALS, { globals: { a: 'b' }, globalTypes: { a: { type: { name: 'string' } } }, }); @@ -80,48 +92,56 @@ describe('globals API', () => { }); it('updates the state when the preview emits GLOBALS_UPDATED', () => { - const api = Object.assign(new EventEmitter(), { findRef: jest.fn() }); + const channel = new EventEmitter(); + const api = { findRef: jest.fn() }; const store = createMockStore(); - const { state, init } = initModule({ store, fullAPI: api } as unknown as ModuleArgs); + const { state } = initModule({ + store, + fullAPI: api, + provider: { channel }, + } as unknown as ModuleArgs); store.setState(state); - init(); - - api.emit(GLOBALS_UPDATED, { globals: { a: 'b' } }); + channel.emit(GLOBALS_UPDATED, { globals: { a: 'b' } }); expect(store.getState()).toEqual({ globals: { a: 'b' }, globalTypes: {} }); - api.emit(GLOBALS_UPDATED, { globals: { a: 'c' } }); + channel.emit(GLOBALS_UPDATED, { globals: { a: 'c' } }); expect(store.getState()).toEqual({ globals: { a: 'c' }, globalTypes: {} }); // SHOULD NOT merge global args - api.emit(GLOBALS_UPDATED, { globals: { d: 'e' } }); + channel.emit(GLOBALS_UPDATED, { globals: { d: 'e' } }); expect(store.getState()).toEqual({ globals: { d: 'e' }, globalTypes: {} }); }); it('ignores GLOBALS_UPDATED from other refs', () => { - const api = Object.assign(new EventEmitter(), { findRef: jest.fn() }); + const channel = new EventEmitter(); + const api = { findRef: jest.fn() }; const store = createMockStore(); - const { state, init } = initModule({ store, fullAPI: api } as unknown as ModuleArgs); + const { state } = initModule({ + store, + fullAPI: api, + provider: { channel }, + } as unknown as ModuleArgs); store.setState(state); - init(); - getEventMetadata.mockReturnValueOnce({ sourceType: 'external', ref: { id: 'ref' } }); logger.warn.mockClear(); - api.emit(GLOBALS_UPDATED, { globals: { a: 'b' } }); + channel.emit(GLOBALS_UPDATED, { globals: { a: 'b' } }); expect(store.getState()).toEqual({ globals: {}, globalTypes: {} }); expect(logger.warn).toHaveBeenCalled(); }); it('emits UPDATE_GLOBALS when updateGlobals is called', () => { - const fullAPI = { emit: jest.fn(), on: jest.fn() } as unknown as API; + const channel = new EventEmitter(); + const fullAPI = {} as unknown as API; const store = createMockStore(); - const { init, api } = initModule({ store, fullAPI } as unknown as ModuleArgs); - - init(); + const listener = jest.fn(); + channel.on(UPDATE_GLOBALS, listener); + const { api } = initModule({ store, fullAPI, provider: { channel } } as unknown as ModuleArgs); (api as SubAPI).updateGlobals({ a: 'b' }); - expect(fullAPI.emit).toHaveBeenCalledWith(UPDATE_GLOBALS, { + + expect(listener).toHaveBeenCalledWith({ globals: { a: 'b' }, options: { target: 'storybook-preview-iframe' }, }); diff --git a/code/lib/manager-api/src/tests/mockStoriesEntries.ts b/code/lib/manager-api/src/tests/mockStoriesEntries.ts new file mode 100644 index 000000000000..703b6e6efb76 --- /dev/null +++ b/code/lib/manager-api/src/tests/mockStoriesEntries.ts @@ -0,0 +1,129 @@ +import type { StoryIndex, API_PreparedStoryIndex } from '@storybook/types'; + +export const mockEntries: StoryIndex['entries'] = { + 'component-a--docs': { + type: 'docs', + id: 'component-a--docs', + title: 'Component A', + name: 'Docs', + importPath: './path/to/component-a.ts', + storiesImports: [], + }, + 'component-a--story-1': { + type: 'story', + id: 'component-a--story-1', + title: 'Component A', + name: 'Story 1', + importPath: './path/to/component-a.ts', + }, + 'component-a--story-2': { + type: 'story', + id: 'component-a--story-2', + title: 'Component A', + name: 'Story 2', + importPath: './path/to/component-a.ts', + }, + 'component-b--story-3': { + type: 'story', + id: 'component-b--story-3', + title: 'Component B', + name: 'Story 3', + importPath: './path/to/component-b.ts', + }, +}; +export const docsEntries: StoryIndex['entries'] = { + 'component-a--page': { + type: 'story', + id: 'component-a--page', + title: 'Component A', + name: 'Page', + importPath: './path/to/component-a.ts', + }, + 'component-a--story-2': { + type: 'story', + id: 'component-a--story-2', + title: 'Component A', + name: 'Story 2', + importPath: './path/to/component-a.ts', + }, + 'component-b-docs': { + type: 'docs', + id: 'component-b--docs', + title: 'Component B', + name: 'Docs', + importPath: './path/to/component-b.ts', + storiesImports: [], + tags: ['stories-mdx'], + }, + 'component-c--story-4': { + type: 'story', + id: 'component-c--story-4', + title: 'Component c', + name: 'Story 4', + importPath: './path/to/component-c.ts', + }, +}; +export const navigationEntries: StoryIndex['entries'] = { + 'a--1': { + type: 'story', + title: 'a', + name: '1', + id: 'a--1', + importPath: './a.ts', + }, + 'a--2': { + type: 'story', + title: 'a', + name: '2', + id: 'a--2', + importPath: './a.ts', + }, + 'b-c--1': { + type: 'story', + title: 'b/c', + name: '1', + id: 'b-c--1', + importPath: './b/c.ts', + }, + 'b-d--1': { + type: 'story', + title: 'b/d', + name: '1', + id: 'b-d--1', + importPath: './b/d.ts', + }, + 'b-d--2': { + type: 'story', + title: 'b/d', + name: '2', + id: 'b-d--2', + importPath: './b/d.ts', + }, + 'custom-id--1': { + type: 'story', + title: 'b/e', + name: '1', + id: 'custom-id--1', + importPath: './b/.ts', + }, +}; +export const preparedEntries: API_PreparedStoryIndex['entries'] = { + 'a--1': { + type: 'story', + title: 'a', + name: '1', + parameters: {}, + id: 'a--1', + args: { a: 'b' }, + importPath: './a.ts', + }, + 'b--1': { + type: 'story', + title: 'b', + name: '1', + parameters: {}, + id: 'b--1', + args: { x: 'y' }, + importPath: './b.ts', + }, +}; diff --git a/code/lib/manager-api/src/tests/stories.test.ts b/code/lib/manager-api/src/tests/stories.test.ts index 5427226865e5..b9f7687f4526 100644 --- a/code/lib/manager-api/src/tests/stories.test.ts +++ b/code/lib/manager-api/src/tests/stories.test.ts @@ -16,28 +16,28 @@ import { import { EventEmitter } from 'events'; import { global } from '@storybook/global'; -import { Channel } from '@storybook/channels'; +import type { API_IndexHash, API_StoryEntry } from '@storybook/types'; +import { getEventMetadata as getEventMetadataOriginal } from '../lib/events'; -import type { API_StoryEntry, StoryIndex, API_PreparedStoryIndex } from '@storybook/types'; -import { getEventMetadata } from '../lib/events'; - -import type { SubAPI } from '../modules/stories'; import { init as initStories } from '../modules/stories'; import type Store from '../store'; -import type { ModuleArgs } from '..'; - -function mockChannel() { - const transport = { - setHandler: () => {}, - send: () => {}, - }; +import type { API, State } from '..'; +import { mockEntries, docsEntries, preparedEntries, navigationEntries } from './mockStoriesEntries'; +import type { ModuleArgs } from '../lib/types'; - return new Channel({ transport }); -} +import { getAncestorIds } from '../../../../ui/manager/src/utils/tree'; const mockGetEntries = jest.fn(); +const fetch = global.fetch as jest.Mock>; +const getEventMetadata = getEventMetadataOriginal as unknown as jest.Mock< + ReturnType +>; -jest.mock('../lib/events'); +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +jest.mock('../lib/events', () => ({ + getEventMetadata: jest.fn(() => ({ sourceType: 'local' })), +})); jest.mock('@storybook/global', () => ({ global: { ...globalThis, @@ -47,41 +47,7 @@ jest.mock('@storybook/global', () => ({ }, })); -const getEventMetadataMock = getEventMetadata as ReturnType; - -const mockEntries: StoryIndex['entries'] = { - 'component-a--docs': { - type: 'docs', - id: 'component-a--docs', - title: 'Component A', - name: 'Docs', - importPath: './path/to/component-a.ts', - storiesImports: [], - }, - 'component-a--story-1': { - type: 'story', - id: 'component-a--story-1', - title: 'Component A', - name: 'Story 1', - importPath: './path/to/component-a.ts', - }, - 'component-a--story-2': { - type: 'story', - id: 'component-a--story-2', - title: 'Component A', - name: 'Story 2', - importPath: './path/to/component-a.ts', - }, - 'component-b--story-3': { - type: 'story', - id: 'component-b--story-3', - title: 'Component B', - name: 'Story 3', - importPath: './path/to/component-b.ts', - }, -}; - -function createMockStore(initialState = {}) { +function createMockStore(initialState: Partial = {}) { let state = initialState; return { getState: jest.fn(() => state), @@ -91,40 +57,34 @@ function createMockStore(initialState = {}) { }), } as any as Store; } - -function initStoriesAndSetState({ store, ...options }: any) { - const { state, ...result } = initStories({ store, ...options } as any); - - store?.setState(state); - - return { state, ...result }; +function createMockProvider() { + return { + getConfig: jest.fn().mockReturnValue({}), + channel: new EventEmitter(), + }; +} +function createMockModuleArgs({ + fullAPI = {}, + initialState = {}, +}: { + fullAPI?: Partial>; + initialState?: Partial; +}) { + const navigate = jest.fn(); + const store = createMockStore(initialState); + const provider = createMockProvider(); + + return { navigate, store, provider, fullAPI }; } - -const provider = { getConfig: jest.fn().mockReturnValue({}), serverChannel: mockChannel() }; - -beforeEach(() => { - provider.getConfig.mockReset().mockReturnValue({}); - provider.serverChannel = mockChannel(); - mockGetEntries.mockReset().mockReturnValue(mockEntries); - - (global.fetch as jest.Mock>).mockReset().mockReturnValue( - Promise.resolve({ - status: 200, - ok: true, - json: () => ({ v: 4, entries: mockGetEntries() }), - } as any as Response) - ); - - getEventMetadataMock.mockReturnValue({ sourceType: 'local' } as any); - getEventMetadataMock.mockReturnValue({ sourceType: 'local' } as any); -}); describe('stories API', () => { it('sets a sensible initialState', () => { - const { state } = initStoriesAndSetState({ + const moduleArgs = createMockModuleArgs({}); + const { state } = initStories({ + ...(moduleArgs as unknown as ModuleArgs), storyId: 'id', viewMode: 'story', - } as ModuleArgs); + }); expect(state).toEqual( expect.objectContaining({ @@ -138,16 +98,11 @@ describe('stories API', () => { describe('setIndex', () => { it('sets the initial set of stories in the stories hash', async () => { - const navigate = jest.fn(); - const store = createMockStore(); - const fullAPI = Object.assign(new EventEmitter()); - - const { api } = initStoriesAndSetState({ store, navigate, provider, fullAPI } as any); - Object.assign(fullAPI, api); - + const moduleArgs = createMockModuleArgs({}); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { store } = moduleArgs; api.setIndex({ v: 4, entries: mockEntries }); const { index } = store.getState(); - // We need exact key ordering, even if in theory JS doesn't guarantee it expect(Object.keys(index)).toEqual([ 'component-a', @@ -162,7 +117,6 @@ describe('stories API', () => { id: 'component-a', children: ['component-a--docs', 'component-a--story-1', 'component-a--story-2'], }); - expect(index['component-a--docs']).toMatchObject({ type: 'docs', id: 'component-a--docs', @@ -172,7 +126,6 @@ describe('stories API', () => { storiesImports: [], prepared: false, }); - expect(index['component-a--story-1']).toMatchObject({ type: 'story', id: 'component-a--story-1', @@ -185,15 +138,10 @@ describe('stories API', () => { (index['component-a--story-1'] as API_StoryEntry as API_StoryEntry).args ).toBeUndefined(); }); - it('trims whitespace of group/component names (which originate from the kind)', () => { - const navigate = jest.fn(); - const store = createMockStore(); - const fullAPI = Object.assign(new EventEmitter()); - - const { api } = initStoriesAndSetState({ store, navigate, provider, fullAPI } as any); - Object.assign(fullAPI, api); - + const moduleArgs = createMockModuleArgs({}); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { store } = moduleArgs; api.setIndex({ v: 4, entries: { @@ -207,7 +155,6 @@ describe('stories API', () => { }, }); const { index } = store.getState(); - // We need exact key ordering, even if in theory JS doesn't guarantee it expect(Object.keys(index)).toEqual([ 'design-system', @@ -228,15 +175,10 @@ describe('stories API', () => { name: ' My Story ', // story name is kept as-is, because it's set directly on the story }); }); - it('moves rootless stories to the front of the list', async () => { - const navigate = jest.fn(); - const store = createMockStore(); - const fullAPI = Object.assign(new EventEmitter()); - - const { api } = initStoriesAndSetState({ store, navigate, provider, fullAPI } as any); - Object.assign(fullAPI, api); - + const moduleArgs = createMockModuleArgs({}); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { store } = moduleArgs; api.setIndex({ v: 4, entries: { @@ -251,7 +193,6 @@ describe('stories API', () => { }, }); const { index } = store.getState(); - // We need exact key ordering, even if in theory JS doesn't guarantee it expect(Object.keys(index)).toEqual([ 'component-a', @@ -270,15 +211,10 @@ describe('stories API', () => { children: ['root-first'], }); }); - it('sets roots when showRoots = true', () => { - const navigate = jest.fn(); - const store = createMockStore(); - const fullAPI = Object.assign(new EventEmitter()); - - const { api } = initStoriesAndSetState({ store, navigate, provider, fullAPI } as any); - Object.assign(fullAPI, api); - + const moduleArgs = createMockModuleArgs({}); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { store, provider } = moduleArgs; provider.getConfig.mockReturnValue({ sidebar: { showRoots: true } }); api.setIndex({ v: 4, @@ -292,9 +228,7 @@ describe('stories API', () => { }, }, }); - const { index } = store.getState(); - // We need exact key ordering, even if in theory JS doens't guarantee it expect(Object.keys(index)).toEqual(['a', 'a-b', 'a-b--1']); expect(index.a).toMatchObject({ @@ -316,15 +250,10 @@ describe('stories API', () => { title: 'a/b', }); }); - it('does not put bare stories into a root when showRoots = true', () => { - const navigate = jest.fn(); - const store = createMockStore(); - const fullAPI = Object.assign(new EventEmitter()); - - const { api } = initStoriesAndSetState({ store, navigate, provider, fullAPI } as any); - Object.assign(fullAPI, api); - + const moduleArgs = createMockModuleArgs({}); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { store, provider } = moduleArgs; provider.getConfig.mockReturnValue({ sidebar: { showRoots: true } }); api.setIndex({ v: 4, @@ -338,9 +267,7 @@ describe('stories API', () => { }, }, }); - const { index } = store.getState(); - // We need exact key ordering, even if in theory JS doens't guarantee it expect(Object.keys(index)).toEqual(['a', 'a--1']); expect(index.a).toMatchObject({ @@ -356,17 +283,12 @@ describe('stories API', () => { name: '1', }); }); - // Stories can get out of order for a few reasons -- see reproductions on // https://github.com/storybookjs/storybook/issues/5518 it('does the right thing for out of order stories', async () => { - const navigate = jest.fn(); - const store = createMockStore(); - const fullAPI = Object.assign(new EventEmitter()); - - const { api } = initStoriesAndSetState({ store, navigate, provider, fullAPI } as any); - Object.assign(fullAPI, api); - + const moduleArgs = createMockModuleArgs({}); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { store, provider } = moduleArgs; provider.getConfig.mockReturnValue({ sidebar: { showRoots: true } }); api.setIndex({ v: 4, @@ -376,9 +298,7 @@ describe('stories API', () => { 'a--2': { type: 'story', title: 'a', name: '2', id: 'a--2', importPath: './a.ts' }, }, }); - const { index } = store.getState(); - // We need exact key ordering, even if in theory JS doens't guarantee it expect(Object.keys(index)).toEqual(['a', 'a--1', 'a--2', 'b', 'b--1']); expect(index.a).toMatchObject({ @@ -386,23 +306,17 @@ describe('stories API', () => { id: 'a', children: ['a--1', 'a--2'], }); - expect(index.b).toMatchObject({ type: 'component', id: 'b', children: ['b--1'], }); }); - // Entries on the SET_STORIES event will be prepared it('handles properly prepared stories', async () => { - const navigate = jest.fn(); - const store = createMockStore(); - const fullAPI = Object.assign(new EventEmitter(), {}); - - const { api } = initStoriesAndSetState({ store, navigate, provider, fullAPI } as any); - Object.assign(fullAPI, api); - + const moduleArgs = createMockModuleArgs({}); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { store } = moduleArgs; api.setIndex({ v: 4, entries: { @@ -417,9 +331,7 @@ describe('stories API', () => { }, }, }); - const { index } = store.getState(); - expect(index['prepared--story']).toMatchObject({ type: 'story', id: 'prepared--story', @@ -431,21 +343,13 @@ describe('stories API', () => { args: { arg: 'exists' }, }); }); - it('retains prepared-ness of stories', async () => { - const navigate = jest.fn(); - const store = createMockStore(); - const fullAPI = Object.assign(new EventEmitter(), { - setOptions: jest.fn(), - }); - - const { api, init } = initStoriesAndSetState({ store, navigate, provider, fullAPI } as any); - Object.assign(fullAPI, api); - init(); - + const fullAPI = { setOptions: jest.fn() }; + const moduleArgs = createMockModuleArgs({ fullAPI }); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { store, provider } = moduleArgs; api.setIndex({ v: 4, entries: mockEntries }); - - fullAPI.emit(STORY_PREPARED, { + provider.channel.emit(STORY_PREPARED, { id: 'component-a--story-1', parameters: { a: 'b' }, args: { c: 'd' }, @@ -457,9 +361,7 @@ describe('stories API', () => { parameters: { a: 'b' }, args: { c: 'd' }, }); - api.setIndex({ v: 4, entries: mockEntries }); - // Let the promise/await chain resolve await new Promise((r) => setTimeout(r, 0)); expect(store.getState().index['component-a--story-1'] as API_StoryEntry).toMatchObject({ @@ -470,51 +372,13 @@ describe('stories API', () => { }); describe('docs entries', () => { - const docsEntries: StoryIndex['entries'] = { - 'component-a--page': { - type: 'story', - id: 'component-a--page', - title: 'Component A', - name: 'Page', - importPath: './path/to/component-a.ts', - }, - 'component-a--story-2': { - type: 'story', - id: 'component-a--story-2', - title: 'Component A', - name: 'Story 2', - importPath: './path/to/component-a.ts', - }, - 'component-b-docs': { - type: 'docs', - id: 'component-b--docs', - title: 'Component B', - name: 'Docs', - importPath: './path/to/component-b.ts', - storiesImports: [], - tags: ['stories-mdx'], - }, - 'component-c--story-4': { - type: 'story', - id: 'component-c--story-4', - title: 'Component c', - name: 'Story 4', - importPath: './path/to/component-c.ts', - }, - }; - it('handles docs entries', async () => { - const navigate = jest.fn(); - const store = createMockStore(); - const fullAPI = Object.assign(new EventEmitter()); - - const { api } = initStoriesAndSetState({ store, navigate, provider, fullAPI } as any); - Object.assign(fullAPI, api); + const moduleArgs = createMockModuleArgs({}); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { store } = moduleArgs; api.setIndex({ v: 4, entries: docsEntries }); - const { index } = store.getState(); - // We need exact key ordering, even if in theory JS doesn't guarantee it expect(Object.keys(index)).toEqual([ 'component-a', @@ -530,26 +394,16 @@ describe('stories API', () => { expect(index['component-b--docs'].type).toBe('docs'); expect(index['component-c--story-4'].type).toBe('story'); }); - describe('when DOCS_MODE = true', () => { it('strips out story entries', async () => { - const navigate = jest.fn(); - const store = createMockStore(); - const fullAPI = Object.assign(new EventEmitter()); - - const { api } = initStoriesAndSetState({ - store, - navigate, - provider, - fullAPI, + const moduleArgs = createMockModuleArgs({}); + const { api } = initStories({ + ...(moduleArgs as unknown as ModuleArgs), docsOptions: { docsMode: true }, - } as any); - Object.assign(fullAPI, api); - + }); + const { store } = moduleArgs; api.setIndex({ v: 4, entries: docsEntries }); - const { index } = store.getState(); - expect(Object.keys(index)).toEqual(['component-b', 'component-b--docs']); }); }); @@ -558,269 +412,197 @@ describe('stories API', () => { describe('SET_INDEX event', () => { it('calls setIndex w/ the data', () => { - const fullAPI = Object.assign(new EventEmitter()); - const navigate = jest.fn(); - const store = createMockStore(); - - const { init, api } = initStoriesAndSetState({ store, navigate, provider, fullAPI } as any); - Object.assign(fullAPI, api, { - setIndex: jest.fn(), - setOptions: jest.fn(), - }); - init(); + const fullAPI = { setOptions: jest.fn() }; + const moduleArgs = createMockModuleArgs({ fullAPI }); + initStories(moduleArgs as unknown as ModuleArgs); + const { store, provider } = moduleArgs; - fullAPI.emit(SET_INDEX, { v: 4, entries: mockEntries }); - - expect(fullAPI.setIndex).toHaveBeenCalled(); + provider.channel.emit(SET_INDEX, { v: 4, entries: mockEntries }); + expect(store.getState().index).toEqual( + expect.objectContaining({ + 'component-a': expect.any(Object), + 'component-a--docs': expect.any(Object), + 'component-a--story-1': expect.any(Object), + }) + ); }); - it('calls setOptions w/ first story parameter', () => { - const fullAPI = Object.assign(new EventEmitter()); - const navigate = jest.fn(); - const store = createMockStore(); - - const { init, api } = initStoriesAndSetState({ store, navigate, provider, fullAPI } as any); - Object.assign(fullAPI, api, { - setIndex: jest.fn(), - setOptions: jest.fn(), + const fullAPI = { setOptions: jest.fn() }; + const moduleArgs = createMockModuleArgs({ fullAPI }); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { provider } = moduleArgs; + + // HACK api to effectively mock getCurrentParameter + Object.assign(api, { getCurrentParameter: jest.fn().mockReturnValue('options'), }); - init(); - - fullAPI.emit(SET_INDEX, { v: 4, entries: mockEntries }); + provider.channel.emit(SET_INDEX, { v: 4, entries: mockEntries }); expect(fullAPI.setOptions).toHaveBeenCalledWith('options'); }); }); describe('fetchIndex', () => { it('deals with 500 errors', async () => { - const navigate = jest.fn(); - const store = createMockStore({}); - const fullAPI = Object.assign(new EventEmitter(), {}, {}); - - (global.fetch as jest.Mock>).mockReturnValue( + fetch.mockReturnValue( Promise.resolve({ status: 500, text: async () => new Error('sorting error'), } as any as Response) ); - const { api, init } = initStoriesAndSetState({ store, navigate, provider, fullAPI } as any); - Object.assign(fullAPI, api); + const moduleArgs = createMockModuleArgs({}); + const { init } = initStories(moduleArgs as unknown as ModuleArgs); + const { store } = moduleArgs; await init(); const { indexError } = store.getState(); expect(indexError).toBeDefined(); }); - it('watches for the INVALIDATE event and re-fetches -- and resets the hash', async () => { - const navigate = jest.fn(); - const store = createMockStore(); - const fullAPI = Object.assign(new EventEmitter(), { - setIndex: jest.fn(), - }); + fetch.mockReturnValue( + Promise.resolve({ + status: 200, + ok: true, + json: () => ({ + v: 4, + entries: { + 'component-a--story-1': { + type: 'story', + id: 'component-a--story-1', + title: 'Component A', + name: 'Story 1', + importPath: './path/to/component-a.ts', + }, + }, + }), + } as any as Response) + ); - const { api, init } = initStoriesAndSetState({ store, navigate, provider, fullAPI } as any); - Object.assign(fullAPI, api); + const moduleArgs = createMockModuleArgs({}); + const { init } = initStories(moduleArgs as unknown as ModuleArgs); + const { store, provider } = moduleArgs; - (global.fetch as jest.Mock>).mockClear(); await init(); - expect(global.fetch as jest.Mock>).toHaveBeenCalledTimes(1); - - (global.fetch as jest.Mock>).mockClear(); - mockGetEntries.mockReturnValueOnce({ - 'component-a--story-1': { - type: 'story', - id: 'component-a--story-1', - title: 'Component A', - name: 'Story 1', - importPath: './path/to/component-a.ts', - }, - }); - fullAPI.emit(STORY_INDEX_INVALIDATED); - expect(global.fetch).toHaveBeenCalledTimes(1); - // Let the promise/await chain resolve - await new Promise((r) => setTimeout(r, 0)); - const { index } = store.getState(); + expect(fetch).toHaveBeenCalledTimes(1); + provider.channel.emit(STORY_INDEX_INVALIDATED); + expect(global.fetch).toHaveBeenCalledTimes(2); + + // this side-effect is in an un-awaited promise. + await wait(16); + const { index } = store.getState(); expect(Object.keys(index)).toEqual(['component-a', 'component-a--story-1']); }); - it('clears 500 errors when invalidated', async () => { - const navigate = jest.fn(); - const store = createMockStore(); - const fullAPI = Object.assign(new EventEmitter(), { - setIndex: jest.fn(), - }); - - (global.fetch as jest.Mock>).mockReturnValueOnce( + fetch.mockReturnValueOnce( Promise.resolve({ status: 500, text: async () => new Error('sorting error'), } as any as Response) ); - const { api, init } = initStoriesAndSetState({ store, navigate, provider, fullAPI } as any); - Object.assign(fullAPI, api); + const moduleArgs = createMockModuleArgs({}); + const { init } = initStories(moduleArgs as unknown as ModuleArgs); + const { store, provider } = moduleArgs; await init(); const { indexError } = store.getState(); expect(indexError).toBeDefined(); - (global.fetch as jest.Mock>).mockClear(); - mockGetEntries.mockReturnValueOnce({ - 'component-a--story-1': { - type: 'story', - id: 'component-a--story-1', - title: 'Component A', - name: 'Story 1', - importPath: './path/to/component-a.ts', - }, - }); - fullAPI.emit(STORY_INDEX_INVALIDATED); - expect(global.fetch).toHaveBeenCalledTimes(1); + fetch.mockReturnValueOnce( + Promise.resolve({ + status: 200, + ok: true, + json: () => ({ + v: 4, + entries: { + 'component-a--story-1': { + type: 'story', + id: 'component-a--story-1', + title: 'Component A', + name: 'Story 1', + importPath: './path/to/component-a.ts', + }, + }, + }), + } as any as Response) + ); + + provider.channel.emit(STORY_INDEX_INVALIDATED); + expect(global.fetch).toHaveBeenCalledTimes(2); + + // this side-effect is in an un-awaited promise. + await wait(16); - // Let the promise/await chain resolve - await new Promise((r) => setTimeout(r, 0)); const { index, indexError: newIndexError } = store.getState(); expect(newIndexError).not.toBeDefined(); - expect(Object.keys(index)).toEqual(['component-a', 'component-a--story-1']); }); }); describe('STORY_SPECIFIED event', () => { it('navigates to the story', async () => { - const navigate = jest.fn(); - const fullAPI = Object.assign(new EventEmitter(), { - isSettingsScreenActive() { - return false; - }, - }); - const store = createMockStore({ viewMode: 'story' }); - const { init, api } = initStoriesAndSetState({ - store, - navigate, - provider, - fullAPI, - viewMode: 'story', - } as any); - - Object.assign(fullAPI, api); - init(); - fullAPI.emit(STORY_SPECIFIED, { storyId: 'a--1', viewMode: 'story' }); + const moduleArgs = createMockModuleArgs({ initialState: { path: '/' } }); + initStories(moduleArgs as unknown as ModuleArgs); + const { navigate, provider } = moduleArgs; + provider.channel.emit(STORY_SPECIFIED, { storyId: 'a--1', viewMode: 'story' }); expect(navigate).toHaveBeenCalledWith('/story/a--1'); }); - it('DOES not navigate if the story was already selected', async () => { - const navigate = jest.fn(); - const fullAPI = Object.assign(new EventEmitter(), { - isSettingsScreenActive() { - return true; - }, - }); - const store = createMockStore({ viewMode: 'story', storyId: 'a--1' }); - const { api, init } = initStoriesAndSetState({ - store, - navigate, - provider, - fullAPI, - viewMode: 'story', - storyId: 'a--1', - } as any); - Object.assign(fullAPI, api); - init(); - - fullAPI.emit(STORY_SPECIFIED, { storyId: 'a--1', viewMode: 'story' }); + const moduleArgs = createMockModuleArgs({ initialState: { path: '/story/a--1' } }); + initStories(moduleArgs as unknown as ModuleArgs); + const { navigate, provider } = moduleArgs; + provider.channel.emit(STORY_SPECIFIED, { storyId: 'a--1', viewMode: 'story' }); expect(navigate).not.toHaveBeenCalled(); }); - it('DOES not navigate if a settings page was selected', async () => { - const navigate = jest.fn(); - const fullAPI = Object.assign(new EventEmitter(), { - isSettingsScreenActive() { - return true; - }, - }); - const store = createMockStore({ viewMode: 'settings', storyId: 'about' }); - const { api, init } = initStoriesAndSetState({ - store, - navigate, - provider, - fullAPI, - viewMode: 'settings', - storyId: 'about', - } as any); - Object.assign(fullAPI, api); - init(); - - fullAPI.emit(STORY_SPECIFIED, { storyId: 'a--1', viewMode: 'story' }); + const moduleArgs = createMockModuleArgs({ initialState: { path: '/settings/about' } }); + initStories(moduleArgs as unknown as ModuleArgs); + const { navigate, provider } = moduleArgs; + provider.channel.emit(STORY_SPECIFIED, { storyId: 'a--1', viewMode: 'story' }); expect(navigate).not.toHaveBeenCalled(); }); - it('DOES not navigate if a custom page was selected', async () => { - const navigate = jest.fn(); - const fullAPI = Object.assign(new EventEmitter(), { - isSettingsScreenActive() { - return true; - }, - }); - const store = createMockStore({ viewMode: 'custom', storyId: undefined }); - const { api, init } = initStoriesAndSetState({ - store, - navigate, - provider, - fullAPI, - viewMode: 'custom', - storyId: undefined, - } as any); - Object.assign(fullAPI, api); - init(); - - fullAPI.emit(STORY_SPECIFIED, { storyId: 'a--1', viewMode: 'story' }); + const moduleArgs = createMockModuleArgs({ initialState: { path: '/custom/page' } }); + initStories(moduleArgs as unknown as ModuleArgs); + const { navigate, provider } = moduleArgs; + provider.channel.emit(STORY_SPECIFIED, { storyId: 'a--1', viewMode: 'story' }); expect(navigate).not.toHaveBeenCalled(); }); }); describe('CURRENT_STORY_WAS_SET event', () => { it('sets previewInitialized', async () => { - const navigate = jest.fn(); - const fullAPI = Object.assign(new EventEmitter(), {}); - const store = createMockStore({}); - const { init, api } = initStoriesAndSetState({ store, navigate, provider, fullAPI } as any); - - Object.assign(fullAPI, api); - await init(); - fullAPI.emit(CURRENT_STORY_WAS_SET, { id: 'a--1' }); + const moduleArgs = createMockModuleArgs({}); + initStories(moduleArgs as unknown as ModuleArgs); + const { store, provider } = moduleArgs; + provider.channel.emit(CURRENT_STORY_WAS_SET, { id: 'a--1' }); expect(store.getState().previewInitialized).toBe(true); }); - it('sets a ref to previewInitialized', async () => { - const navigate = jest.fn(); - const fullAPI = Object.assign(new EventEmitter(), { - updateRef: jest.fn(), - }); - const store = createMockStore(); - const { api, init } = initStoriesAndSetState({ store, navigate, provider, fullAPI } as any); - - Object.assign(fullAPI, api); + const fullAPI = { updateRef: jest.fn() }; + const moduleArgs = createMockModuleArgs({ fullAPI }); + initStories(moduleArgs as unknown as ModuleArgs); + const { provider } = moduleArgs; + provider.channel.emit(CURRENT_STORY_WAS_SET, { id: 'a--1' }); - getEventMetadataMock.mockReturnValueOnce({ + getEventMetadata.mockReturnValueOnce({ sourceType: 'external', - ref: { id: 'refId', index: { 'a--1': { args: { a: 'b' } } } }, - } as any); - await init(); - fullAPI.emit(CURRENT_STORY_WAS_SET, { id: 'a--1' }); - + refId: 'refId', + source: '', + sourceLocation: '', + type: '', + ref: { id: 'refId', index: { 'a--1': { args: { a: 'b' } } } } as any, + }); + provider.channel.emit(CURRENT_STORY_WAS_SET, { id: 'a--1' }); expect(fullAPI.updateRef.mock.calls.length).toBe(1); - expect(fullAPI.updateRef.mock.calls[0][1]).toEqual({ previewInitialized: true, }); @@ -828,88 +610,53 @@ describe('stories API', () => { }); describe('args handling', () => { - const parameters = {}; - const preparedEntries: API_PreparedStoryIndex['entries'] = { - 'a--1': { - type: 'story', - title: 'a', - name: '1', - parameters, - id: 'a--1', - args: { a: 'b' }, - importPath: './a.ts', - }, - 'b--1': { - type: 'story', - title: 'b', - name: '1', - parameters, - id: 'b--1', - args: { x: 'y' }, - importPath: './b.ts', - }, - }; - it('changes args properly, per story when receiving STORY_ARGS_UPDATED', () => { - const navigate = jest.fn(); - const store = createMockStore(); - const fullAPI = Object.assign(new EventEmitter(), { - updateRef: jest.fn(), - }); - - const { api, init } = initStoriesAndSetState({ store, navigate, provider, fullAPI } as any); - - const { setIndex } = Object.assign(fullAPI, api); - setIndex({ v: 4, entries: preparedEntries }); + const fullAPI = { updateRef: jest.fn() }; + const moduleArgs = createMockModuleArgs({ fullAPI }); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { provider, store } = moduleArgs; + api.setIndex({ v: 4, entries: preparedEntries }); const { index } = store.getState(); expect((index['a--1'] as API_StoryEntry).args).toEqual({ a: 'b' }); expect((index['b--1'] as API_StoryEntry).args).toEqual({ x: 'y' }); - - init(); - fullAPI.emit(STORY_ARGS_UPDATED, { storyId: 'a--1', args: { foo: 'bar' } }); - + provider.channel.emit(STORY_ARGS_UPDATED, { storyId: 'a--1', args: { foo: 'bar' } }); const { index: changedIndex } = store.getState(); expect((changedIndex['a--1'] as API_StoryEntry).args).toEqual({ foo: 'bar' }); expect((changedIndex['b--1'] as API_StoryEntry).args).toEqual({ x: 'y' }); }); - it('changes reffed args properly, per story when receiving STORY_ARGS_UPDATED', () => { - const navigate = jest.fn(); - const store = createMockStore(); - const fullAPI = new EventEmitter(); - - const { init, api } = initStoriesAndSetState({ store, navigate, provider, fullAPI } as any); - Object.assign(fullAPI, api, { - updateRef: jest.fn(), - }); + const fullAPI = { updateRef: jest.fn() }; + const moduleArgs = createMockModuleArgs({ fullAPI }); + initStories(moduleArgs as unknown as ModuleArgs); + const { provider } = moduleArgs; - init(); - getEventMetadataMock.mockReturnValueOnce({ + getEventMetadata.mockReturnValueOnce({ sourceType: 'external', - ref: { id: 'refId', index: { 'a--1': { args: { a: 'b' } } } }, - } as any); - fullAPI.emit(STORY_ARGS_UPDATED, { storyId: 'a--1', args: { foo: 'bar' } }); - expect((fullAPI as any).updateRef).toHaveBeenCalledWith('refId', { + refId: 'refId', + source: '', + sourceLocation: '', + type: '', + ref: { id: 'refId', index: { 'a--1': { args: { a: 'b' } } } } as any, + }); + provider.channel.emit(STORY_ARGS_UPDATED, { storyId: 'a--1', args: { foo: 'bar' } }); + expect(fullAPI.updateRef).toHaveBeenCalledWith('refId', { index: { 'a--1': { args: { foo: 'bar' } } }, }); }); - it('updateStoryArgs emits UPDATE_STORY_ARGS to the local frame and does not change anything', () => { - const navigate = jest.fn(); - const emit = jest.fn(); - const on = jest.fn(); - const fullAPI = { emit, on }; - const store = createMockStore(); + const fullAPI = { updateRef: jest.fn() }; + const moduleArgs = createMockModuleArgs({ fullAPI }); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { provider, store } = moduleArgs; - const { api, init } = initStoriesAndSetState({ store, navigate, provider, fullAPI } as any); - const { setIndex } = Object.assign(fullAPI, api); - setIndex({ v: 4, entries: preparedEntries }); - - init(); + const listener = jest.fn(); + provider.channel.on(UPDATE_STORY_ARGS, listener); + api.setIndex({ v: 4, entries: preparedEntries }); api.updateStoryArgs({ id: 'a--1' } as API_StoryEntry, { foo: 'bar' }); - expect(emit).toHaveBeenCalledWith(UPDATE_STORY_ARGS, { + + expect(listener).toHaveBeenCalledWith({ storyId: 'a--1', updatedArgs: { foo: 'bar' }, options: { @@ -921,23 +668,18 @@ describe('stories API', () => { expect((index['a--1'] as API_StoryEntry).args).toEqual({ a: 'b' }); expect((index['b--1'] as API_StoryEntry).args).toEqual({ x: 'y' }); }); - it('updateStoryArgs emits UPDATE_STORY_ARGS to the right frame', () => { - const navigate = jest.fn(); - const emit = jest.fn(); - const on = jest.fn(); - const fullAPI = { emit, on }; - const store = createMockStore(); + const fullAPI = { updateRef: jest.fn() }; + const moduleArgs = createMockModuleArgs({ fullAPI }); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { provider } = moduleArgs; - const { api, init } = initStoriesAndSetState({ store, navigate, provider, fullAPI } as any); - - const { setIndex } = Object.assign(fullAPI, api); - setIndex({ v: 4, entries: preparedEntries }); - - init(); + const listener = jest.fn(); + provider.channel.on(UPDATE_STORY_ARGS, listener); + api.setIndex({ v: 4, entries: preparedEntries }); api.updateStoryArgs({ id: 'a--1', refId: 'refId' } as API_StoryEntry, { foo: 'bar' }); - expect(emit).toHaveBeenCalledWith(UPDATE_STORY_ARGS, { + expect(listener).toHaveBeenCalledWith({ storyId: 'a--1', updatedArgs: { foo: 'bar' }, options: { @@ -945,22 +687,18 @@ describe('stories API', () => { }, }); }); - it('refId to the local frame and does not change anything', () => { - const navigate = jest.fn(); - const emit = jest.fn(); - const on = jest.fn(); - const fullAPI = { emit, on }; - const store = createMockStore(); - - const { api, init } = initStoriesAndSetState({ store, navigate, provider, fullAPI } as any); - - const { setIndex } = Object.assign(fullAPI, api); - setIndex({ v: 4, entries: preparedEntries }); - init(); - + const fullAPI = { updateRef: jest.fn() }; + const moduleArgs = createMockModuleArgs({ fullAPI }); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { provider, store } = moduleArgs; + const listener = jest.fn(); + provider.channel.on(RESET_STORY_ARGS, listener); + + api.setIndex({ v: 4, entries: preparedEntries }); api.resetStoryArgs({ id: 'a--1' } as API_StoryEntry, ['foo']); - expect(emit).toHaveBeenCalledWith(RESET_STORY_ARGS, { + + expect(listener).toHaveBeenCalledWith({ storyId: 'a--1', argNames: ['foo'], options: { @@ -972,22 +710,18 @@ describe('stories API', () => { expect((index['a--1'] as API_StoryEntry).args).toEqual({ a: 'b' }); expect((index['b--1'] as API_StoryEntry).args).toEqual({ x: 'y' }); }); - it('resetStoryArgs emits RESET_STORY_ARGS to the right frame', () => { - const navigate = jest.fn(); - const emit = jest.fn(); - const on = jest.fn(); - const fullAPI = { emit, on }; - const store = createMockStore(); - - const { api, init } = initStoriesAndSetState({ store, navigate, provider, fullAPI } as any); + const fullAPI = { updateRef: jest.fn() }; + const moduleArgs = createMockModuleArgs({ fullAPI }); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { provider } = moduleArgs; - const { setIndex } = Object.assign(fullAPI, api); - setIndex({ v: 4, entries: preparedEntries }); - init(); + const listener = jest.fn(); + provider.channel.on(RESET_STORY_ARGS, listener); + api.setIndex({ v: 4, entries: preparedEntries }); api.resetStoryArgs({ id: 'a--1', refId: 'refId' } as API_StoryEntry, ['foo']); - expect(emit).toHaveBeenCalledWith(RESET_STORY_ARGS, { + expect(listener).toHaveBeenCalledWith({ storyId: 'a--1', argNames: ['foo'], options: { @@ -997,268 +731,156 @@ describe('stories API', () => { }); }); - const navigationEntries: StoryIndex['entries'] = { - 'a--1': { - type: 'story', - title: 'a', - name: '1', - id: 'a--1', - importPath: './a.ts', - }, - 'a--2': { - type: 'story', - title: 'a', - name: '2', - id: 'a--2', - importPath: './a.ts', - }, - 'b-c--1': { - type: 'story', - title: 'b/c', - name: '1', - id: 'b-c--1', - importPath: './b/c.ts', - }, - 'b-d--1': { - type: 'story', - title: 'b/d', - name: '1', - id: 'b-d--1', - importPath: './b/d.ts', - }, - 'b-d--2': { - type: 'story', - title: 'b/d', - name: '2', - id: 'b-d--2', - importPath: './b/d.ts', - }, - 'custom-id--1': { - type: 'story', - title: 'b/e', - name: '1', - id: 'custom-id--1', - importPath: './b/.ts', - }, - }; - describe('jumpToStory', () => { it('works forward', () => { - const navigate = jest.fn(); - const store = createMockStore(); + const initialState = { path: '/story/a--1', storyId: 'a--1', viewMode: 'story' }; + const moduleArgs = createMockModuleArgs({ initialState }); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { navigate } = moduleArgs; - const { - api: { setIndex, jumpToStory }, - } = initStoriesAndSetState({ - store, - storyId: 'a--1', - viewMode: 'story', - navigate, - provider, - } as any); - setIndex({ v: 4, entries: navigationEntries }); + api.setIndex({ v: 4, entries: navigationEntries }); + api.jumpToStory(1); - jumpToStory(1); expect(navigate).toHaveBeenCalledWith('/story/a--2'); }); - it('works backwards', () => { - const navigate = jest.fn(); - const store = createMockStore(); + const initialState = { path: '/story/a--2', storyId: 'a--2', viewMode: 'story' }; + const moduleArgs = createMockModuleArgs({ initialState }); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { navigate } = moduleArgs; - const { - api: { setIndex, jumpToStory }, - } = initStoriesAndSetState({ - store, - storyId: 'a--2', - viewMode: 'story', - navigate, - provider, - } as any); - setIndex({ v: 4, entries: navigationEntries }); + api.setIndex({ v: 4, entries: navigationEntries }); + api.jumpToStory(-1); - jumpToStory(-1); expect(navigate).toHaveBeenCalledWith('/story/a--1'); }); - it('does nothing if you are at the last story and go forward', () => { - const navigate = jest.fn(); - const store = createMockStore(); - - const { - api: { setIndex, jumpToStory }, - } = initStoriesAndSetState({ - store, + const initialState = { + path: '/story/custom-id--1', storyId: 'custom-id--1', viewMode: 'story', - navigate, - provider, - } as any); - setIndex({ v: 4, entries: navigationEntries }); + }; + const moduleArgs = createMockModuleArgs({ initialState }); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { navigate } = moduleArgs; - jumpToStory(1); + api.setIndex({ v: 4, entries: navigationEntries }); + api.jumpToStory(1); expect(navigate).not.toHaveBeenCalled(); }); - it('does nothing if you are at the first story and go backward', () => { - const navigate = jest.fn(); - const store = createMockStore(); - - const { - api: { setIndex, jumpToStory }, - } = initStoriesAndSetState({ - store, - storyId: 'a--1', - viewMode: 'story', - navigate, - provider, - } as any); - setIndex({ v: 4, entries: navigationEntries }); + const initialState = { path: '/story/a--1', storyId: 'a--1', viewMode: 'story' }; + const moduleArgs = createMockModuleArgs({ initialState }); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { navigate } = moduleArgs; - jumpToStory(-1); + api.setIndex({ v: 4, entries: navigationEntries }); + api.jumpToStory(-1); expect(navigate).not.toHaveBeenCalled(); }); - it('does nothing if you have not selected a story', () => { - const navigate = jest.fn(); - const store = createMockStore(); - - const { - api: { setIndex, jumpToStory }, - } = initStoriesAndSetState({ store, navigate, provider } as any); - setIndex({ v: 4, entries: navigationEntries }); - - jumpToStory(1); + // @ts-expect-error (storyId type is maybe wrong?) + const initialState = { path: '/story', storyId: undefined, viewMode: 'story' }; + const moduleArgs = createMockModuleArgs({ initialState }); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { navigate } = moduleArgs; + + api.setIndex({ v: 4, entries: navigationEntries }); + api.jumpToStory(1); expect(navigate).not.toHaveBeenCalled(); }); }); describe('findSiblingStoryId', () => { it('works forward', () => { - const navigate = jest.fn(); - const store = createMockStore(); + const initialState = { path: '/story/a--1', storyId: 'a--1', viewMode: 'story' }; + const moduleArgs = createMockModuleArgs({ initialState }); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { store } = moduleArgs; - const storyId = 'a--1'; - const { - api: { setIndex, findSiblingStoryId }, - } = initStoriesAndSetState({ store, navigate, storyId, viewMode: 'story', provider } as any); - setIndex({ v: 4, entries: navigationEntries }); - - const result = findSiblingStoryId(storyId, store.getState().index, 1, false); + api.setIndex({ v: 4, entries: navigationEntries }); + const result = api.findSiblingStoryId('a--1', store.getState().index, 1, false); expect(result).toBe('a--2'); }); it('works forward toSiblingGroup', () => { - const navigate = jest.fn(); - const store = createMockStore(); - - const storyId = 'a--1'; - const { - api: { setIndex, findSiblingStoryId }, - } = initStoriesAndSetState({ store, navigate, storyId, viewMode: 'story', provider } as any); - setIndex({ v: 4, entries: navigationEntries }); + const initialState = { path: '/story/a--1', storyId: 'a--1', viewMode: 'story' }; + const moduleArgs = createMockModuleArgs({ initialState }); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { store } = moduleArgs; - const result = findSiblingStoryId(storyId, store.getState().index, 1, true); + api.setIndex({ v: 4, entries: navigationEntries }); + const result = api.findSiblingStoryId('a--1', store.getState().index, 1, true); expect(result).toBe('b-c--1'); }); }); describe('jumpToComponent', () => { it('works forward', () => { - const navigate = jest.fn(); - const store = createMockStore(); + const initialState = { path: '/story/a--1', storyId: 'a--1', viewMode: 'story' }; + const moduleArgs = createMockModuleArgs({ initialState }); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { navigate } = moduleArgs; - const { - api: { setIndex, jumpToComponent }, - } = initStoriesAndSetState({ - store, - navigate, - storyId: 'a--1', - viewMode: 'story', - provider, - } as any); - setIndex({ v: 4, entries: navigationEntries }); - - jumpToComponent(1); + api.setIndex({ v: 4, entries: navigationEntries }); + api.jumpToComponent(1); expect(navigate).toHaveBeenCalledWith('/story/b-c--1'); }); - it('works backwards', () => { - const navigate = jest.fn(); - const store = createMockStore(); - - const { - api: { setIndex, jumpToComponent }, - } = initStoriesAndSetState({ - store, - navigate, + const initialState = { + path: '/story/b-c--1', storyId: 'b-c--1', viewMode: 'story', - provider, - } as any); - setIndex({ v: 4, entries: navigationEntries }); + }; + const moduleArgs = createMockModuleArgs({ initialState }); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { navigate } = moduleArgs; - jumpToComponent(-1); + api.setIndex({ v: 4, entries: navigationEntries }); + api.jumpToComponent(-1); expect(navigate).toHaveBeenCalledWith('/story/a--1'); }); - it('does nothing if you are in the last component and go forward', () => { - const navigate = jest.fn(); - const store = createMockStore(); - - const { - api: { setIndex, jumpToComponent }, - } = initStoriesAndSetState({ - store, - navigate, + const initialState = { + path: '/story/custom-id--1', storyId: 'custom-id--1', viewMode: 'story', - provider, - } as any); - setIndex({ v: 4, entries: navigationEntries }); + }; + const moduleArgs = createMockModuleArgs({ initialState }); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { navigate } = moduleArgs; - jumpToComponent(1); + api.setIndex({ v: 4, entries: navigationEntries }); + api.jumpToComponent(1); expect(navigate).not.toHaveBeenCalled(); }); - it('does nothing if you are at the first component and go backward', () => { - const navigate = jest.fn(); - const store = createMockStore(); - - const { - api: { setIndex, jumpToComponent }, - } = initStoriesAndSetState({ - store, - navigate, - storyId: 'a--2', - viewMode: 'story', - provider, - } as any); - setIndex({ v: 4, entries: navigationEntries }); + const initialState = { path: '/story/a--2', storyId: 'a--2', viewMode: 'story' }; + const moduleArgs = createMockModuleArgs({ initialState }); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { navigate } = moduleArgs; - jumpToComponent(-1); + api.setIndex({ v: 4, entries: navigationEntries }); + api.jumpToComponent(-1); expect(navigate).not.toHaveBeenCalled(); }); }); - describe('selectStory', () => { it('navigates', () => { - const navigate = jest.fn(); - const store = createMockStore({ storyId: 'a--1', viewMode: 'story' }); - const { - api: { setIndex, selectStory }, - } = initStoriesAndSetState({ store, navigate, provider } as any); - setIndex({ v: 4, entries: navigationEntries }); + const initialState = { path: '/story/a--1', storyId: 'a--1', viewMode: 'story' }; + const moduleArgs = createMockModuleArgs({ initialState }); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { navigate } = moduleArgs; - selectStory('a--2'); + api.setIndex({ v: 4, entries: navigationEntries }); + api.selectStory('a--2'); expect(navigate).toHaveBeenCalledWith('/story/a--2'); }); - it('sets view mode to docs if doc-level component is selected', () => { - const navigate = jest.fn(); - const store = createMockStore({ storyId: 'a--1', viewMode: 'docs' }); - const { - api: { setIndex, selectStory }, - } = initStoriesAndSetState({ store, navigate, provider } as any); - setIndex({ + const initialState = { path: '/docs/a--1', storyId: 'a--1', viewMode: 'docs' }; + const moduleArgs = createMockModuleArgs({ initialState }); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { navigate } = moduleArgs; + + api.setIndex({ v: 4, entries: { ...navigationEntries, @@ -1272,194 +894,129 @@ describe('stories API', () => { }, }, }); - - selectStory('intro'); + api.selectStory('intro'); expect(navigate).toHaveBeenCalledWith('/docs/intro--docs'); }); - - describe('legacy api', () => { + describe('deprecated api', () => { it('allows navigating to a combination of title + name', () => { - const navigate = jest.fn(); - const store = createMockStore(); - const { - api: { setIndex, selectStory }, - } = initStoriesAndSetState({ - store, - storyId: 'a--1', - viewMode: 'story', - navigate, - provider, - } as any); - setIndex({ v: 4, entries: navigationEntries }); - - selectStory('a', '2'); + const initialState = { path: '/story/a--1', storyId: 'a--1', viewMode: 'story' }; + const moduleArgs = createMockModuleArgs({ initialState }); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { navigate } = moduleArgs; + + api.setIndex({ v: 4, entries: navigationEntries }); + api.selectStory('a', '2'); expect(navigate).toHaveBeenCalledWith('/story/a--2'); }); - it('allows navigating to a given name (in the current component)', () => { - const navigate = jest.fn(); - const store = createMockStore(); - const { - api: { setIndex, selectStory }, - } = initStoriesAndSetState({ - store, - storyId: 'a--1', - viewMode: 'story', - navigate, - provider, - } as any); - setIndex({ v: 4, entries: navigationEntries }); - - selectStory(undefined, '2'); + const initialState = { path: '/story/a--1', storyId: 'a--1', viewMode: 'story' }; + const moduleArgs = createMockModuleArgs({ initialState }); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { navigate } = moduleArgs; + + api.setIndex({ v: 4, entries: navigationEntries }); + api.selectStory(undefined, '2'); expect(navigate).toHaveBeenCalledWith('/story/a--2'); }); }); - it('allows navigating away from the settings pages', () => { - const navigate = jest.fn(); - const store = createMockStore({ storyId: 'a--1', viewMode: 'settings' }); - const { - api: { setIndex, selectStory }, - } = initStoriesAndSetState({ store, navigate, provider } as any); - setIndex({ v: 4, entries: navigationEntries }); + const initialState = { path: '/settings/a--1', storyId: 'a--1', viewMode: 'settings' }; + const moduleArgs = createMockModuleArgs({ initialState }); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { navigate } = moduleArgs; - selectStory('a--2'); + api.setIndex({ v: 4, entries: navigationEntries }); + api.selectStory('a--2'); expect(navigate).toHaveBeenCalledWith('/story/a--2'); }); - it('allows navigating to first story in component on call by component id', () => { - const navigate = jest.fn(); - const store = createMockStore(); - const { - api: { setIndex, selectStory }, - } = initStoriesAndSetState({ - store, - storyId: 'a--1', - viewMode: 'story', - navigate, - provider, - } as any); - setIndex({ v: 4, entries: navigationEntries }); + const initialState = { path: '/story/a--1', storyId: 'a--1', viewMode: 'story' }; + const moduleArgs = createMockModuleArgs({ initialState }); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { navigate } = moduleArgs; - selectStory('a'); + api.setIndex({ v: 4, entries: navigationEntries }); + api.selectStory('a'); expect(navigate).toHaveBeenCalledWith('/story/a--1'); }); - it('allows navigating to first story in group on call by group id', () => { - const navigate = jest.fn(); - const store = createMockStore(); - const { - api: { setIndex, selectStory }, - } = initStoriesAndSetState({ - store, - storyId: 'a--1', - viewMode: 'story', - navigate, - provider, - } as any); - setIndex({ v: 4, entries: navigationEntries }); + const initialState = { path: '/story/a--1', storyId: 'a--1', viewMode: 'story' }; + const moduleArgs = createMockModuleArgs({ initialState }); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { navigate } = moduleArgs; - selectStory('b'); + api.setIndex({ v: 4, entries: navigationEntries }); + api.selectStory('b'); expect(navigate).toHaveBeenCalledWith('/story/b-c--1'); }); - it('allows navigating to first story in component on call by title', () => { - const navigate = jest.fn(); - const store = createMockStore(); - const { - api: { setIndex, selectStory }, - } = initStoriesAndSetState({ - store, - storyId: 'a--1', - viewMode: 'story', - navigate, - provider, - } as any); - setIndex({ v: 4, entries: navigationEntries }); + const initialState = { path: '/story/a--1', storyId: 'a--1', viewMode: 'story' }; + const moduleArgs = createMockModuleArgs({ initialState }); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { navigate } = moduleArgs; - selectStory('A'); + api.setIndex({ v: 4, entries: navigationEntries }); + api.selectStory('A'); expect(navigate).toHaveBeenCalledWith('/story/a--1'); }); - it('allows navigating to the first story of the current component if passed nothing', () => { - const navigate = jest.fn(); - const store = createMockStore(); - const { - api: { setIndex, selectStory }, - } = initStoriesAndSetState({ - store, - storyId: 'a--2', - viewMode: 'story', - navigate, - provider, - } as any); - setIndex({ v: 4, entries: navigationEntries }); + const initialState = { path: '/story/a--1', storyId: 'a--1', viewMode: 'story' }; + const moduleArgs = createMockModuleArgs({ initialState }); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { navigate } = moduleArgs; - selectStory(); + api.setIndex({ v: 4, entries: navigationEntries }); + api.selectStory(); expect(navigate).toHaveBeenCalledWith('/story/a--1'); }); - describe('component permalinks', () => { it('allows navigating to kind/storyname (legacy api)', () => { - const navigate = jest.fn(); - const store = createMockStore(); + const initialState = { path: '/story/a--1', storyId: 'a--1', viewMode: 'story' }; + const moduleArgs = createMockModuleArgs({ initialState }); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { navigate } = moduleArgs; - const { - api: { selectStory, setIndex }, - } = initStoriesAndSetState({ store, navigate, provider } as any); - setIndex({ v: 4, entries: navigationEntries }); - - selectStory('b/e', '1'); + api.setIndex({ v: 4, entries: navigationEntries }); + api.selectStory('b/e', '1'); expect(navigate).toHaveBeenCalledWith('/story/custom-id--1'); }); - it('allows navigating to component permalink/storyname (legacy api)', () => { - const navigate = jest.fn(); - const store = createMockStore(); - - const { - api: { selectStory, setIndex }, - } = initStoriesAndSetState({ store, navigate, provider } as any); - setIndex({ v: 4, entries: navigationEntries }); + const initialState = { path: '/story/a--1', storyId: 'a--1', viewMode: 'story' }; + const moduleArgs = createMockModuleArgs({ initialState }); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { navigate } = moduleArgs; - selectStory('custom-id', '1'); + api.setIndex({ v: 4, entries: navigationEntries }); + api.selectStory('custom-id', '1'); expect(navigate).toHaveBeenCalledWith('/story/custom-id--1'); }); - it('allows navigating to first story in kind on call by kind', () => { - const navigate = jest.fn(); - const store = createMockStore(); - - const { - api: { selectStory, setIndex }, - } = initStoriesAndSetState({ store, navigate, provider } as any); - setIndex({ v: 4, entries: navigationEntries }); + const initialState = { path: '/story/a--1', storyId: 'a--1', viewMode: 'story' }; + const moduleArgs = createMockModuleArgs({ initialState }); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { navigate } = moduleArgs; - selectStory('b/e'); + api.setIndex({ v: 4, entries: navigationEntries }); + api.selectStory('b/e'); expect(navigate).toHaveBeenCalledWith('/story/custom-id--1'); }); }); }); - describe('STORY_PREPARED', () => { it('prepares the story', async () => { - const navigate = jest.fn(); - const store = createMockStore(); - const fullAPI = Object.assign(new EventEmitter(), { - setStories: jest.fn(), - setOptions: jest.fn(), - }); + const fullAPI = { setOptions: jest.fn() }; + const initialState = { path: '/story/a--1', storyId: 'a--1', viewMode: 'story' }; + const moduleArgs = createMockModuleArgs({ initialState, fullAPI }); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { provider, store } = moduleArgs; - const { api, init } = initStoriesAndSetState({ store, navigate, provider, fullAPI } as any); - Object.assign(fullAPI, api); + api.setIndex({ v: 4, entries: mockEntries }); - await init(); - fullAPI.emit(STORY_PREPARED, { + provider.channel.emit(STORY_PREPARED, { id: 'component-a--story-1', parameters: { a: 'b' }, args: { c: 'd' }, }); - const { index } = store.getState(); expect(index['component-a--story-1']).toMatchObject({ type: 'story', @@ -1472,54 +1029,42 @@ describe('stories API', () => { args: { c: 'd' }, }); }); - it('sets options the first time it is called', async () => { - const navigate = jest.fn(); - const store = createMockStore(); - const fullAPI = Object.assign(new EventEmitter(), { - setStories: jest.fn(), - setOptions: jest.fn(), - }); + const fullAPI = { setOptions: jest.fn() }; + const initialState = { path: '/story/a--1', storyId: 'a--1', viewMode: 'story' }; + const moduleArgs = createMockModuleArgs({ initialState, fullAPI }); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { provider } = moduleArgs; - const { api, init } = initStoriesAndSetState({ store, navigate, provider, fullAPI } as any); - Object.assign(fullAPI, api); + api.setIndex({ v: 4, entries: mockEntries }); - await init(); - fullAPI.emit(STORY_PREPARED, { + provider.channel.emit(STORY_PREPARED, { id: 'component-a--story-1', parameters: { options: 'options' }, }); - expect(fullAPI.setOptions).toHaveBeenCalledWith('options'); fullAPI.setOptions.mockClear(); - fullAPI.emit(STORY_PREPARED, { + + provider.channel.emit(STORY_PREPARED, { id: 'component-a--story-1', parameters: { options: 'options2' }, }); - expect(fullAPI.setOptions).not.toHaveBeenCalled(); }); }); - describe('DOCS_PREPARED', () => { it('prepares the docs entry', async () => { - const navigate = jest.fn(); - const store = createMockStore(); - const fullAPI = Object.assign(new EventEmitter(), { - setStories: jest.fn(), - setOptions: jest.fn(), - }); + const moduleArgs = createMockModuleArgs({}); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { provider, store } = moduleArgs; - const { api, init } = initStoriesAndSetState({ store, navigate, provider, fullAPI } as any); - Object.assign(fullAPI, api); + api.setIndex({ v: 4, entries: mockEntries }); - await init(); - fullAPI.emit(DOCS_PREPARED, { + provider.channel.emit(DOCS_PREPARED, { id: 'component-a--docs', parameters: { a: 'b' }, }); - const { index } = store.getState(); expect(index['component-a--docs']).toMatchObject({ type: 'docs', @@ -1532,104 +1077,75 @@ describe('stories API', () => { }); }); }); - describe('CONFIG_ERROR', () => { it('sets previewInitialized to true, local', async () => { - const navigate = jest.fn(); - const store = createMockStore(); - const fullAPI = Object.assign(new EventEmitter(), {}); - - const { api, init } = initStoriesAndSetState({ store, navigate, provider, fullAPI } as any); - Object.assign(fullAPI, api); + const moduleArgs = createMockModuleArgs({}); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { provider, store } = moduleArgs; - await init(); - - fullAPI.emit(CONFIG_ERROR, { message: 'Failed to run configure' }); + api.setIndex({ v: 4, entries: mockEntries }); + provider.channel.emit(CONFIG_ERROR, { message: 'Failed to run configure' }); const { previewInitialized } = store.getState(); expect(previewInitialized).toBe(true); }); - it('sets previewInitialized to true, ref', async () => { - const navigate = jest.fn(); - const fullAPI = Object.assign(new EventEmitter(), { - updateRef: jest.fn(), - }); - const store = createMockStore(); - const { api, init } = initStoriesAndSetState({ store, navigate, provider, fullAPI } as any); + const fullAPI = { updateRef: jest.fn() }; + const moduleArgs = createMockModuleArgs({ fullAPI }); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { provider } = moduleArgs; - Object.assign(fullAPI, api); + api.setIndex({ v: 4, entries: mockEntries }); - getEventMetadataMock.mockReturnValueOnce({ + getEventMetadata.mockReturnValueOnce({ sourceType: 'external', ref: { id: 'refId', stories: { 'a--1': { args: { a: 'b' } } } }, } as any); - await init(); - fullAPI.emit(CONFIG_ERROR, { message: 'Failed to run configure' }); - + provider.channel.emit(CONFIG_ERROR, { message: 'Failed to run configure' }); expect(fullAPI.updateRef.mock.calls.length).toBe(1); expect(fullAPI.updateRef.mock.calls[0][1]).toEqual({ previewInitialized: true, }); }); }); - describe('STORY_MISSING', () => { it('sets previewInitialized to true, local', async () => { - const navigate = jest.fn(); - const store = createMockStore(); - const fullAPI = Object.assign(new EventEmitter(), {}); - - const { api, init } = initStoriesAndSetState({ store, navigate, provider, fullAPI } as any); - Object.assign(fullAPI, api); - - await init(); - - fullAPI.emit(STORY_MISSING, { message: 'Failed to run configure' }); + const moduleArgs = createMockModuleArgs({}); + initStories(moduleArgs as unknown as ModuleArgs); + const { provider, store } = moduleArgs; + provider.channel.emit(STORY_MISSING, { message: 'Failed to run configure' }); const { previewInitialized } = store.getState(); expect(previewInitialized).toBe(true); }); - it('sets previewInitialized to true, ref', async () => { - const navigate = jest.fn(); - const fullAPI = Object.assign(new EventEmitter(), { - updateRef: jest.fn(), - }); - const store = createMockStore(); - const { api, init } = initStoriesAndSetState({ store, navigate, provider, fullAPI } as any); + const fullAPI = { updateRef: jest.fn() }; + const moduleArgs = createMockModuleArgs({ fullAPI }); + initStories(moduleArgs as unknown as ModuleArgs); + const { provider } = moduleArgs; - Object.assign(fullAPI, api); - - getEventMetadataMock.mockReturnValueOnce({ + getEventMetadata.mockReturnValueOnce({ sourceType: 'external', ref: { id: 'refId', stories: { 'a--1': { args: { a: 'b' } } } }, } as any); - await init(); - fullAPI.emit(STORY_MISSING, { message: 'Failed to run configure' }); - + provider.channel.emit(STORY_MISSING, { message: 'Failed to run configure' }); expect(fullAPI.updateRef.mock.calls.length).toBe(1); expect(fullAPI.updateRef.mock.calls[0][1]).toEqual({ previewInitialized: true, }); }); }); - describe('v2 SET_STORIES event', () => { it('normalizes parameters and calls setRef for external stories', () => { - const fullAPI = Object.assign(new EventEmitter(), {}); - const navigate = jest.fn(); - const store = createMockStore(); - - const { init, api } = initStoriesAndSetState({ store, navigate, provider, fullAPI } as any); - const finalAPI = Object.assign(fullAPI, api, { - setIndex: jest.fn(), + const fullAPI = { findRef: jest.fn(), setRef: jest.fn(), - }); - init(); + }; + const moduleArgs = createMockModuleArgs({ fullAPI }); + initStories(moduleArgs as unknown as ModuleArgs); + const { provider, store } = moduleArgs; - getEventMetadataMock.mockReturnValueOnce({ + getEventMetadata.mockReturnValueOnce({ sourceType: 'external', ref: { id: 'ref' }, } as any); @@ -1639,10 +1155,9 @@ describe('stories API', () => { kindParameters: { a: { kind: 'kind' } }, stories: { 'a--1': { kind: 'a', parameters: { story: 'story' } } }, }; - finalAPI.emit(SET_STORIES, setStoriesPayload); - - expect(finalAPI.setIndex).not.toHaveBeenCalled(); - expect(finalAPI.setRef).toHaveBeenCalledWith( + provider.channel.emit(SET_STORIES, setStoriesPayload); + expect(store.getState().index).toBeUndefined(); + expect(fullAPI.setRef).toHaveBeenCalledWith( 'ref', { id: 'ref', @@ -1656,28 +1171,23 @@ describe('stories API', () => { }); describe('legacy (v1) SET_STORIES event', () => { it('calls setRef with stories', () => { - const fullAPI = Object.assign(new EventEmitter()); - const navigate = jest.fn(); - const store = createMockStore(); - - const { init, api } = initStoriesAndSetState({ store, navigate, provider, fullAPI } as any); - Object.assign(fullAPI, api, { - setIndex: jest.fn(), + const fullAPI = { findRef: jest.fn(), setRef: jest.fn(), - }); - init(); + }; + const moduleArgs = createMockModuleArgs({ fullAPI }); + initStories(moduleArgs as unknown as ModuleArgs); + const { provider, store } = moduleArgs; - getEventMetadataMock.mockReturnValueOnce({ + getEventMetadata.mockReturnValueOnce({ sourceType: 'external', ref: { id: 'ref' }, } as any); const setStoriesPayload = { stories: { 'a--1': {} }, }; - fullAPI.emit(SET_STORIES, setStoriesPayload); - - expect(fullAPI.setIndex).not.toHaveBeenCalled(); + provider.channel.emit(SET_STORIES, setStoriesPayload); + expect(store.getState().index).toBeUndefined(); expect(fullAPI.setRef).toHaveBeenCalledWith( 'ref', { @@ -1690,47 +1200,33 @@ describe('stories API', () => { ); }); }); +}); +describe('experimental_updateStatus', () => { + it('is included in the initial state', () => { + const moduleArgs = createMockModuleArgs({}); + const { state } = initStories(moduleArgs as unknown as ModuleArgs); - describe('experimental_updateStatus', () => { - it('is included in the initial state', () => { - const { state } = initStoriesAndSetState({ - storyId: 'id', - viewMode: 'story', - } as ModuleArgs); - - expect(state).toEqual( - expect.objectContaining({ - status: {}, - }) - ); - }); - - it('updates a story', async () => { - const fullAPI = Object.assign(new EventEmitter()); - const navigate = jest.fn(); - const store = createMockStore(); - - const { init, api } = initStoriesAndSetState({ store, navigate, provider, fullAPI } as any); - - const API: SubAPI = Object.assign(fullAPI, api, { - setIndex: jest.fn(), - findRef: jest.fn(), - setRef: jest.fn(), - }); - - await init(); - - await expect( - API.experimental_updateStatus('a-addon-id', { - 'a-story-id': { - status: 'pending', - title: 'an addon title', - description: 'an addon description', - }, - }) - ).resolves.not.toThrow(); - - expect(store.getState().status).toMatchInlineSnapshot(` + expect(state).toEqual( + expect.objectContaining({ + status: {}, + }) + ); + }); + it('updates a story', async () => { + const moduleArgs = createMockModuleArgs({}); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { store } = moduleArgs; + + await expect( + api.experimental_updateStatus('a-addon-id', { + 'a-story-id': { + status: 'pending', + title: 'an addon title', + description: 'an addon description', + }, + }) + ).resolves.not.toThrow(); + expect(store.getState().status).toMatchInlineSnapshot(` Object { "a-story-id": Object { "a-addon-id": Object { @@ -1741,35 +1237,23 @@ describe('stories API', () => { }, } `); - }); - - it('updates multiple stories', async () => { - const fullAPI = Object.assign(new EventEmitter()); - const navigate = jest.fn(); - const store = createMockStore(); - - const { init, api } = initStoriesAndSetState({ store, navigate, provider, fullAPI } as any); - - const API: SubAPI = Object.assign(fullAPI, api, { - setIndex: jest.fn(), - findRef: jest.fn(), - setRef: jest.fn(), - }); - - await init(); - - await expect( - API.experimental_updateStatus('a-addon-id', { - 'a-story-id': { - status: 'pending', - title: 'an addon title', - description: 'an addon description', - }, - 'another-story-id': { status: 'success', title: 'a addon title', description: '' }, - }) - ).resolves.not.toThrow(); - - expect(store.getState().status).toMatchInlineSnapshot(` + }); + it('updates multiple stories', async () => { + const moduleArgs = createMockModuleArgs({}); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { store } = moduleArgs; + + await expect( + api.experimental_updateStatus('a-addon-id', { + 'a-story-id': { + status: 'pending', + title: 'an addon title', + description: 'an addon description', + }, + 'another-story-id': { status: 'success', title: 'a addon title', description: '' }, + }) + ).resolves.not.toThrow(); + expect(store.getState().status).toMatchInlineSnapshot(` Object { "a-story-id": Object { "a-addon-id": Object { @@ -1787,6 +1271,132 @@ describe('stories API', () => { }, } `); + }); + describe('experimental_setFilter', () => { + it('is included in the initial state', () => { + const moduleArgs = createMockModuleArgs({}); + const { state } = initStories(moduleArgs as unknown as ModuleArgs); + + expect(state).toEqual( + expect.objectContaining({ + filters: {}, + }) + ); + }); + it('updates state', () => { + const moduleArgs = createMockModuleArgs({}); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { store } = moduleArgs; + + api.experimental_setFilter('myCustomFilter', () => true); + + expect(store.getState()).toEqual( + expect.objectContaining({ + filters: { + myCustomFilter: expect.any(Function), + }, + }) + ); + }); + + it('can filter', () => { + const moduleArgs = createMockModuleArgs({}); + const { + api, + state: { status }, + } = initStories(moduleArgs as unknown as ModuleArgs); + const { store } = moduleArgs; + + /** + * This function is a copy of the one in the containers/sidebar.ts file inside of ui/manager + * I'm hoping we can eventually merge this 2 packages so there's no odd looking import and no re-implementation. + */ + const applyFilters = (originalIndex: API_IndexHash) => { + if (!originalIndex) { + return originalIndex; + } + + const filtered = new Set(); + Object.values(originalIndex).forEach((item) => { + if (item.type === 'story' || item.type === 'docs') { + let result = true; + + Object.values(filters).forEach((filter) => { + if (result === true) { + result = filter({ ...item, status: status[item.id] }); + } + }); + + if (result) { + filtered.add(item.id); + getAncestorIds(originalIndex, item.id).forEach((id) => { + filtered.add(id); + }); + } + } + }); + + return Object.fromEntries( + Object.entries(originalIndex).filter(([key]) => filtered.has(key)) + ); + }; + + api.experimental_setFilter('myCustomFilter', (item) => item.id.startsWith('a')); + api.setIndex({ v: 4, entries: navigationEntries }); + + const { index, filters } = store.getState(); + + const filtered = applyFilters(index); + + expect(filtered).toMatchInlineSnapshot(` + Object { + "a": Object { + "children": Array [ + "a--1", + "a--2", + ], + "depth": 0, + "id": "a", + "isComponent": true, + "isLeaf": false, + "isRoot": false, + "name": "a", + "parent": undefined, + "renderLabel": undefined, + "type": "component", + }, + "a--1": Object { + "depth": 1, + "id": "a--1", + "importPath": "./a.ts", + "isComponent": false, + "isLeaf": true, + "isRoot": false, + "kind": "a", + "name": "1", + "parent": "a", + "prepared": false, + "renderLabel": undefined, + "title": "a", + "type": "story", + }, + "a--2": Object { + "depth": 1, + "id": "a--2", + "importPath": "./a.ts", + "isComponent": false, + "isLeaf": true, + "isRoot": false, + "kind": "a", + "name": "2", + "parent": "a", + "prepared": false, + "renderLabel": undefined, + "title": "a", + "type": "story", + }, + } + `); }); }); }); diff --git a/code/lib/manager-api/src/tests/url.test.js b/code/lib/manager-api/src/tests/url.test.js index c269331b1c77..33cf4a1872c1 100644 --- a/code/lib/manager-api/src/tests/url.test.js +++ b/code/lib/manager-api/src/tests/url.test.js @@ -2,6 +2,7 @@ import qs from 'qs'; import { SET_CURRENT_STORY, GLOBALS_UPDATED, UPDATE_QUERY_PARAMS } from '@storybook/core-events'; +import EventEmitter from 'events'; import { init as initURL } from '../modules/url'; jest.mock('@storybook/client-logger'); @@ -17,7 +18,7 @@ describe('initial state', () => { const { state: { layout }, - } = initURL({ navigate, state: { location } }); + } = initURL({ navigate, state: { location }, provider: { channel: new EventEmitter() } }); expect(layout).toEqual({ isFullscreen: true }); }); @@ -28,7 +29,7 @@ describe('initial state', () => { const { state: { layout }, - } = initURL({ navigate, state: { location } }); + } = initURL({ navigate, state: { location }, provider: { channel: new EventEmitter() } }); expect(layout).toEqual({ showNav: false }); }); @@ -39,7 +40,7 @@ describe('initial state', () => { const { state: { ui }, - } = initURL({ navigate, state: { location } }); + } = initURL({ navigate, state: { location }, provider: { channel: new EventEmitter() } }); expect(ui).toEqual({ enableShortcuts: false }); }); @@ -50,7 +51,7 @@ describe('initial state', () => { const { state: { layout }, - } = initURL({ navigate, state: { location } }); + } = initURL({ navigate, state: { location }, provider: { channel: new EventEmitter() } }); expect(layout).toEqual({ panelPosition: 'bottom' }); }); @@ -61,7 +62,7 @@ describe('initial state', () => { const { state: { layout }, - } = initURL({ navigate, state: { location } }); + } = initURL({ navigate, state: { location }, provider: { channel: new EventEmitter() } }); expect(layout).toEqual({ panelPosition: 'right' }); }); @@ -72,7 +73,7 @@ describe('initial state', () => { const { state: { layout }, - } = initURL({ navigate, state: { location } }); + } = initURL({ navigate, state: { location }, provider: { channel: new EventEmitter() } }); expect(layout).toEqual({ showPanel: false }); }); @@ -88,18 +89,23 @@ describe('queryParams', () => { }, getState: () => state, }; - const fullAPI = { emit: jest.fn() }; + const channel = new EventEmitter(); const { api } = initURL({ state: { location: { search: '' } }, navigate: jest.fn(), store, - fullAPI, + provider: { channel }, }); + const listener = jest.fn(); + + channel.on(UPDATE_QUERY_PARAMS, listener); + api.setQueryParams({ foo: 'bar' }); expect(api.getQueryParam('foo')).toEqual('bar'); - expect(fullAPI.emit).toHaveBeenCalledWith(UPDATE_QUERY_PARAMS, { foo: 'bar' }); + + expect(listener).toHaveBeenCalledWith({ foo: 'bar' }); }); }); @@ -120,14 +126,6 @@ describe('initModule', () => { }); const fullAPI = { - callbacks: {}, - on(event, fn) { - this.callbacks[event] = this.callbacks[event] || []; - this.callbacks[event].push(fn); - }, - emit(event, ...args) { - this.callbacks[event]?.forEach((cb) => cb(...args)); - }, showReleaseNotesOnLaunch: jest.fn(), }; @@ -140,19 +138,22 @@ describe('initModule', () => { store.setState(storyState('test--story')); const navigate = jest.fn(); - - const { api, init } = initURL({ store, state: { location: {} }, navigate, fullAPI }); - Object.assign(fullAPI, api, { - getCurrentStoryData: () => ({ - type: 'story', - args: { a: 1, b: 2 }, - initialArgs: { a: 1, b: 1 }, - isLeaf: true, + const channel = new EventEmitter(); + initURL({ + store, + provider: { channel }, + state: { location: {} }, + navigate, + fullAPI: Object.assign(fullAPI, { + getCurrentStoryData: () => ({ + type: 'story', + args: { a: 1, b: 2 }, + initialArgs: { a: 1, b: 1 }, + isLeaf: true, + }), }), }); - init(); - - fullAPI.emit(SET_CURRENT_STORY); + channel.emit(SET_CURRENT_STORY); expect(navigate).toHaveBeenCalledWith( '/story/test--story&args=b:2', expect.objectContaining({ replace: true }) @@ -164,12 +165,10 @@ describe('initModule', () => { store.setState(storyState('test--story')); const navigate = jest.fn(); + const channel = new EventEmitter(); + initURL({ store, provider: { channel }, state: { location: {} }, navigate, fullAPI }); - const { api, init } = initURL({ store, state: { location: {} }, navigate, fullAPI }); - Object.assign(fullAPI, api); - init(); - - fullAPI.emit(GLOBALS_UPDATED, { globals: { a: 2 }, initialGlobals: { a: 1, b: 1 } }); + channel.emit(GLOBALS_UPDATED, { globals: { a: 2 }, initialGlobals: { a: 1, b: 1 } }); expect(navigate).toHaveBeenCalledWith( '/story/test--story&globals=a:2;b:!undefined', expect.objectContaining({ replace: true }) @@ -180,20 +179,24 @@ describe('initModule', () => { it('adds url params alphabetically', async () => { store.setState({ ...storyState('test--story'), customQueryParams: { full: 1 } }); const navigate = jest.fn(); - - const { api, init } = initURL({ store, state: { location: {} }, navigate, fullAPI }); - Object.assign(fullAPI, api, { - getCurrentStoryData: () => ({ type: 'story', args: { a: 1 }, isLeaf: true }), + const channel = new EventEmitter(); + const { api } = initURL({ + store, + provider: { channel }, + state: { location: {} }, + navigate, + fullAPI: Object.assign(fullAPI, { + getCurrentStoryData: () => ({ type: 'story', args: { a: 1 }, isLeaf: true }), + }), }); - init(); - fullAPI.emit(GLOBALS_UPDATED, { globals: { g: 2 } }); + channel.emit(GLOBALS_UPDATED, { globals: { g: 2 } }); expect(navigate).toHaveBeenCalledWith( '/story/test--story&full=1&globals=g:2', expect.objectContaining({ replace: true }) ); - fullAPI.emit(SET_CURRENT_STORY); + channel.emit(SET_CURRENT_STORY); expect(navigate).toHaveBeenCalledWith( '/story/test--story&args=a:1&full=1&globals=g:2', expect.objectContaining({ replace: true }) diff --git a/code/lib/types/src/modules/addons.ts b/code/lib/types/src/modules/addons.ts index f55efcd29983..7c6a7987a2e3 100644 --- a/code/lib/types/src/modules/addons.ts +++ b/code/lib/types/src/modules/addons.ts @@ -10,6 +10,7 @@ import type { } from 'react'; import type { RenderData as RouterData } from '../../../router/src/types'; import type { ThemeVars } from '../../../theming/src/types'; +import type { API_SidebarOptions } from './api'; import type { Args, ArgsStoryFn as ArgsStoryFnForFramework, @@ -477,6 +478,7 @@ export interface Addon_Config { toolbar?: { [id: string]: Addon_ToolbarConfig; }; + sidebar?: API_SidebarOptions; [key: string]: any; } diff --git a/code/lib/types/src/modules/api-stories.ts b/code/lib/types/src/modules/api-stories.ts index 414f1384d761..fd0f3ca31d04 100644 --- a/code/lib/types/src/modules/api-stories.ts +++ b/code/lib/types/src/modules/api-stories.ts @@ -130,7 +130,7 @@ export interface API_IndexHash { } // We used to received a bit more data over the channel on the SET_STORIES event, including // the full parameters for each story. -type API_PreparedIndexEntry = IndexEntry & { +export type API_PreparedIndexEntry = IndexEntry & { parameters?: Parameters; argTypes?: ArgTypes; args?: Args; @@ -184,3 +184,7 @@ export interface API_StatusObject { export type API_StatusState = Record>; export type API_StatusUpdate = Record; + +export type API_FilterFunction = ( + item: API_IndexHash[keyof API_IndexHash] & { status: Record } +) => boolean; diff --git a/code/lib/types/src/modules/api.ts b/code/lib/types/src/modules/api.ts index 762166b48fee..1fbaf0bba9bd 100644 --- a/code/lib/types/src/modules/api.ts +++ b/code/lib/types/src/modules/api.ts @@ -4,7 +4,7 @@ import type { RenderData } from '../../../router/src/types'; import type { Channel } from '../../../channels/src'; import type { ThemeVars } from '../../../theming/src/types'; import type { DocsOptions } from './core-common'; -import type { API_HashEntry, API_IndexHash } from './api-stories'; +import type { API_FilterFunction, API_HashEntry, API_IndexHash } from './api-stories'; import type { SetStoriesStory, SetStoriesStoryData } from './channelApi'; import type { Addon_BaseType, Addon_Collection, Addon_RenderOptions, Addon_Type } from './addons'; import type { StoryIndex } from './indexer'; @@ -112,6 +112,7 @@ export type API_ActiveTabsType = 'sidebar' | 'canvas' | 'addons'; export interface API_SidebarOptions { showRoots?: boolean; + filters?: Record; collapsedRoots?: string[]; renderLabel?: (item: API_HashEntry) => any; } diff --git a/code/ui/.storybook/manager.tsx b/code/ui/.storybook/manager.tsx index 775a1f63c8ed..64691f64f4a3 100644 --- a/code/ui/.storybook/manager.tsx +++ b/code/ui/.storybook/manager.tsx @@ -1,7 +1,5 @@ import { addons, types } from '@storybook/manager-api'; -import { IconButton, Icons } from '@storybook/components'; import startCase from 'lodash/startCase.js'; -import React, { Fragment } from 'react'; addons.setConfig({ sidebar: { diff --git a/code/ui/manager/src/containers/sidebar.tsx b/code/ui/manager/src/containers/sidebar.tsx index c4beb5bda4c4..a97f929bf246 100755 --- a/code/ui/manager/src/containers/sidebar.tsx +++ b/code/ui/manager/src/containers/sidebar.tsx @@ -1,10 +1,11 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import type { Combo, StoriesHash } from '@storybook/manager-api'; import { Consumer } from '@storybook/manager-api'; import { Sidebar as SidebarComponent } from '../components/sidebar/Sidebar'; import { useMenu } from './menu'; +import { getAncestorIds } from '../utils/tree'; export type Item = StoriesHash[keyof StoriesHash]; @@ -16,11 +17,12 @@ const Sidebar = React.memo(function Sideber() { storyId, refId, layout: { showToolbar, isFullscreen, showPanel, showNav }, - index, + index: originalIndex, status, indexError, previewInitialized, refs, + filters, } = state; const menu = useMenu( @@ -36,6 +38,34 @@ const Sidebar = React.memo(function Sideber() { const whatsNewNotificationsEnabled = state.whatsNewData?.status === 'SUCCESS' && !state.disableWhatsNewNotifications; + const index = useMemo(() => { + if (!originalIndex) { + return originalIndex; + } + + const filtered = new Set(); + Object.values(originalIndex).forEach((item) => { + if (item.type === 'story' || item.type === 'docs') { + let result = true; + + Object.values(filters).forEach((filter) => { + if (result === true) { + result = filter({ ...item, status: status[item.id] }); + } + }); + + if (result) { + filtered.add(item.id); + getAncestorIds(originalIndex, item.id).forEach((id) => { + filtered.add(id); + }); + } + } + }); + + return Object.fromEntries(Object.entries(originalIndex).filter(([key]) => filtered.has(key))); + }, [originalIndex, filters, status]); + return { title: name, url, diff --git a/docs/essentials/controls.md b/docs/essentials/controls.md index 5266c0760f02..cc0f937ec97b 100644 --- a/docs/essentials/controls.md +++ b/docs/essentials/controls.md @@ -123,26 +123,26 @@ If you haven't used the CLI to setup the configuration, or if you want to define ## Fully custom args -Until now, we only used auto-generated controls based on the component we're writing stories for. If we are writing [complex stories](../writing-stories/stories-for-multiple-components.md), we may want to add controls for args that aren’t part of the component. +Until now, we only used auto-generated controls based on the component we're writing stories for. If we are writing [complex stories](../writing-stories/stories-for-multiple-components.md), we may want to add controls for args that aren’t part of the component. For example, here's how you could use a `footer` arg to populate a child component: diff --git a/docs/snippets/angular/page-story-slots.ts.mdx b/docs/snippets/angular/page-story-slots.ts.mdx index 2b79a6caa3e3..63ac7f3309cc 100644 --- a/docs/snippets/angular/page-story-slots.ts.mdx +++ b/docs/snippets/angular/page-story-slots.ts.mdx @@ -5,26 +5,23 @@ import type { Meta, StoryObj } from '@storybook/angular'; import { Page } from './page.component'; -const meta: Meta = { - component: Page, -}; - -export default meta; -type Story = StoryObj; +type PagePropsAndCustomArgs = Page & { footer?: string }; -/* - *πŸ‘‡ Render functions are a framework specific feature to allow you control on how the component renders. - * See https://storybook.js.org/docs/angular/api/csf - * to learn how to use render functions. - */ -export const CustomFooter: Story = { - render: (args) => ({ +const meta: Meta = { + component: Page, + render: ({ footer, ...args }) => ({ props: args, template: ` - ${args.footer} + ${footer} `, }), +}; +export default meta; + +type Story = StoryObj; + +export const CustomFooter: Story = { args: { footer: 'Built with Storybook', }, diff --git a/docs/snippets/angular/table-story-fully-customize-controls.ts.mdx b/docs/snippets/angular/table-story-fully-customize-controls.ts.mdx deleted file mode 100644 index 387ca9506c93..000000000000 --- a/docs/snippets/angular/table-story-fully-customize-controls.ts.mdx +++ /dev/null @@ -1,39 +0,0 @@ -```ts -// Table.stories.ts - -import type { Meta, StoryObj } from '@storybook/angular'; - -import { Table } from './Table.component'; - -const meta: Meta = { - component: Table, -}; - -export default meta; -type Story = StoryObj
; - -export const Numeric: Story = { - render: (args) => ({ - props: args, - template: ` -
- - - - - -
- {{data[i][j]}} -
- `, - }), - args: { - data: [ - [1, 2, 3], - [4, 5, 6], - ], - //πŸ‘‡ The remaining args get passed to the `Table` component - size: 'large', - }, -}; -``` diff --git a/docs/snippets/angular/typed-csf-file.ts.mdx b/docs/snippets/angular/typed-csf-file.ts.mdx new file mode 100644 index 000000000000..6ac8b473a93b --- /dev/null +++ b/docs/snippets/angular/typed-csf-file.ts.mdx @@ -0,0 +1,22 @@ +```ts +// Button.stories.ts + +import type { Meta, StoryObj } from '@storybook/angular'; + +import { Button } from './button.component'; + +const meta: Meta + + +``` + +The same setup works with Svelte stories files too, providing both type safety and autocompletion. + +