diff --git a/.babelrc-optimize.js b/.babelrc-optimize.js index 6d1b8f012a0..02f1cc8dd0e 100644 --- a/.babelrc-optimize.js +++ b/.babelrc-optimize.js @@ -4,7 +4,7 @@ const baseConfig = require('./.babelrc.js'); // Skip `propType` generation. baseConfig.plugins.splice( baseConfig.plugins.indexOf( - './scripts/babel/proptypes-from-ts-props' + `${__dirname}/scripts/babel/proptypes-from-ts-props` ), 1 ); @@ -12,4 +12,4 @@ baseConfig.plugins.splice( // Requires consuming applications to use `@babel/runtime`. baseConfig.plugins.push('@babel/plugin-transform-runtime'); baseConfig.env = {}; -module.exports = baseConfig; \ No newline at end of file +module.exports = baseConfig; diff --git a/.babelrc.js b/.babelrc.js index 09f3ccbce9b..5bf29e39aa2 100644 --- a/.babelrc.js +++ b/.babelrc.js @@ -23,7 +23,7 @@ module.exports = { "plugins": [ "@babel/plugin-syntax-dynamic-import", "pegjs-inline-precompile", - "./scripts/babel/proptypes-from-ts-props", + `${__dirname}/scripts/babel/proptypes-from-ts-props`, "add-module-exports", // stage 3 "@babel/proposal-object-rest-spread", diff --git a/scripts/jest/config.json b/scripts/jest/config.json index eb18fffaa1a..69eab095809 100644 --- a/scripts/jest/config.json +++ b/scripts/jest/config.json @@ -2,6 +2,7 @@ "rootDir": "../../", "roots": [ "/src/", + "/src-docs/src/components", "/scripts/babel", "/packages/eslint-plugin" ], diff --git a/src-docs/.babelrc.js b/src-docs/.babelrc.js index a80333ae140..72f92c2eaf7 100644 --- a/src-docs/.babelrc.js +++ b/src-docs/.babelrc.js @@ -1,10 +1,10 @@ const baseConfig = require('../.babelrc.js'); const index = baseConfig.plugins.indexOf( - './scripts/babel/proptypes-from-ts-props' + `${__dirname}/scripts/babel/proptypes-from-ts-props` ); baseConfig.plugins.splice( index + 1, 0, - './scripts/babel/react-docgen-typescript' + `${__dirname}/../scripts/babel/react-docgen-typescript` ); module.exports = baseConfig; diff --git a/src-docs/src/components/codesandbox/link.js b/src-docs/src/components/codesandbox/link.js index 01ca14a7319..3f1fc40f96a 100644 --- a/src-docs/src/components/codesandbox/link.js +++ b/src-docs/src/components/codesandbox/link.js @@ -231,7 +231,7 @@ ReactDOM.render( > {/* 7 */} - + {childWithSubmit} ); diff --git a/src-docs/src/components/guide_section/_utils.js b/src-docs/src/components/guide_section/_utils.js index ddce7322575..33661ef692b 100644 --- a/src-docs/src/components/guide_section/_utils.js +++ b/src-docs/src/components/guide_section/_utils.js @@ -10,17 +10,36 @@ import { cleanEuiImports } from '../../services'; export const renderJsSourceCode = (code) => { let renderedCode = cleanEuiImports(code.default); - /* ----- Combine and clean EUI imports ----- */ - let elasticImports = ['']; + /** + * Extract React import (to ensure it's always at the top) + */ + let reactImport = ''; + + renderedCode = renderedCode.replace( + // import - import + space + // (React)? - optional import `React` prefix - some files (like hooks) do not need it + // (, )? - optional comma after React - some files, like tests, only need the main React import + // ({([^}]+?)})? - optionally capture anything that isn't a closing brace between the import braces + // from 'react'; - ` from 'react';` exactly + /import (React)?(, )?({([^}]+?)})? from 'react';/, + (match) => { + reactImport = match; + return ''; + } + ); + + /** + * Combine and clean EUI imports + */ + const elasticImports = []; // Find all imports that come from '@elastic/eui' renderedCode = renderedCode.replace( - // [\r\n] - start of a line - // import\s+\{ - import / whitespace / opening brace - // ([^}]+) - group together anything that isn't a closing brace - // \}\s+from\s+'@elastic\/eui'; - closing brace / whitespace / from / whitespace / '@elastic/eui'; - // [\r\n] - match end of line, so the extra new line is removed via the replace operation - /[\r\n]import\s+\{([^}]+)\}\s+from\s+'@elastic\/eui';/g, + // import { - import / whitespace / opening brace + // ([^}]+) - group together anything that isn't a closing brace + // } from '@elastic\/eui'; - closing brace / whitespace / from / whitespace / '@elastic/eui'; + // [\r\n] - match end of line, so the extra new line is removed via the replace operation + /import {([^}]+)} from '@elastic\/eui';[\r\n]/g, (match, imports) => { // remove any additional characters from imports const namedImports = imports.match(/[a-zA-Z0-9]+/g); @@ -29,35 +48,49 @@ export const renderJsSourceCode = (code) => { } ); - // Remove empty spaces in the array - elasticImports = elasticImports.filter((ele) => ele); - let formattedEuiImports = ''; - // determine if imports should be wrapped to new lines based on the import statement length - const combinedImports = elasticImports.join(', '); - const singleLineImports = `import { ${combinedImports} } from '@elastic/eui';`; + if (elasticImports.length) { + // Determine if imports should be wrapped to new lines based on the import statement length + const combinedImports = elasticImports.join(', '); + const singleLineImports = `import { ${combinedImports} } from '@elastic/eui';`; - if (singleLineImports.length <= 81) { - formattedEuiImports = singleLineImports; - } else { - const lineSeparatedImports = elasticImports.join(',\n '); - formattedEuiImports = `import {\n ${lineSeparatedImports},\n} from '@elastic/eui';`; + if (singleLineImports.length <= 81) { + formattedEuiImports = singleLineImports; + } else { + const lineSeparatedImports = elasticImports.join(',\n '); + formattedEuiImports = `import {\n ${lineSeparatedImports},\n} from '@elastic/eui';`; + } } - // Find any non-EUI imports and join them with new lines between each import for uniformity - const nonEuiImports = []; + /** + * Extract remaining non-React/EUI imports + */ + const remainingImports = []; renderedCode = renderedCode.replace( - /import\s+([^]+?)\s+from\s+(\'[A-Za-z0-9 _./-]*\'\;)/g, + // (\/\/.+\n)? - optional preceding comments that must be above specific imports, e.g. // @ts-ignore + // import - import + whitespace + // ([^]+?) - capture any characters (including newlines) + // from ('[A-Za-z0-9 -_.@/]*';) - ` from 'someLibrary';` - alphanumeric and certain special characters only + /(\/\/.+\n)?import ([^]+?) from ('[A-Za-z0-9 -_.@/]*';)/g, (match) => { - nonEuiImports.push(match); + remainingImports.push(match); return ''; } ); - const formattedNonEuiImports = nonEuiImports.join('\n'); + /** + * Putting it all together + */ + // Render each import with just 1 newline between them for uniformity + const renderedImports = [ + reactImport, + formattedEuiImports, + ...remainingImports, + ] + .filter((stripEmptyImports) => stripEmptyImports) + .join('\n'); - const fullyFormattedCode = `${formattedEuiImports}\n${formattedNonEuiImports}\n\n${renderedCode.trim()}`; - return fullyFormattedCode; + return `${renderedImports}\n\n${renderedCode.trim()}`; }; diff --git a/src-docs/src/components/guide_section/_utils.test.js b/src-docs/src/components/guide_section/_utils.test.js new file mode 100644 index 00000000000..1a8fdf885b0 --- /dev/null +++ b/src-docs/src/components/guide_section/_utils.test.js @@ -0,0 +1,286 @@ +const dedent = require('dedent'); +const { renderJsSourceCode } = require('./_utils.js'); + +describe('renderJsSourceCode', () => { + describe('EUI imports', () => { + it('automatically converts relative `src` imports to `@elastic/eui`', () => { + expect( + renderJsSourceCode({ + default: dedent(` + import { EuiButton } from '../src/components'; + + export default () => Hello world!;`), + }) + ).toEqual( + dedent(` + import { EuiButton } from '@elastic/eui'; + + export default () => Hello world!;`) + ); + }); + + it('combines multiple relative EUI imports into a single absolute import', () => { + expect( + renderJsSourceCode({ + default: dedent(` + import { EuiCode } from '../../../src/components/code'; + import { EuiCallOut } from '../../../src/components/callout'; + import { useGeneratedHtmlId } from '../../../src/services'; + + export default () => {useGeneratedHtmlId()};`), + }) + ).toEqual( + dedent(` + import { EuiCode, EuiCallOut, useGeneratedHtmlId } from '@elastic/eui'; + + export default () => {useGeneratedHtmlId()};`) + ); + }); + + it('sets each import on a new line if the line would have been longer than 81 characters', () => { + expect( + renderJsSourceCode({ + default: dedent(` + import { EuiButton, EuiFlexGroup, EuiFlexItem } from '../../../src/components'; + import { useGeneratedHtmlId } from '../../../src/services'; + + export default () => {useGeneratedHtmlId()};`), + }) + ).toEqual( + dedent(` + import { + EuiButton, + EuiFlexGroup, + EuiFlexItem, + useGeneratedHtmlId, + } from '@elastic/eui'; + + export default () => {useGeneratedHtmlId()};`) + ); + }); + }); + + describe('React import', () => { + it('always moves the React import to the top of the file', () => { + expect( + renderJsSourceCode({ + default: dedent(` + import { EuiButton } from '../../src/components'; + import React, { useState } from 'react'; + + export default () => Hello world!;`), + }) + ).toEqual( + dedent(` + import React, { useState } from 'react'; + import { EuiButton } from '@elastic/eui'; + + export default () => Hello world!;`) + ); + }); + }); + + describe('remaining imports', () => { + it('moves all remaining imports below React/EUI but otherwise leaves them intact', () => { + expect( + renderJsSourceCode({ + default: dedent(` + import { css } from '@emotion/react'; + import classNames from 'classnames'; + import React, { useState } from 'react'; + import { EuiButton } from '../../src/components'; + import { data } from '../data_store'; + import mockDepA from 'fake-dep'; + import mockDepB from 'fake_dep'; + import mockDepC from 'fake.dep'; + import { + _mockDepMultiline, + mockDepMultiline, + } from 'fakeDep000'; + + export default () => Hello world!;`), + }) + ).toEqual( + dedent(` + import React, { useState } from 'react'; + import { EuiButton } from '@elastic/eui'; + import { css } from '@emotion/react'; + import classNames from 'classnames'; + import { data } from '../data_store'; + import mockDepA from 'fake-dep'; + import mockDepB from 'fake_dep'; + import mockDepC from 'fake.dep'; + import { + _mockDepMultiline, + mockDepMultiline, + } from 'fakeDep000'; + + export default () => Hello world!;`) + ); + }); + + it('keeps // comments preceding an import line together with its import', () => { + expect( + renderJsSourceCode({ + default: dedent(` + import React, { useState } from 'react'; + import { EuiButton } from '@elastic/eui'; + // @ts-ignore no types definitions + import hello from 'world'; + + export default () => Hello world!;`), + }) + ).toEqual( + dedent(` + import React, { useState } from 'react'; + import { EuiButton } from '@elastic/eui'; + // @ts-ignore no types definitions + import hello from 'world'; + + export default () => Hello world!;`) + ); + }); + }); + + describe('newline behavior', () => { + it('normalizes newlines between all imports', () => { + expect( + renderJsSourceCode({ + default: dedent(` + import React, { useState } from 'react'; + + import { EuiButton } from '../../src/components'; + + import { useGeneratedHtmlId } from '../../../src/services'; + + import hello from 'world'; + + import { data } from '../data_store'; + + export default () => Hello world!;`), + }) + ).toEqual( + dedent(` + import React, { useState } from 'react'; + import { EuiButton, useGeneratedHtmlId } from '@elastic/eui'; + import hello from 'world'; + import { data } from '../data_store'; + + export default () => Hello world!;`) + ); + }); + }); + + describe('import permutations', () => { + // These seem dumb but are here primarily to catch newline regressions + + it('handles only EUI imports', () => { + expect( + renderJsSourceCode({ + default: dedent(` + import { EuiButton } from '@elastic/eui'; + + export default () => 'Hello world!';`), + }) + ).toEqual( + dedent(` + import { EuiButton } from '@elastic/eui'; + + export default () => 'Hello world!';`) + ); + }); + + it('handles only React imports', () => { + expect( + renderJsSourceCode({ + default: dedent(` + import React from 'react'; + + export default () => 'Hello world!';`), + }) + ).toEqual( + dedent(` + import React from 'react'; + + export default () => 'Hello world!';`) + ); + }); + + it('handles only non-React/EUI imports', () => { + expect( + renderJsSourceCode({ + default: dedent(` + import hello from 'world'; + + export default () => 'Hello world!';`), + }) + ).toEqual( + dedent(` + import hello from 'world'; + + export default () => 'Hello world!';`) + ); + }); + + it('handles code with no EUI imports', () => { + expect( + renderJsSourceCode({ + default: dedent(` + import React from 'react'; + import hello from 'world'; + + export default () => 'Hello world!';`), + }) + ).toEqual( + dedent(` + import React from 'react'; + import hello from 'world'; + + export default () => 'Hello world!';`) + ); + }); + + it('handles code with no React imports', () => { + expect( + renderJsSourceCode({ + default: dedent(` + import { is } from '@elastic/eui'; + import hello from 'world'; + + export default () => 'Hello world!';`), + }) + ).toEqual( + dedent(` + import { is } from '@elastic/eui'; + import hello from 'world'; + + export default () => 'Hello world!';`) + ); + }); + + it('handles code with no non-EUI/React imports', () => { + expect( + renderJsSourceCode({ + default: dedent(` + import React, { + useState, + } from 'react'; + import { + EuiButton, + EuiCode, + } from '@elastic/eui'; + + export default () => 'Hello world!';`), + }) + ).toEqual( + dedent(` + import React, { + useState, + } from 'react'; + import { EuiButton, EuiCode } from '@elastic/eui'; + + export default () => 'Hello world!';`) + ); + }); + }); +}); diff --git a/src-docs/src/components/guide_section/guide_section_parts/guide_section_code.tsx b/src-docs/src/components/guide_section/guide_section_parts/guide_section_code.tsx index a85e3dc0f7b..c230ff07bdb 100644 --- a/src-docs/src/components/guide_section/guide_section_parts/guide_section_code.tsx +++ b/src-docs/src/components/guide_section/guide_section_parts/guide_section_code.tsx @@ -20,25 +20,25 @@ export const GuideSectionExampleCode: FunctionComponent () => [GuideSectionTypes.JS, GuideSectionTypes.TSX].includes(type), [type] ); + const sourceCode = useMemo( + () => (isJavascript ? renderJsSourceCode(code) : code), + [isJavascript, code] + ); const [codeToRender, setCodeToRender] = useState(); useEffect(() => { - if (isJavascript) { - setCodeToRender(renderJsSourceCode(code)); - } else { - setCodeToRender(code); - } + setCodeToRender(sourceCode); return () => { setCodeToRender(undefined); }; - }, [code, isJavascript]); + }, [sourceCode]); const codeSandboxLink = isJavascript ? (