Skip to content

Commit 64bce6a

Browse files
authored
fix(sidebar): change organization logos from the icon (#7785)
Make the organization logo the upload control while preserving the existing permission checks, keyboard access, and upload lifecycle.
1 parent 2be2670 commit 64bce6a

2 files changed

Lines changed: 87 additions & 25 deletions

File tree

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

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -49,16 +49,16 @@ afterEach(async () => {
4949
vi.unstubAllGlobals()
5050
})
5151

52-
async function render(canEditLogo = true) {
52+
async function render(canEditLogo = true, isCollapsed = false, onExpandSidebar = vi.fn()) {
5353
await act(async () => {
5454
root.render(
5555
<QueryClientProvider client={queryClient}>
5656
<ToastProvider>
5757
<OrganizationHeader
5858
organization={organization}
5959
canEditLogo={canEditLogo}
60-
isCollapsed={false}
61-
onExpandSidebar={vi.fn()}
60+
isCollapsed={isCollapsed}
61+
onExpandSidebar={onExpandSidebar}
6262
/>
6363
</ToastProvider>
6464
</QueryClientProvider>
@@ -74,9 +74,9 @@ async function openMenu() {
7474
})
7575
}
7676

77-
function menuItem(name: string) {
78-
return Array.from(document.querySelectorAll<HTMLElement>('[role="menuitem"]')).find(
79-
(item) => item.textContent === name
77+
function logoControl() {
78+
return document.querySelector<HTMLElement>(
79+
'[role="menuitem"][aria-label="Change organization logo"]'
8080
)
8181
}
8282

@@ -87,21 +87,70 @@ async function pickFile(file: File) {
8787
}
8888

8989
describe('OrganizationHeader logo upload', () => {
90-
it('opens the same native file picker from the admin menu', async () => {
90+
it('opens the native file picker by clicking the logo and keeps the menu open', async () => {
9191
await render()
9292
await openMenu()
9393
const input = container.querySelector<HTMLInputElement>('input[type="file"]')!
9494
const click = vi.spyOn(input, 'click').mockImplementation(() => {})
9595
expect(input.accept).toContain('image/png')
96-
await act(async () => menuItem('Upload logo')!.click())
96+
await act(async () => logoControl()!.click())
97+
expect(click).toHaveBeenCalledOnce()
98+
expect(logoControl()).not.toBeNull()
99+
expect(document.body.textContent).not.toContain('Upload logo')
100+
expect(document.querySelector('[role="menu"]')?.textContent).toContain('Settings')
101+
})
102+
103+
it.each(['Enter', ' '])('opens the file picker using the %j key', async (key) => {
104+
await render()
105+
await openMenu()
106+
const input = container.querySelector<HTMLInputElement>('input[type="file"]')!
107+
const click = vi.spyOn(input, 'click').mockImplementation(() => {})
108+
await act(async () => {
109+
logoControl()!.focus()
110+
logoControl()!.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }))
111+
})
97112
expect(click).toHaveBeenCalledOnce()
98113
})
99114

100115
it('does not offer logo changes to members', async () => {
101116
await render(false)
102117
await openMenu()
103-
expect(menuItem('Upload logo')).toBeUndefined()
118+
expect(logoControl()).toBeNull()
119+
expect(container.querySelector('input[type="file"]')).toBeNull()
120+
expect(document.querySelector('[role="menu"]')?.textContent).toContain('Design')
121+
})
122+
123+
it('preserves the collapsed logo as the sidebar expand control', async () => {
124+
const expand = vi.fn()
125+
await render(true, true, expand)
126+
await act(async () => {
127+
container.querySelector<HTMLButtonElement>('[aria-label="Expand sidebar"]')!.click()
128+
})
129+
expect(expand).toHaveBeenCalledOnce()
104130
expect(container.querySelector('input[type="file"]')).toBeNull()
131+
expect(mocks.upload).not.toHaveBeenCalled()
132+
})
133+
134+
it('disables logo changes while the upload is pending', async () => {
135+
let completeUpload!: () => void
136+
mocks.upload.mockImplementation(
137+
() =>
138+
new Promise<void>((resolve) => {
139+
completeUpload = resolve
140+
})
141+
)
142+
await render()
143+
await openMenu()
144+
await pickFile(new File(['image'], 'logo.png', { type: 'image/png' }))
145+
await act(async () => {
146+
await vi.waitFor(() => expect(logoControl()?.getAttribute('aria-disabled')).toBe('true'))
147+
})
148+
expect(logoControl()?.getAttribute('aria-busy')).toBe('true')
149+
expect(container.querySelector<HTMLInputElement>('input[type="file"]')!.disabled).toBe(true)
150+
await act(async () => logoControl()!.click())
151+
expect(mocks.upload).toHaveBeenCalledOnce()
152+
await act(async () => completeUpload())
153+
expect(mocks.refresh).toHaveBeenCalledOnce()
105154
})
106155

107156
it('uploads under the organization scope and refreshes its identity after success', async () => {

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

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@ import {
99
DropdownMenuItem,
1010
DropdownMenuTrigger,
1111
OverflowText,
12+
Tooltip,
1213
toast,
1314
} from '@sim/emcn'
14-
import { PanelLeft, Settings, Upload } from '@sim/emcn/icons'
15+
import { PanelLeft, Settings } from '@sim/emcn/icons'
1516
import { useRouter } from 'next/navigation'
1617
import { IdentityTile } from '@/components/identity-tile/identity-tile'
1718
import { getOrganizationSettingsHref } from '@/components/settings/navigation'
@@ -80,6 +81,9 @@ export function OrganizationHeader({
8081
}
8182

8283
const { memberCount } = organization
84+
const logo = (
85+
<IdentityTile size='lg' initial={initial} logoUrl={organization.logo} alt={organization.name} />
86+
)
8387

8488
return (
8589
<div className='min-w-0 flex-1'>
@@ -120,28 +124,37 @@ export function OrganizationHeader({
120124
className='w-64 max-w-[calc(100vw-24px)]'
121125
>
122126
<div className='flex items-center gap-2 px-2 py-1.5'>
123-
<IdentityTile
124-
size='lg'
125-
initial={initial}
126-
logoUrl={organization.logo}
127-
alt={organization.name}
128-
/>
127+
{canEditLogo ? (
128+
<Tooltip.Root>
129+
<Tooltip.Trigger asChild>
130+
<DropdownMenuItem
131+
aria-label='Change organization logo'
132+
aria-busy={isUploadingLogo}
133+
textValue='Change organization logo'
134+
className='h-auto shrink-0 p-1'
135+
disabled={isUploadingLogo}
136+
onSelect={(event) => {
137+
event.preventDefault()
138+
fileInputRef.current?.click()
139+
}}
140+
>
141+
{logo}
142+
</DropdownMenuItem>
143+
</Tooltip.Trigger>
144+
<Tooltip.Content>
145+
{isUploadingLogo ? 'Uploading...' : 'Change logo'}
146+
</Tooltip.Content>
147+
</Tooltip.Root>
148+
) : (
149+
logo
150+
)}
129151
<div className='flex min-w-0 flex-col'>
130152
<OverflowText label={organization.name} />
131153
<span className='text-[var(--text-muted)] text-caption'>
132154
{memberCount} {memberCount === 1 ? 'member' : 'members'}
133155
</span>
134156
</div>
135157
</div>
136-
{canEditLogo && (
137-
<DropdownMenuItem
138-
disabled={isUploadingLogo}
139-
onSelect={() => fileInputRef.current?.click()}
140-
>
141-
<Upload className='size-[14px]' />
142-
{isUploadingLogo ? 'Uploading...' : 'Upload logo'}
143-
</DropdownMenuItem>
144-
)}
145158
<DropdownMenuItem asChild>
146159
<SettingsGuardedLink href={getOrganizationSettingsHref(organization.id, 'members')}>
147160
<Settings className='size-[14px]' />

0 commit comments

Comments
 (0)