diff --git a/grafana-plugin/src/PluginPage.tsx b/grafana-plugin/src/PluginPage.tsx index b8bce7a8..43e0d91f 100644 --- a/grafana-plugin/src/PluginPage.tsx +++ b/grafana-plugin/src/PluginPage.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { PluginPageProps, PluginPage as RealPluginPage } from '@grafana/runtime'; import Header from 'navbar/Header/Header'; +import { pages } from 'pages'; import { isTopNavbar } from 'plugin/GrafanaPluginRootPage.helpers'; import { useStore } from 'state/useStore'; import { useQueryParams } from 'utils/hooks'; @@ -18,6 +19,7 @@ function RealPlugin(props: PluginPageProps): React.ReactNode { return (
+

{pages[page].text}

{props.children} ); diff --git a/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.tsx b/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.tsx index 782ea07a..71c38142 100644 --- a/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.tsx +++ b/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.tsx @@ -1,7 +1,6 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { SelectableValue } from '@grafana/data'; -import { getLocationSrv } from '@grafana/runtime'; import { Label, Button, HorizontalGroup, VerticalGroup, Select, LoadingPlaceholder } from '@grafana/ui'; import { capitalCase } from 'change-case'; import cn from 'classnames/bind'; @@ -19,6 +18,7 @@ import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_ import { Alert } from 'models/alertgroup/alertgroup.types'; import { makeRequest } from 'network'; import { UserAction } from 'state/userAction'; +import LocationHelper from 'utils/LocationHelper'; import styles from './AlertTemplatesForm.module.css'; @@ -162,9 +162,7 @@ const AlertTemplatesForm = (props: AlertTemplatesFormProps) => { ) : null} ); - const handleGoToTemplateSettingsCllick = () => { - getLocationSrv().update({ partial: true, query: { tab: 'Autoresolve' } }); - }; + const handleGoToTemplateSettingsCllick = () => LocationHelper.update({ tab: 'Autoresolve' }, 'partial'); return (
diff --git a/grafana-plugin/src/components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.tsx b/grafana-plugin/src/components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.tsx index 224698dd..7644563c 100644 --- a/grafana-plugin/src/components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.tsx +++ b/grafana-plugin/src/components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.tsx @@ -36,7 +36,7 @@ export default function PageErrorHandlingWrapper({ objectName?: string; pageName: string; itemNotFoundMessage?: string; - children: React.ReactNode; + children: () => React.ReactNode; }): JSX.Element { useEffect(() => { if (!errorData) { @@ -51,7 +51,7 @@ export default function PageErrorHandlingWrapper({ const store = useStore(); if (!errorData || !errorData.isWrongTeamError) { - return <>{children}; + return <>{children()}; } const currentTeamId = store.userStore.currentUser?.current_team; diff --git a/grafana-plugin/src/containers/DefaultPageLayout/DefaultPageLayout.tsx b/grafana-plugin/src/containers/DefaultPageLayout/DefaultPageLayout.tsx index 9f18e251..c98c58f7 100644 --- a/grafana-plugin/src/containers/DefaultPageLayout/DefaultPageLayout.tsx +++ b/grafana-plugin/src/containers/DefaultPageLayout/DefaultPageLayout.tsx @@ -1,7 +1,6 @@ import plugin from '../../../package.json'; // eslint-disable-line import React, { FC, useEffect, useState, useCallback } from 'react'; -import { getLocationSrv } from '@grafana/runtime'; import { Alert } from '@grafana/ui'; import cn from 'classnames/bind'; import { observer } from 'mobx-react'; @@ -12,6 +11,7 @@ import { getIfChatOpsConnected } from 'containers/DefaultPageLayout/helper'; import { AppFeature } from 'state/features'; import { useStore } from 'state/useStore'; import { UserAction } from 'state/userAction'; +import LocationHelper from 'utils/LocationHelper'; import { GRAFANA_LICENSE_OSS } from 'utils/consts'; import { useForceUpdate } from 'utils/hooks'; import { getItem, setItem } from 'utils/localStorage'; @@ -46,7 +46,7 @@ const DefaultPageLayout: FC = observer((props) => { if (query.slack_error) { setShowSlackInstallAlert(query.slack_error); - getLocationSrv().update({ partial: true, query: { slack_error: undefined }, replace: true }); + LocationHelper.update({ slack_error: undefined }, 'replace'); } }, []); diff --git a/grafana-plugin/src/containers/IntegrationSettings/IntegrationSettings.tsx b/grafana-plugin/src/containers/IntegrationSettings/IntegrationSettings.tsx index 2d35e3d8..f6652400 100644 --- a/grafana-plugin/src/containers/IntegrationSettings/IntegrationSettings.tsx +++ b/grafana-plugin/src/containers/IntegrationSettings/IntegrationSettings.tsx @@ -1,6 +1,5 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { getLocationSrv } from '@grafana/runtime'; import { Drawer, Tab, TabContent, TabsBar, Button, VerticalGroup, Input } from '@grafana/ui'; import cn from 'classnames/bind'; import { observer } from 'mobx-react'; @@ -15,6 +14,7 @@ import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_ import { Alert } from 'models/alertgroup/alertgroup.types'; import { useStore } from 'state/useStore'; import { openNotification } from 'utils'; +import LocationHelper from 'utils/LocationHelper'; import { IntegrationSettingsTab } from './IntegrationSettings.types'; import Autoresolve from './parts/Autoresolve'; @@ -46,7 +46,7 @@ const IntegrationSettings = observer((props: IntegrationSettingsProps) => { const getTabClickHandler = useCallback((tab: IntegrationSettingsTab) => { return () => { setActiveTab(tab); - getLocationSrv().update({ partial: true, query: { tab: tab } }); + LocationHelper.update({ tab }, 'partial'); }; }, []); @@ -56,7 +56,7 @@ const IntegrationSettings = observer((props: IntegrationSettingsProps) => { useEffect(() => { setActiveTab(startTab || IntegrationSettingsTab.Templates); - getLocationSrv().update({ partial: true, query: { tab: startTab || IntegrationSettingsTab.Templates } }); + LocationHelper.update({ tab: startTab || IntegrationSettingsTab.Templates }, 'partial'); }, [startTab]); const integration = alertReceiveChannelStore.getIntegration(alertReceiveChannel); diff --git a/grafana-plugin/src/containers/IntegrationSettings/parts/Autoresolve.tsx b/grafana-plugin/src/containers/IntegrationSettings/parts/Autoresolve.tsx index 3fdf07bf..420c64a1 100644 --- a/grafana-plugin/src/containers/IntegrationSettings/parts/Autoresolve.tsx +++ b/grafana-plugin/src/containers/IntegrationSettings/parts/Autoresolve.tsx @@ -1,6 +1,5 @@ import React, { useCallback, useState, useEffect } from 'react'; -import { getLocationSrv } from '@grafana/runtime'; import { Alert, Button, Icon, Label, Modal, Select } from '@grafana/ui'; import cn from 'classnames/bind'; import { get } from 'lodash-es'; @@ -15,6 +14,7 @@ import { Team } from 'models/team/team.types'; import { useStore } from 'state/useStore'; import { UserAction } from 'state/userAction'; import { openErrorNotification, openNotification } from 'utils'; +import LocationHelper from 'utils/LocationHelper'; import styles from 'containers/IntegrationSettings/parts/Autoresolve.module.css'; @@ -114,7 +114,7 @@ const Autoresolve = ({ alertReceiveChannelId, onSwitchToTemplate, alertGroupId } }; const handleGoToTemplateSettingsCllick = () => { - getLocationSrv().update({ partial: true, query: { tab: 'Templates' } }); + LocationHelper.update({ tab: 'Templates' }, 'partial'); onSwitchToTemplate('resolve_condition_template'); }; diff --git a/grafana-plugin/src/img/grafanaGlobalStyles.css b/grafana-plugin/src/img/grafanaGlobalStyles.css index 29ac020c..9e29d7ba 100644 --- a/grafana-plugin/src/img/grafanaGlobalStyles.css +++ b/grafana-plugin/src/img/grafanaGlobalStyles.css @@ -9,7 +9,7 @@ padding-bottom: 36px; } -.scrollbar-view [class*='-page-header'] { +.scrollbar-view h1:first-child { margin-bottom: 0 !important; } @@ -21,6 +21,7 @@ max-width: unset !important; flex-grow: unset !important; flex-basis: unset !important; + overflow-x: auto; } .page-scrollbar-content > div:first-child { @@ -29,6 +30,7 @@ .page-header__title { padding-top: 0 !important; + font-size: 2rem !important; margin-right: 8px; } diff --git a/grafana-plugin/src/navbar/LegacyNavTabsBar.module.scss b/grafana-plugin/src/navbar/LegacyNavTabsBar.module.scss new file mode 100644 index 00000000..c3b2ca3c --- /dev/null +++ b/grafana-plugin/src/navbar/LegacyNavTabsBar.module.scss @@ -0,0 +1,3 @@ +.root { + min-width: 1500px; +} diff --git a/grafana-plugin/src/navbar/LegacyNavTabsBar.tsx b/grafana-plugin/src/navbar/LegacyNavTabsBar.tsx index d96ba975..c6564d3c 100644 --- a/grafana-plugin/src/navbar/LegacyNavTabsBar.tsx +++ b/grafana-plugin/src/navbar/LegacyNavTabsBar.tsx @@ -2,10 +2,15 @@ import React from 'react'; import { IconName } from '@grafana/data'; import { Tab, TabsBar } from '@grafana/ui'; +import cn from 'classnames/bind'; import { pages } from 'pages'; import { useStore } from 'state/useStore'; +import styles from './LegacyNavTabsBar.module.scss'; + +const cx = cn.bind(styles); + export default function LegacyNavTabsBar({ currentPage }: { currentPage: string }): JSX.Element { const store = useStore(); @@ -14,7 +19,7 @@ export default function LegacyNavTabsBar({ currentPage }: { currentPage: string .filter((page) => (page.hideFromTabsFn ? !page.hideFromTabsFn(store) : !page.hideFromTabs)); return ( - + {navigationPages.map((page, index) => ( { - getLocationSrv().update({ partial: true, query: { id: escalationChain } }); + LocationHelper.update({ id: escalationChain }, 'partial'); if (escalationChain) { escalationChainStore.updateEscalationChainDetails(escalationChain); } @@ -143,79 +142,81 @@ class EscalationChainsPage extends React.Component - <> -
-
- -
- {!searchResult || searchResult.length ? ( -
-
- - - -
- {searchResult ? ( - - {(item) => } - - ) : ( - - )} -
-
-
{this.renderEscalation()}
+ {() => ( + <> +
+
+
- ) : ( - - No escalations found, check your filtering and current team. - + {!searchResult || searchResult.length ? ( +
+
+ - - } +
+ {searchResult ? ( + + {(item) => } + + ) : ( + + )} +
+
+
{this.renderEscalation()}
+
+ ) : ( + + No escalations found, check your filtering and current team. + + + + + } + /> + )} +
+ {showCreateEscalationChainModal && ( + { + this.setState({ + showCreateEscalationChainModal: false, + escalationChainIdToCopy: undefined, + }); + }} + onUpdate={this.handleEscalationChainCreate} /> )} -
- {showCreateEscalationChainModal && ( - { - this.setState({ - showCreateEscalationChainModal: false, - escalationChainIdToCopy: undefined, - }); - }} - onUpdate={this.handleEscalationChainCreate} - /> - )} - + + )} ); diff --git a/grafana-plugin/src/pages/incident/Incident.tsx b/grafana-plugin/src/pages/incident/Incident.tsx index 2125e374..c4f48065 100644 --- a/grafana-plugin/src/pages/incident/Incident.tsx +++ b/grafana-plugin/src/pages/incident/Incident.tsx @@ -1,6 +1,5 @@ import React, { useState, SyntheticEvent } from 'react'; -import { getLocationSrv } from '@grafana/runtime'; import { Button, HorizontalGroup, @@ -22,7 +21,6 @@ import moment from 'moment-timezone'; import CopyToClipboard from 'react-copy-to-clipboard'; import Emoji from 'react-emoji-render'; import reactStringReplace from 'react-string-replace'; -import { AppRootProps } from 'types'; import Collapse from 'components/Collapse/Collapse'; import Block from 'components/GBlock/Block'; @@ -49,12 +47,12 @@ import { } from 'models/alertgroup/alertgroup.types'; import { ResolutionNoteSourceTypesToDisplayName } from 'models/resolution_note/resolution_note.types'; import { pages } from 'pages'; -import { getQueryParams } from 'plugin/GrafanaPluginRootPage.helpers'; -import { WithStoreProps } from 'state/types'; +import { PageProps, WithStoreProps } from 'state/types'; import { useStore } from 'state/useStore'; import { UserAction } from 'state/userAction'; import { withMobXProviderContext } from 'state/withStore'; import { openNotification } from 'utils'; +import LocationHelper from 'utils/LocationHelper'; import sanitize from 'utils/sanitize'; import { getActionButtons, getIncidentStatusTag, renderRelatedUsers } from './Incident.helpers'; @@ -63,7 +61,7 @@ import styles from './Incident.module.css'; const cx = cn.bind(styles); -interface IncidentPageProps extends WithStoreProps, AppRootProps {} +interface IncidentPageProps extends WithStoreProps, PageProps {} interface IncidentPageState extends PageBaseState { showIntegrationSettings?: boolean; @@ -97,8 +95,10 @@ class IncidentPage extends React.Component update = () => { this.setState({ errorData: initErrorDataState() }); // reset wrong team error to false - const { store } = this.props; - const { id } = getQueryParams(); + const { + store, + query: { id }, + } = this.props; store.alertGroupStore .getAlert(id) @@ -106,8 +106,10 @@ class IncidentPage extends React.Component }; render() { - const { store } = this.props; - const { id, cursor, start, perpage } = getQueryParams(); + const { + store, + query: { id, cursor, start, perpage }, + } = this.props; const { errorData, showIntegrationSettings, showAttachIncidentForm } = this.state; const { isNotFoundError, isWrongTeamError } = errorData; @@ -127,74 +129,78 @@ class IncidentPage extends React.Component return ( -
- {errorData.isNotFoundError ? ( -
- - 404 - Incident not found - - - - -
- ) : ( - <> - {this.renderHeader()} -
-
- - - -
-
{this.renderTimeline()}
+ {() => ( +
+ {errorData.isNotFoundError ? ( +
+ + 404 + Incident not found + + + +
- {showIntegrationSettings && ( - { - alertReceiveChannelStore.updateItem(incident.alert_receive_channel.id); - }} - onUpdateTemplates={() => { - store.alertGroupStore.getAlert(id); - }} - startTab={IntegrationSettingsTab.Templates} - id={incident.alert_receive_channel.id} - onHide={() => - this.setState({ - showIntegrationSettings: undefined, - }) - } - /> - )} - {showAttachIncidentForm && ( - { - this.setState({ - showAttachIncidentForm: false, - }); - }} - onUpdate={this.update} - /> - )} - - )} -
+ ) : ( + <> + {this.renderHeader()} +
+
+ + + +
+
{this.renderTimeline()}
+
+ {showIntegrationSettings && ( + { + alertReceiveChannelStore.updateItem(incident.alert_receive_channel.id); + }} + onUpdateTemplates={() => { + store.alertGroupStore.getAlert(id); + }} + startTab={IntegrationSettingsTab.Templates} + id={incident.alert_receive_channel.id} + onHide={() => + this.setState({ + showIntegrationSettings: undefined, + }) + } + /> + )} + {showAttachIncidentForm && ( + { + this.setState({ + showAttachIncidentForm: false, + }); + }} + onUpdate={this.update} + /> + )} + + )} +
+ )} ); } renderHeader = () => { - const { store } = this.props; + const { + store, + query: { id, cursor, start, perpage }, + } = this.props; - const { id, cursor, start, perpage } = getQueryParams(); const { alerts } = store.alertGroupStore; const incident = alerts.get(id); @@ -311,9 +317,11 @@ class IncidentPage extends React.Component }; renderTimeline = () => { - const { store } = this.props; + const { + store, + query: { id }, + } = this.props; - const { id } = getQueryParams(); const incident = store.alertGroupStore.alerts.get(id); if (!incident.render_after_resolve_report_json) { @@ -401,9 +409,11 @@ class IncidentPage extends React.Component }; handleCreateResolutionNote = () => { - const { store } = this.props; + const { + store, + query: { id }, + } = this.props; - const { id } = getQueryParams(); const { resolutionNoteText } = this.state; store.resolutionNotesStore .createResolutionNote(id, resolutionNoteText) @@ -419,9 +429,7 @@ class IncidentPage extends React.Component case 'author': return ( { - getLocationSrv().update({ query: { page: 'users', id: entity?.author?.pk } }); - }} + onClick={() => LocationHelper.update({ id: entity?.author?.pk, page: 'users' }, 'replace')} style={{ textDecoration: 'underline', cursor: 'pointer' }} > {entity.author?.username} diff --git a/grafana-plugin/src/pages/incidents/Incidents.tsx b/grafana-plugin/src/pages/incidents/Incidents.tsx index 3ef595a6..198c2da4 100644 --- a/grafana-plugin/src/pages/incidents/Incidents.tsx +++ b/grafana-plugin/src/pages/incidents/Incidents.tsx @@ -1,6 +1,5 @@ import React, { ReactElement, SyntheticEvent } from 'react'; -import { getLocationSrv } from '@grafana/runtime'; import { Button, Icon, Tooltip, VerticalGroup, LoadingPlaceholder, HorizontalGroup } from '@grafana/ui'; import { PluginPage } from 'PluginPage'; import cn from 'classnames/bind'; @@ -8,12 +7,10 @@ import { get } from 'lodash-es'; import { observer } from 'mobx-react'; import moment from 'moment-timezone'; import Emoji from 'react-emoji-render'; -import { AppRootProps } from 'types'; import CursorPagination from 'components/CursorPagination/CursorPagination'; import GTable from 'components/GTable/GTable'; import IntegrationLogo from 'components/IntegrationLogo/IntegrationLogo'; -import PageErrorHandlingWrapper from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper'; import PluginLink from 'components/PluginLink/PluginLink'; import Text from 'components/Text/Text'; import Tutorial from 'components/Tutorial/Tutorial'; @@ -25,11 +22,11 @@ import { Alert, Alert as AlertType, AlertAction } from 'models/alertgroup/alertg import { User } from 'models/user/user.types'; import { pages } from 'pages'; import { getActionButtons, getIncidentStatusTag, renderRelatedUsers } from 'pages/incident/Incident.helpers'; -import { getQueryParams } from 'plugin/GrafanaPluginRootPage.helpers'; import { move } from 'state/helpers'; -import { WithStoreProps } from 'state/types'; +import { PageProps, WithStoreProps } from 'state/types'; import { UserAction } from 'state/userAction'; import { withMobXProviderContext } from 'state/withStore'; +import LocationHelper from 'utils/LocationHelper'; import SilenceDropdown from './parts/SilenceDropdown'; @@ -54,7 +51,7 @@ function withSkeleton(fn: (alert: AlertType) => ReactElement | ReactElement[]) { return WithSkeleton; } -interface IncidentsPageProps extends WithStoreProps, AppRootProps {} +interface IncidentsPageProps extends WithStoreProps, PageProps {} interface IncidentsPageState { selectedIncidentIds: Array; @@ -71,8 +68,10 @@ class Incidents extends React.Component constructor(props: IncidentsPageProps) { super(props); - const { store } = props; - const { cursor: cursorQuery, start: startQuery, perpage: perpageQuery } = getQueryParams(); + const { + store, + query: { cursor: cursorQuery, start: startQuery, perpage: perpageQuery }, + } = props; const cursor = cursorQuery || undefined; const start = !isNaN(startQuery) ? Number(startQuery) : 1; @@ -103,12 +102,10 @@ class Incidents extends React.Component render() { return ( - -
- {this.renderIncidentFilters()} - {this.renderTable()} -
-
+
+ {this.renderIncidentFilters()} + {this.renderTable()} +
); } @@ -148,7 +145,7 @@ class Incidents extends React.Component fetchIncidentData = (filters: IncidentsFiltersType, isOnMount: boolean) => { const { store } = this.props; store.alertGroupStore.updateIncidentFilters(filters, isOnMount); // this line fetches incidents - getLocationSrv().update({ query: { page: 'incidents', ...store.alertGroupStore.incidentFilters } }); + LocationHelper.update({ page: 'incidents', ...store.alertGroupStore.incidentFilters }, 'partial'); }; onChangeCursor = (cursor: string, direction: 'prev' | 'next') => { @@ -577,7 +574,9 @@ class Incidents extends React.Component } setPollingInterval(filters: IncidentsFiltersType = this.state.filters, isOnMount = false) { - this.pollingIntervalId = setInterval(() => this.fetchIncidentData(filters, isOnMount), POLLING_NUM_SECONDS * 1000); + this.pollingIntervalId = setInterval(() => { + this.fetchIncidentData(filters, isOnMount); + }, POLLING_NUM_SECONDS * 1000); } } diff --git a/grafana-plugin/src/pages/index.tsx b/grafana-plugin/src/pages/index.tsx index b3fa2499..a837581a 100644 --- a/grafana-plugin/src/pages/index.tsx +++ b/grafana-plugin/src/pages/index.tsx @@ -101,7 +101,7 @@ export const pages: { [id: string]: PageDefinition } = [ { icon: 'cog', id: 'settings', - text: 'Organization Settings', + text: 'Settings', hideFromBreadcrumbs: true, path: getPath('settings'), }, diff --git a/grafana-plugin/src/pages/integrations/Integrations.tsx b/grafana-plugin/src/pages/integrations/Integrations.tsx index e3a759b4..a5fb20c4 100644 --- a/grafana-plugin/src/pages/integrations/Integrations.tsx +++ b/grafana-plugin/src/pages/integrations/Integrations.tsx @@ -1,12 +1,10 @@ import React from 'react'; -import { getLocationSrv } from '@grafana/runtime'; import { Button, LoadingPlaceholder, VerticalGroup } from '@grafana/ui'; import { PluginPage } from 'PluginPage'; import cn from 'classnames/bind'; import { debounce } from 'lodash-es'; import { observer } from 'mobx-react'; -import { AppRootProps } from 'types'; import GList from 'components/GList/GList'; import IntegrationsFilters, { Filters } from 'components/IntegrationsFilters/IntegrationsFilters'; @@ -27,9 +25,10 @@ import { WithPermissionControl } from 'containers/WithPermissionControl/WithPerm import { AlertReceiveChannel } from 'models/alert_receive_channel'; import { AlertReceiveChannelOption } from 'models/alert_receive_channel/alert_receive_channel.types'; import { pages } from 'pages'; -import { WithStoreProps } from 'state/types'; +import { PageProps, WithStoreProps } from 'state/types'; import { UserAction } from 'state/userAction'; import { withMobXProviderContext } from 'state/withStore'; +import LocationHelper from 'utils/LocationHelper'; import styles from './Integrations.module.css'; @@ -42,7 +41,7 @@ interface IntegrationsState extends PageBaseState { integrationSettingsTab?: IntegrationSettingsTab; } -interface IntegrationsProps extends WithStoreProps, AppRootProps {} +interface IntegrationsProps extends WithStoreProps, PageProps {} @observer class Integrations extends React.Component { @@ -62,7 +61,7 @@ class Integrations extends React.Component setSelectedAlertReceiveChannel = (alertReceiveChannelId: AlertReceiveChannel['id']) => { const { store } = this.props; store.selectedAlertReceiveChannel = alertReceiveChannelId; - getLocationSrv().update({ partial: true, query: { id: alertReceiveChannelId } }); + LocationHelper.update({ id: alertReceiveChannelId }, 'partial'); }; parseQueryParams = async () => { @@ -139,110 +138,112 @@ class Integrations extends React.Component pageName="integrations" itemNotFoundMessage={`Integration with id=${query?.id} is not found. Please select integration from the list.`} > - <> -
-
- -
- {searchResult?.length ? ( -
-
- - - -
- - {(item) => ( - { - this.setState({ - alertReceiveChannelToShowSettings: item.id, - integrationSettingsTab: IntegrationSettingsTab.Heartbeat, - }); - }} - /> - )} - -
-
-
- { - this.setState({ - alertReceiveChannelToShowSettings: store.selectedAlertReceiveChannel, - integrationSettingsTab, - }); - }} - /> -
+ {() => ( + <> +
+
+
- ) : searchResult ? ( - - No integrations found. Review your filter and team settings. + {searchResult?.length ? ( +
+
- - } +
+ + {(item) => ( + { + this.setState({ + alertReceiveChannelToShowSettings: item.id, + integrationSettingsTab: IntegrationSettingsTab.Heartbeat, + }); + }} + /> + )} + +
+
+
+ { + this.setState({ + alertReceiveChannelToShowSettings: store.selectedAlertReceiveChannel, + integrationSettingsTab, + }); + }} + /> +
+
+ ) : searchResult ? ( + + No integrations found. Review your filter and team settings. + + + + + } + /> + ) : ( + + )} +
+ {alertReceiveChannelToShowSettings && ( + { + alertReceiveChannelStore.updateItem(alertReceiveChannelToShowSettings); + }} + startTab={integrationSettingsTab} + id={alertReceiveChannelToShowSettings} + onHide={() => { + this.setState({ + alertReceiveChannelToShowSettings: undefined, + integrationSettingsTab: undefined, + }); + LocationHelper.update({ tab: undefined }, 'partial'); + }} /> - ) : ( - )} -
- {alertReceiveChannelToShowSettings && ( - { - alertReceiveChannelStore.updateItem(alertReceiveChannelToShowSettings); - }} - startTab={integrationSettingsTab} - id={alertReceiveChannelToShowSettings} - onHide={() => { - this.setState({ - alertReceiveChannelToShowSettings: undefined, - integrationSettingsTab: undefined, - }); - getLocationSrv().update({ partial: true, query: { tab: undefined } }); - }} - /> - )} - {showCreateIntegrationModal && ( - { - this.setState({ showCreateIntegrationModal: false }); - }} - onCreate={this.handleCreateNewAlertReceiveChannel} - /> - )} - + {showCreateIntegrationModal && ( + { + this.setState({ showCreateIntegrationModal: false }); + }} + onCreate={this.handleCreateNewAlertReceiveChannel} + /> + )} + + )} ); diff --git a/grafana-plugin/src/pages/maintenance/Maintenance.tsx b/grafana-plugin/src/pages/maintenance/Maintenance.tsx index 4d329475..9ec72ea3 100644 --- a/grafana-plugin/src/pages/maintenance/Maintenance.tsx +++ b/grafana-plugin/src/pages/maintenance/Maintenance.tsx @@ -7,7 +7,6 @@ import { observer } from 'mobx-react'; import moment from 'moment-timezone'; import LegacyNavHeading from 'navbar/LegacyNavHeading'; import Emoji from 'react-emoji-render'; -import { AppRootProps } from 'types'; import GTable from 'components/GTable/GTable'; import Text from 'components/Text/Text'; @@ -18,7 +17,7 @@ import { getAlertReceiveChannelDisplayName } from 'models/alert_receive_channel/ import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types'; import { Maintenance, MaintenanceMode, MaintenanceType } from 'models/maintenance/maintenance.types'; import { pages } from 'pages'; -import { WithStoreProps } from 'state/types'; +import { PageProps, WithStoreProps } from 'state/types'; import { UserAction } from 'state/userAction'; import { withMobXProviderContext } from 'state/withStore'; @@ -26,7 +25,7 @@ import styles from './Maintenance.module.css'; const cx = cn.bind(styles); -interface MaintenancePageProps extends AppRootProps, WithStoreProps {} +interface MaintenancePageProps extends PageProps, WithStoreProps {} interface MaintenancePageState { maintenanceData?: { diff --git a/grafana-plugin/src/pages/outgoing_webhooks/OutgoingWebhooks.test.tsx b/grafana-plugin/src/pages/outgoing_webhooks/OutgoingWebhooks.test.tsx new file mode 100644 index 00000000..463b3256 --- /dev/null +++ b/grafana-plugin/src/pages/outgoing_webhooks/OutgoingWebhooks.test.tsx @@ -0,0 +1,86 @@ +import 'jest/matchMedia.ts'; +import React from 'react'; + +import { describe, expect, test } from '@jest/globals'; +import { render, screen, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import outgoingWebhooksStub from 'jest/outgoingWebhooksStub'; + +import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types'; +import { OutgoingWebhooks } from 'pages/outgoing_webhooks/OutgoingWebhooks'; + +const outgoingWebhooks = outgoingWebhooksStub as OutgoingWebhook[]; +const outgoingWebhookStore = () => ({ + loadItem: () => Promise.resolve(outgoingWebhooks[0]), + updateItems: () => Promise.resolve(), + getSearchResult: () => outgoingWebhooks, + items: outgoingWebhooks.reduce((prev, current) => { + prev[current.id] = current; + return prev; + }, {}), +}); + +jest.mock('plugin/GrafanaPluginRootPage.helpers', () => ({ + isTopNavbar: () => false, +})); + +jest.mock('@grafana/runtime', () => ({ + config: { + featureToggles: { + topNav: false, + }, + }, +})); + +jest.mock('state/useStore', () => ({ + useStore: () => ({ + outgoingWebhookStore: outgoingWebhookStore(), + isUserActionAllowed: jest.fn().mockReturnValue(true), + }), +})); + +jest.mock('@grafana/runtime', () => ({ + getLocationSrv: jest.fn(), +})); + +describe('OutgoingWebhooks', () => { + const storeMock = { + isUserActionAllowed: jest.fn().mockReturnValue(true), + outgoingWebhookStore: outgoingWebhookStore(), + }; + + beforeAll(() => { + console.warn = () => {}; + console.error = () => {}; + }); + + test('It renders all retrieved webhooks', async () => { + render(); + + await waitFor(() => { + const gTable = screen.queryByTestId('test__gTable'); + const rows = gTable.querySelectorAll('tbody tr'); + + expect(() => queryEditForm()).toThrow(); // edit doesn't show for [id=undefined] + expect(rows.length).toBe(outgoingWebhooks.length); + }); + }); + + test('It opens Edit View if [id] is supplied', async () => { + const id = outgoingWebhooks[0].id; + render(); + + expect(() => queryEditForm()).toThrow(); // before updates kick in + await waitFor(() => { + expect(queryEditForm()).toBeDefined(); // edit shows for [id=?] + }); + }); + + function getProps(id: OutgoingWebhook['id'] = undefined): any { + return { store: storeMock, query: { id } }; + } + + function queryEditForm(): HTMLElement { + return screen.getByTestId('test__outgoingWebhookEditForm'); + } +}); diff --git a/grafana-plugin/src/pages/outgoing_webhooks/OutgoingWebhooks.tsx b/grafana-plugin/src/pages/outgoing_webhooks/OutgoingWebhooks.tsx index 797fc6d6..1ff3f4d7 100644 --- a/grafana-plugin/src/pages/outgoing_webhooks/OutgoingWebhooks.tsx +++ b/grafana-plugin/src/pages/outgoing_webhooks/OutgoingWebhooks.tsx @@ -1,12 +1,10 @@ import React from 'react'; -import { getLocationSrv } from '@grafana/runtime'; import { Button, HorizontalGroup } from '@grafana/ui'; import { PluginPage } from 'PluginPage'; import cn from 'classnames/bind'; import { observer } from 'mobx-react'; import LegacyNavHeading from 'navbar/LegacyNavHeading'; -import { AppRootProps } from 'types'; import GTable from 'components/GTable/GTable'; import PageErrorHandlingWrapper, { PageBaseState } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper'; @@ -22,16 +20,16 @@ import { WithPermissionControl } from 'containers/WithPermissionControl/WithPerm import { ActionDTO } from 'models/action'; import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types'; import { pages } from 'pages'; -import { getQueryParams } from 'plugin/GrafanaPluginRootPage.helpers'; -import { WithStoreProps } from 'state/types'; +import { PageProps, WithStoreProps } from 'state/types'; import { UserAction } from 'state/userAction'; import { withMobXProviderContext } from 'state/withStore'; +import LocationHelper from 'utils/LocationHelper'; import styles from './OutgoingWebhooks.module.css'; const cx = cn.bind(styles); -interface OutgoingWebhooksProps extends WithStoreProps, AppRootProps {} +interface OutgoingWebhooksProps extends WithStoreProps, PageProps {} interface OutgoingWebhooksState extends PageBaseState { outgoingWebhookIdToEdit?: OutgoingWebhook['id'] | 'new'; @@ -43,14 +41,12 @@ class OutgoingWebhooks extends React.Component - <> -
- ( -
- - Outgoing Webhooks - -
- - - - - + {() => ( + <> +
+ ( +
+ + Outgoing Webhooks + +
+ + + + + +
-
- )} - rowKey="id" - columns={columns} - data={webhooks} - /> -
- {outgoingWebhookIdToEdit && ( - - )} - + )} + rowKey="id" + columns={columns} + data={webhooks} + /> +
+ {outgoingWebhookIdToEdit && ( + + )} + + )} ); @@ -194,14 +192,14 @@ class OutgoingWebhooks extends React.Component { this.setState({ outgoingWebhookIdToEdit: id }); - getLocationSrv().update({ partial: true, query: { id } }); + LocationHelper.update({ id }, 'partial'); }; }; handleOutgoingWebhookFormHide = () => { this.setState({ outgoingWebhookIdToEdit: undefined }); - getLocationSrv().update({ partial: true, query: { id: undefined } }); + LocationHelper.update({ id: undefined }, 'partial'); }; } diff --git a/grafana-plugin/src/pages/schedule/Schedule.tsx b/grafana-plugin/src/pages/schedule/Schedule.tsx index 7f99d9fe..87e02706 100644 --- a/grafana-plugin/src/pages/schedule/Schedule.tsx +++ b/grafana-plugin/src/pages/schedule/Schedule.tsx @@ -1,12 +1,10 @@ import React from 'react'; -import { getLocationSrv } from '@grafana/runtime'; import { Button, HorizontalGroup, VerticalGroup, IconButton, ToolbarButton, Icon, Modal } from '@grafana/ui'; import { PluginPage } from 'PluginPage'; import cn from 'classnames/bind'; import dayjs from 'dayjs'; import { observer } from 'mobx-react'; -import { AppRootProps } from 'types'; import PageErrorHandlingWrapper from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper'; import PluginLink from 'components/PluginLink/PluginLink'; @@ -23,10 +21,10 @@ import UsersTimezones from 'containers/UsersTimezones/UsersTimezones'; import { Schedule, ScheduleType, Shift } from 'models/schedule/schedule.types'; import { Timezone } from 'models/timezone/timezone.types'; import { pages } from 'pages'; -import { getQueryParams } from 'plugin/GrafanaPluginRootPage.helpers'; -import { WithStoreProps } from 'state/types'; +import { PageProps, WithStoreProps } from 'state/types'; import { UserAction } from 'state/userAction'; import { withMobXProviderContext } from 'state/withStore'; +import LocationHelper from 'utils/LocationHelper'; import { getStartOfWeek } from './Schedule.helpers'; @@ -34,7 +32,7 @@ import styles from './Schedule.module.css'; const cx = cn.bind(styles); -interface SchedulePageProps extends AppRootProps, WithStoreProps {} +interface SchedulePageProps extends PageProps, WithStoreProps {} interface SchedulePageState { startMoment: dayjs.Dayjs; @@ -66,8 +64,10 @@ class SchedulePage extends React.Component } async componentDidMount() { - const { store } = this.props; - const { id } = getQueryParams(); + const { + store, + query: { id }, + } = this.props; store.userStore.updateItems(); @@ -86,8 +86,10 @@ class SchedulePage extends React.Component } render() { - const { store } = this.props; - const { id: scheduleId } = getQueryParams(); + const { + store, + query: { id: scheduleId }, + } = this.props; const { startMoment, @@ -112,139 +114,150 @@ class SchedulePage extends React.Component return ( -
- -
- - - - - - - {schedule?.name} - - {schedule && } - - - {users && ( + {() => ( + <> +
+ +
+ - Current timezone: - + + + + + {schedule?.name} + + {schedule && } - )} - - - - {(schedule?.type === ScheduleType.Ical || schedule?.type === ScheduleType.Calendar) && ( - + + {users && ( + + Current timezone: + + )} + + + + {(schedule?.type === ScheduleType.Ical || schedule?.type === ScheduleType.Calendar) && ( + + )} + + { + this.setState({ showEditForm: true }); + }} + /> + + + + - { - this.setState({ showEditForm: true }); - }} - /> - - - - - -
-
- -
+
+
+ +
-
-
- - - - - - +
+
+ + + + + + + + + {startMoment.format('DD MMM')} - {startMoment.add(6, 'day').format('DD MMM')} + + - - {startMoment.format('DD MMM')} - {startMoment.add(6, 'day').format('DD MMM')} - - - -
- - - +
+ + + +
+
- -
- {showEditForm && ( - { - this.setState({ showEditForm: false }); - }} - /> - )} - {showScheduleICalSettings && ( - this.setState({ showScheduleICalSettings: false })} - > - - + {showEditForm && ( + { + this.setState({ showEditForm: false }); + }} + /> + )} + {showScheduleICalSettings && ( + this.setState({ showScheduleICalSettings: false })} + > + + + )} + )} @@ -293,8 +306,10 @@ class SchedulePage extends React.Component }; updateEvents = () => { - const { store } = this.props; - const { id: scheduleId } = getQueryParams(); + const { + store, + query: { id: scheduleId }, + } = this.props; const { startMoment } = this.state; @@ -418,12 +433,12 @@ class SchedulePage extends React.Component }; handleDelete = () => { - const { store } = this.props; - const { id: scheduleId } = getQueryParams(); + const { + store, + query: { id: scheduleId }, + } = this.props; - store.scheduleStore.delete(scheduleId).then(() => { - getLocationSrv().update({ query: { page: 'schedules' } }); - }); + store.scheduleStore.delete(scheduleId).then(() => LocationHelper.update({ page: 'schedules' }, 'replace')); }; } diff --git a/grafana-plugin/src/pages/schedules/Schedules.tsx b/grafana-plugin/src/pages/schedules/Schedules.tsx index 7b7cdbd1..18d6ca52 100644 --- a/grafana-plugin/src/pages/schedules/Schedules.tsx +++ b/grafana-plugin/src/pages/schedules/Schedules.tsx @@ -1,6 +1,5 @@ import React from 'react'; -import { getLocationSrv } from '@grafana/runtime'; import { Button, HorizontalGroup, IconButton, LoadingPlaceholder, VerticalGroup } from '@grafana/ui'; import { PluginPage } from 'PluginPage'; import cn from 'classnames/bind'; @@ -31,6 +30,7 @@ import { getStartOfWeek } from 'pages/schedule/Schedule.helpers'; import { WithStoreProps } from 'state/types'; import { UserAction } from 'state/userAction'; import { withMobXProviderContext } from 'state/withStore'; +import LocationHelper from 'utils/LocationHelper'; import styles from './Schedules.module.css'; @@ -210,7 +210,7 @@ class SchedulesPage extends React.Component { if (data.type === ScheduleType.API) { - getLocationSrv().update({ query: { page: 'schedule', id: data.id } }); + LocationHelper.update({ page: 'schedule', id: data.id }, 'replace'); } }; @@ -259,9 +259,7 @@ class SchedulesPage extends React.Component { - return () => { - getLocationSrv().update({ query: { page: 'schedule', id: scheduleId } }); - }; + return () => LocationHelper.update({ page: 'schedule', id: scheduleId }, 'replace'); }; renderType = (value: number) => { diff --git a/grafana-plugin/src/pages/settings/tabs/Cloud/CloudPage.tsx b/grafana-plugin/src/pages/settings/tabs/Cloud/CloudPage.tsx index 2675f113..17ebd610 100644 --- a/grafana-plugin/src/pages/settings/tabs/Cloud/CloudPage.tsx +++ b/grafana-plugin/src/pages/settings/tabs/Cloud/CloudPage.tsx @@ -1,6 +1,5 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { getLocationSrv } from '@grafana/runtime'; import { Field, Input, Button, HorizontalGroup, Icon, VerticalGroup, LoadingPlaceholder } from '@grafana/ui'; import cn from 'classnames/bind'; import { observer } from 'mobx-react'; @@ -15,6 +14,7 @@ import { WithStoreProps } from 'state/types'; import { useStore } from 'state/useStore'; import { withMobXProviderContext } from 'state/withStore'; import { openErrorNotification } from 'utils'; +import LocationHelper from 'utils/LocationHelper'; import styles from './CloudPage.module.css'; @@ -116,7 +116,7 @@ const CloudPage = observer((_props: CloudPageProps) => { variant="secondary" size="sm" className={cx('table-button')} - onClick={() => getLocationSrv().update({ query: { page: 'users', p: page, id: user.id } })} + onClick={() => LocationHelper.update({ page: 'users', p: page, id: user.id }, 'replace')} > Configure notifications diff --git a/grafana-plugin/src/pages/users/Users.tsx b/grafana-plugin/src/pages/users/Users.tsx index 623b379b..6faa3b55 100644 --- a/grafana-plugin/src/pages/users/Users.tsx +++ b/grafana-plugin/src/pages/users/Users.tsx @@ -1,13 +1,11 @@ import React from 'react'; -import { getLocationSrv } from '@grafana/runtime'; import { Alert, Button, HorizontalGroup, Icon, VerticalGroup } from '@grafana/ui'; import { PluginPage } from 'PluginPage'; import cn from 'classnames/bind'; import { debounce } from 'lodash-es'; import { observer } from 'mobx-react'; import LegacyNavHeading from 'navbar/LegacyNavHeading'; -import { AppRootProps } from 'types'; import Avatar from 'components/Avatar/Avatar'; import GTable from 'components/GTable/GTable'; @@ -24,10 +22,10 @@ import { WithPermissionControl } from 'containers/WithPermissionControl/WithPerm import { getRole } from 'models/user/user.helpers'; import { User as UserType, UserRole } from 'models/user/user.types'; import { pages } from 'pages'; -import { getQueryParams } from 'plugin/GrafanaPluginRootPage.helpers'; -import { WithStoreProps } from 'state/types'; +import { PageProps, WithStoreProps } from 'state/types'; import { UserAction } from 'state/userAction'; import { withMobXProviderContext } from 'state/withStore'; +import LocationHelper from 'utils/LocationHelper'; import { getRealFilters, getUserRowClassNameFn } from './Users.helpers'; @@ -35,7 +33,7 @@ import styles from './Users.module.css'; const cx = cn.bind(styles); -interface UsersProps extends WithStoreProps, AppRootProps {} +interface UsersProps extends WithStoreProps, PageProps {} const ITEMS_PER_PAGE = 100; @@ -65,10 +63,10 @@ class Users extends React.Component { initialUsersLoaded = false; - private userId: string; - async componentDidMount() { - const { p } = getQueryParams(); + const { + query: { p }, + } = this.props; this.setState({ page: p ? Number(p) : 1 }, this.updateUsers); this.parseParams(); @@ -83,11 +81,11 @@ class Users extends React.Component { return; } - getLocationSrv().update({ query: { p: page }, partial: true }); + LocationHelper.update({ p: page }, 'partial'); return await userStore.updateItems(getRealFilters(usersFilters), page); }; - componentDidUpdate() { + componentDidUpdate(prevProps: UsersProps) { const { store } = this.props; if (!this.initialUsersLoaded && store.isUserActionAllowed(UserAction.ViewOtherUsers)) { @@ -95,7 +93,7 @@ class Users extends React.Component { this.initialUsersLoaded = true; } - if (this.userId !== getQueryParams()['id']) { + if (prevProps.query.id !== this.props.query.id) { this.parseParams(); } } @@ -103,10 +101,10 @@ class Users extends React.Component { parseParams = async () => { this.setState({ errorData: initErrorDataState() }); // reset wrong team error to false on query parse - const { store } = this.props; - const { id } = getQueryParams(); - - this.userId = id; + const { + store, + query: { id }, + } = this.props; if (id) { await (id === 'me' ? store.userStore.loadCurrentUser() : store.userStore.loadUser(String(id), true)).catch( @@ -182,74 +180,76 @@ class Users extends React.Component { pageName="users" itemNotFoundMessage={`User with id=${query?.id} is not found. Please select user from the list.`} > - <> -
-
-
-
-
- - Users - - - To manage permissions or add users, please visit{' '} - Grafana user management - + {() => ( + <> +
+
+
+
+
+ + Users + + + To manage permissions or add users, please visit{' '} + Grafana user management + +
-
- - - -
- {store.isUserActionAllowed(UserAction.ViewOtherUsers) ? ( - <> -
- - -
+ +
+ {store.isUserActionAllowed(UserAction.ViewOtherUsers) ? ( + <> +
+ + +
- + + ) : ( + + You don't have enough permissions to view other users because you are not Admin.{' '} + Click here to open your profile + + } + severity="info" /> - - ) : ( - - You don't have enough permissions to view other users because you are not Admin.{' '} - Click here to open your profile - - } - severity="info" - /> - )} + )} +
+ {userPkToEdit && }
- {userPkToEdit && } -
- + + )} ); @@ -378,7 +378,7 @@ class Users extends React.Component { handleHideUserSettings = () => { this.setState({ userPkToEdit: undefined }); - getLocationSrv().update({ partial: true, query: { id: undefined } }); + LocationHelper.update({ id: undefined }, 'partial'); }; handleUserUpdate = () => { diff --git a/grafana-plugin/src/plugin.json b/grafana-plugin/src/plugin.json index d753a8e6..93e971a1 100644 --- a/grafana-plugin/src/plugin.json +++ b/grafana-plugin/src/plugin.json @@ -34,6 +34,7 @@ "name": "Alert Groups", "path": "/a/grafana-oncall-app/?page=incidents", "role": "Viewer", + "defaultNav": true, "addToNav": true }, { diff --git a/grafana-plugin/src/plugin/GrafanaPluginRootPage.helpers.tsx b/grafana-plugin/src/plugin/GrafanaPluginRootPage.helpers.tsx index 23854334..63b461be 100644 --- a/grafana-plugin/src/plugin/GrafanaPluginRootPage.helpers.tsx +++ b/grafana-plugin/src/plugin/GrafanaPluginRootPage.helpers.tsx @@ -8,7 +8,17 @@ export function getQueryParams(): any { const searchParams = new URLSearchParams(window.location.search); const result = {}; for (const [key, value] of searchParams) { - result[key] = value; + if (result[key]) { + // key already existing, we're handling an array + if (!Array.isArray(result[key])) { + result[key] = new Array(result[key]); + } + + result[key].push(value); + } else { + result[key] = value; + } } + return result; } diff --git a/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx b/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx index d2b4cd59..43288bb3 100644 --- a/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx +++ b/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx @@ -35,7 +35,7 @@ import 'style/vars.css'; import 'style/global.css'; import 'style/utils.css'; -import { isTopNavbar } from './GrafanaPluginRootPage.helpers'; +import { getQueryParams, isTopNavbar } from './GrafanaPluginRootPage.helpers'; import PluginSetup from './PluginSetup'; export const GrafanaPluginRootPage = (props: AppRootProps) => ( @@ -101,7 +101,7 @@ export const Root = observer((props: AppRootProps) => { 'u-position-relative' )} > - +
); diff --git a/grafana-plugin/src/state/types.ts b/grafana-plugin/src/state/types.ts index 642df664..9f9eccbd 100644 --- a/grafana-plugin/src/state/types.ts +++ b/grafana-plugin/src/state/types.ts @@ -1,9 +1,16 @@ +import { AppPluginMeta, KeyValue } from '@grafana/data'; + import { RootStore } from 'state/index'; export interface WithStoreProps { store: RootStore; } +export interface PageProps { + meta: AppPluginMeta; + query: KeyValue; +} + export interface SelectOption { value: string | number; display_name: string; diff --git a/grafana-plugin/src/style/global.css b/grafana-plugin/src/style/global.css index 07a8af39..fe3e65a2 100644 --- a/grafana-plugin/src/style/global.css +++ b/grafana-plugin/src/style/global.css @@ -39,3 +39,7 @@ .navbarRootFallback { margin-top: 24px; } + +.page-title { + margin-bottom: 16px; +} diff --git a/grafana-plugin/src/utils/LocationHelper.ts b/grafana-plugin/src/utils/LocationHelper.ts new file mode 100644 index 00000000..27bfe38f --- /dev/null +++ b/grafana-plugin/src/utils/LocationHelper.ts @@ -0,0 +1,43 @@ +import { KeyValue } from '@grafana/data'; +import { locationService } from '@grafana/runtime'; + +import { getQueryParams } from 'plugin/GrafanaPluginRootPage.helpers'; + +class LocationHelper { + update(params: KeyValue, method: 'replace' | 'push' | 'partial') { + const queryParams = getQueryParams(); + + const sortedExistingParams = sort(queryParams); + const sortedNewParams = sort(params); + + if (toQueryString(sortedExistingParams) !== toQueryString(sortedNewParams)) { + if (method === 'partial') { + locationService.partial(params); + } else { + locationService[method](toQueryString(sortedNewParams)); + } + } + } +} + +function toQueryString(queryParams: KeyValue) { + const urlParams = new URLSearchParams(queryParams); + for (const [key, value] of Object.entries(queryParams)) { + if (Array.isArray(value)) { + urlParams.delete(key); + value.forEach((v) => urlParams.append(key, v)); + } + } + return urlParams.toString(); +} + +function sort(object: KeyValue) { + return Object.keys(object) + .sort() + .reduce((obj, key) => { + obj[key] = object[key]; + return obj; + }, {}); +} + +export default new LocationHelper();