Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest';

import {
aliasTermsForName,
asciidocToMarkdown,
buildEmptyMessage,
byProminence,
COMPONENT_ALIASES,
Expand Down Expand Up @@ -117,30 +116,6 @@ describe('searchableText', () => {
});
});

describe('asciidocToMarkdown', () => {
it('turns AsciiDoc section titles into Markdown headings instead of leaking "=="', () => {
const out = asciidocToMarkdown('== Performance\nThis output benefits from batching.');
expect(out).toBe('#### Performance\nThis output benefits from batching.');
expect(out).not.toContain('== ');
});

it('handles multiple heading levels and keeps paragraphs separated', () => {
const out = asciidocToMarkdown('Intro paragraph.\n\n=== Delivery Guarantees\nAt least once.');
expect(out).toBe('Intro paragraph.\n\n#### Delivery Guarantees\nAt least once.');
});

it('converts link/xref macros to label text and bare URL macros to Markdown links', () => {
expect(asciidocToMarkdown('See xref:guides:about.adoc[the guide] for details.')).toBe('See the guide for details.');
expect(asciidocToMarkdown('Uses https://github.com/twmb/franz-go[franz-go] under the hood.')).toBe(
'Uses [franz-go](https://github.com/twmb/franz-go) under the hood.'
);
});

it('converts AsciiDoc bullets to Markdown list items', () => {
expect(asciidocToMarkdown('* first\n* second')).toBe('- first\n- second');
});
});

