diff --git a/static/app/views/explore/releases/detail/index.spec.tsx b/static/app/views/explore/releases/detail/index.spec.tsx new file mode 100644 index 000000000000..8b4a78bce789 --- /dev/null +++ b/static/app/views/explore/releases/detail/index.spec.tsx @@ -0,0 +1,114 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; +import {ProjectFixture} from 'sentry-fixture/project'; +import {ReleaseFixture} from 'sentry-fixture/release'; +import {ReleaseMetaFixture} from 'sentry-fixture/releaseMeta'; +import {ReleaseProjectFixture} from 'sentry-fixture/releaseProject'; + +import {render, waitFor} from 'sentry-test/reactTestingLibrary'; +import type {RouterConfig} from 'sentry-test/reactTestingLibrary'; + +import {ProjectsStore} from 'sentry/stores/projectsStore'; +import type {ReleaseProject} from 'sentry/types/release'; +import ReleasesDetailContainer from 'sentry/views/explore/releases/detail'; + +describe('ReleasesDetailContainer', () => { + const organization = OrganizationFixture(); + const project = ReleaseProjectFixture({ + id: 1, + slug: 'sentry-android-shop', + platform: 'android', + }) as Required; + const release = ReleaseFixture({ + version: 'test-release', + projects: [project], + }); + const releaseMeta = ReleaseMetaFixture({ + version: 'test-release', + projects: [project], + }); + + function renderContainer(query: Record) { + const pathname = `/organizations/${organization.slug}/explore/releases/test-release/`; + const initialRouterConfig: RouterConfig = { + location: {pathname, query}, + route: '/organizations/:orgId/explore/releases/:release/', + }; + + return render(, { + organization, + initialRouterConfig, + }); + } + + beforeEach(() => { + MockApiClient.clearMockResponses(); + jest.clearAllMocks(); + + ProjectsStore.reset(); + ProjectsStore.loadInitialData([ + ProjectFixture({id: String(project.id), slug: project.slug}), + ]); + + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/releases/test-release/meta/', + method: 'GET', + body: releaseMeta, + }); + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/releases/test-release/', + method: 'GET', + body: release, + }); + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/releases/test-release/deploys/', + method: 'GET', + body: [], + }); + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/sessions/', + method: 'GET', + body: {groups: []}, + }); + }); + + it('strips a trailing slash from the project query param', async () => { + const {router} = renderContainer({project: '1/'}); + + await waitFor(() => { + expect(router.location.query.project).toBe('1'); + }); + }); + + it('strips a trailing slash and removes datetime params in a single navigation', async () => { + const {router} = renderContainer({project: '1/', statsPeriod: '24h'}); + + await waitFor(() => { + expect(router.location.query.project).toBe('1'); + }); + expect(router.location.query.statsPeriod).toBeUndefined(); + expect(router.location.query.start).toBeUndefined(); + expect(router.location.query.end).toBeUndefined(); + expect(router.location.query.utc).toBeUndefined(); + }); + + it('removes datetime params while keeping the project query param intact', async () => { + const {router} = renderContainer({project: '1', statsPeriod: '24h'}); + + await waitFor(() => { + expect(router.location.query.statsPeriod).toBeUndefined(); + }); + expect(router.location.query.project).toBe('1'); + }); + + it('keeps the project query param unchanged when there is no trailing slash', async () => { + const {router} = renderContainer({project: '1'}); + + // Give effects a chance to run; the URL must stay untouched. + await waitFor(() => { + expect(router.location.query.project).toBe('1'); + }); + expect(router.location.pathname).toBe( + `/organizations/${organization.slug}/explore/releases/test-release/` + ); + }); +}); diff --git a/static/app/views/explore/releases/detail/index.tsx b/static/app/views/explore/releases/detail/index.tsx index 7d3575618681..f53ebbd0d2ab 100644 --- a/static/app/views/explore/releases/detail/index.tsx +++ b/static/app/views/explore/releases/detail/index.tsx @@ -241,19 +241,35 @@ function ReleasesDetailContainer() { useRouteAnalyticsParams({release}); - // Remove global date time from URL + // Strip trailing slashes from the project query param, e.g. `?project=123/` + // fails backend validation (it is neither a decimal id nor a slug) and the + // page would otherwise render blank because 400 errors are filtered out + // below. Also remove global date time params from the URL. Both URL + // cleanups are applied in a single navigate() call so the two rewrites + // cannot race and undo each other. useEffect(() => { - const {start, end, statsPeriod, utc, ...restQuery} = location.query; + const {start, end, statsPeriod, utc, project, ...restQuery} = location.query; - if (start || end || statsPeriod || utc) { - navigate( - { - ...location, - query: restQuery, - }, - {replace: true} - ); + const shouldStripProjectSlash = + typeof project === 'string' && project !== project.replace(/\/+$/, ''); + const shouldRemoveDateTimeParams = !!(start || end || statsPeriod || utc); + + if (!shouldStripProjectSlash && !shouldRemoveDateTimeParams) { + return; } + + navigate( + { + ...location, + query: { + ...restQuery, + ...(project === undefined + ? {} + : {project: shouldStripProjectSlash ? project.replace(/\/+$/, '') : project}), + }, + }, + {replace: true} + ); }, [location, navigate]); const {data: releaseMeta, isPending, isError, error} = useReleaseMeta({release});