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
8 changes: 6 additions & 2 deletions .claude/rules/sim-settings-pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ The Next.js `settings/[section]/layout.tsx` owns all settings page chrome via
`SettingsHeaderShell` — a fixed header bar (a left back chip + right-aligned
action chips), a scroll region, and a centered `max-w-[48rem]` content column led
by a **title + description from navigation metadata**. The chrome stays mounted
across section navigation (it never re-renders or re-lays-out). Each section
renders through the **`SettingsPanel`** registrar
across section navigation. Its routed title and description are available before
the section body resolves. Each section renders through the **`SettingsPanel`**
registrar
(`@/app/workspace/[workspaceId]/settings/components/settings-panel`), which feeds
the shell its header data and renders only the section body. Sections supply
**data**, never chrome.
Expand Down Expand Up @@ -82,6 +83,9 @@ return (
`children` instead and omit the prop.
- `title?` / `description?` — overrides for the nav-driven defaults. **Only** for a
detail sub-view that needs a different heading; normal pages never pass these.
A top-level page's header identity must remain stable while its data loads:
never replace navigation metadata with client-fetched copy after first paint.
Put data-dependent context in the page body instead.
- `scrollContainerRef?: React.Ref<HTMLDivElement>` — forwards a ref to the scroll
region (e.g. programmatic scroll-to-bottom).

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,6 @@ export function SettingsPage({ section }: SettingsPageProps) {
<Billing
scope={organizationId ? 'organization' : 'account'}
organizationId={organizationId ?? undefined}
governingWorkspaceName={hostContext.workspace.name}
creditUsageHref={`/workspace/${hostContext.workspace.id}/settings/billing/credit-usage`}
/>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,14 @@ vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-panel', () =
),
}))

vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-empty-state', () => ({
SettingsEmptyState: ({ children, tone }: { children: ReactNode; tone?: 'muted' | 'error' }) => (
<div data-testid='settings-empty-state' data-tone={tone ?? 'muted'}>
{children}
</div>
),
}))

