Skip to content

Commit

Permalink
Feat: improve delete private probe flow (#951)
Browse files Browse the repository at this point in the history
* refactor(checks-filters): simplify code

- remove redundant variables
- clean-up unneeded if-statements

* refactor(probes): improve delete probe flow

- add `ConfirmModal` that supports async confirm (including errors/catch).
- add business logic to avoid error when trying to delete probe that is in use

* refactor(datasource): use .fetchAPI

* refactor: remove redundant code

* refactor: update tests

* chore: fix linting issues

* chore: fix ts errors after refactor

* fix(ConfirmModal): wire up button text props

* fix: change requests

- Add missing type in `DataSource.fetchAPI` usage
- Fragment code to make it more readable
- Upper case first letter in `ConfirmModal`
- Refactor `DeleteProbeButton`

* fix: change requests #2

- Remove optional chaining

* fix: change requests #2

- Remove optional chaining
- Remove commented code
- Navigate to probe list on delete success
- Make tooltip accessible

* fix: change requests #3

- Remove unused styles
- Add test id to `dataTestIds.ts`

* fix: linting error

---------

Co-authored-by: w1kman <[email protected]>
  • Loading branch information
w1kman and grafanaman authored Oct 15, 2024
1 parent c281b77 commit 8be4a9e
Show file tree
Hide file tree
Showing 38 changed files with 874 additions and 447 deletions.
31 changes: 13 additions & 18 deletions src/components/CheckList/checkFilters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,10 @@ const matchesFilterType = (check: Check, typeFilter: CheckTypeFilter) => {
if (typeFilter === 'all') {
return true;
}

const checkType = getCheckType(check.settings);
if (checkType === typeFilter) {
return true;
}
return false;

return checkType === typeFilter;
};

const matchesSearchFilter = ({ target, job, labels }: Check, searchFilter: string) => {
Expand All @@ -36,37 +35,33 @@ const matchesLabelFilter = ({ labels }: Check, labelFilters: string[]) => {
if (!labelFilters || labelFilters.length === 0) {
return true;
}
const result = labels?.some(({ name, value }) => {
const filtersResult = labelFilters.some((filter) => {
return filter === `${name}: ${value}`;
});
return filtersResult;

return labels?.some(({ name, value }) => {
return labelFilters.some((filter) => filter === `${name}: ${value}`);
});
return result;
};

const matchesStatusFilter = ({ enabled }: Check, { value }: SelectableValue) => {
if (
return (
value === CheckEnabledStatus.All ||
(value === CheckEnabledStatus.Enabled && enabled) ||
(value === CheckEnabledStatus.Disabled && !enabled)
) {
return true;
}
return false;
);
};

const matchesSelectedProbes = (check: Check, selectedProbes: SelectableValue[]) => {
if (selectedProbes.length === 0) {
return true;
} else {
const probeIds = selectedProbes.map((p) => p.value);
return check.probes.some((id) => probeIds.includes(id));
}

const probeIds = selectedProbes.map((p) => p.value);

return check.probes.some((id) => probeIds.includes(id));
};

export const matchesAllFilters = (check: Check, checkFilters: CheckFiltersType) => {
const { type, search, labels, status, probes } = checkFilters;

return (
Boolean(check.id) &&
matchesFilterType(check, type) &&
Expand Down
1 change: 1 addition & 0 deletions src/components/ConfirmModal/ConfirmModal.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const GENERIC_ERROR_MESSAGE = 'Something went wrong. Unable to fulfill the requested action.';
121 changes: 121 additions & 0 deletions src/components/ConfirmModal/ConfirmModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import React, { useEffect, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Alert, Button, ConfirmModal as GrafanaConfirmModal, ConfirmModalProps, Modal, useTheme2 } from '@grafana/ui';
import { css } from '@emotion/css';

import type { AsyncConfirmModalProps } from './ConfirmModal.types';

import { GENERIC_ERROR_MESSAGE } from './ConfirmModal.constants';
import { getErrorWithFallback, hasOnError } from './ConfirmModal.utils';

export function ConfirmModal(props: ConfirmModalProps | AsyncConfirmModalProps) {
if (!('async' in props)) {
return <GrafanaConfirmModal {...props} />;
}

return <AsyncConfirmModal {...props} />;
}

export function AsyncConfirmModal({
title,
isOpen,
description,
error: _error,
onDismiss,
body,
dismissText = 'Cancel',
confirmText = 'Confirm',
...props
}: AsyncConfirmModalProps) {
const [error, setError] = useState<AsyncConfirmModalProps['error']>(_error);
const [pending, setPending] = useState<boolean>(false);
const theme2 = useTheme2();
useEffect(() => {
setError(_error);
}, [_error]);

useEffect(() => {
if (!isOpen) {
setError(undefined);
}
}, [isOpen]);

const handleDismiss = () => {
!pending && onDismiss?.();
};

const styles = getStyles(theme2);

const disabled = pending || error !== undefined;

const handleConfirm = async () => {
if (pending) {
return;
}

setPending(true);

try {
const response = await props.onConfirm?.();
props.onSuccess?.(response);
handleDismiss();
} catch (error: any) {
if (hasOnError(props)) {
props.onError(error);
return;
}

setError(getErrorWithFallback(error, title));
} finally {
setPending(false);
}
};

return (
<Modal className={styles.modal} title={title} isOpen={isOpen} onDismiss={handleDismiss}>
{!!error && (
<Alert className={styles.alert} title={error?.name ?? `${title} error`} severity="error">
<div>{error.message ?? GENERIC_ERROR_MESSAGE}</div>
</Alert>
)}
<div className={styles.body}>{body}</div>
{!!description && <div className={styles.description}>{description}</div>}
<Modal.ButtonRow>
<Button variant="secondary" fill="outline" onClick={handleDismiss}>
{dismissText}
</Button>
<Button
disabled={disabled}
icon={pending ? 'fa fa-spinner' : undefined}
variant="destructive"
onClick={handleConfirm}
>
{confirmText}
</Button>
</Modal.ButtonRow>
</Modal>
);
}

const getStyles = (theme: GrafanaTheme2) => ({
// Same as (grafana/ui) ConfirmModal
modal: css({
width: '100%',
maxWidth: '500px',
}),
alert: css({
'& *:first-letter': {
textTransform: 'uppercase',
},
}),
body: css({
fontSize: theme.typography.h5.fontSize,
}),
description: css({
fontSize: theme.typography.body.fontSize,
}),
error: css({
fontSize: theme.typography.body.fontSize,
color: theme.colors.error.text,
}),
});
14 changes: 14 additions & 0 deletions src/components/ConfirmModal/ConfirmModal.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { ConfirmModalProps } from '@grafana/ui';

export interface ConfirmError {
name: string;
message: string;
}

export interface AsyncConfirmModalProps extends Omit<ConfirmModalProps, 'onConfirm'> {
async: boolean;
error?: ConfirmError;
onSuccess?: (response: unknown) => void;
onError?: (error: ConfirmError) => void;
onConfirm?: () => Promise<unknown>;
}
28 changes: 28 additions & 0 deletions src/components/ConfirmModal/ConfirmModal.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { ConfirmError } from './ConfirmModal.types';

import { GENERIC_ERROR_MESSAGE } from './ConfirmModal.constants';

export function isErrorLike(error: any): error is ConfirmError {
return (
error &&
typeof error === 'object' &&
'name' in error &&
typeof error.name === 'string' &&
'message' in error &&
typeof error.message === 'string'
);
}

export function getFallbackError(title: string): ConfirmError {
return { name: `${title} error`, message: GENERIC_ERROR_MESSAGE };
}

export function getErrorWithFallback(error: any, title: string): ConfirmError {
return isErrorLike(error) ? error : getFallbackError(title);
}

export function hasOnError(props: {
onError?: (error: ConfirmError) => void;
}): props is { onError: (error: ConfirmError) => void } {
return 'onError' in props && typeof props.onError === 'function';
}
4 changes: 4 additions & 0 deletions src/components/ConfirmModal/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from './ConfirmModal';
export type * from './ConfirmModal.types';
export * from './ConfirmModal.constants';
export * from './ConfirmModal.utils';
102 changes: 102 additions & 0 deletions src/components/DeleteProbeButton/DeleteProbeButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import React, { useCallback, useState } from 'react';
import { createPortal } from 'react-dom';
import { Button, Tooltip } from '@grafana/ui';
import { css } from '@emotion/css';

import type { BackendError, DeleteProbeButtonProps } from './DeleteProbeButton.types';
import { useDeleteProbe } from 'data/useProbes';
import { useCanEditProbe } from 'hooks/useCanEditProbe';
import { ConfirmModal } from 'components/ConfirmModal';
import { ProbeUsageLink } from 'components/ProbeUsageLink';

import { getPrettyError } from './DeleteProbeButton.utils';

export function DeleteProbeButton({ probe, onDeleteSuccess: _onDeleteSuccess }: DeleteProbeButtonProps) {
const [showDeleteModal, setShowDeleteModal] = useState(false);
const onDeleteSuccess = useCallback(() => {
setShowDeleteModal(false);
_onDeleteSuccess?.();
}, [_onDeleteSuccess]);

const { mutateAsync: deleteProbe, isPending } = useDeleteProbe({ onSuccess: onDeleteSuccess });
const canEdit = useCanEditProbe(probe);
const canDelete = canEdit && !probe.checks.length;
const styles = getStyles();
const [error, setError] = useState<undefined | { name: string; message: string }>();

const handleError = (error: Error | BackendError) => {
if (!error) {
setError(undefined);
}

setError(getPrettyError(error, probe));
};

const handleOnClick = () => {
setShowDeleteModal(true);
};

if (!canDelete) {
const tooltipContent = canEdit ? (
<>
Unable to delete the probe because it is currently in use.
<br />
<ProbeUsageLink variant="bodySmall" probe={probe} />.
</>
) : (
<>
You do not have sufficient permissions
<br />
to delete the probe <span className={styles.probeName}>&apos;{probe.name}&apos;</span>.
</>
);

// Both tooltip component and button prob is used for accessibility reasons
return (
<Tooltip content={tooltipContent} interactive={canEdit && !canDelete}>
<Button type="button" variant="destructive" tooltip={tooltipContent} disabled>
Delete probe
</Button>
</Tooltip>
);
}

return (
<>
<Button
icon={isPending ? 'fa fa-spinner' : undefined}
type="button"
variant="destructive"
onClick={handleOnClick}
disabled={isPending}
>
Delete probe
</Button>
{createPortal(
<ConfirmModal
async
isOpen={showDeleteModal}
title="Delete probe"
body={
<>
Are you sure you want to delete this (<strong>{probe.name}</strong>) probe?
</>
}
description="This action cannot be undone."
confirmText="Delete probe"
onConfirm={() => deleteProbe(probe)}
onDismiss={() => setShowDeleteModal(false)}
error={error}
onError={handleError}
/>,
document.body
)}
</>
);
}

const getStyles = () => ({
probeName: css({
display: 'inline-block',
}),
});
10 changes: 10 additions & 0 deletions src/components/DeleteProbeButton/DeleteProbeButton.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { FetchResponse } from '@grafana/runtime';

import type { ExtendedProbe } from 'types';

export type BackendError = FetchResponse<{ err: string; msg: string }>;

export interface DeleteProbeButtonProps {
probe: ExtendedProbe;
onDeleteSuccess?: () => void;
}
14 changes: 14 additions & 0 deletions src/components/DeleteProbeButton/DeleteProbeButton.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { BackendError } from './DeleteProbeButton.types';
import { Probe } from 'types';

export function getPrettyError(error: Error | BackendError, probe: Probe) {
if (!error) {
return undefined;
}

if ('data' in error && 'err' in error.data && 'msg' in error.data && typeof error.data.msg === 'string') {
return { name: error.data.err, message: error.data.msg.replace(String(probe.id), `'${probe.name}'`) };
}

return { name: 'Unknown error', message: 'An unknown error occurred' };
}
1 change: 1 addition & 0 deletions src/components/DeleteProbeButton/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './DeleteProbeButton';
Loading

0 comments on commit 8be4a9e

Please sign in to comment.