From 93aa1e42d524a9a63d652e1268851ce9cba6f2b5 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Mon, 7 Nov 2022 15:54:44 -0500 Subject: [PATCH 01/14] feat(spinner): add spinner to target creation button --- src/app/Shared/ProgressIndicator.tsx | 44 ++++++++++++++ src/app/Shared/Services/Api.service.tsx | 4 +- src/app/TargetSelect/CreateTargetModal.tsx | 68 ++++++++++++++++++---- src/app/TargetSelect/TargetSelect.tsx | 29 ++------- 4 files changed, 109 insertions(+), 36 deletions(-) create mode 100644 src/app/Shared/ProgressIndicator.tsx diff --git a/src/app/Shared/ProgressIndicator.tsx b/src/app/Shared/ProgressIndicator.tsx new file mode 100644 index 000000000..8667b1227 --- /dev/null +++ b/src/app/Shared/ProgressIndicator.tsx @@ -0,0 +1,44 @@ +/* + * 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. + */ + +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; +} diff --git a/src/app/Shared/Services/Api.service.tsx b/src/app/Shared/Services/Api.service.tsx index 772870e51..5d88408ee 100644 --- a/src/app/Shared/Services/Api.service.tsx +++ b/src/app/Shared/Services/Api.service.tsx @@ -179,7 +179,9 @@ export class ApiService { body: form, }).pipe( map((resp) => resp.ok), - first() + catchError(() => of(false)), + first(), + tap((ok) => !ok && this.notifications.danger('Target Creation Failed')) ); } 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..840d941b2 100644 --- a/src/app/TargetSelect/TargetSelect.tsx +++ b/src/app/TargetSelect/TargetSelect.tsx @@ -53,14 +53,13 @@ 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'; export const CUSTOM_TARGETS_REALM = 'Custom Targets'; @@ -171,27 +170,9 @@ 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); @@ -320,7 +301,7 @@ export const TargetSelect: React.FunctionComponent = (props) {deleteArchivedWarningModal} From 2add02c46ec76080bcbe882464fd2fa3b41e3c1e Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Thu, 10 Nov 2022 16:40:12 -0500 Subject: [PATCH 02/14] feat(spinner): add spinner to target delete button --- src/app/TargetSelect/TargetSelect.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/app/TargetSelect/TargetSelect.tsx b/src/app/TargetSelect/TargetSelect.tsx index 840d941b2..d92047f40 100644 --- a/src/app/TargetSelect/TargetSelect.tsx +++ b/src/app/TargetSelect/TargetSelect.tsx @@ -60,6 +60,7 @@ import { getFromLocalStorage, removeFromLocalStorage, saveToLocalStorage } from 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'; @@ -257,6 +258,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 ( <> @@ -278,6 +289,7 @@ export const TargetSelect: React.FunctionComponent = (props) onClick={handleDeleteButton} variant="control" icon={} + {...deleteButtonLoadingProps} /> From 8d81916b191b283e89862e6c01bf30d37e2ee5d5 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Thu, 10 Nov 2022 17:01:49 -0500 Subject: [PATCH 03/14] feat(spinner): add spinner to rule creation button --- src/app/Rules/CreateRule.tsx | 49 ++++++++++++++++--- .../FormSelectTemplateSelector.tsx | 2 + 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/app/Rules/CreateRule.tsx b/src/app/Rules/CreateRule.tsx index def9f3248..484413523 100644 --- a/src/app/Rules/CreateRule.tsx +++ b/src/app/Rules/CreateRule.tsx @@ -56,7 +56,7 @@ import { ValidatedOptions, } from '@patternfly/react-core'; import { useHistory, withRouter } from 'react-router-dom'; -import { filter, first, mergeMap, toArray } from 'rxjs/operators'; +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'; @@ -66,8 +66,9 @@ import { Rule } from './Rules'; import { MatchExpressionEvaluator } from '../Shared/MatchExpressionEvaluator'; import { FormSelectTemplateSelector } from '../TemplateSelector/FormSelectTemplateSelector'; import { NO_TARGET } from '@app/Shared/Services/Target.service'; -import { iif } from 'rxjs'; +import { iif, of } from 'rxjs'; import { authFailMessage, ErrorView, isAuthFail } from '@app/ErrorView/ErrorView'; +import { LoadingPropsType } from '@app/Shared/ProgressIndicator'; // FIXME check if this is correct/matches backend name validation export const RuleNamePattern = /^[\w_]+$/; @@ -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) => { @@ -156,6 +158,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`); @@ -180,14 +183,19 @@ const Comp = () => { addSubscription( context.api .createRule(rule) - .pipe(first()) + .pipe( + first(), + catchError((err) => of(false)) + ) .subscribe((success) => { + setLoading(false); if (success) { history.push('/rules'); } }) ); }, [ + setLoading, addSubscription, context, context.api, @@ -261,6 +269,16 @@ const Comp = () => { }, ]; + const createButtonLoadingProps = React.useMemo( + () => + ({ + spinnerAriaValueText: 'Creating', + spinnerAriaLabel: 'deleting-automatic-rule', + isLoading: loading, + } as LoadingPropsType), + [loading] + ); + const authRetry = React.useCallback(() => { context.target.setAuthRetry(); }, [context.target, context.target.setAuthRetry]); @@ -294,6 +312,7 @@ const Comp = () => { > { > { > { > { helperTextInvalid="A Template must be selected" > { { @@ -414,6 +439,7 @@ const Comp = () => { { @@ -444,6 +471,7 @@ const Comp = () => { { @@ -474,6 +503,7 @@ const Comp = () => { { @@ -502,6 +533,7 @@ const Comp = () => { > { variant="primary" onClick={handleSubmit} isDisabled={ - nameValid !== ValidatedOptions.success || !template || !templateType || !matchExpression + loading || + nameValid !== ValidatedOptions.success || + !template || + !templateType || + !matchExpression } + {...createButtonLoadingProps} > - Create + {loading ? 'Creating' : 'Create'} - diff --git a/src/app/TemplateSelector/FormSelectTemplateSelector.tsx b/src/app/TemplateSelector/FormSelectTemplateSelector.tsx index 282bf76ba..7c385d0e0 100644 --- a/src/app/TemplateSelector/FormSelectTemplateSelector.tsx +++ b/src/app/TemplateSelector/FormSelectTemplateSelector.tsx @@ -42,6 +42,7 @@ import { TemplateSelector } from './TemplateSelector'; export interface FormSelectTemplateSelectorProps { selected: string; + disabled?: boolean; templates: EventTemplate[]; onChange: (specifier: string) => void; } @@ -70,6 +71,7 @@ export const FormSelectTemplateSelector: React.FunctionComponent Date: Thu, 10 Nov 2022 17:31:08 -0500 Subject: [PATCH 04/14] feat(spinner): add spinner to credentials creation button --- src/app/AppLayout/JmxAuthForm.tsx | 53 +++++++++++++------ .../Credentials/CreateJmxCredentialModal.tsx | 25 ++++++--- .../__snapshots__/CreateRule.test.tsx.snap | 2 +- 3 files changed, 56 insertions(+), 24 deletions(-) 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/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 renders correctly 1`] = ` - From cee4cea9608301d242d0e6e2b4f95f1bbb0a661a Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Sun, 13 Nov 2022 22:30:12 -0500 Subject: [PATCH 06/14] feat(spinner): add spinner to event template upload --- src/app/Events/EventTemplates.tsx | 248 ++++++++++-------- .../SecurityPanel/CertificateUploadModal.tsx | 17 +- src/app/Shared/Services/Api.service.tsx | 26 +- 3 files changed, 169 insertions(+), 122 deletions(-) diff --git a/src/app/Events/EventTemplates.tsx b/src/app/Events/EventTemplates.tsx index 12e56691b..101473f4a 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,136 @@ 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(); + } else { + reset(); + } + }) + ); + }, [ + fileRejected, + uploadFile, + window.console, + setUploading, + addSubscription, + context.api, + setUploadFile, + setUploadFilename, + handleClose, + reset, + ]); + + const submitButtonLoadingProps = React.useMemo( + () => + ({ + spinnerAriaValueText: 'Submitting', + spinnerAriaLabel: 'submitting-custom-event-template', + isLoading: uploading, + } as LoadingPropsType), + [uploading] + ); + + return ( + +
+ + + + + + + +
+
+ ); +}; diff --git a/src/app/SecurityPanel/CertificateUploadModal.tsx b/src/app/SecurityPanel/CertificateUploadModal.tsx index 0debc0826..bd7d5d6ec 100644 --- a/src/app/SecurityPanel/CertificateUploadModal.tsx +++ b/src/app/SecurityPanel/CertificateUploadModal.tsx @@ -37,7 +37,7 @@ */ 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'; @@ -97,13 +97,14 @@ export const CertificateUploadModal: React.FunctionComponent setUploading(false)) - ) - .subscribe({ - next: handleClose, - error: reset, + .pipe(first()) + .subscribe((success) => { + setUploading(false); + if (success) { + handleClose(); + } else { + reset(); + } }); }, [rejected, uploadFile, notifications, setUploading, context.api, handleClose, reset]); diff --git a/src/app/Shared/Services/Api.service.tsx b/src/app/Shared/Services/Api.service.tsx index 5d88408ee..fa1e0cf06 100644 --- a/src/app/Shared/Services/Api.service.tsx +++ b/src/app/Shared/Services/Api.service.tsx @@ -463,13 +463,15 @@ export class ApiService { method: 'POST', body, }).pipe( - map((response) => { - if (!response.ok) { - throw response.statusText; + map((resp) => resp.ok), + catchError((_) => of(false)), + tap((ok) => { + if (ok) { + this.notifications.success('Successfully uploaded event template'); + } else { + this.notifications.danger('Upload event template failed'); } - return true; - }), - catchError((): ObservableInput => of(false)) + }) ); } @@ -736,19 +738,21 @@ 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) { + map((resp) => resp.ok), + catchError((_) => of(false)), + tap((ok) => { + if (ok) { this.notifications.success('Successfully uploaded certificate'); - return from(resp.text()); + } else { + this.notifications.danger('Upload certificate failed'); } - throw resp.statusText; }) ); } From 50468b2e7c9c8be56de88d855b118620b724573e Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Sun, 13 Nov 2022 22:42:54 -0500 Subject: [PATCH 07/14] chore(notifications): remove duplicate notifications --- src/app/Shared/Services/Api.service.tsx | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/src/app/Shared/Services/Api.service.tsx b/src/app/Shared/Services/Api.service.tsx index fa1e0cf06..253460715 100644 --- a/src/app/Shared/Services/Api.service.tsx +++ b/src/app/Shared/Services/Api.service.tsx @@ -180,8 +180,7 @@ export class ApiService { }).pipe( map((resp) => resp.ok), catchError(() => of(false)), - first(), - tap((ok) => !ok && this.notifications.danger('Target Creation Failed')) + first() ); } @@ -464,14 +463,7 @@ export class ApiService { body, }).pipe( map((resp) => resp.ok), - catchError((_) => of(false)), - tap((ok) => { - if (ok) { - this.notifications.success('Successfully uploaded event template'); - } else { - this.notifications.danger('Upload event template failed'); - } - }) + catchError((_) => of(false)) ); } @@ -746,14 +738,7 @@ export class ApiService { body, }).pipe( map((resp) => resp.ok), - catchError((_) => of(false)), - tap((ok) => { - if (ok) { - this.notifications.success('Successfully uploaded certificate'); - } else { - this.notifications.danger('Upload certificate failed'); - } - }) + catchError((_) => of(false)) ); } From 1d4860c347fe1821b8e47d4aea52f21a46b99b32 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Sun, 13 Nov 2022 22:59:38 -0500 Subject: [PATCH 08/14] feat(spinner): add spinner to agent probe template upload --- src/app/Agent/AgentProbeTemplates.tsx | 212 ++++++++++++++---------- src/app/Events/EventTemplates.tsx | 13 +- src/app/Shared/Services/Api.service.tsx | 7 +- 3 files changed, 127 insertions(+), 105 deletions(-) diff --git a/src/app/Agent/AgentProbeTemplates.tsx b/src/app/Agent/AgentProbeTemplates.tsx index 409cd254b..219020c3f 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,127 @@ 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(); + } else { + reset(); + } + }) + ); + }, [fileRejected, uploadFile, setUploading, addSubscription, context.api, reset, handleClose]); + + const submitButtonLoadingProps = React.useMemo( + () => + ({ + spinnerAriaValueText: 'Submitting', + spinnerAriaLabel: 'submitting-custom-event-template', + isLoading: uploading, + } as LoadingPropsType), + [uploading] + ); + + return ( + +
+ + + + + + + +
+
+ ); +}; diff --git a/src/app/Events/EventTemplates.tsx b/src/app/Events/EventTemplates.tsx index 101473f4a..5aac6fff2 100644 --- a/src/app/Events/EventTemplates.tsx +++ b/src/app/Events/EventTemplates.tsx @@ -453,18 +453,7 @@ export const EventTemplatesUploadModal: React.FunctionComponent diff --git a/src/app/Shared/Services/Api.service.tsx b/src/app/Shared/Services/Api.service.tsx index 253460715..a4bf9880c 100644 --- a/src/app/Shared/Services/Api.service.tsx +++ b/src/app/Shared/Services/Api.service.tsx @@ -517,12 +517,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)) ); } From fc9cbc96da176190f79c27abef4b529de24cf84b Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Mon, 14 Nov 2022 00:22:55 -0500 Subject: [PATCH 09/14] feat(spinner): add spinner to rule upload modal --- src/app/Agent/AgentProbeTemplates.tsx | 6 +- src/app/Events/EventTemplates.tsx | 4 +- src/app/Rules/Rules.tsx | 22 +++++++ src/app/Rules/RulesUploadModal.tsx | 60 ++++++++++++++----- .../SecurityPanel/CertificateUploadModal.tsx | 4 +- src/app/Shared/Services/Api.service.tsx | 1 + 6 files changed, 72 insertions(+), 25 deletions(-) diff --git a/src/app/Agent/AgentProbeTemplates.tsx b/src/app/Agent/AgentProbeTemplates.tsx index 219020c3f..652e5ed4d 100644 --- a/src/app/Agent/AgentProbeTemplates.tsx +++ b/src/app/Agent/AgentProbeTemplates.tsx @@ -399,18 +399,16 @@ export const AgentProbeTemplateUploadModal: React.FunctionComponent ({ spinnerAriaValueText: 'Submitting', - spinnerAriaLabel: 'submitting-custom-event-template', + spinnerAriaLabel: 'submitting-probe-template', isLoading: uploading, } as LoadingPropsType), [uploading] diff --git a/src/app/Events/EventTemplates.tsx b/src/app/Events/EventTemplates.tsx index 5aac6fff2..77b4c2bae 100644 --- a/src/app/Events/EventTemplates.tsx +++ b/src/app/Events/EventTemplates.tsx @@ -448,12 +448,10 @@ export const EventTemplatesUploadModal: React.FunctionComponent 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..af7ceef63 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,9 @@ 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) => { + notifications.danger('Automated rule upload failed', err.message); return of(false); }) ) @@ -125,13 +137,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 +163,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 +199,7 @@ export const RuleUploadModal: React.FunctionComponent = (p value={uploadFile} filename={filename} onChange={handleFileChange} + isDisabled={uploading} isLoading={uploading} validated={rejected ? 'error' : 'default'} dropzoneProps={{ @@ -184,10 +209,15 @@ export const RuleUploadModal: React.FunctionComponent = (p /> - - diff --git a/src/app/SecurityPanel/CertificateUploadModal.tsx b/src/app/SecurityPanel/CertificateUploadModal.tsx index bd7d5d6ec..81ef96b54 100644 --- a/src/app/SecurityPanel/CertificateUploadModal.tsx +++ b/src/app/SecurityPanel/CertificateUploadModal.tsx @@ -102,11 +102,9 @@ export const CertificateUploadModal: React.FunctionComponent diff --git a/src/app/Shared/Services/Api.service.tsx b/src/app/Shared/Services/Api.service.tsx index a4bf9880c..d645630ff 100644 --- a/src/app/Shared/Services/Api.service.tsx +++ b/src/app/Shared/Services/Api.service.tsx @@ -202,6 +202,7 @@ export class ApiService { headers, }).pipe( map((resp) => resp.ok), + catchError((_) => of(false)), first() ); } From 6c2594730839ea36d39244823366c2273711bd0e Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Wed, 16 Nov 2022 17:23:14 -0500 Subject: [PATCH 10/14] feat(spinner): add spinner to recording creation form --- src/app/Archives/ArchiveUploadModal.tsx | 13 +- src/app/CreateRecording/CreateRecording.tsx | 59 +------- .../CreateRecording/CustomRecordingForm.tsx | 130 +++++++++++------- .../CreateRecording/SnapshotRecordingForm.tsx | 44 +++++- .../RecordingLabelFields.tsx | 19 ++- src/app/Rules/CreateRule.tsx | 51 +++---- .../FormSelectTemplateSelector.tsx | 100 -------------- .../SelectTemplateSelectorForm.tsx | 121 ++++++++++++++++ src/app/TemplateSelector/TemplateSelector.tsx | 67 --------- .../CustomRecordingForm.test.tsx | 14 +- .../CustomRecordingForm.test.tsx.snap | 24 ++-- .../__snapshots__/CreateRule.test.tsx.snap | 14 +- 12 files changed, 326 insertions(+), 330 deletions(-) delete mode 100644 src/app/TemplateSelector/FormSelectTemplateSelector.tsx create mode 100644 src/app/TemplateSelector/SelectTemplateSelectorForm.tsx delete mode 100644 src/app/TemplateSelector/TemplateSelector.tsx diff --git a/src/app/Archives/ArchiveUploadModal.tsx b/src/app/Archives/ArchiveUploadModal.tsx index 99248dc4d..27442a222 100644 --- a/src/app/Archives/ArchiveUploadModal.tsx +++ b/src/app/Archives/ArchiveUploadModal.tsx @@ -122,6 +122,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 +134,11 @@ 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(); @@ -210,7 +217,7 @@ export const ArchiveUploadModal: React.FunctionComponent Submit diff --git a/src/app/CreateRecording/CreateRecording.tsx b/src/app/CreateRecording/CreateRecording.tsx index fdd343a57..f14d009eb 100644 --- a/src/app/CreateRecording/CreateRecording.tsx +++ b/src/app/CreateRecording/CreateRecording.tsx @@ -36,23 +36,14 @@ * SOFTWARE. */ import * as React from 'react'; -import { RecordingAttributes } from '@app/Shared/Services/Api.service'; -import { ServiceContext } from '@app/Shared/Services/Services'; import { TargetView } from '@app/TargetView/TargetView'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; import { Card, CardBody, Tab, Tabs } from '@patternfly/react-core'; import { StaticContext } from 'react-router'; -import { RouteComponentProps, useHistory, withRouter } from 'react-router-dom'; -import { first } from 'rxjs/operators'; +import { RouteComponentProps, withRouter } from 'react-router-dom'; import { CustomRecordingForm } from './CustomRecordingForm'; import { SnapshotRecordingForm } from './SnapshotRecordingForm'; -export interface CreateRecordingProps { - recordingName?: string; - template?: string; - templateType?: string; - eventSpecifiers?: string[]; -} +export type TemplateType = 'TARGET' | 'CUSTOM'; export interface EventTemplate { name: string; @@ -61,57 +52,21 @@ export interface EventTemplate { type: TemplateType; } -export type TemplateType = 'TARGET' | 'CUSTOM'; - -const Comp: React.FunctionComponent> = (props) => { - const context = React.useContext(ServiceContext); - const history = useHistory(); - const addSubscription = useSubscriptions(); - +const Comp: React.FunctionComponent<{}> = () => { const [activeTab, setActiveTab] = React.useState(0); - const handleCreateRecording = (recordingAttributes: RecordingAttributes): void => { - addSubscription( - context.api - .createRecording(recordingAttributes) - .pipe(first()) - .subscribe((success) => { - if (success) { - history.push('/recordings'); - } - }) - ); - }; - - const handleCreateSnapshot = (): void => { - addSubscription( - context.api - .createSnapshot() - .pipe(first()) - .subscribe((success) => { - if (success) { - history.push('/recordings'); - } - }) - ); - }; + const onTabSelect = React.useCallback((evt, idx) => setActiveTab(Number(idx)), [setActiveTab]); return ( - setActiveTab(Number(idx))}> + - + - + diff --git a/src/app/CreateRecording/CustomRecordingForm.tsx b/src/app/CreateRecording/CustomRecordingForm.tsx index 5afc64d8d..9440f1866 100644 --- a/src/app/CreateRecording/CustomRecordingForm.tsx +++ b/src/app/CreateRecording/CustomRecordingForm.tsx @@ -60,31 +60,28 @@ import { } from '@patternfly/react-core'; import { HelpIcon } from '@patternfly/react-icons'; import { useHistory } from 'react-router-dom'; -import { concatMap } from 'rxjs/operators'; +import { concatMap, first } from 'rxjs/operators'; import { EventTemplate, TemplateType } from './CreateRecording'; import { RecordingOptions, RecordingAttributes } from '@app/Shared/Services/Api.service'; import { DurationPicker } from '@app/DurationPicker/DurationPicker'; -import { FormSelectTemplateSelector } from '../TemplateSelector/FormSelectTemplateSelector'; +import { SelectTemplateSelectorForm } from '@app/TemplateSelector/SelectTemplateSelectorForm'; import { RecordingLabel } from '@app/RecordingMetadata/RecordingLabel'; import { RecordingLabelFields } from '@app/RecordingMetadata/RecordingLabelFields'; import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { LoadingPropsType } from '@app/Shared/ProgressIndicator'; -export interface CustomRecordingFormProps { - onSubmit: (recordingAttributes: RecordingAttributes) => void; -} +export interface CustomRecordingFormProps {} export const RecordingNamePattern = /^[\w_]+$/; export const DurationPattern = /^[1-9][0-9]*$/; -export const CustomRecordingForm = (props) => { +export const CustomRecordingForm: React.FunctionComponent = (props) => { const context = React.useContext(ServiceContext); const notifications = React.useContext(NotificationsContext); const history = useHistory(); const addSubscription = useSubscriptions(); - const [recordingName, setRecordingName] = React.useState( - props.recordingName || props?.location?.state?.recordingName || '' - ); + const [recordingName, setRecordingName] = React.useState(''); const [nameValid, setNameValid] = React.useState(ValidatedOptions.default); const [continuous, setContinuous] = React.useState(false); const [archiveOnStop, setArchiveOnStop] = React.useState(true); @@ -92,10 +89,8 @@ export const CustomRecordingForm = (props) => { const [durationUnit, setDurationUnit] = React.useState(1000); const [durationValid, setDurationValid] = React.useState(ValidatedOptions.success); const [templates, setTemplates] = React.useState([] as EventTemplate[]); - const [template, setTemplate] = React.useState(props.template || props?.location?.state?.template || null); - const [templateType, setTemplateType] = React.useState( - props.templateType || props?.location?.state?.templateType || (null as TemplateType | 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); @@ -103,6 +98,25 @@ export const CustomRecordingForm = (props) => { const [toDisk, setToDisk] = React.useState(true); const [labels, setLabels] = React.useState([] as RecordingLabel[]); const [labelsValid, setLabelsValid] = React.useState(ValidatedOptions.default); + const [loading, setLoading] = React.useState(false); + + const handleCreateRecording = React.useCallback( + (recordingAttributes: RecordingAttributes) => { + setLoading(true); + addSubscription( + context.api + .createRecording(recordingAttributes) + .pipe(first()) + .subscribe((success) => { + setLoading(false); + if (success) { + history.push('/recordings'); + } + }) + ); + }, + [addSubscription, context.api, history, setLoading] + ); const handleContinuousChange = React.useCallback( (checked) => { @@ -129,24 +143,23 @@ export const CustomRecordingForm = (props) => { ); 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 getEventString = React.useCallback(() => { var str = ''; - if (!!template) { - str += `template=${template}`; + if (!!templateName) { + str += `template=${templateName}`; } if (!!templateType) { str += `,type=${templateType}`; } return str; - }, [template, templateType]); + }, [templateName, templateType]); const getFormattedLabels = React.useCallback(() => { let obj = {}; @@ -239,7 +252,7 @@ export const CustomRecordingForm = (props) => { options: options, metadata: { labels: getFormattedLabels() }, }; - props.onSubmit(recordingAttributes); + handleCreateRecording(recordingAttributes); }, [ getEventString, getFormattedLabels, @@ -255,6 +268,7 @@ export const CustomRecordingForm = (props) => { notifications.warning, recordingName, toDisk, + handleCreateRecording, ]); React.useEffect(() => { @@ -287,17 +301,27 @@ export const CustomRecordingForm = (props) => { return ( nameValid !== ValidatedOptions.success || durationValid !== ValidatedOptions.success || - !template || + !templateName || !templateType || labelsValid !== ValidatedOptions.success ); - }, [nameValid, durationValid, template, templateType, labelsValid]); + }, [nameValid, durationValid, templateName, templateType, labelsValid]); const hasReservedLabels = React.useMemo( () => labels.some((label) => label.key === 'template.name' || label.key === 'template.type'), [labels] ); + const createButtonLoadingProps = React.useMemo( + () => + ({ + spinnerAriaValueText: 'Creating', + spinnerAriaLabel: 'create-active-recording', + isLoading: loading, + } as LoadingPropsType), + [loading] + ); + return ( <> @@ -316,6 +340,7 @@ export const CustomRecordingForm = (props) => { { { : 'Time before the recording is automatically stopped' } helperTextInvalid="A recording may only have a positive integer duration" - fieldId="recording-duration" > { { { 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 in this recording'} helperTextInvalid="A Template must be selected" > - @@ -410,7 +432,12 @@ export const CustomRecordingForm = (props) => { } > - + @@ -419,7 +446,13 @@ export const CustomRecordingForm = (props) => { fieldId="To Disk" helperText="Write contents of buffer onto disk. If disabled, the buffer acts as circular buffer only keeping the most recent recording information" > - + { aria-label="max size value" onChange={handleMaxSizeChange} min="0" - isDisabled={!toDisk} + isDisabled={!toDisk || loading} /> @@ -444,7 +477,7 @@ export const CustomRecordingForm = (props) => { value={maxSizeUnits} onChange={handleMaxSizeUnitChange} aria-label="Max size units input" - isDisabled={!toDisk} + isDisabled={!toDisk || loading} > @@ -464,7 +497,7 @@ export const CustomRecordingForm = (props) => { aria-label="Max age duration" onChange={handleMaxAgeChange} min="0" - isDisabled={!continuous || !toDisk} + isDisabled={!continuous || !toDisk || loading} /> @@ -472,7 +505,7 @@ export const CustomRecordingForm = (props) => { value={maxAgeUnits} onChange={handleMaxAgeUnitChange} aria-label="Max Age units Input" - isDisabled={!continuous || !toDisk} + isDisabled={!continuous || !toDisk || loading} > @@ -483,10 +516,15 @@ export const CustomRecordingForm = (props) => { - - 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/RecordingMetadata/RecordingLabelFields.tsx b/src/app/RecordingMetadata/RecordingLabelFields.tsx index 792cc045f..675392c7c 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 handleDeleteLabelButtonClick(idx)} variant="link" aria-label="Remove Label" + isDisabled={props.isDisabled} icon={} /> diff --git a/src/app/Rules/CreateRule.tsx b/src/app/Rules/CreateRule.tsx index 484413523..0685da408 100644 --- a/src/app/Rules/CreateRule.tsx +++ b/src/app/Rules/CreateRule.tsx @@ -56,24 +56,24 @@ import { ValidatedOptions, } from '@patternfly/react-core'; import { useHistory, withRouter } from 'react-router-dom'; +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, of } 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(); @@ -86,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); @@ -110,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]); @@ -222,7 +221,7 @@ const Comp = () => { setTemplates(templates); setErrorMessage(''); }, - [setTemplate, setErrorMessage] + [setTemplateName, setErrorMessage] ); const refreshTemplateList = React.useCallback(() => { @@ -273,7 +272,7 @@ const Comp = () => { () => ({ spinnerAriaValueText: 'Creating', - spinnerAriaLabel: 'deleting-automatic-rule', + spinnerAriaLabel: 'creating-automatic-rule', isLoading: loading, } as LoadingPropsType), [loading] @@ -381,21 +380,15 @@ 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" > - { isDisabled={ loading || nameValid !== ValidatedOptions.success || - !template || + !templateName || !templateType || !matchExpression } diff --git a/src/app/TemplateSelector/FormSelectTemplateSelector.tsx b/src/app/TemplateSelector/FormSelectTemplateSelector.tsx deleted file mode 100644 index 7c385d0e0..000000000 --- a/src/app/TemplateSelector/FormSelectTemplateSelector.tsx +++ /dev/null @@ -1,100 +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; - disabled?: boolean; - 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/app/TemplateSelector/TemplateSelector.tsx b/src/app/TemplateSelector/TemplateSelector.tsx deleted file mode 100644 index 2dc0dcdf0..000000000 --- a/src/app/TemplateSelector/TemplateSelector.tsx +++ /dev/null @@ -1,67 +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 * as React from 'react'; -import { EventTemplate } from '../CreateRecording/CreateRecording'; - -export interface TemplateSelectorProps { - templates: EventTemplate[]; - templateMapper: (template: EventTemplate, idx: number, offset: number) => JSX.Element; - placeholder: JSX.Element; - customGroup: (el: JSX.Element[]) => JSX.Element; - targetGroup: (el: JSX.Element[]) => JSX.Element; -} - -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/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`] = `
Date: Wed, 16 Nov 2022 20:40:31 -0500 Subject: [PATCH 11/14] feat(spinner): add spinner to archive upload modal --- src/app/Archives/ArchiveUploadModal.tsx | 39 +++++++++++++++++++++---- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/src/app/Archives/ArchiveUploadModal.tsx b/src/app/Archives/ArchiveUploadModal.tsx index 27442a222..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; @@ -135,10 +136,20 @@ export const ArchiveUploadModal: React.FunctionComponent handleClose(), - error: (_) => reset() + next: () => handleClose(), + error: (_) => reset(), }); - }, [context.api, notifications, setUploading, abort.signal, handleClose, reset, getFormattedLabels, uploadFile, rejected]); + }, [ + context.api, + notifications, + setUploading, + abort.signal, + handleClose, + reset, + getFormattedLabels, + uploadFile, + rejected, + ]); const handleAbort = React.useCallback(() => { abort.abort(); @@ -146,6 +157,16 @@ export const ArchiveUploadModal: React.FunctionComponent + ({ + spinnerAriaValueText: 'Submitting', + spinnerAriaLabel: 'submitting-uploaded-recording', + isLoading: uploading, + } as LoadingPropsType), + [uploading] + ); + return ( <> @@ -193,6 +214,7 @@ export const ArchiveUploadModal: React.FunctionComponent } > - + @@ -218,8 +246,9 @@ export const ArchiveUploadModal: React.FunctionComponent - Submit + {uploading ? 'Submitting' : 'Submit'} - diff --git a/src/app/RecordingMetadata/RecordingLabelFields.tsx b/src/app/RecordingMetadata/RecordingLabelFields.tsx index 675392c7c..87e721e80 100644 --- a/src/app/RecordingMetadata/RecordingLabelFields.tsx +++ b/src/app/RecordingMetadata/RecordingLabelFields.tsx @@ -282,6 +282,7 @@ export const RecordingLabelFields: React.FunctionComponent handleValueChange(idx, value)} validated={validValues[idx]} + isDisabled={props.isDisabled} /> Value From e72da2c12547f487b9edfa5e3e605046b6285d2a Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Wed, 16 Nov 2022 22:30:33 -0500 Subject: [PATCH 13/14] feat(spinner): add spinner to recording table actions --- src/app/Recordings/ActiveRecordingsTable.tsx | 122 ++++++++++++++++-- .../Recordings/ArchivedRecordingsTable.tsx | 57 +++++++- 2 files changed, 164 insertions(+), 15 deletions(-) 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 ( - From f95f73188c8c4e03fbabca5521de6070e6939c68 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Thu, 17 Nov 2022 13:07:32 -0500 Subject: [PATCH 14/14] chore(spinner): clean up error handlers --- src/app/Rules/CreateRule.tsx | 18 ++++++------------ src/app/Rules/RulesUploadModal.tsx | 1 + src/app/Shared/Services/Api.service.tsx | 1 + src/app/TargetSelect/TargetSelect.tsx | 24 ++++++++++-------------- 4 files changed, 18 insertions(+), 26 deletions(-) diff --git a/src/app/Rules/CreateRule.tsx b/src/app/Rules/CreateRule.tsx index 0685da408..85b97d12c 100644 --- a/src/app/Rules/CreateRule.tsx +++ b/src/app/Rules/CreateRule.tsx @@ -180,18 +180,12 @@ const Comp: React.FunctionComponent<{}> = () => { maxSizeBytes: maxSize * maxSizeUnits, }; addSubscription( - context.api - .createRule(rule) - .pipe( - first(), - catchError((err) => of(false)) - ) - .subscribe((success) => { - setLoading(false); - if (success) { - history.push('/rules'); - } - }) + context.api.createRule(rule).subscribe((success) => { + setLoading(false); + if (success) { + history.push('/rules'); + } + }) ); }, [ setLoading, diff --git a/src/app/Rules/RulesUploadModal.tsx b/src/app/Rules/RulesUploadModal.tsx index af7ceef63..b351bc482 100644 --- a/src/app/Rules/RulesUploadModal.tsx +++ b/src/app/Rules/RulesUploadModal.tsx @@ -126,6 +126,7 @@ export const RuleUploadModal: React.FunctionComponent = (p first(), 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); }) diff --git a/src/app/Shared/Services/Api.service.tsx b/src/app/Shared/Services/Api.service.tsx index d645630ff..5a432088b 100644 --- a/src/app/Shared/Services/Api.service.tsx +++ b/src/app/Shared/Services/Api.service.tsx @@ -189,6 +189,7 @@ export class ApiService { method: 'DELETE', }).pipe( map((resp) => resp.ok), + catchError(() => of(false)), first() ); } diff --git a/src/app/TargetSelect/TargetSelect.tsx b/src/app/TargetSelect/TargetSelect.tsx index d92047f40..b7a92ae1c 100644 --- a/src/app/TargetSelect/TargetSelect.tsx +++ b/src/app/TargetSelect/TargetSelect.tsx @@ -178,20 +178,16 @@ export const TargetSelect: React.FunctionComponent = (props) 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]);