diff --git a/CHANGELOG.md b/CHANGELOG.md index b8f9b953f99..840b37377a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ ## [`master`](https://github.com/elastic/eui/tree/master) -No public interface changes since `23.2.0`. +- Added `testenv` mock for `EuiCode` and `EuiCodeBlock` ([#3405](https://github.com/elastic/eui/pull/3405)) + +**Bug Fixes** + +- Fixed `EuiCode` and `EuiCodeBlock` from erroring in environments without a DOM implementation ([#3405](https://github.com/elastic/eui/pull/3405)) ## [`23.2.0`](https://github.com/elastic/eui/tree/v23.2.0) diff --git a/src/components/code/_code_block.testenv.tsx b/src/components/code/_code_block.testenv.tsx new file mode 100644 index 00000000000..b1480600eb5 --- /dev/null +++ b/src/components/code/_code_block.testenv.tsx @@ -0,0 +1,38 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +export const EuiCodeBlockImpl = ({ children, inline }: any) => { + const snippet = {children}; + return inline ? ( + {snippet} + ) : ( +
+
{snippet}
+
+ ); +}; + +export const FONT_SIZES: Array<'s' | 'm' | 'l'> = ['s', 'm', 'l']; +export const PADDING_SIZES: Array<'s' | 'm' | 'l' | 'none'> = [ + 'none', + 's', + 'm', + 'l', +]; diff --git a/src/components/code/_code_block.tsx b/src/components/code/_code_block.tsx index 8a43067feb2..f35bfce9ee6 100644 --- a/src/components/code/_code_block.tsx +++ b/src/components/code/_code_block.tsx @@ -17,7 +17,14 @@ * under the License. */ -import React, { Component, KeyboardEvent, CSSProperties } from 'react'; +import React, { + FunctionComponent, + KeyboardEvent, + CSSProperties, + useEffect, + useRef, + useState, +} from 'react'; import { createPortal } from 'react-dom'; import classNames from 'classnames'; import hljs from 'highlight.js'; @@ -55,7 +62,7 @@ export const PADDING_SIZES = keysOf(paddingSizeToClassNameMap); interface Props { className?: string; - fontSize: FontSize; + fontSize?: FontSize; /** * Displays the passed code in an inline format. Also removes any margins set. @@ -65,15 +72,15 @@ interface Props { /** * Displays an icon button to copy the code snippet to the clipboard. */ - isCopyable: boolean; + isCopyable?: boolean; /** * Sets the syntax highlighting for a specific language */ language?: string; overflowHeight?: number; - paddingSize: PaddingSize; - transparentBackground: boolean; + paddingSize?: PaddingSize; + transparentBackground?: boolean; /** * Specify how `white-space` inside the element is handled. * `pre` respects line breaks/white space but doesn't force them to wrap the line @@ -82,36 +89,35 @@ interface Props { whiteSpace?: 'pre' | 'pre-wrap'; } -interface State { - isFullScreen: boolean; -} - /** * This is the base component extended by EuiCode and EuiCodeBlock. * These components share the same propTypes definition with EuiCodeBlockImpl. */ -export class EuiCodeBlockImpl extends Component { - static defaultProps = { - transparentBackground: false, - paddingSize: 'l', - fontSize: 's', - isCopyable: false, - whiteSpace: 'pre-wrap', - }; - - constructor(props: Props) { - super(props); - - this.state = { - isFullScreen: false, - }; - } - - codeTarget = document.createElement('div'); - code: HTMLElement | null = null; - codeFullScreen: HTMLElement | null = null; - - highlight = () => { +export const EuiCodeBlockImpl: FunctionComponent = ({ + transparentBackground = false, + paddingSize = 'l', + fontSize = 's', + isCopyable = false, + whiteSpace = 'pre-wrap', + language, + inline, + children, + className, + overflowHeight, + ...rest +}) => { + const [isFullScreen, setIsFullScreen] = useState(false); + const [isPortalTargetReady, setIsPortalTargetReady] = useState(false); + const codeTarget = useRef(null); + const code = useRef(null); + const codeFullScreen = useRef(null); + + useEffect(() => { + codeTarget.current = document.createElement('div'); + setIsPortalTargetReady(true); + }, []); + + useEffect(() => { /** * because React maintains a mapping between its Virtual DOM representation and the actual * DOM elements (including text nodes), and hljs modifies the DOM structure which leads @@ -119,252 +125,212 @@ export class EuiCodeBlockImpl extends Component { * copy from that fragment into the target elements * (https://github.com/elastic/eui/issues/2322) */ - const html = this.codeTarget.innerHTML; + const html = isPortalTargetReady ? codeTarget.current!.innerHTML : ''; - if (this.code) { - this.code.innerHTML = html; + if (code.current) { + code.current.innerHTML = html; } - if (this.codeFullScreen) { - this.codeFullScreen.innerHTML = html; + if (codeFullScreen.current) { + codeFullScreen.current.innerHTML = html; } - if (this.props.language) { - if (this.code) { - hljs.highlightBlock(this.code); + if (language) { + if (code.current) { + hljs.highlightBlock(code.current); } - if (this.codeFullScreen) { - hljs.highlightBlock(this.codeFullScreen); + if (codeFullScreen.current) { + hljs.highlightBlock(codeFullScreen.current); } } - }; + }); - onKeyDown = (event: KeyboardEvent) => { + const onKeyDown = (event: KeyboardEvent) => { if (event.keyCode === keyCodes.ESCAPE) { event.preventDefault(); event.stopPropagation(); - this.closeFullScreen(); + closeFullScreen(); } }; - toggleFullScreen = () => { - this.setState(prevState => ({ - isFullScreen: !prevState.isFullScreen, - })); + const toggleFullScreen = () => { + setIsFullScreen(!isFullScreen); }; - closeFullScreen = () => { - this.setState({ - isFullScreen: false, - }); + const closeFullScreen = () => { + setIsFullScreen(false); }; - componentDidMount() { - this.highlight(); + const classes = classNames( + 'euiCodeBlock', + fontSizeToClassNameMap[fontSize], + paddingSizeToClassNameMap[paddingSize], + { + 'euiCodeBlock--transparentBackground': transparentBackground, + 'euiCodeBlock--inline': inline, + 'euiCodeBlock--hasControls': isCopyable || overflowHeight, + }, + className + ); + + const codeClasses = classNames('euiCodeBlock__code', language); + + const preClasses = classNames('euiCodeBlock__pre', { + 'euiCodeBlock__pre--whiteSpacePre': whiteSpace === 'pre', + 'euiCodeBlock__pre--whiteSpacePreWrap': whiteSpace === 'pre-wrap', + }); + + const optionalStyles: CSSProperties = {}; + + if (overflowHeight) { + optionalStyles.maxHeight = overflowHeight; } - componentDidUpdate() { - this.highlight(); - } + const codeSnippet = ; - render() { - const { - inline, - children, - className, - fontSize, - language, - overflowHeight, - paddingSize, - transparentBackground, - isCopyable, - whiteSpace, - ...otherProps - } = this.props; - - const classes = classNames( - 'euiCodeBlock', - fontSizeToClassNameMap[fontSize], - paddingSizeToClassNameMap[paddingSize], - { - 'euiCodeBlock--transparentBackground': transparentBackground, - 'euiCodeBlock--inline': inline, - 'euiCodeBlock--hasControls': isCopyable || overflowHeight, - }, - className - ); - - const codeClasses = classNames('euiCodeBlock__code', language); - - const preClasses = classNames('euiCodeBlock__pre', { - 'euiCodeBlock__pre--whiteSpacePre': whiteSpace === 'pre', - 'euiCodeBlock__pre--whiteSpacePreWrap': whiteSpace === 'pre-wrap', - }); + const wrapperProps = { + className: classes, + style: optionalStyles, + }; - const optionalStyles: CSSProperties = {}; + if (inline) { + return isPortalTargetReady ? ( + <> + {createPortal(children, codeTarget.current!)} + {codeSnippet} + + ) : null; + } - if (overflowHeight) { - optionalStyles.maxHeight = overflowHeight; + const getCopyButton = (textToCopy?: string) => { + let copyButton: JSX.Element | undefined; + + if (isCopyable && textToCopy) { + copyButton = ( +
+ + {(copyButton: string) => ( + + {copy => ( + + )} + + )} + +
+ ); } - const codeSnippet = ( - { - this.code = ref; - }} - className={codeClasses} - {...otherProps} - /> - ); + return copyButton; + }; - const wrapperProps = { - className: classes, - style: optionalStyles, - }; - - if (inline) { - return ( - <> - {createPortal(children, this.codeTarget)} - {codeSnippet} - - ); - } + let fullScreenButton: JSX.Element | undefined; + + if (!inline && overflowHeight) { + fullScreenButton = ( + + {([fullscreenCollapse, fullscreenExpand]: string[]) => ( + + )} + + ); + } - const getCopyButton = (textToCopy?: string) => { - let copyButton: JSX.Element | undefined; - - if (isCopyable && textToCopy) { - copyButton = ( -
- - {(copyButton: string) => ( - - {copy => ( - - )} - - )} - -
- ); - } + const getCodeBlockControls = (textToCopy?: string) => { + let codeBlockControls; + const copyButton = getCopyButton(textToCopy); - return copyButton; - }; - - let fullScreenButton: JSX.Element | undefined; - - if (!inline && overflowHeight) { - fullScreenButton = ( - - {([fullscreenCollapse, fullscreenExpand]: string[]) => ( - - )} - + if (copyButton || fullScreenButton) { + codeBlockControls = ( +
+ {fullScreenButton} + {copyButton} +
); } - const getCodeBlockControls = (textToCopy?: string) => { - let codeBlockControls; - const copyButton = getCopyButton(textToCopy); - - if (copyButton || fullScreenButton) { - codeBlockControls = ( -
- {fullScreenButton} - {copyButton} -
- ); - } + return codeBlockControls; + }; - return codeBlockControls; - }; - - const getFullScreenDisplay = (codeBlockControls?: JSX.Element) => { - let fullScreenDisplay; - - if (this.state.isFullScreen) { - // Force fullscreen to use large font and padding. - const fullScreenClasses = classNames( - 'euiCodeBlock', - fontSizeToClassNameMap[fontSize], - 'euiCodeBlock-paddingLarge', - 'euiCodeBlock-isFullScreen', - className - ); - - fullScreenDisplay = ( - - -
-
-                   {
-                      this.codeFullScreen = ref;
-                    }}
-                    className={codeClasses}
-                    tabIndex={0}
-                    onKeyDown={this.onKeyDown}
-                  />
-                
+ const getFullScreenDisplay = (codeBlockControls?: JSX.Element) => { + let fullScreenDisplay; + + if (isFullScreen) { + // Force fullscreen to use large font and padding. + const fullScreenClasses = classNames( + 'euiCodeBlock', + fontSizeToClassNameMap[fontSize], + 'euiCodeBlock-paddingLarge', + 'euiCodeBlock-isFullScreen', + className + ); - {codeBlockControls} -
-
-
- ); - } + fullScreenDisplay = ( + + +
+
+                
+              
+ + {codeBlockControls} +
+
+
+ ); + } - return fullScreenDisplay; - }; + return fullScreenDisplay; + }; - return ( - <> - {createPortal(children, this.codeTarget)} - - {(innerTextRef, innerText) => { - const codeBlockControls = getCodeBlockControls(innerText); - return ( -
-
-                  {codeSnippet}
-                
- - {/* + return isPortalTargetReady ? ( + <> + {createPortal(children, codeTarget.current!)} + + {(innerTextRef, innerText) => { + const codeBlockControls = getCodeBlockControls(innerText); + return ( +
+
+                {codeSnippet}
+              
+ + {/* If the below fullScreen code renders, it actually attaches to the body because of EuiOverlayMask's React portal usage. */} - {codeBlockControls} - {getFullScreenDisplay(codeBlockControls)} -
- ); - }} -
- - ); - } -} + {codeBlockControls} + {getFullScreenDisplay(codeBlockControls)} +
+ ); + }} +
+ + ) : null; +}; diff --git a/src/components/code/code_block.test.tsx b/src/components/code/code_block.test.tsx index f9fae994d58..68f8929891e 100644 --- a/src/components/code/code_block.test.tsx +++ b/src/components/code/code_block.test.tsx @@ -21,6 +21,7 @@ import React, { useState, useEffect } from 'react'; import ReactDOM from 'react-dom'; import { mount, ReactWrapper } from 'enzyme'; import html from 'html'; +import { act } from 'react-dom/test-utils'; import { requiredProps } from '../../test/required_props'; import { EuiCodeBlock } from './code_block'; @@ -130,8 +131,13 @@ describe('EuiCodeBlock', () => { const [value, setValue] = useState('State 1'); useEffect(() => { - takeSnapshot(); - setValue('State 2'); + // Wait a tick for EuiCodeBlock internal state to update on mount + setTimeout(() => { + takeSnapshot(); + act(() => { + setValue('State 2'); + }); + }); }, []); useEffect(() => {