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: Re-design probe selection when creating / editing checks #973

Merged
merged 21 commits into from
Nov 11, 2024
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 0 additions & 125 deletions src/components/CheckEditor/CheckProbes.tsx

This file was deleted.

75 changes: 75 additions & 0 deletions src/components/CheckEditor/CheckProbes/CheckProbes.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import React, { forwardRef, useMemo, useState } from 'react';
import { Field, Stack } from '@grafana/ui';

import { Probe } from 'types';

import { PrivateProbesAlert } from './PrivateProbesAlert';
import { ProbesFilter } from './ProbesFilter';
import { ProbesList } from './ProbesList';

interface CheckProbesProps {
probes: number[];
availableProbes: Probe[];
disabled?: boolean;
onChange: (probes: number[]) => void;
onBlur?: () => void;
invalid?: boolean;
error?: string;
}
export const CheckProbes = forwardRef(({ probes, availableProbes, onChange, error }: CheckProbesProps, ref) => {
VikaCep marked this conversation as resolved.
Show resolved Hide resolved
const [filteredProbes, setFilteredProbes] = useState<Probe[]>(availableProbes);

const publicProbes = useMemo(() => filteredProbes.filter((probe) => probe.public), [filteredProbes]);
const privateProbes = useMemo(() => filteredProbes.filter((probe) => !probe.public), [filteredProbes]);

const groupedByRegion = useMemo(
() =>
publicProbes.reduce((acc: Record<string, Probe[]>, curr: Probe) => {
const region = curr.region;
if (!acc[region]) {
acc[region] = [];
}
acc[region].push(curr);
return acc;
}, {}),
[publicProbes]
);

return (
<div>
<Field
label="Probe locations"
description="Select one, multiple, or all probes where this target will be checked from. Deprecated probes can be removed, but they cannot be added."
invalid={!!error}
error={error}
>
<div>
<ProbesFilter probes={availableProbes} onSearch={setFilteredProbes} />
<Stack wrap={'wrap'}>
VikaCep marked this conversation as resolved.
Show resolved Hide resolved
{privateProbes.length > 0 && (
<ProbesList
title="Private probes"
probes={privateProbes}
selectedProbes={probes}
onSelectionChange={onChange}
/>
)}

{Object.entries(groupedByRegion).map(([region, allProbes]) => (
<ProbesList
key={region}
title={region}
probes={allProbes}
selectedProbes={probes}
onSelectionChange={onChange}
/>
))}
</Stack>
</div>
</Field>
{privateProbes.length === 0 && filteredProbes.length === availableProbes.length && <PrivateProbesAlert />}
VikaCep marked this conversation as resolved.
Show resolved Hide resolved
</div>
);
});

CheckProbes.displayName = 'CheckProbes';
31 changes: 31 additions & 0 deletions src/components/CheckEditor/CheckProbes/PrivateProbesAlert.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import React, { useState } from 'react';
import { Alert, LinkButton, Stack } from '@grafana/ui';

import { ROUTES } from 'types';
import { getRoute } from 'components/Routing.utils';

export const PrivateProbesAlert = () => {
const [display, setDisplay] = useState(true);
VikaCep marked this conversation as resolved.
Show resolved Hide resolved

if (!display) {
return null;
}

return (
<Alert
title="You haven't set up any private probes yet."
severity="info"
Copy link
Contributor Author

@VikaCep VikaCep Oct 29, 2024

Choose a reason for hiding this comment

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

The original design used a different color and icon for this message, but with the intention of reusing and not modifying the elements we get from grafana/ui for consistency with other parts of the app, I've used the Alert component with info severity which displays in light blue. Happy to adapt it to the original design if you think that's better though.

image

image
(edit: changed content text after Chris comments)

onRemove={() => {
setDisplay(false);
}}
>
<Stack gap={1} direction={'column'} alignItems={'flex-start'}>
VikaCep marked this conversation as resolved.
Show resolved Hide resolved
Probes are automated tools that test websites and apps. They act like users by sending requests and checking the
responses.
Copy link
Contributor

Choose a reason for hiding this comment

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

Let's update the copy here as it isn't too specific to private probes but probes on a whole. @heitortsergent any thoughts?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

<LinkButton size="sm" href={`${getRoute(ROUTES.NewProbe)}`}>
Set up a Private Probe
</LinkButton>
</Stack>
</Alert>
);
};
33 changes: 33 additions & 0 deletions src/components/CheckEditor/CheckProbes/ProbesFilter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Icon, Input, useStyles2 } from '@grafana/ui';
import { css } from '@emotion/css';

import { Probe } from 'types';

export const ProbesFilter = ({ probes, onSearch }: { probes: Probe[]; onSearch: (probes: Probe[]) => void }) => {
const styles = useStyles2(getStyles);

const handleSearch = (event: React.ChangeEvent<HTMLInputElement>) => {
const searchValue = event.target.value.toLowerCase();

const filteredProbes = probes.filter(
(probe) => probe.region.toLowerCase().includes(searchValue) || probe.name.toLowerCase().includes(searchValue)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

In the original design it's suggested to search by more fields, but we don't have all that probes's info in the frontend, so I've limited to what we have available.

image

Copy link
Contributor

Choose a reason for hiding this comment

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

So, on that note, let's create our own internal mapping of it and make a tech-debt ticket that we would rather the api provides us this info at some point in the future. Our probes are static enough (despite the current migrations...) that I feel comfortable we can hard-code this in our repo as it gives a huge UX boost to the user.

As a user I'd just be confused why the docs website provides this level of info but the app doesn't.

A table of Public Probes in the Americas. It shows the Location, Country and Cloud Provider.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good idea. Addressed in c4a5d42 and 32e4775

);

onSearch(filteredProbes);
};

return (
<div className={styles.searchInput}>
<Input prefix={<Icon name="search" />} placeholder="Find a probe by city or region" onChange={handleSearch} />
</div>
);
};

