Routing NG: Replace Angular routing with react-router (#31463)

* Add router packages

* Get react app root work instead of Angular one

* Logger util

* Patch Angular routing ($routeProvider, $routeParamsProvider)

* Use react-router-dom history instead of separate dependency

* Add test routes

* Sidemenu - use Link instead of anchors

* Patch Angular $location service (stub)

* WIP: geting rid of $location provider from TimeSrv

* Intercept anchor clicks to use history under the hood

* Sync Redux location slice with history state

* Make login/logout work

* Debug routes for testing

* Make force login work

* Make sure query param change does not recreate page components

* Hide side menu in specified locations

* Make the dashboar route query parameters work, make panel edit menu work

* Enable more routes

* Fix side menu

* Handle view modes

* Disable playlist routes

* Make SafeDynamicImport work again

* Bring back router-debug

* Separate redux location sync from route rendering

* Refactor updateLocation to thunk and move force refresh(login) to it

* Fixing init dashboard issue

* Support switching between dashboards without an unmount of DashboardPage

* More fixes for init dashboard and panel edit

* More type fixes

* Moving angular location wrapper out of main LocationService, and fixing typescript issues

* Fixed last typescript errors

* LocationService: Move to runtime and  remove getLocationService and export singleston const instead (#31523)

* Moving location service implementation to runtime and removing get function and making it a package const singleton

* Added test that used locationService directly

* removed unused import

* AngularApp: Moving angular dependencies and the app boot out of the main app into it's own file  (#31525)

* Fixes angular panels by calling the monkey patch

* Moving angular stuff to to it's own files

* udpated

* Fixing clicking on divs and spans inside anchor

* Moving app notifications out of angular app and removing angular directive wrapper

* Moving search from angular to react and removing angular search wrapper

* Clean up, tried to remove the redux location wrapper but requires a big update for DashboardPage, so adding it back

* Moving AppWrapper to root to limit circular dependencies (app/core -> app/routing and back)

* Open and close search now works

* Hide sidemenu when in kiosk mode

* Restoring some keybindings like ESC key

* Removed kiosk events and simplified it, just handled through updating URL

* Fixing typescript errors

* Simplified GrafanaRouteComponentProps and renamed to ContainerProps

* renamed back

* Changed AlertRuleList to use GrafanaRouteComponentProps and location.search passed to it

* Removing the reloadOnSearch property, this is not needed now for react as react by default does not unmount components when only url match or query parmas change

* SafeDynamicImport causing unmount un every search update, not sure how to fix yet

* Fix signature for SafeDynamicImport so we do not create new route components on every route render

* Removing the redux location wrapper as it was causing errors, and making dashboard page work with RouteProps (location, match) etc

* Updating DashboardPage and SoloPanelPage to use match params and history location

* Fixed DashboardPage tests

* Fixing solo route tests

* LocationService: Rename getCurrentLocation to just getLocation

* do not intercept link clicks with target blank or self

* Experimental useUrlParams hook

* Update DataSourceSettingsPage to use router match params

* fix links with urls that have no starting / to work like before

* Fix forceLogin

* Add queryParams to GrafanaRouteComponentProps

* PanelEditor get rid of updateLocation and location state

* Improve grafana route query params typing

* Add getSearchObject to LocationService

* Use DashboardPAge queryParams instead of location.search parsing

* Fix DashboardPage typing

* Fix some tests weirdness

* Bring back KeyboardSrv

* Fixes typescript issues

* Team pages now use router match params

* Get rid of  from GrafanaRouteComponent props

* Removed unnessary calls to getSearchObject when calling locationService.partial

* Updated DashboardPage tests after queryParams was added

* Fixing dashboard settings back

* GrafanaRoute: Adding tests and remove use of global locationService

* Fixing tests and typescript errors

* Bring back kiosk modes and add tests

* Fix TimeSrv tests

* Fix typecheck errors

* Fixing tests

* Updated SideMenu test to react-testing and wrapped component in Router, and fixed issue importing createMemoryHistory

* Get rid of routeChange event from TimeSrv from

* Fixed TopSectionItem test

* Trying to make basename work but failing

* Update TopSectionItem snapshot

* Fix TopSectionItem snapshot test

* Fix API keys creation

* Remove Angular dependencies from KeybindingSrv (#31617)

* Remove Angular dependency from KeybindingsSrv

* Fix tests and typecheck issues

* basename is starting to work

* Make dashboard save work

* KeybindingSrv: Remove as angular service and no usage angular scope

* So long bridge_srv, we won't miss you

* Update snapshots

* Dashboard: Refactoring ChangeTracker to use History api and no angular (#31653)

* Dashboard: Refactoring ChangeTracker to use History api and no angular

* Updated

* Removed logging

* fixed unit tests

* updated snapshots

* Mechanism for force reloading routes (#31683)

* e2e: Fixes various things in e2e scenarios after router migration (#31685)

* Explore: Update reading query params from router props and updating location via locationService (ReactRouter)  (#31688)

* RoutingNG: Initial explore redux location to router location migration

* Updated explore Wrapper tests

* Fixing more tests

* remove loggin

* rename back to make naming consistent

* Fixing return to dashboard button

* fixing navigation to explore from dashboard

* updated routeProps

* Updated tests

* Make DashboardListPage work

* Fixing navigation after add new data source, and fixes explore e2e

* Fixing solo panel page

* PluginsPage now works

* RoutingNG: When parsing and rendering url search/query params preseve old logic of handling booleans and arrays (#31725)

* RoutingNG: When parsing and rendering url search/query params preserve old logic of handling booleans and arrays

* Fixed test

* Make snapshots list work

* fixed alert notification channel edit page

* Simplify LocationService, did not need special handling for login or forceLogin as target _self on link already handles that

* fixed UserAdminPage

* fixed edit orgs page

* Fixing LdapPage

* fixing dashboard import

* Fixed new folder page

* Fixed data source dashboards page

* fixing Folder permissions and folder settings page

* fixing snapshot list page nav model

* remove unused file

* Added placeholder page for playlist

* Moved browser compatability to index-template

* Restored 404/default page

* Fixed reset password page

* Fixed SignUpInvited page

* Fixing CreateTeam, Create user page, add panel widget

* Restore browwser file to make tests happy

* Fixed unit tests

* Removed unused import

* Replacing usage of updateLocation

* Fixed test

* Updating search filters to use history / location service for filters

* remove unused file

* AppRootPage fixed

* Fixing test and search issue

* Changes to support enterprise extensions

* remove console.log

* Removing more use of redux location

* Fixed signup page

* removed unused old angular controllers

* Fixing bugs

* one final bugfix

* Removed location from redux state

* Fixing ts issues and tests

* Fixing test issue

* fixing tests

* Fixing tests

* removed unused stuff

* Fixed search test

* Adding some doc comments

* Routing NG: Angular location provider patch (#31773)

* Patch Angulars $location provider

* Update public/app/angular/bridgeReactAngularRouting.ts

* Remove only test

* Update tests, disable loggers in test env

* Routing NG: remove $location provider usage (#31816)

* Remove dashboard_loaders

* Remove $location from Analytics service, track page views form GrafanaRoute

* Remove NotificationsEditCtrl

* Remove Angular dependencies from uploadDashboardDirective

* Update public/app/features/dashboard/containers/DashboardPage.tsx

Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>

* Update public/app/features/dashboard/containers/DashboardPage.tsx

Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>

* Remove unused test helpers (#31831)

* Playlist react (#31829)

* playlist list in react

* Playlist start

* Things started to work

* Updated

* Handle empty list

* Fix ts

* Fixes and kiosk mode stuff

* Removed unused events

* fixing ts issue

* Another ts issue

* Fixing tests

Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>

* fixed test

* Update public/app/AppWrapper.tsx

Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>

* Update public/app/AppWrapper.tsx

Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>

* Remove Angular dependency from DashboardLoaderSrv (#31863)

Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
Co-authored-by: Torkel Ödegaard <torkel@grafana.org>
Co-authored-by: Hugo Häggmark <hugo.haggmark@grafana.com>
Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>
This commit is contained in:
Dominik Prokop 2021-03-10 18:03:36 +01:00 committed by GitHub
parent def58f1f4a
commit a55a272276
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
242 changed files with 5367 additions and 5293 deletions

View File

@ -3,7 +3,7 @@ import { e2e } from '@grafana/e2e';
e2e.scenario({
describeName: 'Explore',
itName: 'Basic path through Explore.',
addScenarioDataSource: true,
addScenarioDataSource: false,
addScenarioDashBoard: false,
skipScenario: false,
scenario: () => {

View File

@ -1,6 +1,6 @@
import { e2e } from '@grafana/e2e';
const PAGE_UNDER_TEST = '-Y-tnEDWk';
const PAGE_UNDER_TEST = '-Y-tnEDWk/templating-nested-template-variables';
describe('Variables - Load options from Url', () => {
it('default options should be correct', () => {
@ -58,7 +58,7 @@ describe('Variables - Load options from Url', () => {
it('options set in url should load correct options', () => {
e2e.flows.login('admin', 'admin');
e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?var-datacenter=B&var-server=BB&var-pod=BBB` });
e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1&var-datacenter=B&var-server=BB&var-pod=BBB` });
e2e().server();
e2e()
.route({
@ -122,7 +122,7 @@ describe('Variables - Load options from Url', () => {
return true;
});
e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?var-datacenter=X` });
e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1&var-datacenter=X` });
e2e().server();
e2e()
.route({

View File

@ -1,11 +1,11 @@
import { e2e } from '@grafana/e2e';
const PAGE_UNDER_TEST = '-Y-tnEDWk';
const PAGE_UNDER_TEST = '-Y-tnEDWk/templating-nested-template-variables';
describe('Variables - Add variable', () => {
it('query variable should be default and default fields should be correct', () => {
e2e.flows.login('admin', 'admin');
e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?editview=templating` });
e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1&editview=templating` });
e2e.pages.Dashboard.Settings.Variables.List.newButton().should('be.visible').click();
@ -73,7 +73,7 @@ describe('Variables - Add variable', () => {
it('adding a single value query variable', () => {
e2e.flows.login('admin', 'admin');
e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?editview=templating` });
e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1&editview=templating` });
e2e.pages.Dashboard.Settings.Variables.List.newButton().should('be.visible').click();
@ -127,7 +127,7 @@ describe('Variables - Add variable', () => {
it('adding a multi value query variable', () => {
e2e.flows.login('admin', 'admin');
e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?editview=templating` });
e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1&editview=templating` });
e2e.pages.Dashboard.Settings.Variables.List.newButton().should('be.visible').click();

View File

@ -1,11 +1,11 @@
import { e2e } from '@grafana/e2e';
const PAGE_UNDER_TEST = '-Y-tnEDWk';
const PAGE_UNDER_TEST = '-Y-tnEDWk/templating-nested-template-variables';
describe('Variables - Set options from ui', () => {
it('clicking a value that is not part of dependents options should change these to All', () => {
e2e.flows.login('admin', 'admin');
e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?var-datacenter=A&var-server=AA&var-pod=AAA` });
e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1&var-datacenter=A&var-server=AA&var-pod=AAA` });
e2e().server();
e2e()
.route({
@ -26,6 +26,8 @@ describe('Variables - Set options from ui', () => {
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('B').scrollIntoView().should('be.visible');
e2e.components.LoadingIndicator.icon().should('have.length', 0);
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('All').should('have.length', 2);
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('All').eq(0).should('be.visible').click();
@ -63,7 +65,7 @@ describe('Variables - Set options from ui', () => {
it('adding a value that is not part of dependents options should add the new values dependant options', () => {
e2e.flows.login('admin', 'admin');
e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?var-datacenter=A&var-server=AA&var-pod=AAA` });
e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1&var-datacenter=A&var-server=AA&var-pod=AAA` });
e2e().server();
e2e()
.route({
@ -84,6 +86,8 @@ describe('Variables - Set options from ui', () => {
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('A + B').scrollIntoView().should('be.visible');
e2e.components.LoadingIndicator.icon().should('have.length', 0);
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('AA').should('be.visible').click();
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown()
@ -117,7 +121,7 @@ describe('Variables - Set options from ui', () => {
it('removing a value that is part of dependents options should remove the new values dependant options', () => {
e2e.flows.login('admin', 'admin');
e2e.flows.openDashboard({
uid: `${PAGE_UNDER_TEST}?var-datacenter=A&var-datacenter=B&var-server=AA&var-server=BB&var-pod=AAA&var-pod=BBB`,
uid: `${PAGE_UNDER_TEST}?orgId=1&var-datacenter=A&var-datacenter=B&var-server=AA&var-server=BB&var-pod=AAA&var-pod=BBB`,
});
e2e().server();
e2e()
@ -139,6 +143,8 @@ describe('Variables - Set options from ui', () => {
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('B').scrollIntoView().should('be.visible');
e2e.components.LoadingIndicator.icon().should('have.length', 0);
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('BB').should('be.visible').click();
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown()

View File

@ -5,32 +5,16 @@ const PAGE_UNDER_TEST = 'AejrN1AMz';
describe('TextBox - load options scenarios', function () {
it('default options should be correct', function () {
e2e.flows.login('admin', 'admin');
e2e.flows.openDashboard({ uid: PAGE_UNDER_TEST });
e2e().server();
e2e()
.route({
method: 'GET',
url: `/api/dashboards/uid/${PAGE_UNDER_TEST}`,
})
.as('dash');
e2e().wait('@dash');
e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}/templating-textbox-e2e-scenarios?orgId=1` });
validateTextboxAndMarkup('default value');
});
it('loading variable from url should be correct', function () {
e2e.flows.login('admin', 'admin');
e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?var-text=not default value` });
e2e().server();
e2e()
.route({
method: 'GET',
url: `/api/dashboards/uid/${PAGE_UNDER_TEST}`,
})
.as('dash');
e2e().wait('@dash');
e2e.flows.openDashboard({
uid: `${PAGE_UNDER_TEST}/templating-textbox-e2e-scenarios?orgId=1&var-text=not default value`,
});
validateTextboxAndMarkup('not default value');
});
@ -159,7 +143,7 @@ function copyExistingDashboard() {
url: /\/api\/dashboards\/uid\/(?!AejrN1AMz)\w+/,
})
.as('load-dash');
e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?editview=settings&orgId=1` });
e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}/templating-textbox-e2e-scenarios?orgId=1&editview=settings` });
e2e().wait('@dash-settings');

View File

@ -92,6 +92,7 @@
"@types/enzyme": "3.10.5",
"@types/enzyme-adapter-react-16": "1.0.6",
"@types/file-saver": "2.0.1",
"@types/history": "^4.7.8",
"@types/is-hotkey": "0.1.1",
"@types/jest": "26.0.15",
"@types/jquery": "3.3.38",
@ -107,6 +108,7 @@
"@types/react-dom": "16.9.9",
"@types/react-grid-layout": "1.1.1",
"@types/react-redux": "7.1.7",
"@types/react-router-dom": "^5.1.7",
"@types/react-select": "3.0.8",
"@types/react-test-renderer": "16.9.2",
"@types/react-transition-group": "4.4.0",
@ -218,6 +220,7 @@
"@types/react-virtualized-auto-sizer": "1.0.0",
"@types/uuid": "8.3.0",
"@welldone-software/why-did-you-render": "4.0.6",
"history": "4.10.1",
"abortcontroller-polyfill": "1.4.0",
"angular": "1.8.2",
"angular-bindonce": "0.3.1",
@ -269,6 +272,7 @@
"react-popper": "2.2.4",
"react-redux": "7.2.0",
"react-reverse-portal": "^2.0.1",
"react-router-dom": "^5.2.0",
"react-sizeme": "2.6.12",
"react-split-pane": "0.1.89",
"react-transition-group": "4.4.1",

View File

@ -6,8 +6,10 @@ import { fallBackTreshold } from './thresholds';
import { getScaleCalculator, ColorScaleValue } from './scale';
import { reduceField } from '../transformations/fieldReducer';
/** @beta */
export type FieldValueColorCalculator = (value: number, percent: number, Threshold?: Threshold) => string;
/** @beta */
export interface FieldColorMode extends RegistryItem {
getCalculator: (field: Field, theme: GrafanaTheme) => FieldValueColorCalculator;
colors?: string[];
@ -15,6 +17,7 @@ export interface FieldColorMode extends RegistryItem {
isByValue?: boolean;
}
/** @internal */
export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
return [
{
@ -203,10 +206,12 @@ export class FieldColorSchemeMode implements FieldColorMode {
}
}
/** @beta */
export function getFieldColorModeForField(field: Field): FieldColorMode {
return fieldColorModeRegistry.get(field.config.color?.mode ?? FieldColorModeId.Thresholds);
}
/** @beta */
export function getFieldColorMode(mode?: FieldColorModeId): FieldColorMode {
return fieldColorModeRegistry.get(mode ?? FieldColorModeId.Thresholds);
}

View File

@ -5,6 +5,7 @@ import isNumber from 'lodash/isNumber';
type IndexComparer = (a: number, b: number) => number;
/** @public */
export const fieldIndexComparer = (field: Field, reverse = false): IndexComparer => {
const values = field.values;
@ -22,6 +23,7 @@ export const fieldIndexComparer = (field: Field, reverse = false): IndexComparer
}
};
/** @public */
export const timeComparer = (a: any, b: any): number => {
if (!a || !b) {
return falsyComparer(a, b);
@ -42,10 +44,12 @@ export const timeComparer = (a: any, b: any): number => {
return 0;
};
/** @public */
export const numericComparer = (a: number, b: number): number => {
return a - b;
};
/** @public */
export const stringComparer = (a: string, b: string): number => {
if (!a || !b) {
return falsyComparer(a, b);

View File

@ -66,9 +66,7 @@ export const customFieldRegistry: FieldConfigOptionsRegistry = new Registry<Fiel
});
locationUtil.initialize({
getConfig: () => {
return { appSubUrl: '/subUrl' } as any;
},
config: { appSubUrl: '/subUrl' } as any,
// @ts-ignore
buildParamsFromVariables: () => {},
// @ts-ignore
@ -529,7 +527,7 @@ describe('setDynamicConfigValue', () => {
describe('getLinksSupplier', () => {
it('will replace variables in url and title of the data link', () => {
locationUtil.initialize({
getConfig: () => ({} as any),
config: {} as any,
buildParamsFromVariables: (() => {}) as any,
getTimeRangeForUrl: (() => {}) as any,
});
@ -573,7 +571,7 @@ describe('getLinksSupplier', () => {
it('handles internal links', () => {
locationUtil.initialize({
getConfig: () => ({ appSubUrl: '' } as any),
config: { appSubUrl: '' } as any,
buildParamsFromVariables: (() => {}) as any,
getTimeRangeForUrl: (() => {}) as any,
});

View File

@ -3,15 +3,14 @@ import { locationUtil } from './location';
describe('locationUtil', () => {
beforeAll(() => {
locationUtil.initialize({
getConfig: () => {
return { appSubUrl: '/subUrl' } as any;
},
config: { appSubUrl: '/subUrl' } as any,
// @ts-ignore
buildParamsFromVariables: () => {},
// @ts-ignore
getTimeRangeForUrl: () => {},
});
});
describe('With /subUrl as appSubUrl', () => {
it('/subUrl should be stripped', () => {
const urlWithoutMaster = locationUtil.stripBaseFromUrl('/subUrl/grafana/');

View File

@ -2,7 +2,7 @@ import { GrafanaConfig, RawTimeRange, ScopedVars } from '../types';
import { urlUtil } from './url';
import { textUtil } from '../text';
let grafanaConfig: () => GrafanaConfig;
let grafanaConfig: GrafanaConfig = { appSubUrl: '' } as any;
let getTimeRangeUrlParams: () => RawTimeRange;
let getVariablesUrlParams: (params?: Record<string, any>, scopedVars?: ScopedVars) => string;
@ -12,7 +12,7 @@ let getVariablesUrlParams: (params?: Record<string, any>, scopedVars?: ScopedVar
* @internal
*/
const stripBaseFromUrl = (url: string): string => {
const appSubUrl = grafanaConfig ? grafanaConfig().appSubUrl : '';
const appSubUrl = grafanaConfig.appSubUrl ?? '';
const stripExtraChars = appSubUrl.endsWith('/') ? 1 : 0;
const urlWithoutBase =
url.length > 0 && url.indexOf(appSubUrl) === 0 ? url.slice(appSubUrl.length - stripExtraChars) : url;
@ -27,13 +27,13 @@ const stripBaseFromUrl = (url: string): string => {
*/
const assureBaseUrl = (url: string): string => {
if (url.startsWith('/')) {
return `${grafanaConfig ? grafanaConfig().appSubUrl : ''}${stripBaseFromUrl(url)}`;
return `${grafanaConfig.appSubUrl}${stripBaseFromUrl(url)}`;
}
return url;
};
interface LocationUtilDependencies {
getConfig: () => GrafanaConfig;
config: GrafanaConfig;
getTimeRangeForUrl: () => RawTimeRange;
buildParamsFromVariables: (params: any, scopedVars?: ScopedVars) => string;
}
@ -46,8 +46,8 @@ export const locationUtil = {
* @param getTimeRangeForUrl
* @internal
*/
initialize: ({ getConfig, buildParamsFromVariables, getTimeRangeForUrl }: LocationUtilDependencies) => {
grafanaConfig = getConfig;
initialize: ({ config, buildParamsFromVariables, getTimeRangeForUrl }: LocationUtilDependencies) => {
grafanaConfig = config;
getTimeRangeUrlParams = getTimeRangeForUrl;
getVariablesUrlParams = buildParamsFromVariables;
},
@ -68,6 +68,6 @@ export const locationUtil = {
return urlUtil.toUrlParams(params);
},
processUrl: (url: string) => {
return grafanaConfig().disableSanitizeHtml ? url : textUtil.sanitizeUrl(url);
return grafanaConfig.disableSanitizeHtml ? url : textUtil.sanitizeUrl(url);
},
};

View File

@ -23,3 +23,25 @@ describe('toUrlParams', () => {
expect(url).toBe('server=:@');
});
});
describe('parseKeyValue', () => {
it('should parse url search params to object', () => {
const obj = urlUtil.parseKeyValue('param=value&param2=value2&kiosk');
expect(obj).toEqual({ param: 'value', param2: 'value2', kiosk: true });
});
it('should parse same url key multiple times to array', () => {
const obj = urlUtil.parseKeyValue('servers=A&servers=B');
expect(obj).toEqual({ servers: ['A', 'B'] });
});
it('should parse numeric params', () => {
const obj = urlUtil.parseKeyValue('num1=12&num2=12.2');
expect(obj).toEqual({ num1: 12, num2: 12.2 });
});
it('should parse boolean params', () => {
const obj = urlUtil.parseKeyValue('bool1&bool2=true&bool3=false');
expect(obj).toEqual({ bool1: true, bool2: true, bool3: false });
});
});

View File

@ -2,6 +2,7 @@
* @preserve jquery-param (c) 2015 KNOWLEDGECODE | MIT
*/
import _ from 'lodash';
import { ExploreUrlState } from '../types/explore';
/**
@ -125,11 +126,69 @@ function getUrlSearchParams() {
return params;
}
/**
* Parses an escaped url query string into key-value pairs.
* Attribution: Code dervived from https://github.com/angular/angular.js/master/src/Angular.js#L1396
* @returns {Object.<string,boolean|Array>}
*/
export function parseKeyValue(keyValue: string) {
var obj: any = {};
const parts = (keyValue || '').split('&');
for (let keyValue of parts) {
let splitPoint: number | undefined;
let key: string | undefined;
let val: string | undefined | boolean;
if (keyValue) {
key = keyValue = keyValue.replace(/\+/g, '%20');
splitPoint = keyValue.indexOf('=');
if (splitPoint !== -1) {
key = keyValue.substring(0, splitPoint);
val = keyValue.substring(splitPoint + 1);
}
key = tryDecodeURIComponent(key);
if (key !== undefined) {
val = val !== undefined ? tryDecodeURIComponent(val as string) : true;
let parsedVal: any;
if (typeof val === 'string') {
parsedVal = val === 'true' || val === 'false' ? val === 'true' : _.toNumber(val);
} else {
parsedVal = val;
}
if (!obj.hasOwnProperty(key)) {
obj[key] = isNaN(parsedVal) ? val : parsedVal;
} else if (Array.isArray(obj[key])) {
obj[key].push(val);
} else {
obj[key] = [obj[key], isNaN(parsedVal) ? val : parsedVal];
}
}
}
}
return obj;
}
function tryDecodeURIComponent(value: string): string | undefined {
try {
return decodeURIComponent(value);
} catch (e) {
return undefined;
}
}
export const urlUtil = {
renderUrl,
toUrlParams,
appendQueryToUrl,
getUrlSearchParams,
parseKeyValue,
};
export function serializeStateToUrlParam(urlState: ExploreUrlState, compact?: boolean): string {

View File

@ -178,6 +178,9 @@ export const Components = {
dropDown: 'Dashboard link dropdown',
link: 'Dashboard link',
},
LoadingIndicator: {
icon: 'Loading indicator',
},
CallToActionCard: {
button: (name: string) => `Call to action button ${name}`,
},

View File

@ -34,7 +34,11 @@ export const Pages = {
},
Dashboard: {
url: (uid: string) => `/d/${uid}`,
DashNav: {
nav: 'Dashboard navigation',
},
SubMenu: {
submenu: 'Dashboard submenu',
submenuItem: 'Dashboard template variables submenu item',
submenuItemLabels: (item: string) => `Dashboard template variables submenu Label ${item}`,
submenuItemValueDropDownValueLinkTexts: (item: string) =>

View File

@ -25,7 +25,8 @@
"@grafana/data": "7.5.0-pre.0",
"@grafana/ui": "7.5.0-pre.0",
"systemjs": "0.20.19",
"systemjs-plugin-css": "0.1.37"
"systemjs-plugin-css": "0.1.37",
"history": "4.10.1"
},
"devDependencies": {
"@grafana/tsconfig": "^1.0.0-rc1",
@ -34,6 +35,7 @@
"@types/jest": "26.0.15",
"@types/rollup-plugin-visualizer": "2.6.0",
"@types/systemjs": "^0.20.6",
"@types/history": "^4.7.8",
"lodash": "4.17.21",
"pretty-format": "25.1.0",
"rollup": "2.33.3",

View File

@ -0,0 +1,39 @@
import { locationService } from './LocationService';
describe('LocationService', () => {
describe('getSearchObject', () => {
it('returns query string as object', () => {
locationService.push('/test?query1=false&query2=123&query3=text');
expect(locationService.getSearchObject()).toEqual({
query1: false,
query2: 123,
query3: 'text',
});
});
it('returns keys added multiple times as an array', () => {
locationService.push('/test?servers=A&servers=B&servers=C');
expect(locationService.getSearchObject()).toEqual({
servers: ['A', 'B', 'C'],
});
});
});
describe('partial', () => {
it('should handle removing params and updating', () => {
locationService.push('/test?query1=false&query2=123&query3=text');
locationService.partial({ query1: null, query2: 'update' });
expect(locationService.getLocation().search).toBe('?query2=update&query3=text');
});
it('should handle array values', () => {
locationService.push('/');
locationService.partial({ servers: ['A', 'B', 'C'] });
expect(locationService.getLocation().search).toBe('?servers=A&servers=B&servers=C');
});
});
});

View File

@ -0,0 +1,171 @@
import { UrlQueryMap, urlUtil } from '@grafana/data';
import * as H from 'history';
import { LocationUpdate } from './LocationSrv';
import { createLogger } from '@grafana/ui';
import { config } from '../config';
/**
* @alpha
* A wrapper to help work with browser location and history
*/
export interface LocationService {
partial: (query: Record<string, any>, replace?: boolean) => void;
push: (location: H.Path | H.LocationDescriptor<any>) => void;
replace: (location: H.Path | H.LocationDescriptor<any>, forceRouteReload?: boolean) => void;
reload: () => void;
getLocation: () => H.Location;
getHistory: () => H.History;
getSearch: () => URLSearchParams;
getSearchObject: () => UrlQueryMap;
/**
* This is from the old LocationSrv interface
* @deprecated use partial, push or replace instead */
update: (update: LocationUpdate) => void;
}
/** @internal */
export class HistoryWrapper implements LocationService {
private readonly history: H.History;
constructor(history?: H.History) {
// If no history passed create an in memory one if being called from test
this.history =
history || process.env.NODE_ENV === 'test'
? H.createMemoryHistory({ initialEntries: ['/'] })
: H.createBrowserHistory({ basename: config.appSubUrl ?? '/' });
// For debugging purposes the location service is attached to global _debug variable
if (process.env.NODE_ENV !== 'production') {
// @ts-ignore
let debugGlobal = window['_debug'];
if (debugGlobal) {
debugGlobal = {
...debugGlobal,
location: this,
};
} else {
debugGlobal = {
location: this,
};
}
// @ts-ignore
window['_debug'] = debugGlobal;
}
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);
}
getHistory() {
return this.history;
}
getSearch() {
return new URLSearchParams(this.history.location.search);
}
partial(query: Record<string, any>, replace?: boolean) {
const currentLocation = this.history.location;
const newQuery = this.getSearchObject();
for (const key of Object.keys(query)) {
// removing params with null | undefined
if (query[key] === null || query[key] === undefined) {
delete newQuery[key];
} else {
newQuery[key] = query[key];
}
}
const updatedUrl = urlUtil.renderUrl(currentLocation.pathname, newQuery);
if (replace) {
this.history.replace(updatedUrl);
} else {
this.history.push(updatedUrl);
}
}
push(location: H.Path | H.LocationDescriptor) {
this.history.push(location);
}
replace(location: H.Path | H.LocationDescriptor, forceRouteReload?: boolean) {
const state = forceRouteReload ? { forceRouteReload: true } : undefined;
if (typeof location === 'string') {
this.history.replace(location, state);
} else {
this.history.replace({
...location,
state,
});
}
}
reload() {
this.history.replace({
...this.history.location,
state: { forceRouteReload: true },
});
}
getLocation() {
return this.history.location;
}
getSearchObject() {
return locationSearchToObject(this.history.location.search);
}
/** @depecreated */
update(options: LocationUpdate) {
if (options.partial && options.query) {
this.partial(options.query, options.partial);
} else if (options.replace) {
this.replace(options.path!);
} else {
this.push(options.path!);
}
}
}
/**
* @alpha
* Parses a location search string to an object
* */
export function locationSearchToObject(search: string | number): UrlQueryMap {
let queryString = typeof search === 'number' ? String(search) : search;
if (queryString.length > 0) {
if (queryString.startsWith('?')) {
return urlUtil.parseKeyValue(queryString.substring(1));
}
return urlUtil.parseKeyValue(queryString);
}
return {};
}
/**
* @alpha
*/
export let locationService: LocationService = new HistoryWrapper();
/** @internal
* Used for tests only
*/
export const setLocationService = (location: LocationService) => {
if (process.env.NODE_ENV !== 'test') {
throw new Error('locationService can be only overriden in test environment');
}
locationService = location;
};
/** @internal */
export const navigationLogger = createLogger('Router');

View File

@ -6,3 +6,4 @@ export * from './EchoSrv';
export * from './templateSrv';
export * from './legacyAngularInjector';
export * from './live';
export * from './LocationService';

View File

@ -48,6 +48,7 @@
"@visx/scale": "1.4.0",
"@visx/shape": "1.4.0",
"@visx/tooltip": "1.3.0",
"react-router-dom": "^5.2.0",
"classnames": "2.2.6",
"d3": "5.15.0",
"emotion": "10.0.27",
@ -89,6 +90,7 @@
"@storybook/addon-storysource": "6.1.15",
"@storybook/react": "6.1.15",
"@storybook/theming": "6.1.15",
"@types/react-router-dom": "^5.1.7",
"@types/classnames": "2.2.7",
"@types/common-tags": "^1.8.0",
"@types/d3": "5.7.2",

View File

@ -0,0 +1,20 @@
import { locationUtil, textUtil } from '@grafana/data';
import React, { AnchorHTMLAttributes, forwardRef } from 'react';
import { Link as RouterLink } from 'react-router-dom';
export interface Props extends AnchorHTMLAttributes<HTMLAnchorElement> {}
/**
* @alpha
*/
export const Link = forwardRef<HTMLAnchorElement, Props>(({ href, children, ...rest }, ref) => {
const validUrl = locationUtil.stripBaseFromUrl(textUtil.sanitizeUrl(href ?? ''));
return (
<RouterLink ref={ref as React.Ref<HTMLAnchorElement>} to={validUrl} {...rest}>
{children}
</RouterLink>
);
});
Link.displayName = 'Link';

View File

@ -144,6 +144,7 @@ export { Button, LinkButton, ButtonVariant, ToolbarButton, ButtonGroup, ToolbarB
export { ValuePicker } from './ValuePicker/ValuePicker';
export { fieldMatchersUI } from './MatchersUI/fieldMatchersUI';
export { getFormStyles } from './Forms/getFormStyles';
export { Link } from './Link/Link';
export { Label } from './Forms/Label';
export { Field } from './Forms/Field';

View File

@ -1,7 +1,7 @@
import { DataFrame, dateTime, FieldType } from '@grafana/data';
import throttle from 'lodash/throttle';
import { AlignedData, Options } from 'uplot';
import { PlotPlugin, PlotProps } from './types';
import { createLogger } from '../../utils/logger';
const LOGGING_ENABLED = false;
const ALLOWED_FORMAT_STRINGS_REGEX = /\b(YYYY|YY|MMMM|MMM|MM|M|DD|D|WWWW|WWW|HH|H|h|AA|aa|a|mm|m|ss|s|fff)\b/g;
@ -53,15 +53,4 @@ export function preparePlotData(frame: DataFrame): AlignedData {
// Dev helpers
/** @internal */
export const throttledLog = throttle((...t: any[]) => {
console.log(...t);
}, 500);
/** @internal */
export function pluginLog(id: string, throttle = false, ...t: any[]) {
if (process.env.NODE_ENV === 'production' || !LOGGING_ENABLED) {
return;
}
const fn = throttle ? throttledLog : console.log;
fn(`[Plugin: ${id}]: `, ...t);
}
export const pluginLog = createLogger('uPlot Plugin', LOGGING_ENABLED);

View File

@ -10,3 +10,4 @@ export { default as ansicolor } from './ansicolor';
import * as DOMUtil from './dom'; // includes Element.closest polyfill
export { DOMUtil };
export { renderOrCallToRender } from './renderOrCallToRender';
export { createLogger } from './logger';

View File

@ -0,0 +1,15 @@
import throttle from 'lodash/throttle';
/** @internal */
const throttledLog = throttle((...t: any[]) => {
console.log(...t);
}, 500);
/** @internal */
export const createLogger = (name: string, enable = true) => (id: string, throttle = false, ...t: any[]) => {
if (process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'test' || !enable) {
return;
}
const fn = throttle ? throttledLog : console.log;
fn(`[${name}: ${id}]: `, ...t);
};

112
public/app/AppWrapper.tsx Normal file
View File

@ -0,0 +1,112 @@
import React from 'react';
import { Router, Route, Redirect, Switch } from 'react-router-dom';
import { config, locationService, navigationLogger } from '@grafana/runtime';
import { Provider } from 'react-redux';
import { store } from 'app/store/store';
import { ErrorBoundaryAlert, ModalRoot, ModalsProvider } from '@grafana/ui';
import { GrafanaApp } from './app';
import { getAppRoutes } from 'app/routes/routes';
import { ConfigContext, ThemeProvider } from './core/utils/ConfigProvider';
import { RouteDescriptor } from './core/navigation/types';
import { contextSrv } from './core/services/context_srv';
import { SideMenu } from './core/components/sidemenu/SideMenu';
import { GrafanaRoute } from './core/navigation/GrafanaRoute';
import { AppNotificationList } from './core/components/AppNotifications/AppNotificationList';
import { SearchWrapper } from 'app/features/search';
interface AppWrapperProps {
app: GrafanaApp;
}
interface AppWrapperState {
ngInjector: any;
}
export class AppWrapper extends React.Component<AppWrapperProps, AppWrapperState> {
container = React.createRef<HTMLDivElement>();
constructor(props: AppWrapperProps) {
super(props);
this.state = {
ngInjector: null,
};
}
componentDidMount() {
if (this.container) {
this.bootstrapNgApp();
} else {
throw new Error('Failed to boot angular app, no container to attach to');
}
}
bootstrapNgApp() {
const injector = this.props.app.angularApp.bootstrap();
this.setState({ ngInjector: injector });
}
renderRoute = (route: RouteDescriptor) => {
const roles = route.roles ? route.roles() : [];
return (
<Route
exact
path={route.path}
key={route.path}
render={(props) => {
navigationLogger('AppWrapper', false, 'Rendering route', route, 'with match', props.location);
// TODO[Router]: test this logic
if (roles?.length) {
if (!roles.some((r: string) => contextSrv.hasRole(r))) {
return <Redirect to="/" />;
}
}
return <GrafanaRoute {...props} route={route} />;
}}
/>
);
};
renderRoutes() {
return <Switch>{getAppRoutes().map((r) => this.renderRoute(r))}</Switch>;
}
render() {
navigationLogger('AppWrapper', false, 'rendering');
// @ts-ignore
const appSeed = `<grafana-app ng-cloak></app-notifications-list><div id="ngRoot"></div></grafana-app>`;
return (
<Provider store={store}>
<ErrorBoundaryAlert style="page">
<ConfigContext.Provider value={config}>
<ThemeProvider>
<ModalsProvider>
<div className="grafana-app">
<Router history={locationService.getHistory()}>
<SideMenu />
<div className="main-view">
<div
ref={this.container}
dangerouslySetInnerHTML={{
__html: appSeed,
}}
/>
<AppNotificationList />
<SearchWrapper />
{this.state.ngInjector && this.container && this.renderRoutes()}
</div>
</Router>
</div>
<ModalRoot />
</ModalsProvider>
</ThemeProvider>
</ConfigContext.Provider>
</ErrorBoundaryAlert>
</Provider>
);
}
}

View File

@ -0,0 +1,118 @@
import angular from 'angular';
import 'angular-route';
import 'angular-sanitize';
import 'angular-bindonce';
import 'vendor/bootstrap/bootstrap';
import 'vendor/angular-other/angular-strap';
import { config } from 'app/core/config';
import { angularModules } from 'app/core/core_module';
import { DashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv';
import { registerAngularDirectives } from 'app/core/core';
import { initAngularRoutingBridge } from 'app/angular/bridgeReactAngularRouting';
import { monkeyPatchInjectorWithPreAssignedBindings } from 'app/core/injectorMonkeyPatch';
import { extend } from 'lodash';
export class AngularApp {
ngModuleDependencies: any[];
preBootModules: any[];
registerFunctions: any;
constructor() {
this.preBootModules = [];
this.ngModuleDependencies = [];
this.registerFunctions = {};
}
init() {
const app = angular.module('grafana', []);
app.config(
(
$controllerProvider: angular.IControllerProvider,
$compileProvider: angular.ICompileProvider,
$filterProvider: angular.IFilterProvider,
$httpProvider: angular.IHttpProvider,
$provide: angular.auto.IProvideService
) => {
if (config.buildInfo.env !== 'development') {
$compileProvider.debugInfoEnabled(false);
}
$httpProvider.useApplyAsync(true);
this.registerFunctions.controller = $controllerProvider.register;
this.registerFunctions.directive = $compileProvider.directive;
this.registerFunctions.factory = $provide.factory;
this.registerFunctions.service = $provide.service;
this.registerFunctions.filter = $filterProvider.register;
$provide.decorator('$http', [
'$delegate',
'$templateCache',
($delegate: any, $templateCache: any) => {
const get = $delegate.get;
$delegate.get = (url: string, config: any) => {
if (url.match(/\.html$/)) {
// some template's already exist in the cache
if (!$templateCache.get(url)) {
url += '?v=' + new Date().getTime();
}
}
return get(url, config);
};
return $delegate;
},
]);
}
);
this.ngModuleDependencies = [
'grafana.core',
'ngSanitize',
'$strap.directives',
'grafana',
'pasvaz.bindonce',
'react',
];
// makes it possible to add dynamic stuff
angularModules.forEach((m: angular.IModule) => {
this.useModule(m);
});
// register react angular wrappers
angular.module('grafana.services').service('dashboardLoaderSrv', DashboardLoaderSrv);
registerAngularDirectives();
initAngularRoutingBridge();
}
useModule(module: angular.IModule) {
if (this.preBootModules) {
this.preBootModules.push(module);
} else {
extend(module, this.registerFunctions);
}
this.ngModuleDependencies.push(module.name);
return module;
}
bootstrap() {
const injector = angular.bootstrap(document, this.ngModuleDependencies);
monkeyPatchInjectorWithPreAssignedBindings(injector);
console.log('Angular app bootstrap');
injector.invoke(() => {
this.preBootModules.forEach((module) => {
extend(module, this.registerFunctions);
});
// I don't know
return () => {};
});
return injector;
}
}

View File

@ -0,0 +1,201 @@
import { AngularLocationWrapper } from './AngularLocationWrapper';
import { HistoryWrapper, locationService, setLocationService } from '@grafana/runtime';
describe('AngularLocationWrapper', () => {
const { location } = window;
beforeEach(() => {
setLocationService(new HistoryWrapper());
});
beforeAll(() => {
// @ts-ignore
delete window.location;
window.location = {
...location,
hash: '#hash',
host: 'localhost:3000',
hostname: 'localhost',
href: 'http://www.domain.com:9877/path/b?search=a&b=c&d#hash',
origin: 'http://www.domain.com:9877',
pathname: '/path/b',
port: '9877',
protocol: 'http:',
search: '?search=a&b=c&d',
};
});
afterAll(() => {
window.location = location;
});
const wrapper = new AngularLocationWrapper();
it('should provide common getters', () => {
locationService.push('/path/b?search=a&b=c&d#hash');
expect(wrapper.absUrl()).toBe('http://www.domain.com:9877/path/b?search=a&b=c&d#hash');
expect(wrapper.protocol()).toBe('http');
expect(wrapper.host()).toBe('www.domain.com');
expect(wrapper.port()).toBe(9877);
expect(wrapper.path()).toBe('/path/b');
expect(wrapper.search()).toEqual({ search: 'a', b: 'c', d: true });
expect(wrapper.hash()).toBe('hash');
expect(wrapper.url()).toBe('/path/b?search=a&b=c&d#hash');
});
describe('path', () => {
it('should change path', function () {
locationService.push('/path/b?search=a&b=c&d#hash');
wrapper.path('/new/path');
expect(wrapper.path()).toBe('/new/path');
expect(wrapper.absUrl()).toBe('http://www.domain.com:9877/new/path?search=a&b=c&d#hash');
});
it('should not break on numeric values', function () {
locationService.push('/path/b?search=a&b=c&d#hash');
wrapper.path(1);
expect(wrapper.path()).toBe('/1');
expect(wrapper.absUrl()).toBe('http://www.domain.com:9877/1?search=a&b=c&d#hash');
});
it('should allow using 0 as path', function () {
locationService.push('/path/b?search=a&b=c&d#hash');
wrapper.path(0);
expect(wrapper.path()).toBe('/0');
expect(wrapper.absUrl()).toBe('http://www.domain.com:9877/0?search=a&b=c&d#hash');
});
it('should set to empty path on null value', function () {
locationService.push('/path/b?search=a&b=c&d#hash');
wrapper.path('/foo');
expect(wrapper.path()).toBe('/foo');
wrapper.path(null);
expect(wrapper.path()).toBe('/');
});
});
describe('search', () => {
it('should accept string', function () {
locationService.push('/path/b');
wrapper.search('x=y&c');
expect(wrapper.search()).toEqual({ x: 'y', c: true });
expect(wrapper.absUrl()).toBe('http://www.domain.com:9877/path/b?x=y&c');
});
it('search() should accept object', function () {
locationService.push('/path/b');
wrapper.search({ one: 1, two: true });
expect(wrapper.search()).toEqual({ one: 1, two: true });
expect(wrapper.absUrl()).toBe('http://www.domain.com:9877/path/b?one=1&two');
});
it('should copy object', function () {
locationService.push('/path/b');
const obj: Record<string, any> = { one: 1, two: true, three: null };
wrapper.search(obj);
expect(obj).toEqual({ one: 1, two: true, three: null });
obj.one = 'changed';
expect(wrapper.search()).toEqual({ one: 1, two: true });
expect(wrapper.absUrl()).toBe('http://www.domain.com:9877/path/b?one=1&two');
});
it('should change single parameter', function () {
wrapper.search({ id: 'old', preserved: true });
wrapper.search('id', 'new');
expect(wrapper.search()).toEqual({ id: 'new', preserved: true });
});
it('should remove single parameter', function () {
wrapper.search({ id: 'old', preserved: true });
wrapper.search('id', null);
expect(wrapper.search()).toEqual({ preserved: true });
});
it('should remove multiple parameters', function () {
locationService.push('/path/b');
wrapper.search({ one: 1, two: true });
expect(wrapper.search()).toEqual({ one: 1, two: true });
wrapper.search({ one: null, two: null });
expect(wrapper.search()).toEqual({});
expect(wrapper.absUrl()).toBe('http://www.domain.com:9877/path/b');
});
it('should accept numeric keys', function () {
locationService.push('/path/b');
wrapper.search({ 1: 'one', 2: 'two' });
expect(wrapper.search()).toEqual({ '1': 'one', '2': 'two' });
expect(wrapper.absUrl()).toBe('http://www.domain.com:9877/path/b?1=one&2=two');
});
it('should handle multiple value', function () {
wrapper.search('a&b');
expect(wrapper.search()).toEqual({ a: true, b: true });
wrapper.search('a', null);
expect(wrapper.search()).toEqual({ b: true });
wrapper.search('b', undefined);
expect(wrapper.search()).toEqual({});
});
it('should handle single value', function () {
wrapper.search('ignore');
expect(wrapper.search()).toEqual({ ignore: true });
wrapper.search(1);
expect(wrapper.search()).toEqual({ 1: true });
});
});
describe('url', () => {
it('should change the path, search and hash', function () {
wrapper.url('/some/path?a=b&c=d#hhh');
expect(wrapper.url()).toBe('/some/path?a=b&c=d#hhh');
expect(wrapper.absUrl()).toBe('http://www.domain.com:9877/some/path?a=b&c=d#hhh');
expect(wrapper.path()).toBe('/some/path');
expect(wrapper.search()).toEqual({ a: 'b', c: 'd' });
expect(wrapper.hash()).toBe('hhh');
});
it('should change only hash when no search and path specified', function () {
locationService.push('/path/b?search=a&b=c&d');
wrapper.url('#some-hash');
expect(wrapper.hash()).toBe('some-hash');
expect(wrapper.url()).toBe('/path/b?search=a&b=c&d#some-hash');
expect(wrapper.absUrl()).toBe('http://www.domain.com:9877/path/b?search=a&b=c&d#some-hash');
});
it('should change only search and hash when no path specified', function () {
locationService.push('/path/b');
wrapper.url('?a=b');
expect(wrapper.search()).toEqual({ a: 'b' });
expect(wrapper.hash()).toBe('');
expect(wrapper.path()).toBe('/path/b');
});
it('should reset search and hash when only path specified', function () {
locationService.push('/path/b?search=a&b=c&d#hash');
wrapper.url('/new/path');
expect(wrapper.path()).toBe('/new/path');
expect(wrapper.search()).toEqual({});
expect(wrapper.hash()).toBe('');
});
it('should change path when empty string specified', function () {
locationService.push('/path/b?search=a&b=c&d#hash');
wrapper.url('');
expect(wrapper.path()).toBe('/');
expect(wrapper.search()).toEqual({});
expect(wrapper.hash()).toBe('');
});
});
});

View File

@ -0,0 +1,127 @@
import { locationSearchToObject, locationService, navigationLogger } from '@grafana/runtime';
import { urlUtil } from '@grafana/data';
// Ref: https://github.com/angular/angular.js/blob/ae8e903edf88a83fedd116ae02c0628bf72b150c/src/ng/location.js#L5
const DEFAULT_PORTS: Record<string, number> = { http: 80, https: 443, ftp: 21 };
export class AngularLocationWrapper {
absUrl(): string {
return `${window.location.origin}${this.url()}`;
}
hash(newHash?: any) {
navigationLogger('AngularLocationWrapper', false, 'Angular compat layer: hash');
if (!newHash) {
return locationService.getLocation().hash.substr(1);
} else {
throw new Error('AngularLocationWrapper method not implemented.');
}
}
host(): string {
return new URL(window.location.href).hostname;
}
path(pathname?: any) {
navigationLogger('AngularLocationWrapper', false, 'Angular compat layer: path');
const location = locationService.getLocation();
if (pathname !== undefined && pathname !== null) {
let parsedPath = String(pathname);
parsedPath = parsedPath.startsWith('/') ? parsedPath : `/${parsedPath}`;
const url = new URL(`${window.location.origin}${parsedPath}`);
locationService.push({
pathname: url.pathname,
search: url.search.length > 0 ? url.search : location.search,
hash: url.hash.length > 0 ? url.hash : location.hash,
});
return this;
}
if (pathname === null) {
locationService.push('/');
return this;
}
return location.pathname;
}
port(): number | null {
const url = new URL(window.location.href);
return parseInt(url.port, 10) || DEFAULT_PORTS[url.protocol] || null;
}
protocol(): string {
return new URL(window.location.href).protocol.slice(0, -1);
}
replace() {
throw new Error('AngularLocationWrapper method not implemented.');
}
search(search?: any, paramValue?: any) {
navigationLogger('AngularLocationWrapper', false, 'Angular compat layer: search');
if (!search) {
return locationService.getSearchObject();
}
if (search && arguments.length > 1) {
locationService.partial({
[search]: paramValue,
});
return this;
}
if (search) {
let newQuery;
if (typeof search === 'object') {
newQuery = { ...search };
} else {
newQuery = locationSearchToObject(search);
}
for (const key of Object.keys(newQuery)) {
// removing params with null | undefined
if (newQuery[key] === null || newQuery[key] === undefined) {
delete newQuery[key];
}
}
const updatedUrl = urlUtil.renderUrl(locationService.getLocation().pathname, newQuery);
locationService.push(updatedUrl);
}
return this;
}
state(state?: any) {
navigationLogger('AngularLocationWrapper', false, 'Angular compat layer: state');
throw new Error('AngularLocationWrapper method not implemented.');
}
url(newUrl?: any) {
navigationLogger('AngularLocationWrapper', false, 'Angular compat layer: url');
if (newUrl !== undefined) {
if (newUrl.startsWith('#')) {
locationService.push({ ...locationService.getLocation(), hash: newUrl });
} else if (newUrl.startsWith('?')) {
locationService.push({ ...locationService.getLocation(), search: newUrl });
} else if (newUrl.trim().length === 0) {
console.log('pushing emptu');
locationService.push('/');
} else {
locationService.push(newUrl);
}
return locationService;
}
const location = locationService.getLocation();
return `${location.pathname}${location.search}${location.hash}`;
}
}

View File

@ -0,0 +1,47 @@
import { coreModule } from '../core/core_module';
import { RouteProvider } from '../core/navigation/patch/RouteProvider';
import { RouteParamsProvider } from '../core/navigation/patch/RouteParamsProvider';
import { ILocationService } from 'angular';
import { AngularLocationWrapper } from './AngularLocationWrapper';
// Neutralizing Angulars location tampering
// https://stackoverflow.com/a/19825756
const tamperAngularLocation = () => {
coreModule.config([
'$provide',
($provide: any) => {
$provide.decorator('$browser', [
'$delegate',
($delegate: any) => {
$delegate.onUrlChange = () => {};
$delegate.url = () => '';
return $delegate;
},
]);
},
]);
};
// Intercepting $location service with implementation based on history
const interceptAngularLocation = () => {
coreModule.config([
'$provide',
($provide: any) => {
$provide.decorator('$location', [
'$delegate',
($delegate: ILocationService) => {
$delegate = new AngularLocationWrapper() as ILocationService;
return $delegate;
},
]);
},
]);
coreModule.provider('$route', RouteProvider);
coreModule.provider('$routeParams', RouteParamsProvider);
};
export function initAngularRoutingBridge() {
tamperAngularLocation();
interceptAngularLocation();
}

View File

@ -10,32 +10,19 @@ import ttiPolyfill from 'tti-polyfill';
import 'file-saver';
import 'jquery';
import _ from 'lodash';
import angular from 'angular';
import 'angular-route';
import 'angular-sanitize';
import 'angular-bindonce';
import 'react';
import 'react-dom';
import 'vendor/bootstrap/bootstrap';
import 'vendor/angular-other/angular-strap';
import ReactDOM from 'react-dom';
import React from 'react';
import config from 'app/core/config';
// @ts-ignore ignoring this for now, otherwise we would have to extend _ interface with move
import {
AppEvents,
setLocale,
setTimeZoneResolver,
standardEditorsRegistry,
standardFieldConfigEditorRegistry,
standardTransformersRegistry,
} from '@grafana/data';
import appEvents from 'app/core/app_events';
import { checkBrowserCompatibility } from 'app/core/utils/browser';
import { arrayMove } from 'app/core/utils/arrayMove';
import { importPluginModule } from 'app/features/plugins/plugin_loader';
import { angularModules, coreModule } from 'app/core/core_module';
import { registerAngularDirectives } from 'app/core/core';
import { setupAngularRoutes } from 'app/routes/routes';
import { registerEchoBackend, setEchoSrv } from '@grafana/runtime';
import { Echo } from './core/services/echo/Echo';
import { reportPerformance } from './core/services/echo/EchoSrv';
@ -47,8 +34,11 @@ import { getDefaultVariableAdapters, variableAdapters } from './features/variabl
import { initDevFeatures } from './dev';
import { getStandardTransformers } from 'app/core/utils/standardTransformers';
import { SentryEchoBackend } from './core/services/echo/backends/sentry/SentryBackend';
import { monkeyPatchInjectorWithPreAssignedBindings } from './core/injectorMonkeyPatch';
import { setVariableQueryRunner, VariableQueryRunner } from './features/variables/query/VariableQueryRunner';
import { configureStore } from './store/configureStore';
import { AppWrapper } from './AppWrapper';
import { interceptLinkClicks } from './core/navigation/patch/interceptLinkClicks';
import { AngularApp } from './angular/AngularApp';
// add move to lodash for backward compatabilty with plugins
// @ts-ignore
@ -56,8 +46,8 @@ _.move = arrayMove;
// import symlinked extensions
const extensionsIndex = (require as any).context('.', true, /extensions\/index.ts/);
extensionsIndex.keys().forEach((key: any) => {
extensionsIndex(key);
const extensionsExports = extensionsIndex.keys().map((key: any) => {
return extensionsIndex(key);
});
if (process.env.NODE_ENV === 'development') {
@ -65,32 +55,20 @@ if (process.env.NODE_ENV === 'development') {
}
export class GrafanaApp {
registerFunctions: any;
ngModuleDependencies: any[];
preBootModules: any[] | null;
angularApp: AngularApp;
constructor() {
this.preBootModules = [];
this.registerFunctions = {};
this.ngModuleDependencies = [];
}
useModule(module: angular.IModule) {
if (this.preBootModules) {
this.preBootModules.push(module);
} else {
_.extend(module, this.registerFunctions);
}
this.ngModuleDependencies.push(module.name);
return module;
this.angularApp = new AngularApp();
}
init() {
const app = angular.module('grafana', []);
initEchoSrv();
addClassIfNoOverlayScrollbar();
setLocale(config.bootData.user.locale);
setTimeZoneResolver(() => config.bootData.user.timezone);
// Important that extensions are initialized before store
initExtensions();
configureStore();
standardEditorsRegistry.setInit(getStandardOptionEditors);
standardFieldConfigEditorRegistry.setInit(getStandardFieldConfigs);
@ -99,97 +77,38 @@ export class GrafanaApp {
setVariableQueryRunner(new VariableQueryRunner());
app.config(
(
$controllerProvider: angular.IControllerProvider,
$compileProvider: angular.ICompileProvider,
$filterProvider: angular.IFilterProvider,
$httpProvider: angular.IHttpProvider,
$provide: angular.auto.IProvideService
) => {
if (config.buildInfo.env !== 'development') {
$compileProvider.debugInfoEnabled(false);
}
$httpProvider.useApplyAsync(true);
this.registerFunctions.controller = $controllerProvider.register;
this.registerFunctions.directive = $compileProvider.directive;
this.registerFunctions.factory = $provide.factory;
this.registerFunctions.service = $provide.service;
this.registerFunctions.filter = $filterProvider.register;
$provide.decorator('$http', [
'$delegate',
'$templateCache',
($delegate: any, $templateCache: any) => {
const get = $delegate.get;
$delegate.get = (url: string, config: any) => {
if (url.match(/\.html$/)) {
// some template's already exist in the cache
if (!$templateCache.get(url)) {
url += '?v=' + new Date().getTime();
}
}
return get(url, config);
};
return $delegate;
},
]);
}
);
this.ngModuleDependencies = [
'grafana.core',
'ngRoute',
'ngSanitize',
'$strap.directives',
'grafana',
'pasvaz.bindonce',
'react',
];
// makes it possible to add dynamic stuff
_.each(angularModules, (m: angular.IModule) => {
this.useModule(m);
});
// register react angular wrappers
coreModule.config(setupAngularRoutes);
registerAngularDirectives();
// intercept anchor clicks and forward it to custom history instead of relying on browser's history
document.addEventListener('click', interceptLinkClicks);
// disable tool tip animation
$.fn.tooltip.defaults.animation = false;
// bootstrap the app
const injector: any = angular.bootstrap(document, this.ngModuleDependencies);
injector.invoke(() => {
_.each(this.preBootModules, (module: angular.IModule) => {
_.extend(module, this.registerFunctions);
});
this.preBootModules = null;
if (!checkBrowserCompatibility()) {
setTimeout(() => {
appEvents.emit(AppEvents.alertWarning, [
'Your browser is not fully supported',
'A newer browser version is recommended',
]);
}, 1000);
}
});
monkeyPatchInjectorWithPreAssignedBindings(injector);
this.angularApp.init();
// Preload selected app plugins
const promises = [];
for (const modulePath of config.pluginsToPreload) {
importPluginModule(modulePath);
promises.push(importPluginModule(modulePath));
}
Promise.all(promises).then(() => {
ReactDOM.render(
React.createElement(AppWrapper, {
app: this,
}),
document.getElementById('reactRoot')
);
});
}
}
initEchoSrv() {
function initExtensions() {
if (extensionsExports.length > 0) {
extensionsExports[0].init();
}
}
function initEchoSrv() {
setEchoSrv(new Echo({ debug: process.env.NODE_ENV === 'development' }));
ttiPolyfill.getFirstConsistentlyInteractive().then((tti: any) => {
@ -217,7 +136,6 @@ export class GrafanaApp {
reportPerformance('dcl', Math.round(performance.now()));
});
}
}
function addClassIfNoOverlayScrollbar() {
if (getScrollbarWidth() > 0) {

View File

@ -1,5 +1,3 @@
import { clearAppNotification, notifyApp } from '../reducers/appNotification';
import { updateLocation } from '../reducers/location';
import { updateNavIndex, updateConfigurationSubtitle } from '../reducers/navModel';
export { updateLocation, updateNavIndex, updateConfigurationSubtitle, notifyApp, clearAppNotification };
export { updateNavIndex, updateConfigurationSubtitle, notifyApp, clearAppNotification };

View File

@ -5,9 +5,7 @@ import { AnnotationQueryEditor as CloudWatchAnnotationQueryEditor } from 'app/pl
import PageHeader from './components/PageHeader/PageHeader';
import EmptyListCTA from './components/EmptyListCTA/EmptyListCTA';
import { TagFilter } from './components/TagFilter/TagFilter';
import { SideMenu } from './components/sidemenu/SideMenu';
import { MetricSelect } from './components/Select/MetricSelect';
import AppNotificationList from './components/AppNotifications/AppNotificationList';
import {
ColorPicker,
DataLinksInlineEditor,
@ -24,7 +22,7 @@ import { LokiAnnotationsQueryEditor } from '../plugins/datasource/loki/component
import { HelpModal } from './components/help/HelpModal';
import { Footer } from './components/Footer/Footer';
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
import { SearchField, SearchResults, SearchResultsFilter, SearchWrapper } from '../features/search';
import { SearchField, SearchResults, SearchResultsFilter } from '../features/search';
const { SecretFormField } = LegacyForms;
@ -38,9 +36,7 @@ export function registerAngularDirectives() {
]);
react2AngularDirective('spinner', Spinner, ['inline']);
react2AngularDirective('helpModal', HelpModal, []);
react2AngularDirective('sidemenu', SideMenu, []);
react2AngularDirective('functionEditor', FunctionEditor, ['func', 'onRemove', 'onMoveLeft', 'onMoveRight']);
react2AngularDirective('appNotificationsList', AppNotificationList, []);
react2AngularDirective('pageHeader', PageHeader, ['model', 'noTabs']);
react2AngularDirective('emptyListCta', EmptyListCTA, [
'title',
@ -84,7 +80,6 @@ export function registerAngularDirectives() {
['onStarredFilterChange', { watchDepth: 'reference' }],
['onTagFilterChange', { watchDepth: 'reference' }],
]);
react2AngularDirective('searchWrapper', SearchWrapper, []);
react2AngularDirective('tagFilter', TagFilter, [
'tags',
['onChange', { watchDepth: 'reference' }],

View File

@ -2,8 +2,7 @@ import React, { PureComponent } from 'react';
import appEvents from 'app/core/app_events';
import AppNotificationItem from './AppNotificationItem';
import { notifyApp, clearAppNotification } from 'app/core/actions';
import { connectWithStore } from 'app/core/utils/connectWithReduxStore';
import { AppNotification, StoreState } from 'app/types';
import { StoreState } from 'app/types';
import {
createErrorNotification,
@ -11,14 +10,24 @@ import {
createWarningNotification,
} from '../../copy/appNotification';
import { AppEvents } from '@grafana/data';
import { connect, ConnectedProps } from 'react-redux';
export interface Props {
appNotifications: AppNotification[];
notifyApp: typeof notifyApp;
clearAppNotification: typeof clearAppNotification;
}
export interface OwnProps {}
export class AppNotificationList extends PureComponent<Props> {
const mapStateToProps = (state: StoreState, props: OwnProps) => ({
appNotifications: state.appNotifications.appNotifications,
});
const mapDispatchToProps = {
notifyApp,
clearAppNotification,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
export type Props = OwnProps & ConnectedProps<typeof connector>;
export class AppNotificationListUnConnected extends PureComponent<Props> {
componentDidMount() {
const { notifyApp } = this.props;
@ -35,7 +44,7 @@ export class AppNotificationList extends PureComponent<Props> {
const { appNotifications } = this.props;
return (
<div>
<div className="page-alert-list">
{appNotifications.map((appNotification, index) => {
return (
<AppNotificationItem
@ -50,13 +59,4 @@ export class AppNotificationList extends PureComponent<Props> {
}
}
const mapStateToProps = (state: StoreState) => ({
appNotifications: state.appNotifications.appNotifications,
});
const mapDispatchToProps = {
notifyApp,
clearAppNotification,
};
export default connectWithStore(AppNotificationList, mapStateToProps, mapDispatchToProps);
export const AppNotificationList = connector(AppNotificationListUnConnected);

View File

@ -2,6 +2,7 @@ import React from 'react';
import Loadable from 'react-loadable';
import { LoadingChunkPlaceHolder } from './LoadingChunkPlaceHolder';
import { ErrorLoadingChunk } from './ErrorLoadingChunk';
import { GrafanaRouteComponent } from 'app/core/navigation/types';
export const loadComponentHandler = (props: { error: Error; pastDelay: boolean }) => {
const { error, pastDelay } = props;
@ -17,11 +18,8 @@ export const loadComponentHandler = (props: { error: Error; pastDelay: boolean }
return null;
};
export const SafeDynamicImport = (importStatement: Promise<any>) => ({ ...props }) => {
const LoadableComponent = Loadable({
loader: () => importStatement,
export const SafeDynamicImport = (loader: () => Promise<any>): GrafanaRouteComponent =>
Loadable({
loader: loader,
loading: loadComponentHandler,
});
return <LoadableComponent {...props} />;
};

View File

@ -2,12 +2,17 @@ import React, { FC } from 'react';
import { LoginLayout, InnerBox } from '../Login/LoginLayout';
import { ChangePassword } from './ChangePassword';
import LoginCtrl from '../Login/LoginCtrl';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
export const ChangePasswordPage: FC = () => {
interface Props extends GrafanaRouteComponentProps<{}, { code: string }> {}
export const ChangePasswordPage: FC<Props> = (props) => {
return (
<LoginLayout>
<InnerBox>
<LoginCtrl>{({ changePassword }) => <ChangePassword onSubmit={changePassword} />}</LoginCtrl>
<LoginCtrl resetCode={props.queryParams.code}>
{({ changePassword }) => <ChangePassword onSubmit={changePassword} />}
</LoginCtrl>
</InnerBox>
</LoginLayout>
);

View File

@ -1,11 +1,6 @@
import React, { PureComponent } from 'react';
import config from 'app/core/config';
import { updateLocation } from 'app/core/actions';
import { connect } from 'react-redux';
import { StoreState } from 'app/types';
import { getBackendSrv } from '@grafana/runtime';
import { hot } from 'react-hot-loader';
import appEvents from 'app/core/app_events';
import { AppEvents } from '@grafana/data';
@ -18,9 +13,10 @@ export interface FormModel {
password: string;
email: string;
}
interface Props {
routeParams?: any;
updateLocation?: typeof updateLocation;
resetCode?: string;
children: (props: {
isLoggingIn: boolean;
changePassword: (pw: string) => void;
@ -44,6 +40,7 @@ interface State {
export class LoginCtrl extends PureComponent<Props, State> {
result: any = {};
constructor(props: Props) {
super(props);
this.state = {
@ -62,7 +59,8 @@ export class LoginCtrl extends PureComponent<Props, State> {
confirmNew: password,
oldPassword: 'admin',
};
if (!this.props.routeParams.code) {
if (!this.props.resetCode) {
getBackendSrv()
.put('/api/user/password', pw)
.then(() => {
@ -72,7 +70,7 @@ export class LoginCtrl extends PureComponent<Props, State> {
}
const resetModel = {
code: this.props.routeParams.code,
code: this.props.resetCode,
newPassword: password,
confirmPassword: password,
};
@ -153,10 +151,4 @@ export class LoginCtrl extends PureComponent<Props, State> {
}
}
export const mapStateToProps = (state: StoreState) => ({
routeParams: state.location.routeParams,
});
const mapDispatchToProps = { updateLocation };
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(LoginCtrl));
export default LoginCtrl;

View File

@ -1,126 +0,0 @@
import React, { FC } from 'react';
import { connect, MapStateToProps } from 'react-redux';
import { StoreState } from 'app/types';
import { Form, Field, Input, Button, HorizontalGroup, LinkButton } from '@grafana/ui';
import { getConfig } from 'app/core/config';
import { getBackendSrv } from '@grafana/runtime';
import appEvents from 'app/core/app_events';
import { AppEvents } from '@grafana/data';
interface SignupDTO {
name?: string;
email: string;
username: string;
orgName?: string;
password: string;
code: string;
confirm?: string;
}
interface ConnectedProps {
email?: string;
code?: string;
}
const SignupUnconnected: FC<ConnectedProps> = (props) => {
const onSubmit = async (formData: SignupDTO) => {
if (formData.name === '') {
delete formData.name;
}
delete formData.confirm;
const response = await getBackendSrv()
.post('/api/user/signup/step2', {
email: formData.email,
code: formData.code,
username: formData.email,
orgName: formData.orgName,
password: formData.password,
name: formData.name,
})
.catch((err) => {
const msg = err.data?.message || err;
appEvents.emit(AppEvents.alertWarning, [msg]);
});
if (response.code === 'redirect-to-select-org') {
window.location.href = getConfig().appSubUrl + '/profile/select-org?signup=1';
}
window.location.href = getConfig().appSubUrl + '/';
};
const defaultValues = {
email: props.email,
code: props.code,
};
return (
<Form defaultValues={defaultValues} onSubmit={onSubmit}>
{({ errors, register, getValues }) => (
<>
<Field label="Your name">
<Input name="name" placeholder="(optional)" ref={register} />
</Field>
<Field label="Email" invalid={!!errors.email} error={errors.email?.message}>
<Input
name="email"
type="email"
placeholder="Email"
ref={register({
required: 'Email is required',
pattern: {
value: /^\S+@\S+$/,
message: 'Email is invalid',
},
})}
/>
</Field>
{!getConfig().autoAssignOrg && (
<Field label="Org. name">
<Input name="orgName" placeholder="Org. name" ref={register} />
</Field>
)}
{getConfig().verifyEmailEnabled && (
<Field label="Email verification code (sent to your email)">
<Input name="code" ref={register} placeholder="Code" />
</Field>
)}
<Field label="Password" invalid={!!errors.password} error={errors?.password?.message}>
<Input
autoFocus
type="password"
name="password"
ref={register({
required: 'Password is required',
})}
/>
</Field>
<Field label="Confirm password" invalid={!!errors.confirm} error={errors?.confirm?.message}>
<Input
type="password"
name="confirm"
ref={register({
required: 'Confirmed password is required',
validate: (v) => v === getValues().password || 'Passwords must match!',
})}
/>
</Field>
<HorizontalGroup>
<Button type="submit">Submit</Button>
<LinkButton variant="link" href={getConfig().appSubUrl + '/login'}>
Back to login
</LinkButton>
</HorizontalGroup>
</>
)}
</Form>
);
};
const mapStateToProps: MapStateToProps<ConnectedProps, {}, StoreState> = (state: StoreState) => ({
email: state.location.routeParams.email?.toString(),
code: state.location.routeParams.code?.toString(),
});
export const Signup = connect(mapStateToProps)(SignupUnconnected);

View File

@ -1,12 +1,124 @@
import React, { FC } from 'react';
import { LoginLayout, InnerBox } from '../Login/LoginLayout';
import { Signup } from './Signup';
import { Form, Field, Input, Button, HorizontalGroup, LinkButton } from '@grafana/ui';
import { getConfig } from 'app/core/config';
import { getBackendSrv } from '@grafana/runtime';
import appEvents from 'app/core/app_events';
import { AppEvents } from '@grafana/data';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { InnerBox, LoginLayout } from '../Login/LoginLayout';
interface SignupDTO {
name?: string;
email: string;
username: string;
orgName?: string;
password: string;
code: string;
confirm?: string;
}
interface QueryParams {
email?: string;
code?: string;
}
interface Props extends GrafanaRouteComponentProps<{}, QueryParams> {}
export const SignupPage: FC<Props> = (props) => {
const onSubmit = async (formData: SignupDTO) => {
if (formData.name === '') {
delete formData.name;
}
delete formData.confirm;
const response = await getBackendSrv()
.post('/api/user/signup/step2', {
email: formData.email,
code: formData.code,
username: formData.email,
orgName: formData.orgName,
password: formData.password,
name: formData.name,
})
.catch((err) => {
const msg = err.data?.message || err;
appEvents.emit(AppEvents.alertWarning, [msg]);
});
if (response.code === 'redirect-to-select-org') {
window.location.href = getConfig().appSubUrl + '/profile/select-org?signup=1';
}
window.location.href = getConfig().appSubUrl + '/';
};
const defaultValues = {
email: props.queryParams.email,
code: props.queryParams.code,
};
export const SignupPage: FC = () => {
return (
<LoginLayout>
<InnerBox>
<Signup />
<Form defaultValues={defaultValues} onSubmit={onSubmit}>
{({ errors, register, getValues }) => (
<>
<Field label="Your name">
<Input name="name" placeholder="(optional)" ref={register} />
</Field>
<Field label="Email" invalid={!!errors.email} error={errors.email?.message}>
<Input
name="email"
type="email"
placeholder="Email"
ref={register({
required: 'Email is required',
pattern: {
value: /^\S+@\S+$/,
message: 'Email is invalid',
},
})}
/>
</Field>
{!getConfig().autoAssignOrg && (
<Field label="Org. name">
<Input name="orgName" placeholder="Org. name" ref={register} />
</Field>
)}
{getConfig().verifyEmailEnabled && (
<Field label="Email verification code (sent to your email)">
<Input name="code" ref={register} placeholder="Code" />
</Field>
)}
<Field label="Password" invalid={!!errors.password} error={errors?.password?.message}>
<Input
autoFocus
type="password"
name="password"
ref={register({
required: 'Password is required',
})}
/>
</Field>
<Field label="Confirm password" invalid={!!errors.confirm} error={errors?.confirm?.message}>
<Input
type="password"
name="confirm"
ref={register({
required: 'Confirmed password is required',
validate: (v) => v === getValues().password || 'Passwords must match!',
})}
/>
</Field>
<HorizontalGroup>
<Button type="submit">Submit</Button>
<LinkButton variant="link" href={getConfig().appSubUrl + '/login'}>
Back to login
</LinkButton>
</HorizontalGroup>
</>
)}
</Form>
</InnerBox>
</LoginLayout>
);

View File

@ -169,7 +169,7 @@ export class FormDropdownCtrl {
this.linkMode = true;
this.inputElement.hide();
this.linkElement.show();
this.updateValue(this.inputElement.val());
this.updateValue(this.inputElement.val() as string);
}
inputBlur() {

View File

@ -2,10 +2,10 @@ import React from 'react';
import { shallow } from 'enzyme';
import BottomNavLinks from './BottomNavLinks';
import appEvents from '../../app_events';
import { CoreEvents } from 'app/types';
import { ShowModalEvent } from '../../../types/events';
jest.mock('../../app_events', () => ({
emit: jest.fn(),
publish: jest.fn(),
}));
const setup = (propOverrides?: object) => {
@ -94,7 +94,11 @@ describe('Functions', () => {
const instance = wrapper.instance() as BottomNavLinks;
instance.onOpenShortcuts();
expect(appEvents.emit).toHaveBeenCalledWith(CoreEvents.showModal, { templateHtml: '<help-modal></help-modal>' });
expect(appEvents.publish).toHaveBeenCalledWith(
new ShowModalEvent({
templateHtml: '<help-modal></help-modal>',
})
);
});
});
});

View File

@ -3,10 +3,10 @@ import { css } from 'emotion';
import appEvents from '../../app_events';
import { User } from '../../services/context_srv';
import { NavModelItem } from '@grafana/data';
import { Icon, IconName } from '@grafana/ui';
import { CoreEvents } from 'app/types';
import { Icon, IconName, Link } from '@grafana/ui';
import { OrgSwitcher } from '../OrgSwitcher';
import { getFooterLinks } from '../Footer/Footer';
import { ShowModalEvent } from '../../../types/events';
export interface Props {
link: NavModelItem;
@ -23,9 +23,11 @@ export default class BottomNavLinks extends PureComponent<Props, State> {
};
onOpenShortcuts = () => {
appEvents.emit(CoreEvents.showModal, {
appEvents.publish(
new ShowModalEvent({
templateHtml: '<help-modal></help-modal>',
});
})
);
};
toggleSwitcherModal = () => {
@ -49,12 +51,12 @@ export default class BottomNavLinks extends PureComponent<Props, State> {
return (
<div className="sidemenu-item dropdown dropup">
<a href={link.url} className="sidemenu-link" target={link.target}>
<Link href={link.url} className="sidemenu-link" target={link.target}>
<span className="icon-circle sidemenu-icon">
{link.icon && <Icon name={link.icon as IconName} size="xl" />}
{link.img && <img src={link.img} />}
</span>
</a>
</Link>
<ul className="dropdown-menu dropdown-menu--sidemenu" role="menu">
{link.subTitle && (
<li className="sidemenu-subtitle">

View File

@ -1,6 +1,6 @@
import React from 'react';
import _ from 'lodash';
import SignIn from './SignIn';
import { SignIn } from './SignIn';
import BottomNavLinks from './BottomNavLinks';
import { contextSrv } from 'app/core/services/context_srv';
import config from '../../config';

View File

@ -1,6 +1,6 @@
import React, { FC } from 'react';
import { css } from 'emotion';
import { Icon, IconName, useTheme } from '@grafana/ui';
import { Icon, IconName, Link, useTheme } from '@grafana/ui';
export interface Props {
child: any;
@ -14,14 +14,16 @@ const DropDownChild: FC<Props> = (props) => {
margin-right: ${theme.spacing.sm};
`;
return (
<li className={listItemClassName}>
<a href={child.url}>
const linkContent = (
<>
{child.icon && <Icon name={child.icon as IconName} className={iconClassName} />}
{child.text}
</a>
</li>
</>
);
const anchor = child.url ? <Link href={child.url}>{linkContent}</Link> : <a>{linkContent}</a>;
return <li className={listItemClassName}>{anchor}</li>;
};
export default DropDownChild;

View File

@ -1,22 +1,10 @@
import React from 'react';
import { shallow } from 'enzyme';
import { SideMenu } from './SideMenu';
import appEvents from '../../app_events';
import { CoreEvents } from 'app/types';
jest.mock('../../app_events', () => ({
emit: jest.fn(),
}));
jest.mock('app/store/store', () => ({
store: {
getState: jest.fn().mockReturnValue({
location: {
lastUpdated: 0,
},
}),
},
}));
import { render, screen } from '@testing-library/react';
import { Router } from 'react-router-dom';
import { locationService } from '@grafana/runtime';
import { configureStore } from 'app/store/configureStore';
import { Provider } from 'react-redux';
jest.mock('app/core/services/context_srv', () => ({
contextSrv: {
@ -29,37 +17,30 @@ jest.mock('app/core/services/context_srv', () => ({
},
}));
const setup = (propOverrides?: object) => {
const props = Object.assign(
{
loginUrl: '',
user: {},
mainLinks: [],
bottomeLinks: [],
isSignedIn: false,
},
propOverrides
);
const setup = () => {
const store = configureStore();
return shallow(<SideMenu {...props} />);
return render(
<Provider store={store}>
<Router history={locationService.getHistory()}>
<SideMenu />
</Router>
</Provider>
);
};
describe('Render', () => {
it('should render component', () => {
const wrapper = setup();
expect(wrapper).toMatchSnapshot();
});
it('should render component', async () => {
setup();
const sidemenu = await screen.findByTestId('sidemenu');
expect(sidemenu).toBeInTheDocument();
});
describe('Functions', () => {
describe('toggle side menu on mobile', () => {
const wrapper = setup();
const instance = wrapper.instance() as SideMenu;
instance.toggleSideMenuSmallBreakpoint();
it('should not render when in kiosk mode', async () => {
setup();
it('should emit toggle sidemenu event', () => {
expect(appEvents.emit).toHaveBeenCalledWith(CoreEvents.toggleSidemenuMobile);
});
locationService.partial({ kiosk: 'full' });
const sidemenu = screen.queryByTestId('sidemenu');
expect(sidemenu).not.toBeInTheDocument();
});
});

View File

@ -1,33 +1,44 @@
import React, { PureComponent } from 'react';
import React, { FC, useCallback } from 'react';
import appEvents from '../../app_events';
import TopSection from './TopSection';
import BottomSection from './BottomSection';
import config from 'app/core/config';
import { CoreEvents } from 'app/types';
import { CoreEvents, KioskMode } from 'app/types';
import { Branding } from 'app/core/components/Branding/Branding';
import { Icon } from '@grafana/ui';
import { useLocation } from 'react-router-dom';
const homeUrl = config.appSubUrl || '/';
export class SideMenu extends PureComponent {
toggleSideMenuSmallBreakpoint = () => {
appEvents.emit(CoreEvents.toggleSidemenuMobile);
};
export const SideMenu: FC = React.memo(() => {
const location = useLocation();
const query = new URLSearchParams(location.search);
const kiosk = query.get('kiosk') as KioskMode;
render() {
return [
const toggleSideMenuSmallBreakpoint = useCallback(() => {
appEvents.emit(CoreEvents.toggleSidemenuMobile);
}, []);
if (kiosk !== null) {
return null;
}
return (
<div className="sidemenu" data-testid="sidemenu">
<a href={homeUrl} className="sidemenu__logo" key="logo">
<Branding.MenuLogo />
</a>,
<div className="sidemenu__logo_small_breakpoint" onClick={this.toggleSideMenuSmallBreakpoint} key="hamburger">
</a>
<div className="sidemenu__logo_small_breakpoint" onClick={toggleSideMenuSmallBreakpoint} key="hamburger">
<Icon name="bars" size="xl" />
<span className="sidemenu__close">
<Icon name="times" />
&nbsp;Close
</span>
</div>,
<TopSection key="topsection" />,
<BottomSection key="bottomsection" />,
];
}
}
</div>
<TopSection key="topsection" />
<BottomSection key="bottomsection" />
</div>
);
});
SideMenu.displayName = 'SideMenu';

View File

@ -2,6 +2,7 @@ import React, { FC } from 'react';
import _ from 'lodash';
import DropDownChild from './DropDownChild';
import { NavModelItem } from '@grafana/data';
import { Link } from '@grafana/ui';
interface Props {
link: NavModelItem;
@ -15,13 +16,20 @@ const SideMenuDropDown: FC<Props> = (props) => {
childrenLinks = _.filter(link.children, (item) => !item.hideFromMenu);
}
const linkContent = <span className="sidemenu-item-text">{link.text}</span>;
const anchor = link.url ? (
<Link href={link.url} onClick={onHeaderClick} className="side-menu-header-link">
{linkContent}
</Link>
) : (
<a onClick={onHeaderClick} className="side-menu-header-link">
{linkContent}
</a>
);
return (
<ul className="dropdown-menu dropdown-menu--sidemenu" role="menu">
<li className="side-menu-header">
<a className="side-menu-header-link" href={link.url} onClick={onHeaderClick}>
<span className="sidemenu-item-text">{link.text}</span>
</a>
</li>
<li className="side-menu-header">{anchor}</li>
{childrenLinks.map((child, index) => {
return <DropDownChild child={child} key={`${child.url}-${index}`} />;
})}

View File

@ -1,11 +1,18 @@
import React from 'react';
import { shallow } from 'enzyme';
import { render, screen } from '@testing-library/react';
import { SignIn } from './SignIn';
import { Router } from 'react-router-dom';
import { locationService } from '@grafana/runtime';
describe('Render', () => {
it('should render component', () => {
const wrapper = shallow(<SignIn url="/whatever" />);
it('should render component', async () => {
render(
<Router history={locationService.getHistory()}>
<SignIn url="/whatever" />
</Router>
);
expect(wrapper).toMatchSnapshot();
const link = await screen.getByText('Sign In');
expect(link).toBeInTheDocument();
});
});

View File

@ -1,12 +1,11 @@
import React, { FC } from 'react';
import { connectWithStore } from 'app/core/utils/connectWithReduxStore';
import { StoreState } from 'app/types';
import { Icon } from '@grafana/ui';
import { useLocation } from 'react-router-dom';
import { getForcedLoginUrl } from './utils';
export const SignIn: FC<any> = ({ url }) => {
const forcedLoginUrl = getForcedLoginUrl(url);
export const SignIn: FC<any> = () => {
const location = useLocation();
const forcedLoginUrl = getForcedLoginUrl(location.pathname + location.search);
return (
<div className="sidemenu-item">
@ -25,9 +24,3 @@ export const SignIn: FC<any> = ({ url }) => {
</div>
);
};
const mapStateToProps = (state: StoreState) => ({
url: state.location.url,
});
export default connectWithStore(SignIn, mapStateToProps);

View File

@ -2,7 +2,7 @@ import React, { FC } from 'react';
import _ from 'lodash';
import TopSectionItem from './TopSectionItem';
import config from '../../config';
import { getLocationSrv } from '@grafana/runtime';
import { locationService } from '@grafana/runtime';
const TopSection: FC<any> = () => {
const navTree = _.cloneDeep(config.bootData.navTree);
@ -13,7 +13,7 @@ const TopSection: FC<any> = () => {
};
const onOpenSearch = () => {
getLocationSrv().update({ query: { search: 'open' }, partial: true });
locationService.partial({ search: 'open' });
};
return (

View File

@ -1,6 +1,7 @@
import React from 'react';
import { mount } from 'enzyme';
import TopSectionItem from './TopSectionItem';
import { MemoryRouter } from 'react-router-dom';
const setup = (propOverrides?: object) => {
const props = Object.assign(
@ -14,7 +15,11 @@ const setup = (propOverrides?: object) => {
propOverrides
);
return mount(<TopSectionItem {...props} />);
return mount(
<MemoryRouter initialEntries={[{ pathname: '/', key: 'testKey' }]}>
<TopSectionItem {...props} />
</MemoryRouter>
);
};
describe('Render', () => {

View File

@ -1,6 +1,6 @@
import React, { FC } from 'react';
import SideMenuDropDown from './SideMenuDropDown';
import { Icon } from '@grafana/ui';
import { Icon, Link } from '@grafana/ui';
import { NavModelItem } from '@grafana/data';
export interface Props {
@ -9,14 +9,25 @@ export interface Props {
}
const TopSectionItem: FC<Props> = ({ link, onClick }) => {
return (
<div className="sidemenu-item dropdown">
<a className="sidemenu-link" href={link.url} target={link.target} onClick={onClick}>
const linkContent = (
<span className="icon-circle sidemenu-icon">
{link.icon && <Icon name={link.icon as any} size="xl" />}
{link.img && <img src={link.img} />}
</span>
);
const anchor = link.url ? (
<Link className="sidemenu-link" href={link.url} target={link.target} onClick={onClick}>
{linkContent}
</Link>
) : (
<a className="sidemenu-link" onClick={onClick}>
{linkContent}
</a>
);
return (
<div className="sidemenu-item dropdown">
{anchor}
<SideMenuDropDown link={link} onHeaderClick={onClick} />
</div>
);

View File

@ -4,13 +4,13 @@ exports[`Render should render children 1`] = `
<div
className="sidemenu-item dropdown dropup"
>
<a
<Link
className="sidemenu-link"
>
<span
className="icon-circle sidemenu-icon"
/>
</a>
</Link>
<ul
className="dropdown-menu dropdown-menu--sidemenu"
role="menu"
@ -58,13 +58,13 @@ exports[`Render should render component 1`] = `
<div
className="sidemenu-item dropdown dropup"
>
<a
<Link
className="sidemenu-link"
>
<span
className="icon-circle sidemenu-icon"
/>
</a>
</Link>
<ul
className="dropdown-menu dropdown-menu--sidemenu"
role="menu"
@ -86,13 +86,13 @@ exports[`Render should render organization switcher 1`] = `
<div
className="sidemenu-item dropdown dropup"
>
<a
<Link
className="sidemenu-link"
>
<span
className="icon-circle sidemenu-icon"
/>
</a>
</Link>
<ul
className="dropdown-menu dropdown-menu--sidemenu"
role="menu"
@ -144,13 +144,13 @@ exports[`Render should render subtitle 1`] = `
<div
className="sidemenu-item dropdown dropup"
>
<a
<Link
className="sidemenu-link"
>
<span
className="icon-circle sidemenu-icon"
/>
</a>
</Link>
<ul
className="dropdown-menu dropdown-menu--sidemenu"
role="menu"

View File

@ -4,7 +4,7 @@ exports[`Render should render component 1`] = `
<div
className="sidemenu__bottom"
>
<Component />
<SignIn />
<BottomNavLinks
key="undefined-0"
link={

View File

@ -1,37 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render component 1`] = `
Array [
<a
className="sidemenu__logo"
href="/"
key="logo"
>
<MenuLogo />
</a>,
<div
className="sidemenu__logo_small_breakpoint"
key="hamburger"
onClick={[Function]}
>
<Icon
name="bars"
size="xl"
/>
<span
className="sidemenu__close"
>
<Icon
name="times"
/>
 Close
</span>
</div>,
<TopSection
key="topsection"
/>,
<BottomSection
key="bottomsection"
/>,
]
`;

View File

@ -1,41 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render component 1`] = `
<div
className="sidemenu-item"
>
<a
className="sidemenu-link"
href="/whatever?forceLogin=true"
target="_self"
>
<span
className="icon-circle sidemenu-icon"
>
<Icon
name="signout"
size="xl"
/>
</span>
</a>
<a
href="/whatever?forceLogin=true"
target="_self"
>
<ul
className="dropdown-menu dropdown-menu--sidemenu"
role="menu"
>
<li
className="side-menu-header"
>
<span
className="sidemenu-item-text"
>
Sign In
</span>
</li>
</ul>
</a>
</div>
`;

View File

@ -1,6 +1,48 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render component 1`] = `
<MemoryRouter
initialEntries={
Array [
Object {
"key": "testKey",
"pathname": "/",
},
]
}
>
<Router
history={
Object {
"action": "POP",
"block": [Function],
"canGo": [Function],
"createHref": [Function],
"entries": Array [
Object {
"hash": "",
"key": "testKey",
"pathname": "/",
"search": "",
},
],
"go": [Function],
"goBack": [Function],
"goForward": [Function],
"index": 0,
"length": 1,
"listen": [Function],
"location": Object {
"hash": "",
"key": "testKey",
"pathname": "/",
"search": "",
},
"push": [Function],
"replace": [Function],
}
}
>
<TopSectionItem
link={
Object {
@ -12,10 +54,24 @@ exports[`Render should render component 1`] = `
>
<div
className="sidemenu-item dropdown"
>
<Link
className="sidemenu-link"
href="/asd"
>
<Link
className="sidemenu-link"
to="/asd"
>
<LinkAnchor
className="sidemenu-link"
href="/asd"
navigate={[Function]}
>
<a
className="sidemenu-link"
href="/asd"
onClick={[Function]}
>
<span
className="icon-circle sidemenu-icon"
@ -49,6 +105,9 @@ exports[`Render should render component 1`] = `
</Icon>
</span>
</a>
</LinkAnchor>
</Link>
</Link>
<SideMenuDropDown
link={
Object {
@ -64,10 +123,24 @@ exports[`Render should render component 1`] = `
>
<li
className="side-menu-header"
>
<Link
className="side-menu-header-link"
href="/asd"
>
<Link
className="side-menu-header-link"
to="/asd"
>
<LinkAnchor
className="side-menu-header-link"
href="/asd"
navigate={[Function]}
>
<a
className="side-menu-header-link"
href="/asd"
onClick={[Function]}
>
<span
className="sidemenu-item-text"
@ -75,9 +148,14 @@ exports[`Render should render component 1`] = `
Hello
</span>
</a>
</LinkAnchor>
</Link>
</Link>
</li>
</ul>
</SideMenuDropDown>
</div>
</TopSectionItem>
</Router>
</MemoryRouter>
`;

View File

@ -1,4 +1 @@
import './invited_ctrl';
import './signup_ctrl';
import './reset_password_ctrl';
import './json_editor_ctrl';

View File

@ -1,53 +0,0 @@
import coreModule from '../core_module';
import config from 'app/core/config';
import { getBackendSrv } from '@grafana/runtime';
import { promiseToDigest } from '../utils/promiseToDigest';
export class InvitedCtrl {
/** @ngInject */
constructor($scope: any, $routeParams: any, contextSrv: any) {
contextSrv.sidemenu = false;
$scope.formModel = {};
$scope.navModel = {
main: {
icon: 'grafana',
text: 'Invite',
subTitle: 'Register your Grafana account',
breadcrumbs: [{ title: 'Login', url: 'login' }],
},
};
$scope.init = () => {
promiseToDigest($scope)(
getBackendSrv()
.get('/api/user/invite/' + $routeParams.code)
.then((invite: any) => {
$scope.formModel.name = invite.name;
$scope.formModel.email = invite.email;
$scope.formModel.username = invite.email;
$scope.formModel.inviteCode = $routeParams.code;
$scope.greeting = invite.name || invite.email || invite.username;
$scope.invitedBy = invite.invitedBy;
})
);
};
$scope.submit = () => {
if (!$scope.inviteForm.$valid) {
return;
}
getBackendSrv()
.post('/api/user/invite/complete', $scope.formModel)
.then(() => {
window.location.href = config.appSubUrl + '/';
});
};
$scope.init();
}
}
coreModule.controller('InvitedCtrl', InvitedCtrl);

View File

@ -1,64 +0,0 @@
import coreModule from '../core_module';
import config from 'app/core/config';
import { AppEvents } from '@grafana/data';
import { getBackendSrv } from '@grafana/runtime';
import { promiseToDigest } from '../utils/promiseToDigest';
export class ResetPasswordCtrl {
/** @ngInject */
constructor($scope: any, $location: any) {
$scope.formModel = {};
$scope.mode = 'send';
$scope.ldapEnabled = config.ldapEnabled;
$scope.authProxyEnabled = config.authProxyEnabled;
$scope.disableLoginForm = config.disableLoginForm;
const params = $location.search();
if (params.code) {
$scope.mode = 'reset';
$scope.formModel.code = params.code;
}
$scope.navModel = {
main: {
icon: 'grafana',
text: 'Reset Password',
subTitle: 'Reset your Grafana password',
breadcrumbs: [{ title: 'Login', url: 'login' }],
},
};
$scope.sendResetEmail = () => {
if (!$scope.sendResetForm.$valid) {
return;
}
promiseToDigest($scope)(
getBackendSrv()
.post('/api/user/password/send-reset-email', $scope.formModel)
.then(() => {
$scope.mode = 'email-sent';
})
);
};
$scope.submitReset = () => {
if (!$scope.resetForm.$valid) {
return;
}
if ($scope.formModel.newPassword !== $scope.formModel.confirmPassword) {
$scope.appEvent(AppEvents.alertWarning, ['New passwords do not match']);
return;
}
getBackendSrv()
.post('/api/user/password/reset', $scope.formModel)
.then(() => {
$location.path('login');
});
};
}
}
coreModule.controller('ResetPasswordCtrl', ResetPasswordCtrl);

View File

@ -1,66 +0,0 @@
import config from 'app/core/config';
import coreModule from '../core_module';
import { getBackendSrv } from '@grafana/runtime/src/services';
import { promiseToDigest } from '../utils/promiseToDigest';
export class SignUpCtrl {
/** @ngInject */
constructor(private $scope: any, $location: any, contextSrv: any) {
contextSrv.sidemenu = false;
$scope.ctrl = this;
$scope.formModel = {};
const params = $location.search();
// validate email is semi ok
if (params.email && !params.email.match(/^\S+@\S+$/)) {
console.error('invalid email');
return;
}
$scope.formModel.orgName = params.email;
$scope.formModel.email = params.email;
$scope.formModel.username = params.email;
$scope.formModel.code = params.code;
$scope.verifyEmailEnabled = false;
$scope.autoAssignOrg = false;
$scope.navModel = {
main: {
icon: 'grafana',
text: 'Sign Up',
subTitle: 'Register your Grafana account',
breadcrumbs: [{ title: 'Login', url: 'login' }],
},
};
promiseToDigest($scope)(
getBackendSrv()
.get('/api/user/signup/options')
.then((options: any) => {
$scope.verifyEmailEnabled = options.verifyEmailEnabled;
$scope.autoAssignOrg = options.autoAssignOrg;
})
);
}
submit() {
if (!this.$scope.signUpForm.$valid) {
return;
}
getBackendSrv()
.post('/api/user/signup/step2', this.$scope.formModel)
.then((rsp: any) => {
if (rsp.code === 'redirect-to-select-org') {
window.location.href = config.appSubUrl + '/profile/select-org?signup=1';
} else {
window.location.href = config.appSubUrl + '/';
}
});
}
}
coreModule.controller('SignUpCtrl', SignUpCtrl);

View File

@ -0,0 +1,56 @@
import React from 'react';
import { render } from '@testing-library/react';
import { GrafanaRoute } from './GrafanaRoute';
import { locationService } from '@grafana/runtime';
describe('GrafanaRoute', () => {
it('Parses search', () => {
let capturedProps: any;
const PageComponent = (props: any) => {
capturedProps = props;
return <div />;
};
const location = { search: '?query=hello&test=asd' } as any;
const history = {} as any;
const match = {} as any;
render(
<GrafanaRoute location={location} history={history} match={match} route={{ component: PageComponent } as any} />
);
expect(capturedProps.queryParams.query).toBe('hello');
});
it('Should clear history forceRouteReload state after route change', () => {
const renderSpy = jest.fn();
const route = {
/* eslint-disable-next-line react/display-name */
component: () => {
renderSpy();
return <div />;
},
} as any;
const history = locationService.getHistory();
const { rerender } = render(
<GrafanaRoute location={history.location} history={history} match={{} as any} route={route} />
);
expect(renderSpy).toBeCalledTimes(1);
locationService.replace('/test', true);
expect(history.location.state).toMatchInlineSnapshot(`
Object {
"forceRouteReload": true,
}
`);
rerender(<GrafanaRoute location={history.location} history={history} match={{} as any} route={route} />);
expect(history.location.state).toMatchInlineSnapshot(`Object {}`);
expect(renderSpy).toBeCalledTimes(2);
});
});

View File

@ -0,0 +1,83 @@
import React from 'react';
// @ts-ignore
import Drop from 'tether-drop';
import { GrafanaRouteComponentProps } from './types';
import { locationSearchToObject, navigationLogger } from '@grafana/runtime';
import { keybindingSrv } from '../services/keybindingSrv';
import { shouldReloadPage } from './utils';
import { analyticsService } from '../services/analytics';
export interface Props extends Omit<GrafanaRouteComponentProps, 'queryParams'> {}
export class GrafanaRoute extends React.Component<Props> {
componentDidMount() {
this.updateBodyClassNames();
this.cleanupDOM();
// unbinds all and re-bind global keybindins
keybindingSrv.reset();
keybindingSrv.initGlobals();
analyticsService.track();
navigationLogger('GrafanaRoute', false, 'Mounted', this.props.match);
}
componentDidUpdate(prevProps: Props) {
this.cleanupDOM();
// Clear force reload state when route updates
if (shouldReloadPage(this.props.location)) {
navigationLogger('GrafanaRoute', false, 'Force reload', this.props, prevProps);
delete (this.props.history.location.state as any)?.forceRouteReload;
}
analyticsService.track();
navigationLogger('GrafanaRoute', false, 'Updated', this.props, prevProps);
}
componentWillUnmount() {
this.updateBodyClassNames(true);
navigationLogger('GrafanaRoute', false, 'Unmounted', this.props.route);
}
getPageClasses() {
return this.props.route.pageClass ? this.props.route.pageClass.split(' ') : [];
}
updateBodyClassNames(clear = false) {
for (const cls of this.getPageClasses()) {
if (clear) {
document.body.classList.remove(cls);
} else {
document.body.classList.add(cls);
}
}
}
cleanupDOM() {
document.body.classList.remove('sidemenu-open--xs');
// cleanup tooltips
const tooltipById = document.getElementById('tooltip');
tooltipById?.parentElement?.removeChild(tooltipById);
const tooltipsByClass = document.querySelectorAll('.tooltip');
for (let i = 0; i < tooltipsByClass.length; i++) {
const tooltip = tooltipsByClass[i];
tooltip.parentElement?.removeChild(tooltip);
}
// cleanup tether-drop
for (const drop of Drop.drops) {
drop.destroy();
}
}
render() {
const { props } = this;
navigationLogger('GrafanaRoute', false, 'Rendered', props.route);
const RouteComponent = props.route.component;
return <RouteComponent {...props} queryParams={locationSearchToObject(props.location.search)} />;
}
}

View File

@ -0,0 +1,45 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { getAppRoutes } from '../../routes/routes';
import { PageContents } from '../components/Page/PageContents';
import { RouteDescriptor } from './types';
export const RouterDebugger: React.FC<any> = () => {
const manualRoutes: RouteDescriptor[] = [];
return (
<PageContents>
<h1>Static routes</h1>
<ul>
{getAppRoutes().map((r, i) => {
if (r.path.indexOf(':') > -1 || r.path.indexOf('test') > -1) {
if (r.path.indexOf('test') === -1) {
manualRoutes.push(r);
}
return null;
}
return (
<li key={i}>
<Link target="_blank" to={r.path}>
{r.path}
</Link>
</li>
);
})}
</ul>
<h1>Dynamic routes - check those manually</h1>
<ul>
{manualRoutes.map((r, i) => {
return (
<li key={i}>
<Link key={`${i}-${r.path}`} target="_blank" to={r.path}>
{r.path}
</Link>
</li>
);
})}
</ul>
</PageContents>
);
};

View File

@ -0,0 +1,19 @@
import { GrafanaRouteComponentProps } from '../types';
import { createMemoryHistory } from 'history';
import { merge } from 'lodash';
export function getRouteComponentProps<T = {}, Q extends Record<string, string | null | undefined> = {}>(
overrides: Partial<GrafanaRouteComponentProps> = {}
): GrafanaRouteComponentProps<T, Q> {
const defaults: GrafanaRouteComponentProps<T, Q> = {
history: createMemoryHistory(),
location: {
search: '',
} as any,
match: { params: {} } as any,
route: {} as any,
queryParams: {} as any,
};
return merge(overrides, defaults);
}

View File

@ -0,0 +1,17 @@
import { locationService } from '@grafana/runtime';
import { useLocation } from 'react-router-dom';
export type UseUrlParamsResult = [URLSearchParams, (params: Record<string, any>, replace?: boolean) => void];
/** @internal experimental */
export function useUrlParams(): UseUrlParamsResult {
const location = useLocation();
const params = new URLSearchParams(location.search);
const updateUrlParams = (params: Record<string, any>, replace?: boolean) => {
// Should find a way to use history directly here
locationService.partial(params, replace);
};
return [params, updateUrlParams];
}

View File

@ -0,0 +1,40 @@
import { AppEvents, UrlQueryValue } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import appEvents from '../app_events';
import { KioskMode } from '../../types';
export function toggleKioskMode() {
let kiosk = locationService.getSearchObject().kiosk;
switch (kiosk) {
case 'tv':
kiosk = true;
appEvents.emit(AppEvents.alertSuccess, ['Press ESC to exit Kiosk mode']);
break;
case '1':
case true:
kiosk = null;
break;
default:
kiosk = 'tv';
}
locationService.partial({ kiosk });
}
export function getKioskMode(queryParam?: UrlQueryValue): KioskMode {
switch (queryParam) {
case 'tv':
return KioskMode.TV;
// legacy support
case '1':
case true:
return KioskMode.Full;
default:
return KioskMode.Off;
}
}
export function exitKioskMode() {
locationService.partial({ kiosk: null });
}

View File

@ -0,0 +1,165 @@
// tslint:disable
// Most of this file is just a copy of some content from
// https://github.com/angular/angular.js/blob/937fb891fa4fcf79e9fa02f8e0d517593e781077/src/Angular.js
// Long term this code should be refactored to tru-type-script ;)
// tslint disabled on purpose
const getPrototypeOf = Object.getPrototypeOf;
const toString = Object.prototype.toString;
const hasOwnProperty = Object.prototype.hasOwnProperty;
let jqLite: any;
export function isArray(arr: any) {
return Array.isArray(arr) || arr instanceof Array;
}
export function isError(value: any) {
var tag = toString.call(value);
switch (tag) {
case '[object Error]':
return true;
case '[object Exception]':
return true;
case '[object DOMException]':
return true;
default:
return value instanceof Error;
}
}
export function isDate(value: any) {
return toString.call(value) === '[object Date]';
}
export function isNumber(value: any) {
return typeof value === 'number';
}
export function isString(value: any) {
return typeof value === 'string';
}
export function isBlankObject(value: any) {
return value !== null && typeof value === 'object' && !getPrototypeOf(value);
}
export function isObject(value: any) {
// http://jsperf.com/isobject4
return value !== null && typeof value === 'object';
}
export function isDefined(value: any) {
return typeof value !== 'undefined';
}
export function isWindow(obj: { window: any }) {
return obj && obj.window === obj;
}
export function isArrayLike(obj: any) {
// `null`, `undefined` and `window` are not array-like
if (obj == null || isWindow(obj)) {
return false;
}
// arrays, strings and jQuery/jqLite objects are array like
// * jqLite is either the jQuery or jqLite constructor function
// * we have to check the existence of jqLite first as this method is called
// via the forEach method when constructing the jqLite object in the first place
if (isArray(obj) || isString(obj) || (jqLite && obj instanceof jqLite)) {
return true;
}
// Support: iOS 8.2 (not reproducible in simulator)
// "length" in obj used to prevent JIT error (gh-11508)
var length = 'length' in Object(obj) && obj.length;
// NodeList objects (with `item` method) and
// other objects with suitable length characteristics are array-like
return isNumber(length) && ((length >= 0 && length - 1 in obj) || typeof obj.item === 'function');
}
export function isFunction(value: any) {
return typeof value === 'function';
}
export function forEach(obj: any, iterator: any, context?: any) {
var key, length;
if (obj) {
if (isFunction(obj)) {
for (key in obj) {
if (key !== 'prototype' && key !== 'length' && key !== 'name' && obj.hasOwnProperty(key)) {
iterator.call(context, obj[key], key, obj);
}
}
} else if (isArray(obj) || isArrayLike(obj)) {
var isPrimitive = typeof obj !== 'object';
for (key = 0, length = obj.length; key < length; key++) {
if (isPrimitive || key in obj) {
iterator.call(context, obj[key], key, obj);
}
}
} else if (obj.forEach && obj.forEach !== forEach) {
obj.forEach(iterator, context, obj);
} else if (isBlankObject(obj)) {
// createMap() fast path --- Safe to avoid hasOwnProperty check because prototype chain is empty
for (key in obj) {
iterator.call(context, obj[key], key, obj);
}
} else if (typeof obj.hasOwnProperty === 'function') {
// Slow path for objects inheriting Object.prototype, hasOwnProperty check needed
for (key in obj) {
if (obj.hasOwnProperty(key)) {
iterator.call(context, obj[key], key, obj);
}
}
} else {
// Slow path for objects which do not have a method `hasOwnProperty`
for (key in obj) {
if (hasOwnProperty.call(obj, key)) {
iterator.call(context, obj[key], key, obj);
}
}
}
}
return obj;
}
export function tryDecodeURIComponent(value: string): string | void {
try {
return decodeURIComponent(value);
} catch (e) {
// Ignore any invalid uri component.
}
}
function parseKeyValue(keyValue: string | null) {
var obj = {};
forEach((keyValue || '').split('&'), function (keyValue: string) {
var splitPoint, key, val;
if (keyValue) {
key = keyValue = keyValue.replace(/\+/g, '%20');
splitPoint = keyValue.indexOf('=');
if (splitPoint !== -1) {
key = keyValue.substring(0, splitPoint);
val = keyValue.substring(splitPoint + 1);
}
key = tryDecodeURIComponent(key);
if (isDefined(key)) {
val = isDefined(val) ? tryDecodeURIComponent(val as string) : true;
if (!hasOwnProperty.call(obj, key)) {
// @ts-ignore
obj[key] = val;
// @ts-ignore
} else if (isArray(obj[key])) {
// @ts-ignore
obj[key].push(val);
} else {
// @ts-ignore
obj[key] = [obj[key], val];
}
}
}
});
return obj;
}
export function isUndefined(value: any) {
return typeof value === 'undefined';
}
export default parseKeyValue;
// tslint:enable

View File

@ -0,0 +1,13 @@
// This is empty for now, as I think it's not going to be necessary.
// This replaces Angular RouteParamsProvider implementation with a dummy one to keep the ball rolling
import { navigationLogger } from '@grafana/runtime';
export class RouteParamsProvider {
constructor() {
navigationLogger('Patch angular', false, 'RouteParamsProvider');
}
$get = () => {
// throw new Error('TODO: Refactor $routeParams');
};
}

View File

@ -0,0 +1,14 @@
// This is empty for now, as I think it's not going to be necessary.
// This replaces Angular RouteProvider implementation with a dummy one to keep the ball rolling
import { navigationLogger } from '@grafana/runtime';
export class RouteProvider {
constructor() {
navigationLogger('Patch angular', false, 'RouteProvider');
}
$get() {
return this;
}
}

View File

@ -0,0 +1,41 @@
import { locationUtil } from '@grafana/data';
import { locationService, navigationLogger } from '@grafana/runtime';
export function interceptLinkClicks(e: MouseEvent) {
const anchor = getParentAnchor(e.target as HTMLElement);
// Ignore if opening new tab or already default prevented
if (e.ctrlKey || e.metaKey || e.defaultPrevented) {
return;
}
if (anchor) {
let href = anchor.getAttribute('href');
const target = anchor.getAttribute('target');
if (href && !target) {
navigationLogger('utils', false, 'intercepting link click', e);
e.preventDefault();
href = locationUtil.stripBaseFromUrl(href);
// Ensure old angular urls with no starting '/' are handled the same as before
// That is they where seen as being absolute from app root
if (href[0] !== '/') {
href = `/${href}`;
}
locationService.push(href);
}
}
}
function getParentAnchor(element: HTMLElement | null): HTMLElement | null {
while (element !== null && element.tagName) {
if (element.tagName.toUpperCase() === 'A') {
return element;
}
element = element.parentNode as HTMLElement;
}
return null;
}

View File

@ -0,0 +1,10 @@
export const queryString = (params: any) => {
return Object.keys(params)
.filter((k) => {
return !!params[k];
})
.map((k) => {
return k + '=' + params[k];
})
.join('&');
};

View File

@ -0,0 +1,35 @@
import React from 'react';
import { Link, NavLink } from 'react-router-dom';
import { RouterDebugger } from './RouterDebugger';
import { RouteDescriptor } from './types';
export const testRoutes: RouteDescriptor[] = [
{
path: '/test1',
// eslint-disable-next-line react/display-name
component: () =>
(
<>
<h1>Test1</h1>
<Link to={'/test2'}>Test2 link</Link>
<NavLink to={'/test2'}>Test2 navlink</NavLink>
</>
) as any,
},
{
path: '/test2',
// eslint-disable-next-line react/display-name
component: () => (
<>
<h1>Test2 </h1>
<Link to={'/test1'}>Test1 link</Link>
<NavLink to={'/test1'}>Test1 navlink</NavLink>
</>
),
},
{
path: '/router-debug',
// eslint-disable-next-line react/display-name
component: () => RouterDebugger,
},
];

View File

@ -0,0 +1,19 @@
import { UrlQueryMap } from '@grafana/data';
import React from 'react';
import { RouteComponentProps } from 'react-router-dom';
export interface GrafanaRouteComponentProps<T = {}, Q = UrlQueryMap> extends RouteComponentProps<T> {
route: RouteDescriptor;
queryParams: Q;
}
export type GrafanaRouteComponent<T = any> = React.ComponentType<GrafanaRouteComponentProps<T>>;
export interface RouteDescriptor {
path: string;
component: GrafanaRouteComponent<any>;
roles?: () => string[];
pageClass?: string;
/** Can be used like an id for the route if the same component is used by many routes */
routeName?: string;
}

View File

@ -0,0 +1,5 @@
import * as H from 'history';
export function shouldReloadPage(location: H.Location<any>) {
return !!location.state?.forceRouteReload;
}

View File

@ -1,11 +1,9 @@
import { navIndexReducer as navIndex } from './navModel';
import { locationReducer as location } from './location';
import { appNotificationsReducer as appNotifications } from './appNotification';
import { applicationReducer as application } from './application';
export default {
navIndex,
location,
appNotifications,
application,
};

View File

@ -1,154 +0,0 @@
import { reducerTester } from '../../../test/core/redux/reducerTester';
import { initialState, locationReducer, updateLocation } from './location';
import { LocationState } from '../../types';
describe('locationReducer', () => {
describe('when updateLocation is dispatched', () => {
it('then state should be correct', () => {
reducerTester<LocationState>()
.givenReducer(locationReducer, { ...initialState, query: { queryParam: 3, queryParam2: 2 } })
.whenActionIsDispatched(
updateLocation({
query: { queryParam: 1 },
partial: false,
path: '/api/dashboard',
replace: false,
routeParams: { routeParam: 2 },
})
)
.thenStatePredicateShouldEqual((resultingState) => {
expect(resultingState.path).toEqual('/api/dashboard');
expect(resultingState.url).toEqual('/api/dashboard?queryParam=1');
expect(resultingState.query).toEqual({ queryParam: 1 });
expect(resultingState.routeParams).toEqual({ routeParam: 2 });
expect(resultingState.replace).toEqual(false);
return true;
});
});
});
describe('when updateLocation is dispatched with replace', () => {
it('then state should be correct', () => {
reducerTester<LocationState>()
.givenReducer(locationReducer, { ...initialState, query: { queryParam: 3, queryParam2: 2 } })
.whenActionIsDispatched(
updateLocation({
query: { queryParam: 1 },
partial: false,
path: '/api/dashboard',
replace: true,
routeParams: { routeParam: 2 },
})
)
.thenStatePredicateShouldEqual((resultingState) => {
expect(resultingState.path).toEqual('/api/dashboard');
expect(resultingState.url).toEqual('/api/dashboard?queryParam=1');
expect(resultingState.query).toEqual({ queryParam: 1 });
expect(resultingState.routeParams).toEqual({ routeParam: 2 });
expect(resultingState.replace).toEqual(true);
return true;
});
});
});
describe('when updateLocation is dispatched with partial', () => {
it('then state should be correct', () => {
reducerTester<LocationState>()
.givenReducer(locationReducer, { ...initialState, query: { queryParam: 3, queryParam2: 2 } })
.whenActionIsDispatched(
updateLocation({
query: { queryParam: 1 },
partial: true,
path: '/api/dashboard',
replace: false,
routeParams: { routeParam: 2 },
})
)
.thenStatePredicateShouldEqual((resultingState) => {
expect(resultingState.path).toEqual('/api/dashboard');
expect(resultingState.url).toEqual('/api/dashboard?queryParam=1&queryParam2=2');
expect(resultingState.query).toEqual({ queryParam: 1, queryParam2: 2 });
expect(resultingState.routeParams).toEqual({ routeParam: 2 });
expect(resultingState.replace).toEqual(false);
return true;
});
});
});
describe('when updateLocation is dispatched without query', () => {
it('then state should be correct', () => {
reducerTester<LocationState>()
.givenReducer(locationReducer, { ...initialState, query: { queryParam: 3, queryParam2: 2 } })
.whenActionIsDispatched(
updateLocation({
partial: false,
path: '/api/dashboard',
replace: false,
routeParams: { routeParam: 2 },
})
)
.thenStatePredicateShouldEqual((resultingState) => {
expect(resultingState.path).toEqual('/api/dashboard');
expect(resultingState.url).toEqual('/api/dashboard?queryParam=3&queryParam2=2');
expect(resultingState.query).toEqual({ queryParam: 3, queryParam2: 2 });
expect(resultingState.routeParams).toEqual({ routeParam: 2 });
expect(resultingState.replace).toEqual(false);
return true;
});
});
});
describe('when updateLocation is dispatched without routeParams', () => {
it('then state should be correct', () => {
reducerTester<LocationState>()
.givenReducer(locationReducer, {
...initialState,
query: { queryParam: 3, queryParam2: 2 },
routeParams: { routeStateParam: 4 },
})
.whenActionIsDispatched(
updateLocation({
query: { queryParam: 1 },
partial: false,
path: '/api/dashboard',
replace: false,
})
)
.thenStatePredicateShouldEqual((resultingState) => {
expect(resultingState.path).toEqual('/api/dashboard');
expect(resultingState.url).toEqual('/api/dashboard?queryParam=1');
expect(resultingState.query).toEqual({ queryParam: 1 });
expect(resultingState.routeParams).toEqual({ routeStateParam: 4 });
expect(resultingState.replace).toEqual(false);
return true;
});
});
});
describe('when updateLocation is dispatched without path', () => {
it('then state should be correct', () => {
reducerTester<LocationState>()
.givenReducer(locationReducer, {
...initialState,
query: { queryParam: 3, queryParam2: 2 },
path: '/api/state/path',
})
.whenActionIsDispatched(
updateLocation({
query: { queryParam: 1 },
partial: false,
replace: false,
routeParams: { routeParam: 2 },
})
)
.thenStatePredicateShouldEqual((resultingState) => {
expect(resultingState.path).toEqual('/api/state/path');
expect(resultingState.url).toEqual('/api/state/path?queryParam=1');
expect(resultingState.query).toEqual({ queryParam: 1 });
expect(resultingState.routeParams).toEqual({ routeParam: 2 });
expect(resultingState.replace).toEqual(false);
return true;
});
});
});
});

View File

@ -1,46 +0,0 @@
import _ from 'lodash';
import { Action, createAction } from '@reduxjs/toolkit';
import { LocationUpdate } from '@grafana/runtime';
import { LocationState } from 'app/types';
import { urlUtil } from '@grafana/data';
export const initialState: LocationState = {
url: '',
path: '',
query: {},
routeParams: {},
replace: false,
lastUpdated: 0,
};
export const updateLocation = createAction<LocationUpdate>('location/updateLocation');
// Redux Toolkit uses ImmerJs as part of their solution to ensure that state objects are not mutated.
// ImmerJs has an autoFreeze option that freezes objects from change which means this reducer can't be migrated to createSlice
// because the state would become frozen and during run time we would get errors because Angular would try to mutate
// the frozen state.
// https://github.com/reduxjs/redux-toolkit/issues/242
export const locationReducer = (state: LocationState = initialState, action: Action<unknown>) => {
if (updateLocation.match(action)) {
const payload: LocationUpdate = action.payload;
const { path, routeParams, replace } = payload;
let query = payload.query || state.query;
if (payload.partial) {
query = _.defaults(query, state.query);
query = _.omitBy(query, _.isNull);
}
return {
url: urlUtil.renderUrl(path || state.path, query),
path: path || state.path,
query: { ...query },
routeParams: routeParams || state.routeParams,
replace: replace === true,
lastUpdated: new Date().getTime(),
};
}
return state;
};

View File

@ -1,7 +0,0 @@
import { LocationState } from 'app/types';
export const getRouteParamsId = (state: LocationState) => state.routeParams.id;
export const getRouteParamsPage = (state: LocationState) => state.routeParams.page;
export const getRouteParams = (state: LocationState) => state.routeParams;
export const getLocationQuery = (state: LocationState) => state.query;
export const getUrl = (state: LocationState) => state.url;

View File

@ -7,4 +7,3 @@ import './popover_srv';
import './segment_srv';
import './backend_srv';
import './dynamic_directive_srv';
import './bridge_srv';

View File

@ -1,18 +1,28 @@
import $ from 'jquery';
import coreModule from 'app/core/core_module';
import config from 'app/core/config';
import { GrafanaRootScope } from 'app/routes/GrafanaCtrl';
import { locationService } from '@grafana/runtime';
export class Analytics {
/** @ngInject */
constructor(private $rootScope: GrafanaRootScope, private $location: any) {}
private gaId?: string;
private ga?: any;
constructor() {
this.track = this.track.bind(this);
this.gaId = (config as any).googleAnalyticsId;
this.init();
}
init() {
if (!this.gaId) {
return;
}
gaInit() {
$.ajax({
url: 'https://www.google-analytics.com/analytics.js',
dataType: 'script',
cache: true,
});
const ga = ((window as any).ga =
(window as any).ga ||
// this had the equivalent of `eslint-disable-next-line prefer-arrow/prefer-arrow-functions`
@ -22,24 +32,21 @@ export class Analytics {
ga.l = +new Date();
ga('create', (config as any).googleAnalyticsId, 'auto');
ga('set', 'anonymizeIp', true);
this.ga = ga;
return ga;
}
init() {
this.$rootScope.$on('$viewContentLoaded', () => {
const track = { page: `${config.appSubUrl ?? ''}${this.$location.url()}` };
const ga = (window as any).ga || this.gaInit();
ga('set', track);
ga('send', 'pageview');
});
track() {
if (!this.ga) {
return;
}
const location = locationService.getLocation();
const track = { page: `${config.appSubUrl ?? ''}${location.pathname}${location.search}${location.hash}` };
this.ga('set', track);
this.ga('send', 'pageview');
}
}
/** @ngInject */
function startAnalytics(googleAnalyticsSrv: Analytics) {
if ((config as any).googleAnalyticsId) {
googleAnalyticsSrv.init();
}
}
coreModule.service('googleAnalyticsSrv', Analytics).run(startAnalytics);
export const analyticsService = new Analytics();

View File

@ -1,76 +0,0 @@
import { UrlQueryMap } from '@grafana/data';
import { findTemplateVarChanges } from './bridge_srv';
describe('when checking template variables', () => {
it('detect adding/removing a variable', () => {
const a: UrlQueryMap = {};
const b: UrlQueryMap = {
'var-xyz': 'hello',
aaa: 'ignore me',
};
expect(findTemplateVarChanges(b, a)).toEqual({ 'var-xyz': 'hello' });
expect(findTemplateVarChanges(a, b)).toEqual({ 'var-xyz': '' });
});
it('then should ignore equal values', () => {
const a: UrlQueryMap = {
'var-xyz': 'hello',
bbb: 'ignore me',
};
const b: UrlQueryMap = {
'var-xyz': 'hello',
aaa: 'ignore me',
};
expect(findTemplateVarChanges(b, a)).toBeUndefined();
expect(findTemplateVarChanges(a, b)).toBeUndefined();
});
it('then should ignore equal values with empty values', () => {
const a: UrlQueryMap = {
'var-xyz': '',
bbb: 'ignore me',
};
const b: UrlQueryMap = {
'var-xyz': '',
aaa: 'ignore me',
};
expect(findTemplateVarChanges(b, a)).toBeUndefined();
expect(findTemplateVarChanges(a, b)).toBeUndefined();
});
it('then should ignore empty array values', () => {
const a: UrlQueryMap = {
'var-adhoc': [],
};
const b: UrlQueryMap = {};
expect(findTemplateVarChanges(b, a)).toBeUndefined();
expect(findTemplateVarChanges(a, b)).toBeUndefined();
});
it('Should handle array values with one value same as just value', () => {
const a: UrlQueryMap = {
'var-test': ['test'],
};
const b: UrlQueryMap = {
'var-test': 'test',
};
expect(findTemplateVarChanges(b, a)).toBeUndefined();
expect(findTemplateVarChanges(a, b)).toBeUndefined();
});
it('Should detect change in array value and return array with single value', () => {
const a: UrlQueryMap = {
'var-test': ['test'],
};
const b: UrlQueryMap = {
'var-test': 'asd',
};
expect(findTemplateVarChanges(a, b)!['var-test']).toEqual(['test']);
});
});

View File

@ -1,152 +0,0 @@
import coreModule from 'app/core/core_module';
import { dispatch, store } from 'app/store/store';
import { updateLocation } from 'app/core/actions';
import { ILocationService, ITimeoutService } from 'angular';
import { GrafanaRootScope } from 'app/routes/GrafanaCtrl';
import { UrlQueryMap } from '@grafana/data';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { templateVarsChangedInUrl } from 'app/features/variables/state/actions';
import { isArray, isEqual } from 'lodash';
// Services that handles angular -> redux store sync & other react <-> angular sync
export class BridgeSrv {
private lastQuery: UrlQueryMap = {};
private lastPath = '';
private angularUrl: string;
private lastUrl: string | null = null;
/** @ngInject */
constructor(
private $location: ILocationService,
private $timeout: ITimeoutService,
private $rootScope: GrafanaRootScope,
private $route: any
) {
this.angularUrl = $location.url();
}
init() {
this.$rootScope.$on('$routeUpdate', (evt, data) => {
const state = store.getState();
this.angularUrl = this.$location.url();
if (state.location.url !== this.angularUrl) {
store.dispatch(
updateLocation({
path: this.$location.path(),
query: this.$location.search(),
routeParams: this.$route.current.params,
})
);
}
});
this.$rootScope.$on('$routeChangeSuccess', (evt, data) => {
this.angularUrl = this.$location.url();
store.dispatch(
updateLocation({
path: this.$location.path(),
query: this.$location.search(),
routeParams: this.$route.current.params,
})
);
});
// Listen for changes in redux location -> update angular location
store.subscribe(() => {
const state = store.getState();
const url = state.location.url;
// No url change ignore redux store change
if (url === this.lastUrl) {
return;
}
if (this.angularUrl !== url) {
// store angular url right away as otherwise we end up syncing multiple times
this.angularUrl = url;
this.$timeout(() => {
this.$location.url(url);
// some state changes should not trigger new browser history
if (state.location.replace) {
this.$location.replace();
}
});
}
// if only query params changed, check if variables changed
if (state.location.path === this.lastPath && state.location.query !== this.lastQuery) {
// Find template variable changes
const changes = findTemplateVarChanges(state.location.query, this.lastQuery);
// Store current query params to avoid recursion
this.lastQuery = state.location.query;
if (changes) {
const dash = getDashboardSrv().getCurrent();
if (dash) {
dispatch(templateVarsChangedInUrl(changes));
}
}
}
this.lastPath = state.location.path;
this.lastQuery = state.location.query;
this.lastUrl = state.location.url;
});
}
}
function getUrlValueForComparison(value: any): any {
if (isArray(value)) {
if (value.length === 0) {
value = undefined;
} else if (value.length === 1) {
value = value[0];
}
}
return value;
}
export function findTemplateVarChanges(query: UrlQueryMap, old: UrlQueryMap): UrlQueryMap | undefined {
let count = 0;
const changes: UrlQueryMap = {};
for (const key in query) {
if (!key.startsWith('var-')) {
continue;
}
let oldValue = getUrlValueForComparison(old[key]);
let newValue = getUrlValueForComparison(query[key]);
if (!isEqual(newValue, oldValue)) {
changes[key] = query[key];
count++;
}
}
for (const key in old) {
if (!key.startsWith('var-')) {
continue;
}
const value = old[key];
// ignore empty array values
if (isArray(value) && value.length === 0) {
continue;
}
if (!query.hasOwnProperty(key)) {
changes[key] = ''; // removed
count++;
}
}
return count ? changes : undefined;
}
coreModule.service('bridgeSrv', BridgeSrv);

View File

@ -1,47 +1,39 @@
import _ from 'lodash';
import Mousetrap from 'mousetrap';
import 'mousetrap-global-bind';
import { ILocationService, IRootScopeService, ITimeoutService } from 'angular';
import { LegacyGraphHoverClearEvent, locationUtil } from '@grafana/data';
import coreModule from 'app/core/core_module';
import appEvents from 'app/core/app_events';
import { getExploreUrl } from 'app/core/utils/explore';
import { dispatch, store } from 'app/store/store';
import { exitPanelEditor } from 'app/features/dashboard/components/PanelEditor/state/actions';
import { AppEventEmitter, CoreEvents } from 'app/types';
import { GrafanaRootScope } from 'app/routes/GrafanaCtrl';
import { DashboardModel } from 'app/features/dashboard/state';
import { ShareModal } from 'app/features/dashboard/components/ShareModal';
import { SaveDashboardModalProxy } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardModalProxy';
import { defaultQueryParams } from 'app/features/search/reducers/searchQueryReducer';
import { ContextSrv } from './context_srv';
import { locationService } from '@grafana/runtime';
import { exitKioskMode, toggleKioskMode } from '../navigation/kiosk';
import {
HideModalEvent,
RemovePanelEvent,
ShiftTimeEvent,
ShiftTimeEventPayload,
ShowModalEvent,
ShowModalReactEvent,
ZoomOutEvent,
} from '../../types/events';
import { contextSrv } from '../core';
import { getDatasourceSrv } from '../../features/plugins/datasource_srv';
import { getTimeSrv } from '../../features/dashboard/services/TimeSrv';
export class KeybindingSrv {
modalOpen = false;
/** @ngInject */
constructor(
private $rootScope: GrafanaRootScope,
private $location: ILocationService,
private $timeout: ITimeoutService,
private datasourceSrv: any,
private timeSrv: any,
private contextSrv: ContextSrv
) {
// clear out all shortcuts on route change
$rootScope.$on('$routeChangeSuccess', () => {
Mousetrap.reset();
// rebind global shortcuts
this.setupGlobal();
});
this.setupGlobal();
appEvents.on(CoreEvents.showModal, () => (this.modalOpen = true));
constructor() {
appEvents.subscribe(ShowModalEvent, () => (this.modalOpen = true));
}
setupGlobal() {
if (!(this.$location.path() === '/login')) {
reset() {
Mousetrap.reset();
}
initGlobals() {
if (locationService.getLocation().pathname !== '/login') {
this.bind(['?', 'h'], this.showHelpModal);
this.bind('g h', this.goToHome);
this.bind('g a', this.openAlerting);
@ -53,7 +45,7 @@ export class KeybindingSrv {
}
}
globalEsc() {
private globalEsc() {
const anyDoc = document as any;
const activeElement = anyDoc.activeElement;
@ -79,68 +71,62 @@ export class KeybindingSrv {
this.exit();
}
openSearch() {
const search = _.extend(this.$location.search(), { search: 'open' });
this.$location.search(search);
private openSearch() {
locationService.partial({ search: 'open' });
}
closeSearch() {
const search = _.extend(this.$location.search(), { search: null, ...defaultQueryParams });
this.$location.search(search);
private closeSearch() {
locationService.partial({ search: null });
}
openAlerting() {
this.$location.url('/alerting');
private openAlerting() {
locationService.push('/alerting');
}
goToHome() {
this.$location.url('/');
private goToHome() {
locationService.push('/');
}
goToProfile() {
this.$location.url('/profile');
private goToProfile() {
locationService.push('/profile');
}
showHelpModal() {
appEvents.emit(CoreEvents.showModal, { templateHtml: '<help-modal></help-modal>' });
private showHelpModal() {
appEvents.publish(new ShowModalEvent({ templateHtml: '<help-modal></help-modal>' }));
}
exit() {
appEvents.emit(CoreEvents.hideModal);
private exit() {
appEvents.publish(new HideModalEvent());
if (this.modalOpen) {
this.modalOpen = false;
return;
}
// close settings view
const search = this.$location.search();
const search = locationService.getSearchObject();
if (search.editview) {
delete search.editview;
this.$location.search(search);
locationService.partial({ editview: null });
return;
}
if (search.inspect) {
delete search.inspect;
delete search.inspectTab;
this.$location.search(search);
locationService.partial({ inspect: null, inspectTab: null });
return;
}
if (search.editPanel) {
dispatch(exitPanelEditor());
locationService.partial({ editPanel: null, tab: null });
return;
}
if (search.viewPanel) {
delete search.viewPanel;
this.$location.search(search);
locationService.partial({ viewPanel: null, tab: null });
return;
}
if (search.kiosk) {
this.$rootScope.appEvent(CoreEvents.toggleKioskMode, { exit: true });
exitKioskMode();
}
if (search.search) {
@ -148,6 +134,12 @@ export class KeybindingSrv {
}
}
private showDashEditView() {
locationService.partial({
editview: 'settings',
});
}
bind(keyArg: string | string[], fn: () => void) {
Mousetrap.bind(
keyArg,
@ -155,7 +147,7 @@ export class KeybindingSrv {
evt.preventDefault();
evt.stopPropagation();
evt.returnValue = false;
return this.$rootScope.$apply(fn.bind(this));
fn.call(this);
},
'keydown'
);
@ -168,7 +160,7 @@ export class KeybindingSrv {
evt.preventDefault();
evt.stopPropagation();
evt.returnValue = false;
return this.$rootScope.$apply(fn.bind(this));
fn.call(this);
},
'keydown'
);
@ -178,12 +170,7 @@ export class KeybindingSrv {
Mousetrap.unbind(keyArg, keyType);
}
showDashEditView() {
const search = _.extend(this.$location.search(), { editview: 'settings' });
this.$location.search(search);
}
setupDashboardBindings(scope: IRootScopeService & AppEventEmitter, dashboard: DashboardModel) {
setupDashboardBindings(dashboard: DashboardModel) {
this.bind('mod+o', () => {
dashboard.graphTooltip = (dashboard.graphTooltip + 1) % 3;
dashboard.events.publish(new LegacyGraphHoverClearEvent());
@ -191,28 +178,30 @@ export class KeybindingSrv {
});
this.bind('mod+s', () => {
appEvents.emit(CoreEvents.showModalReact, {
appEvents.publish(
new ShowModalReactEvent({
component: SaveDashboardModalProxy,
props: {
dashboard,
},
});
})
);
});
this.bind('t z', () => {
scope.appEvent(CoreEvents.zoomOut, 2);
appEvents.publish(new ZoomOutEvent(2));
});
this.bind('ctrl+z', () => {
scope.appEvent(CoreEvents.zoomOut, 2);
appEvents.publish(new ZoomOutEvent(2));
});
this.bind('t left', () => {
scope.appEvent(CoreEvents.shiftTime, -1);
appEvents.publish(new ShiftTimeEvent(ShiftTimeEventPayload.Left));
});
this.bind('t right', () => {
scope.appEvent(CoreEvents.shiftTime, 1);
appEvents.publish(new ShiftTimeEvent(ShiftTimeEventPayload.Right));
});
// edit panel
@ -222,44 +211,47 @@ export class KeybindingSrv {
}
if (dashboard.canEditPanelById(dashboard.meta.focusPanelId)) {
const search = _.extend(this.$location.search(), { editPanel: dashboard.meta.focusPanelId });
this.$location.search(search);
locationService.partial({
editPanel: dashboard.meta.focusPanelId,
});
}
});
// view panel
this.bind('v', () => {
if (dashboard.meta.focusPanelId) {
const search = _.extend(this.$location.search(), { viewPanel: dashboard.meta.focusPanelId });
this.$location.search(search);
locationService.partial({
viewPanel: dashboard.meta.focusPanelId,
});
}
});
this.bind('i', () => {
if (dashboard.meta.focusPanelId) {
const search = _.extend(this.$location.search(), { inspect: dashboard.meta.focusPanelId });
this.$location.search(search);
locationService.partial({
inspect: dashboard.meta.focusPanelId,
});
}
});
// jump to explore if permissions allow
if (this.contextSrv.hasAccessToExplore()) {
if (contextSrv.hasAccessToExplore()) {
this.bind('x', async () => {
if (dashboard.meta.focusPanelId) {
const panel = dashboard.getPanelById(dashboard.meta.focusPanelId)!;
const datasource = await this.datasourceSrv.get(panel.datasource);
const datasource = await getDatasourceSrv().get(panel.datasource);
const url = await getExploreUrl({
panel,
panelTargets: panel.targets,
panelDatasource: datasource,
datasourceSrv: this.datasourceSrv,
timeSrv: this.timeSrv,
datasourceSrv: getDatasourceSrv(),
timeSrv: getTimeSrv(),
});
if (url) {
const urlWithoutBase = locationUtil.stripBaseFromUrl(url);
if (urlWithoutBase) {
this.$timeout(() => this.$location.url(urlWithoutBase));
locationService.push(urlWithoutBase);
}
}
}
@ -271,7 +263,7 @@ export class KeybindingSrv {
const panelId = dashboard.meta.focusPanelId;
if (panelId && dashboard.canEditPanelById(panelId) && !(dashboard.panelInView || dashboard.panelInEdit)) {
appEvents.emit(CoreEvents.removePanel, panelId);
appEvents.publish(new RemovePanelEvent(panelId));
dashboard.meta.focusPanelId = 0;
}
});
@ -291,13 +283,15 @@ export class KeybindingSrv {
if (dashboard.meta.focusPanelId) {
const panelInfo = dashboard.getPanelInfoById(dashboard.meta.focusPanelId);
appEvents.emit(CoreEvents.showModalReact, {
appEvents.publish(
new ShowModalReactEvent({
component: ShareModal,
props: {
dashboard: dashboard,
panel: panelInfo?.panel,
},
});
})
);
}
});
@ -329,7 +323,7 @@ export class KeybindingSrv {
});
this.bind('d n', () => {
this.$location.url('/dashboard/new');
locationService.push('/dashboard/new');
});
this.bind('d r', () => {
@ -341,35 +335,17 @@ export class KeybindingSrv {
});
this.bind('d k', () => {
appEvents.emit(CoreEvents.toggleKioskMode);
});
this.bind('d v', () => {
appEvents.emit(CoreEvents.toggleViewMode);
toggleKioskMode();
});
//Autofit panels
this.bind('d a', () => {
// this has to be a full page reload
const queryParams = store.getState().location.query;
const queryParams = locationService.getSearchObject();
const newUrlParam = queryParams.autofitpanels ? '' : '&autofitpanels';
window.location.href = window.location.href + newUrlParam;
});
}
}
coreModule.service('keybindingSrv', KeybindingSrv);
/**
* Code below exports the service to react components
*/
let singletonInstance: KeybindingSrv;
export function setKeybindingSrv(instance: KeybindingSrv) {
singletonInstance = instance;
}
export function getKeybindingSrv(): KeybindingSrv {
return singletonInstance;
}
export const keybindingSrv = new KeybindingSrv();

View File

@ -4,10 +4,11 @@ import { selectors } from '@grafana/e2e-selectors';
import coreModule from 'app/core/core_module';
import appEvents from 'app/core/app_events';
import { CoreEvents } from 'app/types';
import { GrafanaRootScope } from 'app/routes/GrafanaCtrl';
import { AngularModalProxy } from '../components/modals/AngularModalProxy';
import { provideTheme } from '../utils/ConfigProvider';
import { HideModalEvent, ShowConfirmModalEvent, ShowModalEvent, ShowModalReactEvent } from '../../types/events';
export class UtilSrv {
modalScope: any;
@ -20,10 +21,10 @@ export class UtilSrv {
}
init() {
appEvents.on(CoreEvents.showModal, this.showModal.bind(this), this.$rootScope);
appEvents.on(CoreEvents.hideModal, this.hideModal.bind(this), this.$rootScope);
appEvents.on(CoreEvents.showConfirmModal, this.showConfirmModal.bind(this), this.$rootScope);
appEvents.on(CoreEvents.showModalReact, this.showModalReact.bind(this), this.$rootScope);
appEvents.subscribe(ShowModalEvent, (e) => this.showModal(e.payload));
appEvents.subscribe(HideModalEvent, this.hideModal.bind(this));
appEvents.subscribe(ShowConfirmModalEvent, (e) => this.showConfirmModal(e.payload));
appEvents.subscribe(ShowModalReactEvent, (e) => this.showModalReact(e.payload));
}
showModalReact(options: any) {
@ -105,11 +106,13 @@ export class UtilSrv {
scope.confirmTextValid = scope.confirmText ? false : true;
scope.selectors = selectors.pages.ConfirmModal;
appEvents.emit(CoreEvents.showModal, {
appEvents.publish(
new ShowModalEvent({
src: 'public/app/partials/confirm_modal.html',
scope: scope,
modalClass: 'confirm-modal',
});
})
);
}
}

View File

@ -1,4 +1,7 @@
// Check to see if browser is not supported by Grafana
/**
* Check to see if browser is not supported by Grafana
* This function is copied to index-template.html but is here so we can write tests
* */
export function checkBrowserCompatibility() {
const isIE = navigator.userAgent.indexOf('MSIE') > -1;
const isEdge = navigator.userAgent.indexOf('Edge/') > -1 || navigator.userAgent.indexOf('Edg/') > -1;

View File

@ -1,60 +0,0 @@
import { getBackendSrv } from '@grafana/runtime';
import { NavModelSrv } from 'app/core/core';
import { promiseToDigest } from 'app/core/utils/promiseToDigest';
export default class AdminEditOrgCtrl {
/** @ngInject */
constructor($scope: any, $routeParams: any, $location: any, navModelSrv: NavModelSrv) {
$scope.init = () => {
$scope.navModel = navModelSrv.getNav('admin', 'global-orgs', 0);
if ($routeParams.id) {
promiseToDigest($scope)(Promise.all([$scope.getOrg($routeParams.id), $scope.getOrgUsers($routeParams.id)]));
}
};
$scope.getOrg = (id: number) => {
return getBackendSrv()
.get('/api/orgs/' + id)
.then((org: any) => {
$scope.org = org;
});
};
$scope.getOrgUsers = (id: number) => {
return getBackendSrv()
.get('/api/orgs/' + id + '/users')
.then((orgUsers: any) => {
$scope.orgUsers = orgUsers;
});
};
$scope.update = () => {
if (!$scope.orgDetailsForm.$valid) {
return;
}
promiseToDigest($scope)(
getBackendSrv()
.put('/api/orgs/' + $scope.org.id, $scope.org)
.then(() => {
$location.path('/admin/orgs');
})
);
};
$scope.updateOrgUser = (orgUser: any) => {
getBackendSrv().patch('/api/orgs/' + orgUser.orgId + '/users/' + orgUser.userId, orgUser);
};
$scope.removeOrgUser = (orgUser: any) => {
promiseToDigest($scope)(
getBackendSrv()
.delete('/api/orgs/' + orgUser.orgId + '/users/' + orgUser.userId)
.then(() => $scope.getOrgUsers($scope.org.id))
);
};
$scope.init();
}
}

View File

@ -9,6 +9,7 @@ import { getBackendSrv } from '@grafana/runtime';
import { UrlQueryValue } from '@grafana/data';
import { Form, Field, Input, Button, Legend } from '@grafana/ui';
import { css } from 'emotion';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
interface OrgNameDTO {
orgName: string;
@ -30,11 +31,12 @@ const removeOrgUser = async (orgUser: OrgUser, orgId: UrlQueryValue) => {
return await getBackendSrv().delete('/api/orgs/' + orgId + '/users/' + orgUser.userId);
};
export const AdminEditOrgPage: FC = () => {
interface Props extends GrafanaRouteComponentProps<{ id: string }> {}
export const AdminEditOrgPage: FC<Props> = ({ match }) => {
const navIndex = useSelector((state: StoreState) => state.navIndex);
const navModel = getNavModel(navIndex, 'global-orgs');
const orgId = useSelector((state: StoreState) => state.location.routeParams.id);
const orgId = parseInt(match.params.id, 10);
const [users, setUsers] = useState<OrgUser[]>([]);

View File

@ -3,7 +3,6 @@ import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
import { NavModel } from '@grafana/data';
import { getNavModel } from 'app/core/selectors/navModel';
import { getRouteParamsId } from 'app/core/selectors/location';
import config from 'app/core/config';
import Page from 'app/core/components/Page/Page';
import { UserProfile } from './UserProfile';
@ -27,10 +26,10 @@ import {
syncLdapUser,
} from './state/actions';
import { UserOrgs } from './UserOrgs';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
interface Props {
interface Props extends GrafanaRouteComponentProps<{ id: string }> {
navModel: NavModel;
userId: number;
user: UserDTO;
orgs: UserOrg[];
sessions: UserSession[];
@ -63,8 +62,8 @@ export class UserAdminPage extends PureComponent<Props, State> {
};
async componentDidMount() {
const { userId, loadAdminUserPage } = this.props;
loadAdminUserPage(userId);
const { match, loadAdminUserPage } = this.props;
loadAdminUserPage(parseInt(match.params.id, 10));
}
onUserUpdate = (user: UserDTO) => {
@ -72,8 +71,8 @@ export class UserAdminPage extends PureComponent<Props, State> {
};
onPasswordChange = (password: string) => {
const { userId, setUserPassword } = this.props;
setUserPassword(userId, password);
const { user, setUserPassword } = this.props;
setUserPassword(user.id, password);
};
onUserDelete = (userId: number) => {
@ -89,18 +88,18 @@ export class UserAdminPage extends PureComponent<Props, State> {
};
onGrafanaAdminChange = (isGrafanaAdmin: boolean) => {
const { userId, updateUserPermissions } = this.props;
updateUserPermissions(userId, isGrafanaAdmin);
const { user, updateUserPermissions } = this.props;
updateUserPermissions(user.id, isGrafanaAdmin);
};
onOrgRemove = (orgId: number) => {
const { userId, deleteOrgUser } = this.props;
deleteOrgUser(userId, orgId);
const { user, deleteOrgUser } = this.props;
deleteOrgUser(user.id, orgId);
};
onOrgRoleChange = (orgId: number, newRole: string) => {
const { userId, updateOrgUserRole } = this.props;
updateOrgUserRole(userId, orgId, newRole);
const { user, updateOrgUserRole } = this.props;
updateOrgUserRole(user.id, orgId, newRole);
};
onOrgAdd = (orgId: number, role: string) => {
@ -109,18 +108,18 @@ export class UserAdminPage extends PureComponent<Props, State> {
};
onSessionRevoke = (tokenId: number) => {
const { userId, revokeSession } = this.props;
revokeSession(tokenId, userId);
const { user, revokeSession } = this.props;
revokeSession(tokenId, user.id);
};
onAllSessionsRevoke = () => {
const { userId, revokeAllSessions } = this.props;
revokeAllSessions(userId);
const { user, revokeAllSessions } = this.props;
revokeAllSessions(user.id);
};
onUserSync = () => {
const { userId, syncLdapUser } = this.props;
syncLdapUser(userId);
const { user, syncLdapUser } = this.props;
syncLdapUser(user.id);
};
render() {
@ -171,7 +170,6 @@ export class UserAdminPage extends PureComponent<Props, State> {
}
const mapStateToProps = (state: StoreState) => ({
userId: getRouteParamsId(state.location),
navModel: getNavModel(state.navIndex, 'global-users'),
user: state.userAdmin.user,
sessions: state.userAdmin.sessions,

View File

@ -7,11 +7,10 @@ import { getBackendSrv } from '@grafana/runtime';
import { StoreState } from '../../types';
import { getNavModel } from '../../core/selectors/navModel';
import Page from 'app/core/components/Page/Page';
import { updateLocation } from 'app/core/actions';
import { useHistory } from 'react-router-dom';
interface UserCreatePageProps {
navModel: NavModel;
updateLocation: typeof updateLocation;
}
interface UserDTO {
name: string;
@ -22,10 +21,12 @@ interface UserDTO {
const createUser = async (user: UserDTO) => getBackendSrv().post('/api/admin/users', user);
const UserCreatePage: React.FC<UserCreatePageProps> = ({ navModel, updateLocation }) => {
const UserCreatePage: React.FC<UserCreatePageProps> = ({ navModel }) => {
const history = useHistory();
const onSubmit = useCallback(async (data: UserDTO) => {
await createUser(data);
updateLocation({ path: '/admin/users' });
history.push('/admin/users');
}, []);
return (
@ -80,7 +81,4 @@ const mapStateToProps = (state: StoreState) => ({
navModel: getNavModel(state.navIndex, 'global-users'),
});
const mapDispatchToProps = {
updateLocation,
};
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(UserCreatePage));
export default hot(module)(connect(mapStateToProps)(UserCreatePage));

View File

@ -1,16 +0,0 @@
import AdminEditOrgCtrl from './AdminEditOrgCtrl';
import coreModule from 'app/core/core_module';
import { NavModelSrv } from 'app/core/core';
class AdminHomeCtrl {
navModel: any;
/** @ngInject */
constructor(navModelSrv: NavModelSrv) {
this.navModel = navModelSrv.getNav('admin');
}
}
coreModule.controller('AdminEditOrgCtrl', AdminEditOrgCtrl);
coreModule.controller('AdminHomeCtrl', AdminHomeCtrl);

View File

@ -18,15 +18,15 @@ import {
clearUserError,
clearUserMappingInfo,
} from '../state/actions';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
interface Props {
interface Props extends GrafanaRouteComponentProps<{}, { username: string }> {
navModel: NavModel;
ldapConnectionInfo: LdapConnectionInfo;
ldapUser: LdapUser;
ldapSyncInfo: SyncInfo;
ldapError: LdapError;
userError?: LdapError;
username?: string;
loadLdapState: typeof loadLdapState;
loadLdapSyncStatus: typeof loadLdapSyncStatus;
@ -45,12 +45,14 @@ export class LdapPage extends PureComponent<Props, State> {
};
async componentDidMount() {
const { username, clearUserMappingInfo, loadUserMapping } = this.props;
const { clearUserMappingInfo, queryParams } = this.props;
await clearUserMappingInfo();
await this.fetchLDAPStatus();
if (username) {
await loadUserMapping(username);
if (queryParams.username) {
await this.fetchUserMapping(queryParams.username);
}
this.setState({ isLoading: false });
}
@ -77,7 +79,7 @@ export class LdapPage extends PureComponent<Props, State> {
};
render() {
const { ldapUser, userError, ldapError, ldapSyncInfo, ldapConnectionInfo, navModel, username } = this.props;
const { ldapUser, userError, ldapError, ldapSyncInfo, ldapConnectionInfo, navModel, queryParams } = this.props;
const { isLoading } = this.state;
return (
@ -106,7 +108,7 @@ export class LdapPage extends PureComponent<Props, State> {
type="text"
id="username"
name="username"
defaultValue={username}
defaultValue={queryParams.username}
/>
<button type="submit" className="btn btn-primary">
Run
@ -134,7 +136,6 @@ export class LdapPage extends PureComponent<Props, State> {
const mapStateToProps = (state: StoreState) => ({
navModel: getNavModel(state.navIndex, 'ldap'),
username: state.location.routeParams.user,
ldapConnectionInfo: state.ldap.connectionInfo,
ldapUser: state.ldap.user,
ldapSyncInfo: state.ldap.syncInfo,

View File

@ -1,7 +1,6 @@
import { updateLocation } from 'app/core/actions';
import config from 'app/core/config';
import { dateTimeFormat, dateTimeFormatTimeAgo } from '@grafana/data';
import { getBackendSrv } from '@grafana/runtime';
import { getBackendSrv, locationService } from '@grafana/runtime';
import { ThunkResult, LdapUser, UserSession, UserDTO } from 'app/types';
import {
@ -74,8 +73,7 @@ export function setUserPassword(userId: number, password: string): ThunkResult<v
export function disableUser(userId: number): ThunkResult<void> {
return async (dispatch) => {
await getBackendSrv().post(`/api/admin/users/${userId}/disable`);
// dispatch(loadAdminUserPage(userId));
dispatch(updateLocation({ path: '/admin/users' }));
locationService.push('/admin/users');
};
}
@ -89,7 +87,7 @@ export function enableUser(userId: number): ThunkResult<void> {
export function deleteUser(userId: number): ThunkResult<void> {
return async (dispatch) => {
await getBackendSrv().delete(`/api/admin/users/${userId}`);
dispatch(updateLocation({ path: '/admin/users' }));
locationService.push('/admin/users');
};
}

View File

@ -4,24 +4,24 @@ import { AlertRuleListUnconnected, Props } from './AlertRuleList';
import { AlertRule } from '../../types';
import appEvents from '../../core/app_events';
import { NavModel } from '@grafana/data';
import { CoreEvents } from 'app/types';
import { updateLocation } from '../../core/actions';
import { setSearchQuery } from './state/reducers';
import { mockToolkitActionCreator } from 'test/core/redux/mocks';
import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps';
import { locationService } from '@grafana/runtime';
import { ShowModalEvent } from '../../types/events';
jest.mock('../../core/app_events', () => ({
emit: jest.fn(),
publish: jest.fn(),
}));
const setup = (propOverrides?: object) => {
const props: Props = {
...getRouteComponentProps({}),
navModel: {} as NavModel,
alertRules: [] as AlertRule[],
updateLocation: mockToolkitActionCreator(updateLocation),
getAlertRulesAsync: jest.fn(),
setSearchQuery: mockToolkitActionCreator(setSearchQuery),
togglePauseAlertRule: jest.fn(),
stateFilter: '',
search: '',
isLoading: false,
ngAlertDefinitions: [],
@ -52,7 +52,7 @@ describe('Life cycle', () => {
const { instance } = setup();
instance.fetchRules = jest.fn();
instance.componentDidUpdate({ stateFilter: 'ok' } as Props);
instance.componentDidUpdate({ queryParams: { state: 'ok' } } as any);
expect(instance.fetchRules).toHaveBeenCalled();
});
@ -63,19 +63,16 @@ describe('Functions', () => {
describe('Get state filter', () => {
it('should get all if prop is not set', () => {
const { instance } = setup();
const stateFilter = instance.getStateFilter();
expect(stateFilter).toEqual('all');
});
it('should return state filter if set', () => {
const { instance } = setup({
stateFilter: 'ok',
queryParams: { state: 'ok' },
});
const stateFilter = instance.getStateFilter();
expect(stateFilter).toEqual('ok');
});
});
@ -84,10 +81,8 @@ describe('Functions', () => {
it('should update location', () => {
const { instance } = setup();
const mockEvent = { value: 'alerting' };
instance.onStateFilterChanged(mockEvent);
expect(instance.props.updateLocation).toHaveBeenCalledWith({ query: { state: 'alerting' } });
expect(locationService.getSearchObject().state).toBe('alerting');
});
});
@ -97,20 +92,20 @@ describe('Functions', () => {
instance.onOpenHowTo();
expect(appEvents.emit).toHaveBeenCalledWith(CoreEvents.showModal, {
expect(appEvents.publish).toHaveBeenCalledWith(
new ShowModalEvent({
src: 'public/app/features/alerting/partials/alert_howto.html',
modalClass: 'confirm-modal',
model: {},
});
})
);
});
});
describe('Search query change', () => {
it('should set search query', () => {
const { instance } = setup();
instance.onSearchQueryChange('dashboard');
expect(instance.props.setSearchQuery).toHaveBeenCalledWith('dashboard');
});
});

View File

@ -4,23 +4,23 @@ import { connect, ConnectedProps } from 'react-redux';
import Page from 'app/core/components/Page/Page';
import AlertRuleItem from './AlertRuleItem';
import appEvents from 'app/core/app_events';
import { updateLocation } from 'app/core/actions';
import { getNavModel } from 'app/core/selectors/navModel';
import { AlertDefinition, AlertRule, CoreEvents, StoreState } from 'app/types';
import { AlertDefinition, AlertRule, StoreState } from 'app/types';
import { getAlertRulesAsync, togglePauseAlertRule } from './state/actions';
import { getAlertRuleItems, getSearchQuery } from './state/selectors';
import { FilterInput } from 'app/core/components/FilterInput/FilterInput';
import { SelectableValue } from '@grafana/data';
import { config } from '@grafana/runtime';
import { config, locationService } from '@grafana/runtime';
import { setSearchQuery } from './state/reducers';
import { Button, LinkButton, Select, VerticalGroup } from '@grafana/ui';
import { AlertDefinitionItem } from './components/AlertDefinitionItem';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { ShowModalEvent } from '../../types/events';
function mapStateToProps(state: StoreState) {
return {
navModel: getNavModel(state.navIndex, 'alert-list'),
alertRules: getAlertRuleItems(state),
stateFilter: state.location.query.state,
search: getSearchQuery(state.alertRules),
isLoading: state.alertRules.isLoading,
ngAlertDefinitions: state.alertDefinition.alertDefinitions,
@ -28,7 +28,6 @@ function mapStateToProps(state: StoreState) {
}
const mapDispatchToProps = {
updateLocation,
getAlertRulesAsync,
setSearchQuery,
togglePauseAlertRule,
@ -36,11 +35,11 @@ const mapDispatchToProps = {
const connector = connect(mapStateToProps, mapDispatchToProps);
interface OwnProps {}
interface OwnProps extends GrafanaRouteComponentProps<{}, { state: string }> {}
export type Props = OwnProps & ConnectedProps<typeof connector>;
export class AlertRuleListUnconnected extends PureComponent<Props, any> {
export class AlertRuleListUnconnected extends PureComponent<Props> {
stateFilters = [
{ label: 'All', value: 'all' },
{ label: 'OK', value: 'ok' },
@ -56,7 +55,7 @@ export class AlertRuleListUnconnected extends PureComponent<Props, any> {
}
componentDidUpdate(prevProps: Props) {
if (prevProps.stateFilter !== this.props.stateFilter) {
if (prevProps.queryParams.state !== this.props.queryParams.state) {
this.fetchRules();
}
}
@ -66,25 +65,21 @@ export class AlertRuleListUnconnected extends PureComponent<Props, any> {
}
getStateFilter(): string {
const { stateFilter } = this.props;
if (stateFilter) {
return stateFilter.toString();
}
return 'all';
return this.props.queryParams.state ?? 'all';
}
onStateFilterChanged = (option: SelectableValue) => {
this.props.updateLocation({
query: { state: option.value },
});
locationService.partial({ state: option.value });
};
onOpenHowTo = () => {
appEvents.emit(CoreEvents.showModal, {
appEvents.publish(
new ShowModalEvent({
src: 'public/app/features/alerting/partials/alert_howto.html',
modalClass: 'confirm-modal',
model: {},
});
})
);
};
onSearchQueryChange = (value: string) => {

View File

@ -13,6 +13,7 @@ import { PanelModel } from 'app/features/dashboard/state';
import { getDefaultCondition } from './getAlertingValidationMessage';
import { CoreEvents } from 'app/types';
import { promiseToDigest } from 'app/core/utils/promiseToDigest';
import { ShowConfirmModalEvent } from '../../types/events';
export class AlertTabCtrl {
panel: PanelModel;
@ -230,7 +231,8 @@ export class AlertTabCtrl {
}
if (!model) {
appEvents.emit(CoreEvents.showConfirmModal, {
appEvents.publish(
new ShowConfirmModalEvent({
title: 'Notifier with invalid identifier is detected',
text: `Do you want to delete notifier with invalid identifier: ${identifier} from the dashboard JSON?`,
text2: 'After successful deletion, make sure to save the dashboard for storing the update JSON.',
@ -240,7 +242,8 @@ export class AlertTabCtrl {
onConfirm: async () => {
this.removeNotification(addedNotification);
},
});
})
);
}
if (model && model.isDefault === false) {
@ -424,7 +427,8 @@ export class AlertTabCtrl {
}
delete() {
appEvents.emit(CoreEvents.showConfirmModal, {
appEvents.publish(
new ShowConfirmModalEvent({
title: 'Delete Alert',
text: 'Are you sure you want to delete this alert rule?',
text2: 'You need to save dashboard for the delete to take effect',
@ -438,7 +442,8 @@ export class AlertTabCtrl {
this.panelCtrl.alertState = null;
this.panelCtrl.render();
},
});
})
);
}
enable = () => {
@ -474,7 +479,8 @@ export class AlertTabCtrl {
}
clearHistory() {
appEvents.emit(CoreEvents.showConfirmModal, {
appEvents.publish(
new ShowConfirmModalEvent({
title: 'Delete Alert History',
text: 'Are you sure you want to remove all history & annotations for this alert?',
icon: 'trash-alt',
@ -492,7 +498,8 @@ export class AlertTabCtrl {
})
);
},
});
})
);
}
}

View File

@ -8,16 +8,15 @@ import { connectWithCleanUp } from 'app/core/components/connectWithCleanUp';
import { NotificationChannelForm } from './components/NotificationChannelForm';
import { loadNotificationChannel, testNotificationChannel, updateNotificationChannel } from './state/actions';
import { getNavModel } from 'app/core/selectors/navModel';
import { getRouteParamsId } from 'app/core/selectors/location';
import { mapChannelsToSelectableValue, transformSubmitData, transformTestData } from './utils/notificationChannels';
import { NotificationChannelType, NotificationChannelDTO, StoreState } from 'app/types';
import { resetSecureField } from './state/reducers';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
interface OwnProps {}
interface OwnProps extends GrafanaRouteComponentProps<{ id: string }> {}
interface ConnectedProps {
navModel: NavModel;
channelId: number;
notificationChannel: any;
notificationChannelTypes: NotificationChannelType[];
}
@ -33,9 +32,7 @@ type Props = OwnProps & ConnectedProps & DispatchProps;
export class EditNotificationChannelPage extends PureComponent<Props> {
componentDidMount() {
const { channelId } = this.props;
this.props.loadNotificationChannel(channelId);
this.props.loadNotificationChannel(parseInt(this.props.match.params.id, 10));
}
onSubmit = (formData: NotificationChannelDTO) => {
@ -119,10 +116,8 @@ export class EditNotificationChannelPage extends PureComponent<Props> {
}
const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = (state) => {
const channelId = getRouteParamsId(state.location) as number;
return {
navModel: getNavModel(state.navIndex, 'channels'),
channelId,
notificationChannel: state.notificationChannel.notificationChannel,
notificationChannelTypes: state.notificationChannel.notificationChannelTypes,
};

View File

@ -21,19 +21,17 @@ import {
updateAlertDefinitionOption,
updateAlertDefinitionUiState,
} from './state/actions';
import { getRouteParamsId } from 'app/core/selectors/location';
import { StoreState } from 'app/types';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
function mapStateToProps(state: StoreState) {
const pageId = getRouteParamsId(state.location);
function mapStateToProps(state: StoreState, props: RouteProps) {
return {
uiState: state.alertDefinition.uiState,
getQueryOptions: state.alertDefinition.getQueryOptions,
queryRunner: state.alertDefinition.queryRunner,
getInstances: state.alertDefinition.getInstances,
alertDefinition: state.alertDefinition.alertDefinition,
pageId: (pageId as string) ?? '',
pageId: props.match.params.id as string,
};
}
@ -51,6 +49,8 @@ const mapDispatchToProps = {
const connector = connect(mapStateToProps, mapDispatchToProps);
interface RouteProps extends GrafanaRouteComponentProps<{ id: string }> {}
interface OwnProps {
saveDefinition: typeof createAlertDefinition | typeof updateAlertDefinition;
}

View File

@ -1,179 +0,0 @@
import _ from 'lodash';
import { appEvents, coreModule, NavModelSrv } from 'app/core/core';
import { getBackendSrv } from '@grafana/runtime';
import { AppEvents } from '@grafana/data';
import { IScope } from 'angular';
import { promiseToDigest } from '../../core/utils/promiseToDigest';
import config from 'app/core/config';
import { CoreEvents } from 'app/types';
export class AlertNotificationEditCtrl {
theForm: any;
navModel: any;
testSeverity = 'critical';
notifiers: any;
notifierTemplateId: string;
isNew: boolean;
model: any;
defaults: any = {
type: 'email',
sendReminder: false,
disableResolveMessage: false,
frequency: '15m',
settings: {
httpMethod: 'POST',
autoResolve: true,
severity: 'critical',
uploadImage: true,
},
secureSettings: {},
isDefault: false,
};
getFrequencySuggestion: any;
rendererAvailable: boolean;
/** @ngInject */
constructor(
private $scope: IScope,
private $routeParams: any,
private $location: any,
private $templateCache: any,
navModelSrv: NavModelSrv
) {
this.navModel = navModelSrv.getNav('alerting', 'channels', 0);
this.isNew = !this.$routeParams.id;
this.getFrequencySuggestion = () => {
return ['1m', '5m', '10m', '15m', '30m', '1h'];
};
this.defaults.settings.uploadImage = config.rendererAvailable;
this.rendererAvailable = config.rendererAvailable;
promiseToDigest(this.$scope)(
getBackendSrv()
.get(`/api/alert-notifiers`)
.then((notifiers: any) => {
this.notifiers = notifiers;
// add option templates
for (const notifier of this.notifiers) {
this.$templateCache.put(this.getNotifierTemplateId(notifier.type), notifier.optionsTemplate);
}
if (!this.$routeParams.id) {
this.navModel.breadcrumbs.push({ text: 'New channel' });
this.navModel.node = { text: 'New channel' };
return _.defaults(this.model, this.defaults);
}
return getBackendSrv()
.get(`/api/alert-notifications/${this.$routeParams.id}`)
.then((result: any) => {
this.navModel.breadcrumbs.push({ text: result.name });
this.navModel.node = { text: result.name };
result.settings = _.defaults(result.settings, this.defaults.settings);
result.secureSettings = _.defaults(result.secureSettings, this.defaults.secureSettings);
return result;
});
})
.then((model: any) => {
this.model = model;
this.notifierTemplateId = this.getNotifierTemplateId(this.model.type);
})
);
}
save() {
if (!this.theForm.$valid) {
return;
}
if (this.model.id) {
promiseToDigest(this.$scope)(
getBackendSrv()
.put(`/api/alert-notifications/${this.model.id}`, this.model)
.then((res: any) => {
this.model = res;
appEvents.emit(AppEvents.alertSuccess, ['Notification updated']);
})
.catch((err: any) => {
if (err.data && err.data.error) {
appEvents.emit(AppEvents.alertError, [err.data.error]);
}
})
);
} else {
promiseToDigest(this.$scope)(
getBackendSrv()
.post(`/api/alert-notifications`, this.model)
.then((res: any) => {
appEvents.emit(AppEvents.alertSuccess, ['Notification created']);
this.$location.path('alerting/notifications');
})
.catch((err: any) => {
if (err.data && err.data.error) {
appEvents.emit(AppEvents.alertError, [err.data.error]);
}
})
);
}
}
deleteNotification() {
appEvents.emit(CoreEvents.showConfirmModal, {
title: 'Delete',
text: 'Do you want to delete this notification channel?',
text2: `Deleting this notification channel will not delete from alerts any references to it`,
icon: 'trash-alt',
confirmText: 'Delete',
yesText: 'Delete',
onConfirm: () => {
this.deleteNotificationConfirmed();
},
});
}
deleteNotificationConfirmed() {
promiseToDigest(this.$scope)(
getBackendSrv()
.delete(`/api/alert-notifications/${this.model.id}`)
.then((res: any) => {
this.model = res;
this.$location.path('alerting/notifications');
})
);
}
getNotifierTemplateId(type: string) {
return `notifier-options-${type}`;
}
typeChanged() {
this.model.settings = _.defaults({}, this.defaults.settings);
this.model.secureSettings = _.defaults({}, this.defaults.secureSettings);
this.notifierTemplateId = this.getNotifierTemplateId(this.model.type);
}
testNotification() {
if (!this.theForm.$valid) {
return;
}
const payload: any = {
name: this.model.name,
type: this.model.type,
frequency: this.model.frequency,
settings: this.model.settings,
secureSettings: this.model.secureSettings,
};
if (this.model.id) {
payload.id = this.model.id;
}
promiseToDigest(this.$scope)(getBackendSrv().post(`/api/alert-notifications/test`, payload));
}
}
coreModule.controller('AlertNotificationEditCtrl', AlertNotificationEditCtrl);

View File

@ -6,8 +6,8 @@ import { useAsyncFn } from 'react-use';
import { appEvents } from 'app/core/core';
import { useNavModel } from 'app/core/hooks/useNavModel';
import { HorizontalGroup, Button, LinkButton } from '@grafana/ui';
import { CoreEvents } from 'app/types';
import { AlertNotification } from 'app/types/alerting';
import { ShowConfirmModalEvent } from '../../types/events';
const NotificationsListPage: FC = () => {
const navModel = useNavModel('channels');
@ -26,7 +26,8 @@ const NotificationsListPage: FC = () => {
}, []);
const deleteNotification = (id: number) => {
appEvents.emit(CoreEvents.showConfirmModal, {
appEvents.publish(
new ShowConfirmModalEvent({
title: 'Delete',
text: 'Do you want to delete this notification channel?',
text2: `Deleting this notification channel will not delete from alerts any references to it`,
@ -36,7 +37,8 @@ const NotificationsListPage: FC = () => {
onConfirm: async () => {
deleteNotificationConfirmed(id);
},
});
})
);
};
const deleteNotificationConfirmed = async (id: number) => {

Some files were not shown because too many files have changed in this diff Show More