diff --git a/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.tsx b/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.tsx
index f84cf589856..5cf76ca42ab 100644
--- a/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.tsx
+++ b/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.tsx
@@ -14,7 +14,6 @@ import type { ResourceScope } from '@/lib/core/resource-scope'
import { organizationRoutes } from '@/lib/navigation/paths'
import { describeSearchSource } from '@/lib/sim-search/source-identity'
import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
-import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail/components/unsaved-changes-modal'
import { ConnectorActionFeedback } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-actions'
import { getConnectorSyncState } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-sync-state'
import { useConnectorActions } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/use-connector-actions'
@@ -341,7 +340,11 @@ function SourceSettingsForm({
isSearchIndex: true,
onSaved,
})
- const guard = useSettingsUnsavedGuard({ isDirty: form.dirty, navigationBlocked: form.saving })
+ const guard = useSettingsUnsavedGuard({
+ isDirty: form.dirty,
+ navigationBlocked: form.saving,
+ onDiscard,
+ })
return (
-
)
}
diff --git a/apps/sim/app/o/[organizationId]/settings/layout.tsx b/apps/sim/app/o/[organizationId]/settings/layout.tsx
index 84e588433a7..d4714304ae9 100644
--- a/apps/sim/app/o/[organizationId]/settings/layout.tsx
+++ b/apps/sim/app/o/[organizationId]/settings/layout.tsx
@@ -2,7 +2,6 @@
import type { ReactNode } from 'react'
import { SettingsPendingSection } from '@/components/settings/settings-pending-section'
-import { useSettingsBeforeUnload } from '@/components/settings/use-settings-before-unload'
import { resolveOrganizationSurfaceHeaderMeta } from '@/app/o/[organizationId]/settings/navigation'
interface OrganizationSettingsLayoutProps {
@@ -10,7 +9,6 @@ interface OrganizationSettingsLayoutProps {
}
export default function OrganizationSettingsLayout({ children }: OrganizationSettingsLayoutProps) {
- useSettingsBeforeUnload()
return (
diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-credential-detail-form.ts b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-credential-detail-form.ts
index f2d3fe05e63..89a5ec32257 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-credential-detail-form.ts
+++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-credential-detail-form.ts
@@ -1,11 +1,14 @@
'use client'
-import { useCallback, useState } from 'react'
+import { type MouseEvent, useCallback, useState } from 'react'
import { toast } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
-import { useUnsavedChangesGuard } from '@/app/workspace/[workspaceId]/components/credential-detail/hooks/use-unsaved-changes-guard'
+import { filterUndefined } from '@sim/utils/object'
+import { useRouter } from 'next/navigation'
+import { useSettingsUnsavedGuard } from '@/components/settings/use-settings-unsaved-guard'
import { useUpdateWorkspaceCredential, type WorkspaceCredential } from '@/hooks/queries/credentials'
+import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
const logger = createLogger('CredentialDetailForm')
@@ -25,6 +28,12 @@ export interface CredentialDetailFormSection {
discard: () => void
}
+interface CredentialMetadata {
+ displayName: string
+ description: string
+ unredacted: boolean
+}
+
interface UseCredentialDetailFormParams {
workspaceId?: string
credential: WorkspaceCredential | null
@@ -33,9 +42,7 @@ interface UseCredentialDetailFormParams {
backHref: string
/**
* An additional editable section on the page, folded into one dirty state, one
- * save, and one unsaved-changes guard. Two independent guards on a page cannot
- * coexist: each seeds its own same-URL history entry while dirty, so Back would
- * pop only one of them and leave the other stranded.
+ * save, and one unsaved-changes guard.
*/
section?: CredentialDetailFormSection
}
@@ -55,43 +62,107 @@ export function useCredentialDetailForm({
section,
}: UseCredentialDetailFormParams) {
const updateCredential = useUpdateWorkspaceCredential(workspaceId)
+ const isSaving = updateCredential.isPending || (section?.isSaving ?? false)
- const [displayNameDraft, setDisplayNameDraft] = useState('')
- const [descriptionDraft, setDescriptionDraft] = useState('')
- const [unredactedDraft, setUnredactedDraft] = useState(false)
- const [seededCredentialId, setSeededCredentialId] = useState(null)
-
- // Seed drafts when the credential first resolves (or the route id changes); a
- // background refetch of the same credential must not clobber an in-progress
- // edit — Discard is the one way to reset.
- /** Applies a credential to every draft — the one definition of "reset to server state". */
- const seedDrafts = useCallback((source: WorkspaceCredential) => {
- setDisplayNameDraft(source.displayName)
- setDescriptionDraft(source.description ?? '')
- setUnredactedDraft(source.unredacted)
- }, [])
-
- if (credential && credential.id !== seededCredentialId) {
- setSeededCredentialId(credential.id)
- seedDrafts(credential)
+ const savedValues = {
+ displayName: credential?.displayName ?? '',
+ description: credential?.description ?? '',
+ unredacted: credential?.unredacted ?? false,
}
+ const [draft, setDraft] = useState<{
+ credentialId: string
+ values: Partial
+ } | null>(null)
- const isDisplayNameDirty = credential ? displayNameDraft !== credential.displayName : false
- const isDescriptionDirty = credential
- ? descriptionDraft !== (credential.description || '')
- : false
- const isUnredactedDirty = credential ? unredactedDraft !== credential.unredacted : false
+ if (draft && credential && draft.credentialId !== credential.id) setDraft(null)
+
+ const values = { ...savedValues, ...draft?.values }
+ const displayNameDraft = values.displayName
+ const descriptionDraft = values.description
+ const unredactedDraft = values.unredacted
+ const isDisplayNameDirty = values.displayName.trim() !== savedValues.displayName.trim()
+ const isDescriptionDirty = values.description.trim() !== savedValues.description.trim()
+ const isUnredactedDirty = values.unredacted !== savedValues.unredacted
const isMetadataDirty = isDisplayNameDirty || isDescriptionDirty || isUnredactedDirty
+
+ if (
+ draft &&
+ !isSaving &&
+ values.displayName === savedValues.displayName &&
+ values.description === savedValues.description &&
+ values.unredacted === savedValues.unredacted
+ )
+ setDraft(null)
+
+ const updateDraft = useCallback(
+ (change: Partial) => {
+ if (!credential) return
+ setDraft((current) => {
+ const saved = {
+ displayName: credential.displayName,
+ description: credential.description ?? '',
+ unredacted: credential.unredacted,
+ }
+ const values = { ...current?.values, ...change }
+ if (!isSaving) {
+ if (values.displayName === saved.displayName) values.displayName = undefined
+ if (values.description === saved.description) values.description = undefined
+ if (values.unredacted === saved.unredacted) values.unredacted = undefined
+ }
+ const overrides = filterUndefined(values)
+ return Object.keys(overrides).length
+ ? { credentialId: credential.id, values: overrides }
+ : null
+ })
+ },
+ [credential, isSaving]
+ )
+ const setDisplayNameDraft = useCallback(
+ (displayName: string) => updateDraft({ displayName }),
+ [updateDraft]
+ )
+ const setDescriptionDraft = useCallback(
+ (description: string) => updateDraft({ description }),
+ [updateDraft]
+ )
+ const setUnredactedDraft = useCallback(
+ (unredacted: boolean) => updateDraft({ unredacted }),
+ [updateDraft]
+ )
+
const isSectionDirty = section?.isDirty ?? false
const isDirty = isMetadataDirty || isSectionDirty
- const isSaving = updateCredential.isPending || (section?.isSaving ?? false)
+ const router = useRouter()
+ const discard = useCallback(() => {
+ if (isSaving) return
+ setDraft(null)
+ section?.discard()
+ }, [isSaving, section])
- const guard = useUnsavedChangesGuard({ isDirty, backHref })
+ const guard = useSettingsUnsavedGuard({
+ isDirty,
+ navigationBlocked: isSaving,
+ onDiscard: discard,
+ })
+ const handleBackClick = useCallback(
+ (event: MouseEvent) => {
+ const { isDirty, navigationBlocked } = useSettingsDirtyStore.getState()
+ if (!isDirty && !navigationBlocked) return
+ event.preventDefault()
+ guard.guardBack(() => router.push(backHref))
+ },
+ [guard.guardBack, router, backHref]
+ )
const save = useCallback(async () => {
if (!credential || isSaving) return
- if (isSectionDirty && !(await section?.save())) return
- if (!isAdmin || !isMetadataDirty) return
+ const submitted = draft
+ if (isSectionDirty && !(await section?.save())) {
+ return
+ }
+ if (!isAdmin || !isMetadataDirty) {
+ return
+ }
try {
await updateCredential.mutateAsync({
@@ -100,8 +171,17 @@ export function useCredentialDetailForm({
...(isDescriptionDirty ? { description: descriptionDraft.trim() || null } : {}),
...(isUnredactedDirty ? { unredacted: unredactedDraft } : {}),
})
- if (isDisplayNameDirty) setDisplayNameDraft((value) => value.trim())
- if (isDescriptionDirty) setDescriptionDraft((value) => value.trim())
+ setDraft((current) => {
+ if (current === submitted) return null
+ if (!current || !submitted || current.credentialId !== submitted.credentialId)
+ return current
+ const values = { ...current.values }
+ if (values.displayName === submitted.values.displayName) values.displayName = undefined
+ if (values.description === submitted.values.description) values.description = undefined
+ if (values.unredacted === submitted.values.unredacted) values.unredacted = undefined
+ const overrides = filterUndefined(values)
+ return Object.keys(overrides).length ? { ...current, values: overrides } : null
+ })
} catch (error) {
toast.error("Couldn't save changes", {
description: getErrorMessage(error, 'Please try again in a moment.'),
@@ -110,6 +190,7 @@ export function useCredentialDetailForm({
}
}, [
credential,
+ draft,
isAdmin,
isMetadataDirty,
isSectionDirty,
@@ -124,11 +205,6 @@ export function useCredentialDetailForm({
updateCredential.mutateAsync,
])
- const discard = useCallback(() => {
- if (credential) seedDrafts(credential)
- section?.discard()
- }, [credential, section, seedDrafts])
-
return {
displayNameDraft,
setDisplayNameDraft,
@@ -140,9 +216,6 @@ export function useCredentialDetailForm({
save,
discard,
isSaving,
- handleBackClick: guard.handleBackClick,
- showUnsavedAlert: guard.showUnsavedAlert,
- setShowUnsavedAlert: guard.setShowUnsavedAlert,
- confirmDiscard: guard.confirmDiscard,
+ handleBackClick,
}
}
diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx
index 52d433fa254..08f4fbfc668 100644
--- a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx
@@ -26,7 +26,6 @@ import {
CredentialDetailLayout,
CredentialMembersSection,
DetailSection,
- UnsavedChangesModal,
useCredentialDetailForm,
} from '@/app/workspace/[workspaceId]/components/credential-detail'
import {
@@ -357,12 +356,6 @@ export function ConnectedCredentialDetail({
/>
)}
-
-
{credential.type === 'personal_token' && (
{
+ setWorkflowId('')
+ setTargetWorkspaceId('')
+ }
+ const importGuard = useSettingsUnsavedGuard({
+ isDirty: Boolean(workflowId.trim() || targetWorkspaceId.trim()),
+ navigationBlocked: importWorkflow.isPending,
+ onDiscard: discardImport,
+ })
const [{ q: searchQuery, offset: usersOffset }, setAdminParams] = useQueryStates(
adminParsers,
@@ -148,34 +158,33 @@ export function Admin() {
return
}
- setImpersonatingUserId(userId)
- impersonateUser.reset()
- impersonateUser.mutate(
- { userId },
- {
- onError: () => {
- setImpersonatingUserId(null)
- },
- onSuccess: async () => {
- recordImpersonation(email)
- await clearUserData({ preserveRecentImpersonations: true })
- window.location.assign(APP_ENTRY_PATH)
- },
- }
- )
+ importGuard.guardBack(() => {
+ setImpersonatingUserId(userId)
+ impersonateUser.reset()
+ impersonateUser.mutate(
+ { userId },
+ {
+ onError: () => {
+ setImpersonatingUserId(null)
+ },
+ onSuccess: async () => {
+ recordImpersonation(email)
+ await clearUserData({ preserveRecentImpersonations: true })
+ window.location.assign(APP_ENTRY_PATH)
+ },
+ }
+ )
+ })
}
const handleImport = () => {
const sourceId = workflowId.trim()
const targetId = targetWorkspaceId.trim()
- if (!sourceId || !targetId) return
+ if (!sourceId || !targetId || importWorkflow.isPending || impersonateUser.isPending) return
importWorkflow.mutate(
{ workflowId: sourceId, targetWorkspaceId: targetId },
{
- onSuccess: () => {
- setWorkflowId('')
- setTargetWorkspaceId('')
- },
+ onSuccess: discardImport,
}
)
}
@@ -235,7 +244,7 @@ export function Admin() {
handleImpersonate(u.id, u.email)}
- disabled={pendingUserIds.has(u.id)}
+ disabled={importWorkflow.isPending || pendingUserIds.has(u.id)}
>
{impersonatingUserId === u.id ? 'Switching...' : 'Impersonate'}
@@ -350,7 +359,7 @@ export function Admin() {
importWorkflow.reset()
}}
placeholder='Source workflow ID'
- disabled={importWorkflow.isPending}
+ disabled={importWorkflow.isPending || impersonateUser.isPending}
/>
{importWorkflow.isPending ? 'Importing...' : 'Import'}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/components/usage-limit-field/usage-limit-field.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/components/usage-limit-field/usage-limit-field.tsx
index 8d7d9dc5072..1f2cff43577 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/components/usage-limit-field/usage-limit-field.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/components/usage-limit-field/usage-limit-field.tsx
@@ -3,6 +3,7 @@
import { useEffect, useRef, useState } from 'react'
import { ChipInput, Info, toast } from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
+import { useSettingsUnsavedGuard } from '@/components/settings/use-settings-unsaved-guard'
import { ON_DEMAND_UNLIMITED } from '@/lib/billing/constants'
import { creditsToDollars, dollarsToCredits } from '@/lib/billing/credits/conversion'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
@@ -49,48 +50,60 @@ export function UsageLimitField({
context,
organizationId,
}: UsageLimitFieldProps) {
- const { mutate: saveUserLimit } = useUpdateUsageLimit()
- const { mutate: saveOrgLimit } = useUpdateOrganizationUsageLimit()
+ const userUpdate = useUpdateUsageLimit()
+ const orgUpdate = useUpdateOrganizationUsageLimit()
+ const saveUserLimit = userUpdate.mutate
+ const saveOrgLimit = orgUpdate.mutate
- const [draft, setDraft] = useState('')
+ const [draft, setDraft] = useState(null)
const debouncedDraft = useDebounce(draft, AUTOSAVE_DELAY_MS)
- const syncedRef = useRef(null)
- /**
- * Read the latest limit inside the auto-save effect WITHOUT making it a
- * dependency. If `currentLimit` were a dep, an external change (e.g. the
- * on-demand toggle optimistically bumping the limit) would re-run the effect
- * with a stale `debouncedDraft` and save the old value, clobbering the toggle.
- */
+ const value =
+ draft ?? (currentLimit >= ON_DEMAND_UNLIMITED ? '' : String(dollarsToCredits(currentLimit)))
const currentLimitRef = useRef(currentLimit)
- currentLimitRef.current = currentLimit
-
+ const draftRef = useRef(draft)
+ const pendingRef = useRef(false)
+ const pending = userUpdate.isPending || orgUpdate.isPending
useEffect(() => {
- if (currentLimit == null || syncedRef.current === currentLimit) return
- // Display in credits; the prop is dollars. Integer credits round-trip exactly
- // through creditsToDollars/dollarsToCredits, so the value never drifts. The
- // on-demand "uncapped" sentinel renders as a blank field (No Usage Limit
- // placeholder) rather than a meaningless giant credit number.
- const lastSyncedDraft =
- syncedRef.current == null || syncedRef.current >= ON_DEMAND_UNLIMITED
- ? ''
- : String(dollarsToCredits(syncedRef.current))
- const isClean = draft === '' || draft === lastSyncedDraft
- syncedRef.current = currentLimit
- if (isClean) {
- setDraft(currentLimit >= ON_DEMAND_UNLIMITED ? '' : String(dollarsToCredits(currentLimit)))
- }
- }, [currentLimit, draft])
+ currentLimitRef.current = currentLimit
+ draftRef.current = draft
+ pendingRef.current = pending
+ }, [currentLimit, draft, pending])
+
+ useSettingsUnsavedGuard({
+ isDirty:
+ canEdit &&
+ draft !== null &&
+ (draft.trim() === ''
+ ? currentLimit < ON_DEMAND_UNLIMITED
+ : Number.parseFloat(draft) !== dollarsToCredits(currentLimit)),
+ navigationBlocked: pending,
+ onDiscard: () => setDraft(null),
+ })
useEffect(() => {
- if (!canEdit) return
+ if (
+ !canEdit ||
+ debouncedDraft === null ||
+ draftRef.current !== debouncedDraft ||
+ pendingRef.current
+ )
+ return
const currentLimit = currentLimitRef.current
- if (currentLimit == null || debouncedDraft.trim() === '') return
+ if (currentLimit == null) return
+ if (debouncedDraft.trim() === '') {
+ if (currentLimit >= ON_DEMAND_UNLIMITED)
+ setDraft((current) => (current === debouncedDraft ? null : current))
+ return
+ }
const parsedCredits = Number.parseFloat(debouncedDraft)
if (Number.isNaN(parsedCredits)) {
toast.error('Usage limit must be a number')
return
}
- if (parsedCredits === dollarsToCredits(currentLimit)) return
+ if (parsedCredits === dollarsToCredits(currentLimit)) {
+ setDraft((current) => (current === debouncedDraft ? null : current))
+ return
+ }
const minimumCredits = dollarsToCredits(minimumLimit)
if (parsedCredits < minimumCredits) {
toast.error(`Usage limit must be at least ${minimumCredits.toLocaleString()} credits`)
@@ -105,6 +118,8 @@ export function UsageLimitField({
})
}
+ const onSuccess = () => setDraft((current) => (current === debouncedDraft ? null : current))
+
if (context === 'organization') {
if (!organizationId) {
toast.error("Couldn't update usage limit", {
@@ -112,11 +127,11 @@ export function UsageLimitField({
})
return
}
- saveOrgLimit({ organizationId, limit: limitDollars }, { onError })
+ saveOrgLimit({ organizationId, limit: limitDollars }, { onError, onSuccess })
return
}
- saveUserLimit({ limit: limitDollars }, { onError })
+ saveUserLimit({ limit: limitDollars }, { onError, onSuccess })
}, [debouncedDraft, minimumLimit, canEdit, context, organizationId, saveOrgLimit, saveUserLimit])
return (
@@ -129,7 +144,7 @@ export function UsageLimitField({
*/}
setDraft(e.target.value)}
placeholder={
currentLimit == null
@@ -138,7 +153,7 @@ export function UsageLimitField({
? 'No Usage Limit'
: String(dollarsToCredits(currentLimit))
}
- disabled={!canEdit}
+ disabled={!canEdit || pending}
/>
)
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail/custom-tool-detail.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail/custom-tool-detail.tsx
index 809feeeb408..ccce42cda14 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail/custom-tool-detail.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail/custom-tool-detail.tsx
@@ -6,7 +6,7 @@ import { ArrowLeft } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { saveDiscardActions } from '@/components/settings/save-discard-actions'
-import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail'
+import { useSettingsUnsavedGuard } from '@/components/settings/use-settings-unsaved-guard'
import {
CUSTOM_TOOL_DELETE_CONFIRM_TEXT,
CustomToolCodeField,
@@ -22,7 +22,6 @@ import {
} from '@/app/workspace/[workspaceId]/components/custom-tool-editor'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
-import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard'
import type { CustomToolDefinition } from '@/hooks/queries/custom-tools'
import {
useCreateCustomTool,
@@ -117,9 +116,19 @@ export function CustomToolDetail({
? jsonSchema !== seededSchema || functionCode !== seededCode
: jsonSchema.trim().length > 0 || functionCode.trim().length > 0
- const guard = useSettingsUnsavedGuard({ isDirty: dirty })
+ const guard = useSettingsUnsavedGuard({
+ isDirty: dirty,
+ navigationBlocked:
+ createTool.isPending ||
+ updateTool.isPending ||
+ deleteTool.isPending ||
+ schemaGeneration.isStreaming ||
+ codeGeneration.isStreaming,
+ onDiscard: () => handleDiscard(),
+ })
const saving = createTool.isPending || updateTool.isPending
+ const controlsDisabled = readOnly || saving || deleteTool.isPending
const isSchemaValid = useMemo(() => validateCustomToolSchema(jsonSchema).isValid, [jsonSchema])
const streaming = schemaGeneration.isStreaming || codeGeneration.isStreaming
@@ -235,11 +244,13 @@ export function CustomToolDetail({
schemaError ? {schemaError} : undefined
}
action={
- readOnly ? undefined : (
+ controlsDisabled ? undefined : (
schemaGeneration.generateStream({ prompt })}
+ onSubmit={(prompt) => {
+ if (!controlsDisabled) schemaGeneration.generateStream({ prompt })
+ }}
/>
)
}
@@ -247,12 +258,13 @@ export function CustomToolDetail({
{
+ if (controlsDisabled) return
setJsonSchema(value)
setSchemaError(value.trim() ? validateCustomToolSchema(value).error : null)
}}
error={!!schemaError}
generation={schemaGeneration}
- disabled={readOnly}
+ disabled={controlsDisabled}
/>
@@ -260,11 +272,13 @@ export function CustomToolDetail({
label='Code'
headerAccessory={codeError ? {codeError} : undefined}
action={
- readOnly ? undefined : (
+ controlsDisabled ? undefined : (
codeGeneration.generateStream({ prompt })}
+ onSubmit={(prompt) => {
+ if (!controlsDisabled) codeGeneration.generateStream({ prompt })
+ }}
/>
)
}
@@ -272,6 +286,7 @@ export function CustomToolDetail({
{
+ if (controlsDisabled) return
setFunctionCode(value)
if (codeError) setCodeError(null)
}}
@@ -279,7 +294,7 @@ export function CustomToolDetail({
generation={codeGeneration}
schemaParameters={schemaParameters}
workspaceId={workspaceId}
- disabled={readOnly}
+ disabled={controlsDisabled}
/>
@@ -298,12 +313,6 @@ export function CustomToolDetail({
pendingLabel: 'Deleting...',
}}
/>
-
-
>
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx
index c2efa65b787..d4ffc01f2e1 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx
@@ -14,6 +14,7 @@ import {
import { formatDateTime } from '@sim/utils/formatting'
import Link from 'next/link'
import { useQueryStates } from 'nuqs'
+import { useSettingsUnsavedGuard } from '@/components/settings/use-settings-unsaved-guard'
import {
type MothershipTab,
mothershipParsers,
@@ -28,6 +29,7 @@ import {
useMothershipRequests,
useMothershipUserBreakdown,
} from '@/hooks/queries/mothership-admin'
+import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
const TABS: { id: MothershipTab; label: string }[] = [
{ id: 'overview', label: 'Overview' },
@@ -83,6 +85,7 @@ export function Mothership() {
const defaults = useMemo(() => defaultTimeRange(), [])
const [start, setStart] = useState(defaults.start)
const [end, setEnd] = useState(defaults.end)
+ const requestLeave = useSettingsDirtyStore((state) => state.requestLeave)
return (
@@ -93,7 +96,12 @@ export function Mothership() {
align='start'
dropdownWidth={160}
value={environment}
- onChange={(value) => setMothershipParams({ env: value as MothershipEnv })}
+ onChange={(value) => {
+ if (value !== environment)
+ requestLeave(() => {
+ void setMothershipParams({ env: value as MothershipEnv })
+ })
+ }}
placeholder='Select environment'
options={ENV_OPTIONS}
/>
@@ -102,7 +110,12 @@ export function Mothership() {
({ value: tab.id, label: tab.label }))}
value={activeTab}
- onChange={(value) => setMothershipParams({ tab: value as MothershipTab })}
+ onChange={(value) => {
+ if (value !== activeTab)
+ requestLeave(() => {
+ void setMothershipParams({ tab: value as MothershipTab })
+ })
+ }}
/>
@@ -348,9 +361,19 @@ function LicensesTab({ environment }: { environment: MothershipEnv }) {
const [newExpiry, setNewExpiry] = useState('')
const [approvalReference, setApprovalReference] = useState('')
const [generatedKey, setGeneratedKey] = useState
(null)
+ const discardDraft = useCallback(() => {
+ setNewName('')
+ setNewExpiry('')
+ setApprovalReference('')
+ }, [])
+ useSettingsUnsavedGuard({
+ isDirty: Boolean(newName.trim() || newExpiry || approvalReference.trim()),
+ navigationBlocked: generateLicense.isPending,
+ onDiscard: discardDraft,
+ })
const handleGenerate = useCallback(() => {
- if (!newName.trim() || !approvalReference.trim()) return
+ if (!newName.trim() || !approvalReference.trim() || generateLicense.isPending) return
generateLicense.mutate(
{
name: newName.trim(),
@@ -360,13 +383,18 @@ function LicensesTab({ environment }: { environment: MothershipEnv }) {
{
onSuccess: (result) => {
setGeneratedKey(result.license_key)
- setNewName('')
- setNewExpiry('')
- setApprovalReference('')
+ discardDraft()
},
}
)
- }, [newName, newExpiry, approvalReference, generateLicense.mutate])
+ }, [
+ newName,
+ newExpiry,
+ approvalReference,
+ generateLicense.mutate,
+ generateLicense.isPending,
+ discardDraft,
+ ])
return (
@@ -375,6 +403,7 @@ function LicensesTab({ environment }: { environment: MothershipEnv }) {
Enterprise Name
{
setNewName(e.target.value)
@@ -387,6 +416,7 @@ function LicensesTab({ environment }: { environment: MothershipEnv }) {
Approval reference
setApprovalReference(event.target.value)}
placeholder='Signed order form or written approval'
@@ -396,6 +426,7 @@ function LicensesTab({ environment }: { environment: MothershipEnv }) {
Expiration (optional)
setNewExpiry(e.target.value)}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/sandboxes.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/sandboxes.tsx
index 249416267f6..b7cc4656887 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/sandboxes.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/sandboxes.tsx
@@ -9,8 +9,8 @@ import { useQueryState } from 'nuqs'
import { CodeIcon } from '@/components/icons'
import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation'
import { saveDiscardActions } from '@/components/settings/save-discard-actions'
+import { useSettingsUnsavedGuard } from '@/components/settings/use-settings-unsaved-guard'
import type { SandboxDependencyIssue } from '@/lib/api/contracts/sandboxes'
-import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
SandboxEditor,
@@ -39,7 +39,6 @@ import {
} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
import { SettingsUpgradeNotice } from '@/app/workspace/[workspaceId]/settings/components/settings-upgrade-notice'
import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search'
-import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard'
import {
type Sandbox,
useCreateSandbox,
@@ -109,6 +108,7 @@ export function Sandboxes() {
isEditing &&
(isCreating
? current.name.trim().length > 0 ||
+ current.language !== original.language ||
current.dependencies.trim().length > 0 ||
current.systemPackages.trim().length > 0 ||
current.cliTools.length > 0
@@ -118,17 +118,26 @@ export function Sandboxes() {
current.systemPackages !== original.systemPackages ||
current.cliTools.join(',') !== original.cliTools.join(','))
- // Called before every early return — a hook after a gate is skipped on gated renders.
- const guard = useSettingsUnsavedGuard({ isDirty })
-
- const closeEditor = useCallback(() => {
+ const discardDraft = useCallback(() => {
setDraft(null)
setDependencyIssues([])
setSystemPackageIssues([])
+ }, [])
+
+ // Called before every early return — a hook after a gate is skipped on gated renders.
+ const guard = useSettingsUnsavedGuard({
+ isDirty,
+ navigationBlocked:
+ createSandbox.isPending || updateSandbox.isPending || deleteSandbox.isPending,
+ onDiscard: discardDraft,
+ })
+
+ const closeEditor = useCallback(() => {
+ discardDraft()
setIsCreating(false)
// Opening pushed a history entry; closing must not push another.
void setSelectedId(null, { history: 'replace' })
- }, [setSelectedId])
+ }, [discardDraft, setSelectedId])
const handleSave = useCallback(async () => {
setDependencyIssues([])
@@ -267,11 +276,7 @@ export function Sandboxes() {
dirty: isDirty,
saving,
onSave: () => void handleSave(),
- onDiscard: () => {
- setDraft(null)
- setDependencyIssues([])
- setSystemPackageIssues([])
- },
+ onDiscard: discardDraft,
saveDisabled: !canAdmin || current.name.trim().length === 0,
creating: isCreating,
}),
@@ -317,12 +322,6 @@ export function Sandboxes() {
confirm={{ label: 'Delete', onClick: () => void handleDelete(selected) }}
/>
)}
-
-
>
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx
index 82c1009e63d..972052894af 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx
@@ -67,6 +67,7 @@ export function NoOrganizationView({
Team Name
setOrgSlug(e.target.value)}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx
index 2ff61ebb00e..db0fbb21782 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx
@@ -1,9 +1,10 @@
'use client'
-import { useEffect, useState } from 'react'
+import { useState } from 'react'
import { Plus } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
+import { useSettingsUnsavedGuard } from '@/components/settings/use-settings-unsaved-guard'
import { useSession } from '@/lib/auth/auth-client'
import { getSubscriptionAccessState } from '@/lib/billing/client/utils'
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
@@ -118,8 +119,31 @@ export function TeamManagement({
}>({ open: false, memberId: '', memberName: '' })
const [transferDialogOpen, setTransferDialogOpen] = useState(false)
const [transferPortalError, setTransferPortalError] = useState(null)
- const [orgName, setOrgName] = useState('')
- const [orgSlug, setOrgSlug] = useState('')
+ const defaultOrgName =
+ (hasTeamPlan || hasEnterprisePlan) && session?.user?.name ? `${session.user.name}'s Team` : ''
+ const defaultOrganization = { name: defaultOrgName, slug: generateSlug(defaultOrgName) }
+ const [organizationDraft, setOrganizationDraft] = useState<{
+ baseline: typeof defaultOrganization
+ values: typeof defaultOrganization
+ } | null>(null)
+ const orgName = organizationDraft?.values.name ?? defaultOrganization.name
+ const orgSlug = organizationDraft?.values.slug ?? defaultOrganization.slug
+ const updateOrganizationDraft = (change: Partial) => {
+ if (createOrgMutation.isPending) return
+ setOrganizationDraft((current) => {
+ const baseline = current?.baseline ?? defaultOrganization
+ const values = { ...(current?.values ?? baseline), ...change }
+ return values.name === baseline.name && values.slug === baseline.slug
+ ? null
+ : { baseline, values }
+ })
+ }
+ const setOrgSlug = (slug: string) => updateOrganizationDraft({ slug })
+ useSettingsUnsavedGuard({
+ isDirty: !organization && (hasTeamPlan || hasEnterprisePlan) && organizationDraft !== null,
+ navigationBlocked: createOrgMutation.isPending,
+ onDiscard: () => setOrganizationDraft(null),
+ })
/**
* `isFetching` (not `isLoading`) gates the confirm button: a background
@@ -160,22 +184,13 @@ export function TeamManagement({
}
: null
- useEffect(() => {
- if ((hasTeamPlan || hasEnterprisePlan) && session?.user?.name && !orgName) {
- const defaultName = `${session.user.name}'s Team`
- setOrgName(defaultName)
- setOrgSlug(generateSlug(defaultName))
- }
- }, [hasTeamPlan, hasEnterprisePlan, session?.user?.name, orgName])
-
const handleOrgNameChange = (e: React.ChangeEvent) => {
const newName = e.target.value
- setOrgName(newName)
- setOrgSlug(generateSlug(newName))
+ updateOrganizationDraft({ name: newName, slug: generateSlug(newName) })
}
const handleCreateOrganization = async () => {
- if (!session?.user || !orgName.trim()) return
+ if (!session?.user || !orgName.trim() || createOrgMutation.isPending) return
try {
await createOrgMutation.mutateAsync({
@@ -184,8 +199,7 @@ export function TeamManagement({
})
setCreateOrgDialogOpen(false)
- setOrgName('')
- setOrgSlug('')
+ setOrganizationDraft(null)
} catch (error) {
logger.error('Failed to create organization', error)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard.ts b/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard.ts
deleted file mode 100644
index baf8a42748f..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { useSettingsUnsavedGuard } from '@/components/settings/use-settings-unsaved-guard'
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/layout.tsx b/apps/sim/app/workspace/[workspaceId]/settings/layout.tsx
index ed92a72a23a..b6c6f7bb302 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/layout.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/layout.tsx
@@ -4,7 +4,6 @@ import { useCallback } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useSettingsIntentHandler } from '@/components/settings/settings-navigation-provider'
import { SettingsPendingSection } from '@/components/settings/settings-pending-section'
-import { useSettingsBeforeUnload } from '@/components/settings/use-settings-before-unload'
import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
import {
resolveSettingsSection,
@@ -22,7 +21,6 @@ function pendingSectionMeta(section: string) {
* sidebar navigation intent runs.
*/
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
- useSettingsBeforeUnload()
const queryClient = useQueryClient()
const hostContext = useWorkspaceHostContext()
const workspaceId = hostContext.workspace.id
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx
index 9b6b54939f8..406561b3547 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx
@@ -21,7 +21,6 @@ import {
CredentialDetailLayout,
CredentialMembersSection,
DetailSection,
- UnsavedChangesModal,
useCredentialDetailForm,
} from '@/app/workspace/[workspaceId]/components/credential-detail'
import { ResourceTile } from '@/app/workspace/[workspaceId]/components/resource-tile'
@@ -302,12 +301,6 @@ export function SecretDetail({ workspaceId, credentialId }: SecretDetailProps) {
onOpenChange={setIsShareModalOpen}
/>
)}
-
-
>
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx
index bc0731add9f..6b2ad581076 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx
@@ -3,7 +3,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Chip,
- ChipConfirmModal,
ChipTag,
chipContentIconClass,
chipVariants,
@@ -73,10 +72,6 @@ export function SettingsSidebar({
const router = useRouter()
const requestLeave = useSettingsDirtyStore((s) => s.requestLeave)
- const confirmLeave = useSettingsDirtyStore((s) => s.confirmLeave)
- const cancelLeave = useSettingsDirtyStore((s) => s.cancelLeave)
- const pendingLeave = useSettingsDirtyStore((s) => s.pendingLeave)
- const showDiscardDialog = pendingLeave !== null
const scrollEdges = useScrollEdges(scrollContainerRef, {
contentRef: scrollContentRef,
@@ -288,14 +283,6 @@ export function SettingsSidebar({
})
}, [requestLeave, router, popSettingsReturnUrl, workspaceId])
- const handleConfirmDiscard = useCallback(() => {
- confirmLeave()
- }, [confirmLeave])
-
- const handleCancelDiscard = useCallback(() => {
- cancelLeave()
- }, [cancelLeave])
-
useEffect(() => {
setDesktopSurfaces({
settings: hasDesktopSettings(),
@@ -364,10 +351,12 @@ export function SettingsSidebar({
{key === 'organization' && organizationSettingsId && (
{
- if (!useSettingsDirtyStore.getState().isDirty) return
+ const { isDirty, navigationBlocked } = useSettingsDirtyStore.getState()
+ if (!isDirty && !navigationBlocked) return
event.preventDefault()
requestLeave(() =>
router.push(
@@ -446,6 +435,7 @@ export function SettingsSidebar({
) : (
navigateToSection(section, href))
}}
>
@@ -487,19 +473,6 @@ export function SettingsSidebar({
))}
-
- !open && handleCancelDiscard()}
- srTitle='Unsaved changes'
- title='Unsaved changes'
- text='You have unsaved changes. Are you sure you want to discard them?'
- dismissLabel='Keep editing'
- confirm={{
- label: 'Discard changes',
- onClick: handleConfirmDiscard,
- }}
- />
>
)
}
diff --git a/apps/sim/components/secrets/organization-secrets-editor.tsx b/apps/sim/components/secrets/organization-secrets-editor.tsx
index f907692160b..60ace35afef 100644
--- a/apps/sim/components/secrets/organization-secrets-editor.tsx
+++ b/apps/sim/components/secrets/organization-secrets-editor.tsx
@@ -4,7 +4,6 @@ import { ArrowLeft } from '@sim/emcn/icons'
import { useRouter } from 'next/navigation'
import { SecretsEditor } from '@/components/secrets/secrets-editor'
import { SettingsPanel } from '@/components/settings/settings-panel'
-import { useSettingsBeforeUnload } from '@/components/settings/use-settings-before-unload'
import { organizationRoutes } from '@/lib/navigation/paths'
import type { SecretSourceMode } from '@/lib/organization-secrets/validation'
import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
@@ -24,7 +23,6 @@ interface OrganizationSecretsEditorProps {
export function OrganizationSecretsEditor({ mode }: OrganizationSecretsEditorProps) {
const { organization, viewer } = useOrganizationContext()
const router = useRouter()
- useSettingsBeforeUnload()
const secrets = useOrganizationSecrets(organization.id, mode, mode === 'member' || viewer.isAdmin)
const save = useSaveOrganizationSecrets(organization.id)
const routes = organizationRoutes(organization.id)
diff --git a/apps/sim/components/secrets/secrets-editor.test.tsx b/apps/sim/components/secrets/secrets-editor.test.tsx
index 70527192c10..36fe8a371ee 100644
--- a/apps/sim/components/secrets/secrets-editor.test.tsx
+++ b/apps/sim/components/secrets/secrets-editor.test.tsx
@@ -2,11 +2,13 @@
import { act, type ComponentProps } from 'react'
import { ToastProvider } from '@sim/emcn'
+import { createDeferred } from '@sim/testing/helpers/deferred'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { SecretsEditor } from '@/components/secrets/secrets-editor'
import { SettingsHeaderProvider, SettingsHeaderShell } from '@/components/settings/settings-header'
+import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
const mocks = vi.hoisted(() => ({ save: vi.fn() }))
vi.mock(
@@ -75,6 +77,418 @@ async function change(input: HTMLInputElement, value: string) {
}
describe('shared secrets editor', () => {
+ it.each(['existing value', 'remove new row'])(
+ 'preserves the surviving shared secret when a submitted duplicate changes: %s',
+ async (changeKind) => {
+ const request = createDeferred()
+ const persisted: Record = { TOKEN: 'original' }
+ let first = true
+ await render({
+ variables: { TOKEN: 'original' },
+ save: async ({ upsert, remove }) => {
+ if (first) {
+ first = false
+ await request.promise
+ }
+ for (const key of remove) delete persisted[key]
+ Object.assign(persisted, upsert)
+ },
+ })
+ const key = container.querySelector('input[name^="new_workspace_key_"]')
+ const value = container.querySelector('input[name^="new_workspace_value_"]')
+ const existing = container.querySelector(
+ 'input[name^="workspace_env_value_TOKEN"]'
+ )
+ if (!key || !value || !existing) throw new Error('Missing shared secret rows')
+ await change(key, 'TOKEN')
+ await change(value, 'submitted')
+ act(() => button('Save').click())
+ if (changeKind === 'existing value') await change(existing, 'later')
+ else {
+ await change(key, '')
+ await change(value, '')
+ }
+ await act(async () => request.resolve())
+ await act(async () => button('Save').click())
+ expect(persisted).toEqual({ TOKEN: changeKind === 'existing value' ? 'later' : 'original' })
+ }
+ )
+
+ it.each(['rename', 'value', 'delete'])(
+ 'rebases a new shared secret row changed during Save: %s',
+ async (changeKind) => {
+ const request = createDeferred()
+ const persisted: Record = {}
+ let first = true
+ await render({
+ save: async ({ upsert, remove }) => {
+ if (first) {
+ first = false
+ await request.promise
+ }
+ for (const key of remove) delete persisted[key]
+ Object.assign(persisted, upsert)
+ },
+ })
+ const key = container.querySelector('input[name^="new_workspace_key_"]')
+ const value = container.querySelector('input[name^="new_workspace_value_"]')
+ if (!key || !value) throw new Error('Missing new shared secret row')
+ await change(key, 'TOKEN')
+ await change(value, 'submitted')
+ act(() => button('Save').click())
+ if (changeKind === 'rename') await change(key, 'RENAMED')
+ else if (changeKind === 'value') await change(value, 'later')
+ else {
+ await change(key, '')
+ await change(value, '')
+ }
+ await act(async () => request.resolve())
+ const populated = [
+ ...container.querySelectorAll('input[name*="value"]'),
+ ].filter((field) => field.value)
+ expect(populated).toHaveLength(changeKind === 'delete' ? 0 : 1)
+ await act(async () => button('Save').click())
+ expect(persisted).toEqual(
+ changeKind === 'delete'
+ ? {}
+ : changeKind === 'rename'
+ ? { RENAMED: 'submitted' }
+ : { TOKEN: 'later' }
+ )
+ }
+ )
+
+ it.each(['', 'remotely-populated'])(
+ 'preserves a saved empty personal secret refreshed to %s while saving another edited secret',
+ async (remoteValue) => {
+ let persisted: unknown
+ const personal = {
+ variables: {
+ EMPTY: { key: 'EMPTY', value: '' },
+ TOKEN: { key: 'TOKEN', value: 'original' },
+ },
+ save: async (variables: Record) => {
+ persisted = variables
+ },
+ }
+ await render({ personal })
+ const fields = [
+ ...container.querySelectorAll('input[name^="env_variable_value_"]'),
+ ]
+ const token = fields.find((field) => field.value === 'original')
+ if (!token) throw new Error('Missing saved token')
+ await change(token, 'updated')
+ await render({
+ personal: {
+ ...personal,
+ variables: { ...personal.variables, EMPTY: { key: 'EMPTY', value: remoteValue } },
+ },
+ })
+ await act(async () => button('Save').click())
+ expect(persisted).toEqual({ EMPTY: remoteValue, TOKEN: 'updated' })
+ let left = false
+ act(() =>
+ useSettingsDirtyStore.getState().requestLeave(() => {
+ left = true
+ })
+ )
+ expect(left).toBe(true)
+ }
+ )
+
+ it('persists deletion of a saved empty personal secret', async () => {
+ let persisted: unknown
+ await render({
+ personal: {
+ variables: {
+ EMPTY: { key: 'EMPTY', value: '' },
+ TOKEN: { key: 'TOKEN', value: 'original' },
+ },
+ save: async (variables) => {
+ persisted = variables
+ },
+ },
+ })
+ const empty = [
+ ...container.querySelectorAll('input[name^="env_variable_name_"]'),
+ ].find((field) => field.value === 'EMPTY')
+ if (!empty) throw new Error('Missing empty secret')
+ await change(empty, '')
+ await act(async () => button('Save').click())
+ expect(persisted).toEqual({ TOKEN: 'original' })
+ })
+
+ it.each([
+ { scope: 'workspace', kind: 'key', prefix: 'new_workspace_key_' },
+ { scope: 'workspace', kind: 'value', prefix: 'new_workspace_value_' },
+ { scope: 'personal', kind: 'key', prefix: 'env_variable_name_' },
+ { scope: 'personal', kind: 'value', prefix: 'env_variable_value_' },
+ ])('protects a partially entered $scope secret $kind', async ({ scope, kind, prefix }) => {
+ await render(scope === 'personal' ? { personal: { variables: {}, save: mocks.save } } : {})
+ const input = container.querySelector(`input[name^="${prefix}"]`)
+ if (!input) throw new Error('Missing new secret input')
+ await change(input, kind === 'key' ? 'DRAFT_KEY' : 'draft-value')
+ let left = false
+ act(() =>
+ useSettingsDirtyStore.getState().requestLeave(() => {
+ left = true
+ })
+ )
+ expect(left).toBe(false)
+ const save = button('Save')
+ if (!save) throw new Error('Missing Save button')
+ await act(async () => save.click())
+ const retainedInput = container.querySelector(`input[name^="${prefix}"]`)
+ if (!retainedInput) throw new Error('Missing retained secret draft')
+ expect(retainedInput.value).toBe(kind === 'key' ? 'DRAFT_KEY' : 'draft-value')
+ await change(retainedInput, '')
+ act(() =>
+ useSettingsDirtyStore.getState().requestLeave(() => {
+ left = true
+ })
+ )
+ expect(left).toBe(true)
+ })
+
+ it.each(['personal', 'shared'])(
+ 'resumes a skipped %s refresh after reverting the draft',
+ async (scope) => {
+ const original = { TOKEN: 'original' }
+ const refreshed = { TOKEN: 'remote', REMOTE: 'new' }
+ const props = (variables: Record) =>
+ scope === 'shared'
+ ? { variables }
+ : {
+ personal: {
+ variables: Object.fromEntries(
+ Object.entries(variables).map(([key, value]) => [key, { key, value }])
+ ),
+ save: mocks.save,
+ },
+ }
+ const selector =
+ scope === 'shared'
+ ? 'input[name^="workspace_env_value_TOKEN"]'
+ : 'input[name^="env_variable_value_"]'
+ await render(props(original))
+ const field = container.querySelector(selector)
+ if (!field) throw new Error('Missing secret value')
+ await change(field, 'draft')
+ await render(props(refreshed))
+ const retained = container.querySelector(selector)
+ if (!retained) throw new Error('Missing retained draft')
+ expect(retained.value).toBe('draft')
+ await change(retained, 'original')
+ const values = [...container.querySelectorAll('input[name*="value"]')].map(
+ (input) => input.value
+ )
+ expect(values).toContain('remote')
+ expect(values).toContain('new')
+ let left = false
+ act(() =>
+ useSettingsDirtyStore.getState().requestLeave(() => {
+ left = true
+ })
+ )
+ expect(left).toBe(true)
+ }
+ )
+
+ it.each(['personal', 'shared'])(
+ 'consumes a fresh %s snapshot received before Save without hiding remote keys',
+ async (scope) => {
+ const empty = {}
+ const original = { TOKEN: 'original' }
+ const refreshed = { TOKEN: 'remote', REMOTE: 'new' }
+ const personalOriginal = { TOKEN: { key: 'TOKEN', value: 'original' } }
+ const personalRefreshed = {
+ TOKEN: { key: 'TOKEN', value: 'remote' },
+ REMOTE: { key: 'REMOTE', value: 'new' },
+ }
+ const props = (fresh: boolean) =>
+ scope === 'shared'
+ ? { variables: fresh ? refreshed : original }
+ : {
+ variables: empty,
+ personal: {
+ variables: fresh ? personalRefreshed : personalOriginal,
+ save: mocks.save,
+ },
+ }
+ const selector =
+ scope === 'shared'
+ ? 'input[name^="workspace_env_value_TOKEN"]'
+ : 'input[name^="env_variable_value_"]'
+ await render(props(false))
+ const field = container.querySelector(selector)
+ if (!field) throw new Error('Missing secret value')
+ await change(field, 'submitted')
+ await render(props(true))
+ await act(async () => button('Save').click())
+ expect(container.querySelector(selector)?.value).toBe('submitted')
+ const values = [...container.querySelectorAll('input[name*="value"]')].map(
+ (input) => input.value
+ )
+ expect(values).toContain('new')
+ let left = false
+ act(() =>
+ useSettingsDirtyStore.getState().requestLeave(() => {
+ left = true
+ })
+ )
+ expect(left).toBe(true)
+ }
+ )
+
+ it.each([
+ { scope: 'personal', refresh: 'during Save' },
+ { scope: 'shared', refresh: 'during Save' },
+ { scope: 'personal', refresh: 'after Save' },
+ { scope: 'shared', refresh: 'after Save' },
+ ])(
+ 'acknowledges fresh $scope values received $refresh without rolling back the save',
+ async ({ scope, refresh }) => {
+ const request = createDeferred()
+ const original = { TOKEN: 'original' }
+ const canonical = { TOKEN: 'submitted', REMOTE: 'canonical' }
+ const emptyShared = {}
+ const personalOriginal = { TOKEN: { key: 'TOKEN', value: 'original' } }
+ const personalCanonical = {
+ TOKEN: { key: 'TOKEN', value: 'submitted' },
+ REMOTE: { key: 'REMOTE', value: 'canonical' },
+ }
+ const props = (fresh: boolean, isSaving: boolean) =>
+ scope === 'shared'
+ ? { variables: fresh ? canonical : original, isSaving, save: () => request.promise }
+ : {
+ variables: emptyShared,
+ isSaving,
+ personal: {
+ variables: fresh ? personalCanonical : personalOriginal,
+ save: () => request.promise,
+ },
+ }
+ const selector =
+ scope === 'shared'
+ ? 'input[name^="workspace_env_value_TOKEN"]'
+ : 'input[name^="env_variable_value_"]'
+ await render(props(false, false))
+ const field = container.querySelector(selector)
+ if (!field) throw new Error('Missing secret value')
+ await change(field, 'submitted')
+ act(() => button('Save').click())
+ await render(props(refresh === 'during Save', true))
+ await act(async () => request.resolve())
+ await render(props(refresh === 'during Save', false))
+ expect(container.querySelector(selector)?.value).toBe('submitted')
+ if (refresh === 'after Save') await render(props(true, false))
+ const values = [...container.querySelectorAll('input[name*="value"]')].map(
+ (input) => input.value
+ )
+ expect(values).toContain('canonical')
+ let left = false
+ act(() =>
+ useSettingsDirtyStore.getState().requestLeave(() => {
+ left = true
+ })
+ )
+ expect(left).toBe(true)
+ }
+ )
+
+ it('keeps an edited personal secret and its navigation protection through a refresh', async () => {
+ const personal = { variables: { TOKEN: { key: 'TOKEN', value: 'original' } }, save: mocks.save }
+ await render({ personal })
+ const input = container.querySelector('input[name^="env_variable_value_"]')
+ if (!input) throw new Error('Missing personal secret input')
+ await change(input, 'draft-value')
+ await render({
+ personal: {
+ ...personal,
+ variables: {
+ TOKEN: { key: 'TOKEN', value: 'server-refresh' },
+ REMOTE: { key: 'REMOTE', value: 'new' },
+ },
+ },
+ })
+ expect(
+ container.querySelector('input[name^="env_variable_value_"]')?.value
+ ).toBe('draft-value')
+ let left = false
+ act(() =>
+ useSettingsDirtyStore.getState().requestLeave(() => {
+ left = true
+ })
+ )
+ expect(left).toBe(false)
+ await act(async () => button('Save').click())
+ expect(mocks.save.mock.calls[0]?.[0]).toEqual({ TOKEN: 'draft-value', REMOTE: 'new' })
+ })
+
+ it('rebases later personal edits onto remotely added keys after a pending save', async () => {
+ const request = createDeferred()
+ mocks.save.mockReturnValueOnce(request.promise)
+ const personal = { variables: { TOKEN: { key: 'TOKEN', value: 'original' } }, save: mocks.save }
+ await render({ personal })
+ const selector = 'input[name^="env_variable_value_"]'
+ const first = container.querySelector(selector)
+ if (!first) throw new Error('Missing personal value')
+ await change(first, 'submitted')
+ await render({
+ personal: {
+ ...personal,
+ variables: {
+ TOKEN: { key: 'TOKEN', value: 'original' },
+ REMOTE: { key: 'REMOTE', value: 'keep' },
+ },
+ },
+ })
+ act(() => button('Save').click())
+ const pending = container.querySelector(selector)
+ if (!pending) throw new Error('Missing pending personal value')
+ await change(pending, 'later')
+ await act(async () => request.resolve())
+ expect(container.querySelector(selector)?.value).toBe('later')
+ await act(async () => button('Save').click())
+ expect(mocks.save.mock.calls[1]?.[0]).toEqual({ TOKEN: 'later', REMOTE: 'keep' })
+ })
+
+ it('keeps an incomplete later row unique when a remotely added key is acknowledged', async () => {
+ const request = createDeferred()
+ mocks.save.mockReturnValueOnce(request.promise)
+ const personal = { variables: { TOKEN: { key: 'TOKEN', value: 'original' } }, save: mocks.save }
+ await render({ personal })
+ const value = container.querySelector('input[name^="env_variable_value_"]')
+ if (!value) throw new Error('Missing personal value')
+ await change(value, 'submitted')
+ await render({
+ personal: {
+ ...personal,
+ variables: {
+ TOKEN: { key: 'TOKEN', value: 'original' },
+ REMOTE: { key: 'REMOTE', value: 'keep' },
+ },
+ },
+ })
+ act(() => button('Save').click())
+ const emptyKey = [
+ ...container.querySelectorAll('input[name^="env_variable_name_"]'),
+ ].find((field) => field.value === '')
+ if (!emptyKey) throw new Error('Missing empty row')
+ await change(emptyKey, 'REMOTE')
+ await act(async () => request.resolve())
+ const keys = [
+ ...container.querySelectorAll('input[name^="env_variable_name_"]'),
+ ].filter((field) => field.value === 'REMOTE')
+ expect(keys).toHaveLength(1)
+ const key = keys[0]
+ if (!key) throw new Error('Missing retained incomplete key')
+ const rowValue = container.querySelector(
+ `input[name="${key.name.replace('env_variable_name_', 'env_variable_value_')}"]`
+ )
+ expect(rowValue?.value).toBe('')
+ })
+
it('uses the existing masked .env paste and save flow for organization secrets', async () => {
await render()
expect(container.textContent).toContain('Organization')
diff --git a/apps/sim/components/secrets/secrets-editor.tsx b/apps/sim/components/secrets/secrets-editor.tsx
index 1a75496cbe6..c983aa37d3d 100644
--- a/apps/sim/components/secrets/secrets-editor.tsx
+++ b/apps/sim/components/secrets/secrets-editor.tsx
@@ -8,6 +8,7 @@ import { getErrorMessage } from '@sim/utils/errors'
import { countPasteRows } from '@sim/utils/paste'
import { useRouter } from 'next/navigation'
import { saveDiscardActions } from '@/components/settings/save-discard-actions'
+import { useSettingsUnsavedGuard } from '@/components/settings/use-settings-unsaved-guard'
import { setRecordValue } from '@/lib/core/utils/records'
import {
clearPendingCredentialCreateRequest,
@@ -16,7 +17,6 @@ import {
readPendingCredentialCreateRequest,
} from '@/lib/credentials/client-state'
import type { SecretChanges } from '@/lib/organization-secrets/validation'
-import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail'
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
import { SecretValueField } from '@/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
@@ -24,7 +24,6 @@ import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search'
import { isValidEnvVarName } from '@/executor/constants'
-import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
const logger = createLogger('SecretsManager')
@@ -109,6 +108,21 @@ function updateEnvVarArray(
return updated.filter((v, i) => i === lastIndex || v.key !== '' || v.value !== '')
}
+function applyVariableEdits(
+ baseline: Record,
+ edited: Record,
+ latest: Record
+): Record {
+ const merged = { ...latest }
+ for (const [key, value] of Object.entries(edited)) {
+ if (baseline[key] !== value) setRecordValue(merged, key, value)
+ }
+ for (const key of Object.keys(baseline)) {
+ if (!Object.hasOwn(edited, key)) delete merged[key]
+ }
+ return merged
+}
+
/**
* Validates an environment variable key.
* Returns an error message if invalid, undefined if valid.
@@ -374,22 +388,23 @@ export function SecretsEditor({
const personalEnvData = personal?.variables
const hasPersonal = Boolean(personal)
const [envVars, setEnvVars] = useState([])
- const [newWorkspaceRows, setNewWorkspaceRows] = useState([
- createEmptyEnvVar(),
- ])
+ const [workspaceDraft, setWorkspaceDraft] = useState<{
+ variables: Record
+ rows: UIEnvironmentVariable[]
+ }>(() => ({ variables: {}, rows: [createEmptyEnvVar()] }))
+ const workspaceVars = workspaceDraft.variables
+ const newWorkspaceRows = workspaceDraft.rows
const [searchTerm, setSearchTerm] = useSettingsSearch()
- const [showUnsavedChanges, setShowUnsavedChanges] = useState(false)
- const [workspaceVars, setWorkspaceVars] = useState>({})
const [renamingKey, setRenamingKey] = useState(null)
const [pendingKeyValue, setPendingKeyValue] = useState('')
const initialWorkspaceVarsRef = useRef>({})
const scrollContainerRef = useRef(null)
const initialVarsRef = useRef([])
- const hasChangesRef = useRef(false)
- const hasSavedPersonalRef = useRef(false)
- const hasSavedWorkspaceRef = useRef(false)
- const shouldBlockNavRef = useRef(false)
- const pendingNavigationUrlRef = useRef(null)
+ const acknowledgedPersonalRef = useRef<{
+ data: typeof personalEnvData
+ hasPersonal: boolean
+ } | null>(null)
+ const acknowledgedWorkspaceRef = useRef(undefined)
const filteredEnvVars = useMemo(() => {
const mapped = envVars.map((envVar, index) => ({ envVar, originalIndex: index }))
@@ -455,10 +470,23 @@ export function SecretsEditor({
if (before[key] !== after[key]) return true
}
- if (newWorkspaceRows.some((row) => row.key && row.value)) return true
+ if (newWorkspaceRows.some((row) => row.key || row.value)) return true
+ if (renamingKey && pendingKeyValue !== renamingKey) return true
return false
- }, [envVars, workspaceVars, newWorkspaceRows])
+ }, [envVars, workspaceVars, newWorkspaceRows, renamingKey, pendingKeyValue])
+
+ const initialPersonalVariables = Object.fromEntries(
+ initialVarsRef.current.filter((row) => row.key).map(({ key, value }) => [key, value])
+ )
+ const hasIncompleteRows =
+ newWorkspaceRows.some((row) => Boolean(row.key) !== Boolean(row.value)) ||
+ envVars.some(
+ (row) =>
+ Boolean(row.key) !== Boolean(row.value) &&
+ (!Object.hasOwn(initialPersonalVariables, row.key) ||
+ initialPersonalVariables[row.key] !== row.value)
+ )
const hasConflicts = useMemo(() => {
return envVars.some((envVar) => !!envVar.key && allWorkspaceKeys.has(envVar.key))
@@ -470,23 +498,17 @@ export function SecretsEditor({
return personalInvalid || workspaceInvalid
}, [envVars, newWorkspaceRows])
- hasChangesRef.current = hasChanges
- shouldBlockNavRef.current = hasChanges
-
- const setNavGuardDirty = useSettingsDirtyStore((s) => s.setDirty)
- const resetNavGuard = useSettingsDirtyStore((s) => s.reset)
+ const guard = useSettingsUnsavedGuard({
+ isDirty: hasChanges,
+ navigationBlocked: isListSaving,
+ onDiscard: () => resetToSaved(),
+ })
useEffect(() => {
- setNavGuardDirty(hasChanges)
- }, [hasChanges, setNavGuardDirty])
-
- useEffect(() => () => resetNavGuard(), [resetNavGuard])
-
- useEffect(() => {
- if (hasSavedPersonalRef.current) {
- hasSavedPersonalRef.current = false
- return
- }
+ if (hasChanges || isListSaving) return
+ const acknowledged = acknowledgedPersonalRef.current
+ if (acknowledged?.data === personalEnvData && acknowledged?.hasPersonal === hasPersonal) return
+ acknowledgedPersonalRef.current = { data: personalEnvData, hasPersonal }
const existingVars = Object.values(personalEnvData || {})
const initialVars = [
@@ -498,17 +520,15 @@ export function SecretsEditor({
]
initialVarsRef.current = structuredClone(initialVars)
setEnvVars(structuredClone(initialVars))
- }, [personalEnvData, hasPersonal])
+ }, [personalEnvData, hasPersonal, hasChanges, isListSaving])
useEffect(() => {
- if (!variables || hasChangesRef.current) return
- if (hasSavedWorkspaceRef.current) {
- hasSavedWorkspaceRef.current = false
+ if (!variables || hasChanges || isListSaving || acknowledgedWorkspaceRef.current === variables)
return
- }
- setWorkspaceVars(variables)
+ acknowledgedWorkspaceRef.current = variables
+ setWorkspaceDraft((current) => ({ ...current, variables }))
initialWorkspaceVarsRef.current = variables
- }, [variables])
+ }, [variables, hasChanges, isListSaving])
const scrollToBottom = useCallback(() => {
requestAnimationFrame(() => {
@@ -519,46 +539,6 @@ export function SecretsEditor({
})
}, [])
- /**
- * Navigation guard: intercept link clicks in the capture phase before
- * Next.js App Router processes them. This is needed because Next.js
- * internally bypasses window.history.pushState overrides.
- */
- useEffect(() => {
- const handleClick = (e: MouseEvent) => {
- if (!shouldBlockNavRef.current) return
-
- const anchor = (e.target as HTMLElement).closest('a[href]')
- if (!anchor) return
-
- const href = anchor.getAttribute('href')
- if (!href || href.startsWith('http') || href.startsWith('#')) return
-
- const currentPath = window.location.pathname
- if (href === currentPath) return
-
- e.preventDefault()
- e.stopPropagation()
- pendingNavigationUrlRef.current = href
- setShowUnsavedChanges(true)
- }
-
- const handlePopState = () => {
- if (shouldBlockNavRef.current) {
- window.history.pushState(null, '', window.location.href)
- setShowUnsavedChanges(true)
- }
- }
-
- document.addEventListener('click', handleClick, true)
- window.addEventListener('popstate', handlePopState)
-
- return () => {
- document.removeEventListener('click', handleClick, true)
- window.removeEventListener('popstate', handlePopState)
- }
- }, [])
-
const applyPendingCredentialCreateRequest = useCallback(
(request: PendingCredentialCreateRequest) => {
if (request.workspaceId !== workspaceId) return
@@ -615,12 +595,7 @@ export function SecretsEditor({
const handleViewDetails = (envKey: string) => {
const url = rowAccess?.get(envKey)?.detailsHref
if (!url) return
- if (shouldBlockNavRef.current) {
- pendingNavigationUrlRef.current = url
- setShowUnsavedChanges(true)
- return
- }
- router.push(url)
+ guard.guardBack(() => router.push(url))
}
const handleWorkspaceKeyRename = (currentKey: string, currentValue: string) => {
@@ -633,28 +608,34 @@ export function SecretsEditor({
return
}
- setWorkspaceVars((prev) => {
- const next = { ...prev }
+ setWorkspaceDraft((current) => {
+ const next = { ...current.variables }
delete next[currentKey]
setRecordValue(next, newKey, currentValue)
- return next
+ return { ...current, variables: next }
})
}
const handleWorkspaceValueChange = (key: string, value: string) => {
- setWorkspaceVars((prev) => ({ ...prev, [key]: value }))
+ setWorkspaceDraft((current) => ({
+ ...current,
+ variables: { ...current.variables, [key]: value },
+ }))
}
const handleDeleteWorkspaceVar = (key: string) => {
- setWorkspaceVars((prev) => {
- const next = { ...prev }
+ setWorkspaceDraft((current) => {
+ const next = { ...current.variables }
delete next[key]
- return next
+ return { ...current, variables: next }
})
}
const updateNewWorkspaceRow = (index: number, field: 'key' | 'value', value: string) => {
- setNewWorkspaceRows((prev) => updateEnvVarArray(prev, index, field, value))
+ setWorkspaceDraft((current) => ({
+ ...current,
+ rows: updateEnvVarArray(current.rows, index, field, value),
+ }))
}
const updateEnvVar = (index: number, field: 'key' | 'value', value: string) => {
@@ -757,30 +738,30 @@ export function SecretsEditor({
const parsedVars = parseValidEnvVars(lines)
if (parsedVars.length > 0) {
e.preventDefault()
- setNewWorkspaceRows((prev) => {
- const existing = prev.filter((v) => v.key || v.value)
- return [...existing, ...parsedVars, createEmptyEnvVar()]
+ setWorkspaceDraft((current) => {
+ const existing = current.rows.filter((v) => v.key || v.value)
+ return { ...current, rows: [...existing, ...parsedVars, createEmptyEnvVar()] }
})
scrollToBottom()
}
}
const resetToSaved = () => {
+ if (isListSaving) return
setEnvVars(structuredClone(initialVarsRef.current))
- setWorkspaceVars({ ...initialWorkspaceVarsRef.current })
- setNewWorkspaceRows([createEmptyEnvVar()])
- setShowUnsavedChanges(false)
+ setWorkspaceDraft({
+ variables: { ...initialWorkspaceVarsRef.current },
+ rows: [createEmptyEnvVar()],
+ })
+ setRenamingKey(null)
+ setPendingKeyValue('')
}
- const handleCancel = resetToSaved
-
const handleSave = async () => {
- if (isListSaving) return
+ if (isListSaving || hasIncompleteRows || hasConflicts || hasInvalidKeys) return
const mutations: Promise[] = []
- setShowUnsavedChanges(false)
-
const mergedWorkspaceVars = { ...workspaceVars }
for (const row of newWorkspaceRows) {
if (row.key && row.value) {
@@ -789,7 +770,15 @@ export function SecretsEditor({
}
const validVariables = Object.fromEntries(
- envVars.filter((v) => v.key && v.value).map(({ key, value }) => [key, value])
+ envVars.filter((v) => v.key).map(({ key, value }) => [key, value])
+ )
+ const latestPersonalVariables = Object.fromEntries(
+ Object.values(personalEnvData ?? {}).map(({ key, value }) => [key, value])
+ )
+ const personalVariablesToSave = applyVariableEdits(
+ initialPersonalVariables,
+ validVariables,
+ latestPersonalVariables
)
const before = initialWorkspaceVarsRef.current
@@ -810,7 +799,7 @@ export function SecretsEditor({
const personalChanged = (() => {
const initialMap = new Map()
for (const v of initialVarsRef.current) {
- if (v.key && v.value) initialMap.set(v.key, v.value)
+ if (v.key) initialMap.set(v.key, v.value)
}
const currentKeys = Object.keys(validVariables)
if (initialMap.size !== currentKeys.length) return true
@@ -823,48 +812,85 @@ export function SecretsEditor({
const workspaceChanged = Object.keys(toUpsert).length > 0 || toDelete.length > 0
if (personalChanged && personal) {
- mutations.push(personal.save(validVariables))
+ mutations.push(personal.save(personalVariablesToSave))
}
if (workspaceChanged) {
mutations.push(save({ upsert: toUpsert, remove: toDelete }))
}
- hasSavedPersonalRef.current = personalChanged
- hasSavedWorkspaceRef.current = Boolean(workspaceChanged)
-
try {
const results = await Promise.allSettled(mutations)
const firstFailure = results.find((r): r is PromiseRejectedResult => r.status === 'rejected')
if (firstFailure) throw firstFailure.reason
- initialWorkspaceVarsRef.current = { ...mergedWorkspaceVars }
- initialVarsRef.current = structuredClone(envVars.filter((v) => v.key && v.value))
+ if (personalChanged) acknowledgedPersonalRef.current = { data: personalEnvData, hasPersonal }
+ if (workspaceChanged) acknowledgedWorkspaceRef.current = variables
+ const savedWorkspaceVars = applyVariableEdits(
+ before,
+ mergedWorkspaceVars,
+ variables ?? before
+ )
+ initialWorkspaceVarsRef.current = savedWorkspaceVars
+ const savedPersonalRows = Object.entries(personalVariablesToSave).map(([key, value]) => ({
+ key,
+ value,
+ id: generateRowId(),
+ }))
+ initialVarsRef.current = structuredClone(savedPersonalRows)
+ setEnvVars((current) => {
+ const rowIds = new Map(current.map((row) => [row.key, row.id]))
+ const currentVariables = Object.fromEntries(
+ current.filter((row) => row.key).map(({ key, value }) => [key, value])
+ )
+ const rebasedVariables = applyVariableEdits(
+ validVariables,
+ currentVariables,
+ personalVariablesToSave
+ )
+ const incompleteRows = current.filter(
+ (row) => !row.key || (!row.value && validVariables[row.key] !== row.value)
+ )
+ const incompleteKeys = new Set(incompleteRows.map((row) => row.key).filter(Boolean))
+ const rows = Object.entries(rebasedVariables)
+ .filter(([key]) => !incompleteKeys.has(key))
+ .map(([key, value]) => ({
+ key,
+ value,
+ id: rowIds.get(key) ?? generateRowId(),
+ }))
+ return [...rows, ...incompleteRows]
+ })
- setWorkspaceVars(mergedWorkspaceVars)
- setNewWorkspaceRows([createEmptyEnvVar()])
+ const submittedRows = new Map(
+ newWorkspaceRows.filter((row) => row.key && row.value).map((row) => [row.id, row])
+ )
+ setWorkspaceDraft((current) => {
+ const variables = { ...savedWorkspaceVars }
+ for (const submitted of submittedRows.values()) {
+ if (Object.hasOwn(workspaceVars, submitted.key))
+ setRecordValue(variables, submitted.key, workspaceVars[submitted.key])
+ else delete variables[submitted.key]
+ }
+ const rows: UIEnvironmentVariable[] = []
+ for (const row of current.rows) {
+ if (submittedRows.has(row.id) && row.key && row.value)
+ setRecordValue(variables, row.key, row.value)
+ else rows.push(row)
+ }
+ return {
+ variables: applyVariableEdits(workspaceVars, current.variables, variables),
+ rows: rows.length > 0 ? rows : [createEmptyEnvVar()],
+ }
+ })
if (mutations.length > 0) {
toast.success('Secrets saved')
}
} catch (error) {
- hasSavedPersonalRef.current = false
- hasSavedWorkspaceRef.current = false
logger.error('Failed to save environment variables:', error)
toast.error(getErrorMessage(error, 'Failed to save secrets'))
}
}
- const handleDiscardAndNavigate = () => {
- shouldBlockNavRef.current = false
- resetNavGuard()
- resetToSaved()
-
- if (pendingNavigationUrlRef.current) {
- const url = pendingNavigationUrlRef.current
- pendingNavigationUrlRef.current = null
- router.push(url)
- }
- }
-
const renderEnvVarRow = (envVar: UIEnvironmentVariable, originalIndex: number) => {
const isConflicted = !!envVar.key && allWorkspaceKeys.has(envVar.key)
const keyError = validateEnvVarKey(envVar.key)
@@ -932,8 +958,6 @@ export function SecretsEditor({
)
}
- const isPendingNavigation = pendingNavigationUrlRef.current !== null
-
return (
<>
@@ -970,12 +994,7 @@ export function SecretsEditor({
back: {
text: back.text,
icon: ArrowLeft,
- onSelect: () => {
- if (shouldBlockNavRef.current) {
- pendingNavigationUrlRef.current = back.href
- setShowUnsavedChanges(true)
- } else router.push(back.href)
- },
+ onSelect: () => guard.guardBack(() => router.push(back.href)),
},
}
: { title: undefined, back: undefined })}
@@ -989,13 +1008,15 @@ export function SecretsEditor({
dirty: hasChanges,
saving: isListSaving,
onSave: handleSave,
- onDiscard: handleCancel,
- saveDisabled: hasConflicts || hasInvalidKeys || isLoading,
+ onDiscard: resetToSaved,
+ saveDisabled: hasConflicts || hasInvalidKeys || hasIncompleteRows || isLoading,
saveTooltip: hasConflicts
? 'Resolve all conflicts before saving'
: hasInvalidKeys
? 'Fix invalid variable names before saving'
- : undefined,
+ : hasIncompleteRows
+ ? 'Enter both a name and value for each secret'
+ : undefined,
})}
>
{!isLoading && (
@@ -1074,12 +1095,6 @@ export function SecretsEditor({
)}
-
-
>
)
}
diff --git a/apps/sim/components/settings/settings-guarded-link.tsx b/apps/sim/components/settings/settings-guarded-link.tsx
index 337e3f57f51..2e945a78b34 100644
--- a/apps/sim/components/settings/settings-guarded-link.tsx
+++ b/apps/sim/components/settings/settings-guarded-link.tsx
@@ -20,6 +20,7 @@ export function SettingsGuardedLink({ href, onNavigate, ...props }: SettingsGuar
{
const { isDirty, navigationBlocked, requestLeave } = useSettingsDirtyStore.getState()
if (isDirty || navigationBlocked) {
diff --git a/apps/sim/components/settings/settings-navigation-guard.tsx b/apps/sim/components/settings/settings-navigation-guard.tsx
new file mode 100644
index 00000000000..0b5d064f471
--- /dev/null
+++ b/apps/sim/components/settings/settings-navigation-guard.tsx
@@ -0,0 +1,69 @@
+'use client'
+
+import { useEffect } from 'react'
+import { ChipConfirmModal } from '@sim/emcn'
+import { useRouter } from 'next/navigation'
+import { useSettingsBrowserNavigation } from '@/components/settings/use-settings-browser-navigation'
+import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
+
+/** One browser guard and discard dialog for every registered editor in the app. */
+export function SettingsNavigationGuard() {
+ useSettingsBrowserNavigation()
+ const router = useRouter()
+ useEffect(() => {
+ const handleClick = (event: MouseEvent) => {
+ const { isDirty, navigationBlocked, requestLeave } = useSettingsDirtyStore.getState()
+ if (
+ (!isDirty && !navigationBlocked) ||
+ event.defaultPrevented ||
+ event.button !== 0 ||
+ event.metaKey ||
+ event.ctrlKey ||
+ event.shiftKey ||
+ event.altKey
+ )
+ return
+ const anchor = event.target instanceof Element ? event.target.closest('a[href]') : null
+ if (
+ !(anchor instanceof HTMLAnchorElement) ||
+ anchor.hasAttribute('download') ||
+ anchor.dataset.settingsNavigation === 'managed' ||
+ (anchor.target && anchor.target !== '_self') ||
+ anchor.getAttribute('aria-disabled') === 'true'
+ )
+ return
+ if (
+ event.target instanceof Element &&
+ event.target.closest('button, input, textarea, select, [role="button"]')
+ )
+ return
+ const destination = new URL(anchor.href, window.location.href)
+ if (
+ destination.origin !== window.location.origin ||
+ (destination.pathname === window.location.pathname &&
+ destination.search === window.location.search)
+ )
+ return
+ event.preventDefault()
+ event.stopImmediatePropagation()
+ requestLeave(() => router.push(destination.pathname + destination.search + destination.hash))
+ }
+ document.addEventListener('click', handleClick, true)
+ return () => document.removeEventListener('click', handleClick, true)
+ }, [router])
+ const pendingLeave = useSettingsDirtyStore((state) => state.pendingLeave)
+ const confirmLeave = useSettingsDirtyStore((state) => state.confirmLeave)
+ const cancelLeave = useSettingsDirtyStore((state) => state.cancelLeave)
+
+ return (
+ !open && cancelLeave()}
+ srTitle='Unsaved changes'
+ title='Unsaved changes'
+ text='You have unsaved changes. Are you sure you want to discard them?'
+ dismissLabel='Keep editing'
+ confirm={{ label: 'Discard changes', onClick: confirmLeave }}
+ />
+ )
+}
diff --git a/apps/sim/components/settings/settings-sidebar.tsx b/apps/sim/components/settings/settings-sidebar.tsx
index d8c389c83e8..1131009226a 100644
--- a/apps/sim/components/settings/settings-sidebar.tsx
+++ b/apps/sim/components/settings/settings-sidebar.tsx
@@ -3,7 +3,6 @@
import { type ComponentType, useRef } from 'react'
import {
Chip,
- ChipConfirmModal,
ChipTag,
chipContentIconClass,
chipVariants,
@@ -97,9 +96,6 @@ export function SettingsSidebar({
usePendingSettingsSelection(activeSection)
const requestLeave = useSettingsDirtyStore((state) => state.requestLeave)
- const confirmLeave = useSettingsDirtyStore((state) => state.confirmLeave)
- const cancelLeave = useSettingsDirtyStore((state) => state.cancelLeave)
- const pendingLeave = useSettingsDirtyStore((state) => state.pendingLeave)
const scrollEdges = useScrollEdges(scrollContainerRef, {
contentRef: scrollContentRef,
enabled: !isCollapsed,
@@ -180,6 +176,7 @@ export function SettingsSidebar({
enabled={showCollapsedTooltips}
>
({
))}
-
-
!open && cancelLeave()}
- srTitle='Unsaved changes'
- title='Unsaved changes'
- text='You have unsaved changes. Are you sure you want to discard them?'
- dismissLabel='Keep editing'
- confirm={{ label: 'Discard changes', onClick: confirmLeave }}
- />
>
)
}
diff --git a/apps/sim/components/settings/standalone-settings-shell-seeding.test.tsx b/apps/sim/components/settings/standalone-settings-shell-seeding.test.tsx
index 44df9022e59..a06f8542af4 100644
--- a/apps/sim/components/settings/standalone-settings-shell-seeding.test.tsx
+++ b/apps/sim/components/settings/standalone-settings-shell-seeding.test.tsx
@@ -21,9 +21,6 @@ vi.mock('@/components/settings/settings-header', () => ({
vi.mock('@/components/settings/settings-panel', () => ({
SettingsSectionProvider: ({ children }: { children: ReactNode }) => children,
}))
-vi.mock('@/components/settings/use-settings-before-unload', () => ({
- useSettingsBeforeUnload: vi.fn(),
-}))
import { StandaloneSettingsShell } from '@/components/settings/standalone-settings-shell'
import {
diff --git a/apps/sim/components/settings/standalone-settings-shell.tsx b/apps/sim/components/settings/standalone-settings-shell.tsx
index 35ec77e0f8f..04e45d3b64a 100644
--- a/apps/sim/components/settings/standalone-settings-shell.tsx
+++ b/apps/sim/components/settings/standalone-settings-shell.tsx
@@ -21,7 +21,6 @@ import { SettingsNavigationProvider } from '@/components/settings/settings-navig
import { SettingsSectionProvider } from '@/components/settings/settings-panel'
import { SettingsPendingSection } from '@/components/settings/settings-pending-section'
import { SettingsSidebar } from '@/components/settings/settings-sidebar'
-import { useSettingsBeforeUnload } from '@/components/settings/use-settings-before-unload'
import type { DeploymentShape } from '@/lib/api/contracts/workspaces'
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
import { SIDEBAR_NO_MOTION_CLASS } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
@@ -53,7 +52,6 @@ function pendingSectionMeta(plane: 'account' | 'selfhost', section: string) {
export function StandaloneSettingsShell(props: StandaloneSettingsShellProps) {
const { children, plane } = props
useSeedDeploymentShape(props.deployment)
- useSettingsBeforeUnload()
const pathname = usePathname()
const { hosted, billingEnabled } = useDeploymentShape()
const isSuperUser = plane === 'account' ? (props.isSuperUser ?? false) : false
diff --git a/apps/sim/components/settings/unsaved-changes-regression.test.tsx b/apps/sim/components/settings/unsaved-changes-regression.test.tsx
new file mode 100644
index 00000000000..9cf005512dc
--- /dev/null
+++ b/apps/sim/components/settings/unsaved-changes-regression.test.tsx
@@ -0,0 +1,832 @@
+/** @vitest-environment jsdom */
+
+import { act, type ReactNode } from 'react'
+import { toast } from '@sim/emcn'
+import { flushMicrotasks } from '@sim/testing/helpers/async'
+import { createDeferred } from '@sim/testing/helpers/deferred'
+import { jsonResponse } from '@sim/testing/helpers/http'
+import { authClientMock, authClientMockFns } from '@sim/testing/mocks/auth-client.mock'
+import { nextNavigationMockFns } from '@sim/testing/mocks/next-navigation.mock'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { SettingsHeaderProvider, SettingsHeaderShell } from '@/components/settings/settings-header'
+import type { WorkspaceCredential } from '@/lib/api/contracts/credentials'
+import { ON_DEMAND_UNLIMITED } from '@/lib/billing/constants'
+import {
+ getDeploymentShape,
+ resetDeploymentShape,
+ seedDeploymentShape,
+} from '@/lib/core/config/deployment-shape'
+import { useCredentialDetailForm } from '@/app/workspace/[workspaceId]/components/credential-detail/hooks/use-credential-detail-form'
+import * as workspacePermissions from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
+import { Admin } from '@/app/workspace/[workspaceId]/settings/components/admin/admin'
+import { UsageLimitField } from '@/app/workspace/[workspaceId]/settings/components/billing/components/usage-limit-field/usage-limit-field'
+import { Mothership } from '@/app/workspace/[workspaceId]/settings/components/mothership/mothership'
+import { Sandboxes } from '@/app/workspace/[workspaceId]/settings/components/sandboxes/sandboxes'
+import { TeamManagement } from '@/app/workspace/[workspaceId]/settings/components/team-management/team-management'
+import { ScimSection } from '@/ee/scim/components/scim-section'
+import { scimKeys } from '@/ee/scim/hooks/scim'
+import { SessionPolicySettings } from '@/ee/session-policy/components/session-policy-settings'
+import { sessionPolicyKeys } from '@/ee/session-policy/hooks/session-policy'
+import { SsoProviderSettings } from '@/ee/sso/components/sso-provider-settings'
+import { SSO } from '@/ee/sso/components/sso-settings'
+import { domainKeys } from '@/ee/sso/hooks/domains'
+import { ssoKeys } from '@/ee/sso/hooks/sso'
+import { useWorkspaceCredential } from '@/hooks/queries/credentials'
+import { sandboxKeys } from '@/hooks/queries/sandboxes'
+import { type SubscriptionApiResponse, useSubscriptionData } from '@/hooks/queries/subscription'
+import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys'
+import { organizationKeys } from '@/hooks/queries/utils/organization-keys'
+import { permissionGroupKeys } from '@/hooks/queries/utils/permission-group-keys'
+import { subscriptionKeys } from '@/hooks/queries/utils/subscription-keys'
+import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
+
+vi.mock(
+ 'next/navigation',
+ async () => (await import('@sim/testing/mocks/next-navigation.mock')).nextNavigationMock
+)
+vi.mock('@/lib/auth/auth-client', () => authClientMock)
+
+const credential: WorkspaceCredential = {
+ id: 'credential-a',
+ workspaceId: 'workspace-a',
+ type: 'env_workspace',
+ displayName: 'Saved name',
+ description: 'Saved description',
+ unredacted: false,
+ providerId: null,
+ accountId: null,
+ envKey: 'TOKEN',
+ envOwnerUserId: null,
+ createdBy: 'user-a',
+ createdAt: '2026-01-01T00:00:00Z',
+ updatedAt: '2026-01-01T00:00:00Z',
+ role: 'admin',
+}
+
+function CredentialEditor() {
+ const { data } = useWorkspaceCredential(credential.id)
+ const form = useCredentialDetailForm({
+ credential: data ?? null,
+ isAdmin: true,
+ backHref: '/settings',
+ })
+ return (
+ <>
+ form.setDisplayNameDraft(event.target.value)}
+ />
+ form.setDescriptionDraft(event.target.value)}
+ />
+ void form.save()}>
+ Save credential
+
+
+ Discard credential
+
+ >
+ )
+}
+
+function UsageEditor() {
+ const { data } = useSubscriptionData({ includeOrg: false })
+ return (
+
+ )
+}
+
+const PRO_BILLING = {
+ success: true,
+ context: 'user',
+ data: {
+ type: 'individual',
+ plan: 'pro',
+ currentUsage: 0,
+ usageLimit: 5,
+ percentUsed: 0,
+ isWarning: false,
+ isExceeded: false,
+ daysRemaining: 10,
+ creditBalance: 0,
+ billingInterval: 'month',
+ isPaid: true,
+ isPro: true,
+ isTeam: false,
+ isEnterprise: false,
+ isOrgScoped: false,
+ organizationId: null,
+ status: 'active',
+ seats: null,
+ metadata: null,
+ stripeSubscriptionId: null,
+ periodEnd: null,
+ cancelAtPeriodEnd: false,
+ billingBlocked: false,
+ billingBlockedReason: null,
+ blockedByOrgOwner: false,
+ upgradeWorkspaceId: null,
+ usage: {
+ current: 0,
+ limit: 5,
+ percentUsed: 0,
+ isWarning: false,
+ isExceeded: false,
+ billingPeriodStart: null,
+ billingPeriodEnd: null,
+ lastPeriodCost: 0,
+ lastPeriodCopilotCost: 0,
+ daysRemaining: 10,
+ copilotCost: 0,
+ },
+ },
+}
+
+let root: Root
+let container: HTMLDivElement
+let client: QueryClient
+
+beforeEach(() => {
+ vi.useFakeTimers()
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
+ vi.stubGlobal(
+ 'ResizeObserver',
+ class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ }
+ )
+ vi.spyOn(toast, 'success').mockReturnValue('toast-a')
+ vi.spyOn(toast, 'error').mockReturnValue('toast-a')
+ nextNavigationMockFns.mockUseParams.mockReturnValue({ workspaceId: 'workspace-a' })
+ vi.spyOn(workspacePermissions, 'useUserPermissionsContext').mockReturnValue({
+ canAdmin: true,
+ canEdit: true,
+ canRead: true,
+ userPermissions: 'admin',
+ isLoading: false,
+ error: null,
+ })
+ useSettingsDirtyStore.getState().reset()
+ client = new QueryClient({
+ defaultOptions: { queries: { retry: false, gcTime: Number.POSITIVE_INFINITY } },
+ })
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+})
+
+afterEach(async () => {
+ await act(async () => {
+ root.unmount()
+ await flushMicrotasks()
+ })
+ client.clear()
+ container.remove()
+ nextNavigationMockFns.mockUseParams.mockReset()
+ authClientMockFns.mockUseSession.mockReset()
+ resetDeploymentShape()
+ vi.useRealTimers()
+})
+
+async function render(children: ReactNode, searchParams = '') {
+ await act(async () => {
+ root.render(
+
+
+
+ {children}
+
+
+
+ )
+ await vi.advanceTimersByTimeAsync(1)
+ })
+}
+
+function input(selector: string) {
+ const field = container.querySelector(selector)
+ if (!field) throw new Error(`Missing input ${selector}`)
+ return field
+}
+
+function edit(selector: string, value: string) {
+ const field = input(selector)
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
+ if (!setter) throw new Error('Missing input setter')
+ act(() => {
+ setter.call(field, value)
+ field.dispatchEvent(new Event('input', { bubbles: true }))
+ })
+}
+
+function click(text: string) {
+ const button = [...document.querySelectorAll('button')].find(
+ (node) => node.textContent?.trim() === text
+ )
+ if (!button) throw new Error(`Missing button ${text}`)
+ act(() => button.click())
+}
+
+function expectLeave(allowed: boolean) {
+ let left = false
+ act(() =>
+ useSettingsDirtyStore.getState().requestLeave(() => {
+ left = true
+ })
+ )
+ expect(left).toBe(allowed)
+ useSettingsDirtyStore.getState().cancelLeave()
+}
+
+describe('credential metadata drafts', () => {
+ it('becomes clean when an authored field converges with a same-ID saved refresh', async () => {
+ client.setQueryData(workspaceCredentialKeys.detail(credential.id), credential)
+ await render( )
+ edit('[aria-label="Description"]', 'Shared description')
+ expectLeave(false)
+ await act(async () => {
+ client.setQueryData(workspaceCredentialKeys.detail(credential.id), {
+ ...credential,
+ displayName: 'Remote name',
+ description: 'Shared description',
+ })
+ await vi.advanceTimersByTimeAsync(1)
+ })
+ expectLeave(true)
+ expect(input('[aria-label="Name"]').value).toBe('Remote name')
+ edit('[aria-label="Description"]', 'Another edit')
+ expectLeave(false)
+ edit('[aria-label="Description"]', 'Shared description')
+ expectLeave(true)
+ })
+
+ it('follows same-ID refreshes while untouched, preserves authored edits, and resumes saved values on revert', async () => {
+ client.setQueryData(workspaceCredentialKeys.detail(credential.id), credential)
+ await render( )
+ expectLeave(true)
+ await act(async () => {
+ client.setQueryData(workspaceCredentialKeys.detail(credential.id), {
+ ...credential,
+ displayName: 'Refreshed name',
+ })
+ await vi.advanceTimersByTimeAsync(1)
+ })
+ expect(input('[aria-label="Name"]').value).toBe('Refreshed name')
+ expectLeave(true)
+ edit('[aria-label="Description"]', 'My draft')
+ expectLeave(false)
+ await act(async () => {
+ client.setQueryData(workspaceCredentialKeys.detail(credential.id), {
+ ...credential,
+ displayName: 'Latest name',
+ description: 'Latest description',
+ })
+ await vi.advanceTimersByTimeAsync(1)
+ })
+ expect(input('[aria-label="Description"]').value).toBe('My draft')
+ expectLeave(false)
+ edit('[aria-label="Description"]', 'Latest description')
+ expectLeave(true)
+ expect(input('[aria-label="Name"]').value).toBe('Latest name')
+ })
+
+ it('preserves a revert typed while a different description is being saved', async () => {
+ client.setQueryData(workspaceCredentialKeys.detail(credential.id), credential)
+ const request = createDeferred()
+ let persisted = credential
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn((_url: string, init?: RequestInit) =>
+ init?.method === 'PUT'
+ ? request.promise
+ : Promise.resolve(jsonResponse({ credential: persisted }))
+ )
+ )
+ await render( )
+ edit('[aria-label="Description"]', 'Submitted description')
+ click('Save credential')
+ await act(async () => {
+ await flushMicrotasks()
+ await vi.advanceTimersByTimeAsync(50)
+ })
+ edit('[aria-label="Description"]', credential.description ?? '')
+ expect(input('[aria-label="Description"]').value).toBe(credential.description)
+ persisted = { ...credential, description: 'Submitted description' }
+ await act(async () => {
+ request.resolve(jsonResponse({ credential: persisted }))
+ await flushMicrotasks()
+ await vi.advanceTimersByTimeAsync(50)
+ })
+ expect(input('[aria-label="Description"]').value).toBe(credential.description)
+ expectLeave(false)
+ })
+
+ it('saves only authored metadata and retains a failed draft', async () => {
+ client.setQueryData(workspaceCredentialKeys.detail(credential.id), credential)
+ const request = createDeferred()
+ let payload: unknown
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn((_url: string, init?: RequestInit) => {
+ if (init?.method === 'PUT') {
+ payload = JSON.parse(String(init.body))
+ return request.promise
+ }
+ return Promise.resolve(jsonResponse({ credential }))
+ })
+ )
+ await render( )
+ edit('[aria-label="Description"]', 'My draft')
+ await act(async () => {
+ client.setQueryData(workspaceCredentialKeys.detail(credential.id), {
+ ...credential,
+ displayName: 'Remote name',
+ })
+ await vi.advanceTimersByTimeAsync(1)
+ })
+ click('Save credential')
+ await act(async () => {
+ await flushMicrotasks()
+ await vi.advanceTimersByTimeAsync(1)
+ })
+ expect(payload).toEqual({ description: 'My draft' })
+ expectLeave(false)
+ await act(async () => {
+ request.resolve(jsonResponse({ error: 'Unable to save' }, 503))
+ await flushMicrotasks()
+ await vi.advanceTimersByTimeAsync(1)
+ })
+ expect(input('[aria-label="Description"]').value).toBe('My draft')
+ expectLeave(false)
+ click('Discard credential')
+ expectLeave(true)
+ })
+})
+
+it('protects a language-only new sandbox draft and clears protection on revert', async () => {
+ client.setQueryData(sandboxKeys.list('workspace-a'), {
+ sandboxes: [],
+ entitled: true,
+ strategy: 'runtime',
+ })
+ await render( )
+ expectLeave(true)
+ click('New sandbox')
+ expectLeave(true)
+ const trigger = [...container.querySelectorAll('button')].find(
+ (node) => node.textContent?.trim() === 'JavaScript'
+ )
+ if (!trigger) throw new Error('Missing language trigger')
+ act(() => trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })))
+ const option = [...document.querySelectorAll('[role="menuitem"]')].find(
+ (node) => node.textContent?.trim() === 'Python'
+ )
+ if (!option) throw new Error('Missing Python option')
+ act(() => option.click())
+ expectLeave(false)
+ const restoredTrigger = [...container.querySelectorAll('button')].find(
+ (node) => node.textContent?.trim() === 'Python'
+ )
+ if (!restoredTrigger) throw new Error('Missing language trigger')
+ act(() =>
+ restoredTrigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
+ )
+ const javascript = [...document.querySelectorAll('[role="menuitem"]')].find(
+ (node) => node.textContent?.trim() === 'JavaScript'
+ )
+ if (!javascript) throw new Error('Missing JavaScript option')
+ act(() => javascript.click())
+ expectLeave(true)
+})
+
+it('protects a usage-limit edit before its debounce and permits equivalent numeric values', async () => {
+ await render( )
+ expectLeave(true)
+ edit('input[inputmode="numeric"]', '1000.0')
+ expectLeave(true)
+ edit('input[inputmode="numeric"]', '2000')
+ expectLeave(false)
+ edit('input[inputmode="numeric"]', '1000')
+ expectLeave(true)
+})
+
+it('preserves SSO domain drafts across tabs and protects them when leaving the page', async () => {
+ client.setQueryData(ssoKeys.providerList('org-a'), { providers: [] })
+ client.setQueryData(domainKeys.list('org-a'), { domains: [] })
+ client.setQueryData(organizationKeys.billing('org-a'), {
+ data: { subscriptionPlan: 'enterprise' },
+ })
+ await render( , '?sso-tab=domains')
+ click('Domains')
+ expectLeave(true)
+ edit('#sso-add-domain', 'draft.example.com')
+ expectLeave(false)
+ click('Sign-in')
+ expectLeave(false)
+ click('Domains')
+ expect(input('#sso-add-domain').value).toBe('draft.example.com')
+ act(() => useSettingsDirtyStore.getState().requestLeave(() => {}))
+ act(() => useSettingsDirtyStore.getState().confirmLeave())
+ expect(input('#sso-add-domain').value).toBe('')
+ expectLeave(true)
+})
+
+it('retains an authored usage limit after its optimistic update rolls back', async () => {
+ const request = createDeferred()
+ const billing = PRO_BILLING
+ client.setQueryData(subscriptionKeys.user(false), billing)
+ let payload: unknown
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn((_url: string, init?: RequestInit) => {
+ if (init?.method === 'PUT') {
+ payload = JSON.parse(String(init.body))
+ return request.promise
+ }
+ return Promise.resolve(jsonResponse(billing))
+ })
+ )
+ await render( )
+ expectLeave(true)
+ edit('input[inputmode="numeric"]', '2000')
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1001)
+ await flushMicrotasks()
+ await vi.advanceTimersByTimeAsync(1)
+ })
+ expect(payload).toEqual({ context: 'user', limit: 10 })
+ expect(
+ client.getQueryData(subscriptionKeys.user(false))?.data.usage.limit
+ ).toBe(10)
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(50)
+ })
+ expectLeave(false)
+ await act(async () => {
+ request.resolve(jsonResponse({ error: 'Unable to save' }, 503))
+ await flushMicrotasks()
+ await vi.advanceTimersByTimeAsync(50)
+ })
+ expect(input('input[inputmode="numeric"]').value).toBe('2000')
+ expectLeave(false)
+})
+
+it('retains newer session-policy edits when an earlier save completes', async () => {
+ const saved = { isEnterprise: true, configured: { maxSessionHours: 720, idleTimeoutHours: null } }
+ client.setQueryData(sessionPolicyKeys.settings('org-a'), saved)
+ const request = createDeferred()
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn((_url: string, init?: RequestInit) =>
+ init?.method === 'PUT'
+ ? request.promise
+ : Promise.resolve(jsonResponse({ success: true, data: saved }))
+ )
+ )
+ await render( )
+ expectLeave(true)
+ edit('#max-session-hours', '100')
+ click('Save')
+ await act(async () => {
+ await flushMicrotasks()
+ await vi.advanceTimersByTimeAsync(1)
+ })
+ edit('#max-session-hours', '200')
+ await act(async () => {
+ request.resolve(
+ jsonResponse({
+ success: true,
+ data: { ...saved, configured: { maxSessionHours: 100, idleTimeoutHours: null } },
+ })
+ )
+ await flushMicrotasks()
+ await vi.advanceTimersByTimeAsync(50)
+ })
+ expect(input('#max-session-hours').value).toBe('200')
+ expectLeave(false)
+})
+
+it('blocks duplicate SSO submissions and changes while provider configuration is pending', async () => {
+ const request = createDeferred()
+ const submitted: unknown[] = []
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn((_url: string, init?: RequestInit) => {
+ if (init?.method === 'POST') {
+ submitted.push(JSON.parse(String(init.body)))
+ return request.promise
+ }
+ return Promise.resolve(jsonResponse({ providers: [] }))
+ })
+ )
+ await render( {}} />)
+ expectLeave(true)
+ edit('#sso-provider-id', 'example')
+ edit('#sso-issuer', 'https://identity.example.com')
+ edit('#sso-domain', 'example.com')
+ edit('#sso-client-id', 'test-client')
+ edit('#sso-client-secret', 'test-secret')
+ const form = container.querySelector('form')
+ if (!form) throw new Error('Missing provider form')
+ await act(async () => {
+ form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
+ await flushMicrotasks()
+ await vi.advanceTimersByTimeAsync(50)
+ })
+ expect(submitted).toHaveLength(1)
+ click('Invite only')
+ await act(async () => {
+ form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
+ await flushMicrotasks()
+ await vi.advanceTimersByTimeAsync(50)
+ })
+ expect(submitted).toHaveLength(1)
+ let left = false
+ act(() =>
+ useSettingsDirtyStore.getState().requestLeave(() => {
+ left = true
+ })
+ )
+ expect(left).toBe(false)
+ expect(useSettingsDirtyStore.getState().pendingLeave).toBeNull()
+ await act(async () => {
+ request.resolve(jsonResponse({ error: 'Unable to configure' }, 503))
+ await flushMicrotasks()
+ await vi.advanceTimersByTimeAsync(50)
+ })
+ expect(input('#sso-client-id').value).toBe('test-client')
+ expectLeave(false)
+ await act(async () => {
+ form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
+ await flushMicrotasks()
+ await vi.advanceTimersByTimeAsync(50)
+ })
+ expect(submitted).toHaveLength(2)
+ expect(submitted[1]).toMatchObject({ jitProvisioningEnabled: true })
+})
+
+it('resumes saved billing values after reverting an uncapped field to its original blank', async () => {
+ await render(
+
+ )
+ edit('input[inputmode="numeric"]', '2000')
+ edit('input[inputmode="numeric"]', '')
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1001)
+ })
+ expectLeave(true)
+ await render( )
+ expect(input('input[inputmode="numeric"]').value).toBe('1000')
+ expectLeave(true)
+})
+
+it.each([
+ ['input[placeholder="e.g. Acme Corp"]', 'Draft enterprise'],
+ ['input[placeholder="Signed order form or written approval"]', 'Draft approval'],
+ ['input[type="date"]', '2027-01-01'],
+])('protects a license draft entered in %s and clears on discard', async (selector, value) => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => jsonResponse({ licenses: [] }))
+ )
+ await render( , '?tab=licenses')
+ expectLeave(true)
+ edit(selector, value)
+ expectLeave(false)
+ edit(selector, '')
+ expectLeave(true)
+ edit(selector, value)
+ act(() => {
+ useSettingsDirtyStore.getState().requestLeave(() => {})
+ useSettingsDirtyStore.getState().confirmLeave()
+ })
+ expect(input(selector).value).toBe('')
+ expectLeave(true)
+})
+
+it.each(['Source workflow ID', 'Target workspace ID'])(
+ 'protects a partial admin import draft in %s',
+ async (placeholder) => {
+ await render( )
+ const selector = `input[placeholder="${placeholder}"]`
+ expectLeave(true)
+ edit(selector, 'draft-id')
+ expectLeave(false)
+ edit(selector, '')
+ expectLeave(true)
+ }
+)
+
+function seedScim() {
+ const shape = getDeploymentShape()
+ seedDeploymentShape({ ...shape, features: { ...shape.features, scim: true } })
+ client.setQueryData(scimKeys.connection('org-a'), {
+ connection: {
+ id: 'connection-a',
+ status: 'active',
+ baseUrl: 'https://example.com/scim',
+ settings: {},
+ lastRequestAt: null,
+ reconciledAt: null,
+ createdAt: '2026-01-01T00:00:00Z',
+ credentials: [],
+ userCount: 0,
+ groupCount: 1,
+ },
+ })
+ client.setQueryData(scimKeys.mappings('org-a'), [
+ { id: 'directory-group-a', displayName: 'Directory group', memberCount: 0, mappings: [] },
+ ])
+ client.setQueryData(scimKeys.activity('org-a'), [])
+ client.setQueryData(permissionGroupKeys.list('org-a'), [])
+ client.setQueryData(permissionGroupKeys.orgWorkspaces('org-a'), [])
+}
+
+async function renderScim() {
+ seedScim()
+ await render( {}} />)
+}
+
+it('preserves hidden SSO and SCIM drafts when provisioning is disabled and re-enabled', async () => {
+ seedScim()
+ client.setQueryData(ssoKeys.providerList('org-a'), { providers: [] })
+ client.setQueryData(domainKeys.list('org-a'), { domains: [] })
+ client.setQueryData(organizationKeys.billing('org-a'), {
+ data: { subscriptionPlan: 'enterprise' },
+ })
+ let persisted = client.getQueryData<{ connection: { status: string } }>(
+ scimKeys.connection('org-a')
+ )
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn((_url: string, init?: RequestInit) => {
+ if (init?.method === 'PUT' && persisted) {
+ const body = JSON.parse(String(init.body)) as { status: string }
+ persisted = { connection: { ...persisted.connection, status: body.status } }
+ }
+ return Promise.resolve(jsonResponse(persisted))
+ })
+ )
+ await render( , '?sso-tab=domains')
+ click('Domains')
+ edit('#sso-add-domain', 'draft.example.com')
+ click('Provisioning')
+ select('Token expiry', 'Expires in 90 days')
+ const toggle = container.querySelector('#scim-enabled')
+ if (!toggle) throw new Error('Missing provisioning switch')
+ await act(async () => {
+ toggle.click()
+ await flushMicrotasks()
+ await vi.advanceTimersByTimeAsync(50)
+ })
+ expect(persisted?.connection.status).toBe('disabled')
+ expectLeave(false)
+ click('Domains')
+ expect(input('#sso-add-domain').value).toBe('draft.example.com')
+ click('Provisioning')
+ await act(async () => {
+ toggle.click()
+ await flushMicrotasks()
+ await vi.advanceTimersByTimeAsync(50)
+ })
+ expect(persisted?.connection.status).toBe('active')
+ expect(container.querySelector('button[aria-label="Token expiry"]')?.textContent).toContain(
+ 'Expires in 90 days'
+ )
+ expectLeave(false)
+})
+
+it('blocks provisioning toggles while token issuance is pending', async () => {
+ await renderScim()
+ const request = createDeferred()
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn((_url: string, init?: RequestInit) =>
+ init?.method === 'POST'
+ ? request.promise
+ : Promise.resolve(jsonResponse(client.getQueryData(scimKeys.connection('org-a'))))
+ )
+ )
+ click('Issue token')
+ await act(async () => {
+ await flushMicrotasks()
+ await vi.advanceTimersByTimeAsync(1)
+ })
+ const toggle = container.querySelector('#scim-enabled')
+ if (!toggle) throw new Error('Missing provisioning switch')
+ expect(toggle.disabled).toBe(true)
+ expectLeave(false)
+ await act(async () => {
+ request.resolve(jsonResponse({ error: 'Unavailable' }, 503))
+ await flushMicrotasks()
+ await vi.advanceTimersByTimeAsync(50)
+ })
+ expect(toggle.disabled).toBe(false)
+ expectLeave(true)
+})
+
+it('protects the only retrievable SCIM token until its modal is dismissed', async () => {
+ await renderScim()
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn((_url: string, init?: RequestInit) =>
+ Promise.resolve(
+ init?.method === 'POST'
+ ? jsonResponse(
+ {
+ secret: 'test-only-scim-token',
+ credential: {
+ id: 'token-a',
+ tokenPrefix: 'test-only',
+ scopes: ['users:read'],
+ expiresAt: null,
+ lastUsedAt: null,
+ createdAt: '2026-01-01T00:00:00Z',
+ },
+ },
+ 201
+ )
+ : jsonResponse(client.getQueryData(scimKeys.connection('org-a')))
+ )
+ )
+ )
+ click('Issue token')
+ await act(async () => {
+ await flushMicrotasks()
+ await vi.advanceTimersByTimeAsync(50)
+ })
+ expectLeave(false)
+ click('Done')
+ expectLeave(true)
+})
+
+function select(label: string, optionText: string) {
+ const trigger = container.querySelector(`button[aria-label="${label}"]`)
+ if (!trigger) throw new Error(`Missing select ${label}`)
+ act(() => trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })))
+ const option = [...document.querySelectorAll('[role="menuitem"]')].find(
+ (node) => node.textContent?.trim() === optionText
+ )
+ if (!option) throw new Error(`Missing option ${optionText}`)
+ act(() => option.click())
+}
+
+it.each([
+ ['Mapping target type', 'Organization admin'],
+ ['Token expiry', 'Expires in 90 days'],
+])('protects an inline SCIM draft in %s', async (label, option) => {
+ await renderScim()
+ expectLeave(true)
+ select(label, option)
+ expectLeave(false)
+ act(() => {
+ useSettingsDirtyStore.getState().requestLeave(() => {})
+ useSettingsDirtyStore.getState().confirmLeave()
+ })
+ expectLeave(true)
+})
+
+it('keeps generated organization defaults clean while protecting authored recovery fields', async () => {
+ seedDeploymentShape({ ...getDeploymentShape(), billingEnabled: true })
+ authClientMockFns.mockUseSession.mockReturnValue({
+ data: { user: { id: 'user-a', name: 'Example' } },
+ isPending: false,
+ })
+ client.setQueryData(organizationKeys.detail('missing-org'), null)
+ client.setQueryData(organizationKeys.roster('missing-org'), null)
+ client.setQueryData(subscriptionKeys.user(false), {
+ ...PRO_BILLING,
+ data: { ...PRO_BILLING.data, plan: 'team', isPro: false, isTeam: true },
+ })
+ await render( )
+ const name = input('#team-name-field').value
+ const slug = input('#orgSlug').value
+ expect(name).not.toBe('')
+ expectLeave(true)
+ edit('#team-name-field', 'Authored team')
+ expectLeave(false)
+ edit('#team-name-field', name)
+ expectLeave(true)
+ edit('#orgSlug', 'authored-url')
+ expectLeave(false)
+ act(() => {
+ useSettingsDirtyStore.getState().requestLeave(() => {})
+ useSettingsDirtyStore.getState().confirmLeave()
+ })
+ expect(input('#team-name-field').value).toBe(name)
+ expect(input('#orgSlug').value).toBe(slug)
+ expectLeave(true)
+})
diff --git a/apps/sim/components/settings/use-settings-before-unload.ts b/apps/sim/components/settings/use-settings-before-unload.ts
deleted file mode 100644
index 8de59314fe7..00000000000
--- a/apps/sim/components/settings/use-settings-before-unload.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { useEffect } from 'react'
-import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
-
-/**
- * Registers the settings-wide browser unload guard while a section is dirty.
- */
-export function useSettingsBeforeUnload() {
- const isDirty = useSettingsDirtyStore((state) => state.isDirty)
-
- useEffect(() => {
- if (!isDirty) return
- const handleBeforeUnload = (event: BeforeUnloadEvent) => {
- event.preventDefault()
- }
- window.addEventListener('beforeunload', handleBeforeUnload)
- return () => window.removeEventListener('beforeunload', handleBeforeUnload)
- }, [isDirty])
-}
diff --git a/apps/sim/components/settings/use-settings-browser-navigation.test.tsx b/apps/sim/components/settings/use-settings-browser-navigation.test.tsx
new file mode 100644
index 00000000000..0967b0ebba4
--- /dev/null
+++ b/apps/sim/components/settings/use-settings-browser-navigation.test.tsx
@@ -0,0 +1,579 @@
+/** @vitest-environment jsdom */
+
+import { act, useState } from 'react'
+import { nextNavigationMockFns } from '@sim/testing/mocks/next-navigation.mock'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { SettingsIntentLink } from '@/components/settings/settings-intent-link'
+import { SettingsNavigationGuard } from '@/components/settings/settings-navigation-guard'
+import { useSettingsBrowserNavigation } from '@/components/settings/use-settings-browser-navigation'
+import { useSettingsUnsavedGuard } from '@/components/settings/use-settings-unsaved-guard'
+import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
+
+vi.mock(
+ 'next/navigation',
+ async () => (await import('@sim/testing/mocks/next-navigation.mock')).nextNavigationMock
+)
+
+interface SurfaceProps {
+ dirty: boolean
+ blocked?: boolean
+}
+
+function Surface({ dirty, blocked }: SurfaceProps) {
+ useSettingsBrowserNavigation()
+ useSettingsUnsavedGuard({ isDirty: dirty, navigationBlocked: blocked })
+ return null
+}
+
+interface LinkedEditorProps {
+ blocked?: boolean
+}
+
+function LinkedEditor({ blocked }: LinkedEditorProps) {
+ const [value, setValue] = useState('')
+ const guard = useSettingsUnsavedGuard({
+ isDirty: value.length > 0,
+ navigationBlocked: blocked,
+ onDiscard: () => setValue(''),
+ })
+ return (
+ <>
+
+ setValue(event.target.value)} />
+ Other page
+ {
+ event.preventDefault()
+ guard.guardBack(() => window.history.replaceState({ router: 'managed' }, '', '/managed'))
+ }}
+ >
+ Settings tab
+
+ >
+ )
+}
+
+const nativePush = window.history.pushState
+const nativeGo = window.history.go
+
+let root: Root
+let container: HTMLDivElement
+let routedPaths: string[]
+let handleRoute: () => void
+
+async function settle() {
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(100)
+ })
+}
+
+function render(dirty: boolean, blocked = false) {
+ act(() => root.render( ))
+}
+
+beforeEach(() => {
+ vi.useFakeTimers()
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
+ useSettingsDirtyStore.getState().reset()
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ nextNavigationMockFns.router.push.mockImplementation((url: string) =>
+ window.history.pushState({ router: 'link' }, '', url)
+ )
+ window.history.replaceState({ router: 'start' }, '', '/start')
+ render(false)
+ window.history.pushState({ router: 'prior' }, '', '/prior')
+ window.history.pushState({ router: 'editor' }, '', '/editor')
+ routedPaths = []
+ handleRoute = () => {
+ routedPaths.push(window.location.pathname)
+ }
+ window.addEventListener('popstate', handleRoute)
+})
+
+afterEach(async () => {
+ act(() => root.unmount())
+ await settle()
+ window.removeEventListener('popstate', handleRoute)
+ container.remove()
+ vi.useRealTimers()
+})
+
+describe('native settings navigation', () => {
+ it.each([
+ { kind: 'Map', state: new Map([['router', 'map-state']]) },
+ { kind: 'Date', state: new Date('2026-01-01T00:00:00Z') },
+ { kind: 'typed array', state: new Uint8Array([1, 2, 3]) },
+ { kind: 'array', state: ['preserved', 'state'] },
+ ])('preserves $kind state through push and replacement', async ({ state }) => {
+ window.history.pushState(state, '', '/structured-state')
+ expect(window.history.state).toEqual(state)
+ window.history.replaceState(state, '', '/replaced-state')
+ expect(window.history.state).toEqual(state)
+ window.history.back()
+ await settle()
+ window.history.forward()
+ await settle()
+ expect(window.history.state).toEqual(state)
+ })
+
+ it.each(['first Back', 'last Forward', 'large Back', 'large Forward'] as const)(
+ 'retains a draft when %s has no destination',
+ async (operation) => {
+ if (operation === 'first Back') {
+ nativeGo.call(window.history, -(window.history.length - 1))
+ await settle()
+ }
+ routedPaths = []
+ act(() => root.render( ))
+ const field = container.querySelector('input[aria-label="Draft"]')
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
+ if (!field || !setter) throw new Error('Missing draft input')
+ act(() => {
+ setter.call(field, 'authored')
+ field.dispatchEvent(new Event('input', { bubbles: true }))
+ })
+ const currentUrl = window.location.href
+ if (operation === 'first Back') window.history.back()
+ else if (operation === 'last Forward') window.history.forward()
+ else window.history.go(operation === 'large Back' ? -2_147_483_647 : 2_147_483_647)
+ act(() => useSettingsDirtyStore.getState().confirmLeave())
+ await settle()
+ expect(field.value).toBe('authored')
+ expect(window.location.href).toBe(currentUrl)
+ expect(routedPaths).toEqual([])
+ expect(useSettingsDirtyStore.getState().pendingLeave).toBeNull()
+ const unload = new Event('beforeunload', { cancelable: true })
+ window.dispatchEvent(unload)
+ expect(unload.defaultPrevented).toBe(true)
+ }
+ )
+
+ it.each([false, true])(
+ 'discards a confirmed draft only after actual traversal (unstamped: %s)',
+ async (unstamped) => {
+ if (unstamped) {
+ nativePush.call(window.history, { router: 'legacy-forward' }, '', '/legacy-forward')
+ nativeGo.call(window.history, -1)
+ await settle()
+ }
+ act(() => root.render( ))
+ const field = container.querySelector('input[aria-label="Draft"]')
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
+ if (!field || !setter) throw new Error('Missing draft input')
+ act(() => {
+ setter.call(field, 'authored')
+ field.dispatchEvent(new Event('input', { bubbles: true }))
+ })
+ if (unstamped) window.history.forward()
+ else window.history.back()
+ act(() => useSettingsDirtyStore.getState().confirmLeave())
+ expect(field.value).toBe('authored')
+ await settle()
+ expect(window.location.pathname).toBe(unstamped ? '/legacy-forward' : '/prior')
+ expect(field.value).toBe('')
+ expect(useSettingsDirtyStore.getState().isDirty).toBe(false)
+ }
+ )
+
+ it('retains a confirmed draft if a save begins before traversal', async () => {
+ act(() => root.render( ))
+ const field = container.querySelector('input[aria-label="Draft"]')
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
+ if (!field || !setter) throw new Error('Missing draft input')
+ act(() => {
+ setter.call(field, 'authored')
+ field.dispatchEvent(new Event('input', { bubbles: true }))
+ })
+ window.history.back()
+ act(() => useSettingsDirtyStore.getState().confirmLeave())
+ act(() => root.render( ))
+ await settle()
+ expect(field.value).toBe('authored')
+ expect(window.location.pathname).toBe('/editor')
+ expect(routedPaths).not.toContain('/prior')
+ expect(useSettingsDirtyStore.getState().pendingLeave).toBeNull()
+ act(() => root.render( ))
+ window.history.back()
+ act(() => useSettingsDirtyStore.getState().confirmLeave())
+ await settle()
+ expect(window.location.pathname).toBe('/prior')
+ expect(field.value).toBe('')
+ })
+
+ it.each([-1.5, 4_294_967_295])(
+ 'confirms the browser-converted traversal delta %s',
+ async (delta) => {
+ act(() => root.render( ))
+ const field = container.querySelector('input[aria-label="Draft"]')
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
+ if (!field || !setter) throw new Error('Missing draft input')
+ act(() => {
+ setter.call(field, 'authored')
+ field.dispatchEvent(new Event('input', { bubbles: true }))
+ })
+ window.history.go(delta)
+ act(() => useSettingsDirtyStore.getState().confirmLeave())
+ await settle()
+ expect(window.location.pathname).toBe('/prior')
+ expect(field.value).toBe('')
+ expect(useSettingsDirtyStore.getState().pendingLeave).toBeNull()
+ }
+ )
+
+ it.each([false, true])(
+ 'retains a draft when an unresolved traversal only changes the hash (saving: %s)',
+ async (blocked) => {
+ window.history.pushState(new Date('2026-01-01T00:00:00Z'), '', '/editor#opaque')
+ act(() => root.render( ))
+ const field = container.querySelector('input[aria-label="Draft"]')
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
+ if (!field || !setter) throw new Error('Missing draft input')
+ act(() => {
+ setter.call(field, 'authored')
+ field.dispatchEvent(new Event('input', { bubbles: true }))
+ })
+ window.history.back()
+ act(() => useSettingsDirtyStore.getState().confirmLeave())
+ act(() => root.render( ))
+ await settle()
+ expect(field.value).toBe('authored')
+ expect(window.location.pathname).toBe('/editor')
+ expect(window.location.hash).toBe('')
+ expect(useSettingsDirtyStore.getState().isDirty).toBe(true)
+ }
+ )
+
+ it('does not reuse confirmation from a traversal that emitted no event for a later draft', async () => {
+ act(() => root.render( ))
+ const field = container.querySelector('input[aria-label="Draft"]')
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
+ if (!field || !setter) throw new Error('Missing draft input')
+ act(() => {
+ setter.call(field, 'original draft')
+ field.dispatchEvent(new Event('input', { bubbles: true }))
+ })
+ const steps = window.history.length
+ window.history.go(-steps)
+ act(() => useSettingsDirtyStore.getState().confirmLeave())
+ await settle()
+ expect(window.location.pathname).toBe('/editor')
+ expect(field.value).toBe('original draft')
+ for (let index = 0; index < steps; index++)
+ window.history.pushState({ router: 'later' }, '', `/later-${index}`)
+ const currentPath = window.location.pathname
+ act(() => {
+ setter.call(field, 'new draft')
+ field.dispatchEvent(new Event('input', { bubbles: true }))
+ })
+ nativeGo.call(window.history, -steps)
+ await settle()
+ expect(window.location.pathname).toBe(currentPath)
+ expect(routedPaths).toEqual([])
+ expect(field.value).toBe('new draft')
+ expect(useSettingsDirtyStore.getState().pendingLeave).not.toBeNull()
+ })
+
+ it.each(['pushState', 'replaceState'] as const)(
+ 'cancels a stale confirmation when %s changes its source',
+ async (method) => {
+ act(() => root.render( ))
+ const field = container.querySelector('input[aria-label="Draft"]')
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
+ if (!field || !setter) throw new Error('Missing draft input')
+ act(() => {
+ setter.call(field, 'authored')
+ field.dispatchEvent(new Event('input', { bubbles: true }))
+ })
+ window.history.back()
+ expect(useSettingsDirtyStore.getState().pendingLeave).not.toBeNull()
+ window.history[method]({ router: 'new-source' }, '', '/new-source')
+ expect(useSettingsDirtyStore.getState().pendingLeave).toBeNull()
+ act(() => useSettingsDirtyStore.getState().confirmLeave())
+ await settle()
+ expect(window.location.pathname).toBe('/new-source')
+ expect(field.value).toBe('authored')
+ }
+ )
+
+ it.each([
+ { kind: 'metadata-only replacement', native: false },
+ { kind: 'metadata-only replacement', native: true },
+ { kind: 'rejected push', native: false },
+ { kind: 'rejected replacement', native: false },
+ ])(
+ 'preserves the active confirmation after a $kind (native: $native)',
+ async ({ kind, native }) => {
+ render(true)
+ if (native) {
+ nativeGo.call(window.history, -1)
+ await settle()
+ } else window.history.back()
+ expect(useSettingsDirtyStore.getState().pendingLeave).not.toBeNull()
+ if (kind === 'metadata-only replacement')
+ window.history.replaceState({ router: 'updated-metadata' }, '', window.location.href)
+ else {
+ const method = kind === 'rejected push' ? 'pushState' : 'replaceState'
+ expect(() => window.history[method]({}, '', 'https://other.example.com')).toThrow()
+ }
+ expect(useSettingsDirtyStore.getState().pendingLeave).not.toBeNull()
+ act(() => useSettingsDirtyStore.getState().confirmLeave())
+ await settle()
+ expect(window.location.pathname).toBe('/prior')
+ }
+ )
+
+ it('supersedes an older leave dialog when programmatic traversal stays on the same page', async () => {
+ act(() => root.render( ))
+ const field = container.querySelector('input[aria-label="Draft"]')
+ const link = container.querySelector('a[href="/other"]')
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
+ if (!field || !link || !setter) throw new Error('Missing linked editor')
+ window.history.pushState({ router: 'hash' }, '', '/editor#details')
+ act(() => {
+ setter.call(field, 'authored')
+ field.dispatchEvent(new Event('input', { bubbles: true }))
+ link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
+ })
+ expect(useSettingsDirtyStore.getState().pendingLeave).not.toBeNull()
+ window.history.back()
+ await settle()
+ expect(useSettingsDirtyStore.getState().pendingLeave).toBeNull()
+ act(() => useSettingsDirtyStore.getState().confirmLeave())
+ expect(window.location.pathname).toBe('/editor')
+ expect(field.value).toBe('authored')
+ })
+
+ it('guards programmatic Forward to an entry created before tracking began', async () => {
+ nativePush.call(window.history, { router: 'legacy-forward' }, '', '/legacy-forward')
+ nativeGo.call(window.history, -1)
+ await settle()
+ routedPaths = []
+ render(true)
+ window.history.forward()
+ await settle()
+ expect(window.location.pathname).toBe('/editor')
+ expect(routedPaths).toEqual([])
+ expect(useSettingsDirtyStore.getState().pendingLeave).not.toBeNull()
+ act(() => useSettingsDirtyStore.getState().confirmLeave())
+ await settle()
+ expect(window.location.pathname).toBe('/legacy-forward')
+ })
+
+ it.each([false, true])(
+ 'does not guess an unindexed native direction and recovers indexed history after a rejected push: %s',
+ async (rejectPush) => {
+ nativePush.call(window.history, { router: 'legacy-forward' }, '', '/legacy-forward')
+ nativePush.call(window.history, { router: 'legacy-far' }, '', '/legacy-far')
+ nativeGo.call(window.history, -2)
+ await settle()
+ routedPaths = []
+ render(true)
+ nativeGo.call(window.history, 1)
+ await settle()
+ expect(window.location.pathname).toBe('/legacy-forward')
+ expect(routedPaths).toEqual(['/legacy-forward'])
+ if (rejectPush)
+ expect(() => window.history.pushState({}, '', 'https://other.example.com')).toThrow()
+ nativeGo.call(window.history, -1)
+ await settle()
+ expect(window.location.pathname).toBe('/editor')
+ nativeGo.call(window.history, -1)
+ await settle()
+ expect(window.location.pathname).toBe('/editor')
+ expect(useSettingsDirtyStore.getState().pendingLeave).not.toBeNull()
+ }
+ )
+
+ it('allows native traversal within the same page hash while a draft is protected', async () => {
+ window.history.pushState({ router: 'hash' }, '', '/editor#first')
+ render(true)
+ nativeGo.call(window.history, -1)
+ await settle()
+ expect(window.location.pathname).toBe('/editor')
+ expect(window.location.hash).toBe('')
+ expect(useSettingsDirtyStore.getState().pendingLeave).toBeNull()
+ })
+
+ it.each(['back', 'forward', 'go'] as const)(
+ 'preserves a draft during programmatic hash-only %s',
+ async (method) => {
+ act(() => root.render( ))
+ const field = container.querySelector('input[aria-label="Draft"]')
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
+ if (!field || !setter) throw new Error('Missing draft input')
+ window.history.pushState({ router: 'hash' }, '', '/editor#first')
+ if (method === 'forward') {
+ nativeGo.call(window.history, -1)
+ await settle()
+ }
+ act(() => {
+ setter.call(field, 'authored')
+ field.dispatchEvent(new Event('input', { bubbles: true }))
+ })
+ if (method === 'go') window.history.go(-1)
+ else window.history[method]()
+ await settle()
+ expect(useSettingsDirtyStore.getState().pendingLeave).toBeNull()
+ expect(window.location.hash).toBe(method === 'forward' ? '#first' : '')
+ expect(field.value).toBe('authored')
+ expect(useSettingsDirtyStore.getState().isDirty).toBe(true)
+ if (method !== 'forward') {
+ window.history.back()
+ await settle()
+ expect(window.location.pathname).toBe('/editor')
+ expect(useSettingsDirtyStore.getState().pendingLeave).not.toBeNull()
+ }
+ }
+ )
+
+ it('keeps Back and Forward on the edited page until discard is confirmed', async () => {
+ render(true)
+ nativeGo.call(window.history, -1)
+ await settle()
+ expect(window.location.pathname).toBe('/editor')
+ expect(routedPaths).toEqual([])
+ expect(useSettingsDirtyStore.getState().pendingLeave).not.toBeNull()
+ act(() => useSettingsDirtyStore.getState().cancelLeave())
+ expect(window.location.pathname).toBe('/editor')
+ nativeGo.call(window.history, -1)
+ await settle()
+ expect(useSettingsDirtyStore.getState().pendingLeave).not.toBeNull()
+ act(() => useSettingsDirtyStore.getState().confirmLeave())
+ await settle()
+ expect(window.location.pathname).toBe('/prior')
+ expect(routedPaths).toEqual(['/prior'])
+ expect(useSettingsDirtyStore.getState().isDirty).toBe(true)
+ nativeGo.call(window.history, 1)
+ await settle()
+ expect(window.location.pathname).toBe('/prior')
+ expect(useSettingsDirtyStore.getState().pendingLeave).not.toBeNull()
+ act(() => useSettingsDirtyStore.getState().confirmLeave())
+ await settle()
+ expect(window.location.pathname).toBe('/editor')
+ expect(window.history.state.router).toBe('editor')
+ })
+
+ it('keeps history indexing correct through a router wrapper and root effect remount', async () => {
+ const push = window.history.pushState
+ const replace = window.history.replaceState
+ window.history.pushState = (data, unused, url) => push.call(window.history, data, unused, url)
+ window.history.replaceState = (data, unused, url) =>
+ replace.call(window.history, data, unused, url)
+ act(() => root.unmount())
+ root = createRoot(container)
+ render(false)
+ window.history.back()
+ await settle()
+ expect(window.location.pathname).toBe('/prior')
+ window.history.pushState({ router: 'other' }, '', '/other')
+ render(true)
+ window.history.back()
+ await settle()
+ expect(window.location.pathname).toBe('/other')
+ expect(useSettingsDirtyStore.getState().pendingLeave).not.toBeNull()
+ act(() => useSettingsDirtyStore.getState().confirmLeave())
+ await settle()
+ expect(window.location.pathname).toBe('/prior')
+ })
+
+ it('keeps a rejected cross-origin push from corrupting blocked Back', async () => {
+ expect(() => window.history.pushState({}, '', 'https://other.example.com')).toThrow()
+ render(true)
+ window.history.back()
+ await settle()
+ expect(window.location.pathname).toBe('/editor')
+ expect(useSettingsDirtyStore.getState().pendingLeave).not.toBeNull()
+ })
+
+ it.each([1, 2])(
+ 'protects a %s-entry Back jump into history created before indexing began',
+ async (steps) => {
+ nativePush.call(window.history, { router: 'legacy' }, '', '/legacy')
+ if (steps === 2) nativePush.call(window.history, { router: 'near' }, '', '/legacy-near')
+ window.history.pushState({ router: 'editor' }, '', '/editor')
+ render(true)
+ window.history.go(-steps)
+ await settle()
+ expect(window.location.pathname).toBe('/editor')
+ expect(routedPaths).toEqual([])
+ expect(useSettingsDirtyStore.getState().pendingLeave).not.toBeNull()
+ act(() => useSettingsDirtyStore.getState().confirmLeave())
+ await settle()
+ expect(window.location.pathname).toBe('/legacy')
+ expect(window.history.state.router).toBe('legacy')
+ }
+ )
+
+ it('protects ordinary internal links and discards the draft before confirmed navigation', () => {
+ act(() => root.render( ))
+ const input = container.querySelector('input')
+ const link = container.querySelector('a')
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
+ if (!input || !link || !setter) throw new Error('Missing linked editor')
+ act(() => {
+ setter.call(input, 'Draft')
+ input.dispatchEvent(new Event('input', { bubbles: true }))
+ })
+ const click = new MouseEvent('click', { bubbles: true, cancelable: true })
+ act(() => link.dispatchEvent(click))
+ expect(click.defaultPrevented).toBe(true)
+ expect(window.location.pathname).toBe('/editor')
+ expect(useSettingsDirtyStore.getState().pendingLeave).not.toBeNull()
+ act(() => useSettingsDirtyStore.getState().confirmLeave())
+ expect(window.location.pathname).toBe('/other')
+ expect(container.querySelector('input')?.value).toBe('')
+ expect(useSettingsDirtyStore.getState().isDirty).toBe(false)
+ })
+
+ it('preserves an owned settings link navigation callback and replacement history', () => {
+ act(() => root.render( ))
+ const input = container.querySelector('input')
+ const link = container.querySelector('a[href="/managed"]')
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
+ if (!input || !link || !setter) throw new Error('Missing owned settings link')
+ act(() => {
+ setter.call(input, 'Draft')
+ input.dispatchEvent(new Event('input', { bubbles: true }))
+ })
+ const length = window.history.length
+ act(() => link.click())
+ expect(window.location.pathname).toBe('/editor')
+ act(() => useSettingsDirtyStore.getState().confirmLeave())
+ expect(window.location.pathname).toBe('/managed')
+ expect(window.history.length).toBe(length)
+ })
+
+ it('allows clean history traversal without adding entries across edit and revert cycles', async () => {
+ const length = window.history.length
+ render(true)
+ render(false)
+ render(true)
+ render(false)
+ await settle()
+ expect(window.history.length).toBe(length)
+ window.history.back()
+ await settle()
+ expect(window.location.pathname).toBe('/prior')
+ expect(useSettingsDirtyStore.getState().pendingLeave).toBeNull()
+ })
+
+ it('blocks unload and history while saving even after optimistic state becomes clean', async () => {
+ render(false, true)
+ const unload = new Event('beforeunload', { cancelable: true })
+ window.dispatchEvent(unload)
+ expect(unload.defaultPrevented).toBe(true)
+ window.history.back()
+ await settle()
+ expect(window.location.pathname).toBe('/editor')
+ expect(useSettingsDirtyStore.getState().pendingLeave).toBeNull()
+ render(false)
+ window.history.back()
+ await settle()
+ expect(window.location.pathname).toBe('/prior')
+ })
+})
diff --git a/apps/sim/components/settings/use-settings-browser-navigation.ts b/apps/sim/components/settings/use-settings-browser-navigation.ts
new file mode 100644
index 00000000000..02df06794c2
--- /dev/null
+++ b/apps/sim/components/settings/use-settings-browser-navigation.ts
@@ -0,0 +1,214 @@
+import { useEffect } from 'react'
+import { createLogger } from '@sim/logger'
+import { generateId } from '@sim/utils/id'
+import { isRecordLike, toRecord } from '@sim/utils/object'
+import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
+
+const HISTORY_INDEX = '__simSettingsIndex'
+const HISTORY_GENERATION = '__simSettingsGeneration'
+const TRACKER_INSTALLED = Symbol.for('sim.settings.historyTracker')
+const logger = createLogger('SettingsBrowserNavigation')
+
+interface TrackedHistory extends History {
+ [TRACKER_INSTALLED]?: boolean
+}
+
+/**
+ * Installs one document-lifetime tracker. Keeping the same wrappers through root
+ * effect remounts also keeps Next's captured history methods valid.
+ */
+function installBrowserNavigationGuard() {
+ const history: TrackedHistory = window.history
+ if (history[TRACKER_INSTALLED]) return
+ history[TRACKER_INSTALLED] = true
+ const originalPush = history.pushState
+ const originalReplace = history.replaceState
+ const originalGo = history.go
+ const navigation = window.navigation as typeof window.navigation | undefined
+ const initialState = toRecord(history.state)
+ let generation =
+ typeof initialState[HISTORY_GENERATION] === 'string'
+ ? initialState[HISTORY_GENERATION]
+ : generateId()
+ const entryIndex = (data: unknown): number | null => {
+ if (navigation?.currentEntry) return navigation.currentEntry.index
+ const state = toRecord(data)
+ return state[HISTORY_GENERATION] === generation && typeof state[HISTORY_INDEX] === 'number'
+ ? state[HISTORY_INDEX]
+ : null
+ }
+ let currentIndex: number | null = entryIndex(history.state) ?? 0
+ let restoring = false
+ let pendingDelta = 0
+ let allowTraversal = false
+ let warnedUnindexed = false
+ let currentUrl = new URL(window.location.href)
+ const trackedRoutes = new Map()
+ const route = (url: URL) => url.pathname + url.search
+
+ const stamp = (data: unknown, index: number, entryGeneration = generation) => {
+ const prototype = isRecordLike(data) ? Object.getPrototypeOf(data) : undefined
+ return data == null || prototype === Object.prototype || prototype === null
+ ? { ...toRecord(data), [HISTORY_INDEX]: index, [HISTORY_GENERATION]: entryGeneration }
+ : data
+ }
+ const cancelPendingTraversal = () => {
+ allowTraversal = false
+ restoring = false
+ pendingDelta = 0
+ useSettingsDirtyStore.getState().cancelLeave()
+ }
+
+ const traverse = (delta: number) => {
+ pendingDelta = delta
+ allowTraversal = true
+ originalGo.call(history, delta)
+ }
+
+ originalReplace.call(history, stamp(history.state, currentIndex), '', window.location.href)
+ trackedRoutes.set(currentIndex, route(currentUrl))
+
+ history.pushState = (data: unknown, unused, url) => {
+ const previousIndex = entryIndex(history.state)
+ const nextGeneration = previousIndex === null ? generateId() : generation
+ const nextIndex = (previousIndex ?? -1) + 1
+ originalPush.call(history, stamp(data, nextIndex, nextGeneration), unused, url)
+ cancelPendingTraversal()
+ if (generation !== nextGeneration) trackedRoutes.clear()
+ generation = nextGeneration
+ currentIndex = nextIndex
+ currentUrl = new URL(window.location.href)
+ for (const index of trackedRoutes.keys()) {
+ if (index > nextIndex || index < nextIndex - history.length + 1) trackedRoutes.delete(index)
+ }
+ trackedRoutes.set(nextIndex, route(currentUrl))
+ }
+ history.replaceState = (data: unknown, unused, url) => {
+ const index = entryIndex(history.state)
+ originalReplace.call(history, index === null ? data : stamp(data, index), unused, url)
+ if (index !== currentIndex || window.location.href !== currentUrl.href) cancelPendingTraversal()
+ currentIndex = index
+ currentUrl = new URL(window.location.href)
+ if (index !== null) trackedRoutes.set(index, route(currentUrl))
+ }
+
+ history.go = (delta) => {
+ // History.go converts its delta to a signed 32-bit integer before traversing.
+ delta = (delta ?? 0) | 0
+ cancelPendingTraversal()
+ if (!delta || Math.abs(delta) >= history.length) {
+ originalGo.call(history, delta)
+ return
+ }
+ const sourceIndex = entryIndex(history.state)
+ const targetIndex = sourceIndex === null ? null : sourceIndex + delta
+ const target = navigation?.entries().find((entry) => entry.index === targetIndex)
+ if (navigation?.currentEntry && (!target || !target.sameDocument)) {
+ originalGo.call(history, delta)
+ return
+ }
+ const targetRoute = navigation
+ ? target?.sameDocument && target.url
+ ? route(new URL(target.url))
+ : undefined
+ : targetIndex === null
+ ? undefined
+ : trackedRoutes.get(targetIndex)
+ const { isDirty, navigationBlocked, requestLeave } = useSettingsDirtyStore.getState()
+ if (targetRoute === route(new URL(window.location.href)) || (!isDirty && !navigationBlocked)) {
+ originalGo.call(history, delta)
+ return
+ }
+ requestLeave(() => traverse(delta), { discardOnConfirm: false })
+ }
+ history.back = () => history.go(-1)
+ history.forward = () => history.go(1)
+
+ window.addEventListener(
+ 'popstate',
+ (event) => {
+ const index = entryIndex(event.state)
+ const destinationUrl = new URL(window.location.href)
+ if (index !== null) trackedRoutes.set(index, route(destinationUrl))
+ if (!restoring && route(destinationUrl) === route(currentUrl)) {
+ currentIndex = index
+ currentUrl = destinationUrl
+ allowTraversal = false
+ return
+ }
+ if (
+ allowTraversal &&
+ (index === null || currentIndex === null || index - currentIndex === pendingDelta)
+ ) {
+ if (useSettingsDirtyStore.getState().navigationBlocked) {
+ event.stopImmediatePropagation()
+ allowTraversal = false
+ originalGo.call(history, -pendingDelta)
+ return
+ }
+ useSettingsDirtyStore.getState().discardDrafts()
+ currentIndex = index ?? (currentIndex === null ? null : currentIndex + pendingDelta)
+ allowTraversal = false
+ restoring = false
+ currentUrl = destinationUrl
+ if (index === null && currentIndex !== null)
+ history.replaceState(event.state, '', window.location.href)
+ return
+ }
+ allowTraversal = false
+ if (index === null || currentIndex === null) {
+ currentIndex = index
+ currentUrl = destinationUrl
+ restoring = false
+ pendingDelta = 0
+ if (!warnedUnindexed && index === null) {
+ warnedUnindexed = true
+ logger.warn('Cannot guard unindexed native history without the Navigation API')
+ }
+ return
+ }
+ const delta = index - currentIndex
+ if (restoring) {
+ event.stopImmediatePropagation()
+ if (delta !== 0) {
+ originalGo.call(history, -delta)
+ return
+ }
+ restoring = false
+ const requestedDelta = pendingDelta
+ useSettingsDirtyStore
+ .getState()
+ .requestLeave(() => traverse(requestedDelta), { discardOnConfirm: false })
+ return
+ }
+ const { isDirty, navigationBlocked } = useSettingsDirtyStore.getState()
+ if (!delta || (!isDirty && !navigationBlocked)) {
+ currentIndex = index
+ currentUrl = destinationUrl
+ allowTraversal = false
+ return
+ }
+ event.stopImmediatePropagation()
+ pendingDelta = delta
+ restoring = true
+ originalGo.call(history, -delta)
+ },
+ true
+ )
+}
+
+/**
+ * Protects refresh, programmatic traversal, and indexed native Back/Forward.
+ * Without the Navigation API, unindexed native entries (including legacy entries)
+ * cannot be guarded: popstate does not expose their traversal direction.
+ */
+export function useSettingsBrowserNavigation() {
+ const shouldBlock = useSettingsDirtyStore((state) => state.isDirty || state.navigationBlocked)
+ useEffect(installBrowserNavigationGuard, [])
+ useEffect(() => {
+ if (!shouldBlock) return
+ const preventUnload = (event: BeforeUnloadEvent) => event.preventDefault()
+ window.addEventListener('beforeunload', preventUnload)
+ return () => window.removeEventListener('beforeunload', preventUnload)
+ }, [shouldBlock])
+}
diff --git a/apps/sim/components/settings/use-settings-unsaved-guard.test.tsx b/apps/sim/components/settings/use-settings-unsaved-guard.test.tsx
new file mode 100644
index 00000000000..348be1e4233
--- /dev/null
+++ b/apps/sim/components/settings/use-settings-unsaved-guard.test.tsx
@@ -0,0 +1,102 @@
+/** @vitest-environment jsdom */
+
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { useSettingsUnsavedGuard } from '@/components/settings/use-settings-unsaved-guard'
+import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
+
+interface EditorProps {
+ dirty: boolean
+ blocked?: boolean
+}
+
+function Editor({ dirty, blocked }: EditorProps) {
+ useSettingsUnsavedGuard({ isDirty: dirty, navigationBlocked: blocked })
+ return null
+}
+
+let root: Root
+let container: HTMLDivElement
+
+beforeEach(() => {
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
+ useSettingsDirtyStore.getState().reset()
+ container = document.createElement('div')
+ root = createRoot(container)
+})
+
+afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+})
+
+function render(first: EditorProps, second?: EditorProps) {
+ act(() =>
+ root.render(
+ <>
+
+ {second && }
+ >
+ )
+ )
+}
+
+function attemptLeave() {
+ let left = false
+ act(() =>
+ useSettingsDirtyStore.getState().requestLeave(() => {
+ left = true
+ })
+ )
+ return left
+}
+
+describe('settings navigation across multiple editors', () => {
+ it('keeps a dirty editor protected when a clean sibling mounts and unmounts', () => {
+ render({ dirty: true }, { dirty: false })
+ expect(attemptLeave()).toBe(false)
+ render({ dirty: true })
+ expect(attemptLeave()).toBe(false)
+ render({ dirty: false })
+ expect(attemptLeave()).toBe(true)
+ })
+
+ it('keeps a mounted draft protected after an attempted confirmed departure', () => {
+ render({ dirty: true })
+ let departures = 0
+ act(() =>
+ useSettingsDirtyStore.getState().requestLeave(() => {
+ departures++
+ })
+ )
+ act(() => useSettingsDirtyStore.getState().confirmLeave())
+ expect(departures).toBe(1)
+ expect(attemptLeave()).toBe(false)
+ })
+
+ it('cancels a pending discard when every editor becomes clean without navigating', () => {
+ render({ dirty: true })
+ let departures = 0
+ act(() =>
+ useSettingsDirtyStore.getState().requestLeave(() => {
+ departures++
+ })
+ )
+ render({ dirty: false })
+ expect(useSettingsDirtyStore.getState().pendingLeave).toBeNull()
+ act(() => useSettingsDirtyStore.getState().confirmLeave())
+ expect(departures).toBe(0)
+ expect(attemptLeave()).toBe(true)
+ })
+
+ it('keeps navigation blocked until the last pending editor finishes', () => {
+ render({ dirty: false, blocked: true }, { dirty: false, blocked: false })
+ expect(attemptLeave()).toBe(false)
+ expect(useSettingsDirtyStore.getState().pendingLeave).toBeNull()
+ render({ dirty: false, blocked: true })
+ expect(attemptLeave()).toBe(false)
+ render({ dirty: false, blocked: false })
+ expect(attemptLeave()).toBe(true)
+ })
+})
diff --git a/apps/sim/components/settings/use-settings-unsaved-guard.ts b/apps/sim/components/settings/use-settings-unsaved-guard.ts
index a9f272a652d..51279763874 100644
--- a/apps/sim/components/settings/use-settings-unsaved-guard.ts
+++ b/apps/sim/components/settings/use-settings-unsaved-guard.ts
@@ -1,73 +1,43 @@
-import { useCallback, useEffect, useRef, useState } from 'react'
+import { useCallback, useEffect, useId, useRef } from 'react'
import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
interface UseSettingsUnsavedGuardParams {
isDirty: boolean
navigationBlocked?: boolean
+ onDiscard?: () => void
}
interface SettingsUnsavedGuard {
- showUnsavedModal: boolean
- setShowUnsavedModal: (open: boolean) => void
guardBack: (onLeave: () => void) => void
- confirmDiscard: () => void
}
-/**
- * Connects section-local dirty state to shared settings navigation guards.
- */
+/** Registers one editor with the shared settings navigation guard. */
export function useSettingsUnsavedGuard({
isDirty,
navigationBlocked = false,
+ onDiscard,
}: UseSettingsUnsavedGuardParams): SettingsUnsavedGuard {
- const setDirty = useSettingsDirtyStore((state) => state.setDirty)
- const setNavigationBlocked = useSettingsDirtyStore((state) => state.setNavigationBlocked)
- const reset = useSettingsDirtyStore((state) => state.reset)
- const isDirtyRef = useRef(isDirty)
- const navigationBlockedRef = useRef(navigationBlocked)
- const pendingLeaveRef = useRef<(() => void) | null>(null)
- const [showUnsavedModal, setShowUnsavedModal] = useState(false)
+ const id = useId()
+ const discardRef = useRef(onDiscard)
+ const setGuard = useSettingsDirtyStore((state) => state.setGuard)
+ const removeGuard = useSettingsDirtyStore((state) => state.removeGuard)
+ const requestLeave = useSettingsDirtyStore((state) => state.requestLeave)
+ const hasDiscard = Boolean(onDiscard)
+ const discardDraft = useCallback(() => discardRef.current?.(), [])
+ const guardBack = useCallback(
+ (onLeave: () => void) => {
+ requestLeave(onLeave)
+ },
+ [requestLeave]
+ )
useEffect(() => {
- isDirtyRef.current = isDirty
- navigationBlockedRef.current = navigationBlocked
- setDirty(isDirty)
- setNavigationBlocked(navigationBlocked)
- if (navigationBlocked) {
- pendingLeaveRef.current = null
- setShowUnsavedModal(false)
- return
- }
- if (!isDirty) {
- pendingLeaveRef.current = null
- setShowUnsavedModal(false)
- }
- }, [isDirty, navigationBlocked, setDirty, setNavigationBlocked])
-
+ discardRef.current = onDiscard
+ }, [onDiscard])
useEffect(() => {
- return () => reset()
- }, [reset])
-
- const guardBack = useCallback((onLeave: () => void) => {
- if (navigationBlockedRef.current || useSettingsDirtyStore.getState().navigationBlocked) {
- return
- }
- if (isDirtyRef.current) {
- pendingLeaveRef.current = onLeave
- setShowUnsavedModal(true)
- return
- }
- onLeave()
- }, [])
-
- const confirmDiscard = useCallback(() => {
- if (navigationBlockedRef.current || useSettingsDirtyStore.getState().navigationBlocked) {
- return
- }
- setShowUnsavedModal(false)
- pendingLeaveRef.current?.()
- pendingLeaveRef.current = null
- }, [])
+ setGuard(id, { isDirty, navigationBlocked, onDiscard: hasDiscard ? discardDraft : undefined })
+ }, [id, isDirty, navigationBlocked, hasDiscard, setGuard, discardDraft])
+ useEffect(() => () => removeGuard(id), [id, removeGuard])
- return { showUnsavedModal, setShowUnsavedModal, guardBack, confirmDiscard }
+ return { guardBack }
}
diff --git a/apps/sim/ee/access-control/components/group-detail.test.tsx b/apps/sim/ee/access-control/components/group-detail.test.tsx
index 3ca858c2a35..fd82f0b8f7e 100644
--- a/apps/sim/ee/access-control/components/group-detail.test.tsx
+++ b/apps/sim/ee/access-control/components/group-detail.test.tsx
@@ -14,8 +14,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { PermissionGroup } from '@/lib/api/contracts/permission-groups'
import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields'
import { GroupDetail } from '@/ee/access-control/components/group-detail'
+import { allowedProvidersKeys } from '@/hooks/queries/allowed-providers'
import { organizationKeys } from '@/hooks/queries/utils/organization-keys'
import { permissionGroupKeys } from '@/hooks/queries/utils/permission-group-keys'
+import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
vi.mock('@/lib/auth/auth-client', () => authClientMock)
vi.mock('next/navigation', () => nextNavigationMock)
@@ -68,13 +70,13 @@ afterEach(() => {
vi.useRealTimers()
})
-function render() {
+function render(currentGroup = group) {
act(() =>
root.render(
{
+ it('stops warning after restoring the same provider membership in a different order', async () => {
+ providersUtilsMockFns.mockGetAllProviderIds.mockReturnValue(['openai', 'anthropic', 'google'])
+ client.setQueryData(allowedProvidersKeys.blacklisted(), { blacklistedProviders: [] })
+ render({
+ ...group,
+ config: { ...group.config, allowedModelProviders: ['openai', 'anthropic'] },
+ })
+ const toggle = container.querySelector('#provider-openai')
+ if (!toggle) throw new Error('Missing provider toggle')
+ act(() => toggle.click())
+ let left = false
+ act(() =>
+ useSettingsDirtyStore.getState().requestLeave(() => {
+ left = true
+ })
+ )
+ expect(left).toBe(false)
+ act(() => toggle.click())
+ act(() =>
+ useSettingsDirtyStore.getState().requestLeave(() => {
+ left = true
+ })
+ )
+ expect(left).toBe(true)
+ })
+
it('withholds provider edits until the server policy is known', () => {
vi.stubGlobal(
'fetch',
diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx
index ee7190065d5..b0e74779817 100644
--- a/apps/sim/ee/access-control/components/group-detail.tsx
+++ b/apps/sim/ee/access-control/components/group-detail.tsx
@@ -30,6 +30,7 @@ import { getErrorMessage } from '@sim/utils/errors'
import { formatDate } from '@sim/utils/formatting'
import { useQueryState } from 'nuqs'
import { saveDiscardActions } from '@/components/settings/save-discard-actions'
+import { useSettingsUnsavedGuard } from '@/components/settings/use-settings-unsaved-guard'
import type { ShareAuthType } from '@/lib/api/contracts/public-shares'
import { isAccessControlAllowlistRow } from '@/lib/permission-groups/block-access'
import {
@@ -39,7 +40,6 @@ import {
PLATFORM_FEATURES,
} from '@/lib/permission-groups/features'
import type { PermissionGroupConfig } from '@/lib/permission-groups/fields'
-import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail'
import {
groupSearchParam,
groupSearchUrlKeys,
@@ -56,7 +56,6 @@ import {
} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
-import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard'
import { getAllBlocks } from '@/blocks'
import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay'
import type { BlockConfig } from '@/blocks/types'
@@ -873,9 +872,19 @@ export function GroupDetail({
}))
}, [platformCategories])
- const hasConfigChanges = useMemo(() => {
- return JSON.stringify(viewingGroup.config) !== JSON.stringify(editingConfig)
- }, [viewingGroup.config, editingConfig])
+ const hasConfigChanges = (Object.keys(editingConfig) as (keyof PermissionGroupConfig)[]).some(
+ (key) => {
+ const saved = viewingGroup.config[key]
+ const edited = editingConfig[key]
+ if (!Array.isArray(saved) || !Array.isArray(edited)) return saved !== edited
+ const savedMembers = new Set(saved)
+ const editedMembers = new Set(edited)
+ return (
+ savedMembers.size !== editedMembers.size ||
+ [...savedMembers].some((member) => !editedMembers.has(member))
+ )
+ }
+ )
// Both buffers are seeded trimmed and compared against a trimmed baseline. The
// contract trims name and description on write, but a row stored before those
@@ -888,7 +897,11 @@ export function GroupDetail({
const descriptionChanged = trimmedDescription !== (viewingGroup.description ?? '').trim()
const hasChanges = hasConfigChanges || nameChanged || descriptionChanged
- const guard = useSettingsUnsavedGuard({ isDirty: hasChanges })
+ const guard = useSettingsUnsavedGuard({
+ isDirty: hasChanges,
+ navigationBlocked: updatePermissionGroup.isPending || deletePermissionGroup.isPending,
+ onDiscard: () => handleDiscardConfig(),
+ })
const allBlockTypes = useMemo(() => allBlocks.map((b) => b.type), [allBlocks])
@@ -1908,12 +1921,6 @@ export function GroupDetail({
pendingLabel: 'Deleting...',
}}
/>
-
-
>
)
}
diff --git a/apps/sim/ee/custom-blocks/components/custom-block-detail.test.tsx b/apps/sim/ee/custom-blocks/components/custom-block-detail.test.tsx
new file mode 100644
index 00000000000..4b5a7b02990
--- /dev/null
+++ b/apps/sim/ee/custom-blocks/components/custom-block-detail.test.tsx
@@ -0,0 +1,453 @@
+/** @vitest-environment jsdom */
+
+import { act } from 'react'
+import { toast } from '@sim/emcn'
+import { flushMicrotasks } from '@sim/testing/helpers/async'
+import { createDeferred } from '@sim/testing/helpers/deferred'
+import { jsonResponse } from '@sim/testing/helpers/http'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { SettingsHeaderProvider, SettingsHeaderShell } from '@/components/settings/settings-header'
+import {
+ type CustomBlock,
+ type UpdateCustomBlockBody,
+ updateCustomBlockBodySchema,
+} from '@/lib/api/contracts/custom-blocks'
+import * as uploads from '@/lib/uploads/client/session-upload'
+import { CustomBlockDetail } from '@/ee/custom-blocks/components/custom-block-detail'
+import { customBlockKeys } from '@/hooks/queries/custom-blocks'
+import { deploymentKeys } from '@/hooks/queries/deployments'
+import { workflowKeys } from '@/hooks/queries/utils/workflow-keys'
+import { workspaceKeys } from '@/hooks/queries/workspace'
+import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
+
+vi.mock('@/lib/workflows/blocks/block-outputs', () => ({
+ getEffectiveBlockOutputs: () => ({ response: { type: 'string' } }),
+}))
+
+const savedBlock: CustomBlock = {
+ id: 'block-a',
+ organizationId: 'org-a',
+ workspaceId: 'workspace-a',
+ workflowId: 'workflow-a',
+ workflowName: 'Workflow A',
+ workspaceName: 'Workspace A',
+ type: 'custom_block_a',
+ name: 'Block A',
+ description: 'Saved description',
+ enabled: true,
+ iconUrl: null,
+ traceChildRuns: false,
+ inputFields: [{ id: 'input-a', name: 'first', type: 'string' }],
+ exposedOutputs: [{ blockId: 'result', path: 'response', name: 'response' }],
+}
+
+let root: Root
+let container: HTMLDivElement
+let queryClient: QueryClient
+let departures: number
+
+function setBlock(block: CustomBlock) {
+ queryClient.setQueryData(customBlockKeys.list('workspace-a'), {
+ enabled: true,
+ customBlocks: [block],
+ })
+}
+
+function setDeployment(fields = savedBlock.inputFields, includeResult = true) {
+ queryClient.setQueryData(deploymentKeys.deployedState('workflow-a'), {
+ blocks: {
+ start: {
+ id: 'start',
+ type: 'starter',
+ name: 'Start',
+ subBlocks: { inputFormat: { value: fields } },
+ },
+ ...(includeResult ? { result: { id: 'result', type: 'function', name: 'Result' } } : {}),
+ },
+ edges: [],
+ })
+}
+
+async function render(blockId: string | null = savedBlock.id) {
+ await act(async () => {
+ root.render(
+
+
+
+ {
+ departures++
+ }}
+ />
+
+
+
+ )
+ await vi.runOnlyPendingTimersAsync()
+ })
+}
+
+async function refresh(update: () => void) {
+ await act(async () => {
+ update()
+ await vi.runOnlyPendingTimersAsync()
+ })
+}
+
+function expectNavigation(allowed: boolean) {
+ let left = false
+ act(() => {
+ useSettingsDirtyStore.getState().requestLeave(() => {
+ left = true
+ })
+ })
+ expect(left).toBe(allowed)
+ useSettingsDirtyStore.getState().cancelLeave()
+}
+
+function field(selector: string): T {
+ const element = container.querySelector(selector)
+ if (!element) throw new Error(`Missing field: ${selector}`)
+ return element
+}
+
+function edit(selector: string, value: string) {
+ const element = field(selector)
+ const prototype =
+ element instanceof HTMLTextAreaElement
+ ? HTMLTextAreaElement.prototype
+ : HTMLInputElement.prototype
+ const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
+ if (!setter) throw new Error('Missing native value setter')
+ act(() => {
+ setter.call(element, value)
+ element.dispatchEvent(new Event('input', { bubbles: true }))
+ })
+}
+
+function clickButton(text: string) {
+ const button = Array.from(container.querySelectorAll('button')).find(
+ (element) => element.textContent?.trim() === text
+ )
+ if (!button) throw new Error(`Missing button: ${text}`)
+ act(() => {
+ button.click()
+ })
+}
+
+beforeEach(() => {
+ vi.spyOn(toast, 'success').mockReturnValue('toast-a')
+ vi.useFakeTimers()
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
+ vi.stubGlobal(
+ 'ResizeObserver',
+ class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ }
+ )
+ queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false, gcTime: Number.POSITIVE_INFINITY } },
+ })
+ setBlock(savedBlock)
+ setDeployment()
+ queryClient.setQueryData(customBlockKeys.usages(savedBlock.id), { usageCount: 0 })
+ queryClient.setQueryData(workspaceKeys.list(), {
+ workspaces: [
+ { id: 'workspace-a', name: 'Workspace A', organizationId: 'org-a', permissions: 'admin' },
+ ],
+ })
+ queryClient.setQueryData(workflowKeys.list('workspace-a', 'active'), [])
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ departures = 0
+})
+
+afterEach(async () => {
+ await act(async () => {
+ root.unmount()
+ await flushMicrotasks()
+ })
+ queryClient.clear()
+ container.remove()
+ vi.useRealTimers()
+})
+
+describe('custom block unsaved changes', () => {
+ it.each([
+ { selector: 'input[placeholder="Invoice Parser"]', saved: savedBlock.name, separator: ' ' },
+ { selector: 'textarea', saved: savedBlock.description ?? '', separator: '\n' },
+ ])(
+ 'preserves a typed separator while appending text in $selector',
+ async ({ selector, saved, separator }) => {
+ await render()
+ edit(selector, saved + separator)
+ expectNavigation(true)
+ const input = field(selector)
+ expect(input.value).toBe(saved + separator)
+ edit(selector, `${input.value}continued`)
+ expect(input.value).toBe(`${saved + separator}continued`)
+ expectNavigation(false)
+ }
+ )
+
+ it.each(['added input', 'removed output', 'refreshed saved values'] as const)(
+ 'allows tab and back navigation after %s without an edit',
+ async (change) => {
+ await render()
+ expectNavigation(true)
+ await refresh(() => {
+ if (change === 'added input') {
+ setDeployment([
+ ...savedBlock.inputFields,
+ { id: 'input-b', name: 'second', type: 'string' },
+ ])
+ } else if (change === 'removed output') {
+ setDeployment(savedBlock.inputFields, false)
+ } else {
+ setBlock({
+ ...savedBlock,
+ name: 'Updated block',
+ description: 'Refreshed description',
+ iconUrl: '/api/files/serve/saved-icon',
+ traceChildRuns: true,
+ })
+ }
+ })
+ expectNavigation(true)
+ clickButton('Custom blocks')
+ expect(departures).toBe(1)
+ }
+ )
+
+ it('starts clean when saved text has padding and input overrides are empty', async () => {
+ setBlock({
+ ...savedBlock,
+ name: ' Block A ',
+ description: ' Saved description ',
+ inputFields: [{ ...savedBlock.inputFields[0], placeholder: ' ', required: false }],
+ })
+ await render()
+ expectNavigation(true)
+ })
+
+ it('keeps a real edit and its baseline across a refresh, and becomes clean on revert', async () => {
+ await render()
+ edit('input[placeholder="Invoice Parser"]', 'My draft')
+ await refresh(() =>
+ setBlock({ ...savedBlock, name: 'Server draft', description: 'Server update' })
+ )
+ expect(field('input[placeholder="Invoice Parser"]').value).toBe('My draft')
+ expectNavigation(false)
+ edit('input[placeholder="Invoice Parser"]', savedBlock.name)
+ expectNavigation(true)
+ expect(field('textarea').value).toBe('Server update')
+ })
+
+ it('discards to the latest saved values after a refresh', async () => {
+ await render()
+ edit('textarea', 'Unsaved description')
+ await refresh(() => setBlock({ ...savedBlock, description: 'Server update' }))
+ clickButton('Discard')
+ expect(field('textarea').value).toBe('Server update')
+ expectNavigation(true)
+ })
+
+ it('becomes clean when an override on a newly deployed input is reverted', async () => {
+ await render()
+ await refresh(() =>
+ setDeployment([...savedBlock.inputFields, { id: 'input-b', name: 'second', type: 'string' }])
+ )
+ const row = Array.from(container.querySelectorAll('[role="button"]')).find(
+ (element) => element.textContent?.includes('second')
+ )
+ if (!row) throw new Error('Missing input row')
+ act(() => {
+ row.click()
+ })
+ const required = field('#input-required-input-b')
+ act(() => {
+ required.click()
+ })
+ expectNavigation(false)
+ act(() => {
+ required.click()
+ })
+ expectNavigation(true)
+ })
+
+ it('retains edits to an output when that output disappears from the deployment', async () => {
+ await render()
+ edit('input[placeholder="name"]', 'renamed')
+ await refresh(() => setDeployment(savedBlock.inputFields, false))
+ expectNavigation(false)
+ clickButton('Discard')
+ expectNavigation(true)
+ })
+
+ it('preserves icon removal through refresh and restores the newest icon on discard', async () => {
+ setBlock({ ...savedBlock, iconUrl: '/api/files/serve/original-icon' })
+ await render()
+ act(() => {
+ field('button[aria-label="Remove icon"]').click()
+ })
+ expectNavigation(false)
+ await refresh(() => setBlock({ ...savedBlock, iconUrl: '/api/files/serve/refreshed-icon' }))
+ expect(container.querySelector('img')).toBeNull()
+ expectNavigation(false)
+ clickButton('Discard')
+ expect(container.querySelector('img')?.getAttribute('src')).toBe(
+ '/api/files/serve/refreshed-icon'
+ )
+ expectNavigation(true)
+ })
+
+ it('keeps an untouched creation form clean as eligible workspaces arrive', async () => {
+ queryClient.setQueryData(workspaceKeys.list(), { workspaces: [] })
+ await render(null)
+ await refresh(() =>
+ queryClient.setQueryData(workspaceKeys.list(), {
+ workspaces: [
+ { id: 'workspace-a', name: 'Workspace A', organizationId: 'org-a', permissions: 'write' },
+ { id: 'workspace-b', name: 'Workspace B', organizationId: 'org-a', permissions: 'admin' },
+ ],
+ })
+ )
+ expectNavigation(true)
+ })
+
+ it('saves a text edit without deleting an icon added by a background refresh', async () => {
+ let submitted: UpdateCustomBlockBody | undefined
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
+ if (init?.method === 'PATCH' && typeof init.body === 'string') {
+ submitted = updateCustomBlockBodySchema.parse(JSON.parse(init.body))
+ return jsonResponse({ success: true })
+ }
+ return jsonResponse({ enabled: true, customBlocks: [savedBlock] })
+ })
+ await render()
+ edit('input[placeholder="Invoice Parser"]', 'My draft')
+ await refresh(() => setBlock({ ...savedBlock, iconUrl: '/api/files/serve/refreshed-icon' }))
+ clickButton('Save')
+ await act(async () => {
+ await flushMicrotasks(10)
+ await vi.runOnlyPendingTimersAsync()
+ })
+ expect(submitted).toEqual({
+ name: 'My draft',
+ description: 'Saved description',
+ inputs: [],
+ exposedOutputs: [{ blockId: 'result', path: 'response', name: 'response' }],
+ traceChildRuns: false,
+ })
+ expect(departures).toBe(1)
+ })
+
+ it('keeps the submitted custom-block snapshot stable while saving', async () => {
+ const request = createDeferred()
+ vi.spyOn(globalThis, 'fetch').mockImplementation((_url, init) =>
+ init?.method === 'PATCH'
+ ? request.promise
+ : Promise.resolve(jsonResponse({ enabled: true, customBlocks: [savedBlock] }))
+ )
+ await render()
+ edit('input[placeholder="Invoice Parser"]', 'Submitted name')
+ clickButton('Save')
+ await act(async () => {
+ await flushMicrotasks(10)
+ await vi.advanceTimersByTimeAsync(50)
+ })
+ edit('input[placeholder="Invoice Parser"]', 'Later name')
+ expect(field('input[placeholder="Invoice Parser"]').value).toBe(
+ 'Submitted name'
+ )
+ await act(async () => {
+ request.resolve(jsonResponse({ success: true }))
+ await flushMicrotasks(10)
+ await vi.runOnlyPendingTimersAsync()
+ })
+ expect(departures).toBe(1)
+ })
+
+ it('retains unsaved edits and navigation protection when saving fails', async () => {
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) =>
+ init?.method === 'PATCH'
+ ? jsonResponse({ error: 'Save rejected' }, 500)
+ : jsonResponse({ enabled: true, customBlocks: [savedBlock] })
+ )
+ await render()
+ edit('input[placeholder="Invoice Parser"]', 'My draft')
+ clickButton('Save')
+ await act(async () => {
+ await flushMicrotasks(10)
+ await vi.runOnlyPendingTimersAsync()
+ })
+ expect(departures).toBe(0)
+ expect(field('input[placeholder="Invoice Parser"]').value).toBe('My draft')
+ expectNavigation(false)
+ })
+
+ it('blocks discard and navigation during an icon upload, then allows discard after failure', async () => {
+ const upload = createDeferred()
+ vi.spyOn(uploads, 'uploadInternalFileSession').mockImplementation(() => upload.promise)
+ vi.stubGlobal(
+ 'URL',
+ class extends URL {
+ static createObjectURL() {
+ return 'blob:pending-icon'
+ }
+ static revokeObjectURL() {}
+ }
+ )
+ await render()
+ edit('textarea', 'My draft')
+ const picker = field('input[type="file"]')
+ Object.defineProperty(picker, 'files', {
+ value: [new File(['icon'], 'icon.png', { type: 'image/png' })],
+ })
+ act(() => {
+ picker.dispatchEvent(new Event('change', { bubbles: true }))
+ })
+ const secondDrop = new Event('drop', { bubbles: true, cancelable: true })
+ Object.defineProperty(secondDrop, 'dataTransfer', {
+ value: { files: [new File(['icon'], 'second.png', { type: 'image/png' })], types: ['Files'] },
+ })
+ act(() => {
+ field('button[aria-label="Change icon"]').dispatchEvent(secondDrop)
+ })
+ expect(secondDrop.defaultPrevented).toBe(true)
+ clickButton('Custom blocks')
+ expect(departures).toBe(0)
+ act(() => {
+ useSettingsDirtyStore.getState().requestLeave(() => {
+ departures++
+ })
+ })
+ expect(useSettingsDirtyStore.getState().pendingLeave).toBeNull()
+ const discard = Array.from(container.querySelectorAll('button')).find(
+ (element) => element.textContent?.trim() === 'Discard'
+ )
+ if (!discard) throw new Error('Missing discard action')
+ expect(discard.disabled).toBe(true)
+ act(() => {
+ discard.click()
+ })
+ await refresh(() => setBlock({ ...savedBlock, description: 'Server update' }))
+ await act(async () => {
+ upload.reject(new Error('Upload rejected'))
+ await flushMicrotasks(10)
+ await vi.runOnlyPendingTimersAsync()
+ })
+ expect(field('textarea').value).toBe('My draft')
+ expectNavigation(false)
+ clickButton('Discard')
+ expect(field('textarea').value).toBe('Server update')
+ expectNavigation(true)
+ })
+})
diff --git a/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx b/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx
index 3b79b88f587..6f5a2207c49 100644
--- a/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx
+++ b/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx
@@ -20,18 +20,19 @@ import {
} from '@sim/emcn'
import { ArrowLeft, ChevronDown, X } from '@sim/emcn/icons'
import { getErrorMessage } from '@sim/utils/errors'
+import { compareStrings } from '@sim/utils/string'
import { saveDiscardActions } from '@/components/settings/save-discard-actions'
+import { useSettingsUnsavedGuard } from '@/components/settings/use-settings-unsaved-guard'
import {
type FlattenOutputsBlockInput,
type FlattenOutputsEdgeInput,
flattenWorkflowOutputs,
} from '@/lib/workflows/blocks/flatten-outputs'
+import type { CustomBlockInputPlaceholder } from '@/lib/workflows/custom-blocks/settings-input'
import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
-import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail'
import { DropZone } from '@/app/workspace/[workspaceId]/components/drop-zone'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
import { useProfilePictureUpload } from '@/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload'
-import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard'
import {
type CustomBlockInput,
type CustomBlockOutput,
@@ -76,14 +77,25 @@ interface CustomBlockDetailProps {
onBack: () => void
}
+interface CustomBlockFormValues {
+ name: string
+ description: string
+ inputs: CustomBlockInput[]
+ outputs: CustomBlockOutput[]
+ traceChildRuns: boolean
+ iconUrl: string | null
+}
+
+interface CustomBlockDraft {
+ baseline: CustomBlockFormValues
+ values: CustomBlockFormValues
+}
+
export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockDetailProps) {
const isCreate = blockId === null
const { data: blocks = [] } = useCustomBlocks(workspaceId)
- const existing = useMemo(
- () => (blockId ? (blocks.find((b) => b.id === blockId) ?? null) : null),
- [blocks, blockId]
- )
+ const existing = blockId ? (blocks.find((b) => b.id === blockId) ?? null) : null
const publish = usePublishCustomBlock()
const update = useUpdateCustomBlock()
@@ -101,26 +113,15 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
// Custom blocks are org-scoped and the settings list only shows the current org's
// blocks, so a block published to another org's workspace would silently never
// appear here. Restrict the picker to workspaces in the current workspace's org.
- const currentOrgId = useMemo(
- () => workspaces.find((w) => w.id === workspaceId)?.organizationId ?? null,
- [workspaces, workspaceId]
- )
+ const currentOrgId = workspaces.find((w) => w.id === workspaceId)?.organizationId ?? null
// Only workspaces the user can publish from (admin) — the publish route requires
// admin on the source workspace, so a member/read workspace can never be a source.
- const orgWorkspaces = useMemo(
- () =>
- currentOrgId
- ? workspaces.filter((w) => w.organizationId === currentOrgId && w.permissions === 'admin')
- : [],
- [workspaces, currentOrgId]
- )
- const eligibleDefaultWorkspaceId = useMemo(
- () =>
- orgWorkspaces.some((w) => w.id === workspaceId)
- ? workspaceId
- : (orgWorkspaces[0]?.id ?? workspaceId),
- [orgWorkspaces, workspaceId]
- )
+ const orgWorkspaces = currentOrgId
+ ? workspaces.filter((w) => w.organizationId === currentOrgId && w.permissions === 'admin')
+ : []
+ const eligibleDefaultWorkspaceId = orgWorkspaces.some((w) => w.id === workspaceId)
+ ? workspaceId
+ : (orgWorkspaces[0]?.id ?? workspaceId)
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState(workspaceId)
// Once the eligible list loads, snap an ineligible selection (e.g. the current
// workspace when the user isn't its admin) to the first workspace they can publish from.
@@ -136,13 +137,17 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
const workflowId = isCreate ? selectedWorkflowId : (existing?.workflowId ?? '')
- const [name, setName] = useState(existing?.name ?? '')
- const [description, setDescription] = useState(existing?.description ?? '')
- const [inputs, setInputs] = useState(() =>
- toCustomBlockInputs(existing?.inputFields)
- )
- const [outputs, setOutputs] = useState(() => existing?.exposedOutputs ?? [])
- const [traceChildRuns, setTraceChildRuns] = useState(existing?.traceChildRuns ?? false)
+ const savedValues: CustomBlockFormValues = {
+ name: existing?.name ?? '',
+ description: existing?.description ?? '',
+ inputs: toCustomBlockInputs(existing?.inputFields),
+ outputs: existing?.exposedOutputs ?? [],
+ traceChildRuns: existing?.traceChildRuns ?? false,
+ iconUrl: existing?.iconUrl ?? null,
+ }
+ const [draft, setDraft] = useState(null)
+ const values = draft?.values ?? savedValues
+ const { name, description, inputs, outputs, traceChildRuns } = values
const [error, setError] = useState(null)
const [showDelete, setShowDelete] = useState(false)
@@ -159,22 +164,9 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
const usageCountsQuery = useCustomBlockUsageCounts(existing?.id, { enabled: canManageBlock })
const usageCount = usageCountsQuery.data?.usageCount ?? 0
- // Edit mode may mount before `useCustomBlocks` has resolved this row, leaving the
- // buffers empty. Reseed them the first time the block's identity loads (or when it
- // changes) — keyed on `existing.id` so a later refetch of the SAME block doesn't
- // clobber in-progress edits. (The icon reseeds itself via `currentImage`.)
- const [seededId, setSeededId] = useState(existing?.id ?? null)
- if (existing && existing.id !== seededId) {
- setSeededId(existing.id)
- setName(existing.name)
- setDescription(existing.description ?? '')
- setInputs(toCustomBlockInputs(existing.inputFields))
- setOutputs(existing.exposedOutputs ?? [])
- setTraceChildRuns(existing.traceChildRuns)
- }
-
const iconUpload = useProfilePictureUpload({
- currentImage: existing?.iconUrl ?? null,
+ currentImage: values.iconUrl,
+ onUpload: (iconUrl) => updateForm((current) => ({ ...current, iconUrl })),
onError: (e) => setError(e),
context: 'workspace-logos',
workspaceId,
@@ -208,23 +200,19 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
// Every deployed Start input is exposed (no selection). Name/type/description are
// inherited from the field itself (the Start block already defines them); only
// the placeholder and required flag are authored here.
- const visibleInputs = useMemo(
- () =>
- deployedLoaded
- ? availableFields.map((f) => {
- const id = f.id ?? f.name
- return {
- id,
- name: f.name,
- type: f.type,
- description: f.description,
- placeholder: overrideById.get(id)?.placeholder,
- required: overrideById.get(id)?.required,
- }
- })
- : inputs,
- [deployedLoaded, availableFields, overrideById, inputs]
- )
+ const visibleInputs: CustomBlockInput[] = deployedLoaded
+ ? availableFields.map((f) => {
+ const id = f.id ?? f.name
+ return {
+ id,
+ name: f.name,
+ type: f.type,
+ description: f.description,
+ placeholder: overrideById.get(id)?.placeholder,
+ required: overrideById.get(id)?.required,
+ }
+ })
+ : inputs
const [expandedInputs, setExpandedInputs] = useState>(() => new Set())
const toggleInput = (id: string) =>
@@ -263,40 +251,32 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
}
}, [deployed.data])
- const visibleOutputs = useMemo(
- () =>
- deployedLoaded
- ? outputs.filter((o) => labelByKey.has(encodeOutput(o.blockId, o.path)))
- : outputs,
- [outputs, deployedLoaded, labelByKey]
+ const visibleOutputs = deployedLoaded
+ ? outputs.filter((o) => labelByKey.has(encodeOutput(o.blockId, o.path)))
+ : outputs
+ const selectedOutputKeys = visibleOutputs.map((o) => encodeOutput(o.blockId, o.path))
+
+ const formChanged = Boolean(
+ draft && (!sameFormValues(draft.values, draft.baseline) || iconUrl !== draft.baseline.iconUrl)
)
- const selectedOutputKeys = useMemo(
- () => visibleOutputs.map((o) => encodeOutput(o.blockId, o.path)),
- [visibleOutputs]
+ const dirty =
+ formChanged ||
+ (isCreate && Boolean(selectedWorkflowId || selectedWorkspaceId !== eligibleDefaultWorkspaceId))
+
+ if (
+ draft &&
+ !formChanged &&
+ !iconUpload.isUploading &&
+ JSON.stringify(draft.values) === JSON.stringify(draft.baseline)
)
+ setDraft(null)
- const dirty = existing
- ? name.trim() !== existing.name ||
- description.trim() !== (existing.description ?? '') ||
- (iconUrl || null) !== (existing.iconUrl ?? null) ||
- traceChildRuns !== existing.traceChildRuns ||
- JSON.stringify(visibleOutputs) !== JSON.stringify(existing.exposedOutputs) ||
- JSON.stringify(normalizeInputsForCompare(visibleInputs)) !==
- JSON.stringify(normalizeInputsForCompare(existing.inputFields))
- : Boolean(
- name.trim() ||
- description.trim() ||
- selectedWorkflowId ||
- selectedWorkspaceId !== eligibleDefaultWorkspaceId ||
- iconUrl ||
- traceChildRuns ||
- visibleOutputs.length > 0 ||
- visibleInputs.some((i) => i.placeholder?.trim())
- )
-
- const guard = useSettingsUnsavedGuard({ isDirty: dirty })
-
- const saving = publish.isPending || update.isPending || remove.isPending
+ const saving = publish.isPending || update.isPending || remove.isPending || iconUpload.isUploading
+ const guard = useSettingsUnsavedGuard({
+ isDirty: dirty,
+ navigationBlocked: saving,
+ onDiscard: handleDiscard,
+ })
// Outputs are required — there is no "expose the whole result" option.
const saveDisabled =
!name.trim() ||
@@ -306,38 +286,56 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
deployed.isLoading ||
(deployedLoaded && visibleOutputs.length === 0)
- // Upsert an authored per-input override (placeholder/required). `visibleInputs`
- // shows every deployed field; the first edit of a field adds its override row.
+ function updateForm(change: (current: CustomBlockFormValues) => CustomBlockFormValues) {
+ if (publish.isPending || update.isPending || remove.isPending) return
+ setDraft((current) => ({
+ baseline: current?.baseline ?? savedValues,
+ values: change(current?.values ?? savedValues),
+ }))
+ }
+
+ function beginIconUpload() {
+ setDraft((current) => current ?? { baseline: savedValues, values: savedValues })
+ }
+
function setInputOverride(
id: string,
patch: Partial>
) {
- setInputs((prev) => {
+ updateForm((current) => {
+ const prev = current.inputs
if (prev.some((i) => i.id === id)) {
- return prev.map((i) => (i.id === id ? { ...i, ...patch } : i))
+ return { ...current, inputs: prev.map((i) => (i.id === id ? { ...i, ...patch } : i)) }
}
const f = fieldById.get(id)
- return [...prev, { id, name: f?.name ?? id, type: f?.type ?? 'string', ...patch }]
+ return {
+ ...current,
+ inputs: [...prev, { id, name: f?.name ?? id, type: f?.type ?? 'string', ...patch }],
+ }
})
}
function handleOutputsChange(nextKeys: string[]) {
const byKey = new Map(outputs.map((o) => [encodeOutput(o.blockId, o.path), o]))
const taken = new Set(outputs.map((o) => o.name))
- setOutputs(
- nextKeys.map((key) => {
+ updateForm((current) => ({
+ ...current,
+ outputs: nextKeys.map((key) => {
const ex = byKey.get(key)
if (ex) return ex
const { blockId, path } = decodeOutput(key)
return { blockId, path, name: deriveOutputName(path, taken) }
- })
- )
+ }),
+ }))
}
function setOutputName(key: string, value: string) {
- setOutputs((prev) =>
- prev.map((o) => (encodeOutput(o.blockId, o.path) === key ? { ...o, name: value } : o))
- )
+ updateForm((current) => ({
+ ...current,
+ outputs: current.outputs.map((o) =>
+ encodeOutput(o.blockId, o.path) === key ? { ...o, name: value } : o
+ ),
+ }))
}
function handleDiscard() {
@@ -345,11 +343,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
setSelectedWorkspaceId(eligibleDefaultWorkspaceId)
setSelectedWorkflowId('')
}
- setName(existing?.name ?? '')
- setDescription(existing?.description ?? '')
- setTraceChildRuns(existing?.traceChildRuns ?? false)
- setInputs(toCustomBlockInputs(existing?.inputFields))
- setOutputs(existing?.exposedOutputs ?? [])
+ setDraft(null)
iconUpload.reset()
setError(null)
}
@@ -374,19 +368,12 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
setError(`"${reserved.name}" is a reserved output name (success, error, cost)`)
return
}
- // Only the placeholder and required flag are authored; the field set/name/type
- // are always derived from the deployed Start. Persist only non-empty overrides.
- const inputPlaceholders = visibleInputs
- .filter((i) => i.placeholder?.trim() || i.required)
- .map((i) => ({
- id: i.id,
- ...(i.placeholder?.trim() ? { placeholder: i.placeholder.trim() } : {}),
- ...(i.required ? { required: true } : {}),
- }))
+ const inputPlaceholders = normalizeInputOverrides(visibleInputs)
try {
if (existing) {
- const iconChanged = (iconUrl || null) !== (existing.iconUrl ?? null)
+ const iconChanged =
+ (iconUrl || null) !== (draft ? draft.baseline.iconUrl : existing.iconUrl)
await update.mutateAsync({
id: existing.id,
name: name.trim(),
@@ -521,12 +508,19 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
-
{}}>
+ {
+ event.preventDefault()
+ if (!canManageBlock || saving) return
+ if (event.dataTransfer.files[0]) beginIconUpload()
+ iconUpload.handleFileDrop(event)
+ }}
+ >
{iconUrl ? (
@@ -563,7 +557,11 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
type='file'
accept={ICON_ACCEPT}
className='hidden'
- onChange={iconUpload.handleFileChange}
+ onChange={(event) => {
+ if (!canManageBlock || saving) return
+ if (event.target.files?.[0]) beginIconUpload()
+ iconUpload.handleFileChange(event)
+ }}
/>
@@ -571,21 +569,23 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
setName(e.target.value)}
+ onChange={(e) => updateForm((current) => ({ ...current, name: e.target.value }))}
placeholder='Invoice Parser'
maxLength={60}
- disabled={!canManageBlock}
+ disabled={saving || !canManageBlock}
/>
setDescription(e.target.value)}
+ onChange={(e) =>
+ updateForm((current) => ({ ...current, description: e.target.value }))
+ }
placeholder='What this block does'
rows={2}
maxLength={280}
- disabled={!canManageBlock}
+ disabled={saving || !canManageBlock}
/>
@@ -654,7 +654,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
onCheckedChange={(checked) =>
setInputOverride(i.id, { required: checked })
}
- disabled={!canManageBlock}
+ disabled={saving || !canManageBlock}
/>
@@ -666,7 +666,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
}
placeholder='Shown in the empty field'
maxLength={200}
- disabled={!canManageBlock}
+ disabled={saving || !canManageBlock}
/>
@@ -690,7 +690,9 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
className='w-full'
dropdownWidth='trigger'
maxHeight={280}
- disabled={deployed.isLoading || outputGroups.length === 0 || !canManageBlock}
+ disabled={
+ saving || deployed.isLoading || outputGroups.length === 0 || !canManageBlock
+ }
emptyMessage={deployed.isLoading ? 'Loading workflow…' : 'No outputs found.'}
options={[]}
groups={outputGroups}
@@ -722,7 +724,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
placeholder='name'
className='w-[140px]'
maxLength={60}
- disabled={!canManageBlock}
+ disabled={saving || !canManageBlock}
/>
)
@@ -739,8 +741,10 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
+ updateForm((current) => ({ ...current, traceChildRuns: checked }))
+ }
+ disabled={saving || !canManageBlock}
/>
@@ -790,12 +794,6 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
placeholder={existing?.name}
/>
-
-
>
)
}
@@ -823,16 +821,28 @@ function toCustomBlockInputs(
}))
}
-/**
- * Compare inputs by only the authored data — the field id, placeholder, and
- * required flag. name/type/description are derived live from the deployed Start
- * (not stored), so comparing them would flag the form dirty when only Start
- * metadata drifted.
- */
-function normalizeInputsForCompare(items: ReadonlyArray>) {
- return items.map((i) => ({
- id: i.id ?? i.name ?? '',
- placeholder: i.placeholder ?? '',
- required: i.required ?? false,
- }))
+function normalizeInputOverrides(
+ items: readonly CustomBlockInput[]
+): CustomBlockInputPlaceholder[] {
+ return items
+ .filter((input) => input.placeholder?.trim() || input.required)
+ .map((input) => ({
+ id: input.id,
+ ...(input.placeholder?.trim() ? { placeholder: input.placeholder.trim() } : {}),
+ ...(input.required ? { required: true } : {}),
+ }))
+ .sort((left, right) => compareStrings(left.id, right.id))
+}
+
+function sameFormValues(left: CustomBlockFormValues, right: CustomBlockFormValues) {
+ return (
+ left.name.trim() === right.name.trim() &&
+ left.description.trim() === right.description.trim() &&
+ left.iconUrl === right.iconUrl &&
+ left.traceChildRuns === right.traceChildRuns &&
+ JSON.stringify(left.outputs.map((output) => ({ ...output, name: output.name.trim() }))) ===
+ JSON.stringify(right.outputs.map((output) => ({ ...output, name: output.name.trim() }))) &&
+ JSON.stringify(normalizeInputOverrides(left.inputs)) ===
+ JSON.stringify(normalizeInputOverrides(right.inputs))
+ )
}
diff --git a/apps/sim/ee/data-drains/components/data-drain-create.tsx b/apps/sim/ee/data-drains/components/data-drain-create.tsx
index 616e64a092e..59bfd2be9db 100644
--- a/apps/sim/ee/data-drains/components/data-drain-create.tsx
+++ b/apps/sim/ee/data-drains/components/data-drain-create.tsx
@@ -5,18 +5,15 @@ import { ChipInput, ChipSelect, toast } from '@sim/emcn'
import { ArrowLeft, Database } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
+import { useSettingsUnsavedGuard } from '@/components/settings/use-settings-unsaved-guard'
import type { CreateDataDrainBody } from '@/lib/api/contracts/data-drains'
import type { CADENCE_TYPES, SOURCE_TYPES } from '@/lib/data-drains/types'
import { DESTINATION_TYPES } from '@/lib/data-drains/types'
-import {
- CredentialDetailHeading,
- UnsavedChangesModal,
-} from '@/app/workspace/[workspaceId]/components/credential-detail'
+import { CredentialDetailHeading } from '@/app/workspace/[workspaceId]/components/credential-detail'
import { ResourceTile } from '@/app/workspace/[workspaceId]/components/resource-tile'
import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
-import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard'
import { SettingRow } from '@/ee/components/setting-row'
import { DESTINATION_FORM_REGISTRY } from '@/ee/data-drains/destinations/registry'
import { useCreateDataDrain } from '@/ee/data-drains/hooks/data-drains'
@@ -65,9 +62,21 @@ export function DataDrainCreate({ organizationId, onBack, onCreated }: DataDrain
cadence !== 'daily' ||
destinationType !== DESTINATION_TYPES[0]
- const guard = useSettingsUnsavedGuard({ isDirty })
+ const resetDraft = () => {
+ setName('')
+ setSource('workflow_logs')
+ setCadence('daily')
+ setDestinationType(DESTINATION_TYPES[0])
+ setDestState(DESTINATION_FORM_REGISTRY[DESTINATION_TYPES[0]].initialState)
+ }
+ const guard = useSettingsUnsavedGuard({
+ isDirty,
+ navigationBlocked: createDrain.isPending,
+ onDiscard: resetDraft,
+ })
const handleDestinationChange = (next: (typeof DESTINATION_TYPES)[number]) => {
+ if (createDrain.isPending) return
setDestinationType(next)
setDestState(DESTINATION_FORM_REGISTRY[next].initialState)
}
@@ -108,72 +117,78 @@ export function DataDrainCreate({ organizationId, onBack, onCreated }: DataDrain
title='New drain'
actions={actions}
>
-
-
}
- title='New drain'
- subtitle='Export logs, chats, and runs to your own storage or observability stack on a schedule.'
- />
+
+
+
}
+ title='New drain'
+ subtitle='Export logs, chats, and runs to your own storage or observability stack on a schedule.'
+ />
-
-
-
- setName(e.target.value)}
- placeholder='Workflow logs export'
- />
-
-
- setSource(v as (typeof SOURCE_TYPES)[number])}
- options={SOURCE_OPTIONS}
- align='start'
- />
-
-
- setCadence(v as (typeof CADENCE_TYPES)[number])}
- options={CADENCE_OPTIONS}
- align='start'
- />
-
-
-
+
+
+
+ setName(e.target.value)}
+ placeholder='Workflow logs export'
+ />
+
+
+ setSource(v as (typeof SOURCE_TYPES)[number])}
+ options={SOURCE_OPTIONS}
+ align='start'
+ />
+
+
+ setCadence(v as (typeof CADENCE_TYPES)[number])}
+ options={CADENCE_OPTIONS}
+ align='start'
+ />
+
+
+
-
-
-
- handleDestinationChange(v as (typeof DESTINATION_TYPES)[number])}
- options={DESTINATION_OPTIONS}
- displayLabel={DESTINATION_LABELS[destinationType]}
- align='start'
+
+
+
+
+ handleDestinationChange(v as (typeof DESTINATION_TYPES)[number])
+ }
+ options={DESTINATION_OPTIONS}
+ displayLabel={DESTINATION_LABELS[destinationType]}
+ align='start'
+ />
+
+
{
+ if (!createDrain.isPending) setDestState(state)
+ }}
/>
-
-
- {submitError && (
-
- {submitError}
-
- )}
-
-
-
+ {submitError && (
+
+ {submitError}
+
+ )}
+
+
+
+
-
-
>
)
}
diff --git a/apps/sim/ee/data-retention/components/data-retention-settings.tsx b/apps/sim/ee/data-retention/components/data-retention-settings.tsx
index f6ce7420e81..7f670741eca 100644
--- a/apps/sim/ee/data-retention/components/data-retention-settings.tsx
+++ b/apps/sim/ee/data-retention/components/data-retention-settings.tsx
@@ -22,6 +22,7 @@ import { generateId } from '@sim/utils/id'
import { CustomPatternsEditor } from '@/components/pii/custom-patterns-editor'
import { saveDiscardActions } from '@/components/settings/save-discard-actions'
import type { SettingsAction } from '@/components/settings/settings-header'
+import { useSettingsUnsavedGuard } from '@/components/settings/use-settings-unsaved-guard'
import type { UpdateOrganizationDataRetentionBody } from '@/lib/api/contracts/organization'
import type { RetentionOverride } from '@/lib/api/contracts/primitives'
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
@@ -42,7 +43,6 @@ import {
sanitizeCustomPatterns,
stripNerEntities,
} from '@/lib/guardrails/pii-entities'
-import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
import {
@@ -50,7 +50,6 @@ import {
SettingsResourceRow,
} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
-import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard'
import {
type DataRetentionResponse,
useOrganizationRetention,
@@ -523,131 +522,133 @@ function PolicyDetail({
: []),
]}
>
- {!isOrg && (
-
-
-
- {draft.workspaceIds.length > 0
- ? `Overrides ${draft.workspaceIds.length} workspace${draft.workspaceIds.length === 1 ? '' : 's'}`
- : 'Select the workspaces this override applies to'}
-
- onChange({ ...draft, workspaceIds })}
- options={workspaceOptions}
- className='shrink-0'
- />
-
-
- )}
-
-
-
-
- Log retention
- onChange({ ...draft, logDays })}
- />
-
-
- Soft deletion cleanup
- onChange({ ...draft, softDeleteDays })}
- />
-
-
- Task cleanup
- onChange({ ...draft, taskCleanupDays })}
- />
-
-
-
-
-
- onChange({
- ...draft,
- piiStages: {
- ...draft.piiStages,
- [activeStage]: {
- ...draft.piiStages[activeStage],
- entityTypes: [],
- // Clearing entity types leaves any custom patterns intact,
- // so the stage stays enabled while patterns remain.
- enabled: (draft.piiStages[activeStage].customPatterns?.length ?? 0) > 0,
- },
- },
- })
- }
- disabled={draft.piiStages[activeStage].entityTypes.length === 0}
- >
- Deselect all
-
- ) : undefined
- }
- >
-
- {!isOrg && (
+
+ {!isOrg && (
+
-
- Inherit the organization defaults or set workspace-specific redaction
+
+ {draft.workspaceIds.length > 0
+ ? `Overrides ${draft.workspaceIds.length} workspace${draft.workspaceIds.length === 1 ? '' : 's'}`
+ : 'Select the workspaces this override applies to'}
- onChange({ ...draft, piiOverride: mode === 'override' })}
- aria-label='PII redaction override mode'
- options={[
- { value: 'inherit', label: 'Inherit' },
- { value: 'override', label: 'Override' },
- ]}
+ onChange({ ...draft, workspaceIds })}
+ options={workspaceOptions}
+ className='shrink-0'
/>
- )}
- {!isOrg && draft.piiOverride && (
-
- Overriding replaces all three redaction stages for this workspace.
-
- )}
- {showPiiGrid && (
- <>
- ({
- value: stage.key,
- label: stage.label,
- }))}
+
+ )}
+
+
+
+
+ Log retention
+ onChange({ ...draft, logDays })}
+ />
+
+
+ Soft deletion cleanup
+ onChange({ ...draft, softDeleteDays })}
+ />
+
+
+
Task cleanup
+
onChange({ ...draft, taskCleanupDays })}
/>
-
+
+
+
+
+
onChange({
...draft,
- piiStages: { ...draft.piiStages, [activeStage]: next },
+ piiStages: {
+ ...draft.piiStages,
+ [activeStage]: {
+ ...draft.piiStages[activeStage],
+ entityTypes: [],
+ // Clearing entity types leaves any custom patterns intact,
+ // so the stage stays enabled while patterns remain.
+ enabled: (draft.piiStages[activeStage].customPatterns?.length ?? 0) > 0,
+ },
+ },
})
}
- />
- >
- )}
-
-
+ disabled={draft.piiStages[activeStage].entityTypes.length === 0}
+ >
+ Deselect all
+
+ ) : undefined
+ }
+ >
+
+ {!isOrg && (
+
+
+ Inherit the organization defaults or set workspace-specific redaction
+
+ onChange({ ...draft, piiOverride: mode === 'override' })}
+ aria-label='PII redaction override mode'
+ options={[
+ { value: 'inherit', label: 'Inherit' },
+ { value: 'override', label: 'Override' },
+ ]}
+ />
+
+ )}
+ {!isOrg && draft.piiOverride && (
+
+ Overriding replaces all three redaction stages for this workspace.
+
+ )}
+ {showPiiGrid && (
+ <>
+
({
+ value: stage.key,
+ label: stage.label,
+ }))}
+ />
+
+ onChange({
+ ...draft,
+ piiStages: { ...draft.piiStages, [activeStage]: next },
+ })
+ }
+ />
+ >
+ )}
+
+
+
o.workspaceId), ...piiOverrides.map((p) => p.workspaceId)])
@@ -862,7 +867,7 @@ function DataRetentionForm({ initialData: data, orgId, workspaces }: DataRetenti
}
async function savePolicy() {
- if (!editing) return
+ if (!editing || updateMutation.isPending) return
const draft = editing.draft
try {
if (draft.isOrgDefault) {
@@ -962,7 +967,9 @@ function DataRetentionForm({ initialData: data, orgId, workspaces }: DataRetenti
isSaving={updateMutation.isPending}
canRemove={!editing.draft.isOrgDefault && !editing.isNew}
workspaceOptions={workspacePickerOptions(editing.draft)}
- onChange={(draft) => setEditing({ ...editing, draft })}
+ onChange={(draft) => {
+ if (!updateMutation.isPending) setEditing({ ...editing, draft })
+ }}
onBack={() => guard.guardBack(closeEditing)}
onDiscard={handleDiscard}
onSave={savePolicy}
@@ -994,11 +1001,6 @@ function DataRetentionForm({ initialData: data, orgId, workspaces }: DataRetenti
)}
-
>
)
}
diff --git a/apps/sim/ee/scim/components/scim-section.tsx b/apps/sim/ee/scim/components/scim-section.tsx
index 4e7c231d6c8..0874809a6ae 100644
--- a/apps/sim/ee/scim/components/scim-section.tsx
+++ b/apps/sim/ee/scim/components/scim-section.tsx
@@ -20,6 +20,7 @@ import {
} from '@sim/emcn'
import { Key, X } from '@sim/emcn/icons'
import { getErrorMessage } from '@sim/utils/errors'
+import { useSettingsUnsavedGuard } from '@/components/settings/use-settings-unsaved-guard'
import type {
ScimActivityEntry,
ScimConnectionView,
@@ -63,6 +64,7 @@ import {
useScimGroupMappings,
useUpsertScimGroupMapping,
} from '@/ee/scim/hooks/scim'
+import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
interface ScimSectionProps {
organizationId: string
@@ -135,6 +137,16 @@ function AddMapping({ organizationId, groupId, permissionGroups, workspaces }: A
const [targetKind, setTargetKind] = useState('permission_group')
const [targetId, setTargetId] = useState('')
const [permission, setPermission] = useState('read')
+ const discardMapping = () => {
+ setTargetKind('permission_group')
+ setTargetId('')
+ setPermission('read')
+ }
+ useSettingsUnsavedGuard({
+ isDirty: targetKind !== 'permission_group' || Boolean(targetId) || permission !== 'read',
+ navigationBlocked: upsertMapping.isPending,
+ onDiscard: discardMapping,
+ })
function buildBody(): ScimGroupMappingBody | null {
switch (targetKind) {
@@ -152,10 +164,10 @@ function AddMapping({ organizationId, groupId, permissionGroups, workspaces }: A
const body = buildBody()
async function handleAdd() {
- if (!body) return
+ if (!body || upsertMapping.isPending) return
try {
const result = await upsertMapping.mutateAsync({ organizationId, body })
- setTargetId('')
+ discardMapping()
toast.success(
result.reconciledUsers === 0
? 'Mapping added'
@@ -171,17 +183,20 @@ function AddMapping({ organizationId, groupId, permissionGroups, workspaces }: A
return (
{
setTargetKind(next as MappingTargetKind)
setTargetId('')
+ setPermission('read')
}}
options={[...TARGET_KIND_OPTIONS]}
/>
{targetKind !== 'org_role' && (
(null)
const [credentialExpiry, setCredentialExpiry] = useState('never')
+ useSettingsUnsavedGuard({
+ isDirty: credentialExpiry !== 'never' || issuedSecret !== null,
+ navigationBlocked: issueCredential.isPending,
+ onDiscard: () => {
+ setCredentialExpiry('never')
+ setIssuedSecret(null)
+ },
+ })
const [pendingRevokeId, setPendingRevokeId] = useState(null)
const pendingRevoke =
connection.credentials.find((credential) => credential.id === pendingRevokeId) ?? null
@@ -393,12 +417,14 @@ function ConnectionDetails({ organizationId, connection, active }: ConnectionDet
}
async function handleIssue() {
+ if (issueCredential.isPending) return
try {
const result = await issueCredential.mutateAsync({
organizationId,
...(credentialExpiry === 'never' ? {} : { expiresInDays: Number(credentialExpiry) }),
})
setIssuedSecret(result.secret)
+ setCredentialExpiry('never')
} catch (error) {
toast.error(getErrorMessage(error, 'Failed to issue token'))
}
@@ -477,6 +503,7 @@ function ConnectionDetails({ organizationId, connection, active }: ConnectionDet
)}
state.navigationBlocked)
if (!available) return null
@@ -631,7 +659,7 @@ export function ScimSection({ organizationId, onOpenDomains, active }: ScimSecti
const connection = data?.connection ?? null
const enabled = connection?.status === 'active'
- async function handleToggleEnabled(next: boolean) {
+ async function updateEnabled(next: boolean) {
try {
await configure.mutateAsync({ organizationId, status: next ? 'active' : 'disabled' })
toast.success(next ? 'Directory provisioning enabled' : 'Directory provisioning disabled')
@@ -654,8 +682,10 @@ export function ScimSection({ organizationId, onOpenDomains, active }: ScimSecti
void handleToggleEnabled(checked)}
- disabled={isLoading || configure.isPending}
+ onCheckedChange={(checked) => {
+ void updateEnabled(checked)
+ }}
+ disabled={isLoading || configure.isPending || navigationBlocked}
/>
@@ -669,12 +699,14 @@ export function ScimSection({ organizationId, onOpenDomains, active }: ScimSecti
)}
- {connection && enabled && (
-
+ {connection && (
+
+
+
)}
)
diff --git a/apps/sim/ee/session-policy/components/session-policy-settings.tsx b/apps/sim/ee/session-policy/components/session-policy-settings.tsx
index dcd9ac0ca53..36272b10524 100644
--- a/apps/sim/ee/session-policy/components/session-policy-settings.tsx
+++ b/apps/sim/ee/session-policy/components/session-policy-settings.tsx
@@ -4,6 +4,7 @@ import { useState } from 'react'
import { ChipConfirmModal, ChipInput, Label, toast } from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
import { saveDiscardActions } from '@/components/settings/save-discard-actions'
+import { useSettingsUnsavedGuard } from '@/components/settings/use-settings-unsaved-guard'
import {
MAX_SESSION_POLICY_HOURS,
MIN_IDLE_TIMEOUT_HOURS,
@@ -12,7 +13,6 @@ import {
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
-import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard'
import {
type SessionPolicyResponse,
useOrganizationSessionPolicy,
@@ -77,9 +77,14 @@ function SessionPolicyForm({ organizationId, initialData }: SessionPolicyFormPro
const hasChanges =
maxSessionHours !== savedMaxSessionHours || idleTimeoutHours !== savedIdleTimeoutHours
- useSettingsUnsavedGuard({ isDirty: hasChanges })
+ useSettingsUnsavedGuard({
+ isDirty: hasChanges,
+ navigationBlocked: updatePolicy.isPending,
+ onDiscard: handleDiscard,
+ })
async function handleSave() {
+ if (updatePolicy.isPending) return
const max = parseHours(maxSessionHours)
const idle = parseHours(idleTimeoutHours)
@@ -109,8 +114,8 @@ function SessionPolicyForm({ organizationId, initialData }: SessionPolicyFormPro
})
const savedMax = result.data.configured.maxSessionHours?.toString() ?? ''
const savedIdle = result.data.configured.idleTimeoutHours?.toString() ?? ''
- setMaxSessionHours(savedMax)
- setIdleTimeoutHours(savedIdle)
+ setMaxSessionHours((current) => (current === maxSessionHours ? savedMax : current))
+ setIdleTimeoutHours((current) => (current === idleTimeoutHours ? savedIdle : current))
setSavedMaxSessionHours(savedMax)
setSavedIdleTimeoutHours(savedIdle)
toast.success('Session policy updated')
diff --git a/apps/sim/ee/sso/components/sso-provider-settings.tsx b/apps/sim/ee/sso/components/sso-provider-settings.tsx
index 2bfd5af853e..2167ebcd32f 100644
--- a/apps/sim/ee/sso/components/sso-provider-settings.tsx
+++ b/apps/sim/ee/sso/components/sso-provider-settings.tsx
@@ -22,14 +22,13 @@ import { getErrorMessage } from '@sim/utils/errors'
import { isRecordLike } from '@sim/utils/object'
import { saveDiscardActions } from '@/components/settings/save-discard-actions'
import type { SettingsAction } from '@/components/settings/settings-header'
+import { useSettingsUnsavedGuard } from '@/components/settings/use-settings-unsaved-guard'
import type { SsoProviderView, SsoRegistrationBody } from '@/lib/api/contracts/auth'
import { REDACTED_MARKER } from '@/lib/core/security/redaction'
import { getBaseUrl } from '@/lib/core/utils/urls'
-import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail'
import { SettingsField } from '@/app/workspace/[workspaceId]/settings/components/settings-field'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
-import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard'
import { SettingRow } from '@/ee/components/setting-row'
import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/constants'
import { useConfigureSSO } from '@/ee/sso/hooks/sso'
@@ -247,7 +246,11 @@ export function SsoProviderSettings({
(k) => formData[k] !== originalFormData[k]
)
- const guard = useSettingsUnsavedGuard({ isDirty: hasChanges })
+ const guard = useSettingsUnsavedGuard({
+ isDirty: hasChanges,
+ navigationBlocked: configureSSOMutation.isPending,
+ onDiscard: () => handleDiscard(),
+ })
const validateProviderId = (value: string): string[] => {
if (!value || !value.trim()) return ['Provider ID is required.']
@@ -340,6 +343,7 @@ export function SsoProviderSettings({
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault()
+ if (configureSSOMutation.isPending) return
setShowErrors(true)
const validation = validateAll(formData)
@@ -423,6 +427,7 @@ export function SsoProviderSettings({
}
const handleInputChange = (field: keyof typeof formData, value: string | boolean) => {
+ if (configureSSOMutation.isPending) return
const next = { ...formData, [field]: value }
/** Claim names are protocol-specific, so an override must not survive a switch. */
if (field === 'providerType') {
@@ -707,504 +712,511 @@ export function SsoProviderSettings({
))}
- {!existingProvider && (
-
-
- Use a verified email domain for this connection.
-
-
Manage domains
-
- )}
+
+ {!existingProvider && (
+
+
+ Use a verified email domain for this connection.
+
+
Manage domains
+
+ )}
-
-
-
-
- handleInputChange('providerType', value as 'oidc' | 'saml')
+
+
+
+
+ handleInputChange('providerType', value as 'oidc' | 'saml')
+ }
+ options={[
+ { label: 'OIDC', value: 'oidc' },
+ { label: 'SAML', value: 'saml' },
+ ]}
+ placeholder='Select provider type'
+ />
+
+
+
0
+ ? errors.providerId.join(' ')
+ : undefined
}
- options={[
- { label: 'OIDC', value: 'oidc' },
- { label: 'SAML', value: 'saml' },
- ]}
- placeholder='Select provider type'
- />
-
+ >
+ {isEditing ? (
+ <>
+
+
+ Cannot be changed after saving.
+
+ >
+ ) : (
+ <>
+
handleInputChange('providerId', value)}
+ options={PROVIDER_ID_SUGGESTIONS}
+ placeholder='Select or enter a provider ID'
+ editable
+ />
+
+ Unique across Sim, e.g. acme-entra. Cannot be changed later.
+
+ >
+ )}
+
- 0 ? errors.providerId.join(' ') : undefined
- }
- >
- {isEditing ? (
+ 0 ? errors.issuerUrl.join(' ') : undefined
+ }
+ >
+ e.target.removeAttribute('readOnly')}
+ onChange={(e) => handleInputChange('issuerUrl', e.target.value)}
+ error={showErrors && errors.issuerUrl.length > 0}
+ />
+
+
+ 0 ? errors.domain.join(' ') : undefined}
+ >
+ e.target.removeAttribute('readOnly')}
+ onChange={(e) => handleInputChange('domain', e.target.value)}
+ error={showErrors && errors.domain.length > 0}
+ />
+
+
+ {formData.providerType === 'oidc' ? (
<>
-
-
- Cannot be changed after saving.
-
+ 0
+ ? errors.clientId.join(' ')
+ : undefined
+ }
+ >
+ e.target.removeAttribute('readOnly')}
+ onChange={(e) => handleInputChange('clientId', e.target.value)}
+ error={showErrors && errors.clientId.length > 0}
+ />
+
+
+ 0
+ ? errors.clientSecret.join(' ')
+ : undefined
+ }
+ >
+ setIsReplacingClientSecret(true)}
+ onCancelReplace={handleKeepSavedSecret}
+ value={formData.clientSecret}
+ onChange={(next) => handleInputChange('clientSecret', next)}
+ hasError={showErrors && errors.clientSecret.length > 0}
+ />
+
+
+
+
setShowAdvanced((value) => !value)}
+ rightIcon={ChevronDown}
+ aria-expanded={showAdvanced}
+ aria-controls='sso-advanced'
+ className='w-fit'
+ >
+ Advanced options
+
+
+
+
+
+
0
+ ? errors.scopes.join(' ')
+ : undefined
+ }
+ >
+ handleInputChange('scopes', e.target.value)}
+ error={showErrors && errors.scopes.length > 0}
+ />
+
+ Comma-separated list of OIDC scopes to request
+
+
+
+
+ handleInputChange('authorizationEndpoint', e.target.value)
+ }
+ />
+
+
+
+ handleInputChange('tokenEndpoint', e.target.value)}
+ />
+
+
+
+ handleInputChange('jwksEndpoint', e.target.value)}
+ />
+
+ Sim reads these from the issuer's discovery document. Set them only if
+ your provider does not publish one.
+
+
+
+
+
+
>
) : (
<>
- handleInputChange('providerId', value)}
- options={PROVIDER_ID_SUGGESTIONS}
- placeholder='Select or enter a provider ID'
- editable
- />
-
- Unique across Sim, e.g. acme-entra. Cannot be changed later.
-
+ 0
+ ? errors.entryPoint.join(' ')
+ : undefined
+ }
+ >
+ handleInputChange('entryPoint', e.target.value)}
+ error={showErrors && errors.entryPoint.length > 0}
+ />
+
+
+ 0 ? errors.cert.join(' ') : undefined}
+ >
+ handleInputChange('cert', e.target.value)}
+ className='min-h-20'
+ error={showErrors && errors.cert.length > 0}
+ rows={3}
+ />
+
+
+
+
setShowAdvanced((value) => !value)}
+ rightIcon={ChevronDown}
+ aria-expanded={showAdvanced}
+ aria-controls='sso-advanced'
+ className='w-fit'
+ >
+ Advanced options
+
+
+
+
+
+
+ handleInputChange('audience', e.target.value)}
+ />
+
+
+
+ handleInputChange('callbackUrl', e.target.value)}
+ />
+
+
+
+
+ Require signed SAML assertions
+
+
+ handleInputChange('wantAssertionsSigned', checked)
+ }
+ />
+
+
+
+
+ handleInputChange('identifierFormat', value)
+ }
+ options={[...SAML_NAMEID_FORMATS]}
+ placeholder='Provider default'
+ />
+
+
+
+ handleInputChange('idpMetadata', e.target.value)}
+ className='min-h-15'
+ rows={2}
+ />
+
+
+
+
+
>
)}
-
-
- 0 ? errors.issuerUrl.join(' ') : undefined
- }
- >
- e.target.removeAttribute('readOnly')}
- onChange={(e) => handleInputChange('issuerUrl', e.target.value)}
- error={showErrors && errors.issuerUrl.length > 0}
- />
-
-
- 0 ? errors.domain.join(' ') : undefined}
- >
- e.target.removeAttribute('readOnly')}
- onChange={(e) => handleInputChange('domain', e.target.value)}
- error={showErrors && errors.domain.length > 0}
- />
-
-
- {formData.providerType === 'oidc' ? (
- <>
- 0 ? errors.clientId.join(' ') : undefined
- }
- >
- e.target.removeAttribute('readOnly')}
- onChange={(e) => handleInputChange('clientId', e.target.value)}
- error={showErrors && errors.clientId.length > 0}
- />
-
- 0
- ? errors.clientSecret.join(' ')
- : undefined
- }
- >
- setIsReplacingClientSecret(true)}
- onCancelReplace={handleKeepSavedSecret}
- value={formData.clientSecret}
- onChange={(next) => handleInputChange('clientSecret', next)}
- hasError={showErrors && errors.clientSecret.length > 0}
- />
-
+
+
+
+ Configure this in your identity provider
+
+
-
-
setShowAdvanced((value) => !value)}
- rightIcon={ChevronDown}
- aria-expanded={showAdvanced}
- aria-controls='sso-advanced'
- className='w-fit'
- >
- Advanced options
-
-
-
-
-
-
0
- ? errors.scopes.join(' ')
- : undefined
- }
- >
- handleInputChange('scopes', e.target.value)}
- error={showErrors && errors.scopes.length > 0}
- />
-
- Comma-separated list of OIDC scopes to request
-
-
-
-
- handleInputChange('authorizationEndpoint', e.target.value)
- }
- />
-
-
-
- handleInputChange('tokenEndpoint', e.target.value)}
- />
-
-
-
- handleInputChange('jwksEndpoint', e.target.value)}
- />
-
- Sim reads these from the issuer's discovery document. Set them only if
- your provider does not publish one.
-
-
-
-
-
-
- >
- ) : (
- <>
- 0
- ? errors.entryPoint.join(' ')
- : undefined
- }
- >
- handleInputChange('entryPoint', e.target.value)}
- error={showErrors && errors.entryPoint.length > 0}
+ {/** Sim publishes no SP metadata document; these are the values it would carry. */}
+ {isSaml && (
+
+
+
+ Use this as Sim's entity ID in your identity provider.
+
+ )}
- 0 ? errors.cert.join(' ') : undefined}
+
+
setShowMapping((value) => !value)}
+ rightIcon={ChevronDown}
+ aria-expanded={showMapping}
+ aria-controls='sso-mapping'
+ className='w-fit'
>
- handleInputChange('cert', e.target.value)}
- className='min-h-20'
- error={showErrors && errors.cert.length > 0}
- rows={3}
- />
-
-
-
-
setShowAdvanced((value) => !value)}
- rightIcon={ChevronDown}
- aria-expanded={showAdvanced}
- aria-controls='sso-advanced'
- className='w-fit'
- >
- Advanced options
-
-
-
-
-
-
- handleInputChange('audience', e.target.value)}
- />
-
-
-
- handleInputChange('callbackUrl', e.target.value)}
- />
-
-
-
-
- Require signed SAML assertions
-
-
- handleInputChange('wantAssertionsSigned', checked)
- }
- />
-
-
-
-
- handleInputChange('identifierFormat', value)
- }
- options={[...SAML_NAMEID_FORMATS]}
- placeholder='Provider default'
- />
-
-
-
- handleInputChange('idpMetadata', e.target.value)}
- className='min-h-15'
- rows={2}
- />
-
-
-
-
-
- >
- )}
+ Attribute mapping
+
+
+
+
+
+
+ handleInputChange('mapEmail', e.target.value)}
+ />
+
+
+
+ handleInputChange('mapName', e.target.value)}
+ />
+
+
+
+ handleInputChange('mapId', e.target.value)}
+ />
+
+ Must be stable and unique per user — changing it later re-links accounts.
+
+
+
+
+
+
+
+
+
-
+ handleInputChange('jitProvisioningEnabled', value === 'automatic')
+ }
+ aria-label='SSO member provisioning mode'
+ options={[
+ { value: 'automatic', label: 'Automatic' },
+ { value: 'invite-only', label: 'Invite only' },
+ ]}
/>
- Configure this in your identity provider
+ {formData.jitProvisioningEnabled
+ ? 'New users join as Members and use a seat. Grant workspace access separately.'
+ : 'Invite or provision new members before they sign in. Existing members keep their access.'}
-
- {/** Sim publishes no SP metadata document; these are the values it would carry. */}
- {isSaml && (
-
-
-
- Use this as Sim's entity ID in your identity provider.
-
-
- )}
-
-
-
setShowMapping((value) => !value)}
- rightIcon={ChevronDown}
- aria-expanded={showMapping}
- aria-controls='sso-mapping'
- className='w-fit'
- >
- Attribute mapping
-
-
-
-
-
-
- handleInputChange('mapEmail', e.target.value)}
- />
-
-
-
- handleInputChange('mapName', e.target.value)}
- />
-
-
-
- handleInputChange('mapId', e.target.value)}
- />
-
- Must be stable and unique per user — changing it later re-links accounts.
-
-
-
-
-
-
-
-
-
-
-
-
- handleInputChange('jitProvisioningEnabled', value === 'automatic')
- }
- aria-label='SSO member provisioning mode'
- options={[
- { value: 'automatic', label: 'Automatic' },
- { value: 'invite-only', label: 'Invite only' },
- ]}
- />
-
- {formData.jitProvisioningEnabled
- ? 'New users join as Members and use a seat. Grant workspace access separately.'
- : 'Invite or provision new members before they sign in. Existing members keep their access.'}
-
-
-
+
+
-
>
)
}
diff --git a/apps/sim/ee/sso/components/sso-settings.test.tsx b/apps/sim/ee/sso/components/sso-settings.test.tsx
index 371db59dcc1..6efb6418bec 100644
--- a/apps/sim/ee/sso/components/sso-settings.test.tsx
+++ b/apps/sim/ee/sso/components/sso-settings.test.tsx
@@ -212,12 +212,9 @@ vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-panel', () =
}))
/** The guard's own behavior is tested with its hook; here leaving is always allowed. */
-vi.mock('@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard', () => ({
+vi.mock('@/components/settings/use-settings-unsaved-guard', () => ({
useSettingsUnsavedGuard: () => ({
- showUnsavedModal: false,
- setShowUnsavedModal: vi.fn(),
guardBack: (onLeave: () => void) => onLeave(),
- confirmDiscard: vi.fn(),
}),
}))
diff --git a/apps/sim/ee/sso/components/sso-settings.tsx b/apps/sim/ee/sso/components/sso-settings.tsx
index cbaf2af126f..51c300fba1c 100644
--- a/apps/sim/ee/sso/components/sso-settings.tsx
+++ b/apps/sim/ee/sso/components/sso-settings.tsx
@@ -235,11 +235,10 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) {
/>
- {tab === 'domains' && (
-
-
-
- )}
+
+ {tab === 'domains' && }
+
+
{provisioningAvailable && (
diff --git a/apps/sim/ee/sso/components/verified-domains-section.tsx b/apps/sim/ee/sso/components/verified-domains-section.tsx
index eddf0bc18e7..0098a688f6b 100644
--- a/apps/sim/ee/sso/components/verified-domains-section.tsx
+++ b/apps/sim/ee/sso/components/verified-domains-section.tsx
@@ -4,6 +4,7 @@ import { useState } from 'react'
import { Chip, ChipConfirmModal, ChipCopyInput, ChipInput, ChipTag, toast } from '@sim/emcn'
import { Link } from '@sim/emcn/icons'
import { getErrorMessage } from '@sim/utils/errors'
+import { useSettingsUnsavedGuard } from '@/components/settings/use-settings-unsaved-guard'
import type { OrganizationDomain } from '@/lib/api/contracts/organization'
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
import {
@@ -25,6 +26,7 @@ const ADD_DOMAIN_FIELD_ID = 'sso-add-domain'
interface VerifiedDomainsSectionProps {
organizationId: string
+ active?: boolean
}
interface DomainRowProps {
@@ -102,7 +104,10 @@ function DomainRow({ organizationId, domain, onRemove }: DomainRowProps) {
/**
* Domain ownership shared by SSO and SCIM, managed in the Domains tab.
*/
-export function VerifiedDomainsSection({ organizationId }: VerifiedDomainsSectionProps) {
+export function VerifiedDomainsSection({
+ organizationId,
+ active = true,
+}: VerifiedDomainsSectionProps) {
const { data, isLoading, isError, error, isFetching, refetch } =
useOrganizationDomains(organizationId)
const addDomain = useAddOrganizationDomain()
@@ -112,13 +117,18 @@ export function VerifiedDomainsSection({ organizationId }: VerifiedDomainsSectio
const [pendingRemovalId, setPendingRemovalId] = useState
(null)
const domains = data?.domains ?? []
const pendingRemoval = domains.find((domain) => domain.id === pendingRemovalId) ?? null
+ useSettingsUnsavedGuard({
+ isDirty: newDomain.trim().length > 0,
+ navigationBlocked: addDomain.isPending || removeDomain.isPending,
+ onDiscard: () => setNewDomain(''),
+ })
async function handleAdd() {
const value = newDomain.trim()
if (!value) return
try {
await addDomain.mutateAsync({ orgId: organizationId, body: { domain: value } })
- setNewDomain('')
+ setNewDomain((current) => (current.trim() === value ? '' : current))
toast.success(`${value} added — add the DNS record and verify`)
} catch (error) {
toast.error(getErrorMessage(error, 'Failed to add domain'))
@@ -196,7 +206,7 @@ export function VerifiedDomainsSection({ organizationId }: VerifiedDomainsSectio
!open && setPendingRemovalId(null)}
title='Remove domain'
text={[
diff --git a/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx b/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx
index b9348bbb8ae..38c8d1945c0 100644
--- a/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx
+++ b/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx
@@ -7,6 +7,7 @@ import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import Image from 'next/image'
import { saveDiscardActions } from '@/components/settings/save-discard-actions'
+import { useSettingsUnsavedGuard } from '@/components/settings/use-settings-unsaved-guard'
import { HEX_COLOR_REGEX } from '@/lib/branding'
import type { OrganizationWhitelabelSettings } from '@/lib/branding/types'
import { DropZone } from '@/app/workspace/[workspaceId]/components/drop-zone'
@@ -14,7 +15,6 @@ import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/compo
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
import { useProfilePictureUpload } from '@/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload'
-import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard'
import { SettingRow } from '@/ee/components/setting-row'
import {
useUpdateWhitelabelSettings,
@@ -148,10 +148,16 @@ function WhitelabelingForm({ initialSettings, orgId, uploadWorkspaceId }: Whitel
(logoUpload.previewUrl || null) !== savedLogoUrl ||
(wordmarkUpload.previewUrl || null) !== savedWordmarkUrl
- useSettingsUnsavedGuard({ isDirty: hasChanges })
+ const isUploading = logoUpload.isUploading || wordmarkUpload.isUploading
+ const saving = updateSettings.isPending || isUploading
+ useSettingsUnsavedGuard({
+ isDirty: hasChanges,
+ navigationBlocked: saving,
+ onDiscard: handleDiscard,
+ })
async function handleSave() {
- if (!orgId) return
+ if (!orgId || saving) return
const colorFields: Array<[string, string]> = [
['Primary color', primaryColor],
@@ -202,6 +208,9 @@ function WhitelabelingForm({ initialSettings, orgId, uploadWorkspaceId }: Whitel
}
function handleDiscard() {
+ if (saving) return
+ logoUpload.reset()
+ wordmarkUpload.reset()
setBrandName(savedBrandName)
setPrimaryColor(savedPrimaryColor)
setPrimaryHoverColor(savedPrimaryHoverColor)
@@ -215,10 +224,9 @@ function WhitelabelingForm({ initialSettings, orgId, uploadWorkspaceId }: Whitel
setWordmarkUrl(savedWordmarkUrl)
}
- const isUploading = logoUpload.isUploading || wordmarkUpload.isUploading
const actions = saveDiscardActions({
dirty: hasChanges,
- saving: updateSettings.isPending,
+ saving,
saveDisabled: isUploading,
onSave: handleSave,
onDiscard: handleDiscard,
@@ -246,8 +254,14 @@ function WhitelabelingForm({ initialSettings, orgId, uploadWorkspaceId }: Whitel
labelTooltip='Shown in the collapsed sidebar. Square image — PNG, JPEG, or SVG, max 5MB.'
>
-
+ {
+ event.preventDefault()
+ if (!saving) logoUpload.handleFileDrop(event)
+ }}
+ >
{
+ if (!saving) logoUpload.handleFileChange(event)
+ }}
className='hidden'
/>
@@ -291,8 +309,15 @@ function WhitelabelingForm({ initialSettings, orgId, uploadWorkspaceId }: Whitel
labelTooltip='Shown in the expanded sidebar. Wide image — PNG, JPEG, or SVG, max 5MB.'
>
-
+ {
+ event.preventDefault()
+ if (!saving) wordmarkUpload.handleFileDrop(event)
+ }}
+ className='min-w-0 flex-1'
+ >
{
+ if (!saving) wordmarkUpload.handleFileChange(event)
+ }}
className='hidden'
/>
diff --git a/apps/sim/ee/workspace-forking/components/fork-sync-detail-view/fork-sync-detail-view.tsx b/apps/sim/ee/workspace-forking/components/fork-sync-detail-view/fork-sync-detail-view.tsx
index 6cea66844c2..c90a87333f1 100644
--- a/apps/sim/ee/workspace-forking/components/fork-sync-detail-view/fork-sync-detail-view.tsx
+++ b/apps/sim/ee/workspace-forking/components/fork-sync-detail-view/fork-sync-detail-view.tsx
@@ -6,14 +6,13 @@ import { ArrowLeft } from '@sim/emcn/icons'
import { useQueryState } from 'nuqs'
import { saveDiscardActions } from '@/components/settings/save-discard-actions'
import type { SettingsAction } from '@/components/settings/settings-header'
+import { useSettingsUnsavedGuard } from '@/components/settings/use-settings-unsaved-guard'
import { buildWebhookTriggerUrl } from '@/lib/webhooks/trigger-url'
-import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail'
import {
forkSyncDirectionParam,
forkSyncDirectionUrlKeys,
} from '@/app/workspace/[workspaceId]/settings/[section]/search-params'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
-import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard'
import { ForkSyncView } from '@/ee/workspace-forking/components/fork-sync/fork-sync-view'
import {
ARCHIVED_PREVIEW_LIMIT,
@@ -67,16 +66,18 @@ export function ForkSyncDetailView({
enabled: true,
})
- // Guard leaving the detail view (Back) while the mapping has unsaved edits, and feed
- // the shared settings dirty store so a sidebar section switch confirms too.
- const guard = useSettingsUnsavedGuard({ isDirty: controller.dirty })
+ const guard = useSettingsUnsavedGuard({
+ isDirty: controller.hasSessionChoices,
+ navigationBlocked: controller.saving || controller.submitting,
+ onDiscard: controller.discard,
+ })
const [confirmSyncOpen, setConfirmSyncOpen] = useState(false)
- // A direction switch drops every in-session choice (see `useForkSync`), so any confirms first.
- const [pendingDirection, setPendingDirection] = useState(null)
const changeDirection = (next: ForkDirection) => {
- if (controller.hasSessionChoices) setPendingDirection(next)
- else void setDirection(next)
+ if (next === controller.direction) return
+ guard.guardBack(() => {
+ void setDirection(next)
+ })
}
// Sync is the edge's primary action, so it's the rightmost/black chip; the caller's
@@ -85,7 +86,7 @@ export function ForkSyncDetailView({
const panelActions: SettingsAction[] = controller.dirty
? saveDiscardActions({
dirty: controller.dirty,
- saving: controller.saving,
+ saving: controller.saving || controller.submitting,
onSave: controller.save,
onDiscard: controller.discard,
})
@@ -122,23 +123,6 @@ export function ForkSyncDetailView({
-
-
- {
- if (!open) setPendingDirection(null)
- }}
- onDiscard={() => {
- if (pendingDirection) void setDirection(pendingDirection)
- setPendingDirection(null)
- }}
- />
-
toggleMany([id], checked)}
onToggleMany={toggleMany}
- disabled={controller.submitting}
+ disabled={controller.submitting || !controller.comparisonReady}
/>
) : (
toggleMany([id], checked)}
- disabled={controller.submitting}
+ disabled={controller.submitting || !controller.comparisonReady}
/>
)
})}
@@ -862,6 +862,7 @@ export function ForkSyncView({ controller, onDirectionChange }: ForkSyncViewProp
({
mockUseForkMapping: vi.fn(),
mockUseForkDiff: vi.fn(),
mockUpdateMutate: vi.fn(),
mockUpdateMutateAsync: vi.fn(),
mockPromote: vi.fn(),
+ mockPromoteIsPending: vi.fn(() => false),
}))
-vi.mock('@sim/emcn', () => emcnMock)
+vi.mock('@sim/emcn', () => ({ ...emcnMock, ChipConfirmModal: () => null }))
+vi.mock('next/navigation', () => nextNavigationMock)
+vi.mock('@/ee/workspace-forking/components/fork-sync/fork-sync-view', () => ({
+ ForkSyncView: ({
+ controller,
+ onDirectionChange,
+ }: {
+ controller: ForkSyncController
+ onDirectionChange: (direction: 'push' | 'pull') => void
+ }) => (
+ <>
+ controller.setTriggerAdoption('trigger-a', 'alternate-path')}
+ >
+ Change trigger
+
+ controller.setTriggerAdoption('trigger-a', 'default-path')}
+ >
+ Restore trigger
+
+ controller.toggleCopyKeys(['table:table-a'], false)}>
+ Skip copy
+
+ onDirectionChange('pull')}>
+ Pull
+
+ void controller.sync()}>
+ Sync choices
+
+
+ Discard choices
+
+ >
+ ),
+}))
vi.mock('@/ee/workspace-forking/hooks/workspace-fork', () => ({
useForkMapping: mockUseForkMapping,
@@ -40,7 +83,7 @@ vi.mock('@/ee/workspace-forking/hooks/workspace-fork', () => ({
mutateAsync: mockUpdateMutateAsync,
isPending: false,
}),
- usePromoteFork: () => ({ mutateAsync: mockPromote }),
+ usePromoteFork: () => ({ mutateAsync: mockPromote, isPending: mockPromoteIsPending() }),
}))
import {
@@ -159,8 +202,7 @@ function diffData(dependentReconfigs: ForkDependentReconfig[]) {
const mountedRoots: Root[] = []
-function renderForkSync(): { get: () => ForkSyncController } {
- ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+function renderForkSync(): { get: () => ForkSyncController; rerender: () => void } {
const container = document.createElement('div')
const root = createRoot(container)
mountedRoots.push(root)
@@ -182,6 +224,7 @@ function renderForkSync(): { get: () => ForkSyncController } {
})
return {
+ rerender: () => act(() => root.render( )),
get: () => {
if (!result) throw new Error('hook result is not ready')
return result
@@ -204,6 +247,7 @@ function valueFor(
}
beforeEach(() => {
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
mockUseForkMapping.mockReturnValue({
data: { entries: [CREDENTIAL_ENTRY] },
isLoading: false,
@@ -218,16 +262,171 @@ beforeEach(() => {
isPlaceholderData: false,
})
mockUpdateMutateAsync.mockResolvedValue({ success: true, updated: 1 })
+ mockPromoteIsPending.mockReturnValue(false)
mockPromote.mockResolvedValue(SUCCESSFUL_PROMOTE_RESULT)
})
afterEach(() => {
+ vi.useRealTimers()
act(() => {
for (const root of mountedRoots.splice(0)) root.unmount()
})
})
describe('useForkSync dependent payload', () => {
+ it('ignores copy choices made against a placeholder diff', async () => {
+ const data = {
+ ...diffData([]),
+ copyableUnmapped: [
+ {
+ kind: 'table',
+ sourceId: 'table-a',
+ label: 'Table',
+ parentId: null,
+ parentLabel: null,
+ referenced: true,
+ },
+ ],
+ }
+ mockUseForkDiff.mockReturnValue({
+ data,
+ isError: false,
+ error: null,
+ isPlaceholderData: true,
+ })
+ const { get, rerender } = renderForkSync()
+ act(() => get().toggleCopyKeys(['table:table-a'], false))
+ mockUseForkDiff.mockReturnValue({
+ data,
+ isError: false,
+ error: null,
+ isPlaceholderData: false,
+ })
+ rerender()
+ await act(async () => {
+ await get().sync()
+ })
+ const [request] = mockPromote.mock.calls[0]
+ expect(request.body.copyResources?.tables).toEqual(['table-a'])
+ })
+
+ it('guards sync-only choices and becomes clean after restoring defaults or discarding', async () => {
+ vi.useFakeTimers()
+ const refreshDiff = (includeNewCandidate = false) =>
+ mockUseForkDiff.mockReturnValue({
+ data: {
+ ...diffData([]),
+ triggerMappings: [
+ {
+ sourceBlockId: 'trigger-a',
+ blockName: 'Trigger',
+ workflowName: 'Workflow',
+ ownPath: null,
+ adoptablePaths: ['default-path', 'alternate-path'],
+ defaultAdoptPath: 'default-path',
+ },
+ ],
+ copyableUnmapped: [
+ {
+ kind: 'table',
+ sourceId: 'table-a',
+ label: 'Table',
+ parentId: null,
+ parentLabel: null,
+ referenced: true,
+ },
+ ...(includeNewCandidate
+ ? [
+ {
+ kind: 'table',
+ sourceId: 'table-b',
+ label: 'New table',
+ parentId: null,
+ parentLabel: null,
+ referenced: true,
+ },
+ ]
+ : []),
+ ],
+ },
+ isError: false,
+ error: null,
+ isPlaceholderData: false,
+ })
+ refreshDiff()
+ const container = document.createElement('div')
+ const root = createRoot(container)
+ mountedRoots.push(root)
+ let queryString = ''
+ const renderDetail = () =>
+ root.render(
+ {
+ queryString = update.queryString
+ }}
+ >
+ {}}
+ actions={[]}
+ />
+
+ )
+ act(renderDetail)
+ const expectLeave = (allowed: boolean) => {
+ let left = false
+ act(() =>
+ useSettingsDirtyStore.getState().requestLeave(() => {
+ left = true
+ })
+ )
+ expect(left).toBe(allowed)
+ useSettingsDirtyStore.getState().cancelLeave()
+ }
+ const click = (text: string) => {
+ const button = [...container.querySelectorAll('button')].find(
+ (element) => element.textContent === text
+ )
+ if (!button) throw new Error(`Missing control ${text}`)
+ act(() => button.click())
+ }
+ expectLeave(true)
+ refreshDiff(true)
+ act(renderDetail)
+ expectLeave(true)
+ mockPromoteIsPending.mockReturnValue(true)
+ act(renderDetail)
+ await act(async () => {
+ click('Pull')
+ await vi.runOnlyPendingTimersAsync()
+ })
+ expect(queryString).toBe('')
+ mockPromoteIsPending.mockReturnValue(false)
+ act(renderDetail)
+ click('Change trigger')
+ expectLeave(false)
+ click('Restore trigger')
+ expectLeave(true)
+ click('Skip copy')
+ expectLeave(false)
+ refreshDiff()
+ act(renderDetail)
+ expectLeave(false)
+ click('Discard choices')
+ expectLeave(true)
+ click('Skip copy')
+ expectLeave(false)
+ await act(async () => {
+ click('Sync choices')
+ await vi.runOnlyPendingTimersAsync()
+ })
+ expectLeave(true)
+ })
+
it('submits an effective blank for an optional dependent invalidated by a real provider change', () => {
const { get } = renderForkSync()
diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/use-fork-sync.ts b/apps/sim/ee/workspace-forking/components/fork-sync/use-fork-sync.ts
index 52fa62adc39..c6775a5481f 100644
--- a/apps/sim/ee/workspace-forking/components/fork-sync/use-fork-sync.ts
+++ b/apps/sim/ee/workspace-forking/components/fork-sync/use-fork-sync.ts
@@ -1,13 +1,6 @@
'use client'
-import {
- type Dispatch,
- type SetStateAction,
- useCallback,
- useEffect,
- useMemo,
- useState,
-} from 'react'
+import { type Dispatch, type SetStateAction, useCallback, useMemo, useState } from 'react'
import { toast } from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
import type {
@@ -300,11 +293,10 @@ function takenTargetOwners(
/**
* The full sync surface's state for one fork edge, in the chosen direction: the editable
* resource mapping (in-session target overrides + dependent re-picks, persisted via Save or
- * as part of Sync), the copy-resources selection (seeded once the diff settles), the reactive
+ * as part of Sync), the copy-resources selection (derived from referenced candidates), the reactive
* would-clear blockers, the per-kind status summaries, the Sync gate, and the promote run
* itself. A direction switch drops every in-session choice — the mapping set, copy candidates,
- * and blockers all depend on the direction — and the copy selection re-seeds only from a
- * settled (non-placeholder) diff so a stale payload can't latch wrong keys.
+ * and blockers all depend on the direction. Placeholder copy controls remain read-only.
*/
export function useForkSync(params: {
workspaceId: string
@@ -328,13 +320,9 @@ export function useForkSync(params: {
// persists as the stored mapping - so the selection survives every future sync without
// re-picking.
const [reconfig, setReconfig] = useState({})
- // Referenced-but-unmapped resources the user chose to copy into the target (keyed by
- // `${kind}:${sourceId}`); default-selected once the diff loads. Selected ones are copied on
- // sync so their references resolve to the copy instead of being cleared.
- const [copySelected, setCopySelected] = useState>(new Set())
- const [copyDefaulted, setCopyDefaulted] = useState(false)
+ const [copyOverrides, setCopyOverrides] = useState>({})
// Source-deleted references the user explicitly accepted losing in the target (keyed by
- // `${kind}:${sourceId}`). In-session only, like `copySelected` - an acknowledgment is a decision
+ // `${kind}:${sourceId}`). In-session only, like `copyOverrides` - an acknowledgment is a decision
// about THIS sync, never a stored mapping. The server re-checks that each source really is gone
// before honouring one.
const [droppedRefs, setDroppedRefs] = useState>(new Set())
@@ -354,8 +342,7 @@ export function useForkSync(params: {
setPreviousSessionKey(sessionKey)
setTargets({})
setReconfig({})
- setCopySelected(new Set())
- setCopyDefaulted(false)
+ setCopyOverrides({})
setDroppedRefs(new Set())
setTriggerAdoptions({})
setComparisonSelection(null)
@@ -379,6 +366,14 @@ export function useForkSync(params: {
() => diff.data?.copyableUnmapped ?? [],
[diff.data?.copyableUnmapped]
)
+ const copySelected = useMemo(() => {
+ const selected = forkDefaultCopySelection(copyableUnmapped)
+ for (const [key, checked] of Object.entries(copyOverrides)) {
+ if (checked) selected.add(key)
+ else selected.delete(key)
+ }
+ return selected
+ }, [copyableUnmapped, copyOverrides])
const clearedRefs = useMemo(() => diff.data?.clearedRefs ?? [], [diff.data?.clearedRefs])
const triggerMappings = useMemo(
() => diff.data?.triggerMappings ?? [],
@@ -446,20 +441,6 @@ export function useForkSync(params: {
return { referencedByKind: referenced, unreferencedByKind: unreferenced }
}, [visibleCopyables])
- // Default every REFERENCED copyable resource to "copy" once the diff loads, so the common case
- // (bring the referenced resources along) needs no clicks; the user can deselect to clear
- // instead. Unreferenced candidates start unselected (see `forkDefaultCopySelection`) - copying
- // them is opt-in since nothing references them. Seed ONLY from a settled diff for the current
- // direction: on a direction switch the reset clears `copyDefaulted`, but `useForkDiff` keeps
- // the previous direction's payload (placeholderData) until the new fetch resolves - seeding
- // from it would latch against stale keys and leave the real copyables unchecked, clearing
- // their references on Sync.
- useEffect(() => {
- if (!enabled || diff.isPlaceholderData || copyableUnmapped.length === 0 || copyDefaulted) return
- setCopyDefaulted(true)
- setCopySelected(forkDefaultCopySelection(copyableUnmapped))
- }, [enabled, diff.isPlaceholderData, copyableUnmapped, copyDefaulted])
-
// Group dependents by their parent (kind:sourceId) once, so each mapping entry gets a
// STABLE `dependents` array reference - a fresh `.filter` per render would defeat the
// workflow-card grouping memos.
@@ -517,15 +498,20 @@ export function useForkSync(params: {
): ReadonlyMap =>
direction === 'push' ? takenTargetOwners(items, targets, entry) : EMPTY_TARGET_OWNERS
- const toggleCopyKeys = (keys: string[], checked: boolean) =>
- setCopySelected((prev) => {
- const next = new Set(prev)
- for (const key of keys) {
- if (checked) next.add(key)
- else next.delete(key)
+ const toggleCopyKeys = (keys: string[], checked: boolean) => {
+ if (!comparisonReady) return
+ const toggledKeys = new Set(keys)
+ setCopyOverrides((prev) => {
+ const next = { ...prev }
+ for (const candidate of copyableUnmapped) {
+ const key = forkRefKey(candidate)
+ if (!toggledKeys.has(key)) continue
+ if (checked === candidate.referenced) delete next[key]
+ else next[key] = checked
}
return next
})
+ }
// Group mappings by resource type - one accordion row per kind, required types first.
const groups = useMemo(() => {
@@ -799,21 +785,28 @@ export function useForkSync(params: {
// by a completed copy never read as a change. A candidate defaults to selected when referenced.
const copySelectionChanged = useMemo(
() =>
- copyDefaulted &&
visibleCopyables.some(
(candidate) => copySelected.has(forkRefKey(candidate)) !== candidate.referenced
),
- [copyDefaulted, visibleCopyables, copySelected]
+ [visibleCopyables, copySelected]
)
+ const triggerMappingOverrides = triggerMappings
+ .filter(
+ (mapping) =>
+ mapping.sourceBlockId in triggerAdoptions &&
+ (triggerAdoptions[mapping.sourceBlockId] || null) !== mapping.defaultAdoptPath
+ )
+ .map((mapping) => ({
+ sourceBlockId: mapping.sourceBlockId,
+ adoptPath: triggerAdoptions[mapping.sourceBlockId] || null,
+ }))
+
const hasSessionChoices =
- dirty ||
- copySelectionChanged ||
- droppedRefs.size > 0 ||
- Object.keys(triggerAdoptions).length > 0
+ dirty || copySelectionChanged || droppedRefs.size > 0 || triggerMappingOverrides.length > 0
const save = () => {
- if (!otherWorkspaceId || !dirty || updateMapping.isPending) return
+ if (!otherWorkspaceId || !dirty || updateMapping.isPending || promote.isPending) return
const submittedTargets = targets
const submittedReconfig = reconfig
updateMapping.mutate(
@@ -919,12 +912,14 @@ export function useForkSync(params: {
setTargets({})
setReconfig({})
setTriggerAdoptions({})
+ setCopyOverrides({})
+ setDroppedRefs(new Set())
setReviewedSourceVersions({})
setComparisonSelection(null)
}
const sync = async () => {
- if (!otherWorkspaceId || !diff.data) return
+ if (!otherWorkspaceId || !diff.data || updateMapping.isPending || promote.isPending) return
const submittedReviewedVersions = reviewedSourceVersions
const sourceVersions = new Map(
diff.data.sourceVersions.map((source) => [source.workflowId, source.deploymentVersionId])
@@ -939,6 +934,7 @@ export function useForkSync(params: {
const submittedReconfig = reconfig
const submittedDroppedRefs = droppedRefs
const submittedTriggerAdoptions = triggerAdoptions
+ const submittedCopyOverrides = copyOverrides
// Capture every payload from the state at confirm time, before any await - the page's
// controls stay mounted during the run (unlike the old modal, which blocked its UI), so a
// mid-flight edit must not leak into the promote body.
@@ -960,18 +956,6 @@ export function useForkSync(params: {
sourceId: key.slice(separator + 1),
}
})
- // Only the choices that DIFFER from the server's default need sending - an untouched row is
- // already what the server would pick, so an empty list means "the preview, as shown".
- const triggerMappingOverrides = triggerMappings
- .filter(
- (mapping) =>
- mapping.sourceBlockId in triggerAdoptions &&
- (triggerAdoptions[mapping.sourceBlockId] || null) !== mapping.defaultAdoptPath
- )
- .map((mapping) => ({
- sourceBlockId: mapping.sourceBlockId,
- adoptPath: triggerAdoptions[mapping.sourceBlockId] || null,
- }))
try {
const copyResources = {
knowledgeBases: selectedCopyables
@@ -1040,6 +1024,7 @@ export function useForkSync(params: {
setReconfig((current) => (current === submittedReconfig ? {} : current))
setDroppedRefs((current) => (current === submittedDroppedRefs ? new Set() : current))
setTriggerAdoptions((current) => (current === submittedTriggerAdoptions ? {} : current))
+ setCopyOverrides((current) => (current === submittedCopyOverrides ? {} : current))
const target = otherWorkspaceName || 'the workspace'
const label = direction === 'pull' ? `Pulled from "${target}"` : `Pushed to "${target}"`
@@ -1090,6 +1075,7 @@ export function useForkSync(params: {
? getErrorMessage(diff.error, "Couldn't load sync details. Reload the page to retry.")
: null,
hasDiff: Boolean(diff.data),
+ comparisonReady,
hasMapping: Boolean(mapping.data),
groups,
kindSummaries,
@@ -1119,7 +1105,6 @@ export function useForkSync(params: {
blockingRefs,
dependentClears,
workflowChanges,
- comparisonReady,
comparisonSelection,
openComparison,
closeComparison,
diff --git a/apps/sim/stores/settings/dirty/store.test.ts b/apps/sim/stores/settings/dirty/store.test.ts
index c0d1ebf01c9..08525a774ab 100644
--- a/apps/sim/stores/settings/dirty/store.test.ts
+++ b/apps/sim/stores/settings/dirty/store.test.ts
@@ -8,8 +8,7 @@ describe('settings dirty store', () => {
it('blocks navigation without creating a discard action while a save is in flight', () => {
const leave = vi.fn()
- useSettingsDirtyStore.getState().setDirty(true)
- useSettingsDirtyStore.getState().setNavigationBlocked(true)
+ useSettingsDirtyStore.getState().setGuard('editor', { isDirty: true, navigationBlocked: true })
expect(useSettingsDirtyStore.getState().requestLeave(leave)).toBe(false)
expect(useSettingsDirtyStore.getState().pendingLeave).toBeNull()
diff --git a/apps/sim/stores/settings/dirty/store.ts b/apps/sim/stores/settings/dirty/store.ts
index a2422075c17..db549b4964b 100644
--- a/apps/sim/stores/settings/dirty/store.ts
+++ b/apps/sim/stores/settings/dirty/store.ts
@@ -1,58 +1,93 @@
+import { omit } from '@sim/utils/object'
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
+interface SettingsGuardState {
+ isDirty: boolean
+ navigationBlocked: boolean
+ onDiscard?: () => void
+}
+
interface SettingsDirtyStore {
isDirty: boolean
navigationBlocked: boolean
- /** Leave action deferred until the user confirms discard. */
+ guards: Record
+ /** Confirmed leave action, including its discard policy. */
pendingLeave: (() => void) | null
- setDirty: (dirty: boolean) => void
- setNavigationBlocked: (blocked: boolean) => void
+ setGuard: (id: string, guard: SettingsGuardState) => void
+ removeGuard: (id: string) => void
/**
* Call before leaving the current settings surface. If clean, runs `leave` immediately
* and returns `true`. If dirty, stashes `leave` and returns `false` so the shared
- * discard dialog can confirm before running it.
+ * discard dialog can confirm before running it. History defers discard until
+ * an actual traversal is observed by passing `discardOnConfirm: false`.
*/
- requestLeave: (leave: () => void) => boolean
- /** Clears dirty + pending state and runs the deferred leave action. */
+ requestLeave: (leave: () => void, options?: { discardOnConfirm?: boolean }) => boolean
+ /** Runs the deferred leave action after confirmation. */
confirmLeave: () => void
+ /** Discards each registered dirty draft. */
+ discardDrafts: () => void
/** Cancels a pending leave without clearing dirty state. */
cancelLeave: () => void
- /** Resets all state — call on component unmount. */
+ /** Resets the entire settings surface. Individual editors remove their own guard. */
reset: () => void
}
const initialState = {
isDirty: false,
navigationBlocked: false,
+ guards: {} as Record,
pendingLeave: null as (() => void) | null,
}
+function summarizeGuards(guards: Record) {
+ const values = Object.values(guards)
+ const isDirty = values.some((guard) => guard.isDirty)
+ const navigationBlocked = values.some((guard) => guard.navigationBlocked)
+ return {
+ guards,
+ isDirty,
+ navigationBlocked,
+ ...(!isDirty || navigationBlocked ? { pendingLeave: null } : {}),
+ }
+}
+
export const useSettingsDirtyStore = create()(
devtools(
(set, get) => ({
...initialState,
- setDirty: (dirty) => set({ isDirty: dirty }),
+ setGuard: (id, guard) => set((state) => summarizeGuards({ ...state.guards, [id]: guard })),
- setNavigationBlocked: (blocked) =>
- set({ navigationBlocked: blocked, ...(blocked ? { pendingLeave: null } : {}) }),
+ removeGuard: (id) => set((state) => summarizeGuards(omit(state.guards, [id]))),
- requestLeave: (leave) => {
+ requestLeave: (leave, { discardOnConfirm = true } = {}) => {
if (get().navigationBlocked) return false
if (!get().isDirty) {
leave()
return true
}
- set({ pendingLeave: leave })
+ set({
+ pendingLeave: () => {
+ if (discardOnConfirm) get().discardDrafts()
+ leave()
+ },
+ })
return false
},
confirmLeave: () => {
const { navigationBlocked, pendingLeave } = get()
if (navigationBlocked) return
- set({ ...initialState })
- pendingLeave?.()
+ if (!pendingLeave) return
+ set({ pendingLeave: null })
+ pendingLeave()
+ },
+
+ discardDrafts: () => {
+ for (const guard of Object.values(get().guards)) {
+ if (guard.isDirty) guard.onDiscard?.()
+ }
},
cancelLeave: () => set({ pendingLeave: null }),
diff --git a/scripts/check-unused-exports.baseline.json b/scripts/check-unused-exports.baseline.json
index cf291b4465e..84e2a8a407d 100644
--- a/scripts/check-unused-exports.baseline.json
+++ b/scripts/check-unused-exports.baseline.json
@@ -948,16 +948,13 @@
"apps/sim/ee/scim/hooks/scim.ts#SCIM_ACTIVITY_STALE_TIME",
"apps/sim/ee/scim/hooks/scim.ts#SCIM_CONNECTION_STALE_TIME",
"apps/sim/ee/scim/hooks/scim.ts#SCIM_MAPPINGS_STALE_TIME",
- "apps/sim/ee/scim/hooks/scim.ts#scimKeys",
"apps/sim/ee/scim/lib/application/authorized-scim-admin-use-case.ts#ScimAdminContext",
"apps/sim/ee/scim/lib/application/authorized-scim-admin-use-case.ts#ScimAdminUseCaseResultArgs",
"apps/sim/ee/scim/lib/application/authorized-scim-use-case.ts#ScimUseCaseResultArgs",
"apps/sim/ee/scim/lib/projection/grants.ts#GrantApplication",
"apps/sim/ee/scim/lib/protocol/resources.ts#ScimResourceMeta",
"apps/sim/ee/session-policy/hooks/session-policy.ts#SESSION_POLICY_STALE_TIME",
- "apps/sim/ee/session-policy/hooks/session-policy.ts#sessionPolicyKeys",
"apps/sim/ee/sso/hooks/domains.ts#DOMAINS_STALE_TIME",
- "apps/sim/ee/sso/hooks/domains.ts#domainKeys",
"apps/sim/ee/sso/hooks/sso-policy.ts#SSO_POLICY_STALE_TIME",
"apps/sim/ee/sso/hooks/sso.ts#SSO_PROVIDERS_STALE_TIME",
"apps/sim/ee/sso/search-params.ts#SSO_SETTINGS_TABS",
@@ -1036,7 +1033,6 @@
"apps/sim/hooks/queries/admin-users.ts#ADMIN_USER_LIST_STALE_TIME",
"apps/sim/hooks/queries/admin-users.ts#adminUserKeys",
"apps/sim/hooks/queries/allowed-providers.ts#BLACKLISTED_PROVIDERS_STALE_TIME",
- "apps/sim/hooks/queries/allowed-providers.ts#allowedProvidersKeys",
"apps/sim/hooks/queries/api-key-list.ts#API_KEYS_COMBINED_STALE_TIME",
"apps/sim/hooks/queries/api-key-list.ts#fetchApiKeys",
"apps/sim/hooks/queries/block-visibility.ts#BLOCK_VISIBILITY_STALE_TIME",
@@ -1058,7 +1054,6 @@
"apps/sim/hooks/queries/credentials.ts#WorkspaceCredentialRole",
"apps/sim/hooks/queries/custom-blocks.ts#CUSTOM_BLOCK_LIST_STALE_TIME",
"apps/sim/hooks/queries/custom-blocks.ts#CUSTOM_BLOCK_USAGES_STALE_TIME",
- "apps/sim/hooks/queries/custom-blocks.ts#customBlockKeys",
"apps/sim/hooks/queries/custom-tools.ts#CUSTOM_TOOL_LIST_STALE_TIME",
"apps/sim/hooks/queries/dashboards.ts#DASHBOARD_STALE_TIME",
"apps/sim/hooks/queries/deployments.ts#CHAT_DEPLOYMENT_STATUS_STALE_TIME",
@@ -1176,7 +1171,6 @@
"apps/sim/hooks/queries/resume-execution.ts#ResumeQueueEntrySummary",
"apps/sim/hooks/queries/sandboxes.ts#SANDBOX_BUILD_POLL_INTERVAL",
"apps/sim/hooks/queries/sandboxes.ts#hasPendingBuild",
- "apps/sim/hooks/queries/sandboxes.ts#sandboxKeys",
"apps/sim/hooks/queries/schedules.ts#SCHEDULE_BLOCK_STALE_TIME",
"apps/sim/hooks/queries/schedules.ts#SCHEDULE_LIST_STALE_TIME",
"apps/sim/hooks/queries/schedules.ts#WorkspaceScheduleData",
@@ -1485,7 +1479,6 @@
"apps/sim/lib/api/contracts/custom-blocks.ts#isAllowedCustomBlockIconUrl",
"apps/sim/lib/api/contracts/custom-blocks.ts#listCustomBlocksQuerySchema",
"apps/sim/lib/api/contracts/custom-blocks.ts#publishCustomBlockBodySchema",
- "apps/sim/lib/api/contracts/custom-blocks.ts#updateCustomBlockBodySchema",
"apps/sim/lib/api/contracts/dashboards.ts#DashboardRecord",
"apps/sim/lib/api/contracts/dashboards.ts#dashboardRecordSchema",
"apps/sim/lib/api/contracts/data-drains.ts#createDataDrainBodySchema",
@@ -5206,7 +5199,6 @@
"apps/sim/lib/tokenization/types.ts#TokenizationInput",
"apps/sim/lib/uploads/archive.ts#MAX_ARCHIVE_ENTRIES",
"apps/sim/lib/uploads/archive.ts#MAX_ARCHIVE_TOTAL_BYTES",
- "apps/sim/lib/uploads/client/session-upload.ts#UploadInternalFileSessionParams",
"apps/sim/lib/uploads/client/upload-session.ts#UploadSessionTransportError",
"apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts#isSupportedFileType",
"apps/sim/lib/uploads/contexts/copilot/index.ts#CopilotStoredFile",
@@ -5307,7 +5299,6 @@
"apps/sim/lib/workflows/custom-blocks/operations.ts#getCustomBlockWithInputsByWorkflowId",
"apps/sim/lib/workflows/custom-blocks/operations.ts#isCustomBlocksEligible",
"apps/sim/lib/workflows/custom-blocks/operations.ts#listCustomBlockSummariesForWorkspace",
- "apps/sim/lib/workflows/custom-blocks/settings-input.ts#CustomBlockInputPlaceholder",
"apps/sim/lib/workflows/custom-blocks/settings-input.ts#isAllowedCustomBlockIconUrl",
"apps/sim/lib/workflows/deployment-lifecycle.ts#DeploymentComponentReadiness",
"apps/sim/lib/workflows/deployment-outbox.ts#PrepareDeploymentWebhooksInput",