11import type React from 'react' ;
2- import { useEffect , useState } from 'react' ;
2+ import { type ReactElement , type ReactNode , useEffect , useReducer , useRef , useState } from 'react' ;
33
44import type { TopicConfigEntry } from '../../../../state/rest-interfaces' ;
55import { Label } from '../../../../utils/tsx-utils' ;
@@ -8,14 +8,27 @@ import './CreateTopicModal.scss';
88import {
99 Box ,
1010 Button ,
11+ CopyButton ,
12+ Flex ,
13+ Grid ,
1114 Input ,
1215 InputGroup ,
1316 InputLeftAddon ,
1417 InputRightAddon ,
1518 isSingleValue ,
19+ Modal ,
20+ ModalBody ,
21+ ModalContent ,
22+ ModalFooter ,
23+ ModalHeader ,
24+ ModalOverlay ,
25+ Result ,
1626 Select ,
27+ Text ,
28+ VStack ,
1729} from '@redpanda-data/ui' ;
1830import { CloseIcon , PlusIcon } from 'components/icons' ;
31+ import { useCreateTopicMutation } from 'react-query/api/topic' ;
1932
2033import { isServerless } from '../../../../config' ;
2134import { api } from '../../../../state/backend-api' ;
@@ -35,6 +48,9 @@ import type { CleanupPolicyType } from '../types';
3548// Regex for checking if value has 4 or more decimal places
3649const DECIMAL_PLACES_REGEX = / \. \d { 4 , } / ;
3750
51+ // Regex for validating topic names
52+ const TOPIC_NAME_REGEX = / ^ \S + $ / ;
53+
3854type CreateTopicModalState = {
3955 topicName : string ; // required
4056
@@ -746,3 +762,298 @@ export function RatioInput(p: { value: number; onChange: (ratio: number) => void
746762 </ div >
747763 ) ;
748764}
765+
766+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: complex business logic
767+ function getRetentionTimeFinalValue ( value : number | undefined , unit : RetentionTimeUnit ) {
768+ if ( unit === 'default' ) {
769+ return ;
770+ }
771+
772+ if ( value === undefined ) {
773+ throw new Error ( `unexpected: value for retention time is 'undefined' but unit is set to ${ unit } ` ) ;
774+ }
775+
776+ if ( unit === 'ms' ) return value ;
777+ if ( unit === 'seconds' ) return value * 1000 ;
778+ if ( unit === 'minutes' ) return value * 1000 * 60 ;
779+ if ( unit === 'hours' ) return value * 1000 * 60 * 60 ;
780+ if ( unit === 'days' ) return value * 1000 * 60 * 60 * 24 ;
781+ if ( unit === 'months' ) return value * 1000 * 60 * 60 * 24 * ( 365 / 12 ) ;
782+ if ( unit === 'years' ) return value * 1000 * 60 * 60 * 24 * 365 ;
783+ if ( unit === 'infinite' ) return - 1 ;
784+ }
785+
786+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: complex business logic
787+ function getRetentionSizeFinalValue ( value : number | undefined , unit : RetentionSizeUnit ) {
788+ if ( unit === 'default' ) {
789+ return ;
790+ }
791+
792+ if ( value === undefined ) {
793+ throw new Error ( `unexpected: value for retention size is 'undefined' but unit is set to ${ unit } ` ) ;
794+ }
795+
796+ if ( unit === 'Bit' ) return value ;
797+ if ( unit === 'KiB' ) return value * 1024 ;
798+ if ( unit === 'MiB' ) return value * 1024 * 1024 ;
799+ if ( unit === 'GiB' ) return value * 1024 * 1024 * 1024 ;
800+ if ( unit === 'TiB' ) return value * 1024 * 1024 * 1024 * 1024 ;
801+ if ( unit === 'infinite' ) return - 1 ;
802+ }
803+
804+ function createInitialState ( tryGetBrokerConfig : ( name : string ) => string | undefined ) : CreateTopicModalState {
805+ return {
806+ topicName : '' ,
807+ retentionTimeMs : 1 ,
808+ retentionTimeUnit : 'default' ,
809+ retentionSize : 1 ,
810+ retentionSizeUnit : 'default' ,
811+ partitions : undefined ,
812+ cleanupPolicy : 'delete' ,
813+ minInSyncReplicas : undefined ,
814+ replicationFactor : undefined ,
815+ additionalConfig : [ { name : '' , value : '' } ] ,
816+ defaults : {
817+ get retentionTime ( ) {
818+ return tryGetBrokerConfig ( 'log.retention.ms' ) ;
819+ } ,
820+ get retentionBytes ( ) {
821+ return tryGetBrokerConfig ( 'log.retention.bytes' ) ;
822+ } ,
823+ get replicationFactor ( ) {
824+ return tryGetBrokerConfig ( 'default.replication.factor' ) ;
825+ } ,
826+ get partitions ( ) {
827+ return tryGetBrokerConfig ( 'num.partitions' ) ;
828+ } ,
829+ get cleanupPolicy ( ) {
830+ return tryGetBrokerConfig ( 'log.cleanup.policy' ) ;
831+ } ,
832+ get minInSyncReplicas ( ) {
833+ return '1' ;
834+ } ,
835+ } ,
836+ hasErrors : false ,
837+ } ;
838+ }
839+
840+ export function CreateTopicModal ( { isOpen, onClose } : { isOpen : boolean ; onClose : ( ) => void } ) {
841+ const { mutateAsync : createTopic } = useCreateTopicMutation ( ) ;
842+ const [ , forceUpdate ] = useReducer ( ( x : number ) => x + 1 , 0 ) ;
843+ const [ isLoading , setIsLoading ] = useState ( false ) ;
844+ const [ result , setResult ] = useState < { error ?: unknown ; returnValue ?: ReactElement } | null > ( null ) ;
845+
846+ const tryGetBrokerConfig = ( configName : string ) : string | undefined =>
847+ api . clusterInfo ?. brokers ?. find ( ( _ ) => true ) ?. config . configs ?. find ( ( x ) => x . name === configName ) ?. value ?? undefined ;
848+
849+ const stateRef = useRef < CreateTopicModalState > ( createInitialState ( tryGetBrokerConfig ) ) ;
850+
851+ const state = new Proxy ( stateRef . current , {
852+ set ( target , prop , value ) {
853+ // biome-ignore lint/suspicious/noExplicitAny: proxy trap requires any
854+ ( target as any ) [ prop ] = value ;
855+ forceUpdate ( ) ;
856+ return true ;
857+ } ,
858+ } ) as CreateTopicModalState ;
859+
860+ useEffect ( ( ) => {
861+ if ( isOpen ) {
862+ api . refreshCluster ( ) ;
863+ stateRef . current = createInitialState ( tryGetBrokerConfig ) ;
864+ setResult ( null ) ;
865+ forceUpdate ( ) ;
866+ }
867+ } , [ isOpen ] ) ;
868+
869+ const isOkEnabled = TOPIC_NAME_REGEX . test ( state . topicName ) && ! state . hasErrors ;
870+
871+ const handleClose = ( ) => {
872+ setResult ( null ) ;
873+ onClose ( ) ;
874+ } ;
875+
876+ const handleOk = async ( ) => {
877+ if ( result ?. error ) {
878+ setResult ( null ) ;
879+ return ;
880+ }
881+
882+ const currentState = stateRef . current ;
883+
884+ if ( ! currentState . topicName ) {
885+ throw new Error ( '"Topic Name" must be set' ) ;
886+ }
887+ if ( ! currentState . cleanupPolicy ) {
888+ throw new Error ( '"Cleanup Policy" must be set' ) ;
889+ }
890+
891+ const config : { name : string ; value : string } [ ] = [ ] ;
892+ const setVal = ( name : string , value : string | number | undefined ) => {
893+ if ( value === undefined ) return ;
894+ config . removeAll ( ( x ) => x . name === name ) ;
895+ config . push ( { name, value : String ( value ) } ) ;
896+ } ;
897+
898+ for ( const x of currentState . additionalConfig ) {
899+ setVal ( x . name , x . value ) ;
900+ }
901+
902+ if ( currentState . retentionTimeUnit !== 'default' ) {
903+ setVal ( 'retention.ms' , getRetentionTimeFinalValue ( currentState . retentionTimeMs , currentState . retentionTimeUnit ) ) ;
904+ }
905+ if ( currentState . retentionSizeUnit !== 'default' ) {
906+ setVal ( 'retention.bytes' , getRetentionSizeFinalValue ( currentState . retentionSize , currentState . retentionSizeUnit ) ) ;
907+ }
908+ if ( currentState . minInSyncReplicas !== undefined ) {
909+ setVal ( 'min.insync.replicas' , currentState . minInSyncReplicas ) ;
910+ }
911+
912+ setVal ( 'cleanup.policy' , currentState . cleanupPolicy ) ;
913+
914+ setIsLoading ( true ) ;
915+ try {
916+ const apiResult = await createTopic ( {
917+ topic : {
918+ name : currentState . topicName ,
919+ partitionCount : currentState . partitions ?? Number ( currentState . defaults . partitions ?? '-1' ) ,
920+ replicationFactor : currentState . replicationFactor ?? Number ( currentState . defaults . replicationFactor ?? '-1' ) ,
921+ configs : config . filter ( ( x ) => x . name . length > 0 ) . map ( ( x ) => ( { name : x . name , value : x . value } ) ) ,
922+ } ,
923+ validateOnly : false ,
924+ } ) ;
925+
926+ const returnValue = (
927+ < Grid
928+ alignItems = "center"
929+ columnGap = { 2 }
930+ justifyContent = "center"
931+ justifyItems = "flex-end"
932+ py = { 2 }
933+ rowGap = { 1 }
934+ templateColumns = "auto auto"
935+ >
936+ < Text > Name:</ Text >
937+ < Flex alignItems = "center" gap = { 2 } justifySelf = "start" >
938+ < Text noOfLines = { 1 } whiteSpace = "break-spaces" wordBreak = "break-word" >
939+ { apiResult . topicName }
940+ </ Text >
941+ < CopyButton content = { apiResult . topicName } variant = "ghost" />
942+ </ Flex >
943+ < Text > Partitions:</ Text >
944+ < Text justifySelf = "start" > { String ( apiResult . partitionCount ) . replace ( '-1' , '(Default)' ) } </ Text >
945+ < Text > Replication Factor:</ Text >
946+ < Text justifySelf = "start" > { String ( apiResult . replicationFactor ) . replace ( '-1' , '(Default)' ) } </ Text >
947+ </ Grid >
948+ ) ;
949+
950+ setResult ( { returnValue, error : undefined } ) ;
951+
952+ api . refreshClusterOverview ( ) ;
953+ api . refreshClusterHealth ( ) . catch ( ( ) => {
954+ // Error handling managed by API layer
955+ } ) ;
956+ } catch ( e ) {
957+ setResult ( { error : e } ) ;
958+ } finally {
959+ setIsLoading ( false ) ;
960+ }
961+ } ;
962+
963+ const renderError = ( err : unknown ) : ReactElement => {
964+ let content : ReactNode ;
965+ let title = 'Error' ;
966+ const codeBoxStyle = {
967+ fontSize : '12px' ,
968+ fontFamily : 'monospace' ,
969+ color : 'hsl(0deg 0% 25%)' ,
970+ margin : '0em 1em' ,
971+ } ;
972+
973+ if ( typeof err === 'string' ) {
974+ content = < div style = { codeBoxStyle } > { err } </ div > ;
975+ } else if ( err instanceof Error ) {
976+ title = err . name ;
977+ content = < div style = { codeBoxStyle } > { err . message } </ div > ;
978+ } else {
979+ content = < div style = { codeBoxStyle } > { JSON . stringify ( err , null , 4 ) } </ div > ;
980+ }
981+
982+ return < Result extra = { content } status = "error" title = { title } /> ;
983+ } ;
984+
985+ const renderSuccess = ( response : ReactElement | undefined ) => (
986+ < Result
987+ extra = {
988+ < VStack >
989+ < Box > { response } </ Box >
990+ < Button
991+ data-testid = "create-topic-success__close-button"
992+ onClick = { handleClose }
993+ size = "lg"
994+ style = { { width : '16rem' } }
995+ variant = "solid"
996+ >
997+ Close
998+ </ Button >
999+ </ VStack >
1000+ }
1001+ status = "success"
1002+ title = "Topic created!"
1003+ />
1004+ ) ;
1005+
1006+ let content : ReactElement ;
1007+ let modalState : 'error' | 'success' | 'normal' = 'normal' ;
1008+
1009+ if ( result ) {
1010+ if ( result . error ) {
1011+ modalState = 'error' ;
1012+ content = renderError ( result . error ) ;
1013+ } else {
1014+ modalState = 'success' ;
1015+ content = renderSuccess ( result . returnValue ) ;
1016+ }
1017+ } else {
1018+ content = < CreateTopicModalContent state = { state } /> ;
1019+ }
1020+
1021+ return (
1022+ < Modal isOpen = { isOpen } onClose = { handleClose } >
1023+ < ModalOverlay />
1024+ < ModalContent
1025+ style = { {
1026+ width : '80%' ,
1027+ minWidth : '600px' ,
1028+ maxWidth : '1000px' ,
1029+ top : '50px' ,
1030+ paddingTop : '10px' ,
1031+ paddingBottom : '10px' ,
1032+ } }
1033+ >
1034+ { modalState !== 'success' && < ModalHeader > Create Topic</ ModalHeader > }
1035+ < ModalBody > { content } </ ModalBody >
1036+ { modalState !== 'success' && (
1037+ < ModalFooter >
1038+ < Flex gap = { 2 } >
1039+ { modalState === 'normal' && (
1040+ < Button onClick = { handleClose } variant = "ghost" >
1041+ Cancel
1042+ </ Button >
1043+ ) }
1044+ < Button
1045+ data-testid = "onOk-button"
1046+ isDisabled = { ! isOkEnabled }
1047+ isLoading = { isLoading }
1048+ onClick = { handleOk }
1049+ variant = "solid"
1050+ >
1051+ { modalState === 'error' ? 'Back' : 'Create' }
1052+ </ Button >
1053+ </ Flex >
1054+ </ ModalFooter >
1055+ ) }
1056+ </ ModalContent >
1057+ </ Modal >
1058+ ) ;
1059+ }
0 commit comments