Skip to content

Commit

Permalink
feat(code-snippet): support removing default props
Browse files Browse the repository at this point in the history
  • Loading branch information
mgadewoll committed Jun 24, 2024
1 parent 2cf9b4f commit 4a979ff
Show file tree
Hide file tree
Showing 5 changed files with 157 additions and 34 deletions.
9 changes: 8 additions & 1 deletion packages/eui/.storybook/addons/code-snippet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,16 +157,23 @@ const meta = {
component: EuiButton,
parameters: {
codeSnippet: {
// will skip code snippet generation for the component or story
// will skip code snippet generation for the component or story
// @default false
skip: true,
// Useful for complex story composition wrappers (using the story component as
// nested child and not as direct return for `render`).
// It will skip the outer story wrapper and return the code snippet for its children
// instead. See the story for `EuiHeader/Multiple Fixed Headers` as an example.
// @default false
resolveChildren: true,
// Useful when the story outputs additional contnt that should not be included in the
// snippet and instead only the actual story component should be output as snippet.
// @default false
resolveStoryElementOnly: true,
// The jsx renderer removes the story components default props. In case that they should
// be added to a specific code snippet it can be enabled by setting this option to `false`.
// @default true
removeDefaultProps: false,
}
}
}
Expand Down
6 changes: 5 additions & 1 deletion packages/eui/.storybook/addons/code-snippet/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,13 @@ export const SPREAD_STORY_ARGS_MARKER = '{{...STORY_ARGS}}';
/**
* JSX snippet generation constants
*/
export const EMOTION_TYPE_KEY = '__EMOTION_TYPE_PLEASE_DO_NOT_USE__';
export const EMOTION_LABEL_KEY = '__EMOTION_LABEL_PLEASE_DO_NOT_USE__';