const getStyles = (theme: GrafanaTheme2) => ({
searchInput: css({
marginTop: theme.spacing(1),
marginBottom: theme.spacing(1),
}),
});
121 changes: 121 additions & 0 deletions src/components/CheckEditor/CheckProbes/ProbesList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import React, { useMemo } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Checkbox, Label, useStyles2 } from '@grafana/ui';
import { css } from '@emotion/css';

import { Probe } from 'types';
import { ProbeStatus } from 'components/ProbeCard/ProbeStatus';

export const ProbesList = ({
title,
probes,
selectedProbes,
onSelectionChange,
}: {
title: string;
probes: Probe[];
selectedProbes: number[];
onSelectionChange: (probes: number[]) => void;
}) => {
const styles = useStyles2(getStyles);

const handleToggleAll = () => {
if (allProbesSelected) {
onSelectionChange(selectedProbes.filter((id) => !probes.some((probe) => probe.id === id)));
return;
}
onSelectionChange([
...selectedProbes,
...(probes.filter((probe) => !probe.deprecated).map((probe) => probe.id) as number[]),
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: use non-null assertion operator instead of type casting

Suggested change
...(probes.filter((probe) => !probe.deprecated).map((probe) => probe.id) as number[]),
...(probes.filter((probe) => !probe.deprecated).map((probe) => probe.id!)),

Copy link
Contributor

Choose a reason for hiding this comment

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

For another (potential) PR: We should probably not use the same type for a new Check|Probe as for an existing one. We have a bunch of components that require an id to be present, having to check for id and/or use non-null assertions in every component isnt super slick.

We should perhaps add type Probe = ExistingProbe | NewProbe where one is with .id and the other one isnt? (same for check) 🤷🏻

]);
};

const handleToggleProbe = (probe: Probe) => {
if (!probe.id || probe.deprecated) {
return;
}
if (selectedProbes.includes(probe.id)) {
onSelectionChange(selectedProbes.filter((p) => p !== probe.id));
return;
}
onSelectionChange([...selectedProbes, probe.id]);
};

const allProbesSelected = useMemo(
() => probes.every((probe) => selectedProbes.includes(probe.id as number)),
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: use non-null assertion operator instead of type casting

Suggested change
() => probes.every((probe) => selectedProbes.includes(probe.id as number)),
() => probes.every((probe) => selectedProbes.includes(probe.id!)),

[probes, selectedProbes]
);

return (
<div className={styles.probesColumn}>
<div className={styles.sectionHeader}>
<Checkbox id={`header-${title}`} onClick={handleToggleAll} checked={allProbesSelected} />
<Label htmlFor={`header-${title}`} className={styles.headerLabel}>
{title} ({probes.length})
</Label>
</div>
<div className={styles.probesList}>
{probes.map((probe: Probe) => (
<div key={probe.id} className={styles.item}>
<Checkbox
id={`probe-${probe.id}`}
onClick={() => handleToggleProbe(probe)}
checked={selectedProbes.includes(probe.id as number)}
VikaCep marked this conversation as resolved.
Show resolved Hide resolved
VikaCep marked this conversation as resolved.
Show resolved Hide resolved
/>
<Label htmlFor={`probe-${probe.id}`} className={styles.columnLabel}>
<ProbeStatus probe={probe} /> {probe.name}
</Label>
</div>
))}
</div>
</div>
);
};

const getStyles = (theme: GrafanaTheme2) => ({
item: css({
marginTop: theme.spacing(1),
marginBottom: theme.spacing(1),
display: `flex`,
gap: theme.spacing(1),
marginLeft: theme.spacing(1),
alignItems: 'center',
}),

probesColumn: css({
fontSize: theme.typography.h6.fontSize,
fontWeight: theme.typography.fontWeightLight,
}),

probesList: css({
display: 'flex',
flexDirection: 'column',
minWidth: '250px',
maxWidth: '350px',
maxHeight: '230px',
overflowY: 'auto',
}),

sectionHeader: css({
display: 'flex',
border: `1px solid ${theme.colors.border.weak}`,
backgroundColor: `${theme.colors.background.secondary}`,
padding: theme.spacing(1),
marginTop: theme.spacing(1),
marginBottom: theme.spacing(1),
gap: theme.spacing(1),
verticalAlign: 'middle',
alignItems: 'center',
}),

headerLabel: css({
fontWeight: theme.typography.fontWeightLight,
fontSize: theme.typography.h5.fontSize,
color: 'white',
}),

columnLabel: css({
fontWeight: theme.typography.fontWeightLight,
fontSize: theme.typography.h6.fontSize,
}),
});
2 changes: 1 addition & 1 deletion src/components/CheckEditor/ProbeOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { CheckFormValues, CheckType, Probe } from 'types';
import { useProbes } from 'data/useProbes';
import { SliderInput } from 'components/SliderInput';

import { CheckProbes } from './CheckProbes';
import { CheckProbes } from './CheckProbes/CheckProbes';

interface ProbeOptionsProps {
checkType: CheckType;
Expand Down
Loading
Loading