mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Cloudwatch: Add feedback labels to log groups selector (#60619)
* add max item text * add tests * add selected log groups counter label * cleanup * fix styling in selector fields * remove unused imports * move selected log groups to a new component * add confirm dialog * remove not used import * set max logs groups to 6 * add margin bottom
This commit is contained in:
parent
e1ea5490b3
commit
b3540b5f46
@ -1,14 +1,11 @@
|
|||||||
import { debounce } from 'lodash';
|
import { debounce } from 'lodash';
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { Icon, Input, useStyles2 } from '@grafana/ui';
|
import { Icon, Input } from '@grafana/ui';
|
||||||
|
|
||||||
import getStyles from './components/styles';
|
|
||||||
|
|
||||||
// TODO: consider moving search into grafana/ui, this is mostly the same as that in azure monitor
|
// TODO: consider moving search into grafana/ui, this is mostly the same as that in azure monitor
|
||||||
const Search = ({ searchFn, searchPhrase }: { searchPhrase: string; searchFn: (searchPhrase: string) => void }) => {
|
const Search = ({ searchFn, searchPhrase }: { searchPhrase: string; searchFn: (searchPhrase: string) => void }) => {
|
||||||
const [searchFilter, setSearchFilter] = useState(searchPhrase);
|
const [searchFilter, setSearchFilter] = useState(searchPhrase);
|
||||||
const styles = useStyles2(getStyles);
|
|
||||||
|
|
||||||
const debouncedSearch = useMemo(() => debounce(searchFn, 600), [searchFn]);
|
const debouncedSearch = useMemo(() => debounce(searchFn, 600), [searchFn]);
|
||||||
|
|
||||||
@ -21,8 +18,6 @@ const Search = ({ searchFn, searchPhrase }: { searchPhrase: string; searchFn: (s
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
className={styles.search}
|
|
||||||
width={64}
|
|
||||||
aria-label="log group search"
|
aria-label="log group search"
|
||||||
prefix={<Icon name="search" />}
|
prefix={<Icon name="search" />}
|
||||||
value={searchFilter}
|
value={searchFilter}
|
||||||
|
@ -177,4 +177,44 @@ describe('CrossAccountLogsQueryField', () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const labelText =
|
||||||
|
'Only the first 50 results can be shown. If you do not see an expected log group, try narrowing down your search.';
|
||||||
|
it('should not display max result info label in case less than 50 logs groups are being displayed', async () => {
|
||||||
|
const defer = new Deferred();
|
||||||
|
const fetchLogGroups = jest.fn(async () => {
|
||||||
|
await Promise.all([defer.promise]);
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
render(<CrossAccountLogsQueryField {...defaultProps} fetchLogGroups={fetchLogGroups} />);
|
||||||
|
await userEvent.click(screen.getByText('Select Log Groups'));
|
||||||
|
expect(screen.queryByText(labelText)).not.toBeInTheDocument();
|
||||||
|
defer.resolve();
|
||||||
|
await waitFor(() => expect(screen.queryByText(labelText)).not.toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display max result info label in case 50 or more logs groups are being displayed', async () => {
|
||||||
|
const defer = new Deferred();
|
||||||
|
const fetchLogGroups = jest.fn(async () => {
|
||||||
|
await Promise.all([defer.promise]);
|
||||||
|
return Array(50).map((i) => ({
|
||||||
|
value: `logGroup${i}`,
|
||||||
|
text: `logGroup${i}`,
|
||||||
|
label: `logGroup${i}`,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
render(<CrossAccountLogsQueryField {...defaultProps} fetchLogGroups={fetchLogGroups} />);
|
||||||
|
await userEvent.click(screen.getByText('Select Log Groups'));
|
||||||
|
expect(screen.queryByText(labelText)).not.toBeInTheDocument();
|
||||||
|
defer.resolve();
|
||||||
|
await waitFor(() => expect(screen.getByText(labelText)).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display log groups counter label', async () => {
|
||||||
|
render(<CrossAccountLogsQueryField {...defaultProps} selectedLogGroups={[]} />);
|
||||||
|
await userEvent.click(screen.getByText('Select Log Groups'));
|
||||||
|
await waitFor(() => expect(screen.getByText('0 log groups selected')).toBeInTheDocument());
|
||||||
|
await userEvent.click(screen.getByLabelText('logGroup2'));
|
||||||
|
await waitFor(() => expect(screen.getByText('1 log group selected')).toBeInTheDocument());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -2,13 +2,14 @@ import React, { useMemo, useState } from 'react';
|
|||||||
|
|
||||||
import { SelectableValue } from '@grafana/data';
|
import { SelectableValue } from '@grafana/data';
|
||||||
import { EditorField, Space } from '@grafana/experimental';
|
import { EditorField, Space } from '@grafana/experimental';
|
||||||
import { Button, Checkbox, IconButton, LoadingPlaceholder, Modal, useStyles2 } from '@grafana/ui';
|
import { Button, Checkbox, Icon, Label, LoadingPlaceholder, Modal, useStyles2 } from '@grafana/ui';
|
||||||
|
|
||||||
import Search from '../Search';
|
import Search from '../Search';
|
||||||
import { SelectableResourceValue } from '../api';
|
import { SelectableResourceValue } from '../api';
|
||||||
import { DescribeLogGroupsRequest } from '../types';
|
import { DescribeLogGroupsRequest } from '../types';
|
||||||
|
|
||||||
import { Account, ALL_ACCOUNTS_OPTION } from './Account';
|
import { Account, ALL_ACCOUNTS_OPTION } from './Account';
|
||||||
|
import { SelectedLogsGroups } from './SelectedLogsGroups';
|
||||||
import getStyles from './styles';
|
import getStyles from './styles';
|
||||||
|
|
||||||
type CrossAccountLogsQueryProps = {
|
type CrossAccountLogsQueryProps = {
|
||||||
@ -81,15 +82,17 @@ export const CrossAccountLogsQueryField = (props: CrossAccountLogsQueryProps) =>
|
|||||||
<>
|
<>
|
||||||
<Modal className={styles.modal} title="Select Log Groups" isOpen={isModalOpen} onDismiss={toggleModal}>
|
<Modal className={styles.modal} title="Select Log Groups" isOpen={isModalOpen} onDismiss={toggleModal}>
|
||||||
<div className={styles.logGroupSelectionArea}>
|
<div className={styles.logGroupSelectionArea}>
|
||||||
<EditorField label="Log Group Name">
|
<div className={styles.searchField}>
|
||||||
<Search
|
<EditorField label="Log Group Name">
|
||||||
searchFn={(phrase) => {
|
<Search
|
||||||
searchFn(phrase, searchAccountId);
|
searchFn={(phrase) => {
|
||||||
setSearchPhrase(phrase);
|
searchFn(phrase, searchAccountId);
|
||||||
}}
|
setSearchPhrase(phrase);
|
||||||
searchPhrase={searchPhrase}
|
}}
|
||||||
/>
|
searchPhrase={searchPhrase}
|
||||||
</EditorField>
|
/>
|
||||||
|
</EditorField>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Account
|
<Account
|
||||||
onChange={(accountId?: string) => {
|
onChange={(accountId?: string) => {
|
||||||
@ -102,6 +105,16 @@ export const CrossAccountLogsQueryField = (props: CrossAccountLogsQueryProps) =>
|
|||||||
</div>
|
</div>
|
||||||
<Space layout="block" v={2} />
|
<Space layout="block" v={2} />
|
||||||
<div>
|
<div>
|
||||||
|
{!isLoading && selectableLogGroups.length >= 25 && (
|
||||||
|
<>
|
||||||
|
<Label className={styles.limitLabel}>
|
||||||
|
<Icon name="info-circle"></Icon>
|
||||||
|
Only the first 50 results can be shown. If you do not see an expected log group, try narrowing down your
|
||||||
|
search.
|
||||||
|
</Label>
|
||||||
|
<Space layout="block" v={1} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<div className={styles.tableScroller}>
|
<div className={styles.tableScroller}>
|
||||||
<table className={styles.table}>
|
<table className={styles.table}>
|
||||||
<thead>
|
<thead>
|
||||||
@ -149,6 +162,10 @@ export const CrossAccountLogsQueryField = (props: CrossAccountLogsQueryProps) =>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Space layout="block" v={2} />
|
<Space layout="block" v={2} />
|
||||||
|
<Label className={styles.logGroupCountLabel}>
|
||||||
|
{selectedLogGroups.length} log group{selectedLogGroups.length !== 1 && 's'} selected
|
||||||
|
</Label>
|
||||||
|
<Space layout="block" v={1.5} />
|
||||||
<div>
|
<div>
|
||||||
<Button onClick={handleApply} type="button" className={styles.addBtn}>
|
<Button onClick={handleApply} type="button" className={styles.addBtn}>
|
||||||
Add log groups
|
Add log groups
|
||||||
@ -165,19 +182,7 @@ export const CrossAccountLogsQueryField = (props: CrossAccountLogsQueryProps) =>
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.selectedLogGroupsContainer}>
|
<SelectedLogsGroups selectedLogGroups={props.selectedLogGroups} onChange={props.onChange}></SelectedLogsGroups>
|
||||||
{props.selectedLogGroups.map((lg) => (
|
|
||||||
<div key={lg.value} className={styles.selectedLogGroup}>
|
|
||||||
{lg.label}
|
|
||||||
<IconButton
|
|
||||||
size="sm"
|
|
||||||
name="times"
|
|
||||||
className={styles.removeButton}
|
|
||||||
onClick={() => props.onChange(props.selectedLogGroups.filter((slg) => slg.value !== lg.value))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -0,0 +1,93 @@
|
|||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { SelectedLogsGroups } from './SelectedLogsGroups';
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
selectedLogGroups: [
|
||||||
|
{
|
||||||
|
value: 'aws/lambda/lambda-name1',
|
||||||
|
label: 'aws/lambda/lambda-name1',
|
||||||
|
text: 'aws/lambda/lambda-name1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'aws/lambda/lambda-name2',
|
||||||
|
label: 'aws/lambda/lambda-name2',
|
||||||
|
text: 'aws/lambda/lambda-name2',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
onChange: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('SelectedLogsGroups', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
});
|
||||||
|
describe("'Show more' button", () => {
|
||||||
|
it('should not be displayed in case 0 logs have been selected', async () => {
|
||||||
|
render(<SelectedLogsGroups {...defaultProps} selectedLogGroups={[]} />);
|
||||||
|
await waitFor(() => expect(screen.queryByText('Show all')).not.toBeInTheDocument());
|
||||||
|
});
|
||||||
|
it('should not be displayed in case logs group have been selected but theyre less than 10', async () => {
|
||||||
|
render(<SelectedLogsGroups {...defaultProps} />);
|
||||||
|
await waitFor(() => expect(screen.queryByText('Show all')).not.toBeInTheDocument());
|
||||||
|
});
|
||||||
|
it('should be displayed in case more than 10 log groups have been selected', async () => {
|
||||||
|
const selectedLogGroups = Array(12).map((i) => ({
|
||||||
|
value: `logGroup${i}`,
|
||||||
|
text: `logGroup${i}`,
|
||||||
|
label: `logGroup${i}`,
|
||||||
|
}));
|
||||||
|
render(<SelectedLogsGroups {...defaultProps} selectedLogGroups={selectedLogGroups} />);
|
||||||
|
await waitFor(() => expect(screen.getByText('Show all')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("'Clear selection' button", () => {
|
||||||
|
it('should not be displayed in case 0 logs have been selected', async () => {
|
||||||
|
render(<SelectedLogsGroups {...defaultProps} selectedLogGroups={[]} />);
|
||||||
|
await waitFor(() => expect(screen.queryByText('Clear selection')).not.toBeInTheDocument());
|
||||||
|
});
|
||||||
|
it('should be displayed in case at least one log group have been selected', async () => {
|
||||||
|
const selectedLogGroups = Array(11).map((i) => ({
|
||||||
|
value: `logGroup${i}`,
|
||||||
|
text: `logGroup${i}`,
|
||||||
|
label: `logGroup${i}`,
|
||||||
|
}));
|
||||||
|
render(<SelectedLogsGroups {...defaultProps} selectedLogGroups={selectedLogGroups} />);
|
||||||
|
await waitFor(() => expect(screen.getByText('Clear selection')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display confirm dialog before clearing all selections', async () => {
|
||||||
|
const selectedLogGroups = Array(11).map((i) => ({
|
||||||
|
value: `logGroup${i}`,
|
||||||
|
text: `logGroup${i}`,
|
||||||
|
label: `logGroup${i}`,
|
||||||
|
}));
|
||||||
|
render(<SelectedLogsGroups {...defaultProps} selectedLogGroups={selectedLogGroups} />);
|
||||||
|
await waitFor(() => userEvent.click(screen.getByText('Clear selection')));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText('Are you sure you want to clear all log groups?')).toBeInTheDocument()
|
||||||
|
);
|
||||||
|
await waitFor(() => userEvent.click(screen.getByLabelText('Confirm Modal Danger Button')));
|
||||||
|
expect(defaultProps.onChange).toHaveBeenCalledWith([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("'Clear selection' button", () => {
|
||||||
|
it('should not be displayed in case 0 logs have been selected', async () => {
|
||||||
|
render(<SelectedLogsGroups {...defaultProps} selectedLogGroups={[]} />);
|
||||||
|
await waitFor(() => expect(screen.queryByText('Clear selection')).not.toBeInTheDocument());
|
||||||
|
});
|
||||||
|
it('should be displayed in case at least one log group have been selected', async () => {
|
||||||
|
const selectedLogGroups = Array(11).map((i) => ({
|
||||||
|
value: `logGroup${i}`,
|
||||||
|
text: `logGroup${i}`,
|
||||||
|
label: `logGroup${i}`,
|
||||||
|
}));
|
||||||
|
render(<SelectedLogsGroups {...defaultProps} selectedLogGroups={selectedLogGroups} />);
|
||||||
|
await waitFor(() => expect(screen.getByText('Clear selection')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,84 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { Button, ConfirmModal, useStyles2 } from '@grafana/ui';
|
||||||
|
|
||||||
|
import { SelectableResourceValue } from '../api';
|
||||||
|
|
||||||
|
import getStyles from './styles';
|
||||||
|
|
||||||
|
type CrossAccountLogsQueryProps = {
|
||||||
|
selectedLogGroups: SelectableResourceValue[];
|
||||||
|
onChange: (selectedLogGroups: SelectableResourceValue[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_VISIBLE_LOG_GROUPS = 6;
|
||||||
|
|
||||||
|
export const SelectedLogsGroups = ({ selectedLogGroups, onChange }: CrossAccountLogsQueryProps) => {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
const [visibleSelectecLogGroups, setVisibleSelectecLogGroups] = useState(
|
||||||
|
selectedLogGroups.slice(0, MAX_VISIBLE_LOG_GROUPS)
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setVisibleSelectecLogGroups(selectedLogGroups.slice(0, MAX_VISIBLE_LOG_GROUPS));
|
||||||
|
}, [selectedLogGroups]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.selectedLogGroupsContainer}>
|
||||||
|
{visibleSelectecLogGroups.map((lg) => (
|
||||||
|
<Button
|
||||||
|
key={lg.value}
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
icon="times"
|
||||||
|
className={styles.removeButton}
|
||||||
|
onClick={() => {
|
||||||
|
onChange(selectedLogGroups.filter((slg) => slg.value !== lg.value));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{lg.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
{visibleSelectecLogGroups.length !== selectedLogGroups.length && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
icon="plus"
|
||||||
|
fill="outline"
|
||||||
|
className={styles.removeButton}
|
||||||
|
onClick={() => setVisibleSelectecLogGroups(selectedLogGroups)}
|
||||||
|
>
|
||||||
|
Show all
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{selectedLogGroups.length > 0 && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
icon="times"
|
||||||
|
fill="outline"
|
||||||
|
className={styles.removeButton}
|
||||||
|
onClick={() => setShowConfirm(true)}
|
||||||
|
>
|
||||||
|
Clear selection
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={showConfirm}
|
||||||
|
title="Clear Log Group Selection"
|
||||||
|
body="Are you sure you want to clear all log groups?"
|
||||||
|
confirmText="Yes"
|
||||||
|
dismissText="No"
|
||||||
|
icon="exclamation-triangle"
|
||||||
|
onConfirm={() => {
|
||||||
|
setShowConfirm(false);
|
||||||
|
onChange([]);
|
||||||
|
}}
|
||||||
|
onDismiss={() => setShowConfirm(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -10,8 +10,27 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
|||||||
|
|
||||||
selectedLogGroupsContainer: css({
|
selectedLogGroupsContainer: css({
|
||||||
marginLeft: theme.spacing(0.5),
|
marginLeft: theme.spacing(0.5),
|
||||||
|
marginBottom: theme.spacing(0.5),
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexFlow: 'wrap',
|
flexFlow: 'wrap',
|
||||||
|
gap: theme.spacing(1),
|
||||||
|
button: {
|
||||||
|
margin: 'unset',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
limitLabel: css({
|
||||||
|
color: theme.colors.text.secondary,
|
||||||
|
textAlign: 'center',
|
||||||
|
maxWidth: 'none',
|
||||||
|
svg: {
|
||||||
|
marginRight: theme.spacing(0.5),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
logGroupCountLabel: css({
|
||||||
|
color: theme.colors.text.secondary,
|
||||||
|
maxWidth: 'none',
|
||||||
}),
|
}),
|
||||||
|
|
||||||
tableScroller: css({
|
tableScroller: css({
|
||||||
@ -61,24 +80,16 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
searchField: css({
|
||||||
|
width: '100%',
|
||||||
|
marginRight: theme.spacing(1),
|
||||||
|
}),
|
||||||
|
|
||||||
resultLimit: css({
|
resultLimit: css({
|
||||||
margin: '4px 0',
|
margin: '4px 0',
|
||||||
fontStyle: 'italic',
|
fontStyle: 'italic',
|
||||||
}),
|
}),
|
||||||
|
|
||||||
selectedLogGroup: css({
|
|
||||||
background: theme.colors.background.secondary,
|
|
||||||
borderRadius: theme.shape.borderRadius(),
|
|
||||||
margin: theme.spacing(0, 1, 1, 0),
|
|
||||||
padding: theme.spacing(0.5, 0, 0.5, 1),
|
|
||||||
color: theme.colors.text.primary,
|
|
||||||
fontSize: theme.typography.size.sm,
|
|
||||||
}),
|
|
||||||
|
|
||||||
search: css({
|
|
||||||
marginRight: '10px',
|
|
||||||
}),
|
|
||||||
|
|
||||||
removeButton: css({
|
removeButton: css({
|
||||||
verticalAlign: 'middle',
|
verticalAlign: 'middle',
|
||||||
marginLeft: theme.spacing(0.5),
|
marginLeft: theme.spacing(0.5),
|
||||||
|
Loading…
Reference in New Issue
Block a user