Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions src/components/CodeBlock/CodeBlock.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<CodeBlock language={language}>{children}</CodeBlock>);

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="<project_id>"');
expect(container.textContent).toContain('<project_id>');
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(
<CodeBlock
language="bash"
onCopy={onCopy}
>
{code}
</CodeBlock>
);

await userEvent.click(getByRole('button'));
expect(onCopy).toHaveBeenCalledWith(code);
});
});
28 changes: 22 additions & 6 deletions src/components/CodeBlock/CodeBlock.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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)}</${tagName}>`;
}
return `<${tagName}>`;
}
return String(node);
};

SyntaxHighlighter.registerLanguage('sql', sql.default || sql);
SyntaxHighlighter.registerLanguage('bash', bash.default || bash);
SyntaxHighlighter.registerLanguage('json', json.default || json);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -197,7 +213,7 @@ export const CodeBlock = ({
wrapLines={wrap || wrapLines}
wrapLongLines={wrap || wrapLines}
>
{children}
{codeString}
</Highlighter>
</CodeBlockContainer>
);
Expand Down
4 changes: 2 additions & 2 deletions src/components/CodeBlock/CodeBlock.types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { HTMLAttributes } from 'react';
import { HTMLAttributes, ReactNode } from 'react';

export type CodeThemeType = 'light' | 'dark';

Expand All @@ -7,7 +7,7 @@ export interface CodeBlockProps extends Omit<
'children' | 'onCopy'
> {
language?: string;
children: string;
children: ReactNode;
theme?: CodeThemeType;
showLineNumbers?: boolean;
showWrapButton?: boolean;
Expand Down
Loading