fix(ui): bind key duration input to one Form.Item so pre-filled expiry submits (#34521)

* fix(ui): bind key duration input to one Form.Item so pre-filled expiry submits

The Create and Edit key forms kept the displayed expiry in a Tremor TextInput's
local state while the value actually submitted lived in a separate hidden antd
Form.Item. After the first create, form.resetFields() cleared the hidden field
but not the local state, so a second create showed a stale "1d" that was never
sent unless the user deleted and retyped it

Wrap the visible input in a real Form.Item (name="duration") inside
KeyLifecycleSettings and drop both hidden mirror fields plus the local
durationValue state, so what is displayed is always what is submitted. The
Regenerate key flow already used this pattern

* test(ui): restore custom rotation interval coverage in real-form harness

The KeyLifecycleSettings test rewrite dropped the custom interval branch:
selecting Custom interval, typing a value, propagation to the parent, and
hiding the input when switching back to a predefined interval. Cover it in
the real antd Form harness, asserting the parent-held rotationInterval state
instead of a mocked callback
This commit is contained in:
ryan-crabbe-berri 2026-07-24 16:08:32 -07:00 • committed by GitHub
parent 57ed2ed718
commit 5f2c9a952d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 158 additions and 318 deletions

View file

@ -1,357 +1,214 @@
import React, { useState } from "react";
// eslint-disable-next-line no-restricted-imports -- exercising KeyLifecycleSettings requires hosting it in a real antd Form (the component it's built on)
import { Form } from "antd";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { renderWithProviders, screen } from "../../../tests/test-utils";
import { renderWithProviders, screen, waitFor } from "../../../tests/test-utils";
import KeyLifecycleSettings from "./KeyLifecycleSettings";
vi.mock("antd", () => {
const Option = ({ children, value }: any) => <option value={value}>{children}</option>;
const Select = ({ children, value, onChange, placeholder }: any) => (
<select
data-testid="select"
value={value}
onChange={(e) => onChange(e.target.value)}
data-placeholder={placeholder}
>
{children}
</select>
);
Select.Option = Option;
return {
Select,
Tooltip: ({ children, title }: any) => (
<div data-testid="tooltip" title={title}>
{children}
</div>
),
Switch: ({ checked, onChange }: any) => (
<input type="checkbox" data-testid="switch" checked={checked} onChange={(e) => onChange(e.target.checked)} />
),
Divider: () => <hr data-testid="divider" />,
};
});
const CREATE_PLACEHOLDER = "e.g., 30d or leave empty to never expire";
const EDIT_PLACEHOLDER = "e.g., 30d";
vi.mock("@ant-design/icons", () => ({
InfoCircleOutlined: () => <span data-testid="info-icon">ℹ</span>,
}));
interface HarnessProps {
isCreateMode?: boolean;
onFinish?: (values: Record<string, unknown>) => void;
}
vi.mock("@tremor/react", () => ({
TextInput: ({ value, onValueChange, onChange, placeholder, name, className }: any) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (onChange) {
onChange(e);
}
if (onValueChange) {
onValueChange(e.target.value);
}
};
return (
<input
data-testid={name === "duration" ? "duration-input" : "custom-interval-input"}
value={value}
onChange={handleChange}
placeholder={placeholder}
className={className}
const Harness: React.FC<HarnessProps> = ({ isCreateMode = true, onFinish = () => {} }) => {
const [form] = Form.useForm();
const [autoRotationEnabled, setAutoRotationEnabled] = useState(false);
const [rotationInterval, setRotationInterval] = useState("");
const [neverExpire, setNeverExpire] = useState(false);
return (
<Form form={form} onFinish={onFinish}>
<KeyLifecycleSettings
form={form}
autoRotationEnabled={autoRotationEnabled}
onAutoRotationChange={setAutoRotationEnabled}
rotationInterval={rotationInterval}
onRotationIntervalChange={setRotationInterval}
isCreateMode={isCreateMode}
neverExpire={neverExpire}
onNeverExpireChange={setNeverExpire}
/>
);
},
}));
<button type="submit">submit</button>
<button type="button" onClick={() => form.resetFields()}>
reset
</button>
<span data-testid="rotation-interval-value">{rotationInterval}</span>
</Form>
);
};
const getDurationInput = (isCreateMode = true) =>
screen.getByPlaceholderText(isCreateMode ? CREATE_PLACEHOLDER : EDIT_PLACEHOLDER) as HTMLInputElement;
describe("KeyLifecycleSettings", () => {
const mockForm = {
getFieldValue: vi.fn(),
setFieldValue: vi.fn(),
setFieldsValue: vi.fn(),
};
const defaultProps = {
form: mockForm,
autoRotationEnabled: false,
onAutoRotationChange: vi.fn(),
rotationInterval: "",
onRotationIntervalChange: vi.fn(),
isCreateMode: false,
};
beforeEach(() => {
vi.clearAllMocks();
mockForm.getFieldValue.mockReturnValue("");
});
it("should render without crashing", () => {
renderWithProviders(<KeyLifecycleSettings {...defaultProps} />);
it("renders the expiry and auto-rotation sections", () => {
renderWithProviders(<Harness />);
expect(screen.getByText("Key Expiry Settings")).toBeInTheDocument();
expect(screen.getByText("Auto-Rotation Settings")).toBeInTheDocument();
expect(getDurationInput()).toBeInTheDocument();
});
describe("Key Expiry Settings", () => {
it("should render expiry input field", () => {
renderWithProviders(<KeyLifecycleSettings {...defaultProps} />);
it("uses the create-mode placeholder in create mode", () => {
renderWithProviders(<Harness isCreateMode={true} />);
expect(screen.getByPlaceholderText(CREATE_PLACEHOLDER)).toBeInTheDocument();
});
expect(screen.getByText("Expire Key")).toBeInTheDocument();
expect(screen.getByTestId("duration-input")).toBeInTheDocument();
});
it("uses the edit-mode placeholder in edit mode", () => {
renderWithProviders(<Harness isCreateMode={false} />);
expect(screen.getByPlaceholderText(EDIT_PLACEHOLDER)).toBeInTheDocument();
});
it("should show correct placeholder in create mode", () => {
renderWithProviders(<KeyLifecycleSettings {...defaultProps} isCreateMode={true} />);
const input = screen.getByTestId("duration-input");
expect(input).toHaveAttribute("placeholder", "e.g., 30d or leave empty to never expire");
});
it("should show correct placeholder in edit mode", () => {
renderWithProviders(<KeyLifecycleSettings {...defaultProps} isCreateMode={false} />);
const input = screen.getByTestId("duration-input");
expect(input).toHaveAttribute("placeholder", "e.g., 30d");
});
it("should show correct tooltip in create mode", () => {
renderWithProviders(<KeyLifecycleSettings {...defaultProps} isCreateMode={true} />);
const tooltips = screen.getAllByTestId("tooltip");
const expiryTooltip = tooltips.find((tooltip) =>
tooltip.getAttribute("title")?.includes("Leave empty to keep the current expiry unchanged"),
);
expect(expiryTooltip).toBeInTheDocument();
expect(expiryTooltip).toHaveAttribute(
"title",
"Set when this key should expire. Format: 30s (seconds), 30m (minutes), 30h (hours), 30d (days). Leave empty to keep the current expiry unchanged.",
);
});
it("should show correct tooltip in edit mode", () => {
renderWithProviders(<KeyLifecycleSettings {...defaultProps} isCreateMode={false} />);
const tooltips = screen.getAllByTestId("tooltip");
const expiryTooltip = tooltips.find((tooltip) =>
tooltip.getAttribute("title")?.includes("Leave empty to keep the current expiry unchanged"),
);
expect(expiryTooltip).toBeInTheDocument();
expect(expiryTooltip).toHaveAttribute(
"title",
"Set when this key should expire. Format: 30s (seconds), 30m (minutes), 30h (hours), 30d (days). Leave empty to keep the current expiry unchanged.",
);
});
it("should initialize with form value if present", () => {
mockForm.getFieldValue.mockReturnValue("30d");
renderWithProviders(<KeyLifecycleSettings {...defaultProps} />);
const input = screen.getByTestId("duration-input") as HTMLInputElement;
expect(input.value).toBe("30d");
});
it("should update form using setFieldValue when duration changes", async () => {
describe("duration is a single source of truth (regression for pre-filled value dropped on submit)", () => {
it("submits the duration the user typed", async () => {
const user = userEvent.setup();
renderWithProviders(<KeyLifecycleSettings {...defaultProps} />);
const onFinish = vi.fn();
renderWithProviders(<Harness onFinish={onFinish} />);
const input = screen.getByTestId("duration-input");
await user.type(input, "60d");
await user.type(getDurationInput(), "1d");
await user.click(screen.getByRole("button", { name: "submit" }));
expect(mockForm.setFieldValue).toHaveBeenCalledWith("duration", "60d");
await waitFor(() => expect(onFinish).toHaveBeenCalledTimes(1));
expect(onFinish.mock.calls[0][0]).toMatchObject({ duration: "1d" });
});
it("should update form using setFieldsValue when setFieldValue is not available", async () => {
it("clears the displayed value when the form is reset, so no stale value lingers", async () => {
const user = userEvent.setup();
const formWithoutSetFieldValue = {
getFieldValue: vi.fn().mockReturnValue(""),
setFieldsValue: vi.fn(),
};
renderWithProviders(<KeyLifecycleSettings {...defaultProps} form={formWithoutSetFieldValue} />);
renderWithProviders(<Harness />);
const input = screen.getByTestId("duration-input");
await user.type(input, "90d");
await user.type(getDurationInput(), "1d");
expect(getDurationInput().value).toBe("1d");
expect(formWithoutSetFieldValue.setFieldsValue).toHaveBeenCalledWith({ duration: "90d" });
await user.click(screen.getByRole("button", { name: "reset" }));
await waitFor(() => expect(getDurationInput().value).toBe(""));
});
it("never submits a value that differs from what is displayed after a reset", async () => {
const user = userEvent.setup();
const onFinish = vi.fn();
renderWithProviders(<Harness onFinish={onFinish} />);
// First create: type "1d" and submit -> "1d" is sent.
await user.type(getDurationInput(), "1d");
await user.click(screen.getByRole("button", { name: "submit" }));
await waitFor(() => expect(onFinish).toHaveBeenCalledTimes(1));
expect(onFinish.mock.calls[0][0]).toMatchObject({ duration: "1d" });
// Second create: form resets, so the field must show empty AND submit empty.
// The old bug showed a stale "1d" while submitting null/empty.
await user.click(screen.getByRole("button", { name: "reset" }));
await waitFor(() => expect(getDurationInput().value).toBe(""));
await user.click(screen.getByRole("button", { name: "submit" }));
await waitFor(() => expect(onFinish).toHaveBeenCalledTimes(2));
expect(onFinish.mock.calls[1][0].duration).not.toBe("1d");
expect(getDurationInput().value).toBe(onFinish.mock.calls[1][0].duration ?? "");
});
});
describe("Auto-Rotation Settings", () => {
it("should render auto-rotation switch", () => {
renderWithProviders(<KeyLifecycleSettings {...defaultProps} />);
expect(screen.getByText("Enable Auto-Rotation")).toBeInTheDocument();
expect(screen.getByTestId("switch")).toBeInTheDocument();
});
it("should show switch as unchecked when autoRotationEnabled is false", () => {
renderWithProviders(<KeyLifecycleSettings {...defaultProps} autoRotationEnabled={false} />);
const switchElement = screen.getByTestId("switch") as HTMLInputElement;
expect(switchElement.checked).toBe(false);
});
it("should show switch as checked when autoRotationEnabled is true", () => {
renderWithProviders(<KeyLifecycleSettings {...defaultProps} autoRotationEnabled={true} />);
const switchElement = screen.getByTestId("switch") as HTMLInputElement;
expect(switchElement.checked).toBe(true);
});
it("should call onAutoRotationChange when switch is toggled", async () => {
describe("Never Expire", () => {
it("clears and disables the duration input, then submits an empty duration", async () => {
const user = userEvent.setup();
const onAutoRotationChange = vi.fn();
renderWithProviders(<KeyLifecycleSettings {...defaultProps} onAutoRotationChange={onAutoRotationChange} />);
const onFinish = vi.fn();
renderWithProviders(<Harness isCreateMode={false} onFinish={onFinish} />);
const switchElement = screen.getByTestId("switch");
await user.click(switchElement);
await user.type(getDurationInput(false), "30d");
expect(getDurationInput(false).value).toBe("30d");
expect(onAutoRotationChange).toHaveBeenCalledWith(true);
await user.click(screen.getByRole("checkbox", { name: /never expire/i }));
await waitFor(() => expect(getDurationInput(false).value).toBe(""));
expect(getDurationInput(false)).toBeDisabled();
await user.click(screen.getByRole("button", { name: "submit" }));
await waitFor(() => expect(onFinish).toHaveBeenCalledTimes(1));
expect(onFinish.mock.calls[0][0]).toMatchObject({ duration: "" });
});
});
it("should not show rotation interval section when auto-rotation is disabled", () => {
renderWithProviders(<KeyLifecycleSettings {...defaultProps} autoRotationEnabled={false} />);
describe("Auto-Rotation", () => {
it("reveals the rotation interval controls when enabled", async () => {
const user = userEvent.setup();
renderWithProviders(<Harness />);
expect(screen.queryByText("Rotation Interval")).not.toBeInTheDocument();
expect(screen.queryByTestId("select")).not.toBeInTheDocument();
await user.click(screen.getByRole("switch"));
await waitFor(() => expect(screen.getByText("Rotation Interval")).toBeInTheDocument());
});
it("should show rotation interval section when auto-rotation is enabled", () => {
renderWithProviders(<KeyLifecycleSettings {...defaultProps} autoRotationEnabled={true} rotationInterval="30d" />);
expect(screen.getByText("Rotation Interval")).toBeInTheDocument();
expect(screen.getByTestId("select")).toBeInTheDocument();
});
it("should show all predefined interval options", () => {
renderWithProviders(<KeyLifecycleSettings {...defaultProps} autoRotationEnabled={true} rotationInterval="30d" />);
expect(screen.getByText("7 days")).toBeInTheDocument();
expect(screen.getByText("30 days")).toBeInTheDocument();
expect(screen.getByText("90 days")).toBeInTheDocument();
expect(screen.getByText("180 days")).toBeInTheDocument();
expect(screen.getByText("365 days")).toBeInTheDocument();
expect(screen.getByText("Custom interval")).toBeInTheDocument();
});
it("should display current rotation interval in select", () => {
renderWithProviders(<KeyLifecycleSettings {...defaultProps} autoRotationEnabled={true} rotationInterval="90d" />);
const select = screen.getByTestId("select") as HTMLSelectElement;
expect(select.value).toBe("90d");
});
it("should call onRotationIntervalChange when predefined interval is selected", async () => {
it("propagates a selected predefined interval", async () => {
const user = userEvent.setup();
const onRotationIntervalChange = vi.fn();
renderWithProviders(
<KeyLifecycleSettings
{...defaultProps}
autoRotationEnabled={true}
rotationInterval="7d"
onRotationIntervalChange={onRotationIntervalChange}
/>,
);
renderWithProviders(<Harness />);
const select = screen.getByTestId("select");
await user.selectOptions(select, "30d");
await user.click(screen.getByRole("switch"));
await waitFor(() => expect(screen.getByText("Rotation Interval")).toBeInTheDocument());
expect(onRotationIntervalChange).toHaveBeenCalledWith("30d");
await user.click(screen.getByRole("combobox"));
await user.click(await screen.findByText("90 days"));
await waitFor(() => expect(document.querySelector(".ant-select-selection-item")?.textContent).toBe("90 days"));
expect(screen.getByTestId("rotation-interval-value")).toHaveTextContent("90d");
});
it("should show custom input when custom option is selected", async () => {
it("shows the custom interval input when Custom interval is selected, without propagating yet", async () => {
const user = userEvent.setup();
renderWithProviders(<KeyLifecycleSettings {...defaultProps} autoRotationEnabled={true} rotationInterval="30d" />);
renderWithProviders(<Harness />);
const select = screen.getByTestId("select");
await user.selectOptions(select, "custom");
await user.click(screen.getByRole("switch"));
await waitFor(() => expect(screen.getByText("Rotation Interval")).toBeInTheDocument());
expect(screen.getByTestId("custom-interval-input")).toBeInTheDocument();
await user.click(screen.getByRole("combobox"));
await user.click(await screen.findByText("Custom interval"));
expect(await screen.findByPlaceholderText("e.g., 1s, 5m, 2h, 14d")).toBeInTheDocument();
expect(screen.getByText("Supported formats: seconds (s), minutes (m), hours (h), days (d)")).toBeInTheDocument();
expect(screen.getByTestId("rotation-interval-value")).toHaveTextContent("");
});
it("should hide custom input when predefined interval is selected after custom", async () => {
it("propagates a typed custom interval to the parent", async () => {
const user = userEvent.setup();
const onRotationIntervalChange = vi.fn();
renderWithProviders(
<KeyLifecycleSettings
{...defaultProps}
autoRotationEnabled={true}
rotationInterval="custom-value"
onRotationIntervalChange={onRotationIntervalChange}
/>,
);
renderWithProviders(<Harness />);
const select = screen.getByTestId("select");
await user.selectOptions(select, "7d");
await user.click(screen.getByRole("switch"));
await waitFor(() => expect(screen.getByText("Rotation Interval")).toBeInTheDocument());
expect(screen.queryByTestId("custom-interval-input")).not.toBeInTheDocument();
expect(onRotationIntervalChange).toHaveBeenCalledWith("7d");
});
await user.click(screen.getByRole("combobox"));
await user.click(await screen.findByText("Custom interval"));
it("should call onRotationIntervalChange when custom interval is entered", async () => {
const user = userEvent.setup();
const onRotationIntervalChange = vi.fn();
renderWithProviders(
<KeyLifecycleSettings
{...defaultProps}
autoRotationEnabled={true}
rotationInterval=""
onRotationIntervalChange={onRotationIntervalChange}
/>,
);
const select = screen.getByTestId("select");
await user.selectOptions(select, "custom");
const customInput = screen.getByTestId("custom-interval-input");
const customInput = await screen.findByPlaceholderText("e.g., 1s, 5m, 2h, 14d");
await user.type(customInput, "14d");
expect(onRotationIntervalChange).toHaveBeenCalledWith("14d");
await waitFor(() => expect(screen.getByTestId("rotation-interval-value")).toHaveTextContent("14d"));
expect((customInput as HTMLInputElement).value).toBe("14d");
});
it("should show info message when auto-rotation is enabled", () => {
renderWithProviders(<KeyLifecycleSettings {...defaultProps} autoRotationEnabled={true} />);
expect(
screen.getByText(
"When rotation occurs, you'll receive a notification with the new key. The old key will be deactivated after a brief grace period.",
),
).toBeInTheDocument();
});
it("should not show info message when auto-rotation is disabled", () => {
renderWithProviders(<KeyLifecycleSettings {...defaultProps} autoRotationEnabled={false} />);
expect(
screen.queryByText(
"When rotation occurs, you'll receive a notification with the new key. The old key will be deactivated after a brief grace period.",
),
).not.toBeInTheDocument();
});
it("should initialize with custom interval input visible when custom interval is provided", () => {
renderWithProviders(<KeyLifecycleSettings {...defaultProps} autoRotationEnabled={true} rotationInterval="14d" />);
expect(screen.getByTestId("custom-interval-input")).toBeInTheDocument();
const customInput = screen.getByTestId("custom-interval-input") as HTMLInputElement;
expect(customInput.value).toBe("14d");
});
it("should show custom option selected when custom interval is provided", () => {
renderWithProviders(<KeyLifecycleSettings {...defaultProps} autoRotationEnabled={true} rotationInterval="14d" />);
const select = screen.getByTestId("select") as HTMLSelectElement;
expect(select.value).toBe("custom");
});
it("should not call onRotationIntervalChange when selecting custom option", async () => {
it("hides the custom input and propagates the value when switching back to a predefined interval", async () => {
const user = userEvent.setup();
const onRotationIntervalChange = vi.fn();
renderWithProviders(
<KeyLifecycleSettings
{...defaultProps}
autoRotationEnabled={true}
rotationInterval="30d"
onRotationIntervalChange={onRotationIntervalChange}
/>,
);
renderWithProviders(<Harness />);
const select = screen.getByTestId("select");
await user.selectOptions(select, "custom");
await user.click(screen.getByRole("switch"));
await waitFor(() => expect(screen.getByText("Rotation Interval")).toBeInTheDocument());
expect(onRotationIntervalChange).not.toHaveBeenCalled();
await user.click(screen.getByRole("combobox"));
await user.click(await screen.findByText("Custom interval"));
const customInput = await screen.findByPlaceholderText("e.g., 1s, 5m, 2h, 14d");
await user.type(customInput, "14d");
await waitFor(() => expect(screen.getByTestId("rotation-interval-value")).toHaveTextContent("14d"));
await user.click(screen.getByRole("combobox"));
await user.click(await screen.findByText("7 days"));
await waitFor(() => expect(screen.getByTestId("rotation-interval-value")).toHaveTextContent("7d"));
expect(screen.queryByPlaceholderText("e.g., 1s, 5m, 2h, 14d")).not.toBeInTheDocument();
});
});
});

View file

@ -1,5 +1,5 @@
import React, { useState } from "react";
import { Select, Tooltip, Divider, Switch, Checkbox } from "antd";
import { Select, Tooltip, Divider, Switch, Checkbox, Form } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
import { TextInput } from "@tremor/react";
@ -34,7 +34,6 @@ const KeyLifecycleSettings: React.FC<KeyLifecycleSettingsProps> = ({
const [showCustomInput, setShowCustomInput] = useState(isCustomInterval);
const [customInterval, setCustomInterval] = useState(isCustomInterval ? rotationInterval : "");
const [durationValue, setDurationValue] = useState<string>(form?.getFieldValue?.("duration") || "");
const handleIntervalChange = (value: string) => {
if (value === "custom") {
@ -53,14 +52,6 @@ const KeyLifecycleSettings: React.FC<KeyLifecycleSettingsProps> = ({
onRotationIntervalChange(value);
};
const handleDurationChange = (value: string) => {
setDurationValue(value);
if (form && typeof form.setFieldValue === "function") {
form.setFieldValue("duration", value);
} else if (form && typeof form.setFieldsValue === "function") {
form.setFieldsValue({ duration: value });
}
};
return (
<div className="space-y-6">
{/* Key Expiry Section */}
@ -80,7 +71,6 @@ const KeyLifecycleSettings: React.FC<KeyLifecycleSettingsProps> = ({
const checked = e.target.checked;
onNeverExpireChange(checked);
if (checked) {
setDurationValue("");
if (form && typeof form.setFieldValue === "function") {
form.setFieldValue("duration", "");
} else if (form && typeof form.setFieldsValue === "function") {
@ -94,14 +84,13 @@ const KeyLifecycleSettings: React.FC<KeyLifecycleSettingsProps> = ({
</Checkbox>
)}
</label>
<TextInput
name="duration"
placeholder={isCreateMode ? "e.g., 30d or leave empty to never expire" : "e.g., 30d"}
className="w-full"
value={durationValue}
onValueChange={handleDurationChange}
disabled={!isCreateMode && neverExpire}
/>
<Form.Item name="duration" noStyle initialValue="">
<TextInput
placeholder={isCreateMode ? "e.g., 30d or leave empty to never expire" : "e.g., 30d"}
className="w-full"
disabled={!isCreateMode && neverExpire}
/>
</Form.Item>
</div>
</div>

View file

@ -1644,9 +1644,6 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
/>
</div>
</AccordionBody>
<Form.Item name="duration" hidden initialValue={null}>
<Input />
</Form.Item>
</Accordion>
<Accordion className="mt-4 mb-4">
<AccordionHeader>

View file

@ -860,9 +860,6 @@ export function KeyEditView({
neverExpire={neverExpire}
onNeverExpireChange={setNeverExpire}
/>
<Form.Item name="duration" hidden initialValue="">
<Input />
</Form.Item>
</div>
{/* Hidden form field for token */}