mirror of
https://github.com/grafana/grafana.git
synced 2026-08-10 05:08:16 -05:00
Navigation: Inject orgId into all navigations (#120978)
* Frontend: inject orgId into all SPA navigations via LocationService Append ?orgId=<N> to every SPA navigation at the history layer in LocationService (push/replace), gated on multi-org, so URLs stay shareable across orgs. Wires the orgId getter at startup in app.ts and covers the behaviour in LocationService tests. Fixes #105040 Signed-off-by: QuentinBisson <quentin@giantswarm.io> * refactor: fix test Signed-off-by: QuentinBisson <quentin@giantswarm.io> * prune suppressions Signed-off-by: QuentinBisson <quentin@giantswarm.io> * Frontend: keep current path for query/hash-only orgId navigations appendOrgId resolved query-only ('?tab=x') and hash-only ('#h') strings against a dummy base and always returned url.pathname, forcing relative navigations to '/'. Omit pathname for those so the router keeps the current path, and cover both cases in tests. Signed-off-by: QuentinBisson <quentin@giantswarm.io> * Frontend: register orgId getter before post-login redirect --------- Signed-off-by: QuentinBisson <quentin@giantswarm.io> Co-authored-by: Laura Benz <laura.benz@grafana.com> Co-authored-by: Ashley Harrison <ashley.harrison@grafana.com> Co-authored-by: joshhunt <josh.hunt@grafana.com>
This commit is contained in:
co-authored by
Laura Benz
Ashley Harrison
joshhunt
parent
9859f65ae0
commit
5417094cb0
@@ -163,8 +163,8 @@ test.describe(
|
||||
await page.getByTestId(selectors.pages.ConfirmModal.input).fill('Delete');
|
||||
await page.getByTestId(selectors.pages.ConfirmModal.delete).click();
|
||||
|
||||
// Wait for redirect to home after deletion
|
||||
await page.waitForURL('**/');
|
||||
// Wait for redirect to home after deletion (?orgId=N may be appended by locationService)
|
||||
await page.waitForURL((url) => url.pathname === '/');
|
||||
|
||||
// Navigate to recently deleted
|
||||
await page.goto('/dashboard/recently-deleted');
|
||||
|
||||
@@ -49,7 +49,7 @@ test.describe(
|
||||
// wait for the page to reload before trying to navigate, otherwise this can cause flakes
|
||||
// see e.g. https://github.com/microsoft/playwright/issues/21451#issuecomment-1502251404
|
||||
await expect(page.getByTestId(selectors.components.UserProfile.preferencesSaveButton)).not.toBeDisabled();
|
||||
await page.waitForURL('/profile');
|
||||
await page.waitForURL((url) => new URL(url).pathname === '/profile');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page.getByTestId(selectors.components.TimeZonePicker.containerV2)).toContainText('Tokyo');
|
||||
|
||||
|
||||
@@ -338,11 +338,8 @@
|
||||
}
|
||||
},
|
||||
"packages/grafana-runtime/src/services/LocationService.tsx": {
|
||||
"@typescript-eslint/consistent-type-assertions": {
|
||||
"count": 1
|
||||
},
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 5
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"packages/grafana-runtime/src/services/backendSrv.ts": {
|
||||
|
||||
@@ -61,6 +61,154 @@ describe('LocationService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('appendOrgId', () => {
|
||||
const wrapper = locationService as HistoryWrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper.setOrgIdGetter(() => 7);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
wrapper.setOrgIdGetter(() => 0);
|
||||
});
|
||||
|
||||
it('returns input unchanged when getter returns 0 / negative / NaN', () => {
|
||||
wrapper.setOrgIdGetter(() => 0);
|
||||
expect(wrapper.appendOrgId('/p')).toBe('/p');
|
||||
wrapper.setOrgIdGetter(() => -1);
|
||||
expect(wrapper.appendOrgId('/p')).toBe('/p');
|
||||
wrapper.setOrgIdGetter(() => NaN);
|
||||
expect(wrapper.appendOrgId('/p')).toBe('/p');
|
||||
});
|
||||
|
||||
it('appends orgId to a bare string path', () => {
|
||||
expect(wrapper.appendOrgId('/p')).toEqual({ pathname: '/p', search: '?orgId=7', hash: '' });
|
||||
});
|
||||
|
||||
it('appends orgId with & when the string path already has a query', () => {
|
||||
expect(wrapper.appendOrgId('/p?a=1')).toEqual({ pathname: '/p', search: '?a=1&orgId=7', hash: '' });
|
||||
});
|
||||
|
||||
it('preserves the fragment on a bare string path', () => {
|
||||
expect(wrapper.appendOrgId('/p#h')).toEqual({ pathname: '/p', search: '?orgId=7', hash: '#h' });
|
||||
});
|
||||
|
||||
it('preserves the fragment when the string path has a query', () => {
|
||||
expect(wrapper.appendOrgId('/p?a=1#h')).toEqual({ pathname: '/p', search: '?a=1&orgId=7', hash: '#h' });
|
||||
});
|
||||
|
||||
it('leaves a string path unchanged when orgId is already present', () => {
|
||||
expect(wrapper.appendOrgId('/p?orgId=3')).toBe('/p?orgId=3');
|
||||
});
|
||||
|
||||
it('does not false-match notOrgId=', () => {
|
||||
expect(wrapper.appendOrgId('/p?notOrgId=5')).toEqual({
|
||||
pathname: '/p',
|
||||
search: '?notOrgId=5&orgId=7',
|
||||
hash: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the current path for a query-only string (no pathname)', () => {
|
||||
expect(wrapper.appendOrgId('?inspectTab=help')).toEqual({ search: '?inspectTab=help&orgId=7', hash: '' });
|
||||
});
|
||||
|
||||
it('keeps the current path for a hash-only string (no pathname)', () => {
|
||||
expect(wrapper.appendOrgId('#h')).toEqual({ search: '?orgId=7', hash: '#h' });
|
||||
});
|
||||
|
||||
it('leaves a query-only string unchanged when orgId is already present', () => {
|
||||
expect(wrapper.appendOrgId('?orgId=3')).toBe('?orgId=3');
|
||||
});
|
||||
|
||||
it('floors fractional orgId', () => {
|
||||
wrapper.setOrgIdGetter(() => 7.9);
|
||||
expect(wrapper.appendOrgId('/p')).toEqual({ pathname: '/p', search: '?orgId=7', hash: '' });
|
||||
});
|
||||
|
||||
it('appends orgId to a LocationDescriptor without a search property', () => {
|
||||
expect(wrapper.appendOrgId({ pathname: '/p' })).toEqual({
|
||||
pathname: '/p',
|
||||
search: '?orgId=7',
|
||||
});
|
||||
});
|
||||
|
||||
it('appends orgId to a LocationDescriptor with only a hash', () => {
|
||||
expect(wrapper.appendOrgId({ pathname: '/p', hash: '#h' })).toEqual({
|
||||
pathname: '/p',
|
||||
search: '?orgId=7',
|
||||
hash: '#h',
|
||||
});
|
||||
});
|
||||
|
||||
it('appends orgId to a LocationDescriptor with a search', () => {
|
||||
expect(wrapper.appendOrgId({ pathname: '/p', search: '?a=1' })).toEqual({
|
||||
pathname: '/p',
|
||||
search: '?a=1&orgId=7',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves a LocationDescriptor unchanged when orgId is already present', () => {
|
||||
expect(wrapper.appendOrgId({ pathname: '/p', search: '?orgId=3' })).toEqual({
|
||||
pathname: '/p',
|
||||
search: '?orgId=3',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('orgId injection on push/replace', () => {
|
||||
const wrapper = locationService as HistoryWrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper.setOrgIdGetter(() => 7);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
wrapper.setOrgIdGetter(() => 0);
|
||||
});
|
||||
|
||||
it('push() injects orgId into a string path with fragment', () => {
|
||||
locationService.push('/test?foo=1#section');
|
||||
expect(locationService.getLocation().search).toBe('?foo=1&orgId=7');
|
||||
expect(locationService.getLocation().hash).toBe('#section');
|
||||
});
|
||||
|
||||
it('push() injects orgId into a LocationDescriptor', () => {
|
||||
locationService.push({ pathname: '/test', search: '?foo=1', hash: '#section' });
|
||||
expect(locationService.getLocation().search).toBe('?foo=1&orgId=7');
|
||||
expect(locationService.getLocation().hash).toBe('#section');
|
||||
});
|
||||
|
||||
it('replace() injects orgId the same way', () => {
|
||||
locationService.replace('/test?foo=1#section');
|
||||
expect(locationService.getLocation().search).toBe('?foo=1&orgId=7');
|
||||
expect(locationService.getLocation().hash).toBe('#section');
|
||||
});
|
||||
|
||||
it('detached history.push call (as react-router <Link> does) still injects orgId', () => {
|
||||
const history = wrapper.getHistory();
|
||||
// react-router does: const method = history.push; method(loc)
|
||||
const detachedPush = history.push;
|
||||
detachedPush('/test?foo=1');
|
||||
expect(locationService.getLocation().search).toBe('?foo=1&orgId=7');
|
||||
});
|
||||
|
||||
it('partial() injects orgId when the current url lacks it', () => {
|
||||
// Reset to a path without orgId
|
||||
wrapper.setOrgIdGetter(() => 0);
|
||||
locationService.push('/test?foo=1');
|
||||
wrapper.setOrgIdGetter(() => 7);
|
||||
locationService.partial({ bar: 2 });
|
||||
expect(locationService.getLocation().search).toBe('?foo=1&bar=2&orgId=7');
|
||||
});
|
||||
|
||||
it('createHref injects orgId so rendered <a href> values are shareable', () => {
|
||||
const href = wrapper.createHref({ pathname: '/test', search: '?foo=1' });
|
||||
expect(href).toContain('orgId=7');
|
||||
expect(href).toContain('foo=1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hook access', () => {
|
||||
it('can set and access service from a context', () => {
|
||||
const locationServiceLocal = new HistoryWrapper();
|
||||
|
||||
@@ -30,47 +30,112 @@ export interface LocationService {
|
||||
update: (update: LocationUpdate) => void;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export class HistoryWrapper implements LocationService {
|
||||
private readonly history: H.History;
|
||||
/**
|
||||
* Wraps `H.History` so every navigation — programmatic push/replace,
|
||||
* `<Link>` clicks, and `<a href>` rendering via createHref — flows through
|
||||
* `appendOrgId` at one chokepoint. `getHistory()` returns `this`, so
|
||||
* react-router uses the wrapper too.
|
||||
* @internal
|
||||
*/
|
||||
export class HistoryWrapper implements LocationService, H.History {
|
||||
private readonly base: H.History;
|
||||
private locationObservable: BehaviorSubject<H.Location>;
|
||||
private orgIdGetter?: () => number;
|
||||
|
||||
constructor(history?: H.History) {
|
||||
// If no history passed create an in memory one if being called from test
|
||||
this.history =
|
||||
this.base =
|
||||
history ||
|
||||
(process.env.NODE_ENV === 'test'
|
||||
? H.createMemoryHistory({ initialEntries: ['/'] })
|
||||
: H.createBrowserHistory({ basename: config.appSubUrl ?? '/' }));
|
||||
|
||||
this.locationObservable = new BehaviorSubject(this.history.location);
|
||||
|
||||
this.history.listen((location) => {
|
||||
this.locationObservable.next(location);
|
||||
});
|
||||
|
||||
this.partial = this.partial.bind(this);
|
||||
this.push = this.push.bind(this);
|
||||
this.replace = this.replace.bind(this);
|
||||
this.getSearch = this.getSearch.bind(this);
|
||||
this.getHistory = this.getHistory.bind(this);
|
||||
this.getLocation = this.getLocation.bind(this);
|
||||
this.locationObservable = new BehaviorSubject(this.base.location);
|
||||
this.base.listen((location) => this.locationObservable.next(location));
|
||||
}
|
||||
|
||||
// The history library mutates these on the base instance after each
|
||||
// navigation, so getters keep readers in sync.
|
||||
get length() {
|
||||
return this.base.length;
|
||||
}
|
||||
get action() {
|
||||
return this.base.action;
|
||||
}
|
||||
get location() {
|
||||
return this.base.location;
|
||||
}
|
||||
|
||||
// Arrow class fields auto-bind, so detached calls (`const m = history.push; m(loc)`)
|
||||
// keep `this` and the orgId injection still fires.
|
||||
push: H.History['push'] = (location, state) => this.base.push(this.appendOrgId(location), state);
|
||||
replace: H.History['replace'] = (location, state) => this.base.replace(this.appendOrgId(location), state);
|
||||
createHref: H.History['createHref'] = (location) => this.base.createHref(this.appendOrgId(location));
|
||||
|
||||
go: H.History['go'] = (n) => this.base.go(n);
|
||||
goBack: H.History['goBack'] = () => this.base.goBack();
|
||||
goForward: H.History['goForward'] = () => this.base.goForward();
|
||||
block: H.History['block'] = (prompt) => this.base.block(prompt);
|
||||
listen: H.History['listen'] = (listener) => this.base.listen(listener);
|
||||
|
||||
setOrgIdGetter(fn: () => number) {
|
||||
this.orgIdGetter = fn;
|
||||
}
|
||||
|
||||
appendOrgId(location: H.LocationDescriptorObject): H.LocationDescriptorObject;
|
||||
appendOrgId(location: H.Path | H.LocationDescriptor): H.Path | H.LocationDescriptor;
|
||||
appendOrgId(location: H.Path | H.LocationDescriptor): H.Path | H.LocationDescriptor {
|
||||
const orgId = this.orgIdGetter?.() ?? 0;
|
||||
if (!Number.isFinite(orgId) || orgId <= 0) {
|
||||
return location;
|
||||
}
|
||||
const orgIdStr = String(Math.floor(orgId));
|
||||
|
||||
if (typeof location === 'string') {
|
||||
const url = new URL(location, 'http://_');
|
||||
if (url.searchParams.has('orgId')) {
|
||||
return location;
|
||||
}
|
||||
url.searchParams.set('orgId', orgIdStr);
|
||||
// A query- or hash-only string is a relative navigation that keeps the
|
||||
// current path; omitting pathname lets the router preserve it instead of
|
||||
// resolving to '/'.
|
||||
const relative = location.startsWith('?') || location.startsWith('#');
|
||||
return relative
|
||||
? { search: url.search, hash: url.hash }
|
||||
: { pathname: url.pathname, search: url.search, hash: url.hash };
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(location.search ?? '');
|
||||
if (params.has('orgId')) {
|
||||
return location;
|
||||
}
|
||||
params.set('orgId', orgIdStr);
|
||||
return { ...location, search: `?${params.toString()}` };
|
||||
}
|
||||
|
||||
// LocationService
|
||||
getLocationObservable() {
|
||||
return this.locationObservable.asObservable();
|
||||
}
|
||||
|
||||
getHistory() {
|
||||
return this.history;
|
||||
return this;
|
||||
}
|
||||
|
||||
getSearch() {
|
||||
return new URLSearchParams(this.history.location.search);
|
||||
return new URLSearchParams(this.base.location.search);
|
||||
}
|
||||
|
||||
getLocation() {
|
||||
return this.base.location;
|
||||
}
|
||||
|
||||
getSearchObject() {
|
||||
return locationSearchToObject(this.base.location.search);
|
||||
}
|
||||
|
||||
partial(query: Record<string, any>, replace?: boolean) {
|
||||
const currentLocation = this.history.location;
|
||||
const currentLocation = this.base.location;
|
||||
const newQuery = this.getSearchObject();
|
||||
|
||||
for (const key in query) {
|
||||
@@ -85,36 +150,27 @@ export class HistoryWrapper implements LocationService {
|
||||
const updatedUrl = urlUtil.renderUrl(currentLocation.pathname, newQuery);
|
||||
|
||||
if (replace) {
|
||||
this.history.replace(updatedUrl, this.history.location.state);
|
||||
this.replace(updatedUrl, currentLocation.state);
|
||||
} else {
|
||||
this.history.push(updatedUrl, this.history.location.state);
|
||||
this.push(updatedUrl, currentLocation.state);
|
||||
}
|
||||
}
|
||||
|
||||
push(location: H.Path | H.LocationDescriptor) {
|
||||
this.history.push(location);
|
||||
}
|
||||
|
||||
replace(location: H.Path | H.LocationDescriptor) {
|
||||
this.history.replace(location);
|
||||
}
|
||||
|
||||
reload() {
|
||||
const prevState = (this.history.location.state as any)?.routeReloadCounter;
|
||||
this.history.replace({
|
||||
...this.history.location,
|
||||
state: { routeReloadCounter: prevState ? prevState + 1 : 1 },
|
||||
const state = this.base.location.state;
|
||||
let prevCounter: number | undefined;
|
||||
if (state !== null && typeof state === 'object' && 'routeReloadCounter' in state) {
|
||||
const counter = state.routeReloadCounter;
|
||||
if (typeof counter === 'number') {
|
||||
prevCounter = counter;
|
||||
}
|
||||
}
|
||||
this.base.replace({
|
||||
...this.base.location,
|
||||
state: { routeReloadCounter: prevCounter !== undefined ? prevCounter + 1 : 1 },
|
||||
});
|
||||
}
|
||||
|
||||
getLocation() {
|
||||
return this.history.location;
|
||||
}
|
||||
|
||||
getSearchObject() {
|
||||
return locationSearchToObject(this.history.location.search);
|
||||
}
|
||||
|
||||
/** @deprecated use partial, push or replace instead */
|
||||
update(options: LocationUpdate) {
|
||||
deprecationWarning('LocationSrv', 'update', 'partial, push or replace');
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { DEFAULT_LANGUAGE } from '@grafana/i18n';
|
||||
import { initializeI18n, loadNamespacedResources } from '@grafana/i18n/internal';
|
||||
import {
|
||||
HistoryWrapper,
|
||||
locationService,
|
||||
setBackendSrv,
|
||||
setDataSourceSrv,
|
||||
@@ -280,6 +281,14 @@ export class GrafanaApp {
|
||||
getVariablesUrlParams: getVariablesUrlParams,
|
||||
});
|
||||
|
||||
// For multi-org users, ensure every SPA navigation carries ?orgId so
|
||||
// dashboard / alert URLs are shareable across orgs. Single-org users
|
||||
// (the OSS / Cloud majority) skip this, no URL pollution. Registered
|
||||
// before handleRedirectTo() so the post-login redirect gets orgId too.
|
||||
if (locationService instanceof HistoryWrapper && contextSrv.user.orgCount > 1) {
|
||||
locationService.setOrgIdGetter(() => contextSrv.user.orgId);
|
||||
}
|
||||
|
||||
if (config.featureToggles.useSessionStorageForRedirection) {
|
||||
handleRedirectTo();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { type SceneObjectUrlSyncHandler, type SceneObjectUrlValues, type VizPanel } from '@grafana/scenes';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
|
||||
import { buildPanelEditScene } from '../panel-edit/PanelEditor';
|
||||
import { createDashboardEditViewFor } from '../settings/createDashboardEditViewFor';
|
||||
@@ -28,7 +27,6 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler {
|
||||
editview: state.editview?.getUrlKey(),
|
||||
editPanel: state.editPanel?.getUrlKey() || undefined,
|
||||
shareView: state.shareView,
|
||||
orgId: contextSrv.user.orgId.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -56,6 +56,7 @@ jest.mock('@grafana/runtime', () => {
|
||||
locationService: {
|
||||
...actual.locationService,
|
||||
push: jest.fn(),
|
||||
getHistory: () => actual.locationService,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -112,10 +112,10 @@ export function initializeFromURL(
|
||||
const oldQuery = location.getSearchObject();
|
||||
|
||||
// we create the default query params from the current URL, omitting all the properties we know should be in the final url.
|
||||
// This includes params from previous schema versions and 'schemaVersion', 'panes', 'orgId' as we want to replace those.
|
||||
// This includes params from previous schema versions and 'schemaVersion', 'panes' as we want to replace those.
|
||||
let defaults: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(oldQuery).filter(
|
||||
([key]) => !['schemaVersion', 'panes', 'orgId', 'left', 'right'].includes(key)
|
||||
([key]) => !['schemaVersion', 'panes', 'left', 'right'].includes(key)
|
||||
)) {
|
||||
defaults[key] = value;
|
||||
}
|
||||
@@ -124,7 +124,6 @@ export function initializeFromURL(
|
||||
// we set the schemaVersion as the first parameter so that when URLs are truncated the schemaVersion is more likely to be present.
|
||||
schemaVersion: `${urlState.schemaVersion}`,
|
||||
panes: JSON.stringify(panesObj),
|
||||
orgId: `${orgId}`,
|
||||
...defaults,
|
||||
});
|
||||
|
||||
|
||||
@@ -202,7 +202,7 @@ export function setupExplore(options?: SetupOptions): {
|
||||
<OpenFeatureProvider client={getTestFeatureFlagClient()}>
|
||||
<Provider store={storeState}>
|
||||
<GrafanaContext.Provider value={contextMock}>
|
||||
<Router history={history}>
|
||||
<Router history={location.getHistory()}>
|
||||
<QueriesDrawerContextProvider>
|
||||
<FinalProvider>
|
||||
{options?.withAppChrome ? (
|
||||
|
||||
@@ -157,9 +157,9 @@ describe('PlaylistSrv', () => {
|
||||
// Start the playlist
|
||||
await srv.start(mockPlaylist);
|
||||
|
||||
// Get history entries
|
||||
// Get history entries via the underlying MemoryHistory (test-env only)
|
||||
const history = locationService.getHistory();
|
||||
const entries = (history as unknown as { entries: Location[] }).entries;
|
||||
const entries = (history as unknown as { base: { entries: Location[] } }).base.entries;
|
||||
|
||||
// The current entry should be the first dashboard
|
||||
expect(entries[entries.length - 1].pathname).toBe('/url/to/aaa');
|
||||
|
||||
@@ -6,14 +6,18 @@ import { type Playlist } from '../../api/clients/playlist/v1';
|
||||
|
||||
import { StartModal } from './StartModal';
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
locationService: {
|
||||
...jest.requireActual('@grafana/runtime').locationService,
|
||||
push: jest.fn(),
|
||||
},
|
||||
reportInteraction: jest.fn(),
|
||||
}));
|
||||
jest.mock('@grafana/runtime', () => {
|
||||
const actual = jest.requireActual('@grafana/runtime');
|
||||
return {
|
||||
...actual,
|
||||
locationService: {
|
||||
...actual.locationService,
|
||||
push: jest.fn(),
|
||||
getHistory: () => actual.locationService,
|
||||
},
|
||||
reportInteraction: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockPlaylist: Playlist = {
|
||||
apiVersion: 'playlist.grafana.app/v1',
|
||||
|
||||
@@ -82,7 +82,7 @@ export const ConfigEditor = (props: Props) => {
|
||||
</InlineField>
|
||||
{options.jsonData.handleGrafanaManagedAlerts && (
|
||||
<Text variant="bodySmall" color="secondary">
|
||||
Make sure to enable the alert forwarding on the <Link to="/alerting/admin">settings page</Link>.
|
||||
Make sure to enable the alert forwarding on the <Link to={'/alerting/admin'}>settings page</Link>.
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -71,7 +71,7 @@ const getWrapper = ({
|
||||
* Conditional router - either a MemoryRouter or just a Fragment
|
||||
*/
|
||||
const PotentialRouter = renderWithRouter
|
||||
? ({ children }: PropsWithChildren) => <Router history={history}>{children}</Router>
|
||||
? ({ children }: PropsWithChildren) => <Router history={locationService.getHistory()}>{children}</Router>
|
||||
: ({ children }: PropsWithChildren) => <Fragment>{children}</Fragment>;
|
||||
|
||||
const PotentialCompatRouter = renderWithRouter ? CompatRouter : Fragment;
|
||||
|
||||
Reference in New Issue
Block a user