From a55a2722764fdae5b7091b432bfc6e0cd1d61f1b Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Wed, 10 Mar 2021 18:03:36 +0100 Subject: [PATCH] Routing NG: Replace Angular routing with react-router (#31463) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * Update public/app/features/dashboard/containers/DashboardPage.tsx Co-authored-by: Alex Khomenko * 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 * fixed test * Update public/app/AppWrapper.tsx Co-authored-by: Alex Khomenko * Update public/app/AppWrapper.tsx Co-authored-by: Alex Khomenko * Remove Angular dependency from DashboardLoaderSrv (#31863) Co-authored-by: Torkel Ödegaard Co-authored-by: Torkel Ödegaard Co-authored-by: Hugo Häggmark Co-authored-by: Alex Khomenko --- e2e/suite1/specs/explore.spec.ts | 2 +- .../specs/variables/load-options-from-url.ts | 6 +- .../specs/variables/new-query-variable.ts | 8 +- .../specs/variables/set-options-from-ui.ts | 14 +- .../specs/variables/textbox-variables.ts | 26 +- package.json | 4 + packages/grafana-data/src/field/fieldColor.ts | 5 + .../grafana-data/src/field/fieldComparers.ts | 4 + .../src/field/fieldOverrides.test.ts | 8 +- .../grafana-data/src/utils/location.test.ts | 5 +- packages/grafana-data/src/utils/location.ts | 14 +- packages/grafana-data/src/utils/url.test.ts | 22 + packages/grafana-data/src/utils/url.ts | 59 ++ .../src/selectors/components.ts | 3 + .../src/selectors/pages.ts | 4 + packages/grafana-runtime/package.json | 4 +- .../src/services/LocationService.test.ts | 39 + .../src/services/LocationService.ts | 171 ++++ .../grafana-runtime/src/services/index.ts | 1 + packages/grafana-ui/package.json | 2 + .../grafana-ui/src/components/Link/Link.tsx | 20 + .../src/components/PageLayout/PageToolbar.tsx | 8 +- packages/grafana-ui/src/components/index.ts | 1 + .../grafana-ui/src/components/uPlot/utils.ts | 15 +- packages/grafana-ui/src/utils/index.ts | 1 + packages/grafana-ui/src/utils/logger.ts | 15 + public/app/AppWrapper.tsx | 112 +++ public/app/angular/AngularApp.ts | 118 +++ .../angular/AngularLocationWrapper.test.ts | 201 +++++ public/app/angular/AngularLocationWrapper.ts | 127 +++ .../app/angular/bridgeReactAngularRouting.ts | 47 ++ public/app/app.ts | 202 ++--- public/app/core/actions/index.ts | 4 +- public/app/core/angular_wrappers.ts | 7 +- .../AppNotifications/AppNotificationList.tsx | 38 +- .../DynamicImports/SafeDynamicImport.tsx | 10 +- .../ForgottenPassword/ChangePasswordPage.tsx | 9 +- .../app/core/components/Login/LoginCtrl.tsx | 24 +- public/app/core/components/Signup/Signup.tsx | 126 --- .../app/core/components/Signup/SignupPage.tsx | 120 ++- .../components/form_dropdown/form_dropdown.ts | 2 +- .../sidemenu/BottomNavLinks.test.tsx | 10 +- .../components/sidemenu/BottomNavLinks.tsx | 16 +- .../components/sidemenu/BottomSection.tsx | 2 +- .../components/sidemenu/DropDownChild.tsx | 18 +- .../components/sidemenu/SideMenu.test.tsx | 69 +- .../app/core/components/sidemenu/SideMenu.tsx | 43 +- .../components/sidemenu/SideMenuDropDown.tsx | 18 +- .../core/components/sidemenu/SignIn.test.tsx | 15 +- .../app/core/components/sidemenu/SignIn.tsx | 15 +- .../core/components/sidemenu/TopSection.tsx | 4 +- .../sidemenu/TopSectionItem.test.tsx | 7 +- .../components/sidemenu/TopSectionItem.tsx | 25 +- .../BottomNavLinks.test.tsx.snap | 16 +- .../__snapshots__/BottomSection.test.tsx.snap | 2 +- .../__snapshots__/SideMenu.test.tsx.snap | 37 - .../__snapshots__/SignIn.test.tsx.snap | 41 - .../TopSectionItem.test.tsx.snap | 206 +++-- public/app/core/controllers/all.ts | 3 - public/app/core/controllers/invited_ctrl.ts | 53 -- .../core/controllers/reset_password_ctrl.ts | 64 -- public/app/core/controllers/signup_ctrl.ts | 66 -- .../app/core/navigation/GrafanaRoute.test.tsx | 56 ++ public/app/core/navigation/GrafanaRoute.tsx | 83 ++ public/app/core/navigation/RouterDebugger.tsx | 45 + .../core/navigation/__mocks__/routeProps.ts | 19 + public/app/core/navigation/hooks.ts | 17 + public/app/core/navigation/kiosk.ts | 40 + public/app/core/navigation/parseKeyValue.ts | 165 ++++ .../navigation/patch/RouteParamsProvider.ts | 13 + .../core/navigation/patch/RouteProvider.ts | 14 + .../navigation/patch/interceptLinkClicks.ts | 41 + public/app/core/navigation/queryString.ts | 10 + public/app/core/navigation/testRoutes.tsx | 35 + public/app/core/navigation/types.ts | 19 + public/app/core/navigation/utils.ts | 5 + public/app/core/reducers/index.ts | 2 - public/app/core/reducers/location.test.ts | 154 ---- public/app/core/reducers/location.ts | 46 - public/app/core/selectors/location.ts | 7 - public/app/core/services/all.ts | 1 - public/app/core/services/analytics.ts | 47 +- public/app/core/services/bridge_srv.test.ts | 76 -- public/app/core/services/bridge_srv.ts | 152 ---- public/app/core/services/keybindingSrv.ts | 210 ++--- public/app/core/services/util_srv.ts | 23 +- public/app/core/utils/browser.ts | 15 +- public/app/features/admin/AdminEditOrgCtrl.ts | 60 -- .../app/features/admin/AdminEditOrgPage.tsx | 8 +- public/app/features/admin/UserAdminPage.tsx | 38 +- public/app/features/admin/UserCreatePage.tsx | 14 +- public/app/features/admin/index.ts | 16 - public/app/features/admin/ldap/LdapPage.tsx | 17 +- public/app/features/admin/state/actions.ts | 8 +- .../features/alerting/AlertRuleList.test.tsx | 35 +- .../app/features/alerting/AlertRuleList.tsx | 37 +- public/app/features/alerting/AlertTabCtrl.ts | 97 ++- .../alerting/EditNotificationChannelPage.tsx | 11 +- .../features/alerting/NextGenAlertingPage.tsx | 10 +- .../alerting/NotificationsEditCtrl.ts | 179 ---- .../alerting/NotificationsListPage.tsx | 26 +- public/app/features/alerting/state/actions.ts | 13 +- public/app/features/all.ts | 2 - public/app/features/api-keys/ApiKeysPage.tsx | 11 +- .../AddPanelWidget/AddPanelWidget.test.tsx | 1 - .../AddPanelWidget/AddPanelWidget.tsx | 17 +- .../dashboard/components/DashNav/DashNav.tsx | 71 +- .../DashNav/DashNavTimeControls.tsx | 31 +- .../components/DashboardRow/DashboardRow.tsx | 27 +- .../DashboardSettings/AnnotationsSettings.tsx | 1 + .../DashboardSettings/DashboardSettings.tsx | 16 +- .../DeleteDashboard/useDashboardDelete.tsx | 14 +- .../components/Inspector/PanelInspector.tsx | 32 +- .../components/PanelEditor/PanelEditor.tsx | 82 +- .../PanelEditor/PanelNotSupported.test.tsx | 13 +- .../PanelEditor/PanelNotSupported.tsx | 4 +- .../components/PanelEditor/state/actions.ts | 35 +- .../PanelEditor/state/selectors.test.ts | 63 +- .../components/PanelEditor/state/selectors.ts | 5 +- .../SaveDashboard/useDashboardSave.tsx | 21 +- .../components/ShareModal/ShareExport.tsx | 12 +- .../VersionHistory/HistoryListCtrl.test.ts | 13 +- .../VersionHistory/HistoryListCtrl.ts | 22 +- .../VersionHistory/HistorySrv.test.ts | 2 +- .../VersionHistory/RevertDashboardModal.tsx | 10 +- .../{history.ts => dashboardHistoryMocks.ts} | 0 .../VersionHistory/useDashboardRestore.tsx | 15 +- .../containers/DashboardPage.test.tsx | 103 ++- .../dashboard/containers/DashboardPage.tsx | 201 ++--- .../containers/SoloPanelPage.test.tsx | 18 +- .../dashboard/containers/SoloPanelPage.tsx | 53 +- .../__snapshots__/DashboardPage.test.tsx.snap | 799 +++++++++--------- .../PanelHeader/PanelHeaderNotices.tsx | 12 +- public/app/features/dashboard/index.ts | 1 - .../dashboard/services/ChangeTracker.test.ts | 39 +- .../dashboard/services/ChangeTracker.ts | 182 ++-- .../dashboard/services/DashboardLoaderSrv.ts | 64 +- .../dashboard/services/DashboardSrv.ts | 5 +- .../dashboard/services/TimeSrv.test.ts | 110 +-- .../features/dashboard/services/TimeSrv.ts | 100 ++- .../dashboard/services/UnsavedChangesSrv.ts | 23 - .../services/__mocks__/ChangeTracker.ts | 9 + .../dashboard/state/DashboardModel.ts | 4 +- .../app/features/dashboard/state/actions.ts | 9 +- .../dashboard/state/initDashboard.test.ts | 97 +-- .../features/dashboard/state/initDashboard.ts | 85 +- .../app/features/dashboard/state/reducers.ts | 6 +- .../features/dashboard/utils/getPanelMenu.ts | 34 +- public/app/features/dashboard/utils/panel.ts | 38 +- .../datasources/DataSourceDashboards.test.tsx | 4 +- .../datasources/DataSourceDashboards.tsx | 64 +- .../settings/DataSourceSettingsPage.test.tsx | 12 +- .../settings/DataSourceSettingsPage.tsx | 144 ++-- .../app/features/datasources/state/actions.ts | 10 +- .../features/explore/ExplorePaneContainer.tsx | 21 +- .../explore/ReturnToDashboardButton.test.tsx | 1 - .../explore/ReturnToDashboardButton.tsx | 13 +- .../explore/RichHistory/RichHistoryCard.tsx | 25 +- .../RichHistory/RichHistorySettings.tsx | 24 +- public/app/features/explore/Wrapper.test.tsx | 81 +- public/app/features/explore/Wrapper.tsx | 29 +- .../app/features/explore/state/main.test.ts | 9 +- public/app/features/explore/state/main.ts | 18 +- .../app/features/explore/state/query.test.ts | 6 +- .../features/folders/FolderPermissions.tsx | 58 +- .../folders/FolderSettingsPage.test.tsx | 2 + .../features/folders/FolderSettingsPage.tsx | 77 +- public/app/features/folders/state/actions.ts | 13 +- .../live/dashboard/dashboardWatcher.ts | 21 +- .../manage-dashboards/DashboardImportPage.tsx | 2 + .../manage-dashboards/SnapshotListPage.tsx | 17 +- .../components/ImportDashboardOverview.tsx | 19 +- .../components/SnapshotListTable.test.tsx | 59 ++ .../components/SnapshotListTable.tsx | 51 +- .../uploadDashboardDirective.ts | 4 +- .../manage-dashboards/state/actions.ts | 6 +- .../manage-dashboards/state/selectors.ts | 28 - public/app/features/org/UserInviteForm.tsx | 20 +- .../panel/panellinks/specs/link_srv.test.ts | 20 +- public/app/features/playlist/PlaylistPage.tsx | 142 ++++ ...aylist_srv.test.ts => PlaylistSrv.test.ts} | 40 +- .../{playlist_srv.ts => PlaylistSrv.ts} | 64 +- .../features/playlist/PlaylistStartPage.tsx | 12 + public/app/features/playlist/all.ts | 1 - .../app/features/playlist/playlist_routes.ts | 66 +- .../app/features/playlist/playlists_ctrl.ts | 65 -- public/app/features/playlist/types.ts | 12 + .../app/features/plugins/AppRootPage.test.tsx | 57 +- public/app/features/plugins/AppRootPage.tsx | 41 +- public/app/features/plugins/PluginPage.tsx | 131 ++- public/app/features/profile/state/reducers.ts | 2 +- .../search/components/DashboardListPage.tsx | 26 +- .../components/DashboardSearch.test.tsx | 11 +- .../search/components/DashboardSearch.tsx | 15 +- .../search/components/ManageDashboards.tsx | 11 +- .../search/components/SearchWrapper.tsx | 47 +- public/app/features/search/connect.ts | 41 - .../features/search/hooks/useSearchQuery.ts | 27 +- public/app/features/search/index.ts | 2 +- .../search/reducers/searchQueryReducer.ts | 4 +- public/app/features/search/types.ts | 3 +- public/app/features/search/utils.test.ts | 44 +- public/app/features/search/utils.ts | 14 +- public/app/features/teams/CreateTeam.test.tsx | 3 - public/app/features/teams/CreateTeam.tsx | 12 +- public/app/features/teams/TeamPages.test.tsx | 9 + public/app/features/teams/TeamPages.tsx | 82 +- public/app/features/teams/TeamSettings.tsx | 12 +- .../__snapshots__/TeamPages.test.tsx.snap | 26 +- .../app/features/users/SignupInvited.test.tsx | 17 +- public/app/features/users/SignupInvited.tsx | 12 +- .../features/variables/adhoc/actions.test.ts | 63 +- .../pickers/OptionsPicker/actions.test.ts | 18 +- .../variables/pickers/shared/VariableLink.tsx | 8 +- .../features/variables/state/actions.test.ts | 26 +- .../app/features/variables/state/actions.ts | 9 +- .../app/features/variables/state/helpers.ts | 14 +- .../variables/textbox/actions.test.ts | 17 +- public/app/features/variables/utils.test.ts | 77 +- public/app/features/variables/utils.ts | 54 +- public/app/index.ts | 1 - .../editor/query_field.tsx | 7 +- .../datasource/graphite/func_editor.ts | 2 +- .../plugins/datasource/mysql/query_ctrl.ts | 22 +- .../plugins/datasource/postgres/query_ctrl.ts | 22 +- .../datasource/prometheus/datasource.test.ts | 1 - public/app/plugins/panel/graph/module.ts | 5 +- .../graph/specs/threshold_manager.test.ts | 2 - .../app/plugins/panel/heatmap/color_legend.ts | 10 +- public/app/routes/GrafanaCtrl.ts | 126 +-- public/app/routes/ReactContainer.tsx | 82 -- public/app/routes/dashboard_loaders.ts | 88 -- public/app/routes/registry.ts | 17 - public/app/routes/routes.ts | 590 ------------- public/app/routes/routes.tsx | 408 +++++++++ public/app/types/dashboard.ts | 12 +- public/app/types/events.ts | 48 +- public/app/types/explore.ts | 5 + public/app/types/store.ts | 3 - public/test/specs/helpers.ts | 45 +- public/views/index-template.html | 161 ++-- yarn.lock | 94 ++- 242 files changed, 5367 insertions(+), 5293 deletions(-) create mode 100644 packages/grafana-runtime/src/services/LocationService.test.ts create mode 100644 packages/grafana-runtime/src/services/LocationService.ts create mode 100644 packages/grafana-ui/src/components/Link/Link.tsx create mode 100644 packages/grafana-ui/src/utils/logger.ts create mode 100644 public/app/AppWrapper.tsx create mode 100644 public/app/angular/AngularApp.ts create mode 100644 public/app/angular/AngularLocationWrapper.test.ts create mode 100644 public/app/angular/AngularLocationWrapper.ts create mode 100644 public/app/angular/bridgeReactAngularRouting.ts delete mode 100644 public/app/core/components/Signup/Signup.tsx delete mode 100644 public/app/core/components/sidemenu/__snapshots__/SideMenu.test.tsx.snap delete mode 100644 public/app/core/components/sidemenu/__snapshots__/SignIn.test.tsx.snap delete mode 100644 public/app/core/controllers/invited_ctrl.ts delete mode 100644 public/app/core/controllers/reset_password_ctrl.ts delete mode 100644 public/app/core/controllers/signup_ctrl.ts create mode 100644 public/app/core/navigation/GrafanaRoute.test.tsx create mode 100644 public/app/core/navigation/GrafanaRoute.tsx create mode 100644 public/app/core/navigation/RouterDebugger.tsx create mode 100644 public/app/core/navigation/__mocks__/routeProps.ts create mode 100644 public/app/core/navigation/hooks.ts create mode 100644 public/app/core/navigation/kiosk.ts create mode 100644 public/app/core/navigation/parseKeyValue.ts create mode 100644 public/app/core/navigation/patch/RouteParamsProvider.ts create mode 100644 public/app/core/navigation/patch/RouteProvider.ts create mode 100644 public/app/core/navigation/patch/interceptLinkClicks.ts create mode 100644 public/app/core/navigation/queryString.ts create mode 100644 public/app/core/navigation/testRoutes.tsx create mode 100644 public/app/core/navigation/types.ts create mode 100644 public/app/core/navigation/utils.ts delete mode 100644 public/app/core/reducers/location.test.ts delete mode 100644 public/app/core/reducers/location.ts delete mode 100644 public/app/core/selectors/location.ts delete mode 100644 public/app/core/services/bridge_srv.test.ts delete mode 100644 public/app/core/services/bridge_srv.ts delete mode 100644 public/app/features/admin/AdminEditOrgCtrl.ts delete mode 100644 public/app/features/admin/index.ts delete mode 100644 public/app/features/alerting/NotificationsEditCtrl.ts rename public/app/features/dashboard/components/VersionHistory/__mocks__/{history.ts => dashboardHistoryMocks.ts} (100%) delete mode 100644 public/app/features/dashboard/services/UnsavedChangesSrv.ts create mode 100644 public/app/features/dashboard/services/__mocks__/ChangeTracker.ts create mode 100644 public/app/features/manage-dashboards/components/SnapshotListTable.test.tsx delete mode 100644 public/app/features/manage-dashboards/state/selectors.ts create mode 100644 public/app/features/playlist/PlaylistPage.tsx rename public/app/features/playlist/{specs/playlist_srv.test.ts => PlaylistSrv.test.ts} (79%) rename public/app/features/playlist/{playlist_srv.ts => PlaylistSrv.ts} (59%) create mode 100644 public/app/features/playlist/PlaylistStartPage.tsx delete mode 100644 public/app/features/playlist/playlists_ctrl.ts create mode 100644 public/app/features/playlist/types.ts delete mode 100644 public/app/features/search/connect.ts delete mode 100644 public/app/routes/ReactContainer.tsx delete mode 100644 public/app/routes/dashboard_loaders.ts delete mode 100644 public/app/routes/registry.ts delete mode 100644 public/app/routes/routes.ts create mode 100644 public/app/routes/routes.tsx diff --git a/e2e/suite1/specs/explore.spec.ts b/e2e/suite1/specs/explore.spec.ts index 7b4765a36f9..f472f04c7cb 100644 --- a/e2e/suite1/specs/explore.spec.ts +++ b/e2e/suite1/specs/explore.spec.ts @@ -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: () => { diff --git a/e2e/suite1/specs/variables/load-options-from-url.ts b/e2e/suite1/specs/variables/load-options-from-url.ts index 172ccc23e9b..2fa8ff7f239 100644 --- a/e2e/suite1/specs/variables/load-options-from-url.ts +++ b/e2e/suite1/specs/variables/load-options-from-url.ts @@ -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({ diff --git a/e2e/suite1/specs/variables/new-query-variable.ts b/e2e/suite1/specs/variables/new-query-variable.ts index acecf71aab3..4da5b2ed6f0 100644 --- a/e2e/suite1/specs/variables/new-query-variable.ts +++ b/e2e/suite1/specs/variables/new-query-variable.ts @@ -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(); diff --git a/e2e/suite1/specs/variables/set-options-from-ui.ts b/e2e/suite1/specs/variables/set-options-from-ui.ts index 4a22c959244..97f7c7ea987 100644 --- a/e2e/suite1/specs/variables/set-options-from-ui.ts +++ b/e2e/suite1/specs/variables/set-options-from-ui.ts @@ -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() diff --git a/e2e/suite1/specs/variables/textbox-variables.ts b/e2e/suite1/specs/variables/textbox-variables.ts index f9ced2945df..0f02ea4987a 100644 --- a/e2e/suite1/specs/variables/textbox-variables.ts +++ b/e2e/suite1/specs/variables/textbox-variables.ts @@ -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'); diff --git a/package.json b/package.json index c5b75a0a447..4be2c456df1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/grafana-data/src/field/fieldColor.ts b/packages/grafana-data/src/field/fieldColor.ts index 2da0b532a3b..046f16fa080 100644 --- a/packages/grafana-data/src/field/fieldColor.ts +++ b/packages/grafana-data/src/field/fieldColor.ts @@ -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(() => { 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); } diff --git a/packages/grafana-data/src/field/fieldComparers.ts b/packages/grafana-data/src/field/fieldComparers.ts index c98c78dbd5b..e0173b42302 100644 --- a/packages/grafana-data/src/field/fieldComparers.ts +++ b/packages/grafana-data/src/field/fieldComparers.ts @@ -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); diff --git a/packages/grafana-data/src/field/fieldOverrides.test.ts b/packages/grafana-data/src/field/fieldOverrides.test.ts index b1e0b859190..d5ab4750b11 100644 --- a/packages/grafana-data/src/field/fieldOverrides.test.ts +++ b/packages/grafana-data/src/field/fieldOverrides.test.ts @@ -66,9 +66,7 @@ export const customFieldRegistry: FieldConfigOptionsRegistry = new Registry { - 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, }); diff --git a/packages/grafana-data/src/utils/location.test.ts b/packages/grafana-data/src/utils/location.test.ts index dfedfa712a9..25a5caf1cc9 100644 --- a/packages/grafana-data/src/utils/location.test.ts +++ b/packages/grafana-data/src/utils/location.test.ts @@ -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/'); diff --git a/packages/grafana-data/src/utils/location.ts b/packages/grafana-data/src/utils/location.ts index af1ae515f52..62ba723fc2a 100644 --- a/packages/grafana-data/src/utils/location.ts +++ b/packages/grafana-data/src/utils/location.ts @@ -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, scopedVars?: ScopedVars) => string; @@ -12,7 +12,7 @@ let getVariablesUrlParams: (params?: Record, 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); }, }; diff --git a/packages/grafana-data/src/utils/url.test.ts b/packages/grafana-data/src/utils/url.test.ts index ca876d10f01..ca531ca3aa7 100644 --- a/packages/grafana-data/src/utils/url.test.ts +++ b/packages/grafana-data/src/utils/url.test.ts @@ -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¶m2=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 }); + }); +}); diff --git a/packages/grafana-data/src/utils/url.ts b/packages/grafana-data/src/utils/url.ts index b8577a8624f..3b2416d65f4 100644 --- a/packages/grafana-data/src/utils/url.ts +++ b/packages/grafana-data/src/utils/url.ts @@ -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.} + */ +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 { diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index 53da452c2fb..055f88fd65a 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -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}`, }, diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts index ee74bd4fd48..5f6a686021e 100644 --- a/packages/grafana-e2e-selectors/src/selectors/pages.ts +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -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) => diff --git a/packages/grafana-runtime/package.json b/packages/grafana-runtime/package.json index 7c625bd7a30..539f42b5031 100644 --- a/packages/grafana-runtime/package.json +++ b/packages/grafana-runtime/package.json @@ -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", diff --git a/packages/grafana-runtime/src/services/LocationService.test.ts b/packages/grafana-runtime/src/services/LocationService.test.ts new file mode 100644 index 00000000000..5b59301b236 --- /dev/null +++ b/packages/grafana-runtime/src/services/LocationService.test.ts @@ -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'); + }); + }); +}); diff --git a/packages/grafana-runtime/src/services/LocationService.ts b/packages/grafana-runtime/src/services/LocationService.ts new file mode 100644 index 00000000000..9131427a498 --- /dev/null +++ b/packages/grafana-runtime/src/services/LocationService.ts @@ -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, replace?: boolean) => void; + push: (location: H.Path | H.LocationDescriptor) => void; + replace: (location: H.Path | H.LocationDescriptor, 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, 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'); diff --git a/packages/grafana-runtime/src/services/index.ts b/packages/grafana-runtime/src/services/index.ts index 44182e20c9f..85a9671b4d8 100644 --- a/packages/grafana-runtime/src/services/index.ts +++ b/packages/grafana-runtime/src/services/index.ts @@ -6,3 +6,4 @@ export * from './EchoSrv'; export * from './templateSrv'; export * from './legacyAngularInjector'; export * from './live'; +export * from './LocationService'; diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 925ff483fd4..3d5ba775da4 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -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", diff --git a/packages/grafana-ui/src/components/Link/Link.tsx b/packages/grafana-ui/src/components/Link/Link.tsx new file mode 100644 index 00000000000..f21687646e6 --- /dev/null +++ b/packages/grafana-ui/src/components/Link/Link.tsx @@ -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 {} + +/** + * @alpha + */ +export const Link = forwardRef(({ href, children, ...rest }, ref) => { + const validUrl = locationUtil.stripBaseFromUrl(textUtil.sanitizeUrl(href ?? '')); + + return ( + } to={validUrl} {...rest}> + {children} + + ); +}); + +Link.displayName = 'Link'; diff --git a/packages/grafana-ui/src/components/PageLayout/PageToolbar.tsx b/packages/grafana-ui/src/components/PageLayout/PageToolbar.tsx index 893d9e0e913..827de262c4b 100644 --- a/packages/grafana-ui/src/components/PageLayout/PageToolbar.tsx +++ b/packages/grafana-ui/src/components/PageLayout/PageToolbar.tsx @@ -114,16 +114,16 @@ const getStyles = (theme: GrafanaTheme) => { const titleStyles = ` font-size: ${typography.size.lg}; - padding-left: ${spacing.sm}; + padding-left: ${spacing.sm}; white-space: nowrap; text-overflow: ellipsis; - overflow: hidden; + overflow: hidden; max-width: 240px; // clear default button styles background: none; - border: none; - + border: none; + @media ${styleMixins.mediaUp(theme.breakpoints.xl)} { max-width: unset; } diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index 4f5e00239aa..f0b55b42cb8 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -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'; diff --git a/packages/grafana-ui/src/components/uPlot/utils.ts b/packages/grafana-ui/src/components/uPlot/utils.ts index a0207af1818..f30317bdac4 100755 --- a/packages/grafana-ui/src/components/uPlot/utils.ts +++ b/packages/grafana-ui/src/components/uPlot/utils.ts @@ -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); diff --git a/packages/grafana-ui/src/utils/index.ts b/packages/grafana-ui/src/utils/index.ts index 02b4d0f5e4c..d959255e1f1 100644 --- a/packages/grafana-ui/src/utils/index.ts +++ b/packages/grafana-ui/src/utils/index.ts @@ -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'; diff --git a/packages/grafana-ui/src/utils/logger.ts b/packages/grafana-ui/src/utils/logger.ts new file mode 100644 index 00000000000..e6ff7e86910 --- /dev/null +++ b/packages/grafana-ui/src/utils/logger.ts @@ -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); +}; diff --git a/public/app/AppWrapper.tsx b/public/app/AppWrapper.tsx new file mode 100644 index 00000000000..9e6e4b8ba0a --- /dev/null +++ b/public/app/AppWrapper.tsx @@ -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 { + container = React.createRef(); + + 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 ( + { + 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 ; + } + } + + return ; + }} + /> + ); + }; + + renderRoutes() { + return {getAppRoutes().map((r) => this.renderRoute(r))}; + } + + render() { + navigationLogger('AppWrapper', false, 'rendering'); + + // @ts-ignore + const appSeed = `
`; + + return ( + + + + + +
+ + +
+
+ + + {this.state.ngInjector && this.container && this.renderRoutes()} +
+ +
+ + + + + + + ); + } +} diff --git a/public/app/angular/AngularApp.ts b/public/app/angular/AngularApp.ts new file mode 100644 index 00000000000..91b851678e7 --- /dev/null +++ b/public/app/angular/AngularApp.ts @@ -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; + } +} diff --git a/public/app/angular/AngularLocationWrapper.test.ts b/public/app/angular/AngularLocationWrapper.test.ts new file mode 100644 index 00000000000..3b4d0d2b8d8 --- /dev/null +++ b/public/app/angular/AngularLocationWrapper.test.ts @@ -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 = { 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(''); + }); + }); +}); diff --git a/public/app/angular/AngularLocationWrapper.ts b/public/app/angular/AngularLocationWrapper.ts new file mode 100644 index 00000000000..9a6e96394c9 --- /dev/null +++ b/public/app/angular/AngularLocationWrapper.ts @@ -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 = { 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}`; + } +} diff --git a/public/app/angular/bridgeReactAngularRouting.ts b/public/app/angular/bridgeReactAngularRouting.ts new file mode 100644 index 00000000000..a97e2f822d9 --- /dev/null +++ b/public/app/angular/bridgeReactAngularRouting.ts @@ -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 Angular’s 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(); +} diff --git a/public/app/app.ts b/public/app/app.ts index f7ee13e5ac4..d0a2a3f7665 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -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,126 +77,66 @@ 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)); } - } - initEchoSrv() { - setEchoSrv(new Echo({ debug: process.env.NODE_ENV === 'development' })); - - ttiPolyfill.getFirstConsistentlyInteractive().then((tti: any) => { - // Collecting paint metrics first - const paintMetrics = performance && performance.getEntriesByType ? performance.getEntriesByType('paint') : []; - - for (const metric of paintMetrics) { - reportPerformance(metric.name, Math.round(metric.startTime + metric.duration)); - } - reportPerformance('tti', tti); - }); - - registerEchoBackend(new PerformanceBackend({})); - if (config.sentry.enabled) { - registerEchoBackend( - new SentryEchoBackend({ - ...config.sentry, - user: config.bootData.user, - buildInfo: config.buildInfo, - }) + Promise.all(promises).then(() => { + ReactDOM.render( + React.createElement(AppWrapper, { + app: this, + }), + document.getElementById('reactRoot') ); - } - - window.addEventListener('DOMContentLoaded', () => { - reportPerformance('dcl', Math.round(performance.now())); }); } } +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) => { + // Collecting paint metrics first + const paintMetrics = performance && performance.getEntriesByType ? performance.getEntriesByType('paint') : []; + + for (const metric of paintMetrics) { + reportPerformance(metric.name, Math.round(metric.startTime + metric.duration)); + } + reportPerformance('tti', tti); + }); + + registerEchoBackend(new PerformanceBackend({})); + if (config.sentry.enabled) { + registerEchoBackend( + new SentryEchoBackend({ + ...config.sentry, + user: config.bootData.user, + buildInfo: config.buildInfo, + }) + ); + } + + window.addEventListener('DOMContentLoaded', () => { + reportPerformance('dcl', Math.round(performance.now())); + }); +} + function addClassIfNoOverlayScrollbar() { if (getScrollbarWidth() > 0) { document.body.classList.add('no-overlay-scrollbar'); diff --git a/public/app/core/actions/index.ts b/public/app/core/actions/index.ts index 6b48b66a501..4df4144e94c 100644 --- a/public/app/core/actions/index.ts +++ b/public/app/core/actions/index.ts @@ -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 }; diff --git a/public/app/core/angular_wrappers.ts b/public/app/core/angular_wrappers.ts index 45800c06bcb..9aea8be0aab 100644 --- a/public/app/core/angular_wrappers.ts +++ b/public/app/core/angular_wrappers.ts @@ -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' }], diff --git a/public/app/core/components/AppNotifications/AppNotificationList.tsx b/public/app/core/components/AppNotifications/AppNotificationList.tsx index e3ea35dfd02..5893992cf04 100644 --- a/public/app/core/components/AppNotifications/AppNotificationList.tsx +++ b/public/app/core/components/AppNotifications/AppNotificationList.tsx @@ -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 { +const mapStateToProps = (state: StoreState, props: OwnProps) => ({ + appNotifications: state.appNotifications.appNotifications, +}); + +const mapDispatchToProps = { + notifyApp, + clearAppNotification, +}; + +const connector = connect(mapStateToProps, mapDispatchToProps); + +export type Props = OwnProps & ConnectedProps; + +export class AppNotificationListUnConnected extends PureComponent { componentDidMount() { const { notifyApp } = this.props; @@ -35,7 +44,7 @@ export class AppNotificationList extends PureComponent { const { appNotifications } = this.props; return ( -
+
{appNotifications.map((appNotification, index) => { return ( { } } -const mapStateToProps = (state: StoreState) => ({ - appNotifications: state.appNotifications.appNotifications, -}); - -const mapDispatchToProps = { - notifyApp, - clearAppNotification, -}; - -export default connectWithStore(AppNotificationList, mapStateToProps, mapDispatchToProps); +export const AppNotificationList = connector(AppNotificationListUnConnected); diff --git a/public/app/core/components/DynamicImports/SafeDynamicImport.tsx b/public/app/core/components/DynamicImports/SafeDynamicImport.tsx index 81a15a67ffa..4544d88a5f1 100644 --- a/public/app/core/components/DynamicImports/SafeDynamicImport.tsx +++ b/public/app/core/components/DynamicImports/SafeDynamicImport.tsx @@ -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) => ({ ...props }) => { - const LoadableComponent = Loadable({ - loader: () => importStatement, +export const SafeDynamicImport = (loader: () => Promise): GrafanaRouteComponent => + Loadable({ + loader: loader, loading: loadComponentHandler, }); - - return ; -}; diff --git a/public/app/core/components/ForgottenPassword/ChangePasswordPage.tsx b/public/app/core/components/ForgottenPassword/ChangePasswordPage.tsx index f734bb3ffb5..cf469a033db 100644 --- a/public/app/core/components/ForgottenPassword/ChangePasswordPage.tsx +++ b/public/app/core/components/ForgottenPassword/ChangePasswordPage.tsx @@ -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) => { return ( - {({ changePassword }) => } + + {({ changePassword }) => } + ); diff --git a/public/app/core/components/Login/LoginCtrl.tsx b/public/app/core/components/Login/LoginCtrl.tsx index cc0ab4a917e..ac63eedd19c 100644 --- a/public/app/core/components/Login/LoginCtrl.tsx +++ b/public/app/core/components/Login/LoginCtrl.tsx @@ -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 { result: any = {}; + constructor(props: Props) { super(props); this.state = { @@ -62,7 +59,8 @@ export class LoginCtrl extends PureComponent { 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 { } const resetModel = { - code: this.props.routeParams.code, + code: this.props.resetCode, newPassword: password, confirmPassword: password, }; @@ -153,10 +151,4 @@ export class LoginCtrl extends PureComponent { } } -export const mapStateToProps = (state: StoreState) => ({ - routeParams: state.location.routeParams, -}); - -const mapDispatchToProps = { updateLocation }; - -export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(LoginCtrl)); +export default LoginCtrl; diff --git a/public/app/core/components/Signup/Signup.tsx b/public/app/core/components/Signup/Signup.tsx deleted file mode 100644 index acc0a58f6a5..00000000000 --- a/public/app/core/components/Signup/Signup.tsx +++ /dev/null @@ -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 = (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 ( -
- {({ errors, register, getValues }) => ( - <> - - - - - - - {!getConfig().autoAssignOrg && ( - - - - )} - {getConfig().verifyEmailEnabled && ( - - - - )} - - - - - v === getValues().password || 'Passwords must match!', - })} - /> - - - - - - Back to login - - - - )} -
- ); -}; - -const mapStateToProps: MapStateToProps = (state: StoreState) => ({ - email: state.location.routeParams.email?.toString(), - code: state.location.routeParams.code?.toString(), -}); - -export const Signup = connect(mapStateToProps)(SignupUnconnected); diff --git a/public/app/core/components/Signup/SignupPage.tsx b/public/app/core/components/Signup/SignupPage.tsx index 1855b1a0d5d..d6a37308c40 100644 --- a/public/app/core/components/Signup/SignupPage.tsx +++ b/public/app/core/components/Signup/SignupPage.tsx @@ -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) => { + 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 ( - +
+ {({ errors, register, getValues }) => ( + <> + + + + + + + {!getConfig().autoAssignOrg && ( + + + + )} + {getConfig().verifyEmailEnabled && ( + + + + )} + + + + + v === getValues().password || 'Passwords must match!', + })} + /> + + + + + + Back to login + + + + )} +
); diff --git a/public/app/core/components/form_dropdown/form_dropdown.ts b/public/app/core/components/form_dropdown/form_dropdown.ts index cf5fac32f39..da97ea9d80e 100644 --- a/public/app/core/components/form_dropdown/form_dropdown.ts +++ b/public/app/core/components/form_dropdown/form_dropdown.ts @@ -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() { diff --git a/public/app/core/components/sidemenu/BottomNavLinks.test.tsx b/public/app/core/components/sidemenu/BottomNavLinks.test.tsx index 94726184eb1..a34062b79db 100644 --- a/public/app/core/components/sidemenu/BottomNavLinks.test.tsx +++ b/public/app/core/components/sidemenu/BottomNavLinks.test.tsx @@ -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: '' }); + expect(appEvents.publish).toHaveBeenCalledWith( + new ShowModalEvent({ + templateHtml: '', + }) + ); }); }); }); diff --git a/public/app/core/components/sidemenu/BottomNavLinks.tsx b/public/app/core/components/sidemenu/BottomNavLinks.tsx index f862b105964..89a3a1a8150 100644 --- a/public/app/core/components/sidemenu/BottomNavLinks.tsx +++ b/public/app/core/components/sidemenu/BottomNavLinks.tsx @@ -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 { }; onOpenShortcuts = () => { - appEvents.emit(CoreEvents.showModal, { - templateHtml: '', - }); + appEvents.publish( + new ShowModalEvent({ + templateHtml: '', + }) + ); }; toggleSwitcherModal = () => { @@ -49,12 +51,12 @@ export default class BottomNavLinks extends PureComponent { return (
- + {link.icon && } {link.img && } - +
    {link.subTitle && (
  • diff --git a/public/app/core/components/sidemenu/BottomSection.tsx b/public/app/core/components/sidemenu/BottomSection.tsx index 25c904c5828..2c8d385f515 100644 --- a/public/app/core/components/sidemenu/BottomSection.tsx +++ b/public/app/core/components/sidemenu/BottomSection.tsx @@ -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'; diff --git a/public/app/core/components/sidemenu/DropDownChild.tsx b/public/app/core/components/sidemenu/DropDownChild.tsx index f8caf679877..d3de0a3aca9 100644 --- a/public/app/core/components/sidemenu/DropDownChild.tsx +++ b/public/app/core/components/sidemenu/DropDownChild.tsx @@ -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) => { margin-right: ${theme.spacing.sm}; `; - return ( -
  • - - {child.icon && } - {child.text} - -
  • + const linkContent = ( + <> + {child.icon && } + {child.text} + ); + + const anchor = child.url ? {linkContent} : {linkContent}; + + return
  • {anchor}
  • ; }; export default DropDownChild; diff --git a/public/app/core/components/sidemenu/SideMenu.test.tsx b/public/app/core/components/sidemenu/SideMenu.test.tsx index 4732e355142..f0798c2c13a 100644 --- a/public/app/core/components/sidemenu/SideMenu.test.tsx +++ b/public/app/core/components/sidemenu/SideMenu.test.tsx @@ -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(); + return render( + + + + + + ); }; describe('Render', () => { - it('should render component', () => { - const wrapper = setup(); + it('should render component', async () => { + setup(); + const sidemenu = await screen.findByTestId('sidemenu'); + expect(sidemenu).toBeInTheDocument(); + }); - expect(wrapper).toMatchSnapshot(); - }); -}); - -describe('Functions', () => { - describe('toggle side menu on mobile', () => { - const wrapper = setup(); - const instance = wrapper.instance() as SideMenu; - instance.toggleSideMenuSmallBreakpoint(); - - it('should emit toggle sidemenu event', () => { - expect(appEvents.emit).toHaveBeenCalledWith(CoreEvents.toggleSidemenuMobile); - }); + it('should not render when in kiosk mode', async () => { + setup(); + + locationService.partial({ kiosk: 'full' }); + const sidemenu = screen.queryByTestId('sidemenu'); + expect(sidemenu).not.toBeInTheDocument(); }); }); diff --git a/public/app/core/components/sidemenu/SideMenu.tsx b/public/app/core/components/sidemenu/SideMenu.tsx index 815edd2bbd3..1de7d82ceb0 100644 --- a/public/app/core/components/sidemenu/SideMenu.tsx +++ b/public/app/core/components/sidemenu/SideMenu.tsx @@ -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 ( +
    - , -
    + +
     Close -
    , - , - , - ]; - } -} +
    + + +
    + ); +}); + +SideMenu.displayName = 'SideMenu'; diff --git a/public/app/core/components/sidemenu/SideMenuDropDown.tsx b/public/app/core/components/sidemenu/SideMenuDropDown.tsx index 6c5d3063bd0..e18d4244e5a 100644 --- a/public/app/core/components/sidemenu/SideMenuDropDown.tsx +++ b/public/app/core/components/sidemenu/SideMenuDropDown.tsx @@ -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) => { childrenLinks = _.filter(link.children, (item) => !item.hideFromMenu); } + const linkContent = {link.text}; + const anchor = link.url ? ( + + {linkContent} + + ) : ( + + {linkContent} + + ); + return (
      -
    • - - {link.text} - -
    • +
    • {anchor}
    • {childrenLinks.map((child, index) => { return ; })} diff --git a/public/app/core/components/sidemenu/SignIn.test.tsx b/public/app/core/components/sidemenu/SignIn.test.tsx index 907b801f301..b57391fb5ef 100644 --- a/public/app/core/components/sidemenu/SignIn.test.tsx +++ b/public/app/core/components/sidemenu/SignIn.test.tsx @@ -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(); + it('should render component', async () => { + render( + + + + ); - expect(wrapper).toMatchSnapshot(); + const link = await screen.getByText('Sign In'); + expect(link).toBeInTheDocument(); }); }); diff --git a/public/app/core/components/sidemenu/SignIn.tsx b/public/app/core/components/sidemenu/SignIn.tsx index 4de185439b7..10a7db9637a 100644 --- a/public/app/core/components/sidemenu/SignIn.tsx +++ b/public/app/core/components/sidemenu/SignIn.tsx @@ -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 = ({ url }) => { - const forcedLoginUrl = getForcedLoginUrl(url); +export const SignIn: FC = () => { + const location = useLocation(); + const forcedLoginUrl = getForcedLoginUrl(location.pathname + location.search); return (
      @@ -25,9 +24,3 @@ export const SignIn: FC = ({ url }) => {
      ); }; - -const mapStateToProps = (state: StoreState) => ({ - url: state.location.url, -}); - -export default connectWithStore(SignIn, mapStateToProps); diff --git a/public/app/core/components/sidemenu/TopSection.tsx b/public/app/core/components/sidemenu/TopSection.tsx index 73df530e01b..5163977780d 100644 --- a/public/app/core/components/sidemenu/TopSection.tsx +++ b/public/app/core/components/sidemenu/TopSection.tsx @@ -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 = () => { const navTree = _.cloneDeep(config.bootData.navTree); @@ -13,7 +13,7 @@ const TopSection: FC = () => { }; const onOpenSearch = () => { - getLocationSrv().update({ query: { search: 'open' }, partial: true }); + locationService.partial({ search: 'open' }); }; return ( diff --git a/public/app/core/components/sidemenu/TopSectionItem.test.tsx b/public/app/core/components/sidemenu/TopSectionItem.test.tsx index 49901e59776..e6b9634a7f3 100644 --- a/public/app/core/components/sidemenu/TopSectionItem.test.tsx +++ b/public/app/core/components/sidemenu/TopSectionItem.test.tsx @@ -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(); + return mount( + + + + ); }; describe('Render', () => { diff --git a/public/app/core/components/sidemenu/TopSectionItem.tsx b/public/app/core/components/sidemenu/TopSectionItem.tsx index e68e3fea8b6..d3ef287aa6b 100644 --- a/public/app/core/components/sidemenu/TopSectionItem.tsx +++ b/public/app/core/components/sidemenu/TopSectionItem.tsx @@ -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 = ({ link, onClick }) => { + const linkContent = ( + + {link.icon && } + {link.img && } + + ); + + const anchor = link.url ? ( + + {linkContent} + + ) : ( + + {linkContent} + + ); return ( ); diff --git a/public/app/core/components/sidemenu/__snapshots__/BottomNavLinks.test.tsx.snap b/public/app/core/components/sidemenu/__snapshots__/BottomNavLinks.test.tsx.snap index 194168b0c8d..6026adf7c95 100644 --- a/public/app/core/components/sidemenu/__snapshots__/BottomNavLinks.test.tsx.snap +++ b/public/app/core/components/sidemenu/__snapshots__/BottomNavLinks.test.tsx.snap @@ -4,13 +4,13 @@ exports[`Render should render children 1`] = `
      - - +
        - - +
          - - +
            - - +
              - + - - , -
              - - - -  Close - -
              , - , - , -] -`; diff --git a/public/app/core/components/sidemenu/__snapshots__/SignIn.test.tsx.snap b/public/app/core/components/sidemenu/__snapshots__/SignIn.test.tsx.snap deleted file mode 100644 index 7c3c353d89a..00000000000 --- a/public/app/core/components/sidemenu/__snapshots__/SignIn.test.tsx.snap +++ /dev/null @@ -1,41 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Render should render component 1`] = ` - -`; diff --git a/public/app/core/components/sidemenu/__snapshots__/TopSectionItem.test.tsx.snap b/public/app/core/components/sidemenu/__snapshots__/TopSectionItem.test.tsx.snap index a0b1950dfaa..e0620bf045f 100644 --- a/public/app/core/components/sidemenu/__snapshots__/TopSectionItem.test.tsx.snap +++ b/public/app/core/components/sidemenu/__snapshots__/TopSectionItem.test.tsx.snap @@ -1,55 +1,49 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Render should render component 1`] = ` - - - + + + +
              + + + + + +
              +
              +
              +
              + + + + + + +
      +
      + + `; diff --git a/public/app/core/controllers/all.ts b/public/app/core/controllers/all.ts index f7f9f4c0486..ae905a7c598 100644 --- a/public/app/core/controllers/all.ts +++ b/public/app/core/controllers/all.ts @@ -1,4 +1 @@ -import './invited_ctrl'; -import './signup_ctrl'; -import './reset_password_ctrl'; import './json_editor_ctrl'; diff --git a/public/app/core/controllers/invited_ctrl.ts b/public/app/core/controllers/invited_ctrl.ts deleted file mode 100644 index e99a1666069..00000000000 --- a/public/app/core/controllers/invited_ctrl.ts +++ /dev/null @@ -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); diff --git a/public/app/core/controllers/reset_password_ctrl.ts b/public/app/core/controllers/reset_password_ctrl.ts deleted file mode 100644 index 76ae67d14be..00000000000 --- a/public/app/core/controllers/reset_password_ctrl.ts +++ /dev/null @@ -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); diff --git a/public/app/core/controllers/signup_ctrl.ts b/public/app/core/controllers/signup_ctrl.ts deleted file mode 100644 index c429519efd9..00000000000 --- a/public/app/core/controllers/signup_ctrl.ts +++ /dev/null @@ -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); diff --git a/public/app/core/navigation/GrafanaRoute.test.tsx b/public/app/core/navigation/GrafanaRoute.test.tsx new file mode 100644 index 00000000000..67e60955f6b --- /dev/null +++ b/public/app/core/navigation/GrafanaRoute.test.tsx @@ -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
      ; + }; + + const location = { search: '?query=hello&test=asd' } as any; + const history = {} as any; + const match = {} as any; + + render( + + ); + + 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
      ; + }, + } as any; + + const history = locationService.getHistory(); + + const { rerender } = render( + + ); + + expect(renderSpy).toBeCalledTimes(1); + locationService.replace('/test', true); + expect(history.location.state).toMatchInlineSnapshot(` + Object { + "forceRouteReload": true, + } + `); + + rerender(); + + expect(history.location.state).toMatchInlineSnapshot(`Object {}`); + expect(renderSpy).toBeCalledTimes(2); + }); +}); diff --git a/public/app/core/navigation/GrafanaRoute.tsx b/public/app/core/navigation/GrafanaRoute.tsx new file mode 100644 index 00000000000..c45ac21703e --- /dev/null +++ b/public/app/core/navigation/GrafanaRoute.tsx @@ -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 {} + +export class GrafanaRoute extends React.Component { + 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 ; + } +} diff --git a/public/app/core/navigation/RouterDebugger.tsx b/public/app/core/navigation/RouterDebugger.tsx new file mode 100644 index 00000000000..2a177379f26 --- /dev/null +++ b/public/app/core/navigation/RouterDebugger.tsx @@ -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 = () => { + const manualRoutes: RouteDescriptor[] = []; + return ( + +

      Static routes

      +
        + {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 ( +
      • + + {r.path} + +
      • + ); + })} +
      + +

      Dynamic routes - check those manually

      +
        + {manualRoutes.map((r, i) => { + return ( +
      • + + {r.path} + +
      • + ); + })} +
      +
      + ); +}; diff --git a/public/app/core/navigation/__mocks__/routeProps.ts b/public/app/core/navigation/__mocks__/routeProps.ts new file mode 100644 index 00000000000..6c3ff25ecd8 --- /dev/null +++ b/public/app/core/navigation/__mocks__/routeProps.ts @@ -0,0 +1,19 @@ +import { GrafanaRouteComponentProps } from '../types'; +import { createMemoryHistory } from 'history'; +import { merge } from 'lodash'; + +export function getRouteComponentProps = {}>( + overrides: Partial = {} +): GrafanaRouteComponentProps { + const defaults: GrafanaRouteComponentProps = { + history: createMemoryHistory(), + location: { + search: '', + } as any, + match: { params: {} } as any, + route: {} as any, + queryParams: {} as any, + }; + + return merge(overrides, defaults); +} diff --git a/public/app/core/navigation/hooks.ts b/public/app/core/navigation/hooks.ts new file mode 100644 index 00000000000..5c30ac06688 --- /dev/null +++ b/public/app/core/navigation/hooks.ts @@ -0,0 +1,17 @@ +import { locationService } from '@grafana/runtime'; +import { useLocation } from 'react-router-dom'; + +export type UseUrlParamsResult = [URLSearchParams, (params: Record, replace?: boolean) => void]; + +/** @internal experimental */ +export function useUrlParams(): UseUrlParamsResult { + const location = useLocation(); + const params = new URLSearchParams(location.search); + + const updateUrlParams = (params: Record, replace?: boolean) => { + // Should find a way to use history directly here + locationService.partial(params, replace); + }; + + return [params, updateUrlParams]; +} diff --git a/public/app/core/navigation/kiosk.ts b/public/app/core/navigation/kiosk.ts new file mode 100644 index 00000000000..57cb7ce7bcc --- /dev/null +++ b/public/app/core/navigation/kiosk.ts @@ -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 }); +} diff --git a/public/app/core/navigation/parseKeyValue.ts b/public/app/core/navigation/parseKeyValue.ts new file mode 100644 index 00000000000..ec20bd650e0 --- /dev/null +++ b/public/app/core/navigation/parseKeyValue.ts @@ -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 diff --git a/public/app/core/navigation/patch/RouteParamsProvider.ts b/public/app/core/navigation/patch/RouteParamsProvider.ts new file mode 100644 index 00000000000..ee2ed35ad07 --- /dev/null +++ b/public/app/core/navigation/patch/RouteParamsProvider.ts @@ -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'); + }; +} diff --git a/public/app/core/navigation/patch/RouteProvider.ts b/public/app/core/navigation/patch/RouteProvider.ts new file mode 100644 index 00000000000..458c9757b3b --- /dev/null +++ b/public/app/core/navigation/patch/RouteProvider.ts @@ -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; + } +} diff --git a/public/app/core/navigation/patch/interceptLinkClicks.ts b/public/app/core/navigation/patch/interceptLinkClicks.ts new file mode 100644 index 00000000000..164084763a6 --- /dev/null +++ b/public/app/core/navigation/patch/interceptLinkClicks.ts @@ -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; +} diff --git a/public/app/core/navigation/queryString.ts b/public/app/core/navigation/queryString.ts new file mode 100644 index 00000000000..9035df7894b --- /dev/null +++ b/public/app/core/navigation/queryString.ts @@ -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('&'); +}; diff --git a/public/app/core/navigation/testRoutes.tsx b/public/app/core/navigation/testRoutes.tsx new file mode 100644 index 00000000000..c7087d16485 --- /dev/null +++ b/public/app/core/navigation/testRoutes.tsx @@ -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: () => + ( + <> +

      Test1

      + Test2 link + Test2 navlink + + ) as any, + }, + { + path: '/test2', + // eslint-disable-next-line react/display-name + component: () => ( + <> +

      Test2

      + Test1 link + Test1 navlink + + ), + }, + { + path: '/router-debug', + // eslint-disable-next-line react/display-name + component: () => RouterDebugger, + }, +]; diff --git a/public/app/core/navigation/types.ts b/public/app/core/navigation/types.ts new file mode 100644 index 00000000000..61902e65a12 --- /dev/null +++ b/public/app/core/navigation/types.ts @@ -0,0 +1,19 @@ +import { UrlQueryMap } from '@grafana/data'; +import React from 'react'; +import { RouteComponentProps } from 'react-router-dom'; + +export interface GrafanaRouteComponentProps extends RouteComponentProps { + route: RouteDescriptor; + queryParams: Q; +} + +export type GrafanaRouteComponent = React.ComponentType>; + +export interface RouteDescriptor { + path: string; + component: GrafanaRouteComponent; + roles?: () => string[]; + pageClass?: string; + /** Can be used like an id for the route if the same component is used by many routes */ + routeName?: string; +} diff --git a/public/app/core/navigation/utils.ts b/public/app/core/navigation/utils.ts new file mode 100644 index 00000000000..d6bfe40529c --- /dev/null +++ b/public/app/core/navigation/utils.ts @@ -0,0 +1,5 @@ +import * as H from 'history'; + +export function shouldReloadPage(location: H.Location) { + return !!location.state?.forceRouteReload; +} diff --git a/public/app/core/reducers/index.ts b/public/app/core/reducers/index.ts index cc0c950ec4a..246c7abc92e 100644 --- a/public/app/core/reducers/index.ts +++ b/public/app/core/reducers/index.ts @@ -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, }; diff --git a/public/app/core/reducers/location.test.ts b/public/app/core/reducers/location.test.ts deleted file mode 100644 index c4f29191aa6..00000000000 --- a/public/app/core/reducers/location.test.ts +++ /dev/null @@ -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() - .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() - .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() - .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() - .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() - .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() - .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; - }); - }); - }); -}); diff --git a/public/app/core/reducers/location.ts b/public/app/core/reducers/location.ts deleted file mode 100644 index b42f466f91a..00000000000 --- a/public/app/core/reducers/location.ts +++ /dev/null @@ -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('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) => { - 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; -}; diff --git a/public/app/core/selectors/location.ts b/public/app/core/selectors/location.ts deleted file mode 100644 index 41f2f0f950e..00000000000 --- a/public/app/core/selectors/location.ts +++ /dev/null @@ -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; diff --git a/public/app/core/services/all.ts b/public/app/core/services/all.ts index 989015d2872..f43e8d6ee82 100644 --- a/public/app/core/services/all.ts +++ b/public/app/core/services/all.ts @@ -7,4 +7,3 @@ import './popover_srv'; import './segment_srv'; import './backend_srv'; import './dynamic_directive_srv'; -import './bridge_srv'; diff --git a/public/app/core/services/analytics.ts b/public/app/core/services/analytics.ts index b0efa1ceaf6..0d03b4d0458 100644 --- a/public/app/core/services/analytics.ts +++ b/public/app/core/services/analytics.ts @@ -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(); diff --git a/public/app/core/services/bridge_srv.test.ts b/public/app/core/services/bridge_srv.test.ts deleted file mode 100644 index 06b42e30640..00000000000 --- a/public/app/core/services/bridge_srv.test.ts +++ /dev/null @@ -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']); - }); -}); diff --git a/public/app/core/services/bridge_srv.ts b/public/app/core/services/bridge_srv.ts deleted file mode 100644 index 839109ba66a..00000000000 --- a/public/app/core/services/bridge_srv.ts +++ /dev/null @@ -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); diff --git a/public/app/core/services/keybindingSrv.ts b/public/app/core/services/keybindingSrv.ts index 658b1c717e9..8ef4acb18f6 100644 --- a/public/app/core/services/keybindingSrv.ts +++ b/public/app/core/services/keybindingSrv.ts @@ -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: '' }); + private showHelpModal() { + appEvents.publish(new ShowModalEvent({ templateHtml: '' })); } - 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, { - component: SaveDashboardModalProxy, - props: { - dashboard, - }, - }); + 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, { - component: ShareModal, - props: { - dashboard: dashboard, - panel: panelInfo?.panel, - }, - }); + 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(); diff --git a/public/app/core/services/util_srv.ts b/public/app/core/services/util_srv.ts index ca6afe0b598..1a9485b1440 100644 --- a/public/app/core/services/util_srv.ts +++ b/public/app/core/services/util_srv.ts @@ -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, { - src: 'public/app/partials/confirm_modal.html', - scope: scope, - modalClass: 'confirm-modal', - }); + appEvents.publish( + new ShowModalEvent({ + src: 'public/app/partials/confirm_modal.html', + scope: scope, + modalClass: 'confirm-modal', + }) + ); } } diff --git a/public/app/core/utils/browser.ts b/public/app/core/utils/browser.ts index 9ed3c77f6e3..346dee58ee9 100644 --- a/public/app/core/utils/browser.ts +++ b/public/app/core/utils/browser.ts @@ -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; @@ -6,11 +9,11 @@ export function checkBrowserCompatibility() { const isChrome = /Chrome/.test(navigator.userAgent) && /Google Inc/.test(navigator.vendor); /* Check for - <= IE11 (Trident 7) - Edge <= 16 - Firefox <= 64 - Chrome <= 54 - */ + <= IE11 (Trident 7) + Edge <= 16 + Firefox <= 64 + Chrome <= 54 + */ const isEdgeVersion = /Edge\/([0-9.]+)/.exec(navigator.userAgent); if (isIE && parseFloat(/Trident\/([0-9.]+)/.exec(navigator.userAgent)![1]) <= 7) { diff --git a/public/app/features/admin/AdminEditOrgCtrl.ts b/public/app/features/admin/AdminEditOrgCtrl.ts deleted file mode 100644 index 7b795173129..00000000000 --- a/public/app/features/admin/AdminEditOrgCtrl.ts +++ /dev/null @@ -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(); - } -} diff --git a/public/app/features/admin/AdminEditOrgPage.tsx b/public/app/features/admin/AdminEditOrgPage.tsx index bd09d5a40b6..e0822c713e3 100644 --- a/public/app/features/admin/AdminEditOrgPage.tsx +++ b/public/app/features/admin/AdminEditOrgPage.tsx @@ -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 = ({ 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([]); diff --git a/public/app/features/admin/UserAdminPage.tsx b/public/app/features/admin/UserAdminPage.tsx index f8e9a0904c4..9efa518c942 100644 --- a/public/app/features/admin/UserAdminPage.tsx +++ b/public/app/features/admin/UserAdminPage.tsx @@ -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 { }; 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 { }; 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 { }; 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 { }; 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 { } const mapStateToProps = (state: StoreState) => ({ - userId: getRouteParamsId(state.location), navModel: getNavModel(state.navIndex, 'global-users'), user: state.userAdmin.user, sessions: state.userAdmin.sessions, diff --git a/public/app/features/admin/UserCreatePage.tsx b/public/app/features/admin/UserCreatePage.tsx index cf8df2ab8b9..4dad3cafb92 100644 --- a/public/app/features/admin/UserCreatePage.tsx +++ b/public/app/features/admin/UserCreatePage.tsx @@ -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 = ({ navModel, updateLocation }) => { +const UserCreatePage: React.FC = ({ 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)); diff --git a/public/app/features/admin/index.ts b/public/app/features/admin/index.ts deleted file mode 100644 index 71ad689da0c..00000000000 --- a/public/app/features/admin/index.ts +++ /dev/null @@ -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); diff --git a/public/app/features/admin/ldap/LdapPage.tsx b/public/app/features/admin/ldap/LdapPage.tsx index 37e9c0f13fe..74ba8031550 100644 --- a/public/app/features/admin/ldap/LdapPage.tsx +++ b/public/app/features/admin/ldap/LdapPage.tsx @@ -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 { }; 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 { }; 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 { type="text" id="username" name="username" - defaultValue={username} + defaultValue={queryParams.username} />