From 19b41743ded3116b6b532b9cf60ff5bc64fc0167 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Thu, 13 Jun 2019 11:08:05 -0400 Subject: [PATCH] 215 templates list skeleton (#251) * adding package-lock.json * deleted unsured file * Removes and unused file * Fixes errant styling change * Fixes an error and uses a prop that PF recognizes * Updates PR to use API Modules * Fixes PR Issues * Addes tests to Templates * Addresses PR Issues * Revert package-lock.json --- __tests__/pages/Templates.test.jsx | 12 +- __tests__/pages/Templates/Templates.test.jsx | 11 ++ .../pages/Templates/TemplatesList.test.jsx | 100 ++++++++++++ .../components/TemplatesListItem.test.jsx | 16 ++ src/api/index.js | 3 + src/api/models/UnifiedJobTemplates.js | 10 ++ .../ExpandCollapse/ExpandCollapse.jsx | 39 +++-- src/index.jsx | 2 +- src/pages/Templates.jsx | 28 ---- src/pages/Templates/Templates.jsx | 63 ++++++++ src/pages/Templates/TemplatesList.jsx | 149 ++++++++++++++++++ .../Templates/components/TemplateListItem.jsx | 61 +++++++ src/util/strings.js | 7 + 13 files changed, 449 insertions(+), 52 deletions(-) create mode 100644 __tests__/pages/Templates/Templates.test.jsx create mode 100644 __tests__/pages/Templates/TemplatesList.test.jsx create mode 100644 __tests__/pages/Templates/components/TemplatesListItem.test.jsx create mode 100644 src/api/models/UnifiedJobTemplates.js delete mode 100644 src/pages/Templates.jsx create mode 100644 src/pages/Templates/Templates.jsx create mode 100644 src/pages/Templates/TemplatesList.jsx create mode 100644 src/pages/Templates/components/TemplateListItem.jsx diff --git a/__tests__/pages/Templates.test.jsx b/__tests__/pages/Templates.test.jsx index 433c1648b2..1a6bf80919 100644 --- a/__tests__/pages/Templates.test.jsx +++ b/__tests__/pages/Templates.test.jsx @@ -1,16 +1,12 @@ import React from 'react'; +import Templates from '../../src/pages/Templates/Templates'; import { mountWithContexts } from '../enzymeHelpers'; -import Templates from '../../src/pages/Templates'; describe('', () => { let pageWrapper; - let pageSections; - let title; beforeEach(() => { pageWrapper = mountWithContexts(); - pageSections = pageWrapper.find('PageSection'); - title = pageWrapper.find('Title'); }); afterEach(() => { @@ -19,11 +15,5 @@ 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'); - pageSections.forEach(section => { - expect(section.props().variant).toBeDefined(); - }); }); }); diff --git a/__tests__/pages/Templates/Templates.test.jsx b/__tests__/pages/Templates/Templates.test.jsx new file mode 100644 index 0000000000..8613454691 --- /dev/null +++ b/__tests__/pages/Templates/Templates.test.jsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { mountWithContexts } from '../../enzymeHelpers'; +import Templates from '../../../src/pages/Templates/Templates'; + +describe('', () => { + test('initially renders succesfully', () => { + mountWithContexts( + + ); + }); +}); diff --git a/__tests__/pages/Templates/TemplatesList.test.jsx b/__tests__/pages/Templates/TemplatesList.test.jsx new file mode 100644 index 0000000000..aa071c29ed --- /dev/null +++ b/__tests__/pages/Templates/TemplatesList.test.jsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { mountWithContexts } from '../../enzymeHelpers'; +import TemplatesList, { _TemplatesList } from '../../../src/pages/Templates/TemplatesList'; + +jest.mock('../../../src/api'); + +const setDefaultState = (templatesList) => { + templatesList.setState({ + itemCount: mockUnifiedJobTemplatesFromAPI.length, + isLoading: false, + isInitialized: true, + selected: [], + templates: mockUnifiedJobTemplatesFromAPI, + }); + templatesList.update(); +}; + +const mockUnifiedJobTemplatesFromAPI = [{ + id: 1, + name: 'Template 1', + url: '/templates/job_template/1', + type: 'job_template', + summary_fields: { + inventory: {}, + project: {}, + } +}, +{ + id: 2, + name: 'Template 2', + url: '/templates/job_template/2', + type: 'job_template', + summary_fields: { + inventory: {}, + project: {}, + } +}, +{ + id: 3, + name: 'Template 3', + url: '/templates/job_template/3', + type: 'job_template', + summary_fields: { + inventory: {}, + project: {}, + } +}]; + +describe('', () => { + test('initially renders succesfully', () => { + mountWithContexts( + + ); + }); + test('Templates are retrieved from the api and the components finishes loading', async (done) => { + const readTemplates = jest.spyOn(_TemplatesList.prototype, 'readUnifiedJobTemplates'); + + const wrapper = mountWithContexts().find('TemplatesList'); + + expect(wrapper.state('isLoading')).toBe(true); + await expect(readTemplates).toHaveBeenCalled(); + wrapper.update(); + expect(wrapper.state('isLoading')).toBe(false); + done(); + }); + + test('handleSelect is called when a template list item is selected', async () => { + const handleSelect = jest.spyOn(_TemplatesList.prototype, 'handleSelect'); + + const wrapper = mountWithContexts(); + + const templatesList = wrapper.find('TemplatesList'); + setDefaultState(templatesList); + + expect(templatesList.state('isLoading')).toBe(false); + wrapper.find('DataListCheck#select-jobTemplate-1').props().onChange(); + expect(handleSelect).toBeCalled(); + templatesList.update(); + expect(templatesList.state('selected').length).toBe(1); + }); + + test('handleSelectAll is called when a template list item is selected', async () => { + const handleSelectAll = jest.spyOn(_TemplatesList.prototype, 'handleSelectAll'); + + const wrapper = mountWithContexts(); + + const templatesList = wrapper.find('TemplatesList'); + setDefaultState(templatesList); + + expect(templatesList.state('isLoading')).toBe(false); + wrapper.find('Checkbox#select-all').props().onChange(true); + expect(handleSelectAll).toBeCalled(); + wrapper.update(); + expect(templatesList.state('selected').length).toEqual(templatesList.state('templates') + .length); + }); +}); diff --git a/__tests__/pages/Templates/components/TemplatesListItem.test.jsx b/__tests__/pages/Templates/components/TemplatesListItem.test.jsx new file mode 100644 index 0000000000..ee88eba5b6 --- /dev/null +++ b/__tests__/pages/Templates/components/TemplatesListItem.test.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { mountWithContexts } from '../../../enzymeHelpers'; +import TemplatesListItem from '../../../../src/pages/Templates/components/TemplateListItem'; + +describe('', () => { + test('initially render successfully', () => { + mountWithContexts(); + }); +}); diff --git a/src/api/index.js b/src/api/index.js index b908cf17d7..018ae790d4 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -4,6 +4,7 @@ import Me from './models/Me'; import Organizations from './models/Organizations'; import Root from './models/Root'; import Teams from './models/Teams'; +import UnifiedJobTemplates from './models/UnifiedJobTemplates'; import Users from './models/Users'; const ConfigAPI = new Config(); @@ -12,6 +13,7 @@ const MeAPI = new Me(); const OrganizationsAPI = new Organizations(); const RootAPI = new Root(); const TeamsAPI = new Teams(); +const UnifiedJobTemplatesAPI = new UnifiedJobTemplates(); const UsersAPI = new Users(); export { @@ -21,5 +23,6 @@ export { OrganizationsAPI, RootAPI, TeamsAPI, + UnifiedJobTemplatesAPI, UsersAPI }; diff --git a/src/api/models/UnifiedJobTemplates.js b/src/api/models/UnifiedJobTemplates.js new file mode 100644 index 0000000000..80b5f5af45 --- /dev/null +++ b/src/api/models/UnifiedJobTemplates.js @@ -0,0 +1,10 @@ +import Base from '../Base'; + +class UnifiedJobTemplates extends Base { + constructor (http) { + super(http); + this.baseUrl = '/api/v2/unified_job_templates/'; + } +} + +export default UnifiedJobTemplates; diff --git a/src/components/ExpandCollapse/ExpandCollapse.jsx b/src/components/ExpandCollapse/ExpandCollapse.jsx index cabc7f2f3b..b884c0871b 100644 --- a/src/components/ExpandCollapse/ExpandCollapse.jsx +++ b/src/components/ExpandCollapse/ExpandCollapse.jsx @@ -3,26 +3,39 @@ import PropTypes from 'prop-types'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { - Button, - ToolbarItem + Button as PFButton, + ToolbarItem as PFToolbarItem } from '@patternfly/react-core'; import { BarsIcon, EqualsIcon, } from '@patternfly/react-icons'; +import styled from 'styled-components'; -const ToolbarActiveStyle = { - backgroundColor: '#007bba', - color: 'white', - padding: '0 5px', -}; +const Button = styled(PFButton)` + padding: 0; + margin: 0; + height: 30px; + width: 30px; + ${props => (props.isActive ? ` + background-color: #007bba; + --pf-c-button--m-plain--active--Color: white; + --pf-c-button--m-plain--focus--Color: white;` + : null)}; +`; + +const ToolbarItem = styled(PFToolbarItem)` + & :not(:last-child) { + margin-right: 20px; + } +`; class ExpandCollapse extends React.Component { render () { const { + isCompact, onCompact, onExpand, - isCompact, i18n } = this.props; @@ -33,7 +46,7 @@ class ExpandCollapse extends React.Component { variant="plain" aria-label={i18n._(t`Collapse`)} onClick={onCompact} - style={isCompact ? ToolbarActiveStyle : null} + isActive={isCompact} > @@ -43,7 +56,7 @@ class ExpandCollapse extends React.Component { variant="plain" aria-label={i18n._(t`Expand`)} onClick={onExpand} - style={!isCompact ? ToolbarActiveStyle : null} + isActive={!isCompact} > @@ -56,9 +69,11 @@ class ExpandCollapse extends React.Component { ExpandCollapse.propTypes = { onCompact: PropTypes.func.isRequired, onExpand: PropTypes.func.isRequired, - isCompact: PropTypes.bool.isRequired + isCompact: PropTypes.bool }; -ExpandCollapse.defaultProps = {}; +ExpandCollapse.defaultProps = { + isCompact: true +}; export default withI18n()(ExpandCollapse); diff --git a/src/index.jsx b/src/index.jsx index b0640ea67e..62a1f72e86 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -44,7 +44,7 @@ import SystemSettings from './pages/SystemSettings'; import UISettings from './pages/UISettings'; import License from './pages/License'; import Teams from './pages/Teams'; -import Templates from './pages/Templates'; +import Templates from './pages/Templates/Templates'; import Users from './pages/Users'; // eslint-disable-next-line import/prefer-default-export diff --git a/src/pages/Templates.jsx b/src/pages/Templates.jsx deleted file mode 100644 index f8a5a99a16..0000000000 --- a/src/pages/Templates.jsx +++ /dev/null @@ -1,28 +0,0 @@ -import React, { Component, Fragment } from 'react'; -import { withI18n } from '@lingui/react'; -import { t } from '@lingui/macro'; -import { - PageSection, - PageSectionVariants, - Title, -} from '@patternfly/react-core'; - -class Templates extends Component { - render () { - const { i18n } = this.props; - const { light, medium } = PageSectionVariants; - - return ( - - - - {i18n._(t`Templates`)} - - - - - ); - } -} - -export default withI18n()(Templates); diff --git a/src/pages/Templates/Templates.jsx b/src/pages/Templates/Templates.jsx new file mode 100644 index 0000000000..4a0e9d6470 --- /dev/null +++ b/src/pages/Templates/Templates.jsx @@ -0,0 +1,63 @@ +import React, { Component, Fragment } from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Route, withRouter, Switch } from 'react-router-dom'; +import { NetworkProvider } from '../../contexts/Network'; +import { withRootDialog } from '../../contexts/RootDialog'; + +import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs'; +import TemplatesList from './TemplatesList'; + +class Templates extends Component { + constructor (props) { + super(props); + const { i18n } = this.props; + + this.state = { + breadcrumbConfig: { + '/templates': i18n._(t`Templates`) + } + }; + } + + render () { + const { match, history, setRootDialogMessage, i18n } = this.props; + const { breadcrumbConfig } = this.state; + return ( + + + + ( + { + history.replace('/templates'); + setRootDialogMessage({ + title: '404', + bodyText: ( + + {i18n._(t`Cannot find template with ID`)} + {` ${newRouteMatch.params.id}`} + + ), + variant: 'warning' + }); + }} + /> + )} + /> + ( + + )} + /> + + + ); + } +} + +export { Templates as _Templates }; +export default withI18n()(withRootDialog(withRouter(Templates))); diff --git a/src/pages/Templates/TemplatesList.jsx b/src/pages/Templates/TemplatesList.jsx new file mode 100644 index 0000000000..e45c2d5aa4 --- /dev/null +++ b/src/pages/Templates/TemplatesList.jsx @@ -0,0 +1,149 @@ +import React, { Component } from 'react'; +import { withRouter } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { + Card, + PageSection, + PageSectionVariants, +} from '@patternfly/react-core'; +import { withNetwork } from '../../contexts/Network'; +import { UnifiedJobTemplatesAPI } from '../../api'; + +import { getQSConfig, parseNamespacedQueryString } from '../../util/qs'; +import DatalistToolbar from '../../components/DataListToolbar'; +import PaginatedDataList from '../../components/PaginatedDataList'; +import TemplateListItem from './components/TemplateListItem'; + +// 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 QS_CONFIG = getQSConfig('template', { + page: 1, + page_size: 5, + order_by: 'name', + type: 'job_template,workflow_job_template' +}); + +class TemplatesList extends Component { + constructor (props) { + super(props); + + this.state = { + error: null, + isLoading: true, + isInitialized: false, + selected: [], + templates: [], + }; + this.readUnifiedJobTemplates = this.readUnifiedJobTemplates.bind(this); + this.handleSelectAll = this.handleSelectAll.bind(this); + this.handleSelect = this.handleSelect.bind(this); + } + + componentDidMount () { + this.readUnifiedJobTemplates(); + } + + componentDidUpdate (prevProps) { + const { location } = this.props; + if (location !== prevProps.location) { + this.readUnifiedJobTemplates(); + } + } + + handleSelectAll (isSelected) { + const { templates } = this.state; + const selected = isSelected ? [...templates] : []; + this.setState({ selected }); + } + + handleSelect (template) { + const { selected } = this.state; + if (selected.some(s => s.id === template.id)) { + this.setState({ selected: selected.filter(s => s.id !== template.id) }); + } else { + this.setState({ selected: selected.concat(template) }); + } + } + + async readUnifiedJobTemplates () { + const { handleHttpError, location } = this.props; + this.setState({ error: false, isLoading: true }); + const params = parseNamespacedQueryString(QS_CONFIG, location.search); + + try { + const { data } = await UnifiedJobTemplatesAPI.read(params); + const { count, results } = data; + + const stateToUpdate = { + itemCount: count, + templates: results, + selected: [], + isInitialized: true, + isLoading: false, + }; + this.setState(stateToUpdate); + } catch (err) { + handleHttpError(err) || this.setState({ error: true, isLoading: false }); + } + } + + render () { + const { + error, + isInitialized, + isLoading, + templates, + itemCount, + selected, + } = this.state; + const { + match, + i18n + } = this.props; + const isAllSelected = selected.length === templates.length; + const { medium } = PageSectionVariants; + return ( + + + {isInitialized && ( + ( + + )} + renderItem={(template) => ( + this.handleSelect(template)} + isSelected={selected.some(row => row.id === template.id)} + /> + )} + /> + )} + {isLoading ?
loading....
: ''} + {error ?
error
: '' } +
+
+ ); + } +} +export { TemplatesList as _TemplatesList }; +export default withI18n()(withNetwork(withRouter(TemplatesList))); diff --git a/src/pages/Templates/components/TemplateListItem.jsx b/src/pages/Templates/components/TemplateListItem.jsx new file mode 100644 index 0000000000..6e78d3a5a9 --- /dev/null +++ b/src/pages/Templates/components/TemplateListItem.jsx @@ -0,0 +1,61 @@ +import React, { Component } from 'react'; +import { Link } from 'react-router-dom'; +import { + DataListItem, + DataListItemRow, + DataListItemCells, + DataListCheck, + DataListCell as PFDataListCell, +} from '@patternfly/react-core'; +import styled from 'styled-components'; + +import { toTitleCase } from '../../../util/strings'; +import VerticalSeparator from '../../../components/VerticalSeparator'; + +const DataListCell = styled(PFDataListCell)` + display: flex; + align-items: center; + @media screen and (min-width: 768px) { + padding-bottom: 0; + } +`; + +class TemplateListItem extends Component { + render () { + const { + template, + isSelected, + onSelect, + } = this.props; + + return ( + + + + + + + + {template.name} + + + , + {toTitleCase(template.type)} + ]} + /> + + + ); + } +} +export { TemplateListItem as _TemplateListItem }; +export default TemplateListItem; diff --git a/src/util/strings.js b/src/util/strings.js index 4dc470fc01..6d6f9a05e1 100644 --- a/src/util/strings.js +++ b/src/util/strings.js @@ -14,3 +14,10 @@ export function getArticle (str) { export function ucFirst (str) { return `${str[0].toUpperCase()}${str.substr(1)}`; } + +export const toTitleCase = (type) => type + .toLowerCase() + .split('_') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +