diff --git a/awx/api/templates/api/dashboard_jobs_graph_view.md b/awx/api/templates/api/dashboard_jobs_graph_view.md index 2e510b2a56cc..baadd4d561b7 100644 --- a/awx/api/templates/api/dashboard_jobs_graph_view.md +++ b/awx/api/templates/api/dashboard_jobs_graph_view.md @@ -8,7 +8,7 @@ The `period` of the data can be adjusted with: ?period=month -Where `month` can be replaced with `week`, or `day`. `month` is the default. +Where `month` can be replaced with `week`, `two_weeks`, or `day`. `month` is the default. The type of job can be filtered with: diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 11df2b20896a..3b5ffc96716f 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -316,6 +316,9 @@ def get(self, request, format=None): if period == 'month': end_date = start_date - dateutil.relativedelta.relativedelta(months=1) interval = 'days' + elif period == 'two_weeks': + end_date = start_date - dateutil.relativedelta.relativedelta(weeks=2) + interval = 'days' elif period == 'week': end_date = start_date - dateutil.relativedelta.relativedelta(weeks=1) interval = 'days' diff --git a/awx/ui_next/src/api/index.js b/awx/ui_next/src/api/index.js index dd0f4a811ced..b3abd8e3bce6 100644 --- a/awx/ui_next/src/api/index.js +++ b/awx/ui_next/src/api/index.js @@ -4,6 +4,7 @@ import Config from './models/Config'; import CredentialInputSources from './models/CredentialInputSources'; import CredentialTypes from './models/CredentialTypes'; import Credentials from './models/Credentials'; +import Dashboard from './models/Dashboard'; import Groups from './models/Groups'; import Hosts from './models/Hosts'; import InstanceGroups from './models/InstanceGroups'; @@ -42,6 +43,7 @@ const ConfigAPI = new Config(); const CredentialInputSourcesAPI = new CredentialInputSources(); const CredentialTypesAPI = new CredentialTypes(); const CredentialsAPI = new Credentials(); +const DashboardAPI = new Dashboard(); const GroupsAPI = new Groups(); const HostsAPI = new Hosts(); const InstanceGroupsAPI = new InstanceGroups(); @@ -81,6 +83,7 @@ export { CredentialInputSourcesAPI, CredentialTypesAPI, CredentialsAPI, + DashboardAPI, GroupsAPI, HostsAPI, InstanceGroupsAPI, diff --git a/awx/ui_next/src/api/models/Dashboard.js b/awx/ui_next/src/api/models/Dashboard.js new file mode 100644 index 000000000000..aa1d86340a2a --- /dev/null +++ b/awx/ui_next/src/api/models/Dashboard.js @@ -0,0 +1,16 @@ +import Base from '../Base'; + +class Dashboard extends Base { + constructor(http) { + super(http); + this.baseUrl = '/api/v2/dashboard/'; + } + + readJobGraph(params) { + return this.http.get(`${this.baseUrl}graphs/jobs/`, { + params, + }); + } +} + +export default Dashboard; diff --git a/awx/ui_next/src/screens/Dashboard/Dashboard.jsx b/awx/ui_next/src/screens/Dashboard/Dashboard.jsx index b8e37fb1adc7..c1235b88e8d5 100644 --- a/awx/ui_next/src/screens/Dashboard/Dashboard.jsx +++ b/awx/ui_next/src/screens/Dashboard/Dashboard.jsx @@ -1,28 +1,253 @@ -import React, { Component, Fragment } from 'react'; +import React, { Fragment, useCallback, useEffect, useState } from 'react'; +import styled from 'styled-components'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { + Card, + CardHeader, + CardActions, + CardBody, PageSection, PageSectionVariants, + Select, + SelectVariant, + SelectOption, + Tabs, + Tab, + TabTitleText, Title, } from '@patternfly/react-core'; -class Dashboard extends Component { - render() { - const { i18n } = this.props; - const { light } = PageSectionVariants; - - return ( - - - - {i18n._(t`Dashboard`)} - - - - - ); +import useRequest from '../../util/useRequest'; +import { DashboardAPI } from '../../api'; +import JobList from '../../components/JobList'; + +import LineChart from './shared/LineChart'; +import Count from './shared/Count'; +import DashboardTemplateList from './shared/DashboardTemplateList'; + +const Counts = styled.div` + display: grid; + grid-template-columns: repeat(6, 1fr); + grid-gap: var(--pf-global--spacer--lg); + + @media (max-width: 900px) { + grid-template-columns: repeat(3, 1fr); + grid-auto-rows: 1fr; } +`; + +const MainPageSection = styled(PageSection)` + padding-top: 0; + padding-bottom: 0; + + & .spacer { + margin-bottom: var(--pf-global--spacer--lg); + } +`; + +const GraphCardHeader = styled(CardHeader)` + margin-top: var(--pf-global--spacer--lg); +`; + +const GraphCardActions = styled(CardActions)` + margin-left: initial; + padding-left: 0; +`; + +function Dashboard({ i18n }) { + const { light } = PageSectionVariants; + + 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 { + result: { jobGraphData, 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: {}, + } + ); + + useEffect(() => { + fetchDashboardGraph(); + }, [fetchDashboardGraph, periodSelection, jobTypeSelection]); + + return ( + + + + {i18n._(t`Dashboard`)} + + + + + + + + + + + + + +
+ + setActiveTabId(eventKey)} + > + {i18n._(t`Job status`)}} + /> + {i18n._(t`Recent Jobs`)}} + /> + {i18n._(t`Recent Templates`)} + } + /> + + {activeTabId === 0 && ( + + + + + + + + + + + + )} + {activeTabId === 1 && } + {activeTabId === 2 && } + +
+
+
+ ); } export default withI18n()(Dashboard); diff --git a/awx/ui_next/src/screens/Dashboard/Dashboard.test.jsx b/awx/ui_next/src/screens/Dashboard/Dashboard.test.jsx index fa8d664c50a3..548c72a1e54a 100644 --- a/awx/ui_next/src/screens/Dashboard/Dashboard.test.jsx +++ b/awx/ui_next/src/screens/Dashboard/Dashboard.test.jsx @@ -1,18 +1,24 @@ import React from 'react'; +import { act } from 'react-dom/test-utils'; import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import { DashboardAPI } from '../../api'; import Dashboard from './Dashboard'; +jest.mock('../../api'); + describe('', () => { let pageWrapper; - let pageSections; - let title; + let graphRequest; - beforeEach(() => { - pageWrapper = mountWithContexts(); - pageSections = pageWrapper.find('PageSection'); - title = pageWrapper.find('Title'); + beforeEach(async () => { + await act(async () => { + DashboardAPI.read.mockResolvedValue({}); + graphRequest = DashboardAPI.readJobGraph; + graphRequest.mockResolvedValue({}); + pageWrapper = mountWithContexts(); + }); }); afterEach(() => { @@ -21,9 +27,24 @@ describe('', () => { test('initially renders without crashing', () => { expect(pageWrapper.length).toBe(1); - expect(pageSections.length).toBe(2); - expect(title.length).toBe(1); - expect(title.props().size).toBe('2xl'); - expect(pageSections.first().props().variant).toBe('light'); + }); + + test('renders dashboard graph by default', () => { + expect(pageWrapper.find('LineChart').length).toBe(1); + }); + + test('renders template list when the active tab is changed', async () => { + expect(pageWrapper.find('DashboardTemplateList').length).toBe(0); + pageWrapper + .find('button[aria-label="Recent Templates list tab"]') + .simulate('click'); + expect(pageWrapper.find('DashboardTemplateList').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/shared/ChartTooltip.jsx b/awx/ui_next/src/screens/Dashboard/shared/ChartTooltip.jsx new file mode 100644 index 000000000000..6db109796eae --- /dev/null +++ b/awx/ui_next/src/screens/Dashboard/shared/ChartTooltip.jsx @@ -0,0 +1,211 @@ +import * as d3 from 'd3'; +import { t } from '@lingui/macro'; + +class Tooltip { + constructor(opts) { + this.label = opts.label; + this.svg = opts.svg; + this.colors = opts.colors; + this.i18n = opts.i18n; + this.draw(); + } + + draw() { + this.toolTipBase = d3.select(`${this.svg} > svg`).append('g'); + this.toolTipBase.attr('id', 'chart-tooltip'); + this.toolTipBase.attr('overflow', 'visible'); + this.toolTipBase.style('opacity', 0); + this.toolTipBase.style('pointer-events', 'none'); + this.toolTipBase.attr('transform', 'translate(100, 100)'); + this.boxWidth = 145; + this.textWidthThreshold = 20; + + this.toolTipPoint = this.toolTipBase + .append('rect') + .attr('transform', 'translate(10, -10) rotate(45)') + .attr('x', 0) + .attr('y', 0) + .attr('height', 20) + .attr('width', 20) + .attr('fill', '#393f44'); + this.boundingBox = this.toolTipBase + .append('rect') + .attr('x', 10) + .attr('y', -41) + .attr('rx', 2) + .attr('height', 82) + .attr('width', this.boxWidth) + .attr('fill', '#393f44'); + this.circleGreen = this.toolTipBase + .append('circle') + .attr('cx', 26) + .attr('cy', 0) + .attr('r', 7) + .attr('stroke', 'white') + .attr('fill', this.colors(1)); + this.circleRed = this.toolTipBase + .append('circle') + .attr('cx', 26) + .attr('cy', 26) + .attr('r', 7) + .attr('stroke', 'white') + .attr('fill', this.colors(0)); + this.successText = this.toolTipBase + .append('text') + .attr('x', 43) + .attr('y', 4) + .attr('font-size', 12) + .attr('fill', 'white') + .text(this.i18n._(t`Successful`)); + this.failText = this.toolTipBase + .append('text') + .attr('x', 43) + .attr('y', 28) + .attr('font-size', 12) + .attr('fill', 'white') + .text(this.i18n._(t`Failed`)); + this.icon = this.toolTipBase + .append('text') + .attr('fill', 'white') + .attr('stroke', 'white') + .attr('x', 24) + .attr('y', 30) + .attr('font-size', 12) + .text('!'); + this.jobs = this.toolTipBase + .append('text') + .attr('fill', 'white') + .attr('x', 137) + .attr('y', -21) + .attr('font-size', 12) + .attr('text-anchor', 'end') + .text(this.i18n._(t`No Jobs`)); + this.successful = this.toolTipBase + .append('text') + .attr('fill', 'white') + .attr('font-size', 12) + .attr('x', 122) + .attr('y', 4) + .attr('id', 'successful-count') + .text('0'); + this.failed = this.toolTipBase + .append('text') + .attr('fill', 'white') + .attr('font-size', 12) + .attr('x', 122) + .attr('y', 28) + .attr('id', 'failed-count') + .text('0'); + this.date = this.toolTipBase + .append('text') + .attr('fill', 'white') + .attr('stroke', 'white') + .attr('x', 20) + .attr('y', -21) + .attr('font-size', 12) + .text(this.i18n._(t`Never`)); + } + + handleMouseOver = d => { + let success = 0; + let fail = 0; + let total = 0; + const x = + d3.event.pageX - + d3 + .select(this.svg) + .node() + .getBoundingClientRect().x + + 10; + const y = + d3.event.pageY - + d3 + .select(this.svg) + .node() + .getBoundingClientRect().y - + 10; + const formatTooltipDate = d3.timeFormat('%m/%d'); + if (!d) { + return; + } + + const toolTipWidth = this.toolTipBase.node().getBoundingClientRect().width; + const chartWidth = d3 + .select(`${this.svg}> svg`) + .node() + .getBoundingClientRect().width; + const overflow = 100 - (toolTipWidth / chartWidth) * 100; + const flipped = overflow < (x / chartWidth) * 100; + if (d) { + success = d.RAN || 0; + fail = d.FAIL || 0; + total = d.TOTAL || 0; + this.date.text(formatTooltipDate(d.DATE || null)); + } + + if (d && d.data) { + success = d.data.RAN || 0; + fail = d.data.FAIL || 0; + total = d.data.TOTAL || 0; + this.date.text(formatTooltipDate(d.data.DATE || null)); + } + + this.jobs.text(`${total} ${this.label}`); + this.jobsWidth = this.jobs.node().getComputedTextLength(); + this.failed.text(`${fail}`); + this.successful.text(`${success}`); + this.successTextWidth = this.successful.node().getComputedTextLength(); + this.failTextWidth = this.failed.node().getComputedTextLength(); + + const maxTextPerc = (this.jobsWidth / this.boxWidth) * 100; + const threshold = 40; + const overage = maxTextPerc / threshold; + let adjustedWidth; + if (maxTextPerc > threshold) { + adjustedWidth = this.boxWidth * overage; + } else { + adjustedWidth = this.boxWidth; + } + + this.boundingBox.attr('width', adjustedWidth); + this.toolTipBase.attr('transform', `translate(${x}, ${y})`); + if (flipped) { + this.toolTipPoint.attr('transform', 'translate(-20, -10) rotate(45)'); + this.boundingBox.attr('x', -adjustedWidth - 20); + this.circleGreen.attr('cx', -adjustedWidth); + this.circleRed.attr('cx', -adjustedWidth); + this.icon.attr('x', -adjustedWidth - 2); + this.successText.attr('x', -adjustedWidth + 17); + this.failText.attr('x', -adjustedWidth + 17); + this.successful.attr('x', -this.successTextWidth - 20 - 12); + this.failed.attr('x', -this.failTextWidth - 20 - 12); + this.date.attr('x', -adjustedWidth - 5); + this.jobs.attr('x', -this.jobsWidth / 2 - 14); + } else { + this.toolTipPoint.attr('transform', 'translate(10, -10) rotate(45)'); + this.boundingBox.attr('x', 10); + this.circleGreen.attr('cx', 26); + this.circleRed.attr('cx', 26); + this.icon.attr('x', 24); + this.successText.attr('x', 43); + this.failText.attr('x', 43); + this.successful.attr('x', adjustedWidth - this.successTextWidth); + this.failed.attr('x', adjustedWidth - this.failTextWidth); + this.date.attr('x', 20); + this.jobs.attr('x', adjustedWidth); + } + + this.toolTipBase.style('opacity', 1); + this.toolTipBase.interrupt(); + }; + + handleMouseOut = () => { + this.toolTipBase + .transition() + .delay(15) + .style('opacity', 0) + .style('pointer-events', 'none'); + }; +} + +export default Tooltip; diff --git a/awx/ui_next/src/screens/Dashboard/shared/Count.jsx b/awx/ui_next/src/screens/Dashboard/shared/Count.jsx new file mode 100644 index 000000000000..eb6c692550a3 --- /dev/null +++ b/awx/ui_next/src/screens/Dashboard/shared/Count.jsx @@ -0,0 +1,45 @@ +import React from 'react'; +import styled from 'styled-components'; +import { Link } from 'react-router-dom'; +import { Card } from '@patternfly/react-core'; + +const CountCard = styled(Card)` + padding: var(--pf-global--spacer--md); + display: flex; + align-items: center; + padding-top: var(--pf-global--spacer--sm); + cursor: pointer; + text-align: center; + color: var(--pf-global--palette--black-1000); + text-decoration: none; + + & h2 { + font-size: var(--pf-global--FontSize--4xl); + color: var(--pf-global--palette--blue-400); + text-decoration: none; + } + + & h2.failed { + color: var(--pf-global--palette--red-200); + } +`; + +const CountLink = styled(Link)` + display: contents; + &:hover { + text-decoration: none; + } +`; + +function Count({ failed, link, data, label }) { + return ( + + +

{data || 0}

+ {label} +
+
+ ); +} + +export default Count; diff --git a/awx/ui_next/src/screens/Dashboard/shared/Count.test.jsx b/awx/ui_next/src/screens/Dashboard/shared/Count.test.jsx new file mode 100644 index 000000000000..58b67ff9c32c --- /dev/null +++ b/awx/ui_next/src/screens/Dashboard/shared/Count.test.jsx @@ -0,0 +1,28 @@ +import React from 'react'; + +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; + +import Count from './Count'; + +describe('', () => { + let pageWrapper; + + afterEach(() => { + pageWrapper.unmount(); + }); + + test('initially renders without crashing', () => { + pageWrapper = mountWithContexts(); + expect(pageWrapper.length).toBe(1); + }); + + test('renders non-failed version of count without prop', () => { + pageWrapper = mountWithContexts(); + expect(pageWrapper.find('h2').hasClass('failed')).toBe(false); + }); + + test('renders failed version of count with appropriate prop', () => { + pageWrapper = mountWithContexts(); + expect(pageWrapper.find('h2').hasClass('failed')).toBe(true); + }); +}); diff --git a/awx/ui_next/src/screens/Dashboard/shared/DashboardTemplateList.jsx b/awx/ui_next/src/screens/Dashboard/shared/DashboardTemplateList.jsx new file mode 100644 index 000000000000..73c12cd0e223 --- /dev/null +++ b/awx/ui_next/src/screens/Dashboard/shared/DashboardTemplateList.jsx @@ -0,0 +1,279 @@ +import React, { Fragment, useEffect, useState, useCallback } from 'react'; +import { useLocation } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Card } from '@patternfly/react-core'; + +import { + JobTemplatesAPI, + UnifiedJobTemplatesAPI, + WorkflowJobTemplatesAPI, +} from '../../../api'; +import AlertModal from '../../../components/AlertModal'; +import DatalistToolbar from '../../../components/DataListToolbar'; +import ErrorDetail from '../../../components/ErrorDetail'; +import PaginatedDataList, { + ToolbarDeleteButton, +} from '../../../components/PaginatedDataList'; +import useRequest, { useDeleteItems } from '../../../util/useRequest'; +import { getQSConfig, parseQueryString } from '../../../util/qs'; +import useWsTemplates from '../../../util/useWsTemplates'; +import AddDropDownButton from '../../../components/AddDropDownButton'; + +import DashboardTemplateListItem from './DashboardTemplateListItem'; + +const QS_CONFIG = getQSConfig( + 'template', + { + page: 1, + page_size: 5, + order_by: 'name', + type: 'job_template,workflow_job_template', + }, + ['id', 'page', 'page_size'] +); + +function DashboardTemplateList({ i18n }) { + // The type value in const QS_CONFIG below does not have a space between job_template and + // workflow_job_template so the params sent to the API match what the api expects. + + const location = useLocation(); + + const [selected, setSelected] = useState([]); + + const { + result: { + results, + count, + jtActions, + wfjtActions, + relatedSearchableKeys, + searchableKeys, + }, + error: contentError, + isLoading, + request: fetchTemplates, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, location.search); + const responses = await Promise.all([ + UnifiedJobTemplatesAPI.read(params), + JobTemplatesAPI.readOptions(), + WorkflowJobTemplatesAPI.readOptions(), + UnifiedJobTemplatesAPI.readOptions(), + ]); + return { + results: responses[0].data.results, + count: responses[0].data.count, + jtActions: responses[1].data.actions, + wfjtActions: responses[2].data.actions, + relatedSearchableKeys: ( + responses[3]?.data?.related_search_fields || [] + ).map(val => val.slice(0, -8)), + searchableKeys: Object.keys( + responses[3].data.actions?.GET || {} + ).filter(key => responses[3].data.actions?.GET[key].filterable), + }; + }, [location]), + { + results: [], + count: 0, + jtActions: {}, + wfjtActions: {}, + relatedSearchableKeys: [], + searchableKeys: [], + } + ); + + useEffect(() => { + fetchTemplates(); + }, [fetchTemplates]); + + const templates = useWsTemplates(results); + + const isAllSelected = + selected.length === templates.length && selected.length > 0; + const { + isLoading: isDeleteLoading, + deleteItems: deleteTemplates, + deletionError, + clearDeletionError, + } = useDeleteItems( + useCallback(async () => { + return Promise.all( + selected.map(({ type, id }) => { + if (type === 'job_template') { + return JobTemplatesAPI.destroy(id); + } + if (type === 'workflow_job_template') { + return WorkflowJobTemplatesAPI.destroy(id); + } + return false; + }) + ); + }, [selected]), + { + qsConfig: QS_CONFIG, + allItemsSelected: isAllSelected, + fetchItems: fetchTemplates, + } + ); + + const handleTemplateDelete = async () => { + await deleteTemplates(); + setSelected([]); + }; + + const handleSelectAll = isSelected => { + setSelected(isSelected ? [...templates] : []); + }; + + const handleSelect = template => { + if (selected.some(s => s.id === template.id)) { + setSelected(selected.filter(s => s.id !== template.id)); + } else { + setSelected(selected.concat(template)); + } + }; + + const canAddJT = + jtActions && Object.prototype.hasOwnProperty.call(jtActions, 'POST'); + const canAddWFJT = + wfjtActions && Object.prototype.hasOwnProperty.call(wfjtActions, 'POST'); + const addButtonOptions = []; + + if (canAddJT) { + addButtonOptions.push({ + label: i18n._(t`Job Template`), + url: `/templates/job_template/add/`, + }); + } + + if (canAddWFJT) { + addButtonOptions.push({ + label: i18n._(t`Workflow Template`), + url: `/templates/workflow_job_template/add/`, + }); + } + + const addButton = ( + + ); + + return ( + + + ( + , + ]} + /> + )} + renderItem={template => ( + handleSelect(template)} + isSelected={selected.some(row => row.id === template.id)} + fetchTemplates={fetchTemplates} + /> + )} + emptyStateControls={(canAddJT || canAddWFJT) && addButton} + /> + + + {i18n._(t`Failed to delete one or more templates.`)} + + + + ); +} + +export default withI18n()(DashboardTemplateList); diff --git a/awx/ui_next/src/screens/Dashboard/shared/DashboardTemplateList.test.jsx b/awx/ui_next/src/screens/Dashboard/shared/DashboardTemplateList.test.jsx new file mode 100644 index 000000000000..5b33d3d71747 --- /dev/null +++ b/awx/ui_next/src/screens/Dashboard/shared/DashboardTemplateList.test.jsx @@ -0,0 +1,336 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { + JobTemplatesAPI, + UnifiedJobTemplatesAPI, + WorkflowJobTemplatesAPI, +} from '../../../api'; +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; + +import DashboardTemplateList from './DashboardTemplateList'; + +jest.mock('../../../api'); + +const mockTemplates = [ + { + id: 1, + name: 'Job Template 1', + url: '/templates/job_template/1', + type: 'job_template', + summary_fields: { + user_capabilities: { + delete: true, + edit: true, + copy: true, + }, + }, + }, + { + id: 2, + name: 'Job Template 2', + url: '/templates/job_template/2', + type: 'job_template', + summary_fields: { + user_capabilities: { + delete: true, + }, + }, + }, + { + id: 3, + name: 'Job Template 3', + url: '/templates/job_template/3', + type: 'job_template', + summary_fields: { + user_capabilities: { + delete: true, + }, + }, + }, + { + id: 4, + name: 'Workflow Job Template 1', + url: '/templates/workflow_job_template/4', + type: 'workflow_job_template', + summary_fields: { + user_capabilities: { + delete: true, + }, + }, + }, + { + id: 5, + name: 'Workflow Job Template 2', + url: '/templates/workflow_job_template/5', + type: 'workflow_job_template', + summary_fields: { + user_capabilities: { + delete: false, + }, + }, + }, +]; + +describe('', () => { + let debug; + beforeEach(() => { + UnifiedJobTemplatesAPI.read.mockResolvedValue({ + data: { + count: mockTemplates.length, + results: mockTemplates, + }, + }); + + UnifiedJobTemplatesAPI.readOptions.mockResolvedValue({ + data: { + actions: [], + }, + }); + debug = global.console.debug; // eslint-disable-line prefer-destructuring + global.console.debug = () => {}; + }); + + afterEach(() => { + jest.clearAllMocks(); + global.console.debug = debug; + }); + + test('initially renders successfully', async () => { + await act(async () => { + mountWithContexts( + + ); + }); + }); + + test('Templates are retrieved from the api and the components finishes loading', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts(); + }); + expect(UnifiedJobTemplatesAPI.read).toBeCalled(); + await act(async () => { + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + expect(wrapper.find('DashboardTemplateListItem').length).toEqual(5); + }); + + test('handleSelect is called when a template list item is selected', async () => { + const wrapper = mountWithContexts(); + await act(async () => { + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + const checkBox = wrapper + .find('DashboardTemplateListItem') + .at(1) + .find('input'); + + checkBox.simulate('change', { + target: { + id: 2, + name: 'Job Template 2', + url: '/templates/job_template/2', + type: 'job_template', + summary_fields: { user_capabilities: { delete: true } }, + }, + }); + + expect( + wrapper + .find('DashboardTemplateListItem') + .at(1) + .prop('isSelected') + ).toBe(true); + }); + + test('handleSelectAll is called when a template list item is selected', async () => { + const wrapper = mountWithContexts(); + await act(async () => { + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + expect(wrapper.find('Checkbox#select-all').prop('isChecked')).toBe(false); + + const toolBarCheckBox = wrapper.find('Checkbox#select-all'); + act(() => { + toolBarCheckBox.prop('onChange')(true); + }); + wrapper.update(); + expect(wrapper.find('Checkbox#select-all').prop('isChecked')).toBe(true); + }); + + test('delete button is disabled if user does not have delete capabilities on a selected template', async () => { + const wrapper = mountWithContexts(); + await act(async () => { + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + const deleteableItem = wrapper + .find('DashboardTemplateListItem') + .at(0) + .find('input'); + const nonDeleteableItem = wrapper + .find('DashboardTemplateListItem') + .at(4) + .find('input'); + + deleteableItem.simulate('change', { + id: 1, + name: 'Job Template 1', + url: '/templates/job_template/1', + type: 'job_template', + summary_fields: { + user_capabilities: { + delete: true, + }, + }, + }); + + expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe( + false + ); + deleteableItem.simulate('change', { + id: 1, + name: 'Job Template 1', + url: '/templates/job_template/1', + type: 'job_template', + summary_fields: { + user_capabilities: { + delete: true, + }, + }, + }); + expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe( + true + ); + nonDeleteableItem.simulate('change', { + id: 5, + name: 'Workflow Job Template 2', + url: '/templates/workflow_job_template/5', + type: 'workflow_job_template', + summary_fields: { + user_capabilities: { + delete: false, + }, + }, + }); + expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe( + true + ); + }); + + test('api is called to delete templates for each selected template.', async () => { + const wrapper = mountWithContexts(); + await act(async () => { + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + const jobTemplate = wrapper + .find('DashboardTemplateListItem') + .at(1) + .find('input'); + const workflowJobTemplate = wrapper + .find('DashboardTemplateListItem') + .at(3) + .find('input'); + + jobTemplate.simulate('change', { + target: { + id: 2, + name: 'Job Template 2', + url: '/templates/job_template/2', + type: 'job_template', + summary_fields: { user_capabilities: { delete: true } }, + }, + }); + + workflowJobTemplate.simulate('change', { + target: { + id: 4, + name: 'Workflow Job Template 1', + url: '/templates/workflow_job_template/4', + type: 'workflow_job_template', + summary_fields: { + user_capabilities: { + delete: true, + }, + }, + }, + }); + + await act(async () => { + wrapper.find('button[aria-label="Delete"]').prop('onClick')(); + }); + wrapper.update(); + await act(async () => { + await wrapper + .find('button[aria-label="confirm delete"]') + .prop('onClick')(); + }); + expect(JobTemplatesAPI.destroy).toBeCalledWith(2); + expect(WorkflowJobTemplatesAPI.destroy).toBeCalledWith(4); + }); + + test('error is shown when template not successfully deleted from api', async () => { + JobTemplatesAPI.destroy.mockRejectedValue( + new Error({ + response: { + config: { + method: 'delete', + url: '/api/v2/job_templates/1', + }, + data: 'An error occurred', + }, + }) + ); + const wrapper = mountWithContexts(); + await act(async () => { + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + const checkBox = wrapper + .find('DashboardTemplateListItem') + .at(1) + .find('input'); + + checkBox.simulate('change', { + target: { + id: 'a', + name: 'Job Template 2', + url: '/templates/job_template/2', + type: 'job_template', + summary_fields: { user_capabilities: { delete: true } }, + }, + }); + await act(async () => { + wrapper.find('button[aria-label="Delete"]').prop('onClick')(); + }); + wrapper.update(); + await act(async () => { + await wrapper + .find('button[aria-label="confirm delete"]') + .prop('onClick')(); + }); + + await waitForElement( + wrapper, + 'Modal[aria-label="Deletion Error"]', + el => el.props().isOpen === true && el.props().title === 'Error!' + ); + }); + test('should properly copy template', async () => { + JobTemplatesAPI.copy.mockResolvedValue({}); + const wrapper = mountWithContexts(); + await act(async () => { + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + await act(async () => + wrapper.find('Button[aria-label="Copy"]').prop('onClick')() + ); + expect(JobTemplatesAPI.copy).toHaveBeenCalled(); + expect(UnifiedJobTemplatesAPI.read).toHaveBeenCalled(); + wrapper.update(); + }); +}); diff --git a/awx/ui_next/src/screens/Dashboard/shared/DashboardTemplateListItem.jsx b/awx/ui_next/src/screens/Dashboard/shared/DashboardTemplateListItem.jsx new file mode 100644 index 000000000000..f06cf71aebef --- /dev/null +++ b/awx/ui_next/src/screens/Dashboard/shared/DashboardTemplateListItem.jsx @@ -0,0 +1,179 @@ +import 'styled-components/macro'; +import React, { useState, useCallback } from 'react'; +import { Link } from 'react-router-dom'; +import { + Button, + DataListAction as _DataListAction, + DataListCheck, + DataListItem, + DataListItemRow, + DataListItemCells, + Tooltip, +} from '@patternfly/react-core'; +import { t } from '@lingui/macro'; +import { withI18n } from '@lingui/react'; +import { + ExclamationTriangleIcon, + PencilAltIcon, + ProjectDiagramIcon, + RocketIcon, +} from '@patternfly/react-icons'; +import styled from 'styled-components'; + +import DataListCell from '../../../components/DataListCell'; +import { timeOfDay } from '../../../util/dates'; +import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '../../../api'; +import LaunchButton from '../../../components/LaunchButton'; +import Sparkline from '../../../components/Sparkline'; +import { toTitleCase } from '../../../util/strings'; +import CopyButton from '../../../components/CopyButton'; + +const DataListAction = styled(_DataListAction)` + align-items: center; + display: grid; + grid-gap: 16px; + grid-template-columns: repeat(4, 40px); +`; + +function DashboardTemplateListItem({ + i18n, + template, + isSelected, + onSelect, + detailUrl, + fetchTemplates, +}) { + const [isDisabled, setIsDisabled] = useState(false); + const labelId = `check-action-${template.id}`; + + const copyTemplate = useCallback(async () => { + if (template.type === 'job_template') { + await JobTemplatesAPI.copy(template.id, { + name: `${template.name} @ ${timeOfDay()}`, + }); + } else { + await WorkflowJobTemplatesAPI.copy(template.id, { + name: `${template.name} @ ${timeOfDay()}`, + }); + } + await fetchTemplates(); + }, [fetchTemplates, template.id, template.name, template.type]); + + const handleCopyStart = useCallback(() => { + setIsDisabled(true); + }, []); + + const handleCopyFinish = useCallback(() => { + setIsDisabled(false); + }, []); + + const missingResourceIcon = + template.type === 'job_template' && + (!template.summary_fields.project || + (!template.summary_fields.inventory && + !template.ask_inventory_on_launch)); + return ( + + + + + + + {template.name} + + + {missingResourceIcon && ( + + + + + + )} + , + + {toTitleCase(template.type)} + , + + + , + ]} + /> + + {template.type === 'workflow_job_template' && ( + + + + )} + {template.summary_fields.user_capabilities.start && ( + + + {({ handleLaunch }) => ( + + )} + + + )} + {template.summary_fields.user_capabilities.edit && ( + + + + )} + {template.summary_fields.user_capabilities.copy && ( + + )} + + + + ); +} + +export { DashboardTemplateListItem as _TemplateListItem }; +export default withI18n()(DashboardTemplateListItem); diff --git a/awx/ui_next/src/screens/Dashboard/shared/DashboardTemplateListItem.test.jsx b/awx/ui_next/src/screens/Dashboard/shared/DashboardTemplateListItem.test.jsx new file mode 100644 index 000000000000..571ef260c092 --- /dev/null +++ b/awx/ui_next/src/screens/Dashboard/shared/DashboardTemplateListItem.test.jsx @@ -0,0 +1,268 @@ +import React from 'react'; +import { createMemoryHistory } from 'history'; +import { act } from 'react-dom/test-utils'; + +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import { JobTemplatesAPI } from '../../../api'; + +import mockJobTemplateData from './data.job_template.json'; +import DashboardTemplateListItem from './DashboardTemplateListItem'; + +jest.mock('../../../api'); + +describe('', () => { + test('launch button shown to users with start capabilities', () => { + const wrapper = mountWithContexts( + + ); + expect(wrapper.find('LaunchButton').exists()).toBeTruthy(); + }); + test('launch button hidden from users without start capabilities', () => { + const wrapper = mountWithContexts( + + ); + expect(wrapper.find('LaunchButton').exists()).toBeFalsy(); + }); + test('edit button shown to users with edit capabilities', () => { + const wrapper = mountWithContexts( + + ); + expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy(); + }); + test('edit button hidden from users without edit capabilities', () => { + const wrapper = mountWithContexts( + + ); + expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy(); + }); + test('missing resource icon is shown.', () => { + const wrapper = mountWithContexts( + + ); + expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(true); + }); + test('missing resource icon is not shown when there is a project and an inventory.', () => { + const wrapper = mountWithContexts( + + ); + expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(false); + }); + test('missing resource icon is not shown when inventory is prompt_on_launch, and a project', () => { + const wrapper = mountWithContexts( + + ); + expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(false); + }); + test('missing resource icon is not shown type is workflow_job_template', () => { + const wrapper = mountWithContexts( + + ); + expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(false); + }); + test('clicking on template from templates list navigates properly', () => { + const history = createMemoryHistory({ + initialEntries: ['/templates'], + }); + const wrapper = mountWithContexts( + , + { context: { router: { history } } } + ); + wrapper.find('Link').simulate('click', { button: 0 }); + expect(history.location.pathname).toEqual( + '/templates/job_template/1/details' + ); + }); + test('should call api to copy template', async () => { + JobTemplatesAPI.copy.mockResolvedValue(); + + const wrapper = mountWithContexts( + + ); + await act(async () => + wrapper.find('Button[aria-label="Copy"]').prop('onClick')() + ); + expect(JobTemplatesAPI.copy).toHaveBeenCalled(); + jest.clearAllMocks(); + }); + + test('should render proper alert modal on copy error', async () => { + JobTemplatesAPI.copy.mockRejectedValue(new Error()); + + const wrapper = mountWithContexts( + + ); + await act(async () => + wrapper.find('Button[aria-label="Copy"]').prop('onClick')() + ); + wrapper.update(); + expect(wrapper.find('Modal').prop('isOpen')).toBe(true); + jest.clearAllMocks(); + }); + + test('should not render copy button', async () => { + const wrapper = mountWithContexts( + + ); + expect(wrapper.find('CopyButton').length).toBe(0); + }); + + test('should render visualizer button for workflow', async () => { + const wrapper = mountWithContexts( + + ); + expect(wrapper.find('ProjectDiagramIcon').length).toBe(1); + }); + + test('should not render visualizer button for job template', async () => { + const wrapper = mountWithContexts( + + ); + expect(wrapper.find('ProjectDiagramIcon').length).toBe(0); + }); +}); diff --git a/awx/ui_next/src/screens/Dashboard/shared/LineChart.jsx b/awx/ui_next/src/screens/Dashboard/shared/LineChart.jsx new file mode 100644 index 000000000000..922605a3ad9b --- /dev/null +++ b/awx/ui_next/src/screens/Dashboard/shared/LineChart.jsx @@ -0,0 +1,292 @@ +import React, { useEffect, useCallback } from 'react'; +import { string, number, shape, arrayOf } from 'prop-types'; +import * as d3 from 'd3'; +import { t } from '@lingui/macro'; +import { withI18n } from '@lingui/react'; +import { PageContextConsumer } from '@patternfly/react-core'; + +import ChartTooltip from './ChartTooltip'; + +function LineChart({ id, data, height, i18n, pageContext }) { + const { isNavOpen } = pageContext; + + // Methods + const draw = useCallback(() => { + const margin = { top: 15, right: 15, bottom: 35, left: 70 }; + + const getWidth = () => { + let width; + // This is in an a try/catch due to an error from jest. + // Even though the d3.select returns a valid selector with + // style function, it says it is null in the test + try { + width = + parseInt(d3.select(`#${id}`).style('width'), 10) - + margin.left - + margin.right || 700; + } catch (error) { + width = 700; + } + return width; + }; + + // Clear our chart container element first + d3.selectAll(`#${id} > *`).remove(); + const width = getWidth(); + + function transition(path) { + path + .transition() + .duration(1000) + .attrTween('stroke-dasharray', tweenDash); + } + + function tweenDash(...params) { + const l = params[2][params[1]].getTotalLength(); + const i = d3.interpolateString(`0,${l}`, `${l},${l}`); + return val => i(val); + } + + const x = d3.scaleTime().rangeRound([0, width]); + const y = d3.scaleLinear().range([height, 0]); + + // [success, fail, total] + const colors = d3.scaleOrdinal(['#6EC664', '#A30000', '#06C']); + const svg = d3 + .select(`#${id}`) + .append('svg') + .attr('width', width + margin.left + margin.right) + .attr('height', height + margin.top + margin.bottom) + // .attr('id', 'foo') + .attr('z', 100) + .append('g') + .attr('id', 'chart-container') + .attr('transform', `translate(${margin.left}, ${margin.top})`); + // Tooltip + const tooltip = new ChartTooltip({ + svg: `#${id}`, + colors, + label: i18n._(t`Jobs`), + i18n, + }); + const parseTime = d3.timeParse('%Y-%m-%d'); + + const formattedData = data.reduce( + (formatted, { created, successful, failed }) => { + const DATE = parseTime(created) || new Date(); + const RAN = +successful || 0; + const FAIL = +failed || 0; + const TOTAL = +successful + failed || 0; + return formatted.concat({ DATE, RAN, FAIL, TOTAL }); + }, + [] + ); + // Scale the range of the data + const largestY = formattedData.reduce((a_max, b) => { + const b_max = Math.max(b.RAN > b.FAIL ? b.RAN : b.FAIL); + return a_max > b_max ? a_max : b_max; + }, 0); + x.domain(d3.extent(formattedData, d => d.DATE)); + y.domain([ + 0, + largestY > 4 ? largestY + Math.max(largestY / 10, 1) : 5, + ]).nice(); + + const successLine = d3 + .line() + .curve(d3.curveMonotoneX) + .x(d => x(d.DATE)) + .y(d => y(d.RAN)); + + const failLine = d3 + .line() + .defined(d => typeof d.FAIL === 'number') + .curve(d3.curveMonotoneX) + .x(d => x(d.DATE)) + .y(d => y(d.FAIL)); + // Add the Y Axis + svg + .append('g') + .attr('class', 'y-axis') + .call( + d3 + .axisLeft(y) + .ticks( + largestY > 3 + ? Math.min(largestY + Math.max(largestY / 10, 1), 10) + : 5 + ) + .tickSize(-width) + .tickFormat(d3.format('d')) + ) + .selectAll('line') + .attr('stroke', '#d7d7d7'); + svg.selectAll('.y-axis .tick text').attr('x', -5); + + // text label for the y axis + svg + .append('text') + .attr('transform', 'rotate(-90)') + .attr('y', 0 - margin.left) + .attr('x', 0 - height / 2) + .attr('dy', '1em') + .style('text-anchor', 'middle') + .text('Job Runs'); + + // Add the X Axis + let ticks; + const maxTicks = Math.round( + formattedData.length / (formattedData.length / 2) + ); + ticks = formattedData.map(d => d.DATE); + if (formattedData.length === 31) { + ticks = formattedData + .map((d, i) => (i % maxTicks === 0 ? d.DATE : undefined)) + .filter(item => item); + } + + svg.select('.domain').attr('stroke', '#d7d7d7'); + + svg + .append('g') + .attr('class', 'x-axis') + .attr('transform', `translate(0, ${height})`) + .call( + d3 + .axisBottom(x) + .tickValues(ticks) + .tickSize(-height) + .tickFormat(d3.timeFormat('%-m/%-d')) // "1/19" + ) // "Jan-01" + .selectAll('line') + .attr('stroke', '#d7d7d7'); + + svg.selectAll('.x-axis .tick text').attr('y', 10); + + // text label for the x axis + svg + .append('text') + .attr( + 'transform', + `translate(${width / 2} , ${height + margin.top + 20})` + ) + .style('text-anchor', 'middle') + .text('Date'); + const vertical = svg + .append('path') + .attr('class', 'mouse-line') + .style('stroke', 'black') + .style('stroke-width', '3px') + .style('stroke-dasharray', '3, 3') + .style('opacity', '0'); + + const handleMouseOver = d => { + tooltip.handleMouseOver(d); + // show vertical line + vertical.transition().style('opacity', '1'); + }; + + const handleMouseMove = function mouseMove(...params) { + const intersectX = params[2][params[1]].cx.baseVal.value; + vertical.attr('d', () => `M${intersectX},${height} ${intersectX},${0}`); + }; + + const handleMouseOut = () => { + // hide tooltip + tooltip.handleMouseOut(); + // hide vertical line + 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 => `success-dot-${dateFormat(d.DATE)}`) + .on('mouseover', handleMouseOver) + .on('mousemove', handleMouseMove) + .on('mouseout', handleMouseOut); + }, [data, height, i18n, id]); + + useEffect(() => { + draw(); + }, [draw, isNavOpen]); + + useEffect(() => { + function handleResize() { + draw(); + } + + window.addEventListener('resize', handleResize); + + handleResize(); + + return () => window.removeEventListener('resize', handleResize); + }, [draw]); + + return
; +} + +LineChart.propTypes = { + id: string.isRequired, + data: arrayOf(shape({})).isRequired, + height: number.isRequired, +}; + +const withPageContext = Component => { + return function contextComponent(props) { + return ( + + {pageContext => } + + ); + }; +}; + +export default withI18n()(withPageContext(LineChart)); diff --git a/awx/ui_next/src/screens/Dashboard/shared/data.job_template.json b/awx/ui_next/src/screens/Dashboard/shared/data.job_template.json new file mode 100644 index 000000000000..e28410769112 --- /dev/null +++ b/awx/ui_next/src/screens/Dashboard/shared/data.job_template.json @@ -0,0 +1,181 @@ +{ + "id": 7, + "type": "job_template", + "url": "/api/v2/job_templates/7/", + "related": { + "named_url": "/api/v2/job_templates/Mike's JT/", + "created_by": "/api/v2/users/1/", + "modified_by": "/api/v2/users/1/", + "labels": "/api/v2/job_templates/7/labels/", + "inventory": "/api/v2/inventories/1/", + "project": "/api/v2/projects/6/", + "credentials": "/api/v2/job_templates/7/credentials/", + "last_job": "/api/v2/jobs/12/", + "jobs": "/api/v2/job_templates/7/jobs/", + "schedules": "/api/v2/job_templates/7/schedules/", + "activity_stream": "/api/v2/job_templates/7/activity_stream/", + "launch": "/api/v2/job_templates/7/launch/", + "notification_templates_started": "/api/v2/job_templates/7/notification_templates_started/", + "notification_templates_success": "/api/v2/job_templates/7/notification_templates_success/", + "notification_templates_error": "/api/v2/job_templates/7/notification_templates_error/", + "access_list": "/api/v2/job_templates/7/access_list/", + "survey_spec": "/api/v2/job_templates/7/survey_spec/", + "object_roles": "/api/v2/job_templates/7/object_roles/", + "instance_groups": "/api/v2/job_templates/7/instance_groups/", + "slice_workflow_jobs": "/api/v2/job_templates/7/slice_workflow_jobs/", + "copy": "/api/v2/job_templates/7/copy/", + "webhook_receiver": "/api/v2/job_templates/7/github/", + "webhook_key": "/api/v2/job_templates/7/webhook_key/" + }, + "summary_fields": { + "inventory": { + "id": 1, + "name": "Mike's Inventory", + "description": "", + "has_active_failures": false, + "total_hosts": 1, + "hosts_with_active_failures": 0, + "total_groups": 0, + "groups_with_active_failures": 0, + "has_inventory_sources": false, + "total_inventory_sources": 0, + "inventory_sources_with_failures": 0, + "organization_id": 1, + "kind": "" + }, + "project": { + "id": 6, + "name": "Mike's Project", + "description": "", + "status": "successful", + "scm_type": "git" + }, + "last_job": { + "id": 12, + "name": "Mike's JT", + "description": "", + "finished": "2019-10-01T14:34:35.142483Z", + "status": "successful", + "failed": false + }, + "last_update": { + "id": 12, + "name": "Mike's JT", + "description": "", + "status": "successful", + "failed": false + }, + "created_by": { + "id": 1, + "username": "admin", + "first_name": "", + "last_name": "" + }, + "modified_by": { + "id": 1, + "username": "admin", + "first_name": "", + "last_name": "" + }, + "object_roles": { + "admin_role": { + "description": "Can manage all aspects of the job template", + "name": "Admin", + "id": 24 + }, + "execute_role": { + "description": "May run the job template", + "name": "Execute", + "id": 25 + }, + "read_role": { + "description": "May view settings for the job template", + "name": "Read", + "id": 26 + } + }, + "user_capabilities": { + "edit": true, + "delete": true, + "start": true, + "schedule": true, + "copy": true + }, + "labels": { + "count": 1, + "results": [{ + "id": 91, + "name": "L_91o2" + }] + }, + "survey": { + "title": "", + "description": "" + }, + "recent_jobs": [{ + "id": 12, + "status": "successful", + "finished": "2019-10-01T14:34:35.142483Z", + "type": "job" + }], + "credentials": [{ + "id": 1, + "kind": "ssh", + "name": "Credential 1" + }, + { + "id": 2, + "kind": "awx", + "name": "Credential 2" + } + ], + "webhook_credential": { + "id": "1", + "name": "Webhook Credential" + + } + }, + "created": "2019-09-30T16:18:34.564820Z", + "modified": "2019-10-01T14:47:31.818431Z", + "name": "Mike's JT", + "description": "", + "job_type": "run", + "inventory": 1, + "project": 6, + "playbook": "ping.yml", + "scm_branch": "Foo branch", + "forks": 0, + "limit": "", + "verbosity": 0, + "extra_vars": "", + "job_tags": "T_100,T_200", + "force_handlers": false, + "skip_tags": "S_100,S_200", + "start_at_task": "", + "timeout": 0, + "use_fact_cache": true, + "last_job_run": "2019-10-01T14:34:35.142483Z", + "last_job_failed": false, + "next_job_run": null, + "status": "successful", + "host_config_key": "", + "ask_scm_branch_on_launch": false, + "ask_diff_mode_on_launch": false, + "ask_variables_on_launch": false, + "ask_limit_on_launch": false, + "ask_tags_on_launch": false, + "ask_skip_tags_on_launch": false, + "ask_job_type_on_launch": false, + "ask_verbosity_on_launch": false, + "ask_inventory_on_launch": false, + "ask_credential_on_launch": false, + "survey_enabled": true, + "become_enabled": false, + "diff_mode": false, + "allow_simultaneous": false, + "custom_virtualenv": null, + "job_slice_count": 1, + "webhook_credential": 1, + "webhook_key": "asertdyuhjkhgfd234567kjgfds", + "webhook_service": "github" +} diff --git a/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx b/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx index 455bf4ce6c3e..a40def657d2e 100644 --- a/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx +++ b/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx @@ -1,8 +1,8 @@ -import React, { useEffect, useState, useCallback } from 'react'; +import React, { Fragment, useEffect, useState, useCallback } from 'react'; import { useLocation } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { Card, PageSection } from '@patternfly/react-core'; +import { Card } from '@patternfly/react-core'; import { JobTemplatesAPI, @@ -17,7 +17,7 @@ import PaginatedDataList, { } from '../../../components/PaginatedDataList'; import useRequest, { useDeleteItems } from '../../../util/useRequest'; import { getQSConfig, parseQueryString } from '../../../util/qs'; -import useWsTemplates from './useWsTemplates'; +import useWsTemplates from '../../../util/useWsTemplates'; import AddDropDownButton from '../../../components/AddDropDownButton'; import TemplateListItem from './TemplateListItem'; @@ -134,7 +134,6 @@ function TemplateList({ i18n }) { jtActions && Object.prototype.hasOwnProperty.call(jtActions, 'POST'); const canAddWFJT = wfjtActions && Object.prototype.hasOwnProperty.call(wfjtActions, 'POST'); - // spreading Set() returns only unique keys const addButtonOptions = []; if (canAddJT) { @@ -156,7 +155,7 @@ function TemplateList({ i18n }) { ); return ( - + - + ); } diff --git a/awx/ui_next/src/screens/Template/Templates.jsx b/awx/ui_next/src/screens/Template/Templates.jsx index 0b529a95af3f..d63d8f7d4689 100644 --- a/awx/ui_next/src/screens/Template/Templates.jsx +++ b/awx/ui_next/src/screens/Template/Templates.jsx @@ -2,6 +2,7 @@ import React, { Component } from 'react'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Route, withRouter, Switch } from 'react-router-dom'; +import { PageSection } from '@patternfly/react-core'; import { Config } from '../../contexts/Config'; import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs'; @@ -120,7 +121,9 @@ class Templates extends Component { )} /> - + + + diff --git a/awx/ui_next/src/screens/Template/TemplateList/useWsTemplates.js b/awx/ui_next/src/util/useWsTemplates.jsx similarity index 96% rename from awx/ui_next/src/screens/Template/TemplateList/useWsTemplates.js rename to awx/ui_next/src/util/useWsTemplates.jsx index fa10424c85ce..3c6a8d4d9c9f 100644 --- a/awx/ui_next/src/screens/Template/TemplateList/useWsTemplates.js +++ b/awx/ui_next/src/util/useWsTemplates.jsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import useWebsocket from '../../../util/useWebsocket'; +import useWebsocket from './useWebsocket'; export default function useWsTemplates(initialTemplates) { const [templates, setTemplates] = useState(initialTemplates); diff --git a/awx/ui_next/src/screens/Template/TemplateList/useWsTemplates.test.jsx b/awx/ui_next/src/util/useWsTemplates.test.jsx similarity index 97% rename from awx/ui_next/src/screens/Template/TemplateList/useWsTemplates.test.jsx rename to awx/ui_next/src/util/useWsTemplates.test.jsx index 61bc6e042e1e..9b32fb3eac65 100644 --- a/awx/ui_next/src/screens/Template/TemplateList/useWsTemplates.test.jsx +++ b/awx/ui_next/src/util/useWsTemplates.test.jsx @@ -1,14 +1,14 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import WS from 'jest-websocket-mock'; -import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import { mountWithContexts } from '../../testUtils/enzymeHelpers'; import useWsTemplates from './useWsTemplates'; /* Jest mock timers don’t play well with jest-websocket-mock, so we'll stub out throttling to resolve immediately */ -jest.mock('../../../util/useThrottle', () => ({ +jest.mock('./useThrottle', () => ({ __esModule: true, default: jest.fn(val => val), }));