lib/api/src/modules/stories.ts
import global from 'global';
import { toId, sanitize } from '@storybook/csf';
import {
STORY_PREPARED,
UPDATE_STORY_ARGS,
RESET_STORY_ARGS,
STORY_ARGS_UPDATED,
STORY_CHANGED,
SELECT_STORY,
SET_STORIES,
STORY_SPECIFIED,
STORY_INDEX_INVALIDATED,
CONFIG_ERROR,
} from '@storybook/core-events';
import deprecate from 'util-deprecate';
import { logger } from '@storybook/client-logger';
import { getEventMetadata } from '../lib/events';
import {
denormalizeStoryParameters,
transformStoriesRawToStoriesHash,
isStory,
isRoot,
transformStoryIndexToStoriesHash,
} from '../lib/stories';
import type {
StoriesHash,
Story,
Group,
StoryId,
Root,
StoriesRaw,
SetStoriesPayload,
StoryIndex,
} from '../lib/stories';
import { Args, ModuleFn } from '../index';
import { ComposedRef } from './refs';
const { DOCS_MODE, FEATURES, fetch } = global;
const STORY_INDEX_PATH = './stories.json';
type Direction = -1 | 1;
type ParameterName = string;
type ViewMode = 'story' | 'info' | 'settings' | string | undefined;
type StoryUpdate = Pick<Story, 'parameters' | 'initialArgs' | 'argTypes' | 'args'>;
export interface SubState {
storiesHash: StoriesHash;
storyId: StoryId;
viewMode: ViewMode;
storiesConfigured: boolean;
storiesFailed?: Error;
}
export interface SubAPI {
storyId: typeof toId;
resolveStory: (storyId: StoryId, refsId?: string) => Story | Group | Root;
selectFirstStory: () => void;
selectStory: (
kindOrId: string,
story?: string,
obj?: { ref?: string; viewMode?: ViewMode }
) => void;
getCurrentStoryData: () => Story | Group;
setStories: (stories: StoriesRaw, failed?: Error) => Promise<void>;
jumpToComponent: (direction: Direction) => void;
jumpToStory: (direction: Direction) => void;
getData: (storyId: StoryId, refId?: string) => Story | Group;
isPrepared: (storyId: StoryId, refId?: string) => boolean;
getParameters: (
storyId: StoryId | { storyId: StoryId; refId: string },
parameterName?: ParameterName
) => Story['parameters'] | any;
getCurrentParameter<S>(parameterName?: ParameterName): S;
updateStoryArgs(story: Story, newArgs: Args): void;
resetStoryArgs: (story: Story, argNames?: string[]) => void;
findLeafStoryId(StoriesHash: StoriesHash, storyId: StoryId): StoryId;
fetchStoryList: () => Promise<void>;
setStoryList: (storyList: StoryIndex) => Promise<void>;
updateStory: (storyId: StoryId, update: StoryUpdate, ref?: ComposedRef) => Promise<void>;
}
interface Meta {
ref?: ComposedRef;
source?: string;
sourceType?: 'local' | 'external';
sourceLocation?: string;
refId?: string;
v?: number;
type: string;
}
const deprecatedOptionsParameterWarnings: Record<string, () => void> = [
'enableShortcuts',
'theme',
'showRoots',
].reduce((acc, option: string) => {
acc[option] = deprecate(
() => {},
`parameters.options.${option} is deprecated and will be removed in Storybook 7.0.
To change this setting, use \`addons.setConfig\`. See https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#deprecated-immutable-options-parameters
`
);
return acc;
}, {} as Record<string, () => void>);
function checkDeprecatedOptionParameters(options?: Record<string, any>) {
if (!options) {
return;
}
Object.keys(options).forEach((option: string) => {
if (deprecatedOptionsParameterWarnings[option]) {
deprecatedOptionsParameterWarnings[option]();
}
});
}
export const init: ModuleFn = ({
fullAPI,
store,
navigate,
provider,
storyId: initialStoryId,
viewMode: initialViewMode,
}) => {
const api: SubAPI = {
storyId: toId,
getData: (storyId, refId) => {
const result = api.resolveStory(storyId, refId);
return isRoot(result) ? undefined : result;
},
isPrepared: (storyId, refId) => {
const data = api.getData(storyId, refId);
if (data.isLeaf) {
return data.prepared;
}
// Groups are always prepared :shrug:
return true;
},
resolveStory: (storyId, refId) => {
const { refs, storiesHash } = store.getState();
if (refId) {
return refs[refId].stories ? refs[refId].stories[storyId] : undefined;
}
return storiesHash ? storiesHash[storyId] : undefined;
},
getCurrentStoryData: () => {
const { storyId, refId } = store.getState();
return api.getData(storyId, refId);
},
getParameters: (storyIdOrCombo, parameterName) => {
const { storyId, refId } =
typeof storyIdOrCombo === 'string'
? { storyId: storyIdOrCombo, refId: undefined }
: storyIdOrCombo;
const data = api.getData(storyId, refId);
if (isStory(data)) {
const { parameters } = data;
if (parameters) {
return parameterName ? parameters[parameterName] : parameters;
}
return {};
}
return null;
},
getCurrentParameter: (parameterName) => {
const { storyId, refId } = store.getState();
const parameters = api.getParameters({ storyId, refId }, parameterName);
// FIXME Returning falsey parameters breaks a bunch of toolbars code,
// so this strange logic needs to be here until various client code is updated.
return parameters || undefined;
},
jumpToComponent: (direction) => {
const { storiesHash, storyId, refs, refId } = store.getState();
const story = api.getData(storyId, refId);
// cannot navigate when there's no current selection
if (!story) {
return;
}
const hash = refId ? refs[refId].stories || {} : storiesHash;
const lookupList = Object.entries(hash).reduce((acc, i) => {
const value = i[1];
if (value.isComponent) {
acc.push([...i[1].children]);
}
return acc;
}, []);
const index = lookupList.findIndex((i) => i.includes(storyId));
// cannot navigate beyond fist or last
if (index === lookupList.length - 1 && direction > 0) {
return;
}
if (index === 0 && direction < 0) {
return;
}
const result = lookupList[index + direction][0];
if (result) {
api.selectStory(result, undefined, { ref: refId });
}
},
jumpToStory: (direction) => {
const { storiesHash, storyId, refs, refId } = store.getState();
const story = api.getData(storyId, refId);
if (DOCS_MODE) {
api.jumpToComponent(direction);
return;
}
// cannot navigate when there's no current selection
if (!story) {
return;
}
const hash = story.refId ? refs[story.refId].stories : storiesHash;
const lookupList = Object.keys(hash).filter(
(k) => !(hash[k].children || Array.isArray(hash[k]))
);
const index = lookupList.indexOf(storyId);
// cannot navigate beyond fist or last
if (index === lookupList.length - 1 && direction > 0) {
return;
}
if (index === 0 && direction < 0) {
return;
}
const result = lookupList[index + direction];
if (result) {
api.selectStory(result, undefined, { ref: refId });
}
},
setStories: async (input, error) => {
// Now create storiesHash by reordering the above by group
const hash = transformStoriesRawToStoriesHash(input, {
provider,
});
await store.setState({
storiesHash: hash,
storiesConfigured: true,
storiesFailed: error,
});
},
selectFirstStory: () => {
const { storiesHash } = store.getState();
const firstStory = Object.keys(storiesHash).find(
(k) => !(storiesHash[k].children || Array.isArray(storiesHash[k]))
);
if (firstStory) {
api.selectStory(firstStory);
return;
}
navigate('/');
},
selectStory: (kindOrId = undefined, story = undefined, options = {}) => {
const { ref, viewMode: viewModeFromArgs } = options;
const {
viewMode: viewModeFromState = 'story',
storyId,
storiesHash,
refs,
} = store.getState();
const hash = ref ? refs[ref].stories : storiesHash;
const kindSlug = storyId?.split('--', 2)[0];
if (!story) {
const s = kindOrId ? hash[kindOrId] || hash[sanitize(kindOrId)] : hash[kindSlug];
// eslint-disable-next-line no-nested-ternary
const id = s ? (s.children ? s.children[0] : s.id) : kindOrId;
let viewMode =
s && !isRoot(s) && (viewModeFromArgs || s.parameters.viewMode)
? s.parameters.viewMode
: viewModeFromState;
// Some viewModes are not story-specific, and we should reset viewMode
// to 'story' if one of those is active when navigating to another story
if (['settings', 'about', 'release'].includes(viewMode)) {
viewMode = 'story';
}
const p = s && s.refId ? `/${viewMode}/${s.refId}_${id}` : `/${viewMode}/${id}`;
navigate(p);
} else if (!kindOrId) {
// This is a slugified version of the kind, but that's OK, our toId function is idempotent
const id = toId(kindSlug, story);
api.selectStory(id, undefined, options);
} else {
const id = ref ? `${ref}_${toId(kindOrId, story)}` : toId(kindOrId, story);
if (hash[id]) {
api.selectStory(id, undefined, options);
} else {
// Support legacy API with component permalinks, where kind is `x/y` but permalink is 'z'
const k = hash[sanitize(kindOrId)];
if (k && k.children) {
const foundId = k.children.find((childId) => hash[childId].name === story);
if (foundId) {
api.selectStory(foundId, undefined, options);
}
}
}
}
},
findLeafStoryId(storiesHash, storyId) {
if (storiesHash[storyId].isLeaf) {
return storyId;
}
const childStoryId = storiesHash[storyId].children[0];
return api.findLeafStoryId(storiesHash, childStoryId);
},
updateStoryArgs: (story, updatedArgs) => {
const { id: storyId, refId } = story;
fullAPI.emit(UPDATE_STORY_ARGS, {
storyId,
updatedArgs,
options: {
target: refId ? `storybook-ref-${refId}` : 'storybook-preview-iframe',
},
});
},
resetStoryArgs: (story, argNames?: [string]) => {
const { id: storyId, refId } = story;
fullAPI.emit(RESET_STORY_ARGS, {
storyId,
argNames,
options: {
target: refId ? `storybook-ref-${refId}` : 'storybook-preview-iframe',
},
});
},
fetchStoryList: async () => {
try {
const result = await fetch(STORY_INDEX_PATH);
if (result.status !== 200) throw new Error(await result.text());
const storyIndex = (await result.json()) as StoryIndex;
// We can only do this if the stories.json is a proper storyIndex
if (storyIndex.v !== 3) {
logger.warn(`Skipping story index with version v${storyIndex.v}, awaiting SET_STORIES.`);
return;
}
await fullAPI.setStoryList(storyIndex);
} catch (err) {
store.setState({
storiesConfigured: true,
storiesFailed: err,
});
}
},
setStoryList: async (storyIndex: StoryIndex) => {
const hash = transformStoryIndexToStoriesHash(storyIndex, {
provider,
});
await store.setState({
storiesHash: hash,
storiesConfigured: true,
storiesFailed: null,
});
},
updateStory: async (
storyId: StoryId,
update: StoryUpdate,
ref?: ComposedRef
): Promise<void> => {
if (!ref) {
const { storiesHash } = store.getState();
storiesHash[storyId] = {
...storiesHash[storyId],
...update,
} as Story;
await store.setState({ storiesHash });
} else {
const { id: refId, stories } = ref;
stories[storyId] = {
...stories[storyId],
...update,
} as Story;
await fullAPI.updateRef(refId, { stories });
}
},
};
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: ViewMode;
[k: string]: any;
}) {
const { sourceType } = getEventMetadata(this, fullAPI);
if (fullAPI.isSettingsScreenActive()) return;
if (sourceType === 'local') {
// Special case -- if we are already at the story being specified (i.e. the user started at a given story),
// we don't need to change URL. See https://github.com/storybookjs/storybook/issues/11677
const state = store.getState();
if (state.storyId !== storyId || state.viewMode !== viewMode) {
navigate(`/${viewMode}/${storyId}`);
}
}
}
);
fullAPI.on(STORY_CHANGED, function handler() {
const { sourceType } = getEventMetadata(this, fullAPI);
if (sourceType === 'local') {
const options = fullAPI.getCurrentParameter('options');
if (options) {
checkDeprecatedOptionParameters(options);
fullAPI.setOptions(options);
}
}
});
fullAPI.on(SET_STORIES, function handler(data: SetStoriesPayload) {
const { ref } = getEventMetadata(this, fullAPI);
const stories = data.v ? denormalizeStoryParameters(data) : data.stories;
if (!ref) {
if (!data.v) {
throw new Error('Unexpected legacy SET_STORIES event from local source');
}
fullAPI.setStories(stories);
const options = fullAPI.getCurrentParameter('options');
checkDeprecatedOptionParameters(options);
fullAPI.setOptions(options);
} else {
fullAPI.setRef(ref.id, { ...ref, ...data, stories }, true);
}
});
fullAPI.on(
SELECT_STORY,
function handler({
kind,
story,
storyId,
...rest
}: {
kind: string;
story: string;
storyId: string;
viewMode: ViewMode;
}) {
const { ref } = getEventMetadata(this, fullAPI);
if (!ref) {
fullAPI.selectStory(storyId || kind, story, rest);
} else {
fullAPI.selectStory(storyId || kind, story, { ...rest, ref: ref.id });
}
}
);
fullAPI.on(STORY_PREPARED, function handler({ id, ...update }) {
const { ref } = getEventMetadata(this, fullAPI);
fullAPI.updateStory(id, { ...update, prepared: true }, ref);
if (!ref) {
if (!store.getState().hasCalledSetOptions) {
const { options } = update.parameters;
checkDeprecatedOptionParameters(options);
fullAPI.setOptions(options);
store.setState({ hasCalledSetOptions: true });
}
} else {
fullAPI.updateRef(ref.id, { ready: true });
}
});
fullAPI.on(
STORY_ARGS_UPDATED,
function handleStoryArgsUpdated({ storyId, args }: { storyId: StoryId; args: Args }) {
const { ref } = getEventMetadata(this, fullAPI);
fullAPI.updateStory(storyId, { args }, ref);
}
);
fullAPI.on(CONFIG_ERROR, function handleConfigError(err) {
store.setState({
storiesConfigured: true,
storiesFailed: err,
});
});
if (FEATURES?.storyStoreV7) {
provider.serverChannel?.on(STORY_INDEX_INVALIDATED, () => fullAPI.fetchStoryList());
await fullAPI.fetchStoryList();
}
};
return {
api,
state: {
storiesHash: {},
storyId: initialStoryId,
viewMode: initialViewMode,
storiesConfigured: false,
hasCalledSetOptions: false,
},
init: initModule,
};
};