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(' ');
+