diff --git a/apps/sim/app/account/settings/[section]/page.tsx b/apps/sim/app/account/settings/[section]/page.tsx index a4a551f1a89..d5cd82d5dbb 100644 --- a/apps/sim/app/account/settings/[section]/page.tsx +++ b/apps/sim/app/account/settings/[section]/page.tsx @@ -1,4 +1,3 @@ -import { Suspense } from 'react' import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import type { Metadata } from 'next' import { notFound, redirect } from 'next/navigation' @@ -58,14 +57,10 @@ export default async function AccountSettingsSectionPage({ } /** - * Sections read URL query params via nuqs, so the renderer must sit under a - * Suspense boundary. The null fallback preserves the existing chunk-loading UI. + * No Suspense boundary: a fallback mounted with the section puts React's 300ms reveal floor + * under every switch (see the workspace section layout). */ - const content = ( - - - - ) + const content = if (parsed === 'general') { const queryClient = getQueryClient() diff --git a/apps/sim/app/o/[organizationId]/settings/[section]/loading.tsx b/apps/sim/app/o/[organizationId]/settings/[section]/loading.tsx deleted file mode 100644 index 8c85b14beed..00000000000 --- a/apps/sim/app/o/[organizationId]/settings/[section]/loading.tsx +++ /dev/null @@ -1,4 +0,0 @@ -/** Lets the section layout show its heading while authorization and content finish loading. */ -export default function OrganizationSettingsSectionLoading() { - return null -} diff --git a/apps/sim/app/o/[organizationId]/settings/[section]/page.tsx b/apps/sim/app/o/[organizationId]/settings/[section]/page.tsx index 9da86653b73..54211800776 100644 --- a/apps/sim/app/o/[organizationId]/settings/[section]/page.tsx +++ b/apps/sim/app/o/[organizationId]/settings/[section]/page.tsx @@ -1,4 +1,3 @@ -import { Suspense } from 'react' import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import type { Metadata } from 'next' import { notFound, redirect } from 'next/navigation' @@ -82,12 +81,13 @@ export default async function OrganizationSettingsSectionPage({ return } - /** Account sections read URL params via nuqs, so the renderer sits under a boundary; nothing stands in for it. */ + /** + * No Suspense boundary: a fallback mounted with the section puts React's 300ms reveal floor + * under every switch (see the workspace section layout). + */ const content = ( - - - + ) diff --git a/apps/sim/app/selfhost/settings/[section]/page.tsx b/apps/sim/app/selfhost/settings/[section]/page.tsx index 933b783a082..e5b0d30a7a4 100644 --- a/apps/sim/app/selfhost/settings/[section]/page.tsx +++ b/apps/sim/app/selfhost/settings/[section]/page.tsx @@ -1,4 +1,3 @@ -import { Suspense } from 'react' import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import type { Metadata } from 'next' import { notFound, redirect } from 'next/navigation' @@ -48,14 +47,10 @@ export default async function SelfHostSettingsSectionPage({ if (parsed === 'chat-keys' && !isHosted) redirect(getSelfHostSettingsHref('general')) /** - * Sections read URL query params via nuqs, so the renderer must sit under a - * Suspense boundary. The null fallback preserves the existing chunk-loading UI. + * No Suspense boundary: a fallback mounted with the section puts React's 300ms reveal floor + * under every switch (see the workspace section layout). */ - const content = ( - - - - ) + const content = if (parsed === 'general') { const queryClient = getQueryClient() diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/layout.tsx index a2965fd9ab0..de9cd125c5d 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/layout.tsx @@ -25,13 +25,20 @@ const TOP_LEVEL_REDIRECTS: Readonly stri * The heading is resolved here rather than pushed up from the section body, so it renders with * the shell instead of waiting on the body's lazily-loaded chunk. * - * Whether a segment names a section at all is decided here too, above the sibling - * `loading.tsx`. Inside that Suspense boundary a `notFound()` or `redirect()` can no longer set - * the response status — React replays the boundary on the client and the shell still flushes - * 200 — so a bad or legacy URL loaded directly would answer 200 and redirect in a second round - * trip. Deciding it above the boundary keeps the 404 and the 307. Whether the *viewer* may open - * a section is a different question and stays in the page, where it belongs; those checks need - * the database and are reached almost entirely by client navigation. + * Whether a segment names a section at all is decided here too, before the shell renders, so a + * bad or legacy URL loaded directly answers 404 or 307 without touching the database. Whether + * the *viewer* may open a section is a different question and stays in the page, where it + * belongs; those checks need the database and are reached almost entirely by client navigation. + * + * There is deliberately no sibling `loading.tsx`, and the page renders its body under no Suspense + * boundary of its own. React holds content that resolves inside a freshly committed fallback for + * at least 300ms, so any boundary mounted with the section — a route fallback, or a page-level + * `` around the code-split body — put that floor under every section switch. Without + * one, a switch is a transition that keeps the outgoing section on screen until the incoming one + * and its chunk are ready (immediately, when the sidebar's intent prefetch has landed), and the + * sidebar moves its selection on click so the click still reads as acknowledged. A boundary above + * this layout (a `settings/loading.tsx`, or a `` in the settings layout) would also + * swallow the 404 and 307 above. * * Authentication is already enforced by the ancestor workspace layout, so this runs only for a * signed-in viewer. diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/loading.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/loading.tsx deleted file mode 100644 index 925929408ab..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/loading.tsx +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Route-transition fallback for the workspace settings sections. - * - * Its job is to exist. Without a loading boundary the App Router holds the outgoing section on - * screen until the incoming page's access gate resolves, so a click reads as a dead click; with - * one, the navigation commits immediately and the heading changes with it. It is also what - * makes the sidebar's `router.prefetch` worth anything — with no loading boundary in the - * subtree the scheduler skips the segment request entirely, and an `AUTO` prefetch caches the - * shell only as far as the nearest boundary. - * - * It renders no body of its own because the shell that owns the header, heading and scroll - * region renders above it and is already resolved by this point. That lands in the same place - * as the two neighbouring settings fallbacks — credit-usage renders its title and description - * over an empty body, `ResourceChromeFallback` renders a real header and column headers over - * zero rows — without restating chrome this route already has. - */ -export default function WorkspaceSettingsSectionLoading() { - return null -} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx index d845f3b6b41..0ee633ade62 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx @@ -111,9 +111,7 @@ export default async function WorkspaceSettingsSectionPage({ return ( - - - + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx index c79380f8966..d6ac817c5c1 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx @@ -3,6 +3,7 @@ import { useState } from 'react' import { Plus, Wrench } from '@sim/emcn/icons' import { getErrorMessage } from '@sim/utils/errors' +import dynamic from 'next/dynamic' import { useParams } from 'next/navigation' import { useQueryState } from 'nuqs' import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation' @@ -11,7 +12,6 @@ import { customToolIdParam, customToolIdUrlKeys, } from '@/app/workspace/[workspaceId]/settings/[section]/search-params' -import { CustomToolDetail } from '@/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' @@ -22,6 +22,20 @@ import { import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { useCustomTools } from '@/hooks/queries/custom-tools' +/** + * The editor reaches the code-generation and tag-dropdown tooling, which pulls in the block + * registry. Loaded on open (or on Add-chip intent), so the list does not download it. `loading` + * gives it its own boundary: opening is a plain state update, and the section page has no + * boundary of its own. + */ +const loadCustomToolDetail = () => + import( + '@/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail' + ) +const CustomToolDetail = dynamic(() => loadCustomToolDetail().then((m) => m.CustomToolDetail), { + loading: () => null, +}) + export function CustomTools() { const params = useParams() const workspaceId = params.workspaceId as string @@ -68,6 +82,7 @@ export function CustomTools() { icon: Plus, variant: 'primary', onSelect: () => setIsCreating(true), + onPrefetch: () => void loadCustomToolDetail(), disabled: isLoading, }, ] diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx index bc7e01d96b6..5d1f48293c0 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx @@ -51,10 +51,13 @@ import { useUserProfile, } from '@/hooks/queries/user-profile' -const AuthorizedApps = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/authorized-apps/authorized-apps').then( - (module) => module.AuthorizedApps - ) +/** `loading` gives the view its own boundary; the section page has none to suspend into. */ +const AuthorizedApps = dynamic( + () => + import( + '@/app/workspace/[workspaceId]/settings/components/authorized-apps/authorized-apps' + ).then((module) => module.AuthorizedApps), + { loading: () => null } ) const logger = createLogger('General') diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-query-warmers.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-query-warmers.ts index cd3f68d822c..90d22fc97cc 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-query-warmers.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-query-warmers.ts @@ -1,18 +1,47 @@ import type { QueryClient } from '@tanstack/react-query' import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation' +import { forkLineageQueryOptions } from '@/ee/workspace-forking/hooks/workspace-fork' +import { apiKeysQueryOptions } from '@/hooks/queries/api-key-list' +import { customToolsQueryOptions } from '@/hooks/queries/custom-tools' +import { + personalEnvironmentQueryOptions, + workspaceEnvironmentQueryOptions, +} from '@/hooks/queries/environment' +import { pendingInvitationsQueryOptions } from '@/hooks/queries/invitations' +import { mcpServersQueryOptions } from '@/hooks/queries/mcp' import { organizationBillingSummaryOptions } from '@/hooks/queries/organization-billing-summary' import { subscriptionDataQueryOptions } from '@/hooks/queries/subscription-data' import { workspaceCredentialListQueryOptions } from '@/hooks/queries/utils/fetch-workspace-credentials' import { prefetchQueryOnIntent } from '@/hooks/queries/utils/prefetch-query-on-intent' +import { workflowMcpServersQueryOptions } from '@/hooks/queries/workflow-mcp-servers' +/** + * Each section's first-content queries, started on navigation intent so they load alongside the + * route payload instead of after the section mounts. + */ const SETTINGS_QUERY_WARMERS: Partial< Record void> > = { - secrets: (queryClient, { workspaceId }) => + secrets: (queryClient, { workspaceId }) => { + prefetchQueryOnIntent(queryClient, personalEnvironmentQueryOptions()) + prefetchQueryOnIntent(queryClient, workspaceEnvironmentQueryOptions(workspaceId)) prefetchQueryOnIntent( queryClient, workspaceCredentialListQueryOptions(workspaceId, 'env_workspace') - ), + ) + }, + forks: (queryClient, { workspaceId }) => + prefetchQueryOnIntent(queryClient, forkLineageQueryOptions(workspaceId)), + teammates: (queryClient, { workspaceId }) => + prefetchQueryOnIntent(queryClient, pendingInvitationsQueryOptions(workspaceId)), + apikeys: (queryClient, { workspaceId }) => + prefetchQueryOnIntent(queryClient, apiKeysQueryOptions(workspaceId, 'combined')), + 'custom-tools': (queryClient, { workspaceId }) => + prefetchQueryOnIntent(queryClient, customToolsQueryOptions(workspaceId)), + mcp: (queryClient, { workspaceId }) => + prefetchQueryOnIntent(queryClient, mcpServersQueryOptions(workspaceId)), + 'workflow-mcp-servers': (queryClient, { workspaceId }) => + prefetchQueryOnIntent(queryClient, workflowMcpServersQueryOptions(workspaceId)), billing: (queryClient, { billingOrganizationId }) => { if (billingOrganizationId) { prefetchQueryOnIntent(queryClient, organizationBillingSummaryOptions(billingOrganizationId)) 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 3593e96d90b..3bf9559e163 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 @@ -24,6 +24,7 @@ import { ORGANIZATION_PLANE_UNIFIED_SECTIONS, } from '@/components/settings/navigation' import { SettingsIntentLink } from '@/components/settings/settings-intent-link' +import { usePendingSettingsSelection } from '@/components/settings/use-pending-settings-selection' import { useSession } from '@/lib/auth/auth-client' import { getSubscriptionAccessState } from '@/lib/billing/client' import { canViewWorkspaceBillingSettings } from '@/lib/billing/workspace-permissions' @@ -294,10 +295,11 @@ export function SettingsSidebar({ const segments = pathname?.split('/') ?? [] const settingsIndex = segments.indexOf('settings') - const activeSection: SettingsSection = + const routeSection: SettingsSection = settingsIndex !== -1 && segments[settingsIndex + 1] ? (segments[settingsIndex + 1] as SettingsSection) : 'general' + const { activeSection, navigateToSection } = usePendingSettingsSelection(routeSection) const { popSettingsReturnUrl, getSettingsHref } = useSettingsNavigation() @@ -488,9 +490,12 @@ export function SettingsSidebar({ event.preventDefault() return } - if (!useSettingsDirtyStore.getState().isDirty) return event.preventDefault() - requestLeave(() => router.replace(href, { scroll: false })) + if (!useSettingsDirtyStore.getState().isDirty) { + navigateToSection(section, href) + return + } + requestLeave(() => navigateToSection(section, href)) }} > {content} diff --git a/apps/sim/components/settings/settings-sidebar.tsx b/apps/sim/components/settings/settings-sidebar.tsx index c8e6a0810ba..e26d05a36a7 100644 --- a/apps/sim/components/settings/settings-sidebar.tsx +++ b/apps/sim/components/settings/settings-sidebar.tsx @@ -23,6 +23,7 @@ import { type StandaloneSettingsPlane, } from '@/components/settings/navigation' import { SettingsIntentLink } from '@/components/settings/settings-intent-link' +import { usePendingSettingsSelection } from '@/components/settings/use-pending-settings-selection' import { APP_ENTRY_PATH } from '@/lib/navigation/paths' import { SidebarSection } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-section' import { SidebarTooltip } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-tooltip' @@ -91,6 +92,8 @@ export function SettingsSidebar
({ const scrollContainerRef = useRef(null) const scrollContentRef = useRef(null) const router = useRouter() + const { activeSection: selectedSection, navigateToSection } = + usePendingSettingsSelection(activeSection) const requestLeave = useSettingsDirtyStore((state) => state.requestLeave) const confirmLeave = useSettingsDirtyStore((state) => state.confirmLeave) @@ -167,7 +170,7 @@ export function SettingsSidebar
({
{group.items.map((item) => { const Icon = item.icon - const active = activeSection === item.id + const active = selectedSection === item.id const href = hrefForSection(item.id) return ( ({ event.preventDefault() return } - const { isDirty, navigationBlocked } = useSettingsDirtyStore.getState() - if (!isDirty && !navigationBlocked) return event.preventDefault() - requestLeave(() => router.replace(href, { scroll: false })) + const { isDirty, navigationBlocked } = useSettingsDirtyStore.getState() + if (!isDirty && !navigationBlocked) { + navigateToSection(item.id, href) + return + } + requestLeave(() => navigateToSection(item.id, href)) }} > diff --git a/apps/sim/components/settings/use-pending-settings-selection.ts b/apps/sim/components/settings/use-pending-settings-selection.ts new file mode 100644 index 00000000000..2237ff02ef2 --- /dev/null +++ b/apps/sim/components/settings/use-pending-settings-selection.ts @@ -0,0 +1,35 @@ +'use client' + +import { useCallback, useOptimistic, useTransition } from 'react' +import { useRouter } from 'next/navigation' + +/** + * Settings sidebar navigation whose clicked row shows as selected until the route settles. + * + * Section routes have no loading boundary (see the workspace section layout), so a navigation + * keeps the outgoing section on screen until the incoming one is ready. Moving the selection on + * click keeps the click acknowledged while the route resolves. + * + * The selection is optimistic state set inside the navigation's own transition, so React drops it + * the moment that transition settles — on commit, and equally when the server redirects back to + * the current section or the navigation fails. It never outlives the navigation that set it. + */ +export function usePendingSettingsSelection( + routeSection: TSection +): { activeSection: TSection; navigateToSection: (section: TSection, href: string) => void } { + const router = useRouter() + const [activeSection, setOptimisticSection] = useOptimistic(routeSection) + const [, startTransition] = useTransition() + + const navigateToSection = useCallback( + (section: TSection, href: string) => { + startTransition(() => { + setOptimisticSection(section) + router.replace(href, { scroll: false }) + }) + }, + [router, setOptimisticSection] + ) + + return { activeSection, navigateToSection } +} diff --git a/apps/sim/ee/workspace-forking/components/fork-sync-detail-view/fork-sync-detail-view.tsx b/apps/sim/ee/workspace-forking/components/fork-sync-detail-view/fork-sync-detail-view.tsx new file mode 100644 index 00000000000..89f4acc6b7b --- /dev/null +++ b/apps/sim/ee/workspace-forking/components/fork-sync-detail-view/fork-sync-detail-view.tsx @@ -0,0 +1,202 @@ +'use client' + +import { useState } from 'react' +import { ChipConfirmModal } from '@sim/emcn' +import { ArrowLeft } from '@sim/emcn/icons' +import { useQueryState } from 'nuqs' +import { saveDiscardActions } from '@/components/settings/save-discard-actions' +import type { SettingsAction } from '@/components/settings/settings-header' +import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail' +import { + forkSyncDirectionParam, + forkSyncDirectionUrlKeys, +} from '@/app/workspace/[workspaceId]/settings/[section]/search-params' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard' +import { ForkSyncView } from '@/ee/workspace-forking/components/fork-sync/fork-sync-view' +import { + ARCHIVED_PREVIEW_LIMIT, + useForkSync, +} from '@/ee/workspace-forking/components/fork-sync/use-fork-sync' +import { buildWebhookTriggerUrl } from '@/triggers/webhook-url' + +interface ForkSyncDetailViewProps { + title: string + workspaceId: string + /** This workspace's name — a pull overwrites it, and the copy has to say which side that is. */ + workspaceName?: string + /** The other side of the edge being synced (this workspace's parent). */ + otherWorkspaceId: string + otherWorkspaceName: string + onBack: () => void + /** Header chips rendered left of Sync (e.g. Open workspace) — the caller owns those. */ + actions: SettingsAction[] +} + +/** + * The parent edge's sync page (reached from the parent row): direction, deployed-workflow + * changes, per-kind mappings (each an expandable row whose status badge is the summary), + * copy resources, and blocking references, all as page sections. + * The header's Sync chip is gated until zero blockers + required mappings + reconfigure are + * complete, and always confirms the overwrite first — that confirm is the flow's one modal. + * While the mapping has unsaved edits the header swaps to Discard/Save and leaving is guarded; + * Sync itself persists the effective mapping as part of the run. + */ +export function ForkSyncDetailView({ + title, + workspaceId, + workspaceName, + otherWorkspaceId, + otherWorkspaceName, + onBack, + actions, +}: ForkSyncDetailViewProps) { + // Sync direction is shareable view state: a copied link opens the same side of the sync. + const [direction, setDirection] = useQueryState(forkSyncDirectionParam.key, { + ...forkSyncDirectionParam.parser, + ...forkSyncDirectionUrlKeys, + }) + + const controller = useForkSync({ + workspaceId, + workspaceName, + otherWorkspaceId, + otherWorkspaceName, + direction, + enabled: true, + }) + + // Guard leaving the detail view (Back) while the mapping has unsaved edits, and feed + // the shared settings dirty store so a sidebar section switch confirms too. + const guard = useSettingsUnsavedGuard({ isDirty: controller.dirty }) + + const [confirmSyncOpen, setConfirmSyncOpen] = useState(false) + + // Sync is the edge's primary action, so it's the rightmost/black chip; the caller's + // Open workspace chip sits left of it. Dirty mapping edits swap the whole cluster + // for Discard/Save until they're saved or discarded. + const panelActions: SettingsAction[] = controller.dirty + ? saveDiscardActions({ + dirty: controller.dirty, + saving: controller.saving, + onSave: controller.save, + onDiscard: controller.discard, + }) + : [ + ...actions, + { + text: controller.submitting ? 'Working...' : 'Sync', + variant: 'primary' as const, + onSelect: () => setConfirmSyncOpen(true), + disabled: controller.syncDisabled, + tooltip: controller.syncDisabled + ? controller.syncDisabledReason + : `Push to or pull from ${otherWorkspaceName}`, + }, + ] + + const targetWorkspaceName = controller.targetWorkspaceName + + return ( + <> + + guard.guardBack(() => { + void setDirection(null) + onBack() + }), + }} + title={title} + actions={panelActions} + > + void setDirection(next)} + /> + + + + + { + setConfirmSyncOpen(false) + void controller.sync() + }, + pending: controller.submitting, + pendingLabel: 'Syncing...', + }} + > + {controller.archivedWorkflowNames.length > 0 ? ( +
+

+ Will be archived in {targetWorkspaceName} (deleted in the source): +

+ {controller.archivedWorkflowNames + .slice(0, ARCHIVED_PREVIEW_LIMIT) + .map((name, index) => ( +
+ {name} +
+ ))} + {controller.archivedWorkflowNames.length > ARCHIVED_PREVIEW_LIMIT ? ( +
+ and {controller.archivedWorkflowNames.length - ARCHIVED_PREVIEW_LIMIT} more +
+ ) : null} +
+ ) : null} + {/* A dead trigger URL is only discoverable after the fact, when the external caller goes + quiet - so it belongs in the confirm, next to the other irreversible consequences. */} + {controller.triggerUrlChanges.length > 0 ? ( +
+

+ {controller.triggerUrlChanges.length === 1 ? 'A webhook URL' : 'Webhook URLs'} in{' '} + {targetWorkspaceName} will stop being served — anything calling{' '} + {controller.triggerUrlChanges.length === 1 ? 'it' : 'them'} breaks until you + re-register: +

+ {controller.triggerUrlChanges.slice(0, ARCHIVED_PREVIEW_LIMIT).map((change) => ( + // Naming the URL, not just its workflow: several URLs in one workflow would render + // as identical lines, and this confirm is the last point before they stop serving. +
+ {change.workflowName} + + {buildWebhookTriggerUrl(change.path)} + +
+ ))} + {controller.triggerUrlChanges.length > ARCHIVED_PREVIEW_LIMIT ? ( +
+ and {controller.triggerUrlChanges.length - ARCHIVED_PREVIEW_LIMIT} more +
+ ) : null} +
+ ) : null} +
+ + ) +} diff --git a/apps/sim/ee/workspace-forking/components/forks.tsx b/apps/sim/ee/workspace-forking/components/forks.tsx index b25486dde76..2487b458c49 100644 --- a/apps/sim/ee/workspace-forking/components/forks.tsx +++ b/apps/sim/ee/workspace-forking/components/forks.tsx @@ -4,20 +4,17 @@ import { useState } from 'react' import { ChipConfirmModal, toast } from '@sim/emcn' import { ArrowLeft, Plus, TriangleAlert } from '@sim/emcn/icons' import { getErrorMessage } from '@sim/utils/errors' +import dynamic from 'next/dynamic' import { useParams, useRouter } from 'next/navigation' import { useQueryState } from 'nuqs' -import { saveDiscardActions } from '@/components/settings/save-discard-actions' import type { SettingsAction } from '@/components/settings/settings-header' import type { ForkLineageChildApi, ForkLineageNodeApi } from '@/lib/api/contracts/workspace-fork' import { useDeploymentShape } from '@/lib/core/config/deployment-shape' -import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail' import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { forkIdParam, forkIdUrlKeys, - forkSyncDirectionParam, - forkSyncDirectionUrlKeys, forkViewParam, forkViewUrlKeys, } from '@/app/workspace/[workspaceId]/settings/[section]/search-params' @@ -29,13 +26,7 @@ import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/compo import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' -import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard' import { ForkActivityPanel } from '@/ee/workspace-forking/components/fork-activity-panel/fork-activity-panel' -import { ForkSyncView } from '@/ee/workspace-forking/components/fork-sync/fork-sync-view' -import { - ARCHIVED_PREVIEW_LIMIT, - useForkSync, -} from '@/ee/workspace-forking/components/fork-sync/use-fork-sync' import { ForkSyncDefaultToggle } from '@/ee/workspace-forking/components/fork-sync-default-toggle/fork-sync-default-toggle' import { ForkSyncedWorkflows } from '@/ee/workspace-forking/components/fork-synced-workflows/fork-synced-workflows' import { ForkWorkspaceModal } from '@/ee/workspace-forking/components/fork-workspace-modal/fork-workspace-modal' @@ -47,7 +38,19 @@ import { } from '@/ee/workspace-forking/hooks/workspace-fork' import { useWorkspaceCreationPolicy, useWorkspacesQuery } from '@/hooks/queries/workspace' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' -import { buildWebhookTriggerUrl } from '@/triggers/webhook-url' + +/** + * The parent edge's sync page carries the mapping editors, which reach the block and trigger + * registries. Loaded when opened, so the fork list does not download them; `loading` gives it + * its own boundary, since the section page has none. + */ +const ForkSyncDetailView = dynamic( + () => + import('@/ee/workspace-forking/components/fork-sync-detail-view/fork-sync-detail-view').then( + (m) => m.ForkSyncDetailView + ), + { loading: () => null } +) /** Explains a disabled lineage action whose target workspace the viewer cannot open. */ const NO_ACCESS_TOOLTIP = "You don't have access to this workspace" @@ -83,187 +86,6 @@ function ForkListRow({ name, actions }: ForkListRowProps) { ) } -interface ForkSyncDetailViewProps { - title: string - workspaceId: string - /** This workspace's name — a pull overwrites it, and the copy has to say which side that is. */ - workspaceName?: string - /** The other side of the edge being synced (this workspace's parent). */ - otherWorkspaceId: string - otherWorkspaceName: string - onBack: () => void - /** Header chips rendered left of Sync (e.g. Open workspace) — the caller owns those. */ - actions: SettingsAction[] -} - -/** - * The parent edge's sync page (reached from the parent row): direction, deployed-workflow - * changes, per-kind mappings (each an expandable row whose status badge is the summary), - * copy resources, and blocking references, all as page sections. - * The header's Sync chip is gated until zero blockers + required mappings + reconfigure are - * complete, and always confirms the overwrite first — that confirm is the flow's one modal. - * While the mapping has unsaved edits the header swaps to Discard/Save and leaving is guarded; - * Sync itself persists the effective mapping as part of the run. - */ -function ForkSyncDetailView({ - title, - workspaceId, - workspaceName, - otherWorkspaceId, - otherWorkspaceName, - onBack, - actions, -}: ForkSyncDetailViewProps) { - // Sync direction is shareable view state: a copied link opens the same side of the sync. - const [direction, setDirection] = useQueryState(forkSyncDirectionParam.key, { - ...forkSyncDirectionParam.parser, - ...forkSyncDirectionUrlKeys, - }) - - const controller = useForkSync({ - workspaceId, - workspaceName, - otherWorkspaceId, - otherWorkspaceName, - direction, - enabled: true, - }) - - // Guard leaving the detail view (Back) while the mapping has unsaved edits, and feed - // the shared settings dirty store so a sidebar section switch confirms too. - const guard = useSettingsUnsavedGuard({ isDirty: controller.dirty }) - - const [confirmSyncOpen, setConfirmSyncOpen] = useState(false) - - // Sync is the edge's primary action, so it's the rightmost/black chip; the caller's - // Open workspace chip sits left of it. Dirty mapping edits swap the whole cluster - // for Discard/Save until they're saved or discarded. - const panelActions: SettingsAction[] = controller.dirty - ? saveDiscardActions({ - dirty: controller.dirty, - saving: controller.saving, - onSave: controller.save, - onDiscard: controller.discard, - }) - : [ - ...actions, - { - text: controller.submitting ? 'Working...' : 'Sync', - variant: 'primary' as const, - onSelect: () => setConfirmSyncOpen(true), - disabled: controller.syncDisabled, - tooltip: controller.syncDisabled - ? controller.syncDisabledReason - : `Push to or pull from ${otherWorkspaceName}`, - }, - ] - - const targetWorkspaceName = controller.targetWorkspaceName - - return ( - <> - - guard.guardBack(() => { - void setDirection(null) - onBack() - }), - }} - title={title} - actions={panelActions} - > - void setDirection(next)} - /> - - - - - { - setConfirmSyncOpen(false) - void controller.sync() - }, - pending: controller.submitting, - pendingLabel: 'Syncing...', - }} - > - {controller.archivedWorkflowNames.length > 0 ? ( -
-

- Will be archived in {targetWorkspaceName} (deleted in the source): -

- {controller.archivedWorkflowNames - .slice(0, ARCHIVED_PREVIEW_LIMIT) - .map((name, index) => ( -
- {name} -
- ))} - {controller.archivedWorkflowNames.length > ARCHIVED_PREVIEW_LIMIT ? ( -
- and {controller.archivedWorkflowNames.length - ARCHIVED_PREVIEW_LIMIT} more -
- ) : null} -
- ) : null} - {/* A dead trigger URL is only discoverable after the fact, when the external caller goes - quiet - so it belongs in the confirm, next to the other irreversible consequences. */} - {controller.triggerUrlChanges.length > 0 ? ( -
-

- {controller.triggerUrlChanges.length === 1 ? 'A webhook URL' : 'Webhook URLs'} in{' '} - {targetWorkspaceName} will stop being served — anything calling{' '} - {controller.triggerUrlChanges.length === 1 ? 'it' : 'them'} breaks until you - re-register: -

- {controller.triggerUrlChanges.slice(0, ARCHIVED_PREVIEW_LIMIT).map((change) => ( - // Naming the URL, not just its workflow: several URLs in one workflow would render - // as identical lines, and this confirm is the last point before they stop serving. -
- {change.workflowName} - - {buildWebhookTriggerUrl(change.path)} - -
- ))} - {controller.triggerUrlChanges.length > ARCHIVED_PREVIEW_LIMIT ? ( -
- and {controller.triggerUrlChanges.length - ARCHIVED_PREVIEW_LIMIT} more -
- ) : null} -
- ) : null} -
- - ) -} - interface ForkActivityDetailViewProps { workspaceId: string /** Lineage partner names by id, for phrasing rows recorded on the other side of an edge. */ diff --git a/apps/sim/ee/workspace-forking/hooks/workspace-fork.ts b/apps/sim/ee/workspace-forking/hooks/workspace-fork.ts index 21e5acda94b..d735739a760 100644 --- a/apps/sim/ee/workspace-forking/hooks/workspace-fork.ts +++ b/apps/sim/ee/workspace-forking/hooks/workspace-fork.ts @@ -1,4 +1,10 @@ -import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + keepPreviousData, + queryOptions, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' import { type ForkWorkspaceBody, @@ -60,13 +66,20 @@ export function useForkResources(workspaceId?: string, enabled = true) { }) } -export function useForkLineage(workspaceId?: string, enabled = true) { - return useQuery({ +export function forkLineageQueryOptions(workspaceId: string) { + return queryOptions({ queryKey: forkKeys.lineage(workspaceId), queryFn: ({ signal }) => - requestJson(getForkLineageContract, { params: { id: workspaceId as string }, signal }), - enabled: Boolean(workspaceId) && enabled, + requestJson(getForkLineageContract, { params: { id: workspaceId }, signal }), staleTime: WORKSPACE_FORK_LINEAGE_STALE_TIME, + retryOnMount: true, + }) +} + +export function useForkLineage(workspaceId?: string, enabled = true) { + return useQuery({ + ...forkLineageQueryOptions(workspaceId ?? ''), + enabled: Boolean(workspaceId) && enabled, placeholderData: keepPreviousData, }) } diff --git a/apps/sim/hooks/queries/custom-tools.ts b/apps/sim/hooks/queries/custom-tools.ts index 0edd2e4737d..066c7136fb6 100644 --- a/apps/sim/hooks/queries/custom-tools.ts +++ b/apps/sim/hooks/queries/custom-tools.ts @@ -1,6 +1,12 @@ import { createLogger } from '@sim/logger' import { isRecordLike, toRecord } from '@sim/utils/object' -import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + keepPreviousData, + queryOptions, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' import { deleteCustomToolContract, @@ -164,15 +170,23 @@ async function fetchCustomTools( return normalizedTools } +/** A workspace's custom tools list, shared by {@link useCustomTools} and the settings intent warmer. */ +export function customToolsQueryOptions(workspaceId: string) { + return queryOptions({ + queryKey: customToolsKeys.list(workspaceId), + queryFn: ({ signal }) => fetchCustomTools(workspaceId, signal), + staleTime: CUSTOM_TOOL_LIST_STALE_TIME, + retryOnMount: true, + }) +} + /** * Hook to fetch custom tools */ export function useCustomTools(workspaceId: string) { - return useQuery({ - queryKey: customToolsKeys.list(workspaceId), - queryFn: ({ signal }) => fetchCustomTools(workspaceId, signal), + return useQuery({ + ...customToolsQueryOptions(workspaceId), enabled: !!workspaceId, - staleTime: CUSTOM_TOOL_LIST_STALE_TIME, placeholderData: keepPreviousData, }) } diff --git a/apps/sim/hooks/queries/environment.ts b/apps/sim/hooks/queries/environment.ts index 3152276b492..9c5a48e346f 100644 --- a/apps/sim/hooks/queries/environment.ts +++ b/apps/sim/hooks/queries/environment.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' import { removeWorkspaceEnvironmentContract, @@ -26,15 +26,12 @@ export const environmentKeys = { workspace: (workspaceId: string) => [...environmentKeys.workspaces(), workspaceId] as const, } -/** - * Hook to fetch personal environment variables - */ -export function usePersonalEnvironment(options?: { enabled?: boolean }) { - return useQuery({ +export function personalEnvironmentQueryOptions() { + return queryOptions({ queryKey: environmentKeys.personal(), queryFn: ({ signal }) => fetchPersonalEnvironment(signal), - enabled: options?.enabled ?? true, staleTime: PERSONAL_ENVIRONMENT_STALE_TIME, + retryOnMount: true, // Pinned off (not inheriting the desktop QueryClient default): the secrets // manager seeds an editable form from this data, so a background focus // refetch during a concurrent edit would drop the user's unsaved rows. @@ -42,6 +39,28 @@ export function usePersonalEnvironment(options?: { enabled?: boolean }) { }) } +export function workspaceEnvironmentQueryOptions(workspaceId: string) { + return queryOptions({ + queryKey: environmentKeys.workspace(workspaceId), + queryFn: ({ signal }) => fetchWorkspaceEnvironment(workspaceId, signal), + staleTime: WORKSPACE_ENVIRONMENT_STALE_TIME, + retryOnMount: true, + // See personalEnvironmentQueryOptions: seeds an editable form, so a focus refetch + // during a concurrent workspace-env edit must not clobber unsaved rows. + refetchOnWindowFocus: false, + }) +} + +/** + * Hook to fetch personal environment variables + */ +export function usePersonalEnvironment(options?: { enabled?: boolean }) { + return useQuery({ + ...personalEnvironmentQueryOptions(), + enabled: options?.enabled ?? true, + }) +} + /** * Hook to fetch workspace environment variables */ @@ -50,13 +69,8 @@ export function useWorkspaceEnvironment( options?: { enabled?: boolean; select?: (data: WorkspaceEnvironmentData) => TData } ) { return useQuery({ - queryKey: environmentKeys.workspace(workspaceId), - queryFn: ({ signal }) => fetchWorkspaceEnvironment(workspaceId, signal), + ...workspaceEnvironmentQueryOptions(workspaceId), enabled: Boolean(workspaceId) && (options?.enabled ?? true), - staleTime: WORKSPACE_ENVIRONMENT_STALE_TIME, - // See usePersonalEnvironment: seeds an editable form, so a focus refetch - // during a concurrent workspace-env edit must not clobber unsaved rows. - refetchOnWindowFocus: false, select: options?.select, }) } diff --git a/apps/sim/hooks/queries/invitations.ts b/apps/sim/hooks/queries/invitations.ts index 49cc718dfc6..d1643cfd0e3 100644 --- a/apps/sim/hooks/queries/invitations.ts +++ b/apps/sim/hooks/queries/invitations.ts @@ -1,6 +1,12 @@ import { toast } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' -import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + keepPreviousData, + queryOptions, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' import { extractValidationIssues } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' import type { ContractBodyInput } from '@/lib/api/contracts' @@ -112,16 +118,23 @@ async function fetchPendingInvitations( ) } +export function pendingInvitationsQueryOptions(workspaceId: string) { + return queryOptions({ + queryKey: invitationKeys.list(workspaceId), + queryFn: ({ signal }) => fetchPendingInvitations(workspaceId, signal), + staleTime: WORKSPACE_INVITATION_LIST_STALE_TIME, + retryOnMount: true, + }) +} + /** * Fetches pending invitations for a workspace. * @param workspaceId - The workspace ID to fetch invitations for */ export function usePendingInvitations(workspaceId: string | undefined) { return useQuery({ - queryKey: invitationKeys.list(workspaceId ?? ''), - queryFn: ({ signal }) => fetchPendingInvitations(workspaceId as string, signal), + ...pendingInvitationsQueryOptions(workspaceId ?? ''), enabled: Boolean(workspaceId), - staleTime: WORKSPACE_INVITATION_LIST_STALE_TIME, placeholderData: keepPreviousData, }) } diff --git a/scripts/check-tool-registry-boundary.baseline.json b/scripts/check-tool-registry-boundary.baseline.json index 50d9498a2f2..64630d7f4b4 100644 --- a/scripts/check-tool-registry-boundary.baseline.json +++ b/scripts/check-tool-registry-boundary.baseline.json @@ -318,10 +318,6 @@ "modules": 8, "gateways": {} }, - "app/workspace/[workspaceId]/settings/[section]/loading.tsx": { - "modules": 1, - "gateways": {} - }, "app/workspace/[workspaceId]/settings/[section]/page.tsx": { "modules": 2410, "gateways": {