From 31e82e7e07066988052066f7c58908e130980193 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Thu, 17 Nov 2022 14:37:36 -0800 Subject: [PATCH] feat(spinner): add spinner to action buttons (#640) * feat(spinner): add spinner to target creation button * feat(spinner): add spinner to target delete button * feat(spinner): add spinner to rule creation button * feat(spinner): add spinner to credentials creation button * feat(spinner): add spinner to certificate upload form * feat(spinner): add spinner to event template upload * chore(notifications): remove duplicate notifications * feat(spinner): add spinner to agent probe template upload * feat(spinner): add spinner to rule upload modal * feat(spinner): add spinner to recording creation form * feat(spinner): add spinner to archive upload modal * feat(spinner): add spinner to label bulk-edit * feat(spinner): add spinner to recording table actions * chore(spinner): clean up error handlers --- src/app/Agent/AgentProbeTemplates.tsx | 210 +++++++++------- src/app/AppLayout/JmxAuthForm.tsx | 53 ++-- src/app/Archives/ArchiveUploadModal.tsx | 46 +++- src/app/CreateRecording/CreateRecording.tsx | 59 +---- .../CreateRecording/CustomRecordingForm.tsx | 130 ++++++---- .../CreateRecording/SnapshotRecordingForm.tsx | 44 +++- src/app/Events/EventTemplates.tsx | 235 ++++++++++-------- src/app/RecordingMetadata/BulkEditLabels.tsx | 45 +++- .../RecordingLabelFields.tsx | 20 +- src/app/Recordings/ActiveRecordingsTable.tsx | 122 ++++++++- .../Recordings/ArchivedRecordingsTable.tsx | 57 ++++- src/app/Rules/CreateRule.tsx | 102 +++++--- src/app/Rules/Rules.tsx | 22 ++ src/app/Rules/RulesUploadModal.tsx | 61 +++-- .../SecurityPanel/CertificateUploadModal.tsx | 68 +++-- .../Credentials/CreateJmxCredentialModal.tsx | 25 +- .../ProgressIndicator.tsx} | 33 +-- src/app/Shared/Services/Api.service.tsx | 30 +-- src/app/TargetSelect/CreateTargetModal.tsx | 68 ++++- src/app/TargetSelect/TargetSelect.tsx | 65 ++--- .../FormSelectTemplateSelector.tsx | 98 -------- .../SelectTemplateSelectorForm.tsx | 121 +++++++++ .../CustomRecordingForm.test.tsx | 14 +- .../CustomRecordingForm.test.tsx.snap | 24 +- .../__snapshots__/CreateRule.test.tsx.snap | 16 +- 25 files changed, 1124 insertions(+), 644 deletions(-) rename src/app/{TemplateSelector/TemplateSelector.tsx => Shared/ProgressIndicator.tsx} (65%) delete mode 100644 src/app/TemplateSelector/FormSelectTemplateSelector.tsx create mode 100644 src/app/TemplateSelector/SelectTemplateSelectorForm.tsx diff --git a/src/app/Agent/AgentProbeTemplates.tsx b/src/app/Agent/AgentProbeTemplates.tsx index 409cd254b..652e5ed4d 100644 --- a/src/app/Agent/AgentProbeTemplates.tsx +++ b/src/app/Agent/AgentProbeTemplates.tsx @@ -78,6 +78,8 @@ import { ProbeTemplate } from '@app/Shared/Services/Api.service'; import { DeleteWarningType } from '@app/Modal/DeleteWarningUtils'; import { DeleteWarningModal } from '@app/Modal/DeleteWarningModal'; import { AboutAgentCard } from './AboutAgentCard'; +import { NotificationsContext } from '@app/Notifications/Notifications'; +import { LoadingPropsType } from '@app/Shared/ProgressIndicator'; export interface AgentProbeTemplatesProps { agentDetected: boolean; @@ -90,11 +92,7 @@ export const AgentProbeTemplates: React.FunctionComponent { - setUploadFile(undefined); - setUploadFilename(''); - setModalOpen(false); - }, [setUploadFile, setUploadFilename, setModalOpen]); - const handleDeleteButton = React.useCallback( (rowData) => { if (context.settings.deletionDialogsEnabledFor(DeleteWarningType.DeleteEventTemplates)) { @@ -169,10 +161,6 @@ export const AgentProbeTemplates: React.FunctionComponent { - setFileRejected(true); - }, [setFileRejected]); - const handleSort = React.useCallback( (event, index, direction) => { setSortBy({ index, direction }); @@ -216,42 +204,12 @@ export const AgentProbeTemplates: React.FunctionComponent { - setModalOpen(true); - }, [setModalOpen]); + setUploadModalOpen(true); + }, [setUploadModalOpen]); const handleUploadModalClose = React.useCallback(() => { - setModalOpen(false); - }, [setModalOpen]); - - const handleFileChange = React.useCallback( - (value, filename) => { - setFileRejected(false); - setUploadFile(value); - setUploadFilename(filename); - }, - [setFileRejected, setUploadFile, setUploadFilename] - ); - - const handleUploadSubmit = React.useCallback(() => { - if (!uploadFile) { - window.console.error('Attempted to submit probe template upload without a file selected'); - return; - } - setUploading(true); - addSubscription( - context.api - .addCustomProbeTemplate(uploadFile) - .pipe(first()) - .subscribe((success) => { - setUploading(false); - if (success) { - setUploadFile(undefined); - setUploadFilename(''); - setModalOpen(false); - } - }) - ); - }, [uploadFile, setUploading, addSubscription, context.api, setUploadFile, setUploadFilename, setModalOpen]); + setUploadModalOpen(false); + }, [setUploadModalOpen]); React.useEffect(() => { refreshTemplates(); @@ -376,47 +334,125 @@ export const AgentProbeTemplates: React.FunctionComponent )} - -
- - - - - - - -
-
+ ); } }; + +export interface AgentProbeTemplateUploadModalProps { + isOpen: boolean; + onClose: () => void; +} + +export const AgentProbeTemplateUploadModal: React.FunctionComponent = (props) => { + const [uploadFile, setUploadFile] = React.useState(undefined as File | undefined); + const [uploadFilename, setUploadFilename] = React.useState(''); + const [uploading, setUploading] = React.useState(false); + const [fileRejected, setFileRejected] = React.useState(false); + const addSubscription = useSubscriptions(); + const context = React.useContext(ServiceContext); + const notifications = React.useContext(NotificationsContext); + + const reset = React.useCallback(() => { + setUploadFile(undefined); + setUploadFilename(''); + setUploading(false); + setFileRejected(false); + }, [setUploadFile, setUploadFilename, setUploading, setFileRejected]); + + const handleFileChange = React.useCallback( + (file, filename) => { + setFileRejected(false); + setUploadFile(file); + setUploadFilename(filename); + }, + [setFileRejected, setUploadFile, setUploadFilename] + ); + + const handleFileRejected = React.useCallback(() => { + setFileRejected(true); + }, [setFileRejected]); + + const handleClose = React.useCallback(() => { + reset(); + props.onClose(); + }, [reset, props.onClose]); + + const handleUploadSubmit = React.useCallback(() => { + if (fileRejected) { + notifications.warning('File format is not compatible'); + return; + } + if (!uploadFile) { + notifications.warning('Attempted to submit probe template upload without a file selected'); + return; + } + setUploading(true); + addSubscription( + context.api + .addCustomProbeTemplate(uploadFile) + .pipe(first()) + .subscribe((success) => { + setUploading(false); + if (success) { + handleClose(); + } + }) + ); + }, [fileRejected, uploadFile, setUploading, addSubscription, context.api, handleClose]); + + const submitButtonLoadingProps = React.useMemo( + () => + ({ + spinnerAriaValueText: 'Submitting', + spinnerAriaLabel: 'submitting-probe-template', + isLoading: uploading, + } as LoadingPropsType), + [uploading] + ); + + return ( + +
+ + + + + + + +
+
+ ); +}; diff --git a/src/app/AppLayout/JmxAuthForm.tsx b/src/app/AppLayout/JmxAuthForm.tsx index 027116a72..956e7b003 100644 --- a/src/app/AppLayout/JmxAuthForm.tsx +++ b/src/app/AppLayout/JmxAuthForm.tsx @@ -38,6 +38,7 @@ import * as React from 'react'; import { ActionGroup, Button, Form, FormGroup, TextInput } from '@patternfly/react-core'; import { ServiceContext } from '@app/Shared/Services/Services'; +import { LoadingPropsType } from '@app/Shared/ProgressIndicator'; export interface JmxAuthFormProps { onDismiss: () => void; @@ -49,30 +50,43 @@ export const JmxAuthForm: React.FunctionComponent = (props) => const context = React.useContext(ServiceContext); const [username, setUsername] = React.useState(''); const [password, setPassword] = React.useState(''); - - const clear = React.useCallback(() => { - setUsername(''); - setPassword(''); - }, [setUsername, setPassword]); + const [loading, setLoading] = React.useState(false); const handleSave = React.useCallback(() => { - props.onSave(username, password).then(() => { - context.target.setAuthRetry(); - }); - }, [context, context.target, clear, props.onSave, username, password]); + setLoading(true); + props + .onSave(username, password) + .then(() => { + // Do not set state as form is unmounted after successful submission + context.target.setAuthRetry(); + }) + .catch((_) => { + setLoading(false); + }); + }, [context.target, setLoading, props.onSave, username, password]); const handleDismiss = React.useCallback(() => { - clear(); + // Do not set state as form is unmounted after cancel props.onDismiss(); - }, [clear, props.onDismiss]); + }, [props.onDismiss]); const handleKeyUp = React.useCallback( (event: React.KeyboardEvent): void => { - if (event.code === 'Enter') { + if (event.code === 'Enter' && username !== '' && password !== '') { handleSave(); } }, - [handleSave] + [handleSave, username, password] + ); + + const saveButtonLoadingProps = React.useMemo( + () => + ({ + spinnerAriaValueText: 'Saving', + spinnerAriaLabel: 'saving-jmx-credentials', + isLoading: loading, + } as LoadingPropsType), + [loading] ); return ( @@ -81,6 +95,7 @@ export const JmxAuthForm: React.FunctionComponent = (props) => = (props) => = (props) => /> - - diff --git a/src/app/Archives/ArchiveUploadModal.tsx b/src/app/Archives/ArchiveUploadModal.tsx index 99248dc4d..c54b2653f 100644 --- a/src/app/Archives/ArchiveUploadModal.tsx +++ b/src/app/Archives/ArchiveUploadModal.tsx @@ -57,6 +57,7 @@ import { CancelUploadModal } from '@app/Modal/CancelUploadModal'; import { RecordingLabelFields } from '@app/RecordingMetadata/RecordingLabelFields'; import { HelpIcon } from '@patternfly/react-icons'; import { RecordingLabel } from '@app/RecordingMetadata/RecordingLabel'; +import { LoadingPropsType } from '@app/Shared/ProgressIndicator'; export interface ArchiveUploadModalProps { visible: boolean; @@ -122,6 +123,10 @@ export const ArchiveUploadModal: React.FunctionComponent { + if (rejected) { + notifications.warning('File format is not compatible'); + return; + } if (!uploadFile) { notifications.warning('Attempted to submit JFR upload without a file selected'); return; @@ -130,8 +135,21 @@ export const ArchiveUploadModal: React.FunctionComponent handleClose(), + error: (_) => reset(), + }); + }, [ + context.api, + notifications, + setUploading, + abort.signal, + handleClose, + reset, + getFormattedLabels, + uploadFile, + rejected, + ]); const handleAbort = React.useCallback(() => { abort.abort(); @@ -139,6 +157,16 @@ export const ArchiveUploadModal: React.FunctionComponent + ({ + spinnerAriaValueText: 'Submitting', + spinnerAriaLabel: 'submitting-uploaded-recording', + isLoading: uploading, + } as LoadingPropsType), + [uploading] + ); + return ( <> @@ -186,6 +214,7 @@ export const ArchiveUploadModal: React.FunctionComponent } > - + - diff --git a/src/app/CreateRecording/SnapshotRecordingForm.tsx b/src/app/CreateRecording/SnapshotRecordingForm.tsx index 0816c3127..ccc2d39c9 100644 --- a/src/app/CreateRecording/SnapshotRecordingForm.tsx +++ b/src/app/CreateRecording/SnapshotRecordingForm.tsx @@ -38,13 +38,43 @@ import * as React from 'react'; import { ActionGroup, Button, Form, Text, TextVariants } from '@patternfly/react-core'; import { useHistory } from 'react-router-dom'; +import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { ServiceContext } from '@app/Shared/Services/Services'; +import { first } from 'rxjs'; +import { LoadingPropsType } from '@app/Shared/ProgressIndicator'; -export interface SnapshotRecordingFormProps { - onSubmit: Function; -} +export interface SnapshotRecordingFormProps {} -export const SnapshotRecordingForm = (props) => { +export const SnapshotRecordingForm: React.FunctionComponent = (props) => { const history = useHistory(); + const addSubscription = useSubscriptions(); + const context = React.useContext(ServiceContext); + const [loading, setLoading] = React.useState(false); + + const handleCreateSnapshot = React.useCallback(() => { + setLoading(true); + addSubscription( + context.api + .createSnapshot() + .pipe(first()) + .subscribe((success) => { + setLoading(false); + if (success) { + history.push('/recordings'); + } + }) + ); + }, [addSubscription, context.api, history, setLoading]); + + const createButtonLoadingProps = React.useMemo( + () => + ({ + spinnerAriaValueText: 'Creating', + spinnerAriaLabel: 'create-snapshot-recording', + isLoading: loading, + } as LoadingPropsType), + [loading] + ); return ( <> @@ -56,10 +86,10 @@ export const SnapshotRecordingForm = (props) => { the moment it is created. - - diff --git a/src/app/Events/EventTemplates.tsx b/src/app/Events/EventTemplates.tsx index 12e56691b..77b4c2bae 100644 --- a/src/app/Events/EventTemplates.tsx +++ b/src/app/Events/EventTemplates.tsx @@ -72,11 +72,13 @@ import { sortable, } from '@patternfly/react-table'; import { useHistory } from 'react-router-dom'; -import { concatMap, filter, first } from 'rxjs/operators'; +import { concatMap, filter, first, tap } from 'rxjs/operators'; import { LoadingView } from '@app/LoadingView/LoadingView'; import { authFailMessage, ErrorView, isAuthFail } from '@app/ErrorView/ErrorView'; import { DeleteWarningType } from '@app/Modal/DeleteWarningUtils'; import { DeleteWarningModal } from '@app/Modal/DeleteWarningModal'; +import { NotificationsContext } from '@app/Notifications/Notifications'; +import { LoadingPropsType } from '@app/Shared/ProgressIndicator'; export interface EventTemplatesProps {} @@ -88,11 +90,7 @@ export const EventTemplates: React.FunctionComponent = (pro const [filteredTemplates, setFilteredTemplates] = React.useState([] as EventTemplate[]); const [filterText, setFilterText] = React.useState(''); const [warningModalOpen, setWarningModalOpen] = React.useState(false); - const [modalOpen, setModalOpen] = React.useState(false); - const [uploadFile, setUploadFile] = React.useState(undefined as File | undefined); - const [uploadFilename, setUploadFilename] = React.useState(''); - const [uploading, setUploading] = React.useState(false); - const [fileRejected, setFileRejected] = React.useState(false); + const [uploadModalOpen, setUploadModalOpen] = React.useState(false); const [sortBy, setSortBy] = React.useState({} as ISortBy); const [isLoading, setIsLoading] = React.useState(false); const [errorMessage, setErrorMessage] = React.useState(''); @@ -279,65 +277,13 @@ export const EventTemplates: React.FunctionComponent = (pro return actions; }; - const handleModalToggle = React.useCallback(() => { - setModalOpen((v) => { - if (v) { - setUploadFile(undefined); - setUploadFilename(''); - setUploading(false); - } - return !v; - }); - }, [setModalOpen, setUploadFile, setUploadFilename, setUploading]); - - const handleFileChange = React.useCallback( - (value, filename) => { - setFileRejected(false); - setUploadFile(value); - setUploadFilename(filename); - }, - [setFileRejected, setUploadFile, setUploadFilename] - ); - - const handleUploadSubmit = React.useCallback(() => { - if (!uploadFile) { - window.console.error('Attempted to submit template upload without a file selected'); - return; - } - setUploading(true); - addSubscription( - context.api - .addCustomEventTemplate(uploadFile) - .pipe(first()) - .subscribe((success) => { - setUploading(false); - if (success) { - setUploadFile(undefined); - setUploadFilename(''); - setModalOpen(false); - } - }) - ); - }, [ - uploadFile, - window.console, - setUploading, - addSubscription, - context.api, - setUploadFile, - setUploadFilename, - setModalOpen, - ]); - - const handleUploadCancel = React.useCallback(() => { - setUploadFile(undefined); - setUploadFilename(''); - setModalOpen(false); - }, [setUploadFile, setUploadFilename, setModalOpen]); + const handleUploadModalClose = React.useCallback(() => { + setUploadModalOpen(false); + }, [setUploadModalOpen]); - const handleFileRejected = React.useCallback(() => { - setFileRejected(true); - }, [setFileRejected]); + const handleUploadModalOpen = React.useCallback(() => { + setUploadModalOpen(true); + }, [setUploadModalOpen]); const handleSort = React.useCallback( (event, index, direction) => { @@ -400,7 +346,12 @@ export const EventTemplates: React.FunctionComponent = (pro - @@ -434,45 +385,123 @@ export const EventTemplates: React.FunctionComponent = (pro )} - -
- - - - - - - -
-
+ ); } }; + +export interface EventTemplatesUploadModalProps { + isOpen: boolean; + onClose: () => void; +} + +export const EventTemplatesUploadModal: React.FunctionComponent = (props) => { + const [uploadFile, setUploadFile] = React.useState(undefined); + const [uploadFilename, setUploadFilename] = React.useState(''); + const [uploading, setUploading] = React.useState(false); + const [fileRejected, setFileRejected] = React.useState(false); + const addSubscription = useSubscriptions(); + const context = React.useContext(ServiceContext); + const notifications = React.useContext(NotificationsContext); + + const reset = React.useCallback(() => { + setUploadFile(undefined); + setUploadFilename(''); + setUploading(false); + setFileRejected(false); + }, [setUploadFile, setUploadFilename, setUploading, setFileRejected]); + + const handleClose = React.useCallback(() => { + reset(); + props.onClose(); + }, [reset, props.onClose]); + + const handleFileRejected = React.useCallback(() => { + setFileRejected(true); + }, [setFileRejected]); + + const handleFileChange = React.useCallback( + (file, filename) => { + setFileRejected(false); + setUploadFile(file); + setUploadFilename(filename); + }, + [setFileRejected, setUploadFile, setUploadFilename] + ); + + const handleUploadSubmit = React.useCallback(() => { + if (fileRejected) { + notifications.warning('File format is not compatible'); + return; + } + if (!uploadFile) { + notifications.warning('Attempted to submit template upload without a file selected'); + return; + } + setUploading(true); + addSubscription( + context.api + .addCustomEventTemplate(uploadFile) + .pipe(first()) + .subscribe((success) => { + setUploading(false); + if (success) { + handleClose(); + } + }) + ); + }, [fileRejected, uploadFile, window.console, setUploading, addSubscription, context.api, handleClose]); + + const submitButtonLoadingProps = React.useMemo( + () => + ({ + spinnerAriaValueText: 'Submitting', + spinnerAriaLabel: 'submitting-custom-event-template', + isLoading: uploading, + } as LoadingPropsType), + [uploading] + ); + + return ( + +
+ + + + + + + +
+
+ ); +}; diff --git a/src/app/RecordingMetadata/BulkEditLabels.tsx b/src/app/RecordingMetadata/BulkEditLabels.tsx index 473f85aa2..8f844eaea 100644 --- a/src/app/RecordingMetadata/BulkEditLabels.tsx +++ b/src/app/RecordingMetadata/BulkEditLabels.tsx @@ -55,6 +55,7 @@ import { NO_TARGET } from '@app/Shared/Services/Target.service'; import { NotificationCategory } from '@app/Shared/Services/NotificationChannel.service'; import { hashCode } from '@app/utils/utils'; import { uploadAsTarget } from '@app/Archives/Archives'; +import { LoadingPropsType } from '@app/Shared/ProgressIndicator'; export interface BulkEditLabelsProps { isTargetRecording: boolean; @@ -71,6 +72,7 @@ export const BulkEditLabels: React.FunctionComponent = (pro const [commonLabels, setCommonLabels] = React.useState([] as RecordingLabel[]); const [savedCommonLabels, setSavedCommonLabels] = React.useState([] as RecordingLabel[]); const [valid, setValid] = React.useState(ValidatedOptions.default); + const [loading, setLoading] = React.useState(false); const addSubscription = useSubscriptions(); const getIdxFromRecording = React.useCallback( @@ -78,7 +80,13 @@ export const BulkEditLabels: React.FunctionComponent = (pro [hashCode, props.isTargetRecording] ); + const handlePostUpdate = React.useCallback(() => { + setEditing(false); + setLoading(false); + }, [setLoading, setEditing]); + const handleUpdateLabels = React.useCallback(() => { + setLoading(true); const tasks: Observable[] = []; const toDelete = savedCommonLabels.filter((label) => !includesLabel(commonLabels, label)); @@ -103,7 +111,12 @@ export const BulkEditLabels: React.FunctionComponent = (pro } } }); - addSubscription(forkJoin(tasks).subscribe(() => setEditing((editing) => !editing))); + addSubscription( + forkJoin(tasks).subscribe({ + next: handlePostUpdate, + error: handlePostUpdate, + }) + ); }, [ addSubscription, recordings, @@ -113,11 +126,11 @@ export const BulkEditLabels: React.FunctionComponent = (pro props.directory, props.directoryRecordings, editing, - setEditing, commonLabels, savedCommonLabels, parseLabels, context.api, + handlePostUpdate, ]); const handleEditLabels = React.useCallback(() => { @@ -227,6 +240,16 @@ export const BulkEditLabels: React.FunctionComponent = (pro setRecordings, ]); + const saveButtonLoadingProps = React.useMemo( + () => + ({ + spinnerAriaValueText: 'Saving', + spinnerAriaLabel: 'saving-recording-labels', + isLoading: loading, + } as LoadingPropsType), + [loading] + ); + React.useEffect(() => { addSubscription(context.target.target().subscribe(refreshRecordingList)); }, [addSubscription, context, context.target, refreshRecordingList]); @@ -296,15 +319,25 @@ export const BulkEditLabels: React.FunctionComponent = (pro {editing ? ( <> - + - - diff --git a/src/app/RecordingMetadata/RecordingLabelFields.tsx b/src/app/RecordingMetadata/RecordingLabelFields.tsx index 792cc045f..87e721e80 100644 --- a/src/app/RecordingMetadata/RecordingLabelFields.tsx +++ b/src/app/RecordingMetadata/RecordingLabelFields.tsx @@ -61,6 +61,7 @@ export interface RecordingLabelFieldsProps { setLabels: (labels: RecordingLabel[]) => void; setValid: (isValid: ValidatedOptions) => void; isUploadable?: boolean; + isDisabled?: boolean; } export const LabelPattern = /^\S+$/; @@ -189,7 +190,13 @@ export const RecordingLabelFields: React.FunctionComponent ) : ( <> - {props.isUploadable && ( @@ -217,7 +224,13 @@ export const RecordingLabelFields: React.FunctionComponent } > - @@ -244,6 +257,7 @@ export const RecordingLabelFields: React.FunctionComponent handleKeyChange(idx, key)} validated={validKeys[idx]} + isDisabled={props.isDisabled} /> Key handleValueChange(idx, value)} validated={validValues[idx]} + isDisabled={props.isDisabled} /> Value @@ -276,6 +291,7 @@ export const RecordingLabelFields: React.FunctionComponent handleDeleteLabelButtonClick(idx)} variant="link" aria-label="Remove Label" + isDisabled={props.isDisabled} icon={} /> diff --git a/src/app/Recordings/ActiveRecordingsTable.tsx b/src/app/Recordings/ActiveRecordingsTable.tsx index 1f767de3a..81b7cf192 100644 --- a/src/app/Recordings/ActiveRecordingsTable.tsx +++ b/src/app/Recordings/ActiveRecordingsTable.tsx @@ -83,6 +83,7 @@ import { import { TargetRecordingFilters, UpdateFilterOptions } from '@app/Shared/Redux/RecordingFilterReducer'; import { RootState, StateDispatch } from '@app/Shared/Redux/ReduxStore'; import { authFailMessage } from '@app/ErrorView/ErrorView'; +import { LoadingPropsType } from '@app/Shared/ProgressIndicator'; export enum PanelContent { LABELS, @@ -108,6 +109,11 @@ export const ActiveRecordingsTable: React.FunctionComponent>({ + ARCHIVE: false, + DELETE: false, + STOP: false, + }); const targetRecordingFilters = useSelector((state: RootState) => { const filters = state.recordingFilters.list.filter( @@ -313,7 +319,19 @@ export const ActiveRecordingsTable: React.FunctionComponent window.clearInterval(id); }, [refreshRecordingList, context, context.settings]); + const handlePostActions = React.useCallback( + (action: ActiveActions) => { + setActionLoadings((old) => { + const newActionLoadings = { ...old }; + newActionLoadings[action] = false; + return newActionLoadings; + }); + }, + [setActionLoadings] + ); + const handleArchiveRecordings = React.useCallback(() => { + setActionLoadings((old) => ({ ...old, ARCHIVE: true })); const tasks: Observable[] = []; filteredRecordings.forEach((r: ActiveRecording) => { if (checkedIndices.includes(r.id)) { @@ -321,10 +339,24 @@ export const ActiveRecordingsTable: React.FunctionComponent {} /* do nothing */, window.console.error)); - }, [filteredRecordings, checkedIndices, handleRowCheck, context.api, addSubscription]); + addSubscription( + forkJoin(tasks).subscribe({ + next: () => handlePostActions('ARCHIVE'), + error: () => handlePostActions('ARCHIVE'), + }) + ); + }, [ + filteredRecordings, + checkedIndices, + handleRowCheck, + context.api, + addSubscription, + setActionLoadings, + handlePostActions, + ]); const handleStopRecordings = React.useCallback(() => { + setActionLoadings((old) => ({ ...old, STOP: true })); const tasks: Observable[] = []; filteredRecordings.forEach((r: ActiveRecording) => { if (checkedIndices.includes(r.id)) { @@ -334,10 +366,24 @@ export const ActiveRecordingsTable: React.FunctionComponent {} /* do nothing */, window.console.error)); - }, [filteredRecordings, checkedIndices, handleRowCheck, context.api, addSubscription]); + addSubscription( + forkJoin(tasks).subscribe({ + next: () => handlePostActions('STOP'), + error: () => handlePostActions('STOP'), + }) + ); + }, [ + filteredRecordings, + checkedIndices, + handleRowCheck, + context.api, + addSubscription, + setActionLoadings, + handlePostActions, + ]); const handleDeleteRecordings = React.useCallback(() => { + setActionLoadings((old) => ({ ...old, DELETE: true })); const tasks: Observable<{}>[] = []; filteredRecordings.forEach((r: ActiveRecording) => { if (checkedIndices.includes(r.id)) { @@ -345,8 +391,21 @@ export const ActiveRecordingsTable: React.FunctionComponent {} /* do nothing */, window.console.error)); - }, [filteredRecordings, checkedIndices, context.reports, context.api, addSubscription]); + addSubscription( + forkJoin(tasks).subscribe({ + next: () => handlePostActions('DELETE'), + error: () => handlePostActions('DELETE'), + }) + ); + }, [ + filteredRecordings, + checkedIndices, + context.reports, + context.api, + addSubscription, + setActionLoadings, + handlePostActions, + ]); const handleClearFilters = React.useCallback(() => { dispatch(deleteAllFiltersIntent(targetConnectURL, false)); @@ -542,6 +601,7 @@ export const ActiveRecordingsTable: React.FunctionComponent ), [ @@ -558,6 +618,7 @@ export const ActiveRecordingsTable: React.FunctionComponent void; handleStopRecordings: () => void; handleDeleteRecordings: () => void; + actionLoadings: Record; } const ActiveRecordingsToolbar: React.FunctionComponent = (props) => { @@ -652,6 +716,27 @@ const ActiveRecordingsToolbar: React.FunctionComponent>( + () => ({ + ARCHIVE: { + spinnerAriaValueText: 'Archiving', + spinnerAriaLabel: 'archive-active-recording', + isLoading: props.actionLoadings['ARCHIVE'], + }, + STOP: { + spinnerAriaValueText: 'Stopping', + spinnerAriaLabel: 'stop-active-recording', + isLoading: props.actionLoadings['STOP'], + }, + DELETE: { + spinnerAriaValueText: 'Deleting', + spinnerAriaLabel: 'deleting-active-recording', + isLoading: props.actionLoadings['DELETE'], + }, + }), + [props.actionLoadings] + ); + const buttons = React.useMemo(() => { let arr = [ ); } @@ -680,11 +766,23 @@ const ActiveRecordingsToolbar: React.FunctionComponent Edit Labels , - , - , ]; return ( @@ -700,7 +798,9 @@ const ActiveRecordingsToolbar: React.FunctionComponent { diff --git a/src/app/Recordings/ArchivedRecordingsTable.tsx b/src/app/Recordings/ArchivedRecordingsTable.tsx index 6c0dab506..63f9e2127 100644 --- a/src/app/Recordings/ArchivedRecordingsTable.tsx +++ b/src/app/Recordings/ArchivedRecordingsTable.tsx @@ -79,6 +79,7 @@ import { } from '@app/Shared/Redux/RecordingFilterActions'; import { RootState, StateDispatch } from '@app/Shared/Redux/ReduxStore'; import { formatBytes, hashCode } from '@app/utils/utils'; +import { LoadingPropsType } from '@app/Shared/ProgressIndicator'; export interface ArchivedRecordingsTableProps { target: Observable; @@ -103,6 +104,7 @@ export const ArchivedRecordingsTable: React.FunctionComponent>({ DELETE: false }); const targetRecordingFilters = useSelector((state: RootState) => { const filters = state.recordingFilters.list.filter( @@ -346,7 +348,19 @@ export const ArchivedRecordingsTable: React.FunctionComponent { + setActionLoadings((old) => { + const newActionLoadings = { ...old }; + newActionLoadings[action] = false; + return newActionLoadings; + }); + }, + [setActionLoadings] + ); + const handleDeleteRecordings = React.useCallback(() => { + setActionLoadings((old) => ({ ...old, DELETE: true })); const tasks: Observable[] = []; if (props.directory) { const directory = props.directory; @@ -366,11 +380,25 @@ export const ArchivedRecordingsTable: React.FunctionComponent handlePostActions('DELETE'), + error: () => handlePostActions('DELETE'), + }) + ); }) ); } - }, [addSubscription, filteredRecordings, checkedIndices, context.reports, context.api, props.directory]); + }, [ + addSubscription, + filteredRecordings, + checkedIndices, + context.reports, + context.api, + props.directory, + setActionLoadings, + handlePostActions, + ]); const toggleExpanded = React.useCallback( (id: string) => { @@ -508,6 +536,7 @@ export const ArchivedRecordingsTable: React.FunctionComponent setShowUploadModal(true)} isUploadsTable={props.isUploadsTable} + actionLoadings={actionLoadings} /> ), [ @@ -522,6 +551,7 @@ export const ArchivedRecordingsTable: React.FunctionComponent void; handleShowUploadModal: () => void; isUploadsTable: boolean; + actionLoadings: Record; } const ArchivedRecordingsToolbar: React.FunctionComponent = (props) => { @@ -660,6 +693,17 @@ const ArchivedRecordingsToolbar: React.FunctionComponent>( + () => ({ + DELETE: { + spinnerAriaValueText: 'Deleting', + spinnerAriaLabel: 'deleting-archived-recording', + isLoading: props.actionLoadings['DELETE'], + } as LoadingPropsType, + }), + [props.actionLoadings] + ); + return ( -
diff --git a/src/app/Rules/CreateRule.tsx b/src/app/Rules/CreateRule.tsx index def9f3248..85b97d12c 100644 --- a/src/app/Rules/CreateRule.tsx +++ b/src/app/Rules/CreateRule.tsx @@ -56,23 +56,24 @@ import { ValidatedOptions, } from '@patternfly/react-core'; import { useHistory, withRouter } from 'react-router-dom'; -import { filter, first, mergeMap, toArray } from 'rxjs/operators'; +import { iif, of } from 'rxjs'; +import { catchError, filter, first, mergeMap, toArray } from 'rxjs/operators'; import { ServiceContext } from '@app/Shared/Services/Services'; import { NotificationsContext } from '@app/Notifications/Notifications'; import { BreadcrumbPage, BreadcrumbTrail } from '@app/BreadcrumbPage/BreadcrumbPage'; import { useSubscriptions } from '@app/utils/useSubscriptions'; -import { EventTemplate } from '../CreateRecording/CreateRecording'; -import { Rule } from './Rules'; -import { MatchExpressionEvaluator } from '../Shared/MatchExpressionEvaluator'; -import { FormSelectTemplateSelector } from '../TemplateSelector/FormSelectTemplateSelector'; +import { EventTemplate, TemplateType } from '@app/CreateRecording/CreateRecording'; +import { MatchExpressionEvaluator } from '@app/Shared/MatchExpressionEvaluator'; +import { SelectTemplateSelectorForm } from '@app/TemplateSelector/SelectTemplateSelectorForm'; import { NO_TARGET } from '@app/Shared/Services/Target.service'; -import { iif } from 'rxjs'; import { authFailMessage, ErrorView, isAuthFail } from '@app/ErrorView/ErrorView'; +import { LoadingPropsType } from '@app/Shared/ProgressIndicator'; +import { Rule } from './Rules'; // FIXME check if this is correct/matches backend name validation export const RuleNamePattern = /^[\w_]+$/; -const Comp = () => { +const Comp: React.FunctionComponent<{}> = () => { const context = React.useContext(ServiceContext); const notifications = React.useContext(NotificationsContext); const history = useHistory(); @@ -85,8 +86,8 @@ const Comp = () => { const [matchExpression, setMatchExpression] = React.useState(''); const [matchExpressionValid, setMatchExpressionValid] = React.useState(ValidatedOptions.default); const [templates, setTemplates] = React.useState([] as EventTemplate[]); - const [template, setTemplate] = React.useState(null as string | null); - const [templateType, setTemplateType] = React.useState(null as string | null); + const [templateName, setTemplateName] = React.useState(undefined); + const [templateType, setTemplateType] = React.useState(undefined); const [maxAge, setMaxAge] = React.useState(0); const [maxAgeUnits, setMaxAgeUnits] = React.useState(1); const [maxSize, setMaxSize] = React.useState(0); @@ -97,6 +98,7 @@ const Comp = () => { const [initialDelayUnits, setInitialDelayUnits] = React.useState(1); const [preservedArchives, setPreservedArchives] = React.useState(0); const [errorMessage, setErrorMessage] = React.useState(''); + const [loading, setLoading] = React.useState(false); const handleNameChange = React.useCallback( (evt) => { @@ -108,22 +110,21 @@ const Comp = () => { const eventSpecifierString = React.useMemo(() => { var str = ''; - if (template) { - str += `template=${template}`; + if (templateName) { + str += `template=${templateName}`; } if (templateType) { str += `,type=${templateType}`; } return str; - }, [template]); + }, [templateName]); const handleTemplateChange = React.useCallback( - (template) => { - const parts: string[] = template.split(','); - setTemplate(parts[0]); - setTemplateType(parts[1]); + (templateName?: string, templateType?: TemplateType) => { + setTemplateName(templateName); + setTemplateType(templateType); }, - [setTemplate, setTemplateType] + [setTemplateName, setTemplateType] ); const handleMaxAgeChange = React.useCallback((evt) => setMaxAge(Number(evt)), [setMaxAge]); @@ -156,6 +157,7 @@ const Comp = () => { const handleError = React.useCallback((error) => setErrorMessage(error.message), [setErrorMessage]); const handleSubmit = React.useCallback((): void => { + setLoading(true); const notificationMessages: string[] = []; if (nameValid !== ValidatedOptions.success) { notificationMessages.push(`Rule name ${name} is invalid`); @@ -178,16 +180,15 @@ const Comp = () => { maxSizeBytes: maxSize * maxSizeUnits, }; addSubscription( - context.api - .createRule(rule) - .pipe(first()) - .subscribe((success) => { - if (success) { - history.push('/rules'); - } - }) + context.api.createRule(rule).subscribe((success) => { + setLoading(false); + if (success) { + history.push('/rules'); + } + }) ); }, [ + setLoading, addSubscription, context, context.api, @@ -214,7 +215,7 @@ const Comp = () => { setTemplates(templates); setErrorMessage(''); }, - [setTemplate, setErrorMessage] + [setTemplateName, setErrorMessage] ); const refreshTemplateList = React.useCallback(() => { @@ -261,6 +262,16 @@ const Comp = () => { }, ]; + const createButtonLoadingProps = React.useMemo( + () => + ({ + spinnerAriaValueText: 'Creating', + spinnerAriaLabel: 'creating-automatic-rule', + isLoading: loading, + } as LoadingPropsType), + [loading] + ); + const authRetry = React.useCallback(() => { context.target.setAuthRetry(); }, [context.target, context.target.setAuthRetry]); @@ -294,6 +305,7 @@ const Comp = () => { > { > { > { > { label="Template" isRequired fieldId="recording-template" - validated={ - template === null - ? ValidatedOptions.default - : !!template - ? ValidatedOptions.success - : ValidatedOptions.error - } + validated={!templateName ? ValidatedOptions.default : ValidatedOptions.success} helperText="The Event Template to be applied by this Rule against matching target applications." helperTextInvalid="A Template must be selected" > - { { @@ -414,6 +426,7 @@ const Comp = () => { { @@ -444,6 +458,7 @@ const Comp = () => { { @@ -474,6 +490,7 @@ const Comp = () => { { @@ -502,6 +520,7 @@ const Comp = () => { > { variant="primary" onClick={handleSubmit} isDisabled={ - nameValid !== ValidatedOptions.success || !template || !templateType || !matchExpression + loading || + nameValid !== ValidatedOptions.success || + !templateName || + !templateType || + !matchExpression } + {...createButtonLoadingProps} > - Create + {loading ? 'Creating' : 'Create'} - diff --git a/src/app/Rules/Rules.tsx b/src/app/Rules/Rules.tsx index a83d9e005..05709ef1d 100644 --- a/src/app/Rules/Rules.tsx +++ b/src/app/Rules/Rules.tsx @@ -90,6 +90,28 @@ export interface Rule { maxSizeBytes: number; } +export const ruleObjKeys = [ + 'name', + 'description', + 'matchExpression', + 'enabled', + 'eventSpecifier', + 'archivalPeriodSeconds', + 'initialDelaySeconds', + 'preservedArchives', + 'maxAgeSeconds', + 'maxSizeBytes', +]; + +export const isRule = (obj: Object): boolean => { + for (const key of ruleObjKeys) { + if (!obj.hasOwnProperty(key)) { + return false; + } + } // Ignore unknown fields + return true; +}; + export interface RulesProps {} export const Rules: React.FunctionComponent = (props) => { diff --git a/src/app/Rules/RulesUploadModal.tsx b/src/app/Rules/RulesUploadModal.tsx index 37c9c665a..b351bc482 100644 --- a/src/app/Rules/RulesUploadModal.tsx +++ b/src/app/Rules/RulesUploadModal.tsx @@ -43,9 +43,10 @@ import { NotificationsContext } from '@app/Notifications/Notifications'; import { CancelUploadModal } from '@app/Modal/CancelUploadModal'; import { useSubscriptions } from '@app/utils/useSubscriptions'; import { HelpIcon } from '@patternfly/react-icons'; -import { Rule } from './Rules'; -import { from, mergeMap, Observable, of } from 'rxjs'; -import { catchError, first } from 'rxjs/operators'; +import { isRule, Rule } from './Rules'; +import { from, Observable, of } from 'rxjs'; +import { catchError, concatMap, first } from 'rxjs/operators'; +import { LoadingPropsType } from '@app/Shared/ProgressIndicator'; export interface RuleUploadModalProps { visible: boolean; @@ -53,7 +54,16 @@ export interface RuleUploadModalProps { } export const parseRule = (file: File): Observable => { - return from(file.text().then(JSON.parse)); + return from( + file.text().then((content) => { + const obj = JSON.parse(content); + if (isRule(obj)) { + return obj; + } else { + throw new Error('Automatic rule content is invalid.'); + } + }) + ); }; export const RuleUploadModal: React.FunctionComponent = (props) => { @@ -101,6 +111,10 @@ export const RuleUploadModal: React.FunctionComponent = (p }, [uploading, setShowCancelPrompt, reset]); const handleSubmit = React.useCallback(() => { + if (rejected) { + notifications.warning('File format is not compatible'); + return; + } if (!uploadFile) { notifications.warning('Attempted to submit automated rule without a file selected'); return; @@ -110,11 +124,10 @@ export const RuleUploadModal: React.FunctionComponent = (p parseRule(uploadFile) .pipe( first(), - mergeMap((rule) => context.api.createRule(rule)), - catchError((err, _) => { - if (err instanceof SyntaxError) { - notifications.danger('Automated rule upload failed', err.message); - } + concatMap((rule) => context.api.createRule(rule)), // FIXME: Add abort signal to request + catchError((err) => { + // parseRule might throw + notifications.danger('Automated rule upload failed', err.message); return of(false); }) ) @@ -125,13 +138,25 @@ export const RuleUploadModal: React.FunctionComponent = (p } }) ); - }, [context.api, notifications, setUploading, uploadFile, handleClose]); + }, [context.api, notifications, setUploading, uploadFile, rejected, handleClose]); const handleAbort = React.useCallback(() => { abort.abort(); reset(); props.onClose(); - }, [abort, reset]); + }, [abort, reset, props.onClose]); + + const handleCloseCancelModal = React.useCallback(() => setShowCancelPrompt(false), [setShowCancelPrompt]); + + const submitButtonLoadingProps = React.useMemo( + () => + ({ + spinnerAriaValueText: 'Submitting', + spinnerAriaLabel: 'submitting-automatic-rule', + isLoading: uploading, + } as LoadingPropsType), + [uploading] + ); return ( <> @@ -139,7 +164,7 @@ export const RuleUploadModal: React.FunctionComponent = (p = (p title="Upload in Progress" message="Are you sure you wish to cancel the file upload?" onYes={handleAbort} - onNo={() => setShowCancelPrompt(false)} + onNo={handleCloseCancelModal} />
@@ -175,6 +200,7 @@ export const RuleUploadModal: React.FunctionComponent = (p value={uploadFile} filename={filename} onChange={handleFileChange} + isDisabled={uploading} isLoading={uploading} validated={rejected ? 'error' : 'default'} dropzoneProps={{ @@ -184,10 +210,15 @@ export const RuleUploadModal: React.FunctionComponent = (p /> - - diff --git a/src/app/SecurityPanel/CertificateUploadModal.tsx b/src/app/SecurityPanel/CertificateUploadModal.tsx index 077254b89..81ef96b54 100644 --- a/src/app/SecurityPanel/CertificateUploadModal.tsx +++ b/src/app/SecurityPanel/CertificateUploadModal.tsx @@ -37,9 +37,10 @@ */ import * as React from 'react'; import { ActionGroup, Button, FileUpload, Form, FormGroup, Modal, ModalVariant } from '@patternfly/react-core'; -import { first, tap } from 'rxjs/operators'; +import { first } from 'rxjs/operators'; import { ServiceContext } from '@app/Shared/Services/Services'; import { NotificationsContext } from '@app/Notifications/Notifications'; +import { LoadingPropsType } from '@app/Shared/ProgressIndicator'; export interface CertificateUploadModalProps { visible: boolean; @@ -54,29 +55,32 @@ export const CertificateUploadModal: React.FunctionComponent { + const reset = React.useCallback(() => { setUploadFile(undefined); setFilename(''); setUploading(false); setRejected(true); - }; + }, [setUploadFile, setFilename, setUploading, setRejected]); - const handleFileChange = (file, filename) => { - setRejected(false); - setUploadFile(file); - setFilename(filename); - }; + const handleFileChange = React.useCallback( + (file, filename) => { + setRejected(false); + setUploadFile(file); + setFilename(filename); + }, + [setRejected, setUploadFile, setFilename] + ); - const handleReject = () => { + const handleReject = React.useCallback(() => { setRejected(true); - }; + }, [setRejected]); - const handleClose = () => { + const handleClose = React.useCallback(() => { reset(); props.onClose(); - }; + }, [reset, props.onClose]); - const handleSubmit = () => { + const handleSubmit = React.useCallback(() => { if (rejected) { notifications.warning('File format is not compatible'); return; @@ -93,18 +97,30 @@ export const CertificateUploadModal: React.FunctionComponent setUploading(false)) - ) - .subscribe(handleClose, reset); - }; + .pipe(first()) + .subscribe((success) => { + setUploading(false); + if (success) { + handleClose(); + } + }); + }, [rejected, uploadFile, notifications, setUploading, context.api, handleClose]); + + const submitButtonLoadingProps = React.useMemo( + () => + ({ + spinnerAriaValueText: 'Submitting', + spinnerAriaLabel: 'submitting-ssl-certitficates', + isLoading: uploading, + } as LoadingPropsType), + [uploading] + ); return ( - - diff --git a/src/app/SecurityPanel/Credentials/CreateJmxCredentialModal.tsx b/src/app/SecurityPanel/Credentials/CreateJmxCredentialModal.tsx index e00f2d61a..216877888 100644 --- a/src/app/SecurityPanel/Credentials/CreateJmxCredentialModal.tsx +++ b/src/app/SecurityPanel/Credentials/CreateJmxCredentialModal.tsx @@ -47,7 +47,7 @@ import { } from '@patternfly/react-core'; import { JmxAuthForm } from '@app/AppLayout/JmxAuthForm'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { first } from 'rxjs'; +import { catchError, first, of } from 'rxjs'; import { MatchExpressionEvaluator } from '@app/Shared/MatchExpressionEvaluator'; import { useSubscriptions } from '@app/utils/useSubscriptions'; @@ -61,22 +61,32 @@ export const CreateJmxCredentialModal: React.FunctionComponent => { - return new Promise((resolve) => { + setLoading(true); + return new Promise((resolve, reject) => { addSubscription( context.api .postCredentials(matchExpression, username, password) - .pipe(first()) - .subscribe(() => { - props.onClose(); - resolve(); + .pipe( + first(), + catchError((_) => of(false)) + ) + .subscribe((ok) => { + setLoading(false); + if (ok) { + props.onClose(); + resolve(); + } else { + reject(); + } }) ); }); }, - [props.onClose, context, context.target, context.api, matchExpression] + [props.onClose, context.target, context.api, matchExpression, setLoading] ); return ( @@ -107,6 +117,7 @@ export const CreateJmxCredentialModal: React.FunctionComponent JSX.Element; - placeholder: JSX.Element; - customGroup: (el: JSX.Element[]) => JSX.Element; - targetGroup: (el: JSX.Element[]) => JSX.Element; +export interface LoadingPropsType { + spinnerAriaValueText?: string; // Text describing that current loading status or progress + spinnerAriaLabelledBy?: string; // Id of element which describes what is being loaded + spinnerAriaLabel?: string; // Accessible label for the spinner to describe what is loading + isLoading: boolean; } - -export const TemplateSelector: React.FunctionComponent = (props) => { - const offset = props.templates.filter((t) => t.type === 'CUSTOM').length; - - const customs = props.templates - .filter((t) => t.type === 'CUSTOM') - .map((t: EventTemplate, idx: number) => props.templateMapper(t, idx, offset)); - const targets = props.templates - .filter((t) => t.type === 'TARGET') - .map((t: EventTemplate, idx: number) => props.templateMapper(t, idx, offset)); - - return ( - <> - {props.children} - {props.placeholder} - {props.customGroup(customs)} - {props.targetGroup(targets)} - - ); -}; diff --git a/src/app/Shared/Services/Api.service.tsx b/src/app/Shared/Services/Api.service.tsx index 772870e51..5a432088b 100644 --- a/src/app/Shared/Services/Api.service.tsx +++ b/src/app/Shared/Services/Api.service.tsx @@ -179,6 +179,7 @@ export class ApiService { body: form, }).pipe( map((resp) => resp.ok), + catchError(() => of(false)), first() ); } @@ -188,6 +189,7 @@ export class ApiService { method: 'DELETE', }).pipe( map((resp) => resp.ok), + catchError(() => of(false)), first() ); } @@ -201,6 +203,7 @@ export class ApiService { headers, }).pipe( map((resp) => resp.ok), + catchError((_) => of(false)), first() ); } @@ -461,13 +464,8 @@ export class ApiService { method: 'POST', body, }).pipe( - map((response) => { - if (!response.ok) { - throw response.statusText; - } - return true; - }), - catchError((): ObservableInput => of(false)) + map((resp) => resp.ok), + catchError((_) => of(false)) ); } @@ -521,12 +519,7 @@ export class ApiService { method: 'POST', body, }).pipe( - map((response) => { - if (!response.ok) { - throw response.statusText; - } - return true; - }), + map((resp) => resp.ok), catchError((): ObservableInput => of(false)) ); } @@ -734,20 +727,15 @@ export class ApiService { ); } - uploadSSLCertificate(file: File): Observable { + uploadSSLCertificate(file: File): Observable { const body = new window.FormData(); body.append('cert', file); return this.sendRequest('v2', 'certificates', { method: 'POST', body, }).pipe( - concatMap((resp) => { - if (resp.ok) { - this.notifications.success('Successfully uploaded certificate'); - return from(resp.text()); - } - throw resp.statusText; - }) + map((resp) => resp.ok), + catchError((_) => of(false)) ); } diff --git a/src/app/TargetSelect/CreateTargetModal.tsx b/src/app/TargetSelect/CreateTargetModal.tsx index 1bdd508ef..a8238065e 100644 --- a/src/app/TargetSelect/CreateTargetModal.tsx +++ b/src/app/TargetSelect/CreateTargetModal.tsx @@ -35,7 +35,10 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ +import { ServiceContext } from '@app/Shared/Services/Services'; import { Target } from '@app/Shared/Services/Target.service'; +import { LoadingPropsType } from '@app/Shared/ProgressIndicator'; +import { useSubscriptions } from '@app/utils/useSubscriptions'; import { ActionGroup, Button, @@ -51,7 +54,7 @@ import * as React from 'react'; export interface CreateTargetModalProps { visible: boolean; - onSubmit: (target: Target) => void; + onSuccess: () => void; onDismiss: () => void; } @@ -59,34 +62,63 @@ const jmxServiceUrlFormat = /service:jmx:([a-zA-Z0-9-]+)/g; const hostPortPairFormat = /([a-zA-Z0-9-]+):([0-9]+)/g; export const CreateTargetModal: React.FunctionComponent = (props) => { + const addSubscription = useSubscriptions(); + const context = React.useContext(ServiceContext); + const [connectUrl, setConnectUrl] = React.useState(''); const [validConnectUrl, setValidConnectUrl] = React.useState(ValidatedOptions.default); const [alias, setAlias] = React.useState(''); + const [loading, setLoading] = React.useState(false); - const createTarget = React.useCallback(() => { - props.onSubmit({ connectUrl, alias: alias.trim() || connectUrl }); + const resetForm = React.useCallback(() => { setConnectUrl(''); setAlias(''); - }, [props.onSubmit, connectUrl, alias, setConnectUrl, setAlias]); + }, [setConnectUrl, setAlias]); + + const createTarget = React.useCallback( + (target: Target) => { + setLoading(true); + addSubscription( + context.api.createTarget(target).subscribe((success) => { + setLoading(false); + if (success) { + resetForm(); + props.onSuccess(); + } + }) + ); + }, + [addSubscription, context.api, props.onSuccess, setLoading, resetForm] + ); const handleKeyDown = React.useCallback( (evt) => { if (connectUrl && evt.key === 'Enter') { - createTarget(); + createTarget({ connectUrl, alias: alias.trim() || connectUrl }); } }, - [createTarget, connectUrl] + [createTarget, connectUrl, alias] ); const handleSubmit = React.useCallback(() => { const isValid = connectUrl && (connectUrl.match(jmxServiceUrlFormat) || connectUrl.match(hostPortPairFormat)); if (isValid) { - createTarget(); + createTarget({ connectUrl, alias: alias.trim() || connectUrl }); } else { setValidConnectUrl(ValidatedOptions.error); } - }, [createTarget, setValidConnectUrl, connectUrl]); + }, [createTarget, setValidConnectUrl, connectUrl, alias]); + + const createButtonLoadingProps = React.useMemo( + () => + ({ + spinnerAriaValueText: 'Creating', + spinnerAriaLabel: 'creating-custom-target', + isLoading: loading, + } as LoadingPropsType), + [loading] + ); return ( <> @@ -117,15 +149,29 @@ export const CreateTargetModal: React.FunctionComponent id="connect-url" onChange={setConnectUrl} onKeyDown={handleKeyDown} + isDisabled={loading} /> - + - diff --git a/src/app/TargetSelect/TargetSelect.tsx b/src/app/TargetSelect/TargetSelect.tsx index 006dad612..b7a92ae1c 100644 --- a/src/app/TargetSelect/TargetSelect.tsx +++ b/src/app/TargetSelect/TargetSelect.tsx @@ -53,14 +53,14 @@ import { SelectVariant, } from '@patternfly/react-core'; import { ContainerNodeIcon, PlusCircleIcon, TrashIcon } from '@patternfly/react-icons'; -import { of } from 'rxjs'; -import { catchError, first } from 'rxjs/operators'; import { CreateTargetModal } from './CreateTargetModal'; import { DeleteWarningType } from '@app/Modal/DeleteWarningUtils'; import { DeleteWarningModal } from '@app/Modal/DeleteWarningModal'; import { getFromLocalStorage, removeFromLocalStorage, saveToLocalStorage } from '@app/utils/LocalStorage'; import { SerializedTarget } from '@app/Shared/SerializedTarget'; import { NoTargetSelected } from '@app/TargetView/NoTargetSelected'; +import { first } from 'rxjs'; +import { LoadingPropsType } from '@app/Shared/ProgressIndicator'; export const CUSTOM_TARGETS_REALM = 'Custom Targets'; @@ -171,45 +171,23 @@ export const TargetSelect: React.FunctionComponent = (props) setModalOpen(true); }, [setModalOpen]); - const createTarget = React.useCallback( - (target: Target) => { - setLoading(true); - addSubscription( - context.api - .createTarget(target) - .pipe( - first(), - catchError(() => of(false)) - ) - .subscribe((success) => { - setLoading(false); - setModalOpen(false); - if (!success) { - notifications.danger('Target Creation Failed'); - } - }) - ); - }, - [addSubscription, context.api, notifications, setLoading, setModalOpen] - ); + const closeCreateTargetModal = React.useCallback(() => { + setModalOpen(false); + }, [setModalOpen]); const deleteTarget = React.useCallback(() => { setLoading(true); addSubscription( - context.api - .deleteTarget(selected) - .pipe(first()) - .subscribe({ - next: () => setLoading(false), - error: () => { - setLoading(false); - const id = - !selected.alias || selected.alias === selected.connectUrl - ? selected.connectUrl - : `${selected.alias} [${selected.connectUrl}]`; - notifications.danger('Target Deletion Failed', `The selected target (${id}) could not be deleted`); - }, - }) + context.api.deleteTarget(selected).subscribe((ok) => { + setLoading(false); + if (!ok) { + const id = + !selected.alias || selected.alias === selected.connectUrl + ? selected.connectUrl + : `${selected.alias} [${selected.connectUrl}]`; + notifications.danger('Target Deletion Failed', `The selected target (${id}) could not be deleted`); + } + }) ); }, [addSubscription, context.api, notifications, selected, setLoading]); @@ -276,6 +254,16 @@ export const TargetSelect: React.FunctionComponent = (props) [props.simple, onExpand, isExpanded] ); + const deleteButtonLoadingProps = React.useMemo( + () => + ({ + spinnerAriaValueText: 'Deleting', + spinnerAriaLabel: 'deleting-custom-target', + isLoading: isLoading, + } as LoadingPropsType), + [isLoading] + ); + return ( <> @@ -297,6 +285,7 @@ export const TargetSelect: React.FunctionComponent = (props) onClick={handleDeleteButton} variant="control" icon={} + {...deleteButtonLoadingProps} /> @@ -320,7 +309,7 @@ export const TargetSelect: React.FunctionComponent = (props) {deleteArchivedWarningModal} diff --git a/src/app/TemplateSelector/FormSelectTemplateSelector.tsx b/src/app/TemplateSelector/FormSelectTemplateSelector.tsx deleted file mode 100644 index 282bf76ba..000000000 --- a/src/app/TemplateSelector/FormSelectTemplateSelector.tsx +++ /dev/null @@ -1,98 +0,0 @@ -/* - * 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 { FormSelect, FormSelectOption, FormSelectOptionGroup } from '@patternfly/react-core'; -import * as React from 'react'; -import { EventTemplate, TemplateType } from '../CreateRecording/CreateRecording'; -import { TemplateSelector } from './TemplateSelector'; - -export interface FormSelectTemplateSelectorProps { - selected: string; - templates: EventTemplate[]; - onChange: (specifier: string) => void; -} - -export const FormSelectTemplateSelector: React.FunctionComponent = (props) => { - const [template, setTemplate] = React.useState(props.selected || undefined); - - const asSpecifier = (template: EventTemplate | undefined): string => { - if (!template) { - return ','; - } - return `${template.name},${template.type}`; - }; - const [specifier, setSpecifier] = React.useState(props.selected || undefined); - - const handleTemplateChange = (specifier) => { - const templateName = specifier.split(',')[0]; - const templateType = specifier.split(',')[1] as TemplateType; - setSpecifier(specifier); - setTemplate( - asSpecifier(props.templates.find((template) => template.name === templateName && template.type === templateType)) - ); - props.onChange(specifier); - }; - - return ( - <> - - } - templates={props.templates} - templateMapper={(template: EventTemplate, idx: number, offset: number) => ( - - )} - customGroup={(children) => ( - - {children} - - )} - targetGroup={(children) => ( - - {children} - - )} - /> - - - ); -}; diff --git a/src/app/TemplateSelector/SelectTemplateSelectorForm.tsx b/src/app/TemplateSelector/SelectTemplateSelectorForm.tsx new file mode 100644 index 000000000..424e23c39 --- /dev/null +++ b/src/app/TemplateSelector/SelectTemplateSelectorForm.tsx @@ -0,0 +1,121 @@ +/* + * 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 { FormSelect, FormSelectOption, FormSelectOptionGroup, ValidatedOptions } from '@patternfly/react-core'; +import * as React from 'react'; +import { EventTemplate, TemplateType } from '@app/CreateRecording/CreateRecording'; + +export interface TemplateSelectionGroup { + groupLabel: string; + disabled?: boolean; + options: { + value: any; + label: string; + disabled?: boolean; + }[]; +} + +export interface SelectTemplateSelectorFormProps { + templates: EventTemplate[]; + disabled?: boolean; + validated?: ValidatedOptions; + onSelect: (template?: string, templateType?: TemplateType) => void; +} + +export const SelectTemplateSelectorForm: React.FunctionComponent = (props) => { + const [selected, setSelected] = React.useState(''); + + const handleTemplateSelect = React.useCallback( + (selected: string) => { + setSelected(selected); + if (!selected.length) { + props.onSelect(undefined, undefined); + } else { + const str = selected.split(','); + props.onSelect(str[0], str[1] as TemplateType); + } + }, + [setSelected, props.onSelect] + ); + + const groups = React.useMemo( + () => + [ + { + groupLabel: 'Target Templates', + options: props.templates + .filter((t) => t.type === 'TARGET') + .map((t) => ({ + value: `${t.name},${t.type}`, + label: t.name, + })), + }, + { + groupLabel: 'Custom Templates', + options: props.templates + .filter((t) => t.type === 'CUSTOM') + .map((t) => ({ + value: `${t.name},${t.type}`, + label: t.name, + })), + }, + ] as TemplateSelectionGroup[], + [props.templates] + ); + + return ( + <> + + + {groups.map((group, index) => ( + + {group.options.map((option, idx) => ( + + ))} + + ))} + + + ); +}; diff --git a/src/test/CreateRecording/CustomRecordingForm.test.tsx b/src/test/CreateRecording/CustomRecordingForm.test.tsx index e4ea99d5f..9ccd98207 100644 --- a/src/test/CreateRecording/CustomRecordingForm.test.tsx +++ b/src/test/CreateRecording/CustomRecordingForm.test.tsx @@ -91,10 +91,8 @@ jest.mock('react-router-dom', () => ({ })); describe('', () => { - let onSubmit: (recordingAttributes: RecordingAttributes) => void; beforeEach(() => { history.go(-history.length); - onSubmit = jest.fn((recordingAttributes) => {}); }); afterEach(cleanup); @@ -104,7 +102,7 @@ describe('', () => { await act(async () => { tree = renderer.create( - + ); }); @@ -112,8 +110,8 @@ describe('', () => { }); it('should create recording when form is filled and create is clicked', async () => { - const onSubmit = jest.fn((recordingAttributes: RecordingAttributes) => {}); - const { user } = renderWithServiceContext(); + const onSubmitSpy = jest.spyOn(defaultServices.api, 'createRecording').mockReturnValue(of(true)); + const { user } = renderWithServiceContext(); const nameInput = screen.getByLabelText('Name *'); expect(nameInput).toBeInTheDocument(); @@ -133,8 +131,8 @@ describe('', () => { expect(createButton).not.toBeDisabled(); await user.click(createButton); - expect(onSubmit).toHaveBeenCalledTimes(1); - expect(onSubmit).toHaveBeenCalledWith({ + expect(onSubmitSpy).toHaveBeenCalledTimes(1); + expect(onSubmitSpy).toHaveBeenCalledWith({ name: 'a_recording', events: 'template=someEventTemplate,type=CUSTOM', duration: 30, @@ -149,7 +147,7 @@ describe('', () => { }); it('should show correct helper texts in metadata label editor', async () => { - const { user } = renderWithServiceContext(); + const { user } = renderWithServiceContext(); const metadataEditorToggle = screen.getByText('Show metadata options'); expect(metadataEditorToggle).toBeInTheDocument(); diff --git a/src/test/CreateRecording/__snapshots__/CustomRecordingForm.test.tsx.snap b/src/test/CreateRecording/__snapshots__/CustomRecordingForm.test.tsx.snap index 7069f1bbf..ce75c1b9e 100644 --- a/src/test/CreateRecording/__snapshots__/CustomRecordingForm.test.tsx.snap +++ b/src/test/CreateRecording/__snapshots__/CustomRecordingForm.test.tsx.snap @@ -265,7 +265,7 @@ Array [ - +
+ The Event Template to be applied in this recording +
renders correctly 1`] = `
renders correctly 1`] = `