mirror of
https://github.com/storybookjs/storybook.git
synced 2025-04-05 05:51:21 +08:00
300 lines
8.4 KiB
TypeScript
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 defaultMakeDisplayName = (key: string) => 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
|
|
);
|
|
|
|
getMakeDisplayName = () =>
|
|
(this._globalParameters.options && this._globalParameters.options.makeDisplayName) ||
|
|
defaultMakeDisplayName;
|
|
|
|
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;
|
|
}
|