Tweaks for mobile app verification (#916)
* slightly style mobile app screen * minor tweaks * more changes * wrap calls in waitFor to stop jest complains, PR review fix * fixed polling * use timeout instead of interval * suggestion from Joe
This commit is contained in:
parent
96868e1038
commit
329beb62ff
14 changed files with 880 additions and 5126 deletions
|
|
@ -14,9 +14,9 @@ module.exports = {
|
|||
'jest/outgoingWebhooksStub': '<rootDir>/src/jest/outgoingWebhooksStub.ts',
|
||||
'^jest$': '<rootDir>/src/jest',
|
||||
'^.+\\.(css|scss)$': '<rootDir>/src/jest/styleMock.ts',
|
||||
// '^.+\\.(ts|tsx)$': 'ts-jest',
|
||||
'^lodash-es$': 'lodash',
|
||||
'^.+\\.svg$': '<rootDir>/src/jest/svgTransform.ts',
|
||||
'^.+\\.png$': '<rootDir>/src/jest/grafanaMock.ts',
|
||||
},
|
||||
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
||||
|
|
|
|||
BIN
grafana-plugin/src/assets/img/qr-code.png
Normal file
BIN
grafana-plugin/src/assets/img/qr-code.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
|
|
@ -0,0 +1,35 @@
|
|||
.container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
&__box {
|
||||
flex-basis: 50%;
|
||||
}
|
||||
|
||||
&__box:first-child {
|
||||
margin-right: 8px;
|
||||
}
|
||||
&__box:last-child {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-top: -6px;
|
||||
margin-left: 4px;
|
||||
fill: var(--green-6);
|
||||
}
|
||||
|
||||
.disconnect__container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.disconnect__qrCode {
|
||||
width: 240px;
|
||||
height: auto;
|
||||
filter: blur(6px);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { UserStore } from 'models/user/user';
|
||||
|
|
@ -13,10 +13,12 @@ import MobileAppVerification from './MobileAppVerification';
|
|||
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) => {
|
||||
const store = {
|
||||
userStore: {
|
||||
loadUser: loadUserMock,
|
||||
currentUser: {
|
||||
messaging_backends: {
|
||||
MOBILE_APP: { connected },
|
||||
|
|
@ -35,6 +37,10 @@ const USER_PK = '8585';
|
|||
const BACKEND = 'MOBILE_APP';
|
||||
|
||||
describe('MobileAppVerification', () => {
|
||||
beforeEach(() => {
|
||||
loadUserMock.mockClear();
|
||||
});
|
||||
|
||||
test('it shows a loading message if it is currently fetching the QR code', async () => {
|
||||
const { userStore } = mockUseStore({
|
||||
sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dfd'),
|
||||
|
|
@ -43,8 +49,10 @@ describe('MobileAppVerification', () => {
|
|||
const component = render(<MobileAppVerification userPk={USER_PK} />);
|
||||
expect(component.container).toMatchSnapshot();
|
||||
|
||||
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1);
|
||||
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND);
|
||||
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 () => {
|
||||
|
|
@ -58,7 +66,9 @@ describe('MobileAppVerification', () => {
|
|||
const component = render(<MobileAppVerification userPk={USER_PK} />);
|
||||
expect(component.container).toMatchSnapshot();
|
||||
|
||||
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(0);
|
||||
waitFor(() => {
|
||||
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('it shows an error message if there was an error fetching the QR code', async () => {
|
||||
|
|
@ -69,10 +79,12 @@ describe('MobileAppVerification', () => {
|
|||
const component = render(<MobileAppVerification userPk={USER_PK} />);
|
||||
await screen.findByText(/.*error fetching your QR code.*/);
|
||||
|
||||
expect(component.container).toMatchSnapshot();
|
||||
waitFor(() => {
|
||||
expect(component.container).toMatchSnapshot();
|
||||
|
||||
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1);
|
||||
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND);
|
||||
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1);
|
||||
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND);
|
||||
});
|
||||
});
|
||||
|
||||
test("it shows a QR code if the app isn't already connected", async () => {
|
||||
|
|
@ -81,12 +93,12 @@ describe('MobileAppVerification', () => {
|
|||
});
|
||||
|
||||
const component = render(<MobileAppVerification userPk={USER_PK} />);
|
||||
await screen.findByText(/.*the QR code is only valid for one minute.*/);
|
||||
|
||||
expect(component.container).toMatchSnapshot();
|
||||
|
||||
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1);
|
||||
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND);
|
||||
waitFor(() => {
|
||||
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1);
|
||||
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND);
|
||||
});
|
||||
});
|
||||
|
||||
test('if we disconnect the app, it disconnects and fetches a new QR code', async () => {
|
||||
|
|
@ -108,15 +120,15 @@ describe('MobileAppVerification', () => {
|
|||
// click the confirm button within the modal, which actually triggers the callback
|
||||
await user.click(screen.getByText('Remove'));
|
||||
|
||||
await screen.findByText(/.*the QR code is only valid for one minute.*/);
|
||||
|
||||
expect(component.container).toMatchSnapshot();
|
||||
|
||||
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1);
|
||||
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND);
|
||||
waitFor(() => {
|
||||
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1);
|
||||
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND);
|
||||
|
||||
expect(userStore.unlinkBackend).toHaveBeenCalledTimes(1);
|
||||
expect(userStore.unlinkBackend).toHaveBeenCalledWith(USER_PK, BACKEND);
|
||||
expect(userStore.unlinkBackend).toHaveBeenCalledTimes(1);
|
||||
expect(userStore.unlinkBackend).toHaveBeenCalledWith(USER_PK, BACKEND);
|
||||
});
|
||||
});
|
||||
|
||||
test('it shows a loading message if it is currently disconnecting', async () => {
|
||||
|
|
@ -144,11 +156,13 @@ describe('MobileAppVerification', () => {
|
|||
|
||||
expect(component.container).toMatchSnapshot();
|
||||
|
||||
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1);
|
||||
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND);
|
||||
waitFor(() => {
|
||||
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1);
|
||||
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND);
|
||||
|
||||
expect(userStore.unlinkBackend).toHaveBeenCalledTimes(1);
|
||||
expect(userStore.unlinkBackend).toHaveBeenCalledWith(USER_PK, BACKEND);
|
||||
expect(userStore.unlinkBackend).toHaveBeenCalledTimes(1);
|
||||
expect(userStore.unlinkBackend).toHaveBeenCalledWith(USER_PK, BACKEND);
|
||||
});
|
||||
});
|
||||
|
||||
test('it shows an error message if there was an error disconnecting the mobile app', async () => {
|
||||
|
|
@ -163,7 +177,7 @@ describe('MobileAppVerification', () => {
|
|||
const component = render(<MobileAppVerification userPk={USER_PK} />);
|
||||
|
||||
const user = userEvent.setup();
|
||||
const button = await screen.findByRole('button');
|
||||
const button = await screen.findByTestId('test__disconnect');
|
||||
|
||||
// click the disconnect button, which opens the modal
|
||||
await user.click(button);
|
||||
|
|
@ -174,9 +188,51 @@ describe('MobileAppVerification', () => {
|
|||
|
||||
expect(component.container).toMatchSnapshot();
|
||||
|
||||
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(0);
|
||||
waitFor(() => {
|
||||
expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(0);
|
||||
|
||||
expect(userStore.unlinkBackend).toHaveBeenCalledTimes(1);
|
||||
expect(userStore.unlinkBackend).toHaveBeenCalledWith(USER_PK, BACKEND);
|
||||
expect(userStore.unlinkBackend).toHaveBeenCalledTimes(1);
|
||||
expect(userStore.unlinkBackend).toHaveBeenCalledWith(USER_PK, BACKEND);
|
||||
});
|
||||
});
|
||||
|
||||
test('it polls loadUser on first render if not connected', async () => {
|
||||
mockUseStore(
|
||||
{
|
||||
sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dfd'),
|
||||
unlinkBackend: jest.fn().mockRejectedValueOnce('asdfadsfafds'),
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
render(<MobileAppVerification userPk={USER_PK} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(loadUserMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
test('it polls loadUser after disconnect', async () => {
|
||||
mockUseStore(
|
||||
{
|
||||
sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dff'),
|
||||
unlinkBackend: jest.fn().mockRejectedValueOnce('asdff'),
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
render(<MobileAppVerification userPk={USER_PK} />);
|
||||
|
||||
const user = userEvent.setup();
|
||||
const button = await screen.findByRole('button');
|
||||
|
||||
loadUserMock.mockClear();
|
||||
|
||||
await user.click(button); // click the disconnect button, which opens the modal
|
||||
await user.click(screen.getByText('Remove')); // click the confirm button within the modal, which actually triggers the callback
|
||||
|
||||
await waitFor(() => {
|
||||
expect(loadUserMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,29 +1,33 @@
|
|||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { HorizontalGroup, LoadingPlaceholder, VerticalGroup } from '@grafana/ui';
|
||||
import { Icon, LoadingPlaceholder, VerticalGroup } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { observer } from 'mobx-react';
|
||||
|
||||
import qrCodeImage from 'assets/img/qr-code.png';
|
||||
import Block from 'components/GBlock/Block';
|
||||
import Text from 'components/Text/Text';
|
||||
import { User } from 'models/user/user.types';
|
||||
import { useStore } from 'state/useStore';
|
||||
|
||||
import DisconnectButton from './parts/DisconnectButton';
|
||||
import styles from './MobileAppVerification.module.scss';
|
||||
import DisconnectButton from './parts/DisconnectButton/DisconnectButton';
|
||||
import DownloadIcons from './parts/DownloadIcons';
|
||||
import QRCode from './parts/QRCode';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
type Props = {
|
||||
userPk: User['pk'];
|
||||
};
|
||||
|
||||
const INTERVAL_MS = 5000;
|
||||
const BACKEND = 'MOBILE_APP';
|
||||
|
||||
const MobileAppVerification = observer(({ userPk }: Props) => {
|
||||
const { userStore } = useStore();
|
||||
|
||||
const [mobileAppIsCurrentlyConnected, setMobileAppIsCurrentlyConnected] = useState<boolean>(
|
||||
userStore.currentUser.messaging_backends[BACKEND]?.connected === true
|
||||
);
|
||||
const [mobileAppIsCurrentlyConnected, setMobileAppIsCurrentlyConnected] = useState<boolean>(isUserConnected());
|
||||
|
||||
const [fetchingQRCode, setFetchingQRCode] = useState<boolean>(!mobileAppIsCurrentlyConnected);
|
||||
const [QRCodeValue, setQRCodeValue] = useState<string>(null);
|
||||
|
|
@ -31,6 +35,7 @@ const MobileAppVerification = observer(({ userPk }: Props) => {
|
|||
|
||||
const [disconnectingMobileApp, setDisconnectingMobileApp] = useState<boolean>(false);
|
||||
const [errorDisconnectingMobileApp, setErrorDisconnectingMobileApp] = useState<string>(null);
|
||||
const [userTimeoutId, setUserTimeoutId] = useState<NodeJS.Timeout>(undefined);
|
||||
|
||||
const fetchQRCode = useCallback(async () => {
|
||||
setFetchingQRCode(true);
|
||||
|
|
@ -59,9 +64,24 @@ const MobileAppVerification = observer(({ userPk }: Props) => {
|
|||
} catch (e) {
|
||||
setErrorDisconnectingMobileApp('There was an error disconnecting your mobile app. Please try again.');
|
||||
}
|
||||
|
||||
setDisconnectingMobileApp(false);
|
||||
pollUserProfile();
|
||||
}, [userPk, resetState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isUserConnected()) {
|
||||
pollUserProfile();
|
||||
}
|
||||
|
||||
// clear on unmount
|
||||
return () => {
|
||||
if (userTimeoutId) {
|
||||
clearTimeout(userTimeoutId);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mobileAppIsCurrentlyConnected) {
|
||||
fetchQRCode();
|
||||
|
|
@ -76,33 +96,63 @@ const MobileAppVerification = observer(({ userPk }: Props) => {
|
|||
content = <Text type="primary">{errorFetchingQRCode || errorDisconnectingMobileApp}</Text>;
|
||||
} else if (mobileAppIsCurrentlyConnected) {
|
||||
content = (
|
||||
<VerticalGroup>
|
||||
<Text type="primary">Your mobile app is currently connected. Click below to disconnect.</Text>
|
||||
<DisconnectButton onClick={disconnectMobileApp} />
|
||||
<VerticalGroup spacing="lg">
|
||||
<Text strong type="primary">
|
||||
App connected <Icon name="check-circle" size="md" className={cx('icon')} />
|
||||
</Text>
|
||||
<Text type="primary">
|
||||
You can sync one application to your account. To setup new device please disconnect app first.
|
||||
</Text>
|
||||
<div className={cx('disconnect__container')}>
|
||||
<img src={qrCodeImage} className={cx('disconnect__qrCode')} />
|
||||
<DisconnectButton onClick={disconnectMobileApp} />
|
||||
</div>
|
||||
</VerticalGroup>
|
||||
);
|
||||
} else if (QRCodeValue) {
|
||||
content = (
|
||||
<VerticalGroup>
|
||||
<QRCode value={QRCodeValue} />
|
||||
<Text type="primary">
|
||||
Note: the QR code is only valid for one minute. If you have issues connecting your mobile app, try refreshing
|
||||
this page to generate a new code.
|
||||
<VerticalGroup spacing="lg">
|
||||
<Text type="primary" strong>
|
||||
Sign In
|
||||
</Text>
|
||||
<Text type="primary">Open Grafana IRM mobile application and scan this code to sync it with your account.</Text>
|
||||
<div className="u-width-100 u-flex u-flex-center">
|
||||
<QRCode value={QRCodeValue} />
|
||||
</div>
|
||||
<Text type="primary" className="u-break-word">
|
||||
<strong>Note:</strong> the QR code is only valid for one minute. If you have issues connecting your mobile
|
||||
app, try refreshing this page to generate a new code.
|
||||
</Text>
|
||||
</VerticalGroup>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HorizontalGroup>
|
||||
<Block bordered withBackground>
|
||||
{content}
|
||||
</Block>
|
||||
<Block bordered withBackground>
|
||||
<div className={cx('container')}>
|
||||
<Block shadowed bordered withBackground className={cx('container__box')}>
|
||||
<DownloadIcons />
|
||||
</Block>
|
||||
</HorizontalGroup>
|
||||
<Block shadowed bordered withBackground className={cx('container__box')}>
|
||||
{content}
|
||||
</Block>
|
||||
</div>
|
||||
);
|
||||
|
||||
function isUserConnected(user?: User): boolean {
|
||||
return !!(user || userStore.currentUser).messaging_backends[BACKEND]?.connected;
|
||||
}
|
||||
|
||||
async function pollUserProfile(): Promise<void> {
|
||||
clearTimeout(userTimeoutId);
|
||||
setUserTimeoutId(undefined);
|
||||
|
||||
const user = await userStore.loadUser(userPk);
|
||||
if (!isUserConnected(user)) {
|
||||
setUserTimeoutId(setTimeout(() => pollUserProfile(), INTERVAL_MS));
|
||||
} else {
|
||||
setMobileAppIsCurrentlyConnected(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default MobileAppVerification;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,6 @@
|
|||
.disconnect-button {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import React from 'react';
|
|||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import DisconnectButton from '.';
|
||||
import DisconnectButton from './DisconnectButton';
|
||||
|
||||
describe('DisconnectButton', () => {
|
||||
test('it renders properly', () => {
|
||||
|
|
|
|||
|
|
@ -1,17 +1,27 @@
|
|||
import React, { FC } from 'react';
|
||||
|
||||
import { Button } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
|
||||
import WithConfirm from 'components/WithConfirm/WithConfirm';
|
||||
|
||||
import styles from './DisconnectButton.module.scss';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
type Props = {
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
// TODO: right now this shows a confirmation pop-up modal on top of the user settings modal, do we want to maybe change this?
|
||||
const DisconnectButton: FC<Props> = ({ onClick }) => (
|
||||
<WithConfirm title="Are you sure to disconnect your mobile application?" confirmText="Remove">
|
||||
<Button variant="destructive" onClick={onClick} size="md">
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={onClick}
|
||||
size="md"
|
||||
className={cx('disconnect-button')}
|
||||
data-testid="test__disconnect"
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
</WithConfirm>
|
||||
|
|
@ -3,7 +3,8 @@
|
|||
exports[`DisconnectButton it renders properly 1`] = `
|
||||
<div>
|
||||
<button
|
||||
class="css-mk7eo3-button"
|
||||
class="css-mk7eo3-button disconnect-button"
|
||||
data-testid="test__disconnect"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ exports[`DownloadIcons it renders properly 1`] = `
|
|||
class="css-ztyofd-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="root text text--primary text--medium"
|
||||
class="root text text--primary text--medium text--strong"
|
||||
>
|
||||
Download
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ const cx = cn.bind(styles);
|
|||
|
||||
const DownloadIcons: FC = () => (
|
||||
<VerticalGroup spacing="lg">
|
||||
<Text type="primary">Download</Text>
|
||||
<Text type="primary" strong>
|
||||
Download
|
||||
</Text>
|
||||
<Text type="primary">The Grafana IRM app is available on both the App Store and Google Play Store.</Text>
|
||||
<VerticalGroup>
|
||||
<Block hover fullWidth withBackground bordered className={cx('icon-block')}>
|
||||
|
|
|
|||
|
|
@ -74,13 +74,15 @@ export class UserStore extends BaseStore {
|
|||
}
|
||||
|
||||
@action
|
||||
async loadUser(userPk: User['pk'], skipErrorHandling = false) {
|
||||
async loadUser(userPk: User['pk'], skipErrorHandling = false): Promise<User> {
|
||||
const user = await this.getById(userPk, skipErrorHandling);
|
||||
|
||||
this.items = {
|
||||
...this.items,
|
||||
[user.pk]: { ...user, timezone: getTimezone(user) },
|
||||
};
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
@action
|
||||
|
|
|
|||
|
|
@ -1,12 +1,3 @@
|
|||
.u-flex {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.u-align-items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.u-position-relative {
|
||||
position: relative;
|
||||
}
|
||||
|
|
@ -18,3 +9,25 @@
|
|||
.u-pull-left {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.u-break-word {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.u-width-100 {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.u-flex {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.u-flex-center {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.u-align-items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue