diff --git a/awx/ui_next/src/screens/Dashboard/Dashboard.jsx b/awx/ui_next/src/screens/Dashboard/Dashboard.jsx index f8772f5185..ee8fa5b82e 100644 --- a/awx/ui_next/src/screens/Dashboard/Dashboard.jsx +++ b/awx/ui_next/src/screens/Dashboard/Dashboard.jsx @@ -4,13 +4,7 @@ import styled from 'styled-components'; import { t } from '@lingui/macro'; import { Card, - CardHeader, - CardActions, - CardBody, PageSection, - Select, - SelectVariant, - SelectOption, Tabs, Tab, TabTitleText, @@ -21,9 +15,9 @@ import { DashboardAPI } from '../../api'; import ScreenHeader from '../../components/ScreenHeader'; import JobList from '../../components/JobList'; import ContentLoading from '../../components/ContentLoading'; -import LineChart from './shared/LineChart'; import Count from './shared/Count'; import TemplateList from '../../components/TemplateList'; +import DashboardGraph from './DashboardGraph'; const Counts = styled.div` display: grid; @@ -45,67 +39,25 @@ const MainPageSection = styled(PageSection)` } `; -const GraphCardHeader = styled(CardHeader)` - margin-top: var(--pf-global--spacer--lg); -`; - -const GraphCardActions = styled(CardActions)` - margin-left: initial; - padding-left: 0; -`; - function Dashboard() { - const [isPeriodDropdownOpen, setIsPeriodDropdownOpen] = useState(false); - const [isJobTypeDropdownOpen, setIsJobTypeDropdownOpen] = useState(false); - const [periodSelection, setPeriodSelection] = useState('month'); - const [jobTypeSelection, setJobTypeSelection] = useState('all'); const [activeTabId, setActiveTabId] = useState(0); const { isLoading, - result: { jobGraphData, countData }, + result: countData, request: fetchDashboardGraph, } = useRequest( useCallback(async () => { - const [{ data }, { data: dataFromCount }] = await Promise.all([ - DashboardAPI.readJobGraph({ - period: periodSelection, - job_type: jobTypeSelection, - }), - DashboardAPI.read(), - ]); - const newData = {}; - data.jobs.successful.forEach(([dateSecs, count]) => { - if (!newData[dateSecs]) { - newData[dateSecs] = {}; - } - newData[dateSecs].successful = count; - }); - data.jobs.failed.forEach(([dateSecs, count]) => { - if (!newData[dateSecs]) { - newData[dateSecs] = {}; - } - newData[dateSecs].failed = count; - }); - const jobData = Object.keys(newData).map(dateSecs => { - const [created] = new Date(dateSecs * 1000).toISOString().split('T'); - newData[dateSecs].created = created; - return newData[dateSecs]; - }); - return { - jobGraphData: jobData, - countData: dataFromCount, - }; - }, [periodSelection, jobTypeSelection]), - { - jobGraphData: [], - countData: {}, - } + const { data: dataFromCount } = await DashboardAPI.read(); + + return dataFromCount; + }, []), + {} ); useEffect(() => { fetchDashboardGraph(); - }, [fetchDashboardGraph, periodSelection, jobTypeSelection]); + }, [fetchDashboardGraph]); if (isLoading) { return ( @@ -171,67 +123,7 @@ function Dashboard() { eventKey={0} title={{t`Job status`}} > - - - - - - - - - - - + ', () => { pageWrapper.update(); expect(pageWrapper.find('TemplateList').length).toBe(1); }); - - test('renders month-based/all job type chart by default', () => { - expect(graphRequest).toHaveBeenCalledWith({ - job_type: 'all', - period: 'month', - }); - }); }); diff --git a/awx/ui_next/src/screens/Dashboard/DashboardGraph.jsx b/awx/ui_next/src/screens/Dashboard/DashboardGraph.jsx new file mode 100644 index 0000000000..3d0950e2cd --- /dev/null +++ b/awx/ui_next/src/screens/Dashboard/DashboardGraph.jsx @@ -0,0 +1,182 @@ +import React, { Fragment, useCallback, useEffect, useState } from 'react'; +import styled from 'styled-components'; + +import { t } from '@lingui/macro'; +import { + Card, + CardHeader, + CardActions, + CardBody, + PageSection, + Select, + SelectVariant, + SelectOption, +} from '@patternfly/react-core'; + +import useRequest from '../../util/useRequest'; +import { DashboardAPI } from '../../api'; +import ContentLoading from '../../components/ContentLoading'; +import LineChart from './shared/LineChart'; + +const StatusSelect = styled(Select)` + && { + --pf-c-select__toggle--MinWidth: 165px; + } +`; +const GraphCardHeader = styled(CardHeader)` + margin-top: var(--pf-global--spacer--lg); +`; + +const GraphCardActions = styled(CardActions)` + margin-left: initial; + padding-left: 0; +`; + +function DashboardGraph() { + const [isPeriodDropdownOpen, setIsPeriodDropdownOpen] = useState(false); + const [isJobTypeDropdownOpen, setIsJobTypeDropdownOpen] = useState(false); + const [isJobStatusDropdownOpen, setIsJobStatusDropdownOpen] = useState(false); + const [periodSelection, setPeriodSelection] = useState('month'); + const [jobTypeSelection, setJobTypeSelection] = useState('all'); + const [jobStatusSelection, setJobStatusSelection] = useState('all'); + + const { + isLoading, + result: jobGraphData, + request: fetchDashboardGraph, + } = useRequest( + useCallback(async () => { + const { data } = await DashboardAPI.readJobGraph({ + period: periodSelection, + job_type: jobTypeSelection, + }); + const newData = {}; + data.jobs.successful.forEach(([dateSecs, count]) => { + if (!newData[dateSecs]) { + newData[dateSecs] = {}; + } + newData[dateSecs].successful = count; + }); + data.jobs.failed.forEach(([dateSecs, count]) => { + if (!newData[dateSecs]) { + newData[dateSecs] = {}; + } + newData[dateSecs].failed = count; + }); + const jobData = Object.keys(newData).map(dateSecs => { + const [created] = new Date(dateSecs * 1000).toISOString().split('T'); + newData[dateSecs].created = created; + return newData[dateSecs]; + }); + return jobData; + }, [periodSelection, jobTypeSelection]), + [] + ); + + useEffect(() => { + fetchDashboardGraph(); + }, [fetchDashboardGraph, periodSelection, jobTypeSelection]); + if (isLoading) { + return ( + + + + + + ); + } + + return ( + + + + + + { + setIsJobStatusDropdownOpen(false); + setJobStatusSelection(selection); + }} + selections={jobStatusSelection} + isOpen={isJobStatusDropdownOpen} + > + {t`All jobs`} + {t`Successful jobs`} + {t`Failed jobs`} + + + + + + + + ); +} +export default DashboardGraph; diff --git a/awx/ui_next/src/screens/Dashboard/DashboardGraph.test.jsx b/awx/ui_next/src/screens/Dashboard/DashboardGraph.test.jsx new file mode 100644 index 0000000000..d25d9d8d56 --- /dev/null +++ b/awx/ui_next/src/screens/Dashboard/DashboardGraph.test.jsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; + +import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; + +import { DashboardAPI } from '../../api'; +import DashboardGraph from './DashboardGraph'; + +jest.mock('../../api'); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), +})); + +describe('', () => { + let pageWrapper; + let graphRequest; + + beforeEach(async () => { + await act(async () => { + DashboardAPI.read.mockResolvedValue({}); + graphRequest = DashboardAPI.readJobGraph; + graphRequest.mockResolvedValue({}); + pageWrapper = mountWithContexts(); + }); + }); + + afterEach(() => { + pageWrapper.unmount(); + }); + test('renders month-based/all job type chart by default', () => { + expect(graphRequest).toHaveBeenCalledWith({ + job_type: 'all', + period: 'month', + }); + }); + + test('should render all three line chart filters with correct number of options', async () => { + expect(pageWrapper.find('Select[variant="single"]')).toHaveLength(3); + await act(async () => { + pageWrapper + .find('Select[placeholderText="Select job type"]') + .prop('onToggle')(true); + }); + pageWrapper.update(); + expect(pageWrapper.find('SelectOption')).toHaveLength(4); + await act(async () => { + pageWrapper + .find('Select[placeholderText="Select job type"]') + .prop('onToggle')(false); + pageWrapper + .find('Select[placeholderText="Select period"]') + .prop('onToggle')(true); + }); + pageWrapper.update(); + expect(pageWrapper.find('SelectOption')).toHaveLength(4); + await act(async () => { + pageWrapper + .find('Select[placeholderText="Select period"]') + .prop('onToggle')(false); + pageWrapper + .find('Select[placeholderText="Select status"]') + .prop('onToggle')(true); + }); + pageWrapper.update(); + expect(pageWrapper.find('SelectOption')).toHaveLength(3); + }); +}); diff --git a/awx/ui_next/src/screens/Dashboard/shared/LineChart.jsx b/awx/ui_next/src/screens/Dashboard/shared/LineChart.jsx index ddf9d62458..5b84973059 100644 --- a/awx/ui_next/src/screens/Dashboard/shared/LineChart.jsx +++ b/awx/ui_next/src/screens/Dashboard/shared/LineChart.jsx @@ -7,7 +7,7 @@ import { PageContextConsumer } from '@patternfly/react-core'; import ChartTooltip from './ChartTooltip'; -function LineChart({ id, data, height, pageContext }) { +function LineChart({ id, data, height, pageContext, jobStatus }) { const { isNavOpen } = pageContext; // Methods @@ -197,61 +197,68 @@ function LineChart({ id, data, height, pageContext }) { vertical.transition().style('opacity', 0); }; - // Add the successLine path. - svg - .append('path') - .data([formattedData]) - .attr('class', 'line') - .style('fill', 'none') - .style('stroke', () => colors(1)) - .attr('stroke-width', 2) - .attr('d', successLine) - .call(transition); - - // Add the failLine path. - svg - .append('path') - .data([formattedData]) - .attr('class', 'line') - .style('fill', 'none') - .style('stroke', () => colors(0)) - .attr('stroke-width', 2) - .attr('d', failLine) - .call(transition); - const dateFormat = d3.timeFormat('%-m-%-d'); - // create our successLine circles - svg - .selectAll('dot') - .data(formattedData) - .enter() - .append('circle') - .attr('r', 3) - .style('stroke', () => colors(1)) - .style('fill', () => colors(1)) - .attr('cx', d => x(d.DATE)) - .attr('cy', d => y(d.RAN)) - .attr('id', d => `success-dot-${dateFormat(d.DATE)}`) - .on('mouseover', handleMouseOver) - .on('mousemove', handleMouseMove) - .on('mouseout', handleMouseOut); - // create our failLine circles - svg - .selectAll('dot') - .data(formattedData) - .enter() - .append('circle') - .attr('r', 3) - .style('stroke', () => colors(0)) - .style('fill', () => colors(0)) - .attr('cx', d => x(d.DATE)) - .attr('cy', d => y(d.FAIL)) - .attr('id', d => `fail-dot-${dateFormat(d.DATE)}`) - .on('mouseover', handleMouseOver) - .on('mousemove', handleMouseMove) - .on('mouseout', handleMouseOut); - }, [data, height, id]); + if (jobStatus !== 'failed') { + // Add the success line path. + svg + .append('path') + .data([formattedData]) + .attr('class', 'line') + .style('fill', 'none') + .style('stroke', () => colors(1)) + .attr('stroke-width', 2) + .attr('d', successLine) + .call(transition); + + // create our success line circles + + svg + .selectAll('dot') + .data(formattedData) + .enter() + .append('circle') + .attr('r', 3) + .style('stroke', () => colors(1)) + .style('fill', () => colors(1)) + .attr('cx', d => x(d.DATE)) + .attr('cy', d => y(d.RAN)) + .attr('id', d => `success-dot-${dateFormat(d.DATE)}`) + .on('mouseover', handleMouseOver) + .on('mousemove', handleMouseMove) + .on('mouseout', handleMouseOut); + } + + if (jobStatus !== 'successful') { + // Add the failed line path. + svg + .append('path') + .data([formattedData]) + .attr('class', 'line') + .style('fill', 'none') + .style('stroke', () => colors(0)) + .attr('stroke-width', 2) + .attr('d', failLine) + .call(transition); + + // create our failed line circles + + svg + .selectAll('dot') + .data(formattedData) + .enter() + .append('circle') + .attr('r', 3) + .style('stroke', () => colors(0)) + .style('fill', () => colors(0)) + .attr('cx', d => x(d.DATE)) + .attr('cy', d => y(d.FAIL)) + .attr('id', d => `fail-dot-${dateFormat(d.DATE)}`) + .on('mouseover', handleMouseOver) + .on('mousemove', handleMouseMove) + .on('mouseout', handleMouseOut); + } + }, [data, height, id, jobStatus]); useEffect(() => { draw();