vi.mock(
'@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section',
() => ({
Expand Down Expand Up @@ -269,13 +277,7 @@ describe('Billing payer scope', () => {

it('uses the target organization DTO for annual, canceled, credit, cap, and link state', async () => {
await act(async () => {
root.render(
<Billing
scope='organization'
organizationId='org-target'
governingWorkspaceName='Production'
/>
)
root.render(<Billing scope='organization' organizationId='org-target' />)
})

expect(mockUseSubscriptionData).toHaveBeenCalledWith(
Expand All @@ -290,9 +292,7 @@ describe('Billing payer scope', () => {
container.querySelector('a[href="/workspace/organization-workspace/upgrade"]')?.textContent
).toBe('Explore organization plans')
expect(container.textContent).toContain('Organization Max for Teams plan')
expect(container.textContent).toContain(
'Target organization’s subscription governs Production.'
)
expect(container.querySelector('main > p')).toBeNull()
expect(container.textContent).toContain('billed annually')
expect(container.textContent).toContain('Access until')
expect(container.textContent).toContain('Subscription canceled')
Expand All @@ -316,19 +316,45 @@ describe('Billing payer scope', () => {

it('uses a guaranteed personal payer workspace for account upgrades', async () => {
await act(async () => {
root.render(<Billing scope='account' governingWorkspaceName='Personal workspace' />)
root.render(<Billing scope='account' />)
})

expect(
container.querySelector('a[href="/workspace/personal-workspace/upgrade"]')?.textContent
).toBe('Explore personal plans')
expect(container.textContent).toContain('Personal Pro plan')
expect(container.textContent).toContain(
'Your personal subscription governs Personal workspace.'
)
})

it('does not show a governing subscription description for a free personal workspace', async () => {
it('does not override the route-owned header while billing transitions from loading to success', async () => {
mockPersonalQuery.current = {
data: undefined,
error: null,
isLoading: true,
refetch: vi.fn(),
}

await act(async () => {
root.render(<Billing scope='account' />)
})

expect(container.innerHTML).toBe('')

mockPersonalQuery.current = {
data: { success: true, context: 'user', data: PERSONAL_DATA },
error: null,
isLoading: false,
refetch: vi.fn(),
}

await act(async () => {
root.render(<Billing scope='account' />)
})

expect(container.textContent).toContain('Personal Pro plan')
expect(container.querySelector('main > p')).toBeNull()
})

it('does not add a dynamic header description for a free personal workspace', async () => {
mockPersonalQuery.current = {
data: {
success: true,
Expand All @@ -340,7 +366,7 @@ describe('Billing payer scope', () => {
}

await act(async () => {
root.render(<Billing scope='account' governingWorkspaceName='Free workspace' />)
root.render(<Billing scope='account' />)
})

expect(container.textContent).toContain('Personal Free plan')
Expand Down Expand Up @@ -368,13 +394,7 @@ describe('Billing payer scope', () => {
}

await act(async () => {
root.render(
<Billing
scope='organization'
organizationId='org-target'
governingWorkspaceName='Free organization workspace'
/>
)
root.render(<Billing scope='organization' organizationId='org-target' />)
})

expect(container.textContent).toContain('Organization Free plan')
Expand All @@ -398,13 +418,7 @@ describe('Billing payer scope', () => {
}

await act(async () => {
root.render(
<Billing
scope='organization'
organizationId='org-target'
governingWorkspaceName='Lapsed organization workspace'
/>
)
root.render(<Billing scope='organization' organizationId='org-target' />)
})

expect(container.textContent).toContain('Organization Max for Teams plan ended')
Expand All @@ -415,4 +429,54 @@ describe('Billing payer scope', () => {
container.querySelector('a[href="/workspace/organization-workspace/upgrade"]')?.textContent
).toBe('Explore organization plans')
})

it('renders the canonical error state when the active billing query fails', async () => {
mockPersonalQuery.current = {
data: undefined,
error: new Error('Billing temporarily unavailable'),
isLoading: false,
refetch: vi.fn(),
}

await act(async () => {
root.render(<Billing scope='account' />)
})

const errorState = container.querySelector('[data-testid="settings-empty-state"]')
expect(errorState).toHaveAttribute('data-tone', 'error')
expect(errorState?.textContent).toBe('Billing temporarily unavailable')
})

it('keeps cached billing content visible when a background refresh fails', async () => {
mockPersonalQuery.current = {
data: { success: true, context: 'user', data: PERSONAL_DATA },
error: new Error('Background refresh failed'),
isLoading: false,
refetch: vi.fn(),
}

await act(async () => {
root.render(<Billing scope='account' />)
})

expect(container.textContent).toContain('Personal Pro plan')
expect(container.querySelector('[data-testid="settings-empty-state"]')).toBeNull()
})

it('renders the canonical fallback error when billing completes without data', async () => {
mockOrganizationQuery.current = {
data: undefined,
error: null,
isLoading: false,
refetch: vi.fn(),
}

await act(async () => {
root.render(<Billing scope='organization' organizationId='org-target' />)
})

const errorState = container.querySelector('[data-testid="settings-empty-state"]')
expect(errorState).toHaveAttribute('data-tone', 'error')
expect(errorState?.textContent).toBe('Failed to load billing information')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
import { CreditUsageSection } from '@/app/workspace/[workspaceId]/settings/components/billing/components/credit-usage-section/credit-usage-section'
import { UsageLimitField } from '@/app/workspace/[workspaceId]/settings/components/billing/components/usage-limit-field/usage-limit-field'
import { getSubscriptionPermissions } from '@/app/workspace/[workspaceId]/settings/components/billing/subscription-permissions'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
import { RESOURCE_ROW_ARROW_CLASSES } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
Expand Down Expand Up @@ -103,20 +104,15 @@ interface BillingProps {
scope: 'account' | 'organization'
organizationId?: string
creditUsageHref?: string
governingWorkspaceName?: string
}

export function Billing({
scope,
organizationId,
creditUsageHref,
governingWorkspaceName,
}: BillingProps) {
export function Billing({ scope, organizationId, creditUsageHref }: BillingProps) {
const router = useRouter()
const isOrganizationScope = scope === 'organization'

const {
data: subscriptionData,
error: subscriptionError,
isLoading: isSubscriptionLoading,
refetch: refetchSubscription,
} = useSubscriptionData({
Expand All @@ -127,6 +123,7 @@ export function Billing({

const {
data: organizationBillingData,
error: organizationBillingError,
isLoading: isOrgBillingLoading,
refetch: refetchOrganizationBilling,
} = useOrganizationBilling(billingOrganizationId || '', { enabled: isOrganizationScope })
Expand Down Expand Up @@ -157,6 +154,7 @@ export function Billing({
? (organizationBilling?.subscriptionStatus ?? 'inactive')
: (subscriptionData?.data?.status ?? 'inactive')
const isLoading = isOrganizationScope ? isOrgBillingLoading : isSubscriptionLoading
const billingError = isOrganizationScope ? organizationBillingError : subscriptionError

const subscription = {
isFree: isFree(plan),
Expand Down Expand Up @@ -403,7 +401,15 @@ export function Billing({
}

if (isLoading) return null
if (isOrganizationScope ? !organizationBilling : !subscriptionData?.data) return null
if (isOrganizationScope ? !organizationBilling : !subscriptionData?.data) {
return (
<SettingsPanel>
<SettingsEmptyState tone='error'>
{getErrorMessage(billingError, 'Failed to load billing information')}
</SettingsEmptyState>
</SettingsPanel>
)
}

const planName = getDisplayPlanName(subscription.plan)
const billingInterval = isOrganizationScope
Expand Down Expand Up @@ -458,16 +464,9 @@ export function Billing({
const explorePlansLabel = isOrganizationScope
? 'Explore organization plans'
: 'Explore personal plans'
const subscriptionOwner = isOrganizationScope
? `${organizationBilling?.organizationName ?? 'The organization'}’s subscription`
: 'Your personal subscription'
const settingsDescription =
governingWorkspaceName && subscription.isPaid
? `${subscriptionOwner} governs ${governingWorkspaceName}.`
: undefined

return (
<SettingsPanel description={settingsDescription}>
<SettingsPanel>
<div className='flex items-center justify-between gap-3'>
<div className='flex items-center gap-2.5'>
<div className='size-9 flex-shrink-0'>
Expand Down
10 changes: 7 additions & 3 deletions apps/sim/components/settings/settings-header-shell.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ function renderHeader(actions: SettingsAction[]) {
root.render(
<SettingsHeaderProvider>
<SettingsHeaderShell>
<SettingsPanel title='Thing' actions={actions}>
<SettingsPanel back={{ text: 'Back', onSelect: vi.fn() }} title='Thing' actions={actions}>
<div />
</SettingsPanel>
</SettingsHeaderShell>
Expand Down Expand Up @@ -152,7 +152,11 @@ describe('SettingsHeaderShell static meta', () => {

it('yields to a body that registers its own header', () => {
renderWithMeta(
<SettingsPanel title='Add secret' description='One value.'>
<SettingsPanel
back={{ text: 'Secrets', onSelect: vi.fn() }}
title='Add secret'
description='One value.'
>
<div />
</SettingsPanel>
)
Expand Down Expand Up @@ -192,7 +196,7 @@ describe('SettingsHeaderShell static meta', () => {

it('falls back to the meta title when the body unmounts mid-navigation', () => {
renderWithMeta(
<SettingsPanel title='Add secret'>
<SettingsPanel back={{ text: 'Secrets', onSelect: vi.fn() }} title='Add secret'>
<div />
</SettingsPanel>
)
Expand Down
19 changes: 15 additions & 4 deletions apps/sim/components/settings/settings-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,17 +38,28 @@ export function SettingsSectionProvider({
)
}

interface SettingsPanelProps {
interface SettingsPanelBaseProps {
children?: ReactNode
actions?: SettingsAction[]
back?: SettingsBackAction
search?: SettingsHeaderSearch
title?: string
description?: string
docsLink?: string
scrollContainerRef?: Ref<HTMLDivElement>
}

type SettingsPanelProps = SettingsPanelBaseProps &
(
| {
back: SettingsBackAction
title?: string
description?: string
}
| {
back?: undefined
title?: never
description?: never
}
)

export function SettingsPanel({
children,
actions,
Expand Down
Loading