From 62cbbcd2c03cff7b85e76d6f3e1fadf766c0b819 Mon Sep 17 00:00:00 2001 From: Nathan <370788475@qq.com> Date: Tue, 21 Jul 2026 09:22:36 +0800 Subject: [PATCH] fix(dashboard): preserve quota cutoff drafts (#7909) Co-authored-by: Bryan Nathan --- changelog.d/fixes/7889-quota-cutoff-drafts.md | 1 + .../ProviderLimits/QuotaCutoffModal.tsx | 16 +++- .../usage/components/ProviderLimits/index.tsx | 1 + ...ota-cutoff-modal-draft-reset-7889.test.tsx | 85 +++++++++++++++++++ 4 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 changelog.d/fixes/7889-quota-cutoff-drafts.md create mode 100644 tests/unit/ui/quota-cutoff-modal-draft-reset-7889.test.tsx diff --git a/changelog.d/fixes/7889-quota-cutoff-drafts.md b/changelog.d/fixes/7889-quota-cutoff-drafts.md new file mode 100644 index 0000000000..177a1a37fc --- /dev/null +++ b/changelog.d/fixes/7889-quota-cutoff-drafts.md @@ -0,0 +1 @@ +- **fix(dashboard):** Provider Quota cutoff inputs preserve unsaved values across quota refreshes instead of reverting while the operator is typing ([#7889](https://github.com/diegosouzapw/OmniRoute/issues/7889)). diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCutoffModal.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCutoffModal.tsx index f075816261..87ec50c31c 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCutoffModal.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCutoffModal.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslations } from "next-intl"; import Modal from "@/shared/components/Modal"; import Button from "@/shared/components/Button"; @@ -16,6 +16,8 @@ export interface QuotaCutoffModalWindow { interface QuotaCutoffModalProps { isOpen: boolean; onClose: () => void; + /** Stable identity used to distinguish refreshes from connection changes. */ + connectionId: string; /** Label shown in the modal title. */ connectionName: string; /** Used in the modal title for context (e.g. "(codex)"). */ @@ -44,6 +46,7 @@ interface QuotaCutoffModalProps { export default function QuotaCutoffModal({ isOpen, onClose, + connectionId, connectionName, provider, windows, @@ -59,10 +62,17 @@ export default function QuotaCutoffModal({ const [drafts, setDrafts] = useState>({}); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); + const wasOpenRef = useRef(false); + const seededConnectionIdRef = useRef(null); // Reset drafts whenever the modal opens against a new connection. useEffect(() => { - if (!isOpen) return; + const shouldSeed = + isOpen && (!wasOpenRef.current || seededConnectionIdRef.current !== connectionId); + wasOpenRef.current = isOpen; + if (!shouldSeed) return; + + seededConnectionIdRef.current = connectionId; const initial: Record = {}; for (const w of windows) { const persisted = current?.[w.key]; @@ -70,7 +80,7 @@ export default function QuotaCutoffModal({ } setDrafts(initial); setError(null); - }, [isOpen, windows, current]); + }, [isOpen, connectionId, windows, current]); const resolveDefaultFor = (windowKey: string): number => typeof providerDefaults[windowKey] === "number" diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx index 84c93e87df..fdc9310700 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx @@ -1069,6 +1069,7 @@ export default function ProviderLimits({ setCutoffModalConn(null); setCutoffModalWindows([]); }} + connectionId={cutoffModalConn.id} connectionName={ pickDisplayValue( [cutoffModalConn.name, cutoffModalConn.displayName, cutoffModalConn.email], diff --git a/tests/unit/ui/quota-cutoff-modal-draft-reset-7889.test.tsx b/tests/unit/ui/quota-cutoff-modal-draft-reset-7889.test.tsx new file mode 100644 index 0000000000..e44e423bad --- /dev/null +++ b/tests/unit/ui/quota-cutoff-modal-draft-reset-7889.test.tsx @@ -0,0 +1,85 @@ +// @vitest-environment jsdom +/** + * Regression guard for #7889: quota polling recreates `windows` and `current` + * while the cutoff modal is open. Those identity-only prop changes must not + * overwrite an operator's unsaved input; opening another connection still + * seeds that connection's persisted values. + */ +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +const { default: QuotaCutoffModal } = + await import("../../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCutoffModal"); + +const cleanupCallbacks: Array<() => void> = []; + +function props(connectionId: string, persisted: number) { + return { + isOpen: true, + onClose: () => {}, + connectionId, + connectionName: connectionId, + provider: "codex", + windows: [{ key: "session", displayName: "Session" }], + current: { session: persisted }, + providerDefaults: { session: 2 }, + globalDefaultPercent: 2, + onSave: async () => {}, + }; +} + +function setInputValue(input: HTMLInputElement, value: string) { + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!; + act(() => { + setter.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); +} + +beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + while (cleanupCallbacks.length) cleanupCallbacks.pop()!(); +}); + +describe("QuotaCutoffModal draft lifetime (#7889)", () => { + it("preserves an unsaved draft across same-connection prop refreshes and resets for a new connection", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + cleanupCallbacks.push(() => { + act(() => root.unmount()); + container.remove(); + }); + + await act(async () => { + root.render(); + }); + + const input = container.querySelector('input[type="number"]')!; + expect(input.value).toBe("2"); + setInputValue(input, "10"); + expect(input.value).toBe("10"); + + // Quota polling reconstructs both objects without changing the active connection. + await act(async () => { + root.render(); + }); + expect(input.value).toBe("10"); + + // Switching the same mounted modal to another connection must seed its persisted value. + await act(async () => { + root.render(); + }); + expect(input.value).toBe("7"); + }); +});