storybook/lib/client-api/src/client_api.ts

300 lines
8.4 KiB
TypeScript

/* eslint no-underscore-dangle: 0 */
import deprecate from 'util-deprecate';
import isPlainObject from 'is-plain-object';
import startCase from 'lodash/startCase';
import { logger } from '@storybook/client-logger';
import addons, { StoryContext, StoryFn, Parameters, OptionsParameter } from '@storybook/addons';
import Events from '@storybook/core-events';
import { toId } from '@storybook/router/utils';
import mergeWith from 'lodash/mergeWith';
import isEqual from 'lodash/isEqual';
import get from 'lodash/get';
import { ClientApiParams, DecoratorFunction, ClientApiAddons, StoryApi } from './types';
import subscriptionsStore from './subscriptions_store';
import { applyHooks } from './hooks';
import StoryStore from './story_store';
// merge with concatenating arrays, but no duplicates
const merge = (a: any, b: any) =>
mergeWith({}, a, b, (objValue, srcValue) => {
if (Array.isArray(srcValue) && Array.isArray(objValue)) {
srcValue.forEach(s => {
const existing = objValue.find(o => o === s || isEqual(o, s));
if (!existing) {
objValue.push(s);
}
});
return objValue;
}
if (Array.isArray(objValue)) {
logger.log('the types mismatch, picking', objValue);
return objValue;
}
return undefined;
});
const defaultContext: StoryContext = {
id: 'unspecified',
name: 'unspecified',
kind: 'unspecified',
parameters: {},
};
export const defaultDecorateStory = (storyFn: StoryFn, decorators: DecoratorFunction[]) =>
decorators.reduce(
(decorated, decorator) => (context: StoryContext = defaultContext) =>
decorator(
p =>
decorated(
p
? {
...context,
...p,
parameters: { ...context.parameters, ...p.parameters },
}
: context
),
context
),
storyFn
);
const metaSubscriptionHandler = deprecate(
subscriptionsStore.register,
'Events.REGISTER_SUBSCRIPTION is deprecated and will be removed in 6.0. Please use useEffect from @storybook/client-api instead.'
);
const metaSubscription = () => {
addons.getChannel().on(Events.REGISTER_SUBSCRIPTION, metaSubscriptionHandler);
return () =>
addons.getChannel().removeListener(Events.REGISTER_SUBSCRIPTION, metaSubscriptionHandler);
};
const withSubscriptionTracking = (storyFn: StoryFn) => {
if (!addons.hasChannel()) {
return storyFn();
}
subscriptionsStore.markAllAsUnused();
subscriptionsStore.register(metaSubscription);
const result = storyFn();
subscriptionsStore.clearUnused();
return result;
};
export const defaultDisplayName = key => startCase(key);
export default class ClientApi {
private _storyStore: StoryStore;
private _addons: ClientApiAddons<unknown>;
private _globalDecorators: DecoratorFunction[];
private _globalParameters: Parameters;
private _decorateStory: (storyFn: StoryFn, decorators: DecoratorFunction[]) => any;
constructor({ storyStore, decorateStory = defaultDecorateStory }: ClientApiParams) {
this._storyStore = storyStore;
this._addons = {};
this._globalDecorators = [];
this._globalParameters = {};
this._decorateStory = decorateStory;
if (!storyStore) {
throw new Error('storyStore is required');
}
}
setAddon = (addon: any) => {
this._addons = {
...this._addons,
...addon,
};
};
getSeparators = () =>
Object.assign(
{},
{
hierarchyRootSeparator: '|',
hierarchySeparator: /\/|\./,
},
this._globalParameters.options
);
getDisplayName = () =>
(this._globalParameters.options && this._globalParameters.options.displayName) ||
defaultDisplayName;
addDecorator = (decorator: DecoratorFunction) => {
this._globalDecorators.push(decorator);
};
addParameters = (parameters: Parameters | { globalParameter: 'string' }) => {
this._globalParameters = {
...this._globalParameters,
...parameters,
options: {
...merge(get(this._globalParameters, 'options', {}), get(parameters, 'options', {})),
},
};
};
clearDecorators = () => {
this._globalDecorators = [];
};
// what are the occasions that "m" is simply a boolean, vs an obj
storiesOf = <StoryFnReturnType = unknown>(
kind: string,
m: NodeModule
): StoryApi<StoryFnReturnType> => {
if (!kind && typeof kind !== 'string') {
throw new Error('Invalid or missing kind provided for stories, should be a string');
}
if (!m) {
logger.warn(
`Missing 'module' parameter for story with a kind of '${kind}'. It will break your HMR`
);
}
if (m && m.hot && m.hot.dispose) {
m.hot.dispose(() => {
const { _storyStore } = this;
_storyStore.removeStoryKind(kind);
_storyStore.incrementRevision();
});
}
const localDecorators: DecoratorFunction<StoryFnReturnType>[] = [];
let localParameters: Parameters = {};
let hasAdded = false;
const api: StoryApi<StoryFnReturnType> = {
kind: kind.toString(),
add: () => api,
addDecorator: () => api,
addParameters: () => api,
};
// apply addons
Object.keys(this._addons).forEach(name => {
const addon = this._addons[name];
api[name] = (...args: any[]) => {
addon.apply(api, args);
return api;
};
});
api.add = (storyName, storyFn, parameters) => {
hasAdded = true;
const { _globalParameters, _globalDecorators } = this;
const id = toId(kind, storyName);
if (typeof storyName !== 'string') {
throw new Error(`Invalid or missing storyName provided for a "${kind}" story.`);
}
if (m && m.hot && m.hot.dispose) {
m.hot.dispose(() => {
const { _storyStore } = this;
_storyStore.remove(id);
});
}
const fileName = m && m.id ? `${m.id}` : undefined;
const { hierarchyRootSeparator, hierarchySeparator } = this.getSeparators();
const baseOptions: OptionsParameter = {
hierarchyRootSeparator,
hierarchySeparator,
};
const allParam = [
{ options: baseOptions },
_globalParameters,
localParameters,
parameters,
].reduce(
(acc: Parameters, p) => {
if (p) {
Object.entries(p).forEach(([key, value]) => {
const existingValue = acc[key];
if (Array.isArray(value)) {
acc[key] = value;
} else if (isPlainObject(value) && isPlainObject(existingValue)) {
acc[key] = merge(existingValue, value);
} else {
acc[key] = value;
}
});
}
return acc;
},
{ fileName }
);
this._storyStore.addStory(
{
id,
kind,
name: storyName,
storyFn,
parameters: allParam,
},
{
applyDecorators: applyHooks(this._decorateStory),
getDecorators: () => [
...(allParam.decorators || []),
...localDecorators,
..._globalDecorators,
withSubscriptionTracking,
],
}
);
return api;
};
api.addDecorator = (decorator: DecoratorFunction<StoryFnReturnType>) => {
if (hasAdded) {
logger.warn(`You have added a decorator to the kind '${kind}' after a story has already been added.
In Storybook 4 this applied the decorator only to subsequent stories. In Storybook 5+ it applies to all stories.
This is probably not what you intended. Read more here: https://github.com/storybookjs/storybook/blob/master/MIGRATION.md`);
}
localDecorators.push(decorator);
return api;
};
api.addParameters = (parameters: Parameters) => {
localParameters = { ...localParameters, ...parameters };
return api;
};
return api;
};
// legacy
getStorybook = () =>
this._storyStore.getStoryKinds().map(kind => {
const fileName = this._storyStore.getStoryFileName(kind);
const stories = this._storyStore.getStories(kind).map(name => {
const render = this._storyStore.getStoryWithContext(kind, name);
return { name, render };
});
return { kind, fileName, stories };
});
raw = () => this._storyStore.raw();
// FIXME: temporary expose the store for react-native
// Longer term react-native should use the Provider/Consumer api
store = () => this._storyStore;
}