From a3e57d143bee05356686355412c8f667fc9fdaa4 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Thu, 26 Feb 2026 12:53:51 +0300 Subject: [PATCH 1/4] feat: apply settings to every env --- .../settings/environment-provider.tsx | 2 +- .../onboarding/steps/configure-deployment.tsx | 6 +- .../steps/onboarding-environment-provider.tsx | 80 +++++++++++++++++++ .../deploy/environment-settings.ts | 21 ++++- 4 files changed, 102 insertions(+), 7 deletions(-) create mode 100644 web/apps/dashboard/app/(app)/[workspaceSlug]/projects/onboarding/steps/onboarding-environment-provider.tsx diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/environment-provider.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/environment-provider.tsx index 740faf54cb..f468c0069d 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/environment-provider.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/environment-provider.tsx @@ -10,7 +10,7 @@ type EnvironmentContextType = { settings: EnvironmentSettings; }; -const EnvironmentContext = createContext(null); +export const EnvironmentContext = createContext(null); export const EnvironmentSettingsProvider = ({ children }: PropsWithChildren) => { const { environments } = useProjectData(); diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/onboarding/steps/configure-deployment.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/onboarding/steps/configure-deployment.tsx index 8fe1132ebd..0d6219896a 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/onboarding/steps/configure-deployment.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/onboarding/steps/configure-deployment.tsx @@ -3,7 +3,7 @@ import { Button } from "@unkey/ui"; import { ProjectDataProvider } from "../../[projectId]/(overview)/data-provider"; import { DeploymentSettings } from "../../[projectId]/(overview)/settings/deployment-settings"; -import { EnvironmentSettingsProvider } from "../../[projectId]/(overview)/settings/environment-provider"; +import { OnboardingEnvironmentSettingsProvider } from "./onboarding-environment-provider"; type ConfigureDeploymentStepProps = { projectId: string; @@ -12,7 +12,7 @@ type ConfigureDeploymentStepProps = { export const ConfigureDeploymentStep = ({ projectId }: ConfigureDeploymentStepProps) => { return ( - +
@@ -25,7 +25,7 @@ export const ConfigureDeploymentStep = ({ projectId }: ConfigureDeploymentStepPr
-
+
); }; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/onboarding/steps/onboarding-environment-provider.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/onboarding/steps/onboarding-environment-provider.tsx new file mode 100644 index 0000000000..ee82afd057 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/onboarding/steps/onboarding-environment-provider.tsx @@ -0,0 +1,80 @@ +"use client"; +import { collection } from "@/lib/collections"; +import { + type EnvironmentSettings, + buildSettingsMutations, +} from "@/lib/collections/deploy/environment-settings"; +import { eq, useLiveQuery } from "@tanstack/react-db"; +import { type PropsWithChildren, useEffect, useMemo, useRef } from "react"; +import { useProjectData } from "../../[projectId]/(overview)/data-provider"; +import { EnvironmentContext } from "../../[projectId]/(overview)/settings/environment-provider"; + + +/** + * Drop-in replacement for EnvironmentSettingsProvider used during onboarding. + * + * Provides the same EnvironmentContext (so useEnvironmentSettings() works), + * but syncs production setting changes to every other environment so new + * projects start with consistent config. + */ +export const OnboardingEnvironmentSettingsProvider = ({ children }: PropsWithChildren) => { + const { environments } = useProjectData(); + + const prodEnvId = useMemo( + () => (environments.find((e) => e.slug === "production") ?? environments.at(0))?.id, + [environments], + ); + + const otherEnvIds = useMemo( + () => environments.filter((e) => e.id !== prodEnvId).map((e) => e.id), + [environments, prodEnvId], + ); + + const { data } = useLiveQuery( + (q) => q.from({ s: collection.environmentSettings }).where(({ s }) => eq(s.environmentId, prodEnvId)), + [prodEnvId], + ); + + const settings = data.at(0); + + useSyncSettingsToOtherEnvironments(settings, otherEnvIds); + + if (!settings) return null; + + return {children}; +}; + + +function useSyncSettingsToOtherEnvironments( + settings: EnvironmentSettings | undefined, + otherEnvIds: string[], +) { + const settingsRef = useRef(settings); + settingsRef.current = settings; + const otherEnvIdsRef = useRef(otherEnvIds); + otherEnvIdsRef.current = otherEnvIds; + const prevSettingsRef = useRef(null); + + // biome-ignore lint/correctness/useExhaustiveDependencies: JSON.stringify used for deep comparison; values read from refs to avoid stale closures + useEffect(() => { + const current = settingsRef.current; + const envIds = otherEnvIdsRef.current; + + if (!current || envIds.length === 0) { return }; + + const prev = prevSettingsRef.current; + prevSettingsRef.current = current; + + if (!prev) { + return; // skip initial load + } + + const mutations = envIds.flatMap((envId) => + buildSettingsMutations(envId, prev, current), + ); + + if (mutations.length > 0) { + Promise.all(mutations).catch(console.error); + } + }, [JSON.stringify(settings), JSON.stringify(otherEnvIds)]); +} diff --git a/web/apps/dashboard/lib/collections/deploy/environment-settings.ts b/web/apps/dashboard/lib/collections/deploy/environment-settings.ts index 37866dfc99..938ee11bb8 100644 --- a/web/apps/dashboard/lib/collections/deploy/environment-settings.ts +++ b/web/apps/dashboard/lib/collections/deploy/environment-settings.ts @@ -123,11 +123,17 @@ function flattenSettingsResponse( }; } -async function dispatchSettingsMutations( +/** + * Build an array of tRPC mutation promises for settings that changed between + * `original` and `modified`, targeting `environmentId`. + * + * Pure function — no toasts, no side-effects beyond the network calls. + */ +export function buildSettingsMutations( + environmentId: string, original: EnvironmentSettings, modified: EnvironmentSettings, -): Promise { - const { environmentId } = original; +): Promise[] { const mutations: Promise[] = []; if (modified.dockerfile !== original.dockerfile) { @@ -232,6 +238,15 @@ async function dispatchSettingsMutations( ); } + return mutations; +} + +async function dispatchSettingsMutations( + original: EnvironmentSettings, + modified: EnvironmentSettings, +): Promise { + const mutations = buildSettingsMutations(original.environmentId, original, modified); + if (mutations.length === 0) { return; } From c0b5678653c1b1f508b2295c81e968f60bd49798 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Thu, 26 Feb 2026 13:30:03 +0300 Subject: [PATCH 2/4] refactor: make saveState flat --- .../custom-domains/index.tsx | 10 ++-- .../advanced-settings/env-vars/index.tsx | 12 +++-- .../build-settings/dockerfile-settings.tsx | 11 +++-- .../root-directory-settings.tsx | 11 +++-- .../components/runtime-settings/command.tsx | 11 +++-- .../components/runtime-settings/cpu.tsx | 11 +++-- .../runtime-settings/healthcheck/index.tsx | 11 +++-- .../components/runtime-settings/instances.tsx | 16 +++++-- .../components/runtime-settings/memory.tsx | 11 +++-- .../runtime-settings/port-settings.tsx | 11 +++-- .../components/runtime-settings/regions.tsx | 11 +++-- .../components/runtime-settings/scaling.tsx | 10 ++-- .../components/runtime-settings/storage.tsx | 10 ++-- .../sentinel-settings/keyspaces.tsx | 11 +++-- .../components/shared/form-setting-card.tsx | 47 +++++++++++++------ .../steps/onboarding-environment-provider.tsx | 15 +++--- 16 files changed, 156 insertions(+), 63 deletions(-) diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/custom-domains/index.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/custom-domains/index.tsx index 5a928c61c9..1e55e70e4b 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/custom-domains/index.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/custom-domains/index.tsx @@ -15,7 +15,7 @@ import { import { Controller, useForm } from "react-hook-form"; import { useProjectData } from "../../../../data-provider"; import { useEnvironmentSettings } from "../../../environment-provider"; -import { FormSettingCard } from "../../shared/form-setting-card"; +import { FormSettingCard, resolveSaveState } from "../../shared/form-setting-card"; import { CustomDomainRow } from "./custom-domain-row"; import { type CustomDomainFormValues, customDomainSchema } from "./schema"; @@ -90,6 +90,11 @@ const CustomDomainSettings: React.FC = ({ reset({ environmentId: values.environmentId, domain: "" }); }; + const saveState = resolveSaveState([ + [isSubmitting, { status: "saving" }], + [!isValid, { status: "disabled" }], + ]); + const displayValue = customDomains.length === 0 ? null : (
@@ -107,8 +112,7 @@ const CustomDomainSettings: React.FC = ({ description="Serve your deployment from your own domain name" displayValue={displayValue} onSubmit={handleSubmit(onSubmit)} - canSave={isValid && !isSubmitting} - isSaving={isSubmitting} + saveState={saveState} >
diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/env-vars/index.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/env-vars/index.tsx index e0c710fcea..bc4b6dca2b 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/env-vars/index.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/env-vars/index.tsx @@ -9,7 +9,7 @@ import { useMemo } from "react"; import { useFieldArray, useForm } from "react-hook-form"; import { useProjectData } from "../../../../data-provider"; import { useEnvironmentSettings } from "../../../environment-provider"; -import { FormSettingCard } from "../../shared/form-setting-card"; +import { FormSettingCard, resolveSaveState } from "../../shared/form-setting-card"; import { EnvVarRow } from "./env-var-row"; import { type EnvVarsFormValues, createEmptyRow, envVarsSchema } from "./schema"; import { useDecryptedValues } from "./use-decrypted-values"; @@ -136,6 +136,13 @@ const EnvVarsForm = ({ ]); }; + const saveState = resolveSaveState([ + [isSubmitting, { status: "saving" }], + [isDecrypting, { status: "disabled", reason: "Decrypting values…" }], + [!isValid, { status: "disabled" }], + [!isDirty, { status: "disabled", reason: "No changes to save" }], + ]); + const varCount = defaultValues.envVars.filter((v) => v.key !== "").length; const displayValue = varCount === 0 ? null : ( @@ -152,8 +159,7 @@ const EnvVarsForm = ({ description="Set environment variables available at runtime. Changes apply on next deploy." displayValue={displayValue} onSubmit={handleSubmit(onSubmit)} - canSave={isValid && !isSubmitting && !isDecrypting && isDirty} - isSaving={isSubmitting} + saveState={saveState} ref={ref} className={cn("relative", isDragging && "bg-primary/5")} > diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/build-settings/dockerfile-settings.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/build-settings/dockerfile-settings.tsx index 339c950197..3a945c5ed4 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/build-settings/dockerfile-settings.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/build-settings/dockerfile-settings.tsx @@ -5,7 +5,7 @@ import { FormInput } from "@unkey/ui"; import { useForm, useWatch } from "react-hook-form"; import { z } from "zod"; import { useEnvironmentSettings } from "../../environment-provider"; -import { FormSettingCard } from "../shared/form-setting-card"; +import { FormSettingCard, resolveSaveState } from "../shared/form-setting-card"; const dockerfileSchema = z.object({ dockerfile: z.string().min(1, "Dockerfile path is required"), @@ -28,6 +28,12 @@ export const Dockerfile = () => { const currentDockerfile = useWatch({ control, name: "dockerfile" }); + const saveState = resolveSaveState([ + [isSubmitting, { status: "saving" }], + [!isValid, { status: "disabled" }], + [currentDockerfile === defaultValue, { status: "disabled", reason: "No changes to save" }], + ]); + const onSubmit = async (values: z.infer) => { collection.environmentSettings.update(environmentId, (draft) => { draft.dockerfile = values.dockerfile; @@ -41,8 +47,7 @@ export const Dockerfile = () => { description="Dockerfile location used for docker build. (e.g., services/api/Dockerfile)" displayValue={defaultValue} onSubmit={handleSubmit(onSubmit)} - canSave={isValid && !isSubmitting && currentDockerfile !== defaultValue} - isSaving={isSubmitting} + saveState={saveState} > { const currentDockerContext = useWatch({ control, name: "dockerContext" }); + const saveState = resolveSaveState([ + [isSubmitting, { status: "saving" }], + [!isValid, { status: "disabled" }], + [currentDockerContext === defaultValue, { status: "disabled", reason: "No changes to save" }], + ]); + const onSubmit = async (values: z.infer) => { collection.environmentSettings.update(environmentId, (draft) => { draft.dockerContext = values.dockerContext; @@ -41,8 +47,7 @@ export const RootDirectory = () => { description="Build context directory. All COPY/ADD commands are relative to this path. (e.g., services/api)" displayValue={defaultValue || "."} onSubmit={handleSubmit(onSubmit)} - canSave={isValid && !isSubmitting && currentDockerContext !== defaultValue} - isSaving={isSubmitting} + saveState={saveState} > { const currentCommand = useWatch({ control, name: "command" }); const hasChanges = currentCommand !== defaultCommand; + const saveState = resolveSaveState([ + [isSubmitting, { status: "saving" }], + [!isValid, { status: "disabled" }], + [!hasChanges, { status: "disabled", reason: "No changes to save" }], + ]); + const onSubmit = async (values: CommandFormValues) => { const trimmed = values.command.trim(); const command = trimmed === "" ? [] : trimmed.split(/\s+/).filter(Boolean); @@ -63,8 +69,7 @@ export const Command = () => { ) : null } onSubmit={handleSubmit(onSubmit)} - canSave={isValid && !isSubmitting && hasChanges} - isSaving={isSubmitting} + saveState={saveState} > { const hasChanges = currentCpu !== defaultCpu; const currentIndex = valueToIndex(CPU_OPTIONS, currentCpu); + const saveState = resolveSaveState([ + [isSubmitting, { status: "saving" }], + [!isValid, { status: "disabled" }], + [!hasChanges, { status: "disabled", reason: "No changes to save" }], + ]); + return ( } @@ -76,8 +82,7 @@ export const Cpu = () => { ); })()} onSubmit={handleSubmit(onSubmit)} - canSave={isValid && !isSubmitting && hasChanges} - isSaving={isSubmitting} + saveState={saveState} >
CPU per instance diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/healthcheck/index.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/healthcheck/index.tsx index 44286c67a9..040a420eb8 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/healthcheck/index.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/healthcheck/index.tsx @@ -14,7 +14,7 @@ import { import { useEffect } from "react"; import { Controller, useForm, useWatch } from "react-hook-form"; import { useEnvironmentSettings } from "../../../environment-provider"; -import { FormSettingCard } from "../../shared/form-setting-card"; +import { FormSettingCard, resolveSaveState } from "../../shared/form-setting-card"; import { MethodBadge } from "./method-badge"; import { HTTP_METHODS, type HealthcheckFormValues, healthcheckSchema } from "./schema"; import { intervalToSeconds, secondsToInterval } from "./utils"; @@ -72,6 +72,12 @@ export const Healthcheck = () => { currentPath !== defaultValues.path || currentInterval !== defaultValues.interval; + const saveState = resolveSaveState([ + [isSubmitting, { status: "saving" }], + [!isValid, { status: "disabled" }], + [!hasChanges, { status: "disabled", reason: "No changes to save" }], + ]); + return ( } @@ -87,8 +93,7 @@ export const Healthcheck = () => { ) : null } onSubmit={handleSubmit(onSubmit)} - canSave={isValid && !isSubmitting && hasChanges} - isSaving={isSubmitting} + saveState={saveState} >
{/* TODO: multi-check when API supports diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/instances.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/instances.tsx index 73c3493ad9..09979aeca1 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/instances.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/instances.tsx @@ -10,7 +10,7 @@ import { useForm, useWatch } from "react-hook-form"; import { z } from "zod"; import { RegionFlag } from "../../../../components/region-flag"; import { useEnvironmentSettings } from "../../environment-provider"; -import { FormSettingCard } from "../shared/form-setting-card"; +import { FormSettingCard, resolveSaveState } from "../shared/form-setting-card"; import { SettingDescription } from "../shared/setting-description"; const instancesSchema = z.object({ @@ -55,6 +55,17 @@ export const Instances = () => { }; const hasChanges = currentInstances !== defaultInstances; + const hasRegions = Object.keys(regionConfig).length > 0; + + const saveState = resolveSaveState([ + [isSubmitting, { status: "saving" }], + [ + !hasRegions, + { status: "disabled", reason: "Select at least one region before setting instance count" }, + ], + [!isValid, { status: "disabled" }], + [!hasChanges, { status: "disabled", reason: "No changes to save" }], + ]); return ( {
} onSubmit={handleSubmit(onSubmit)} - canSave={isValid && !isSubmitting && hasChanges} - isSaving={isSubmitting} + saveState={saveState} >
Instances per region diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/memory.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/memory.tsx index f00ba9b6ad..487865383a 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/memory.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/memory.tsx @@ -9,7 +9,7 @@ import { useEffect } from "react"; import { useForm, useWatch } from "react-hook-form"; import { z } from "zod"; import { useEnvironmentSettings } from "../../environment-provider"; -import { FormSettingCard } from "../shared/form-setting-card"; +import { FormSettingCard, resolveSaveState } from "../shared/form-setting-card"; import { SettingDescription } from "../shared/setting-description"; import { indexToValue, valueToIndex } from "../shared/slider-utils"; @@ -61,6 +61,12 @@ export const Memory = () => { const hasChanges = currentMemory !== defaultMemory; const currentIndex = valueToIndex(MEMORY_OPTIONS, currentMemory); + const saveState = resolveSaveState([ + [isSubmitting, { status: "saving" }], + [!isValid, { status: "disabled" }], + [!hasChanges, { status: "disabled", reason: "No changes to save" }], + ]); + return ( } @@ -76,8 +82,7 @@ export const Memory = () => { ); })()} onSubmit={handleSubmit(onSubmit)} - canSave={isValid && !isSubmitting && hasChanges} - isSaving={isSubmitting} + saveState={saveState} >
Memory per instance diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/port-settings.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/port-settings.tsx index b89022d984..d64afab86f 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/port-settings.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/port-settings.tsx @@ -5,7 +5,7 @@ import { FormInput } from "@unkey/ui"; import { useForm, useWatch } from "react-hook-form"; import { z } from "zod"; import { useEnvironmentSettings } from "../../environment-provider"; -import { FormSettingCard } from "../shared/form-setting-card"; +import { FormSettingCard, resolveSaveState } from "../shared/form-setting-card"; const portSchema = z.object({ port: z.number().int().min(2000).max(54000), @@ -28,6 +28,12 @@ export const Port = () => { const currentPort = useWatch({ control, name: "port" }); + const saveState = resolveSaveState([ + [isSubmitting, { status: "saving" }], + [!isValid, { status: "disabled" }], + [currentPort === defaultValue, { status: "disabled", reason: "No changes to save" }], + ]); + const onSubmit = async (values: z.infer) => { collection.environmentSettings.update(environmentId, (draft) => { draft.port = values.port; @@ -41,8 +47,7 @@ export const Port = () => { description="Port your application listens on" displayValue={String(defaultValue)} onSubmit={handleSubmit(onSubmit)} - canSave={isValid && !isSubmitting && currentPort !== defaultValue} - isSaving={isSubmitting} + saveState={saveState} > = ({ currentRegions.length !== defaultRegions.length || currentRegions.some((r) => !defaultRegions.includes(r)); + const saveState = resolveSaveState([ + [isSubmitting, { status: "saving" }], + [!isValid, { status: "disabled" }], + [!hasChanges, { status: "disabled", reason: "No changes to save" }], + ]); + const displayValue = defaultRegions.length === 0 ? null : defaultRegions.length <= 2 ? ( @@ -143,8 +149,7 @@ const RegionsForm: React.FC = ({ description="Geographic regions where your project will run" displayValue={displayValue} onSubmit={handleSubmit(onSubmit)} - canSave={isValid && !isSubmitting && hasChanges} - isSaving={isSubmitting} + saveState={saveState} > { currentMax !== DEFAULT_VALUES.maxInstances || currentCpuThreshold !== DEFAULT_VALUES.cpuThreshold; + const saveState = resolveSaveState([ + [!isValid, { status: "disabled" }], + [!hasChanges, { status: "disabled", reason: "No changes to save" }], + ]); + return ( } @@ -64,8 +69,7 @@ export const Scaling = () => {
} onSubmit={(e) => e.preventDefault()} - canSave={isValid && hasChanges} - isSaving={false} + saveState={saveState} >
diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/storage.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/storage.tsx index 8dee3c6a69..26da87aeaf 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/storage.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/storage.tsx @@ -6,7 +6,7 @@ import { Harddrive } from "@unkey/icons"; import { Slider } from "@unkey/ui"; import { useForm, useWatch } from "react-hook-form"; import { z } from "zod"; -import { FormSettingCard } from "../shared/form-setting-card"; +import { FormSettingCard, resolveSaveState } from "../shared/form-setting-card"; import { SettingDescription } from "../shared/setting-description"; import { indexToValue, valueToIndex } from "../shared/slider-utils"; @@ -52,6 +52,11 @@ const StorageForm: React.FC = ({ defaultStorage }) => { const hasChanges = currentStorage !== defaultStorage; const currentIndex = valueToIndex(STORAGE_OPTIONS, currentStorage); + const saveState = resolveSaveState([ + [!isValid, { status: "disabled" }], + [!hasChanges, { status: "disabled", reason: "No changes to save" }], + ]); + return ( } @@ -67,8 +72,7 @@ const StorageForm: React.FC = ({ defaultStorage }) => { ); })()} onSubmit={(e) => e.preventDefault()} - canSave={isValid && hasChanges} - isSaving={false} + saveState={saveState} >
Storage per instance diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/sentinel-settings/keyspaces.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/sentinel-settings/keyspaces.tsx index 2e24d34828..512654fed0 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/sentinel-settings/keyspaces.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/sentinel-settings/keyspaces.tsx @@ -10,7 +10,7 @@ import { useEffect } from "react"; import { useForm, useWatch } from "react-hook-form"; import { z } from "zod"; import { useEnvironmentSettings } from "../../environment-provider"; -import { FormSettingCard } from "../shared/form-setting-card"; +import { FormSettingCard, resolveSaveState } from "../shared/form-setting-card"; const keyspacesSchema = z.object({ keyspaces: z.array(z.string()).min(1, "Select at least one region"), @@ -114,6 +114,12 @@ const KeyspacesForm: React.FC = ({ currentKeyspaceIds.length !== defaultKeyspaceIds.length || currentKeyspaceIds.some((r) => !defaultKeyspaceIds.includes(r)); + const saveState = resolveSaveState([ + [isSubmitting, { status: "saving" }], + [!isValid, { status: "disabled" }], + [!hasChanges, { status: "disabled", reason: "No changes to save" }], + ]); + const displayValue = defaultKeyspaceIds.length === 0 ? ( "No keyspaces selected" @@ -153,8 +159,7 @@ const KeyspacesForm: React.FC = ({ description="Enforce key authentication in your sentinel." displayValue={displayValue} onSubmit={handleSubmit(onSubmit)} - canSave={isValid && !isSubmitting && hasChanges} - isSaving={isSubmitting} + saveState={saveState} > ): SaveState { + for (const [condition, state] of checks) { + if (condition) return state; + } + return { status: "ready" }; +} + type EditableSettingCardProps = { icon: React.ReactNode; title: string; @@ -14,8 +26,7 @@ type EditableSettingCardProps = { onSubmit: React.FormEventHandler; children: React.ReactNode; - canSave: boolean; - isSaving: boolean; + saveState: SaveState; ref?: React.Ref; className?: string; @@ -29,8 +40,7 @@ export const FormSettingCard = ({ displayValue, onSubmit, children, - canSave, - isSaving, + saveState, ref, className, }: EditableSettingCardProps) => { @@ -56,16 +66,25 @@ export const FormSettingCard = ({ {children}
- + +
} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/onboarding/steps/onboarding-environment-provider.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/onboarding/steps/onboarding-environment-provider.tsx index ee82afd057..f61424fb36 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/onboarding/steps/onboarding-environment-provider.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/onboarding/steps/onboarding-environment-provider.tsx @@ -9,7 +9,6 @@ import { type PropsWithChildren, useEffect, useMemo, useRef } from "react"; import { useProjectData } from "../../[projectId]/(overview)/data-provider"; import { EnvironmentContext } from "../../[projectId]/(overview)/settings/environment-provider"; - /** * Drop-in replacement for EnvironmentSettingsProvider used during onboarding. * @@ -31,7 +30,10 @@ export const OnboardingEnvironmentSettingsProvider = ({ children }: PropsWithChi ); const { data } = useLiveQuery( - (q) => q.from({ s: collection.environmentSettings }).where(({ s }) => eq(s.environmentId, prodEnvId)), + (q) => + q + .from({ s: collection.environmentSettings }) + .where(({ s }) => eq(s.environmentId, prodEnvId)), [prodEnvId], ); @@ -44,7 +46,6 @@ export const OnboardingEnvironmentSettingsProvider = ({ children }: PropsWithChi return {children}; }; - function useSyncSettingsToOtherEnvironments( settings: EnvironmentSettings | undefined, otherEnvIds: string[], @@ -60,7 +61,9 @@ function useSyncSettingsToOtherEnvironments( const current = settingsRef.current; const envIds = otherEnvIdsRef.current; - if (!current || envIds.length === 0) { return }; + if (!current || envIds.length === 0) { + return; + } const prev = prevSettingsRef.current; prevSettingsRef.current = current; @@ -69,9 +72,7 @@ function useSyncSettingsToOtherEnvironments( return; // skip initial load } - const mutations = envIds.flatMap((envId) => - buildSettingsMutations(envId, prev, current), - ); + const mutations = envIds.flatMap((envId) => buildSettingsMutations(envId, prev, current)); if (mutations.length > 0) { Promise.all(mutations).catch(console.error); From 81206d790f8e9da11e89acb5e559610c2d1fd444 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Thu, 26 Feb 2026 16:06:09 +0300 Subject: [PATCH 3/4] chore: tidy up --- .../components/shared/form-setting-card.tsx | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/shared/form-setting-card.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/shared/form-setting-card.tsx index 30edc338a0..d27d2a0a94 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/shared/form-setting-card.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/shared/form-setting-card.tsx @@ -3,17 +3,7 @@ import { Button, InfoTooltip, SettingCard, type SettingCardBorder } from "@unkey import type React from "react"; import { SelectedConfig } from "./selected-config"; -export type SaveState = - | { status: "ready" } - | { status: "disabled"; reason?: string } - | { status: "saving" }; -export function resolveSaveState(checks: ReadonlyArray<[boolean, SaveState]>): SaveState { - for (const [condition, state] of checks) { - if (condition) return state; - } - return { status: "ready" }; -} type EditableSettingCardProps = { icon: React.ReactNode; @@ -95,3 +85,16 @@ export const FormSettingCard = ({ ); }; + + +export type SaveState = + | { status: "ready" } + | { status: "disabled"; reason?: string } + | { status: "saving" }; + +export function resolveSaveState(checks: ReadonlyArray<[boolean, SaveState]>): SaveState { + for (const [condition, state] of checks) { + if (condition) { return state }; + } + return { status: "ready" }; +} From 58ed410eecf5cce1312e1bc6bac7b52eb3e16ae7 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Mon, 2 Mar 2026 21:09:07 +0300 Subject: [PATCH 4/4] fix: linter --- .../settings/components/shared/form-setting-card.tsx | 7 +++---- .../onboarding/steps/onboarding-environment-provider.tsx | 4 +++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/shared/form-setting-card.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/shared/form-setting-card.tsx index d27d2a0a94..bcb9a1149e 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/shared/form-setting-card.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/shared/form-setting-card.tsx @@ -3,8 +3,6 @@ import { Button, InfoTooltip, SettingCard, type SettingCardBorder } from "@unkey import type React from "react"; import { SelectedConfig } from "./selected-config"; - - type EditableSettingCardProps = { icon: React.ReactNode; title: string; @@ -86,7 +84,6 @@ export const FormSettingCard = ({ ); }; - export type SaveState = | { status: "ready" } | { status: "disabled"; reason?: string } @@ -94,7 +91,9 @@ export type SaveState = export function resolveSaveState(checks: ReadonlyArray<[boolean, SaveState]>): SaveState { for (const [condition, state] of checks) { - if (condition) { return state }; + if (condition) { + return state; + } } return { status: "ready" }; } diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/onboarding/steps/onboarding-environment-provider.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/onboarding/steps/onboarding-environment-provider.tsx index f61424fb36..3383860619 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/onboarding/steps/onboarding-environment-provider.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/onboarding/steps/onboarding-environment-provider.tsx @@ -41,7 +41,9 @@ export const OnboardingEnvironmentSettingsProvider = ({ children }: PropsWithChi useSyncSettingsToOtherEnvironments(settings, otherEnvIds); - if (!settings) return null; + if (!settings) { + return null; + } return {children}; };