From 4b568fe5ab235b6ad5870ae0e2a8f9c8d03c2488 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1n=20Vor=C4=8D=C3=A1k?= Date: Tue, 16 Jun 2026 10:44:40 +0200 Subject: [PATCH 1/2] feat(topics): migrate produce record page to UI registry Replace legacy @redpanda-data/ui components with Registry equivalents (Field, Select, Input, Button, Alert, typography) and react-hook-form + Zod validation. Use standardized KeyValueField for Kafka headers. Convert the PageComponent class to a functional component using setPageHeader and useApiStoreHook for store reactivity. --- .../components/pages/topics/topic-produce.tsx | 813 +++++++++--------- .../topics/$topicName/produce-record.tsx | 2 +- 2 files changed, 406 insertions(+), 409 deletions(-) diff --git a/frontend/src/components/pages/topics/topic-produce.tsx b/frontend/src/components/pages/topics/topic-produce.tsx index 8340ea1947..3e58bad238 100644 --- a/frontend/src/components/pages/topics/topic-produce.tsx +++ b/frontend/src/components/pages/topics/topic-produce.tsx @@ -1,25 +1,22 @@ +/** + * 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 { create } from '@bufbuild/protobuf'; -import { - Alert, - Box, - Button, - Divider, - Flex, - FormControl, - Grid, - GridItem, - Heading, - HStack, - IconButton, - Input, - SectionHeading, - Text, - useToast, -} from '@redpanda-data/ui'; +import { zodResolver } from '@hookform/resolvers/zod'; import { Link } from '@tanstack/react-router'; -import { TrashIcon } from 'components/icons'; +import type React from 'react'; import { type FC, useEffect, useState } from 'react'; -import { Controller, type SubmitHandler, useFieldArray, useForm, useWatch } from 'react-hook-form'; +import { Controller, type SubmitHandler, useForm, useWatch } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod'; import { setMonacoTheme } from '../../../config'; import { @@ -34,12 +31,23 @@ import { } from '../../../protogen/redpanda/api/console/v1alpha1/publish_messages_pb'; import { appGlobal } from '../../../state/app-global'; import { api, useApiStoreHook } from '../../../state/backend-api'; -import { uiState } from '../../../state/ui-state'; -import { Label } from '../../../utils/tsx-utils'; +import { setPageHeader, uiState } from '../../../state/ui-state'; import { base64ToUInt8Array, isValidBase64, substringWithEllipsis } from '../../../utils/utils'; import KowlEditor from '../../misc/kowl-editor'; -import { SingleSelect } from '../../misc/select'; -import { PageComponent, type PageInitHelper } from '../page'; +import { Alert, AlertDescription } from '../../redpanda-ui/components/alert'; +import { Button } from '../../redpanda-ui/components/button'; +import { + Field, + FieldError, + FieldLabel, + FieldLegend, + FieldSeparator, + FieldSet, +} from '../../redpanda-ui/components/field'; +import { Input } from '../../redpanda-ui/components/input'; +import { KeyValueField } from '../../redpanda-ui/components/key-value-field'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../redpanda-ui/components/select'; +import { Heading, Text } from '../../redpanda-ui/components/typography'; type EncodingOption = { value: PayloadEncoding | 'base64'; @@ -80,7 +88,7 @@ const encodingOptions: EncodingOption[] = [ ]; const protoBufInfoElement = ( - + Protobuf schemas can define multiple types. Specify which type you want to use for this message.{' '} Learn more here. @@ -104,25 +112,66 @@ function encodingToLanguage(encoding: PayloadEncoding) { return; } -type PayloadOptions = { - encoding: PayloadEncoding; - data: string; - - // Schema name - schemaName?: string; - schemaVersion?: number; - schemaId?: number; +// Numeric-valued select wrapper bridging the registry Select (string values) with +// react-hook-form fields that hold numeric enums (partition, compression, encoding, version). +function NumberSelect({ + value, + onChange, + options, + testId, + placeholder, +}: { + value: number | undefined; + onChange: (value: number) => void; + options: { label: React.ReactNode; value: number }[]; + testId?: string; + placeholder?: string; +}) { + return ( + + ); +} - protobufIndex?: number; // if encoding is protobuf, we also need an index -}; +const payloadOptionsSchema = z.object({ + encoding: z.nativeEnum(PayloadEncoding), + data: z.string(), + schemaName: z.string().optional(), + schemaVersion: z.number().optional(), + schemaId: z.number().optional(), + protobufIndex: z.number().optional(), +}); + +const produceRecordSchema = z + .object({ + partition: z.number(), + compressionType: z.nativeEnum(CompressionType), + headers: z.array(z.object({ key: z.string(), value: z.string() })), + key: payloadOptionsSchema, + value: payloadOptionsSchema, + }) + .superRefine((data, ctx) => { + if (data.key.encoding === PayloadEncoding.BINARY && !isValidBase64(data.key.data)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid Base64 format', path: ['key', 'data'] }); + } + if (data.value.encoding === PayloadEncoding.BINARY && !isValidBase64(data.value.data)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid Base64 format', path: ['value', 'data'] }); + } + }); -type Inputs = { - partition: number; - compressionType: CompressionType; - headers: { key: string; value: string }[]; - key: PayloadOptions; - value: PayloadOptions; -}; +type Inputs = z.infer; const persistCompressionType = (compressionType: CompressionType) => { uiState.topicSettings.produceRecordCompression = compressionType; @@ -130,8 +179,6 @@ const persistCompressionType = (compressionType: CompressionType) => { // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: complex business logic const PublishTopicForm: FC<{ topicName: string }> = ({ topicName }) => { - const toast = useToast(); - const { control, register, @@ -139,8 +186,9 @@ const PublishTopicForm: FC<{ topicName: string }> = ({ topicName }) => { handleSubmit, setError, formState: { isSubmitting, errors }, - clearErrors, } = useForm({ + resolver: zodResolver(produceRecordSchema), + mode: 'onChange', defaultValues: { partition: -1, compressionType: uiState.topicSettings.produceRecordCompression, @@ -156,36 +204,10 @@ const PublishTopicForm: FC<{ topicName: string }> = ({ topicName }) => { }, }); - const { fields, append, remove } = useFieldArray({ - control, - name: 'headers', - }); - const keyPayloadOptions = useWatch({ control, name: 'key' }); const valuePayloadOptions = useWatch({ control, name: 'value' }); const [isKeyExpanded, setKeyExpanded] = useState(false); - useEffect(() => { - if (keyPayloadOptions.encoding === PayloadEncoding.BINARY && !isValidBase64(keyPayloadOptions.data)) { - setError('key.data', { - type: 'manual', - message: 'Invalid Base64 format', - }); - } else { - clearErrors('key.data'); - } - }, [keyPayloadOptions.encoding, keyPayloadOptions.data, setError, clearErrors]); - - useEffect(() => { - if (valuePayloadOptions.encoding === PayloadEncoding.BINARY && !isValidBase64(valuePayloadOptions.data)) { - setError('value.data', { - type: 'manual', - message: 'Invalid Base64 format', - }); - } else { - clearErrors('value.data'); - } - }, [valuePayloadOptions.encoding, valuePayloadOptions.data, setError, clearErrors]); const showKeySchemaSelection = keyPayloadOptions.encoding === PayloadEncoding.AVRO || keyPayloadOptions.encoding === PayloadEncoding.PROTOBUF; @@ -199,10 +221,11 @@ const PublishTopicForm: FC<{ topicName: string }> = ({ topicName }) => { value: value.number as CompressionType, })); + const topics = useApiStoreHook((s) => s.topics); const availablePartitions = (() => { const partitions: { label: string; value: number }[] = [{ label: 'Auto (Murmur2)', value: -1 }]; - const count = api.topics?.first((t) => t.topicName === topicName)?.partitionCount; + const count = topics?.first((t) => t.topicName === topicName)?.partitionCount; if (count === undefined) { // topic not found return partitions; @@ -220,14 +243,8 @@ const PublishTopicForm: FC<{ topicName: string }> = ({ topicName }) => { return partitions; })(); - useEffect(() => { - // Fetch schema subjects list if not already loaded - if (!api.schemaSubjects) { - api.refreshSchemaSubjects(); - } - }, []); - - const availableValues = api.schemaSubjects?.filter((x) => !x.isSoftDeleted) ?? []; + const schemaSubjects = useApiStoreHook((s) => s.schemaSubjects); + const availableValues = schemaSubjects?.filter((x) => !x.isSoftDeleted) ?? []; const keySchemaName = useWatch({ control, name: 'key.schemaName' }); const valueSchemaName = useWatch({ control, name: 'value.schemaName' }); @@ -298,7 +315,9 @@ const PublishTopicForm: FC<{ topicName: string }> = ({ topicName }) => { } } - req.key.index = data.key.protobufIndex; + if (data.key.protobufIndex !== undefined) { + req.key.index = data.key.protobufIndex; + } } // Value @@ -331,7 +350,9 @@ const PublishTopicForm: FC<{ topicName: string }> = ({ topicName }) => { } } - req.value.index = data.value.protobufIndex; + if (data.value.protobufIndex !== undefined) { + req.value.index = data.value.protobufIndex; + } } const result = await api.publishMessage(req).catch((err) => { @@ -341,364 +362,340 @@ const PublishTopicForm: FC<{ topicName: string }> = ({ topicName }) => { }); if (result) { - toast({ - status: 'success', - description: ( - <> - Record published on partition {result.partitionId} with offset{' '} - {Number(result.offset)} - - ), - duration: 3000, - }); + toast.success(`Record published on partition ${result.partitionId} with offset ${Number(result.offset)}`); appGlobal.historyPush(`/topics/${encodeURIComponent(topicName)}`); } }; - const filteredEncodingOptions = encodingOptions.filter((x) => x.value !== PayloadEncoding.AVRO); + const filteredEncodingOptions = encodingOptions + .filter((x) => x.value !== PayloadEncoding.AVRO) + .map((x) => ({ label: x.label, value: x.value as number })); + + const schemaNameOptions = availableValues.map((schema) => ({ label: schema.name, value: schema.name })); + + const sortedKeyVersions = + keySchemaDetail?.versions + .slice() + .sort(({ version: version1 }, { version: version2 }) => version2 - version1) + .map(({ version }) => ({ label: version, value: version })) ?? []; + + const sortedValueVersions = + valueSchemaDetail?.versions + .slice() + .sort(({ version: version1 }, { version: version2 }) => version2 - version1) + .map(({ version }) => ({ label: version, value: version })) ?? []; return ( -
- - - -