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:
Joey Orlando 2024-01-10 06:59:44 -05:00 committed by GitHub
parent f20aa75869
commit 616d474e59
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 581 additions and 1076 deletions

View file

@ -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))
- 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

View file

@ -1,5 +1,12 @@
import { OrgRole } from '@grafana/data';
import { test as setup, chromium, expect, Page, BrowserContext, FullConfig, APIRequestContext } from '@playwright/test';
import {
test as setup,
chromium,
expect,
type Page,
type BrowserContext,
type FullConfig,
type APIRequestContext,
} from '@playwright/test';
import { getOnCallApiUrl } from 'utils/consts';
@ -21,6 +28,13 @@ import { goToGrafanaPage } from './utils/navigation';
const grafanaApiClient = new GrafanaAPIClient(GRAFANA_ADMIN_USERNAME, GRAFANA_ADMIN_PASSWORD);
enum OrgRole {
None = 'None',
Viewer = 'Viewer',
Editor = 'Editor',
Admin = 'Admin',
}
type UserCreationSettings = {
adminAuthedRequest: APIRequestContext;
role: OrgRole;

View file

@ -119,11 +119,11 @@
"@dnd-kit/core": "^6.0.8",
"@dnd-kit/sortable": "^7.0.2",
"@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-tracing": "^1.0.0-beta4",
"@grafana/labels": "~1.4.4",
"@grafana/runtime": "9.3.0-beta1",
"@grafana/runtime": "^10.2.2",
"@grafana/scenes": "^1.28.0",
"@grafana/schema": "^10.2.2",
"@grafana/ui": "^10.2.0",

View file

@ -1,9 +1,6 @@
import React, { ReactElement, useMemo, useState } from 'react';
// Note: these imports are available in Grafana>=10.0.
// @ts-expect-error
import { PluginExtensionLink } from '@grafana/data';
// @ts-expect-error
import { getPluginLinkExtensions } from '@grafana/runtime';
import { Dropdown, ToolbarButton } from '@grafana/ui';
import { OnCallPluginExtensionPoints } from 'types';
@ -54,7 +51,10 @@ function useExtensionPointContext(incident: Alert): PluginExtensionOnCallAlertGr
return { alertGroup: incident };
}
function useExtensionLinks<T>(context: T, extensionPointId: OnCallPluginExtensionPoints): PluginExtensionLink[] {
function useExtensionLinks<T extends object>(
context: T,
extensionPointId: OnCallPluginExtensionPoints
): PluginExtensionLink[] {
return useMemo(() => {
// getPluginLinkExtensions is available in Grafana>=10.0,
// so will be undefined in earlier versions. Just return an

View file

@ -1,9 +1,7 @@
import React, { ReactElement, useMemo } from 'react';
// Note: `PluginExtensionLink` is available in Grafana>=10.0.
// @ts-expect-error
import { locationUtil, PluginExtensionLink } from '@grafana/data';
import { Menu } from '@grafana/ui';
import { locationUtil, PluginExtensionLink, PluginExtensionTypes } from '@grafana/data';
import { IconName, Menu } from '@grafana/ui';
import { PluginBridge, SupportedPlugin } from 'components/PluginBridge/PluginBridge';
import { truncateTitle } from 'utils/string';
@ -48,6 +46,7 @@ function DeclareIncidentMenuItem({ extensions, declareIncidentLink, grafanaIncid
const declareIncidentExtensionLink = extensions.find(
(extension) => extension.pluginId === 'grafana-incident-app' && extension.title === 'Declare incident'
);
if (
// Don't show a custom Declare incident button if the Grafana Incident plugin already configured one.
declareIncidentExtensionLink ||
@ -58,29 +57,30 @@ function DeclareIncidentMenuItem({ extensions, declareIncidentLink, grafanaIncid
) {
return null;
}
return (
<PluginBridge plugin={SupportedPlugin.Incident}>
<Menu.Group key={'Declare incident'} label={'Incident'}>
{renderItems([
{
type: 'link',
type: PluginExtensionTypes.link,
path: declareIncidentLink,
icon: 'fire',
category: 'Incident',
title: 'Declare incident',
pluginId: 'grafana-oncall-app',
},
} as Partial<PluginExtensionLink>,
])}
</Menu.Group>
</PluginBridge>
);
}
function renderItems(extensions: PluginExtensionLink[]): JSX.Element[] {
function renderItems(extensions: Array<Partial<PluginExtensionLink>>): JSX.Element[] {
return extensions.map((extension) => (
<Menu.Item
ariaLabel={extension.title}
icon={extension?.icon || 'plug'}
icon={(extension?.icon || 'plug') as IconName}
key={extension.id}
label={truncateTitle(extension.title, 25)}
onClick={(event) => {

View file

@ -30,7 +30,7 @@ const DefaultPageLayout: FC<DefaultPageLayoutProps> = observer((props) => {
function renderTopNavbar(): JSX.Element {
return (
<PluginPage page={page} pageNav={pageNav}>
<PluginPage page={page} pageNav={pageNav as any}>
<div className={cx('root')}>{children}</div>
</PluginPage>
);

View file

@ -1,6 +1,7 @@
.container {
display: flex;
flex-direction: row;
min-width: 100%;
&__box {
flex-basis: 50%;

View file

@ -4,24 +4,30 @@ import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
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 { RootStore } from 'state';
import { useStore as useStoreOriginal } from 'state/useStore';
import { rootStore } from 'state';
import MobileAppConnection from './MobileAppConnection';
import { MobileAppConnection } from './MobileAppConnection';
jest.mock('plugin/GrafanaPluginRootPage.helpers', () => ({
isTopNavbar: () => false,
}));
jest.mock('@grafana/runtime', () => ({
__esModule: true,
config: {
featureToggles: {
topNav: false,
},
},
getBackendSrv: jest.fn().mockImplementation(() => ({
get: jest.fn(),
post: jest.fn(),
})),
getLocationSrv: jest.fn(),
}));
jest.mock('utils/authorization', () => ({
@ -29,49 +35,50 @@ jest.mock('utils/authorization', () => ({
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 mockUseStore = (rest?: any, connected = false, cloud_connected = true) => {
const store = {
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;
jest.mock('state', () => ({
rootStore: jest.fn(),
}));
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 BACKEND = 'MOBILE_APP';
describe('MobileAppConnection', () => {
test('', () => {
expect(true).toBe(true);
});
});
describe('MobileAppConnection', () => {
beforeEach(() => {
loadUserMock.mockClear();
(rootStore as any).mockClear();
});
test('it shows a loading message if it is currently fetching the QR code', async () => {
const { userStore } = mockUseStore({
mockRootStore({
sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dfd'),
});
@ -79,29 +86,13 @@ describe('MobileAppConnection', () => {
expect(component.container).toMatchSnapshot();
await waitFor(() => {
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1);
expect(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);
expect(rootStore.userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1);
expect(rootStore.userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND);
});
});
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'),
});
@ -111,13 +102,13 @@ describe('MobileAppConnection', () => {
await waitFor(() => {
expect(component.container).toMatchSnapshot();
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1);
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND);
expect(rootStore.userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1);
expect(rootStore.userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND);
});
});
test("it shows a QR code if the app isn't already connected", async () => {
const { userStore } = mockUseStore({
mockRootStore({
sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dfd'),
});
@ -125,13 +116,13 @@ describe('MobileAppConnection', () => {
expect(component.container).toMatchSnapshot();
await waitFor(() => {
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1);
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND);
expect(rootStore.userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1);
expect(rootStore.userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND);
});
});
test('if we disconnect the app, it disconnects and fetches a new QR code', async () => {
const { userStore } = mockUseStore(
mockRootStore(
{
sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dfd'),
unlinkBackend: jest.fn().mockResolvedValueOnce('asdfadsfafds'),
@ -150,16 +141,16 @@ describe('MobileAppConnection', () => {
expect(component.container).toMatchSnapshot();
await waitFor(() => {
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1);
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND);
expect(rootStore.userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1);
expect(rootStore.userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND);
expect(userStore.unlinkBackend).toHaveBeenCalledTimes(1);
expect(userStore.unlinkBackend).toHaveBeenCalledWith(USER_PK, BACKEND);
expect(rootStore.userStore.unlinkBackend).toHaveBeenCalledTimes(1);
expect(rootStore.userStore.unlinkBackend).toHaveBeenCalledWith(USER_PK, BACKEND);
});
});
test('it shows a loading message if it is currently disconnecting', async () => {
const { userStore } = mockUseStore(
mockRootStore(
{
sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dfd'),
unlinkBackend: jest.fn().mockResolvedValueOnce(new Promise((resolve) => setTimeout(resolve, 500))),
@ -181,16 +172,16 @@ describe('MobileAppConnection', () => {
expect(component.container).toMatchSnapshot();
await waitFor(() => {
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1);
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND);
expect(rootStore.userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1);
expect(rootStore.userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND);
expect(userStore.unlinkBackend).toHaveBeenCalledTimes(1);
expect(userStore.unlinkBackend).toHaveBeenCalledWith(USER_PK, BACKEND);
expect(rootStore.userStore.unlinkBackend).toHaveBeenCalledTimes(1);
expect(rootStore.userStore.unlinkBackend).toHaveBeenCalledWith(USER_PK, BACKEND);
});
});
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'),
unlinkBackend: jest.fn().mockRejectedValueOnce('asdfadsfafds'),
@ -211,15 +202,15 @@ describe('MobileAppConnection', () => {
expect(component.container).toMatchSnapshot();
await waitFor(() => {
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(0);
expect(rootStore.userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(0);
expect(userStore.unlinkBackend).toHaveBeenCalledTimes(1);
expect(userStore.unlinkBackend).toHaveBeenCalledWith(USER_PK, BACKEND);
expect(rootStore.userStore.unlinkBackend).toHaveBeenCalledTimes(1);
expect(rootStore.userStore.unlinkBackend).toHaveBeenCalledWith(USER_PK, BACKEND);
});
});
test('it polls loadUser on first render if not connected', async () => {
mockUseStore(
mockRootStore(
{
sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dfd'),
unlinkBackend: jest.fn().mockRejectedValueOnce('asdfadsfafds'),
@ -238,7 +229,7 @@ describe('MobileAppConnection', () => {
});
test('it polls loadUser after disconnect', async () => {
mockUseStore(
mockRootStore(
{
sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dff'),
unlinkBackend: jest.fn().mockRejectedValueOnce('asdff'),
@ -263,7 +254,7 @@ describe('MobileAppConnection', () => {
});
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>"
const component = render(

View file

@ -10,8 +10,8 @@ import PluginLink from 'components/PluginLink/PluginLink';
import Text from 'components/Text/Text';
import { WithPermissionControlDisplay } from 'containers/WithPermissionControl/WithPermissionControlDisplay';
import { User } from 'models/user/user.types';
import { RootStore, rootStore as store } from 'state';
import { AppFeature } from 'state/features';
import { useStore } from 'state/useStore';
import { openErrorNotification, openNotification, openWarningNotification } from 'utils';
import { UserActions } from 'utils/authorization';
@ -23,7 +23,8 @@ import QRCode from './parts/QRCode/QRCode';
const cx = cn.bind(styles);
type Props = {
userPk: User['pk'];
userPk?: User['pk'];
store?: RootStore;
};
const INTERVAL_MIN_THROTTLING = 500;
@ -36,31 +37,10 @@ const INTERVAL_QUEUE_QR = 290_000;
const INTERVAL_POLLING = 5000;
const BACKEND = 'MOBILE_APP';
const MobileAppConnection = observer(({ userPk }: Props) => {
const store = useStore();
export const MobileAppConnection = observer(({ userPk }: Props) => {
const { userStore, cloudStore } = store;
// Show link to cloud page for OSS instances with no cloud connection
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 [basicDataLoaded, setBasicDataLoaded] = useState(false);
const isMounted = useRef(false);
const [mobileAppIsCurrentlyConnected, setMobileAppIsCurrentlyConnected] = useState<boolean>(isUserConnected());
@ -75,10 +55,34 @@ const MobileAppConnection = observer(({ userPk }: Props) => {
const [refreshTimeoutId, setRefreshTimeoutId] = useState<NodeJS.Timeout>(undefined);
const [isQRBlurry, setIsQRBlurry] = useState<boolean>(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(
async (showLoader = true) => {
if (!userPk) {
return;
}
if (showLoader) {
setFetchingQRCode(true);
}
@ -105,6 +109,9 @@ const MobileAppConnection = observer(({ userPk }: Props) => {
}, []);
const disconnectMobileApp = useCallback(async () => {
if (!userPk) {
return;
}
setDisconnectingMobileApp(true);
try {
@ -119,29 +126,24 @@ const MobileAppConnection = observer(({ userPk }: Props) => {
triggerTimeouts();
}, [userPk, resetState]);
useEffect(() => {
isMounted.current = true;
if (!isUserConnected()) {
triggerTimeouts();
}
// clear on unmount
return () => {
isMounted.current = false;
clearTimeouts();
};
}, []);
useEffect(() => {
if (!mobileAppIsCurrentlyConnected) {
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;
if (fetchingQRCode || disconnectingMobileApp) {
if (fetchingQRCode || disconnectingMobileApp || !userPk || !basicDataLoaded) {
content = <LoadingPlaceholder text="Loading..." />;
} else if (errorFetchingQRCode || errorDisconnectingMobileApp) {
content = <Text type="primary">{errorFetchingQRCode || errorDisconnectingMobileApp}</Text>;
@ -199,7 +201,7 @@ const MobileAppConnection = observer(({ userPk }: Props) => {
{content}
</Block>
</div>
{mobileAppIsCurrentlyConnected && isCurrentUser && (
{mobileAppIsCurrentlyConnected && isCurrentUser && !disconnectingMobileApp && (
<div className={cx('notification-buttons')}>
<HorizontalGroup spacing={'md'} justify={'flex-end'}>
<Button
@ -222,7 +224,31 @@ const MobileAppConnection = observer(({ userPk }: Props) => {
</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) {
if (!userPk) {
return;
}
setIsAttemptingTestNotification(true);
try {
@ -258,11 +284,11 @@ const MobileAppConnection = observer(({ userPk }: Props) => {
}
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> {
if (!isMounted.current) {
if (!isMounted.current || !userPk) {
return;
}
@ -300,7 +326,7 @@ const MobileAppConnection = observer(({ userPk }: Props) => {
}
async function pollUserProfile(): Promise<void> {
if (!isMounted.current) {
if (!isMounted.current || !userPk) {
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..." />;
});

View file

@ -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 };

View file

@ -528,163 +528,6 @@ exports[`MobileAppConnection it shows a loading message if it is currently fetch
</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`] = `
<div>
<div

View file

@ -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} />
</>
);
};

View file

@ -5,7 +5,8 @@ import cn from 'classnames/bind';
import { observer } from 'mobx-react';
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 { SlackTab } from 'containers/UserSettings/parts/tabs//SlackTab/SlackTab';
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 { AppFeature } from 'state/features';
import { useStore } from 'state/useStore';
import { isUseProfileExtensionPointEnabled } from 'utils';
import styles from 'containers/UserSettings/parts/index.module.css';
@ -151,10 +153,18 @@ export const TabsContent = observer(({ id, activeTab, onTabChange, isDesktopOrLa
) : (
<PhoneVerification userPk={id} />
))}
{activeTab === UserSettingsTab.MobileAppConnection && <MobileAppConnection userPk={id} />}
{activeTab === UserSettingsTab.MobileAppConnection && renderMobileTab()}
{activeTab === UserSettingsTab.SlackInfo && <SlackTab />}
{activeTab === UserSettingsTab.TelegramInfo && <TelegramInfo />}
{activeTab === UserSettingsTab.MSTeamsInfo && <MSTeamsInfo />}
</TabContent>
);
function renderMobileTab() {
if (!isUseProfileExtensionPointEnabled()) {
return <MobileAppConnection userPk={id} />;
}
return <MobileAppConnectionTab userPk={id} />;
}
});

View file

@ -4,7 +4,7 @@ import { InlineField, Input, Legend } from '@grafana/ui';
import GrafanaTeamSelect from 'containers/GrafanaTeamSelect/GrafanaTeamSelect';
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 { useStore } from 'state/useStore';

View file

@ -1,15 +1,47 @@
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 { GrafanaPluginRootPage } from 'plugin/GrafanaPluginRootPage';
import { getGrafanaVersion } from 'plugin/GrafanaPluginRootPage.helpers';
import { IRM_TAB } from 'utils/consts';
import { OnCallPluginConfigPageProps, OnCallPluginMetaJSONData } from './types';
export const plugin = new AppPlugin<OnCallPluginMetaJSONData>().setRootPage(GrafanaPluginRootPage).addConfigPage({
const plugin = new AppPlugin<OnCallPluginMetaJSONData>().setRootPage(GrafanaPluginRootPage).addConfigPage({
title: 'Configuration',
icon: 'cog',
body: PluginConfigPage as unknown as ComponentClass<OnCallPluginConfigPageProps, unknown>,
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 };

View file

@ -1,7 +1,6 @@
import React from 'react';
import { IconName } from '@grafana/data';
import { Tab, TabsBar } from '@grafana/ui';
import { IconName, Tab, TabsBar } from '@grafana/ui';
import cn from 'classnames/bind';
import { pages } from 'pages';

View file

@ -8,13 +8,6 @@ import { Timezone } from 'models/timezone/timezone.types';
import { RootStore } from 'state';
import { SelectOption } from 'state/types';
const mondayDayOffset = {
saturday: -2,
sunday: -1,
monday: 0,
browser: 0,
};
export const getWeekStartString = () => {
const weekStart = (config?.bootData?.user?.weekStart || '').toLowerCase();
@ -35,15 +28,11 @@ export const getStartOfDay = (tz: Timezone) => {
};
export const getStartOfWeek = (tz: Timezone) => {
return getNow(tz)
.startOf('isoWeek') // it's Monday always
.add(mondayDayOffset[getWeekStartString()], 'day');
return getNow(tz).startOf('isoWeek'); // it's Monday always
};
export const getStartOfWeekBasedOnCurrentDate = (date: dayjs.Dayjs) => {
return date
.startOf('isoWeek') // it's Monday always
.add(mondayDayOffset[getWeekStartString()], 'day');
return date.startOf('isoWeek'); // it's Monday always
};
export const getUTCString = (moment: dayjs.Dayjs) => {

View file

@ -3,6 +3,7 @@
"type": "app",
"name": "Grafana OnCall",
"id": "grafana-oncall-app",
"preload": true,
"info": {
"description": "Collect and analyze alerts, escalate based on schedules and deliver them to Slack, Phone Calls, SMS and others.",
"author": {

View file

@ -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);
});
});

View file

@ -4,6 +4,21 @@ export function isTopNavbar(): boolean {
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 {
const searchParams = new URLSearchParams(window.location.search);
const result = {};

View file

@ -1,7 +1,5 @@
import React, { useEffect } from 'react';
import './dayjs';
import { LoadingPlaceholder } from '@grafana/ui';
import classnames from 'classnames';
import { observer, Provider } from 'mobx-react';

View file

@ -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';
export class RootStore extends RootBaseStore {}

View file

@ -72,6 +72,7 @@ const roleMapping: Record<OrgRole, number> = {
[OrgRole.Admin]: 0,
[OrgRole.Editor]: 1,
[OrgRole.Viewer]: 2,
[OrgRole.None]: 3,
};
/**

View file

@ -66,3 +66,4 @@ export enum PAGE {
export const TEXT_ELLIPSIS_CLASS = 'overflow-child';
export const INCIDENT_HORIZONTAL_SCROLLING_STORAGE = 'isIncidentalTableHorizontalScrolling';
export const IRM_TAB = 'IRM';

View file

@ -6,6 +6,7 @@ import appEvents from 'grafana/app/core/app_events';
import { isArray, concat, isPlainObject, flatMap, map, keys } from 'lodash-es';
import { isNetworkError } from 'network';
import { getGrafanaVersion } from 'plugin/GrafanaPluginRootPage.helpers';
export class KeyValuePair<T = string | number> {
key: T;
@ -95,3 +96,10 @@ export function getPaths(obj?: any, parentKey?: string): string[] {
export function pluralize(word: string, count: number): string {
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