Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions apps/sim/app/o/[organizationId]/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { SettingsNavigationProvider } from '@/components/settings/settings-navigation-provider'
import { getSession } from '@/lib/auth'
import { getActiveOrganizationId } from '@/lib/auth/session-response'
import { isMothershipModelSelectorEnabled, isPlanModeEnabled } from '@/lib/mothership/feature-flags'
Expand Down Expand Up @@ -81,12 +82,14 @@ export default async function OrganizationLayout({
<div className='workspace-root flex h-screen w-full flex-col overflow-hidden bg-[var(--surface-1)]'>
<ImpersonationBanner />
<SessionExpired />
<WorkspaceChrome
sidebar={<OrganizationSidebar />}
initialSidebarCollapsed={initialSidebarCollapsed}
>
{children}
</WorkspaceChrome>
<SettingsNavigationProvider>
<WorkspaceChrome
sidebar={<OrganizationSidebar />}
initialSidebarCollapsed={initialSidebarCollapsed}
>
{children}
</WorkspaceChrome>
</SettingsNavigationProvider>
</div>
</GlobalCommandsProvider>
</OrganizationProvider>
Expand Down
17 changes: 3 additions & 14 deletions apps/sim/app/o/[organizationId]/settings/[section]/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
import type { ReactNode } from 'react'
import { notFound, redirect } from 'next/navigation'
import {
getSettingsSectionMeta,
ORGANIZATION_SETTINGS_ITEMS,
toSettingsHeaderMeta,
} from '@/components/settings/navigation'
import { SettingsHeaderProvider, SettingsHeaderShell } from '@/components/settings/settings-header'
import { organizationRoutes } from '@/lib/navigation/paths'
import { resolveOrganizationSurfaceSection } from '@/app/o/[organizationId]/settings/navigation'
import { resolveOrganizationSurfaceHeaderMeta } from '@/app/o/[organizationId]/settings/navigation'

interface OrganizationSettingsSectionLayoutProps {
children: ReactNode
Expand All @@ -24,18 +19,12 @@ export default async function OrganizationSettingsSectionLayout({
`${organizationRoutes(organizationId).settingsSection('general')}?view=authorized-apps`
)
}
const resolved = resolveOrganizationSurfaceSection(section)
const meta =
resolved?.plane === 'organization'
? ORGANIZATION_SETTINGS_ITEMS.find(({ id }) => id === resolved.section)
: resolved
? getSettingsSectionMeta('account', resolved.section)
: null
const meta = resolveOrganizationSurfaceHeaderMeta(section)
if (!meta) notFound()

return (
<SettingsHeaderProvider>
<SettingsHeaderShell meta={toSettingsHeaderMeta(meta)}>{children}</SettingsHeaderShell>
<SettingsHeaderShell meta={meta}>{children}</SettingsHeaderShell>
</SettingsHeaderProvider>
)
}
10 changes: 9 additions & 1 deletion apps/sim/app/o/[organizationId]/settings/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
'use client'

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 {
children: ReactNode
}

export default function OrganizationSettingsLayout({ children }: OrganizationSettingsLayoutProps) {
useSettingsBeforeUnload()
return <div className='flex h-full flex-col bg-[var(--bg)]'>{children}</div>
return (
<div className='flex h-full flex-col bg-[var(--bg)]'>
<SettingsPendingSection resolveMeta={resolveOrganizationSurfaceHeaderMeta}>
{children}
</SettingsPendingSection>
</div>
)
}
15 changes: 15 additions & 0 deletions apps/sim/app/o/[organizationId]/settings/navigation.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
ACCOUNT_SETTINGS_ITEMS,
type AccountSettingsSection,
getSettingsSectionMeta,
isOrganizationSettingsSectionAvailable,
ORGANIZATION_SETTINGS_GROUPS,
ORGANIZATION_SETTINGS_ITEMS,
Expand All @@ -9,7 +10,9 @@ import {
parseSettingsPathSection,
resolveOrganizationSectionAccess,
type SettingsNavigationItem,
toSettingsHeaderMeta,
} from '@/components/settings/navigation'
import type { SettingsHeaderMeta } from '@/components/settings/settings-header'

/**
* A section on the organization surface's settings, tagged with the plane that
Expand Down Expand Up @@ -64,6 +67,18 @@ export function resolveOrganizationSurfaceSection(
return account ? { plane: 'account', section: account } : null
}

/** The heading an organization-surface section path renders with, or null for an unknown path. */
export function resolveOrganizationSurfaceHeaderMeta(path: string): SettingsHeaderMeta | null {
const resolved = resolveOrganizationSurfaceSection(path)
const item =
resolved?.plane === 'organization'
? ORGANIZATION_SETTINGS_ITEMS.find(({ id }) => id === resolved.section)
: resolved
? getSettingsSectionMeta('account', resolved.section)
: null
return item ? toSettingsHeaderMeta(item) : null
}

export function organizationSettingsNavigation(
isAdmin: boolean,
features: OrganizationSettingsFeatures,
Expand Down
4 changes: 4 additions & 0 deletions apps/sim/app/workspace/[workspaceId]/layout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@ const {
mockPrefetchWorkspaceHostContext,
mockPrefetchWorkspaceSidebar,
mockPrefetchWorkspaceAccess,
mockPrefetchWorkspaceForkAvailability,
} = vi.hoisted(() => ({
mockBrandingProvider: vi.fn(({ children }: { children: ReactNode }) => children),
mockGetOrgWhitelabelSettings: vi.fn(),
mockPrefetchWorkspaceHostContext: vi.fn(),
mockPrefetchWorkspaceSidebar: vi.fn(),
mockPrefetchWorkspaceAccess: vi.fn(),
mockPrefetchWorkspaceForkAvailability: vi.fn(),
}))

vi.mock('@sim/emcn', () => emcnMock)
Expand All @@ -38,6 +40,7 @@ vi.mock('@/app/_shell/providers/get-query-client', () => ({
vi.mock('@/app/workspace/[workspaceId]/prefetch', () => ({
prefetchWorkspaceHostContext: mockPrefetchWorkspaceHostContext,
prefetchWorkspaceSidebar: mockPrefetchWorkspaceSidebar,
prefetchWorkspaceForkAvailability: mockPrefetchWorkspaceForkAvailability,
}))

vi.mock('@/app/workspace/[workspaceId]/prefetch-access', () => ({
Expand Down Expand Up @@ -149,6 +152,7 @@ describe('WorkspaceLayout host context', () => {
mockPrefetchWorkspaceHostContext.mockResolvedValue(HOST_CONTEXT)
mockPrefetchWorkspaceSidebar.mockResolvedValue(undefined)
mockPrefetchWorkspaceAccess.mockResolvedValue(undefined)
mockPrefetchWorkspaceForkAvailability.mockResolvedValue(undefined)
mockGetOrgWhitelabelSettings.mockResolvedValue({ brandName: 'Host B' })
})

Expand Down
28 changes: 17 additions & 11 deletions apps/sim/app/workspace/[workspaceId]/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { SettingsNavigationProvider } from '@/components/settings/settings-navigation-provider'
import { getSession } from '@/lib/auth'
import { getActiveOrganizationId } from '@/lib/auth/session-response'
import { isMothershipModelSelectorEnabled, isPlanModeEnabled } from '@/lib/mothership/feature-flags'
Expand All @@ -12,6 +13,7 @@ import { SessionExpired } from '@/app/workspace/[workspaceId]/components/session
import { WorkspaceAccessDenied } from '@/app/workspace/[workspaceId]/components/workspace-access-denied'
import { WorkspaceChrome } from '@/app/workspace/[workspaceId]/components/workspace-chrome'
import {
prefetchWorkspaceForkAvailability,
prefetchWorkspaceHostContext,
prefetchWorkspaceSidebar,
} from '@/app/workspace/[workspaceId]/prefetch'
Expand Down Expand Up @@ -50,6 +52,11 @@ export default async function WorkspaceLayout({
}

const activeOrganizationId = getActiveOrganizationId(session)
const principal = {
kind: 'session',
userId: session.user.id,
sessionId: session.session.id,
} as const
const [
cookieStore,
initialOrgSettings,
Expand All @@ -74,11 +81,8 @@ export default async function WorkspaceLayout({
isMothershipModelSelectorEnabled(),
isPlanModeEnabled(),
resolveOrganizationEntryPath(session),
prefetchWorkspaceAccess(queryClient, workspaceId, {
kind: 'session',
userId: session.user.id,
sessionId: session.session.id,
}),
prefetchWorkspaceAccess(queryClient, workspaceId, principal),
prefetchWorkspaceForkAvailability(queryClient, workspaceId, principal, hostContext),
Comment thread
waleedlatif1 marked this conversation as resolved.
])
const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1'

Expand Down Expand Up @@ -108,12 +112,14 @@ export default async function WorkspaceLayout({
<SessionExpired />
<WorkspacePermissionsProvider>
<WorkspaceScopeSync />
<WorkspaceChrome
sidebar={<Sidebar organizationHref={organizationHref} />}
initialSidebarCollapsed={initialSidebarCollapsed}
>
{children}
</WorkspaceChrome>
<SettingsNavigationProvider>
<WorkspaceChrome
sidebar={<Sidebar organizationHref={organizationHref} />}
initialSidebarCollapsed={initialSidebarCollapsed}
>
{children}
</WorkspaceChrome>
</SettingsNavigationProvider>
</WorkspacePermissionsProvider>
</div>
</GlobalCommandsProvider>
Expand Down
30 changes: 30 additions & 0 deletions apps/sim/app/workspace/[workspaceId]/prefetch.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { SessionPrincipal } from '@sim/auth/principal'
import type { QueryClient } from '@tanstack/react-query'
import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces'
import { isChatEnabled } from '@/lib/core/config/env-flags'
Expand All @@ -8,6 +9,10 @@ import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context'
import { getWorkspacePermissionsForAuthorizedViewer } from '@/lib/workspaces/permissions/utils'
import { seedWorkspaceList } from '@/lib/workspaces/seed-workspace-list'
import { prefetchResourceFolders } from '@/app/workspace/[workspaceId]/lib/prefetch-resource-folders'
import {
FORK_AVAILABILITY_STALE_TIME,
forkAvailabilityKeys,
} from '@/ee/workspace-forking/hooks/use-forking-available'
import {
MOTHERSHIP_CHAT_LIST_STALE_TIME,
mapChat,
Expand Down Expand Up @@ -114,3 +119,28 @@ export async function prefetchWorkspaceSidebar(
seedWorkspaceList(queryClient, userId, activeOrganizationId),
])
}

/**
* Seeds fork availability, which decides whether the settings sidebar lists Workspace Forks, so
* the row renders with the rest of the sidebar. Only admins can read it
* (`forkOperations.discover`), so it is skipped for everyone else. It runs the availability
* route's own use case; a failed read stays out of hydration and the client refetches it.
*/
export async function prefetchWorkspaceForkAvailability(
queryClient: QueryClient,
workspaceId: string,
principal: SessionPrincipal,
hostContext: WorkspaceHostContext
): Promise<void> {
if (hostContext.viewer.permission !== 'admin') return
await queryClient.prefetchQuery({
queryKey: forkAvailabilityKeys.detail(workspaceId),
queryFn: async () => {
const { getWorkspaceForkAvailability } = await import(
'@/ee/workspace-forking/application/discovery'
)
return getWorkspaceForkAvailability.execute({ principal, input: { workspaceId } })
},
staleTime: FORK_AVAILABILITY_STALE_TIME,
})
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,19 +26,19 @@ const TOP_LEVEL_REDIRECTS: Readonly<Record<string, (workspaceId: string) => stri
* the shell instead of waiting on the body's lazily-loaded chunk.
*
* 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
* bad or legacy URL loaded directly answers 404 or 307 without running section authorization.
* 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
* `<Suspense>` 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 `<Suspense>` in the settings layout) would also
* swallow the 404 and 307 above.
* one, a switch is a transition: the sidebar moves its selection and the settings layout paints
* the incoming heading over the outgoing section, which stays mounted but invisible until the
* incoming section and its chunk are ready. A boundary above this layout (a
* `settings/loading.tsx`, or a `<Suspense>` 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.
Expand Down
Loading
Loading