// excluded props to not be shown in the code snippet
export const EXCLUDED_PROPS = new Set([
'__EMOTION_TYPE_PLEASE_DO_NOT_USE__',
EMOTION_TYPE_KEY,
EMOTION_LABEL_KEY,
'key',
]);
// props with 'false' value that should not be removed but shown in the code snippet
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ export const customJsxDecorator = (
const euiTheme = useEuiTheme();

// generate JSX from the story
const renderedJsx = renderJsx(storyJsx, options, context, euiTheme);
const renderedJsx = renderJsx(storyJsx, context, options, euiTheme);
if (renderedJsx) {
getFormattedCode(renderedJsx)
.then((res: string) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {
PRESERVED_FALSE_VALUE_PROPS,
} from '../constants';
import {
getComponentDisplayName,
getStoryComponentDisplayName,
getEmotionComponentDisplayName,
getReactSymbolName,
isForwardRef,
Expand All @@ -39,6 +39,7 @@ import {
isSubcomponent,
getStoryComponent,
getResolvedStoryChild,
getDefaultPropsfromDocgenInfo,
} from './utils';

export type JSXOptions = Options & {
Expand All @@ -55,8 +56,8 @@ export type JSXOptions = Options & {
*/
export const renderJsx = (
code: React.ReactElement,
context: StoryContext<ReactRenderer>,
options?: JSXOptions,
context?: StoryContext<ReactRenderer>,
euiTheme?: UseEuiTheme
): string | null => {
if (typeof code === 'undefined') {
Expand Down Expand Up @@ -103,7 +104,7 @@ export const renderJsx = (
// naming convention: `Stateful{COMPONENT_NAME}`
if (isStatefulComponent(el)) {
const displayName =
getComponentDisplayName(context) ??
getStoryComponentDisplayName(context) ??
context?.title.split('/').pop() ??
el.type.name;
el.type.displayName = displayName;
Expand All @@ -112,7 +113,7 @@ export const renderJsx = (
return el.type.name;
} else if (typeof el.type === 'function') {
// this happens e.g. when using decorators where the <Story /> is wrapped
return getComponentDisplayName(context) ?? 'No Display Name';
return getStoryComponentDisplayName(context) ?? 'No Display Name';
} else if (isForwardRef(el.type)) {
return el.type.render.name;
} else if (isMemo(el.type)) {
Expand Down Expand Up @@ -166,10 +167,16 @@ export const renderJsx = (
context?.parameters?.codeSnippet?.resolveChildren === true;
const shouldResolveStoryElementOnly =
context?.parameters?.codeSnippet?.resolveStoryElementOnly === true;
const shouldRemoveDefaultProps =
context?.parameters?.codeSnippet?.removeDefaultProps !== false;

let node = child;
let defaultProps: Record<string, Record<string, any>> | undefined;

if (typeof child !== 'string') {
if (shouldRemoveDefaultProps) {
defaultProps = getDefaultPropsfromDocgenInfo(child, context);
}
// manual flag to remove an outer story wrapper and resolve its children instead
// useful when complex custom stories are build where the actual story component is
// not the outer component but part of a composition within another wrapper
Expand Down Expand Up @@ -217,6 +224,7 @@ export const renderJsx = (
node,
euiTheme,
argsOverride: context?.parameters[ADDON_PARAMETER_KEY]?.args,
defaultProps,
}),
opts as Options
);
Expand Down Expand Up @@ -294,10 +302,12 @@ const _simplifyNodeForStringify = ({
node,
euiTheme,
argsOverride,
defaultProps,
}: {
node: ReactNode;
euiTheme?: UseEuiTheme;
argsOverride?: Args;
defaultProps?: Args;
}): ReactNode => {
if (isValidElement(node)) {
let updatedNode = node;
Expand Down Expand Up @@ -328,6 +338,10 @@ const _simplifyNodeForStringify = ({
? Object.keys(updatedNode.props).reduce<{
[key: string]: any;
}>((acc, cur) => {
// filter out default props
if (defaultProps?.includes(cur)) {
return acc;
}
// check if the story has manual prop overrides that should be
// used instead of the original value
if (argsOverride?.[cur]) {
Expand Down Expand Up @@ -460,6 +474,7 @@ const _simplifyNodeForStringify = ({

acc[cur] = _simplifyNodeForStringify({
node: updatedNode.props[cur],
defaultProps,
});

return acc;
Expand All @@ -479,7 +494,7 @@ const _simplifyNodeForStringify = ({
// recursively resolve array or object nodes (e.g. props)
if (Array.isArray(node)) {
const children = node.map((child) =>
_simplifyNodeForStringify({ node: child, euiTheme })
_simplifyNodeForStringify({ node: child, euiTheme, defaultProps })
);
return children.flat();
}
Expand All @@ -506,6 +521,7 @@ const _simplifyNodeForStringify = ({
} else {
updatedChildren[childrenKeys[i]] = _simplifyNodeForStringify({
node: n,
defaultProps,
});
}
}
Expand Down
148 changes: 122 additions & 26 deletions packages/eui/.storybook/addons/code-snippet/decorators/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ import tsParser from 'prettier/parser-typescript';

// @ts-ignore - config import
import basePrettierConfig from '../../../../.prettierrc';
import {
ADDON_PARAMETER_KEY,
EMOTION_LABEL_KEY,
EMOTION_TYPE_KEY,
} from '../constants';

export const toPascalCase = (str: string) =>
str.charAt(0).toUpperCase() + str.slice(1);
Expand Down Expand Up @@ -68,17 +73,23 @@ export const getEmotionComponentDisplayName = (

if (
(typeof displayName === 'string' && displayName.startsWith('Emotion')) ||
node.props?.__EMOTION_TYPE_PLEASE_DO_NOT_USE__ != null
node.props?.[EMOTION_TYPE_KEY] != null
) {
const { __EMOTION_TYPE_PLEASE_DO_NOT_USE__: emotionData } = node.props;
const isForwardRefComponent = isForwardRef(emotionData);

const {
[EMOTION_TYPE_KEY]: emotionTypeData,
[EMOTION_LABEL_KEY]: emotionLabelData,
} = node.props;
const isForwardRefComponent = isForwardRef(emotionTypeData);

const emotionTypeName =
emotionTypeData.__docgenInfo?.displayName ??
emotionTypeData.render?.displayName;
// we need to rely here on the reference Emotion stores to know what component this actually is
const replacementName: string | undefined = isForwardRefComponent
? emotionData.__docgenInfo.displayName
: typeof emotionData === 'string'
? emotionData
: emotionData?.displayName;
? emotionTypeName
: typeof emotionTypeData === 'string'
? emotionTypeData ?? emotionLabelData
: emotionTypeName ?? emotionLabelData?.displayName;

// remove internal component underscore markings
return replacementName ? replacementName.replace('_', '') : displayName;
Expand All @@ -87,7 +98,7 @@ export const getEmotionComponentDisplayName = (
return displayName;
};

export const getComponentDisplayName = (
export const getStoryComponentDisplayName = (
context: StoryContext | undefined
): string | undefined => {
if (!context) return undefined;
Expand All @@ -98,12 +109,15 @@ export const getComponentDisplayName = (
return component?.displayName ?? component.__docgenInfo?.displayName;
};

/** Determine if a component is an Emotion component based on displayName.
* Emotion components are renamed 'EmotionCssPropInternal'
*/
export const isEmotionComponent = (node: ReactElement): boolean => {
const displayName = getElementDisplayName(node);
const matches =
typeof displayName === 'string' ? displayName.startsWith('Emotion') : null;
typeof displayName === 'string' ? displayName.startsWith('Emotion') : false;

return matches != null;
return !!matches;
};

/* Story specific checks */
Expand All @@ -113,14 +127,23 @@ export const isStoryComponent = (
): boolean => {
if (!context) return false;

const displayName = getEmotionComponentDisplayName(node);
const displayName = getEmotionComponentDisplayName(node)?.replace(/^_/, '');
const isCurrentStory = displayName
? displayName === context?.component?.displayName
: false;

return isCurrentStory;
};

const isStoryWrapper = (node: ReactElement, context: StoryContext) => {
const displayName = getEmotionComponentDisplayName(node);
const isStoryWrapper =
(typeof displayName === 'string' && displayName.startsWith('Story')) ||
context.parameters?.[ADDON_PARAMETER_KEY]?.resolveChildren === true;

return isStoryWrapper;
};

/**
* checks if the outer most component is a parent of the actual story component
*/
Expand Down Expand Up @@ -195,7 +218,8 @@ export const getResolvedStoryChild = (
/**
* Helper to resolve the current story element from a composition preview,
* e.g. when the story element is a child of a wrapper and only the story
* should be output without wrappers or siblings.
* should be determined without wrappers or siblings.
* (e.g. for singular output or for getting defaultProps or the story element)
*
* It checks the passed story node recursively until it finds the current
* story element and returns it.
Expand All @@ -210,20 +234,41 @@ export const getStoryComponent = (
if (isStoryComponent(childNode, context)) {
storyNode = childNode;
return;
} else if (
isValidElement<any>(childNode) &&
Array.isArray(childNode.props?.children)
) {
const { children } = childNode.props;

for (const child of children) {
// break out of the loop early if possible
if (child == null || storyNode != null) break;

} else if (isValidElement<any>(childNode) && !storyNode) {
// CASE: array of children
if (Array.isArray(childNode.props?.children)) {
const { children } = childNode.props;

for (const child of children) {
// break out of the loop early if possible
if (child == null || storyNode != null) break;
// skip non-ReactElement children
if (!isValidElement(child)) continue;

// Story wrappers need to be resolved first to ensure the right data
const resolvedChild = getResolvedStoryChild(child, context);
resolveChildren(resolvedChild);
}
} else if (
// CASE: story wrapper; no children
childNode.props?.children == null &&
(isStoryWrapper(childNode, context) || isStatefulComponent(childNode))
) {
const displayName = getEmotionComponentDisplayName(childNode);
// Story wrappers need to be resolved first to ensure the right data
const resolvedChild = getResolvedStoryChild(child, context);

resolveChildren(resolvedChild);
const resolvedChild = getResolvedStoryChild(childNode, context);
const resolvedDisplayName =
getEmotionComponentDisplayName(resolvedChild);

if (resolvedDisplayName && resolvedDisplayName !== displayName) {
resolveChildren(resolvedChild);
}
} else if (
// CASE: single child element
childNode.props?.children &&
!Array.isArray(childNode.props?.children)
) {
resolveChildren(childNode.props?.children);
}
}
};
Expand All @@ -233,6 +278,57 @@ export const getStoryComponent = (
return storyNode;
};

type ReactElementWithDocgenInfo = ReactElement & {
type?: { __docgenInfo?: { props: { [key: string]: any } } };
};

/**
* Helper to retrieve a story components default props.
* Only returns props that have the default prop value;
* any prop value that's changed in the story is not
* considered a default prop in this context
*/
export const getDefaultPropsfromDocgenInfo = (
component: ReactElementWithDocgenInfo,
context: StoryContext
): Record<string, any> | undefined => {
if (typeof component.type === 'string') return undefined;

// determine the story element first
// this is required because the story might be wrapped and
// only the story element has the required docgenInfo
let storyComponent: ReactElementWithDocgenInfo | undefined =
getStoryComponent(component, context);

if (!storyComponent) return undefined;

let propsInfo = isEmotionComponent(storyComponent)
? storyComponent.props?.[EMOTION_TYPE_KEY]?.__docgenInfo.props
: storyComponent.type?.__docgenInfo?.props;

const args = context.args;

const defaultProps = propsInfo
? Object.keys(propsInfo).filter((key) => {
if (propsInfo[key].defaultValue == null) return false;

const defaultValue = propsInfo[key].defaultValue.value;
// clean added string (e.g. done by EuiI18n or inline type casting with 'as')
// checks if the string starts with wrapping quotes, then matches tonly the quoted string
// to remove access content (e.g. "'div' as TComponent" => 'div')
const cleanedDefaultValue = defaultValue.startsWith("'")
? defaultValue.match(/^'.*'/)[0].replace(/^\'/, '').replace(/\'$/, '')
: propsInfo[key].defaultValue?.value;

// check that the prop value is not the default value
return cleanedDefaultValue === args[key]?.toString();
})
: undefined;

// if available, returns an array of prop names
return defaultProps;
};

/**
* NOTE: This code is from the original Storybook jsxDecorator
* Converts a React symbol to a React-like displayName
Expand Down

0 comments on commit 4a979ff

Please sign in to comment.