mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 18:05:11 +03:00
## What kind of change does this PR introduce? Bug fix. Resolves [FE-4192](https://linear.app/supabase/issue/FE-4192/org-and-project-selectors-sometimes-dont-register-selections). ## What is the current behavior? Navigation actions sometimes nest links inside command or dropdown menu items. Closing the menu during selection can prevent the nested link navigation from registering. ## What is the new behavior? - Adds a documented Studio CommandItemLink composition that wraps command items with their navigation link. - Migrates all Studio command-item links, including organisation, project, function, database, branch, and integration actions. - Uses the dropdown menu asChild composition for both infrastructure-diagram Manage replica actions. - Preserves native link behaviour and leaves disabled command items non-navigable. ## To test - [ ] [Organisation and project selectors](https://studio-staging-git-dnywh-fe-4192-selector-links-supabase.vercel.app/dashboard/org): open the organisation selector and try an organisation, All Organizations, and New organization. Open a project, then use the project selector to switch projects and open New project. Confirm every action navigates on the first click. - [ ] [Branch selector](https://studio-staging-git-dnywh-fe-4192-selector-links-supabase.vercel.app/dashboard/project/_): in a project with branching enabled, open the branch selector. Switch branches and select Manage branches. Confirm both navigate on the first click. - [ ] [Database selector](https://studio-staging-git-dnywh-fe-4192-selector-links-supabase.vercel.app/dashboard/project/_/observability/query-performance): open the Source selector. Switch between the primary database and a read replica if available, then select Create a new read replica. Confirm selections apply and the footer action navigates on the first click. - [ ] [Function selector](https://studio-staging-git-dnywh-fe-4192-selector-links-supabase.vercel.app/dashboard/project/_/auth/hooks): select Add a new hook, choose a hook, select Postgres, then open the Postgres function selector and select New function. Confirm it navigates on the first click. - [ ] [Infrastructure diagram](https://studio-staging-git-dnywh-fe-4192-selector-links-supabase.vercel.app/dashboard/project/_/settings/infrastructure): for a project with a read replica, select Manage replica from both diagram variants. Confirm the replica settings open on the first click. - [ ] On any navigational row above, modifier-click and confirm native link behaviour is preserved. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added consistent link navigation across organization, project, branch, function, replica, and integration menus. * Added project-specific destinations to organization and project selectors. * Preserved disabled-item behavior while improving accessible command-menu link semantics. * **Bug Fixes** * Improved navigation and menu-closing behavior for command items and dropdown actions. * **Tests** * Added coverage for link destinations, accessibility roles, disabled states, and route preservation. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
194 lines
5.7 KiB
TypeScript
194 lines
5.7 KiB
TypeScript
import { screen } from '@testing-library/react'
|
|
import type { ComponentProps } from 'react'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import { ProjectDropdown } from './ProjectDropdown'
|
|
import type { OrganizationProjectSelector } from '@/components/ui/OrganizationProjectSelector'
|
|
import { MANAGED_BY } from '@/lib/constants/infrastructure'
|
|
import { createMockOrganization, render } from '@/tests/helpers'
|
|
|
|
type OrganizationProjectSelectorProps = ComponentProps<typeof OrganizationProjectSelector>
|
|
|
|
const { mockSelectedOrganization, mockSelectedProject, mockSelectorProject, mockSelectorProps } =
|
|
vi.hoisted(() => ({
|
|
mockSelectedOrganization: vi.fn(),
|
|
mockSelectedProject: vi.fn(),
|
|
mockSelectorProject: vi.fn(),
|
|
mockSelectorProps: vi.fn(),
|
|
}))
|
|
|
|
const mockPush = vi.hoisted(() => vi.fn())
|
|
|
|
vi.mock('common', async (importOriginal) => {
|
|
const original = (await importOriginal()) as typeof import('common')
|
|
return {
|
|
...original,
|
|
useParams: () => ({ ref: 'proj_1' }),
|
|
}
|
|
})
|
|
|
|
vi.mock('next/router', () => ({
|
|
useRouter: () => ({
|
|
route: '/project/[ref]/settings',
|
|
query: { ref: 'proj_1' },
|
|
push: mockPush,
|
|
}),
|
|
}))
|
|
|
|
vi.mock('@/lib/constants', async (importOriginal) => {
|
|
const original = (await importOriginal()) as typeof import('@/lib/constants')
|
|
return { ...original, IS_PLATFORM: true }
|
|
})
|
|
|
|
vi.mock('@/hooks/misc/useSelectedProject', () => ({
|
|
useSelectedProjectQuery: () => ({
|
|
data: mockSelectedProject(),
|
|
isPending: false,
|
|
}),
|
|
}))
|
|
|
|
vi.mock('@/data/projects/project-detail-query', () => ({
|
|
useProjectDetailQuery: () => ({ data: undefined, isPending: false }),
|
|
}))
|
|
|
|
vi.mock('@/hooks/misc/useSelectedOrganization', () => ({
|
|
useSelectedOrganizationQuery: () => ({ data: mockSelectedOrganization() }),
|
|
}))
|
|
|
|
vi.mock('@/hooks/misc/useIsFeatureEnabled', () => ({
|
|
useIsFeatureEnabled: () => false,
|
|
}))
|
|
|
|
vi.mock('@/components/ui/OrganizationProjectSelector', () => ({
|
|
OrganizationProjectSelector: (props: OrganizationProjectSelectorProps) => {
|
|
mockSelectorProps(props)
|
|
return (
|
|
<div>
|
|
<div data-testid="project-selector-trigger">
|
|
{props.renderTrigger?.({
|
|
isLoading: false,
|
|
project: mockSelectorProject(),
|
|
listboxId: 'project-selector-listbox',
|
|
open: false,
|
|
})}
|
|
</div>
|
|
<div data-testid="project-selector-row">{props.renderRow?.(mockSelectorProject())}</div>
|
|
</div>
|
|
)
|
|
},
|
|
}))
|
|
|
|
vi.mock('@/components/ui/PartnerIcon', () => ({
|
|
default: ({ organization }: { organization: { managed_by: string } }) =>
|
|
organization.managed_by === MANAGED_BY.SUPABASE ? null : (
|
|
<div data-testid="partner-icon" data-managed-by={organization.managed_by} />
|
|
),
|
|
}))
|
|
|
|
describe('ProjectDropdown', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mockSelectedOrganization.mockReturnValue(
|
|
createMockOrganization({
|
|
slug: 'supabase-org',
|
|
managed_by: MANAGED_BY.SUPABASE,
|
|
})
|
|
)
|
|
mockSelectedProject.mockReturnValue({
|
|
ref: 'proj_1',
|
|
name: 'Hammer',
|
|
parentRef: 'proj_1',
|
|
parent_project_ref: undefined,
|
|
integration_source: 'stripe_projects',
|
|
})
|
|
mockSelectorProject.mockReturnValue({
|
|
ref: 'proj_1',
|
|
name: 'Hammer',
|
|
status: 'ACTIVE_HEALTHY',
|
|
integration_source: 'stripe_projects',
|
|
})
|
|
})
|
|
|
|
it('uses project detail integration_source for the selected pill and project row data for selector rows', () => {
|
|
mockSelectedOrganization.mockReturnValue(
|
|
createMockOrganization({
|
|
slug: 'stripe-org',
|
|
billing_partner: null,
|
|
integration_source: 'stripe_projects',
|
|
managed_by: MANAGED_BY.STRIPE_PROJECTS,
|
|
})
|
|
)
|
|
|
|
render(<ProjectDropdown />)
|
|
|
|
expect(screen.getAllByTestId('partner-icon')).toHaveLength(2)
|
|
})
|
|
|
|
it('does not fall back to org-level integration state when matching project data is unavailable', () => {
|
|
mockSelectedOrganization.mockReturnValue(
|
|
createMockOrganization({
|
|
slug: 'stripe-org',
|
|
billing_partner: null,
|
|
integration_source: 'stripe_projects',
|
|
managed_by: MANAGED_BY.STRIPE_PROJECTS,
|
|
})
|
|
)
|
|
mockSelectedProject.mockReturnValue({
|
|
ref: 'proj_2',
|
|
name: 'Mallet',
|
|
parentRef: 'proj_2',
|
|
parent_project_ref: undefined,
|
|
integration_source: null,
|
|
})
|
|
mockSelectorProject.mockReturnValue({
|
|
ref: 'proj_2',
|
|
name: 'Mallet',
|
|
status: 'ACTIVE_HEALTHY',
|
|
integration_source: null,
|
|
})
|
|
|
|
render(<ProjectDropdown />)
|
|
|
|
expect(screen.queryAllByTestId('partner-icon')).toHaveLength(0)
|
|
})
|
|
|
|
it('falls back to the organization-level state for partner-billed orgs', () => {
|
|
mockSelectedOrganization.mockReturnValue(
|
|
createMockOrganization({
|
|
slug: 'partner-org',
|
|
billing_partner: 'vercel_marketplace',
|
|
managed_by: MANAGED_BY.VERCEL_MARKETPLACE,
|
|
})
|
|
)
|
|
mockSelectedProject.mockReturnValue({
|
|
ref: 'proj_2',
|
|
name: 'Mallet',
|
|
parentRef: 'proj_2',
|
|
parent_project_ref: undefined,
|
|
integration_source: null,
|
|
})
|
|
mockSelectorProject.mockReturnValue({
|
|
ref: 'proj_2',
|
|
name: 'Mallet',
|
|
status: 'ACTIVE_HEALTHY',
|
|
integration_source: null,
|
|
})
|
|
|
|
render(<ProjectDropdown />)
|
|
|
|
expect(screen.getAllByTestId('partner-icon')).toHaveLength(1)
|
|
expect(screen.getByTestId('partner-icon')).toHaveAttribute(
|
|
'data-managed-by',
|
|
MANAGED_BY.VERCEL_MARKETPLACE
|
|
)
|
|
})
|
|
|
|
it('provides project command items with links that preserve the current route', () => {
|
|
render(<ProjectDropdown />)
|
|
|
|
const selectorProps = mockSelectorProps.mock.lastCall?.[0]
|
|
expect(selectorProps.getItemHref({ ref: 'proj_2' })).toBe('/project/proj_2/settings')
|
|
expect(selectorProps.onSelect).toBeUndefined()
|
|
})
|
|
})
|