From 1e727f4382172f6049fdbd5a1426dc8961892879 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Fri, 24 May 2024 14:51:09 +0200 Subject: [PATCH 01/51] feat: add custom code-sippet addon panel - adds addon panel for outputting code snippet with Storybooks SyntaxHighlighter - registers code-snippet addon panel in Storybook manager --- .../addons/code-snippet/components/panel.tsx | 64 +++++++++++++++++++ .../addons/code-snippet/constants.ts | 14 ++++ packages/eui/.storybook/manager.ts | 16 ++++- packages/eui/package.json | 4 +- 4 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 packages/eui/.storybook/addons/code-snippet/components/panel.tsx create mode 100644 packages/eui/.storybook/addons/code-snippet/constants.ts diff --git a/packages/eui/.storybook/addons/code-snippet/components/panel.tsx b/packages/eui/.storybook/addons/code-snippet/components/panel.tsx new file mode 100644 index 00000000000..baaa1ea86b8 --- /dev/null +++ b/packages/eui/.storybook/addons/code-snippet/components/panel.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { FunctionComponent } from 'react'; +import { useAddonState, useChannel } from '@storybook/manager-api'; +import { AddonPanel, SyntaxHighlighter } from '@storybook/components'; +import { styled } from '@storybook/theming'; +import { STORY_RENDERED } from '@storybook/core-events'; + +import { ADDON_ID, EVENTS } from '../constants'; + +interface PanelProps { + active?: boolean; +} + +export const Panel: FunctionComponent = ({ active, ...rest }) => { + const [addonState, setAddonState] = useAddonState(ADDON_ID, { + code: '', + isLoaded: false, + }); + const { code, isLoaded } = addonState; + + useChannel({ + [EVENTS.SNIPPET_RENDERED]: (args) => { + setAddonState((prevState) => ({ ...prevState, code: args.source ?? '' })); + }, + [STORY_RENDERED]: () => { + setAddonState((prevState) => ({ ...prevState, isLoaded: true })); + }, + }); + + const emptyState = No code snippet available; + const loadingState = Loading...; + + return ( + + {code ? ( + + {code} + + ) : ( + {isLoaded ? emptyState : loadingState} + )} + + ); +}; + +const Container = styled.div(({ theme }) => ({ + display: 'flex', + justifyContent: 'flex-start', + margin: 0, + padding: theme.layoutMargin, +})); diff --git a/packages/eui/.storybook/addons/code-snippet/constants.ts b/packages/eui/.storybook/addons/code-snippet/constants.ts new file mode 100644 index 00000000000..64a9a44353b --- /dev/null +++ b/packages/eui/.storybook/addons/code-snippet/constants.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const ADDON_ID = 'storybook/addon-code-snippet'; +export const PANEL_ID = `${ADDON_ID}/panel`; + +export const EVENTS = { + SNIPPET_RENDERED: `${ADDON_ID}/snippet-rendered`, +}; diff --git a/packages/eui/.storybook/manager.ts b/packages/eui/.storybook/manager.ts index 7fabcc77163..cc8a5c9ebe1 100644 --- a/packages/eui/.storybook/manager.ts +++ b/packages/eui/.storybook/manager.ts @@ -6,7 +6,10 @@ * Side Public License, v 1. */ -import { addons } from '@storybook/manager-api'; +import { addons, types } from '@storybook/manager-api'; + +import { ADDON_ID, PANEL_ID } from './addons/code-snippet/constants'; +import { Panel } from './addons/code-snippet/components/panel'; // filter out stories based on tags that should not // be shown in the Storybook sidebar menu @@ -21,3 +24,14 @@ addons.setConfig({ }, }, }); + +// Register a addon +addons.register(ADDON_ID, () => { + // Register a panel + addons.add(PANEL_ID, { + type: types.PANEL, + title: 'Code Snippet', + match: ({ viewMode }) => viewMode === 'story', + render: Panel, + }); +}); diff --git a/packages/eui/package.json b/packages/eui/package.json index 212243d97c3..29e2aab918c 100644 --- a/packages/eui/package.json +++ b/packages/eui/package.json @@ -118,7 +118,8 @@ "@storybook/addon-links": "^8.0.5", "@storybook/addon-webpack5-compiler-babel": "^3.0.3", "@storybook/blocks": "^8.0.5", - "@storybook/manager-api": "^8.1.2", + "@storybook/manager-api": "^8.1.3", + "@storybook/preview-api": "^8.1.3", "@storybook/react": "^8.0.5", "@storybook/react-webpack5": "^8.0.5", "@storybook/test": "^8.0.5", @@ -135,6 +136,7 @@ "@types/classnames": "^2.3.1", "@types/enzyme": "^3.10.5", "@types/jest": "^29.5.12", + "@types/prettier": "^3.0.0", "@types/react": "^18.2.14", "@types/react-dom": "^18.2.6", "@types/react-is": "^17.0.3", From 556e4de3e587c613c1a57d16156109bae2d5fcc1 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Fri, 24 May 2024 14:51:21 +0200 Subject: [PATCH 02/51] refactor: renames prettier config file to js file for extension usage --- packages/eui/.prettierrc.js | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 packages/eui/.prettierrc.js diff --git a/packages/eui/.prettierrc.js b/packages/eui/.prettierrc.js new file mode 100644 index 00000000000..c88902fac9b --- /dev/null +++ b/packages/eui/.prettierrc.js @@ -0,0 +1,7 @@ +module.exports = { + parser: "typescript", + printWidth: 80, + semi: true, + singleQuote: true, + trailingComma: "es5" +} From 3416c6d1a8aee103d8a76a6b73e21c2652eac925 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Fri, 24 May 2024 16:38:33 +0200 Subject: [PATCH 03/51] feat: add and use jsxDecorator - adds the main bulk of the addon functionality; the jsxDecorator takes the story element, transforms it according to our needs and passes the element to react-element-to-jsx-string to output a jsx string --- .../addons/code-snippet/constants.ts | 2 + .../code-snippet/decorators/jsx_decorator.tsx | 157 ++++++ .../code-snippet/decorators/render_jsx.tsx | 527 ++++++++++++++++++ .../addons/code-snippet/decorators/utils.ts | 217 ++++++++ packages/eui/.storybook/preview.tsx | 3 + 5 files changed, 906 insertions(+) create mode 100644 packages/eui/.storybook/addons/code-snippet/decorators/jsx_decorator.tsx create mode 100644 packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx create mode 100644 packages/eui/.storybook/addons/code-snippet/decorators/utils.ts diff --git a/packages/eui/.storybook/addons/code-snippet/constants.ts b/packages/eui/.storybook/addons/code-snippet/constants.ts index 64a9a44353b..cc5b6b162a4 100644 --- a/packages/eui/.storybook/addons/code-snippet/constants.ts +++ b/packages/eui/.storybook/addons/code-snippet/constants.ts @@ -12,3 +12,5 @@ export const PANEL_ID = `${ADDON_ID}/panel`; export const EVENTS = { SNIPPET_RENDERED: `${ADDON_ID}/snippet-rendered`, }; + +export const STORY_ARGS_MARKER = '{{STORY_ARGS}}'; diff --git a/packages/eui/.storybook/addons/code-snippet/decorators/jsx_decorator.tsx b/packages/eui/.storybook/addons/code-snippet/decorators/jsx_decorator.tsx new file mode 100644 index 00000000000..ee1077f4699 --- /dev/null +++ b/packages/eui/.storybook/addons/code-snippet/decorators/jsx_decorator.tsx @@ -0,0 +1,157 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/* DISCLAIMER: This file was originally copied from Storybook jsxDecorator and then adjusted for more specific needs. +https://github.com/storybookjs/storybook/blob/2bff7a1c156bbd42ab381f84b8a55a07694e7e53/code/renderers/react/src/docs/jsxDecorator.tsx#L224 */ + +import type { ReactRenderer } from '@storybook/react'; +import type { + StoryContext, + ArgsStoryFn, + PartialStoryFn, +} from '@storybook/types'; +import { addons, useEffect, useCallback } from '@storybook/preview-api'; +import { logger } from '@storybook/client-logger'; + +import { EVENTS, STORY_ARGS_MARKER } from '../constants'; + +import { getFormattedCode, skipJsxRender } from './utils'; +import { JSXOptions, renderJsx } from './render_jsx'; + +const defaultJsxOptions = { + skip: 0, + showFunctions: false, + enableBeautify: true, + showDefaultProps: false, +}; + +/** + * main jsx decorator function that transforms the story react element to a jsx string + * - checks if a manual code snippet is provided or code snippet should be generated + * - if a snippet is available it replaces args and returns the snippet + * - if a snippet should be generated, it: + * - determines what should be used as story element (e.g. skip wrappers and resolve elements) + * - adds displayName overwrites (e.g. for Emotion or Stateful wrappers) + * - passes story react element to reactElementToJSXString + * - filters the returned string from reactElementToJSXString for expected formatting + * - runs prettier on the output for expected formatting + */ +export const customJsxDecorator = ( + storyFn: PartialStoryFn, + context: StoryContext +) => { + const story = storyFn(); + const channel = addons.getChannel(); + const skip = skipJsxRender(context); + + let jsx = ''; + + // using Storybook Channel events to send the code string + // to the addon panel to output + // uses Storybook useCallback hook not React one + // eslint-disable-next-line react-hooks/rules-of-hooks + const emitChannel = useCallback( + (jsx: string, skip: boolean, shouldSkip = false) => { + const { id, unmappedArgs } = context; + if (skip || shouldSkip) { + channel.emit(EVENTS.SNIPPET_RENDERED, { + id, + source: '', + args: unmappedArgs, + }); + } else { + channel.emit(EVENTS.SNIPPET_RENDERED, { + id, + source: jsx, + args: unmappedArgs, + }); + } + }, + [context, channel] + ); + + // disabling this rule as this is how Storybook handles it + // they export their own hook wrappers and have the eslint rule disabled completely + // https://github.com/storybookjs/storybook/blob/2bff7a1c156bbd42ab381f84b8a55a07694e7e53/code/renderers/react/src/docs/jsxDecorator.tsx#L233 + // https://github.com/storybookjs/storybook/blob/4c1d585ca07db5097f01a84bc6a4092ada33629b/code/lib/preview-api/src/modules/addons/hooks.ts#L474 + // eslint-disable-next-line react-hooks/rules-of-hooks + useEffect(() => { + if (jsx) { + emitChannel(jsx, skip); + } + if (skip) { + emitChannel(jsx, skip, true); + } + }, [jsx, skip, emitChannel]); + + // We only need to render JSX if the source block is actually going to + // consume it. Otherwise it's just slowing us down. + if (skip) { + return story; + } + + // use manually provided code snippet and replace args if available + const codeSnippet = context?.parameters?.codeSnippet?.snippet; + if (codeSnippet) { + const args: typeof context.args = { ...context.args }; + + for (const key of Object.keys(context.args)) { + if (!context.args[key]) { + delete args[key]; + } + } + + const code = codeSnippet.replace(STORY_ARGS_MARKER, JSON.stringify(args)); + + getFormattedCode(code) + .then((res: string) => { + jsx = res; + }) + .catch((error: Error): void => { + logger.error( + 'An error occurred and no formatted code was provided. Falling back to pre-formatted code.', + error + ); + jsx = code; + }); + + return story; + } + + const options = { + ...defaultJsxOptions, + ...(context?.parameters.jsx || {}), + } as Required; + + // Exclude decorators from source code snippet by default + const storyJsx = context?.parameters.docs?.source?.excludeDecorators + ? (context.originalStoryFn as ArgsStoryFn)( + context.args, + context + ) + : story; + + // generate JSX from the story + const renderedJsx = renderJsx(storyJsx, options, context); + if (renderedJsx) { + getFormattedCode(renderedJsx) + .then((res: string) => { + // prettier adds a semicolon due to semi: true but semi:false adds one at the beginning ¯\_(ツ)_/¯ + jsx = res.replace(';\n', '\n'); + }) + .catch((error: Error): void => { + logger.error( + 'An error occurred and no formatted code was provided. Falling back to pre-formatted code.', + error + ); + jsx = renderedJsx; + }); + } + + return story; +}; diff --git a/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx b/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx new file mode 100644 index 00000000000..b10a3c2500d --- /dev/null +++ b/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx @@ -0,0 +1,527 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/* DISCLAIMER: Parts of this file were originally copied from Storybook jsxDecorator and then adjusted for more specific needs. +https://github.com/storybookjs/storybook/blob/2bff7a1c156bbd42ab381f84b8a55a07694e7e53/code/renderers/react/src/docs/jsxDecorator.tsx#L224 */ + +import type { ReactElement, ReactNode } from 'react'; +import React, { isValidElement } from 'react'; +import type { Options } from 'react-element-to-jsx-string'; +import reactElementToJSXString from 'react-element-to-jsx-string'; +import { camelCase } from 'lodash'; +import type { ReactRenderer } from '@storybook/react'; +import type { StoryContext } from '@storybook/types'; +import { getDocgenSection } from '@storybook/docs-tools'; +import { logger } from '@storybook/client-logger'; + +import { useEuiTheme } from '../../../../src/services'; +import { + getComponentDisplayName, + getEmotionComponentDisplayName, + getReactSymbolName, + isForwardRef, + isFragment, + isMemo, + isEmotionComponent, + isStatefulComponent, + isStoryComponent, + isStoryParent, + isSubcomponent, +} from './utils'; + +// excluded props to not be shown in the code snippet +const EXCLUDED_PROPS = ['__EMOTION_TYPE_PLEASE_DO_NOT_USE__', 'key']; +// props with 'false' value that should not be removed but shown in the code snippet +const PRESERVED_FALSE_VALUE_PROPS = ['grow']; + +export type JSXOptions = Options & { + /** How many wrappers to skip when rendering the jsx */ + skip?: number; + /** Whether to show the function in the jsx tab */ + showFunctions?: boolean; + /** Whether to format HTML or Vue markup */ + enableBeautify?: boolean; + /** Override the display name used for a component */ + displayName?: string | Options['displayName']; +}; + +/** + * Apply the users parameters, apply filtering and render the jsx for a story + */ +export const renderJsx = ( + code: React.ReactElement, + options?: JSXOptions, + context?: StoryContext +): string | null => { + if (typeof code === 'undefined') { + logger.warn('Too many skip or undefined component'); + return null; + } + + let renderedJSX = code; + const Type = renderedJSX.type; + + // @ts-expect-error (Converted from ts-ignore) + for (let i = 0; i < options?.skip; i += 1) { + if (typeof renderedJSX === 'undefined') { + logger.warn('Cannot skip undefined element'); + return null; + } + + if (React.Children.count(renderedJSX) > 1) { + logger.warn('Trying to skip an array of elements'); + return null; + } + + if (typeof renderedJSX.props.children === 'undefined') { + logger.warn('Not enough children to skip elements.'); + + if ( + typeof renderedJSX.type === 'function' && + renderedJSX.type.name === '' + ) { + renderedJSX = ; + } + } else if (typeof renderedJSX.props.children === 'function') { + renderedJSX = renderedJSX.props.children(); + } else { + renderedJSX = renderedJSX.props.children; + } + } + + let displayNameDefaults; + + // component case based resolving of its displayName + if (typeof options?.displayName === 'string') { + displayNameDefaults = { + showFunctions: true, + displayName: () => options.displayName, + }; + /** + * add `renderedJSX?.type`to handle this case: + * + * https://github.com/zhyd1997/storybook/blob/20863a75ba4026d7eba6b288991a2cf091d4dfff/code/renderers/react/template/stories/errors.stories.tsx#L14 + * + * or it show the error message when run `yarn build-storybook --quiet`: + * + * Cannot read properties of undefined (reading '__docgenInfo'). + */ + } else { + displayNameDefaults = { + // To get exotic component names resolving properly + displayName: (el: any): string => { + if (el.type.displayName) { + let displayName = el.type.displayName; + // rename Emotion elements + if (isEmotionComponent(el)) { + // NOTE: overwriting el.type.displayName here and returning it + // causes some stale value for Emotion components + displayName = getEmotionComponentDisplayName(el) ?? displayName; + } + + return displayName; + } else if (getDocgenSection(el.type, 'displayName')) { + return getDocgenSection(el.type, 'displayName'); + } else if (el.type.render?.displayName) { + return el.type.render.displayName; + } else if ( + typeof el.type === 'symbol' || + (el.type.$$typeof && typeof el.type.$$typeof === 'symbol') + ) { + // check if it's an emotion component and we have a displayName available on it + const displayName = getEmotionComponentDisplayName(el); + return displayName ?? getReactSymbolName(el.type); + } else if (el.type.name && el.type.name !== '_default') { + // rename stateful wrappers + // naming convention: `Stateful{COMPONENT_NAME}` + if (isStatefulComponent(el)) { + const displayName = + getComponentDisplayName(context) ?? + context?.title.split('/').pop() ?? + el.type.name; + + el.type.displayName = displayName; + + return displayName; + } + 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'; + } else if (isForwardRef(el.type)) { + return el.type.render.name; + } else if (isMemo(el.type)) { + return el.type.type.name; + } else { + return el.type; + } + }, + }; + } + + // react-element-to-jsx-string options + const opts = { + ...displayNameDefaults, + ...options, + useBooleanShorthandSyntax: false, // disabled in favor of manual filtering + useFragmentShortSyntax: true, + sortProps: true, + filterProps: (value: any, key: string) => { + if (EXCLUDED_PROPS.includes(key) || value == null) { + return false; + } + + // manually filter `false` values as this ensures proper formatting of tags + // while `useBooleanShorthandSyntax={true}` leaves closing tags `>` on a new line + // filter out specific props that are needed to show with value `false` + if (value === false && !PRESERVED_FALSE_VALUE_PROPS.includes(key)) { + return false; + } + + if (value === '') return false; + + return true; + }, + }; + + const result = React.Children.map(code, (c) => { + // @ts-expect-error FIXME: workaround react-element-to-jsx-string + const child = typeof c === 'number' ? (c.toString() as string) : c; + const toJSXString: typeof reactElementToJSXString = + typeof reactElementToJSXString === 'function' + ? reactElementToJSXString + : // @ts-expect-error (Converted from ts-ignore) + reactElementToJSXString.default; + + const shouldResolveChildren = + context?.parameters?.codeSnippet?.resolveChildren === true; + + let node = child; + + if (typeof child !== 'string') { + // 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 + if (shouldResolveChildren) { + node = + child.type && typeof child.type === 'function' + ? (child.type as (args: any) => ReactElement)(context?.args) // kinda hacky way to return the children of a wrapper component instead by calling the component + : child; + } else { + // removes outer wrapper components but leaves: + // - stateful wrappers (kept and renamed later via displayName) + // - fragments (needed for reactElementToJSXString to work initially but skipped later) + // - parent and subcomponents components (subcomponents likely require their parent to display) + // - default fallback: if the children are an array we resolve for the story component within the wrapper + // (this prevents errors with reactElementToJSXString which can't handle array children) + node = + isStoryComponent(child, context) || + isStoryParent(child, context) || + isSubcomponent(child, context) || + isStatefulComponent(child) || + isFragment(child) + ? child + : child.props.children; + + if (Array.isArray(node)) { + const children = node as ReactElement[]; + + for (const child of children) { + const displayName = getEmotionComponentDisplayName(child); + if (displayName === context?.component?.displayName) { + node = child; + } + } + } + } + } + + let string: string = toJSXString( + _simplifyNodeForStringify(node), + opts as Options + ); + + if (string.indexOf('"') > -1) { + const matches = string.match(/\S+=\\"([^"]*)\\"/g); + if (matches) { + matches.forEach((match) => { + string = string.replace(match, match.replace(/"/g, "'")); + }); + } + } + + // renaming internal components + if (string.indexOf('<_') > -1) { + const regexStart = new RegExp(/<_/g); + const regexEnd = new RegExp(/<\/_/g); + const matchesStart = string.match(regexStart); + const matchesEnd = string.match(regexEnd); + + // renaming internal component opening tags that start with _underscore + if (matchesStart) { + matchesStart.forEach((match) => { + string = string.replace(match, match.replace(regexStart, '<')); + }); + } + + // renaming internal component closing tags that start with _underscore + if (matchesEnd) { + matchesEnd.forEach((match) => { + string = string.replace(match, match.replace(regexEnd, ' (react-element-to-jsx-string outputs ) + if (string.indexOf('React.Fragment') > -1) { + const regex = new RegExp(/React.Fragment/g); + const matches = string.match(regex); + if (matches) { + matches.forEach((match) => { + string = string.replace(match, match.replace(regex, '')); + }); + } + } + + // manually filter out ={true} to achieve shorthand syntax for boolean values as we're + // not using the global option `useBooleanShorthandSyntax` to have more control + if (string.indexOf('={true}') > -1) { + const regex = new RegExp(/={true}/g); + const matches = string.match(regex); + if (matches) { + matches.forEach((match) => { + string = string.replace(match, match.replace(regex, '')); + }); + } + } + + // ensure tokens are output properly by removing added variable markers + if (string.indexOf('{{') > -1) { + const regex = new RegExp(/'{{|}}'/g); + const matches = string.match(regex); + if (matches) { + matches.forEach((match) => { + string = string.replace(match, match.replace(regex, '')); + }); + } + } + + return string; + }).join('\n'); + + return result.replace(/function\s+noRefCheck\(\)\s*\{\}/g, '() => {}'); +}; + +/** + * recursively resolves ReactElement nodes: + * - removes obsolete outer and inner wrappers + * - resolves Emotion css prop back to its input state + * - resolves arrays and objects to single elements + */ +const _simplifyNodeForStringify = (node: ReactNode): ReactNode => { + if (isValidElement(node)) { + let updatedNode = node; + + // eslint-disable-next-line react-hooks/rules-of-hooks + const euiTheme = useEuiTheme(); + + // remove outer fragments + if (isFragment(updatedNode) && !Array.isArray(updatedNode.props.children)) { + updatedNode = node.props.children; + } + + // remove inner fragments + if ( + updatedNode.props?.children && + !Array.isArray(updatedNode.props.children) && + isFragment(updatedNode.props.children) + ) { + updatedNode = { + ...updatedNode, + props: { + ...updatedNode.props, + children: updatedNode.props.children.props.children, + }, + }; + } + + // check and resolve props recursively + const updatedProps = updatedNode.props + ? Object.keys(updatedNode.props).reduce<{ + [key: string]: any; + }>((acc, cur) => { + // resolve css Emotion object back to css prop + // ensures tokens are output as is and not its resolved value + if (cur === 'css') { + // example: + // css={({ euiTheme }) => ({})} + if (typeof updatedNode.props[cur] === 'function') { + const styles = updatedNode.props[cur]?.(euiTheme); + const fnString = String(updatedNode.props[cur]); + + /** + * get the style definitions from the function body + * example: + * "return { + * backgroundColor: euiTheme.colors.emptyShade, + * minBlockSize: '150vh' + * };" + */ + const regex = /return \{([\S\s]*?)(;)$/gm; + const matches = fnString.match(regex); + + if (matches) { + const rules = matches[0] + .replace('return {\n', '') + .replace(/(\/\/)([\S\s]*?)$/g, '') + .replaceAll(' ', '') + .replaceAll('\n', '') + .split(','); + + // transform string to styles object + const cssStyles = rules.reduce((acc, cur) => { + const [property, value] = cur.split(':'); + const isToken = value.match('euiTheme') != null; + + // if the value is a token, we pass the token name with variable + // markers which are removed in a later step. + // this way the value won't be coerced to another type when + // transforming the element to a jsx string + acc[property] = isToken + ? `{{${value}}}` + : value.replaceAll("'", ''); + + return acc; + }, {} as Record); + + acc[cur] = { + ...acc.style, + ...cssStyles, + }; + + return acc; + } + + acc[cur] = { + ...acc.style, + ...styles, + }; + } + + /** resolves Emotion css object styles string to a styles object + * example: + * styles: "background-color:rgba(0, 119, 204, 0.1);:first-child{min-height:5em;};label:flexItem;" + * returns: + * { + * "backgroundColor": "rgba(0, 119, 204, 0.1)", + * ":first-child": { + * "min-height": "5em" + * } + * } + */ + if ( + typeof updatedNode.props[cur] === 'object' && + !Array.isArray(cur) + ) { + const styles: string[] = updatedNode.props[cur].styles + .replace(';};', '};') + .split(';'); + + const styleRules = styles.reduce((acc, cur) => { + if (cur && !cur.startsWith(':') && !cur.startsWith('label')) { + const [property, value] = cur.split(':'); + const propertyName = camelCase(property); + + acc[propertyName] = value; + } else if (cur.startsWith(':')) { + const string = cur.replace('{', ';').replace('}', ''); + const [property, propertyValue] = string.split(';'); + const [key, value] = propertyValue.split(':'); + + acc[property] = { + [key]: value, + }; + } + + return acc; + }, {} as Record); + + acc[cur] = { + ...acc.style, + ...styleRules, + }; + } + + return acc; + } + + if (cur === 'style') { + return (acc[cur] = { + // prevent resolving style attribute + style: { + ...acc[cur], + ...updatedNode.props[cur], + }, + }); + } + + acc[cur] = _simplifyNodeForStringify(updatedNode.props[cur]); + + return acc; + }, {} as Record) + : {}; + + return { + ...updatedNode, + props: updatedProps, + // Recursively remove "_owner" property from elements to avoid crash on docs page when + // passing components as an array prop (#17482) + // Note: It may be better to use this function only in development environment. + // @ts-expect-error (this is an internal or removed api) + _owner: null, + }; + } + // recursively resolve array or object nodes (e.g. props) + if (Array.isArray(node)) { + const children = node.map(_simplifyNodeForStringify); + return children.flat(); + } + + // e.g. props of object shape + // props = { text: 'foobar' color: 'green' } + if (node && !Array.isArray(node) && typeof node === 'object') { + const updatedChildren = { + ...node, + } as Record; + let objectValue: ReactElement | undefined; + const childrenKeys = Object.keys(updatedChildren); + const childrenValues = Object.values(updatedChildren); + + for (const [i, n] of childrenValues.entries()) { + const hasConstructor = + updatedChildren.hasOwnProperty('_constructor-name_'); + + // resolves a prop value that is a class method to a function + // e.g. query={Query.parse('')} + if (hasConstructor) { + objectValue = (() => {}) as unknown as ReactElement; + break; + } else { + updatedChildren[childrenKeys[i]] = _simplifyNodeForStringify(n); + } + } + + return typeof objectValue === 'function' + ? (objectValue as ReactNode) + : (updatedChildren as ReactNode); + } + + // TODO: handle the case when a prop is a render function + + return node; +}; diff --git a/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts b/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts new file mode 100644 index 00000000000..ac092ac01bc --- /dev/null +++ b/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts @@ -0,0 +1,217 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/* DISCLAIMER: Parts of this file were copied from Storybook jsxDecorator and adjusted for more specific needs. +https://github.com/storybookjs/storybook/blob/2bff7a1c156bbd42ab381f84b8a55a07694e7e53/code/renderers/react/src/docs/jsxDecorator.tsx#L224 */ + +import { ReactElement, FunctionComponent, ComponentType } from 'react'; +import * as prettier from 'prettier'; +// @ts-ignore - types are available but not resolved +import tsParser from 'prettier/parser-typescript'; +import { StoryContext } from '@storybook/react'; + +// @ts-ignore - config import +import basePrettierConfig from '../../../../.prettierrc'; + +export const toPascalCase = (str: string) => + str.charAt(0).toUpperCase() + str.slice(1); + +/* Helpers for React specific checks */ +export const isMemo = (component: any) => + component.$$typeof === Symbol.for('react.memo'); +export const isForwardRef = (component: any) => + component.$$typeof === Symbol.for('react.forward_ref'); +export const isFragment = (component: any) => + component.type?.toString().includes('fragment') || + component.$$typeof?.toString().includes('fragment'); + +/* Helpers */ +// returns the displayName and handles typing as +// otherwise `type` would not be typed +export const getElementDisplayName = ( + node: ReactElement +): string | undefined => { + let displayName; + + if (typeof node.type === 'function' || typeof node.type === 'object') { + displayName = + (node.type as FunctionComponent).displayName ?? + (node.type as FunctionComponent).name ?? + undefined; + } + + return displayName; +}; + +// returns the displayName after resolving Emotion wrappers +export const getEmotionComponentDisplayName = ( + node: ReactElement +): string | undefined => { + const displayName = getElementDisplayName(node); + + if ( + (typeof displayName === 'string' && + displayName.match(/^(Emotion)(\w)*/g)) || + node.props?.__EMOTION_TYPE_PLEASE_DO_NOT_USE__ != null + ) { + const isForwardRefComponent = isForwardRef( + node.props.__EMOTION_TYPE_PLEASE_DO_NOT_USE__ + ); + // we need to rely here on the reference Emotion stores to know what component this actually is + const replacementName = isForwardRefComponent + ? node.props?.__EMOTION_TYPE_PLEASE_DO_NOT_USE__.__docgenInfo.displayName + : node.props?.__EMOTION_TYPE_PLEASE_DO_NOT_USE__?.displayName; + + // remove internal component underscore markings + return replacementName ? replacementName.replace('_', '') : displayName; + } + + return displayName; +}; + +export const getComponentDisplayName = ( + context: StoryContext | undefined +): string | undefined => { + if (!context) return undefined; + const component = context.component as ComponentType & { + __docgenInfo?: { displayName?: string }; + }; + + return component?.displayName ?? component.__docgenInfo?.displayName; +}; + +export const isEmotionComponent = (node: ReactElement): boolean => { + const displayName = getElementDisplayName(node); + const matches = + typeof displayName === 'string' + ? displayName.match(/^(Emotion)(\w)*/g) + : null; + + return matches != null; +}; + +/* Story specific checks */ +export const isStoryComponent = ( + node: ReactElement, + context: StoryContext | undefined +): boolean => { + if (!context) return false; + + const displayName = getEmotionComponentDisplayName(node); + + return displayName === context?.component?.displayName; +}; +/** + * checks if the outer most component is a parent of the actual story component + */ +export const isStoryParent = ( + node: ReactElement, + context: StoryContext | undefined +): boolean => { + if (!context) return false; + + const displayName = getEmotionComponentDisplayName(node); + + if (!displayName) return false; + + const parentComponents = context.title.split('/'); + parentComponents.shift(); + parentComponents.pop(); + + return parentComponents.includes(displayName); +}; + +export const isSubcomponent = ( + node: ReactElement, + context: StoryContext | undefined +): boolean => { + if (!context) return false; + + const parentComponents = context.title.split('/'); + parentComponents.shift(); + const displayName = getEmotionComponentDisplayName(node); + + if (!displayName || !parentComponents || parentComponents.length === 0) { + return false; + } + + let isSub = false; + + for (const parent of parentComponents) { + if (typeof displayName === 'string' && displayName.includes(parent)) { + isSub = true; + } + } + + return isSub; +}; + +export const isStatefulComponent = (node: ReactElement): boolean => { + const displayName = getEmotionComponentDisplayName(node); + const isStateful = + typeof displayName === 'string' + ? displayName.match(/^(Stateful|Component)(\w)*/) + : null; + + return Array.isArray(isStateful); +}; + +/** + * Converts a React symbol to a React-like displayName + * + * Symbols come from here + * https://github.com/facebook/react/blob/338dddc089d5865761219f02b5175db85c54c489/packages/react-devtools-shared/src/backend/ReactSymbols.js + * + * E.g. + * Symbol(react.suspense) -> React.Suspense + * Symbol(react.strict_mode) -> React.StrictMode + * Symbol(react.server_context.defaultValue) -> React.ServerContext.DefaultValue + * + * @param {Symbol} elementType - The symbol to convert + * @returns {string | null} A displayName for the Symbol in case elementType is a Symbol; otherwise, null. + */ +export const getReactSymbolName = (elementType: any): string => { + const elementName = elementType.$$typeof || elementType; + const symbolDescription: string = elementName + .toString() + .replace(/^Symbol\((.*)\)$/, '$1'); + + const reactComponentName = symbolDescription + .split('.') + .map((segment) => { + // Split segment by underscore to handle cases like 'strict_mode' separately, and PascalCase them + return segment.split('_').map(toPascalCase).join(''); + }) + .join('.'); + + return reactComponentName; +}; + +export const skipJsxRender = (context: StoryContext): boolean => { + const isArgsStory = context?.parameters.__isArgsStory; + const isManuallySkipped = context?.parameters?.codeSnippet?.skip === true; + + // never render if the user is skipping it manually or if it's not an args story. + return !isArgsStory || isManuallySkipped; +}; + +/** + * runs prettier (ts) on a code string to apply code formatting + */ +export const getFormattedCode = async (code: string) => { + const prettierConfig = { + ...basePrettierConfig, + trailingComma: 'none' as const, + parser: 'typescript', + plugins: [tsParser], + }; + + const formattedCode = await prettier.format(code, prettierConfig); + + return formattedCode; +}; diff --git a/packages/eui/.storybook/preview.tsx b/packages/eui/.storybook/preview.tsx index 78dd319a6b0..d8be14a6169 100644 --- a/packages/eui/.storybook/preview.tsx +++ b/packages/eui/.storybook/preview.tsx @@ -50,10 +50,13 @@ setEuiDevProviderWarning('error'); */ import type { CommonProps } from '../src/components/common'; + +import { customJsxDecorator } from './addons/code-snippet/decorators/jsx_decorator'; import { hideStorybookControls } from './utils'; const preview: Preview = { decorators: [ + customJsxDecorator, (Story, context) => ( Date: Fri, 24 May 2024 17:35:27 +0200 Subject: [PATCH 04/51] fix: prevent fewer hooks called error on euiTheme - calling useEuiTheme only on the initial decorator call and not every recursive check --- .../code-snippet/decorators/jsx_decorator.tsx | 8 ++++++- .../code-snippet/decorators/render_jsx.tsx | 24 ++++++++++++------- 2 files changed, 22 insertions(+), 10 deletions(-) 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 ee1077f4699..fbf34a872e1 100644 --- a/packages/eui/.storybook/addons/code-snippet/decorators/jsx_decorator.tsx +++ b/packages/eui/.storybook/addons/code-snippet/decorators/jsx_decorator.tsx @@ -18,6 +18,7 @@ import type { import { addons, useEffect, useCallback } from '@storybook/preview-api'; import { logger } from '@storybook/client-logger'; +import { useEuiTheme } from '../../../../src/services'; import { EVENTS, STORY_ARGS_MARKER } from '../constants'; import { getFormattedCode, skipJsxRender } from './utils'; @@ -136,8 +137,13 @@ export const customJsxDecorator = ( ) : story; + // NOTE: euiTheme is defined on global level to prevent errors on conditionally rendered hooks + // when stories have conditionally rendered components (via mapping) that rely on euiTheme + // eslint-disable-next-line react-hooks/rules-of-hooks + const euiTheme = useEuiTheme(); + // generate JSX from the story - const renderedJsx = renderJsx(storyJsx, options, context); + const renderedJsx = renderJsx(storyJsx, options, context, 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 b10a3c2500d..1c967ae8117 100644 --- a/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx +++ b/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx @@ -19,7 +19,7 @@ import type { StoryContext } from '@storybook/types'; import { getDocgenSection } from '@storybook/docs-tools'; import { logger } from '@storybook/client-logger'; -import { useEuiTheme } from '../../../../src/services'; +import { UseEuiTheme } from '../../../../src/services'; import { getComponentDisplayName, getEmotionComponentDisplayName, @@ -56,7 +56,8 @@ export type JSXOptions = Options & { export const renderJsx = ( code: React.ReactElement, options?: JSXOptions, - context?: StoryContext + context?: StoryContext, + euiTheme?: UseEuiTheme ): string | null => { if (typeof code === 'undefined') { logger.warn('Too many skip or undefined component'); @@ -242,7 +243,7 @@ export const renderJsx = ( } let string: string = toJSXString( - _simplifyNodeForStringify(node), + _simplifyNodeForStringify(node, euiTheme), opts as Options ); @@ -323,13 +324,13 @@ export const renderJsx = ( * - resolves Emotion css prop back to its input state * - resolves arrays and objects to single elements */ -const _simplifyNodeForStringify = (node: ReactNode): ReactNode => { +const _simplifyNodeForStringify = ( + node: ReactNode, + euiTheme?: UseEuiTheme +): ReactNode => { if (isValidElement(node)) { let updatedNode = node; - // eslint-disable-next-line react-hooks/rules-of-hooks - const euiTheme = useEuiTheme(); - // remove outer fragments if (isFragment(updatedNode) && !Array.isArray(updatedNode.props.children)) { updatedNode = node.props.children; @@ -360,7 +361,10 @@ const _simplifyNodeForStringify = (node: ReactNode): ReactNode => { if (cur === 'css') { // example: // css={({ euiTheme }) => ({})} - if (typeof updatedNode.props[cur] === 'function') { + if ( + typeof updatedNode.props[cur] === 'function' && + euiTheme != null + ) { const styles = updatedNode.props[cur]?.(euiTheme); const fnString = String(updatedNode.props[cur]); @@ -488,7 +492,9 @@ const _simplifyNodeForStringify = (node: ReactNode): ReactNode => { } // recursively resolve array or object nodes (e.g. props) if (Array.isArray(node)) { - const children = node.map(_simplifyNodeForStringify); + const children = node.map((child) => + _simplifyNodeForStringify(child, euiTheme) + ); return children.flat(); } From 0eca4679a8725c2c3108c4a3c695fc2e74b0a8c7 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Fri, 24 May 2024 17:38:51 +0200 Subject: [PATCH 05/51] docs(storybook): updates stories to ensure code-snippet generation - ensure naming convention for stateful wrapper to start with Stateful... for filters to work - ensure args are passed along when using decorators - ensure no anonymous render functions are used as they would otherwise be skipped --- .../button_group/button_group.stories.tsx | 10 +- .../collapsible_nav_beta.stories.tsx | 6 +- .../src/components/header/header.stories.tsx | 137 +++++++++--------- .../components/modal/modal_body.stories.tsx | 4 +- .../components/modal/modal_footer.stories.tsx | 4 +- .../popover/popover_footer.stories.tsx | 4 +- .../popover/popover_title.stories.tsx | 4 +- .../components/side_nav/side_nav.stories.tsx | 4 +- .../text_block_truncate.stories.tsx | 4 +- 9 files changed, 91 insertions(+), 86 deletions(-) diff --git a/packages/eui/src/components/button/button_group/button_group.stories.tsx b/packages/eui/src/components/button/button_group/button_group.stories.tsx index 144851fd22e..9f9a3fe1bd4 100644 --- a/packages/eui/src/components/button/button_group/button_group.stories.tsx +++ b/packages/eui/src/components/button/button_group/button_group.stories.tsx @@ -67,7 +67,7 @@ const options: EuiButtonGroupOptionProps[] = [ }, ]; -const EuiButtonGroupSingle = (props: any) => { +const StatefulEuiButtonGroupSingle = (props: any) => { const [idSelected, setIdSelected] = useState(props.idSelected); return ( @@ -80,7 +80,7 @@ const EuiButtonGroupSingle = (props: any) => { }; export const SingleSelection: Story = { - render: ({ ...args }) => , + render: ({ ...args }) => , args: { legend: 'EuiButtonGroup - single selection', options, @@ -89,7 +89,7 @@ export const SingleSelection: Story = { }, }; -const EuiButtonGroupMulti = (props: any) => { +const StatefulEuiButtonGroupMulti = (props: any) => { const [idToSelectedMap, setIdToSelectedMap] = useState< Record >(props.idToSelectedMap); @@ -113,7 +113,7 @@ const EuiButtonGroupMulti = (props: any) => { }; export const MultiSelection: Story = { - render: ({ ...args }) => , + render: ({ ...args }) => , args: { legend: 'EuiButtonGroup - multiple selections', options, @@ -123,7 +123,7 @@ export const MultiSelection: Story = { }; export const WithTooltips: Story = { - render: ({ ...args }) => , + render: ({ ...args }) => , args: { legend: 'EuiButtonGroup - tooltip UI testing', isIconOnly: true, // Start example with icons to demonstrate usefulness of tooltips diff --git a/packages/eui/src/components/collapsible_nav_beta/collapsible_nav_beta.stories.tsx b/packages/eui/src/components/collapsible_nav_beta/collapsible_nav_beta.stories.tsx index ae847270f47..f5d82b24b48 100644 --- a/packages/eui/src/components/collapsible_nav_beta/collapsible_nav_beta.stories.tsx +++ b/packages/eui/src/components/collapsible_nav_beta/collapsible_nav_beta.stories.tsx @@ -48,7 +48,7 @@ const meta: Meta = { export default meta; type Story = StoryObj; -const OpenCollapsibleNav: FunctionComponent< +const StatefulCollapsibleNav: FunctionComponent< PropsWithChildren & Partial > = (props) => { return ( @@ -91,7 +91,7 @@ const renderGroup = ( export const Playground: Story = { render: ({ ...args }) => ( - + - + ), }; diff --git a/packages/eui/src/components/header/header.stories.tsx b/packages/eui/src/components/header/header.stories.tsx index 2ee3e036e95..2e8e23e0d0b 100644 --- a/packages/eui/src/components/header/header.stories.tsx +++ b/packages/eui/src/components/header/header.stories.tsx @@ -84,74 +84,79 @@ export const Sections: Story = { }, }; +const MultipleFixedHeadersExample = () => { + const [fixedHeadersCount, setFixedHeadersCount] = useState(3); // eslint-disable-line react-hooks/rules-of-hooks + const [isFlyoutOpen, setIsFlyoutOpen] = useState(false); // eslint-disable-line react-hooks/rules-of-hooks + + const sections = [ + { + items: [ + , + ], + }, + { + items: [ + setIsFlyoutOpen(!isFlyoutOpen)}> + Toggle flyout + , + ], + }, + ]; + + return ( + + + The page template and flyout should automatically adjust dynamically to + the number of fixed headers on the page. + {isFlyoutOpen && ( + setIsFlyoutOpen(false)}> + The flyout position and mask should automatically adjust dynamically + to the number of fixed headers on the page. + + )} +
+
+ setFixedHeadersCount((count) => count - 1)} + > + Remove a fixed header + +   + setFixedHeadersCount((count) => count + 1)} + > + Add a fixed header + +
+
+ {/* Always render at least one static header so we can toggle/test the flyout */} + + {/* Conditionally render additional fixed headers */} + {Array.from({ length: fixedHeadersCount - 1 }).map((_, i) => ( + + ))} +
+
+ ); +}; + export const MultipleFixedHeaders: Story = { parameters: { layout: 'fullscreen', + codeSnippet: { + resolveChildren: true, + }, }, - render: () => { - const [fixedHeadersCount, setFixedHeadersCount] = useState(3); // eslint-disable-line react-hooks/rules-of-hooks - const [isFlyoutOpen, setIsFlyoutOpen] = useState(false); // eslint-disable-line react-hooks/rules-of-hooks - - const sections = [ - { - items: [ - , - ], - }, - { - items: [ - setIsFlyoutOpen(!isFlyoutOpen)}> - Toggle flyout - , - ], - }, - ]; - - return ( - - - The page template and flyout should automatically adjust dynamically - to the number of fixed headers on the page. - {isFlyoutOpen && ( - setIsFlyoutOpen(false)}> - The flyout position and mask should automatically adjust - dynamically to the number of fixed headers on the page. - - )} -
-
- setFixedHeadersCount((count) => count - 1)} - > - Remove a fixed header - -   - setFixedHeadersCount((count) => count + 1)} - > - Add a fixed header - -
-
- {/* Always render at least one static header so we can toggle/test the flyout */} - - {/* Conditionally render additional fixed headers */} - {Array.from({ length: fixedHeadersCount - 1 }).map((_, i) => ( - - ))} -
-
- ); - }, + render: (args) => , }; diff --git a/packages/eui/src/components/modal/modal_body.stories.tsx b/packages/eui/src/components/modal/modal_body.stories.tsx index 8669a9dafd7..1388e04f691 100644 --- a/packages/eui/src/components/modal/modal_body.stories.tsx +++ b/packages/eui/src/components/modal/modal_body.stories.tsx @@ -18,9 +18,9 @@ const meta: Meta = { title: 'Layout/EuiModal/EuiModalBody', component: EuiModalBody, decorators: [ - (Story) => ( + (Story, { args }) => ( - + ), ], diff --git a/packages/eui/src/components/modal/modal_footer.stories.tsx b/packages/eui/src/components/modal/modal_footer.stories.tsx index e22ecb3ad2b..af5cb4aa03d 100644 --- a/packages/eui/src/components/modal/modal_footer.stories.tsx +++ b/packages/eui/src/components/modal/modal_footer.stories.tsx @@ -18,9 +18,9 @@ const meta: Meta = { title: 'Layout/EuiModal/EuiModalFooter', component: EuiModalFooter, decorators: [ - (Story) => ( + (Story, { args }) => ( - + ), ], diff --git a/packages/eui/src/components/popover/popover_footer.stories.tsx b/packages/eui/src/components/popover/popover_footer.stories.tsx index 0f7eba4ba9e..9ddb92a8a7e 100644 --- a/packages/eui/src/components/popover/popover_footer.stories.tsx +++ b/packages/eui/src/components/popover/popover_footer.stories.tsx @@ -18,9 +18,9 @@ const meta: Meta = { title: 'Layout/EuiPopover/EuiPopoverFooter', component: EuiPopoverFooter, decorators: [ - (Story) => ( + (Story, { args }) => ( trigger}> - + ), ], diff --git a/packages/eui/src/components/popover/popover_title.stories.tsx b/packages/eui/src/components/popover/popover_title.stories.tsx index 0885d7e3214..357c987f98b 100644 --- a/packages/eui/src/components/popover/popover_title.stories.tsx +++ b/packages/eui/src/components/popover/popover_title.stories.tsx @@ -18,9 +18,9 @@ const meta: Meta = { title: 'Layout/EuiPopover/EuiPopoverTitle', component: EuiPopoverTitle, decorators: [ - (Story) => ( + (Story, { args }) => ( trigger}> - + ), ], diff --git a/packages/eui/src/components/side_nav/side_nav.stories.tsx b/packages/eui/src/components/side_nav/side_nav.stories.tsx index dd716f5e257..da991080f05 100644 --- a/packages/eui/src/components/side_nav/side_nav.stories.tsx +++ b/packages/eui/src/components/side_nav/side_nav.stories.tsx @@ -28,10 +28,10 @@ const meta: Meta = { isOpenOnMobile: false, }, decorators: [ - (Story) => ( + (Story, { args }) => (
{/* The side nav is visually easier to see with the width set */} - +
), ], diff --git a/packages/eui/src/components/text_truncate/text_block_truncate.stories.tsx b/packages/eui/src/components/text_truncate/text_block_truncate.stories.tsx index 6b2c6ff68df..60238aab07b 100644 --- a/packages/eui/src/components/text_truncate/text_block_truncate.stories.tsx +++ b/packages/eui/src/components/text_truncate/text_block_truncate.stories.tsx @@ -20,9 +20,9 @@ const meta: Meta = { title: 'Utilities/EuiTextBlockTruncate', component: EuiTextBlockTruncate, decorators: [ - (Story) => ( + (Story, { args }) => ( - + ), ], From 8485fc34549262784ff8d9d1e74e82e69a2943e5 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Fri, 24 May 2024 17:45:13 +0200 Subject: [PATCH 06/51] docs(storybook): various story adjustments - EuiCombobox: setup onChange handler and use it in the stateful wrapper; fix static value for options - EuiheaderAlert: adds a storybook only prop to ensure the full code is used for the snippet generation and not the close state one as the snippet is only generated on arg change and not triggered by preview changes --- .../src/components/button/button.stories.tsx | 7 +- .../combo_box/combo_box.stories.tsx | 114 +++++++++++------- .../header_alert/header_alert.stories.tsx | 47 ++++++-- .../components/i18n/i18n_number.stories.tsx | 4 +- .../popover/input_popover.stories.tsx | 21 ++-- .../components/popover/popover.stories.tsx | 6 +- .../popover/wrapping_popover.stories.tsx | 4 +- 7 files changed, 134 insertions(+), 69 deletions(-) diff --git a/packages/eui/src/components/button/button.stories.tsx b/packages/eui/src/components/button/button.stories.tsx index 6875c27da7b..f6135409d97 100644 --- a/packages/eui/src/components/button/button.stories.tsx +++ b/packages/eui/src/components/button/button.stories.tsx @@ -7,7 +7,11 @@ */ import type { Meta, StoryObj } from '@storybook/react'; -import { disableStorybookControls } from '../../../.storybook/utils'; + +import { + disableStorybookControls, + enableFunctionToggleControls, +} from '../../../.storybook/utils'; import { EuiButton, Props as EuiButtonProps } from './button'; @@ -34,6 +38,7 @@ const meta: Meta = { isSelected: false, }, }; +enableFunctionToggleControls(meta, ['onClick']); export default meta; type Story = StoryObj; diff --git a/packages/eui/src/components/combo_box/combo_box.stories.tsx b/packages/eui/src/components/combo_box/combo_box.stories.tsx index 9d404ce9079..0d1e487df6b 100644 --- a/packages/eui/src/components/combo_box/combo_box.stories.tsx +++ b/packages/eui/src/components/combo_box/combo_box.stories.tsx @@ -8,9 +8,12 @@ import React, { useCallback, useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; -import { action } from '@storybook/addon-actions'; import { userEvent, waitFor, within, expect } from '@storybook/test'; +import { + enableFunctionToggleControls, + hideStorybookControls, +} from '../../../.storybook/utils'; import { LOKI_SELECTORS, lokiPlayDecorator } from '../../../.storybook/loki'; import { EuiCode } from '../code'; import { EuiFlexItem } from '../flex'; @@ -72,6 +75,7 @@ const meta: Meta> = { onCreateOption: undefined, // Override Storybook's default callback }, }; +enableFunctionToggleControls(meta, ['onChange', 'onCreateOption']); export default meta; type Story = StoryObj>; @@ -83,7 +87,7 @@ export const Playground: Story = { export const WithTooltip: Story = { parameters: { controls: { - include: ['fullWidth', 'options', 'selectedOptions'], + include: ['fullWidth', 'options', 'selectedOptions', 'onChange'], }, loki: { // popover and tooltip are rendered in a portal @@ -92,7 +96,11 @@ export const WithTooltip: Story = { }, }, args: { - options: options.map((option) => ({ ...option, ...toolTipProps })), + options: options.map((option, idx) => ({ + ...option, + ...toolTipProps, + value: idx, + })), }, render: (args) => , play: lokiPlayDecorator(async (context) => { @@ -120,47 +128,12 @@ export const WithTooltip: Story = { ); }), }; +// manually hide onChange as it's not important as control but needs to be included +// to use the defined control (via enableFunctionToggleControls) in the stateful wrapper +hideStorybookControls(WithTooltip, ['onChange']); export const CustomMatcher: Story = { - render: function Render({ singleSelection, onCreateOption, ...args }) { - const [selectedOptions, setSelectedOptions] = useState( - args.selectedOptions - ); - const onChange: EuiComboBoxProps<{}>['onChange'] = (options, ...args) => { - setSelectedOptions(options); - action('onChange')(options, ...args); - }; - - const optionMatcher = useCallback>( - ({ option, searchValue }) => { - return option.label.startsWith(searchValue); - }, - [] - ); - - return ( - <> -

- This matcher example uses option.label.startsWith() - . Only options that start exactly like the given search string will be - matched. -

-
- - - ); - }, + render: (args) => , }; export const Groups: Story = { @@ -225,12 +198,16 @@ export const NestedOptionsGroups: Story = { const StatefulComboBox = ({ singleSelection, onCreateOption, + onChange, ...args }: EuiComboBoxProps<{}>) => { const [selectedOptions, setSelectedOptions] = useState(args.selectedOptions); - const onChange: EuiComboBoxProps<{}>['onChange'] = (options, ...args) => { + const handleOnChange: EuiComboBoxProps<{}>['onChange'] = ( + options, + ...args + ) => { setSelectedOptions(options); - action('onChange')(options, ...args); + onChange?.(options, ...args); }; const _onCreateOption: EuiComboBoxProps<{}>['onCreateOption'] = ( searchValue, @@ -242,7 +219,7 @@ const StatefulComboBox = ({ ? [createdOption] : [...prevState, createdOption] ); - action('onCreateOption')(searchValue, ...args); + onCreateOption?.(searchValue, ...args); }; return ( ); }; + +const StatefulCustomMatcher = ({ + singleSelection, + onChange, + ...args +}: EuiComboBoxProps<{}>) => { + const [selectedOptions, setSelectedOptions] = useState(args.selectedOptions); + const handleOnChange: EuiComboBoxProps<{}>['onChange'] = ( + options, + ...args + ) => { + setSelectedOptions(options); + onChange?.(options, ...args); + }; + + const optionMatcher = useCallback>( + ({ option, searchValue }) => { + return option.label.startsWith(searchValue); + }, + [] + ); + + return ( + <> +

+ This matcher example uses option.label.startsWith(). + Only options that start exactly like the given search string will be + matched. +

+
+ + + ); +}; diff --git a/packages/eui/src/components/header/header_alert/header_alert.stories.tsx b/packages/eui/src/components/header/header_alert/header_alert.stories.tsx index 1ad16621999..0c41fa30632 100644 --- a/packages/eui/src/components/header/header_alert/header_alert.stories.tsx +++ b/packages/eui/src/components/header/header_alert/header_alert.stories.tsx @@ -6,9 +6,8 @@ * Side Public License, v 1. */ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; - import { EuiLink, EuiBadge, @@ -62,11 +61,27 @@ export const Playground: Story = {}; /** * Flyout example */ -const Flyout = (props: EuiHeaderAlertProps) => { +const Flyout = ( + props: EuiHeaderAlertProps & { __STORYBOOK_ONLY__isOpen: boolean } +) => { + const { __STORYBOOK_ONLY__isOpen, ...rest } = props ?? { + __STORYBOOK_ONLY__isOpen: true, + }; + const [isMounted, setMounted] = useState(false); const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); const closeFlyout = () => setIsFlyoutVisible(false); - const flyout = isFlyoutVisible && ( + useEffect(() => { + if (!props || isMounted) return; + + if (props.__STORYBOOK_ONLY__isOpen) { + setMounted(true); + } + }, [props, isMounted]); + + const shouldShowCode = !isMounted && !isFlyoutVisible; + + const flyout = (shouldShowCode || (isMounted && isFlyoutVisible)) && ( @@ -74,11 +89,11 @@ const Flyout = (props: EuiHeaderAlertProps) => { - - - - - + + + + + @@ -118,7 +133,12 @@ const Flyout = (props: EuiHeaderAlertProps) => { ); }; export const FlyoutExample: Story = { - render: ({ ...args }) => , + parameters: { + codeSnippet: { + resolveChildren: true, + }, + }, + render: (args) => , }; /** @@ -178,5 +198,10 @@ const Popover = (props: any) => { ); }; export const PopoverExample: Story = { - render: ({ ...args }) => , + parameters: { + codeSnippet: { + resolveChildren: true, + }, + }, + render: (args) => , }; diff --git a/packages/eui/src/components/i18n/i18n_number.stories.tsx b/packages/eui/src/components/i18n/i18n_number.stories.tsx index 2cad9a29bfd..67edd2e2aa5 100644 --- a/packages/eui/src/components/i18n/i18n_number.stories.tsx +++ b/packages/eui/src/components/i18n/i18n_number.stories.tsx @@ -48,8 +48,8 @@ export const MultipleValues: Story = { values: [0, 1, 2], children: (values: ReactChild[]) => ( <> - {values.map((value) => ( - + {values.map((value, index) => ( + Formatted number: {value} ))} diff --git a/packages/eui/src/components/popover/input_popover.stories.tsx b/packages/eui/src/components/popover/input_popover.stories.tsx index 0a466e613d7..f2c4c4b53d4 100644 --- a/packages/eui/src/components/popover/input_popover.stories.tsx +++ b/packages/eui/src/components/popover/input_popover.stories.tsx @@ -67,6 +67,12 @@ export const Playground: Story = { args: { children: 'Popover content', isOpen: true, + input: ( + + ), }, render: (args) => , }; @@ -90,17 +96,18 @@ const StatefulInputPopover = ({ closePopover?.(); }; + const connectedInput = React.isValidElement(input) + ? React.cloneElement(input, { + ...input.props, + onFocus: () => setOpen(true), + }) + : input; + return ( setOpen(true)} - placeholder="Focus me to toggle an input popover" - aria-label="Popover attached to input element" - /> - } + input={connectedInput} {...rest} > {children} diff --git a/packages/eui/src/components/popover/popover.stories.tsx b/packages/eui/src/components/popover/popover.stories.tsx index 22833f0ba73..45c47dc62c4 100644 --- a/packages/eui/src/components/popover/popover.stories.tsx +++ b/packages/eui/src/components/popover/popover.stories.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { css } from '@emotion/react'; import type { Meta, StoryObj } from '@storybook/react'; @@ -72,6 +72,10 @@ const StatefulPopover = ({ }: EuiPopoverProps) => { const [isOpen, setOpen] = useState(_isOpen); + useEffect(() => { + setOpen(_isOpen); + }, [_isOpen]); + const handleOnClose = () => { setOpen(false); closePopover?.(); diff --git a/packages/eui/src/components/popover/wrapping_popover.stories.tsx b/packages/eui/src/components/popover/wrapping_popover.stories.tsx index a827e29b2b8..238429e63d6 100644 --- a/packages/eui/src/components/popover/wrapping_popover.stories.tsx +++ b/packages/eui/src/components/popover/wrapping_popover.stories.tsx @@ -11,6 +11,7 @@ import { css } from '@emotion/react'; import type { Meta, StoryObj } from '@storybook/react'; import { + disableStorybookControls, enableFunctionToggleControls, hideStorybookControls, moveStorybookControlsToCategory, @@ -50,7 +51,8 @@ const meta: Meta = { buffer: 16, }, }; -enableFunctionToggleControls(meta, ['closePopover', 'onPositionChange']); +disableStorybookControls(meta, ['closePopover']); +enableFunctionToggleControls(meta, ['onPositionChange']); moveStorybookControlsToCategory( meta, [ From 47af532648003a23ef01c55ba8588c115dc86c02 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Fri, 24 May 2024 17:45:50 +0200 Subject: [PATCH 07/51] docs(storybook): skip code snippet generation for components that have render functions --- .../basic_table/in_memory_table.stories.tsx | 6 ++++++ .../src/components/datagrid/data_grid.stories.tsx | 6 ++++++ .../drag_and_drop/drag_drop_context.stories.tsx | 4 ++++ .../components/drag_and_drop/draggable.stories.tsx | 4 ++++ .../components/drag_and_drop/droppable.stories.tsx | 6 ++++++ .../mutation_observer/mutation_observer.stories.tsx | 6 ++++++ .../resize_observer/resize_observer.stories.tsx | 6 ++++++ .../resizable_container.stories.tsx | 6 ++++++ .../resizable_container/resizable_panel.stories.tsx | 6 ++++++ .../src/components/selectable/selectable.stories.tsx | 12 +++++++++++- .../src/components/text_diff/text_diff.stories.tsx | 10 ++++++++++ packages/eui/src/components/tour/tour.stories.tsx | 4 ++++ 12 files changed, 75 insertions(+), 1 deletion(-) diff --git a/packages/eui/src/components/basic_table/in_memory_table.stories.tsx b/packages/eui/src/components/basic_table/in_memory_table.stories.tsx index 53cd1889839..ddd3d3c5e33 100644 --- a/packages/eui/src/components/basic_table/in_memory_table.stories.tsx +++ b/packages/eui/src/components/basic_table/in_memory_table.stories.tsx @@ -24,6 +24,12 @@ const meta: Meta = { title: 'Tabular Content/EuiInMemoryTable', // @ts-ignore complex component: EuiInMemoryTable, + parameters: { + codeSnippet: { + // TODO: enable once render functions are supported + skip: true, + }, + }, args: { allowNeutralSort: true, searchFormat: 'eql', diff --git a/packages/eui/src/components/datagrid/data_grid.stories.tsx b/packages/eui/src/components/datagrid/data_grid.stories.tsx index 590bba21055..dc909f42f12 100644 --- a/packages/eui/src/components/datagrid/data_grid.stories.tsx +++ b/packages/eui/src/components/datagrid/data_grid.stories.tsx @@ -205,6 +205,12 @@ const RenderCellValue = ({ const meta: Meta = { title: 'Tabular Content/EuiDataGrid', component: EuiDataGrid, + parameters: { + codeSnippet: { + // TODO: enable once render functions are supported + skip: true, + }, + }, argTypes: { width: { control: 'text' }, height: { control: 'text' }, diff --git a/packages/eui/src/components/drag_and_drop/drag_drop_context.stories.tsx b/packages/eui/src/components/drag_and_drop/drag_drop_context.stories.tsx index 88e46a5470b..f9e0f09918f 100644 --- a/packages/eui/src/components/drag_and_drop/drag_drop_context.stories.tsx +++ b/packages/eui/src/components/drag_and_drop/drag_drop_context.stories.tsx @@ -26,6 +26,10 @@ const meta: Meta = { // visual parts with the Drag and Drop components separately skip: true, }, + codeSnippet: { + // TODO: enable once render functions are supported + skip: true, + }, }, }; enableFunctionToggleControls(meta, [ diff --git a/packages/eui/src/components/drag_and_drop/draggable.stories.tsx b/packages/eui/src/components/drag_and_drop/draggable.stories.tsx index 64f81162767..b8a08fca957 100644 --- a/packages/eui/src/components/drag_and_drop/draggable.stories.tsx +++ b/packages/eui/src/components/drag_and_drop/draggable.stories.tsx @@ -77,6 +77,10 @@ export const Interactive: Story = { 'customDragHandle', ], }, + codeSnippet: { + // TODO: enable once render functions are supported + skip: true, + }, }, args: { draggableId: 'draggable-item', diff --git a/packages/eui/src/components/drag_and_drop/droppable.stories.tsx b/packages/eui/src/components/drag_and_drop/droppable.stories.tsx index 3391599fabd..72151a5af42 100644 --- a/packages/eui/src/components/drag_and_drop/droppable.stories.tsx +++ b/packages/eui/src/components/drag_and_drop/droppable.stories.tsx @@ -27,6 +27,12 @@ const makeId = htmlIdGenerator(); const meta: Meta = { title: 'Display/EuiDroppable', component: EuiDroppable, + parameters: { + codeSnippet: { + // TODO: enable once render functions are supported + skip: true, + }, + }, argTypes: { droppableId: { type: { diff --git a/packages/eui/src/components/observer/mutation_observer/mutation_observer.stories.tsx b/packages/eui/src/components/observer/mutation_observer/mutation_observer.stories.tsx index 51f7394b875..7ed7692db66 100644 --- a/packages/eui/src/components/observer/mutation_observer/mutation_observer.stories.tsx +++ b/packages/eui/src/components/observer/mutation_observer/mutation_observer.stories.tsx @@ -22,6 +22,12 @@ import { const meta: Meta = { title: 'Utilities/EuiMutationObserver', component: EuiMutationObserver, + parameters: { + codeSnippet: { + // TODO: enable once render functions are supported + skip: true, + }, + }, }; export default meta; diff --git a/packages/eui/src/components/observer/resize_observer/resize_observer.stories.tsx b/packages/eui/src/components/observer/resize_observer/resize_observer.stories.tsx index f2baa73e127..85019d099e7 100644 --- a/packages/eui/src/components/observer/resize_observer/resize_observer.stories.tsx +++ b/packages/eui/src/components/observer/resize_observer/resize_observer.stories.tsx @@ -20,6 +20,12 @@ import { EuiResizeObserver, EuiResizeObserverProps } from './resize_observer'; const meta: Meta = { title: 'Utilities/EuiResizeObserver', component: EuiResizeObserver, + parameters: { + codeSnippet: { + // TODO: enable once render functions are supported + skip: true, + }, + }, }; export default meta; diff --git a/packages/eui/src/components/resizable_container/resizable_container.stories.tsx b/packages/eui/src/components/resizable_container/resizable_container.stories.tsx index 9162db19b46..491fd88c187 100644 --- a/packages/eui/src/components/resizable_container/resizable_container.stories.tsx +++ b/packages/eui/src/components/resizable_container/resizable_container.stories.tsx @@ -155,6 +155,12 @@ const MultiCollapsible: EuiResizableContainerProps['children'] = ( const meta: Meta = { title: 'Layout/EuiResizableContainer/EuiResizableContainer', component: EuiResizableContainer, + parameters: { + codeSnippet: { + // TODO: enable once render functions are supported + skip: true, + }, + }, args: { direction: 'horizontal', }, diff --git a/packages/eui/src/components/resizable_container/resizable_panel.stories.tsx b/packages/eui/src/components/resizable_container/resizable_panel.stories.tsx index 9b7cd67e850..c13f5bf230d 100644 --- a/packages/eui/src/components/resizable_container/resizable_panel.stories.tsx +++ b/packages/eui/src/components/resizable_container/resizable_panel.stories.tsx @@ -23,6 +23,12 @@ faker.seed(42); const meta: Meta = { title: 'Layout/EuiResizableContainer/Subcomponents/EuiResizablePanel', component: EuiResizablePanel, + parameters: { + codeSnippet: { + // TODO: enable once render functions are supported + skip: true, + }, + }, argTypes: { mode: { control: 'radio', diff --git a/packages/eui/src/components/selectable/selectable.stories.tsx b/packages/eui/src/components/selectable/selectable.stories.tsx index 467189b76bb..ae549dfacff 100644 --- a/packages/eui/src/components/selectable/selectable.stories.tsx +++ b/packages/eui/src/components/selectable/selectable.stories.tsx @@ -68,6 +68,12 @@ const options: EuiSelectableOption[] = [ const meta: Meta = { title: 'Forms/EuiSelectable', component: EuiSelectable, + parameters: { + codeSnippet: { + // TODO: enable once render functions are supported + skip: true, + }, + }, argTypes: { singleSelection: { control: 'radio', options: [true, false, 'always'] }, emptyMessage: { control: 'text' }, @@ -111,7 +117,11 @@ export const WithTooltip: Story = { }, }, args: { - options: options.map((option) => ({ ...option, ...toolTipProps })), + options: options.map((option, idx) => ({ + ...option, + ...toolTipProps, + value: idx, + })), searchable: false, }, render: ({ ...args }: EuiSelectableProps) => , diff --git a/packages/eui/src/components/text_diff/text_diff.stories.tsx b/packages/eui/src/components/text_diff/text_diff.stories.tsx index 474583bd937..11b016b27df 100644 --- a/packages/eui/src/components/text_diff/text_diff.stories.tsx +++ b/packages/eui/src/components/text_diff/text_diff.stories.tsx @@ -10,12 +10,22 @@ import React, { ReactElement } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import { hideStorybookControls } from '../../../.storybook/utils'; +import { STORY_ARGS_MARKER } from '../../../.storybook/addons/code-snippet/constants'; import { useEuiTextDiff, EuiTextDiffProps } from './text_diff'; const meta: Meta = { title: 'Utilities/useEuiTextDiff', // casting here to match story output while preserving component docgen information component: useEuiTextDiff as unknown as () => ReactElement, + parameters: { + codeSnippet: { + // the story returns a component but the actual code is a hook pattern + // we can provide a manual snippet instead + snippet: ` + const [rendered, textDiffObject] = useTextDiff(${STORY_ARGS_MARKER}) + `, + }, + }, argTypes: { insertComponent: { control: 'text' }, deleteComponent: { control: 'text' }, diff --git a/packages/eui/src/components/tour/tour.stories.tsx b/packages/eui/src/components/tour/tour.stories.tsx index 47c1bdd8e5f..e100b9f95c5 100644 --- a/packages/eui/src/components/tour/tour.stories.tsx +++ b/packages/eui/src/components/tour/tour.stories.tsx @@ -20,6 +20,10 @@ const meta: Meta = { component: EuiTour, parameters: { layout: 'fullscreen', + codeSnippet: { + // TODO: enable once render functions are supported + skip: true, + }, }, decorators: [ (Story, { args }) => ( From ccc2f2dfe3c905c2025c7c6566a2a277a7d130d7 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Fri, 24 May 2024 19:43:56 +0200 Subject: [PATCH 08/51] refactor: update style object construction regex --- .../addons/code-snippet/decorators/render_jsx.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) 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 1c967ae8117..93c962233c1 100644 --- a/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx +++ b/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx @@ -376,29 +376,34 @@ const _simplifyNodeForStringify = ( * minBlockSize: '150vh' * };" */ - const regex = /return \{([\S\s]*?)(;)$/gm; + const regex = /return([\S\s]*?)\{([\S\s]*?)(};?)$/gm; const matches = fnString.match(regex); if (matches) { const rules = matches[0] .replace('return {\n', '') + .replace('return{', '') .replace(/(\/\/)([\S\s]*?)$/g, '') .replaceAll(' ', '') .replaceAll('\n', '') + .replace(/}}/g, '') .split(','); // transform string to styles object const cssStyles = rules.reduce((acc, cur) => { const [property, value] = cur.split(':'); const isToken = value.match('euiTheme') != null; + const cleanedValue = isToken + ? value.replace(/.+?(?=euiTheme)/g, '') + : value.replaceAll("'", '').replaceAll('"', ''); // if the value is a token, we pass the token name with variable // markers which are removed in a later step. // this way the value won't be coerced to another type when // transforming the element to a jsx string acc[property] = isToken - ? `{{${value}}}` - : value.replaceAll("'", ''); + ? `{{${cleanedValue}}}` + : cleanedValue; return acc; }, {} as Record); From 87dc4b309df4dd204949283c9c60d1028db1a861 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Fri, 7 Jun 2024 13:30:26 +0200 Subject: [PATCH 09/51] chore: update yarn lock --- yarn.lock | 122 ++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 82 insertions(+), 40 deletions(-) diff --git a/yarn.lock b/yarn.lock index 52939cbd4d3..02f8ec2b33c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5827,7 +5827,8 @@ __metadata: "@storybook/addon-links": "npm:^8.0.5" "@storybook/addon-webpack5-compiler-babel": "npm:^3.0.3" "@storybook/blocks": "npm:^8.0.5" - "@storybook/manager-api": "npm:^8.1.2" + "@storybook/manager-api": "npm:^8.1.3" + "@storybook/preview-api": "npm:^8.1.3" "@storybook/react": "npm:^8.0.5" "@storybook/react-webpack5": "npm:^8.0.5" "@storybook/test": "npm:^8.0.5" @@ -5846,6 +5847,7 @@ __metadata: "@types/jest": "npm:^29.5.12" "@types/lodash": "npm:^4.14.202" "@types/numeral": "npm:^2.0.5" + "@types/prettier": "npm:^3.0.0" "@types/react": "npm:^18.2.14" "@types/react-dom": "npm:^18.2.6" "@types/react-is": "npm:^17.0.3" @@ -8128,16 +8130,16 @@ __metadata: languageName: node linkType: hard -"@storybook/channels@npm:8.1.2": - version: 8.1.2 - resolution: "@storybook/channels@npm:8.1.2" +"@storybook/channels@npm:8.1.6": + version: 8.1.6 + resolution: "@storybook/channels@npm:8.1.6" dependencies: - "@storybook/client-logger": "npm:8.1.2" - "@storybook/core-events": "npm:8.1.2" + "@storybook/client-logger": "npm:8.1.6" + "@storybook/core-events": "npm:8.1.6" "@storybook/global": "npm:^5.0.0" telejson: "npm:^7.2.0" tiny-invariant: "npm:^1.3.1" - checksum: 10c0/19b8a373dc080b3c3514f6e1e3f6c47b94f5d1e8b603d92dd9c5d7528a4dda291ab68b26b026eae809ac53b25a88e183079e39edbb7fb08a920657f8639c7632 + checksum: 10c0/57304d9091b24104bb8cb0d8a87cc4c0096772cea5542da8f9cf58454fe83c480f06c33ff4489e2530b8f75f576c9d4237fffde106a72663859a9d58666a257d languageName: node linkType: hard @@ -8197,12 +8199,12 @@ __metadata: languageName: node linkType: hard -"@storybook/client-logger@npm:8.1.2": - version: 8.1.2 - resolution: "@storybook/client-logger@npm:8.1.2" +"@storybook/client-logger@npm:8.1.6": + version: 8.1.6 + resolution: "@storybook/client-logger@npm:8.1.6" dependencies: "@storybook/global": "npm:^5.0.0" - checksum: 10c0/f4cc40be7ad0dbc970e1339872b6be4a5925008eed1f6596206737905240b2271d4fd4abf9efe418704df6a63caaa0bf1527d26eba95a152d7f206c5e565bbdc + checksum: 10c0/09de69bb2526a7c717b7a522bc984dd4913372e2b9a75d321222af9675201eff32213aaf18b7ced1baf2e9c93b944a841207895f293842fa8aac313e18caa182 languageName: node linkType: hard @@ -8294,13 +8296,13 @@ __metadata: languageName: node linkType: hard -"@storybook/core-events@npm:8.1.2": - version: 8.1.2 - resolution: "@storybook/core-events@npm:8.1.2" +"@storybook/core-events@npm:8.1.6": + version: 8.1.6 + resolution: "@storybook/core-events@npm:8.1.6" dependencies: "@storybook/csf": "npm:^0.1.7" ts-dedent: "npm:^2.0.0" - checksum: 10c0/fb479fc9b7dcf625ed522b8d476739caff7fcfbcb92dd674c37469f2c840c9f1958df2fca62a2d74a9898a095450f4428697ec9ce28058c36c95ec98d6e587a4 + checksum: 10c0/3bf5d43040c66eb6af7048f87ae925605ed4914f9776eaeb2dae4713995ef6a0838c5bae72333d1f3457081fad438c3dbc72cb723f1c08896599c5290004f3b5 languageName: node linkType: hard @@ -8499,26 +8501,26 @@ __metadata: languageName: node linkType: hard -"@storybook/manager-api@npm:^8.1.2": - version: 8.1.2 - resolution: "@storybook/manager-api@npm:8.1.2" +"@storybook/manager-api@npm:^8.1.3": + version: 8.1.6 + resolution: "@storybook/manager-api@npm:8.1.6" dependencies: - "@storybook/channels": "npm:8.1.2" - "@storybook/client-logger": "npm:8.1.2" - "@storybook/core-events": "npm:8.1.2" + "@storybook/channels": "npm:8.1.6" + "@storybook/client-logger": "npm:8.1.6" + "@storybook/core-events": "npm:8.1.6" "@storybook/csf": "npm:^0.1.7" "@storybook/global": "npm:^5.0.0" "@storybook/icons": "npm:^1.2.5" - "@storybook/router": "npm:8.1.2" - "@storybook/theming": "npm:8.1.2" - "@storybook/types": "npm:8.1.2" + "@storybook/router": "npm:8.1.6" + "@storybook/theming": "npm:8.1.6" + "@storybook/types": "npm:8.1.6" dequal: "npm:^2.0.2" lodash: "npm:^4.17.21" memoizerific: "npm:^1.11.3" store2: "npm:^2.14.2" telejson: "npm:^7.2.0" ts-dedent: "npm:^2.0.0" - checksum: 10c0/15c7a62377ff6d7469b96fbb4a0010f0af25aaaa66b6f958664b803d48fd95f0eeb34dff88e12761525bde779cc22021aa8db55bab1d1856c54441aaefe159c9 + checksum: 10c0/4967126179d71cb3eae490e6bc7c05a616d561302d6d9970ea4af44c7a2e64f6fdf41d0b050073344a24dee07201ea9aef012b70dc623cc56777a8a2204af4ca languageName: node linkType: hard @@ -8587,6 +8589,28 @@ __metadata: languageName: node linkType: hard +"@storybook/preview-api@npm:^8.1.3": + version: 8.1.6 + resolution: "@storybook/preview-api@npm:8.1.6" + dependencies: + "@storybook/channels": "npm:8.1.6" + "@storybook/client-logger": "npm:8.1.6" + "@storybook/core-events": "npm:8.1.6" + "@storybook/csf": "npm:^0.1.7" + "@storybook/global": "npm:^5.0.0" + "@storybook/types": "npm:8.1.6" + "@types/qs": "npm:^6.9.5" + dequal: "npm:^2.0.2" + lodash: "npm:^4.17.21" + memoizerific: "npm:^1.11.3" + qs: "npm:^6.10.0" + tiny-invariant: "npm:^1.3.1" + ts-dedent: "npm:^2.0.0" + util-deprecate: "npm:^1.0.2" + checksum: 10c0/7402944ac2179c0abc4205796ebd20387ae850e1a2495223eb1fa3245d4bfd3743fdeaa1d8c7bbd1a4b84c0456a42aa5ae8f619f0966e882d25b231eb2b9af14 + languageName: node + linkType: hard + "@storybook/preview@npm:8.0.6": version: 8.0.6 resolution: "@storybook/preview@npm:8.0.6" @@ -8688,14 +8712,14 @@ __metadata: languageName: node linkType: hard -"@storybook/router@npm:8.1.2": - version: 8.1.2 - resolution: "@storybook/router@npm:8.1.2" +"@storybook/router@npm:8.1.6": + version: 8.1.6 + resolution: "@storybook/router@npm:8.1.6" dependencies: - "@storybook/client-logger": "npm:8.1.2" + "@storybook/client-logger": "npm:8.1.6" memoizerific: "npm:^1.11.3" qs: "npm:^6.10.0" - checksum: 10c0/dd830d106437ebc2bb209e5ec57a55181e798d56c1922fa7ca1de618d7e26fe2a4826709f78ab05440ac6a830a58eb727569aa68aa4b47a9cf15050eda8445cc + checksum: 10c0/426d14cc7905e7bf0f6e784cb02002205bd2b054c3df13d85d59551ceaf1d1409f071170a0283f3d395b58e8e690c06df95961e3a94fc15a7772f24e9f19829b languageName: node linkType: hard @@ -8754,12 +8778,12 @@ __metadata: languageName: node linkType: hard -"@storybook/theming@npm:8.1.2": - version: 8.1.2 - resolution: "@storybook/theming@npm:8.1.2" +"@storybook/theming@npm:8.1.6": + version: 8.1.6 + resolution: "@storybook/theming@npm:8.1.6" dependencies: "@emotion/use-insertion-effect-with-fallbacks": "npm:^1.0.1" - "@storybook/client-logger": "npm:8.1.2" + "@storybook/client-logger": "npm:8.1.6" "@storybook/global": "npm:^5.0.0" memoizerific: "npm:^1.11.3" peerDependencies: @@ -8770,7 +8794,7 @@ __metadata: optional: true react-dom: optional: true - checksum: 10c0/ff094cb6e61573567348f8f9d56f2ccf39be14ec483ba3ec013d05e5c82114f47bccaefc410db738121b1641580247603fd8249456c2c6eee7d32dc0bdb08b89 + checksum: 10c0/13bf3c940ce2ff27088d9147d22cd45f53de5251e8d41cc170d2f569cd6ba30aa1a4574494a15774c82f501889709accff370b9131a5d973243528160d7a0d50 languageName: node linkType: hard @@ -8785,14 +8809,14 @@ __metadata: languageName: node linkType: hard -"@storybook/types@npm:8.1.2": - version: 8.1.2 - resolution: "@storybook/types@npm:8.1.2" +"@storybook/types@npm:8.1.6": + version: 8.1.6 + resolution: "@storybook/types@npm:8.1.6" dependencies: - "@storybook/channels": "npm:8.1.2" + "@storybook/channels": "npm:8.1.6" "@types/express": "npm:^4.7.0" file-system-cache: "npm:2.3.0" - checksum: 10c0/13e7228596c77bbbe47ace99d2cf55973e71efce161e90af8e7b6332be8a99466a5e3017388db26fcf17064a4de9ef4d26219b14429b054ec304369fcbe78334 + checksum: 10c0/f6ccfe58e921cbe533b33d8bcfab2e0289f7018f9baa5052f0997f46ade3e0c5fc150819e1ced4bf6dc384de0964564c4dbd3383a380e9786906739574cd82b7 languageName: node linkType: hard @@ -9923,6 +9947,15 @@ __metadata: languageName: node linkType: hard +"@types/prettier@npm:^3.0.0": + version: 3.0.0 + resolution: "@types/prettier@npm:3.0.0" + dependencies: + prettier: "npm:*" + checksum: 10c0/edab8c0c0e56936e89c919cac17e384a9f231ce12062fb3beeb45bc45e7dcc4035dd3d7df3333b0bdd39f3b4501f22267a9dba45e22b9728c139857142e90282 + languageName: node + linkType: hard + "@types/pretty-hrtime@npm:^1.0.0": version: 1.0.1 resolution: "@types/pretty-hrtime@npm:1.0.1" @@ -30018,6 +30051,15 @@ __metadata: languageName: node linkType: hard +"prettier@npm:*": + version: 3.3.1 + resolution: "prettier@npm:3.3.1" + bin: + prettier: bin/prettier.cjs + checksum: 10c0/c25a709c9f0be670dc6bcb190b622347e1dbeb6c3e7df8b0711724cb64d8647c60b839937a4df4df18e9cfb556c2b08ca9d24d9645eb5488a7fc032a2c4d5cb3 + languageName: node + linkType: hard + "prettier@npm:^2.8.8": version: 2.8.8 resolution: "prettier@npm:2.8.8" From 72e8139e4d0c798b4ce5829d97ad88a42ebc7cab Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Fri, 7 Jun 2024 14:52:16 +0200 Subject: [PATCH 10/51] build: update prettier types dependency to correct version --- .../addons/code-snippet/decorators/utils.ts | 1 - packages/eui/package.json | 2 +- yarn.lock | 21 +++++-------------- 3 files changed, 6 insertions(+), 18 deletions(-) diff --git a/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts b/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts index ac092ac01bc..de2f1eb52d4 100644 --- a/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts +++ b/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts @@ -11,7 +11,6 @@ https://github.com/storybookjs/storybook/blob/2bff7a1c156bbd42ab381f84b8a55a0769 import { ReactElement, FunctionComponent, ComponentType } from 'react'; import * as prettier from 'prettier'; -// @ts-ignore - types are available but not resolved import tsParser from 'prettier/parser-typescript'; import { StoryContext } from '@storybook/react'; diff --git a/packages/eui/package.json b/packages/eui/package.json index 29e2aab918c..8e5f1a346d5 100644 --- a/packages/eui/package.json +++ b/packages/eui/package.json @@ -136,7 +136,7 @@ "@types/classnames": "^2.3.1", "@types/enzyme": "^3.10.5", "@types/jest": "^29.5.12", - "@types/prettier": "^3.0.0", + "@types/prettier": "2.7.3", "@types/react": "^18.2.14", "@types/react-dom": "^18.2.6", "@types/react-is": "^17.0.3", diff --git a/yarn.lock b/yarn.lock index 02f8ec2b33c..6e8bdd9b103 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5847,7 +5847,7 @@ __metadata: "@types/jest": "npm:^29.5.12" "@types/lodash": "npm:^4.14.202" "@types/numeral": "npm:^2.0.5" - "@types/prettier": "npm:^3.0.0" + "@types/prettier": "npm:2.7.3" "@types/react": "npm:^18.2.14" "@types/react-dom": "npm:^18.2.6" "@types/react-is": "npm:^17.0.3" @@ -9947,12 +9947,10 @@ __metadata: languageName: node linkType: hard -"@types/prettier@npm:^3.0.0": - version: 3.0.0 - resolution: "@types/prettier@npm:3.0.0" - dependencies: - prettier: "npm:*" - checksum: 10c0/edab8c0c0e56936e89c919cac17e384a9f231ce12062fb3beeb45bc45e7dcc4035dd3d7df3333b0bdd39f3b4501f22267a9dba45e22b9728c139857142e90282 +"@types/prettier@npm:2.7.3": + version: 2.7.3 + resolution: "@types/prettier@npm:2.7.3" + checksum: 10c0/0960b5c1115bb25e979009d0b44c42cf3d792accf24085e4bfce15aef5794ea042e04e70c2139a2c3387f781f18c89b5706f000ddb089e9a4a2ccb7536a2c5f0 languageName: node linkType: hard @@ -30051,15 +30049,6 @@ __metadata: languageName: node linkType: hard -"prettier@npm:*": - version: 3.3.1 - resolution: "prettier@npm:3.3.1" - bin: - prettier: bin/prettier.cjs - checksum: 10c0/c25a709c9f0be670dc6bcb190b622347e1dbeb6c3e7df8b0711724cb64d8647c60b839937a4df4df18e9cfb556c2b08ca9d24d9645eb5488a7fc032a2c4d5cb3 - languageName: node - linkType: hard - "prettier@npm:^2.8.8": version: 2.8.8 resolution: "prettier@npm:2.8.8" From 754d719abb772cfdefa70054e3a598d0a4333955 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Fri, 7 Jun 2024 14:53:07 +0200 Subject: [PATCH 11/51] refactor: update to exclude empty object and array values from props --- .../code-snippet/decorators/render_jsx.tsx | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) 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 93c962233c1..e66f045a1f0 100644 --- a/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx +++ b/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx @@ -13,7 +13,7 @@ import type { ReactElement, ReactNode } from 'react'; import React, { isValidElement } from 'react'; import type { Options } from 'react-element-to-jsx-string'; import reactElementToJSXString from 'react-element-to-jsx-string'; -import { camelCase } from 'lodash'; +import { camelCase, isEmpty } from 'lodash'; import type { ReactRenderer } from '@storybook/react'; import type { StoryContext } from '@storybook/types'; import { getDocgenSection } from '@storybook/docs-tools'; @@ -173,19 +173,22 @@ export const renderJsx = ( useFragmentShortSyntax: true, sortProps: true, filterProps: (value: any, key: string) => { - if (EXCLUDED_PROPS.includes(key) || value == null) { + if ( + EXCLUDED_PROPS.includes(key) || + value == null || + value === '' || + // empty objects/arrays that we set up for easier testing + (typeof value === 'object' && isEmpty(value)) + ) { return false; } - // manually filter `false` values as this ensures proper formatting of tags - // while `useBooleanShorthandSyntax={true}` leaves closing tags `>` on a new line - // filter out specific props that are needed to show with value `false` + // manually filter props with `false` values as this allows us to preserve + // `false` values where required e.g. grow={false} if (value === false && !PRESERVED_FALSE_VALUE_PROPS.includes(key)) { return false; } - if (value === '') return false; - return true; }, }; @@ -242,11 +245,13 @@ export const renderJsx = ( } } + // convert node to jsx string let string: string = toJSXString( _simplifyNodeForStringify(node, euiTheme), opts as Options ); + /** Start of filtering the generated jsx string */ if (string.indexOf('"') > -1) { const matches = string.match(/\S+=\\"([^"]*)\\"/g); if (matches) { From ce7a4baf152d44986054c474b56fc68666d8b0bd Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Fri, 7 Jun 2024 15:36:29 +0200 Subject: [PATCH 12/51] fix: ensure default tags with emotion css are output correctly --- packages/eui/.storybook/addons/code-snippet/decorators/utils.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts b/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts index de2f1eb52d4..e15b3e59942 100644 --- a/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts +++ b/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts @@ -64,6 +64,8 @@ export const getEmotionComponentDisplayName = ( // we need to rely here on the reference Emotion stores to know what component this actually is const replacementName = isForwardRefComponent ? node.props?.__EMOTION_TYPE_PLEASE_DO_NOT_USE__.__docgenInfo.displayName + : typeof node.props?.__EMOTION_TYPE_PLEASE_DO_NOT_USE__ === 'string' + ? node.props?.__EMOTION_TYPE_PLEASE_DO_NOT_USE__ : node.props?.__EMOTION_TYPE_PLEASE_DO_NOT_USE__?.displayName; // remove internal component underscore markings From 9af0693af5e138ef6baa8a2f4384c82919686ebc Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Fri, 7 Jun 2024 15:39:12 +0200 Subject: [PATCH 13/51] refactor: rename emotion object data key --- .../addons/code-snippet/decorators/utils.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts b/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts index e15b3e59942..6f41c174cc8 100644 --- a/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts +++ b/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts @@ -58,15 +58,15 @@ export const getEmotionComponentDisplayName = ( displayName.match(/^(Emotion)(\w)*/g)) || node.props?.__EMOTION_TYPE_PLEASE_DO_NOT_USE__ != null ) { - const isForwardRefComponent = isForwardRef( - node.props.__EMOTION_TYPE_PLEASE_DO_NOT_USE__ - ); + const { __EMOTION_TYPE_PLEASE_DO_NOT_USE__: emotionData } = node.props; + const isForwardRefComponent = isForwardRef(emotionData); + // we need to rely here on the reference Emotion stores to know what component this actually is const replacementName = isForwardRefComponent - ? node.props?.__EMOTION_TYPE_PLEASE_DO_NOT_USE__.__docgenInfo.displayName - : typeof node.props?.__EMOTION_TYPE_PLEASE_DO_NOT_USE__ === 'string' - ? node.props?.__EMOTION_TYPE_PLEASE_DO_NOT_USE__ - : node.props?.__EMOTION_TYPE_PLEASE_DO_NOT_USE__?.displayName; + ? emotionData.__docgenInfo.displayName + : typeof emotionData === 'string' + ? emotionData + : emotionData?.displayName; // remove internal component underscore markings return replacementName ? replacementName.replace('_', '') : displayName; From faa31d37fdc6c5f6eccc372320508c1925467f24 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Wed, 12 Jun 2024 11:01:09 +0200 Subject: [PATCH 14/51] docs(storybook): revert custom opening handling for EuiheaderAlert flyout story in favor of initially setting it to open --- .../header_alert/header_alert.stories.tsx | 36 ++++++------------- 1 file changed, 10 insertions(+), 26 deletions(-) diff --git a/packages/eui/src/components/header/header_alert/header_alert.stories.tsx b/packages/eui/src/components/header/header_alert/header_alert.stories.tsx index 0c41fa30632..2ffdfe3501f 100644 --- a/packages/eui/src/components/header/header_alert/header_alert.stories.tsx +++ b/packages/eui/src/components/header/header_alert/header_alert.stories.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import { EuiLink, @@ -61,27 +61,11 @@ export const Playground: Story = {}; /** * Flyout example */ -const Flyout = ( - props: EuiHeaderAlertProps & { __STORYBOOK_ONLY__isOpen: boolean } -) => { - const { __STORYBOOK_ONLY__isOpen, ...rest } = props ?? { - __STORYBOOK_ONLY__isOpen: true, - }; - const [isMounted, setMounted] = useState(false); - const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); +const Flyout = (props: EuiHeaderAlertProps) => { + const [isFlyoutVisible, setIsFlyoutVisible] = useState(true); const closeFlyout = () => setIsFlyoutVisible(false); - useEffect(() => { - if (!props || isMounted) return; - - if (props.__STORYBOOK_ONLY__isOpen) { - setMounted(true); - } - }, [props, isMounted]); - - const shouldShowCode = !isMounted && !isFlyoutVisible; - - const flyout = (shouldShowCode || (isMounted && isFlyoutVisible)) && ( + const flyout = isFlyoutVisible && ( @@ -89,11 +73,11 @@ const Flyout = ( - - - - - + + + + + @@ -138,7 +122,7 @@ export const FlyoutExample: Story = { resolveChildren: true, }, }, - render: (args) => , + render: (args) => , }; /** From 135ddce913f3154ad28be0ec1ffea124e7177e12 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Thu, 13 Jun 2024 17:02:29 +0200 Subject: [PATCH 15/51] feat(code-snippet-addon): show/hide addon conditionally based on skip parameter --- .../addons/code-snippet/components/panel.tsx | 53 ++++++++++++++++--- .../addons/code-snippet/constants.ts | 4 +- .../code-snippet/utils/addon_visibility.ts | 31 +++++++++++ 3 files changed, 80 insertions(+), 8 deletions(-) create mode 100644 packages/eui/.storybook/addons/code-snippet/utils/addon_visibility.ts diff --git a/packages/eui/.storybook/addons/code-snippet/components/panel.tsx b/packages/eui/.storybook/addons/code-snippet/components/panel.tsx index baaa1ea86b8..0c11b43fb0b 100644 --- a/packages/eui/.storybook/addons/code-snippet/components/panel.tsx +++ b/packages/eui/.storybook/addons/code-snippet/components/panel.tsx @@ -6,13 +6,24 @@ * Side Public License, v 1. */ -import React, { FunctionComponent } from 'react'; -import { useAddonState, useChannel } from '@storybook/manager-api'; +import React, { useEffect, FunctionComponent } from 'react'; +import { + useAddonState, + useChannel, + useStorybookApi, +} from '@storybook/manager-api'; import { AddonPanel, SyntaxHighlighter } from '@storybook/components'; import { styled } from '@storybook/theming'; import { STORY_RENDERED } from '@storybook/core-events'; -import { ADDON_ID, EVENTS } from '../constants'; +import { ADDON_ID, ADDON_PARAMETER_KEY, EVENTS } from '../constants'; +import { addHiddenStyle, clearHiddenStyle } from '../utils/addon_visibility'; + +const addonTabStyles = (selector: string) => ` + ${selector} { + display: none; + } + `; interface PanelProps { active?: boolean; @@ -22,18 +33,46 @@ export const Panel: FunctionComponent = ({ active, ...rest }) => { const [addonState, setAddonState] = useAddonState(ADDON_ID, { code: '', isLoaded: false, + isSkipped: true, }); - const { code, isLoaded } = addonState; + const { code, isLoaded, isSkipped } = addonState; + const storybookApi = useStorybookApi(); + + useEffect(() => { + const addonTabId = `#tabbutton-${ADDON_ID.split('/').join('-')}-panel`; + + /** + * we manually hide the addon tab element initially and show it only if it's not skipped. + * This uses style element injection over classes as we don't have access to the actual elements. + * We would need to wait for the elements to be rendered by Storybook to get them which is less + * consitent as controlling the styles. + * reference: https://storybook.js.org/docs/addons/writing-addons#style-the-addon + */ + if (isSkipped) { + addHiddenStyle(ADDON_ID, addonTabStyles(addonTabId)); + } else { + clearHiddenStyle(ADDON_ID); + } + }, [isSkipped]); - useChannel({ + const emit = useChannel({ [EVENTS.SNIPPET_RENDERED]: (args) => { setAddonState((prevState) => ({ ...prevState, code: args.source ?? '' })); }, - [STORY_RENDERED]: () => { - setAddonState((prevState) => ({ ...prevState, isLoaded: true })); + [STORY_RENDERED]: (id: string) => { + const parameters = storybookApi.getParameters(id); + const isStorySkipped = parameters?.[ADDON_PARAMETER_KEY]?.skip ?? false; + + setAddonState((prevState) => ({ + ...prevState, + isLoaded: true, + isSkipped: isStorySkipped, + })); }, }); + if (isSkipped) return null; + const emptyState = No code snippet available; const loadingState = Loading...; diff --git a/packages/eui/.storybook/addons/code-snippet/constants.ts b/packages/eui/.storybook/addons/code-snippet/constants.ts index cc5b6b162a4..8e9c359ce81 100644 --- a/packages/eui/.storybook/addons/code-snippet/constants.ts +++ b/packages/eui/.storybook/addons/code-snippet/constants.ts @@ -6,11 +6,13 @@ * Side Public License, v 1. */ -export const ADDON_ID = 'storybook/addon-code-snippet'; +export const ADDON_ID = 'storybook/code-snippet'; export const PANEL_ID = `${ADDON_ID}/panel`; export const EVENTS = { SNIPPET_RENDERED: `${ADDON_ID}/snippet-rendered`, }; +export const ADDON_PARAMETER_KEY = 'codeSnippet'; + export const STORY_ARGS_MARKER = '{{STORY_ARGS}}'; diff --git a/packages/eui/.storybook/addons/code-snippet/utils/addon_visibility.ts b/packages/eui/.storybook/addons/code-snippet/utils/addon_visibility.ts new file mode 100644 index 00000000000..fdbcd19e970 --- /dev/null +++ b/packages/eui/.storybook/addons/code-snippet/utils/addon_visibility.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const clearHiddenStyle = (id: string) => { + const styleElement = document.getElementById(id); + + if (styleElement && styleElement.parentElement) { + styleElement.parentElement.removeChild(styleElement); + } +}; + +export const addHiddenStyle = (id: string, css: string) => { + const existingStyle = document.getElementById(id); + + if (existingStyle) { + if (existingStyle.innerHTML !== css) { + existingStyle.innerHTML = css; + } + } else { + const style = global.document.createElement('style'); + + style.setAttribute('id', id); + style.innerHTML = css; + document.head.appendChild(style); + } +}; From 9e9d2e366d6d43c02dd891c145b3b0ff9dbd5c78 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Thu, 13 Jun 2024 17:05:12 +0200 Subject: [PATCH 16/51] feat(code-snippet-addon): enable opening the addon panel based on link query param --- .../addons/code-snippet/components/panel.tsx | 13 ++++ .../addons/code-snippet/constants.ts | 5 ++ .../event-handlers/query_params.ts | 66 +++++++++++++++++++ packages/eui/.storybook/manager.ts | 26 +++++++- 4 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 packages/eui/.storybook/addons/code-snippet/event-handlers/query_params.ts diff --git a/packages/eui/.storybook/addons/code-snippet/components/panel.tsx b/packages/eui/.storybook/addons/code-snippet/components/panel.tsx index 0c11b43fb0b..cdb2f9372dc 100644 --- a/packages/eui/.storybook/addons/code-snippet/components/panel.tsx +++ b/packages/eui/.storybook/addons/code-snippet/components/panel.tsx @@ -71,6 +71,19 @@ export const Panel: FunctionComponent = ({ active, ...rest }) => { }, }); + useEffect(() => { + if (isSkipped || !isLoaded || !active) return; + + // emit OPENED event + emit(EVENTS.SNIPPET_PANEL_OPENED); + + return () => { + // emit CLOSED event + emit(EVENTS.SNIPPET_PANEL_CLOSED); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isSkipped, isLoaded, active]); + if (isSkipped) return null; const emptyState = No code snippet available; diff --git a/packages/eui/.storybook/addons/code-snippet/constants.ts b/packages/eui/.storybook/addons/code-snippet/constants.ts index 8e9c359ce81..07e4ad30a66 100644 --- a/packages/eui/.storybook/addons/code-snippet/constants.ts +++ b/packages/eui/.storybook/addons/code-snippet/constants.ts @@ -11,8 +11,13 @@ export const PANEL_ID = `${ADDON_ID}/panel`; export const EVENTS = { SNIPPET_RENDERED: `${ADDON_ID}/snippet-rendered`, + SNIPPET_PANEL_OPENED: `${ADDON_ID}/snippet-panel-opened`, + SNIPPET_PANEL_CLOSED: `${ADDON_ID}/snippet-panel-closed`, }; export const ADDON_PARAMETER_KEY = 'codeSnippet'; +export const QUERY_PARAMS = { + SHOW_SNIPPET: 'showSnippet', +}; export const STORY_ARGS_MARKER = '{{STORY_ARGS}}'; diff --git a/packages/eui/.storybook/addons/code-snippet/event-handlers/query_params.ts b/packages/eui/.storybook/addons/code-snippet/event-handlers/query_params.ts new file mode 100644 index 00000000000..25b4aaa9a8c --- /dev/null +++ b/packages/eui/.storybook/addons/code-snippet/event-handlers/query_params.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { StoryContext } from '@storybook/react'; +import { API } from '@storybook/manager-api'; + +import { ADDON_PARAMETER_KEY, PANEL_ID, QUERY_PARAMS } from '../constants'; + +export const updateQueryParamsOnStoryPrepared = ( + api: API, + context: StoryContext +) => { + const selectedPanel = api.getSelectedPanel(); + const isCodeSnippetSkipped = + context.parameters[ADDON_PARAMETER_KEY]?.skip ?? false; + const showSnippetEnabled = + api.getQueryParam(QUERY_PARAMS.SHOW_SNIPPET) === 'true'; + + if (showSnippetEnabled && !isCodeSnippetSkipped) { + if (selectedPanel !== PANEL_ID) { + _updateSnippetQueryParam(api, 'true'); + api.setSelectedPanel(PANEL_ID); + } + } else { + const resetPanelId = + selectedPanel !== PANEL_ID ? selectedPanel : 'storybook/controls'; // fallback to intial addon panel + + _updateSnippetQueryParam(api, undefined); + api.setSelectedPanel(resetPanelId); + } +}; + +export const updateQueryParamsOnAddonOpened = (api: API) => { + const selectedPanel = api.getSelectedPanel(); + const showSnippetEnabled = + api.getQueryParam(QUERY_PARAMS.SHOW_SNIPPET) === 'true'; + + if (selectedPanel === PANEL_ID && !showSnippetEnabled) { + _updateSnippetQueryParam(api, 'true'); + } +}; + +export const updateQueryParamsOnAddonClosed = (api: API) => { + const showSnippetEnabled = + api.getQueryParam(QUERY_PARAMS.SHOW_SNIPPET) === 'true'; + + if (showSnippetEnabled) { + _updateSnippetQueryParam(api, undefined); + } +}; + +/* Helper function to handle updating code snippet storybook query param */ +const _updateSnippetQueryParam = (api: API, value: 'true' | undefined) => { + const params = { + [QUERY_PARAMS.SHOW_SNIPPET]: value, + }; + // set internal state + api.setQueryParams(params); + // apply state to url + api.applyQueryParams(params); +}; diff --git a/packages/eui/.storybook/manager.ts b/packages/eui/.storybook/manager.ts index cc8a5c9ebe1..b0440ceac0a 100644 --- a/packages/eui/.storybook/manager.ts +++ b/packages/eui/.storybook/manager.ts @@ -6,10 +6,17 @@ * Side Public License, v 1. */ -import { addons, types } from '@storybook/manager-api'; +import { addons, API, types } from '@storybook/manager-api'; +import { STORY_PREPARED } from '@storybook/core-events'; -import { ADDON_ID, PANEL_ID } from './addons/code-snippet/constants'; +import { ADDON_ID, EVENTS, PANEL_ID } from './addons/code-snippet/constants'; import { Panel } from './addons/code-snippet/components/panel'; +import { StoryContext } from '@storybook/react'; +import { + updateQueryParamsOnAddonClosed, + updateQueryParamsOnAddonOpened, + updateQueryParamsOnStoryPrepared, +} from './addons/code-snippet/event-handlers/query_params'; // filter out stories based on tags that should not // be shown in the Storybook sidebar menu @@ -26,7 +33,20 @@ addons.setConfig({ }); // Register a addon -addons.register(ADDON_ID, () => { +addons.register(ADDON_ID, (api: API) => { + // set up channel event listeners + api.on(STORY_PREPARED, (context: StoryContext) => { + updateQueryParamsOnStoryPrepared(api, context); + }); + + api.on(EVENTS.SNIPPET_PANEL_OPENED, () => { + updateQueryParamsOnAddonOpened(api); + }); + + api.on(EVENTS.SNIPPET_PANEL_CLOSED, () => { + updateQueryParamsOnAddonClosed(api); + }); + // Register a panel addons.add(PANEL_ID, { type: types.PANEL, From b46d24b3f01ee5ca7675610570d9697d43234d01 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Thu, 13 Jun 2024 17:23:55 +0200 Subject: [PATCH 17/51] refactor(code-snippet): move event setup to addon directory --- .../code-snippet/event-handlers/setup.ts | 33 +++++++++++++++++++ packages/eui/.storybook/manager.ts | 23 ++----------- 2 files changed, 36 insertions(+), 20 deletions(-) create mode 100644 packages/eui/.storybook/addons/code-snippet/event-handlers/setup.ts diff --git a/packages/eui/.storybook/addons/code-snippet/event-handlers/setup.ts b/packages/eui/.storybook/addons/code-snippet/event-handlers/setup.ts new file mode 100644 index 00000000000..e46b9aee915 --- /dev/null +++ b/packages/eui/.storybook/addons/code-snippet/event-handlers/setup.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { StoryContext } from '@storybook/react'; +import type { API } from '@storybook/manager-api'; +import { STORY_PREPARED } from '@storybook/core-events'; + +import { EVENTS } from '../constants'; +import { + updateQueryParamsOnAddonClosed, + updateQueryParamsOnAddonOpened, + updateQueryParamsOnStoryPrepared, +} from './query_params'; + +export const setupCodeSnippetEvents = (api: API) => { + // set up channel event listeners + api.on(STORY_PREPARED, (context: StoryContext) => { + updateQueryParamsOnStoryPrepared(api, context); + }); + + api.on(EVENTS.SNIPPET_PANEL_OPENED, () => { + updateQueryParamsOnAddonOpened(api); + }); + + api.on(EVENTS.SNIPPET_PANEL_CLOSED, () => { + updateQueryParamsOnAddonClosed(api); + }); +}; diff --git a/packages/eui/.storybook/manager.ts b/packages/eui/.storybook/manager.ts index b0440ceac0a..8409388157c 100644 --- a/packages/eui/.storybook/manager.ts +++ b/packages/eui/.storybook/manager.ts @@ -7,16 +7,10 @@ */ import { addons, API, types } from '@storybook/manager-api'; -import { STORY_PREPARED } from '@storybook/core-events'; -import { ADDON_ID, EVENTS, PANEL_ID } from './addons/code-snippet/constants'; +import { ADDON_ID, PANEL_ID } from './addons/code-snippet/constants'; import { Panel } from './addons/code-snippet/components/panel'; -import { StoryContext } from '@storybook/react'; -import { - updateQueryParamsOnAddonClosed, - updateQueryParamsOnAddonOpened, - updateQueryParamsOnStoryPrepared, -} from './addons/code-snippet/event-handlers/query_params'; +import { setupCodeSnippetEvents } from './addons/code-snippet/event-handlers/setup'; // filter out stories based on tags that should not // be shown in the Storybook sidebar menu @@ -34,18 +28,7 @@ addons.setConfig({ // Register a addon addons.register(ADDON_ID, (api: API) => { - // set up channel event listeners - api.on(STORY_PREPARED, (context: StoryContext) => { - updateQueryParamsOnStoryPrepared(api, context); - }); - - api.on(EVENTS.SNIPPET_PANEL_OPENED, () => { - updateQueryParamsOnAddonOpened(api); - }); - - api.on(EVENTS.SNIPPET_PANEL_CLOSED, () => { - updateQueryParamsOnAddonClosed(api); - }); + setupCodeSnippetEvents(api); // Register a panel addons.add(PANEL_ID, { From 35de340ea02c54ac905c4d6e82f0fe1b2bbc2c23 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Thu, 13 Jun 2024 21:02:47 +0200 Subject: [PATCH 18/51] refactor(code-snippet): remove obsolete double quote transform since prettier handles it --- .../addons/code-snippet/decorators/render_jsx.tsx | 9 --------- 1 file changed, 9 deletions(-) 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 e66f045a1f0..296aff940b8 100644 --- a/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx +++ b/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx @@ -252,15 +252,6 @@ export const renderJsx = ( ); /** Start of filtering the generated jsx string */ - if (string.indexOf('"') > -1) { - const matches = string.match(/\S+=\\"([^"]*)\\"/g); - if (matches) { - matches.forEach((match) => { - string = string.replace(match, match.replace(/"/g, "'")); - }); - } - } - // renaming internal components if (string.indexOf('<_') > -1) { const regexStart = new RegExp(/<_/g); From 416c4e17fc2d839569652f928f85830d68b7aa7c Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Thu, 13 Jun 2024 21:03:04 +0200 Subject: [PATCH 19/51] docs: add addon README --- .../.storybook/addons/code-snippet/README.md | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 packages/eui/.storybook/addons/code-snippet/README.md diff --git a/packages/eui/.storybook/addons/code-snippet/README.md b/packages/eui/.storybook/addons/code-snippet/README.md new file mode 100644 index 00000000000..0e4f7b5a4cc --- /dev/null +++ b/packages/eui/.storybook/addons/code-snippet/README.md @@ -0,0 +1,154 @@ +# Storybook code-snippet addon + +## Description + +> This is an internal EUI Storybook addon which adds code snippets to EUI stories. + +The purpose of this addon is to improve the developer experience by providing code snippets with dynamically updated props based on the story controls. + +This addon is provided as additional story panel next to the available panels for "Controls", "Actions" and "Interactions". + +The basis for the code snippet generation is based on Storybooks [`Source`](https://storybook.js.org/docs/writing-docs/doc-blocks#source) block. The internally used [`jsxDecorator`](https://github.com/storybookjs/storybook/blob/2bff7a1c156bbd42ab381f84b8a55a07694e7e53/code/renderers/react/src/docs/jsxDecorator.tsx) file was copied and then adjusted and extended to fit the specific needs for EUI. The main functionality to generate a jsx string from react elements comes from the [`react-element-to-jsx-string`](https://github.com/algolia/react-element-to-jsx-strin) package. + +## Concept + +The `code-snippet` addon follows the [official guides](https://storybook.js.org/docs/addons/writing-addons) to create a Storybook addon. The only real difference is that this addon is not released separately but simply added and used internally. + +The addon is defined and registered in `manager.ts` this ensures it's available in Storybook. Storybook handles most of the rendered output (e.g. tab list and tab buttons), the only custom content is what is passed via the `render` key on the addon config. This content will be output as child of the addon panel that Storybook renders. + +```ts +// Register a addon +addons.register(ADDON_ID, (api: API) => { + // Register a panel + addons.add(PANEL_ID, { + type: types.PANEL, + title: 'Code Snippet', + match: ({ viewMode }) => viewMode === 'story', + render: Panel, + }); +}); +``` + +The main code snippet generation functionality is done in `jsx_decorator.tsx`. It's used as a decorator for every story in `preview.tsx`. + + +```ts +import { customJsxDecorator } from './addons/code-snippet/decorators/jsx_decorator'; + +const preview: Preview = { + decorators: [ + customJsxDecorator, + ] +} +``` + +This decorator generates the code snippet as a `string` and sends it via Storybooks [Channel events](https://storybook.js.org/docs/addons/addons-api#usechannel) to the custom addon panel which outputs the code string to the panel which updates its state on receiving the event ([code](https://github.com/elastic/eui/pull/7716/files#diff-04d46d73aec032a8aa1b757e4f9bbc800bcf7545d33852276919da5134001e09R58)). + +```ts +channel.emit(EVENTS.SNIPPET_RENDERED, { + id, + source: jsx, + args: unmappedArgs, +}); +``` + +![Dimensions](https://github.com/elastic/eui/assets/44670957/9bb087f5-82bd-4b55-8264-5decc0a36cff) + +## Differences to the Storybook `jsxDecorator` + +The main changes/additions to the original `jsxDecorator` from Storybook are to ensure the generator outputs clean and EUI relevant code snippets. + +Additional features added: +- renames Emotion wrappers to the actual component name (whenever we use `css` on a component in a story it will be an Emotion-wrapped component) +- renames stateful wrappers that start with the wording Stateful (requires us to follow an agreed naming convention) +- removes obsolete fragment wrappers (but keeps required ones) +- removes story specific wrappers (e.g. layout or styling) +- keep related wrappers (e.g. parent & subcomponent or related by name) +- resolves any other unexpected wrapper we might add to structure complex stories +- renames internal component names that start with _underscore (e.g. `<_Component>` is changed to ``) +- ensures `css` attribute is output properly and not as resolved Emotion object +- ensures boolean props are output in a meaningful way (generally as shorthand but it keeps specifically defined `false` values where `false` has a meaning) +- ensures project specific formatting via `prettier` +- supports adding manual code snippets + + +## How it works + +The generation happens in different stages: + +1. `pre-conversion`: determine what react element should be passed to react-element-to-jsx-string and with which options +2. `conversion`: pass react elements to react-element-to-jsx-string +3. `post-conversion`: do additional replacements on the returned string +4. `formatting`: format the result using prettier + +### 1. Pre-conversion + +Before passing a React element to the `react-element-to-jsx-string` package functionality, we first determine: + +1. Should a story be skipped? ([code](https://github.com/elastic/eui/pull/7716/files#diff-c4b2d2b565adebd3d1fc19c04a10a1cbe645c261f0fa08bd3049d0b9f7b36883R196)) + - a story may be skipped: + - by using `parameters.codeSnippet.skip` ([example](https://github.com/elastic/eui/pull/7716/files#diff-ef6647f8b84f33adf19bf9fc7ef62367364bfbbf7c8529242f149ec7d4ae0040R31)) + - by returning an anonymous function from story `render` +2. Is a manual code snippet provided? ([code](https://github.com/elastic/eui/pull/7716/files#diff-8b1bc9195faa159bf3e141e0d6e3e63712a69fe4c0846f1ab472e0522e4e28f1R100)) ([example](https://github.com/elastic/eui/pull/7716/files#diff-712463f9e973b829726cd0e2ee0d9f517ad547359a649f3848921b7d066f27bbR24)) + +3. What React element should be used? (only a single React element can be passed to `react-element-to-jsx-string`) ([code](https://github.com/elastic/eui/pull/7716/files#diff-8b1bc9195faa159bf3e141e0d6e3e63712a69fe4c0846f1ab472e0522e4e28f1R146)) + + 1. Check if the outer element should be resolved due to manual flagging via `parameters.codeSnippet.resolveChildren`. The children would be used instead ([code](https://github.com/elastic/eui/pull/7716/files#diff-9cbc1be254e2f2e2860603307348b84da7ea487579b579b77ca4f45dd96f4dedR214)). + 2. We check the story react element for some base conditions ([code](https://github.com/elastic/eui/pull/7716/files#diff-9cbc1be254e2f2e2860603307348b84da7ea487579b579b77ca4f45dd96f4dedR226)) for which we return the current element. Otherwise we move to the elements `children`: + - Is the element the story component? + - Is the element the stories parent? (We usually want to show Parent & subcomponents together) + - Is the element a subcomponent? + - Is the element a stateful wrapper? (To add interactivity we usually wrap stories in stateful wrappers that are not relevant for the snippet) + - Is the element a React.Fragment? (where obsolete we would want to remove wrapping fragments) + 3. If the element is an array we resolve for the children ([code](https://github.com/elastic/eui/pull/7716/files#diff-9cbc1be254e2f2e2860603307348b84da7ea487579b579b77ca4f45dd96f4dedR235)). + +4. Once a single React element is determine the node and all its props (+ children) are recursively checked and resolved to ensure expected output: + + - skip any obsolete React.Fragments (returning children instead) ([code](https://github.com/elastic/eui/pull/7716/files#diff-9cbc1be254e2f2e2860603307348b84da7ea487579b579b77ca4f45dd96f4dedR339)) + - ensure Emotion `css` is resolved and reversed as Emotion transforms the input syntax to an Emotion style object. (e.g. resolve `css={({ euiTheme }) => ({})}`) ([code](https://github.com/elastic/eui/pull/7716/files#diff-9cbc1be254e2f2e2860603307348b84da7ea487579b579b77ca4f45dd96f4dedR366)) + - ensure euiTheme tokens are output as variables (e.g. `someProp=euiTheme.colors.lightShade`) - This step adds the variable in special markes that are removed later. This is to prevent `react-element-to-jsx-string` from assuming a type and formatting unexpectedly ([code](https://github.com/elastic/eui/pull/7716/files#diff-9cbc1be254e2f2e2860603307348b84da7ea487579b579b77ca4f45dd96f4dedR410)) + - ensure `style` attribute is applied ([code](https://github.com/elastic/eui/pull/7716/files#diff-9cbc1be254e2f2e2860603307348b84da7ea487579b579b77ca4f45dd96f4dedR477)) + - resolve arrays (this outputs e.g. `someProp={[, ]}` instead of `[]`) ([code](https://github.com/elastic/eui/pull/7716/files#diff-9cbc1be254e2f2e2860603307348b84da7ea487579b579b77ca4f45dd96f4dedR504)) + - resolve objects (e.g. ensures output like `{ text: 'foobar' color: 'green' }`) ([code](https://github.com/elastic/eui/pull/7716/files#diff-9cbc1be254e2f2e2860603307348b84da7ea487579b579b77ca4f45dd96f4dedR513)) + - resolve class instances used as values to functions ([code](https://github.com/elastic/eui/pull/7716/files#diff-9cbc1be254e2f2e2860603307348b84da7ea487579b579b77ca4f45dd96f4dedR527)) + - [_todo_] resolve render functions + +### 2. Conversion from React element to string + +Once the React element is properly checked and resolved according to expected output needs, it can be passed to the functionality from `react-element-to-jsx-string` which will generate a jsx string based on the React element. ([code](https://github.com/elastic/eui/pull/7716/files#diff-9cbc1be254e2f2e2860603307348b84da7ea487579b579b77ca4f45dd96f4dedR249)) + +```tsx +// example output + + Flex item + +``` + +### 3. Post-conversion cleanup + +The returned string of the conversion is then cleaned to ensure: + +- rename internal Components (e.g. `<_Component>` to ``) ([code](https://github.com/elastic/eui/pull/7716/files#diff-9cbc1be254e2f2e2860603307348b84da7ea487579b579b77ca4f45dd96f4dedR265)) +- rename necessary React.Fragment to shorthand (e.g. `` to `<>`) [code](https://github.com/elastic/eui/pull/7716/files#diff-9cbc1be254e2f2e2860603307348b84da7ea487579b579b77ca4f45dd96f4dedR286) +- ensure boolean value shorthand by manually filtering out values of `true` ([code](https://github.com/elastic/eui/pull/7716/files#diff-9cbc1be254e2f2e2860603307348b84da7ea487579b579b77ca4f45dd96f4dedR299)) + - this is manually handled and not by `react-element-to-jsx-string` because we want to keep some occurrences of `false` values when they have meaning (e.g. `) +- replace variable markers that were added in "1: Pre-conversion" ([code](https://github.com/elastic/eui/pull/7716/files#diff-9cbc1be254e2f2e2860603307348b84da7ea487579b579b77ca4f45dd96f4dedR309)) +- remove obsolete function naming ([code](https://github.com/elastic/eui/pull/7716/files#diff-9cbc1be254e2f2e2860603307348b84da7ea487579b579b77ca4f45dd96f4dedR323)) + + +### 4. Final Formatting + +To ensure the formatting is correct after adjusting the string returned from `react-element-to-jsx-string` and to align it with the EUI projects formatting rules, we run `prettier` on the string as a final step. ([code](https://github.com/elastic/eui/pull/7716/files#diff-8b1bc9195faa159bf3e141e0d6e3e63712a69fe4c0846f1ab472e0522e4e28f1R148)) + + +## Limitations + +1. Currently it's not yet supported to resolve `"render functions"` (either used as children or as any prop value). Components that make use of render functions (specifically for children) are currently (manually) skipped via `parameters.codeSnippet.skip: true` until support is added. + +2. Currently the addon uses Storybooks `SyntaxHighlighter` component to output the code snippets. This works generally well but seems to have trouble properly detecting and styling code parts for large snippets. This results in some partially uncolored snippets. Using EUI components does currently not work just out of the box as there seem to be issues with applying Emotion correctly. \ No newline at end of file From ebfbf420ea385f623f1a8c51d77855378ef0bc37 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Thu, 13 Jun 2024 21:09:05 +0200 Subject: [PATCH 20/51] docs: update addon readme --- .../.storybook/addons/code-snippet/README.md | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/eui/.storybook/addons/code-snippet/README.md b/packages/eui/.storybook/addons/code-snippet/README.md index 0e4f7b5a4cc..680bef73a20 100644 --- a/packages/eui/.storybook/addons/code-snippet/README.md +++ b/packages/eui/.storybook/addons/code-snippet/README.md @@ -146,6 +146,32 @@ The returned string of the conversion is then cleaned to ensure: To ensure the formatting is correct after adjusting the string returned from `react-element-to-jsx-string` and to align it with the EUI projects formatting rules, we run `prettier` on the string as a final step. ([code](https://github.com/elastic/eui/pull/7716/files#diff-8b1bc9195faa159bf3e141e0d6e3e63712a69fe4c0846f1ab472e0522e4e28f1R148)) +## Options + +Currently there are two addon specific parameter options added with this PR that can be used under the key `codeSnippet` in the parameters config key. + +```ts +// meta or story config +const meta = { + title: 'Navigation/EuiButton', + component: EuiButton, + parameters: { + codeSnippet: { + // will skip code snippet generation for the component or story + skip: true, + // useful for complex story composition wrappers (using the story component as nested child) + // it will resolve the outer wrapper and return the code snippet for it's children + // see the story for `EuiHeader/Multiple Fixed Headers` as example + resolveChildren: true, + } + } +} +``` + +## Additional functionality + +🚧 Will follow soon 🚧 + ## Limitations From a5d890bdd63359762e2646242690009d15f98e74 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Mon, 17 Jun 2024 10:45:00 +0200 Subject: [PATCH 21/51] chore(code-snippet): cleanup --- .../eui/.storybook/addons/code-snippet/components/panel.tsx | 2 +- .../eui/.storybook/addons/code-snippet/decorators/utils.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/eui/.storybook/addons/code-snippet/components/panel.tsx b/packages/eui/.storybook/addons/code-snippet/components/panel.tsx index cdb2f9372dc..21283970dbd 100644 --- a/packages/eui/.storybook/addons/code-snippet/components/panel.tsx +++ b/packages/eui/.storybook/addons/code-snippet/components/panel.tsx @@ -45,7 +45,7 @@ export const Panel: FunctionComponent = ({ active, ...rest }) => { * we manually hide the addon tab element initially and show it only if it's not skipped. * This uses style element injection over classes as we don't have access to the actual elements. * We would need to wait for the elements to be rendered by Storybook to get them which is less - * consitent as controlling the styles. + * consistent as controlling the styles. * reference: https://storybook.js.org/docs/addons/writing-addons#style-the-addon */ if (isSkipped) { diff --git a/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts b/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts index 6f41c174cc8..b0886cf4ca9 100644 --- a/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts +++ b/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts @@ -54,8 +54,7 @@ export const getEmotionComponentDisplayName = ( const displayName = getElementDisplayName(node); if ( - (typeof displayName === 'string' && - displayName.match(/^(Emotion)(\w)*/g)) || + (typeof displayName === 'string' && displayName.startsWith('Emotion')) || node.props?.__EMOTION_TYPE_PLEASE_DO_NOT_USE__ != null ) { const { __EMOTION_TYPE_PLEASE_DO_NOT_USE__: emotionData } = node.props; @@ -107,6 +106,7 @@ export const isStoryComponent = ( return displayName === context?.component?.displayName; }; + /** * checks if the outer most component is a parent of the actual story component */ From d9eb54293506c83fcf151fc93655d741ff063916 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Mon, 17 Jun 2024 11:12:37 +0200 Subject: [PATCH 22/51] chore(code-snippet): remove unused skip code -add additional comments --- .../code-snippet/decorators/jsx_decorator.tsx | 2 + .../code-snippet/decorators/render_jsx.tsx | 46 ++----------------- 2 files changed, 5 insertions(+), 43 deletions(-) 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 fbf34a872e1..907e3154eff 100644 --- a/packages/eui/.storybook/addons/code-snippet/decorators/jsx_decorator.tsx +++ b/packages/eui/.storybook/addons/code-snippet/decorators/jsx_decorator.tsx @@ -107,6 +107,8 @@ export const customJsxDecorator = ( } } + // add the story args/props to the manual code snippet + // by replacing the {{STORY_ARGS}} marker const code = codeSnippet.replace(STORY_ARGS_MARKER, JSON.stringify(args)); getFormattedCode(code) 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 296aff940b8..8acfacae5c3 100644 --- a/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx +++ b/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx @@ -40,8 +40,6 @@ const EXCLUDED_PROPS = ['__EMOTION_TYPE_PLEASE_DO_NOT_USE__', 'key']; const PRESERVED_FALSE_VALUE_PROPS = ['grow']; export type JSXOptions = Options & { - /** How many wrappers to skip when rendering the jsx */ - skip?: number; /** Whether to show the function in the jsx tab */ showFunctions?: boolean; /** Whether to format HTML or Vue markup */ @@ -64,54 +62,16 @@ export const renderJsx = ( return null; } - let renderedJSX = code; - const Type = renderedJSX.type; - - // @ts-expect-error (Converted from ts-ignore) - for (let i = 0; i < options?.skip; i += 1) { - if (typeof renderedJSX === 'undefined') { - logger.warn('Cannot skip undefined element'); - return null; - } - - if (React.Children.count(renderedJSX) > 1) { - logger.warn('Trying to skip an array of elements'); - return null; - } - - if (typeof renderedJSX.props.children === 'undefined') { - logger.warn('Not enough children to skip elements.'); - - if ( - typeof renderedJSX.type === 'function' && - renderedJSX.type.name === '' - ) { - renderedJSX = ; - } - } else if (typeof renderedJSX.props.children === 'function') { - renderedJSX = renderedJSX.props.children(); - } else { - renderedJSX = renderedJSX.props.children; - } - } - let displayNameDefaults; - // component case based resolving of its displayName + // NOTE: This code is from the original Storybook jsxDecorator + // and was enhanced to add additional checks for EUI Emotion + // component usages to ensure resolving the proper displayName if (typeof options?.displayName === 'string') { displayNameDefaults = { showFunctions: true, displayName: () => options.displayName, }; - /** - * add `renderedJSX?.type`to handle this case: - * - * https://github.com/zhyd1997/storybook/blob/20863a75ba4026d7eba6b288991a2cf091d4dfff/code/renderers/react/template/stories/errors.stories.tsx#L14 - * - * or it show the error message when run `yarn build-storybook --quiet`: - * - * Cannot read properties of undefined (reading '__docgenInfo'). - */ } else { displayNameDefaults = { // To get exotic component names resolving properly From e24fb1dfa8595b33d27ef9d697202f86ed682caf Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Mon, 17 Jun 2024 11:13:00 +0200 Subject: [PATCH 23/51] docs: add documentation on manual code snippet --- .../eui/.storybook/addons/code-snippet/README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/eui/.storybook/addons/code-snippet/README.md b/packages/eui/.storybook/addons/code-snippet/README.md index 680bef73a20..2efe7905c10 100644 --- a/packages/eui/.storybook/addons/code-snippet/README.md +++ b/packages/eui/.storybook/addons/code-snippet/README.md @@ -170,6 +170,22 @@ const meta = { ## Additional functionality +### Manual code snippets + +Instead of using the automatic code snippet generation, we can also provide a manual snippet which will be output instead. This is especially useful when the story content is not actually a component (e.g. a hook). You can see an example of this for the story of `useEuiTextDiff`. + +To add the story args tot he code snippet, add the defined marker `{{STORY_ARGS}}` to the snippet string. This marker will be replaced automatically with the current story args. + +```ts +parameters: { + codeSnippet: { + snippet: ` + const [rendered, textDiffObject] = useTextDiff(${STORY_ARGS_MARKER}) + `, + }, +} +``` + 🚧 Will follow soon 🚧 From 6b58f3276c30205025b1ad58e70efd4cd0a11b00 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Mon, 17 Jun 2024 11:24:40 +0200 Subject: [PATCH 24/51] docs(code-snippet): update links --- .../.storybook/addons/code-snippet/README.md | 53 ++++++++++--------- 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/packages/eui/.storybook/addons/code-snippet/README.md b/packages/eui/.storybook/addons/code-snippet/README.md index 2efe7905c10..8e334657937 100644 --- a/packages/eui/.storybook/addons/code-snippet/README.md +++ b/packages/eui/.storybook/addons/code-snippet/README.md @@ -42,7 +42,7 @@ const preview: Preview = { } ``` -This decorator generates the code snippet as a `string` and sends it via Storybooks [Channel events](https://storybook.js.org/docs/addons/addons-api#usechannel) to the custom addon panel which outputs the code string to the panel which updates its state on receiving the event ([code](https://github.com/elastic/eui/pull/7716/files#diff-04d46d73aec032a8aa1b757e4f9bbc800bcf7545d33852276919da5134001e09R58)). +This decorator generates the code snippet as a `string` and sends it via Storybooks [Channel events](https://storybook.js.org/docs/addons/addons-api#usechannel) to the custom addon panel which outputs the code string to the panel which updates its state on receiving the event ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/components/panel.tsx#L58)). ```ts channel.emit(EVENTS.SNIPPET_RENDERED, { @@ -85,37 +85,37 @@ The generation happens in different stages: Before passing a React element to the `react-element-to-jsx-string` package functionality, we first determine: -1. Should a story be skipped? ([code](https://github.com/elastic/eui/pull/7716/files#diff-c4b2d2b565adebd3d1fc19c04a10a1cbe645c261f0fa08bd3049d0b9f7b36883R196)) +1. Should a story be skipped? ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts#L196)) - a story may be skipped: - - by using `parameters.codeSnippet.skip` ([example](https://github.com/elastic/eui/pull/7716/files#diff-ef6647f8b84f33adf19bf9fc7ef62367364bfbbf7c8529242f149ec7d4ae0040R31)) + - by using `parameters.codeSnippet.skip` ([example](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/src/components/drag_and_drop/drag_drop_context.stories.tsx#L31)) - by returning an anonymous function from story `render` -2. Is a manual code snippet provided? ([code](https://github.com/elastic/eui/pull/7716/files#diff-8b1bc9195faa159bf3e141e0d6e3e63712a69fe4c0846f1ab472e0522e4e28f1R100)) ([example](https://github.com/elastic/eui/pull/7716/files#diff-712463f9e973b829726cd0e2ee0d9f517ad547359a649f3848921b7d066f27bbR24)) +2. Is a manual code snippet provided? ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/jsx_decorator.tsx#L100)) ([example](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/src/components/text_diff/text_diff.stories.tsx#L24)) -3. What React element should be used? (only a single React element can be passed to `react-element-to-jsx-string`) ([code](https://github.com/elastic/eui/pull/7716/files#diff-8b1bc9195faa159bf3e141e0d6e3e63712a69fe4c0846f1ab472e0522e4e28f1R146)) +3. What React element should be used? (only a single React element can be passed to `react-element-to-jsx-string`) ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/jsx_decorator.tsx#L146)) - 1. Check if the outer element should be resolved due to manual flagging via `parameters.codeSnippet.resolveChildren`. The children would be used instead ([code](https://github.com/elastic/eui/pull/7716/files#diff-9cbc1be254e2f2e2860603307348b84da7ea487579b579b77ca4f45dd96f4dedR214)). - 2. We check the story react element for some base conditions ([code](https://github.com/elastic/eui/pull/7716/files#diff-9cbc1be254e2f2e2860603307348b84da7ea487579b579b77ca4f45dd96f4dedR226)) for which we return the current element. Otherwise we move to the elements `children`: + 1. Check if the outer element should be resolved due to manual flagging via `parameters.codeSnippet.resolveChildren`. The children would be used instead. ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx#L214)). + 2. We check the story react element for some base conditions ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx#L226)) for which we return the current element. Otherwise we move to the elements `children`: - Is the element the story component? - Is the element the stories parent? (We usually want to show Parent & subcomponents together) - Is the element a subcomponent? - Is the element a stateful wrapper? (To add interactivity we usually wrap stories in stateful wrappers that are not relevant for the snippet) - Is the element a React.Fragment? (where obsolete we would want to remove wrapping fragments) - 3. If the element is an array we resolve for the children ([code](https://github.com/elastic/eui/pull/7716/files#diff-9cbc1be254e2f2e2860603307348b84da7ea487579b579b77ca4f45dd96f4dedR235)). + 3. If the element is an array we resolve for the children ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx#L235)). 4. Once a single React element is determine the node and all its props (+ children) are recursively checked and resolved to ensure expected output: - - skip any obsolete React.Fragments (returning children instead) ([code](https://github.com/elastic/eui/pull/7716/files#diff-9cbc1be254e2f2e2860603307348b84da7ea487579b579b77ca4f45dd96f4dedR339)) - - ensure Emotion `css` is resolved and reversed as Emotion transforms the input syntax to an Emotion style object. (e.g. resolve `css={({ euiTheme }) => ({})}`) ([code](https://github.com/elastic/eui/pull/7716/files#diff-9cbc1be254e2f2e2860603307348b84da7ea487579b579b77ca4f45dd96f4dedR366)) - - ensure euiTheme tokens are output as variables (e.g. `someProp=euiTheme.colors.lightShade`) - This step adds the variable in special markes that are removed later. This is to prevent `react-element-to-jsx-string` from assuming a type and formatting unexpectedly ([code](https://github.com/elastic/eui/pull/7716/files#diff-9cbc1be254e2f2e2860603307348b84da7ea487579b579b77ca4f45dd96f4dedR410)) - - ensure `style` attribute is applied ([code](https://github.com/elastic/eui/pull/7716/files#diff-9cbc1be254e2f2e2860603307348b84da7ea487579b579b77ca4f45dd96f4dedR477)) - - resolve arrays (this outputs e.g. `someProp={[, ]}` instead of `[]`) ([code](https://github.com/elastic/eui/pull/7716/files#diff-9cbc1be254e2f2e2860603307348b84da7ea487579b579b77ca4f45dd96f4dedR504)) - - resolve objects (e.g. ensures output like `{ text: 'foobar' color: 'green' }`) ([code](https://github.com/elastic/eui/pull/7716/files#diff-9cbc1be254e2f2e2860603307348b84da7ea487579b579b77ca4f45dd96f4dedR513)) - - resolve class instances used as values to functions ([code](https://github.com/elastic/eui/pull/7716/files#diff-9cbc1be254e2f2e2860603307348b84da7ea487579b579b77ca4f45dd96f4dedR527)) + - skip any obsolete React.Fragments (returning children instead) ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx#L330)) + - ensure Emotion `css` is resolved and reversed as Emotion transforms the input syntax to an Emotion style object. (e.g. resolve `css={({ euiTheme }) => ({})}`) ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx#L357)) + - ensure euiTheme tokens are output as variables (e.g. `someProp=euiTheme.colors.lightShade`) - This step adds the variable in special markes that are removed later. This is to prevent `react-element-to-jsx-string` from assuming a type and formatting unexpectedly ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx#L400)) + - ensure `style` attribute is applied ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx#L468)) + - resolve arrays (this outputs e.g. `someProp={[, ]}` instead of `[]`) ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx#L495)) + - resolve objects (e.g. ensures output like `{ text: 'foobar' color: 'green' }`) ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx#L504)) + - resolve class instances used as values to functions ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx#L518)) - [_todo_] resolve render functions ### 2. Conversion from React element to string -Once the React element is properly checked and resolved according to expected output needs, it can be passed to the functionality from `react-element-to-jsx-string` which will generate a jsx string based on the React element. ([code](https://github.com/elastic/eui/pull/7716/files#diff-9cbc1be254e2f2e2860603307348b84da7ea487579b579b77ca4f45dd96f4dedR249)) +Once the React element is properly checked and resolved according to expected output needs, it can be passed to the functionality from `react-element-to-jsx-string` which will generate a jsx string based on the React element. ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx#L249)) ```tsx // example output @@ -134,17 +134,17 @@ Once the React element is properly checked and resolved according to expected ou The returned string of the conversion is then cleaned to ensure: -- rename internal Components (e.g. `<_Component>` to ``) ([code](https://github.com/elastic/eui/pull/7716/files#diff-9cbc1be254e2f2e2860603307348b84da7ea487579b579b77ca4f45dd96f4dedR265)) -- rename necessary React.Fragment to shorthand (e.g. `` to `<>`) [code](https://github.com/elastic/eui/pull/7716/files#diff-9cbc1be254e2f2e2860603307348b84da7ea487579b579b77ca4f45dd96f4dedR286) -- ensure boolean value shorthand by manually filtering out values of `true` ([code](https://github.com/elastic/eui/pull/7716/files#diff-9cbc1be254e2f2e2860603307348b84da7ea487579b579b77ca4f45dd96f4dedR299)) +- rename internal Components (e.g. `<_Component>` to ``) ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx#L256)) +- rename necessary React.Fragment to shorthand (e.g. `` to `<>`) [code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx#L277) +- ensure boolean value shorthand by manually filtering out values of `true` ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx#L290)) - this is manually handled and not by `react-element-to-jsx-string` because we want to keep some occurrences of `false` values when they have meaning (e.g. `) -- replace variable markers that were added in "1: Pre-conversion" ([code](https://github.com/elastic/eui/pull/7716/files#diff-9cbc1be254e2f2e2860603307348b84da7ea487579b579b77ca4f45dd96f4dedR309)) -- remove obsolete function naming ([code](https://github.com/elastic/eui/pull/7716/files#diff-9cbc1be254e2f2e2860603307348b84da7ea487579b579b77ca4f45dd96f4dedR323)) +- replace variable markers that were added in "1: Pre-conversion" ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx#L301)) +- remove obsolete function naming ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx#L314)) ### 4. Final Formatting -To ensure the formatting is correct after adjusting the string returned from `react-element-to-jsx-string` and to align it with the EUI projects formatting rules, we run `prettier` on the string as a final step. ([code](https://github.com/elastic/eui/pull/7716/files#diff-8b1bc9195faa159bf3e141e0d6e3e63712a69fe4c0846f1ab472e0522e4e28f1R148)) +To ensure the formatting is correct after adjusting the string returned from `react-element-to-jsx-string` and to align it with the EUI projects formatting rules, we run `prettier` on the string as a final step. ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts#L207)) ## Options @@ -159,9 +159,10 @@ const meta = { codeSnippet: { // will skip code snippet generation for the component or story skip: true, - // useful for complex story composition wrappers (using the story component as nested child) - // it will resolve the outer wrapper and return the code snippet for it's children - // see the story for `EuiHeader/Multiple Fixed Headers` as example + // 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. resolveChildren: true, } } @@ -186,7 +187,7 @@ parameters: { } ``` -🚧 Will follow soon 🚧 +🚧 More will follow soon 🚧 ## Limitations From 1f7eb3f9d86888743d18dd119f6a7c9ab0c92a7b Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Wed, 19 Jun 2024 10:02:18 +0200 Subject: [PATCH 25/51] docs(code-snippet): update readme --- packages/eui/.storybook/addons/code-snippet/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/eui/.storybook/addons/code-snippet/README.md b/packages/eui/.storybook/addons/code-snippet/README.md index 8e334657937..25916c6a51e 100644 --- a/packages/eui/.storybook/addons/code-snippet/README.md +++ b/packages/eui/.storybook/addons/code-snippet/README.md @@ -137,7 +137,7 @@ The returned string of the conversion is then cleaned to ensure: - rename internal Components (e.g. `<_Component>` to ``) ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx#L256)) - rename necessary React.Fragment to shorthand (e.g. `` to `<>`) [code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx#L277) - ensure boolean value shorthand by manually filtering out values of `true` ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx#L290)) - - this is manually handled and not by `react-element-to-jsx-string` because we want to keep some occurrences of `false` values when they have meaning (e.g. `) + - this is manually handled and not by `react-element-to-jsx-string` because we want to keep some occurrences of `false` values when they have meaning (e.g. ``) - replace variable markers that were added in "1: Pre-conversion" ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx#L301)) - remove obsolete function naming ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx#L314)) @@ -148,7 +148,7 @@ To ensure the formatting is correct after adjusting the string returned from `re ## Options -Currently there are two addon specific parameter options added with this PR that can be used under the key `codeSnippet` in the parameters config key. +Currently there are two addon specific parameter options added that can be used under the key `codeSnippet` in the parameters config key. ```ts // meta or story config @@ -175,7 +175,7 @@ const meta = { Instead of using the automatic code snippet generation, we can also provide a manual snippet which will be output instead. This is especially useful when the story content is not actually a component (e.g. a hook). You can see an example of this for the story of `useEuiTextDiff`. -To add the story args tot he code snippet, add the defined marker `{{STORY_ARGS}}` to the snippet string. This marker will be replaced automatically with the current story args. +To add the story args to the code snippet, add the defined marker `{{STORY_ARGS}}` to the snippet string. This marker will be replaced automatically with the current story args. ```ts parameters: { From c102bff60764205c1cf16d1624fab171e46eaff9 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Wed, 19 Jun 2024 10:20:26 +0200 Subject: [PATCH 26/51] refactor: update .match usages --- .../code-snippet/decorators/render_jsx.tsx | 2 +- .../addons/code-snippet/decorators/utils.ts | 17 ++++++----------- 2 files changed, 7 insertions(+), 12 deletions(-) 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 8acfacae5c3..0d399b0dbd8 100644 --- a/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx +++ b/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx @@ -348,7 +348,7 @@ const _simplifyNodeForStringify = ( // transform string to styles object const cssStyles = rules.reduce((acc, cur) => { const [property, value] = cur.split(':'); - const isToken = value.match('euiTheme') != null; + const isToken = value.includes('euiTheme'); const cleanedValue = isToken ? value.replace(/.+?(?=euiTheme)/g, '') : value.replaceAll("'", '').replaceAll('"', ''); diff --git a/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts b/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts index b0886cf4ca9..fc3b3aeed1a 100644 --- a/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts +++ b/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts @@ -38,10 +38,8 @@ export const getElementDisplayName = ( let displayName; if (typeof node.type === 'function' || typeof node.type === 'object') { - displayName = - (node.type as FunctionComponent).displayName ?? - (node.type as FunctionComponent).name ?? - undefined; + const component = node.type as FunctionComponent; + displayName = component.displayName ?? component.name ?? undefined; } return displayName; @@ -88,9 +86,7 @@ export const getComponentDisplayName = ( export const isEmotionComponent = (node: ReactElement): boolean => { const displayName = getElementDisplayName(node); const matches = - typeof displayName === 'string' - ? displayName.match(/^(Emotion)(\w)*/g) - : null; + typeof displayName === 'string' ? displayName.startsWith('Emotion') : null; return matches != null; }; @@ -155,11 +151,10 @@ export const isSubcomponent = ( export const isStatefulComponent = (node: ReactElement): boolean => { const displayName = getEmotionComponentDisplayName(node); const isStateful = - typeof displayName === 'string' - ? displayName.match(/^(Stateful|Component)(\w)*/) - : null; + typeof displayName === 'string' && + (displayName.startsWith('Stateful') || displayName.startsWith('Component')); - return Array.isArray(isStateful); + return isStateful; }; /** From ed25571dfd1179d7068e7631147783a7e9c20a04 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Wed, 19 Jun 2024 10:20:55 +0200 Subject: [PATCH 27/51] move jsx constants and change them to be Sets --- .../.storybook/addons/code-snippet/constants.ts | 14 ++++++++++++++ .../addons/code-snippet/decorators/render_jsx.tsx | 10 +++------- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/packages/eui/.storybook/addons/code-snippet/constants.ts b/packages/eui/.storybook/addons/code-snippet/constants.ts index 07e4ad30a66..2c549254fbc 100644 --- a/packages/eui/.storybook/addons/code-snippet/constants.ts +++ b/packages/eui/.storybook/addons/code-snippet/constants.ts @@ -6,6 +6,9 @@ * Side Public License, v 1. */ +/** + * Addon specific constants + */ export const ADDON_ID = 'storybook/code-snippet'; export const PANEL_ID = `${ADDON_ID}/panel`; @@ -21,3 +24,14 @@ export const QUERY_PARAMS = { }; export const STORY_ARGS_MARKER = '{{STORY_ARGS}}'; + +/** + * JSX snippet generation constants + */ +// excluded props to not be shown in the code snippet +export const EXCLUDED_PROPS = new Set([ + '__EMOTION_TYPE_PLEASE_DO_NOT_USE__', + 'key', +]); +// props with 'false' value that should not be removed but shown in the code snippet +export const PRESERVED_FALSE_VALUE_PROPS = new Set(['grow']); 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 0d399b0dbd8..d557a6fe289 100644 --- a/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx +++ b/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx @@ -20,6 +20,7 @@ import { getDocgenSection } from '@storybook/docs-tools'; import { logger } from '@storybook/client-logger'; import { UseEuiTheme } from '../../../../src/services'; +import { EXCLUDED_PROPS, PRESERVED_FALSE_VALUE_PROPS } from '../constants'; import { getComponentDisplayName, getEmotionComponentDisplayName, @@ -34,11 +35,6 @@ import { isSubcomponent, } from './utils'; -// excluded props to not be shown in the code snippet -const EXCLUDED_PROPS = ['__EMOTION_TYPE_PLEASE_DO_NOT_USE__', 'key']; -// props with 'false' value that should not be removed but shown in the code snippet -const PRESERVED_FALSE_VALUE_PROPS = ['grow']; - export type JSXOptions = Options & { /** Whether to show the function in the jsx tab */ showFunctions?: boolean; @@ -134,7 +130,7 @@ export const renderJsx = ( sortProps: true, filterProps: (value: any, key: string) => { if ( - EXCLUDED_PROPS.includes(key) || + EXCLUDED_PROPS.has(key) || value == null || value === '' || // empty objects/arrays that we set up for easier testing @@ -145,7 +141,7 @@ export const renderJsx = ( // manually filter props with `false` values as this allows us to preserve // `false` values where required e.g. grow={false} - if (value === false && !PRESERVED_FALSE_VALUE_PROPS.includes(key)) { + if (value === false && !PRESERVED_FALSE_VALUE_PROPS.has(key)) { return false; } From e052ae0b3f37b309d12953aafdde36bcb5000a58 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Wed, 19 Jun 2024 11:37:14 +0200 Subject: [PATCH 28/51] refactor: tighten types where possible --- .../code-snippet/decorators/jsx_decorator.tsx | 4 +- .../code-snippet/decorators/render_jsx.tsx | 13 +++--- .../addons/code-snippet/decorators/utils.ts | 41 ++++++++++++------- 3 files changed, 36 insertions(+), 22 deletions(-) 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 907e3154eff..7edf9b000ac 100644 --- a/packages/eui/.storybook/addons/code-snippet/decorators/jsx_decorator.tsx +++ b/packages/eui/.storybook/addons/code-snippet/decorators/jsx_decorator.tsx @@ -97,7 +97,7 @@ export const customJsxDecorator = ( } // use manually provided code snippet and replace args if available - const codeSnippet = context?.parameters?.codeSnippet?.snippet; + const codeSnippet: string = context?.parameters?.codeSnippet?.snippet; if (codeSnippet) { const args: typeof context.args = { ...context.args }; @@ -123,6 +123,7 @@ export const customJsxDecorator = ( jsx = code; }); + // return story from decorator to be rendered return story; } @@ -161,5 +162,6 @@ export const customJsxDecorator = ( }); } + // return story from decorator to be rendered return story; }; 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 d557a6fe289..6bb96d48836 100644 --- a/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx +++ b/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx @@ -14,7 +14,7 @@ import React, { isValidElement } from 'react'; import type { Options } from 'react-element-to-jsx-string'; import reactElementToJSXString from 'react-element-to-jsx-string'; import { camelCase, isEmpty } from 'lodash'; -import type { ReactRenderer } from '@storybook/react'; +import type { ReactRenderer, Args } from '@storybook/react'; import type { StoryContext } from '@storybook/types'; import { getDocgenSection } from '@storybook/docs-tools'; import { logger } from '@storybook/client-logger'; @@ -80,7 +80,6 @@ export const renderJsx = ( // causes some stale value for Emotion components displayName = getEmotionComponentDisplayName(el) ?? displayName; } - return displayName; } else if (getDocgenSection(el.type, 'displayName')) { return getDocgenSection(el.type, 'displayName'); @@ -101,9 +100,7 @@ export const renderJsx = ( getComponentDisplayName(context) ?? context?.title.split('/').pop() ?? el.type.name; - el.type.displayName = displayName; - return displayName; } return el.type.name; @@ -128,6 +125,7 @@ export const renderJsx = ( useBooleanShorthandSyntax: false, // disabled in favor of manual filtering useFragmentShortSyntax: true, sortProps: true, + // using any type here as component props can have any type filterProps: (value: any, key: string) => { if ( EXCLUDED_PROPS.has(key) || @@ -170,7 +168,7 @@ export const renderJsx = ( if (shouldResolveChildren) { node = child.type && typeof child.type === 'function' - ? (child.type as (args: any) => ReactElement)(context?.args) // kinda hacky way to return the children of a wrapper component instead by calling the component + ? (child.type as (args: Args) => ReactElement)(context?.args) // kinda hacky way to return the children of a wrapper component instead by calling the component : child; } else { // removes outer wrapper components but leaves: @@ -304,6 +302,7 @@ const _simplifyNodeForStringify = ( } // check and resolve props recursively + // NOTE: we're using any types here as component props can have any type const updatedProps = updatedNode.props ? Object.keys(updatedNode.props).reduce<{ [key: string]: any; @@ -458,9 +457,9 @@ const _simplifyNodeForStringify = ( // e.g. props of object shape // props = { text: 'foobar' color: 'green' } if (node && !Array.isArray(node) && typeof node === 'object') { - const updatedChildren = { + const updatedChildren: Record = { ...node, - } as Record; + }; let objectValue: ReactElement | undefined; const childrenKeys = Object.keys(updatedChildren); const childrenValues = Object.values(updatedChildren); diff --git a/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts b/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts index fc3b3aeed1a..e05e36a6412 100644 --- a/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts +++ b/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts @@ -9,7 +9,12 @@ /* DISCLAIMER: Parts of this file were copied from Storybook jsxDecorator and adjusted for more specific needs. https://github.com/storybookjs/storybook/blob/2bff7a1c156bbd42ab381f84b8a55a07694e7e53/code/renderers/react/src/docs/jsxDecorator.tsx#L224 */ -import { ReactElement, FunctionComponent, ComponentType } from 'react'; +import { + ReactElement, + FunctionComponent, + ComponentType, + ExoticComponent, +} from 'react'; import * as prettier from 'prettier'; import tsParser from 'prettier/parser-typescript'; import { StoryContext } from '@storybook/react'; @@ -21,19 +26,26 @@ export const toPascalCase = (str: string) => str.charAt(0).toUpperCase() + str.slice(1); /* Helpers for React specific checks */ -export const isMemo = (component: any) => +export const isMemo = (component: ExoticComponent) => component.$$typeof === Symbol.for('react.memo'); -export const isForwardRef = (component: any) => +export const isForwardRef = (component: ExoticComponent) => component.$$typeof === Symbol.for('react.forward_ref'); -export const isFragment = (component: any) => - component.type?.toString().includes('fragment') || - component.$$typeof?.toString().includes('fragment'); +export const isFragment = (component: ReactElement | ExoticComponent) => { + const isExoticComponent = (el: any): el is ExoticComponent => + el.$$typeof !== undefined; + + if (isExoticComponent(component)) { + return component.$$typeof?.toString().includes('fragment'); + } + + return component.type?.toString().includes('fragment'); +}; /* Helpers */ // returns the displayName and handles typing as // otherwise `type` would not be typed export const getElementDisplayName = ( - node: ReactElement + node: ReactElement ): string | undefined => { let displayName; @@ -47,7 +59,7 @@ export const getElementDisplayName = ( // returns the displayName after resolving Emotion wrappers export const getEmotionComponentDisplayName = ( - node: ReactElement + node: ReactElement ): string | undefined => { const displayName = getElementDisplayName(node); @@ -59,7 +71,7 @@ export const getEmotionComponentDisplayName = ( const isForwardRefComponent = isForwardRef(emotionData); // we need to rely here on the reference Emotion stores to know what component this actually is - const replacementName = isForwardRefComponent + const replacementName: string | undefined = isForwardRefComponent ? emotionData.__docgenInfo.displayName : typeof emotionData === 'string' ? emotionData @@ -76,14 +88,14 @@ export const getComponentDisplayName = ( context: StoryContext | undefined ): string | undefined => { if (!context) return undefined; - const component = context.component as ComponentType & { + const component = context.component as ComponentType & { __docgenInfo?: { displayName?: string }; }; return component?.displayName ?? component.__docgenInfo?.displayName; }; -export const isEmotionComponent = (node: ReactElement): boolean => { +export const isEmotionComponent = (node: ReactElement): boolean => { const displayName = getElementDisplayName(node); const matches = typeof displayName === 'string' ? displayName.startsWith('Emotion') : null; @@ -93,7 +105,7 @@ export const isEmotionComponent = (node: ReactElement): boolean => { /* Story specific checks */ export const isStoryComponent = ( - node: ReactElement, + node: ReactElement, context: StoryContext | undefined ): boolean => { if (!context) return false; @@ -107,7 +119,7 @@ export const isStoryComponent = ( * checks if the outer most component is a parent of the actual story component */ export const isStoryParent = ( - node: ReactElement, + node: ReactElement, context: StoryContext | undefined ): boolean => { if (!context) return false; @@ -124,7 +136,7 @@ export const isStoryParent = ( }; export const isSubcomponent = ( - node: ReactElement, + node: ReactElement, context: StoryContext | undefined ): boolean => { if (!context) return false; @@ -158,6 +170,7 @@ export const isStatefulComponent = (node: ReactElement): boolean => { }; /** + * NOTE: This code is from the original Storybook jsxDecorator * Converts a React symbol to a React-like displayName * * Symbols come from here From 69a32d174a05bb6159840a9dcea1b797c964b513 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Wed, 19 Jun 2024 13:30:59 +0200 Subject: [PATCH 29/51] fix(code-snippet): fix isFragment type guards --- .../addons/code-snippet/decorators/utils.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts b/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts index e05e36a6412..282725f2702 100644 --- a/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts +++ b/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts @@ -31,14 +31,16 @@ export const isMemo = (component: ExoticComponent) => export const isForwardRef = (component: ExoticComponent) => component.$$typeof === Symbol.for('react.forward_ref'); export const isFragment = (component: ReactElement | ExoticComponent) => { + // use type guards to ensure keys are available + const isReactElement = (el: any): el is ReactElement => el.type !== undefined; const isExoticComponent = (el: any): el is ExoticComponent => el.$$typeof !== undefined; - if (isExoticComponent(component)) { - return component.$$typeof?.toString().includes('fragment'); - } - - return component.type?.toString().includes('fragment'); + return isReactElement(component) + ? component.type?.toString().includes('fragment') + : isExoticComponent(component) + ? component.$$typeof?.toString().includes('fragment') + : false; }; /* Helpers */ From 014c2bdda4414144e9a42a9f0b8654db009a4e12 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Wed, 19 Jun 2024 15:15:00 +0200 Subject: [PATCH 30/51] docs(storybook): ensure EuiSpacer args are spread to component --- packages/eui/src/components/spacer/spacer.stories.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/eui/src/components/spacer/spacer.stories.tsx b/packages/eui/src/components/spacer/spacer.stories.tsx index 3d8a18fd918..b33bec09a8b 100644 --- a/packages/eui/src/components/spacer/spacer.stories.tsx +++ b/packages/eui/src/components/spacer/spacer.stories.tsx @@ -16,12 +16,12 @@ const meta: Meta = { title: 'Layout/EuiSpacer', component: EuiSpacer, decorators: [ - (Story) => ( + (Story, { args }) => ( <>

Observe the space created between this and the next text block.

- +

Observe the space created between this and the previous text block. @@ -33,6 +33,7 @@ const meta: Meta = { args: { size: 'l', }, + render: (args) => , }; export default meta; From de8fb1c9170570f5ad4ae6ecd8344595ddb34137 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Wed, 19 Jun 2024 15:15:22 +0200 Subject: [PATCH 31/51] docs(storybook): skip snippet generation for EuiInnerText --- packages/eui/src/components/inner_text/inner_text.stories.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/eui/src/components/inner_text/inner_text.stories.tsx b/packages/eui/src/components/inner_text/inner_text.stories.tsx index 0a299d70821..ebe97f0d5c9 100644 --- a/packages/eui/src/components/inner_text/inner_text.stories.tsx +++ b/packages/eui/src/components/inner_text/inner_text.stories.tsx @@ -26,6 +26,9 @@ export const Playground: Story = { docs: { source: { language: 'tsx' }, }, + codeSnippet: { + skip: true, + }, }, argTypes: { children: { control: { type: 'text' } }, From b5cd7d0cfd90d614302fafecb1c007dafd96fd2e Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Wed, 19 Jun 2024 15:24:37 +0200 Subject: [PATCH 32/51] feat(code-snippet): add support for snippet value overrides -adds example for EuiDatePicker story --- .../code-snippet/decorators/render_jsx.tsx | 43 +++++++++++++++---- .../date_picker/date_picker.stories.tsx | 7 +++ 2 files changed, 41 insertions(+), 9 deletions(-) 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 6bb96d48836..07c11a99bf1 100644 --- a/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx +++ b/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx @@ -20,7 +20,11 @@ import { getDocgenSection } from '@storybook/docs-tools'; import { logger } from '@storybook/client-logger'; import { UseEuiTheme } from '../../../../src/services'; -import { EXCLUDED_PROPS, PRESERVED_FALSE_VALUE_PROPS } from '../constants'; +import { + ADDON_PARAMETER_KEY, + EXCLUDED_PROPS, + PRESERVED_FALSE_VALUE_PROPS, +} from '../constants'; import { getComponentDisplayName, getEmotionComponentDisplayName, @@ -201,7 +205,11 @@ export const renderJsx = ( // convert node to jsx string let string: string = toJSXString( - _simplifyNodeForStringify(node, euiTheme), + _simplifyNodeForStringify({ + node, + euiTheme, + argsOverride: context?.parameters[ADDON_PARAMETER_KEY]?.args, + }), opts as Options ); @@ -274,10 +282,15 @@ export const renderJsx = ( * - resolves Emotion css prop back to its input state * - resolves arrays and objects to single elements */ -const _simplifyNodeForStringify = ( - node: ReactNode, - euiTheme?: UseEuiTheme -): ReactNode => { +const _simplifyNodeForStringify = ({ + node, + euiTheme, + argsOverride, +}: { + node: ReactNode; + euiTheme?: UseEuiTheme; + argsOverride?: Args; +}): ReactNode => { if (isValidElement(node)) { let updatedNode = node; @@ -307,6 +320,14 @@ const _simplifyNodeForStringify = ( ? Object.keys(updatedNode.props).reduce<{ [key: string]: any; }>((acc, cur) => { + // check if the story has manual prop overrides that should be + // used instead of the original value + if (argsOverride?.[cur]) { + console.log('OVERRIDE', cur, argsOverride?.[cur]); + acc[cur] = argsOverride?.[cur]; + + return acc; + } // resolve css Emotion object back to css prop // ensures tokens are output as is and not its resolved value if (cur === 'css') { @@ -430,7 +451,9 @@ const _simplifyNodeForStringify = ( }); } - acc[cur] = _simplifyNodeForStringify(updatedNode.props[cur]); + acc[cur] = _simplifyNodeForStringify({ + node: updatedNode.props[cur], + }); return acc; }, {} as Record) @@ -449,7 +472,7 @@ const _simplifyNodeForStringify = ( // recursively resolve array or object nodes (e.g. props) if (Array.isArray(node)) { const children = node.map((child) => - _simplifyNodeForStringify(child, euiTheme) + _simplifyNodeForStringify({ node: child, euiTheme }) ); return children.flat(); } @@ -474,7 +497,9 @@ const _simplifyNodeForStringify = ( objectValue = (() => {}) as unknown as ReactElement; break; } else { - updatedChildren[childrenKeys[i]] = _simplifyNodeForStringify(n); + updatedChildren[childrenKeys[i]] = _simplifyNodeForStringify({ + node: n, + }); } } diff --git a/packages/eui/src/components/date_picker/date_picker.stories.tsx b/packages/eui/src/components/date_picker/date_picker.stories.tsx index 294cafc703c..11f7c2edeea 100644 --- a/packages/eui/src/components/date_picker/date_picker.stories.tsx +++ b/packages/eui/src/components/date_picker/date_picker.stories.tsx @@ -125,6 +125,13 @@ export default meta; type Story = StoryObj; export const Playground: Story = { + parameters: { + codeSnippet: { + args: { + selected: 'Tue Mar 19 2024 18:54:51 GMT+0100', + }, + }, + }, args: { // NOTE: loki play interactions won't work in CLI somehow // TODO: exchange with loki play() interactions once fixed From 78b55d5dce495e0f50ee0bd1b718a82ba1525ee6 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Thu, 20 Jun 2024 09:41:46 +0200 Subject: [PATCH 33/51] chore: cleanup log --- .../eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx | 1 - 1 file changed, 1 deletion(-) 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 07c11a99bf1..bb44c185e87 100644 --- a/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx +++ b/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx @@ -323,7 +323,6 @@ const _simplifyNodeForStringify = ({ // check if the story has manual prop overrides that should be // used instead of the original value if (argsOverride?.[cur]) { - console.log('OVERRIDE', cur, argsOverride?.[cur]); acc[cur] = argsOverride?.[cur]; return acc; From e9faa76f3dcede9c153b01533f80dcf54cf20bc3 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Thu, 20 Jun 2024 10:36:34 +0200 Subject: [PATCH 34/51] feat(code-snippet): add story element only filter functionality --- .../.storybook/addons/code-snippet/README.md | 5 +- .../code-snippet/decorators/render_jsx.tsx | 16 +++-- .../addons/code-snippet/decorators/utils.ts | 66 ++++++++++++++++++- .../validatable_control.stories.tsx | 3 + .../src/components/spacer/spacer.stories.tsx | 5 ++ 5 files changed, 88 insertions(+), 7 deletions(-) diff --git a/packages/eui/.storybook/addons/code-snippet/README.md b/packages/eui/.storybook/addons/code-snippet/README.md index 25916c6a51e..95a9cd45587 100644 --- a/packages/eui/.storybook/addons/code-snippet/README.md +++ b/packages/eui/.storybook/addons/code-snippet/README.md @@ -148,7 +148,7 @@ To ensure the formatting is correct after adjusting the string returned from `re ## Options -Currently there are two addon specific parameter options added that can be used under the key `codeSnippet` in the parameters config key. +Currently there are a few addon specific parameter options added that can be used under the key `codeSnippet` in the parameters config key. ```ts // meta or story config @@ -164,6 +164,9 @@ const meta = { // 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. 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. + resolveStoryElementOnly: true, } } } 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 bb44c185e87..21d1fe44f13 100644 --- a/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx +++ b/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx @@ -37,6 +37,8 @@ import { isStoryComponent, isStoryParent, isSubcomponent, + getStoryComponent, + getResolvedStoryChild, } from './utils'; export type JSXOptions = Options & { @@ -162,6 +164,8 @@ export const renderJsx = ( const shouldResolveChildren = context?.parameters?.codeSnippet?.resolveChildren === true; + const shouldResolveStoryElementOnly = + context?.parameters?.codeSnippet?.resolveStoryElementOnly === true; let node = child; @@ -170,10 +174,14 @@ export const renderJsx = ( // 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 if (shouldResolveChildren) { - node = - child.type && typeof child.type === 'function' - ? (child.type as (args: Args) => ReactElement)(context?.args) // kinda hacky way to return the children of a wrapper component instead by calling the component - : child; + node = getResolvedStoryChild(child, context); + // resolves the story element only and removes any wrapper or siblings + } else if (shouldResolveStoryElementOnly) { + const storyNode = getStoryComponent(child, context); + + if (storyNode) { + node = storyNode; + } } else { // removes outer wrapper components but leaves: // - stateful wrappers (kept and renamed later via displayName) diff --git a/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts b/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts index 282725f2702..9fe68c0172f 100644 --- a/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts +++ b/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts @@ -14,10 +14,11 @@ import { FunctionComponent, ComponentType, ExoticComponent, + isValidElement, } from 'react'; +import { Args, StoryContext } from '@storybook/react'; import * as prettier from 'prettier'; import tsParser from 'prettier/parser-typescript'; -import { StoryContext } from '@storybook/react'; // @ts-ignore - config import import basePrettierConfig from '../../../../.prettierrc'; @@ -113,8 +114,11 @@ export const isStoryComponent = ( if (!context) return false; const displayName = getEmotionComponentDisplayName(node); + const isCurrentStory = displayName + ? displayName === context?.component?.displayName + : false; - return displayName === context?.component?.displayName; + return isCurrentStory; }; /** @@ -171,6 +175,64 @@ export const isStatefulComponent = (node: ReactElement): boolean => { return isStateful; }; +/** + * Helper to resolve components that are wrapped. + * It's a bit hacky way to return the children of a story wrapper component + * by calling the component first. This way we ensure to get the right information + * for the story. + * (e.g. when resolving from a story decorator or when + * resolving the children of a wrapper component) + */ +export const getResolvedStoryChild = ( + child: ReactElement, + context: StoryContext +) => { + return child.type && typeof child.type === 'function' + ? (child.type as (args: Args) => ReactElement)(context?.args) + : child; +}; + +/** + * 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. + * + * It checks the passed story node recursively until it finds the current + * story element and returns it. + */ +export const getStoryComponent = ( + node: ReactElement, + context: StoryContext +): ReactElement | undefined => { + let storyNode: ReactElement | undefined; + + const resolveChildren = (childNode: ReactElement) => { + 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; + + // Story wrappers need to be resolved first to ensure the right data + const resolvedChild = getResolvedStoryChild(child, context); + + resolveChildren(resolvedChild); + } + } + }; + + resolveChildren(node); + + return storyNode; +}; + /** * NOTE: This code is from the original Storybook jsxDecorator * Converts a React symbol to a React-like displayName diff --git a/packages/eui/src/components/form/validatable_control/validatable_control.stories.tsx b/packages/eui/src/components/form/validatable_control/validatable_control.stories.tsx index 87626c82b65..821a47facf8 100644 --- a/packages/eui/src/components/form/validatable_control/validatable_control.stories.tsx +++ b/packages/eui/src/components/form/validatable_control/validatable_control.stories.tsx @@ -26,6 +26,9 @@ const meta: Meta = { // it only adds attributes in the DOM skip: true, }, + codeSnippet: { + resolveStoryElementOnly: true, + }, }, decorators: [ (Story, { args }) => ( diff --git a/packages/eui/src/components/spacer/spacer.stories.tsx b/packages/eui/src/components/spacer/spacer.stories.tsx index b33bec09a8b..fe28d07383c 100644 --- a/packages/eui/src/components/spacer/spacer.stories.tsx +++ b/packages/eui/src/components/spacer/spacer.stories.tsx @@ -15,6 +15,11 @@ import { EuiSpacer, EuiSpacerProps } from './spacer'; const meta: Meta = { title: 'Layout/EuiSpacer', component: EuiSpacer, + parameters: { + codeSnippet: { + resolveStoryElementOnly: true, + }, + }, decorators: [ (Story, { args }) => ( <> From d9a7c023803d4d707f878bb0a0bb455ce7e0a593 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Thu, 20 Jun 2024 10:44:14 +0200 Subject: [PATCH 35/51] chore: cleanup --- packages/eui/src/components/spacer/spacer.stories.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/eui/src/components/spacer/spacer.stories.tsx b/packages/eui/src/components/spacer/spacer.stories.tsx index fe28d07383c..e8a18cffff9 100644 --- a/packages/eui/src/components/spacer/spacer.stories.tsx +++ b/packages/eui/src/components/spacer/spacer.stories.tsx @@ -38,7 +38,6 @@ const meta: Meta = { args: { size: 'l', }, - render: (args) => , }; export default meta; From c8253d36a45190c454fea5392187c9fe9d1aae8f Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Mon, 24 Jun 2024 11:35:57 +0200 Subject: [PATCH 36/51] feat(code-snippet): support spread args for manual code snippets --- .../.storybook/addons/code-snippet/README.md | 14 ++++++- .../addons/code-snippet/constants.ts | 1 + .../code-snippet/decorators/jsx_decorator.tsx | 38 ++++++++++++++++--- 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/packages/eui/.storybook/addons/code-snippet/README.md b/packages/eui/.storybook/addons/code-snippet/README.md index 95a9cd45587..09a7c8149e5 100644 --- a/packages/eui/.storybook/addons/code-snippet/README.md +++ b/packages/eui/.storybook/addons/code-snippet/README.md @@ -178,13 +178,23 @@ const meta = { Instead of using the automatic code snippet generation, we can also provide a manual snippet which will be output instead. This is especially useful when the story content is not actually a component (e.g. a hook). You can see an example of this for the story of `useEuiTextDiff`. -To add the story args to the code snippet, add the defined marker `{{STORY_ARGS}}` to the snippet string. This marker will be replaced automatically with the current story args. +To add the story args to the code snippet, add the defined marker `{{STORY_ARGS}}` to the snippet string. This marker will be replaced automatically with the current story args. If the args should be spread on the root component use `{{...STORY_ARGS}}` instead. ```ts +// {{STORY_ARGS}} parameters: { codeSnippet: { snippet: ` - const [rendered, textDiffObject] = useTextDiff(${STORY_ARGS_MARKER}) + const [rendered, textDiffObject] = useTextDiff({{STORY_ARGS}}) + `, + }, +} + +// {{...STORY_ARGS}} +parameters: { + codeSnippet: { + snippet: ` + `, }, } diff --git a/packages/eui/.storybook/addons/code-snippet/constants.ts b/packages/eui/.storybook/addons/code-snippet/constants.ts index 2c549254fbc..423ed834d5b 100644 --- a/packages/eui/.storybook/addons/code-snippet/constants.ts +++ b/packages/eui/.storybook/addons/code-snippet/constants.ts @@ -24,6 +24,7 @@ export const QUERY_PARAMS = { }; export const STORY_ARGS_MARKER = '{{STORY_ARGS}}'; +export const SPREAD_STORY_ARGS_MARKER = '{{...STORY_ARGS}}'; /** * JSX snippet generation constants 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 7edf9b000ac..ae1f9be12d7 100644 --- a/packages/eui/.storybook/addons/code-snippet/decorators/jsx_decorator.tsx +++ b/packages/eui/.storybook/addons/code-snippet/decorators/jsx_decorator.tsx @@ -19,7 +19,11 @@ import { addons, useEffect, useCallback } from '@storybook/preview-api'; import { logger } from '@storybook/client-logger'; import { useEuiTheme } from '../../../../src/services'; -import { EVENTS, STORY_ARGS_MARKER } from '../constants'; +import { + EVENTS, + SPREAD_STORY_ARGS_MARKER, + STORY_ARGS_MARKER, +} from '../constants'; import { getFormattedCode, skipJsxRender } from './utils'; import { JSXOptions, renderJsx } from './render_jsx'; @@ -48,7 +52,8 @@ export const customJsxDecorator = ( ) => { const story = storyFn(); const channel = addons.getChannel(); - const skip = skipJsxRender(context); + const codeSnippet: string = context?.parameters?.codeSnippet?.snippet; + const skip = skipJsxRender(context) && !codeSnippet; let jsx = ''; @@ -97,7 +102,6 @@ export const customJsxDecorator = ( } // use manually provided code snippet and replace args if available - const codeSnippet: string = context?.parameters?.codeSnippet?.snippet; if (codeSnippet) { const args: typeof context.args = { ...context.args }; @@ -108,8 +112,32 @@ export const customJsxDecorator = ( } // add the story args/props to the manual code snippet - // by replacing the {{STORY_ARGS}} marker - const code = codeSnippet.replace(STORY_ARGS_MARKER, JSON.stringify(args)); + // by replacing the {{STORY_ARGS}} || {{...STORY_ARGS}} marker + let outputArgs = JSON.stringify(args); + const shouldSpread = codeSnippet.includes(SPREAD_STORY_ARGS_MARKER); + const argsMarker = shouldSpread + ? SPREAD_STORY_ARGS_MARKER + : STORY_ARGS_MARKER; + + // if the spread marker is used, resolve the props object to the first level values + // e.g. { foo: 'bar' } => foo="bar" + // { a: { b: 'B' } } => a={{ b: 'B' }} + if (shouldSpread) { + outputArgs = Object.entries(args) + .map(([key, value]) => { + const formattedValue = + typeof value === 'function' ? `() => {}` : JSON.stringify(value); + const formattedOutput = + typeof value === 'string' + ? `${[key, formattedValue].join('=')}` + : `${[key, formattedValue].join('={')}}`; + + return formattedOutput; + }) + .join(' '); + } + + const code = codeSnippet.replace(argsMarker, outputArgs); getFormattedCode(code) .then((res: string) => { From 18ca9a52388f028c5a160624df50641aa157add2 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Mon, 24 Jun 2024 11:36:28 +0200 Subject: [PATCH 37/51] docs(storybook): enable code snippets for providers --- .../components/provider/provider.stories.tsx | 14 +++++++++++--- .../eui/src/services/theme/provider.stories.tsx | 17 +++++++++++------ 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/packages/eui/src/components/provider/provider.stories.tsx b/packages/eui/src/components/provider/provider.stories.tsx index 6c84468bda0..08115a748b9 100644 --- a/packages/eui/src/components/provider/provider.stories.tsx +++ b/packages/eui/src/components/provider/provider.stories.tsx @@ -9,6 +9,7 @@ import React from 'react'; import type { Meta, StoryObj } from '@storybook/react'; +import { SPREAD_STORY_ARGS_MARKER } from '../../../.storybook/addons/code-snippet/constants'; import { EuiProvider, EuiProviderProps } from './provider'; const meta: Meta> = { @@ -30,6 +31,16 @@ export default meta; type Story = StoryObj>; export const FontDefaultUnits: Story = { + parameters: { + codeSnippet: { + snippet: ` + + `, + }, + }, + args: { + modify: { font: { defaultUnits: 'rem' } }, + }, render: () => ( <> Change `modify.font.defaultUnits` to{' '} @@ -37,7 +48,4 @@ export const FontDefaultUnits: Story = { CSS ), - args: { - modify: { font: { defaultUnits: 'rem' } }, - }, }; diff --git a/packages/eui/src/services/theme/provider.stories.tsx b/packages/eui/src/services/theme/provider.stories.tsx index dfeff43e2a3..7ce87e1b4d8 100644 --- a/packages/eui/src/services/theme/provider.stories.tsx +++ b/packages/eui/src/services/theme/provider.stories.tsx @@ -21,9 +21,14 @@ export default meta; type Story = StoryObj>; export const WrapperCloneElement: Story = { - render: () => ( + args: { + wrapperProps: { + cloneElement: true, + }, + }, + render: (args) => ( <> - +

This example should only have 1 main wrapper rendered.
@@ -33,14 +38,14 @@ export const WrapperCloneElement: Story = { }; export const CSSVariablesNearest: Story = { - render: () => ( + render: (args) => ( <> This component sets the nearest theme provider (the global theme) with a red CSS variable color. Inspect the `:root` styles to see the variable set. - + This component sets the nearest local theme provider with a blue CSS variable color. Inspect the parent theme wrapper to see the variable @@ -52,14 +57,14 @@ export const CSSVariablesNearest: Story = { }; export const CSSVariablesGlobal: Story = { - render: () => ( + render: (args) => ( <> This component sets the nearest theme provider (the global theme) with a red CSS variable color. However, it should be overridden by the next component. - + This component sets the global theme with a blue CSS variable color. It should override the previous component. Inspect the `:root` styles From ec9add9c7c1e61fb377e606cc20fdfc45a52fbf9 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Mon, 24 Jun 2024 12:57:37 +0200 Subject: [PATCH 38/51] feat(code-snippet): support removing default props --- .../.storybook/addons/code-snippet/README.md | 9 +- .../addons/code-snippet/constants.ts | 6 +- .../code-snippet/decorators/jsx_decorator.tsx | 2 +- .../code-snippet/decorators/render_jsx.tsx | 26 ++- .../addons/code-snippet/decorators/utils.ts | 148 +++++++++++++++--- 5 files changed, 157 insertions(+), 34 deletions(-) diff --git a/packages/eui/.storybook/addons/code-snippet/README.md b/packages/eui/.storybook/addons/code-snippet/README.md index 09a7c8149e5..1776e8dbd60 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 423ed834d5b..09a917a5489 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 ae1f9be12d7..e8e08ba36ed 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 21d1fe44f13..ebc08963fbd 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 9fe68c0172f..75ade61b799 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 From 478b16a651df8ccebc16c2d87fe7b1de62986641 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Mon, 24 Jun 2024 14:16:45 +0200 Subject: [PATCH 39/51] docs(storybook): remove plugin controls from markdown stories --- .../markdown_editor/markdown_editor.stories.tsx | 8 -------- .../markdown_editor/markdown_format.stories.tsx | 6 ------ 2 files changed, 14 deletions(-) diff --git a/packages/eui/src/components/markdown_editor/markdown_editor.stories.tsx b/packages/eui/src/components/markdown_editor/markdown_editor.stories.tsx index 9de45b03d2c..fa7e8a8ca81 100644 --- a/packages/eui/src/components/markdown_editor/markdown_editor.stories.tsx +++ b/packages/eui/src/components/markdown_editor/markdown_editor.stories.tsx @@ -9,11 +9,6 @@ import type { Meta, StoryObj } from '@storybook/react'; import { EuiMarkdownEditor, EuiMarkdownEditorProps } from './markdown_editor'; -import { - defaultParsingPlugins, - defaultProcessingPlugins, - defaultUiPlugins, -} from './plugins/markdown_default_plugins'; import { MODE_EDITING, MODE_VIEWING } from './markdown_modes'; const initialContent = `## Hello world! @@ -39,9 +34,6 @@ const meta: Meta = { height: 250, maxHeight: 500, autoExpandPreview: true, - parsingPluginList: defaultParsingPlugins, - processingPluginList: defaultProcessingPlugins, - uiPlugins: defaultUiPlugins, errors: [], initialViewMode: MODE_EDITING, dropHandlers: [], diff --git a/packages/eui/src/components/markdown_editor/markdown_format.stories.tsx b/packages/eui/src/components/markdown_editor/markdown_format.stories.tsx index 6957ea14b1f..6581e71af85 100644 --- a/packages/eui/src/components/markdown_editor/markdown_format.stories.tsx +++ b/packages/eui/src/components/markdown_editor/markdown_format.stories.tsx @@ -13,10 +13,6 @@ import { moveStorybookControlsToCategory, } from '../../../.storybook/utils'; import { EuiMarkdownFormat, EuiMarkdownFormatProps } from './markdown_format'; -import { - defaultParsingPlugins, - defaultProcessingPlugins, -} from './plugins/markdown_default_plugins'; import { ALIGNMENTS } from '../text/text_align'; const initialContent = `## Hello world! @@ -48,8 +44,6 @@ const meta: Meta = { // Component defaults args: { textSize: 'm', - parsingPluginList: defaultParsingPlugins, - processingPluginList: defaultProcessingPlugins, }, }; moveStorybookControlsToCategory( From ec83ba73a2242db6a8d2dd4ffb8edc26eccc8408 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Mon, 24 Jun 2024 14:16:54 +0200 Subject: [PATCH 40/51] docs: update code-snippet readme --- packages/eui/.storybook/addons/code-snippet/README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/eui/.storybook/addons/code-snippet/README.md b/packages/eui/.storybook/addons/code-snippet/README.md index 1776e8dbd60..0809ed0dc32 100644 --- a/packages/eui/.storybook/addons/code-snippet/README.md +++ b/packages/eui/.storybook/addons/code-snippet/README.md @@ -157,6 +157,11 @@ const meta = { component: EuiButton, parameters: { codeSnippet: { + // optional way to override selected story args with manual values + // this is useful when the story arg would render unreadable or not useful output + args: { + propA: 'new value for propA', + }, // will skip code snippet generation for the component or story // @default false skip: true, From ac3fcda4e409ddf6de7a83f58e7a7d87660e3453 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Mon, 24 Jun 2024 17:19:20 +0200 Subject: [PATCH 41/51] fix: determine defaultProps per node iteration instead of root only --- .../code-snippet/decorators/render_jsx.tsx | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) 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 ebc08963fbd..7e52e844af1 100644 --- a/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx +++ b/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx @@ -40,6 +40,7 @@ import { getStoryComponent, getResolvedStoryChild, getDefaultPropsfromDocgenInfo, + isStoryWrapper, } from './utils'; export type JSXOptions = Options & { @@ -167,16 +168,10 @@ 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 @@ -222,9 +217,9 @@ export const renderJsx = ( let string: string = toJSXString( _simplifyNodeForStringify({ node, + context, euiTheme, argsOverride: context?.parameters[ADDON_PARAMETER_KEY]?.args, - defaultProps, }), opts as Options ); @@ -300,17 +295,23 @@ export const renderJsx = ( */ const _simplifyNodeForStringify = ({ node, + context, euiTheme, argsOverride, - defaultProps, }: { node: ReactNode; + context: StoryContext; euiTheme?: UseEuiTheme; argsOverride?: Args; - defaultProps?: Args; }): ReactNode => { if (isValidElement(node)) { let updatedNode = node; + const shouldRemoveDefaultProps = + context?.parameters?.codeSnippet?.removeDefaultProps !== false; + // default props for the current node + const defaultProps = shouldRemoveDefaultProps + ? getDefaultPropsfromDocgenInfo(node, context) + : []; // remove outer fragments if (isFragment(updatedNode) && !Array.isArray(updatedNode.props.children)) { @@ -474,7 +475,7 @@ const _simplifyNodeForStringify = ({ acc[cur] = _simplifyNodeForStringify({ node: updatedNode.props[cur], - defaultProps, + context, }); return acc; @@ -494,7 +495,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, defaultProps }) + _simplifyNodeForStringify({ node: child, context, euiTheme }) ); return children.flat(); } @@ -521,7 +522,7 @@ const _simplifyNodeForStringify = ({ } else { updatedChildren[childrenKeys[i]] = _simplifyNodeForStringify({ node: n, - defaultProps, + context, }); } } From cbec91fd720a504ba318570c80c83ee15dc9f10f Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Mon, 24 Jun 2024 17:30:39 +0200 Subject: [PATCH 42/51] fix: ensure story wrapper is resolved correctly - uses Story wrapper for ComboBox story as example --- .../code-snippet/decorators/render_jsx.tsx | 12 ++--- .../addons/code-snippet/decorators/utils.ts | 49 +++++++++++++------ .../combo_box/combo_box.stories.tsx | 9 +++- 3 files changed, 48 insertions(+), 22 deletions(-) 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 7e52e844af1..8bb66fe64c6 100644 --- a/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx +++ b/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx @@ -164,8 +164,6 @@ export const renderJsx = ( : // @ts-expect-error (Converted from ts-ignore) reactElementToJSXString.default; - const shouldResolveChildren = - context?.parameters?.codeSnippet?.resolveChildren === true; const shouldResolveStoryElementOnly = context?.parameters?.codeSnippet?.resolveStoryElementOnly === true; @@ -175,15 +173,17 @@ export const renderJsx = ( // 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 - if (shouldResolveChildren) { - node = getResolvedStoryChild(child, context); - // resolves the story element only and removes any wrapper or siblings - } else if (shouldResolveStoryElementOnly) { + if (shouldResolveStoryElementOnly) { const storyNode = getStoryComponent(child, context); if (storyNode) { node = storyNode; } + // 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 + } else if (isStoryWrapper(child, context)) { + node = getResolvedStoryChild(child, context); } else { // removes outer wrapper components but leaves: // - stateful wrappers (kept and renamed later via displayName) diff --git a/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts b/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts index 75ade61b799..fcd571427e4 100644 --- a/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts +++ b/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts @@ -15,6 +15,7 @@ import { ComponentType, ExoticComponent, isValidElement, + Component, } from 'react'; import { Args, StoryContext } from '@storybook/react'; import * as prettier from 'prettier'; @@ -56,8 +57,11 @@ export const getElementDisplayName = ( node: ReactElement ): string | undefined => { let displayName; + const isClassComponent = node instanceof Component; - if (typeof node.type === 'function' || typeof node.type === 'object') { + if (isClassComponent) { + displayName = node.constructor.name; + } else if (typeof node.type === 'function' || typeof node.type === 'object') { const component = node.type as FunctionComponent; displayName = component.displayName ?? component.name ?? undefined; } @@ -92,10 +96,10 @@ export const getEmotionComponentDisplayName = ( : emotionTypeName ?? emotionLabelData?.displayName; // remove internal component underscore markings - return replacementName ? replacementName.replace('_', '') : displayName; + return replacementName ?? displayName; } - return displayName; + return displayName ? displayName.replace('_', '') : displayName; }; export const getStoryComponentDisplayName = ( @@ -127,7 +131,10 @@ export const isStoryComponent = ( ): boolean => { if (!context) return false; - const displayName = getEmotionComponentDisplayName(node)?.replace(/^_/, ''); + const isClassComponent = node instanceof Component; + const displayName = isClassComponent + ? node.constructor?.name + : getEmotionComponentDisplayName(node)?.replace(/^_/, ''); const isCurrentStory = displayName ? displayName === context?.component?.displayName : false; @@ -135,7 +142,7 @@ export const isStoryComponent = ( return isCurrentStory; }; -const isStoryWrapper = (node: ReactElement, context: StoryContext) => { +export const isStoryWrapper = (node: ReactElement, context: StoryContext) => { const displayName = getEmotionComponentDisplayName(node); const isStoryWrapper = (typeof displayName === 'string' && displayName.startsWith('Story')) || @@ -210,9 +217,15 @@ export const getResolvedStoryChild = ( child: ReactElement, context: StoryContext ) => { - return child.type && typeof child.type === 'function' - ? (child.type as (args: Args) => ReactElement)(context?.args) - : child; + if (!child.type) return child; + if (typeof child.type !== 'function') return child; + + const isClassComponent = child.type.prototype instanceof Component; + const resolvedChild = isClassComponent + ? child + : (child.type as (args: Args) => ReactElement)(context?.args); + + return resolvedChild; }; /** @@ -245,9 +258,15 @@ export const getStoryComponent = ( // skip non-ReactElement children if (!isValidElement(child)) continue; + const displayName = getEmotionComponentDisplayName(child); // Story wrappers need to be resolved first to ensure the right data const resolvedChild = getResolvedStoryChild(child, context); - resolveChildren(resolvedChild); + const resolvedDisplayName = + getEmotionComponentDisplayName(resolvedChild); + + if (resolvedDisplayName !== displayName) { + resolveChildren(resolvedChild); + } } } else if ( // CASE: story wrapper; no children @@ -260,7 +279,7 @@ export const getStoryComponent = ( const resolvedDisplayName = getEmotionComponentDisplayName(resolvedChild); - if (resolvedDisplayName && resolvedDisplayName !== displayName) { + if (resolvedDisplayName !== displayName) { resolveChildren(resolvedChild); } } else if ( @@ -268,7 +287,7 @@ export const getStoryComponent = ( childNode.props?.children && !Array.isArray(childNode.props?.children) ) { - resolveChildren(childNode.props?.children); + storyNode = childNode.props.children; } } }; @@ -302,9 +321,11 @@ export const getDefaultPropsfromDocgenInfo = ( if (!storyComponent) return undefined; - let propsInfo = isEmotionComponent(storyComponent) - ? storyComponent.props?.[EMOTION_TYPE_KEY]?.__docgenInfo.props - : storyComponent.type?.__docgenInfo?.props; + let propsInfo = + isEmotionComponent(storyComponent) && + typeof storyComponent.props?.[EMOTION_TYPE_KEY] !== 'string' + ? storyComponent.props?.[EMOTION_TYPE_KEY]?.__docgenInfo.props + : storyComponent.type?.__docgenInfo?.props; const args = context.args; diff --git a/packages/eui/src/components/combo_box/combo_box.stories.tsx b/packages/eui/src/components/combo_box/combo_box.stories.tsx index 0d1e487df6b..061e120c6f4 100644 --- a/packages/eui/src/components/combo_box/combo_box.stories.tsx +++ b/packages/eui/src/components/combo_box/combo_box.stories.tsx @@ -133,7 +133,12 @@ export const WithTooltip: Story = { hideStorybookControls(WithTooltip, ['onChange']); export const CustomMatcher: Story = { - render: (args) => , + parameters: { + codeSnippet: { + resolveStoryElementOnly: true, + }, + }, + render: (args) => , }; export const Groups: Story = { @@ -237,7 +242,7 @@ const StatefulComboBox = ({ ); }; -const StatefulCustomMatcher = ({ +const StoryCustomMatcher = ({ singleSelection, onChange, ...args From cec0c1c74822d531c8a265607250b1979c018ce3 Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Wed, 26 Jun 2024 17:12:10 -0700 Subject: [PATCH 43/51] Revert EuiValidatableControl change - it's now only outputting `` which is even less helpful than before. We can just move ahead with extra cruft in the snippet for now, that's fine --- .../form/validatable_control/validatable_control.stories.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/eui/src/components/form/validatable_control/validatable_control.stories.tsx b/packages/eui/src/components/form/validatable_control/validatable_control.stories.tsx index 821a47facf8..87626c82b65 100644 --- a/packages/eui/src/components/form/validatable_control/validatable_control.stories.tsx +++ b/packages/eui/src/components/form/validatable_control/validatable_control.stories.tsx @@ -26,9 +26,6 @@ const meta: Meta = { // it only adds attributes in the DOM skip: true, }, - codeSnippet: { - resolveStoryElementOnly: true, - }, }, decorators: [ (Story, { args }) => ( From 489cd2d12988b5dbab908e497254630f4b42c701 Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Wed, 26 Jun 2024 17:12:26 -0700 Subject: [PATCH 44/51] Show EuiInMemoryTable code snippet - still useful even without full render functions --- .../src/components/basic_table/in_memory_table.stories.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/eui/src/components/basic_table/in_memory_table.stories.tsx b/packages/eui/src/components/basic_table/in_memory_table.stories.tsx index ddd3d3c5e33..53cd1889839 100644 --- a/packages/eui/src/components/basic_table/in_memory_table.stories.tsx +++ b/packages/eui/src/components/basic_table/in_memory_table.stories.tsx @@ -24,12 +24,6 @@ const meta: Meta = { title: 'Tabular Content/EuiInMemoryTable', // @ts-ignore complex component: EuiInMemoryTable, - parameters: { - codeSnippet: { - // TODO: enable once render functions are supported - skip: true, - }, - }, args: { allowNeutralSort: true, searchFormat: 'eql', From 85ea8c4bd641fcdcf7c6dcf00f17a0f5f649fba3 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Thu, 27 Jun 2024 15:05:09 +0200 Subject: [PATCH 45/51] fix(code-snippet): ensure story element is correctly resolved - adds check if single child is story element to prevent overrides and additional checks to ensure proper element - adds simplification for return undefined --- .../addons/code-snippet/decorators/utils.ts | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts b/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts index fcd571427e4..0c09fdf4802 100644 --- a/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts +++ b/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts @@ -105,7 +105,8 @@ export const getEmotionComponentDisplayName = ( export const getStoryComponentDisplayName = ( context: StoryContext | undefined ): string | undefined => { - if (!context) return undefined; + if (!context) return; + const component = context.component as ComponentType & { __docgenInfo?: { displayName?: string }; }; @@ -135,9 +136,9 @@ export const isStoryComponent = ( const displayName = isClassComponent ? node.constructor?.name : getEmotionComponentDisplayName(node)?.replace(/^_/, ''); - const isCurrentStory = displayName - ? displayName === context?.component?.displayName - : false; + const storyDisplayName = getStoryComponentDisplayName(context); + const isCurrentStory = + displayName && storyDisplayName ? displayName === storyDisplayName : false; return isCurrentStory; }; @@ -264,8 +265,10 @@ export const getStoryComponent = ( const resolvedDisplayName = getEmotionComponentDisplayName(resolvedChild); - if (resolvedDisplayName !== displayName) { + if (resolvedDisplayName && resolvedDisplayName !== displayName) { resolveChildren(resolvedChild); + } else if (typeof resolvedChild.type !== 'string') { + storyNode = resolvedChild; } } } else if ( @@ -285,9 +288,14 @@ export const getStoryComponent = ( } else if ( // CASE: single child element childNode.props?.children && + typeof childNode.props?.children === 'object' && !Array.isArray(childNode.props?.children) ) { - storyNode = childNode.props.children; + const { children } = childNode.props; + + if (isStoryComponent(children, context)) { + storyNode = children; + } } } }; @@ -311,7 +319,7 @@ export const getDefaultPropsfromDocgenInfo = ( component: ReactElementWithDocgenInfo, context: StoryContext ): Record | undefined => { - if (typeof component.type === 'string') return undefined; + if (typeof component.type === 'string') return; // determine the story element first // this is required because the story might be wrapped and @@ -319,7 +327,7 @@ export const getDefaultPropsfromDocgenInfo = ( let storyComponent: ReactElementWithDocgenInfo | undefined = getStoryComponent(component, context); - if (!storyComponent) return undefined; + if (!storyComponent) return; let propsInfo = isEmotionComponent(storyComponent) && From a44df623178d3e38585db4ea8d99d636f5d33d36 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Thu, 27 Jun 2024 15:05:42 +0200 Subject: [PATCH 46/51] docs(storybook): re-enables EuiValidatableControl single story element code snippet --- .../form/validatable_control/validatable_control.stories.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/eui/src/components/form/validatable_control/validatable_control.stories.tsx b/packages/eui/src/components/form/validatable_control/validatable_control.stories.tsx index 87626c82b65..821a47facf8 100644 --- a/packages/eui/src/components/form/validatable_control/validatable_control.stories.tsx +++ b/packages/eui/src/components/form/validatable_control/validatable_control.stories.tsx @@ -26,6 +26,9 @@ const meta: Meta = { // it only adds attributes in the DOM skip: true, }, + codeSnippet: { + resolveStoryElementOnly: true, + }, }, decorators: [ (Story, { args }) => ( From 1c784f40d03130edf7f5638853f606056978aba3 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Thu, 27 Jun 2024 18:32:06 +0200 Subject: [PATCH 47/51] feat(code-snippet): support arg value override replacement marker - this will ensure the passed content is output as is instead of being executed or coerced --- .../code-snippet/decorators/render_jsx.tsx | 21 +++++++++++++++++-- .../date_picker/date_picker.stories.tsx | 2 +- 2 files changed, 20 insertions(+), 3 deletions(-) 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 8bb66fe64c6..80e66a50c76 100644 --- a/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx +++ b/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx @@ -272,11 +272,28 @@ export const renderJsx = ( // ensure tokens are output properly by removing added variable markers if (string.indexOf('{{') > -1) { - const regex = new RegExp(/'{{|}}'/g); + const regex = new RegExp(/'{{|"{{|}}'|}}"/g); const matches = string.match(regex); if (matches) { matches.forEach((match) => { - string = string.replace(match, match.replace(regex, '')); + string = string.replace(match, ''); + }); + } + } + + // removed arg value overwrite markers + // example: + // in: selected: "#{moment('Tue Mar 19 2024 18:54:51 GMT+0100')}" + // out: selected={moment('Tue Mar 19 2024 18:54:51 GMT+0100')} + if (string.indexOf('#{') > -1) { + const variableRegex = new RegExp(/("|')#{.*?}("|')/g); + const variableContentRegex = new RegExp(/(?<="#{).*?(?=}")/g); + const variableMatch = string.match(variableRegex); + + if (variableMatch) { + variableMatch.forEach((match) => { + const content = match.match(variableContentRegex)!; + string = string.replace(match, `{${content[0]}}`); }); } } diff --git a/packages/eui/src/components/date_picker/date_picker.stories.tsx b/packages/eui/src/components/date_picker/date_picker.stories.tsx index 11f7c2edeea..bfe89b5868e 100644 --- a/packages/eui/src/components/date_picker/date_picker.stories.tsx +++ b/packages/eui/src/components/date_picker/date_picker.stories.tsx @@ -128,7 +128,7 @@ export const Playground: Story = { parameters: { codeSnippet: { args: { - selected: 'Tue Mar 19 2024 18:54:51 GMT+0100', + selected: "#{moment('Tue Mar 19 2024 18:54:51 GMT+0100')}", }, }, }, From 58f724e1799c89e8aafbb4c91eb79f85ab1d4b25 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Thu, 27 Jun 2024 18:33:46 +0200 Subject: [PATCH 48/51] fix(code-snippet): ensure isForwardRef type guard captures all cases - ensures forwarded components are properly resolved when checking for the story component instead of being skipped --- .../addons/code-snippet/decorators/utils.ts | 23 +++++++++++++------ .../text_truncate/text_truncate.stories.tsx | 3 +++ 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts b/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts index 0c09fdf4802..c4bc080ddba 100644 --- a/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts +++ b/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts @@ -33,16 +33,22 @@ export const toPascalCase = (str: string) => str.charAt(0).toUpperCase() + str.slice(1); /* Helpers for React specific checks */ +const isReactElement = (el: any): el is ReactElement => el.type !== undefined; +const isExoticComponent = (el: any): el is ExoticComponent => + el.$$typeof !== undefined; + export const isMemo = (component: ExoticComponent) => component.$$typeof === Symbol.for('react.memo'); -export const isForwardRef = (component: ExoticComponent) => - component.$$typeof === Symbol.for('react.forward_ref'); +export const isForwardRef = (component: ReactElement | ExoticComponent) => { + // use type guards to ensure keys are available + return isReactElement(component) && isExoticComponent(component.type) + ? component.type?.$$typeof === Symbol.for('react.forward_ref') + : isExoticComponent(component) + ? component.$$typeof === Symbol.for('react.forward_ref') + : false; +}; export const isFragment = (component: ReactElement | ExoticComponent) => { // use type guards to ensure keys are available - const isReactElement = (el: any): el is ReactElement => el.type !== undefined; - const isExoticComponent = (el: any): el is ExoticComponent => - el.$$typeof !== undefined; - return isReactElement(component) ? component.type?.toString().includes('fragment') : isExoticComponent(component) @@ -265,7 +271,10 @@ export const getStoryComponent = ( const resolvedDisplayName = getEmotionComponentDisplayName(resolvedChild); - if (resolvedDisplayName && resolvedDisplayName !== displayName) { + if ( + (resolvedDisplayName && resolvedDisplayName !== displayName) || + isForwardRef(resolvedChild) + ) { resolveChildren(resolvedChild); } else if (typeof resolvedChild.type !== 'string') { storyNode = resolvedChild; diff --git a/packages/eui/src/components/text_truncate/text_truncate.stories.tsx b/packages/eui/src/components/text_truncate/text_truncate.stories.tsx index b7c36f51a43..28cd979ee22 100644 --- a/packages/eui/src/components/text_truncate/text_truncate.stories.tsx +++ b/packages/eui/src/components/text_truncate/text_truncate.stories.tsx @@ -74,6 +74,9 @@ enableFunctionToggleControls(ResizeObserver, ['onResize']); export const StartEndAnchorForSearch: Story = { parameters: { controls: { include: ['text', 'calculationDelayMs', 'ellipsis', 'width'] }, + codeSnippet: { + resolveStoryElementOnly: true, + }, }, render: function Render(props) { const [highlight, setHighlight] = useState(''); From b5a1e39128d9a019fd1a8ae8797178ee3ffc1deb Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Fri, 28 Jun 2024 09:42:46 +0200 Subject: [PATCH 49/51] docs: update readme and comment --- packages/eui/.storybook/addons/code-snippet/README.md | 7 +++++-- .../addons/code-snippet/decorators/render_jsx.tsx | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/eui/.storybook/addons/code-snippet/README.md b/packages/eui/.storybook/addons/code-snippet/README.md index 0809ed0dc32..936a77fda17 100644 --- a/packages/eui/.storybook/addons/code-snippet/README.md +++ b/packages/eui/.storybook/addons/code-snippet/README.md @@ -157,10 +157,13 @@ const meta = { component: EuiButton, parameters: { codeSnippet: { - // optional way to override selected story args with manual values - // this is useful when the story arg would render unreadable or not useful output + // Optional way to override selected story args with manual values. + // This is useful when the story arg would render unreadable or not useful output. + // You can use interpolation markers #{} to ensure the value is output as is, this + // is useful for e.g. functions to prevent them from being called. args: { propA: 'new value for propA', + propB: "#{someFunctionCall('inputValue')}" // returns: propB={someFunctionCall('inputValue')} }, // will skip code snippet generation for the component or story // @default false 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 80e66a50c76..6a9c8225792 100644 --- a/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx +++ b/packages/eui/.storybook/addons/code-snippet/decorators/render_jsx.tsx @@ -281,7 +281,7 @@ export const renderJsx = ( } } - // removed arg value overwrite markers + // remove arg value override markers #{} and replace them with their content // example: // in: selected: "#{moment('Tue Mar 19 2024 18:54:51 GMT+0100')}" // out: selected={moment('Tue Mar 19 2024 18:54:51 GMT+0100')} From d272e3c1dc35c98197cb9c9e97a672e147c24dd9 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Fri, 28 Jun 2024 09:48:51 +0200 Subject: [PATCH 50/51] docs: update readme --- packages/eui/.storybook/addons/code-snippet/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/eui/.storybook/addons/code-snippet/README.md b/packages/eui/.storybook/addons/code-snippet/README.md index 936a77fda17..2069d076c88 100644 --- a/packages/eui/.storybook/addons/code-snippet/README.md +++ b/packages/eui/.storybook/addons/code-snippet/README.md @@ -88,7 +88,7 @@ Before passing a React element to the `react-element-to-jsx-string` package func 1. Should a story be skipped? ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/utils.ts#L196)) - a story may be skipped: - by using `parameters.codeSnippet.skip` ([example](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/src/components/drag_and_drop/drag_drop_context.stories.tsx#L31)) - - by returning an anonymous function from story `render` + - by returning an anonymous function without `args` from story `render` 2. Is a manual code snippet provided? ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/jsx_decorator.tsx#L100)) ([example](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/src/components/text_diff/text_diff.stories.tsx#L24)) 3. What React element should be used? (only a single React element can be passed to `react-element-to-jsx-string`) ([code](https://github.com/elastic/eui/blob/03d20559b4262d6a18de5fc8edf4ec3854753995/packages/eui/.storybook/addons/code-snippet/decorators/jsx_decorator.tsx#L146)) @@ -174,7 +174,7 @@ const meta = { // 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 + // Useful when the story outputs additional content that should not be included in the // snippet and instead only the actual story component should be output as snippet. // @default false resolveStoryElementOnly: true, From dbb9a2055f517c1cd78e3a806ade3600e8709ba7 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Fri, 28 Jun 2024 10:04:00 +0200 Subject: [PATCH 51/51] feat(code-snippet): remove default props from manual snippet - updates readme to reflect that functionality --- .../.storybook/addons/code-snippet/README.md | 6 ++++-- .../code-snippet/decorators/jsx_decorator.tsx | 19 ++++++++++++++++--- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/eui/.storybook/addons/code-snippet/README.md b/packages/eui/.storybook/addons/code-snippet/README.md index 2069d076c88..6a32ada5883 100644 --- a/packages/eui/.storybook/addons/code-snippet/README.md +++ b/packages/eui/.storybook/addons/code-snippet/README.md @@ -191,9 +191,11 @@ const meta = { ### Manual code snippets -Instead of using the automatic code snippet generation, we can also provide a manual snippet which will be output instead. This is especially useful when the story content is not actually a component (e.g. a hook). You can see an example of this for the story of `useEuiTextDiff`. +Instead of using the automatic code snippet generation, you can also provide a manual snippet which will be output instead. This is especially useful when the story content is not actually a component (e.g. a hook). You can see an example of this for the story of `useEuiTextDiff`. + +To add the story args to the code snippet, add the defined marker `{{STORY_ARGS}}` to the snippet string. If the args should be spread on the root component use `{{...STORY_ARGS}}` instead. +These markers will be replaced automatically with the current story args. It's important to note that the `children` prop is removed and it should be manually added to the snippet input instead. Additionally the story `args` are filtered to remove the default props. This can be changed via the `removeDefaultProps` option. -To add the story args to the code snippet, add the defined marker `{{STORY_ARGS}}` to the snippet string. This marker will be replaced automatically with the current story args. If the args should be spread on the root component use `{{...STORY_ARGS}}` instead. ```ts // {{STORY_ARGS}} 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 e8e08ba36ed..1d332279a20 100644 --- a/packages/eui/.storybook/addons/code-snippet/decorators/jsx_decorator.tsx +++ b/packages/eui/.storybook/addons/code-snippet/decorators/jsx_decorator.tsx @@ -25,7 +25,11 @@ import { STORY_ARGS_MARKER, } from '../constants'; -import { getFormattedCode, skipJsxRender } from './utils'; +import { + getDefaultPropsfromDocgenInfo, + getFormattedCode, + skipJsxRender, +} from './utils'; import { JSXOptions, renderJsx } from './render_jsx'; const defaultJsxOptions = { @@ -104,9 +108,18 @@ export const customJsxDecorator = ( // use manually provided code snippet and replace args if available if (codeSnippet) { const args: typeof context.args = { ...context.args }; + const defaultProps = getDefaultPropsfromDocgenInfo(story, context); for (const key of Object.keys(context.args)) { - if (!context.args[key]) { + // checks story args for: + // - remove if no value + // - remove if `chidlren` + // - remove if arg is a default prop + if ( + !context.args[key] || + key === 'children' || + defaultProps?.includes(key) + ) { delete args[key]; } } @@ -141,7 +154,7 @@ export const customJsxDecorator = ( getFormattedCode(code) .then((res: string) => { - jsx = res; + jsx = res.replace(';\n', '\n'); }) .catch((error: Error): void => { logger.error(