diff --git a/src/app/ErrorView/ErrorView.tsx b/src/app/ErrorView/ErrorView.tsx index c1bcf2ed8..bf72f6cd8 100644 --- a/src/app/ErrorView/ErrorView.tsx +++ b/src/app/ErrorView/ErrorView.tsx @@ -49,6 +49,7 @@ import { import { ExclamationCircleIcon } from '@patternfly/react-icons'; export const authFailMessage = 'Auth failure'; +export const isAuthFail = (message: string) => message === authFailMessage; export interface ErrorViewProps { title: string | React.ReactNode; message: string | React.ReactNode; diff --git a/src/app/Events/EventTemplates.tsx b/src/app/Events/EventTemplates.tsx index b4e57cf52..2dd60bd1e 100644 --- a/src/app/Events/EventTemplates.tsx +++ b/src/app/Events/EventTemplates.tsx @@ -54,7 +54,6 @@ import { ToolbarGroup, ToolbarItem, TextInput, - Text, } from '@patternfly/react-core'; import { UploadIcon } from '@patternfly/react-icons'; import { @@ -72,7 +71,7 @@ import { import { useHistory } from 'react-router-dom'; import { concatMap, filter, first } from 'rxjs/operators'; import { LoadingView } from '@app/LoadingView/LoadingView'; -import { authFailMessage, ErrorView } from '@app/ErrorView/ErrorView'; +import { authFailMessage, ErrorView, isAuthFail } from '@app/ErrorView/ErrorView'; import { DeleteWarningType } from '@app/Modal/DeleteWarningUtils'; import { DeleteWarningModal } from '@app/Modal/DeleteWarningModal'; @@ -95,12 +94,15 @@ export const EventTemplates = () => { const [rowDeleteData, setRowDeleteData] = React.useState({} as IRowData); const addSubscription = useSubscriptions(); - const tableColumns = [ - { title: 'Name', transforms: [sortable] }, - 'Description', - { title: 'Provider', transforms: [sortable] }, - { title: 'Type', transforms: [sortable] }, - ]; + const tableColumns = React.useMemo( + () => [ + { title: 'Name', transforms: [sortable] }, + 'Description', + { title: 'Provider', transforms: [sortable] }, + { title: 'Type', transforms: [sortable] }, + ], + [sortable] + ); React.useEffect(() => { let filtered; @@ -219,14 +221,17 @@ export const EventTemplates = () => { [filteredTemplates] ); - const handleDelete = (rowData) => { - addSubscription( - context.api - .deleteCustomEventTemplate(rowData[0]) - .pipe(first()) - .subscribe(() => {} /* do nothing - notification will handle updating state */) - ); - }; + const handleDelete = React.useCallback( + (rowData) => { + addSubscription( + context.api + .deleteCustomEventTemplate(rowData[0]) + .pipe(first()) + .subscribe(() => {} /* do nothing - notification will handle updating state */) + ); + }, + [addSubscription, context.api] + ); const actionResolver = (rowData: IRowData, extraData: IExtraData) => { if (typeof extraData.rowIndex == 'undefined') { @@ -268,7 +273,7 @@ export const EventTemplates = () => { return actions; }; - const handleModalToggle = () => { + const handleModalToggle = React.useCallback(() => { setModalOpen((v) => { if (v) { setUploadFile(undefined); @@ -277,15 +282,18 @@ export const EventTemplates = () => { } return !v; }); - }; + }, [setModalOpen, setUploadFile, setUploadFilename, setUploading]); - const handleFileChange = (value, filename) => { - setFileRejected(false); - setUploadFile(value); - setUploadFilename(filename); - }; + const handleFileChange = React.useCallback( + (value, filename) => { + setFileRejected(false); + setUploadFile(value); + setUploadFilename(filename); + }, + [setFileRejected, setUploadFile, setUploadFilename] + ); - const handleUploadSubmit = () => { + const handleUploadSubmit = React.useCallback(() => { if (!uploadFile) { window.console.error('Attempted to submit template upload without a file selected'); return; @@ -304,21 +312,33 @@ export const EventTemplates = () => { } }) ); - }; + }, [ + uploadFile, + window.console, + setUploading, + addSubscription, + context.api, + setUploadFile, + setUploadFilename, + setModalOpen, + ]); - const handleUploadCancel = () => { + const handleUploadCancel = React.useCallback(() => { setUploadFile(undefined); setUploadFilename(''); setModalOpen(false); - }; + }, [setUploadFile, setUploadFilename, setModalOpen]); - const handleFileRejected = () => { + const handleFileRejected = React.useCallback(() => { setFileRejected(true); - }; + }, [setFileRejected]); - const handleSort = (event, index, direction) => { - setSortBy({ index, direction }); - }; + const handleSort = React.useCallback( + (event, index, direction) => { + setSortBy({ index, direction }); + }, + [setSortBy] + ); const handleDeleteButton = React.useCallback( (rowData) => { @@ -379,7 +399,13 @@ export const EventTemplates = () => { }, [context.target, context.target.setAuthRetry]); if (errorMessage != '') { - return ; + return ( + + ); } else if (isLoading) { return ( <> diff --git a/src/app/Events/EventTypes.tsx b/src/app/Events/EventTypes.tsx index c0cce4e1f..04b2930a2 100644 --- a/src/app/Events/EventTypes.tsx +++ b/src/app/Events/EventTypes.tsx @@ -46,13 +46,11 @@ import { ToolbarItemVariant, Pagination, TextInput, - Text, - Button, } from '@patternfly/react-core'; import { expandable, Table, TableBody, TableHeader, TableVariant } from '@patternfly/react-table'; import { concatMap, filter, first } from 'rxjs/operators'; import { LoadingView } from '@app/LoadingView/LoadingView'; -import { authFailMessage, ErrorView } from '@app/ErrorView/ErrorView'; +import { authFailMessage, ErrorView, isAuthFail } from '@app/ErrorView/ErrorView'; export interface EventType { name: string; @@ -75,6 +73,10 @@ type Row = { fullWidth?: boolean; }; +const getCategoryString = (eventType: EventType): string => { + return eventType.category.join(', ').trim(); +}; + export const EventTypes = () => { const context = React.useContext(ServiceContext); const addSubscription = useSubscriptions(); @@ -89,15 +91,18 @@ export const EventTypes = () => { const [isLoading, setIsLoading] = React.useState(false); const [errorMessage, setErrorMessage] = React.useState(''); - const tableColumns = [ - { - title: 'Name', - cellFormatters: [expandable], - }, - 'Type ID', - 'Description', - 'Categories', - ]; + const tableColumns = React.useMemo( + () => [ + { + title: 'Name', + cellFormatters: [expandable], + }, + 'Type ID', + 'Description', + 'Categories', + ], + [expandable] + ); const handleTypes = React.useCallback( (types) => { @@ -128,10 +133,10 @@ export const EventTypes = () => { context.api.doGet(`targets/${encodeURIComponent(target.connectUrl)}/events`) ) ) - .subscribe( - (value) => handleTypes(value), - (err) => handleError(err) - ) + .subscribe({ + next: handleTypes, + error: handleError, + }) ); }, [addSubscription, context.target, context.api]); @@ -145,16 +150,9 @@ export const EventTypes = () => { }, [addSubscription, context, context.target, refreshEvents]); React.useEffect(() => { - const sub = context.target.authFailure().subscribe(() => { - setErrorMessage(authFailMessage); - }); - return () => sub.unsubscribe(); + addSubscription(context.target.authFailure().subscribe(() => setErrorMessage(authFailMessage))); }, [context.target]); - const getCategoryString = (eventType: EventType): string => { - return eventType.category.join(', ').trim(); - }; - const filterTypesByText = React.useCallback(() => { if (!filterText) { return types; @@ -196,30 +194,39 @@ export const EventTypes = () => { setDisplayedTypes(rows); }, [currentPage, perPage, filterTypesByText, openRow]); - const onCurrentPage = (evt, currentPage) => { - setOpenRow(-1); - setCurrentPage(currentPage); - }; + const onCurrentPage = React.useCallback( + (evt, currentPage) => { + setOpenRow(-1); + setCurrentPage(currentPage); + }, + [setOpenRow, setCurrentPage] + ); - const onPerPage = (evt, perPage) => { - const offset = (currentPage - 1) * prevPerPage.current; - prevPerPage.current = perPage; - setOpenRow(-1); - setPerPage(perPage); - setCurrentPage(1 + Math.floor(offset / perPage)); - }; + const onPerPage = React.useCallback( + (evt, perPage) => { + const offset = (currentPage - 1) * prevPerPage.current; + prevPerPage.current = perPage; + setOpenRow(-1); + setPerPage(perPage); + setCurrentPage(1 + Math.floor(offset / perPage)); + }, + [currentPage, prevPerPage, setOpenRow, setPerPage, setCurrentPage] + ); - const onCollapse = (event, rowKey, isOpen) => { - if (isOpen) { - if (openRow === -1) { - setOpenRow(rowKey); + const onCollapse = React.useCallback( + (event, rowKey, isOpen) => { + if (isOpen) { + if (openRow === -1) { + setOpenRow(rowKey); + } else { + setOpenRow(rowKey > openRow ? rowKey - 1 : rowKey); + } } else { - setOpenRow(rowKey > openRow ? rowKey - 1 : rowKey); + setOpenRow(-1); } - } else { - setOpenRow(-1); - } - }; + }, + [setOpenRow, openRow] + ); const authRetry = React.useCallback(() => { context.target.setAuthRetry(); @@ -227,7 +234,13 @@ export const EventTypes = () => { // TODO replace table with data list so collapsed event options can be custom formatted if (errorMessage != '') { - return ; + return ( + + ); } else if (isLoading) { return ; } else { diff --git a/src/app/Recordings/ActiveRecordingsTable.tsx b/src/app/Recordings/ActiveRecordingsTable.tsx index 91e7a2d78..240e78e71 100644 --- a/src/app/Recordings/ActiveRecordingsTable.tsx +++ b/src/app/Recordings/ActiveRecordingsTable.tsx @@ -258,11 +258,7 @@ export const ActiveRecordingsTable: React.FunctionComponent { - addSubscription( - context.target.authFailure().subscribe(() => { - setErrorMessage(authFailMessage); - }) - ); + addSubscription(context.target.authFailure().subscribe(() => setErrorMessage(authFailMessage))); }, [context, context.target, setErrorMessage, addSubscription]); React.useEffect(() => { diff --git a/src/app/Recordings/RecordingActions.tsx b/src/app/Recordings/RecordingActions.tsx index 661d5863f..c7a81955e 100644 --- a/src/app/Recordings/RecordingActions.tsx +++ b/src/app/Recordings/RecordingActions.tsx @@ -68,12 +68,13 @@ export const RecordingActions: React.FunctionComponent = const addSubscription = useSubscriptions(); React.useEffect(() => { - const sub = context.api - .grafanaDatasourceUrl() - .pipe(first()) - .subscribe(() => setGrafanaEnabled(true)); - return () => sub.unsubscribe(); - }, [context.api, setGrafanaEnabled]); + addSubscription( + context.api + .grafanaDatasourceUrl() + .pipe(first()) + .subscribe(() => setGrafanaEnabled(true)) + ); + }, [context.api, setGrafanaEnabled, addSubscription]); const grafanaUpload = React.useCallback(() => { notifications.info('Upload Started', `Recording "${props.recording.name}" uploading...`); diff --git a/src/app/Recordings/RecordingsTable.tsx b/src/app/Recordings/RecordingsTable.tsx index f45b679a7..59b3c1a8b 100644 --- a/src/app/Recordings/RecordingsTable.tsx +++ b/src/app/Recordings/RecordingsTable.tsx @@ -48,7 +48,7 @@ import { import SearchIcon from '@patternfly/react-icons/dist/esm/icons/search-icon'; import { TableComposable, Thead, Tr, Th, OuterScrollContainer, InnerScrollContainer } from '@patternfly/react-table'; import { LoadingView } from '@app/LoadingView/LoadingView'; -import { ErrorView } from '@app/ErrorView/ErrorView'; +import { ErrorView, isAuthFail } from '@app/ErrorView/ErrorView'; import { ServiceContext } from '@app/Shared/Services/Services'; export interface RecordingsTableProps { @@ -77,7 +77,11 @@ export const RecordingsTable: React.FunctionComponent = (p if (props.errorMessage != '') { view = ( <> - + > ); } else if (props.isLoading) { diff --git a/src/app/Rules/CreateRule.tsx b/src/app/Rules/CreateRule.tsx index cddaaa507..f8bb7405c 100644 --- a/src/app/Rules/CreateRule.tsx +++ b/src/app/Rules/CreateRule.tsx @@ -51,8 +51,6 @@ import { GridItem, Split, SplitItem, - Stack, - StackItem, Switch, Text, TextInput, @@ -71,7 +69,7 @@ import { MatchExpressionEvaluator } from '../Shared/MatchExpressionEvaluator'; import { FormSelectTemplateSelector } from '../TemplateSelector/FormSelectTemplateSelector'; import { NO_TARGET } from '@app/Shared/Services/Target.service'; import { iif } from 'rxjs'; -import { authFailMessage, ErrorView } from '@app/ErrorView/ErrorView'; +import { authFailMessage, ErrorView, isAuthFail } from '@app/ErrorView/ErrorView'; // FIXME check if this is correct/matches backend name validation export const RuleNamePattern = /^[\w_]+$/; @@ -274,7 +272,11 @@ const Comp = () => { {errorMessage ? ( - + ) : ( diff --git a/src/test/Common.tsx b/src/test/Common.tsx index 4b778c01c..7e191156a 100644 --- a/src/test/Common.tsx +++ b/src/test/Common.tsx @@ -62,12 +62,13 @@ export const renderWithServiceContextAndReduxStore = ( { preloadState = {}, store = setupStore(preloadState), // Create a new store instance if no store was passed in + services = defaultServices, ...renderOptions } = {} ) => { const Wrapper = ({ children }: PropsWithChildren<{}>) => { return ( - + {children} ); @@ -80,13 +81,14 @@ export const renderWithServiceContextAndReduxStoreWithRouter = ( { preloadState = {}, store = setupStore(preloadState), // Create a new store instance if no store was passed in + services = defaultServices, history, ...renderOptions } ) => { const Wrapper = ({ children }: PropsWithChildren<{}>) => { return ( - + {children} diff --git a/src/test/Events/EventTemplates.test.tsx b/src/test/Events/EventTemplates.test.tsx index 6808e65e8..06089db23 100644 --- a/src/test/Events/EventTemplates.test.tsx +++ b/src/test/Events/EventTemplates.test.tsx @@ -37,15 +37,16 @@ */ import * as React from 'react'; import renderer, { act } from 'react-test-renderer'; -import { render, screen, within } from '@testing-library/react'; +import { act as doAct, render, screen, within } from '@testing-library/react'; import '@testing-library/jest-dom'; -import { of } from 'rxjs'; +import { of, Subject } from 'rxjs'; import { EventTemplate } from '@app/Shared/Services/Api.service'; import { MessageMeta, MessageType, NotificationMessage } from '@app/Shared/Services/NotificationChannel.service'; -import { ServiceContext, defaultServices } from '@app/Shared/Services/Services'; +import { ServiceContext, defaultServices, Services } from '@app/Shared/Services/Services'; import { EventTemplates } from '@app/Events/EventTemplates'; import userEvent from '@testing-library/user-event'; import { DeleteWarningType } from '@app/Modal/DeleteWarningUtils'; +import { TargetService } from '@app/Shared/Services/Target.service'; const mockConnectUrl = 'service:jmx:rmi://someUrl'; const mockTarget = { connectUrl: mockConnectUrl, alias: 'fooTarget' }; @@ -239,4 +240,36 @@ describe('', () => { expect(deleteRequestSpy).toHaveBeenCalledTimes(1); expect(screen.queryByLabelText('Event template delete warning')).not.toBeInTheDocument(); }); + + it('should show error view if failing to retrieve event templates', async () => { + const subj = new Subject(); + const mockTargetSvc = { + target: () => of(mockTarget), + authFailure: () => subj.asObservable(), + } as TargetService; + const services: Services = { + ...defaultServices, + target: mockTargetSvc, + }; + + render( + + + + ); + + await doAct(async () => subj.next()); + + const failTitle = screen.getByText('Error retrieving event templates'); + expect(failTitle).toBeInTheDocument(); + expect(failTitle).toBeVisible(); + + const authFailText = screen.getByText('Auth failure'); + expect(authFailText).toBeInTheDocument(); + expect(authFailText).toBeVisible(); + + const retryButton = screen.getByText('Retry'); + expect(retryButton).toBeInTheDocument(); + expect(retryButton).toBeVisible(); + }); }); diff --git a/src/test/Events/EventTypes.test.tsx b/src/test/Events/EventTypes.test.tsx new file mode 100644 index 000000000..0adb99281 --- /dev/null +++ b/src/test/Events/EventTypes.test.tsx @@ -0,0 +1,106 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import * as React from 'react'; +import renderer, { act } from 'react-test-renderer'; +import { act as doAct, render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { of, Subject } from 'rxjs'; +import { ServiceContext, defaultServices, Services } from '@app/Shared/Services/Services'; +import { TargetService } from '@app/Shared/Services/Target.service'; +import { EventType, EventTypes } from '@app/Events/EventTypes'; + +const mockConnectUrl = 'service:jmx:rmi://someUrl'; +const mockTarget = { connectUrl: mockConnectUrl, alias: 'fooTarget' }; + +const mockEventType: EventType = { + name: 'Some Event', + typeId: 'org.some_eventId', + description: 'Some Descriptions', + category: ['Category 1', 'Category 2'], + options: [{ some_key: { name: 'some_name', description: 'a_desc', defaultValue: 'some_value' } }], +}; + +jest.spyOn(defaultServices.api, 'doGet').mockReturnValue(of([mockEventType])); +jest.spyOn(defaultServices.target, 'target').mockReturnValue(of(mockTarget)); +jest.spyOn(defaultServices.target, 'authFailure').mockReturnValue(of()); + +describe('', () => { + it('renders correctly', async () => { + let tree; + await act(async () => { + tree = renderer.create( + + + + ); + }); + expect(tree.toJSON()).toMatchSnapshot(); + }); + + it('should show error view if failing to retrieve event types', async () => { + const subj = new Subject(); + const mockTargetSvc = { + target: () => of(mockTarget), + authFailure: () => subj.asObservable(), + } as TargetService; + const services: Services = { + ...defaultServices, + target: mockTargetSvc, + }; + + render( + + + + ); + + await doAct(async () => subj.next()); + + const failTitle = screen.getByText('Error retrieving event types'); + expect(failTitle).toBeInTheDocument(); + expect(failTitle).toBeVisible(); + + const authFailText = screen.getByText('Auth failure'); + expect(authFailText).toBeInTheDocument(); + expect(authFailText).toBeVisible(); + + const retryButton = screen.getByText('Retry'); + expect(retryButton).toBeInTheDocument(); + expect(retryButton).toBeVisible(); + }); +}); diff --git a/src/test/Events/__snapshots__/EventTypes.test.tsx.snap b/src/test/Events/__snapshots__/EventTypes.test.tsx.snap new file mode 100644 index 000000000..48e944c4a --- /dev/null +++ b/src/test/Events/__snapshots__/EventTypes.test.tsx.snap @@ -0,0 +1,390 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders correctly 1`] = ` +Array [ + + + + + + + + + + + 1 + - + 1 + + + of + + + 1 + + + + + + + + + 1 + - + 1 + + + of + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + , + + + + + + + + Name + + + Type ID + + + Description + + + Categories + + + + + + + + + + + + + + + + Some Event + + + org.some_eventId + + + Some Descriptions + + + Category 1, Category 2 + + + + , +] +`; diff --git a/src/test/Recordings/ActiveRecordingsTable.test.tsx b/src/test/Recordings/ActiveRecordingsTable.test.tsx index 34dd836ba..06265d5aa 100644 --- a/src/test/Recordings/ActiveRecordingsTable.test.tsx +++ b/src/test/Recordings/ActiveRecordingsTable.test.tsx @@ -40,7 +40,7 @@ import { createMemoryHistory } from 'history'; import { act, cleanup, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom'; -import { of } from 'rxjs'; +import { of, Subject } from 'rxjs'; import { ActiveRecording, RecordingState } from '@app/Shared/Services/Api.service'; import { NotificationMessage } from '@app/Shared/Services/NotificationChannel.service'; @@ -96,22 +96,23 @@ jest.mock('@app/Recordings/RecordingFilters', () => { }); import { ActiveRecordingsTable } from '@app/Recordings/ActiveRecordingsTable'; -import { defaultServices } from '@app/Shared/Services/Services'; +import { defaultServices, Services } from '@app/Shared/Services/Services'; import { DeleteActiveRecordings, DeleteWarningType } from '@app/Modal/DeleteWarningUtils'; import { emptyActiveRecordingFilters, emptyArchivedRecordingFilters } from '@app/Recordings/RecordingFilters'; import { TargetRecordingFilters } from '@app/Shared/Redux/RecordingFilterReducer'; -import { RootState, setupStore } from '@app/Shared/Redux/ReduxStore'; +import { RootState } from '@app/Shared/Redux/ReduxStore'; import { renderWithServiceContextAndReduxStoreWithRouter } from '../Common'; +import { TargetService } from '@app/Shared/Services/Target.service'; jest.spyOn(defaultServices.api, 'archiveRecording').mockReturnValue(of(true)); jest.spyOn(defaultServices.api, 'deleteRecording').mockReturnValue(of(true)); jest.spyOn(defaultServices.api, 'doGet').mockReturnValue(of([mockRecording])); jest.spyOn(defaultServices.api, 'downloadRecording').mockReturnValue(); jest.spyOn(defaultServices.api, 'downloadReport').mockReturnValue(); -jest.spyOn(defaultServices.api, 'grafanaDatasourceUrl').mockReturnValue(of('/grafanaUrl')); +jest.spyOn(defaultServices.api, 'grafanaDashboardUrl').mockReturnValue(of('/grafanaUrl')); +jest.spyOn(defaultServices.api, 'grafanaDatasourceUrl').mockReturnValue(of('/datasource')); jest.spyOn(defaultServices.api, 'stopRecording').mockReturnValue(of(true)); jest.spyOn(defaultServices.api, 'uploadActiveRecordingToGrafana').mockReturnValue(of(true)); - jest.spyOn(defaultServices.target, 'target').mockReturnValue(of(mockTarget)); jest.spyOn(defaultServices.target, 'authFailure').mockReturnValue(of()); @@ -168,6 +169,8 @@ jest .mockReturnValueOnce(of(mockDeleteNotification)) .mockReturnValue(of()); // all other tests +jest.spyOn(window, 'open').mockReturnValue(null); + describe('', () => { let preloadedState: RootState; @@ -450,4 +453,36 @@ describe('', () => { expect(grafanaUploadSpy).toHaveBeenCalledTimes(1); expect(grafanaUploadSpy).toBeCalledWith('someRecording'); }); + + it('should show error view if failing to retrieve recordings', async () => { + const subj = new Subject(); + const mockTargetSvc = { + target: () => of(mockTarget), + authFailure: () => subj.asObservable(), + } as TargetService; + const services: Services = { + ...defaultServices, + target: mockTargetSvc, + }; + + renderWithServiceContextAndReduxStoreWithRouter(, { + preloadState: preloadedState, + history: history, + services, + }); + + await act(async () => subj.next()); + + const failTitle = screen.getByText('Error retrieving recordings'); + expect(failTitle).toBeInTheDocument(); + expect(failTitle).toBeVisible(); + + const authFailText = screen.getByText('Auth failure'); + expect(authFailText).toBeInTheDocument(); + expect(authFailText).toBeVisible(); + + const retryButton = screen.getByText('Retry'); + expect(retryButton).toBeInTheDocument(); + expect(retryButton).toBeVisible(); + }); }); diff --git a/src/test/Recordings/ArchivedRecordingsTable.test.tsx b/src/test/Recordings/ArchivedRecordingsTable.test.tsx index acf048fcf..c3b748d1a 100644 --- a/src/test/Recordings/ArchivedRecordingsTable.test.tsx +++ b/src/test/Recordings/ArchivedRecordingsTable.test.tsx @@ -124,7 +124,7 @@ jest.mock('@app/Recordings/RecordingFilters', () => { jest.spyOn(defaultServices.api, 'deleteArchivedRecording').mockReturnValue(of(true)); jest.spyOn(defaultServices.api, 'downloadRecording').mockReturnValue(); jest.spyOn(defaultServices.api, 'downloadReport').mockReturnValue(); -jest.spyOn(defaultServices.api, 'grafanaDatasourceUrl').mockReturnValue(of('/grafanaUrl')); +jest.spyOn(defaultServices.api, 'grafanaDatasourceUrl').mockReturnValue(of('/datasource')); jest.spyOn(defaultServices.api, 'grafanaDashboardUrl').mockReturnValue(of('/grafanaUrl')); jest.spyOn(defaultServices.api, 'graphql').mockReturnValue(of(mockArchivedRecordingsResponse)); jest.spyOn(defaultServices.api, 'uploadArchivedRecordingToGrafana').mockReturnValue(of(true));