Skip to content

Commit 2fd1f9d

Browse files
authored
Add validation for ISO date-time when resetting offset (#2322)
* Add validation for ISO date-time when resetting offset Signed-off-by: hemahg <hhg@redhat.com> * Add validation for specific date time Signed-off-by: hemahg <hhg@redhat.com> * Add validation for specific date time Signed-off-by: hemahg <hhg@redhat.com> --------- Signed-off-by: hemahg <hhg@redhat.com>
1 parent e8e76d6 commit 2fd1f9d

File tree

7 files changed

+389
-123
lines changed

7 files changed

+389
-123
lines changed
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { Meta, StoryObj } from "@storybook/nextjs";
2+
import { ISODateTimeInput } from "./ISODateTimeInput";
3+
import { useState } from "react";
4+
import { fn } from "storybook/test";
5+
6+
export default {
7+
component: ISODateTimeInput,
8+
} as Meta<typeof ISODateTimeInput>;
9+
10+
type Story = StoryObj<typeof ISODateTimeInput>;
11+
12+
const ControlledTemplate: Story = {
13+
render: (args) => {
14+
const [value, setValue] = useState(args.value ?? "");
15+
16+
return (
17+
<ISODateTimeInput {...args} value={value} onValidChange={setValue} />
18+
);
19+
},
20+
args: {
21+
onValidityChange: fn(),
22+
},
23+
};
24+
25+
export const WithUTCValue: Story = {
26+
...ControlledTemplate,
27+
args: {
28+
...ControlledTemplate.args,
29+
value: "2023-11-02T22:37:22.000Z",
30+
},
31+
};
32+
33+
export const WithLocalValue: Story = {
34+
...ControlledTemplate,
35+
args: {
36+
...ControlledTemplate.args,
37+
value: "2023-11-02T18:37:22-04:00",
38+
},
39+
};
40+
41+
export const ErrorState: Story = {
42+
...ControlledTemplate,
43+
args: {
44+
...ControlledTemplate.args,
45+
value: "invalid-date-string",
46+
},
47+
};
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
"use client";
2+
3+
import { useEffect, useState, useMemo } from "react";
4+
import {
5+
FormGroup,
6+
TextInput,
7+
FormHelperText,
8+
HelperText,
9+
HelperTextItem,
10+
Button,
11+
} from "@/libs/patternfly/react-core";
12+
import { parseISO, isValid } from "date-fns";
13+
import { useTranslations } from "next-intl";
14+
import { formatInTimeZone } from "date-fns-tz";
15+
16+
const ISO_REGEX = {
17+
UTC: /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z?$/,
18+
LOCAL: /([+-]\d{2}:\d{2})$/,
19+
};
20+
21+
type TimeType = "UTC" | "LOCAL" | "INVALID";
22+
23+
type Props = {
24+
value?: string;
25+
onValidChange: (value: string) => void;
26+
onValidityChange?: (isValid: boolean) => void;
27+
label?: string;
28+
};
29+
30+
export function ISODateTimeInput({
31+
value = "",
32+
onValidChange,
33+
label = "ISO date time",
34+
onValidityChange,
35+
}: Props) {
36+
const t = useTranslations("ConsumerGroupsTable");
37+
const [inputValue, setInputValue] = useState(value);
38+
const [displayZone, setDisplayZone] = useState<"UTC" | "LOCAL">("UTC");
39+
const [originalOffset, setOriginalOffset] = useState<string | null>(null);
40+
41+
useEffect(() => {
42+
setInputValue(value);
43+
}, [value]);
44+
45+
const { timeType, hasError, parsedDate } = useMemo(() => {
46+
const isLocal = ISO_REGEX.LOCAL.test(inputValue);
47+
const isUtc = ISO_REGEX.UTC.test(inputValue);
48+
49+
let type: TimeType = "INVALID";
50+
51+
if (!inputValue) {
52+
return { timeType: null, hasError: false, parsedDate: null };
53+
}
54+
55+
const date = parseISO(inputValue);
56+
const valid = isValid(date) && (isLocal || isUtc);
57+
58+
if (valid) {
59+
type = isLocal ? "LOCAL" : "UTC";
60+
}
61+
62+
return {
63+
timeType: type,
64+
hasError: !valid && inputValue.length > 0,
65+
parsedDate: valid ? date : null,
66+
};
67+
}, [inputValue]);
68+
69+
useEffect(() => {
70+
onValidityChange?.(!hasError && timeType !== "INVALID");
71+
}, [hasError, timeType, onValidityChange]);
72+
73+
const handleChange = (_e: unknown, val: string) => {
74+
setInputValue(val);
75+
76+
const offsetMatch = val.match(ISO_REGEX.LOCAL);
77+
if (offsetMatch) {
78+
setOriginalOffset(offsetMatch[1]);
79+
}
80+
81+
const date = parseISO(val);
82+
if (isValid(date)) {
83+
const formatted =
84+
ISO_REGEX.UTC.test(val) && !val.endsWith("Z") ? `${val}Z` : val;
85+
onValidChange(formatted);
86+
}
87+
};
88+
89+
const handleConvert = (target: "UTC" | "LOCAL") => {
90+
if (hasError || !parsedDate) return;
91+
let converted: string;
92+
93+
if (target === "UTC") {
94+
converted = formatInTimeZone(
95+
parsedDate,
96+
"UTC",
97+
"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
98+
);
99+
} else {
100+
const targetZone =
101+
originalOffset || Intl.DateTimeFormat().resolvedOptions().timeZone;
102+
103+
converted = formatInTimeZone(
104+
parsedDate,
105+
targetZone,
106+
"yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
107+
);
108+
}
109+
110+
setDisplayZone(target);
111+
setInputValue(converted);
112+
onValidChange(converted);
113+
};
114+
return (
115+
<FormGroup label={label}>
116+
<TextInput
117+
value={inputValue}
118+
type="text"
119+
placeholder={t("date_time_placeholder")}
120+
validated={hasError ? "error" : "default"}
121+
onChange={handleChange}
122+
/>
123+
{hasError && (
124+
<FormHelperText>
125+
<HelperText>
126+
<HelperTextItem variant="error">
127+
{t("invalid_format_error")}
128+
</HelperTextItem>
129+
</HelperText>
130+
</FormHelperText>
131+
)}
132+
<FormHelperText>
133+
<HelperText>
134+
<HelperTextItem>
135+
{displayZone === "UTC" ? (
136+
<>
137+
{t("time_in_UTC")}{" "}
138+
<Button
139+
variant="link"
140+
isInline
141+
isDisabled={hasError || !parsedDate}
142+
onClick={() => handleConvert("LOCAL")}
143+
>
144+
{t("change_to_local")}
145+
</Button>
146+
</>
147+
) : (
148+
<>
149+
{t("time_in_local")}{" "}
150+
<Button
151+
variant="link"
152+
isInline
153+
isDisabled={hasError || !parsedDate}
154+
onClick={() => handleConvert("UTC")}
155+
>
156+
{t("change_to_utc")}
157+
</Button>
158+
</>
159+
)}
160+
</HelperTextItem>
161+
</HelperText>
162+
</FormHelperText>
163+
</FormGroup>
164+
);
165+
}

ui/app/[locale]/(authorized)/kafka/[kafkaId]/consumer-groups/[groupId]/reset-offset/ResetConsumerOffset.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,10 @@ export function ResetConsumerOffset({
212212
} else {
213213
for (let partition of partitions) {
214214
if (partition.topicId === offset.topicId) {
215-
if (selectedPartition === "allPartitions" || offset.partition === partition.partitionNumber) {
215+
if (
216+
selectedPartition === "allPartitions" ||
217+
offset.partition === partition.partitionNumber
218+
) {
216219
offsets.push({
217220
topicId: offset.topicId,
218221
partition: partition.partitionNumber,
@@ -241,7 +244,9 @@ export function ResetConsumerOffset({
241244
searchParams.set("data", data);
242245
searchParams.set("cliCommand", cliCommand);
243246
router.push(
244-
`${baseurl}/${consumerGroup.id}/reset-offset/dryrun?${searchParams.toString()}`,
247+
`${baseurl}/${
248+
consumerGroup.id
249+
}/reset-offset/dryrun?${searchParams.toString()}`,
245250
);
246251
};
247252

@@ -295,12 +300,12 @@ export function ResetConsumerOffset({
295300
closeResetOffset();
296301
addAlert({
297302
title: t("ConsumerGroupsTable.reset_offset_submitted_successfully", {
298-
groupId,
303+
groupId: groupId,
299304
}),
300305
variant: "success",
301306
});
302307
}
303-
} catch (e: unknown) {
308+
} catch (e) {
304309
setError({ GeneralError: "Unknown error" });
305310
} finally {
306311
setIsLoading(false);

ui/app/[locale]/(authorized)/kafka/[kafkaId]/consumer-groups/[groupId]/reset-offset/ResetOffset.tsx

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import { DryrunSelect } from "./DryrunSelect";
2626
import { SelectComponent } from "./SelectComponent";
2727
import { ErrorState } from "./ResetConsumerOffset";
2828
import { ExclamationCircleIcon } from "@/libs/patternfly/react-icons";
29+
import { ISODateTimeInput } from "./ISODateTimeInput";
30+
import { useState } from "react";
2931

3032
export type Offset = {
3133
topicId: string;
@@ -81,6 +83,9 @@ export function ResetOffset({
8183
onOffsetSelect: (value: OffsetValue) => void;
8284
}) {
8385
const t = useTranslations("ConsumerGroupsTable");
86+
87+
const [isIsoValid, setIsIsoValid] = useState(false);
88+
8489
const isTopicSelected = selectTopic === "selectedTopic";
8590
const hasTopicName =
8691
typeof offset.topicName === "string" && offset.topicName.trim().length > 0;
@@ -114,7 +119,12 @@ export function ResetOffset({
114119
);
115120

116121
case "specificDateTime":
117-
return !!selectDateTimeFormat && hasDateTimeValue;
122+
if (!hasDateTimeValue) return false;
123+
124+
if (selectDateTimeFormat === "ISO") {
125+
return isIsoValid;
126+
}
127+
return /^\d+$/.test(String(offset.offset));
118128

119129
case "delete":
120130
return isTopicSelected && hasTopicName && partitionValid;
@@ -282,7 +292,9 @@ export function ResetOffset({
282292
<Radio
283293
name={"select_time"}
284294
id={"iso_date_format"}
285-
label={t("iso_date_format")}
295+
label={`${t(
296+
"iso_date_format",
297+
)} (yyyy-MM-dd'T'HH:mm:ss.SSS)`}
286298
isChecked={selectDateTimeFormat === "ISO"}
287299
onChange={() => onDateTimeSelect("ISO")}
288300
/>
@@ -295,17 +307,29 @@ export function ResetOffset({
295307
/>
296308
</FormGroup>
297309
<FormGroup>
298-
<TextInput
299-
id="date-input"
300-
name={"date-input"}
301-
type={selectDateTimeFormat === "ISO" ? "text" : "number"}
302-
placeholder={
303-
selectDateTimeFormat === "ISO"
304-
? "yyyy-MM-dd'T'HH:mm:ss.SSS"
305-
: "specify epoch timestamp"
306-
}
307-
onChange={(_event, value) => handleDateTimeChange(value)}
308-
/>
310+
{selectDateTimeFormat === "ISO" ? (
311+
<ISODateTimeInput
312+
value={
313+
typeof offset.offset === "string" ? offset.offset : ""
314+
}
315+
onValidChange={(val: string) =>
316+
handleDateTimeChange(val)
317+
}
318+
onValidityChange={setIsIsoValid}
319+
label={t("iso_date_format")}
320+
/>
321+
) : (
322+
<TextInput
323+
id="date-input"
324+
name={"date-input"}
325+
type="number"
326+
placeholder="specify epoch timestamp"
327+
value={offset.offset}
328+
onChange={(_event, value) =>
329+
handleDateTimeChange(value)
330+
}
331+
/>
332+
)}
309333
{error?.SpecificDateTimeNotValidError && (
310334
<FormHelperText>
311335
<HelperText>

ui/app/[locale]/(authorized)/kafka/[kafkaId]/consumer-groups/[groupId]/reset-offset/dryrun/Dryrun.stories.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,12 @@ export const DeletedOffsetsStory: Story = {
4242
"$ kafka-consumer-groups --bootstrap-server ${bootstrap-server} --group --reset-offsets",
4343
},
4444
};
45+
46+
export const NoOffsetsFromTimestamp: Story = {
47+
args: {
48+
consumerGroupName: "console_datagen_002-a",
49+
newOffset: [],
50+
cliCommand:
51+
"$ kafka-consumer-groups --bootstrap-server ${bootstrap-server} --group --reset-offsets --to-datetime 2025-01-02T18:37:28-04:00 --dry-run",
52+
},
53+
};

0 commit comments

Comments
 (0)