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'; import sinon, { SinonSpy } from 'sinon';
describe('Filter', () => { 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 setFilter: SinonSpy;
let filterWrapper: ShallowWrapper<FilterProps, Readonly<{}>, Component<{}, {}, Component>>; let filterWrapper: ShallowWrapper<FilterProps, Readonly<{}>, Component<{}, {}, Component>>;
beforeEach(() => { beforeEach(() => {
setFilter = sinon.spy(); 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', () => { it('renders a button group', () => {
@ -29,6 +40,12 @@ describe('Filter', () => {
expect(btn.prop('color')).toBe('primary'); 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', () => { it('renders an all filter button which shows all targets', () => {
const btn = filterWrapper.find(Button).filterWhere((btn): boolean => btn.hasClass('all')); const btn = filterWrapper.find(Button).filterWhere((btn): boolean => btn.hasClass('all'));
btn.simulate('click'); btn.simulate('click');
@ -40,6 +57,46 @@ describe('Filter', () => {
const btn = filterWrapper.find(Button).filterWhere((btn): boolean => btn.hasClass('unhealthy')); const btn = filterWrapper.find(Button).filterWhere((btn): boolean => btn.hasClass('unhealthy'));
btn.simulate('click'); btn.simulate('click');
expect(setFilter.calledOnce).toBe(true); 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; showUnhealthy: boolean;
} }
export interface Expanded {
[scrapePool: string]: boolean;
}
export interface FilterProps { export interface FilterProps {
filter: FilterData; filter: FilterData;
setFilter: Dispatch<SetStateAction<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 { 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 = { const btnProps = {
all: { all: {
active: showHealthy, active: showHealthy,
@ -27,11 +42,18 @@ const Filter: FC<FilterProps> = ({ filter, setFilter }) => {
color: 'primary', color: 'primary',
onClick: (): void => setFilter({ ...filter, showHealthy: false }), onClick: (): void => setFilter({ ...filter, showHealthy: false }),
}, },
expansionState: {
active: false,
className: `expansion ${styles.btn}`,
color: 'primary',
onClick: (): void => setExpanded(mapExpansion(!allExpanded)),
},
}; };
return ( return (
<ButtonGroup> <ButtonGroup>
<Button {...btnProps.all}>All</Button> <Button {...btnProps.all}>All</Button>
<Button {...btnProps.unhealthy}>Unhealthy</Button> <Button {...btnProps.unhealthy}>Unhealthy</Button>
<Button {...btnProps.expansionState}>{allExpanded ? 'Collapse All' : 'Expand All'}</Button>
</ButtonGroup> </ButtonGroup>
); );
}; };

View file

@ -10,10 +10,6 @@ import { FetchMock } from 'jest-fetch-mock/types';
import { PathPrefixContext } from '../../contexts/PathPrefixContext'; import { PathPrefixContext } from '../../contexts/PathPrefixContext';
describe('ScrapePoolList', () => { describe('ScrapePoolList', () => {
const defaultProps = {
filter: { showHealthy: true, showUnhealthy: true },
};
beforeEach(() => { beforeEach(() => {
fetchMock.resetMocks(); fetchMock.resetMocks();
}); });
@ -38,7 +34,7 @@ describe('ScrapePoolList', () => {
await act(async () => { await act(async () => {
scrapePoolList = mount( scrapePoolList = mount(
<PathPrefixContext.Provider value="/path/prefix"> <PathPrefixContext.Provider value="/path/prefix">
<ScrapePoolList {...defaultProps} /> <ScrapePoolList />
</PathPrefixContext.Provider> </PathPrefixContext.Provider>
); );
}); });
@ -55,27 +51,6 @@ describe('ScrapePoolList', () => {
expect(panel).toHaveLength(1); 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', () => { describe('when an error is returned', () => {
@ -86,7 +61,7 @@ describe('ScrapePoolList', () => {
await act(async () => { await act(async () => {
scrapePoolList = mount( scrapePoolList = mount(
<PathPrefixContext.Provider value="/path/prefix"> <PathPrefixContext.Provider value="/path/prefix">
<ScrapePoolList {...defaultProps} /> <ScrapePoolList />
</PathPrefixContext.Provider> </PathPrefixContext.Provider>
); );
}); });

View file

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

View file

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

View file

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

View file

@ -1,7 +1,6 @@
import React from 'react'; import React from 'react';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import Targets from './Targets'; import Targets from './Targets';
import Filter from './Filter';
import ScrapePoolList from './ScrapePoolList'; import ScrapePoolList from './ScrapePoolList';
describe('Targets', () => { describe('Targets', () => {
@ -19,14 +18,8 @@ describe('Targets', () => {
expect(h2).toHaveLength(1); 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', () => { it('renders a scrape pool list', () => {
const scrapePoolList = targets.find(ScrapePoolList); const scrapePoolList = targets.find(ScrapePoolList);
expect(scrapePoolList).toHaveLength(1); 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 React, { FC } from 'react';
import { RouteComponentProps } from '@reach/router'; import { RouteComponentProps } from '@reach/router';
import Filter from './Filter';
import ScrapePoolList from './ScrapePoolList'; 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 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 ( return (
<> <>
<h2>Targets</h2> <h2>Targets</h2>
<Filter {...filterProps} /> <ScrapePoolList />
<ScrapePoolList {...scrapePoolListProps} />
</> </>
); );
}; };