Skip to content

Commit 301da1a

Browse files
feat(settings): Add scope navigation rail (#128341)
A preview of the changes below: https://github.com/user-attachments/assets/b2302244-d47c-4b19-bd46-3e17184fe367 **Note** The items indentation, caret position and leading items (see screenshot below) will be done in a follow-up <img width="130" height="595" alt="image" src="https://github.com/user-attachments/assets/c4127eeb-9ce1-4905-b1a5-02cee780df8c" /> closes DE-1697 --------- Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com>
1 parent 22af3f4 commit 301da1a

43 files changed

Lines changed: 1101 additions & 104 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎static/app/router/routes.spec.tsx‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,6 @@ describe('buildRoutes()', () => {
116116
{
117117
type: 'project',
118118
to: '/settings/:orgId/projects/:projectId/',
119-
switchTo: '/settings/:orgId/projects/:projectId/keys/',
120119
},
121120
{
122121
type: 'link',

‎static/app/router/routes.tsx‎

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -533,8 +533,8 @@ function buildRoutes(): RouteObject[] {
533533
settingsBreadcrumb: {
534534
type: 'project',
535535
to: '/settings/:orgId/projects/:projectId/',
536-
switchTo: '/settings/:orgId/projects/:projectId/',
537536
},
537+
settingsProjectSwitch: {switchTo: '/settings/:orgId/projects/:projectId/'},
538538
},
539539
index: true,
540540
name: t('General'),
@@ -549,8 +549,8 @@ function buildRoutes(): RouteObject[] {
549549
settingsBreadcrumb: {
550550
type: 'project',
551551
to: '/settings/:orgId/projects/:projectId/',
552-
switchTo: '/settings/:orgId/projects/:projectId/teams/',
553552
},
553+
settingsProjectSwitch: {switchTo: '/settings/:orgId/projects/:projectId/teams/'},
554554
},
555555
path: 'teams/',
556556
name: t('Teams'),
@@ -561,8 +561,8 @@ function buildRoutes(): RouteObject[] {
561561
settingsBreadcrumb: {
562562
type: 'project',
563563
to: '/settings/:orgId/projects/:projectId/',
564-
switchTo: '/settings/:orgId/projects/:projectId/alerts/',
565564
},
565+
settingsProjectSwitch: {switchTo: '/settings/:orgId/projects/:projectId/alerts/'},
566566
},
567567
path: 'alerts/',
568568
name: t('Alerts'),
@@ -603,6 +603,8 @@ function buildRoutes(): RouteObject[] {
603603
settingsBreadcrumb: {
604604
type: 'project',
605605
to: '/settings/:orgId/projects/:projectId/',
606+
},
607+
settingsProjectSwitch: {
606608
switchTo: '/settings/:orgId/projects/:projectId/attributes/',
607609
},
608610
},
@@ -615,8 +617,8 @@ function buildRoutes(): RouteObject[] {
615617
settingsBreadcrumb: {
616618
type: 'project',
617619
to: '/settings/:orgId/projects/:projectId/',
618-
switchTo: '/settings/:orgId/projects/:projectId/tags/',
619620
},
621+
settingsProjectSwitch: {switchTo: '/settings/:orgId/projects/:projectId/tags/'},
620622
},
621623
path: 'tags/',
622624
name: t('Tags & Context'),
@@ -627,6 +629,8 @@ function buildRoutes(): RouteObject[] {
627629
settingsBreadcrumb: {
628630
type: 'project',
629631
to: '/settings/:orgId/projects/:projectId/',
632+
},
633+
settingsProjectSwitch: {
630634
switchTo: '/settings/:orgId/projects/:projectId/environments/',
631635
},
632636
},
@@ -647,6 +651,8 @@ function buildRoutes(): RouteObject[] {
647651
settingsBreadcrumb: {
648652
type: 'project',
649653
to: '/settings/:orgId/projects/:projectId/',
654+
},
655+
settingsProjectSwitch: {
650656
switchTo: '/settings/:orgId/projects/:projectId/ownership/',
651657
},
652658
},
@@ -663,6 +669,8 @@ function buildRoutes(): RouteObject[] {
663669
settingsBreadcrumb: {
664670
type: 'project',
665671
to: '/settings/:orgId/projects/:projectId/',
672+
},
673+
settingsProjectSwitch: {
666674
switchTo: '/settings/:orgId/projects/:projectId/user-feedback/',
667675
},
668676
},
@@ -675,6 +683,8 @@ function buildRoutes(): RouteObject[] {
675683
settingsBreadcrumb: {
676684
type: 'project',
677685
to: '/settings/:orgId/projects/:projectId/',
686+
},
687+
settingsProjectSwitch: {
678688
switchTo: '/settings/:orgId/projects/:projectId/toolbar/',
679689
},
680690
},
@@ -687,6 +697,8 @@ function buildRoutes(): RouteObject[] {
687697
settingsBreadcrumb: {
688698
type: 'project',
689699
to: '/settings/:orgId/projects/:projectId/',
700+
},
701+
settingsProjectSwitch: {
690702
switchTo: '/settings/:orgId/projects/:projectId/filters/',
691703
},
692704
},
@@ -708,6 +720,8 @@ function buildRoutes(): RouteObject[] {
708720
settingsBreadcrumb: {
709721
type: 'project',
710722
to: '/settings/:orgId/projects/:projectId/',
723+
},
724+
settingsProjectSwitch: {
711725
switchTo: '/settings/:orgId/projects/:projectId/security-and-privacy/',
712726
},
713727
},
@@ -733,6 +747,8 @@ function buildRoutes(): RouteObject[] {
733747
settingsBreadcrumb: {
734748
type: 'project',
735749
to: '/settings/:orgId/projects/:projectId/',
750+
},
751+
settingsProjectSwitch: {
736752
switchTo: '/settings/:orgId/projects/:projectId/issue-grouping/',
737753
},
738754
},
@@ -745,6 +761,8 @@ function buildRoutes(): RouteObject[] {
745761
settingsBreadcrumb: {
746762
type: 'project',
747763
to: '/settings/:orgId/projects/:projectId/',
764+
},
765+
settingsProjectSwitch: {
748766
switchTo: '/settings/:orgId/projects/:projectId/debug-symbols/',
749767
},
750768
},
@@ -757,6 +775,8 @@ function buildRoutes(): RouteObject[] {
757775
settingsBreadcrumb: {
758776
type: 'project',
759777
to: '/settings/:orgId/projects/:projectId/',
778+
},
779+
settingsProjectSwitch: {
760780
switchTo: '/settings/:orgId/projects/:projectId/proguard/',
761781
},
762782
},
@@ -769,6 +789,8 @@ function buildRoutes(): RouteObject[] {
769789
settingsBreadcrumb: {
770790
type: 'project',
771791
to: '/settings/:orgId/projects/:projectId/',
792+
},
793+
settingsProjectSwitch: {
772794
switchTo: '/settings/:orgId/projects/:projectId/source-maps/',
773795
},
774796
},
@@ -806,6 +828,8 @@ function buildRoutes(): RouteObject[] {
806828
settingsBreadcrumb: {
807829
type: 'project',
808830
to: '/settings/:orgId/projects/:projectId/',
831+
},
832+
settingsProjectSwitch: {
809833
switchTo: '/settings/:orgId/projects/:projectId/performance/',
810834
},
811835
},
@@ -822,6 +846,8 @@ function buildRoutes(): RouteObject[] {
822846
settingsBreadcrumb: {
823847
type: 'project',
824848
to: '/settings/:orgId/projects/:projectId/',
849+
},
850+
settingsProjectSwitch: {
825851
switchTo: '/settings/:orgId/projects/:projectId/replays/',
826852
},
827853
},
@@ -834,8 +860,8 @@ function buildRoutes(): RouteObject[] {
834860
settingsBreadcrumb: {
835861
type: 'project',
836862
to: '/settings/:orgId/projects/:projectId/',
837-
switchTo: '/settings/:orgId/projects/:projectId/logs/',
838863
},
864+
settingsProjectSwitch: {switchTo: '/settings/:orgId/projects/:projectId/logs/'},
839865
},
840866
path: 'logs/',
841867
name: t('Logs'),
@@ -846,6 +872,8 @@ function buildRoutes(): RouteObject[] {
846872
settingsBreadcrumb: {
847873
type: 'project',
848874
to: '/settings/:orgId/projects/:projectId/',
875+
},
876+
settingsProjectSwitch: {
849877
switchTo: '/settings/:orgId/projects/:projectId/playstation/',
850878
},
851879
},
@@ -858,6 +886,8 @@ function buildRoutes(): RouteObject[] {
858886
settingsBreadcrumb: {
859887
type: 'project',
860888
to: '/settings/:orgId/projects/:projectId/',
889+
},
890+
settingsProjectSwitch: {
861891
switchTo: '/settings/:orgId/projects/:projectId/mobile-builds/',
862892
},
863893
},
@@ -870,6 +900,8 @@ function buildRoutes(): RouteObject[] {
870900
settingsBreadcrumb: {
871901
type: 'project',
872902
to: '/settings/:orgId/projects/:projectId/',
903+
},
904+
settingsProjectSwitch: {
873905
switchTo: '/settings/:orgId/projects/:projectId/snapshots/',
874906
},
875907
},
@@ -882,8 +914,8 @@ function buildRoutes(): RouteObject[] {
882914
settingsBreadcrumb: {
883915
type: 'project',
884916
to: '/settings/:orgId/projects/:projectId/',
885-
switchTo: '/settings/:orgId/projects/:projectId/keys/',
886917
},
918+
settingsProjectSwitch: {switchTo: '/settings/:orgId/projects/:projectId/keys/'},
887919
},
888920
path: 'keys/',
889921
name: t('Client Keys'),
@@ -913,6 +945,8 @@ function buildRoutes(): RouteObject[] {
913945
settingsBreadcrumb: {
914946
type: 'project',
915947
to: '/settings/:orgId/projects/:projectId/',
948+
},
949+
settingsProjectSwitch: {
916950
switchTo: '/settings/:orgId/projects/:projectId/loader-script/',
917951
},
918952
},
@@ -925,6 +959,8 @@ function buildRoutes(): RouteObject[] {
925959
settingsBreadcrumb: {
926960
type: 'project',
927961
to: '/settings/:orgId/projects/:projectId/',
962+
},
963+
settingsProjectSwitch: {
928964
switchTo: '/settings/:orgId/projects/:projectId/release-tracking/',
929965
},
930966
},
@@ -939,6 +975,8 @@ function buildRoutes(): RouteObject[] {
939975
settingsBreadcrumb: {
940976
type: 'project',
941977
to: '/settings/:orgId/projects/:projectId/',
978+
},
979+
settingsProjectSwitch: {
942980
switchTo: '/settings/:orgId/projects/:projectId/security-headers/',
943981
},
944982
},
@@ -974,6 +1012,8 @@ function buildRoutes(): RouteObject[] {
9741012
settingsBreadcrumb: {
9751013
type: 'project',
9761014
to: '/settings/:orgId/projects/:projectId/',
1015+
},
1016+
settingsProjectSwitch: {
9771017
switchTo: '/settings/:orgId/projects/:projectId/legacy-webhooks/',
9781018
},
9791019
},

‎static/app/router/types.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type {SettingsBreadcrumb} from 'sentry/views/settings/components/settingsBreadcrumb/types';
2+
import type {SettingsRouteHandle} from 'sentry/views/settings/types';
23

34
/**
45
* This is our "custom" route object. It varies a bit from React Router's
@@ -15,7 +16,8 @@ interface BaseRouteObject {
1516
*/
1617
customerDomainOnlyRoute?: true;
1718

18-
handle?: Record<string, unknown> & {settingsBreadcrumb?: SettingsBreadcrumb};
19+
handle?: Record<string, unknown> &
20+
SettingsRouteHandle & {settingsBreadcrumb?: SettingsBreadcrumb};
1921
/**
2022
* Is a index route
2123
*/
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import {renderHookWithProviders} from 'sentry-test/reactTestingLibrary';
2+
3+
import {getReturnPath, useCaptureReturnPath} from 'sentry/utils/useReturnPath';
4+
5+
const KEY = 'test-return-path';
6+
const options = {key: KEY, excludePrefix: '/settings'};
7+
8+
describe('useReturnPath', () => {
9+
beforeEach(() => {
10+
sessionStorage.clear();
11+
});
12+
13+
it('captures locations outside the excluded prefix', () => {
14+
renderHookWithProviders(() => useCaptureReturnPath(options), {
15+
initialRouterConfig: {
16+
location: {pathname: '/issues/', query: {query: 'is:unresolved'}},
17+
},
18+
});
19+
20+
expect(getReturnPath(KEY, '/fallback/')).toBe('/issues/?query=is%3Aunresolved');
21+
});
22+
23+
it('does not capture without a key', () => {
24+
renderHookWithProviders(
25+
() => useCaptureReturnPath({key: null, excludePrefix: '/settings'}),
26+
{initialRouterConfig: {location: {pathname: '/issues/'}}}
27+
);
28+
29+
expect(getReturnPath(KEY, '/fallback/')).toBe('/fallback/');
30+
});
31+
32+
it('does not capture locations under the excluded prefix', () => {
33+
renderHookWithProviders(() => useCaptureReturnPath(options), {
34+
initialRouterConfig: {location: {pathname: '/settings/org-slug/members/'}},
35+
});
36+
37+
expect(getReturnPath(KEY, '/fallback/')).toBe('/fallback/');
38+
});
39+
40+
it.each(['//attacker.example/', '/\\attacker.example/', 'https://attacker.example/'])(
41+
'falls back instead of returning to another site via %s',
42+
path => {
43+
sessionStorage.setItem(KEY, path);
44+
45+
expect(getReturnPath(KEY, '/fallback/')).toBe('/fallback/');
46+
}
47+
);
48+
});

‎static/app/utils/useReturnPath.ts‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import {useEffect} from 'react';
2+
import {matchPath} from 'react-router';
3+
4+
import {sessionStorageWrapper} from 'sentry/utils/sessionStorage';
5+
import {useLocation} from 'sentry/utils/useLocation';
6+
7+
interface CaptureReturnPathOptions {
8+
/**
9+
* Locations under this path prefix (e.g. `/settings`) are not captured.
10+
*/
11+
excludePrefix: string;
12+
/**
13+
* The session storage key the return path is stored under. Nothing is
14+
* captured while this is null, e.g. when the key depends on something that
15+
* is not known yet.
16+
*/
17+
key: string | null;
18+
}
19+
20+
/**
21+
* Remembers the last page the user visited outside of a section of the app, so
22+
* a "back" control inside that section can return there. Browser history is
23+
* not reliable for this because users navigate between several pages of the
24+
* section before leaving.
25+
*
26+
* Must be mounted outside of the section it excludes.
27+
*/
28+
export function useCaptureReturnPath({key, excludePrefix}: CaptureReturnPathOptions) {
29+
const {pathname, search, hash} = useLocation();
30+
31+
useEffect(() => {
32+
if (key === null || matchPath({path: excludePrefix, end: false}, pathname)) {
33+
return;
34+
}
35+
sessionStorageWrapper.setItem(key, `${pathname}${search}${hash}`);
36+
}, [key, excludePrefix, pathname, search, hash]);
37+
}
38+
39+
// A path on this site. Links treat `//host` and `/\host` as another site, and
40+
// unmatched routes can capture such a pathname.
41+
const SAME_ORIGIN_PATH = /^\/(?![/\\])/;
42+
43+
/**
44+
* The page captured by `useCaptureReturnPath`, or `fallback` when none was
45+
* captured, e.g. when the section was entered directly.
46+
*/
47+
export function getReturnPath(key: string, fallback: string): string {
48+
const path = sessionStorageWrapper.getItem(key);
49+
return path && SAME_ORIGIN_PATH.test(path) ? path : fallback;
50+
}

‎static/app/views/navigation/constants.tsx‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,11 @@ export const TOP_BAR_HEIGHT_CSS_VAR = '--top-bar-height';
2626
// To be passed as the `source` parameter in router navigation state
2727
// e.g. {pathname: '/issues/', state: {source: `sidebar`}}
2828
export const SIDEBAR_NAVIGATION_SOURCE = 'sidebar';
29+
30+
// Settings has its own navigation, so "Back to app" returns to the last page
31+
// visited outside of this path prefix.
32+
export const SETTINGS_PATH_PREFIX = '/settings';
33+
34+
// Scoped by organization so "Back to app" never returns to another organization.
35+
export const getSettingsReturnPathKey = (organizationSlug: string) =>
36+
`settings-return-path:${organizationSlug}`;

0 commit comments

Comments
 (0)