diff --git a/frontend/.claude/skills/frontend-developer/SKILL.md b/frontend/.claude/skills/frontend-developer/SKILL.md new file mode 100644 index 000000000..a7aa7d389 --- /dev/null +++ b/frontend/.claude/skills/frontend-developer/SKILL.md @@ -0,0 +1,326 @@ +--- +name: frontend-developer +description: "Build user interfaces using Redpanda UI Registry components with React, TypeScript, and Vitest testing. Use when user requests UI components, pages, forms, or mentions 'build UI', 'create component', 'design system', 'frontend', or 'registry'." +allowed-tools: Read, Write, Edit, Bash, Glob, Grep, mcp__ide__getDiagnostics +--- + +# Frontend UI Development + +Build UIs using the Redpanda UI Registry design system following this repo's patterns. + +## Critical Rules + +**ALWAYS:** + +- Fetch registry docs FIRST: Invoke `mcp__context7__get-library-docs` with library `/websites/redpanda-ui-registry_netlify_app` +- Use registry components from `src/components/redpanda-ui/` +- Install components via CLI: `yes | bunx @fumadocs/cli add --dir https://redpanda-ui-registry.netlify.app/r ` +- Use functional React components with TypeScript +- Run `bun i --yarn` after installing packages + +**NEVER:** + +- Use deprecated `@redpanda-data/ui` or Chakra UI +- Modify files in registry base directory (check `cli.json`) +- Copy/paste registry source code +- Add margin `className` directly to registry components +- Use inline `style` prop on registry components +- Leave `console.log` or comments in code + +## Workflow + +### 1. Fetch Registry Documentation + +```bash +# REQUIRED FIRST STEP +Invoke: mcp__context7__get-library-docs +Library: /websites/redpanda-ui-registry_netlify_app +``` + +### 2. Install Components + +```bash +# Single component +yes | bunx @fumadocs/cli add --dir https://redpanda-ui-registry.netlify.app/r button + +# Multiple components (space-separated) +yes | bunx @fumadocs/cli add --dir https://redpanda-ui-registry.netlify.app/r card dialog form + +# Then generate yarn.lock +bun i && bun i --yarn +``` + +### 3. Write Component + +**Structure:** + +```typescript +// Functional component with explicit types +interface Props { + title: string; + onSubmit: (data: FormData) => void; +} + +export function MyComponent({ title, onSubmit }: Props) { + // Component logic +} +``` + +**Styling:** + +```typescript +// ✅ CORRECT: Use component variants + + +// ✅ CORRECT: Wrap for spacing +
+ +
+ +// ❌ WRONG: Direct margin on component + +``` + +**TypeScript:** + +- Define prop interfaces +- Never use `any` - infer correct types +- Use generics for collections + +**Performance:** + +- Hoist static content outside component +- Use `useMemo` for expensive computations - but only when there's a noticeable performance impact +- Use `memo` for components receiving props + +### 4. Write Tests + +**Test types:** + +- Unit tests (`.test.ts`): Pure logic, utilities, helpers - Node environment +- Integration tests (`.test.tsx`): React components, UI interactions - JSDOM environment + +**Unit test example:** + +```typescript +// utils.test.ts +import { formatNumber } from "./utils"; + +describe("formatNumber", () => { + test("should format numbers with commas", () => { + expect(formatNumber(1000)).toBe("1,000"); + }); +}); +``` + +**Integration test example:** + +```typescript +// component.test.tsx +import { render, screen, fireEvent, waitFor } from 'test-utils'; +import { createRouterTransport } from '@connectrpc/connect'; +import { createPipeline } from 'protogen/redpanda/api/console/v1alpha1/pipeline-PipelineService_connectquery'; +import { MyComponent } from './component'; + +describe('MyComponent', () => { + test('should trigger gRPC mutation when form is submitted', async () => { + // Mock the gRPC service method + const mockCreatePipeline = vi.fn(() => + Promise.resolve({ id: '123', name: 'test-pipeline' }) + ); + + // Create a mocked transport + const transport = createRouterTransport(({ rpc }) => { + rpc(createPipeline, mockCreatePipeline); + }); + + // Render with the mocked transport + render(, { transport }); + + // Fill out the form + fireEvent.change(screen.getByLabelText('Pipeline Name'), { + target: { value: 'test-pipeline' } + }); + + // Submit the form + fireEvent.click(screen.getByRole('button', { name: 'Create' })); + + // Verify the mutation was called with correct data + await waitFor(() => { + expect(mockCreatePipeline).toHaveBeenCalledWith({ + name: 'test-pipeline' + }); + }); + }); +}); +``` + +**Mocking:** + +```typescript +vi.mock("module-name", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + functionToMock: vi.fn(), + }; +}); + +const mockFunction = vi.mocked(functionToMock); +``` + +### 5. Validation + +```bash +# Run in order +bun run type:check # TypeScript errors +bun run test # All tests +bun run lint # Code quality +bun run build # Production build +bun run start2 --port=3004 # Dev server - check browser +``` + +**Success criteria:** + +- ✓ No TypeScript errors +- ✓ All tests passing +- ✓ No lint errors +- ✓ Build succeeds +- ✓ No runtime errors in browser + +## Testing Commands + +```bash +bun run test # All tests (unit + integration) +bun run test:unit # Unit tests only (.test.ts) +bun run test:integration # Integration tests only (.test.tsx) +bun run test:watch # Watch mode +bun run test:coverage # Coverage report +``` + +## Common Patterns + +### Registry Component Usage + +Check `src/components/redpanda-ui/` for installed components before installing new ones. + +Use component variants instead of custom styling: + +```typescript + + setIsSecretsDialogOpen(true)} + variant="red" + > + Create $secrets.{secret} + + ))} )} - {existingSecrets.length === 0 && detectedSecrets.length === 0 && ( - - Your pipeline doesn't reference any secrets yet. Use ${secrets.NAME} syntax to - reference secrets. - - )} - + setIsSecretsDialogOpen(false)} + onSecretsCreated={() => setIsSecretsDialogOpen(false)} + onUpdateEditorContent={handleUpdateEditorContent} + /> ); }; diff --git a/frontend/src/components/pages/rp-connect/onboarding/add-secrets-dialog.tsx b/frontend/src/components/pages/rp-connect/onboarding/add-secrets-dialog.tsx index fa1f33310..f3c730215 100644 --- a/frontend/src/components/pages/rp-connect/onboarding/add-secrets-dialog.tsx +++ b/frontend/src/components/pages/rp-connect/onboarding/add-secrets-dialog.tsx @@ -18,12 +18,14 @@ export const AddSecretsDialog = ({ missingSecrets, existingSecrets, onSecretsCreated, + onUpdateEditorContent, }: { isOpen: boolean; onClose: () => void; missingSecrets: string[]; existingSecrets: string[]; onSecretsCreated: () => void; + onUpdateEditorContent?: (oldName: string, newName: string) => void; }) => { const [errorMessages, setErrorMessages] = useState([]); @@ -61,6 +63,7 @@ export const AddSecretsDialog = ({ existingSecrets={existingSecrets} onError={handleError} onSecretsCreated={handleSecretsCreated} + onUpdateEditorContent={onUpdateEditorContent} requiredSecrets={missingSecrets} scopes={[Scope.REDPANDA_CONNECT]} /> diff --git a/frontend/src/components/pages/rp-connect/onboarding/add-topic-step.tsx b/frontend/src/components/pages/rp-connect/onboarding/add-topic-step.tsx index 177c0227f..1cd494057 100644 --- a/frontend/src/components/pages/rp-connect/onboarding/add-topic-step.tsx +++ b/frontend/src/components/pages/rp-connect/onboarding/add-topic-step.tsx @@ -48,10 +48,11 @@ import { isUsingDefaultRetentionSettings, parseTopicConfigFromExisting, TOPIC_FO interface AddTopicStepProps { defaultTopicName?: string; + onValidityChange?: (isValid: boolean) => void; } export const AddTopicStep = forwardRef, AddTopicStepProps & MotionProps>( - ({ defaultTopicName, ...motionProps }, ref) => { + ({ defaultTopicName, onValidityChange, ...motionProps }, ref) => { const queryClient = useQueryClient(); const { data: topicList } = useLegacyListTopicsQuery(create(ListTopicsRequestSchema, {}), { @@ -95,6 +96,11 @@ export const AddTopicStep = forwardRef, AddTopicSt const watchedTopicName = form.watch('topicName'); + // Notify parent when validity changes + useEffect(() => { + onValidityChange?.(form.formState.isValid); + }, [form.formState.isValid, onValidityChange]); + const existingTopicSelected = useMemo(() => { // Only check if the CURRENT form topic name matches an existing topic if (!watchedTopicName) { diff --git a/frontend/src/components/pages/rp-connect/onboarding/add-user-step.tsx b/frontend/src/components/pages/rp-connect/onboarding/add-user-step.tsx index 03d4b45db..7b78f7eaa 100644 --- a/frontend/src/components/pages/rp-connect/onboarding/add-user-step.tsx +++ b/frontend/src/components/pages/rp-connect/onboarding/add-user-step.tsx @@ -34,7 +34,7 @@ import type { MotionProps } from 'motion/react'; import { ACL_ResourceType } from 'protogen/redpanda/api/dataplane/v1/acl_pb'; import { listACLs } from 'protogen/redpanda/api/dataplane/v1/acl-ACLService_connectquery'; import { listUsers } from 'protogen/redpanda/api/dataplane/v1/user-UserService_connectquery'; -import { forwardRef, useCallback, useImperativeHandle, useMemo, useState } from 'react'; +import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react'; import { useForm } from 'react-hook-form'; import { useListUsersQuery } from 'react-query/api/user'; import { LONG_LIVED_CACHE_STALE_TIME } from 'react-query/react-query.utils'; @@ -62,6 +62,7 @@ interface AddUserStepProps { topicName?: string; defaultConsumerGroup?: string; showConsumerGroupFields?: boolean; + onValidityChange?: (isValid: boolean) => void; } export const AddUserStep = forwardRef( @@ -72,6 +73,7 @@ export const AddUserStep = forwardRef { + onValidityChange?.(form.formState.isValid); + }, [form.formState.isValid, onValidityChange]); + const existingUserSelected = useMemo(() => { // Only check if the CURRENT form username matches an existing user // Don't use persisted username to avoid showing existing user state when creating a new one diff --git a/frontend/src/components/pages/rp-connect/onboarding/connect-tile.tsx b/frontend/src/components/pages/rp-connect/onboarding/connect-tile.tsx index 4c01b1b37..29fac2be4 100644 --- a/frontend/src/components/pages/rp-connect/onboarding/connect-tile.tsx +++ b/frontend/src/components/pages/rp-connect/onboarding/connect-tile.tsx @@ -6,9 +6,11 @@ import { InlineCode, Text } from 'components/redpanda-ui/components/typography'; import { cn } from 'components/redpanda-ui/lib/utils'; import { CheckIcon, Waypoints } from 'lucide-react'; import { AnimatePresence, type MotionProps, motion } from 'motion/react'; +import { ComponentStatus } from 'protogen/redpanda/api/dataplane/v1/pipeline_pb'; import { ConnectorLogo } from './connector-logo'; import type { ConnectComponentSpec } from '../types/schema'; +import { componentStatusToString } from '../utils/schema'; const logoStyle = { height: '24px', @@ -67,11 +69,14 @@ export const ConnectTile = ({
{component.name} - {component.status && component.status !== 'stable' && component.name !== 'redpanda' && ( - - {component.status} - - )} + {(component.status === ComponentStatus.BETA || + component.status === ComponentStatus.EXPERIMENTAL || + component.status === ComponentStatus.DEPRECATED) && + component.name !== 'redpanda' && ( + + {componentStatusToString(component.status)} + + )}
diff --git a/frontend/src/components/pages/rp-connect/onboarding/connect-tiles.tsx b/frontend/src/components/pages/rp-connect/onboarding/connect-tiles.tsx index 9abde34a0..94284fd59 100644 --- a/frontend/src/components/pages/rp-connect/onboarding/connect-tiles.tsx +++ b/frontend/src/components/pages/rp-connect/onboarding/connect-tiles.tsx @@ -13,10 +13,12 @@ import { Form, FormControl, FormField, FormItem, FormMessage } from 'components/ import { Input, InputStart } from 'components/redpanda-ui/components/input'; import { Label } from 'components/redpanda-ui/components/label'; import { SimpleMultiSelect } from 'components/redpanda-ui/components/multi-select'; +import { Skeleton, SkeletonGroup } from 'components/redpanda-ui/components/skeleton'; import { Heading, Link, Text } from 'components/redpanda-ui/components/typography'; import { cn } from 'components/redpanda-ui/lib/utils'; import { SearchIcon } from 'lucide-react'; import type { MotionProps } from 'motion/react'; +import type { ComponentList } from 'protogen/redpanda/api/dataplane/v1/pipeline_pb'; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; import { useForm } from 'react-hook-form'; @@ -25,7 +27,7 @@ import type { ConnectComponentSpec, ConnectComponentType, ExtendedConnectCompone import type { BaseStepRef } from '../types/wizard'; import { type ConnectTilesListFormData, connectTilesListFormSchema } from '../types/wizard'; import { getAllCategories } from '../utils/categories'; -import { getBuiltInComponents } from '../utils/schema'; +import { parseSchema } from '../utils/schema'; const PRIORITY_COMPONENTS = [ 'redpanda', @@ -38,8 +40,8 @@ const PRIORITY_COMPONENTS = [ 'mongodb_cdc', 'snowflake_streaming', 'redpanda_migrator', - 'kafka_franz', - 'gcp_pubsub', + 'mysql_cdc', + 'snowflake_put', 'slack', 'sftp', 'nats', @@ -113,10 +115,48 @@ const searchComponents = ( return result; }; +const ConnectTilesSkeleton = memo( + ({ + children, + gridCols = 4, + tileCount = 12, + }: { + children?: React.ReactNode; + gridCols?: number; + tileCount?: number; + }) => { + const skeletonTiles = Array.from({ length: tileCount }, (_, i) => ( +
+ + + + + + + +
+ )); + + return ( + +
+ {children} + {skeletonTiles} +
+
+ ); + } +); + +ConnectTilesSkeleton.displayName = 'ConnectTilesSkeleton'; + export type ConnectTilesProps = { + components?: ComponentList; + isLoading?: boolean; additionalComponents?: ExtendedConnectComponentSpec[]; componentTypeFilter?: ConnectComponentType[]; onChange?: (connectionName: string, connectionType: ConnectComponentType) => void; + onValidityChange?: (isValid: boolean) => void; hideHeader?: boolean; hideFilters?: boolean; defaultConnectionName?: string; @@ -134,9 +174,12 @@ export const ConnectTiles = memo( forwardRef, ConnectTilesProps & MotionProps>( ( { + components, + isLoading, additionalComponents, componentTypeFilter, onChange, + onValidityChange, hideHeader, hideFilters, defaultConnectionName, @@ -181,13 +224,14 @@ export const ConnectTiles = memo( const form = useForm({ resolver: zodResolver(connectTilesListFormSchema), - mode: 'onSubmit', + mode: 'onChange', defaultValues, }); + const builtInComponents = useMemo(() => (components ? parseSchema(components) : []), [components]); const allComponents = useMemo( - () => [...getBuiltInComponents(), ...(additionalComponents || [])], - [additionalComponents] + () => [...builtInComponents, ...(additionalComponents || [])], + [builtInComponents, additionalComponents] ); const categories = useMemo( @@ -215,6 +259,11 @@ export const ConnectTiles = memo( }); }, [checkScrollable]); + // Notify parent when validity changes + useEffect(() => { + onValidityChange?.(form.formState.isValid); + }, [form.formState.isValid, onValidityChange]); + useImperativeHandle(ref, () => ({ triggerSubmit: async () => { const isValid = await form.trigger(); @@ -304,48 +353,76 @@ export const ConnectTiles = memo( ( - - - {filteredComponents.length === 0 ? ( -
- No connections found matching your filters - - Try adjusting your search or category filters - + render={({ field }) => { + const renderTiles = () => + filteredComponents.map((component) => { + const uniqueKey = `${component.type}-${component.name}`; + const isChecked = + field.value === component.name && form.getValues('connectionType') === component.type; + + return ( + { + if (isChecked) { + // Unselect if already selected + field.onChange(''); + form.setValue('connectionType', '' as ConnectComponentType, { shouldValidate: true }); + } else { + // Select the component + field.onChange(component.name); + form.setValue('connectionType', component.type as ConnectComponentType, { + shouldValidate: true, + }); + onChange?.(component.name, component.type as ConnectComponentType); + } + }} + uniqueKey={uniqueKey} + /> + ); + }); + + const hasResults = filteredComponents.length > 0; + const showSkeleton = isLoading; + // biome-ignore lint/complexity/useSimplifiedLogicExpression: Logic is intentionally explicit for clarity + const hasNoResults = !showSkeleton && !hasResults; + + let content: React.ReactNode; + + if (hasNoResults) { + content = ( +
+ No connections found matching your filters + + Try adjusting your search or category filters + +
+ ); + } else if (showSkeleton) { + content = ( + + {renderTiles()} + + ); + } else { + content = ( + +
+ {renderTiles()}
- ) : ( - -
- {filteredComponents.map((component) => { - const uniqueKey = `${component.type}-${component.name}`; - const isChecked = - field.value === component.name && - form.getValues('connectionType') === component.type; - - return ( - { - field.onChange(component.name); - form.setValue('connectionType', component.type as ConnectComponentType); - // Only call onChange for non-wizard use cases (e.g., dialog) - // Wizard saves to session storage only after "Next" is clicked - onChange?.(component.name, component.type as ConnectComponentType); - }} - uniqueKey={uniqueKey} - /> - ); - })} -
-
- )} - - - - )} +
+ ); + } + + return ( + + {content} + + + ); + }} />
{/* Gradient overlay to indicate scrollability - only show when not at bottom */} diff --git a/frontend/src/components/pages/rp-connect/onboarding/create-pipeline-sidebar.tsx b/frontend/src/components/pages/rp-connect/onboarding/create-pipeline-sidebar.tsx index 6686a86e7..c83896678 100644 --- a/frontend/src/components/pages/rp-connect/onboarding/create-pipeline-sidebar.tsx +++ b/frontend/src/components/pages/rp-connect/onboarding/create-pipeline-sidebar.tsx @@ -1,41 +1,70 @@ import { useDisclosure } from '@redpanda-data/ui'; +import { Skeleton, SkeletonGroup } from 'components/redpanda-ui/components/skeleton'; import type { editor } from 'monaco-editor'; -import { memo, useMemo, useState } from 'react'; +import type { ComponentList } from 'protogen/redpanda/api/dataplane/v1/pipeline_pb'; +import { memo, useCallback, useMemo, useState } from 'react'; +import { useOnboardingWizardDataStore } from 'state/onboarding-wizard-store'; import { AddConnectorDialog } from './add-connector-dialog'; import { AddConnectorsCard } from './add-connectors-card'; import { AddContextualVariablesCard } from './add-contextual-variables-card'; import { AddSecretsCard } from './add-secrets-card'; -import { AddSecretsDialog } from './add-secrets-dialog'; import type { ConnectComponentType } from '../types/schema'; +import { parseSchema } from '../utils/schema'; +import { getConnectTemplate } from '../utils/yaml'; type CreatePipelineSidebarProps = { editorInstance: editor.IStandaloneCodeEditor | null; - onAddConnector: ((connectionName: string, connectionType: ConnectComponentType) => void) | undefined; - detectedSecrets: string[]; - existingSecrets: string[]; - onSecretsCreated: () => void; editorContent: string; + setYamlContent: (yaml: string) => void; + componentList?: ComponentList; + isComponentListLoading?: boolean; }; export const CreatePipelineSidebar = memo( ({ editorInstance, - onAddConnector, - detectedSecrets, - existingSecrets, - onSecretsCreated, editorContent, + setYamlContent, + componentList: rawComponentList, + isComponentListLoading, }: CreatePipelineSidebarProps) => { const { isOpen: isAddConnectorOpen, onOpen: openAddConnector, onClose: closeAddConnector } = useDisclosure(); const [selectedConnector, setSelectedConnector] = useState(undefined); - const [isSecretsDialogOpen, setIsSecretsDialogOpen] = useState(false); + const components = useMemo(() => (rawComponentList ? parseSchema(rawComponentList) : []), [rawComponentList]); const hasInput = useMemo(() => editorContent.includes('input:'), [editorContent]); const hasOutput = useMemo(() => editorContent.includes('output:'), [editorContent]); + const handleAddConnector = useCallback( + (connectionName: string, connectionType: ConnectComponentType) => { + const template = getConnectTemplate({ + connectionName, + connectionType, + existingYaml: editorContent, + components, + }); + + if (template) { + setYamlContent(template); + + // Sync wizard data to Zustand store + const wizardData = useOnboardingWizardDataStore.getState(); + wizardData.setWizardData({ + ...wizardData, + [connectionType]: { + connectionName, + connectionType, + }, + }); + } + closeAddConnector(); + }, + [editorContent, components, setYamlContent, closeAddConnector] + ); + if (editorInstance === null) { - return
; + return null; } const handleConnectorTypeChange = (connectorType: ConnectComponentType) => { @@ -43,18 +72,16 @@ export const CreatePipelineSidebar = memo( openAddConnector(); }; - const handleAddConnector = (connectionName: string, connectionType: ConnectComponentType) => { - onAddConnector?.(connectionName, connectionType); - closeAddConnector(); - }; - - const existingSecretsSet = new Set(existingSecrets); - const missingSecrets = detectedSecrets.filter((secret) => !existingSecretsSet.has(secret)); - - const handleSecretsCreated = () => { - onSecretsCreated(); - setIsSecretsDialogOpen(false); - }; + if (isComponentListLoading) { + return ( +
+ + + + +
+ ); + } return (
@@ -64,28 +91,18 @@ export const CreatePipelineSidebar = memo( hasOutput={hasOutput} onAddConnector={handleConnectorTypeChange} /> - setIsSecretsDialogOpen(true)} - /> + - setIsSecretsDialogOpen(false)} - onSecretsCreated={handleSecretsCreated} - /> - + {rawComponentList && ( + + )}
); } diff --git a/frontend/src/components/pages/rp-connect/onboarding/onboarding-wizard.tsx b/frontend/src/components/pages/rp-connect/onboarding/onboarding-wizard.tsx index cc72d8208..4ef5cdbfc 100644 --- a/frontend/src/components/pages/rp-connect/onboarding/onboarding-wizard.tsx +++ b/frontend/src/components/pages/rp-connect/onboarding/onboarding-wizard.tsx @@ -1,3 +1,4 @@ +import { create } from '@bufbuild/protobuf'; import PageContent from 'components/misc/page-content'; import { Button } from 'components/redpanda-ui/components/button'; import { Card, CardContent, CardHeader, CardTitle } from 'components/redpanda-ui/components/card'; @@ -6,7 +7,9 @@ import { Heading } from 'components/redpanda-ui/components/typography'; import { CheckIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'; import { runInAction } from 'mobx'; import { AnimatePresence } from 'motion/react'; +import { ComponentSpecSchema } from 'protogen/redpanda/api/dataplane/v1/pipeline_pb'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useListComponentsQuery } from 'react-query/api/connect'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useOnboardingTopicDataStore, @@ -21,7 +24,7 @@ import { useShallow } from 'zustand/react/shallow'; import { AddTopicStep } from './add-topic-step'; import { AddUserStep } from './add-user-step'; import { ConnectTiles } from './connect-tiles'; -import RpConnectPipelinesCreate from '../pipelines-create'; +import PipelinePage from '../pipeline'; import { REDPANDA_TOPIC_AND_USER_COMPONENTS, stepMotionProps, @@ -32,6 +35,7 @@ import { } from '../types/constants'; import type { ExtendedConnectComponentSpec } from '../types/schema'; import type { AddTopicFormData, BaseStepRef, ConnectTilesListFormData, UserStepRef } from '../types/wizard'; +import { parseSchema } from '../utils/schema'; import { handleStepResult, regenerateYamlForTopicUserComponents } from '../utils/wizard'; import { getConnectTemplate } from '../utils/yaml'; @@ -45,17 +49,29 @@ export type ConnectOnboardingWizardProps = { export const ConnectOnboardingWizard = ({ className, additionalComponents = [ - { + create(ComponentSpecSchema, { name: 'custom', type: 'custom', - plugin: false, - }, + status: 0, + summary: 'Build your own pipeline from scratch.', + description: '', + categories: [], + version: '', + examples: [], + footnotes: '', + }) as ExtendedConnectComponentSpec, ], onChange, onCancel: onCancelProp, }: ConnectOnboardingWizardProps = {}) => { const navigate = useNavigate(); + const { data: componentListResponse, isLoading: isComponentListLoading } = useListComponentsQuery(); + const components = useMemo( + () => (componentListResponse?.components ? parseSchema(componentListResponse.components) : []), + [componentListResponse] + ); + const persistedInputConnectionName = useOnboardingWizardDataStore(useShallow((state) => state.input?.connectionName)); const persistedOutputConnectionName = useOnboardingWizardDataStore( useShallow((state) => state.output?.connectionName) @@ -118,6 +134,14 @@ export const ConnectOnboardingWizard = ({ const [isSubmitting, setIsSubmitting] = useState(false); + // Track form validity for each step + const [stepValidity, setStepValidity] = useState>({ + [WizardStep.ADD_INPUT]: false, + [WizardStep.ADD_OUTPUT]: false, + [WizardStep.ADD_TOPIC]: false, + [WizardStep.ADD_USER]: false, + }); + useEffect(() => { runInAction(() => { uiState.pageTitle = 'Create Pipeline'; @@ -132,11 +156,6 @@ export const ConnectOnboardingWizard = ({ if (methods.current.id === WizardStep.ADD_INPUT) { resetOnboardingWizardStore(); } else if (methods.current.id === WizardStep.ADD_OUTPUT) { - const { setWizardData: _, ...currentWizardData } = useOnboardingWizardDataStore.getState(); - setWizardData({ - input: currentWizardData.input, - output: {}, - }); setTopicData({}); setUserData({}); } @@ -159,6 +178,7 @@ export const ConnectOnboardingWizard = ({ const yamlContent = getConnectTemplate({ connectionName, connectionType, + components, showOptionalFields: false, existingYaml: useOnboardingYamlContentStore.getState().yamlContent, }); @@ -208,6 +228,7 @@ export const ConnectOnboardingWizard = ({ const yamlContent = getConnectTemplate({ connectionName, connectionType, + components, showOptionalFields: false, existingYaml: useOnboardingYamlContentStore.getState().yamlContent, }); @@ -253,7 +274,7 @@ export const ConnectOnboardingWizard = ({ const result = await addTopicStepRef.current?.triggerSubmit(); if (result?.success && result.data) { setTopicData({ topicName: result.data.topicName }); - regenerateYamlForTopicUserComponents(); + regenerateYamlForTopicUserComponents(components); } handleStepResult(result, methods.next); } finally { @@ -271,7 +292,7 @@ export const ConnectOnboardingWizard = ({ saslMechanism: result.data.saslMechanism, consumerGroup: result.data.consumerGroup || '', }); - regenerateYamlForTopicUserComponents(); + regenerateYamlForTopicUserComponents(components); methods.next(); } } finally { @@ -296,6 +317,23 @@ export const ConnectOnboardingWizard = ({ } }, [onCancelProp, navigate, resetOnboardingWizardStore, searchParams]); + // Callbacks to update validity for each step + const handleInputValidityChange = useCallback((isValid: boolean) => { + setStepValidity((prev) => ({ ...prev, [WizardStep.ADD_INPUT]: isValid })); + }, []); + + const handleOutputValidityChange = useCallback((isValid: boolean) => { + setStepValidity((prev) => ({ ...prev, [WizardStep.ADD_OUTPUT]: isValid })); + }, []); + + const handleTopicValidityChange = useCallback((isValid: boolean) => { + setStepValidity((prev) => ({ ...prev, [WizardStep.ADD_TOPIC]: isValid })); + }, []); + + const handleUserValidityChange = useCallback((isValid: boolean) => { + setStepValidity((prev) => ({ ...prev, [WizardStep.ADD_USER]: isValid })); + }, []); + return ( @@ -325,12 +363,15 @@ export const ConnectOnboardingWizard = ({ {methods.switch({ [WizardStep.ADD_INPUT]: () => ( ( ), @@ -364,6 +409,7 @@ export const ConnectOnboardingWizard = ({ defaultSaslMechanism={persistedUserSaslMechanism} defaultUsername={persistedUsername} key="add-user-step" + onValidityChange={handleUserValidityChange} ref={addUserStepRef} showConsumerGroupFields={persistedInputIsRedpandaComponent} topicName={persistedTopicName} @@ -377,7 +423,7 @@ export const ConnectOnboardingWizard = ({ - + ), @@ -405,7 +451,11 @@ export const ConnectOnboardingWizard = ({ )} {!methods.isLast && ( -