diff --git a/apps/web/src/components/settings/BackgroundActivitySettings.tsx b/apps/web/src/components/settings/BackgroundActivitySettings.tsx index 9892f9ae753f1..f52be354944a6 100644 --- a/apps/web/src/components/settings/BackgroundActivitySettings.tsx +++ b/apps/web/src/components/settings/BackgroundActivitySettings.tsx @@ -22,13 +22,6 @@ import { DialogPopup, DialogTitle, } from "../ui/dialog"; -import { - NumberField, - NumberFieldDecrement, - NumberFieldGroup, - NumberFieldIncrement, - NumberFieldInput, -} from "../ui/number-field"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Switch } from "../ui/switch"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -36,10 +29,10 @@ import { backgroundActivityOverrideSettings, backgroundActivitySharedPolicySettings, durationToSeconds, - normalizeIntervalSeconds, PROVIDER_HEALTH_INTERVAL_STEP_SECONDS, resolveBackgroundActivityProfileOption, } from "./SettingsPanels.logic"; +import { IntervalSecondsField } from "./IntervalSecondsField"; import { PolicyTooltip, SettingResetButton, SettingsRow } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; @@ -185,32 +178,22 @@ function BackgroundActivityAdvancedDialog({

- + onCommit={(seconds, options) => updateSettings( backgroundActivityOverrideSettings( settings.backgroundActivity, resolvedBackgroundActivity, - { - automaticGitFetchInterval: Duration.seconds( - normalizeIntervalSeconds(value), - ), - }, + { automaticGitFetchInterval: Duration.seconds(seconds) }, ), + options, ) } - > - - - - - - + /> seconds
@@ -224,32 +207,22 @@ function BackgroundActivityAdvancedDialog({

- + onCommit={(seconds, options) => updateSettings( backgroundActivityOverrideSettings( settings.backgroundActivity, resolvedBackgroundActivity, - { - providerHealthRefreshInterval: Duration.seconds( - normalizeIntervalSeconds(value), - ), - }, + { providerHealthRefreshInterval: Duration.seconds(seconds) }, ), + options, ) } - > - - - - - - + /> seconds
@@ -262,32 +235,22 @@ function BackgroundActivityAdvancedDialog({

- + onCommit={(seconds, options) => updateSettings( backgroundActivityOverrideSettings( settings.backgroundActivity, resolvedBackgroundActivity, - { - hostPowerMonitorActiveInterval: Duration.seconds( - normalizeIntervalSeconds(value, 5), - ), - }, + { hostPowerMonitorActiveInterval: Duration.seconds(seconds) }, ), + options, ) } - > - - - - - - + /> seconds
@@ -300,32 +263,22 @@ function BackgroundActivityAdvancedDialog({

- + onCommit={(seconds, options) => updateSettings( backgroundActivityOverrideSettings( settings.backgroundActivity, resolvedBackgroundActivity, - { - hostPowerMonitorIdleInterval: Duration.seconds( - normalizeIntervalSeconds(value, 5), - ), - }, + { hostPowerMonitorIdleInterval: Duration.seconds(seconds) }, ), + options, ) } - > - - - - - - + /> seconds
diff --git a/apps/web/src/components/settings/IntervalSecondsField.tsx b/apps/web/src/components/settings/IntervalSecondsField.tsx new file mode 100644 index 0000000000000..3480e97de3cf7 --- /dev/null +++ b/apps/web/src/components/settings/IntervalSecondsField.tsx @@ -0,0 +1,77 @@ +import { useState } from "react"; +import { + NumberField, + NumberFieldDecrement, + NumberFieldGroup, + NumberFieldIncrement, + NumberFieldInput, +} from "../ui/number-field"; +import { normalizeIntervalSeconds } from "./SettingsPanels.logic"; + +export function IntervalSecondsField({ + label, + value, + min, + step, + onCommit, +}: { + label: string; + value: number; + min: number; + step: number; + onCommit: (seconds: number, options: { onFailed: () => void }) => void; +}) { + const [field, setField] = useState({ + saved: value, + draft: value as number | null, + committed: value, + revision: 0, + }); + if (field.saved !== value) { + setField({ ...field, saved: value, draft: value, committed: value }); + } + + return ( + setField((current) => ({ ...current, draft }))} + onValueCommitted={(next) => { + if (next === null) { + setField((current) => ({ ...current, draft: current.saved })); + return; + } + const seconds = normalizeIntervalSeconds(next, min); + if (seconds === field.committed) { + setField((current) => ({ ...current, draft: seconds })); + return; + } + const revision = field.revision + 1; + setField((current) => ({ ...current, draft: seconds, committed: seconds, revision })); + onCommit(seconds, { + onFailed: () => + setField((current) => + current.revision !== revision + ? current + : { + ...current, + committed: current.saved, + draft: current.draft === seconds ? current.saved : current.draft, + }, + ), + }); + }} + > + + + + + + + ); +} diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx index 4ad9b3986bb3a..79322dc5ff6ee 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx @@ -8,6 +8,7 @@ import { type ServerProvider, type UnifiedSettings, } from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; import { AsyncResult, type Atom } from "effect/reactivity"; import type { ReactNode } from "react"; import { describe, expect, it, vi } from "vite-plus/test"; @@ -357,3 +358,58 @@ describe("EnvironmentProviderSettings routing", () => { ); }); }); + +describe("EnvironmentProviderSettings health check interval", () => { + it("commits interval drafts and allows retrying rejected edits", async () => { + await setup(); + await using _screen = await render(panel()); + const input = page.getByRole("textbox", { name: "Provider health check interval in seconds" }); + + await userEvent.type(input, "{ArrowUp}{ArrowDown}"); + expect( + state.updateSettings.mock.calls.map(([patch]) => + Duration.toSeconds(patch.backgroundActivity.overrides.providerHealthRefreshInterval), + ), + ).toEqual([330, 300]); + state.updateSettings.mockClear(); + + // Replacing 300 with 120 passes through 30, 3, an empty field, 1 and 12. + await userEvent.type(input, "{Backspace}{Backspace}{Backspace}120"); + await expect.element(input).toHaveValue("120"); + expect(state.updateSettings).not.toHaveBeenCalled(); + + await userEvent.tab(); + expect( + state.updateSettings.mock.calls.map(([patch]) => + Duration.toSeconds(patch.backgroundActivity.overrides.providerHealthRefreshInterval), + ), + ).toEqual([120]); + + await act(() => state.updateSettings.mock.calls.at(-1)?.[1]?.onFailed?.()); + await expect.element(input).toHaveValue("300"); + await userEvent.type(input, "{Backspace}{Backspace}{Backspace}120"); + await userEvent.tab(); + expect( + state.updateSettings.mock.calls.map(([patch]) => + Duration.toSeconds(patch.backgroundActivity.overrides.providerHealthRefreshInterval), + ), + ).toEqual([120, 120]); + + const rejectPrevious = state.updateSettings.mock.calls.at(-1)?.[1]?.onFailed; + await userEvent.type(input, "{ArrowUp}"); + await act(() => rejectPrevious?.()); + await expect.element(input).toHaveValue("150"); + }); + + it("restores the saved interval when a cleared field loses focus", async () => { + await setup(); + await using _screen = await render(panel()); + const input = page.getByRole("textbox", { name: "Provider health check interval in seconds" }); + + await userEvent.type(input, "{Backspace}{Backspace}{Backspace}"); + await userEvent.tab(); + + await expect.element(input).toHaveValue("300"); + expect(state.updateSettings).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index b5fb648ff97c2..103afe98620fa 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -84,19 +84,13 @@ import { EmptyMedia, EmptyTitle, } from "../ui/empty"; -import { - NumberField, - NumberFieldDecrement, - NumberFieldGroup, - NumberFieldIncrement, - NumberFieldInput, -} from "../ui/number-field"; import { ScrollArea } from "../ui/scroll-area"; import { Toggle, ToggleGroup } from "../ui/toggle-group"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; import { ExpandableText } from "./ExpandableText"; +import { IntervalSecondsField } from "./IntervalSecondsField"; import { ProviderInstanceCard } from "./ProviderInstanceCard"; import { useSecretSources } from "./SecretSourceInput"; import { UsageProviderSettings } from "./UsageProviderSettings"; @@ -111,7 +105,6 @@ import { backgroundActivityOverrideSettings, buildProviderInstanceUpdatePatch, durationToSeconds, - normalizeIntervalSeconds, PROVIDER_HEALTH_INTERVAL_STEP_SECONDS, } from "./SettingsPanels.logic"; import { @@ -1123,32 +1116,22 @@ export function EnvironmentProviderSettings({ !canWriteSettings && "opacity-50 select-none", )} > - + onCommit={(seconds, options) => updateSettings( backgroundActivityOverrideSettings( settings.backgroundActivity, resolvedBackgroundActivity, - { - providerHealthRefreshInterval: Duration.seconds( - normalizeIntervalSeconds(value), - ), - }, + { providerHealthRefreshInterval: Duration.seconds(seconds) }, ), + options, ) } - > - - - - - - + /> seconds } diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 2c24f13760092..43dfe3e045bb7 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -588,8 +588,12 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { reportFailure: false, }); const { environments } = useEnvironments(); - const persistServerSettings = async (request: Parameters[0]) => { + const persistServerSettings = async ( + request: Parameters[0], + onFailed?: () => void, + ) => { const result = await persist(request); + if (result._tag === "Failure") onFailed?.(); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const label = environments.find((environment) => environment.environmentId === request.environmentId) @@ -604,7 +608,10 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { return result; }; const sharedSettingsSyncTargetIds = useSharedSettingsSyncTargetIds(true); - const updateSettings = (patch: UnifiedSettingsPatch) => { + const updateSettings = ( + patch: UnifiedSettingsPatch, + options?: { readonly onFailed?: () => void }, + ) => { const { serverPatch, clientPatch } = splitPatch(patch); const canWriteServerPatch = @@ -613,6 +620,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { readEnvironmentScope(environmentId, scope), ); if (Object.keys(serverPatch).length > 0 && !canWriteServerPatch) { + options?.onFailed?.(); toastManager.add({ type: "warning", title: "Setting not saved", @@ -622,18 +630,20 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { if (Object.keys(serverPatch).length > 0 && canWriteServerPatch) { const { sharedPatch, localPatch } = splitSharedServerPatch(serverPatch); // Dropping the write silently leaves the control looking saved. - const warnUnsaved = (description = PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE) => - toastManager.add({ + const warnUnsaved = (description = PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE) => { + options?.onFailed?.(); + return toastManager.add({ type: "warning", title: "Setting not saved", description, }); + }; if (Object.keys(localPatch).length > 0) { if (environmentId) { - void persistServerSettings({ - environmentId, - input: { patch: localPatch }, - }); + void persistServerSettings( + { environmentId, input: { patch: localPatch } }, + options?.onFailed, + ); } else { warnUnsaved(); } @@ -688,6 +698,12 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { result: await persist(request), })), ).then((outcomes) => { + if ( + deniedLabels.length > 0 || + outcomes.some(({ result }) => result._tag === "Failure") + ) { + options?.onFailed?.(); + } const failures = outcomes.flatMap(({ label, result }) => { if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return []; const error = squashAtomCommandFailure(result);