diff --git a/.agents/skills/add-settings-page/SKILL.md b/.agents/skills/add-settings-page/SKILL.md index 5cddb31b5a9..94ff0f182e7 100644 --- a/.agents/skills/add-settings-page/SKILL.md +++ b/.agents/skills/add-settings-page/SKILL.md @@ -66,8 +66,8 @@ Each grep lists candidates; review every match against the expected ones named b - Editable pages: confirm Save/Discard go through `saveDiscardActions()` and dirty is wired via `useSettingsUnsavedGuard` (called before early-return gates) — flag any hand-rolled Save button, `beforeunload`, or unsaved modal. - `git grep -n "beforeunload" -- 'apps/sim/**/settings/**' 'apps/sim/ee/'` - should only hit the centralized `use-settings-before-unload.ts`. + `git grep -n "beforeunload" -- 'apps/sim/**/settings/**' 'apps/sim/ee/' 'apps/sim/components/settings/' ':(exclude,glob)**/*.test.*'` + should only hit the centralized `use-settings-browser-navigation.ts`. 5. Fix each finding with the smallest structural change that satisfies the checklist; do not touch handlers, state, queries, or gate returns. A pixel-size fix swaps only the size class for its exact-pixel token (`text-[12px]` → `text-caption`). diff --git a/.claude/rules/sim-settings-pages.md b/.claude/rules/sim-settings-pages.md index 40a6b5dcfba..4915865cc8a 100644 --- a/.claude/rules/sim-settings-pages.md +++ b/.claude/rules/sim-settings-pages.md @@ -339,34 +339,33 @@ rendered through `` from `@/components/settings/settings-header` — that is the shared chip path, and it is what keeps tone/icon/variant/tooltip handling from drifting between the two shells. Reach for it before hand-rolling a `Chip`. -- **`useSettingsUnsavedGuard({ isDirty })`** (`…/settings/hooks/use-settings-unsaved-guard`) - — syncs the page's local `isDirty` into the shared `useSettingsDirtyStore` (so - the sidebar's **section-switch** confirm + the centralized `beforeunload` both - apply for free) and returns `{ showUnsavedModal, setShowUnsavedModal, guardBack, - confirmDiscard }` for a detail view's **in-view back** chip. - - **Top-level pages** (whitelabeling, sso): call it **unassigned** — - `useSettingsUnsavedGuard({ isDirty: hasChanges })` — they only need the - store-sync; the sidebar/`beforeunload` do the rest. - - **Detail sub-views** (data-retention, access-control group-detail): route the - back chip through `onClick={() => guard.guardBack(closeFn)}` and render the - shared `` - (from `@/app/workspace/[workspaceId]/components/credential-detail`). The - in-view header **Discard** chip (via `saveDiscardActions({ onDiscard })`) is a - *reset to original* — distinct from the back-confirm's discard, which leaves. -- **`useSettingsBeforeUnload`** is mounted by the settings shells - (`settings/layout.tsx` and `components/settings/standalone-settings-shell.tsx`) — - never add a per-page `beforeunload`. -- **Dirty *computation* stays local** (shapes differ: field-compare vs - normalize+stringify) — only how dirty is *consumed* is shared. Derive it (a - `const`/`useMemo`), never store it in `useState`. -- **CRITICAL — rules of hooks:** call `useSettingsUnsavedGuard(...)` - **unconditionally, before every early-return gate** (entitlement / loading / - not-entitled `return `). A hook placed after a gate is - skipped on gated renders and crashes. -- The route-based credential detail keeps its own `useUnsavedChangesGuard` (it - guards real `router.push` navigation + browser Back via a history sentinel); - it already shares `UnsavedChangesModal`, so copy stays unified. +- **`useSettingsUnsavedGuard({ isDirty, navigationBlocked, onDiscard })`** from + `@/components/settings/use-settings-unsaved-guard` registers one editor with the + shared settings store. Register every full-page draft, including inline creation + forms. Dirty computation stays local and derived; fetched defaults stay clean. +- **`onDiscard` resets the registered draft.** The root `SettingsNavigationGuard` + owns the discard dialog, internal link interception, browser Back/Forward, and + refresh protection for registered editors. Do not add a per-page dialog or + history listener for an editor using this hook. Skill create/detail pages retain + their existing `useUnsavedChangesGuard` and local dialog until they migrate to + the shared registration. +- **Native history needs entry indexes.** The Navigation API supplies them for + legacy entries. Without it, native traversal to an unindexed entry cannot be + cancelled reliably; never guess a direction or rewrite the history stack. +- **History confirmation authorizes traversal before discarding.** Drafts are + discarded only when the browser reports the confirmed traversal, so a no-op + Back or Forward retains edits. Known cross-document traversal uses native unload + protection. An unknown cross-document target on a classic History browser can + require a second native confirmation after the shared dialog. +- **Detail back controls** call `guard.guardBack(closeFn)`. Other destructive view + transitions, such as switching an editor's direction or environment, also use + the shared `requestLeave` action before resetting their draft. +- **`navigationBlocked` covers pending saves and uploads.** The shared guard blocks + navigation while requests are pending. Attempts are not queued; retry after they + settle. Disable or preserve edits made during requests; + failed saves retain drafts, and successful saves only clear committed values. +- **Call the hook unconditionally, before every early-return gate** (entitlement, + loading, or empty-state return), so gated renders preserve hook order. ## Detail sub-views diff --git a/.cursor/rules/sim-settings-pages.mdc b/.cursor/rules/sim-settings-pages.mdc index 9f6959a04eb..d594f0b3ccf 100644 --- a/.cursor/rules/sim-settings-pages.mdc +++ b/.cursor/rules/sim-settings-pages.mdc @@ -336,34 +336,33 @@ rendered through `` from `@/components/settings/settings-header` — that is the shared chip path, and it is what keeps tone/icon/variant/tooltip handling from drifting between the two shells. Reach for it before hand-rolling a `Chip`. -- **`useSettingsUnsavedGuard({ isDirty })`** (`…/settings/hooks/use-settings-unsaved-guard`) - — syncs the page's local `isDirty` into the shared `useSettingsDirtyStore` (so - the sidebar's **section-switch** confirm + the centralized `beforeunload` both - apply for free) and returns `{ showUnsavedModal, setShowUnsavedModal, guardBack, - confirmDiscard }` for a detail view's **in-view back** chip. - - **Top-level pages** (whitelabeling, sso): call it **unassigned** — - `useSettingsUnsavedGuard({ isDirty: hasChanges })` — they only need the - store-sync; the sidebar/`beforeunload` do the rest. - - **Detail sub-views** (data-retention, access-control group-detail): route the - back chip through `onClick={() => guard.guardBack(closeFn)}` and render the - shared `` - (from `@/app/workspace/[workspaceId]/components/credential-detail`). The - in-view header **Discard** chip (via `saveDiscardActions({ onDiscard })`) is a - *reset to original* — distinct from the back-confirm's discard, which leaves. -- **`useSettingsBeforeUnload`** is mounted by the settings shells - (`settings/layout.tsx` and `components/settings/standalone-settings-shell.tsx`) — - never add a per-page `beforeunload`. -- **Dirty *computation* stays local** (shapes differ: field-compare vs - normalize+stringify) — only how dirty is *consumed* is shared. Derive it (a - `const`/`useMemo`), never store it in `useState`. -- **CRITICAL — rules of hooks:** call `useSettingsUnsavedGuard(...)` - **unconditionally, before every early-return gate** (entitlement / loading / - not-entitled `return `). A hook placed after a gate is - skipped on gated renders and crashes. -- The route-based credential detail keeps its own `useUnsavedChangesGuard` (it - guards real `router.push` navigation + browser Back via a history sentinel); - it already shares `UnsavedChangesModal`, so copy stays unified. +- **`useSettingsUnsavedGuard({ isDirty, navigationBlocked, onDiscard })`** from + `@/components/settings/use-settings-unsaved-guard` registers one editor with the + shared settings store. Register every full-page draft, including inline creation + forms. Dirty computation stays local and derived; fetched defaults stay clean. +- **`onDiscard` resets the registered draft.** The root `SettingsNavigationGuard` + owns the discard dialog, internal link interception, browser Back/Forward, and + refresh protection for registered editors. Do not add a per-page dialog or + history listener for an editor using this hook. Skill create/detail pages retain + their existing `useUnsavedChangesGuard` and local dialog until they migrate to + the shared registration. +- **Native history needs entry indexes.** The Navigation API supplies them for + legacy entries. Without it, native traversal to an unindexed entry cannot be + cancelled reliably; never guess a direction or rewrite the history stack. +- **History confirmation authorizes traversal before discarding.** Drafts are + discarded only when the browser reports the confirmed traversal, so a no-op + Back or Forward retains edits. Known cross-document traversal uses native unload + protection. An unknown cross-document target on a classic History browser can + require a second native confirmation after the shared dialog. +- **Detail back controls** call `guard.guardBack(closeFn)`. Other destructive view + transitions, such as switching an editor's direction or environment, also use + the shared `requestLeave` action before resetting their draft. +- **`navigationBlocked` covers pending saves and uploads.** The shared guard blocks + navigation while requests are pending. Attempts are not queued; retry after they + settle. Disable or preserve edits made during requests; + failed saves retain drafts, and successful saves only clear committed values. +- **Call the hook unconditionally, before every early-return gate** (entitlement, + loading, or empty-state return), so gated renders preserve hook order. ## Detail sub-views diff --git a/apps/sim/app/layout.tsx b/apps/sim/app/layout.tsx index d542ca8eb17..874c1c50257 100644 --- a/apps/sim/app/layout.tsx +++ b/apps/sim/app/layout.tsx @@ -3,6 +3,7 @@ import type { Metadata, Viewport } from 'next' import Script from 'next/script' import { NuqsAdapter } from 'nuqs/adapters/next/app' import { BrandedLayout } from '@/components/branded-layout' +import { SettingsNavigationGuard } from '@/components/settings/settings-navigation-guard' import { PasteAdmissionGuard } from '@/app/_shell/paste-admission-guard' import { BrowserTelemetry } from '@/app/_shell/providers/browser-telemetry' import { PostHogProvider } from '@/app/_shell/providers/posthog-provider' @@ -45,6 +46,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) const themeCSS = generateThemeCSS() const application = ( + 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} /> 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 }) {
{ setNewName(e.target.value) @@ -387,6 +416,7 @@ function LicensesTab({ environment }: { environment: MothershipEnv }) {
setApprovalReference(event.target.value)} placeholder='Signed order form or written approval' @@ -396,6 +426,7 @@ function LicensesTab({ environment }: { environment: MothershipEnv }) {
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({
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 ( <> )} - - ) } 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)} + /> + + + + ) +} + +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)} + /> + + +
+ + + 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)} - /> - - -
- - - 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' && ( - - - - )} + {provisioningAvailable && (