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
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,27 @@ import { act, type ComponentProps } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const { mockNavigate, mockPush } = vi.hoisted(() => ({
const { mockNavigate, mockPush, context } = vi.hoisted(() => ({
mockNavigate: vi.fn(),
mockPush: vi.fn(),
context: {
organization: { id: 'org-1' },
},
}))

vi.mock('next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
usePathname: () => '/o/org-1/home',
}))
vi.mock('next/link', () => ({
default: ({
onNavigate,
prefetch: _prefetch,
...props
}: ComponentProps<'a'> & { onNavigate?: (event: { preventDefault: () => void }) => void }) => (
}: ComponentProps<'a'> & {
prefetch?: boolean
onNavigate?: (event: { preventDefault: () => void }) => void
}) => (
<a
{...props}
href={props.href}
Expand All @@ -34,6 +42,7 @@ vi.mock('next/link', () => ({
/>
),
}))
vi.mock('@/lib/auth/sign-out', () => ({ signOutAndRedirect: vi.fn() }))
vi.mock('@/lib/desktop', () => ({ getDesktopUpdates: () => null }))
vi.mock('@/hooks/use-desktop-update-state', () => ({
useDesktopUpdateState: () => ({ status: 'idle' }),
Expand All @@ -42,12 +51,14 @@ vi.mock('@/hooks/queries/user-profile', () => ({
useUserProfile: () => ({ data: { id: 'user-1', name: 'Ada', email: 'ada@example.com' } }),
}))
vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => ({
useOrganizationContext: () => ({ organization: { id: 'org-1' } }),
useOrganizationContext: () => context,
}))
vi.mock('@/app/workspace/[workspaceId]/w/components/sidebar/components', () => ({
SidebarTooltip: ({ children }: { children: React.ReactNode }) => children,
}))
vi.mock('@/components/icons', () => ({ SlackIcon: () => <svg /> }))
vi.mock('@/components/icons', () => ({
SlackIcon: () => <svg />,
}))

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

async function selectSettings() {
async function openProfileMenu() {
await act(async () => {
root.render(
<OrganizationFooter
Expand All @@ -80,6 +91,7 @@ async function selectSettings() {
showCollapsedTooltips={false}
onOpenDocs={() => {}}
onJoinSlack={() => {}}
onContactSupport={() => {}}
/>
)
})
Expand All @@ -88,15 +100,27 @@ async function selectSettings() {
await act(async () => {
trigger.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 }))
})
}

async function selectSettings() {
await openProfileMenu()
const link = document.querySelector<HTMLAnchorElement>('a[href="/o/org-1/settings/general"]')
if (!link) throw new Error('Settings link is missing')
await act(async () => link.click())
}

describe('OrganizationFooter settings navigation', () => {
it('keeps only Settings and Sign out in the organization profile menu', async () => {
await openProfileMenu()
expect(
[...document.querySelectorAll('[role="menuitem"]')].map((item) => item.textContent)
).toEqual(['Settings', 'Sign out'])
expect(document.querySelector('[role="separator"]')).toBeNull()
})

it('navigates immediately when settings are clean', async () => {
await selectSettings()
expect(mockNavigate).toHaveBeenCalledWith('/o/org-1/settings/general')
expect(mockPush).toHaveBeenCalledWith('/o/org-1/settings/general')
expect(useSettingsDirtyStore.getState().pendingLeave).toBeNull()
})

Expand Down
Original file line number Diff line number Diff line change
@@ -1,242 +1,28 @@
'use client'

import type { DesktopUpdateState } from '@sim/desktop-bridge'
import {
Chip,
chipContentLabelClass,
chipPrimaryFillTokens,
chipVariants,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuItemLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
OverflowText,
Skeleton,
} from '@sim/emcn'
import { BookOpen, Download, HelpCircle, Settings } from '@sim/emcn/icons'
import { SlackIcon } from '@/components/icons'
import { SettingsGuardedLink } from '@/components/settings/settings-guarded-link'
import { getDesktopUpdates } from '@/lib/desktop'
import type { ComponentProps } from 'react'
import { useRouter } from 'next/navigation'
import { organizationRoutes } from '@/lib/navigation/paths'
import { getUserColor } from '@/lib/workspaces/colors'
import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
import { SidebarTooltip } from '@/app/workspace/[workspaceId]/w/components/sidebar/components'
import {
SIDEBAR_ITEM_GAP_CLASS,
SIDEBAR_RAIL_CHIP_CLASS,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
import { useUserProfile } from '@/hooks/queries/user-profile'
import { useDesktopUpdateState } from '@/hooks/use-desktop-update-state'
import { SidebarFooter } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer'

function hasAvailableDesktopUpdate(state: DesktopUpdateState): boolean {
return state.status === 'available' || state.status === 'downloading' || state.status === 'ready'
}

function desktopUpdateActionLabel(state: DesktopUpdateState): string {
if (state.status === 'downloading') {
return state.percent === undefined
? 'Downloading update…'
: `Downloading update ${state.percent}%`
}
return state.status === 'ready' ? 'Restart to update' : 'Update'
}

/** Compact primary update circle using the same footprint as the surrounding sidebar icons. */
function DesktopUpdateIcon({ className }: { className?: string }) {
return (
<div
className={cn(
className,
'flex size-[17px] shrink-0 items-center justify-center rounded-full',
chipPrimaryFillTokens
)}
>
{/* Download's default viewBox is asymmetric around its paths. Center the
artwork itself, not merely its SVG box, inside the avatar-sized circle. */}
<Download className='size-[11px]' viewBox='-1.75 -1.75 24 24' />
</div>
)
}
interface OrganizationFooterProps
extends Omit<
ComponentProps<typeof SidebarFooter>,
'accountSettingsHref' | 'onOpenAccountSettings' | 'navigationLinks'
> {}

interface OrganizationFooterProps {
/**
* True while the scroll region above still hides rows beyond its bottom edge —
* the same test the divider under the pinned nav applies at the top. The bar's
* top rule is drawn only then, so a list that fits meets the footer with no line.
*/
showDivider: boolean
isCollapsed: boolean
showCollapsedTooltips: boolean
onOpenDocs: () => void
onJoinSlack: () => void
}

/**
* Pinned bottom bar of the organization sidebar: the viewer's avatar and name,
* which open their account settings, plus a help menu. Same two elements and the
* same layout as the workspace footer — expanded they share one row with help hard
* right, collapsed they stack as icon chips with help on top.
*
* Collapsed reverses the flex direction instead of reordering the DOM, which keeps
* both elements (and the help menu's trigger) alive across a toggle.
*/
export function OrganizationFooter({
showDivider,
isCollapsed,
showCollapsedTooltips,
onOpenDocs,
onJoinSlack,
}: OrganizationFooterProps) {
export function OrganizationFooter(props: OrganizationFooterProps) {
const { organization } = useOrganizationContext()
const { data: profile } = useUserProfile()
const updateState = useDesktopUpdateState()

const name = profile ? profile.name?.trim() || profile.email : ''
const updateAvailable = hasAvailableDesktopUpdate(updateState)

const handleUpdateSelect = () => {
const updates = getDesktopUpdates()
if (updateState.status === 'ready') {
updates?.install()
} else if (updateState.status === 'available') {
updates?.check()
}
}

/**
* Plain `img`/`div` rather than the emcn `Avatar`, whose Radix root renders a
* `<span>` — and globals fade every `span` in the collapsed rail to `opacity: 0`,
* which would blank the avatar exactly where it is the only thing left to see.
*/
const avatar = !profile ? (
<Skeleton className='size-[16px] shrink-0 rounded-full' />
) : profile.image ? (
<img
src={profile.image}
alt=''
referrerPolicy='no-referrer'
className='size-[16px] shrink-0 rounded-full object-cover'
/>
) : (
<div
className='flex size-[16px] shrink-0 items-center justify-center rounded-full text-[9px] text-white leading-none'
style={{ backgroundColor: getUserColor(profile.id) }}
>
{name.charAt(0).toUpperCase()}
</div>
)

/**
* Expanded, the chip hugs its content (`max-w-full` so a long name truncates
* rather than overflowing); collapsed, `fullWidth` fills the narrow rail and
* `min-w-0` lets the hidden label give up its box so the chip never overflows it.
* The name is the button's accessible name — no `aria-label`, which would
* override the visible text.
*/
const profileMenu = (
<DropdownMenu>
<SidebarTooltip label={name} enabled={showCollapsedTooltips && Boolean(name)}>
<DropdownMenuTrigger asChild>
<button
type='button'
data-item-id='profile'
className={cn(
chipVariants({ fullWidth: isCollapsed }),
isCollapsed ? 'min-w-0' : 'max-w-full',
SIDEBAR_RAIL_CHIP_CLASS
)}
>
{avatar}
{profile ? (
<OverflowText
label={name}
className={cn('sidebar-collapse-hide flex-1', chipContentLabelClass)}
tooltipEnabled={!isCollapsed && !showCollapsedTooltips}
focusTarget='nearest-interactive'
/>
) : (
/* Fixed width — the chip hugs its content, so a flexible bar would collapse to nothing. */
<Skeleton className='sidebar-collapse-hide h-[14px] w-[96px] rounded-sm' />
)}
</button>
</DropdownMenuTrigger>
</SidebarTooltip>
<DropdownMenuContent align='start' side='top' sideOffset={4}>
<DropdownMenuItem asChild>
<SettingsGuardedLink
href={organizationRoutes(organization.id).settingsSection('general')}
>
<Settings className='size-[14px]' />
<DropdownMenuItemLabel label='Settings' />
</SettingsGuardedLink>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)

/**
* One node across both states; only `fullWidth` changes, so the same Radix menu
* survives the transition. `shrink-0` keeps the chip off the avatar while the rail
* is briefly narrower than the row — the aside's clip hides it until there is room.
*/
const helpMenu = (
<DropdownMenu>
<SidebarTooltip
label={updateAvailable ? 'Help — update available' : 'Help'}
enabled={showCollapsedTooltips}
>
<DropdownMenuTrigger asChild>
<Chip
data-item-id='help'
aria-label={updateAvailable ? 'Help, update available' : 'Help'}
leftIcon={updateAvailable ? DesktopUpdateIcon : HelpCircle}
fullWidth={isCollapsed}
className={cn('shrink-0', SIDEBAR_RAIL_CHIP_CLASS)}
/>
</DropdownMenuTrigger>
</SidebarTooltip>
{/* Anchored to whichever edge the trigger sits on, so the menu never overhangs the rail. */}
<DropdownMenuContent align={isCollapsed ? 'start' : 'end'} side='top' sideOffset={4}>
{updateAvailable && (
<>
<DropdownMenuItem
onSelect={handleUpdateSelect}
disabled={updateState.status === 'downloading'}
>
<img src='/favicon/favicon-32x32.png' alt='' className='size-[14px] rounded-[3px]' />
{desktopUpdateActionLabel(updateState)}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onSelect={onOpenDocs}>
<BookOpen className='size-[14px]' />
Docs
</DropdownMenuItem>
<DropdownMenuItem onSelect={onJoinSlack}>
<SlackIcon className='size-[14px]' />
Join Slack
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
const router = useRouter()
const accountSettingsHref = organizationRoutes(organization.id).settingsSection('general')

return (
<div
className={cn(
'flex shrink-0 border-t px-2 pt-[9px] pb-2 transition-colors duration-150',
!showDivider && 'border-transparent',
isCollapsed ? cn(SIDEBAR_ITEM_GAP_CLASS, 'flex-col-reverse') : 'items-center'
)}
>
{/* Expanded, claims the row's free width so the help button lands hard right.
`flex` makes the inline-flex chip a flex item, so the wrapper is exactly the
chip's 30px rather than a line box padded by the strut's half-leading. */}
<div className={cn('flex min-w-0', !isCollapsed && 'flex-1')}>{profileMenu}</div>
{helpMenu}
</div>
<SidebarFooter
{...props}
accountSettingsHref={accountSettingsHref}
onOpenAccountSettings={() => router.push(accountSettingsHref)}
navigationLinks={[]}
/>
)
}
Loading
Loading