Skip to content

Commit 1d4c004

Browse files
authored
fix(sidebar): unify navigation menus and stabilize hydration (#7791)
* fix(sidebar): unify navigation menus and stabilize hydration * fix(sidebar): simplify organization profile controls
1 parent 9d7557a commit 1d4c004

19 files changed

Lines changed: 779 additions & 639 deletions

File tree

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

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,27 @@ 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+
},
1114
}))
1215

1316
vi.mock('next/navigation', () => ({
1417
useRouter: () => ({ push: mockPush }),
18+
usePathname: () => '/o/org-1/home',
1519
}))
1620
vi.mock('next/link', () => ({
1721
default: ({
1822
onNavigate,
23+
prefetch: _prefetch,
1924
...props
20-
}: ComponentProps<'a'> & { onNavigate?: (event: { preventDefault: () => void }) => void }) => (
25+
}: ComponentProps<'a'> & {
26+
prefetch?: boolean
27+
onNavigate?: (event: { preventDefault: () => void }) => void
28+
}) => (
2129
<a
2230
{...props}
2331
href={props.href}
@@ -34,6 +42,7 @@ vi.mock('next/link', () => ({
3442
/>
3543
),
3644
}))
45+
vi.mock('@/lib/auth/sign-out', () => ({ signOutAndRedirect: vi.fn() }))
3746
vi.mock('@/lib/desktop', () => ({ getDesktopUpdates: () => null }))
3847
vi.mock('@/hooks/use-desktop-update-state', () => ({
3948
useDesktopUpdateState: () => ({ status: 'idle' }),
@@ -42,12 +51,14 @@ vi.mock('@/hooks/queries/user-profile', () => ({
4251
useUserProfile: () => ({ data: { id: 'user-1', name: 'Ada', email: 'ada@example.com' } }),
4352
}))
4453
vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => ({
45-
useOrganizationContext: () => ({ organization: { id: 'org-1' } }),
54+
useOrganizationContext: () => context,
4655
}))
4756
vi.mock('@/app/workspace/[workspaceId]/w/components/sidebar/components', () => ({
4857
SidebarTooltip: ({ children }: { children: React.ReactNode }) => children,
4958
}))
50-
vi.mock('@/components/icons', () => ({ SlackIcon: () => <svg /> }))
59+
vi.mock('@/components/icons', () => ({
60+
SlackIcon: () => <svg />,
61+
}))
5162

5263
import { OrganizationFooter } from '@/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer'
5364
import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
@@ -71,7 +82,7 @@ afterEach(async () => {
7182
vi.unstubAllGlobals()
7283
})
7384

74-
async function selectSettings() {
85+
async function openProfileMenu() {
7586
await act(async () => {
7687
root.render(
7788
<OrganizationFooter
@@ -80,6 +91,7 @@ async function selectSettings() {
8091
showCollapsedTooltips={false}
8192
onOpenDocs={() => {}}
8293
onJoinSlack={() => {}}
94+
onContactSupport={() => {}}
8395
/>
8496
)
8597
})
@@ -88,15 +100,27 @@ async function selectSettings() {
88100
await act(async () => {
89101
trigger.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 }))
90102
})
103+
}
104+
105+
async function selectSettings() {
106+
await openProfileMenu()
91107
const link = document.querySelector<HTMLAnchorElement>('a[href="/o/org-1/settings/general"]')
92108
if (!link) throw new Error('Settings link is missing')
93109
await act(async () => link.click())
94110
}
95111

96112
describe('OrganizationFooter settings navigation', () => {
113+
it('keeps only Settings and Sign out in the organization profile menu', async () => {
114+
await openProfileMenu()
115+
expect(
116+
[...document.querySelectorAll('[role="menuitem"]')].map((item) => item.textContent)
117+
).toEqual(['Settings', 'Sign out'])
118+
expect(document.querySelector('[role="separator"]')).toBeNull()
119+
})
120+
97121
it('navigates immediately when settings are clean', async () => {
98122
await selectSettings()
99-
expect(mockNavigate).toHaveBeenCalledWith('/o/org-1/settings/general')
123+
expect(mockPush).toHaveBeenCalledWith('/o/org-1/settings/general')
100124
expect(useSettingsDirtyStore.getState().pendingLeave).toBeNull()
101125
})
102126

Lines changed: 17 additions & 231 deletions
Original file line numberDiff line numberDiff line change
@@ -1,242 +1,28 @@
11
'use client'
22

3-
import type { DesktopUpdateState } from '@sim/desktop-bridge'
4-
import {
5-
Chip,
6-
chipContentLabelClass,
7-
chipPrimaryFillTokens,
8-
chipVariants,
9-
cn,
10-
DropdownMenu,
11-
DropdownMenuContent,
12-
DropdownMenuItem,
13-
DropdownMenuItemLabel,
14-
DropdownMenuSeparator,
15-
DropdownMenuTrigger,
16-
OverflowText,
17-
Skeleton,
18-
} from '@sim/emcn'
19-
import { BookOpen, Download, HelpCircle, Settings } from '@sim/emcn/icons'
20-
import { SlackIcon } from '@/components/icons'
21-
import { SettingsGuardedLink } from '@/components/settings/settings-guarded-link'
22-
import { getDesktopUpdates } from '@/lib/desktop'
3+
import type { ComponentProps } from 'react'
4+
import { useRouter } from 'next/navigation'
235
import { organizationRoutes } from '@/lib/navigation/paths'
24-
import { getUserColor } from '@/lib/workspaces/colors'
256
import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
26-
import { SidebarTooltip } from '@/app/workspace/[workspaceId]/w/components/sidebar/components'
27-
import {
28-
SIDEBAR_ITEM_GAP_CLASS,
29-
SIDEBAR_RAIL_CHIP_CLASS,
30-
} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
31-
import { useUserProfile } from '@/hooks/queries/user-profile'
32-
import { useDesktopUpdateState } from '@/hooks/use-desktop-update-state'
7+
import { SidebarFooter } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer'
338

34-
function hasAvailableDesktopUpdate(state: DesktopUpdateState): boolean {
35-
return state.status === 'available' || state.status === 'downloading' || state.status === 'ready'
36-
}
37-
38-
function desktopUpdateActionLabel(state: DesktopUpdateState): string {
39-
if (state.status === 'downloading') {
40-
return state.percent === undefined
41-
? 'Downloading update…'
42-
: `Downloading update ${state.percent}%`
43-
}
44-
return state.status === 'ready' ? 'Restart to update' : 'Update'
45-
}
46-
47-
/** Compact primary update circle using the same footprint as the surrounding sidebar icons. */
48-
function DesktopUpdateIcon({ className }: { className?: string }) {
49-
return (
50-
<div
51-
className={cn(
52-
className,
53-
'flex size-[17px] shrink-0 items-center justify-center rounded-full',
54-
chipPrimaryFillTokens
55-
)}
56-
>
57-
{/* Download's default viewBox is asymmetric around its paths. Center the
58-
artwork itself, not merely its SVG box, inside the avatar-sized circle. */}
59-
<Download className='size-[11px]' viewBox='-1.75 -1.75 24 24' />
60-
</div>
61-
)
62-
}
9+
interface OrganizationFooterProps
10+
extends Omit<
11+
ComponentProps<typeof SidebarFooter>,
12+
'accountSettingsHref' | 'onOpenAccountSettings' | 'navigationLinks'
13+
> {}
6314

64-
interface OrganizationFooterProps {
65-
/**
66-
* True while the scroll region above still hides rows beyond its bottom edge —
67-
* the same test the divider under the pinned nav applies at the top. The bar's
68-
* top rule is drawn only then, so a list that fits meets the footer with no line.
69-
*/
70-
showDivider: boolean
71-
isCollapsed: boolean
72-
showCollapsedTooltips: boolean
73-
onOpenDocs: () => void
74-
onJoinSlack: () => void
75-
}
76-
77-
/**
78-
* Pinned bottom bar of the organization sidebar: the viewer's avatar and name,
79-
* which open their account settings, plus a help menu. Same two elements and the
80-
* same layout as the workspace footer — expanded they share one row with help hard
81-
* right, collapsed they stack as icon chips with help on top.
82-
*
83-
* Collapsed reverses the flex direction instead of reordering the DOM, which keeps
84-
* both elements (and the help menu's trigger) alive across a toggle.
85-
*/
86-
export function OrganizationFooter({
87-
showDivider,
88-
isCollapsed,
89-
showCollapsedTooltips,
90-
onOpenDocs,
91-
onJoinSlack,
92-
}: OrganizationFooterProps) {
15+
export function OrganizationFooter(props: OrganizationFooterProps) {
9316
const { organization } = useOrganizationContext()
94-
const { data: profile } = useUserProfile()
95-
const updateState = useDesktopUpdateState()
96-
97-
const name = profile ? profile.name?.trim() || profile.email : ''
98-
const updateAvailable = hasAvailableDesktopUpdate(updateState)
99-
100-
const handleUpdateSelect = () => {
101-
const updates = getDesktopUpdates()
102-
if (updateState.status === 'ready') {
103-
updates?.install()
104-
} else if (updateState.status === 'available') {
105-
updates?.check()
106-
}
107-
}
108-
109-
/**
110-
* Plain `img`/`div` rather than the emcn `Avatar`, whose Radix root renders a
111-
* `<span>` — and globals fade every `span` in the collapsed rail to `opacity: 0`,
112-
* which would blank the avatar exactly where it is the only thing left to see.
113-
*/
114-
const avatar = !profile ? (
115-
<Skeleton className='size-[16px] shrink-0 rounded-full' />
116-
) : profile.image ? (
117-
<img
118-
src={profile.image}
119-
alt=''
120-
referrerPolicy='no-referrer'
121-
className='size-[16px] shrink-0 rounded-full object-cover'
122-
/>
123-
) : (
124-
<div
125-
className='flex size-[16px] shrink-0 items-center justify-center rounded-full text-[9px] text-white leading-none'
126-
style={{ backgroundColor: getUserColor(profile.id) }}
127-
>
128-
{name.charAt(0).toUpperCase()}
129-
</div>
130-
)
131-
132-
/**
133-
* Expanded, the chip hugs its content (`max-w-full` so a long name truncates
134-
* rather than overflowing); collapsed, `fullWidth` fills the narrow rail and
135-
* `min-w-0` lets the hidden label give up its box so the chip never overflows it.
136-
* The name is the button's accessible name — no `aria-label`, which would
137-
* override the visible text.
138-
*/
139-
const profileMenu = (
140-
<DropdownMenu>
141-
<SidebarTooltip label={name} enabled={showCollapsedTooltips && Boolean(name)}>
142-
<DropdownMenuTrigger asChild>
143-
<button
144-
type='button'
145-
data-item-id='profile'
146-
className={cn(
147-
chipVariants({ fullWidth: isCollapsed }),
148-
isCollapsed ? 'min-w-0' : 'max-w-full',
149-
SIDEBAR_RAIL_CHIP_CLASS
150-
)}
151-
>
152-
{avatar}
153-
{profile ? (
154-
<OverflowText
155-
label={name}
156-
className={cn('sidebar-collapse-hide flex-1', chipContentLabelClass)}
157-
tooltipEnabled={!isCollapsed && !showCollapsedTooltips}
158-
focusTarget='nearest-interactive'
159-
/>
160-
) : (
161-
/* Fixed width — the chip hugs its content, so a flexible bar would collapse to nothing. */
162-
<Skeleton className='sidebar-collapse-hide h-[14px] w-[96px] rounded-sm' />
163-
)}
164-
</button>
165-
</DropdownMenuTrigger>
166-
</SidebarTooltip>
167-
<DropdownMenuContent align='start' side='top' sideOffset={4}>
168-
<DropdownMenuItem asChild>
169-
<SettingsGuardedLink
170-
href={organizationRoutes(organization.id).settingsSection('general')}
171-
>
172-
<Settings className='size-[14px]' />
173-
<DropdownMenuItemLabel label='Settings' />
174-
</SettingsGuardedLink>
175-
</DropdownMenuItem>
176-
</DropdownMenuContent>
177-
</DropdownMenu>
178-
)
179-
180-
/**
181-
* One node across both states; only `fullWidth` changes, so the same Radix menu
182-
* survives the transition. `shrink-0` keeps the chip off the avatar while the rail
183-
* is briefly narrower than the row — the aside's clip hides it until there is room.
184-
*/
185-
const helpMenu = (
186-
<DropdownMenu>
187-
<SidebarTooltip
188-
label={updateAvailable ? 'Help — update available' : 'Help'}
189-
enabled={showCollapsedTooltips}
190-
>
191-
<DropdownMenuTrigger asChild>
192-
<Chip
193-
data-item-id='help'
194-
aria-label={updateAvailable ? 'Help, update available' : 'Help'}
195-
leftIcon={updateAvailable ? DesktopUpdateIcon : HelpCircle}
196-
fullWidth={isCollapsed}
197-
className={cn('shrink-0', SIDEBAR_RAIL_CHIP_CLASS)}
198-
/>
199-
</DropdownMenuTrigger>
200-
</SidebarTooltip>
201-
{/* Anchored to whichever edge the trigger sits on, so the menu never overhangs the rail. */}
202-
<DropdownMenuContent align={isCollapsed ? 'start' : 'end'} side='top' sideOffset={4}>
203-
{updateAvailable && (
204-
<>
205-
<DropdownMenuItem
206-
onSelect={handleUpdateSelect}
207-
disabled={updateState.status === 'downloading'}
208-
>
209-
<img src='/favicon/favicon-32x32.png' alt='' className='size-[14px] rounded-[3px]' />
210-
{desktopUpdateActionLabel(updateState)}
211-
</DropdownMenuItem>
212-
<DropdownMenuSeparator />
213-
</>
214-
)}
215-
<DropdownMenuItem onSelect={onOpenDocs}>
216-
<BookOpen className='size-[14px]' />
217-
Docs
218-
</DropdownMenuItem>
219-
<DropdownMenuItem onSelect={onJoinSlack}>
220-
<SlackIcon className='size-[14px]' />
221-
Join Slack
222-
</DropdownMenuItem>
223-
</DropdownMenuContent>
224-
</DropdownMenu>
225-
)
17+
const router = useRouter()
18+
const accountSettingsHref = organizationRoutes(organization.id).settingsSection('general')
22619

22720
return (
228-
<div
229-
className={cn(
230-
'flex shrink-0 border-t px-2 pt-[9px] pb-2 transition-colors duration-150',
231-
!showDivider && 'border-transparent',
232-
isCollapsed ? cn(SIDEBAR_ITEM_GAP_CLASS, 'flex-col-reverse') : 'items-center'
233-
)}
234-
>
235-
{/* Expanded, claims the row's free width so the help button lands hard right.
236-
`flex` makes the inline-flex chip a flex item, so the wrapper is exactly the
237-
chip's 30px rather than a line box padded by the strut's half-leading. */}
238-
<div className={cn('flex min-w-0', !isCollapsed && 'flex-1')}>{profileMenu}</div>
239-
{helpMenu}
240-
</div>
21+
<SidebarFooter
22+
{...props}
23+
accountSettingsHref={accountSettingsHref}
24+
onOpenAccountSettings={() => router.push(accountSettingsHref)}
25+
navigationLinks={[]}
26+
/>
24127
)
24228
}

0 commit comments

Comments
 (0)