diff --git a/packages/eui/.storybook/addons/code-snippet/README.md b/packages/eui/.storybook/addons/code-snippet/README.md index 09a7c8149e51..1776e8dbd601 100644 --- a/packages/eui/.storybook/addons/code-snippet/README.md +++ b/packages/eui/.storybook/addons/code-snippet/README.md @@ -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, } } } diff --git a/packages/eui/.storybook/addons/code-snippet/constants.ts b/packages/eui/.storybook/addons/code-snippet/constants.ts index 423ed834d5bc..09a917a54890 100644 --- a/packages/eui/.storybook/addons/code-snippet/constants.ts +++ b/packages/eui/.storybook/addons/code-snippet/constants.ts @@ -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 diff --git a/packages/eui/.storybook/addons/code-snippet/decorators/jsx_decorator.tsx b/packages/eui/.storybook/addons/code-snippet/decorators/jsx_decorator.tsx index ae1f9be12d79..e8e08ba36ed4 100644 --- a/packages/eui/.storybook/addons/code-snippet/decorators/jsx_decorator.tsx +++ b/packages/eui/.storybook/addons/code-snippet/decorators/jsx_decorator.tsx @@ -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) => { diff --git a/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx b/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx index 21d1fe44f131..ebc08963fbdd 100644 --- a/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx +++ b/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx @@ -26,7 +26,7 @@ import { PRESERVED_FALSE_VALUE_PROPS, } from '../constants'; import { - getComponentDisplayName, + getStoryComponentDisplayName, getEmotionComponentDisplayName, getReactSymbolName, isForwardRef, @@ -39,6 +39,7 @@ import { isSubcomponent, getStoryComponent, getResolvedStoryChild, + getDefaultPropsfromDocgenInfo, } from './utils'; export type JSXOptions = Options & { @@ -55,8 +56,8 @@ export type JSXOptions = Options & { */ export const renderJsx = ( code: React.ReactElement, + context: StoryContext, options?: JSXOptions, - context?: StoryContext, euiTheme?: UseEuiTheme ): string | null => { if (typeof code === 'undefined') { @@ -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; @@ -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 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)) { @@ -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> | 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 @@ -217,6 +224,7 @@ export const renderJsx = ( node, euiTheme, argsOverride: context?.parameters[ADDON_PARAMETER_KEY]?.args, + defaultProps, }), opts as Options ); @@ -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; @@ -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]) { @@ -460,6 +474,7 @@ const _simplifyNodeForStringify = ({ acc[cur] = _simplifyNodeForStringify({ node: updatedNode.props[cur], + defaultProps, }); return acc; @@ -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(); } @@ -506,6 +521,7 @@ const _simplifyNodeForStringify = ({ } else { updatedChildren[childrenKeys[i]] = _simplifyNodeForStringify({ node: n, + defaultProps, }); } } diff --git a/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts b/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts index 9fe68c0172fc..75ade61b7994 100644 --- a/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts +++ b/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts @@ -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); @@ -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; @@ -87,7 +98,7 @@ export const getEmotionComponentDisplayName = ( return displayName; }; -export const getComponentDisplayName = ( +export const getStoryComponentDisplayName = ( context: StoryContext | undefined ): string | undefined => { if (!context) return undefined; @@ -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 */ @@ -113,7 +127,7 @@ 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; @@ -121,6 +135,15 @@ export const isStoryComponent = ( 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 */ @@ -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. @@ -210,20 +234,41 @@ export const getStoryComponent = ( if (isStoryComponent(childNode, context)) { storyNode = childNode; return; - } else if ( - isValidElement(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(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); } } }; @@ -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 | 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