Skip to content

Commit bf49c0b

Browse files
committed
fix(sidebar): unify navigation menus and stabilize hydration
1 parent 29c604d commit bf49c0b

19 files changed

Lines changed: 839 additions & 640 deletions

File tree

apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer.test.tsx

Lines changed: 78 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,36 @@ import { act, type ComponentProps } from 'react'
55
import { createRoot, type Root } from 'react-dom/client'
66
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
77

8-
const { mockNavigate, mockPush } = vi.hoisted(() => ({
8+
const { mockNavigate, mockPush, context } = vi.hoisted(() => ({
99
mockNavigate: vi.fn(),
1010
mockPush: vi.fn(),
11+
context: {
12+
organization: { id: 'org-1' },
13+
viewer: { isAdmin: true },
14+
settingsFeatures: {
15+
billingEnabled: true,
16+
hasEnterprisePlan: false,
17+
hosted: true,
18+
selfHosted: {},
19+
},
20+
connectedAccountsAvailable: true,
21+
searchAccess: { memberScoped: true },
22+
},
1123
}))
1224

1325
vi.mock('next/navigation', () => ({
1426
useRouter: () => ({ push: mockPush }),
27+
usePathname: () => '/o/org-1/home',
1528
}))
1629
vi.mock('next/link', () => ({
1730
default: ({
1831
onNavigate,
32+
prefetch: _prefetch,
1933
...props
20-
}: ComponentProps<'a'> & { onNavigate?: (event: { preventDefault: () => void }) => void }) => (
34+
}: ComponentProps<'a'> & {
35+
prefetch?: boolean
36+
onNavigate?: (event: { preventDefault: () => void }) => void
37+
}) => (
2138
<a
2239
{...props}
2340
href={props.href}
@@ -34,6 +51,10 @@ vi.mock('next/link', () => ({
3451
/>
3552
),
3653
}))
54+
vi.mock('@/lib/auth/sign-out', () => ({ signOutAndRedirect: vi.fn() }))
55+
vi.mock('@/lib/auth/auth-client', () => ({
56+
useSession: () => ({ data: { user: { id: 'user-1' } } }),
57+
}))
3758
vi.mock('@/lib/desktop', () => ({ getDesktopUpdates: () => null }))
3859
vi.mock('@/hooks/use-desktop-update-state', () => ({
3960
useDesktopUpdateState: () => ({ status: 'idle' }),
@@ -42,12 +63,16 @@ vi.mock('@/hooks/queries/user-profile', () => ({
4263
useUserProfile: () => ({ data: { id: 'user-1', name: 'Ada', email: 'ada@example.com' } }),
4364
}))
4465
vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => ({
45-
useOrganizationContext: () => ({ organization: { id: 'org-1' } }),
66+
useOrganizationContext: () => context,
4667
}))
4768
vi.mock('@/app/workspace/[workspaceId]/w/components/sidebar/components', () => ({
4869
SidebarTooltip: ({ children }: { children: React.ReactNode }) => children,
4970
}))
50-
vi.mock('@/components/icons', () => ({ SlackIcon: () => <svg /> }))
71+
vi.mock('@/components/icons', () => ({
72+
CodeIcon: () => <svg />,
73+
McpIcon: () => <svg />,
74+
SlackIcon: () => <svg />,
75+
}))
5176

5277
import { OrganizationFooter } from '@/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer'
5378
import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
@@ -58,6 +83,8 @@ let root: Root
5883
beforeEach(() => {
5984
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
6085
vi.clearAllMocks()
86+
context.viewer.isAdmin = true
87+
context.settingsFeatures.billingEnabled = true
6188
useSettingsDirtyStore.getState().reset()
6289
container = document.createElement('div')
6390
document.body.appendChild(container)
@@ -71,7 +98,7 @@ afterEach(async () => {
7198
vi.unstubAllGlobals()
7299
})
73100

74-
async function selectSettings() {
101+
async function openProfileMenu() {
75102
await act(async () => {
76103
root.render(
77104
<OrganizationFooter
@@ -80,6 +107,7 @@ async function selectSettings() {
80107
showCollapsedTooltips={false}
81108
onOpenDocs={() => {}}
82109
onJoinSlack={() => {}}
110+
onContactSupport={() => {}}
83111
/>
84112
)
85113
})
@@ -88,15 +116,59 @@ async function selectSettings() {
88116
await act(async () => {
89117
trigger.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 }))
90118
})
119+
}
120+
121+
async function selectSettings() {
122+
await openProfileMenu()
91123
const link = document.querySelector<HTMLAnchorElement>('a[href="/o/org-1/settings/general"]')
92124
if (!link) throw new Error('Settings link is missing')
93125
await act(async () => link.click())
94126
}
95127

96128
describe('OrganizationFooter settings navigation', () => {
129+
it('preserves unsaved settings when opening a shortcut', async () => {
130+
useSettingsDirtyStore.getState().setDirty(true)
131+
await openProfileMenu()
132+
const members = document.querySelector<HTMLAnchorElement>('a[href="/o/org-1/settings/members"]')
133+
if (!members) throw new Error('Members shortcut is missing')
134+
await act(async () => members.click())
135+
expect(mockPush).not.toHaveBeenCalled()
136+
act(() => useSettingsDirtyStore.getState().confirmLeave())
137+
expect(mockPush).toHaveBeenCalledWith('/o/org-1/settings/members')
138+
})
139+
140+
it.each([
141+
{ isAdmin: true, billingEnabled: true, showBilling: true },
142+
{ isAdmin: false, billingEnabled: true, showBilling: false },
143+
{ isAdmin: true, billingEnabled: false, showBilling: false },
144+
])(
145+
'matches settings visibility for $isAdmin admin, $billingEnabled billing',
146+
async ({ isAdmin, billingEnabled, showBilling }) => {
147+
context.viewer.isAdmin = isAdmin
148+
context.settingsFeatures.billingEnabled = billingEnabled
149+
await openProfileMenu()
150+
expect(
151+
[...document.querySelectorAll('[role="menuitem"]')].map((item) => item.textContent)
152+
).toEqual([
153+
'Settings',
154+
...(showBilling ? ['Subscription'] : []),
155+
'Members',
156+
'Recently deleted',
157+
'Sign out',
158+
])
159+
expect(document.querySelector('[role="separator"]')).toBeNull()
160+
const members = document.querySelector<HTMLAnchorElement>(
161+
'a[href="/o/org-1/settings/members"]'
162+
)
163+
if (!members) throw new Error('Members shortcut is missing')
164+
await act(async () => members.click())
165+
expect(mockPush).toHaveBeenCalledWith('/o/org-1/settings/members')
166+
}
167+
)
168+
97169
it('navigates immediately when settings are clean', async () => {
98170
await selectSettings()
99-
expect(mockNavigate).toHaveBeenCalledWith('/o/org-1/settings/general')
171+
expect(mockPush).toHaveBeenCalledWith('/o/org-1/settings/general')
100172
expect(useSettingsDirtyStore.getState().pendingLeave).toBeNull()
101173
})
102174

0 commit comments

Comments
 (0)