Skip to content

Commit 5c95d6b

Browse files
committed
Removed createAutoModal
1 parent 7422065 commit 5c95d6b

3 files changed

Lines changed: 318 additions & 507 deletions

File tree

frontend/src/components/pages/topics/CreateTopicModal/create-topic-modal.tsx

Lines changed: 312 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type React from 'react';
2-
import { useEffect, useState } from 'react';
2+
import { type ReactElement, type ReactNode, useEffect, useReducer, useRef, useState } from 'react';
33

44
import type { TopicConfigEntry } from '../../../../state/rest-interfaces';
55
import { Label } from '../../../../utils/tsx-utils';
@@ -8,14 +8,27 @@ import './CreateTopicModal.scss';
88
import {
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';
1830
import { CloseIcon, PlusIcon } from 'components/icons';
31+
import { useCreateTopicMutation } from 'react-query/api/topic';
1932

2033
import { isServerless } from '../../../../config';
2134
import { 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
3649
const DECIMAL_PLACES_REGEX = /\.\d{4,}/;
3750

51+
// Regex for validating topic names
52+
const TOPIC_NAME_REGEX = /^\S+$/;
53+
3854
type 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

Comments
 (0)