Skip to content
Open
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
135 changes: 123 additions & 12 deletions static/app/components/pageFilters/project/projectPageFilter.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}),
Expand Down Expand Up @@ -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',
});
Expand Down Expand Up @@ -859,18 +868,120 @@ describe('ProjectPageFilter', () => {
});
});

describe('closed-membership org defaults', () => {
let closedOrg: ReturnType<typeof OrganizationFixture>;
describe('open-membership org defaults', () => {
const openOrg = OrganizationFixture({openMembership: true});

it('shows All Projects (not My Projects) as the default trigger label', async () => {
render(<ProjectPageFilter />, {
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(<ProjectPageFilter />, {
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(<ProjectPageFilter />, {
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(<ProjectPageFilter />, {
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(<ProjectPageFilter />, {
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(<ProjectPageFilter />, {
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(<ProjectPageFilter />, {
organization: closedOrg,
organization,
initialRouterConfig: {
location: {pathname: '/organizations/org-slug/issues/', query: {}},
},
Expand All @@ -884,7 +995,7 @@ describe('ProjectPageFilter', () => {

it('correctly round-trips My Projects selection in closed orgs', async () => {
const {router} = render(<ProjectPageFilter />, {
organization: closedOrg,
organization,
initialRouterConfig: {
location: {pathname: '/organizations/org-slug/issues/', query: {}},
},
Expand All @@ -909,7 +1020,7 @@ describe('ProjectPageFilter', () => {

it('selecting All Projects in a closed org writes project=-1 to the URL', async () => {
const {router} = render(<ProjectPageFilter />, {
organization: closedOrg,
organization,
initialRouterConfig: {
location: {pathname: '/organizations/org-slug/issues/', query: {}},
},
Expand All @@ -932,7 +1043,7 @@ describe('ProjectPageFilter', () => {
});

const {router} = render(<ProjectPageFilter />, {
organization: closedOrg,
organization,
initialRouterConfig: {
location: {
pathname: '/organizations/org-slug/issues/',
Expand Down
65 changes: 51 additions & 14 deletions static/app/components/pageFilters/project/projectPageFilter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -233,7 +246,7 @@ export function ProjectPageFilter({
({projects.length})
</Text>
{/* Show separator if we are not displaying My Projects */}
{memberProjectList.length > 0 ? null : (
{memberProjectList.length > 0 && !isOpenMembership ? null : (
<Separator
orientation="horizontal"
aria-hidden
Expand Down Expand Up @@ -273,7 +286,9 @@ export function ProjectPageFilter({
} satisfies SelectOption<number>,
]
: []),
...(memberProjectList.length > 0 && memberProjectList.length < projects.length
...(!isOpenMembership &&
memberProjectList.length > 0 &&
memberProjectList.length < projects.length
? [
{
value: MY_PROJECTS_VALUE,
Expand Down Expand Up @@ -372,6 +387,7 @@ export function ProjectPageFilter({
urlProjectSelection,
optimisticallyBookmarkedProjects,
organization,
isOpenMembership,
]);

const routePath = useMemo(() => getRouteStringFromRoutes(routes), [routes]);
Expand Down Expand Up @@ -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,
{
Expand Down Expand Up @@ -493,7 +510,9 @@ export function ProjectPageFilter({

const handleReset = () => {
clearDraftSelectionState();
commitSelection(memberProjectIds(projects));
commitSelection(
isOpenMembership ? [ALL_ACCESS_PROJECTS] : memberProjectIds(projects)
);
onReset?.();

trackAnalytics('projectselector.clear', {
Expand Down Expand Up @@ -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 ? (
<MenuComponents.ResetButton onClick={handleReset} />
) : null
}
Expand Down Expand Up @@ -650,19 +672,24 @@ interface SelectionIntent {
function urlSelectionToIntent({
projects,
urlSelection,
isOpenMembership,
}: {
isOpenMembership: boolean;
projects: Project[];
urlSelection: number[];
}): SelectionIntent {
if (urlSelection.includes(ALL_ACCESS_PROJECTS)) {
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({
Expand Down Expand Up @@ -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 [];
}

Expand Down
Loading