diff --git a/static/app/components/pageFilters/project/projectPageFilter.spec.tsx b/static/app/components/pageFilters/project/projectPageFilter.spec.tsx index 015a7cd79baf06..2ae5bc0269b8fc 100644 --- a/static/app/components/pageFilters/project/projectPageFilter.spec.tsx +++ b/static/app/components/pageFilters/project/projectPageFilter.spec.tsx @@ -17,7 +17,7 @@ import PageFiltersStore from 'sentry/components/pageFilters/store'; import {OrganizationStore} from 'sentry/stores/organizationStore'; import {ProjectsStore} from 'sentry/stores/projectsStore'; -const organization = OrganizationFixture({features: ['open-membership']}); +const organization = OrganizationFixture(); const projects = [ ProjectFixture({id: '1', slug: 'project-1', isMember: true}), ProjectFixture({id: '2', slug: 'project-2', isMember: true}), @@ -773,9 +773,18 @@ describe('ProjectPageFilter', () => { expect(within(projectRows[2]!).getByText('regular-project-a')).toBeInTheDocument(); expect(within(projectRows[3]!).getByText('regular-project-b')).toBeInTheDocument(); + // Navigate via keyboard to regular-project-a to make the Bookmark button visible. + // (hover is unreliable across tests due to shared pointer state in userEvent) + await waitFor(() => expect(screen.getByPlaceholderText('Search…')).toHaveFocus()); + // No sentinel items (all 4 projects are members), so: + // ArrowDown x1 → selected-project, x2 → already-bookmarked, x3 → regular-project-a + await userEvent.keyboard('{ArrowDown}'); + await userEvent.keyboard('{ArrowDown}'); + await userEvent.keyboard('{ArrowDown}'); + // Bookmark regular-project-a while menu is open const regularProjectARow = screen.getByRole('row', {name: 'regular-project-a'}); - await userEvent.hover(regularProjectARow); + expect(regularProjectARow).toHaveFocus(); const bookmarkButton = within(regularProjectARow).getByRole('button', { name: 'Bookmark', }); @@ -859,18 +868,120 @@ describe('ProjectPageFilter', () => { }); }); - describe('closed-membership org defaults', () => { - let closedOrg: ReturnType; + describe('open-membership org defaults', () => { + const openOrg = OrganizationFixture({openMembership: true}); + + it('shows All Projects (not My Projects) as the default trigger label', async () => { + render(, { + organization: openOrg, + initialRouterConfig: { + location: {pathname: '/organizations/org-slug/issues/', query: {}}, + }, + }); + + expect( + await screen.findByRole('button', {name: 'All Projects'}) + ).toBeInTheDocument(); + }); + + it('does not show the My Projects option in the dropdown', async () => { + render(, { + organization: openOrg, + initialRouterConfig: { + location: {pathname: '/organizations/org-slug/issues/', query: {}}, + }, + }); + + await userEvent.click(screen.getByRole('button', {name: 'All Projects'})); + + expect( + screen.queryByRole('checkbox', {name: 'Select My Projects'}) + ).not.toBeInTheDocument(); + }); + + it('sends no project param for the default All Projects state', async () => { + const {router} = render(, { + organization: openOrg, + initialRouterConfig: { + location: {pathname: '/organizations/org-slug/issues/', query: {}}, + }, + }); + + await userEvent.click(screen.getByRole('button', {name: 'All Projects'})); + await userEvent.click(document.body); + + await waitFor(() => { + expect(router.location.query.project).toBeUndefined(); + }); + }); + + it('resetting navigates to All Projects with no project param', async () => { + PageFiltersStore.onInitializeUrlState({ + projects: [1], + environments: [], + datetime: {start: null, end: null, period: '14d', utc: null}, + }); - beforeEach(() => { - // Org without open-membership; user is a regular member - closedOrg = OrganizationFixture({features: [], orgRole: 'member'}); - OrganizationStore.onUpdate(closedOrg, {replace: true}); + const {router} = render(, { + organization: openOrg, + initialRouterConfig: { + location: {pathname: '/organizations/org-slug/issues/', query: {project: '1'}}, + }, + }); + + await userEvent.click(screen.getByRole('button', {name: 'project-1'})); + await userEvent.click(screen.getByRole('button', {name: 'Reset'})); + + await waitFor(() => { + expect(router.location.query.project).toBeUndefined(); + }); + + expect(screen.getByRole('button', {name: 'All Projects'})).toBeInTheDocument(); }); + it('does not show Reset button when All Projects is the default state', async () => { + // In an open-membership org, the default is All Projects (empty URL). + // The Reset button must not appear just because memberProjectIds != [ALL_ACCESS_PROJECTS]. + render(, { + organization: openOrg, + initialRouterConfig: { + location: {pathname: '/organizations/org-slug/issues/', query: {}}, + }, + }); + + await userEvent.click(screen.getByRole('button', {name: 'All Projects'})); + + expect(screen.queryByRole('button', {name: 'Reset'})).not.toBeInTheDocument(); + }); + + it('selecting exactly member projects preserves explicit project IDs in the URL', async () => { + // Bug: toURLSelection would collapse [1, 2] (member projects) to [] because + // value.length === memberProjectIds.length. In open-membership orgs [] means + // "All Projects", silently re-expanding the user's explicit selection on reload. + const {router} = render(, { + organization: openOrg, + initialRouterConfig: { + location: {pathname: '/organizations/org-slug/issues/', query: {}}, + }, + }); + + // Default is All Projects — open the menu and uncheck the non-member project, + // leaving exactly the two member projects (1 and 2) selected. + await userEvent.click(screen.getByRole('button', {name: 'All Projects'})); + await userEvent.click(screen.getByRole('checkbox', {name: 'Select project-3'})); + await userEvent.click(screen.getByRole('button', {name: 'Apply'})); + + // URL must carry explicit IDs — an undefined project param would mean All Projects. + await waitFor(() => { + expect(router.location.query.project).toBeDefined(); + }); + }); + }); + + describe('closed-membership org defaults', () => { it('shows My Projects (not All Projects) as the default in closed orgs', async () => { render(, { - organization: closedOrg, + organization, initialRouterConfig: { location: {pathname: '/organizations/org-slug/issues/', query: {}}, }, @@ -884,7 +995,7 @@ describe('ProjectPageFilter', () => { it('correctly round-trips My Projects selection in closed orgs', async () => { const {router} = render(, { - organization: closedOrg, + organization, initialRouterConfig: { location: {pathname: '/organizations/org-slug/issues/', query: {}}, }, @@ -909,7 +1020,7 @@ describe('ProjectPageFilter', () => { it('selecting All Projects in a closed org writes project=-1 to the URL', async () => { const {router} = render(, { - organization: closedOrg, + organization, initialRouterConfig: { location: {pathname: '/organizations/org-slug/issues/', query: {}}, }, @@ -932,7 +1043,7 @@ describe('ProjectPageFilter', () => { }); const {router} = render(, { - organization: closedOrg, + organization, initialRouterConfig: { location: { pathname: '/organizations/org-slug/issues/', diff --git a/static/app/components/pageFilters/project/projectPageFilter.tsx b/static/app/components/pageFilters/project/projectPageFilter.tsx index f898f9def36859..7fb408a307fdef 100644 --- a/static/app/components/pageFilters/project/projectPageFilter.tsx +++ b/static/app/components/pageFilters/project/projectPageFilter.tsx @@ -84,13 +84,26 @@ export function ProjectPageFilter({ isReady: pageFilterIsReady, } = usePageFilters(); + // In open-membership orgs every member implicitly has access to all projects, so + // "My Projects" (URL encoding: []) and "All Projects" (URL encoding: [-1]) are + // equivalent from the backend's perspective. We surface this honestly in the UI by + // defaulting to "All Projects" and hiding the "My Projects" option. + // + // Backend reference: src/sentry/api/bases/organization.py + // _filter_projects_by_permissions() — when filter_by_membership=True it calls + // has_project_membership(), but has_global_access=True (set for open-membership + // orgs) means every project passes has_project_access(), making the two sentinel + // values return identical result sets. + const isOpenMembership = organization.openMembership; + const committedSelectionIntent = useMemo( () => urlSelectionToIntent({ projects, urlSelection: urlProjectSelection, + isOpenMembership, }), - [urlProjectSelection, projects] + [urlProjectSelection, projects, isOpenMembership] ); // Track optimistically bookmarked projects to prevent star from disappearing @@ -233,7 +246,7 @@ export function ProjectPageFilter({ ({projects.length}) {/* Show separator if we are not displaying My Projects */} - {memberProjectList.length > 0 ? null : ( + {memberProjectList.length > 0 && !isOpenMembership ? null : ( , ] : []), - ...(memberProjectList.length > 0 && memberProjectList.length < projects.length + ...(!isOpenMembership && + memberProjectList.length > 0 && + memberProjectList.length < projects.length ? [ { value: MY_PROJECTS_VALUE, @@ -372,6 +387,7 @@ export function ProjectPageFilter({ urlProjectSelection, optimisticallyBookmarkedProjects, organization, + isOpenMembership, ]); const routePath = useMemo(() => getRouteStringFromRoutes(routes), [routes]); @@ -442,6 +458,7 @@ export function ProjectPageFilter({ // Preserve the ALL_ACCESS_PROJECTS sentinel before it gets expanded to [] // so toURLSelection can distinguish "All Projects selected" from "nothing selected". value: newValue.includes(ALL_ACCESS_PROJECTS) ? newValue : resolvedValue, + isOpenMembership, }), router, { @@ -493,7 +510,9 @@ export function ProjectPageFilter({ const handleReset = () => { clearDraftSelectionState(); - commitSelection(memberProjectIds(projects)); + commitSelection( + isOpenMembership ? [ALL_ACCESS_PROJECTS] : memberProjectIds(projects) + ); onReset?.(); trackAnalytics('projectselector.clear', { @@ -602,7 +621,10 @@ export function ProjectPageFilter({ } onOpenChange={handleOpenChange} menuHeaderTrailingItems={ - xor(stagedSelect.value, memberProjectIds(projects)).length > 0 ? ( + xor( + stagedSelect.value, + isOpenMembership ? allProjectIds(projects) : memberProjectIds(projects) + ).length > 0 ? ( ) : null } @@ -650,7 +672,9 @@ interface SelectionIntent { function urlSelectionToIntent({ projects, urlSelection, + isOpenMembership, }: { + isOpenMembership: boolean; projects: Project[]; urlSelection: number[]; }): SelectionIntent { @@ -658,11 +682,14 @@ function urlSelectionToIntent({ return {kind: 'all', ids: allProjectIds(projects)}; } - // Empty URL = "My Projects" (not "none" — that would be no selection at all). - // This holds regardless of showNonMemberProjects: in closed orgs the user's member - // projects are their accessible projects, so the default is still "My Projects". + // Empty URL = "My Projects" in closed orgs, but "All Projects" in open-membership + // orgs — because has_global_access=True makes the backend return all projects for + // both sentinels. See the comment near isOpenMembership in ProjectPageFilter for + // the full backend reference. if (urlSelection.length === 0) { - return {kind: 'my', ids: memberProjectIds(projects)}; + return isOpenMembership + ? {kind: 'all', ids: allProjectIds(projects)} + : {kind: 'my', ids: memberProjectIds(projects)}; } return selectionToIntent({ @@ -716,25 +743,35 @@ function selectionToIntent({ function toURLSelection({ projects, value, + isOpenMembership, }: { + isOpenMembership: boolean; projects: Project[]; value: number[]; }): number[] { if (value.includes(ALL_ACCESS_PROJECTS)) { - return [ALL_ACCESS_PROJECTS]; + // In open-membership orgs keep using [] (no param) so the URL stays clean and + // backwards-compatible. The backend returns the same result set for both [] and + // [-1] when the org has open membership. + return isOpenMembership ? [] : [ALL_ACCESS_PROJECTS]; } if (hasSameValues(value, allProjectIds(projects))) { - return [ALL_ACCESS_PROJECTS]; + return isOpenMembership ? [] : [ALL_ACCESS_PROJECTS]; } const memberProjectsSelected = memberProjectIds(projects).every(project => value.includes(project) ); - // "My Projects" — applies regardless of showNonMemberProjects so closed-org - // selections round-trip correctly through the compact [] URL encoding. - if (value.length === memberProjectIds(projects).length && memberProjectsSelected) { + // "My Projects" — applies only in closed-membership orgs. In open-membership orgs, + // [] means "All Projects", so we must not collapse a member-project selection to [] + // or it will silently expand back to all projects on the next page load. + if ( + !isOpenMembership && + value.length === memberProjectIds(projects).length && + memberProjectsSelected + ) { return []; }