diff --git a/frontend/bun.lock b/frontend/bun.lock index b3fdc8d4c0..e80069a62e 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -25,6 +25,7 @@ "@connectrpc/connect": "^2.1.0", "@connectrpc/connect-query": "^2.2.0", "@connectrpc/connect-web": "^2.1.0", + "@dagrejs/dagre": "^3.0.0", "@emotion/css": "^11.13.5", "@hello-pangea/dnd": "^18.0.1", "@hookform/resolvers": "^5.2.2", @@ -437,6 +438,10 @@ "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], + "@dagrejs/dagre": ["@dagrejs/dagre@3.0.0", "", { "dependencies": { "@dagrejs/graphlib": "4.0.1" } }, "sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q=="], + + "@dagrejs/graphlib": ["@dagrejs/graphlib@4.0.1", "", {}, "sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA=="], + "@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="], "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], diff --git a/frontend/package.json b/frontend/package.json index bab242bc06..a46aabe59a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -69,6 +69,7 @@ "@connectrpc/connect": "^2.1.0", "@connectrpc/connect-query": "^2.2.0", "@connectrpc/connect-web": "^2.1.0", + "@dagrejs/dagre": "^3.0.0", "@emotion/css": "^11.13.5", "@hello-pangea/dnd": "^18.0.1", "@hookform/resolvers": "^5.2.2", diff --git a/frontend/src/components/constants.ts b/frontend/src/components/constants.ts index 84fb5590a8..cdc79fc872 100644 --- a/frontend/src/components/constants.ts +++ b/frontend/src/components/constants.ts @@ -10,6 +10,7 @@ export const FEATURE_FLAGS = { enableRemoteMcpInConsole: false, enableRpcnTiles: false, enableRpcnTemplateGallery: false, + enableRpcnVisualEditor: false, enableServerlessOnboardingWizard: false, enableApiKeyConfigurationAgent: false, enableDataplaneObservabilityServerless: false, diff --git a/frontend/src/components/pages/rp-connect/onboarding/add-connector-dialog.tsx b/frontend/src/components/pages/rp-connect/onboarding/add-connector-dialog.tsx index a6696e23f3..656eaea632 100644 --- a/frontend/src/components/pages/rp-connect/onboarding/add-connector-dialog.tsx +++ b/frontend/src/components/pages/rp-connect/onboarding/add-connector-dialog.tsx @@ -1,25 +1,15 @@ import { Dialog, - DialogBody, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from 'components/redpanda-ui/components/dialog'; -import { Link } from 'components/redpanda-ui/components/typography'; import type { ComponentList } from 'protogen/redpanda/api/dataplane/v1/pipeline_pb'; -import { ConnectTiles } from './connect-tiles'; +import { ConnectCommandPalette } from './connect-command-palette'; import type { ConnectComponentType } from '../types/schema'; -function getDocsUrl(connectorType?: ConnectComponentType | ConnectComponentType[]): string | null { - const type = Array.isArray(connectorType) ? connectorType[0] : connectorType; - if (!type) { - return null; - } - return `https://docs.redpanda.com/redpanda-cloud/develop/connect/components/${type}s/about/`; -} - export const AddConnectorDialog = ({ isOpen, onCloseAddConnector, @@ -44,34 +34,22 @@ export const AddConnectorDialog = ({ typeFilter = [connectorType]; } - const docsUrl = getDocsUrl(connectorType); - return ( - + {title ?? 'Add a connector'} - Configure your pipeline.{' '} - {docsUrl ? ( - - Learn more - - ) : null} + Search the component catalog, then select a component to add it to your pipeline. - - - + onAddConnector?.(name, type)} + searchPlaceholder={searchPlaceholder} + /> ); diff --git a/frontend/src/components/pages/rp-connect/onboarding/connect-command-palette-utils.test.ts b/frontend/src/components/pages/rp-connect/onboarding/connect-command-palette-utils.test.ts new file mode 100644 index 0000000000..9d2c20b934 --- /dev/null +++ b/frontend/src/components/pages/rp-connect/onboarding/connect-command-palette-utils.test.ts @@ -0,0 +1,202 @@ +import { ComponentStatus } from 'protogen/redpanda/api/dataplane/v1/pipeline_pb'; +import { describe, expect, it } from 'vitest'; + +import { + aliasTermsForName, + asciidocToMarkdown, + buildEmptyMessage, + byProminence, + COMPONENT_ALIASES, + computeSuggested, + matchRank, + searchableText, +} from './connect-command-palette-utils'; +import type { ConnectComponentSpec, ConnectComponentType } from '../types/schema'; + +const spec = ( + name: string, + status: ComponentStatus = ComponentStatus.STABLE, + type: ConnectComponentType = 'processor' +): ConnectComponentSpec => ({ name, type, status }) as ConnectComponentSpec; + +const sorted = (specs: ConnectComponentSpec[]) => [...specs].sort(byProminence).map((s) => s.name); + +describe('byProminence', () => { + it('sorts common components ahead of the long tail', () => { + // `mapping` is a common processor; `avro` is not. + expect(sorted([spec('avro'), spec('mapping')])).toEqual(['mapping', 'avro']); + }); + + it('demotes deprecated and experimental below stable, even when common', () => { + const result = sorted([ + spec('mapping', ComponentStatus.DEPRECATED), + spec('avro', ComponentStatus.STABLE), + spec('parquet', ComponentStatus.EXPERIMENTAL), + ]); + // Stable (even uncommon) sorts above demoted ones; deprecated/experimental sink. + expect(result[0]).toBe('avro'); + expect(result.slice(1).sort()).toEqual(['mapping', 'parquet']); + }); + + it('falls back to alphabetical within the same prominence bucket', () => { + expect(sorted([spec('zzz'), spec('aaa')])).toEqual(['aaa', 'zzz']); + }); +}); + +describe('matchRank', () => { + const gen = spec('generate'); + const text = searchableText(gen); + + it('ranks exact-name matches highest', () => { + expect(matchRank(gen, 'generate', text)).toBe(0); + }); + + it('ranks a name prefix above a mid-name substring', () => { + expect(matchRank(gen, 'gen', text)).toBe(1); + expect(matchRank(gen, 'erat', text)).toBe(2); + }); + + it('ranks a body-text match below any name match', () => { + const withSummary = { ...spec('kafka'), summary: 'streams events' } as ConnectComponentSpec; + expect(matchRank(withSummary, 'events', searchableText(withSummary))).toBe(3); + }); + + it('returns -1 when nothing matches', () => { + expect(matchRank(gen, 'flurb', text)).toBe(-1); + }); +}); + +describe('component aliases', () => { + it('maps intent terms to a component name via its fragments', () => { + // "queue" lists kafka, so kafka_franz surfaces under that intent. + expect(aliasTermsForName('kafka_franz')).toContain('queue'); + expect(aliasTermsForName('kafka_franz')).toContain('stream'); + }); + + it('maps transform/map intents to bloblang/mapping', () => { + expect(aliasTermsForName('mapping')).toEqual(expect.arrayContaining(['transform', 'map'])); + expect(aliasTermsForName('bloblang')).toContain('transform'); + }); + + it('returns no alias terms for a name no fragment matches', () => { + expect(aliasTermsForName('totally_unrelated_xyz')).toEqual([]); + }); + + it('matches fragments on `_`/word boundaries, not mid-token substrings', () => { + // Whole multi-token fragments and `_`-delimited tokens match... + expect(aliasTermsForName('rate_limit')).toEqual(expect.arrayContaining(['throttle', 'ratelimit'])); + expect(aliasTermsForName('json_schema')).toContain('json'); + // ...but a fragment buried mid-token doesn't (`log` inside `catalog`). + expect(aliasTermsForName('catalog')).not.toContain('log'); + }); + + it('keeps alias keys and fragments lowercase', () => { + for (const [key, fragments] of Object.entries(COMPONENT_ALIASES)) { + expect(key).toBe(key.toLowerCase()); + for (const fragment of fragments) { + expect(fragment).toBe(fragment.toLowerCase()); + } + } + }); +}); + +describe('searchableText', () => { + it('folds name, summary, description and categories into one lowercased haystack', () => { + const component = { + ...spec('http_client'), + summary: 'Sends HTTP requests', + description: 'Talks to REST endpoints', + categories: ['Network'], + } as ConnectComponentSpec; + const text = searchableText(component); + expect(text).toContain('http_client'); + expect(text).toContain('sends http requests'); + expect(text).toContain('rest endpoints'); + expect(text).toContain('network'); + expect(text).toBe(text.toLowerCase()); + }); +}); + +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.'); + expect(buildEmptyMessage('', ['processor'])).toBe('No components available.'); + }); + + it('reports a plain miss without type locking', () => { + expect(buildEmptyMessage('flurb')).toBe('No components match “flurb”.'); + }); + + it('names the locked type when the slot restricts the catalog', () => { + expect(buildEmptyMessage('flurb', ['processor'])).toBe('No processors match “flurb”.'); + expect(buildEmptyMessage('flurb', ['rate_limit'])).toBe('No rate limits match “flurb”.'); + }); + + it('joins multiple locked types', () => { + expect(buildEmptyMessage('flurb', ['cache', 'rate_limit'])).toBe('No caches or rate limits match “flurb”.'); + }); + + it('points at a single out-of-scope type the query exists as', () => { + expect(buildEmptyMessage('generate', ['processor'], ['input'])).toBe( + 'No processors match “generate” — it exists as an input.' + ); + }); + + it('lists multiple out-of-scope types with articles and "or"', () => { + expect(buildEmptyMessage('kafka_franz', ['processor'], ['input', 'output'])).toBe( + 'No processors match “kafka_franz” — it exists as an input or an output.' + ); + expect(buildEmptyMessage('redis', ['input'], ['cache', 'processor', 'rate_limit'])).toBe( + 'No inputs match “redis” — it exists as a cache, a processor or a rate limit.' + ); + }); +}); + +describe('computeSuggested', () => { + const byName = new Map([ + ['generate', spec('generate', ComponentStatus.STABLE, 'input')], + ['kafka_franz', spec('kafka_franz', ComponentStatus.STABLE, 'input')], + ['file', spec('file', ComponentStatus.STABLE, 'input')], + ]); + + it('resolves the curated names for the allowed types against the catalog', () => { + const result = computeSuggested(['input'], byName, []).map((c) => c.name); + // Only the curated input names present in `byName` survive (redpanda/http_client are absent). + expect(result).toEqual(['kafka_franz', 'generate', 'file']); + }); + + it('drops anything already shown under Recent', () => { + const recent = [spec('generate', ComponentStatus.STABLE, 'input')]; + const result = computeSuggested(['input'], byName, recent).map((c) => c.name); + expect(result).not.toContain('generate'); + expect(result).toEqual(['kafka_franz', 'file']); + }); + + it('returns nothing when there are no allowed types', () => { + expect(computeSuggested(undefined, byName, [])).toEqual([]); + }); +}); diff --git a/frontend/src/components/pages/rp-connect/onboarding/connect-command-palette-utils.ts b/frontend/src/components/pages/rp-connect/onboarding/connect-command-palette-utils.ts new file mode 100644 index 0000000000..66bd10c730 --- /dev/null +++ b/frontend/src/components/pages/rp-connect/onboarding/connect-command-palette-utils.ts @@ -0,0 +1,256 @@ +import { ComponentStatus } from 'protogen/redpanda/api/dataplane/v1/pipeline_pb'; + +import type { ConnectComponentSpec, ConnectComponentType } from '../types/schema'; + +const RECENTS_KEY = 'rpcn-recent-components'; +const MAX_RECENTS = 6; + +// Curated "likely next" defaults per slot kind, shown before the user types. Names absent from the catalog are dropped. +const SUGGESTED_BY_TYPE: Partial> = { + input: ['kafka_franz', 'redpanda', 'generate', 'http_client', 'file'], + output: ['kafka_franz', 'redpanda', 'http_client', 'drop', 'stdout'], + processor: ['mapping', 'bloblang', 'cache', 'http', 'rate_limit', 'log'], + cache: ['memory', 'redis', 'memcached'], + rate_limit: ['local'], +}; + +// Everyday components sorted ahead of the long tail when browsing. +const COMMON_COMPONENTS = new Set([ + ...Object.values(SUGGESTED_BY_TYPE).flat(), + 'kafka', + 'redpanda_migrator', + 'sql_insert', + 'sql_select', + 'aws_s3', + 'gcp_cloud_storage', + 'postgres_cdc', + 'mysql_cdc', + 'switch', + 'branch', + 'workflow', + 'json_schema', + 'unarchive', + 'archive', +]); + +// Deprecated/experimental components are de-emphasised: sorted below stable ones. +function isDemoted(status: ComponentStatus): boolean { + return status === ComponentStatus.DEPRECATED || status === ComponentStatus.EXPERIMENTAL; +} + +// Tiebreaker order (after relevance, primary when browsing): stable before demoted, common before long tail, then name. +export function byProminence(a: ConnectComponentSpec, b: ConnectComponentSpec): number { + const demoted = (isDemoted(a.status) ? 1 : 0) - (isDemoted(b.status) ? 1 : 0); + if (demoted !== 0) { + return demoted; + } + const common = (COMMON_COMPONENTS.has(a.name) ? 0 : 1) - (COMMON_COMPONENTS.has(b.name) ? 0 : 1); + if (common !== 0) { + return common; + } + return a.name.localeCompare(b.name); +} + +type RecentEntry = { name: string; type: ConnectComponentType }; + +export function readRecents(): RecentEntry[] { + if (typeof window === 'undefined') { + return []; + } + try { + const raw = window.localStorage.getItem(RECENTS_KEY); + const parsed = raw ? (JSON.parse(raw) as RecentEntry[]) : []; + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +export function pushRecent(entry: RecentEntry): void { + if (typeof window === 'undefined') { + return; + } + try { + const next = [entry, ...readRecents().filter((r) => !(r.name === entry.name && r.type === entry.type))].slice( + 0, + MAX_RECENTS + ); + window.localStorage.setItem(RECENTS_KEY, JSON.stringify(next)); + } catch { + // Best-effort; ignore storage failures. + } +} + +// Search synonyms: a query matching an alias key surfaces every component whose name contains a +// listed fragment (e.g. "queue" finds kafka/nats/redis). Keep entries lowercase. +export const COMPONENT_ALIASES: Record = { + queue: ['kafka', 'nats', 'redis', 'amqp', 'sqs', 'pubsub', 'nsq', 'mqtt'], + stream: ['kafka', 'redpanda', 'kinesis'], + topic: ['kafka', 'redpanda'], + transform: ['mapping', 'bloblang', 'mutation', 'jq'], + map: ['mapping', 'bloblang'], + enrich: ['branch', 'cache', 'http'], + filter: ['mapping', 'bloblang', 'filter'], + database: ['sql', 'postgres', 'mysql', 'mongodb', 'redis'], + db: ['sql', 'postgres', 'mysql', 'mongodb'], + sql: ['sql', 'postgres', 'mysql'], + cdc: ['cdc'], + cache: ['cache', 'redis', 'memcached'], + throttle: ['rate_limit'], + ratelimit: ['rate_limit'], + http: ['http', 'http_client', 'http_server'], + rest: ['http'], + api: ['http'], + webhook: ['http_server'], + s3: ['aws_s3'], + blob: ['aws_s3', 'gcp_cloud_storage', 'azure_blob_storage'], + bucket: ['aws_s3', 'gcp_cloud_storage', 'azure_blob_storage'], + warehouse: ['snowflake', 'bigquery', 'redshift'], + ai: ['openai', 'gpt', 'cohere', 'ollama', 'embeddings'], + llm: ['openai', 'gpt', 'cohere', 'ollama'], + vector: ['pinecone', 'qdrant', 'pgvector', 'embeddings'], + log: ['log'], + delay: ['sleep', 'rate_limit'], + retry: ['retry'], + json: ['mapping', 'bloblang', 'json'], +}; + +// Does `name` contain `fragment` on `_`/string boundaries? Matches `kafka` in `kafka_franz` and +// `rate_limit` whole, but not a fragment buried mid-token (`log` in `catalog`) — names are snake_case. +function nameContainsToken(name: string, fragment: string): boolean { + let from = 0; + for (;;) { + const at = name.indexOf(fragment, from); + if (at === -1) { + return false; + } + const boundedBefore = at === 0 || name[at - 1] === '_'; + const end = at + fragment.length; + const boundedAfter = end === name.length || name[end] === '_'; + if (boundedBefore && boundedAfter) { + return true; + } + from = at + 1; + } +} + +// Every alias key whose fragments the name contains as a token ("queue" matches `kafka_franz`). +export function aliasTermsForName(name: string): string[] { + const lower = name.toLowerCase(); + const terms: string[] = []; + for (const [alias, fragments] of Object.entries(COMPONENT_ALIASES)) { + if (fragments.some((fragment) => nameContainsToken(lower, fragment))) { + terms.push(alias); + } + } + return terms; +} + +// Lowercased searchable text (name + aliases + summary + description + categories) for substring matching. +export function searchableText(component: ConnectComponentSpec): string { + return [ + component.name, + ...aliasTermsForName(component.name), + component.summary ?? '', + component.description ?? '', + ...(component.categories ?? []), + ] + .join(' ') + .toLowerCase(); +} + +// Rank a match: exact name > prefix > substring > other text. Lower is better; -1 = no match. +export function matchRank(component: ConnectComponentSpec, query: string, text: string): number { + const name = component.name.toLowerCase(); + if (name === query) { + return 0; + } + if (name.startsWith(query)) { + return 1; + } + if (name.includes(query)) { + return 2; + } + 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'. +function pluralTypeLabel(labels: string[] | undefined, emptyLabel: string): string { + if (!labels || labels.length === 0) { + return emptyLabel; + } + return labels.map((label) => `${label.replace(/_/g, ' ')}s`).join(' or '); +} + +// Humanized labels prefixed with "a"/"an", joined with "or": ['cache', 'input'] → 'a cache or an input'. +function withArticles(labels: string[]): string { + const labeled = labels.map((label) => { + const humanized = label.replace(/_/g, ' '); + return `${STARTS_WITH_VOWEL_REGEX.test(humanized) ? 'an' : 'a'} ${humanized}`; + }); + if (labeled.length <= 1) { + return labeled[0] ?? ''; + } + return `${labeled.slice(0, -1).join(', ')} or ${labeled.at(-1)}`; +} + +// Empty-state copy; a type-locked miss that exists as another type is called out ("it exists as an input"). +export function buildEmptyMessage( + query: string, + allowedTypes?: ConnectComponentType[], + outOfScopeTypes: ConnectComponentType[] = [] +): string { + if (!query) { + return 'No components available.'; + } + if (outOfScopeTypes.length === 0) { + return `No ${pluralTypeLabel(allowedTypes, 'components')} match “${query}”.`; + } + return `No ${pluralTypeLabel(allowedTypes, 'components')} match “${query}” — it exists as ${withArticles(outOfScopeTypes)}.`; +} + +// "Suggested" tab: curated names for the slot's allowed types, resolved against the catalog, minus recents. +export function computeSuggested( + allowedTypes: ConnectComponentType[] | undefined, + byName: Map, + recentComponents: ConnectComponentSpec[] +): ConnectComponentSpec[] { + const names = new Set((allowedTypes ?? []).flatMap((t) => SUGGESTED_BY_TYPE[t] ?? [])); + const recentNames = new Set(recentComponents.map((c) => c.name)); + return [...names] + .map((n) => byName.get(n)) + .filter((c): c is ConnectComponentSpec => c !== undefined && !recentNames.has(c.name)); +} diff --git a/frontend/src/components/pages/rp-connect/onboarding/connect-command-palette.test.tsx b/frontend/src/components/pages/rp-connect/onboarding/connect-command-palette.test.tsx new file mode 100644 index 0000000000..f8f12370e4 --- /dev/null +++ b/frontend/src/components/pages/rp-connect/onboarding/connect-command-palette.test.tsx @@ -0,0 +1,86 @@ +import userEvent from '@testing-library/user-event'; +import { ComponentStatus } from 'protogen/redpanda/api/dataplane/v1/pipeline_pb'; +import { render, screen, waitFor } from 'test-utils'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ConnectCommandPalette } from './connect-command-palette'; +import type { ConnectComponentType, ExtendedConnectComponentSpec } from '../types/schema'; + +const KAFKA_OPTION = /kafka_franz/; +const GENERATE_OPTION = /generate/; +const HTTP_CLIENT_OPTION = /http_client/; +const NO_INPUTS_MATCH = /No inputs match/; +const EXISTS_AS_CACHE = /it exists as a cache/; + +const component = ( + name: string, + type: ConnectComponentType, + extra: Partial = {} +): ExtendedConnectComponentSpec => + ({ name, type, status: ComponentStatus.STABLE, ...extra }) as ExtendedConnectComponentSpec; + +const INPUT_COMPONENTS: ExtendedConnectComponentSpec[] = [ + component('kafka_franz', 'input', { summary: 'Consume from Kafka', categories: ['Services'] }), + component('generate', 'input', { summary: 'Generate synthetic messages', categories: ['Utility'] }), + component('http_client', 'input', { summary: 'Poll an HTTP endpoint', categories: ['Network'] }), +]; + +const renderPalette = (props: Partial[0]> = {}) => + render( + + ); + +describe('ConnectCommandPalette', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it('lists the in-scope components when browsing', async () => { + renderPalette(); + // Scope to listbox options — the selected component also echoes its name in the detail pane. + expect(await screen.findByRole('option', { name: KAFKA_OPTION })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: GENERATE_OPTION })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: HTTP_CLIENT_OPTION })).toBeInTheDocument(); + }); + + it('filters the list down to matches as the user types', async () => { + const user = userEvent.setup(); + renderPalette(); + + await user.type(screen.getByPlaceholderText('Search components…'), 'generate'); + + await waitFor(() => expect(screen.getByRole('option', { name: GENERATE_OPTION })).toBeInTheDocument()); + expect(screen.queryByRole('option', { name: KAFKA_OPTION })).not.toBeInTheDocument(); + expect(screen.queryByRole('option', { name: HTTP_CLIENT_OPTION })).not.toBeInTheDocument(); + }); + + it('explains a type-locked miss in the empty state', async () => { + const user = userEvent.setup(); + // `redis` is only in scope as a cache, so a query for it while locked to inputs is a scoped miss. + renderPalette({ + additionalComponents: [...INPUT_COMPONENTS, component('redis', 'cache', { summary: 'Redis cache' })], + }); + + await user.type(screen.getByPlaceholderText('Search components…'), 'redis'); + + expect(await screen.findByText(NO_INPUTS_MATCH)).toBeInTheDocument(); + expect(screen.getByText(EXISTS_AS_CACHE)).toBeInTheDocument(); + }); + + it('commits the highlighted component and records it under Recent', async () => { + const user = userEvent.setup(); + const onSelect = vi.fn(); + renderPalette({ onSelect }); + + await user.dblClick(await screen.findByRole('option', { name: GENERATE_OPTION })); + + expect(onSelect).toHaveBeenCalledWith('generate', 'input'); + // The committed component now surfaces its own browse facet. + expect(await screen.findByRole('tab', { name: 'Recent' })).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/pages/rp-connect/onboarding/connect-command-palette.tsx b/frontend/src/components/pages/rp-connect/onboarding/connect-command-palette.tsx new file mode 100644 index 0000000000..450745db7e --- /dev/null +++ b/frontend/src/components/pages/rp-connect/onboarding/connect-command-palette.tsx @@ -0,0 +1,511 @@ +import { type ComponentName, componentLogoMap } from 'assets/connectors/component-logo-map'; +import { Badge } from 'components/redpanda-ui/components/badge'; +import { BadgeGroup } from 'components/redpanda-ui/components/badge-group'; +import { Button } from 'components/redpanda-ui/components/button'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from 'components/redpanda-ui/components/command'; +import { DialogFooter } from 'components/redpanda-ui/components/dialog'; +import { ScrollableTabsList, Tabs, TabsTrigger } from 'components/redpanda-ui/components/tabs'; +import { Link, Text } from 'components/redpanda-ui/components/typography'; +import { ExternalLink, Waypoints } from 'lucide-react'; +import type { ComponentList } from 'protogen/redpanda/api/dataplane/v1/pipeline_pb'; +import { ComponentStatus } from 'protogen/redpanda/api/dataplane/v1/pipeline_pb'; +import { useMemo, useState } from 'react'; +import ReactMarkdown, { type Components } from 'react-markdown'; +import { pluralizeWithNumber } from 'utils/string'; + +import { + asciidocToMarkdown, + buildEmptyMessage, + byProminence, + cleanText, + computeSuggested, + matchRank, + pushRecent, + readRecents, + searchableText, +} from './connect-command-palette-utils'; +import { ConnectorLogo } from './connector-logo'; +import type { ConnectComponentSpec, ConnectComponentType, ExtendedConnectComponentSpec } from '../types/schema'; +import { getCategoryDisplayName } from '../utils/categories'; +import { getConnectorDocsUrl } from '../utils/connector-docs'; +import { componentStatusToString, parseSchema } from '../utils/schema'; + +function ComponentIcon({ component, className = 'size-5' }: { component: ConnectComponentSpec; className?: string }) { + if (component.logoUrl) { + return ; + } + if (componentLogoMap[component.name as ComponentName]) { + return ; + } + return ; +} + +// Compact section title matching the inspector's small-label style. +const MarkdownHeading = ({ children }: { children?: React.ReactNode }) => ( + {children} +); + +// All heading levels collapse to the same compact label — these are short section titles, not a hierarchy. +const MARKDOWN_COMPONENTS: Components = { + h1: MarkdownHeading, + h2: MarkdownHeading, + h3: MarkdownHeading, + h4: MarkdownHeading, + h5: MarkdownHeading, + h6: MarkdownHeading, + p: ({ children }) => {children}, + a: ({ href, children }) => ( + + {children} + + ), + code: ({ children }) => {children}, + ul: ({ children }) =>
    {children}
, + ol: ({ children }) =>
    {children}
, + li: ({ children }) =>
  • {children}
  • , +}; + +function FormattedDescription({ markdown }: { markdown: string }) { + return ( +
    + {markdown} +
    + ); +} + +function statusBadge(status: ComponentStatus, name: string) { + if ( + name !== 'redpanda' && + (status === ComponentStatus.BETA || + status === ComponentStatus.EXPERIMENTAL || + status === ComponentStatus.DEPRECATED) + ) { + return ( + + {componentStatusToString(status)} + + ); + } + return null; +} + +// Bold the matched span of the name so users see why a result surfaced. +function HighlightedName({ name, query }: { name: string; query: string }) { + const idx = query ? name.toLowerCase().indexOf(query) : -1; + if (idx < 0) { + return {name}; + } + return ( + + {name.slice(0, idx)} + {name.slice(idx, idx + query.length)} + {name.slice(idx + query.length)} + + ); +} + +function Row({ + component, + query = '', + onPreview, + onCommit, +}: { + component: ConnectComponentSpec; + query?: string; + onPreview: (component: ConnectComponentSpec) => void; + onCommit: (component: ConnectComponentSpec) => void; +}) { + const category = component.categories?.[0]; + // Fall back to the humanized type so same-named components (redis cache vs redis rate-limit) stay distinguishable. + const badgeLabel = category ? getCategoryDisplayName(category) : component.type.replace(/_/g, ' '); + return ( + onCommit(component)} + onSelect={() => onPreview(component)} + value={`${component.type}:${component.name}`} + > + + + + {statusBadge(component.status, component.name)} + + {badgeLabel ? ( + + {badgeLabel} + + ) : null} + + ); +} + +// Facet tabs (Recent / Suggested / All / per-category) that narrow the browse list. +type Tab = { id: string; label: string }; + +// Right-hand preview of the highlighted row; insertion is deferred to the footer Add action. +function DetailPane({ component }: { component?: ConnectComponentSpec }) { + if (!component) { + return ( +
    + + Select a component to see its description, categories, and documentation, then add it to your pipeline. + +
    + ); + } + + const categories = (component.categories ?? []).map(getCategoryDisplayName).filter(Boolean); + const summary = cleanText(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 docsUrl = getConnectorDocsUrl(component.type, component.name); + + return ( +
    +
    + +
    + + {component.name} + {statusBadge(component.status, component.name)} + + + {component.type.replace(/_/g, ' ')} + {component.version ? ` · v${component.version}` : ''} + +
    +
    + + {summary ? {summary} : null} + + {showDescription ? : null} + + {categories.length > 0 ? ( +
    + Categories +
    {overflow}
    } + variant="neutral-inverted" + wrap + > + {categories.map((category) => ( + + {category} + + ))} +
    +
    + ) : null} + + {docsUrl ? ( + + View documentation + + + ) : null} +
    + ); +} + +type ConnectCommandPaletteProps = { + components?: ComponentList; + /** Pre-parsed specs appended to the catalog — the seam tests use to inject fixtures without a raw schema. */ + additionalComponents?: ExtendedConnectComponentSpec[]; + /** Component types valid in the slot being filled; the palette lists only these. */ + allowedTypes?: ConnectComponentType[]; + onSelect: (connectionName: string, connectionType: ConnectComponentType) => void; + /** When provided, the footer shows a Cancel button wired to this. */ + onCancel?: () => void; + searchPlaceholder?: string; +}; + +/** + * Search-first add-node picker as a master/detail panel: tab-filtered list, metadata preview, and a + * footer that defers insertion until commit. Matches name/aliases/summary/description/categories. + */ +export const ConnectCommandPalette = ({ + components, + additionalComponents, + allowedTypes, + onSelect, + onCancel, + searchPlaceholder, +}: ConnectCommandPaletteProps) => { + const [query, setQuery] = useState(''); + const [activeValue, setActiveValue] = useState(''); + // null until the user picks a tab, so the derived default can react to the catalog. + const [tab, setTab] = useState(null); + // State, not a mount-once memo, so a component committed while the palette stays mounted shows up. + const [recents, setRecents] = useState(() => readRecents()); + + const allComponents = useMemo(() => { + const builtIn = components ? parseSchema(components) : []; + return [...builtIn, ...(additionalComponents ?? [])]; + }, [components, additionalComponents]); + + const typeAllowed = useMemo(() => { + const set = allowedTypes && allowedTypes.length > 0 ? new Set(allowedTypes) : null; + return (type: ConnectComponentType) => !set || set.has(type); + }, [allowedTypes]); + + const inScope = useMemo(() => allComponents.filter((c) => typeAllowed(c.type)), [allComponents, typeAllowed]); + + // Searchable text is derived once per catalog: building it walks the alias table and joins each + // component's full description — too costly to redo per keystroke across hundreds of components. + const searchIndex = useMemo( + () => new Map(allComponents.map((component) => [component, searchableText(component)])), + [allComponents] + ); + const byName = useMemo(() => new Map(inScope.map((c) => [c.name, c])), [inScope]); + // Keyed by CommandItem `value` (`type:name`) since two types can share a name (e.g. kafka in/out). + const byKey = useMemo(() => new Map(inScope.map((c) => [`${c.type}:${c.name}`, c])), [inScope]); + const activeComponent = byKey.get(activeValue); + + const handleCommit = (component: ConnectComponentSpec) => { + pushRecent({ name: component.name, type: component.type }); + setRecents(readRecents()); + onSelect(component.name, component.type); + }; + + // Clicking a row only previews it; cmdk also drives this via hover/arrow nav through `onValueChange`. + const handlePreview = (component: ConnectComponentSpec) => setActiveValue(`${component.type}:${component.name}`); + + const q = query.trim().toLowerCase(); + + // Searching: a single ranked, flat list across the in-scope catalog. + const results = useMemo(() => { + if (!q) { + return []; + } + return inScope + .map((component) => ({ + component, + rank: matchRank(component, q, searchIndex.get(component) ?? ''), + })) + .filter((r) => r.rank >= 0) + .sort((a, b) => a.rank - b.rank || byProminence(a.component, b.component)) + .map((r) => r.component); + }, [inScope, q, searchIndex]); + + // On a type-locked miss, the out-of-scope types that DO match — shown in the empty state so the + // miss isn't read as a gap. + const outOfScopeTypes = useMemo(() => { + if (!q || results.length > 0 || !allowedTypes || allowedTypes.length === 0) { + return []; + } + const types = new Set(); + for (const component of allComponents) { + if (!typeAllowed(component.type) && matchRank(component, q, searchIndex.get(component) ?? '') >= 0) { + types.add(component.type); + } + } + return [...types].sort(); + }, [q, results, allowedTypes, allComponents, typeAllowed, searchIndex]); + + // Browse facets (no query): recents + suggested + everything grouped by category. + const recentComponents = useMemo( + () => recents.map((r) => byName.get(r.name)).filter((c): c is ConnectComponentSpec => Boolean(c)), + [recents, byName] + ); + + const suggested = useMemo( + () => computeSuggested(allowedTypes, byName, recentComponents), + [allowedTypes, byName, recentComponents] + ); + + // Grouped by category for the tabs; a component appears under EVERY category it lists, not just the first. + const grouped = useMemo(() => { + const groups = new Map(); + for (const component of inScope) { + const cats = component.categories?.length ? component.categories : ['other']; + for (const key of cats) { + const list = groups.get(key); + if (list) { + list.push(component); + } else { + groups.set(key, [component]); + } + } + } + return [...groups.entries()] + .map(([id, list]) => ({ id, name: getCategoryDisplayName(id), components: list.sort(byProminence) })) + .sort((a, b) => a.name.localeCompare(b.name)); + }, [inScope]); + + // The "All" tab lists every component once, deduped across the category groups it may appear in. + const allGroups = useMemo(() => { + const seen = new Set(); + return grouped + .map((group) => ({ + ...group, + components: group.components.filter((c) => { + const key = `${c.type}:${c.name}`; + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }), + })) + .filter((group) => group.components.length > 0); + }, [grouped]); + + const tabs = useMemo(() => { + const list: Tab[] = []; + if (recentComponents.length > 0) { + list.push({ id: 'recent', label: 'Recent' }); + } + if (suggested.length > 0) { + list.push({ id: 'suggested', label: 'Suggested' }); + } + list.push({ id: 'all', label: 'All' }); + for (const group of grouped) { + list.push({ id: `cat:${group.id}`, label: group.name }); + } + return list; + }, [recentComponents, suggested, grouped]); + + const defaultTab = suggested.length > 0 ? 'suggested' : 'all'; + const currentTab = tab && tabs.some((t) => t.id === tab) ? tab : defaultTab; + + const browseItems = useMemo(() => { + if (currentTab === 'recent') { + return recentComponents; + } + if (currentTab === 'suggested') { + return suggested; + } + if (currentTab.startsWith('cat:')) { + const id = currentTab.slice(4); + return grouped.find((g) => g.id === id)?.components ?? []; + } + return null; // "all" renders the grouped view, not a flat list. + }, [currentTab, recentComponents, suggested, grouped]); + + // Enter commits the highlighted component; a single click only previews, double-click commits. + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter' && !event.nativeEvent.isComposing && activeComponent) { + event.preventDefault(); + handleCommit(activeComponent); + } + }; + + return ( + // biome-ignore lint/a11y/noStaticElementInteractions: keyboard handling augments the inner cmdk listbox. +
    + + + {q ? ( +
    + {pluralizeWithNumber(results.length, 'result')} +
    + ) : ( +
    + + + {tabs.map((t) => ( + + {t.label} + + ))} + + +
    + )} + +
    + + {buildEmptyMessage(query.trim(), allowedTypes, outOfScopeTypes)} + + {q ? ( + + {results.map((component) => ( + + ))} + + ) : browseItems ? ( + + {browseItems.map((component) => ( + + ))} + + ) : ( + allGroups.map((group) => ( + + {group.components.map((component) => ( + + ))} + + )) + )} + + + +
    +
    + + +
    + {activeComponent ? ( + <> + Selected + + {activeComponent.name} + + ) : ( + Select a component to add + )} +
    +
    + {onCancel ? ( + + ) : null} + +
    +
    +
    + ); +}; diff --git a/frontend/src/components/pages/rp-connect/pipeline/editor-tips-bar.test.tsx b/frontend/src/components/pages/rp-connect/pipeline/editor-tips-bar.test.tsx new file mode 100644 index 0000000000..5ae1252720 --- /dev/null +++ b/frontend/src/components/pages/rp-connect/pipeline/editor-tips-bar.test.tsx @@ -0,0 +1,45 @@ +/** + * 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 { render, screen } from 'test-utils'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// The tips bar derives its key-chip text from the platform at module load, so each test picks the +// platform first, then imports a fresh copy of the module. +const platform = vi.hoisted(() => ({ mac: false })); +vi.mock('utils/platform', () => ({ isMacOS: () => platform.mac })); + +async function renderVisualTips(mac: boolean) { + platform.mac = mac; + vi.resetModules(); + const { EditorTipsBar } = await import('./editor-tips-bar'); + render(); +} + +describe('EditorTipsBar — shortcut chip formatting', () => { + beforeEach(() => { + vi.resetModules(); + }); + + it('joins keys with "+" and spells out modifiers off macOS (Alt+Z, Ctrl+Z, Ctrl+Shift+Z)', async () => { + await renderVisualTips(false); + expect(screen.getByText('Alt+Z')).toBeInTheDocument(); + expect(screen.getByText('Ctrl+Z')).toBeInTheDocument(); + expect(screen.getByText('Ctrl+Shift+Z')).toBeInTheDocument(); + }); + + it('runs glyphs together on macOS (⌥Z, ⌘Z, ⌘⇧Z)', async () => { + await renderVisualTips(true); + expect(screen.getByText('⌥Z')).toBeInTheDocument(); + expect(screen.getByText('⌘Z')).toBeInTheDocument(); + expect(screen.getByText('⌘⇧Z')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/pages/rp-connect/pipeline/editor-tips-bar.tsx b/frontend/src/components/pages/rp-connect/pipeline/editor-tips-bar.tsx new file mode 100644 index 0000000000..02a62f8c03 --- /dev/null +++ b/frontend/src/components/pages/rp-connect/pipeline/editor-tips-bar.tsx @@ -0,0 +1,123 @@ +/** + * 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 { Kbd } from 'components/redpanda-ui/components/kbd'; +import { Lightbulb } from 'lucide-react'; +import { Fragment, type ReactNode } from 'react'; +import { altKey, formatShortcut, modKey, shiftKey } from 'utils/shortcuts'; + +/** Which editor surface the tips relate to — drives a different tip set. */ +export type TipContext = 'yaml' | 'visual'; + +type Tip = { id: string; content: ReactNode }; + +const Key = ({ children }: { children: ReactNode }) => ( + + {children} + +); + +function tipsFor( + context: TipContext, + { slashMenuEnabled, readOnly }: { slashMenuEnabled: boolean; readOnly: boolean } +): Tip[] { + if (context === 'visual') { + const tips: Tip[] = [ + { id: 'select', content: <>Click a node to {readOnly ? 'inspect' : 'edit'} it }, + { + id: 'zoom', + content: ( + <> + Drag to pan, hold Z / {formatShortcut(altKey(), 'Z')} and click to zoom + + ), + }, + { + id: 'palette', + content: ( + <> + / to search nodes and actions + + ), + }, + ]; + if (!readOnly) { + tips.push({ + id: 'history', + content: ( + <> + {formatShortcut(modKey(), 'Z')} to undo, {formatShortcut(modKey(), shiftKey(), 'Z')}{' '} + to redo + + ), + }); + } + return tips; + } + + const tips: Tip[] = []; + if (slashMenuEnabled && !readOnly) { + tips.push({ + id: 'slash', + content: ( + <> + Press / to insert variables, secrets, and topics + + ), + }); + } + if (!readOnly) { + tips.push({ + id: 'save', + content: ( + <> + Save with {formatShortcut(modKey(), 'S')} + + ), + }); + } + return tips; +} + +/** Full-width strip of contextual usage tips beneath the editor (different sets for YAML vs. visual). */ +export function EditorTipsBar({ + context, + slashMenuEnabled = false, + readOnly = false, +}: { + context: TipContext; + slashMenuEnabled?: boolean; + readOnly?: boolean; +}) { + const tips = tipsFor(context, { slashMenuEnabled, readOnly }); + if (tips.length === 0) { + return null; + } + + return ( + + ); +} diff --git a/frontend/src/components/pages/rp-connect/pipeline/floating-chip-panel.tsx b/frontend/src/components/pages/rp-connect/pipeline/floating-chip-panel.tsx new file mode 100644 index 0000000000..70f924ca83 --- /dev/null +++ b/frontend/src/components/pages/rp-connect/pipeline/floating-chip-panel.tsx @@ -0,0 +1,112 @@ +/** + * 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 { cn } from 'components/redpanda-ui/lib/utils'; +import { ChevronDown } from 'lucide-react'; +import type React from 'react'; +import { useEffect, useRef, useState } from 'react'; + +/** + * Popover-style dismissal for a floating chip's expanded list: close on outside click or Escape. + * Returns the ref to attach to the chip's container. + */ +function useChipDismissal(open: boolean, setOpen: (next: boolean) => void) { + const containerRef = useRef(null); + useEffect(() => { + if (!open) { + return; + } + const onPointerDown = (e: PointerEvent) => { + if (e.target instanceof Node && !containerRef.current?.contains(e.target)) { + setOpen(false); + } + }; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + // Claim the event: Escape dismisses only this topmost layer, not also the canvas + // selection (whose window-level handler checks defaultPrevented / stopped events). + e.preventDefault(); + e.stopPropagation(); + setOpen(false); + } + }; + document.addEventListener('pointerdown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('pointerdown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [open, setOpen]); + return containerRef; +} + +type FloatingChipPanelProps = { + /** Leading indicator in the chip — a status dot or an icon. */ + leading: React.ReactNode; + /** Chip text (e.g. "3 problems"). */ + label: React.ReactNode; + /** Tone/color classes appended to the chip button (border/text/hover). */ + chipClassName?: string; + chipTestId?: string; + /** Width/extra classes for the expanded list container. */ + listClassName?: string; + listTestId?: string; + /** Expanded-list content; call `close` after acting on a row to collapse the panel. */ + children: (close: () => void) => React.ReactNode; +}; + +/** + * A floating, bottom-anchored chip on the Visual canvas that expands to a scrollable list on click. + * Owns open/close (outside-click + Escape dismissal); callers supply the chip's tone, label, and rows. + */ +export function FloatingChipPanel({ + leading, + label, + chipClassName, + chipTestId, + listClassName, + listTestId, + children, +}: FloatingChipPanelProps) { + const [open, setOpen] = useState(false); + const containerRef = useChipDismissal(open, setOpen); + + return ( +
    + + + {open ? ( +
    + {children(() => setOpen(false))} +
    + ) : null} +
    + ); +} diff --git a/frontend/src/components/pages/rp-connect/pipeline/index.test.tsx b/frontend/src/components/pages/rp-connect/pipeline/index.test.tsx index f5a4aeaca9..671b048a2c 100644 --- a/frontend/src/components/pages/rp-connect/pipeline/index.test.tsx +++ b/frontend/src/components/pages/rp-connect/pipeline/index.test.tsx @@ -52,13 +52,12 @@ import { } from 'protogen/redpanda/api/dataplane/v1/pipeline-PipelineService_connectquery'; import { act, fireEvent, render, screen, waitFor } from 'test-utils'; -// 1. Mock usePipelineMode const mockUsePipelineMode = vi.fn(() => ({ mode: 'create' as const })); vi.mock('../utils/use-pipeline-mode', () => ({ usePipelineMode: (...args: unknown[]) => mockUsePipelineMode(...args), })); -// 2. Mock config — hoist isFeatureFlagEnabled and isEmbedded so we can control them per-test +// Overridable per test so flags and embedded can be toggled. const mockIsFeatureFlagEnabled = vi.fn((_flag: string) => false); const mockIsEmbedded = vi.fn(() => false); vi.mock('config', async (importOriginal) => { @@ -71,7 +70,6 @@ vi.mock('config', async (importOriginal) => { }; }); -// 3. Mock TanStack Router hooks const mockNavigate = vi.fn(); const mockBack = vi.fn(); const mockSearch = vi.fn(() => ({})); @@ -86,19 +84,36 @@ vi.mock('@tanstack/react-router', async (importOriginal) => { }; }); -// 4. Mock YamlEditor type ContentChangeCallback = (e: { changes: Array<{ text: string }> }) => void; const contentChangeListeners: ContentChangeCallback[] = []; +type CursorPositionCallback = (e: { position: { lineNumber: number; column: number } }) => void; +const cursorPositionListeners: CursorPositionCallback[] = []; const mockEditorInstance = { getPosition: vi.fn(() => ({ lineNumber: 1, column: 4 })), getModel: vi.fn(() => ({ getLineContent: vi.fn(() => ' /'), + // Large enough that node ranges from any test YAML are never clamped. + getLineCount: vi.fn(() => 1000), + getLineMaxColumn: vi.fn(() => 1), })), onDidChangeModelContent: vi.fn((cb: ContentChangeCallback) => { contentChangeListeners.push(cb); return { dispose: vi.fn() }; }), + // Cursor → structure-tree highlight sync subscribes to this. + onDidChangeCursorPosition: vi.fn((cb: CursorPositionCallback) => { + cursorPositionListeners.push(cb); + return { dispose: vi.fn() }; + }), + // Mirrors Monaco: setSelection places the cursor at the selection end and notifies + // cursor-position listeners synchronously, before the call returns. + setSelection: vi.fn((sel: { endLineNumber: number; endColumn: number }) => { + for (const cb of cursorPositionListeners) { + cb({ position: { lineNumber: sel.endLineNumber, column: sel.endColumn } }); + } + }), + revealLineInCenterIfOutsideViewport: vi.fn(), executeEdits: vi.fn(), focus: vi.fn(), // Scroll API used by the read-only viewer's vertical overflow shadows. @@ -132,12 +147,12 @@ vi.mock('components/ui/yaml/yaml-editor', async () => { }; }); -// 5. Mock complex sub-components that are irrelevant to our tests -vi.mock('./pipeline-flow-diagram', async () => { +// The expanded Visual lane renders the canvas; stub it to a marker carrying the YAML. +vi.mock('./pipeline-flow-canvas', async () => { const React = await import('react'); return { - PipelineFlowDiagram: (props: { configYaml: string }) => - React.createElement('div', { 'data-testid': 'flow-diagram', 'data-configyaml': props.configYaml }), + PipelineFlowCanvas: (props: { configYaml: string }) => + React.createElement('div', { 'data-testid': 'flow-canvas', 'data-configyaml': props.configYaml }), }; }); vi.mock('./pipeline-throughput-card', () => ({ PipelineThroughputCard: () => null })); @@ -163,9 +178,7 @@ vi.mock('../onboarding/add-connector-dialog', () => ({ ) : null, })); -// 6. Mock PipelineCommandMenu to render a simple testable version -// The real component needs secrets/topics/users RPCs which are complex to mock -// Includes variant prop to distinguish dialog vs popover instances +// Simplified stub — the real menu needs secrets/topics/users RPCs; variant distinguishes dialog vs popover. vi.mock('./pipeline-command-menu', async () => ({ PipelineCommandMenu: (props: { open: boolean; @@ -189,7 +202,6 @@ vi.mock('./pipeline-command-menu', async () => ({ }, })); -// 7. Mock Zustand stores vi.mock('state/rpcn-wizard-store', () => ({ useRpcnWizardStore: Object.assign( vi.fn(() => ''), @@ -200,7 +212,7 @@ vi.mock('state/rpcn-wizard-store', () => ({ getWizardConnectionData: () => ({ input: undefined, output: undefined }), })); -// Import the component under test AFTER all mocks are set up +// Import after all mocks are set up. import PipelinePage from '.'; function createTransport(overrides?: { @@ -265,16 +277,14 @@ function createTransport(overrides?: { }); } -// The pipeline name now lives in the settings dialog (opened via "Edit settings") -// rather than an inline field in the header. +// The pipeline name lives in the settings dialog (opened via "Edit settings"), not an inline header field. const setPipelineNameViaDialog = async (user: ReturnType, name: string) => { await user.click(screen.getByRole('button', { name: /edit settings/i })); const nameInput = await screen.findByPlaceholderText('Enter pipeline name'); await user.clear(nameInput); await user.type(nameInput, name); await user.click(screen.getByRole('button', { name: /save settings/i })); - // Wait for the settings dialog to fully close so its "Save settings" button - // can't collide with the header's "Save" in later queries. + // Wait for the dialog to close so its "Save settings" button can't collide with the header's "Save". await waitFor(() => expect(screen.queryByPlaceholderText('Enter pipeline name')).not.toBeInTheDocument()); }; @@ -287,9 +297,10 @@ describe('PipelinePage', () => { mockIsEmbedded.mockReturnValue(false); mockUsePipelineMode.mockReturnValue({ mode: 'create' }); contentChangeListeners.length = 0; + cursorPositionListeners.length = 0; }); - // ── Lint panel (molecule gap — LintHintList has no own tests) ─────── + // Lint panel — LintHintList has no tests of its own. it('displays lint warnings from the backend as the user types YAML', async () => { const lintMock = vi.fn().mockReturnValue( @@ -300,14 +311,13 @@ describe('PipelinePage', () => { render(, { transport: createTransport({ lintMock }) }); - // Type YAML content to trigger the lint query (it fires on debounced yaml content) + // Typing triggers the debounced lint query. const yamlEditor = screen.getByTestId('yaml-editor'); act(() => { fireEvent.change(yamlEditor, { target: { value: 'input:\n stdin: {}' } }); }); - // Wait for debounced lint response to appear - // LintHintList renders hints via SimpleCodeBlock with format "Line N, Col N: hint" + // LintHintList renders hints as "Line N, Col N: hint". expect(await screen.findByText('Line 1, Col 1: response lint warning')).toBeInTheDocument(); }); @@ -359,17 +369,35 @@ describe('PipelinePage', () => { fireEvent.change(yamlEditor, { target: { value: 'some: yaml' } }); }); - // Both hints should appear await waitFor(() => { expect(screen.getByText('Line 1, Col 1: first warning')).toBeInTheDocument(); expect(screen.getByText('Line 2, Col 1: second warning')).toBeInTheDocument(); }); - // The "Lint issues" heading should be present expect(screen.getByText('Lint issues')).toBeInTheDocument(); }); - // ── Creating a pipeline ───────────────────────────────────────────── + it('Cmd/Ctrl+S saves the pipeline instead of opening the browser save dialog', async () => { + const user = userEvent.setup(); + const createPipelineMock = vi.fn().mockReturnValue( + create(ConsoleCreatePipelineResponseSchema, { + response: create(CreatePipelineResponseSchema, { + pipeline: create(PipelineSchema, { id: 'new-pipeline' }), + }), + }) + ); + + render(, { transport: createTransport({ createPipelineMock }) }); + + await setPipelineNameViaDialog(user, 'my-pipeline'); + fireEvent.change(screen.getByTestId('yaml-editor'), { target: { value: 'input:\n generate: {}' } }); + + fireEvent.keyDown(window, { key: 's', metaKey: true }); + + await waitFor(() => { + expect(createPipelineMock).toHaveBeenCalled(); + }); + }); it('saving a new pipeline sends the name and YAML config to the backend', async () => { const user = userEvent.setup(); @@ -385,11 +413,9 @@ describe('PipelinePage', () => { await setPipelineNameViaDialog(user, 'my-pipeline'); - // Set YAML via the textarea mock const yamlEditor = screen.getByTestId('yaml-editor'); fireEvent.change(yamlEditor, { target: { value: 'input:\n generate:\n mapping: root = "hello"' } }); - // Click Save const saveButton = screen.getByRole('button', { name: 'Save' }); await user.click(saveButton); @@ -397,7 +423,6 @@ describe('PipelinePage', () => { expect(createPipelineMock).toHaveBeenCalled(); }); - // Verify the request contains our YAML content const callArgs = createPipelineMock.mock.calls[0][0]; expect(callArgs.request.pipeline.configYaml).toBe('input:\n generate:\n mapping: root = "hello"'); }); @@ -416,7 +441,6 @@ describe('PipelinePage', () => { await setPipelineNameViaDialog(user, 'my-pipeline'); - // Set YAML and click Save const yamlEditor = screen.getByTestId('yaml-editor'); fireEvent.change(yamlEditor, { target: { value: 'input:\n stdin: {}' } }); @@ -440,7 +464,7 @@ describe('PipelinePage', () => { render(, { transport: createTransport({ createPipelineMock }) }); - // Set the name directly in the inline title — no need to open the settings dialog. + // Name set directly in the inline title, no settings dialog needed. fireEvent.change(screen.getByRole('textbox', { name: 'Pipeline name' }), { target: { value: 'inline-named' } }); fireEvent.change(screen.getByTestId('yaml-editor'), { target: { value: 'input:\n stdin: {}' } }); @@ -458,7 +482,6 @@ describe('PipelinePage', () => { render(, { transport: createTransport({ createPipelineMock }) }); - // Click Save with no name entered — validation should block it. await user.click(screen.getByRole('button', { name: 'Save' })); expect(await screen.findByText(/at least 3 characters/i)).toBeInTheDocument(); @@ -468,14 +491,12 @@ describe('PipelinePage', () => { it('shows both save errors and real-time lint warnings when a save fails', async () => { const user = userEvent.setup(); - // Lint mock returns a response lint hint const lintMock = vi.fn().mockReturnValue( create(LintPipelineConfigResponseSchema, { lintHints: [create(LintHintSchema, { line: 3, column: 1, hint: 'response warning' })], }) ); - // Create mock throws ConnectError const createPipelineMock = vi.fn().mockImplementation(() => { throw new ConnectError('invalid config'); }); @@ -484,36 +505,30 @@ describe('PipelinePage', () => { await setPipelineNameViaDialog(user, 'my-pipeline'); - // Set YAML (triggers lint query which returns response warning) + // Typing triggers the lint query → response warning. const yamlEditor = screen.getByTestId('yaml-editor'); fireEvent.change(yamlEditor, { target: { value: 'input:\n bad_config: {}' } }); - // Wait for the response lint hint to appear await waitFor(() => { expect(screen.getByText('Line 3, Col 1: response warning')).toBeInTheDocument(); }); - // Click Save to trigger the error const saveButton = screen.getByRole('button', { name: 'Save' }); await user.click(saveButton); - // After error, both the error hint from extractLintHintsFromError AND the response hint should be visible + // Both the save-error hint and the response lint hint stay visible. await waitFor(() => { - // Error hint from extractLintHintsFromError (generic case: "[code] message") expect(screen.getByText(/invalid config/)).toBeInTheDocument(); }); - // Response hint should still be present expect(screen.getByText('Line 3, Col 1: response warning')).toBeInTheDocument(); }); it('editing YAML after a failed save clears the stale error messages', async () => { const user = userEvent.setup(); - // Lint mock returns no response hints const lintMock = vi.fn().mockReturnValue(create(LintPipelineConfigResponseSchema, { lintHints: [] })); - // Create mock throws ConnectError const createPipelineMock = vi.fn().mockImplementation(() => { throw new ConnectError('invalid config'); }); @@ -522,40 +537,33 @@ describe('PipelinePage', () => { await setPipelineNameViaDialog(user, 'my-pipeline'); - // Set YAML const yamlEditor = screen.getByTestId('yaml-editor'); fireEvent.change(yamlEditor, { target: { value: 'input:\n bad: {}' } }); - // Click Save to trigger the error const saveButton = screen.getByRole('button', { name: 'Save' }); await user.click(saveButton); - // Error hint should appear await waitFor(() => { expect(screen.getByText(/invalid config/)).toBeInTheDocument(); }); - // Now edit the YAML — handleYamlChange calls setErrorLintHints({}) (H1 fix) + // Editing YAML clears the stale error hints (setErrorLintHints({})). fireEvent.change(yamlEditor, { target: { value: 'input:\n fixed: {}' } }); - // Error hint should be cleared; with no response hints either, we see "No issues found" await waitFor(() => { expect(screen.queryByText(/invalid config/)).not.toBeInTheDocument(); }); }); - // ── Viewing a pipeline ────────────────────────────────────────────── - it('leaving the view page navigates back to the pipeline list', async () => { const user = userEvent.setup(); mockUsePipelineMode.mockReturnValue({ mode: 'view', pipelineId: 'test-pipeline' }); render(, { transport: createTransport() }); - // Wait for the view mode toolbar to load expect(await screen.findByText('Edit pipeline')).toBeInTheDocument(); - // The back button is the first button in the view toolbar + // The back button is the first button in the view toolbar. const allButtons = screen.getAllByRole('button'); const backButton = allButtons[0]; await user.click(backButton); @@ -570,41 +578,164 @@ describe('PipelinePage', () => { render(, { transport: createTransport() }); - // The pipeline name is the page title (level-1 heading); the generic - // "Pipeline view" chrome heading was removed. The ID shows in the strip below. + // The pipeline name is the page title (level-1 heading), not a generic "Pipeline view" heading. expect(await screen.findByRole('heading', { level: 1, name: 'Test Pipeline' })).toBeInTheDocument(); expect(screen.queryByRole('heading', { name: 'Pipeline view' })).not.toBeInTheDocument(); }); - it('hydrates the flow diagram with pipeline configYaml in view mode', async () => { + it('hydrates the sidebar structure tree from the pipeline config in view mode', async () => { + mockUsePipelineMode.mockReturnValue({ mode: 'view', pipelineId: 'test-pipeline' }); + mockIsFeatureFlagEnabled.mockImplementation( + (flag: string) => flag === 'enablePipelineDiagrams' || flag === 'enableRpcnVisualEditor' + ); + mockIsEmbedded.mockReturnValue(true); + + render(, { transport: createTransport() }); + + // The sidebar structure-tree hydrates from the config: input/output components appear as tree rows. + await waitFor(() => expect(screen.getByText('stdin')).toBeInTheDocument()); + expect(screen.getByText('stdout')).toBeInTheDocument(); + expect(screen.getAllByRole('tree').length).toBeGreaterThan(0); + }); + + it('shows the structure-tree side-lane even when the visual editor flag is off', async () => { + // Diagrams on, visual-editor lane off → the sidebar still uses the structure outline, and the + // full Visual canvas stays hidden. mockUsePipelineMode.mockReturnValue({ mode: 'view', pipelineId: 'test-pipeline' }); mockIsFeatureFlagEnabled.mockImplementation((flag: string) => flag === 'enablePipelineDiagrams'); mockIsEmbedded.mockReturnValue(true); render(, { transport: createTransport() }); - await waitFor(() => { - const diagram = screen.getByTestId('flow-diagram'); - expect(diagram.getAttribute('data-configyaml')).toBe('input:\n stdin: {}\noutput:\n stdout: {}'); + await waitFor(() => expect(screen.getAllByRole('tree').length).toBeGreaterThan(0)); + expect(screen.queryByTestId('flow-canvas')).not.toBeInTheDocument(); + }); + + it('keeps the clicked tree row highlighted while its YAML is revealed in the editor', async () => { + const user = userEvent.setup(); + mockUsePipelineMode.mockReturnValue({ mode: 'create' }); + mockIsFeatureFlagEnabled.mockImplementation((flag: string) => flag === 'enablePipelineDiagrams'); + mockIsEmbedded.mockReturnValue(true); + + render(, { transport: createTransport() }); + + // A processor switch whose last case closes the component: revealing the switch selects its + // whole line range, so the synchronous cursor event lands inside the nested `log` processor. + fireEvent.change(await screen.findByTestId('yaml-editor'), { + target: { + value: [ + 'input:', + ' stdin: {}', + 'pipeline:', + ' processors:', + ' - switch:', + ' - check: this.type == "a"', + ' processors:', + ' - mapping: root = this', + ' - processors:', + ' - log:', + ' message: fallback', + 'output:', + ' stdout: {}', + ].join('\n'), + }, + }); + + const switchRow = await screen.findByRole('treeitem', { name: 'switch' }); + await user.click(switchRow); + + // The reveal ran, and the programmatic cursor move did not steal the explicit tree selection. + expect(mockEditorInstance.setSelection).toHaveBeenCalled(); + expect(switchRow).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByRole('treeitem', { name: 'log' })).toHaveAttribute('aria-selected', 'false'); + }); + + it('offers a "Start from a template" entry in the sidebar visualizer while the pipeline is empty', async () => { + const user = userEvent.setup(); + mockUsePipelineMode.mockReturnValue({ mode: 'create' }); + mockIsFeatureFlagEnabled.mockImplementation( + (flag: string) => + flag === 'enablePipelineDiagrams' || flag === 'enableRpcnVisualEditor' || flag === 'enableRpcnTemplateGallery' + ); + mockIsEmbedded.mockReturnValue(true); + + render(, { transport: createTransport() }); + + // The template CTA lives alongside the YAML lane (not the default Visual lane), so switch there first. + await user.click(await screen.findByRole('tab', { name: 'YAML' })); + + // Empty pipeline → the template gallery entry is offered. + expect(await screen.findByTestId('browse-templates-cta')).toBeInTheDocument(); + + // Once the pipeline has real content, the entry animates away. + fireEvent.change(screen.getByTestId('yaml-editor'), { + target: { value: 'input:\n generate:\n mapping: root = {}' }, }); + await waitFor(() => expect(screen.queryByTestId('browse-templates-cta')).not.toBeInTheDocument()); }); - it('view page exposes Monitor and Configuration lanes; Configuration shows the YAML read-only', async () => { + it('view page exposes Monitor and YAML lanes; YAML shows the config read-only', async () => { const user = userEvent.setup(); mockUsePipelineMode.mockReturnValue({ mode: 'view', pipelineId: 'test-pipeline' }); render(, { transport: createTransport() }); // Monitor is the default lane — no YAML editor shown yet. - expect(await screen.findByText('Configuration')).toBeInTheDocument(); + expect(await screen.findByRole('tab', { name: 'YAML' })).toBeInTheDocument(); expect(screen.queryByTestId('yaml-editor')).not.toBeInTheDocument(); - // Switching to Configuration shows the pipeline YAML. - await user.click(screen.getByText('Configuration')); + // Switching to the YAML lane shows the pipeline config read-only. + await user.click(screen.getByRole('tab', { name: 'YAML' })); const yaml = (await screen.findByTestId('yaml-editor')) as HTMLTextAreaElement; expect(yaml.value).toBe('input:\n stdin: {}\noutput:\n stdout: {}'); }); + it('hides the view-mode Visual lane unless the visual editor flag is enabled', async () => { + mockUsePipelineMode.mockReturnValue({ mode: 'view', pipelineId: 'test-pipeline' }); + + render(, { transport: createTransport() }); + + expect(await screen.findByRole('tab', { name: 'YAML' })).toBeInTheDocument(); + expect(screen.queryByRole('tab', { name: 'Visual' })).not.toBeInTheDocument(); + }); + + it('view page Visual lane renders the full pipeline diagram from the pipeline config', async () => { + const user = userEvent.setup(); + mockUsePipelineMode.mockReturnValue({ mode: 'view', pipelineId: 'test-pipeline' }); + // The visual editor builds on the diagrams flag, so both are required. + mockIsFeatureFlagEnabled.mockImplementation( + (flag: string) => flag === 'enableRpcnVisualEditor' || flag === 'enablePipelineDiagrams' + ); + mockIsEmbedded.mockReturnValue(true); + + render(, { transport: createTransport() }); + + await user.click(await screen.findByRole('tab', { name: 'Visual' })); + + const canvas = await screen.findByTestId('flow-canvas'); + expect(canvas.getAttribute('data-configyaml')).toBe('input:\n stdin: {}\noutput:\n stdout: {}'); + }); + + it('opens editing on the Visual lane when the visual editor is enabled, and YAML swaps in the editor', async () => { + const user = userEvent.setup(); + mockUsePipelineMode.mockReturnValue({ mode: 'edit', pipelineId: 'test-pipeline' }); + // The visual editor builds on the diagrams flag, so both are required. + mockIsFeatureFlagEnabled.mockImplementation( + (flag: string) => flag === 'enableRpcnVisualEditor' || flag === 'enablePipelineDiagrams' + ); + mockIsEmbedded.mockReturnValue(true); + + render(, { transport: createTransport() }); + + // Visual is the default edit lane when the flag is on — the editor is not shown. + expect(await screen.findByTestId('flow-canvas')).toBeInTheDocument(); + expect(screen.queryByTestId('yaml-editor')).not.toBeInTheDocument(); + + // Switching to YAML swaps in the editor. + await user.click(await screen.findByRole('tab', { name: 'YAML' })); + expect(await screen.findByTestId('yaml-editor')).toBeInTheDocument(); + }); + it('confirms before stopping a running pipeline', async () => { const user = userEvent.setup(); mockUsePipelineMode.mockReturnValue({ mode: 'view', pipelineId: 'test-pipeline' }); @@ -627,13 +758,10 @@ describe('PipelinePage', () => { }); }); - // ── Command menu ─────────────────────────────────────────────────── - it('clicking a sidebar variable button opens the command menu with the correct filter', async () => { const user = userEvent.setup(); render(, { transport: createTransport() }); - // The sidebar "Variables" button opens the command menu filtered to variables const variablesButton = screen.getByRole('button', { name: /variables/i }); await user.click(variablesButton); @@ -645,58 +773,48 @@ describe('PipelinePage', () => { it('typing / in the editor dismisses an open command menu to avoid overlap', async () => { const user = userEvent.setup(); - // Enable the slash command feature flag for this test mockIsFeatureFlagEnabled.mockImplementation((flag: string) => flag === 'enableConnectSlashMenu'); render(, { transport: createTransport() }); - // Wait for the editor mock to mount and the useSlashCommand hook to subscribe + // Wait for the editor mock to mount so useSlashCommand subscribes. await waitFor(() => { expect(contentChangeListeners.length).toBeGreaterThan(0); }); - // Open the command menu via a sidebar button const secretsButton = screen.getByRole('button', { name: /secrets/i }); await user.click(secretsButton); - // Verify command menu is open await waitFor(() => { expect(screen.getByTestId('command-menu')).toBeInTheDocument(); }); - // Simulate a slash trigger via the mock editor's content change listener. - // The useSlashCommand hook subscribes to onDidChangeModelContent. - // When a '/' is detected, detectSlashTrigger checks getPosition() + getModel().getLineContent(). - // Our mock returns position {lineNumber:1, column:4} and lineContent ' /' which means - // slashColumn=3, charBefore=' ' (whitespace) → valid trigger position. - // The hook then calls onOpen (handleSlashOpen) which sets commandMenuFilter to null. + // Fire a slash trigger through the mock editor's content-change listener; the mock's position + // {line:1,col:4} + line content ' /' is a valid trigger, so the hook closes the command menu. act(() => { for (const cb of contentChangeListeners) { cb({ changes: [{ text: '/' }] }); } }); - // The command dialog should now be closed await waitFor(() => { expect(screen.queryByTestId('command-menu')).not.toBeInTheDocument(); }); }); - // ── Feature flags and mode routing ────────────────────────────────── - describe('feature flags and mode routing', () => { - it('shows a slash-command tip banner when the feature is enabled', async () => { + it('shows the slash-command tip in the editor tips bar when the feature is enabled', async () => { mockIsFeatureFlagEnabled.mockImplementation((flag: string) => flag === 'enableConnectSlashMenu'); render(, { transport: createTransport() }); + // The tips bar leads with the slash tip (rotation starts at index 0). await waitFor(() => { - expect(screen.getByText(/Tip: Use/)).toBeInTheDocument(); expect(screen.getByText(/to insert variables/)).toBeInTheDocument(); }); }); - it('hides the slash-command tip banner when the feature is disabled', async () => { + it('omits the slash-command tip when the feature is disabled', async () => { // Default: all flags return false render(, { transport: createTransport() }); @@ -704,7 +822,7 @@ describe('PipelinePage', () => { expect(screen.getByTestId('yaml-editor')).toBeInTheDocument(); }); - expect(screen.queryByText(/Tip: Use/)).not.toBeInTheDocument(); + expect(screen.queryByText(/to insert variables/)).not.toBeInTheDocument(); }); it('uses the new log explorer when the feature flag is enabled', async () => { @@ -743,7 +861,6 @@ describe('PipelinePage', () => { expect(screen.getByRole('textbox', { name: 'Pipeline name' })).toHaveValue('Test Pipeline'); }); - // The yaml editor textarea should be populated with the pipeline's configYaml const yamlEditor = screen.getByTestId('yaml-editor') as HTMLTextAreaElement; await waitFor(() => { expect(yamlEditor.value).toBe('input:\n stdin: {}\noutput:\n stdout: {}'); @@ -751,17 +868,14 @@ describe('PipelinePage', () => { }); }); - // ── AddConnectorDialog integration ───────────────────────────────── - it('renders AddConnectorDialog inline and generates YAML on connector selection', async () => { mockIsFeatureFlagEnabled.mockImplementation((flag: string) => flag === 'enablePipelineDiagrams'); mockUsePipelineMode.mockReturnValue({ mode: 'create' }); render(, { transport: createTransport() }); - // The connector card "+" buttons set addConnectorType, but AddConnectorDialog - // is only rendered when addConnectorType is non-null. Since we mock - // AddConnectorsCard to null, we verify the dialog is not rendered by default. + // AddConnectorDialog only renders when addConnectorType is non-null; with AddConnectorsCard + // mocked to null nothing sets it, so the dialog stays absent. expect(screen.queryByTestId('add-connector-dialog')).not.toBeInTheDocument(); }); }); diff --git a/frontend/src/components/pages/rp-connect/pipeline/index.tsx b/frontend/src/components/pages/rp-connect/pipeline/index.tsx index 113c311799..0acc668418 100644 --- a/frontend/src/components/pages/rp-connect/pipeline/index.tsx +++ b/frontend/src/components/pages/rp-connect/pipeline/index.tsx @@ -17,7 +17,6 @@ import { useBlocker, useNavigate, useRouter, useSearch } from '@tanstack/react-r import { getUserTagEntries, isSystemTag } from 'components/constants'; import { ArrowLeftIcon } from 'components/icons'; import { Alert, AlertDescription, AlertTitle } from 'components/redpanda-ui/components/alert'; -import { Banner, BannerClose, BannerContent } from 'components/redpanda-ui/components/banner'; import { Button } from 'components/redpanda-ui/components/button'; import { CountDot } from 'components/redpanda-ui/components/count-dot'; import { @@ -29,7 +28,6 @@ import { DialogHeader, DialogTitle, } from 'components/redpanda-ui/components/dialog'; -import { Kbd } from 'components/redpanda-ui/components/kbd'; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from 'components/redpanda-ui/components/resizable'; import { Separator } from 'components/redpanda-ui/components/separator'; import { Skeleton } from 'components/redpanda-ui/components/skeleton'; @@ -42,7 +40,6 @@ import { DeleteResourceAlertDialog } from 'components/ui/delete-resource-alert-d import { LintHintList } from 'components/ui/lint-hint/lint-hint-list'; import { YamlEditor } from 'components/ui/yaml/yaml-editor'; import { isEmbedded, isFeatureFlagEnabled, isServerless } from 'config'; -import { useDebouncedValue } from 'hooks/use-debounced-value'; import { useRefFormDialog } from 'hooks/use-ref-form-dialog'; import { KeyRound, LayoutGrid, Plus, User, Zap } from 'lucide-react'; import type { editor } from 'monaco-editor'; @@ -62,13 +59,9 @@ import { PipelineUpdateSchema, UpdatePipelineRequestSchema as UpdatePipelineRequestSchemaDataPlane, } from 'protogen/redpanda/api/dataplane/v1/pipeline_pb'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'; import { type Resolver, type UseFormReturn, useForm } from 'react-hook-form'; -import { - useGetPipelineServiceConfigSchemaQuery, - useLintPipelineConfigQuery, - useListComponentsQuery, -} from 'react-query/api/connect'; +import { useGetPipelineServiceConfigSchemaQuery, useListComponentsQuery } from 'react-query/api/connect'; import { useCreatePipelineMutation, useDeletePipelineMutation, @@ -83,12 +76,18 @@ import { z } from 'zod'; import { ConfigDialog } from './config-dialog'; import { DetailsDialog } from './details-dialog'; +import { EditorTipsBar, type TipContext } from './editor-tips-bar'; import { PipelineCommandMenu } from './pipeline-command-menu'; -import { PipelineFlowDiagram } from './pipeline-flow-diagram'; import { PipelineEditHeader, PipelineViewHeader } from './pipeline-header'; +import { PipelineStructureTree } from './pipeline-structure-tree'; import { PipelineThroughputCard } from './pipeline-throughput-card'; +import { ScrollShadow } from './scroll-shadow'; +import { TemplateGalleryCta } from './template-cta'; import { PipelineEditorProvider, usePipelineEditorStore, usePipelineEditorStoreApi } from './use-pipeline-editor-store'; +import { usePipelineLint } from './use-pipeline-lint'; +import { useSaveHotkey } from './use-save-hotkey'; import { useSlashCommand } from './use-slash-command'; +import { VisualEditorPanel } from './visual-editor-panel'; import { extractLintHintsFromError } from '../errors'; import { AddConnectorDialog } from '../onboarding/add-connector-dialog'; import { AddConnectorsCard } from '../onboarding/add-connectors-card'; @@ -106,6 +105,9 @@ import type { UserStepRef, } from '../types/wizard'; import { navigateToConnectClusters } from '../utils/navigation'; +import { changedNodeIds } from '../utils/pipeline-diff'; +import { parsePipelineFlowTree, shouldOfferTemplate } from '../utils/pipeline-flow-parser'; +import { enclosingNodeId, mapLintHintsToNodes, nodeLineRanges } from '../utils/pipeline-lint'; import { parseSchema } from '../utils/schema'; import { useCreateModeInitialYaml } from '../utils/use-create-mode-initial-yaml'; import { usePipelineMode } from '../utils/use-pipeline-mode'; @@ -118,19 +120,27 @@ function getConnectorDialogTitle(type: ConnectComponentType | 'resource' | null) if (type === 'output') { return 'Add an output'; } - if (type) { - return `Add a ${type}`; - } - return; + return type ? `Add a ${type}` : undefined; } function getConnectorDialogPlaceholder(type: ConnectComponentType | 'resource' | null): string | undefined { - if (type) { - return `Search ${type}s...`; + return type ? `Search ${type}s...` : undefined; +} + +// Tips to show beneath the editor for the active lane; read-only YAML and Monitor get none. +function tipsContextForLane(isView: boolean, viewLane: string, editLane: string): TipContext | null { + if (isView) { + return viewLane === 'visual' ? 'visual' : null; } - return; + return editLane === 'visual' ? 'visual' : 'yaml'; } +// Stable empty set for the "nothing unsaved" / view-mode case so highlights don't churn renders. +const EMPTY_NODE_IDS: ReadonlySet = new Set(); + +// How many effect re-runs (editor mount, ranges catch-up) a reveal request survives unresolved. +const MAX_REVEAL_ATTEMPTS = 5; + const pipelineFormSchema = z.object({ name: z .string() @@ -222,31 +232,9 @@ function parseYamlEditorSchema(configSchema: string | undefined) { } } -function usePipelineLint(yamlContent: string, errorLintHints: Record, enabled: boolean) { - const debouncedYamlContent = useDebouncedValue(yamlContent, 500); - const { data: lintResponse, isPending: isLintPending } = useLintPipelineConfigQuery(debouncedYamlContent, { - enabled, - }); - - const lintHints = useMemo(() => { - const merged: Record = {}; - for (const [key, hint] of Object.entries(errorLintHints)) { - merged[`error_${key}`] = hint; - } - if (lintResponse) { - for (const [idx, hint] of Object.entries(lintResponse.lintHints || [])) { - merged[`lint_hint_${idx}`] = hint; - } - } - return merged; - }, [errorLintHints, lintResponse]); - - return { lintHints, isLintPending }; -} - function usePipelineSave({ form, - yamlContent, + editorStore, mode, pipelineId, pipeline, @@ -254,7 +242,8 @@ function usePipelineSave({ onBeforeSaveNavigate, }: { form: UseFormReturn; - yamlContent: string; + /** The editor store — read fresh at save time (after flushing pending visual edits). */ + editorStore: ReturnType; mode: string; pipelineId: string | undefined; pipeline: Pipeline | undefined; @@ -287,6 +276,10 @@ function usePipelineSave({ return; } + // Flush the Visual lane's in-progress edit (the user may save mid-edit), then read fresh YAML. + editorStore.getState().pendingEditCommit?.(); + const yamlContent = editorStore.getState().yamlContent; + const { name, description, computeUnits, tags: formTags } = form.getValues(); const userTags = buildUserTags(formTags); @@ -341,7 +334,7 @@ function usePipelineSave({ } }, [ form, - yamlContent, + editorStore, mode, pipelineId, createMutation, @@ -479,34 +472,41 @@ function YamlViewPanel({ configYaml: string; schema: ReturnType; }) { - // Top/bottom shadows from Monaco's scroll position (useScrollShadow needs a native - // scroll container; Monaco virtualizes, so onDidScrollChange is the only signal). + // Top/bottom shadows from Monaco's scroll position (it virtualizes, so onDidScrollChange is the only signal). const [overflow, setOverflow] = useState({ top: false, bottom: false }); - // Listener disposables from the editor mount, torn down on unmount (effect below). Without - // this the sync closures (which capture the editor) keep the editor + listener graph alive - // per mount of the view page. + // Mount-time listener disposables, torn down on unmount so the editor + listener graph can be GC'd. const scrollSyncSubscriptions = useRef[]>([]); - const handleMount = useCallback((instance: editor.IStandaloneCodeEditor) => { - const sync = () => { - const scrollTop = instance.getScrollTop(); - const maxY = instance.getScrollHeight() - instance.getLayoutInfo().height; - setOverflow({ top: scrollTop > 1, bottom: scrollTop < maxY - 1 }); - }; - scrollSyncSubscriptions.current = [ - instance.onDidScrollChange(sync), - instance.onDidContentSizeChange(sync), - instance.onDidLayoutChange(sync), - ]; - sync(); - }, []); - useEffect(function disposeScrollSyncListeners() { - return () => { - for (const subscription of scrollSyncSubscriptions.current) { - subscription.dispose(); - } - scrollSyncSubscriptions.current = []; - }; - }, []); + // Register the read-only viewer as the active editor so sidebar/Visual selection can reveal lines here too. + const setEditorInstance = usePipelineEditorStore((s) => s.setEditorInstance); + const handleMount = useCallback( + (instance: editor.IStandaloneCodeEditor) => { + const sync = () => { + const scrollTop = instance.getScrollTop(); + const maxY = instance.getScrollHeight() - instance.getLayoutInfo().height; + setOverflow({ top: scrollTop > 1, bottom: scrollTop < maxY - 1 }); + }; + scrollSyncSubscriptions.current = [ + instance.onDidScrollChange(sync), + instance.onDidContentSizeChange(sync), + instance.onDidLayoutChange(sync), + ]; + sync(); + setEditorInstance(instance); + }, + [setEditorInstance] + ); + useEffect( + function disposeScrollSyncListeners() { + return () => { + for (const subscription of scrollSyncSubscriptions.current) { + subscription.dispose(); + } + scrollSyncSubscriptions.current = []; + setEditorInstance(null); + }; + }, + [setEditorInstance] + ); const edge = 'pointer-events-none absolute inset-x-0 h-4 from-black/10 to-transparent transition-opacity duration-150 dark:from-black/40'; @@ -557,7 +557,7 @@ function ViewModePanel({ pipeline }: { pipeline: Pipeline | undefined }) { ) : null} -
    +
    {isFeatureFlagEnabled('enableNewPipelineLogs') ? ( // Title renders inline in the explorer's control row to line up with the table. void; yamlContent: string; onYamlChange: (val: string) => void; onEditorMount: (editorRef: editor.IStandaloneCodeEditor) => void; @@ -605,33 +601,16 @@ function EditorPanel({ {isServerlessInitializing ? ( ) : ( - <> - {slashTipVisible ? ( -
    - - - Tip: Use{' '} - - / - {' '} - to insert variables - - - -
    - ) : null} - {/* Out of flow so Monaco can't feed its width up the layout and latch the page wide. */} -
    - onYamlChange(val || '')} - onEditorMount={onEditorMount} - options={slashTipVisible ? { padding: { top: 32 } } : undefined} - schema={yamlEditorSchema} - transparentBackground - value={yamlContent} - /> -
    - + // Out of flow so Monaco can't feed its width up the layout and latch the page wide. +
    + onYamlChange(val || '')} + onEditorMount={onEditorMount} + schema={yamlEditorSchema} + transparentBackground + value={yamlContent} + /> +
    )} @@ -653,41 +632,151 @@ function EditorPanel({ ); } +function useShouldOfferTemplate(yamlContent: string): boolean { + return useMemo(() => shouldOfferTemplate(yamlContent, parsePipelineFlowTree(yamlContent).nodes), [yamlContent]); +} + function SidebarPanel({ mode, yamlContent, isPipelineDiagramsEnabled, + errorNodeIds, + unsavedNodeIds, onAddConnector, - onAddTopic, - onAddSasl, - onOpenCommandMenu, onBrowseTemplates, + onOpenCommandMenu, }: { mode: string; yamlContent: string; isPipelineDiagramsEnabled: boolean; + errorNodeIds?: ReadonlySet; + unsavedNodeIds?: ReadonlySet; onAddConnector: (type: ConnectComponentType | 'resource') => void; - onAddTopic: (section: string, componentName: string) => void; - onAddSasl: (section: string, componentName: string) => void; - onOpenCommandMenu: (filter?: 'all' | 'variables' | 'secrets' | 'topics' | 'users') => void; onBrowseTemplates?: () => void; + onOpenCommandMenu: (filter?: 'all' | 'variables' | 'secrets' | 'topics' | 'users') => void; }) { - // View mode is read-only; only wire editing handlers otherwise. - const editHandlers = - mode === 'view' - ? {} - : { - onAddConnector: (type: string) => onAddConnector(type as ConnectComponentType), - onAddSasl, - onAddTopic, - onBrowseTemplates, - }; + // View mode is read-only; only wire add handlers otherwise. + const canEdit = mode !== 'view'; + // The full-document parses below tolerate stale YAML — defer it off the per-keystroke critical path. + const deferredYaml = useDeferredValue(yamlContent); + const offerTemplate = useShouldOfferTemplate(deferredYaml); + const showStructureTree = isPipelineDiagramsEnabled; + + // Two-way sync: clicking a node reveals/selects its lines; moving the cursor highlights the node. + const editorInstance = usePipelineEditorStore((s) => s.editorInstance); + const [activeNodeId, setActiveNodeId] = useState(); + const nodeRanges = useMemo(() => { + try { + return nodeLineRanges(deferredYaml); + } catch { + return []; + } + }, [deferredYaml]); + // Latest ranges for the long-lived cursor listener, without re-subscribing per keystroke. + const nodeRangesRef = useRef(nodeRanges); + nodeRangesRef.current = nodeRanges; + + // While true, the cursor listener below skips its highlight sync — a programmatic reveal + // (which fires setSelection synchronously) must not overwrite the user's explicit tree selection. + const suppressCursorSyncRef = useRef(false); + + const revealNodeInEditor = useCallback( + (nodeId?: string) => { + const ed = editorInstance; + const range = nodeId ? nodeRanges.find((r) => r.id === nodeId) : undefined; + const model = ed?.getModel(); + if (!(ed && range && model)) { + return; + } + const endLine = Math.min(range.end, model.getLineCount()); + // setSelection dispatches onDidChangeCursorPosition synchronously, so a same-tick flag + // is enough to keep it from re-deriving the highlight from the (possibly ancestor) range. + suppressCursorSyncRef.current = true; + try { + ed.setSelection({ + startLineNumber: range.start, + startColumn: 1, + endLineNumber: endLine, + endColumn: model.getLineMaxColumn(endLine), + }); + } finally { + suppressCursorSyncRef.current = false; + } + ed.revealLineInCenterIfOutsideViewport(range.start); + ed.focus(); + }, + [editorInstance, nodeRanges] + ); + + const handleSelectNode = useCallback( + (highlightId: string, editableId?: string) => { + setActiveNodeId(highlightId); + revealNodeInEditor(editableId); + }, + [revealNodeInEditor] + ); + + // Editor cursor → highlight the most specific node enclosing the caret line. + useEffect(() => { + if (!editorInstance) { + return; + } + const sub = editorInstance.onDidChangeCursorPosition((e) => { + // Skip while a programmatic reveal is selecting an editable ancestor on the tree's behalf; + // syncing here would snap the highlight from the clicked child row to that ancestor. + if (suppressCursorSyncRef.current) { + return; + } + setActiveNodeId(enclosingNodeId(e.position.lineNumber, nodeRangesRef.current)); + }); + return () => sub.dispose(); + }, [editorInstance]); + + // Pending reveal request from the Visual lane: honour once editor + ranges mount, then clear (fires once). + const revealNodeId = usePipelineEditorStore((s) => s.revealNodeId); + const requestRevealNode = usePipelineEditorStore((s) => s.requestRevealNode); + const revealAttemptRef = useRef<{ id: string | null; count: number }>({ id: null, count: 0 }); + useEffect(() => { + if (!revealNodeId) { + revealAttemptRef.current = { id: null, count: 0 }; + return; + } + if (revealAttemptRef.current.id !== revealNodeId) { + revealAttemptRef.current = { id: revealNodeId, count: 0 }; + } + const range = nodeRanges.find((r) => r.id === revealNodeId); + if (editorInstance?.getModel() && range) { + setActiveNodeId(revealNodeId); + revealNodeInEditor(revealNodeId); + requestRevealNode(null); + return; + } + // Bounded retry: drop the request so an id that never resolves can't fire a surprise jump later. + revealAttemptRef.current.count += 1; + if (revealAttemptRef.current.count > MAX_REVEAL_ATTEMPTS) { + requestRevealNode(null); + } + }, [revealNodeId, editorInstance, nodeRanges, revealNodeInEditor, requestRevealNode]); + const showTemplateCta = showStructureTree && canEdit && Boolean(onBrowseTemplates) && offerTemplate; return (
    -
    - {isPipelineDiagramsEnabled ? ( - + {/* Relative so the template entry point can float pinned at the bottom with an enter/exit animation. */} +
    + + {showStructureTree ? ( + onAddConnector(section as ConnectComponentType) : undefined} + onSelectNode={handleSelectNode} + selectedNodeId={activeNodeId} + unsavedNodeIds={unsavedNodeIds} + /> + ) : null} + + {showStructureTree && onBrowseTemplates ? ( + ) : null}
    {mode !== 'view' && ( @@ -755,12 +844,17 @@ function SidebarPanel({ ); } +// The visual editor builds on the diagram parsing, so it also requires the diagrams flag and the +// embedded Cloud UI. +const isVisualEditorFeatureEnabled = (): boolean => + isFeatureFlagEnabled('enableRpcnVisualEditor') && isFeatureFlagEnabled('enablePipelineDiagrams') && isEmbedded(); + export default function PipelinePage() { - const { mode, pipelineId } = usePipelineMode(); - const isSlashMenuEnabled = isFeatureFlagEnabled('enableConnectSlashMenu'); + const { pipelineId } = usePipelineMode(); + const isVisualEditorEnabled = isVisualEditorFeatureEnabled(); // Keyed by pipeline id so each pipeline gets a fresh editor store. return ( - + ); @@ -775,6 +869,7 @@ function PipelinePageContent() { const isSlashMenuEnabled = isFeatureFlagEnabled('enableConnectSlashMenu'); const isServerlessMode = search.serverless === 'true'; const isPipelineDiagramsEnabled = isFeatureFlagEnabled('enablePipelineDiagrams') && isEmbedded(); + const isVisualEditorEnabled = isVisualEditorFeatureEnabled(); const isTemplateGalleryEnabled = isFeatureFlagEnabled('enableRpcnTemplateGallery'); // Actions are stable, so read them once via getState; values use selectors. @@ -787,9 +882,10 @@ function PipelinePageContent() { resolveInitialYaml, setAllowNavigation, setActiveViewLane, + setActiveEditLane, + requestRevealNode, setCommandMenuFilter, setAddConnectorType, - setSlashTipVisible, setIsConfigDialogOpen, setIsViewConfigDialogOpen, setIsDeleteAlertOpen, @@ -801,13 +897,15 @@ function PipelinePageContent() { const editorInstance = usePipelineEditorStore((s) => s.editorInstance); const hydratedPipelineId = usePipelineEditorStore((s) => s.hydratedPipelineId); const activeViewLane = usePipelineEditorStore((s) => s.activeViewLane); + const activeEditLane = usePipelineEditorStore((s) => s.activeEditLane); + const selectedNodeId = usePipelineEditorStore((s) => s.selectedNodeId); const commandMenuFilter = usePipelineEditorStore((s) => s.commandMenuFilter); const addConnectorType = usePipelineEditorStore((s) => s.addConnectorType); - const slashTipVisible = usePipelineEditorStore((s) => s.slashTipVisible); const isConfigDialogOpen = usePipelineEditorStore((s) => s.isConfigDialogOpen); const isViewConfigDialogOpen = usePipelineEditorStore((s) => s.isViewConfigDialogOpen); const isDeleteAlertOpen = usePipelineEditorStore((s) => s.isDeleteAlertOpen); const isTemplateDialogOpen = usePipelineEditorStore((s) => s.isTemplateDialogOpen); + const tipsContext = tipsContextForLane(mode === 'view', activeViewLane, activeEditLane); const form = useForm({ resolver: zodResolver(pipelineFormSchema) as Resolver, @@ -847,7 +945,7 @@ function PipelinePageContent() { const { handleSave, handleDelete, clearWizardStore, errorLintHints, clearErrorLintHints, isSaving, isDeleting } = usePipelineSave({ form, - yamlContent, + editorStore, mode, pipelineId, pipeline, @@ -859,9 +957,34 @@ function PipelinePageContent() { // Guard against losing unsaved edits when navigating away from the editor (edit or create). const yamlDirty = initialYaml !== null && yamlContent !== initialYaml; const hasUnsavedChanges = mode !== 'view' && (form.formState.isDirty || yamlDirty); + + // Guard-time dirty check: flush any in-progress inspector draft into the store first (the + // rendered `hasUnsavedChanges` above can't see a pending draft), then re-read fresh state. + const checkUnsavedChanges = useCallback(() => { + if (mode === 'view') { + return false; + } + editorStore.getState().pendingEditCommit?.(); + const { yamlContent: yaml, initialYaml: baseline } = editorStore.getState(); + return form.formState.isDirty || (baseline !== null && yaml !== baseline); + }, [mode, editorStore, form]); + + // Structure-tree highlights (lint + unsaved). Deferred YAML keeps the parses off the keystroke path. + const deferredYamlContent = useDeferredValue(yamlContent); + const errorNodeIds = useMemo( + () => new Set(mapLintHintsToNodes(deferredYamlContent, Object.values(lintHints)).keys()), + [deferredYamlContent, lintHints] + ); + const unsavedNodeIds = useMemo( + () => + mode !== 'view' && initialYaml !== null + ? new Set(changedNodeIds(initialYaml, deferredYamlContent)) + : EMPTY_NODE_IDS, + [mode, initialYaml, deferredYamlContent] + ); const blocker = useBlocker({ - shouldBlockFn: () => hasUnsavedChanges && !editorStore.getState().allowNavigation, - enableBeforeUnload: () => hasUnsavedChanges, + shouldBlockFn: () => checkUnsavedChanges() && !editorStore.getState().allowNavigation, + enableBeforeUnload: () => checkUnsavedChanges(), withResolver: true, }); // Re-arm the guard whenever the mode changes (e.g. after the post-save nav to view). @@ -869,6 +992,9 @@ function PipelinePageContent() { setAllowNavigation(false); }, [mode, setAllowNavigation]); + // ⌘S / Ctrl+S saves from both the YAML and Visual lanes. + useSaveHotkey({ enabled: mode !== 'view', isSaving, onSave: handleSave }); + // On any document change: clear stale lint and mirror the create-mode draft to the wizard store. useEffect( () => @@ -929,15 +1055,25 @@ function PipelinePageContent() { } }, [pipeline, mode, hydratedPipelineId, hydrateFromServer]); + // Populate the form from the loaded pipeline. The query polls, so a DIRTY form is never reset + // (would clobber edits) — but a clean form re-syncs when the payload changes (concurrent rename). + const formResetSnapshotRef = useRef(null); useEffect(() => { - if (pipeline && mode === 'edit') { - form.reset({ - name: pipeline.displayName, - description: pipeline.description || '', - computeUnits: cpuToTasks(pipeline.resources?.cpuShares) || MIN_TASKS, - tags: getUserTagEntries(pipeline.tags), - }); + if (!(pipeline && mode === 'edit')) { + return; + } + const values = { + name: pipeline.displayName, + description: pipeline.description || '', + computeUnits: cpuToTasks(pipeline.resources?.cpuShares) || MIN_TASKS, + tags: getUserTagEntries(pipeline.tags), + }; + const snapshot = `${pipeline.id}\n${JSON.stringify(values)}`; + if (snapshot === formResetSnapshotRef.current || form.formState.isDirty) { + return; } + formResetSnapshotRef.current = snapshot; + form.reset(values); }, [pipeline, mode, form]); const handleInitialYamlResolved = useCallback((yaml: string) => resolveInitialYaml(yaml), [resolveInitialYaml]); @@ -950,6 +1086,14 @@ function PipelinePageContent() { onResolved: handleInitialYamlResolved, }); + // Create + diagrams: useCreateModeInitialYaml bails, so seed the baseline here or the unsaved-changes + // guard never arms. Serverless resolves its own baseline later; seeding '' first would read false-dirty. + useEffect(() => { + if (mode === 'create' && isPipelineDiagramsEnabled && !isServerlessMode && initialYaml === null) { + resolveInitialYaml(yamlContent); + } + }, [mode, isPipelineDiagramsEnabled, isServerlessMode, initialYaml, yamlContent, resolveInitialYaml]); + const handleCancel = useCallback(() => { if (mode === 'create') { clearWizardStore(); @@ -970,9 +1114,35 @@ function PipelinePageContent() { } }, [mode, clearWizardStore, navigate, pipelineId, router]); + // Visual lanes take the full canvas, so the YAML/diagram sidebar is hidden. + const isViewVisualLane = mode === 'view' && activeViewLane === 'visual'; + const isEditVisualLane = mode !== 'view' && activeEditLane === 'visual'; + const showSidebar = !(isViewVisualLane || isEditVisualLane); + + // Open the YAML lane and reveal a node: explicit id, else the selected node. Routes per mode. + const goToYamlNode = useCallback( + (nodeId?: string) => { + const target = nodeId ?? selectedNodeId; + if (target) { + requestRevealNode(target); + } + if (mode === 'view') { + setActiveViewLane('configuration'); + } else { + // Commit the selected node's in-progress edit before unmounting the Visual lane, + // otherwise the lane switch discards it (no commit-on-unmount). + editorStore.getState().pendingEditCommit?.(); + setActiveEditLane('yaml'); + } + }, + [mode, selectedNodeId, requestRevealNode, setActiveViewLane, setActiveEditLane, editorStore] + ); + return ( - // overflow-x-clip guards against stray horizontal overflow (clip, not hidden, to keep overflow-y visible). -
    + // Definite viewport-bounded height (7rem = app header + pt-8; the footer intentionally sits below + // the fold) so a tall lane scrolls within the framed panel instead of stretching the page. + // overflow-x-clip (not hidden) blocks stray horizontal overflow but keeps overflow-y. +
    {mode === 'view' && pipeline ? ( ) : null} - {/* View-mode lanes: Monitor (throughput/logs) vs Configuration (read-only YAML). */} - {mode === 'view' && pipeline ? ( - - - setActiveViewLane('monitor')} value="monitor" variant="underline"> - Monitor - - setActiveViewLane('configuration')} value="configuration" variant="underline"> - Configuration - - - - ) : null} - {/* min-w-0 + overflow-hidden keep the editor region from propagating width upward. */} -
    - setAddConnectorType(type)} - onAddSasl={handleAddSasl} - onAddTopic={handleAddTopic} - onBrowseTemplates={ - isTemplateGalleryEnabled && mode !== 'view' ? () => setIsTemplateDialogOpen(true) : undefined - } - onOpenCommandMenu={handleCommandMenuOpen} - yamlContent={yamlContent} - /> -
    - {mode === 'view' && activeViewLane === 'monitor' ? : null} - {mode === 'view' && pipeline && activeViewLane === 'configuration' ? ( - + {/* Editor frame flexes to fill the column; the tips strip is pinned just beneath so it stays visible. */} +
    + {/* Framed panel: the lane tabs sit flush at the top, their underline as the internal divider. */} +
    + {mode === 'view' && pipeline ? ( + + {/* Full-width list (so the underline divider spans) with content-width triggers so tabs pack left. */} + + setActiveViewLane('monitor')} value="monitor" variant="underline"> + Monitor + + goToYamlNode()} value="configuration" variant="underline"> + YAML + + {isVisualEditorEnabled ? ( + setActiveViewLane('visual')} value="visual" variant="underline"> + Visual + + ) : null} + + ) : null} - {mode === 'view' ? null : ( - setSlashTipVisible(false)} - onEditorMount={setEditorInstance} - onYamlChange={setYamlContent} - slashTipVisible={slashTipVisible} - yamlContent={yamlContent} - yamlEditorSchema={yamlEditorSchema} - /> - )} + {mode !== 'view' && isVisualEditorEnabled ? ( + + + goToYamlNode()} value="yaml" variant="underline"> + YAML + + setActiveEditLane('visual')} value="visual" variant="underline"> + Visual + + + + ) : null} + {/* min-w-0 + overflow-hidden keep the editor region from propagating width upward. */} +
    + {showSidebar ? ( + setAddConnectorType(type)} + onBrowseTemplates={isTemplateGalleryEnabled ? () => setIsTemplateDialogOpen(true) : undefined} + onOpenCommandMenu={handleCommandMenuOpen} + unsavedNodeIds={unsavedNodeIds} + yamlContent={yamlContent} + /> + ) : null} +
    + {mode === 'view' && activeViewLane === 'monitor' ? : null} + {mode === 'view' && pipeline && activeViewLane === 'configuration' ? ( + + ) : null} + {mode === 'view' && pipeline && activeViewLane === 'visual' ? ( + + ) : null} + {mode !== 'view' && activeEditLane === 'visual' ? ( + setAddConnectorType(type)} + onAddSasl={handleAddSasl} + onAddTopic={handleAddTopic} + onBrowseTemplates={isTemplateGalleryEnabled ? () => setIsTemplateDialogOpen(true) : undefined} + onNavigateToYaml={goToYamlNode} + onYamlChange={setYamlContent} + yamlContent={yamlContent} + /> + ) : null} + {mode === 'view' || activeEditLane === 'visual' ? null : ( + + )} +
    +
    + {tipsContext ? ( + + ) : null}
    diff --git a/frontend/src/components/pages/rp-connect/pipeline/invalid-config-notice.test.tsx b/frontend/src/components/pages/rp-connect/pipeline/invalid-config-notice.test.tsx new file mode 100644 index 0000000000..cc40c8c008 --- /dev/null +++ b/frontend/src/components/pages/rp-connect/pipeline/invalid-config-notice.test.tsx @@ -0,0 +1,37 @@ +/** + * 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 { render, screen } from 'test-utils'; +import { describe, expect, it } from 'vitest'; + +import { InvalidConfigNotice } from './invalid-config-notice'; + +describe('InvalidConfigNotice', () => { + it('renders its message inside a warning alert', () => { + render(Can't visualize the latest YAML.); + expect(screen.getByRole('alert')).toHaveTextContent("Can't visualize the latest YAML."); + }); + + it('stays content-width (w-auto) rather than the Alert default w-full, so the floating canvas banner does not cover the toolbars', () => { + render(notice); + const alert = screen.getByRole('alert'); + expect(alert).toHaveClass('w-auto'); + expect(alert).not.toHaveClass('w-full'); + }); + + it('forwards positioning/padding classes from the caller', () => { + render(notice); + const alert = screen.getByRole('alert'); + expect(alert).toHaveClass('absolute', 'px-3'); + // Caller padding wins over the Alert's default on the same axis. + expect(alert).not.toHaveClass('px-4'); + }); +}); diff --git a/frontend/src/components/pages/rp-connect/pipeline/invalid-config-notice.tsx b/frontend/src/components/pages/rp-connect/pipeline/invalid-config-notice.tsx new file mode 100644 index 0000000000..60a72f9071 --- /dev/null +++ b/frontend/src/components/pages/rp-connect/pipeline/invalid-config-notice.tsx @@ -0,0 +1,28 @@ +/** + * 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 { Alert } from 'components/redpanda-ui/components/alert'; +import { cn } from 'components/redpanda-ui/lib/utils'; +import { TriangleAlert } from 'lucide-react'; +import type { ReactNode } from 'react'; + +/** + * Warning banner for a pipeline config that can't be shown as-is, shared by the visual canvas and the + * sidebar outline. Overrides the Alert's `w-full` to `w-auto` so it stays content-width when floated + * over the canvas; callers pass positioning, padding and text size via `className`. + */ +export function InvalidConfigNotice({ children, className }: { children: ReactNode; className?: string }) { + return ( + } variant="warning"> + {children} + + ); +} diff --git a/frontend/src/components/pages/rp-connect/pipeline/node-config-form.test.tsx b/frontend/src/components/pages/rp-connect/pipeline/node-config-form.test.tsx new file mode 100644 index 0000000000..f81463fdd0 --- /dev/null +++ b/frontend/src/components/pages/rp-connect/pipeline/node-config-form.test.tsx @@ -0,0 +1,290 @@ +/** + * 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, vi } from 'vitest'; + +import { NodeConfigForm } from './node-config-form'; +import type { ConnectComponentSpec } from '../types/schema'; +import { mockKafkaOutput } from '../utils/__fixtures__/component-schemas'; + +const spec = mockKafkaOutput as unknown as ConnectComponentSpec; + +// The form has no Apply button: it REPORTS the assembled config via onConfigChange (null when +// clean) and the inspector auto-commits on leave/save. Tests assert the latest reported config. +function renderForm(value: Record, onConfigChange = vi.fn()) { + render(); + return onConfigChange; +} + +// The most recent config reported by the form (undefined if never called, null when clean). +function lastReported(onConfigChange: ReturnType): unknown { + return onConfigChange.mock.calls.at(-1)?.[0]; +} + +describe('NodeConfigForm — full schema', () => { + test('renders required scalar fields, a scalar-array field, and nested object groups', async () => { + const user = userEvent.setup(); + renderForm({ kafka: { topic: 't', addresses: ['a:9092'] } }); + + // Required scalars + the scalar array. + expect(screen.getByText('topic')).toBeInTheDocument(); + expect(screen.getByText('addresses')).toBeInTheDocument(); + // A non-advanced nested object is exposed as its own sub-section (not raw YAML). + expect(screen.getByText('batching')).toBeInTheDocument(); + // Optional/advanced groupings exist. + expect(screen.getByText('Optional')).toBeInTheDocument(); + expect(screen.getByText('Advanced')).toBeInTheDocument(); + + // Advanced nested objects appear once the Advanced section is expanded. + await user.click(screen.getByText('Advanced')); + expect(screen.getByText('sasl')).toBeInTheDocument(); + expect(screen.getByText('tls')).toBeInTheDocument(); + }); + + test('marks only no-default fields required; a defaulted field (sasl.mechanism) is not', async () => { + const user = userEvent.setup(); + renderForm({ kafka: { topic: 't', addresses: ['a:9092'] } }); + + // A scalar with no default is genuinely required. + expect(screen.getByText('topic').closest('div')?.querySelector('[title="Required"]')).not.toBeNull(); + + // `mechanism` has a default (`none`), so even though the backend left `optional` + // unset it must NOT be flagged required. + await user.click(screen.getByText('Advanced')); + await user.click(screen.getByText('sasl')); + const mechRow = screen.getByText('mechanism').closest('div'); + expect(mechRow).not.toBeNull(); + expect(mechRow?.querySelector('[title="Required"]')).toBeNull(); + }); + + test('shows the schema default as a hint for optional fields', () => { + renderForm({ kafka: { topic: 't', addresses: ['a:9092'] } }); + // partitioner defaults to fnv1a_hash. + expect(screen.getByText('fnv1a_hash')).toBeInTheDocument(); + }); + + test('reports a config only once something changes', async () => { + const user = userEvent.setup(); + const onConfigChange = renderForm({ kafka: { topic: 'orig', addresses: ['a:9092'] } }); + // Clean on mount → reports null (nothing to commit). + expect(lastReported(onConfigChange)).toBeNull(); + + await user.type(screen.getByDisplayValue('orig'), 'X'); + expect(lastReported(onConfigChange)).not.toBeNull(); + }); + + test('reports a changed scalar and keeps the YAML minimal (no empty optionals)', async () => { + const user = userEvent.setup(); + const onConfigChange = renderForm({ kafka: { topic: 'orig', addresses: ['a:9092'] } }); + + const topic = screen.getByDisplayValue('orig'); + await user.clear(topic); + await user.type(topic, 'new-topic'); + + const next = lastReported(onConfigChange) as { kafka: Record }; + expect(next.kafka.topic).toBe('new-topic'); + expect(next.kafka.addresses).toEqual(['a:9092']); + // Untouched optional fields are not written out. + expect(next.kafka).not.toHaveProperty('key'); + expect(next.kafka).not.toHaveProperty('partitioner'); + }); + + test('preserves complex/untouched settings when reporting an unrelated edit', async () => { + const user = userEvent.setup(); + // `metadata` is not in the schema; `count: 1000$` is a malformed int — both must survive. + const onConfigChange = renderForm({ + kafka: { + topic: 'orig', + addresses: ['a:9092'], + metadata: { include_patterns: ['.*'] }, + batching: { count: '1000$' }, + }, + }); + + await user.type(screen.getByDisplayValue('orig'), '-2'); + + const next = lastReported(onConfigChange) as { kafka: { metadata: unknown; batching: { count: unknown } } }; + expect(next.kafka.metadata).toEqual({ include_patterns: ['.*'] }); + // Malformed value is preserved exactly — not parseInt-ed to 1000. + expect(next.kafka.batching.count).toBe('1000$'); + }); + + test('shows a malformed numeric value instead of blanking it (text input, not type=number)', async () => { + const user = userEvent.setup(); + renderForm({ kafka: { topic: 't', addresses: ['a:9092'], batching: { count: '1000$' } } }); + await user.click(screen.getByText('batching')); + expect(screen.getByDisplayValue('1000$')).toBeInTheDocument(); + // …and flags it inline as not a valid integer (with the not-saved warning). + expect(screen.getByText(/Not a valid integer/)).toBeInTheDocument(); + }); + + test('does not commit a malformed numeric value (the saved value is kept)', async () => { + const user = userEvent.setup(); + const onConfigChange = renderForm({ kafka: { topic: 't', addresses: ['a:9092'], batching: { count: 5 } } }); + await user.click(screen.getByText('batching')); + const countInput = screen.getByDisplayValue('5'); + await user.clear(countInput); + await user.type(countInput, '10x'); + + // The field is flagged, and the reported config keeps the saved value — NOT `10` (parseInt + // truncation) and NOT dropped. + expect(screen.getByText(/won't be saved until fixed/)).toBeInTheDocument(); + const next = lastReported(onConfigChange) as { kafka: { batching: { count: unknown } } }; + expect(next.kafka.batching.count).toBe(5); + }); + + test('masks credential-named fields and offers a secret-reference tip', async () => { + const user = userEvent.setup(); + renderForm({ kafka: { topic: 't', addresses: ['a:9092'], sasl: { password: 'hunter2' } } }); + await user.click(screen.getByText('Advanced')); + await user.click(screen.getByText('sasl')); + + const password = screen.getByDisplayValue('hunter2'); + expect(password).toHaveAttribute('type', 'password'); + // The reveal toggle is the registry Input's built-in one (label "Show password"). + expect(screen.getAllByRole('button', { name: 'Show password' }).length).toBeGreaterThan(0); + expect(screen.getAllByText(/reference a secret/i).length).toBeGreaterThan(0); + }); + + test('keeps the saved label when a resource label field is cleared (references depend on it)', async () => { + const user = userEvent.setup(); + const onConfigChange = vi.fn(); + render( + + ); + + await user.clear(screen.getByDisplayValue('shared')); + expect(screen.getByText(/A resource needs a label/)).toBeInTheDocument(); + const next = lastReported(onConfigChange) as { label?: string }; + expect(next.label).toBe('shared'); + }); + + test('does not flag secret/env interpolations in numeric fields', async () => { + const user = userEvent.setup(); + // biome-ignore lint/suspicious/noTemplateCurlyInString: testing literal interpolation syntax + renderForm({ kafka: { topic: 't', addresses: ['a:9092'], batching: { count: '${secrets.BATCH_COUNT}' } } }); + await user.click(screen.getByText('batching')); + expect(screen.queryByText(/Not a valid integer/)).not.toBeInTheDocument(); + }); + + test('keeps an interpolation typed into a numeric field (not coerced to NaN and dropped)', async () => { + const user = userEvent.setup(); + const onConfigChange = renderForm({ kafka: { topic: 't', addresses: ['a:9092'], batching: { count: 5 } } }); + await user.click(screen.getByText('batching')); + const countInput = screen.getByDisplayValue('5'); + await user.clear(countInput); + // `{{` is userEvent's escape for a literal `{`; this types `${env.COUNT}`. + // biome-ignore lint/suspicious/noTemplateCurlyInString: testing literal interpolation syntax + await user.type(countInput, '${{env.COUNT}'); + + const next = lastReported(onConfigChange) as { kafka: { batching: { count: unknown } } }; + // The interpolation is preserved verbatim, not NaN-coerced to '' and deleted. + // biome-ignore lint/suspicious/noTemplateCurlyInString: testing literal interpolation syntax + expect(next.kafka.batching.count).toBe('${env.COUNT}'); + }); + + test('does not render nested-component fields and preserves them on edit', async () => { + const user = userEvent.setup(); + // A `branch`-like spec: a scalar (request_map) + a nested processor sub-pipeline. + const branchSpec = { + config: { + children: [ + { name: 'request_map', type: 'string', kind: 'scalar', optional: false }, + { name: 'processors', type: 'processor', kind: 'array', optional: false }, + ], + }, + } as unknown as ConnectComponentSpec; + const onConfigChange = vi.fn(); + render( + + ); + + // The scalar renders as a control; the nested component renders nothing here — it's its own + // canvas node (no inline control, and no redundant "select it on the canvas" hint). + expect(screen.getByText('request_map')).toBeInTheDocument(); + expect(screen.queryByText('processors')).not.toBeInTheDocument(); + + await user.type(screen.getByDisplayValue('root = this'), '!'); + + const next = lastReported(onConfigChange) as { branch: Record }; + // The sub-pipeline survives untouched; the scalar edit is written. + expect(next.branch.processors).toEqual([{ http: { url: 'http://x' } }]); + expect(next.branch.request_map).toBe('root = this!'); + }); + + test('round-trips a scalar array edited as one-per-line text', async () => { + const user = userEvent.setup(); + const onConfigChange = renderForm({ kafka: { topic: 't', addresses: ['a:9092'] } }); + + const addresses = screen.getByPlaceholderText('One value per line'); + await user.clear(addresses); + await user.type(addresses, 'b:9092\nc:9092'); + + const next = lastReported(onConfigChange) as { kafka: Record }; + expect(next.kafka.addresses).toEqual(['b:9092', 'c:9092']); + }); +}); + +describe('NodeConfigForm — list-valued components (switch/try/…)', () => { + // A switch's value is an array of cases, not an object of fields. Its schema lists a + // single case's fields, so the form must NOT render them or rebuild the value. + const switchSpec = { + name: 'switch', + type: 'processor', + config: { + name: 'root', + type: 'object', + kind: 'scalar', + children: [ + { name: 'check', type: 'string', kind: 'scalar', optional: false }, + { name: 'processors', type: 'processor', kind: 'array' }, + ], + }, + } as unknown as ConnectComponentSpec; + + const switchValue = () => ({ + switch: [ + { check: 'this.region == "us"', processors: [{ mapping: 'root = this' }] }, + { processors: [{ log: { message: 'default' } }] }, + ], + }); + + test('editing the label preserves the array of cases (no data loss)', async () => { + const user = userEvent.setup(); + const onConfigChange = vi.fn(); + const value = switchValue(); + render(); + + await user.type(screen.getByPlaceholderText('Optional identifier for this component'), 'router'); + + expect(lastReported(onConfigChange)).toEqual({ label: 'router', switch: value.switch }); + }); + + test('hides the (misleading) per-case fields and shows a canvas hint instead', () => { + render(); + // The case-level `check` field must not appear on the container. + expect(screen.queryByText('check')).toBeNull(); + expect(screen.getByText(/edited on the canvas/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/pages/rp-connect/pipeline/node-config-form.tsx b/frontend/src/components/pages/rp-connect/pipeline/node-config-form.tsx new file mode 100644 index 0000000000..86ba4df158 --- /dev/null +++ b/frontend/src/components/pages/rp-connect/pipeline/node-config-form.tsx @@ -0,0 +1,944 @@ +/** + * 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 { Collapsible, CollapsibleContent, CollapsibleTrigger } from 'components/redpanda-ui/components/collapsible'; +import { CountDot } from 'components/redpanda-ui/components/count-dot'; +import { Input } from 'components/redpanda-ui/components/input'; +import { Label } from 'components/redpanda-ui/components/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from 'components/redpanda-ui/components/select'; +import { Switch } from 'components/redpanda-ui/components/switch'; +import { Textarea } from 'components/redpanda-ui/components/textarea'; +import { Text } from 'components/redpanda-ui/components/typography'; +import { cn } from 'components/redpanda-ui/lib/utils'; +import { YamlEditor } from 'components/ui/yaml/yaml-editor'; +import { ChevronDown, ChevronRight, Plus } from 'lucide-react'; +import { createContext, useContext, useEffect, useId } from 'react'; +import { type Control, Controller, type FieldPath, useForm, useWatch } from 'react-hook-form'; +import { parse as parseYaml, stringify as yamlStringify } from 'yaml'; + +import { ScrollShadow } from './scroll-shadow'; +import { getSecretSyntax } from '../types/constants'; +import type { ConnectComponentSpec, RawFieldSpec } from '../types/schema'; +import { + checkRequired, + fieldHasOptions, + isComponentField, + isFormField, + isObjectGroupField, + isScalarArrayField, + isScalarField, +} from '../utils/schema'; +import type { EditTarget, ResourceKind } from '../utils/yaml'; +import { resourceKindForComponentName } from '../utils/yaml'; + +// A direct child (case / step) of a control-flow component, shown as a clickable row to its full config. +export type InspectorChildItem = { + id: string; + target: EditTarget; + caseTarget?: EditTarget; + name: string; + condition?: string; + isDefault?: boolean; + isErrorPath?: boolean; + lintCount?: number; +}; + +const childItemConditionText = (item: InspectorChildItem): string | undefined => { + if (item.condition) { + return `if ${item.condition}`; + } + if (item.isDefault) { + return 'default'; + } + if (item.isErrorPath) { + return 'on error'; + } + return; +}; + +const childItemCondColor = (item: InspectorChildItem): string => { + if (item.isErrorPath) { + return 'text-destructive'; + } + if (item.isDefault) { + return 'text-muted-foreground'; + } + return 'text-warning'; +}; + +const ChildItemRow = ({ + item, + onSelect, +}: { + item: InspectorChildItem; + onSelect: (item: InspectorChildItem) => void; +}) => { + const condText = childItemConditionText(item); + return ( + + ); +}; + +export const ChildItemsList = ({ + items, + onSelect, + label, +}: { + items: InspectorChildItem[]; + onSelect: (item: InspectorChildItem) => void; + label: string; +}) => ( + // A heading, not a