Move mobile app QR code to Grafana user profile page (#3296)
# What this PR does Dependent on https://github.com/grafana/grafana/pull/77863 ## Which issue(s) this PR fixes ## Checklist - [ ] Unit, integration, and e2e (if applicable) tests updated - [ ] Documentation added (or `pr:no public docs` PR label added if not required) - [ ] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not required) --------- Co-authored-by: Rares Mardare <rares.mardare@grafana.com>
This commit is contained in:
parent
f20aa75869
commit
616d474e59
27 changed files with 581 additions and 1076 deletions
|
|
@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
|
|
||||||
- Do not retry `firebase.messaging.UnregisteredError` exceptions for FCM relay tasks by @joeyorlando ([#3637](https://github.com/grafana/oncall/pull/3637))
|
- Do not retry `firebase.messaging.UnregisteredError` exceptions for FCM relay tasks by @joeyorlando ([#3637](https://github.com/grafana/oncall/pull/3637))
|
||||||
- Decrease outgoing webhook timeouts from 10secs to 4secs by @joeyorlando ([#3639](https://github.com/grafana/oncall/pull/3639))
|
- Decrease outgoing webhook timeouts from 10secs to 4secs by @joeyorlando ([#3639](https://github.com/grafana/oncall/pull/3639))
|
||||||
|
- Moved Mobile Connection Tab to separate user profile in Grafana ([#3296](https://github.com/grafana/oncall/pull/3296)
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,12 @@
|
||||||
import { OrgRole } from '@grafana/data';
|
import {
|
||||||
import { test as setup, chromium, expect, Page, BrowserContext, FullConfig, APIRequestContext } from '@playwright/test';
|
test as setup,
|
||||||
|
chromium,
|
||||||
|
expect,
|
||||||
|
type Page,
|
||||||
|
type BrowserContext,
|
||||||
|
type FullConfig,
|
||||||
|
type APIRequestContext,
|
||||||
|
} from '@playwright/test';
|
||||||
|
|
||||||
import { getOnCallApiUrl } from 'utils/consts';
|
import { getOnCallApiUrl } from 'utils/consts';
|
||||||
|
|
||||||
|
|
@ -21,6 +28,13 @@ import { goToGrafanaPage } from './utils/navigation';
|
||||||
|
|
||||||
const grafanaApiClient = new GrafanaAPIClient(GRAFANA_ADMIN_USERNAME, GRAFANA_ADMIN_PASSWORD);
|
const grafanaApiClient = new GrafanaAPIClient(GRAFANA_ADMIN_USERNAME, GRAFANA_ADMIN_PASSWORD);
|
||||||
|
|
||||||
|
enum OrgRole {
|
||||||
|
None = 'None',
|
||||||
|
Viewer = 'Viewer',
|
||||||
|
Editor = 'Editor',
|
||||||
|
Admin = 'Admin',
|
||||||
|
}
|
||||||
|
|
||||||
type UserCreationSettings = {
|
type UserCreationSettings = {
|
||||||
adminAuthedRequest: APIRequestContext;
|
adminAuthedRequest: APIRequestContext;
|
||||||
role: OrgRole;
|
role: OrgRole;
|
||||||
|
|
|
||||||
|
|
@ -119,11 +119,11 @@
|
||||||
"@dnd-kit/core": "^6.0.8",
|
"@dnd-kit/core": "^6.0.8",
|
||||||
"@dnd-kit/sortable": "^7.0.2",
|
"@dnd-kit/sortable": "^7.0.2",
|
||||||
"@dnd-kit/utilities": "^3.2.1",
|
"@dnd-kit/utilities": "^3.2.1",
|
||||||
"@grafana/data": "^9.2.4",
|
"@grafana/data": "^10.2.3",
|
||||||
"@grafana/faro-web-sdk": "^1.0.0-beta4",
|
"@grafana/faro-web-sdk": "^1.0.0-beta4",
|
||||||
"@grafana/faro-web-tracing": "^1.0.0-beta4",
|
"@grafana/faro-web-tracing": "^1.0.0-beta4",
|
||||||
"@grafana/labels": "~1.4.4",
|
"@grafana/labels": "~1.4.4",
|
||||||
"@grafana/runtime": "9.3.0-beta1",
|
"@grafana/runtime": "^10.2.2",
|
||||||
"@grafana/scenes": "^1.28.0",
|
"@grafana/scenes": "^1.28.0",
|
||||||
"@grafana/schema": "^10.2.2",
|
"@grafana/schema": "^10.2.2",
|
||||||
"@grafana/ui": "^10.2.0",
|
"@grafana/ui": "^10.2.0",
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
import React, { ReactElement, useMemo, useState } from 'react';
|
import React, { ReactElement, useMemo, useState } from 'react';
|
||||||
|
|
||||||
// Note: these imports are available in Grafana>=10.0.
|
|
||||||
// @ts-expect-error
|
|
||||||
import { PluginExtensionLink } from '@grafana/data';
|
import { PluginExtensionLink } from '@grafana/data';
|
||||||
// @ts-expect-error
|
|
||||||
import { getPluginLinkExtensions } from '@grafana/runtime';
|
import { getPluginLinkExtensions } from '@grafana/runtime';
|
||||||
import { Dropdown, ToolbarButton } from '@grafana/ui';
|
import { Dropdown, ToolbarButton } from '@grafana/ui';
|
||||||
import { OnCallPluginExtensionPoints } from 'types';
|
import { OnCallPluginExtensionPoints } from 'types';
|
||||||
|
|
@ -54,7 +51,10 @@ function useExtensionPointContext(incident: Alert): PluginExtensionOnCallAlertGr
|
||||||
return { alertGroup: incident };
|
return { alertGroup: incident };
|
||||||
}
|
}
|
||||||
|
|
||||||
function useExtensionLinks<T>(context: T, extensionPointId: OnCallPluginExtensionPoints): PluginExtensionLink[] {
|
function useExtensionLinks<T extends object>(
|
||||||
|
context: T,
|
||||||
|
extensionPointId: OnCallPluginExtensionPoints
|
||||||
|
): PluginExtensionLink[] {
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
// getPluginLinkExtensions is available in Grafana>=10.0,
|
// getPluginLinkExtensions is available in Grafana>=10.0,
|
||||||
// so will be undefined in earlier versions. Just return an
|
// so will be undefined in earlier versions. Just return an
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
import React, { ReactElement, useMemo } from 'react';
|
import React, { ReactElement, useMemo } from 'react';
|
||||||
|
|
||||||
// Note: `PluginExtensionLink` is available in Grafana>=10.0.
|
import { locationUtil, PluginExtensionLink, PluginExtensionTypes } from '@grafana/data';
|
||||||
// @ts-expect-error
|
import { IconName, Menu } from '@grafana/ui';
|
||||||
import { locationUtil, PluginExtensionLink } from '@grafana/data';
|
|
||||||
import { Menu } from '@grafana/ui';
|
|
||||||
|
|
||||||
import { PluginBridge, SupportedPlugin } from 'components/PluginBridge/PluginBridge';
|
import { PluginBridge, SupportedPlugin } from 'components/PluginBridge/PluginBridge';
|
||||||
import { truncateTitle } from 'utils/string';
|
import { truncateTitle } from 'utils/string';
|
||||||
|
|
@ -48,6 +46,7 @@ function DeclareIncidentMenuItem({ extensions, declareIncidentLink, grafanaIncid
|
||||||
const declareIncidentExtensionLink = extensions.find(
|
const declareIncidentExtensionLink = extensions.find(
|
||||||
(extension) => extension.pluginId === 'grafana-incident-app' && extension.title === 'Declare incident'
|
(extension) => extension.pluginId === 'grafana-incident-app' && extension.title === 'Declare incident'
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
// Don't show a custom Declare incident button if the Grafana Incident plugin already configured one.
|
// Don't show a custom Declare incident button if the Grafana Incident plugin already configured one.
|
||||||
declareIncidentExtensionLink ||
|
declareIncidentExtensionLink ||
|
||||||
|
|
@ -58,29 +57,30 @@ function DeclareIncidentMenuItem({ extensions, declareIncidentLink, grafanaIncid
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PluginBridge plugin={SupportedPlugin.Incident}>
|
<PluginBridge plugin={SupportedPlugin.Incident}>
|
||||||
<Menu.Group key={'Declare incident'} label={'Incident'}>
|
<Menu.Group key={'Declare incident'} label={'Incident'}>
|
||||||
{renderItems([
|
{renderItems([
|
||||||
{
|
{
|
||||||
type: 'link',
|
type: PluginExtensionTypes.link,
|
||||||
path: declareIncidentLink,
|
path: declareIncidentLink,
|
||||||
icon: 'fire',
|
icon: 'fire',
|
||||||
category: 'Incident',
|
category: 'Incident',
|
||||||
title: 'Declare incident',
|
title: 'Declare incident',
|
||||||
pluginId: 'grafana-oncall-app',
|
pluginId: 'grafana-oncall-app',
|
||||||
},
|
} as Partial<PluginExtensionLink>,
|
||||||
])}
|
])}
|
||||||
</Menu.Group>
|
</Menu.Group>
|
||||||
</PluginBridge>
|
</PluginBridge>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderItems(extensions: PluginExtensionLink[]): JSX.Element[] {
|
function renderItems(extensions: Array<Partial<PluginExtensionLink>>): JSX.Element[] {
|
||||||
return extensions.map((extension) => (
|
return extensions.map((extension) => (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
ariaLabel={extension.title}
|
ariaLabel={extension.title}
|
||||||
icon={extension?.icon || 'plug'}
|
icon={(extension?.icon || 'plug') as IconName}
|
||||||
key={extension.id}
|
key={extension.id}
|
||||||
label={truncateTitle(extension.title, 25)}
|
label={truncateTitle(extension.title, 25)}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ const DefaultPageLayout: FC<DefaultPageLayoutProps> = observer((props) => {
|
||||||
|
|
||||||
function renderTopNavbar(): JSX.Element {
|
function renderTopNavbar(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<PluginPage page={page} pageNav={pageNav}>
|
<PluginPage page={page} pageNav={pageNav as any}>
|
||||||
<div className={cx('root')}>{children}</div>
|
<div className={cx('root')}>{children}</div>
|
||||||
</PluginPage>
|
</PluginPage>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
.container {
|
.container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
min-width: 100%;
|
||||||
|
|
||||||
&__box {
|
&__box {
|
||||||
flex-basis: 50%;
|
flex-basis: 50%;
|
||||||
|
|
|
||||||
|
|
@ -4,24 +4,30 @@ import { render, screen, waitFor } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
|
||||||
import { CloudStore } from 'models/cloud/cloud';
|
|
||||||
import { UserStore } from 'models/user/user';
|
|
||||||
import { User } from 'models/user/user.types';
|
import { User } from 'models/user/user.types';
|
||||||
import { RootStore } from 'state';
|
import { rootStore } from 'state';
|
||||||
import { useStore as useStoreOriginal } from 'state/useStore';
|
|
||||||
|
|
||||||
import MobileAppConnection from './MobileAppConnection';
|
import { MobileAppConnection } from './MobileAppConnection';
|
||||||
|
|
||||||
jest.mock('plugin/GrafanaPluginRootPage.helpers', () => ({
|
jest.mock('plugin/GrafanaPluginRootPage.helpers', () => ({
|
||||||
isTopNavbar: () => false,
|
isTopNavbar: () => false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('@grafana/runtime', () => ({
|
jest.mock('@grafana/runtime', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
|
||||||
config: {
|
config: {
|
||||||
featureToggles: {
|
featureToggles: {
|
||||||
topNav: false,
|
topNav: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getBackendSrv: jest.fn().mockImplementation(() => ({
|
||||||
|
get: jest.fn(),
|
||||||
|
post: jest.fn(),
|
||||||
|
})),
|
||||||
|
|
||||||
|
getLocationSrv: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('utils/authorization', () => ({
|
jest.mock('utils/authorization', () => ({
|
||||||
|
|
@ -29,49 +35,50 @@ jest.mock('utils/authorization', () => ({
|
||||||
isUserActionAllowed: jest.fn().mockReturnValue(true),
|
isUserActionAllowed: jest.fn().mockReturnValue(true),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('@grafana/runtime', () => ({
|
|
||||||
getLocationSrv: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('state/useStore');
|
|
||||||
|
|
||||||
const useStore = useStoreOriginal as jest.Mock<ReturnType<typeof useStoreOriginal>>;
|
|
||||||
const loadUserMock = jest.fn().mockReturnValue(undefined);
|
const loadUserMock = jest.fn().mockReturnValue(undefined);
|
||||||
|
|
||||||
const mockUseStore = (rest?: any, connected = false, cloud_connected = true) => {
|
jest.mock('state', () => ({
|
||||||
const store = {
|
rootStore: jest.fn(),
|
||||||
userStore: {
|
}));
|
||||||
loadUser: loadUserMock,
|
|
||||||
currentUser: {
|
|
||||||
messaging_backends: {
|
|
||||||
MOBILE_APP: { connected },
|
|
||||||
},
|
|
||||||
} as unknown as User,
|
|
||||||
...(rest ? rest : {}),
|
|
||||||
} as unknown as UserStore,
|
|
||||||
cloudStore: {
|
|
||||||
getCloudConnectionStatus: jest.fn().mockReturnValue({ cloud_connection_status: cloud_connected }),
|
|
||||||
cloudConnectionStatus: { cloud_connection_status: cloud_connected },
|
|
||||||
} as unknown as CloudStore,
|
|
||||||
hasFeature: jest.fn().mockReturnValue(true),
|
|
||||||
isOpenSource: true,
|
|
||||||
} as unknown as RootStore;
|
|
||||||
|
|
||||||
useStore.mockReturnValue(store);
|
const mockRootStore = (rest?: any, connected = false, cloud_connected = true) => {
|
||||||
|
rootStore.userStore = {
|
||||||
|
loadUser: loadUserMock,
|
||||||
|
currentUser: {
|
||||||
|
messaging_backends: {
|
||||||
|
MOBILE_APP: { connected },
|
||||||
|
},
|
||||||
|
} as unknown as User,
|
||||||
|
...(rest ? rest : {}),
|
||||||
|
};
|
||||||
|
|
||||||
return store;
|
rootStore.cloudStore = {
|
||||||
|
getCloudConnectionStatus: jest.fn().mockReturnValue({ cloud_connection_status: cloud_connected }),
|
||||||
|
cloudConnectionStatus: { cloud_connection_status: cloud_connected },
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
rootStore.isOpenSource = jest.fn().mockReturnValue(true);
|
||||||
|
rootStore.hasFeature = jest.fn().mockReturnValue(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const USER_PK = '8585';
|
const USER_PK = '8585';
|
||||||
const BACKEND = 'MOBILE_APP';
|
const BACKEND = 'MOBILE_APP';
|
||||||
|
|
||||||
|
describe('MobileAppConnection', () => {
|
||||||
|
test('', () => {
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('MobileAppConnection', () => {
|
describe('MobileAppConnection', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
loadUserMock.mockClear();
|
loadUserMock.mockClear();
|
||||||
|
(rootStore as any).mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('it shows a loading message if it is currently fetching the QR code', async () => {
|
test('it shows a loading message if it is currently fetching the QR code', async () => {
|
||||||
const { userStore } = mockUseStore({
|
mockRootStore({
|
||||||
sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dfd'),
|
sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dfd'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -79,29 +86,13 @@ describe('MobileAppConnection', () => {
|
||||||
expect(component.container).toMatchSnapshot();
|
expect(component.container).toMatchSnapshot();
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1);
|
expect(rootStore.userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1);
|
||||||
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND);
|
expect(rootStore.userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND);
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it shows a message when the mobile app is already connected', async () => {
|
|
||||||
const { userStore } = mockUseStore(
|
|
||||||
{
|
|
||||||
sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dfd'),
|
|
||||||
},
|
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
const component = render(<MobileAppConnection userPk={USER_PK} />);
|
|
||||||
expect(component.container).toMatchSnapshot();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(0);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('it shows an error message if there was an error fetching the QR code', async () => {
|
test('it shows an error message if there was an error fetching the QR code', async () => {
|
||||||
const { userStore } = mockUseStore({
|
mockRootStore({
|
||||||
sendBackendConfirmationCode: jest.fn().mockRejectedValueOnce('dfd'),
|
sendBackendConfirmationCode: jest.fn().mockRejectedValueOnce('dfd'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -111,13 +102,13 @@ describe('MobileAppConnection', () => {
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(component.container).toMatchSnapshot();
|
expect(component.container).toMatchSnapshot();
|
||||||
|
|
||||||
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1);
|
expect(rootStore.userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1);
|
||||||
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND);
|
expect(rootStore.userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("it shows a QR code if the app isn't already connected", async () => {
|
test("it shows a QR code if the app isn't already connected", async () => {
|
||||||
const { userStore } = mockUseStore({
|
mockRootStore({
|
||||||
sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dfd'),
|
sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dfd'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -125,13 +116,13 @@ describe('MobileAppConnection', () => {
|
||||||
expect(component.container).toMatchSnapshot();
|
expect(component.container).toMatchSnapshot();
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1);
|
expect(rootStore.userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1);
|
||||||
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND);
|
expect(rootStore.userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('if we disconnect the app, it disconnects and fetches a new QR code', async () => {
|
test('if we disconnect the app, it disconnects and fetches a new QR code', async () => {
|
||||||
const { userStore } = mockUseStore(
|
mockRootStore(
|
||||||
{
|
{
|
||||||
sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dfd'),
|
sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dfd'),
|
||||||
unlinkBackend: jest.fn().mockResolvedValueOnce('asdfadsfafds'),
|
unlinkBackend: jest.fn().mockResolvedValueOnce('asdfadsfafds'),
|
||||||
|
|
@ -150,16 +141,16 @@ describe('MobileAppConnection', () => {
|
||||||
expect(component.container).toMatchSnapshot();
|
expect(component.container).toMatchSnapshot();
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1);
|
expect(rootStore.userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1);
|
||||||
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND);
|
expect(rootStore.userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND);
|
||||||
|
|
||||||
expect(userStore.unlinkBackend).toHaveBeenCalledTimes(1);
|
expect(rootStore.userStore.unlinkBackend).toHaveBeenCalledTimes(1);
|
||||||
expect(userStore.unlinkBackend).toHaveBeenCalledWith(USER_PK, BACKEND);
|
expect(rootStore.userStore.unlinkBackend).toHaveBeenCalledWith(USER_PK, BACKEND);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('it shows a loading message if it is currently disconnecting', async () => {
|
test('it shows a loading message if it is currently disconnecting', async () => {
|
||||||
const { userStore } = mockUseStore(
|
mockRootStore(
|
||||||
{
|
{
|
||||||
sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dfd'),
|
sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dfd'),
|
||||||
unlinkBackend: jest.fn().mockResolvedValueOnce(new Promise((resolve) => setTimeout(resolve, 500))),
|
unlinkBackend: jest.fn().mockResolvedValueOnce(new Promise((resolve) => setTimeout(resolve, 500))),
|
||||||
|
|
@ -181,16 +172,16 @@ describe('MobileAppConnection', () => {
|
||||||
expect(component.container).toMatchSnapshot();
|
expect(component.container).toMatchSnapshot();
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1);
|
expect(rootStore.userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1);
|
||||||
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND);
|
expect(rootStore.userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND);
|
||||||
|
|
||||||
expect(userStore.unlinkBackend).toHaveBeenCalledTimes(1);
|
expect(rootStore.userStore.unlinkBackend).toHaveBeenCalledTimes(1);
|
||||||
expect(userStore.unlinkBackend).toHaveBeenCalledWith(USER_PK, BACKEND);
|
expect(rootStore.userStore.unlinkBackend).toHaveBeenCalledWith(USER_PK, BACKEND);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('it shows an error message if there was an error disconnecting the mobile app', async () => {
|
test('it shows an error message if there was an error disconnecting the mobile app', async () => {
|
||||||
const { userStore } = mockUseStore(
|
mockRootStore(
|
||||||
{
|
{
|
||||||
sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dfd'),
|
sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dfd'),
|
||||||
unlinkBackend: jest.fn().mockRejectedValueOnce('asdfadsfafds'),
|
unlinkBackend: jest.fn().mockRejectedValueOnce('asdfadsfafds'),
|
||||||
|
|
@ -211,15 +202,15 @@ describe('MobileAppConnection', () => {
|
||||||
expect(component.container).toMatchSnapshot();
|
expect(component.container).toMatchSnapshot();
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(0);
|
expect(rootStore.userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(0);
|
||||||
|
|
||||||
expect(userStore.unlinkBackend).toHaveBeenCalledTimes(1);
|
expect(rootStore.userStore.unlinkBackend).toHaveBeenCalledTimes(1);
|
||||||
expect(userStore.unlinkBackend).toHaveBeenCalledWith(USER_PK, BACKEND);
|
expect(rootStore.userStore.unlinkBackend).toHaveBeenCalledWith(USER_PK, BACKEND);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('it polls loadUser on first render if not connected', async () => {
|
test('it polls loadUser on first render if not connected', async () => {
|
||||||
mockUseStore(
|
mockRootStore(
|
||||||
{
|
{
|
||||||
sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dfd'),
|
sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dfd'),
|
||||||
unlinkBackend: jest.fn().mockRejectedValueOnce('asdfadsfafds'),
|
unlinkBackend: jest.fn().mockRejectedValueOnce('asdfadsfafds'),
|
||||||
|
|
@ -238,7 +229,7 @@ describe('MobileAppConnection', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('it polls loadUser after disconnect', async () => {
|
test('it polls loadUser after disconnect', async () => {
|
||||||
mockUseStore(
|
mockRootStore(
|
||||||
{
|
{
|
||||||
sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dff'),
|
sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dff'),
|
||||||
unlinkBackend: jest.fn().mockRejectedValueOnce('asdff'),
|
unlinkBackend: jest.fn().mockRejectedValueOnce('asdff'),
|
||||||
|
|
@ -263,7 +254,7 @@ describe('MobileAppConnection', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('it shows a warning when cloud is not connected', async () => {
|
test('it shows a warning when cloud is not connected', async () => {
|
||||||
mockUseStore({}, true, false);
|
mockRootStore({}, true, false);
|
||||||
|
|
||||||
// Using MemoryRouter to avoid "Invariant failed: You should not use <Link> outside a <Router>"
|
// Using MemoryRouter to avoid "Invariant failed: You should not use <Link> outside a <Router>"
|
||||||
const component = render(
|
const component = render(
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ import PluginLink from 'components/PluginLink/PluginLink';
|
||||||
import Text from 'components/Text/Text';
|
import Text from 'components/Text/Text';
|
||||||
import { WithPermissionControlDisplay } from 'containers/WithPermissionControl/WithPermissionControlDisplay';
|
import { WithPermissionControlDisplay } from 'containers/WithPermissionControl/WithPermissionControlDisplay';
|
||||||
import { User } from 'models/user/user.types';
|
import { User } from 'models/user/user.types';
|
||||||
|
import { RootStore, rootStore as store } from 'state';
|
||||||
import { AppFeature } from 'state/features';
|
import { AppFeature } from 'state/features';
|
||||||
import { useStore } from 'state/useStore';
|
|
||||||
import { openErrorNotification, openNotification, openWarningNotification } from 'utils';
|
import { openErrorNotification, openNotification, openWarningNotification } from 'utils';
|
||||||
import { UserActions } from 'utils/authorization';
|
import { UserActions } from 'utils/authorization';
|
||||||
|
|
||||||
|
|
@ -23,7 +23,8 @@ import QRCode from './parts/QRCode/QRCode';
|
||||||
const cx = cn.bind(styles);
|
const cx = cn.bind(styles);
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
userPk: User['pk'];
|
userPk?: User['pk'];
|
||||||
|
store?: RootStore;
|
||||||
};
|
};
|
||||||
|
|
||||||
const INTERVAL_MIN_THROTTLING = 500;
|
const INTERVAL_MIN_THROTTLING = 500;
|
||||||
|
|
@ -36,31 +37,10 @@ const INTERVAL_QUEUE_QR = 290_000;
|
||||||
const INTERVAL_POLLING = 5000;
|
const INTERVAL_POLLING = 5000;
|
||||||
const BACKEND = 'MOBILE_APP';
|
const BACKEND = 'MOBILE_APP';
|
||||||
|
|
||||||
const MobileAppConnection = observer(({ userPk }: Props) => {
|
export const MobileAppConnection = observer(({ userPk }: Props) => {
|
||||||
const store = useStore();
|
|
||||||
const { userStore, cloudStore } = store;
|
const { userStore, cloudStore } = store;
|
||||||
|
|
||||||
// Show link to cloud page for OSS instances with no cloud connection
|
const [basicDataLoaded, setBasicDataLoaded] = useState(false);
|
||||||
if (store.hasFeature(AppFeature.CloudConnection) && !cloudStore.cloudConnectionStatus.cloud_connection_status) {
|
|
||||||
return (
|
|
||||||
<WithPermissionControlDisplay userAction={UserActions.UserSettingsWrite}>
|
|
||||||
<VerticalGroup spacing="lg">
|
|
||||||
<Text type="secondary">Please connect Grafana Cloud OnCall to use the mobile app</Text>
|
|
||||||
<WithPermissionControlDisplay
|
|
||||||
userAction={UserActions.OtherSettingsWrite}
|
|
||||||
message="You do not have permission to perform this action. Ask an admin to connect Grafana Cloud OnCall or upgrade your
|
|
||||||
permissions."
|
|
||||||
>
|
|
||||||
<PluginLink query={{ page: 'cloud' }}>
|
|
||||||
<Button variant="secondary" icon="external-link-alt">
|
|
||||||
Connect Grafana Cloud OnCall
|
|
||||||
</Button>
|
|
||||||
</PluginLink>
|
|
||||||
</WithPermissionControlDisplay>
|
|
||||||
</VerticalGroup>
|
|
||||||
</WithPermissionControlDisplay>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isMounted = useRef(false);
|
const isMounted = useRef(false);
|
||||||
const [mobileAppIsCurrentlyConnected, setMobileAppIsCurrentlyConnected] = useState<boolean>(isUserConnected());
|
const [mobileAppIsCurrentlyConnected, setMobileAppIsCurrentlyConnected] = useState<boolean>(isUserConnected());
|
||||||
|
|
@ -75,10 +55,34 @@ const MobileAppConnection = observer(({ userPk }: Props) => {
|
||||||
const [refreshTimeoutId, setRefreshTimeoutId] = useState<NodeJS.Timeout>(undefined);
|
const [refreshTimeoutId, setRefreshTimeoutId] = useState<NodeJS.Timeout>(undefined);
|
||||||
const [isQRBlurry, setIsQRBlurry] = useState<boolean>(false);
|
const [isQRBlurry, setIsQRBlurry] = useState<boolean>(false);
|
||||||
const [isAttemptingTestNotification, setIsAttemptingTestNotification] = useState(false);
|
const [isAttemptingTestNotification, setIsAttemptingTestNotification] = useState(false);
|
||||||
const isCurrentUser = userStore.currentUserPk === userPk;
|
const isCurrentUser = userPk === undefined || userStore.currentUserPk === userPk;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isMounted.current = true;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
if (!isUserConnected()) {
|
||||||
|
triggerTimeouts();
|
||||||
|
} else {
|
||||||
|
setMobileAppIsCurrentlyConnected(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
setBasicDataLoaded(true);
|
||||||
|
})();
|
||||||
|
|
||||||
|
// clear on unmount
|
||||||
|
return () => {
|
||||||
|
isMounted.current = false;
|
||||||
|
clearTimeouts();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const fetchQRCode = useCallback(
|
const fetchQRCode = useCallback(
|
||||||
async (showLoader = true) => {
|
async (showLoader = true) => {
|
||||||
|
if (!userPk) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (showLoader) {
|
if (showLoader) {
|
||||||
setFetchingQRCode(true);
|
setFetchingQRCode(true);
|
||||||
}
|
}
|
||||||
|
|
@ -105,6 +109,9 @@ const MobileAppConnection = observer(({ userPk }: Props) => {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const disconnectMobileApp = useCallback(async () => {
|
const disconnectMobileApp = useCallback(async () => {
|
||||||
|
if (!userPk) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setDisconnectingMobileApp(true);
|
setDisconnectingMobileApp(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -119,29 +126,24 @@ const MobileAppConnection = observer(({ userPk }: Props) => {
|
||||||
triggerTimeouts();
|
triggerTimeouts();
|
||||||
}, [userPk, resetState]);
|
}, [userPk, resetState]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
isMounted.current = true;
|
|
||||||
|
|
||||||
if (!isUserConnected()) {
|
|
||||||
triggerTimeouts();
|
|
||||||
}
|
|
||||||
|
|
||||||
// clear on unmount
|
|
||||||
return () => {
|
|
||||||
isMounted.current = false;
|
|
||||||
clearTimeouts();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mobileAppIsCurrentlyConnected) {
|
if (!mobileAppIsCurrentlyConnected) {
|
||||||
fetchQRCode();
|
fetchQRCode();
|
||||||
}
|
}
|
||||||
}, [mobileAppIsCurrentlyConnected]);
|
}, [mobileAppIsCurrentlyConnected, userPk]);
|
||||||
|
|
||||||
|
// Show link to cloud page for OSS instances with no cloud connection
|
||||||
|
if (
|
||||||
|
store.isOpenSource &&
|
||||||
|
store.hasFeature(AppFeature.CloudConnection) &&
|
||||||
|
!cloudStore.cloudConnectionStatus.cloud_connection_status
|
||||||
|
) {
|
||||||
|
return renderConnectToCloud();
|
||||||
|
}
|
||||||
|
|
||||||
let content: React.ReactNode = null;
|
let content: React.ReactNode = null;
|
||||||
|
|
||||||
if (fetchingQRCode || disconnectingMobileApp) {
|
if (fetchingQRCode || disconnectingMobileApp || !userPk || !basicDataLoaded) {
|
||||||
content = <LoadingPlaceholder text="Loading..." />;
|
content = <LoadingPlaceholder text="Loading..." />;
|
||||||
} else if (errorFetchingQRCode || errorDisconnectingMobileApp) {
|
} else if (errorFetchingQRCode || errorDisconnectingMobileApp) {
|
||||||
content = <Text type="primary">{errorFetchingQRCode || errorDisconnectingMobileApp}</Text>;
|
content = <Text type="primary">{errorFetchingQRCode || errorDisconnectingMobileApp}</Text>;
|
||||||
|
|
@ -199,7 +201,7 @@ const MobileAppConnection = observer(({ userPk }: Props) => {
|
||||||
{content}
|
{content}
|
||||||
</Block>
|
</Block>
|
||||||
</div>
|
</div>
|
||||||
{mobileAppIsCurrentlyConnected && isCurrentUser && (
|
{mobileAppIsCurrentlyConnected && isCurrentUser && !disconnectingMobileApp && (
|
||||||
<div className={cx('notification-buttons')}>
|
<div className={cx('notification-buttons')}>
|
||||||
<HorizontalGroup spacing={'md'} justify={'flex-end'}>
|
<HorizontalGroup spacing={'md'} justify={'flex-end'}>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -222,7 +224,31 @@ const MobileAppConnection = observer(({ userPk }: Props) => {
|
||||||
</VerticalGroup>
|
</VerticalGroup>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function renderConnectToCloud() {
|
||||||
|
return (
|
||||||
|
<WithPermissionControlDisplay userAction={UserActions.UserSettingsWrite}>
|
||||||
|
<VerticalGroup spacing="lg">
|
||||||
|
<Text type="secondary">Please connect Grafana Cloud OnCall to use the mobile app</Text>
|
||||||
|
<WithPermissionControlDisplay
|
||||||
|
userAction={UserActions.OtherSettingsWrite}
|
||||||
|
message="You do not have permission to perform this action. Ask an admin to connect Grafana Cloud OnCall or upgrade your
|
||||||
|
permissions."
|
||||||
|
>
|
||||||
|
<PluginLink query={{ page: 'cloud' }}>
|
||||||
|
<Button variant="secondary" icon="external-link-alt">
|
||||||
|
Connect Grafana Cloud OnCall
|
||||||
|
</Button>
|
||||||
|
</PluginLink>
|
||||||
|
</WithPermissionControlDisplay>
|
||||||
|
</VerticalGroup>
|
||||||
|
</WithPermissionControlDisplay>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function onSendTestNotification(isCritical = false) {
|
async function onSendTestNotification(isCritical = false) {
|
||||||
|
if (!userPk) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setIsAttemptingTestNotification(true);
|
setIsAttemptingTestNotification(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -258,11 +284,11 @@ const MobileAppConnection = observer(({ userPk }: Props) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
function isUserConnected(user?: User): boolean {
|
function isUserConnected(user?: User): boolean {
|
||||||
return !!(user || userStore.currentUser).messaging_backends[BACKEND]?.connected;
|
return !!(user || userStore.currentUser)?.messaging_backends[BACKEND]?.connected;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function queueRefreshQR(): Promise<void> {
|
async function queueRefreshQR(): Promise<void> {
|
||||||
if (!isMounted.current) {
|
if (!isMounted.current || !userPk) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -300,7 +326,7 @@ const MobileAppConnection = observer(({ userPk }: Props) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pollUserProfile(): Promise<void> {
|
async function pollUserProfile(): Promise<void> {
|
||||||
if (!isMounted.current) {
|
if (!isMounted.current || !userPk) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -327,4 +353,26 @@ function QRLoading() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default MobileAppConnection;
|
export const MobileAppConnectionWrapper: React.FC<{}> = observer(() => {
|
||||||
|
const { userStore } = store;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
if (!store.isBasicDataLoaded) {
|
||||||
|
await store.loadBasicData();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userStore.currentUserPk) {
|
||||||
|
await userStore.loadCurrentUser();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (store.isBasicDataLoaded && userStore.currentUserPk) {
|
||||||
|
return <MobileAppConnection userPk={userStore.currentUserPk} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <LoadingPlaceholder text="Loading..." />;
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { observer } from 'mobx-react';
|
||||||
|
|
||||||
|
import Text from 'components/Text/Text';
|
||||||
|
|
||||||
|
const MobileAppConnectionTab: React.FC<{ userPk?: string }> = observer(() => {
|
||||||
|
return (
|
||||||
|
<Text type="secondary">
|
||||||
|
Mobile settings have been moved to{' '}
|
||||||
|
<a href={`${window.location.origin}/profile?tab=irm`}>
|
||||||
|
<Text type="link">user's profile</Text>
|
||||||
|
</a>
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export { MobileAppConnectionTab };
|
||||||
|
|
@ -528,163 +528,6 @@ exports[`MobileAppConnection it shows a loading message if it is currently fetch
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`MobileAppConnection it shows a message when the mobile app is already connected 1`] = `
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
class="css-gjl87o-vertical-group"
|
|
||||||
style="width: 100%; height: 100%;"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="css-gxt817-layoutChildrenWrapper"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="container"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="root root_bordered root_shadowed root--withBackGround container__box"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="css-gjl87o-vertical-group"
|
|
||||||
style="width: 100%; height: 100%;"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="css-12oo3x0-layoutChildrenWrapper"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="root text text--primary text--medium text--strong"
|
|
||||||
>
|
|
||||||
Download
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="css-12oo3x0-layoutChildrenWrapper"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="root text text--primary text--medium"
|
|
||||||
>
|
|
||||||
The Grafana OnCall app is available on both the App Store and Google Play Store.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="css-12oo3x0-layoutChildrenWrapper"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="css-gjl87o-vertical-group"
|
|
||||||
style="width: 100%; height: 100%;"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="css-gxt817-layoutChildrenWrapper"
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href="https://apps.apple.com/us/app/grafana-oncall-preview/id1669759048"
|
|
||||||
rel="noreferrer"
|
|
||||||
style="width: 100%;"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="root root_bordered root--fullWidth root--withBackGround root--hover icon-block"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
alt="Apple"
|
|
||||||
class="icon"
|
|
||||||
src="[object Object]"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
class="root text text--primary text--medium icon-text"
|
|
||||||
>
|
|
||||||
iOS
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="css-gxt817-layoutChildrenWrapper"
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href="https://play.google.com/store/apps/details?id=com.grafana.oncall.prod"
|
|
||||||
rel="noreferrer"
|
|
||||||
style="width: 100%;"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="root root_bordered root--fullWidth root--hover icon-block"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
alt="Play Store"
|
|
||||||
class="icon"
|
|
||||||
src="[object Object]"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
class="root text text--primary text--medium icon-text"
|
|
||||||
>
|
|
||||||
Android
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="root root_bordered root_shadowed root--withBackGround container__box"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="css-gjl87o-vertical-group"
|
|
||||||
style="width: 100%; height: 100%;"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="css-12oo3x0-layoutChildrenWrapper"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="root text text--primary text--medium text--strong"
|
|
||||||
>
|
|
||||||
App connected
|
|
||||||
<div
|
|
||||||
class="css-1j2891d-Icon"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="css-12oo3x0-layoutChildrenWrapper"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="root text text--primary text--medium"
|
|
||||||
>
|
|
||||||
You can only sync one application to your account. To setup a new device, please disconnect the currently connected device first.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="css-12oo3x0-layoutChildrenWrapper"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="disconnect__container"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
class="disconnect__qrCode"
|
|
||||||
src="[object Object]"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
class="css-ttl745-button disconnect-button"
|
|
||||||
data-testid="test__disconnect"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="css-1riaxdn"
|
|
||||||
>
|
|
||||||
Disconnect
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`MobileAppConnection it shows a warning when cloud is not connected 1`] = `
|
exports[`MobileAppConnection it shows a warning when cloud is not connected 1`] = `
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
import React, { FC } from 'react';
|
||||||
|
|
||||||
|
import { Legend } from '@grafana/ui';
|
||||||
|
|
||||||
|
import { UserSettingsTab } from 'containers/UserSettings/UserSettings.types';
|
||||||
|
import { User } from 'models/user/user.types';
|
||||||
|
import { AppFeature } from 'state/features';
|
||||||
|
import { useStore } from 'state/useStore';
|
||||||
|
|
||||||
|
import ICalConnector from './ICalConnector';
|
||||||
|
// import MobileAppConnector from './MobileAppConnector';
|
||||||
|
import MobileAppConnector from './MobileAppConnector';
|
||||||
|
import PhoneConnector from './PhoneConnector';
|
||||||
|
import SlackConnector from './SlackConnector';
|
||||||
|
import TelegramConnector from './TelegramConnector';
|
||||||
|
|
||||||
|
interface ConnectorsProps {
|
||||||
|
id: User['pk'];
|
||||||
|
onTabChange: (tab: UserSettingsTab) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Connectors: FC<ConnectorsProps> = (props) => {
|
||||||
|
const store = useStore();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PhoneConnector {...props} />
|
||||||
|
<MobileAppConnector {...props} />
|
||||||
|
<SlackConnector {...props} />
|
||||||
|
{store.hasFeature(AppFeature.Telegram) && <TelegramConnector {...props} />}
|
||||||
|
<Legend>Calendar export</Legend>
|
||||||
|
<ICalConnector {...props} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -5,7 +5,8 @@ import cn from 'classnames/bind';
|
||||||
import { observer } from 'mobx-react';
|
import { observer } from 'mobx-react';
|
||||||
|
|
||||||
import Block from 'components/GBlock/Block';
|
import Block from 'components/GBlock/Block';
|
||||||
import MobileAppConnection from 'containers/MobileAppConnection/MobileAppConnection';
|
import { MobileAppConnection } from 'containers/MobileAppConnection/MobileAppConnection';
|
||||||
|
import { MobileAppConnectionTab } from 'containers/MobileAppConnection/MobileAppConnectionTab';
|
||||||
import { UserSettingsTab } from 'containers/UserSettings/UserSettings.types';
|
import { UserSettingsTab } from 'containers/UserSettings/UserSettings.types';
|
||||||
import { SlackTab } from 'containers/UserSettings/parts/tabs//SlackTab/SlackTab';
|
import { SlackTab } from 'containers/UserSettings/parts/tabs//SlackTab/SlackTab';
|
||||||
import CloudPhoneSettings from 'containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings';
|
import CloudPhoneSettings from 'containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings';
|
||||||
|
|
@ -17,6 +18,7 @@ import { UserInfoTab } from 'containers/UserSettings/parts/tabs/UserInfoTab/User
|
||||||
import { User } from 'models/user/user.types';
|
import { User } from 'models/user/user.types';
|
||||||
import { AppFeature } from 'state/features';
|
import { AppFeature } from 'state/features';
|
||||||
import { useStore } from 'state/useStore';
|
import { useStore } from 'state/useStore';
|
||||||
|
import { isUseProfileExtensionPointEnabled } from 'utils';
|
||||||
|
|
||||||
import styles from 'containers/UserSettings/parts/index.module.css';
|
import styles from 'containers/UserSettings/parts/index.module.css';
|
||||||
|
|
||||||
|
|
@ -151,10 +153,18 @@ export const TabsContent = observer(({ id, activeTab, onTabChange, isDesktopOrLa
|
||||||
) : (
|
) : (
|
||||||
<PhoneVerification userPk={id} />
|
<PhoneVerification userPk={id} />
|
||||||
))}
|
))}
|
||||||
{activeTab === UserSettingsTab.MobileAppConnection && <MobileAppConnection userPk={id} />}
|
{activeTab === UserSettingsTab.MobileAppConnection && renderMobileTab()}
|
||||||
{activeTab === UserSettingsTab.SlackInfo && <SlackTab />}
|
{activeTab === UserSettingsTab.SlackInfo && <SlackTab />}
|
||||||
{activeTab === UserSettingsTab.TelegramInfo && <TelegramInfo />}
|
{activeTab === UserSettingsTab.TelegramInfo && <TelegramInfo />}
|
||||||
{activeTab === UserSettingsTab.MSTeamsInfo && <MSTeamsInfo />}
|
{activeTab === UserSettingsTab.MSTeamsInfo && <MSTeamsInfo />}
|
||||||
</TabContent>
|
</TabContent>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function renderMobileTab() {
|
||||||
|
if (!isUseProfileExtensionPointEnabled()) {
|
||||||
|
return <MobileAppConnection userPk={id} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <MobileAppConnectionTab userPk={id} />;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { InlineField, Input, Legend } from '@grafana/ui';
|
||||||
|
|
||||||
import GrafanaTeamSelect from 'containers/GrafanaTeamSelect/GrafanaTeamSelect';
|
import GrafanaTeamSelect from 'containers/GrafanaTeamSelect/GrafanaTeamSelect';
|
||||||
import { UserSettingsTab } from 'containers/UserSettings/UserSettings.types';
|
import { UserSettingsTab } from 'containers/UserSettings/UserSettings.types';
|
||||||
import { Connectors } from 'containers/UserSettings/parts/connectors';
|
import { Connectors } from 'containers/UserSettings/parts/connectors/Connectors';
|
||||||
import { User } from 'models/user/user.types';
|
import { User } from 'models/user/user.types';
|
||||||
import { useStore } from 'state/useStore';
|
import { useStore } from 'state/useStore';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,47 @@
|
||||||
import { ComponentClass } from 'react';
|
import { ComponentClass } from 'react';
|
||||||
|
|
||||||
import { AppPlugin } from '@grafana/data';
|
import { AppPlugin, PluginExtensionPoints } from '@grafana/data';
|
||||||
|
|
||||||
|
import { MobileAppConnectionWrapper } from 'containers/MobileAppConnection/MobileAppConnection';
|
||||||
import PluginConfigPage from 'containers/PluginConfigPage/PluginConfigPage';
|
import PluginConfigPage from 'containers/PluginConfigPage/PluginConfigPage';
|
||||||
import { GrafanaPluginRootPage } from 'plugin/GrafanaPluginRootPage';
|
import { GrafanaPluginRootPage } from 'plugin/GrafanaPluginRootPage';
|
||||||
|
import { getGrafanaVersion } from 'plugin/GrafanaPluginRootPage.helpers';
|
||||||
|
import { IRM_TAB } from 'utils/consts';
|
||||||
|
|
||||||
import { OnCallPluginConfigPageProps, OnCallPluginMetaJSONData } from './types';
|
import { OnCallPluginConfigPageProps, OnCallPluginMetaJSONData } from './types';
|
||||||
|
|
||||||
export const plugin = new AppPlugin<OnCallPluginMetaJSONData>().setRootPage(GrafanaPluginRootPage).addConfigPage({
|
const plugin = new AppPlugin<OnCallPluginMetaJSONData>().setRootPage(GrafanaPluginRootPage).addConfigPage({
|
||||||
title: 'Configuration',
|
title: 'Configuration',
|
||||||
icon: 'cog',
|
icon: 'cog',
|
||||||
body: PluginConfigPage as unknown as ComponentClass<OnCallPluginConfigPageProps, unknown>,
|
body: PluginConfigPage as unknown as ComponentClass<OnCallPluginConfigPageProps, unknown>,
|
||||||
id: 'configuration',
|
id: 'configuration',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (isUseProfileExtensionPointEnabled()) {
|
||||||
|
const extensionPointId = PluginExtensionPoints.UserProfileTab;
|
||||||
|
|
||||||
|
plugin.configureExtensionComponent({
|
||||||
|
title: IRM_TAB,
|
||||||
|
description: 'IRM settings',
|
||||||
|
extensionPointId,
|
||||||
|
/**
|
||||||
|
* typing MobileAppConnectionWrapper as any until 10.2.0 is released
|
||||||
|
* https://github.com/grafana/grafana/pull/75019#issuecomment-1724997540
|
||||||
|
*/
|
||||||
|
component: MobileAppConnectionWrapper,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUseProfileExtensionPointEnabled(): boolean {
|
||||||
|
const { major, minor } = getGrafanaVersion();
|
||||||
|
const isRequiredGrafanaVersion = major > 10 || (major === 10 && minor >= 3); // >= 10.3.0
|
||||||
|
|
||||||
|
return (
|
||||||
|
isRequiredGrafanaVersion &&
|
||||||
|
'configureExtensionComponent' in plugin &&
|
||||||
|
PluginExtensionPoints != null &&
|
||||||
|
'UserProfileTab' in PluginExtensionPoints
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { plugin };
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { IconName } from '@grafana/data';
|
import { IconName, Tab, TabsBar } from '@grafana/ui';
|
||||||
import { Tab, TabsBar } from '@grafana/ui';
|
|
||||||
import cn from 'classnames/bind';
|
import cn from 'classnames/bind';
|
||||||
|
|
||||||
import { pages } from 'pages';
|
import { pages } from 'pages';
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,6 @@ import { Timezone } from 'models/timezone/timezone.types';
|
||||||
import { RootStore } from 'state';
|
import { RootStore } from 'state';
|
||||||
import { SelectOption } from 'state/types';
|
import { SelectOption } from 'state/types';
|
||||||
|
|
||||||
const mondayDayOffset = {
|
|
||||||
saturday: -2,
|
|
||||||
sunday: -1,
|
|
||||||
monday: 0,
|
|
||||||
browser: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getWeekStartString = () => {
|
export const getWeekStartString = () => {
|
||||||
const weekStart = (config?.bootData?.user?.weekStart || '').toLowerCase();
|
const weekStart = (config?.bootData?.user?.weekStart || '').toLowerCase();
|
||||||
|
|
||||||
|
|
@ -35,15 +28,11 @@ export const getStartOfDay = (tz: Timezone) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getStartOfWeek = (tz: Timezone) => {
|
export const getStartOfWeek = (tz: Timezone) => {
|
||||||
return getNow(tz)
|
return getNow(tz).startOf('isoWeek'); // it's Monday always
|
||||||
.startOf('isoWeek') // it's Monday always
|
|
||||||
.add(mondayDayOffset[getWeekStartString()], 'day');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getStartOfWeekBasedOnCurrentDate = (date: dayjs.Dayjs) => {
|
export const getStartOfWeekBasedOnCurrentDate = (date: dayjs.Dayjs) => {
|
||||||
return date
|
return date.startOf('isoWeek'); // it's Monday always
|
||||||
.startOf('isoWeek') // it's Monday always
|
|
||||||
.add(mondayDayOffset[getWeekStartString()], 'day');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getUTCString = (moment: dayjs.Dayjs) => {
|
export const getUTCString = (moment: dayjs.Dayjs) => {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
"type": "app",
|
"type": "app",
|
||||||
"name": "Grafana OnCall",
|
"name": "Grafana OnCall",
|
||||||
"id": "grafana-oncall-app",
|
"id": "grafana-oncall-app",
|
||||||
|
"preload": true,
|
||||||
"info": {
|
"info": {
|
||||||
"description": "Collect and analyze alerts, escalate based on schedules and deliver them to Slack, Phone Calls, SMS and others.",
|
"description": "Collect and analyze alerts, escalate based on schedules and deliver them to Slack, Phone Calls, SMS and others.",
|
||||||
"author": {
|
"author": {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
import * as runtime from '@grafana/runtime';
|
||||||
|
|
||||||
|
import { getGrafanaVersion } from './GrafanaPluginRootPage.helpers';
|
||||||
|
|
||||||
|
jest.mock('@grafana/runtime', () => ({
|
||||||
|
config: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('GrafanaPluginRootPage.helpers', () => {
|
||||||
|
function setGrafanaVersion(version: string) {
|
||||||
|
runtime.config.buildInfo = {
|
||||||
|
version,
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('It figures out grafana version from string', () => {
|
||||||
|
setGrafanaVersion('10.13.95-9.0.1.1test');
|
||||||
|
|
||||||
|
const { major, minor, patch } = getGrafanaVersion();
|
||||||
|
|
||||||
|
expect(major).toBe(10);
|
||||||
|
expect(minor).toBe(13);
|
||||||
|
expect(patch).toBe(95);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('It figures out grafana version for v9', () => {
|
||||||
|
setGrafanaVersion('9.04.3105-rctest100');
|
||||||
|
|
||||||
|
const { major, minor, patch } = getGrafanaVersion();
|
||||||
|
|
||||||
|
expect(major).toBe(9);
|
||||||
|
expect(minor).toBe(4);
|
||||||
|
expect(patch).toBe(3105);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('It figures out grafana version for 1.0.0', () => {
|
||||||
|
setGrafanaVersion('1.0.0-any-asd-value');
|
||||||
|
|
||||||
|
const { major, minor, patch } = getGrafanaVersion();
|
||||||
|
|
||||||
|
expect(major).toBe(1);
|
||||||
|
expect(minor).toBe(0);
|
||||||
|
expect(patch).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -4,6 +4,21 @@ export function isTopNavbar(): boolean {
|
||||||
return !!config.featureToggles.topnav;
|
return !!config.featureToggles.topnav;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getGrafanaVersion(): { major?: number; minor?: number; patch?: number } {
|
||||||
|
const regex = /^([1-9]?[0-9]*)\.([1-9]?[0-9]*)\.([1-9]?[0-9]*)/;
|
||||||
|
const match = config.buildInfo.version.match(regex);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
return {
|
||||||
|
major: Number(match[1]),
|
||||||
|
minor: Number(match[2]),
|
||||||
|
patch: Number(match[3]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
export function getQueryParams(): any {
|
export function getQueryParams(): any {
|
||||||
const searchParams = new URLSearchParams(window.location.search);
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
const result = {};
|
const result = {};
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
|
|
||||||
import './dayjs';
|
|
||||||
|
|
||||||
import { LoadingPlaceholder } from '@grafana/ui';
|
import { LoadingPlaceholder } from '@grafana/ui';
|
||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
import { observer, Provider } from 'mobx-react';
|
import { observer, Provider } from 'mobx-react';
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,11 @@
|
||||||
|
/*
|
||||||
|
* Important!
|
||||||
|
Make sure import of plugin/dayjs is placed in a proper location
|
||||||
|
Otherwise the dayjs extenders won't be called and the dayjs functionality will be altered, thus leading to all sort of bugs
|
||||||
|
*/
|
||||||
|
|
||||||
|
import 'plugin/dayjs';
|
||||||
|
|
||||||
import { RootBaseStore } from './rootBaseStore';
|
import { RootBaseStore } from './rootBaseStore';
|
||||||
|
|
||||||
export class RootStore extends RootBaseStore {}
|
export class RootStore extends RootBaseStore {}
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ const roleMapping: Record<OrgRole, number> = {
|
||||||
[OrgRole.Admin]: 0,
|
[OrgRole.Admin]: 0,
|
||||||
[OrgRole.Editor]: 1,
|
[OrgRole.Editor]: 1,
|
||||||
[OrgRole.Viewer]: 2,
|
[OrgRole.Viewer]: 2,
|
||||||
|
[OrgRole.None]: 3,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -66,3 +66,4 @@ export enum PAGE {
|
||||||
export const TEXT_ELLIPSIS_CLASS = 'overflow-child';
|
export const TEXT_ELLIPSIS_CLASS = 'overflow-child';
|
||||||
|
|
||||||
export const INCIDENT_HORIZONTAL_SCROLLING_STORAGE = 'isIncidentalTableHorizontalScrolling';
|
export const INCIDENT_HORIZONTAL_SCROLLING_STORAGE = 'isIncidentalTableHorizontalScrolling';
|
||||||
|
export const IRM_TAB = 'IRM';
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import appEvents from 'grafana/app/core/app_events';
|
||||||
import { isArray, concat, isPlainObject, flatMap, map, keys } from 'lodash-es';
|
import { isArray, concat, isPlainObject, flatMap, map, keys } from 'lodash-es';
|
||||||
|
|
||||||
import { isNetworkError } from 'network';
|
import { isNetworkError } from 'network';
|
||||||
|
import { getGrafanaVersion } from 'plugin/GrafanaPluginRootPage.helpers';
|
||||||
|
|
||||||
export class KeyValuePair<T = string | number> {
|
export class KeyValuePair<T = string | number> {
|
||||||
key: T;
|
key: T;
|
||||||
|
|
@ -95,3 +96,10 @@ export function getPaths(obj?: any, parentKey?: string): string[] {
|
||||||
export function pluralize(word: string, count: number): string {
|
export function pluralize(word: string, count: number): string {
|
||||||
return count === 1 ? word : `${word}s`;
|
return count === 1 ? word : `${word}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isUseProfileExtensionPointEnabled(): boolean {
|
||||||
|
const { major, minor } = getGrafanaVersion();
|
||||||
|
const isRequiredGrafanaVersion = major > 10 || (major === 10 && minor >= 3);
|
||||||
|
|
||||||
|
return isRequiredGrafanaVersion;
|
||||||
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue