diff --git a/public/app/features/migrate-to-cloud/api/index.ts b/public/app/features/migrate-to-cloud/api/index.ts index e3473ce28a7..ac7a0b07d78 100644 --- a/public/app/features/migrate-to-cloud/api/index.ts +++ b/public/app/features/migrate-to-cloud/api/index.ts @@ -1,5 +1,5 @@ export * from './endpoints.gen'; -import { BaseQueryFn, QueryDefinition } from '@reduxjs/toolkit/dist/query'; +import { BaseQueryFn, EndpointDefinition } from '@reduxjs/toolkit/dist/query'; import { generatedAPI } from './endpoints.gen'; @@ -12,8 +12,9 @@ export const cloudMigrationAPI = generatedAPI.enhanceEndpoints({ }, // Create Cloud Config - createMigration: { - invalidatesTags: ['cloud-migration-config'], + createMigration(endpoint) { + suppressErrorsOnQuery(endpoint); + endpoint.invalidatesTags = ['cloud-migration-config']; }, // Get one Cloud Config @@ -45,7 +46,7 @@ export const cloudMigrationAPI = generatedAPI.enhanceEndpoints({ }); function suppressErrorsOnQuery( - endpoint: QueryDefinition + endpoint: EndpointDefinition ) { if (!endpoint.query) { return; diff --git a/public/app/features/migrate-to-cloud/fixtures/mswAPI.ts b/public/app/features/migrate-to-cloud/fixtures/mswAPI.ts index fe687e9782f..e0052891201 100644 --- a/public/app/features/migrate-to-cloud/fixtures/mswAPI.ts +++ b/public/app/features/migrate-to-cloud/fixtures/mswAPI.ts @@ -1,9 +1,10 @@ import { HttpResponse, http } from 'msw'; import { SetupServer, setupServer } from 'msw/node'; -export function registerAPIHandlers(): SetupServer { +import { validCloudMigrationToken } from './tokens'; + +function createMockAPI(): SetupServer { const server = setupServer( - // TODO http.get('/api/dashboards/uid/:uid', ({ request, params }) => { if (params.uid === 'dashboard-404') { return HttpResponse.json( @@ -24,6 +25,26 @@ export function registerAPIHandlers(): SetupServer { folderTitle: 'Dashboards', }, }); + }), + + http.post('/api/cloudmigration/migration', async ({ request }) => { + const data = await request.json(); + const authToken = typeof data === 'object' && data && data.authToken; + + if (authToken === validCloudMigrationToken) { + return HttpResponse.json({ + created: new Date().toISOString(), + id: 1, + stack: 'abc-123', + }); + } + + return HttpResponse.json( + { + message: 'Invalid token', + }, + { status: 500 } + ); }) ); @@ -31,3 +52,15 @@ export function registerAPIHandlers(): SetupServer { return server; } + +export function registerMockAPI() { + let server: SetupServer; + + beforeAll(() => { + server = createMockAPI(); + }); + + afterAll(() => { + server.close(); + }); +} diff --git a/public/app/features/migrate-to-cloud/fixtures/tokens.ts b/public/app/features/migrate-to-cloud/fixtures/tokens.ts new file mode 100644 index 00000000000..b7bb0a44bc9 --- /dev/null +++ b/public/app/features/migrate-to-cloud/fixtures/tokens.ts @@ -0,0 +1 @@ +export const validCloudMigrationToken = 'valid-cloud-migration-token'; diff --git a/public/app/features/migrate-to-cloud/onprem/EmptyState/CallToAction/CallToAction.test.tsx b/public/app/features/migrate-to-cloud/onprem/EmptyState/CallToAction/CallToAction.test.tsx new file mode 100644 index 00000000000..937ff02ad4d --- /dev/null +++ b/public/app/features/migrate-to-cloud/onprem/EmptyState/CallToAction/CallToAction.test.tsx @@ -0,0 +1,81 @@ +import 'whatwg-fetch'; // fetch polyfill +import { render as rtlRender, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { TestProvider } from 'test/helpers/TestProvider'; + +import { setBackendSrv } from '@grafana/runtime'; +import { backendSrv } from 'app/core/services/backend_srv'; + +import { registerMockAPI } from '../../../fixtures/mswAPI'; +import { validCloudMigrationToken } from '../../../fixtures/tokens'; + +import { CallToAction } from './CallToAction'; + +setBackendSrv(backendSrv); + +function render(...[ui, options]: Parameters) { + rtlRender({ui}, options); +} + +describe('CallToAction', () => { + registerMockAPI(); + + it('opens the modal when clicking on the button', async () => { + render(); + const openButton = screen.getByText('Migrate this instance to Cloud'); + await userEvent.click(openButton); + expect(screen.getByRole('button', { name: 'Connect to this stack' })).toBeInTheDocument(); + }); + + it("closes the modal when clicking on the 'Cancel' button", async () => { + render(); + + const openButton = screen.getByText('Migrate this instance to Cloud'); + await userEvent.click(openButton); + + const closeButton = screen.getByText('Cancel'); + await userEvent.click(closeButton); + + expect(screen.queryByRole('button', { name: 'Connect to this stack' })).not.toBeInTheDocument(); + }); + + it("disables the connect button when the 'token' field is empty", async () => { + render(); + + const openButton = screen.getByText('Migrate this instance to Cloud'); + await userEvent.click(openButton); + + expect(screen.getByRole('button', { name: 'Connect to this stack' })).toBeDisabled(); + }); + + it('closes the modal after successfully submitting', async () => { + render(); + + const openButton = screen.getByText('Migrate this instance to Cloud'); + await userEvent.click(openButton); + + const tokenField = screen.getByRole('textbox', { name: 'Migration token *' }); + await userEvent.type(tokenField, validCloudMigrationToken); + + const submitButton = screen.getByRole('button', { name: 'Connect to this stack' }); + await userEvent.click(submitButton); + + expect(screen.queryByRole('button', { name: 'Connect to this stack' })).not.toBeInTheDocument(); + }); + + it('shows the error', async () => { + render(); + + const openButton = screen.getByText('Migrate this instance to Cloud'); + await userEvent.click(openButton); + + const tokenField = screen.getByRole('textbox', { name: 'Migration token *' }); + await userEvent.type(tokenField, 'a wrong token!!'); + + const submitButton = screen.getByRole('button', { name: 'Connect to this stack' }); + await userEvent.click(submitButton); + + expect(await screen.findByText('Error saving token')).toBeInTheDocument(); + }); +}); diff --git a/public/app/features/migrate-to-cloud/onprem/EmptyState/CallToAction/CallToAction.tsx b/public/app/features/migrate-to-cloud/onprem/EmptyState/CallToAction/CallToAction.tsx index 71a21eb32cc..9bc1bec1429 100644 --- a/public/app/features/migrate-to-cloud/onprem/EmptyState/CallToAction/CallToAction.tsx +++ b/public/app/features/migrate-to-cloud/onprem/EmptyState/CallToAction/CallToAction.tsx @@ -1,6 +1,6 @@ -import React from 'react'; +import React, { useState } from 'react'; -import { Box, Button, ModalsController, Text } from '@grafana/ui'; +import { Box, Button, Text } from '@grafana/ui'; import { Trans } from 'app/core/internationalization'; import { useCreateMigrationMutation } from '../../../api'; @@ -8,28 +8,28 @@ import { useCreateMigrationMutation } from '../../../api'; import { ConnectModal } from './ConnectModal'; export const CallToAction = () => { + const [modalOpen, setModalOpen] = useState(false); const [createMigration, createMigrationResponse] = useCreateMigrationMutation(); return ( - - {({ showModal, hideModal }) => ( - - - Let us manage your Grafana stack - - - - )} - + <> + + + Let us manage your Grafana stack + + + + + + setModalOpen(false)} + /> + ); }; diff --git a/public/app/features/migrate-to-cloud/onprem/EmptyState/CallToAction/ConnectModal.tsx b/public/app/features/migrate-to-cloud/onprem/EmptyState/CallToAction/ConnectModal.tsx index 07bbbd09bd1..2236e8bb9f0 100644 --- a/public/app/features/migrate-to-cloud/onprem/EmptyState/CallToAction/ConnectModal.tsx +++ b/public/app/features/migrate-to-cloud/onprem/EmptyState/CallToAction/ConnectModal.tsx @@ -1,14 +1,17 @@ import { css } from '@emotion/css'; -import React, { useId, useState } from 'react'; +import React, { useId } from 'react'; import { SubmitHandler, useForm } from 'react-hook-form'; import { GrafanaTheme2 } from '@grafana/data'; -import { Modal, Button, Stack, TextLink, Field, Input, Text, useStyles2 } from '@grafana/ui'; +import { Modal, Button, Stack, TextLink, Field, Input, Text, useStyles2, Alert } from '@grafana/ui'; import { Trans, t } from 'app/core/internationalization'; import { CreateMigrationApiArg } from '../../../api'; interface Props { + isOpen: boolean; + isLoading: boolean; + isError: boolean; hideModal: () => void; onConfirm: (connectStackData: CreateMigrationApiArg) => Promise; } @@ -17,8 +20,7 @@ interface FormData { token: string; } -export const ConnectModal = ({ hideModal, onConfirm }: Props) => { - const [isConnecting, setIsConnecting] = useState(false); +export const ConnectModal = ({ isOpen, isLoading, isError, hideModal, onConfirm }: Props) => { const tokenId = useId(); const styles = useStyles2(getStyles); @@ -35,53 +37,73 @@ export const ConnectModal = ({ hideModal, onConfirm }: Props) => { const token = watch('token'); - const onConfirmConnect: SubmitHandler = async (formData) => { - setIsConnecting(true); - // TODO: location of this is kinda weird, making it tricky to handle errors from this. - await onConfirm({ + const onConfirmConnect: SubmitHandler = (formData) => { + onConfirm({ cloudMigrationRequest: { authToken: formData.token, }, + }).then((resp) => { + const didError = typeof resp === 'object' && resp && 'error' in resp; + if (!didError) { + hideModal(); + } }); - setIsConnecting(false); - hideModal(); }; return ( - +
- + To get started, you'll need a Grafana.com account. - - {t('migrate-to-cloud.connect-modal.body-sign-up', 'Sign up for a Grafana.com account')} - +
+ + {t('migrate-to-cloud.connect-modal.body-sign-up', 'Sign up for a Grafana.com account')} + +
You'll also need a cloud stack. If you just signed up, we'll automatically create your first stack. If you have an account, you'll need to select or create a stack. - - {t('migrate-to-cloud.connect-modal.body-view-stacks', 'View my cloud stacks')} - +
+ + {t('migrate-to-cloud.connect-modal.body-view-stacks', 'View my cloud stacks')} + +
- +
Your self-managed Grafana installation needs special access to securely migrate content. You'll need to create a migration token on your chosen cloud stack. - +
- +
Log into your cloud stack and navigate to Administration, General, Migrate to Grafana Cloud. Create a migration token on that screen and paste the token here. - +
+ + {isError && ( + + + There was an error saving the token. See the Grafana server logs for more details. + + + )} {
+ - diff --git a/public/app/features/migrate-to-cloud/onprem/ResourcesTable.test.tsx b/public/app/features/migrate-to-cloud/onprem/ResourcesTable.test.tsx index f4a91f473fa..c8b0119086c 100644 --- a/public/app/features/migrate-to-cloud/onprem/ResourcesTable.test.tsx +++ b/public/app/features/migrate-to-cloud/onprem/ResourcesTable.test.tsx @@ -1,6 +1,5 @@ import 'whatwg-fetch'; // fetch polyfill import { render as rtlRender, screen } from '@testing-library/react'; -import { SetupServer } from 'msw/lib/node'; import React from 'react'; import { TestProvider } from 'test/helpers/TestProvider'; @@ -8,7 +7,7 @@ import { setBackendSrv, config } from '@grafana/runtime'; import { backendSrv } from 'app/core/services/backend_srv'; import { wellFormedDashboardMigrationItem, wellFormedDatasourceMigrationItem } from '../fixtures/migrationItems'; -import { registerAPIHandlers } from '../fixtures/mswAPI'; +import { registerMockAPI } from '../fixtures/mswAPI'; import { wellFormedDatasource } from '../fixtures/others'; import { ResourcesTable } from './ResourcesTable'; @@ -20,7 +19,8 @@ function render(...[ui, options]: Parameters) { } describe('ResourcesTable', () => { - let server: SetupServer; + registerMockAPI(); + let originalDatasources: (typeof config)['datasources']; const datasourceA = wellFormedDatasource(1, { @@ -29,7 +29,6 @@ describe('ResourcesTable', () => { }); beforeAll(() => { - server = registerAPIHandlers(); originalDatasources = config.datasources; config.datasources = { @@ -39,7 +38,6 @@ describe('ResourcesTable', () => { }); afterAll(() => { - server.close(); config.datasources = originalDatasources; });