improvement(settings): SSR prefetch, code splitting, dedicated skeletons

This commit is contained in:
waleed
2026-03-09 20:01:29 -07:00
parent 8fc75a6e9d
commit 4c7e63cf7a
31 changed files with 667 additions and 289 deletions
@@ -0,0 +1,38 @@
import { defaultShouldDehydrateQuery, isServer, QueryClient } from '@tanstack/react-query'
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000,
gcTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
retry: 1,
retryOnMount: false,
},
mutations: {
retry: 1,
},
dehydrate: {
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) || query.state.status === 'pending',
},
},
})
}
let browserQueryClient: QueryClient | undefined
/**
* Returns a QueryClient instance. On the server, creates a new instance per request.
* On the client, reuses a singleton instance.
*/
export function getQueryClient() {
if (isServer) {
return makeQueryClient()
}
if (!browserQueryClient) {
browserQueryClient = makeQueryClient()
}
return browserQueryClient
}
@@ -1,40 +1,10 @@
'use client'
import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { QueryClientProvider } from '@tanstack/react-query'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
/**
* Singleton QueryClient instance for client-side use.
* Can be imported directly for cache operations outside React components.
*/
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000,
gcTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
retry: 1,
retryOnMount: false,
},
mutations: {
retry: 1,
},
},
})
}
let browserQueryClient: QueryClient | undefined
export function getQueryClient() {
if (typeof window === 'undefined') {
return makeQueryClient()
}
if (!browserQueryClient) {
browserQueryClient = makeQueryClient()
}
return browserQueryClient
}
export { getQueryClient } from '@/app/_shell/providers/get-query-client'
export function QueryProvider({ children }: { children: ReactNode }) {
const queryClient = getQueryClient()
@@ -0,0 +1,23 @@
'use client'
import { ErrorState } from '@/app/workspace/[workspaceId]/components'
export default function SettingsSectionError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className='flex h-full items-center justify-center'>
<ErrorState
error={error}
reset={reset}
title='Something went wrong'
description='An unexpected error occurred. Please try again or refresh the page.'
loggerName='SettingsSectionError'
/>
</div>
)
}
@@ -1,5 +1,8 @@
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import type { Metadata } from 'next'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation'
import { prefetchGeneralSettings, prefetchUserProfile } from './prefetch'
import { SettingsPage } from './settings'
const SECTION_TITLES: Record<string, string> = {
@@ -36,5 +39,14 @@ export default async function SettingsSectionPage({
params: Promise<{ workspaceId: string; section: string }>
}) {
const { section } = await params
return <SettingsPage section={section as SettingsSection} />
const queryClient = getQueryClient()
void prefetchGeneralSettings(queryClient)
void prefetchUserProfile(queryClient)
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<SettingsPage section={section as SettingsSection} />
</HydrationBoundary>
)
}
@@ -0,0 +1,75 @@
import type { QueryClient } from '@tanstack/react-query'
import { headers } from 'next/headers'
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
import { generalSettingsKeys } from '@/hooks/queries/general-settings'
import { userProfileKeys } from '@/hooks/queries/user-profile'
/**
* Forwards incoming request cookies so server-side API fetches authenticate correctly.
*/
async function getForwardedHeaders(): Promise<Record<string, string>> {
const h = await headers()
const cookie = h.get('cookie')
return cookie ? { cookie } : {}
}
/**
* Prefetch general settings server-side via internal API fetch.
* Uses the same query keys as the client `useGeneralSettings` hook
* so data is shared via HydrationBoundary.
*/
export function prefetchGeneralSettings(queryClient: QueryClient) {
return queryClient.prefetchQuery({
queryKey: generalSettingsKeys.settings(),
queryFn: async () => {
const fwdHeaders = await getForwardedHeaders()
const baseUrl = getInternalApiBaseUrl()
const response = await fetch(`${baseUrl}/api/users/me/settings`, {
headers: fwdHeaders,
})
if (!response.ok) throw new Error(`Settings prefetch failed: ${response.status}`)
const { data } = await response.json()
return {
autoConnect: data.autoConnect ?? true,
showTrainingControls: data.showTrainingControls ?? false,
superUserModeEnabled: data.superUserModeEnabled ?? true,
theme: data.theme || 'system',
telemetryEnabled: data.telemetryEnabled ?? true,
billingUsageNotificationsEnabled: data.billingUsageNotificationsEnabled ?? true,
errorNotificationsEnabled: data.errorNotificationsEnabled ?? true,
snapToGridSize: data.snapToGridSize ?? 0,
showActionBar: data.showActionBar ?? true,
}
},
staleTime: 60 * 60 * 1000,
})
}
/**
* Prefetch user profile server-side via internal API fetch.
* Uses the same query keys as the client `useUserProfile` hook
* so data is shared via HydrationBoundary.
*/
export function prefetchUserProfile(queryClient: QueryClient) {
return queryClient.prefetchQuery({
queryKey: userProfileKeys.profile(),
queryFn: async () => {
const fwdHeaders = await getForwardedHeaders()
const baseUrl = getInternalApiBaseUrl()
const response = await fetch(`${baseUrl}/api/users/me/profile`, {
headers: fwdHeaders,
})
if (!response.ok) throw new Error(`Profile prefetch failed: ${response.status}`)
const { user } = await response.json()
return {
id: user.id,
name: user.name || '',
email: user.email || '',
image: user.image || null,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
}
},
staleTime: 5 * 60 * 1000,
})
}
@@ -1,29 +1,134 @@
'use client'
import dynamic from 'next/dynamic'
import { useSearchParams } from 'next/navigation'
import {
ApiKeys,
BYOK,
Copilot,
CredentialSets,
Credentials,
CustomTools,
Debug,
General,
MCP,
Skills,
Subscription,
TeamManagement,
TemplateProfile,
WorkflowMcpServers,
} from '@/app/workspace/[workspaceId]/settings/components'
import { Skeleton } from '@/components/emcn'
import { ApiKeysSkeleton } from '@/app/workspace/[workspaceId]/settings/components/api-keys/api-key-skeleton'
import { BYOKSkeleton } from '@/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton'
import { CopilotSkeleton } from '@/app/workspace/[workspaceId]/settings/components/copilot/copilot-skeleton'
import { CredentialSetsSkeleton } from '@/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets-skeleton'
import { CredentialsSkeleton } from '@/app/workspace/[workspaceId]/settings/components/credentials/credential-skeleton'
import { CustomToolsSkeleton } from '@/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tool-skeleton'
import { DebugSkeleton } from '@/app/workspace/[workspaceId]/settings/components/debug/debug-skeleton'
import { GeneralSkeleton } from '@/app/workspace/[workspaceId]/settings/components/general/general-skeleton'
import { McpSkeleton } from '@/app/workspace/[workspaceId]/settings/components/mcp/mcp-skeleton'
import { SkillsSkeleton } from '@/app/workspace/[workspaceId]/settings/components/skills/skill-skeleton'
import { WorkflowMcpServersSkeleton } from '@/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers-skeleton'
import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation'
import {
allNavigationItems,
isBillingEnabled,
} from '@/app/workspace/[workspaceId]/settings/navigation'
import { AccessControl } from '@/ee/access-control/components/access-control'
import { SSO } from '@/ee/sso/components/sso-settings'
/**
* Generic skeleton fallback for sections without a dedicated skeleton.
*/
function SettingsSectionSkeleton() {
return (
<div className='flex flex-col gap-[16px]'>
<Skeleton className='h-[20px] w-[200px] rounded-[4px]' />
<Skeleton className='h-[40px] w-full rounded-[8px]' />
<Skeleton className='h-[40px] w-full rounded-[8px]' />
<Skeleton className='h-[40px] w-full rounded-[8px]' />
</div>
)
}
const General = dynamic(
() =>
import('@/app/workspace/[workspaceId]/settings/components/general/general').then(
(m) => m.General
),
{ loading: () => <GeneralSkeleton /> }
)
const Credentials = dynamic(
() =>
import('@/app/workspace/[workspaceId]/settings/components/credentials/credentials').then(
(m) => m.Credentials
),
{ loading: () => <CredentialsSkeleton /> }
)
const TemplateProfile = dynamic(
() =>
import(
'@/app/workspace/[workspaceId]/settings/components/template-profile/template-profile'
).then((m) => m.TemplateProfile),
{ loading: () => <SettingsSectionSkeleton /> }
)
const CredentialSets = dynamic(
() =>
import(
'@/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets'
).then((m) => m.CredentialSets),
{ loading: () => <CredentialSetsSkeleton /> }
)
const ApiKeys = dynamic(
() =>
import('@/app/workspace/[workspaceId]/settings/components/api-keys/api-keys').then(
(m) => m.ApiKeys
),
{ loading: () => <ApiKeysSkeleton /> }
)
const Subscription = dynamic(
() =>
import('@/app/workspace/[workspaceId]/settings/components/subscription/subscription').then(
(m) => m.Subscription
),
{ loading: () => <SettingsSectionSkeleton /> }
)
const TeamManagement = dynamic(
() =>
import(
'@/app/workspace/[workspaceId]/settings/components/team-management/team-management'
).then((m) => m.TeamManagement),
{ loading: () => <SettingsSectionSkeleton /> }
)
const BYOK = dynamic(
() => import('@/app/workspace/[workspaceId]/settings/components/byok/byok').then((m) => m.BYOK),
{ loading: () => <BYOKSkeleton /> }
)
const Copilot = dynamic(
() =>
import('@/app/workspace/[workspaceId]/settings/components/copilot/copilot').then(
(m) => m.Copilot
),
{ loading: () => <CopilotSkeleton /> }
)
const MCP = dynamic(
() => import('@/app/workspace/[workspaceId]/settings/components/mcp/mcp').then((m) => m.MCP),
{ loading: () => <McpSkeleton /> }
)
const CustomTools = dynamic(
() =>
import('@/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools').then(
(m) => m.CustomTools
),
{ loading: () => <CustomToolsSkeleton /> }
)
const Skills = dynamic(
() =>
import('@/app/workspace/[workspaceId]/settings/components/skills/skills').then((m) => m.Skills),
{ loading: () => <SkillsSkeleton /> }
)
const WorkflowMcpServers = dynamic(
() =>
import(
'@/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers'
).then((m) => m.WorkflowMcpServers),
{ loading: () => <WorkflowMcpServersSkeleton /> }
)
const Debug = dynamic(
() =>
import('@/app/workspace/[workspaceId]/settings/components/debug/debug').then((m) => m.Debug),
{ loading: () => <DebugSkeleton /> }
)
const AccessControl = dynamic(
() => import('@/ee/access-control/components/access-control').then((m) => m.AccessControl),
{ loading: () => <SettingsSectionSkeleton /> }
)
const SSO = dynamic(() => import('@/ee/sso/components/sso-settings').then((m) => m.SSO), {
loading: () => <SettingsSectionSkeleton />,
})
interface SettingsPageProps {
section: SettingsSection
@@ -0,0 +1,31 @@
import { Skeleton } from '@/components/ui'
/**
* Skeleton component for API key list items.
*/
export function ApiKeySkeleton() {
return (
<div className='flex items-center justify-between gap-[12px]'>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<div className='flex items-center gap-[6px]'>
<Skeleton className='h-5 w-[80px]' />
<Skeleton className='h-5 w-[140px]' />
</div>
<Skeleton className='h-5 w-[100px]' />
</div>
<Skeleton className='h-[26px] w-[48px] rounded-[6px]' />
</div>
)
}
/**
* Skeleton for the API Keys section shown during dynamic import loading.
*/
export function ApiKeysSkeleton() {
return (
<div className='flex flex-col gap-[12px]'>
<ApiKeySkeleton />
<ApiKeySkeleton />
</div>
)
}
@@ -18,6 +18,7 @@ import { Input, Skeleton } from '@/components/ui'
import { useSession } from '@/lib/auth/auth-client'
import { formatDate } from '@/lib/core/utils/formatting'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { ApiKeySkeleton } from '@/app/workspace/[workspaceId]/settings/components/api-keys/api-key-skeleton'
import {
type ApiKey,
useApiKeys,
@@ -408,18 +409,3 @@ export function ApiKeys() {
</div>
)
}
function ApiKeySkeleton() {
return (
<div className='flex items-center justify-between gap-[12px]'>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<div className='flex items-center gap-[6px]'>
<Skeleton className='h-5 w-[80px]' />
<Skeleton className='h-5 w-[140px]' />
</div>
<Skeleton className='h-5 w-[100px]' />
</div>
<Skeleton className='h-[26px] w-[48px] rounded-[6px]' />
</div>
)
}
@@ -0,0 +1,32 @@
import { Skeleton } from '@/components/ui'
/**
* Skeleton component for BYOK provider key items.
*/
export function BYOKKeySkeleton() {
return (
<div className='flex items-center justify-between gap-[12px]'>
<div className='flex items-center gap-[12px]'>
<Skeleton className='h-9 w-9 flex-shrink-0 rounded-[6px]' />
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<Skeleton className='h-[14px] w-[100px]' />
<Skeleton className='h-[13px] w-[200px]' />
</div>
</div>
<Skeleton className='h-[32px] w-[72px] rounded-[6px]' />
</div>
)
}
/**
* Skeleton for the BYOK section shown during dynamic import loading.
*/
export function BYOKSkeleton() {
return (
<div className='flex flex-col gap-[12px]'>
<BYOKKeySkeleton />
<BYOKKeySkeleton />
<BYOKKeySkeleton />
</div>
)
}
@@ -30,7 +30,7 @@ import {
PerplexityIcon,
SerperIcon,
} from '@/components/icons'
import { Skeleton } from '@/components/ui'
import { BYOKKeySkeleton } from '@/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton'
import {
type BYOKKey,
useBYOKKeys,
@@ -155,21 +155,6 @@ const PROVIDERS: {
},
]
function BYOKKeySkeleton() {
return (
<div className='flex items-center justify-between gap-[12px]'>
<div className='flex items-center gap-[12px]'>
<Skeleton className='h-9 w-9 flex-shrink-0 rounded-[6px]' />
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<Skeleton className='h-[14px] w-[100px]' />
<Skeleton className='h-[13px] w-[200px]' />
</div>
</div>
<Skeleton className='h-[32px] w-[72px] rounded-[6px]' />
</div>
)
}
export function BYOK() {
const params = useParams()
const workspaceId = (params?.workspaceId as string) || ''
@@ -0,0 +1,14 @@
import {
ApiKeySkeleton,
ApiKeysSkeleton,
} from '@/app/workspace/[workspaceId]/settings/components/api-keys/api-key-skeleton'
/**
* Re-export ApiKeySkeleton as CopilotKeySkeleton since both share identical markup.
*/
export const CopilotKeySkeleton = ApiKeySkeleton
/**
* Skeleton for the Copilot section shown during dynamic import loading.
*/
export const CopilotSkeleton = ApiKeysSkeleton
@@ -14,8 +14,10 @@ import {
ModalHeader,
// Switch,
} from '@/components/emcn'
import { Input, Skeleton } from '@/components/ui'
import { Input } from '@/components/ui'
import { formatDate } from '@/lib/core/utils/formatting'
// import { useMcpServers, useUpdateMcpServer } from '@/hooks/queries/mcp'
import { CopilotKeySkeleton } from '@/app/workspace/[workspaceId]/settings/components/copilot/copilot-skeleton'
import {
type CopilotKey,
useCopilotKeys,
@@ -23,28 +25,8 @@ import {
useGenerateCopilotKey,
} from '@/hooks/queries/copilot-keys'
// import { useMcpServers, useUpdateMcpServer } from '@/hooks/queries/mcp'
const logger = createLogger('CopilotSettings')
/**
* Skeleton component for loading state of copilot key items
*/
function CopilotKeySkeleton() {
return (
<div className='flex items-center justify-between gap-[12px]'>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<div className='flex items-center gap-[6px]'>
<Skeleton className='h-5 w-[80px]' />
<Skeleton className='h-5 w-[140px]' />
</div>
<Skeleton className='h-5 w-[100px]' />
</div>
<Skeleton className='h-[26px] w-[48px] rounded-[6px]' />
</div>
)
}
/**
* Copilot Keys management component for handling API keys used with the Copilot feature.
* Provides functionality to create, view, and delete copilot API keys.
@@ -0,0 +1,37 @@
import { Skeleton } from '@/components/ui'
/**
* Skeleton for the Credential Sets (Email Polling) section shown during dynamic import loading.
*/
export function CredentialSetsSkeleton() {
return (
<div className='flex h-full flex-col gap-[18px]'>
<div className='flex flex-col gap-[8px]'>
<Skeleton className='h-[14px] w-[100px]' />
<div className='flex items-center justify-between'>
<div className='flex items-center gap-[12px]'>
<Skeleton className='h-9 w-9 rounded-[6px]' />
<div className='flex flex-col gap-[4px]'>
<Skeleton className='h-[14px] w-[120px]' />
<Skeleton className='h-[12px] w-[80px]' />
</div>
</div>
<Skeleton className='h-[32px] w-[60px] rounded-[6px]' />
</div>
</div>
<div className='flex flex-col gap-[8px]'>
<Skeleton className='h-[14px] w-[60px]' />
<div className='flex items-center justify-between'>
<div className='flex items-center gap-[12px]'>
<Skeleton className='h-9 w-9 rounded-[6px]' />
<div className='flex flex-col gap-[4px]'>
<Skeleton className='h-[14px] w-[140px]' />
<Skeleton className='h-[12px] w-[100px]' />
</div>
</div>
<Skeleton className='h-[32px] w-[80px] rounded-[6px]' />
</div>
</div>
</div>
)
}
@@ -29,6 +29,7 @@ import { getProviderDisplayName, type PollingProvider } from '@/lib/credential-s
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { getUserColor } from '@/lib/workspaces/colors'
import { getUserRole } from '@/lib/workspaces/organization'
import { CredentialSetsSkeleton } from '@/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets-skeleton'
import {
type CredentialSet,
useAcceptCredentialSetInvitation,
@@ -50,39 +51,6 @@ import { useSubscriptionData } from '@/hooks/queries/subscription'
const logger = createLogger('EmailPolling')
function CredentialSetsSkeleton() {
return (
<div className='flex h-full flex-col gap-[18px]'>
<div className='flex flex-col gap-[8px]'>
<Skeleton className='h-[14px] w-[100px]' />
<div className='flex items-center justify-between'>
<div className='flex items-center gap-[12px]'>
<Skeleton className='h-9 w-9 rounded-[6px]' />
<div className='flex flex-col gap-[4px]'>
<Skeleton className='h-[14px] w-[120px]' />
<Skeleton className='h-[12px] w-[80px]' />
</div>
</div>
<Skeleton className='h-[32px] w-[60px] rounded-[6px]' />
</div>
</div>
<div className='flex flex-col gap-[8px]'>
<Skeleton className='h-[14px] w-[60px]' />
<div className='flex items-center justify-between'>
<div className='flex items-center gap-[12px]'>
<Skeleton className='h-9 w-9 rounded-[6px]' />
<div className='flex flex-col gap-[4px]'>
<Skeleton className='h-[14px] w-[140px]' />
<Skeleton className='h-[12px] w-[100px]' />
</div>
</div>
<Skeleton className='h-[32px] w-[80px] rounded-[6px]' />
</div>
</div>
</div>
)
}
export function CredentialSets() {
const { data: session } = useSession()
const { data: organizationsData } = useOrganizations()
@@ -0,0 +1,32 @@
import { Skeleton } from '@/components/ui'
/**
* Skeleton component for credential list items.
*/
export function CredentialSkeleton() {
return (
<div className='flex items-center justify-between gap-[12px]'>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<Skeleton className='h-[14px] w-[100px]' />
<Skeleton className='h-[13px] w-[200px]' />
</div>
<div className='flex flex-shrink-0 items-center gap-[8px]'>
<Skeleton className='h-[30px] w-[54px] rounded-[4px]' />
<Skeleton className='h-[30px] w-[50px] rounded-[4px]' />
</div>
</div>
)
}
/**
* Skeleton for the Credentials section shown during dynamic import loading.
*/
export function CredentialsSkeleton() {
return (
<div className='flex flex-col gap-[12px]'>
<CredentialSkeleton />
<CredentialSkeleton />
<CredentialSkeleton />
</div>
)
}
@@ -33,6 +33,7 @@ import {
getServiceConfigByProviderId,
type OAuthProvider,
} from '@/lib/oauth'
import { CredentialSkeleton } from '@/app/workspace/[workspaceId]/settings/components/credentials/credential-skeleton'
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
import { isValidEnvVarName } from '@/executor/constants'
import {
@@ -159,21 +160,6 @@ function normalizeEnvKeyInput(raw: string): string {
return wrappedMatch ? wrappedMatch[1] : trimmed
}
function CredentialSkeleton() {
return (
<div className='flex items-center justify-between gap-[12px]'>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<Skeleton className='h-[14px] w-[100px]' />
<Skeleton className='h-[13px] w-[200px]' />
</div>
<div className='flex flex-shrink-0 items-center gap-[8px]'>
<Skeleton className='h-[30px] w-[54px] rounded-[4px]' />
<Skeleton className='h-[30px] w-[50px] rounded-[4px]' />
</div>
</div>
)
}
export function CredentialsManager() {
const params = useParams()
const workspaceId = (params?.workspaceId as string) || ''
@@ -0,0 +1,32 @@
import { Skeleton } from '@/components/ui'
/**
* Skeleton component for custom tool list items.
*/
export function CustomToolSkeleton() {
return (
<div className='flex items-center justify-between gap-[12px]'>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<Skeleton className='h-[14px] w-[100px]' />
<Skeleton className='h-[13px] w-[200px]' />
</div>
<div className='flex flex-shrink-0 items-center gap-[8px]'>
<Skeleton className='h-[30px] w-[40px] rounded-[4px]' />
<Skeleton className='h-[30px] w-[54px] rounded-[4px]' />
</div>
</div>
)
}
/**
* Skeleton for the Custom Tools section shown during dynamic import loading.
*/
export function CustomToolsSkeleton() {
return (
<div className='flex flex-col gap-[12px]'>
<CustomToolSkeleton />
<CustomToolSkeleton />
<CustomToolSkeleton />
</div>
)
}
@@ -5,27 +5,13 @@ import { createLogger } from '@sim/logger'
import { Plus, Search } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
import { Input, Skeleton } from '@/components/ui'
import { Input } from '@/components/ui'
import { CustomToolSkeleton } from '@/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tool-skeleton'
import { CustomToolModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal'
import { useCustomTools, useDeleteCustomTool } from '@/hooks/queries/custom-tools'
const logger = createLogger('CustomToolsSettings')
function CustomToolSkeleton() {
return (
<div className='flex items-center justify-between gap-[12px]'>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<Skeleton className='h-[14px] w-[100px]' />
<Skeleton className='h-[13px] w-[200px]' />
</div>
<div className='flex flex-shrink-0 items-center gap-[8px]'>
<Skeleton className='h-[30px] w-[40px] rounded-[4px]' />
<Skeleton className='h-[30px] w-[54px] rounded-[4px]' />
</div>
</div>
)
}
export function CustomTools() {
const params = useParams()
const workspaceId = params.workspaceId as string
@@ -0,0 +1,13 @@
import { Skeleton } from '@/components/ui'
/**
* Skeleton for the Debug section shown during dynamic import loading.
*/
export function DebugSkeleton() {
return (
<div className='flex flex-col gap-[8px]'>
<Skeleton className='h-5 w-[200px]' />
<Skeleton className='h-5 w-[140px]' />
</div>
)
}
@@ -3,7 +3,7 @@
import { useState } from 'react'
import { useParams } from 'next/navigation'
import { Button, Input as EmcnInput } from '@/components/emcn'
import { Skeleton } from '@/components/ui'
import { DebugSkeleton } from '@/app/workspace/[workspaceId]/settings/components/debug/debug-skeleton'
import { useImportWorkflow } from '@/hooks/queries/workflows'
/**
@@ -73,15 +73,3 @@ export function Debug() {
</div>
)
}
/**
* Loading skeleton displayed during workflow import.
*/
function DebugSkeleton() {
return (
<div className='flex flex-col gap-[8px]'>
<Skeleton className='h-5 w-[200px]' />
<Skeleton className='h-5 w-[140px]' />
</div>
)
}
@@ -0,0 +1,63 @@
import { Skeleton } from '@/components/ui'
/**
* Skeleton component for general settings loading state.
* Matches the exact layout structure of the General component.
*/
export function GeneralSkeleton() {
return (
<div className='flex h-full flex-col gap-[18px]'>
<div className='flex items-center gap-[12px]'>
<Skeleton className='h-9 w-9 rounded-full' />
<div className='flex flex-1 flex-col justify-center gap-[1px]'>
<div className='flex items-center gap-[8px]'>
<Skeleton className='h-5 w-24' />
<Skeleton className='h-[10.5px] w-[10.5px]' />
</div>
<Skeleton className='h-5 w-40' />
</div>
</div>
<div className='flex items-center justify-between border-b pb-[12px]'>
<Skeleton className='h-4 w-12' />
<Skeleton className='h-8 w-[100px] rounded-[4px]' />
</div>
<div className='flex items-center justify-between'>
<Skeleton className='h-4 w-36' />
<Skeleton className='h-[17px] w-[30px] rounded-full' />
</div>
<div className='flex items-center justify-between'>
<Skeleton className='h-4 w-40' />
<Skeleton className='h-[17px] w-[30px] rounded-full' />
</div>
<div className='flex items-center justify-between'>
<Skeleton className='h-4 w-20' />
<Skeleton className='h-8 w-[100px] rounded-[4px]' />
</div>
<div className='flex items-center justify-between'>
<Skeleton className='h-4 w-32' />
<Skeleton className='h-[17px] w-[30px] rounded-full' />
</div>
<div className='flex items-center justify-between border-t pt-[16px]'>
<Skeleton className='h-4 w-44' />
<Skeleton className='h-[17px] w-[30px] rounded-full' />
</div>
<div className='-mt-[8px] flex flex-col gap-1'>
<Skeleton className='h-[12px] w-full' />
<Skeleton className='h-[12px] w-4/5' />
</div>
<div className='mt-auto flex items-center gap-[8px]'>
<Skeleton className='h-8 w-20 rounded-[4px]' />
<Skeleton className='h-8 w-28 rounded-[4px]' />
<Skeleton className='ml-auto h-8 w-24 rounded-[4px]' />
</div>
</div>
)
}
@@ -16,12 +16,13 @@ import {
ModalHeader,
Switch,
} from '@/components/emcn'
import { Input, Skeleton } from '@/components/ui'
import { Input } from '@/components/ui'
import { signOut, useSession } from '@/lib/auth/auth-client'
import { ANONYMOUS_USER_ID } from '@/lib/auth/constants'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { isHosted } from '@/lib/core/config/feature-flags'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { GeneralSkeleton } from '@/app/workspace/[workspaceId]/settings/components/general/general-skeleton'
import { useProfilePictureUpload } from '@/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload'
import { useBrandConfig } from '@/ee/whitelabeling'
import { useGeneralSettings, useUpdateGeneralSetting } from '@/hooks/queries/general-settings'
@@ -49,77 +50,6 @@ function getInitials(name: string | undefined | null): string {
return parts[0][0].toUpperCase()
}
/**
* Skeleton component for general settings loading state.
* Matches the exact layout structure of the General component.
*/
function GeneralSkeleton() {
return (
<div className='flex h-full flex-col gap-[18px]'>
{/* User Info Section */}
<div className='flex items-center gap-[12px]'>
<Skeleton className='h-9 w-9 rounded-full' />
<div className='flex flex-1 flex-col justify-center gap-[1px]'>
<div className='flex items-center gap-[8px]'>
<Skeleton className='h-5 w-24' />
<Skeleton className='h-[10.5px] w-[10.5px]' />
</div>
<Skeleton className='h-5 w-40' />
</div>
</div>
{/* Theme selector row */}
<div className='flex items-center justify-between border-b pb-[12px]'>
<Skeleton className='h-4 w-12' />
<Skeleton className='h-8 w-[100px] rounded-[4px]' />
</div>
{/* Auto-connect row */}
<div className='flex items-center justify-between'>
<Skeleton className='h-4 w-36' />
<Skeleton className='h-[17px] w-[30px] rounded-full' />
</div>
{/* Error notifications row */}
<div className='flex items-center justify-between'>
<Skeleton className='h-4 w-40' />
<Skeleton className='h-[17px] w-[30px] rounded-full' />
</div>
{/* Snap to grid row */}
<div className='flex items-center justify-between'>
<Skeleton className='h-4 w-20' />
<Skeleton className='h-8 w-[100px] rounded-[4px]' />
</div>
{/* Show canvas controls row */}
<div className='flex items-center justify-between'>
<Skeleton className='h-4 w-32' />
<Skeleton className='h-[17px] w-[30px] rounded-full' />
</div>
{/* Telemetry row */}
<div className='flex items-center justify-between border-t pt-[16px]'>
<Skeleton className='h-4 w-44' />
<Skeleton className='h-[17px] w-[30px] rounded-full' />
</div>
{/* Telemetry description */}
<div className='-mt-[8px] flex flex-col gap-1'>
<Skeleton className='h-[12px] w-full' />
<Skeleton className='h-[12px] w-4/5' />
</div>
{/* Action buttons */}
<div className='mt-auto flex items-center gap-[8px]'>
<Skeleton className='h-8 w-20 rounded-[4px]' />
<Skeleton className='h-8 w-28 rounded-[4px]' />
<Skeleton className='ml-auto h-8 w-24 rounded-[4px]' />
</div>
</div>
)
}
export function General() {
const router = useRouter()
const brandConfig = useBrandConfig()
@@ -1,14 +0,0 @@
export { ApiKeys } from './api-keys/api-keys'
export { BYOK } from './byok/byok'
export { Copilot } from './copilot/copilot'
export { CredentialSets } from './credential-sets/credential-sets'
export { Credentials } from './credentials/credentials'
export { CustomTools } from './custom-tools/custom-tools'
export { Debug } from './debug/debug'
export { General } from './general/general'
export { MCP } from './mcp/mcp'
export { Skills } from './skills/skills'
export { Subscription } from './subscription/subscription'
export { TeamManagement } from './team-management/team-management'
export { TemplateProfile } from './template-profile/template-profile'
export { WorkflowMcpServers } from './workflow-mcp-servers/workflow-mcp-servers'
@@ -0,0 +1,14 @@
import { McpServerSkeleton } from '@/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-skeleton/mcp-server-skeleton'
/**
* Skeleton for the MCP section shown during dynamic import loading.
*/
export function McpSkeleton() {
return (
<div className='flex flex-col gap-[12px]'>
<McpServerSkeleton />
<McpServerSkeleton />
<McpServerSkeleton />
</div>
)
}
@@ -0,0 +1,14 @@
import {
CustomToolSkeleton,
CustomToolsSkeleton,
} from '@/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tool-skeleton'
/**
* Re-export CustomToolSkeleton as SkillSkeleton since both share identical markup.
*/
export const SkillSkeleton = CustomToolSkeleton
/**
* Skeleton for the Skills section shown during dynamic import loading.
*/
export const SkillsSkeleton = CustomToolsSkeleton
@@ -5,28 +5,14 @@ import { createLogger } from '@sim/logger'
import { Plus, Search } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
import { Input, Skeleton } from '@/components/ui'
import { Input } from '@/components/ui'
import { SkillModal } from '@/app/workspace/[workspaceId]/settings/components/skills/components/skill-modal'
import { SkillSkeleton } from '@/app/workspace/[workspaceId]/settings/components/skills/skill-skeleton'
import type { SkillDefinition } from '@/hooks/queries/skills'
import { useDeleteSkill, useSkills } from '@/hooks/queries/skills'
const logger = createLogger('SkillsSettings')
function SkillSkeleton() {
return (
<div className='flex items-center justify-between gap-[12px]'>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<Skeleton className='h-[14px] w-[100px]' />
<Skeleton className='h-[13px] w-[200px]' />
</div>
<div className='flex flex-shrink-0 items-center gap-[8px]'>
<Skeleton className='h-[30px] w-[40px] rounded-[4px]' />
<Skeleton className='h-[30px] w-[54px] rounded-[4px]' />
</div>
</div>
)
}
export function Skills() {
const params = useParams()
const workspaceId = params.workspaceId as string
@@ -0,0 +1,6 @@
import { McpSkeleton } from '@/app/workspace/[workspaceId]/settings/components/mcp/mcp-skeleton'
/**
* Re-export McpSkeleton as WorkflowMcpServersSkeleton since both share identical markup.
*/
export const WorkflowMcpServersSkeleton = McpSkeleton
@@ -1,6 +1,7 @@
'use client'
import { useMemo } from 'react'
import { useCallback, useMemo } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import Link from 'next/link'
import { useParams, usePathname, useRouter } from 'next/navigation'
import { ChevronDown, Skeleton } from '@/components/emcn'
@@ -16,9 +17,10 @@ import {
sectionConfig,
} from '@/app/workspace/[workspaceId]/settings/navigation'
import { useSSOProviders } from '@/ee/sso/hooks/sso'
import { useGeneralSettings } from '@/hooks/queries/general-settings'
import { prefetchWorkspaceCredentials } from '@/hooks/queries/credentials'
import { prefetchGeneralSettings, useGeneralSettings } from '@/hooks/queries/general-settings'
import { useOrganizations } from '@/hooks/queries/organization'
import { useSubscriptionData } from '@/hooks/queries/subscription'
import { prefetchSubscriptionData, useSubscriptionData } from '@/hooks/queries/subscription'
import { useSuperUserStatus } from '@/hooks/queries/user-profile'
import { usePermissionConfig } from '@/hooks/use-permission-config'
@@ -28,10 +30,14 @@ export function SettingsSidebar() {
const pathname = usePathname()
const router = useRouter()
const queryClient = useQueryClient()
const { data: session, isPending: sessionLoading } = useSession()
const { data: organizationsData, isLoading: orgsLoading } = useOrganizations()
const { data: generalSettings } = useGeneralSettings()
const { data: subscriptionData } = useSubscriptionData({ enabled: isBillingEnabled })
const { data: subscriptionData } = useSubscriptionData({
enabled: isBillingEnabled,
staleTime: 5 * 60 * 1000,
})
const { data: ssoProvidersData, isLoading: isLoadingSSO } = useSSOProviders()
const { data: superUserData } = useSuperUserStatus(session?.user?.id)
@@ -129,6 +135,26 @@ export function SettingsSidebar() {
return 'general'
}, [pathname])
const handlePrefetch = useCallback(
(itemId: string) => {
switch (itemId) {
case 'general':
prefetchGeneralSettings(queryClient)
void import('@/app/workspace/[workspaceId]/settings/components/general/general')
break
case 'credentials':
prefetchWorkspaceCredentials(queryClient, workspaceId)
void import('@/app/workspace/[workspaceId]/settings/components/credentials/credentials')
break
case 'subscription':
prefetchSubscriptionData(queryClient)
void import('@/app/workspace/[workspaceId]/settings/components/subscription/subscription')
break
}
},
[queryClient, workspaceId]
)
const handleBack = () => {
router.push(`/workspace/${workspaceId}/home`)
}
@@ -213,6 +239,8 @@ export function SettingsSidebar() {
key={item.id}
href={`/workspace/${workspaceId}/settings/${item.id}`}
className={className}
onMouseEnter={() => handlePrefetch(item.id)}
onFocus={() => handlePrefetch(item.id)}
>
{content}
</Link>
+28
View File
@@ -1,5 +1,6 @@
'use client'
import type { QueryClient } from '@tanstack/react-query'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { environmentKeys } from '@/hooks/queries/environment'
import { fetchJson } from '@/hooks/selectors/helpers'
@@ -68,6 +69,33 @@ export const workspaceCredentialKeys = {
[...workspaceCredentialKeys.detail(credentialId), 'members'] as const,
}
/**
* Fetch workspace credential list from API.
* Used by the prefetch function for hover-based cache warming.
*/
async function fetchWorkspaceCredentialList(
workspaceId: string,
signal?: AbortSignal
): Promise<WorkspaceCredential[]> {
const data = await fetchJson<CredentialListResponse>('/api/credentials', {
searchParams: { workspaceId },
signal,
})
return data.credentials ?? []
}
/**
* Prefetch workspace credentials into a QueryClient cache.
* Use on hover to warm data before navigation.
*/
export function prefetchWorkspaceCredentials(queryClient: QueryClient, workspaceId: string) {
queryClient.prefetchQuery({
queryKey: workspaceCredentialKeys.list(workspaceId),
queryFn: ({ signal }) => fetchWorkspaceCredentialList(workspaceId, signal),
staleTime: 60 * 1000,
})
}
export function useWorkspaceCredentials(params: {
workspaceId?: string
type?: WorkspaceCredentialType
@@ -1,4 +1,5 @@
import { createLogger } from '@sim/logger'
import type { QueryClient } from '@tanstack/react-query'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { syncThemeToNextThemes } from '@/lib/core/utils/theme'
@@ -68,6 +69,18 @@ export function useGeneralSettings() {
})
}
/**
* Prefetch general settings into a QueryClient cache.
* Use on hover to warm data before navigation.
*/
export function prefetchGeneralSettings(queryClient: QueryClient) {
queryClient.prefetchQuery({
queryKey: generalSettingsKeys.settings(),
queryFn: () => fetchGeneralSettings(),
staleTime: 60 * 60 * 1000,
})
}
/**
* Convenience selector hooks for individual settings.
* These provide a simple API for components that only need a single setting value.
+17 -2
View File
@@ -1,3 +1,4 @@
import type { QueryClient } from '@tanstack/react-query'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { organizationKeys } from '@/hooks/queries/organization'
@@ -114,6 +115,8 @@ interface UseSubscriptionDataOptions {
includeOrg?: boolean
/** Whether to enable the query (defaults to true) */
enabled?: boolean
/** Override default staleTime (defaults to 30s) */
staleTime?: number
}
/**
@@ -121,17 +124,29 @@ interface UseSubscriptionDataOptions {
* @param options - Optional configuration
*/
export function useSubscriptionData(options: UseSubscriptionDataOptions = {}) {
const { includeOrg = false, enabled = true } = options
const { includeOrg = false, enabled = true, staleTime = 30 * 1000 } = options
return useQuery({
queryKey: subscriptionKeys.user(includeOrg),
queryFn: ({ signal }) => fetchSubscriptionData(includeOrg, signal),
staleTime: 30 * 1000,
staleTime,
placeholderData: keepPreviousData,
enabled,
})
}
/**
* Prefetch subscription data into a QueryClient cache.
* Use on hover to warm data before navigation.
*/
export function prefetchSubscriptionData(queryClient: QueryClient) {
queryClient.prefetchQuery({
queryKey: subscriptionKeys.user(false),
queryFn: () => fetchSubscriptionData(false),
staleTime: 30 * 1000,
})
}
/**
* Fetch user usage limit metadata
* Note: This endpoint returns limit information (currentLimit, minimumLimit, canEdit, etc.)