diff --git a/src/components/CodeBlock/CodeBlock.test.tsx b/src/components/CodeBlock/CodeBlock.test.tsx new file mode 100644 index 000000000..117b9525f --- /dev/null +++ b/src/components/CodeBlock/CodeBlock.test.tsx @@ -0,0 +1,54 @@ +import { renderCUI } from '@/utils/test-utils'; +import userEvent from '@testing-library/user-event'; +import { CodeBlock } from '@/components/CodeBlock'; + +describe('CodeBlock', () => { + const renderCodeBlock = (children: React.ReactNode, language = 'bash') => + renderCUI({children}); + + it('renders a plain string', () => { + const { container } = renderCodeBlock('echo hello'); + expect(container.textContent).toContain('echo hello'); + }); + + it('renders $VARIABLE syntax as literal text', () => { + const { container } = renderCodeBlock('export FOO="$BAR"'); + expect(container.textContent).toContain('$BAR'); + }); + + it('renders angle-bracket placeholders as literal text', () => { + const { container } = renderCodeBlock('export PROJECT_ID=""'); + expect(container.textContent).toContain(''); + expect(container.textContent).not.toContain('[object Object]'); + }); + + it('copies the plain-text string to clipboard', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + + const code = 'echo hello'; + const { getByRole } = renderCodeBlock(code); + + await userEvent.click(getByRole('button')); + expect(writeText).toHaveBeenCalledWith(code); + }); + + it('calls onCopy with the plain-text string', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + const onCopy = vi.fn(); + + const code = 'echo hello'; + const { getByRole } = renderCUI( + + {code} + + ); + + await userEvent.click(getByRole('button')); + expect(onCopy).toHaveBeenCalledWith(code); + }); +}); diff --git a/src/components/CodeBlock/CodeBlock.tsx b/src/components/CodeBlock/CodeBlock.tsx index d38edfa59..e353e425e 100644 --- a/src/components/CodeBlock/CodeBlock.tsx +++ b/src/components/CodeBlock/CodeBlock.tsx @@ -1,9 +1,7 @@ -import React, { HTMLAttributes, useState } from 'react'; +import React, { HTMLAttributes, ReactNode, useState } from 'react'; import { Light as SyntaxHighlighter, createElement } from 'react-syntax-highlighter'; - import { EmptyButton } from '@/components/EmptyButton'; import { IconButton } from '@/components/IconButton'; - import { styled } from 'styled-components'; import useColorStyle from './useColorStyle'; import { CodeBlockProps, CodeThemeType } from './CodeBlock.types'; @@ -21,6 +19,23 @@ import tsx from 'react-syntax-highlighter/dist/cjs/languages/hljs/typescript.js' import plaintext from 'react-syntax-highlighter/dist/cjs/languages/hljs/plaintext.js'; /* eslint-enable import/extensions */ +const nodeToString = (node: ReactNode): string => { + if (node === null || node === undefined || typeof node === 'boolean') { return ''; } + if (typeof node === 'string') { return node; } + if (typeof node === 'number') { return String(node); } + if (Array.isArray(node)) { return node.map(nodeToString).join(''); } + if (React.isValidElement(node)) { + const element = node as React.ReactElement<{ children?: ReactNode }>; + const tagName = typeof element.type === 'string' ? element.type : ''; + const children = element.props.children; + if (children !== undefined && children !== null) { + return `<${tagName}>${nodeToString(children)}`; + } + return `<${tagName}>`; + } + return String(node); +}; + SyntaxHighlighter.registerLanguage('sql', sql.default || sql); SyntaxHighlighter.registerLanguage('bash', bash.default || bash); SyntaxHighlighter.registerLanguage('json', json.default || json); @@ -109,12 +124,13 @@ export const CodeBlock = ({ const [errorCopy, setErrorCopy] = useState(false); const [wrap, setWrap] = useState(wrapLines); const customStyle = useColorStyle(theme); + const codeString = nodeToString(children); const copyCodeToClipboard = async () => { try { - await navigator.clipboard.writeText(children); + await navigator.clipboard.writeText(codeString); if (typeof onCopy == 'function') { - onCopy(children); + onCopy(codeString); } setCopied(true); setTimeout(() => setCopied(false), 2000); @@ -197,7 +213,7 @@ export const CodeBlock = ({ wrapLines={wrap || wrapLines} wrapLongLines={wrap || wrapLines} > - {children} + {codeString} ); diff --git a/src/components/CodeBlock/CodeBlock.types.ts b/src/components/CodeBlock/CodeBlock.types.ts index 5319f710e..b3de0ed39 100644 --- a/src/components/CodeBlock/CodeBlock.types.ts +++ b/src/components/CodeBlock/CodeBlock.types.ts @@ -1,4 +1,4 @@ -import { HTMLAttributes } from 'react'; +import { HTMLAttributes, ReactNode } from 'react'; export type CodeThemeType = 'light' | 'dark'; @@ -7,7 +7,7 @@ export interface CodeBlockProps extends Omit< 'children' | 'onCopy' > { language?: string; - children: string; + children: ReactNode; theme?: CodeThemeType; showLineNumbers?: boolean; showWrapButton?: boolean;