diff --git a/.changeset/cui-335-alert-line-breaks.md b/.changeset/cui-335-alert-line-breaks.md new file mode 100644 index 000000000..ea10770f7 --- /dev/null +++ b/.changeset/cui-335-alert-line-breaks.md @@ -0,0 +1,7 @@ +--- +'@clickhouse/click-ui': minor +--- + +Add `preserveLineBreaks` to `Alert`. When it is true, string `text` keeps `\n` line breaks. + +Visual: unchanged by default. Set `preserveLineBreaks` on `Alert`, `WarningAlert`, `DangerAlert`, `InfoAlert`, or `SuccessAlert` to opt in. ReactNode `text` is unchanged. diff --git a/src/components/Alert/Alert.module.css b/src/components/Alert/Alert.module.css index b66a44eaa..e16ea7652 100644 --- a/src/components/Alert/Alert.module.css +++ b/src/components/Alert/Alert.module.css @@ -154,6 +154,10 @@ margin: 0; } +.alert__text_pre-wrap { + white-space: pre-wrap; +} + .alert__text_size_small { font: var(--click-alert-small-typography-text-default); } diff --git a/src/components/Alert/Alert.stories.tsx b/src/components/Alert/Alert.stories.tsx index 74bece975..6156ad022 100644 --- a/src/components/Alert/Alert.stories.tsx +++ b/src/components/Alert/Alert.stories.tsx @@ -11,6 +11,7 @@ const meta: Meta = { tags: ['alert', 'autodocs'], argTypes: { customIcon: { type: { name: 'enum', value: [...ICON_NAMES] } }, + preserveLineBreaks: { control: 'boolean' }, }, decorators: Story => ( @@ -32,6 +33,7 @@ export const Playground: Story = { type: 'default', showIcon: true, dismissible: false, + preserveLineBreaks: false, }, }; @@ -173,3 +175,94 @@ export const DismissibleBanner: Story = { dismissible: true, }, }; + +const multilineText = + 'Syntax error: failed at position 15.\nExpected one of: token, identifier.\n\nSELECT * FROM events\nWHERE id = 1'; + +// Default: `\n` in text collapses. Opt-in: string text keeps line breaks. + +export const SingleLineText: Story = { + args: { + title: 'Query failed', + text: 'A single-line alert.', + state: 'warning', + size: 'small', + type: 'default', + preserveLineBreaks: false, + }, +}; + +export const CollapsedNewlines: Story = { + args: { + title: 'Query failed Code: 62', + text: multilineText, + state: 'warning', + size: 'small', + type: 'default', + preserveLineBreaks: false, + }, +}; + +export const MultilineText: Story = { + args: { + title: 'Query failed Code: 62', + text: multilineText, + state: 'warning', + size: 'small', + type: 'default', + preserveLineBreaks: true, + }, +}; + +export const LongUnbrokenToken: Story = { + decorators: [ + Story => ( + + + + ), + ], + args: { + title: 'Unknown identifier', + text: 'DB::Exception: Unknown table expression identifier default.extremely_long_table_name_without_any_spaces_that_should_wrap_inside_the_alert_instead_of_overflowing in scope.', + state: 'danger', + size: 'small', + type: 'default', + preserveLineBreaks: true, + }, +}; + +// A multi-line S3 signatures error (SQL-2580 / CUI-335). Leading space before each `-` is intentional. +const multilineS3SignaturesError = `Code: 42. DB::Exception: Storage S3 requires 1 to 9 arguments. All supported signatures: + - url + - url, NOSIGN + - url, format + - url, NOSIGN, format + - url, format, compression_method + - url, NOSIGN, format, compression_method + - url, access_key_id, secret_access_key + - url, access_key_id, secret_access_key, session_token + - url, access_key_id, secret_access_key, format + - url, access_key_id, secret_access_key, session_token, format + - url, access_key_id, secret_access_key, format, compression_method + - url, access_key_id, secret_access_key, session_token, format, compression_method`; + +export const CollapsedSignaturesError: Story = { + args: { + text: multilineS3SignaturesError, + state: 'warning', + size: 'small', + type: 'default', + preserveLineBreaks: false, + }, +}; + +export const PreservedSignaturesError: Story = { + args: { + text: multilineS3SignaturesError, + state: 'warning', + size: 'small', + type: 'default', + preserveLineBreaks: true, + }, +}; diff --git a/src/components/Alert/Alert.test.tsx b/src/components/Alert/Alert.test.tsx index 44e55269c..d7aadd234 100644 --- a/src/components/Alert/Alert.test.tsx +++ b/src/components/Alert/Alert.test.tsx @@ -1,11 +1,19 @@ -import { Alert, AlertProps } from '@/components/Alert'; +import { Alert, AlertProps, WarningAlert } from '@/components/Alert'; import { fireEvent, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { renderCUI } from '@/utils/test-utils'; +import styles from './Alert.module.css'; describe('Alert', () => { const renderAlert = (props: AlertProps) => renderCUI(); + const withExactText = (value: string) => (_content: string, node: Element | null) => + Boolean( + node && + node.textContent === value && + Array.from(node.children).every(child => child.textContent !== value) + ); + it('renders the dismiss button with type="button" so it does not submit a form', () => { const handleSubmit = vi.fn(e => e.preventDefault()); const { getByTestId } = renderCUI( @@ -61,4 +69,60 @@ describe('Alert', () => { userEvent.click(dismissButton); await waitFor(() => expect(queryAllByText(text).length).toEqual(0)); }); + + it('does not forward preserveLineBreaks onto the root element', () => { + const { getByTestId } = renderAlert({ + text: 'body', + preserveLineBreaks: true, + }); + + const root = getByTestId('click-alert'); + expect(root).not.toHaveAttribute('preserveLineBreaks'); + expect(root).not.toHaveAttribute('preservelinebreaks'); + }); + + it('does not apply pre-wrap to string text by default', () => { + const { getByText } = renderAlert({ + text: 'line 1\nline 2', + }); + + const body = getByText(withExactText('line 1\nline 2')); + + expect(body).not.toHaveClass(styles['alert__text_pre-wrap']); + }); + + it('preserves line breaks in string text when preserveLineBreaks is true', () => { + const { getByText } = renderAlert({ + text: 'line 1\nline 2', + preserveLineBreaks: true, + }); + + const body = getByText(withExactText('line 1\nline 2')); + + expect(body).toHaveClass(styles['alert__text_pre-wrap']); + }); + + it('preserves line breaks on WarningAlert when preserveLineBreaks is true', () => { + const { getByText } = renderCUI( + + ); + const body = getByText(withExactText('line 1\nline 2')); + + expect(body).toHaveClass(styles['alert__text_pre-wrap']); + }); + + it('does not apply pre-wrap to ReactNode text when preserveLineBreaks is true', () => { + const { getByText } = renderAlert({ + text: {'line 1\nline 2'}, + preserveLineBreaks: true, + }); + + const body = getByText(withExactText('line 1\nline 2')); + + expect(body.tagName).toBe('SPAN'); + expect(body.parentElement).not.toHaveClass(styles['alert__text_pre-wrap']); + }); }); diff --git a/src/components/Alert/Alert.tsx b/src/components/Alert/Alert.tsx index eae28d68b..6c3db5862 100644 --- a/src/components/Alert/Alert.tsx +++ b/src/components/Alert/Alert.tsx @@ -118,6 +118,7 @@ export const Alert = ({ onDismiss, customIcon, className, + preserveLineBreaks = false, ...delegated }: AlertProps) => { const [isVisible, setIsVisible] = useState(true); @@ -151,7 +152,16 @@ export const Alert = ({ )}
{title &&
{title}
} -
{text}
+
+ {text} +
{dismissible && (