Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: improve delete private probe flow #951

Merged
merged 15 commits into from
Oct 15, 2024
Merged
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);
w1kman marked this conversation as resolved.
Show resolved Hide resolved
_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}>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can add the tooltip prop directly to button now and it works better for accessibility as it becomes keyboard accessible and reads out the tooltip despite the button being 'disabled'.

With that said, I don't love the link in the tooltip as Grafana doesn't support accessing interactive content via the keyboard in tooltips but as there is an alternative presentation of the link in the sidebar this is okay 😄

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had to use both the Tooltip component and the tooltip prop 😓
With only the prop, the tooltip isn't interactable
With disabled on the Button there is no focus (unless aria-disabled is used, which is only possible when tooltip is truthy).

<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
Loading