Feature: Add collapse/expand all button to target page for react ui (#8486)

* Feature: Add collapse/expand all button to target page for react ui

Signed-off-by: Dustin Hooten <dustinhooten@gmail.com>

* update local storage key to prevent bad state

Signed-off-by: Dustin Hooten <dustinhooten@gmail.com>

* PR feedback

Signed-off-by: Dustin Hooten <dustinhooten@gmail.com>

* split big state object into smaller ones

Signed-off-by: Dustin Hooten <dustinhooten@gmail.com>

* fix duplication typo

Signed-off-by: Dustin Hooten <dustinhooten@gmail.com>
This commit is contained in:
Dustin Hooten 2021-03-22 04:54:12 -06:00 committed by GitHub
parent c0c36b1155
commit 0d8db52954
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 132 additions and 67 deletions

View file

@ -5,12 +5,23 @@ import Filter, { FilterData, FilterProps } from './Filter';
import sinon, { SinonSpy } from 'sinon';
describe('Filter', () => {
const initialState: FilterData = { showHealthy: true, showUnhealthy: true };
const initialExpanded = {
scrapePool1: true,
scrapePool2: true,
};
let setExpaned: SinonSpy;
const initialState: FilterData = {
showHealthy: true,
showUnhealthy: true,
};
let setFilter: SinonSpy;
let filterWrapper: ShallowWrapper<FilterProps, Readonly<{}>, Component<{}, {}, Component>>;
beforeEach(() => {
setFilter = sinon.spy();
filterWrapper = shallow(<Filter filter={initialState} setFilter={setFilter} />);
setExpaned = sinon.spy();
filterWrapper = shallow(
<Filter filter={initialState} setFilter={setFilter} expanded={initialExpanded} setExpanded={setExpaned} />
);
});
it('renders a button group', () => {
@ -29,6 +40,12 @@ describe('Filter', () => {
expect(btn.prop('color')).toBe('primary');
});
it('renders an expansion filter button that is inactive', () => {
const btn = filterWrapper.find(Button).filterWhere((btn): boolean => btn.hasClass('expansion'));
expect(btn.prop('active')).toBe(false);
expect(btn.prop('color')).toBe('primary');
});
it('renders an all filter button which shows all targets', () => {
const btn = filterWrapper.find(Button).filterWhere((btn): boolean => btn.hasClass('all'));
btn.simulate('click');
@ -40,6 +57,46 @@ describe('Filter', () => {
const btn = filterWrapper.find(Button).filterWhere((btn): boolean => btn.hasClass('unhealthy'));
btn.simulate('click');
expect(setFilter.calledOnce).toBe(true);
expect(setFilter.getCall(0).args[0]).toEqual({ showHealthy: false, showUnhealthy: true });
expect(setFilter.getCall(0).args[0]).toEqual({
showHealthy: false,
showUnhealthy: true,
});
});
describe('Expansion filter', () => {
[
{
name: 'expanded => collapsed',
initial: initialExpanded,
final: { scrapePool1: false, scrapePool2: false },
text: 'Collapse All',
},
{
name: 'collapsed => expanded',
initial: { scrapePool1: false, scrapePool2: false },
final: initialExpanded,
text: 'Expand All',
},
{
name: 'some expanded => expanded',
initial: { scrapePool1: true, scrapePool2: false },
final: initialExpanded,
text: 'Expand All',
},
].forEach(({ name, text, initial, final }) => {
it(`filters targets ${name}`, (): void => {
const filter = { ...initialState };
const filterCallback = sinon.spy();
const expandedCallback = sinon.spy();
const filterW = shallow(
<Filter filter={filter} setFilter={filterCallback} expanded={initial} setExpanded={expandedCallback} />
);
const btn = filterW.find(Button).filterWhere((btn): boolean => btn.hasClass('expansion'));
expect(btn.children().text()).toEqual(text);
btn.simulate('click');
expect(expandedCallback.calledOnce).toBe(true);
expect(expandedCallback.getCall(0).args[0]).toEqual(final);
});
});
});
});

View file

@ -7,13 +7,28 @@ export interface FilterData {
showUnhealthy: boolean;
}
export interface Expanded {
[scrapePool: string]: boolean;
}
export interface FilterProps {
filter: FilterData;
setFilter: Dispatch<SetStateAction<FilterData>>;
expanded: Expanded;
setExpanded: Dispatch<SetStateAction<Expanded>>;
}
const Filter: FC<FilterProps> = ({ filter, setFilter }) => {
const Filter: FC<FilterProps> = ({ filter, setFilter, expanded, setExpanded }) => {
const { showHealthy } = filter;
const allExpanded = Object.values(expanded).every((v: boolean): boolean => v);
const mapExpansion = (next: boolean): Expanded =>
Object.keys(expanded).reduce(
(acc: { [scrapePool: string]: boolean }, scrapePool: string) => ({
...acc,
[scrapePool]: next,
}),
{}
);
const btnProps = {
all: {
active: showHealthy,
@ -27,11 +42,18 @@ const Filter: FC<FilterProps> = ({ filter, setFilter }) => {
color: 'primary',
onClick: (): void => setFilter({ ...filter, showHealthy: false }),
},
expansionState: {
active: false,
className: `expansion ${styles.btn}`,
color: 'primary',
onClick: (): void => setExpanded(mapExpansion(!allExpanded)),
},
};
return (
<ButtonGroup>
<Button {...btnProps.all}>All</Button>
<Button {...btnProps.unhealthy}>Unhealthy</Button>
<Button {...btnProps.expansionState}>{allExpanded ? 'Collapse All' : 'Expand All'}</Button>
</ButtonGroup>
);
};

View file

@ -10,10 +10,6 @@ import { FetchMock } from 'jest-fetch-mock/types';
import { PathPrefixContext } from '../../contexts/PathPrefixContext';
describe('ScrapePoolList', () => {
const defaultProps = {
filter: { showHealthy: true, showUnhealthy: true },
};
beforeEach(() => {
fetchMock.resetMocks();
});
@ -38,7 +34,7 @@ describe('ScrapePoolList', () => {
await act(async () => {
scrapePoolList = mount(
<PathPrefixContext.Provider value="/path/prefix">
<ScrapePoolList {...defaultProps} />
<ScrapePoolList />
</PathPrefixContext.Provider>
);
});
@ -55,27 +51,6 @@ describe('ScrapePoolList', () => {
expect(panel).toHaveLength(1);
});
});
it('filters by health', async () => {
const props = {
...defaultProps,
filter: { showHealthy: false, showUnhealthy: true },
};
await act(async () => {
scrapePoolList = mount(
<PathPrefixContext.Provider value="/path/prefix">
<ScrapePoolList {...props} />
</PathPrefixContext.Provider>
);
});
scrapePoolList.update();
expect(mock).toHaveBeenCalledWith('/path/prefix/api/v1/targets?state=active', {
cache: 'no-store',
credentials: 'same-origin',
});
const panels = scrapePoolList.find(ScrapePoolPanel);
expect(panels).toHaveLength(0);
});
});
describe('when an error is returned', () => {
@ -86,7 +61,7 @@ describe('ScrapePoolList', () => {
await act(async () => {
scrapePoolList = mount(
<PathPrefixContext.Provider value="/path/prefix">
<ScrapePoolList {...defaultProps} />
<ScrapePoolList />
</PathPrefixContext.Provider>
);
});

View file

@ -1,29 +1,53 @@
import React, { FC } from 'react';
import { FilterData } from './Filter';
import Filter, { Expanded, FilterData } from './Filter';
import { useFetch } from '../../hooks/useFetch';
import { groupTargets, Target } from './target';
import ScrapePoolPanel from './ScrapePoolPanel';
import { withStatusIndicator } from '../../components/withStatusIndicator';
import { usePathPrefix } from '../../contexts/PathPrefixContext';
import { API_PATH } from '../../constants/constants';
import { useLocalStorage } from '../../hooks/useLocalStorage';
interface ScrapePoolListProps {
filter: FilterData;
activeTargets: Target[];
}
export const ScrapePoolContent: FC<ScrapePoolListProps> = ({ filter, activeTargets }) => {
export const ScrapePoolContent: FC<ScrapePoolListProps> = ({ activeTargets }) => {
const targetGroups = groupTargets(activeTargets);
const initialFilter: FilterData = {
showHealthy: true,
showUnhealthy: true,
};
const [filter, setFilter] = useLocalStorage('targets-page-filter', initialFilter);
const initialExpanded: Expanded = Object.keys(targetGroups).reduce(
(acc: { [scrapePool: string]: boolean }, scrapePool: string) => ({
...acc,
[scrapePool]: true,
}),
{}
);
const [expanded, setExpanded] = useLocalStorage('targets-page-expansion-state', initialExpanded);
const { showHealthy, showUnhealthy } = filter;
return (
<>
{Object.keys(targetGroups).reduce<JSX.Element[]>((panels, scrapePool) => {
const targetGroup = targetGroups[scrapePool];
const isHealthy = targetGroup.upCount === targetGroup.targets.length;
return (isHealthy && showHealthy) || (!isHealthy && showUnhealthy)
? [...panels, <ScrapePoolPanel key={scrapePool} scrapePool={scrapePool} targetGroup={targetGroup} />]
: panels;
}, [])}
<Filter filter={filter} setFilter={setFilter} expanded={expanded} setExpanded={setExpanded} />
{Object.keys(targetGroups)
.filter(scrapePool => {
const targetGroup = targetGroups[scrapePool];
const isHealthy = targetGroup.upCount === targetGroup.targets.length;
return (isHealthy && showHealthy) || (!isHealthy && showUnhealthy);
})
.map<JSX.Element>(scrapePool => (
<ScrapePoolPanel
key={scrapePool}
scrapePool={scrapePool}
targetGroup={targetGroups[scrapePool]}
expanded={expanded[scrapePool]}
toggleExpanded={(): void => setExpanded({ ...expanded, [scrapePool]: !expanded[scrapePool] })}
/>
))}
</>
);
};
@ -31,7 +55,7 @@ ScrapePoolContent.displayName = 'ScrapePoolContent';
const ScrapePoolListWithStatusIndicator = withStatusIndicator(ScrapePoolContent);
const ScrapePoolList: FC<{ filter: FilterData }> = ({ filter }) => {
const ScrapePoolList: FC = () => {
const pathPrefix = usePathPrefix();
const { response, error, isLoading } = useFetch<ScrapePoolListProps>(`${pathPrefix}/${API_PATH}/targets?state=active`);
const { status: responseStatus } = response;
@ -39,7 +63,6 @@ const ScrapePoolList: FC<{ filter: FilterData }> = ({ filter }) => {
return (
<ScrapePoolListWithStatusIndicator
{...response.data}
filter={filter}
error={badResponse ? new Error(responseStatus) : error}
isLoading={isLoading}
componentTitle="Targets information"

View file

@ -6,11 +6,14 @@ import { Button, Collapse, Table, Badge } from 'reactstrap';
import { Target, getColor } from './target';
import EndpointLink from './EndpointLink';
import TargetLabels from './TargetLabels';
import sinon from 'sinon';
describe('ScrapePoolPanel', () => {
const defaultProps = {
scrapePool: 'blackbox',
targetGroup: targetGroups.blackbox,
expanded: true,
toggleExpanded: sinon.spy(),
};
const scrapePoolPanel = shallow(<ScrapePoolPanel {...defaultProps} />);
@ -31,6 +34,7 @@ describe('ScrapePoolPanel', () => {
it('renders an anchor with up count and normal color if upCount == targetsCount', () => {
const props = {
...defaultProps,
scrapePool: 'prometheus',
targetGroup: targetGroups.prometheus,
};
@ -45,8 +49,10 @@ describe('ScrapePoolPanel', () => {
it('renders a show more btn if collapsed', () => {
const props = {
...defaultProps,
scrapePool: 'prometheus',
targetGroup: targetGroups.prometheus,
toggleExpanded: sinon.spy(),
};
const div = document.createElement('div');
div.id = `series-labels-prometheus-0`;
@ -55,8 +61,7 @@ describe('ScrapePoolPanel', () => {
const btn = scrapePoolPanel.find(Button);
btn.simulate('click');
const collapse = scrapePoolPanel.find(Collapse);
expect(collapse.prop('isOpen')).toBe(false);
expect(props.toggleExpanded.calledOnce).toBe(true);
});
});

View file

@ -6,19 +6,19 @@ import { Target } from './target';
import EndpointLink from './EndpointLink';
import TargetLabels from './TargetLabels';
import { now } from 'moment';
import { useLocalStorage } from '../../hooks/useLocalStorage';
import { ToggleMoreLess } from '../../components/ToggleMoreLess';
import { formatRelative, humanizeDuration } from '../../utils';
interface PanelProps {
scrapePool: string;
targetGroup: ScrapePool;
expanded: boolean;
toggleExpanded: () => void;
}
export const columns = ['Endpoint', 'State', 'Labels', 'Last Scrape', 'Scrape Duration', 'Error'];
const ScrapePoolPanel: FC<PanelProps> = ({ scrapePool, targetGroup }) => {
const [{ expanded }, setOptions] = useLocalStorage(`targets-${scrapePool}-expanded`, { expanded: true });
const ScrapePoolPanel: FC<PanelProps> = ({ scrapePool, targetGroup, expanded, toggleExpanded }) => {
const modifier = targetGroup.upCount < targetGroup.targets.length ? 'danger' : 'normal';
const id = `pool-${scrapePool}`;
const anchorProps = {
@ -28,7 +28,7 @@ const ScrapePoolPanel: FC<PanelProps> = ({ scrapePool, targetGroup }) => {
return (
<div className={styles.container}>
<ToggleMoreLess event={(): void => setOptions({ expanded: !expanded })} showMore={expanded}>
<ToggleMoreLess event={toggleExpanded} showMore={expanded}>
<a className={styles[modifier]} {...anchorProps}>
{`${scrapePool} (${targetGroup.upCount}/${targetGroup.targets.length} up)`}
</a>

View file

@ -1,7 +1,6 @@
import React from 'react';
import { shallow } from 'enzyme';
import Targets from './Targets';
import Filter from './Filter';
import ScrapePoolList from './ScrapePoolList';
describe('Targets', () => {
@ -19,14 +18,8 @@ describe('Targets', () => {
expect(h2).toHaveLength(1);
});
});
it('renders a filter', () => {
const filter = targets.find(Filter);
expect(filter).toHaveLength(1);
expect(filter.prop('filter')).toEqual({ showHealthy: true, showUnhealthy: true });
});
it('renders a scrape pool list', () => {
const scrapePoolList = targets.find(ScrapePoolList);
expect(scrapePoolList).toHaveLength(1);
expect(scrapePoolList.prop('filter')).toEqual({ showHealthy: true, showUnhealthy: true });
});
});

View file

@ -1,22 +1,12 @@
import React, { FC } from 'react';
import { RouteComponentProps } from '@reach/router';
import Filter from './Filter';
import ScrapePoolList from './ScrapePoolList';
import { useLocalStorage } from '../../hooks/useLocalStorage';
import { usePathPrefix } from '../../contexts/PathPrefixContext';
import { API_PATH } from '../../constants/constants';
const Targets: FC<RouteComponentProps> = () => {
const pathPrefix = usePathPrefix();
const [filter, setFilter] = useLocalStorage('targets-page-filter', { showHealthy: true, showUnhealthy: true });
const filterProps = { filter, setFilter };
const scrapePoolListProps = { filter, pathPrefix, API_PATH };
return (
<>
<h2>Targets</h2>
<Filter {...filterProps} />
<ScrapePoolList {...scrapePoolListProps} />
<ScrapePoolList />
</>
);
};