From 0d8db5295421a8a38f6e68a5f8bf974824d7dd4c Mon Sep 17 00:00:00 2001 From: Dustin Hooten Date: Mon, 22 Mar 2021 04:54:12 -0600 Subject: [PATCH] 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 * update local storage key to prevent bad state Signed-off-by: Dustin Hooten * PR feedback Signed-off-by: Dustin Hooten * split big state object into smaller ones Signed-off-by: Dustin Hooten * fix duplication typo Signed-off-by: Dustin Hooten --- .../src/pages/targets/Filter.test.tsx | 63 ++++++++++++++++++- web/ui/react-app/src/pages/targets/Filter.tsx | 24 ++++++- .../src/pages/targets/ScrapePoolList.test.tsx | 29 +-------- .../src/pages/targets/ScrapePoolList.tsx | 47 ++++++++++---- .../pages/targets/ScrapePoolPanel.test.tsx | 9 ++- .../src/pages/targets/ScrapePoolPanel.tsx | 8 +-- .../src/pages/targets/Targets.test.tsx | 7 --- .../react-app/src/pages/targets/Targets.tsx | 12 +--- 8 files changed, 132 insertions(+), 67 deletions(-) diff --git a/web/ui/react-app/src/pages/targets/Filter.test.tsx b/web/ui/react-app/src/pages/targets/Filter.test.tsx index 79d444ff3e..dc24511375 100644 --- a/web/ui/react-app/src/pages/targets/Filter.test.tsx +++ b/web/ui/react-app/src/pages/targets/Filter.test.tsx @@ -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, Component<{}, {}, Component>>; beforeEach(() => { setFilter = sinon.spy(); - filterWrapper = shallow(); + setExpaned = sinon.spy(); + filterWrapper = shallow( + + ); }); 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( + + ); + 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); + }); + }); }); }); diff --git a/web/ui/react-app/src/pages/targets/Filter.tsx b/web/ui/react-app/src/pages/targets/Filter.tsx index 900a6d5b0d..e913f5d582 100644 --- a/web/ui/react-app/src/pages/targets/Filter.tsx +++ b/web/ui/react-app/src/pages/targets/Filter.tsx @@ -7,13 +7,28 @@ export interface FilterData { showUnhealthy: boolean; } +export interface Expanded { + [scrapePool: string]: boolean; +} + export interface FilterProps { filter: FilterData; setFilter: Dispatch>; + expanded: Expanded; + setExpanded: Dispatch>; } -const Filter: FC = ({ filter, setFilter }) => { +const Filter: FC = ({ 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 = ({ 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 ( + ); }; diff --git a/web/ui/react-app/src/pages/targets/ScrapePoolList.test.tsx b/web/ui/react-app/src/pages/targets/ScrapePoolList.test.tsx index b5ed839a51..2ae9517073 100644 --- a/web/ui/react-app/src/pages/targets/ScrapePoolList.test.tsx +++ b/web/ui/react-app/src/pages/targets/ScrapePoolList.test.tsx @@ -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( - + ); }); @@ -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( - - - - ); - }); - 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( - + ); }); diff --git a/web/ui/react-app/src/pages/targets/ScrapePoolList.tsx b/web/ui/react-app/src/pages/targets/ScrapePoolList.tsx index 8e55f82fc5..72868f7415 100644 --- a/web/ui/react-app/src/pages/targets/ScrapePoolList.tsx +++ b/web/ui/react-app/src/pages/targets/ScrapePoolList.tsx @@ -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 = ({ filter, activeTargets }) => { +export const ScrapePoolContent: FC = ({ 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((panels, scrapePool) => { - const targetGroup = targetGroups[scrapePool]; - const isHealthy = targetGroup.upCount === targetGroup.targets.length; - return (isHealthy && showHealthy) || (!isHealthy && showUnhealthy) - ? [...panels, ] - : panels; - }, [])} + + {Object.keys(targetGroups) + .filter(scrapePool => { + const targetGroup = targetGroups[scrapePool]; + const isHealthy = targetGroup.upCount === targetGroup.targets.length; + return (isHealthy && showHealthy) || (!isHealthy && showUnhealthy); + }) + .map(scrapePool => ( + 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(`${pathPrefix}/${API_PATH}/targets?state=active`); const { status: responseStatus } = response; @@ -39,7 +63,6 @@ const ScrapePoolList: FC<{ filter: FilterData }> = ({ filter }) => { return ( { const defaultProps = { scrapePool: 'blackbox', targetGroup: targetGroups.blackbox, + expanded: true, + toggleExpanded: sinon.spy(), }; const scrapePoolPanel = shallow(); @@ -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); }); }); diff --git a/web/ui/react-app/src/pages/targets/ScrapePoolPanel.tsx b/web/ui/react-app/src/pages/targets/ScrapePoolPanel.tsx index bdbc995ae0..0fe90c4906 100644 --- a/web/ui/react-app/src/pages/targets/ScrapePoolPanel.tsx +++ b/web/ui/react-app/src/pages/targets/ScrapePoolPanel.tsx @@ -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 = ({ scrapePool, targetGroup }) => { - const [{ expanded }, setOptions] = useLocalStorage(`targets-${scrapePool}-expanded`, { expanded: true }); +const ScrapePoolPanel: FC = ({ 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 = ({ scrapePool, targetGroup }) => { return (
- setOptions({ expanded: !expanded })} showMore={expanded}> + {`${scrapePool} (${targetGroup.upCount}/${targetGroup.targets.length} up)`} diff --git a/web/ui/react-app/src/pages/targets/Targets.test.tsx b/web/ui/react-app/src/pages/targets/Targets.test.tsx index 198ab40a39..ea4ffd7c56 100644 --- a/web/ui/react-app/src/pages/targets/Targets.test.tsx +++ b/web/ui/react-app/src/pages/targets/Targets.test.tsx @@ -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 }); }); }); diff --git a/web/ui/react-app/src/pages/targets/Targets.tsx b/web/ui/react-app/src/pages/targets/Targets.tsx index 99314d47c1..0883d1c81f 100644 --- a/web/ui/react-app/src/pages/targets/Targets.tsx +++ b/web/ui/react-app/src/pages/targets/Targets.tsx @@ -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 = () => { - 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 ( <>

Targets

- - + ); };