describe('buildEmptyMessage', () => {
it('reports an empty catalog when there is no query', () => {
expect(buildEmptyMessage('')).toBe('No components available.');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,37 +174,6 @@ export function matchRank(component: ConnectComponentSpec, query: string, text:
return text.includes(query) ? 3 : -1;
}

// Reduce one-line AsciiDoc summaries (link macros, code spans) to plain label text on a single line.
export function cleanText(text: string): string {
return text
.replace(/(?:xref|link):[^\s[]*\[([^\]]*)\]/g, '$1')
.replace(/https?:\/\/[^\s[]+\[([^\]]*)\]/g, '$1')
.replace(/`([^`]+)`/g, '$1')
.replace(/\s+/g, ' ')
.trim();
}

// Convert the AsciiDoc constructs Connect uses (titles, link macros, bullets) to Markdown for react-markdown.
// Unlike cleanText, newlines are preserved so titles/paragraphs stay distinct.
export function asciidocToMarkdown(raw: string): string {
return (
raw
.replace(/\r\n/g, '\n')
// Link macros → label text.
.replace(/(?:xref|link):[^\s[\]]*\[([^\]]*)\]/g, '$1')
// Bare URL macro → Markdown link.
.replace(/(https?:\/\/[^\s[\]]+)\[([^\]]*)\]/g, '[$2]($1)')
// Section titles (`==`/`===`/… Title) → small heading.
.replace(/^=+\s+(.{1,60})$/gm, '#### $1')
// Strip leftover markers from over-long titles.
.replace(/^=+\s+/gm, '')
// List markers → bullets.
.replace(/^\*\s+/gm, '- ')
.replace(/\n{3,}/g, '\n\n')
.trim()
);
}

const STARTS_WITH_VOWEL_REGEX = /^[aeiou]/;

// Humanize + pluralize snake_case labels, joined with "or": ['cache', 'rate_limit'] → 'caches or rate limits'.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,8 @@ import ReactMarkdown, { type Components } from 'react-markdown';
import { pluralizeWithNumber } from 'utils/string';

import {
asciidocToMarkdown,
buildEmptyMessage,
byProminence,
cleanText,
computeSuggested,
matchRank,
pushRecent,
Expand All @@ -33,6 +31,7 @@ import {
} from './connect-command-palette-utils';
import { ConnectorLogo } from './connector-logo';
import type { ConnectComponentSpec, ConnectComponentType, ExtendedConnectComponentSpec } from '../types/schema';
import { asciidocToMarkdown, markdownToPlainText } from '../utils/asciidoc';
import { getCategoryDisplayName } from '../utils/categories';
import { getConnectorDocsUrl } from '../utils/connector-docs';
import { componentStatusToString, parseSchema } from '../utils/schema';
Expand Down Expand Up @@ -163,10 +162,10 @@ function DetailPane({ component }: { component?: ConnectComponentSpec }) {
}

const categories = (component.categories ?? []).map(getCategoryDisplayName).filter(Boolean);
const summary = cleanText(component.summary ?? '');
const summary = markdownToPlainText(asciidocToMarkdown(component.summary ?? ''));
const descriptionMd = component.description ? asciidocToMarkdown(component.description) : '';
// Skip the description when it just repeats the summary.
const showDescription = descriptionMd !== '' && cleanText(component.description ?? '') !== summary;
const showDescription = descriptionMd !== '' && markdownToPlainText(descriptionMd) !== summary;
const docsUrl = getConnectorDocsUrl(component.type, component.name);

return (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/**
* Copyright 2026 Redpanda Data, Inc.
*
* Use of this software is governed by the Business Source License
* included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md
*
* As of the Change Date specified in that file, in accordance with
* the Business Source License, use of this software will be governed
* by the Apache License, Version 2.0
*/

import userEvent from '@testing-library/user-event';
import { render, screen } from 'test-utils';
import { describe, expect, test } from 'vitest';

import { FieldDescription } from './field-description';
import type { RawFieldSpec } from '../types/schema';

const field = (overrides: Partial<RawFieldSpec>): RawFieldSpec =>
({ name: 'topics', type: 'string', kind: 'scalar', ...overrides }) as RawFieldSpec;

// The `input: redpanda` topics field: a one-line short description over a multi-paragraph AsciiDoc one.
const LONG_TOPICS_DESCRIPTION =
'\nA list of topics to consume from. Multiple comma separated topics can be listed in a single element. ' +
'When a `consumer_group` is specified partitions are automatically distributed across consumers of a topic, ' +
'otherwise all partitions are consumed.\n\nAlternatively, it is possible to specify explicit partitions.';

const SHORT_TOPICS_DESCRIPTION =
'A list of topics to consume from. Multiple comma-separated topics may share one element.';

const SHOW_MORE_RE = /show more/i;
const ALTERNATIVELY_RE = /Alternatively, it is possible/;
const TOPICS_LEAD_RE = /A list of topics to consume from/;
const SHOW_LESS_RE = /show less/i;
const TOPICS_DOCS_RE = /topics documentation/i;
const TOPICS_DOCS_URL =
'https://docs.redpanda.com/cloud-data-platform/develop/connect/components/inputs/redpanda/#topics';

describe('FieldDescription', () => {
test('prefers the short description over the long one', () => {
render(
<FieldDescription
spec={field({ description: LONG_TOPICS_DESCRIPTION, shortDescription: SHORT_TOPICS_DESCRIPTION })}
/>
);

expect(screen.getByText(SHORT_TOPICS_DESCRIPTION)).toBeInTheDocument();
expect(screen.queryByText(ALTERNATIVELY_RE)).not.toBeInTheDocument();
// A one-liner needs no expander.
expect(screen.queryByRole('button', { name: SHOW_MORE_RE })).not.toBeInTheDocument();
});

test('falls back to the long description when no short one is served', () => {
render(<FieldDescription spec={field({ description: LONG_TOPICS_DESCRIPTION })} />);

expect(screen.getByText(TOPICS_LEAD_RE)).toBeInTheDocument();
});

test('treats a blank short description as absent', () => {
render(
<FieldDescription spec={field({ description: 'An identifier for the client.', shortDescription: ' ' })} />
);

expect(screen.getByText('An identifier for the client.')).toBeInTheDocument();
});

test('renders nothing when the field carries no prose', () => {
const { container } = render(<FieldDescription spec={field({})} />);

// The render wrapper adds a hidden Chakra node, so assert on text rather than an empty DOM.
expect(container.textContent).toBe('');
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});

test('collapses a long fallback description behind an expander', async () => {
const user = userEvent.setup();
render(<FieldDescription spec={field({ description: LONG_TOPICS_DESCRIPTION })} />);

const toggle = screen.getByRole('button', { name: SHOW_MORE_RE });
expect(toggle).toHaveAttribute('aria-expanded', 'false');
// Collapsed is the flattened single-line rendering: no code spans, trailing paragraph clipped by CSS.
expect(screen.queryByText('consumer_group', { selector: 'code' })).not.toBeInTheDocument();

await user.click(toggle);

expect(screen.getByRole('button', { name: SHOW_LESS_RE })).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByText('consumer_group', { selector: 'code' })).toBeInTheDocument();
expect(screen.getByText(ALTERNATIVELY_RE)).toBeInTheDocument();
});

test('links URL macros out of the AsciiDoc source, bracketed labels included', () => {
const { unmount } = render(
<FieldDescription spec={field({ description: 'Uses https://github.com/twmb/franz-go[franz-go] internally.' })} />
);

const link = screen.getByRole('link', { name: 'franz-go' });
expect(link).toHaveAttribute('href', 'https://github.com/twmb/franz-go');
expect(link).toHaveAttribute('target', '_blank');
unmount();

// A code span binds tighter than the link, so a `]` inside the label doesn't end it.
render(<FieldDescription spec={field({ description: 'A DSN: https://x.com/dsn[`user[:pass\\]@host`].' })} />);
expect(screen.getByRole('link', { name: 'user[:pass]@host' })).toHaveAttribute('href', 'https://x.com/dsn');
});

test('keeps angle-bracket placeholders that Markdown would otherwise swallow', () => {
render(<FieldDescription spec={field({ description: 'Defaults to `cdc_metadata_<stream_id>`.' })} />);

expect(screen.getByText('cdc_metadata_<stream_id>', { selector: 'code' })).toBeInTheDocument();
});

test('deep-links the field on the connector docs page, named for the field', () => {
render(<FieldDescription docsUrl={TOPICS_DOCS_URL} spec={field({ shortDescription: SHORT_TOPICS_DESCRIPTION })} />);

const link = screen.getByRole('link', { name: TOPICS_DOCS_RE });
expect(link).toHaveAttribute('href', TOPICS_DOCS_URL);
expect(link).toHaveAttribute('target', '_blank');
});

test('trails the link on the sentence when the description is a single paragraph', () => {
render(
<FieldDescription docsUrl={TOPICS_DOCS_URL} spec={field({ description: 'Set the `topic` to publish to.' })} />
);

// One block, so the link reads as part of the help text instead of claiming a row of its own.
const link = screen.getByRole('link', { name: TOPICS_DOCS_RE });
expect(link.parentElement?.textContent).toBe('Set the topic to publish to. Docs');
expect(screen.getByText('topic', { selector: 'code' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: SHOW_MORE_RE })).not.toBeInTheDocument();
});

test('keeps the docs link reachable while a long description is collapsed', async () => {
const user = userEvent.setup();
render(<FieldDescription docsUrl={TOPICS_DOCS_URL} spec={field({ description: LONG_TOPICS_DESCRIPTION })} />);

expect(screen.getByRole('link', { name: TOPICS_DOCS_RE })).toBeInTheDocument();

await user.click(screen.getByRole('button', { name: SHOW_MORE_RE }));

expect(screen.getByRole('link', { name: TOPICS_DOCS_RE })).toBeInTheDocument();
});

test('offers the docs link with no prose at all, and none when the component has no docs page', () => {
const { unmount } = render(<FieldDescription docsUrl={TOPICS_DOCS_URL} spec={field({})} />);
expect(screen.getByRole('link', { name: TOPICS_DOCS_RE })).toBeInTheDocument();
unmount();

render(<FieldDescription spec={field({ shortDescription: SHORT_TOPICS_DESCRIPTION })} />);
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
});
Loading
Loading